From 3e584bbba94a165187b2f38ee8204a86f6a8d32b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Oct 2026 23:43:03 -0700 Subject: [PATCH 01/86] chore(quest): claim quest/m1/lite-legacy-close-drain.md Quest-Claim: 87e5f145658af025 From 4c1cdf23459c893d39457b51bbc4e2f865d6419c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 00:03:05 -0700 Subject: [PATCH 02/86] fix(net): lite-05/06 close waits for the subscriber's FIN A transport ACK does not say the subscriber read the final group, and a receiver may drop unread bytes on close. lite-05/06 subscribers already FIN their half of the subscribe stream once SUBSCRIBE_END's tail settles, so the publisher's drain now waits for that FIN, as lite-07 does, in Rust and JS. lite-01..04 carry no SUBSCRIBE_END, so their subscriber FINs only after its grace timer; they keep the ACK drain pending a maintainer decision. Co-Authored-By: Claude Opus 5.5 --- dart/moq/lib/src/client.dart | 5 +- doc/bin/obs.md | 3 +- doc/lib/cpp/index.md | 5 +- doc/lib/dart/index.md | 2 +- doc/lib/go/index.md | 2 +- doc/lib/js/net.md | 2 +- doc/lib/kt/index.md | 2 +- doc/lib/py/index.md | 2 +- doc/lib/rs/moq-net.md | 2 +- doc/lib/swift/index.md | 2 +- go/wrapper/session.go | 4 +- js/net/src/connection/established.ts | 4 +- js/net/src/lite/connection.test.ts | 80 ++++++++++--------- js/net/src/lite/publisher.ts | 7 +- js/net/src/lite/subscriber.ts | 2 +- js/net/src/lite/version.ts | 18 +---- .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 5 +- py/moq-rs/README.md | 2 +- py/moq-rs/moq/session.py | 5 +- quest/m1/lite-legacy-close-drain.md | 32 +++++--- rs/moq-ffi/src/session.rs | 4 +- rs/moq-ffi/src/test.rs | 1 - rs/moq-net/src/lite/publisher.rs | 5 +- rs/moq-net/src/lite/version.rs | 9 +-- rs/moq-net/src/session.rs | 6 +- rs/moq-net/tests/integration/session_close.rs | 58 +++++++++----- rs/moq-tokio/src/connection.rs | 4 +- swift/Sources/Moq/Client.swift | 5 +- 28 files changed, 145 insertions(+), 133 deletions(-) diff --git a/dart/moq/lib/src/client.dart b/dart/moq/lib/src/client.dart index f8bda830179..782c383eca6 100644 --- a/dart/moq/lib/src/client.dart +++ b/dart/moq/lib/src/client.dart @@ -266,9 +266,8 @@ final class Moq { /// Drain within one second, then stop the client. /// - /// Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older - /// Lite versions, including the default lite-06, wait only for transport ACKs. - /// IETF also waits for transport ACKs. + /// `moq-lite-05` and newer wait for the peer to receive finished tails; older + /// Lite versions and IETF wait only for transport ACKs. Future close() async { try { session.shutdown(); diff --git a/doc/bin/obs.md b/doc/bin/obs.md index f2de02b73b3..d58b2c7034e 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -95,8 +95,7 @@ alone writes nothing, and the toggle without it fails the connection. Existing U The plugin is C++ over the [generated C++ bindings](/lib/cpp/) and ships with every moq-cpp release. Stopping finishes the tracks and drains for up to one -second. The default lite-06 waits for transport ACKs, which do not confirm that -the peer received the tail. +second. The default lite-06 waits for the peer to receive the tails. If the relay closes the session for too many requests, reduce the broadcasts or subscribed tracks, or have the operator adjust its diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index 3cfa2f26139..b42c6835746 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -226,9 +226,8 @@ Set `ClientConfig::tokens` instead of putting the credential in the URL. The fir ## Shutdown `session->shutdown()` requests a drain immediately; `session->closed().get()` waits up to one second and reports its result. -Application delivery requires explicitly selecting `moq-lite-07-wip` on both -peers; older Lite versions (including default lite-06) and IETF wait only for -transport ACKs. Finish or abort live tracks first. +moq-lite-05 and newer wait for the peer to receive finished tails; older Lite +versions and IETF wait only for transport ACKs. Finish or abort live tracks first. To end a broadcast cleanly, finish every track, then call `catalog->finish()` on its `moq::MediaCatalogProducer` to finish the catalog, which ends the content. diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 0ddcc1f5403..d5669ccde92 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -146,7 +146,7 @@ shows which module holds each. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Media reads.** `media.TrackConsumer`, `audio.Consumer`, and `video.Consumer` return a `Read` in order: a `ReadFrame`, a `ReadGap` where media was lost, or a `ReadEnd` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. - **Decoded frames hold decoder buffers.** `dispose()` each frame from a `video.Consumer` promptly rather than waiting for the garbage collector, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. -- **Closing.** `await moq.close()` drains for up to one second and reports its result. `session.shutdown()` requests the drain synchronously; `await session.closed()` waits for its result. `session.cancel(code: 0)` closes at once. Finish or abort live tracks first. Awaiting cancellation of an announcement, grant, or request subscription releases its pending native read. Announcements and grants also release their consumer; server listeners leave their shared server usable. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. +- **Closing.** `await moq.close()` drains for up to one second and reports its result. `session.shutdown()` requests the drain synchronously; `await session.closed()` waits for its result. `session.cancel(code: 0)` closes at once. Finish or abort live tracks first. Awaiting cancellation of an announcement, grant, or request subscription releases its pending native read. Announcements and grants also release their consumer; server listeners leave their shared server usable. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline; disposing its `Publication` does not. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ConnectOptions(versions: ...)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), listen to `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 3ca69c86f92..d9d5326f677 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -132,7 +132,7 @@ shows which module holds each. - **Timeline breaks.** On a seek or pause, call `Discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Media reads.** `media.TrackConsumer`, `audio.Consumer`, and `video.Consumer` return a `Read` in order: `ReadFrame`, a `ReadGap` where media was lost, or a `ReadEnd` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. - **Decoded frames hold decoder buffers.** `Close` each `VideoDecodedFrame` promptly, or the decoder stalls. `Resize` is best effort, so read each frame's `Width()` and `Height()`. -- **Closing.** `client.Close()` drains for up to one second and returns an error on failure. `session.Shutdown()` requests that drain immediately; `session.Closed(ctx)` waits for its result, and `session.Cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. +- **Closing.** `client.Close()` drains for up to one second and returns an error on failure. `session.Shutdown()` requests that drain immediately; `session.Closed(ctx)` waits for its result, and `session.Cancel(code)` closes at once. Finish or abort live tracks first. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `Finish()` on its `media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.Close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `Session().Stats()` reports `RTT`, `EstimatedSendRateBps`, and the byte and packet counters (`BytesSent`, `BytesReceived`, `BytesLost`, `PacketsSent`, `PacketsReceived`, `PacketsLost`). A field is nil when the transport does not report it, which is not the same as zero. `Version` names the negotiated protocol. `Dial` returns a connecting session; read `Session().State()` and wait with `StateChanged(ctx, state.Revision)` to observe first-dial failures and reconnects. Hold the owned `BandwidthConsumer` returned by `Session().RecvBandwidth()` to receive bandwidth estimates. `Rate()` reads the current estimate in bits per second and `RateChanged(ctx)` waits for a new one. Calling `Close()` on the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig.Versions`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `Session().Grants()` returns a `GrantConsumer`: `Grant()` is the `Grant` the relay sent back, nil until it answers and while a client session reconnects, and `Changed(ctx)` waits for the next one. `Close()` it when done. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 87d432e4d5d..dbd5e49c0ea 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -53,7 +53,7 @@ publication.announce(); - **Subscriber staleness is `maxDelay`.** It counts from a group's successor, by media time and by wall clock, whichever reaches it first. 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). - **Authorization is in band.** On moq-lite 07 (opt in with `versions: ["moq-lite-07-wip", "moq-lite-06"]`, listing every fallback you still want) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription or track listing the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. On moq-transport, `auth.add(token)` keeps the token even without the extension, and a broadcast announced with the `Auth.Credential` it returns (`publication.announce({ auth })`) carries it on that announcement's `PUBLISH_NAMESPACE`; announcing again with another renews the request. A `Connection`'s `auth` handle (a getter on a pooled connection, following its URL) outlives its sessions: a token added there is presented on every reconnect and keeps riding the announcements that name it, with no re-add or re-announce. A one-shot session's `auth` ends with it, unless `Connection.connect` was given a `new Auth.Handle()` as `auth`. -- **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. +- **A graceful close waits.** `await connection.close()` withdraws announcements and drains for up to one second. `moq-lite-05` and newer wait for the subscriber to receive finished tails; older Lite versions wait only for transport ACKs. IETF media streams are not drained. `abort()` ends immediately. Examples: [`js/net/examples/`](https://github.com/moq-dev/moq/tree/main/js/net/examples). diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index a6921687a12..b104587b329 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -103,7 +103,7 @@ shows which module holds each. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Media reads.** `dev.moq.media.TrackConsumer`, `dev.moq.audio.Consumer`, and `dev.moq.video.Consumer` return a `Read` in order: `ReadFrame`, a `ReadGap` where media was lost, or a `ReadEnd` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. - **Decoded frames hold decoder buffers.** `close()` each frame from a `dev.moq.video.Consumer` promptly (or `use { }` it), or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. -- **Closing.** `moq.shutdown()` requests a drain immediately; suspending `moq.session.closed()` waits up to one second and reports its result. `Moq.close()`, and so `use { }`, cancels at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. +- **Closing.** `moq.shutdown()` requests a drain immediately; suspending `moq.session.closed()` waits up to one second and reports its result. `Moq.close()`, and so `use { }`, cancels at once. Finish or abort live tracks first. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.end()` takes it offline. How long to keep it up is yours to decide. A track still open at the end, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `null` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `ClientConfig(versions = ...)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), collect `moq.grants().updates()` for the `Grant` the relay sent back and each change, `null` until it answers and while a client session reconnects. Ending the collection cancels the consumer. diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 95f029c3daa..283d3649854 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -120,7 +120,7 @@ shows which module holds each. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Media reads.** `media.TrackConsumer`, `moq.audio.Consumer`, and `moq.video.Consumer` yield, in order, frames, a `Gap` where media was lost, and an `End` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. - **Decoded frames hold decoder buffers.** Drop each frame from a `moq.video.Consumer` promptly, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. -- **Closing.** `session.shutdown()` requests a drain immediately; `await session.closed()` waits up to one second and reports its result. `cancel(code)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. +- **Closing.** `session.shutdown()` requests a drain immediately; `await session.closed()` waits up to one second and reports its result. `cancel(code)` closes at once. Finish or abort live tracks first. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `finish()` on its `moq.media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `session.stats()` reports `rtt`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). A field is `None` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recv_bandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rate_changed()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `versions=`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `async for grant in session.grants()` yields the `Grant` the relay sent back and each change, `None` until it answers and while a client session reconnects. `grants().grant()` reads it once; cancelling or leaving an `async with` releases the wait. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 67917ff72d8..81d5d7fc0c1 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -27,7 +27,7 @@ What you use it for, beyond what the concept page already describes: - **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`, and `moq_net::transport::memory::pair` links two sessions in one process with no sockets, for tests or in-process links. - **A session bounds what its peer can hold.** Each request holds a stream, so the transport's stream credit bounds subscriptions and FETCHes. `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds the announcements per session. The default suits a relay mesh, so lower it for untrusted peers. Past it, the session closes with `TOO_MANY_REQUESTS`. Peer-declared lengths are capped before they are buffered. - **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. `add(token)` resolves with the token and how the peer took it: `Answer::Granted` on its AUTH stream, or `Answer::Requests` on moq-transport without AUTH, where only a request that names it carries it. A handle passed to `Client::with_auth` outlives each session, so its tokens, and the routes naming them, carry across your own reconnects. A server verifies tokens itself by taking `handshake.requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. -- **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. Application delivery requires explicit `moq-lite-07-wip`; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. `abort` ends immediately. +- **A graceful close waits.** `session.close().await` withdraws announcements and drains for up to one second. `moq-lite-05` and newer wait for the subscriber to receive finished tails; older Lite versions and IETF wait only for transport ACKs. `abort` ends immediately. ```bash cargo add moq-net moq-tokio diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index 799642c5e6f..8fede66473a 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -103,7 +103,7 @@ shows which module holds each. - **Timeline breaks.** On a seek or pause, call `discontinuity()`, then keep timestamps moving forward and resume video on a keyframe. - **Media reads.** `Media.TrackConsumer`, `Audio.Consumer`, and `Video.Consumer` yield `Read` cases in order: `.frame`, a `.gap` where media was lost, or an `.end` where the publisher stopped its encoder. A gap breaks continuity. An encoder end is not stream EOF: following frames before its endpoint are the old encoder's drain; the first at or past it starts fresh. - **Decoded frames hold decoder buffers.** Release each frame from a `Video.Consumer` promptly, or the decoder stalls. `resize` is best effort, so read each frame's `width()` and `height()`. -- **Closing.** `session.shutdown()` requests a drain immediately; `try await session.closed()` waits up to one second and reports its result. `session.cancel(code:)` closes at once. Finish or abort live tracks first. Application delivery requires explicitly selecting `moq-lite-07-wip` on both peers; older Lite versions (including default lite-06) and IETF wait only for transport ACKs. +- **Closing.** `session.shutdown()` requests a drain immediately; `try await session.closed()` waits up to one second and reports its result. `session.cancel(code:)` closes at once. Finish or abort live tracks first. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - **Ending cleanly.** When the input ends, finish every track, then call `try finish()` on its `Media.CatalogProducer` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `broadcast.close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close, or released without a finish, ends with `Unroutable`, which readers take as the publisher leaving. - **Stats.** `session.stats()` reports `rtt`, `estimatedSendRateBps`, and the byte and packet counters (`bytesSent`, `bytesReceived`, `bytesLost`, `packetsSent`, `packetsReceived`, `packetsLost`). A field is `nil` when the transport does not report it, which is not the same as zero. `version` names the negotiated protocol. Connect returns a connecting session; read its coherent `state()` snapshot and wait with that snapshot's revision to observe first-dial failures and reconnects. Each watcher keeps its own revision. Hold the owned `BandwidthConsumer` returned by `session.recvBandwidth()` to receive bandwidth estimates. `rate()` reads the current estimate in bits per second and `rateChanged()` waits for a new one. Release the consumer or call `cancel()` when finished; releasing the last consumer stops PROBE; the session and stats snapshots do not request it. Client consumers survive reconnects and report no estimate while disconnected. - **Authorization.** On `moq-lite-07-wip` (opt in with `Client(versions:)`) and on moq-transport draft-18+ with the [MoQ Auth extension](/draft/moq-auth), `for try await grant in session.grants()` yields the `Grant` the relay sent back and each change, `nil` until it answers and while a client session reconnects. Cancelling the iterating task cancels the native wait. diff --git a/go/wrapper/session.go b/go/wrapper/session.go index 119ab1317f3..c099c4a63da 100644 --- a/go/wrapper/session.go +++ b/go/wrapper/session.go @@ -187,8 +187,8 @@ func (s *Session) Consume() *OriginConsumer { } // Shutdown requests a draining close now; wait for its result with Closed. -// Explicit moq-lite-07-wip waits for the peer to receive finished tails; older -// Lite versions (including default lite-06) and IETF wait only for transport ACKs. +// moq-lite-05 and newer wait for the peer to receive finished tails; older Lite +// versions and IETF wait only for transport ACKs. // Finish or abort live tracks first; the drain times out after one second. func (s *Session) Shutdown() { s.inner.Shutdown() diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index 991dcb1d920..706e8568ceb 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -68,8 +68,8 @@ export interface Established { /** * Withdraw announcements and drain for up to one second, then close the session. * - * Explicit lite-07-wip waits for the subscriber to receive finished tails; older - * Lite versions wait only for transport ACKs. IETF media streams are not drained. + * Lite-05 and newer wait for the subscriber to read finished tails; older Lite versions + * wait only for transport ACKs. IETF media streams are not drained. */ close(): Promise; diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 1ac03662b5f..83777d63a09 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -448,10 +448,9 @@ for (const [alpn, version] of [ } } -/** A lite-07 publisher serving a finished track to a raw subscriber that has read up to the FIN. */ -async function servedRawSubscription() { - const version = Version.DRAFT_07; - const pair = createMockTransportPair(ALPN_07_WIP); +/** A publisher serving a finished track to a raw subscriber that has read up to the FIN. */ +async function servedRawSubscription(alpn = ALPN_07_WIP, version = Version.DRAFT_07) { + const pair = createMockTransportPair(alpn); const origin = new Producer(); const broadcastPublication = origin.createBroadcast(Path.from("room")); const broadcast = broadcastPublication.broadcast; @@ -502,42 +501,49 @@ async function servedRawSubscription() { }; } -// The publisher's FIN says nothing about whether the subscriber read the tail; on lite-07 the -// subscriber's FIN does, so close waits for it. -test("a lite-07 subscriber withholding FIN holds close until the deadline", async () => { - const deadline = mockDeadline(); - const served = await servedRawSubscription(); - try { - const closing = served.server.close(); - await settle(); - expect(served.closed()).toBe(false); +// The publisher's FIN says nothing about whether the subscriber read the tail, and a receiver +// may drop unread bytes on close. From lite-05, whose SUBSCRIBE_END lets the subscriber settle +// its tail at once, the subscriber's FIN says so, and close waits for it. +for (const [alpn, version] of [ + [ALPN_05, Version.DRAFT_05], + [ALPN_06, Version.DRAFT_06], + [ALPN_07_WIP, Version.DRAFT_07], +] as const) { + test(`a ${versionName(version)} subscriber withholding FIN holds close until the deadline`, async () => { + const deadline = mockDeadline(); + const served = await servedRawSubscription(alpn, version); + try { + const closing = served.server.close(); + await settle(); + expect(served.closed()).toBe(false); - const failure = closing.catch((error: unknown) => error); - await deadline.expire(); - expect(await failure).toHaveProperty("message", "session close timed out"); - expect(served.closed()).toBe(true); - } finally { - deadline.installed.close(); - served.cleanup(); - } -}); + const failure = closing.catch((error: unknown) => error); + await deadline.expire(); + expect(await failure).toHaveProperty("message", "session close timed out"); + expect(served.closed()).toBe(true); + } finally { + deadline.installed.close(); + served.cleanup(); + } + }); -test("a lite-07 subscriber FIN releases close", async () => { - const deadline = mockDeadline(); - const served = await servedRawSubscription(); - try { - const closing = served.server.close(); - await settle(); - expect(served.closed()).toBe(false); + test(`a ${versionName(version)} subscriber FIN releases close`, async () => { + const deadline = mockDeadline(); + const served = await servedRawSubscription(alpn, version); + try { + const closing = served.server.close(); + await settle(); + expect(served.closed()).toBe(false); - served.subscriber.writer.close(); - await closing; - expect(served.closed()).toBe(true); - } finally { - deadline.installed.close(); - served.cleanup(); - } -}); + served.subscriber.writer.close(); + await closing; + expect(served.closed()).toBe(true); + } finally { + deadline.installed.close(); + served.cleanup(); + } + }); +} test("abort during the close drain ends the session at once", async () => { const deadline = mockDeadline(); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 88b7a1a6df5..4ef71179c2a 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -330,7 +330,7 @@ class SubscriptionControls { async #decode({ reader, version, apply, quic }: SubscriptionControlOptions) { try { - // Runs until the peer's half ends, even past our own FIN: a lite-07 publisher waits + // Runs until the peer's half ends, even past our own FIN: a lite-05+ publisher waits // for that end, and every other teardown stops the reader. for (;;) { const update = await SubscribeUpdate.decodeMaybe(reader, version); @@ -998,8 +998,9 @@ export class Publisher { }); console.debug(`publish done: broadcast=${msg.broadcast} track=${track.name}`); - // A lite-07 subscriber FINs once it has read the tail, so leave its half open and - // wait for that. Older versions stop it here, which ends the decoder. + // A lite-05+ subscriber FINs once it has read the tail, so leave its half open and + // wait for that: a receiver may drop unread bytes on close. Older versions stop it + // here, which ends the decoder. if (waitsForSubscriberFin(this.version)) stream.writer.close(); else stream.close(); // Ends the datagram loop. diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 5c89f4b4abd..190e7caeb1e 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1021,7 +1021,7 @@ export class Subscriber { if (tailSettled && entry.finished) producer.finish(); hooks.releaseTrack(producer); } - // A settled lite07 tail acknowledges completion with FIN, without cancelling + // A settled lite-05+ tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. if (tailSettled && waitsForSubscriberFin(this.version)) stream.writer.close(); else stream.close(); diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index ccd26335da1..0437355bb84 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -369,22 +369,12 @@ export function hasHidden(version: Version): boolean { /** * Whether a served SUBSCRIBE completes only once the subscriber FINs or resets its half of the - * Subscribe Stream. Added in lite-07, where a subscriber FINs once its tail accounting settles, - * since a transport ACK does not say the application read the tail. + * Subscribe Stream. A subscriber FINs once its tail accounting settles, since a transport ACK + * does not say the application read the tail. SUBSCRIBE_END says which groups are owed, so that + * is prompt from lite-05; before it, only the subscriber's grace settles the tail. */ export function waitsForSubscriberFin(version: Version): boolean { - // Explicitly list older versions so future versions keep the lite-07+ behavior. - switch (version) { - case Version.DRAFT_01: - case Version.DRAFT_02: - case Version.DRAFT_03: - case Version.DRAFT_04: - case Version.DRAFT_05: - case Version.DRAFT_06: - return false; - default: - return true; - } + return hasTrackStream(version); } /** Whether the announce stream has ANNOUNCE_RESTART: another publisher instance replacing an diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 66528ce225f..3ee01b880da 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -85,9 +85,8 @@ class Moq internal constructor( /** * Request a draining close now; await `session.closed()` for its result, then [close] * to release the client. - * Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older - * Lite versions, including the default lite-06, wait only for transport ACKs. - * IETF also waits for transport ACKs. + * `moq-lite-05` and newer wait for the peer to receive finished tails; older + * Lite versions and IETF wait only for transport ACKs. */ fun shutdown() { session.shutdown() diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index f2e5aa39b7c..5facc7399e1 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -158,7 +158,7 @@ client = moq.Client( - `.cancel()`. Cancel an in-flight `accept()`/`reject()` call. - **`Session`**. A connection, connecting or connected. Holding it keeps the connection alive; it is also an `async with` context manager that drains on a clean exit and cancels on an error. - `await .closed()`. Wait until the session closes. - - `.cancel(code)`, `session.shutdown()`. Cancel immediately, or request a drain now and await `session.closed()` for its result, including a delivery timeout after one second. Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older Lite versions, including default lite-06, wait only for transport ACKs. IETF also waits for transport ACKs. + - `.cancel(code)`, `session.shutdown()`. Cancel immediately, or request a drain now and await `session.closed()` for its result, including a delivery timeout after one second. moq-lite-05 and newer wait for the peer to receive finished tails; older Lite versions and IETF wait only for transport ACKs. - `.publish() → OriginProducer`, `.consume() → OriginConsumer`. The wired origin sides. - `.stats() → ConnectionStats`. Snapshot RTT, bandwidth estimates, and byte/packet counters. - `.state()` and `await .state_changed(after)`. Read one lifecycle snapshot and wait with its revision; first-dial failures and reconnects share the same session. diff --git a/py/moq-rs/moq/session.py b/py/moq-rs/moq/session.py index 33619e8ff04..900877ca1f6 100644 --- a/py/moq-rs/moq/session.py +++ b/py/moq-rs/moq/session.py @@ -78,9 +78,8 @@ def cancel(self, code: int) -> None: def shutdown(self) -> None: """Request a draining close now; await :meth:`closed` for its result. - Explicit ``moq-lite-07-wip`` waits for the peer to receive finished tails; - older Lite versions, including the default lite-06, wait only for transport - ACKs. IETF also waits for transport ACKs. + ``moq-lite-05`` and newer wait for the peer to receive finished tails; older + Lite versions and IETF wait only for transport ACKs. """ self._inner.shutdown() diff --git a/quest/m1/lite-legacy-close-drain.md b/quest/m1/lite-legacy-close-drain.md index 722d351b6c2..4c3714389ba 100644 --- a/quest/m1/lite-legacy-close-drain.md +++ b/quest/m1/lite-legacy-close-drain.md @@ -1,26 +1,34 @@ -# [S] A lite-01..06 graceful close delivers the final group +# [S] A lite-01..04 graceful close delivers the final group ## Goal -A publisher's graceful close on lite-01..06 never cuts a final group the +A publisher's graceful close on lite-01..04 never cuts a final group the subscriber has not read yet, over WebTransport and QMux, in Rust and JS. ## Plan Found while landing [moq-dev/moq#6969](https://github.com/moq-dev/moq/pull/6969): -the JS publisher's drain waits only for the transport's FIN -acknowledgement. The mock resolves that once the bytes are queued at the -peer, and its close discards unread chunks; WebTransport likewise errors a -session's receive streams on close, dropping their unread bytes. lite-07's -subscriber FIN avoids this. +the publisher's drain waits only for the transport's FIN acknowledgement, +and a receiver may drop unread bytes on close (WebTransport errors a +session's receive streams on close, and the JS mock discards unread chunks). Decided 2026-10-28 (maintainer): m1, since lite-01..06 are published and a graceful close losing data breaks what "graceful" promises. -Reproduce first, over real WebTransport and QMux, for the JS publisher and -the Rust publisher. Then make the drain wait until the subscriber has read -the final group, using a signal lite-01..06 already carries (such as the -subscriber closing its side of the stream). If those versions carry no such -signal, stop and report the options rather than add a wait on a timer. +[moq-dev/moq#7296](https://github.com/moq-dev/moq/pull/7296) fixed lite-05 +and lite-06: their publishers now wait for the subscriber's FIN, which Rust +and JS subscribers send once their tail settles, as lite-07 already did. +SUBSCRIBE_END says which groups are owed, so that FIN is prompt. + +lite-01..04 carry no SUBSCRIBE_END, so a subscriber settles its tail only +when its grace expires (the subscription's max delay, or one second), and +only then FINs. Waiting for that FIN is a wait on the subscriber's timer: a +close takes at least the grace and, with the defaults, hits the one-second +drain cap and reports a timeout. The maintainer picks one of: + +- Keep the ACK drain on lite-01..04 and document that only lite-05+ closes + wait for the subscriber to read the tail. Delete this quest. +- Wait for the subscriber's FIN on lite-01..04 too, accepting a close that + waits out the grace and usually times out. Public API: none expected. Wire: none; published versions keep their wire. diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index aa7bbbdeec2..6bcf3d7d613 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -1246,8 +1246,8 @@ impl MoqSession { /// handle or a `closed()` waiter; repeated requests share the same drain. /// `cancel` stays immediate. The name avoids Kotlin's generated /// `AutoCloseable.close()`, which releases the handle. - /// Explicit `moq-lite-07-wip` waits for the peer to receive the tail. Older Lite - /// versions, including the default lite-06, and IETF wait only for transport ACKs. + /// `moq-lite-05` and newer wait for the peer to receive the tail; older Lite + /// versions and IETF wait only for transport ACKs. /// A drain that takes longer than one second ends with a delivery timeout. pub fn shutdown(&self) { let _guard = crate::ffi::enter(); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 4c0bb5c0af7..57bf9e0b21a 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -7841,7 +7841,6 @@ async fn shutdown_failure_survives_a_discarded_waiter() { } /// Real sockets exercise the FFI runtime and both accepted/client session handles. -/// Only lite-07 acknowledges the received tail; default lite-06 closes after transport ACKs. async fn shutdown_pair() -> (Arc, Arc, Arc) { session_pair("moq-lite-07-wip").await } diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 35b34b4154c..cdfa042acf6 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1739,8 +1739,9 @@ impl RequestServe { stream.writer.finish()?; *finished = true; } - // A transport ACK does not say the application read the tail: a lite-07 - // subscriber FINs once its tail accounting settles, so wait for that. + // A transport ACK does not say the application read the tail, and a + // receiver may drop unread bytes on close: the subscriber FINs once its + // tail accounting settles, so wait for that. if R::GRACEFUL && self.shared.version.waits_for_subscriber_fin() { ready!(self.poll_requester(waiter))?; } diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index c3c424f2fff..6c1d755a591 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -226,12 +226,11 @@ impl Version { self.resolves_start() && !self.has_media_time() && !self.has_fetch_max_delay() } - /// Whether subscription completion waits for the subscriber's FIN. + /// Whether subscription completion waits for the subscriber's FIN, which it sends once + /// its tail accounting settles. SUBSCRIBE_END says which groups are owed, so that is + /// prompt from lite-05; before it, only the subscriber's grace settles the tail. pub(crate) fn waits_for_subscriber_fin(self) -> bool { - !matches!( - self, - Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 - ) + self.has_track_stream() } /// Whether ANNOUNCE_REQUEST carries the hidden opt-in. Added in lite-07; older diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 2c2e5555069..4675c507f71 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -270,9 +270,9 @@ impl Session { /// /// Waits until every stream still serving the peer (a subscription whose track /// finished, a fetch, a track info reply) has written its data and FIN and the - /// transport acknowledged them, then closes the transport. Explicit lite-07-wip - /// also waits for the subscriber to receive a subscription's tail. Older Lite - /// versions, including the default lite-06, and IETF wait only for transport ACKs. Returns + /// transport acknowledged them, then closes the transport. Lite-05 and newer, including + /// the default lite-06, also wait for the subscriber to receive a subscription's tail. + /// Older Lite versions and IETF wait only for transport ACKs. Returns /// [`Error::Timeout`] if that takes longer than one second, closing anyway, or /// the session's terminal error if it ended some other way first. A track that /// is still live never finishes, so finish or abort tracks before closing. diff --git a/rs/moq-net/tests/integration/session_close.rs b/rs/moq-net/tests/integration/session_close.rs index 7d268a3dbc8..621763c49de 100644 --- a/rs/moq-net/tests/integration/session_close.rs +++ b/rs/moq-net/tests/integration/session_close.rs @@ -262,12 +262,22 @@ async fn close_times_out_waiting_for_announcement_acknowledgements() { } } -/// A transport ACK is not proof the subscriber read the final group. +/// The versions whose SUBSCRIBE_END lets a subscriber settle its tail, and FIN, at once. +const SETTLED_TAIL: [&str; 3] = ["moq-lite-05", "moq-lite-06", "moq-lite-07-wip"]; + +/// A transport ACK is not proof the subscriber read the final group: a receiver may drop +/// unread bytes on close. #[moq_clock::test] async fn close_tail() { + for version in SETTLED_TAIL { + close_tail_for(version).await; + } +} + +async fn close_tail_for(version: &str) { let Setup { pair, track, reader, .. - } = setup("moq-lite-07-wip").await; + } = setup(version).await; pair.client_transport.ack_fins(); pair.client_transport.hold_unis(); let mut group = track.append_group().unwrap(); @@ -278,27 +288,31 @@ async fn close_tail() { moq_clock::sleep(Duration::from_millis(100)).await; assert!( !close.is_finished(), - "close must wait for the subscriber, not the transport ACK" + "{version}: close must wait for the subscriber, not the transport ACK" ); pair.client_transport.release_unis(); - close - .await - .unwrap() - .expect("close drains after the subscriber reads the tail"); + let res = close.await.unwrap(); + assert!(res.is_ok(), "{version}: close drains after the subscriber reads the tail: {res:?}"); let (got, end) = reader.await.unwrap(); - assert_eq!(got, vec![b"last".to_vec()]); - end.expect("track ends cleanly"); + assert_eq!(got, vec![b"last".to_vec()], "{version}"); + assert!(end.is_ok(), "{version}: track ends cleanly: {end:?}"); } -/// A lite07 peer that never acknowledges the track end cannot report a successful close. +/// A peer that never acknowledges the track end cannot report a successful close. #[moq_clock::test] async fn close_requires_subscriber_fin() { + for version in SETTLED_TAIL { + close_requires_subscriber_fin_for(version).await; + } +} + +async fn close_requires_subscriber_fin_for(version: &str) { let Setup { pair, track, reader: _reader, .. - } = setup("moq-lite-07-wip").await; + } = setup(version).await; pair.client_transport.ack_fins(); pair.server_transport.withhold_bidi_fins(); let mut group = track.append_group().unwrap(); @@ -306,18 +320,13 @@ async fn close_requires_subscriber_fin() { group.finish().unwrap(); track.finish().unwrap(); let started = moq_clock::now(); - assert!(matches!(pair.client.close().await, Err(Error::Timeout))); - assert_eq!(moq_clock::now() - started, Duration::from_secs(1)); -} - -/// Released drafts retain ACK-based close even when application delivery is delayed. -#[moq_clock::test] -async fn older_lite_close_keeps_ack_drain() { - for version in ["moq-lite-05", "moq-lite-06"] { - peer_close_reads_buffered_tail(version).await; - } + let res = pair.client.close().await; + assert!(matches!(res, Err(Error::Timeout)), "{version}: {res:?}"); + assert_eq!(moq_clock::now() - started, Duration::from_secs(1), "{version}"); } +/// Before lite-05 only the subscriber's grace settles its tail, so the close keeps the +/// ACK drain rather than wait out that timer. #[moq_clock::test] async fn earlier_lite_peer_close_reads_buffered_tail() { for version in ["moq-lite-01", "moq-lite-02", "moq-lite-03", "moq-lite-04"] { @@ -369,7 +378,12 @@ async fn peer_close_before_receiver_poll_keeps_buffered_tail() { group.write_frame(Timestamp::ZERO, b"last".as_slice()).unwrap(); group.finish().unwrap(); track.finish().unwrap(); - pair.client.close().await.expect("FIN delivery needs no receiver poll"); + let res = pair.client.close().await; + // From lite-05 the close waits for the subscriber's FIN, which the gate holds back. + match version { + "moq-lite-05" | "moq-lite-06" => assert!(matches!(res, Err(Error::Timeout)), "{version}: {res:?}"), + _ => assert!(res.is_ok(), "{version}: FIN delivery needs no receiver poll: {res:?}"), + } *gate.write().ok().expect("receiver gate remains open") = true; let (got, _) = reader.await.unwrap(); assert_eq!(got, vec![b"last".to_vec()], "{version}"); diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 1b3be3b2420..55bcb05d965 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1061,8 +1061,8 @@ impl Connection { /// still finishing after a GOAWAY) after draining its queued data. /// Tokens stay presented until both sessions finish draining. /// - /// See [`moq_net::Session::close`]: explicit lite-07-wip waits for the subscriber - /// to receive finished tracks' tails; older Lite versions and IETF wait only for + /// See [`moq_net::Session::close`]: lite-05 and newer wait for the subscriber to + /// receive finished tracks' tails; older Lite versions and IETF wait only for /// transport ACKs. Announcements are withdrawn first, bounded by a one second deadline /// (or a predecessor's remaining handover window, if sooner). Call this before /// [`Client::close`], which closes the transport without waiting. Returns `Ok` diff --git a/swift/Sources/Moq/Client.swift b/swift/Sources/Moq/Client.swift index 1a5ec39a438..24825fa4f29 100644 --- a/swift/Sources/Moq/Client.swift +++ b/swift/Sources/Moq/Client.swift @@ -132,9 +132,8 @@ public final class Session: Sendable { } /// Request a draining close now; await `closed()` for its result. - /// Explicit `moq-lite-07-wip` waits for the peer to receive finished tails; older - /// Lite versions, including the default lite-06, wait only for transport ACKs. - /// IETF also waits for transport ACKs. + /// `moq-lite-05` and newer wait for the peer to receive finished tails; older + /// Lite versions and IETF wait only for transport ACKs. public func shutdown() { ffi.shutdown() } From eb6c7cd521e328591cc9550158bbfca01944a37b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 00:08:13 -0700 Subject: [PATCH 03/86] fix(net): satisfy check: fmt, test types, Dart bindings Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 2 +- js/net/src/lite/connection.test.ts | 2 +- rs/moq-net/tests/integration/session_close.rs | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 90c29841245..5e4981dadf2 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -21103,7 +21103,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqsession_recv_bandwidth() != 46568) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqsession_shutdown() != 32451) { + if (uniffi_moq_ffi_checksum_method_moqsession_shutdown() != 2073) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqsession_state() != 32292) { diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 83777d63a09..09202499d66 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -449,7 +449,7 @@ for (const [alpn, version] of [ } /** A publisher serving a finished track to a raw subscriber that has read up to the FIN. */ -async function servedRawSubscription(alpn = ALPN_07_WIP, version = Version.DRAFT_07) { +async function servedRawSubscription(alpn: string = ALPN_07_WIP, version: Version = Version.DRAFT_07) { const pair = createMockTransportPair(alpn); const origin = new Producer(); const broadcastPublication = origin.createBroadcast(Path.from("room")); diff --git a/rs/moq-net/tests/integration/session_close.rs b/rs/moq-net/tests/integration/session_close.rs index 621763c49de..4e95489d4a5 100644 --- a/rs/moq-net/tests/integration/session_close.rs +++ b/rs/moq-net/tests/integration/session_close.rs @@ -292,7 +292,10 @@ async fn close_tail_for(version: &str) { ); pair.client_transport.release_unis(); let res = close.await.unwrap(); - assert!(res.is_ok(), "{version}: close drains after the subscriber reads the tail: {res:?}"); + assert!( + res.is_ok(), + "{version}: close drains after the subscriber reads the tail: {res:?}" + ); let (got, end) = reader.await.unwrap(); assert_eq!(got, vec![b"last".to_vec()], "{version}"); assert!(end.is_ok(), "{version}: track ends cleanly: {end:?}"); From 55bbc932998889e4c0c27c92ce591b296c175f36 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 13:15:06 -0700 Subject: [PATCH 04/86] quest: delete lite-legacy-close-drain, lite-01..04 keep the ACK drain Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/lite-legacy-close-drain.md | 34 ----------------------------- 2 files changed, 35 deletions(-) delete mode 100644 quest/m1/lite-legacy-close-drain.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 3f56b70aabd..8d491e11bed 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -181,7 +181,6 @@ removed). - [Unbounded groups](/quest/m1/unbounded-groups/README.md) - groups and frames lose their size caps; a group keeps a 32 MB tail, and readers behind it replay through paced flights or refresh - [Next Object on a cold Rust copy](/quest/m1/next-object-cold-copy.md) - a join after an upstream group report starts at the first frame received, never skipping - [Lite track end at session death](/quest/m1/lite-track-end.md) - Rust and JS abort an ended lite track whose tail never settled, and agree on what declares the end -- [Legacy lite close drain](/quest/m1/lite-legacy-close-drain.md) - a lite-01..06 graceful close never cuts a final group the subscriber has not read - [moqsrc max-delay](/quest/m1/gst-max-delay.md) - GStreamer's `moqsrc` takes a `max-delay` property instead of a hard-coded 1 s budget - [moq-clock moves into kio::clock](/quest/m1/kio-clock.md) - moq-clock and its test macro become `kio::clock` and `#[kio::test]` before the cut publishes them - [Poll-first async](/quest/m1/poll-first/README.md) - library crates compile no coroutines by default: async wrappers are generated from `poll_*` and gated behind an `async` feature diff --git a/quest/m1/lite-legacy-close-drain.md b/quest/m1/lite-legacy-close-drain.md deleted file mode 100644 index 4c3714389ba..00000000000 --- a/quest/m1/lite-legacy-close-drain.md +++ /dev/null @@ -1,34 +0,0 @@ -# [S] A lite-01..04 graceful close delivers the final group - -## Goal - -A publisher's graceful close on lite-01..04 never cuts a final group the -subscriber has not read yet, over WebTransport and QMux, in Rust and JS. - -## Plan - -Found while landing [moq-dev/moq#6969](https://github.com/moq-dev/moq/pull/6969): -the publisher's drain waits only for the transport's FIN acknowledgement, -and a receiver may drop unread bytes on close (WebTransport errors a -session's receive streams on close, and the JS mock discards unread chunks). - -Decided 2026-10-28 (maintainer): m1, since lite-01..06 are published and a -graceful close losing data breaks what "graceful" promises. - -[moq-dev/moq#7296](https://github.com/moq-dev/moq/pull/7296) fixed lite-05 -and lite-06: their publishers now wait for the subscriber's FIN, which Rust -and JS subscribers send once their tail settles, as lite-07 already did. -SUBSCRIBE_END says which groups are owed, so that FIN is prompt. - -lite-01..04 carry no SUBSCRIBE_END, so a subscriber settles its tail only -when its grace expires (the subscription's max delay, or one second), and -only then FINs. Waiting for that FIN is a wait on the subscriber's timer: a -close takes at least the grace and, with the defaults, hits the one-second -drain cap and reports a timeout. The maintainer picks one of: - -- Keep the ACK drain on lite-01..04 and document that only lite-05+ closes - wait for the subscriber to read the tail. Delete this quest. -- Wait for the subscriber's FIN on lite-01..04 too, accepting a close that - waits out the grace and usually times out. - -Public API: none expected. Wire: none; published versions keep their wire. From 469ce0052d0352093dda5b900de4db80c2e9c194 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 15:47:33 -0800 Subject: [PATCH 05/86] docs(nvenc): record upstream and NVIDIA SDK audit (#7464) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m1/vendor-sync/README.md | 1 - quest/m1/vendor-sync/nvenc.md | 22 -------------------- rs/moq-nvenc/README.md | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 23 deletions(-) delete mode 100644 quest/m1/vendor-sync/nvenc.md diff --git a/quest/m1/vendor-sync/README.md b/quest/m1/vendor-sync/README.md index 8f351670501..e12b778f2dc 100644 --- a/quest/m1/vendor-sync/README.md +++ b/quest/m1/vendor-sync/README.md @@ -61,7 +61,6 @@ behavior follows quinn's fixes. - [moq-quic has every quinn fix it needs](/quest/m1/vendor-sync/quinn.md) - quinn's fixes since the last check are ported with their tests - [UniFFI generator forks are current](/quest/m1/vendor-sync/uniffi.md) - the generator forks carry upstream's fixes until rust2 replaces them - [moq-vaapi has every cros-codecs fix it needs](/quest/m1/vendor-sync/vaapi.md) - cros-libva and cros-codecs fixes reach moq-dev/vaapi -- [moq-nvenc has every upstream fix it needs](/quest/m1/vendor-sync/nvenc.md) - nvidia-video-codec-sdk fixes since the vendored commit are ported - [moq-rtmp's rml has every upstream fix it needs](/quest/m1/vendor-sync/rml.md) - rust-media-libs fixes since rml_rtmp 0.8.0 are ported ## Related diff --git a/quest/m1/vendor-sync/nvenc.md b/quest/m1/vendor-sync/nvenc.md deleted file mode 100644 index f4ba36374fc..00000000000 --- a/quest/m1/vendor-sync/nvenc.md +++ /dev/null @@ -1,22 +0,0 @@ -# [XS] moq-nvenc has every upstream fix it needs - -## Goal - -moq-nvenc carries every fix -[ViliamVadocz/nvidia-video-codec-sdk](https://github.com/ViliamVadocz/nvidia-video-codec-sdk) -has landed since the last check, each ported with its test or recorded as not -applying, and `rs/moq-nvenc/README.md` gains an `Upstream` section naming the -newest upstream commit checked. - -## Plan - -Recurring child of the [vendor sync](/quest/m1/vendor-sync/README.md), which -says how each run works and recurs. - -Last checked: upstream `master` at -[`88c2ca67`](https://github.com/ViliamVadocz/nvidia-video-codec-sdk/commit/88c2ca67406a5087ac117758d0f375d73441c040) -(2026-03-31), the base of the `dynamic-loading` branch vendored in -[moq-dev/moq#2042](https://github.com/moq-dev/moq/pull/2042). It was still -upstream's head on 2026-10-30, so the first run likely only writes the -`Upstream` section. Also check whether NVIDIA released a Video Codec SDK newer -than the vendored headers that a moq encoder or decoder would use. diff --git a/rs/moq-nvenc/README.md b/rs/moq-nvenc/README.md index f1c3119d26d..b02b24dde60 100644 --- a/rs/moq-nvenc/README.md +++ b/rs/moq-nvenc/README.md @@ -37,6 +37,43 @@ The `sys` bindings are generated with bindgen from the vendored headers (`src/sys/headers/`); see the [upstream repo](https://github.com/ViliamVadocz/nvidia-video-codec-sdk) for the generation scripts. +## Upstream + +Last checked on 2026-10-31: upstream `master` at +[`88c2ca67406a5087ac117758d0f375d73441c040`](https://github.com/ViliamVadocz/nvidia-video-codec-sdk/commit/88c2ca67406a5087ac117758d0f375d73441c040) +(2026-03-31), the base of the vendored `dynamic-loading` branch. Upstream +has no commits since that baseline and no +[published security advisories](https://github.com/ViliamVadocz/nvidia-video-codec-sdk/security/advisories), +so there are no wrapper fixes to port this run. + +The vendored encoder header targets NVENC API 12.1. NVIDIA's current +[Video Codec SDK 13.1 release notes](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.1/read-me/index.html) +and [13.0 release notes](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/read-me/index.html) +were checked against `moq-video`'s consumers: + +- AV1 hierarchical B-frames, UHQ iterative encoding, additional profiles and + multiview support do not serve the current low-latency H.264/H.265 NV12 + encoder. It disables B-frames and keeps the low-latency tuning's default of + no lookahead. AV1 encode and Main10 are tracked as separate work. +- Decode statistics, multiview metadata and sample-app seeking have no current + consumer in the live H.264/H.265/AV1 decoder. +- Application-owned CUDA arrays could remove the decoder's device-to-device + copy before NVENC. This is a candidate for a measured optimization, requiring + new bindings, surface ownership and synchronization, rather than a small fix. + See NVIDIA's + [opaque decode output guide](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.1/nvdec-video-decoder-api-prog-guide/index.html). +- The [removed legacy presets and rate-control modes](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.1/deprecation-notices/index.html) + do not affect the encoder's P1/P4/P7 presets with CBR. It does not request + byte-based slices or use the deprecated video-source APIs. + +No SDK header update is needed for those current consumers. An API 13.1 +upgrade would raise the minimum driver requirement; NVIDIA documents driver +610 or newer for that SDK. The +[NVIDIA security bulletins](https://github.com/NVIDIA/product-security/tree/main/2026) +were also checked: GPU driver fixes belong in the installed driver, which this +crate loads but does not vendor. No applicable vendored-source security fix +was identified; updating headers does not update the driver. + ## License MIT, inherited from upstream. See `LICENSE`. From 5d5bd9b969bcdf2cb284ffeec28b3815ab5f66f6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 15:48:07 -0800 Subject: [PATCH 06/86] docs(rtmp): record rust-media-libs upstream audit (#7465) Co-authored-by: GPT-6.1 Sol Co-authored-by: moq-bot --- quest/m1/vendor-sync/README.md | 1 - quest/m1/vendor-sync/rml.md | 26 -------------------------- rs/moq-rtmp/src/rml/mod.rs | 24 ++++++++++++++++++++++++ 3 files changed, 24 insertions(+), 27 deletions(-) delete mode 100644 quest/m1/vendor-sync/rml.md diff --git a/quest/m1/vendor-sync/README.md b/quest/m1/vendor-sync/README.md index e12b778f2dc..c4434ad5131 100644 --- a/quest/m1/vendor-sync/README.md +++ b/quest/m1/vendor-sync/README.md @@ -61,7 +61,6 @@ behavior follows quinn's fixes. - [moq-quic has every quinn fix it needs](/quest/m1/vendor-sync/quinn.md) - quinn's fixes since the last check are ported with their tests - [UniFFI generator forks are current](/quest/m1/vendor-sync/uniffi.md) - the generator forks carry upstream's fixes until rust2 replaces them - [moq-vaapi has every cros-codecs fix it needs](/quest/m1/vendor-sync/vaapi.md) - cros-libva and cros-codecs fixes reach moq-dev/vaapi -- [moq-rtmp's rml has every upstream fix it needs](/quest/m1/vendor-sync/rml.md) - rust-media-libs fixes since rml_rtmp 0.8.0 are ported ## Related diff --git a/quest/m1/vendor-sync/rml.md b/quest/m1/vendor-sync/rml.md deleted file mode 100644 index 4487f37f4aa..00000000000 --- a/quest/m1/vendor-sync/rml.md +++ /dev/null @@ -1,26 +0,0 @@ -# [XS] moq-rtmp's rml has every upstream fix it needs - -## Goal - -moq-rtmp's vendored `rml` module carries every fix -[KallDrexx/rust-media-libs](https://github.com/KallDrexx/rust-media-libs) has -landed in rml_rtmp and rml_amf0 since the last check, each ported with its test -or recorded as not applying, and the module doc in -`rs/moq-rtmp/src/rml/mod.rs` names the newest upstream commit checked. - -## Plan - -Recurring child of the [vendor sync](/quest/m1/vendor-sync/README.md), which -says how each run works and recurs. - -Last checked: rml_rtmp 0.8.0 -([`a25df6bc`](https://github.com/KallDrexx/rust-media-libs/commit/a25df6bc35cdc33f21f7a35efc97e700dcc8fa17), -2023-04-29) and rml_amf0 0.3.0, vendored in -[moq-dev/moq#2060](https://github.com/moq-dev/moq/pull/2060). Upstream -`master` was at -[`953fc41d`](https://github.com/KallDrexx/rust-media-libs/commit/953fc41df2369d64e9c677369c706da797592e49) -(2023-05-31) on 2026-10-30, four commits later and dormant. The two that -matter, `write_all` with exact reads and lowercase publish modes, look already -present (`serializer.rs` and `sessions/server/mod.rs`); confirm and record -`953fc41d` as checked. Upstream being unmaintained makes this child a candidate -to drop in a later run. diff --git a/rs/moq-rtmp/src/rml/mod.rs b/rs/moq-rtmp/src/rml/mod.rs index 58fa2a3f824..fb55bd76b86 100644 --- a/rs/moq-rtmp/src/rml/mod.rs +++ b/rs/moq-rtmp/src/rml/mod.rs @@ -14,6 +14,30 @@ //! AMF0 command in `messages::types::amf0_command`, empty `onMetaData` array in //! `sessions::server`). //! +//! # Upstream +//! +//! Checked upstream `master` through +//! [`953fc41df2369d64e9c677369c706da797592e49`](https://github.com/KallDrexx/rust-media-libs/commit/953fc41df2369d64e9c677369c706da797592e49) +//! (2023-05-31) on 2026-10-31, starting from rml_rtmp 0.8.0 +//! ([`a25df6bc`](https://github.com/KallDrexx/rust-media-libs/commit/a25df6bc35cdc33f21f7a35efc97e700dcc8fa17)) +//! and rml_amf0 0.3.0. All four subsequent commits were checked: +//! - [`ec5935a9`](https://github.com/KallDrexx/rust-media-libs/commit/ec5935a97005c7f8a4e75590b95445758a57cccd) +//! changes connection IDs only in an example we do not vendor. +//! - [`4508e466`](https://github.com/KallDrexx/rust-media-libs/commit/4508e4663798a470fcb9f087983c72199c8ca475) +//! fixes short reads/writes. `chunk_io::serializer::add_message_payload` +//! already uses `write_all`; AMF0's borrowed decoder requires the entire +//! string/property name via checked slice splitting, with truncation tests. +//! Its remaining changes are reference cleanup or unvendored examples/tools. +//! - [`bac6a385`](https://github.com/KallDrexx/rust-media-libs/commit/bac6a3857f056671113169bcb4db0aeff4177951) +//! simplifies an unvendored example's socket writer. +//! - [`953fc41d`](https://github.com/KallDrexx/rust-media-libs/commit/953fc41df2369d64e9c677369c706da797592e49) +//! makes publish modes case-insensitive, already present in `sessions::server`. +//! +//! No code ports were needed. The upstream repository advisories, GitHub +//! Advisory Database (both crate names), and RustSec advisory database had no +//! published advisories for rml_rtmp or rml_amf0 on that date. Upstream has had +//! no commits since the checked head. +//! //! Kept close to upstream (its own tests included), so the whole module opts out //! of the workspace's `-D warnings` clippy/rustdoc gate rather than churning the //! vendor to satisfy our lints. From 5d6343dc0c52787fa2e24463d72a961640e8ec52 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 16:38:28 -0800 Subject: [PATCH 07/86] perf(moq-uring): demux reads wire counters once per receive (#7502) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m2/perf/README.md | 1 - quest/m2/perf/uring-demux-churn.md | 28 --------- rs/moq-uring/src/quic/driver/endpoint.rs | 76 ++++++++++++++++++++---- 3 files changed, 66 insertions(+), 39 deletions(-) delete mode 100644 quest/m2/perf/uring-demux-churn.md diff --git a/quest/m2/perf/README.md b/quest/m2/perf/README.md index 8e3bf71344f..c6ce5757e0b 100644 --- a/quest/m2/perf/README.md +++ b/quest/m2/perf/README.md @@ -100,7 +100,6 @@ revisions on the same host and worker count, and profiles with - [Adapter reads reuse their waker](/quest/m2/perf/adapter-waker-alloc.md) - a measured verdict on the per-read waker allocation moq-tokio and moq-uring reads pay since #7042, fixed if real - [Lite request allocations](/quest/m2/perf/lite-request-allocs.md) - `Path::concat` allocates once and the lite request handler keeps one track-name copy - [Coverage before START](/quest/m2/perf/covered-before-start.md) - the lite-07 publisher's coverage check costs O(1) per wake before SUBSCRIBE_START -- [io_uring demux churn](/quest/m2/perf/uring-demux-churn.md) - the demux snapshots stats and builds its fed list once per completion, not per datagram - [Lock-free single-delivery writes](/quest/m2/perf/single-delivery-write.md) - a frame with one delivery writes without a lock, recovering #6922's remaining FETCH cost - [JS publisher per-frame cost](/quest/m2/perf/js-publisher-frame-cost.md) - fewer promise races per frame, and a per-frame cost that stays flat as streams grow - [Small hot-path waste](/quest/m2/perf/hot-path-waste.md) - one track-name copy per lite subscription, no full-history `#drift` walk, and one header buffer per JS object diff --git a/quest/m2/perf/uring-demux-churn.md b/quest/m2/perf/uring-demux-churn.md deleted file mode 100644 index bd901e17960..00000000000 --- a/quest/m2/perf/uring-demux-churn.md +++ /dev/null @@ -1,28 +0,0 @@ -# [XS] io_uring demux does per-connection work once per completion - -## Goal - -`Endpoint::demux` (`rs/moq-uring/src/quic/driver/endpoint.rs`) updates -each fed connection's wire counters once per receive completion instead of -once per datagram, and a completion that feeds one connection allocates no -`fed` list. - -## Plan - -From the 2026-10-27 audit. Per datagram routed to a connection, demux calls -`conn.conn.borrow().stats()`, which copies the whole `ConnectionStats` and -queries the congestion controller's metrics only to read `udp_rx.bytes`. -Per completion, it allocates `fed: Vec` on the first push, -though a GRO completion almost always feeds exactly one connection. - -Set `received_bytes` once per fed connection in the kick loop after the -batch. Keep the first fed connection inline, and fall back to a list -only when a completion mixes connection IDs. Every connection a mixed batch -feeds must still be kicked. - -Measure with `just bench-runtime` at both revisions on the same host and -worker count, plus allocations per completion, with GRO on and off and one -versus several connections per completion. The payload copy out of the receive pool is -[QUIC I/O ownership](/quest/m2/quic-io-boundary.md)'s, not this quest's. - -Public API: none. Wire: none. diff --git a/rs/moq-uring/src/quic/driver/endpoint.rs b/rs/moq-uring/src/quic/driver/endpoint.rs index 2c033dd6c0c..0aa8a5f7a56 100644 --- a/rs/moq-uring/src/quic/driver/endpoint.rs +++ b/rs/moq-uring/src/quic/driver/endpoint.rs @@ -281,9 +281,7 @@ impl Inner { // retry, a refusal), reused across the whole receive. let mut buf = Vec::new(); - // The connections this packet fed; almost always exactly one (a GRO - // coalesce is a single source, and ids rarely change mid-burst). - let mut fed: Vec = Vec::new(); + let mut fed = Fed::default(); // moq-quic takes each datagram as an owned `BytesMut`: copy the whole // completion out of the receive pool once and split it per datagram. @@ -324,16 +322,11 @@ impl Inner { // we can feed. if let Some(conn) = conn { conn.conn.borrow_mut().handle_event(event); - conn.wire.set_received_bytes(conn.conn.borrow().stats().udp_rx.bytes); - if !fed.contains(&key) { - fed.push(key); - } + fed.push(key); } } Some(DatagramEvent::NewConnection(incoming)) => { - if let Some(key) = self.greet(incoming, &mut buf) - && !fed.contains(&key) - { + if let Some(key) = self.greet(incoming, &mut buf) { fed.push(key); } } @@ -351,6 +344,9 @@ impl Inner { for key in fed { let conn = self.conns.borrow().get(&key).cloned(); if let Some(conn) = conn { + // Once per receive rather than per datagram: `stats` copies the + // whole struct and queries the congestion controller. + conn.wire.set_received_bytes(conn.conn.borrow().stats().udp_rx.bytes); conn.kick(); } } @@ -525,3 +521,63 @@ impl Inner { self.accept_waiters.borrow_mut().wake(); } } + +/// The connections one receive fed, each once, in the order first fed. +/// +/// Almost always exactly one: a GRO coalesce is a single source, and ids +/// rarely change mid-burst. So the first is held inline, and only a receive +/// that mixes connections allocates. +#[derive(Default)] +struct Fed { + first: Option, + rest: Vec, +} + +impl Fed { + fn push(&mut self, key: ConnectionHandle) { + match self.first { + None => self.first = Some(key), + Some(first) if first == key => {} + Some(_) if self.rest.contains(&key) => {} + Some(_) => self.rest.push(key), + } + } +} + +impl IntoIterator for Fed { + type Item = ConnectionHandle; + type IntoIter = std::iter::Chain, std::vec::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + self.first.into_iter().chain(self.rest) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fed(keys: &[usize]) -> Vec { + let mut fed = Fed::default(); + for &key in keys { + fed.push(ConnectionHandle(key)); + } + fed.into_iter().map(|key| key.0).collect() + } + + #[test] + fn fed_lists_each_connection_once_in_order() { + assert_eq!(fed(&[]), Vec::::new()); + assert_eq!(fed(&[3, 3, 3]), [3]); + assert_eq!(fed(&[3, 1, 3, 2, 1, 2]), [3, 1, 2]); + } + + #[test] + fn fed_one_connection_allocates_nothing() { + let mut fed = Fed::default(); + for _ in 0..4 { + fed.push(ConnectionHandle(7)); + } + assert_eq!(fed.rest.capacity(), 0); + } +} From d9d02b9048a5fc335e9acff86ae9080983745daa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:02:47 -0800 Subject: [PATCH 08/86] quest: land main through GitHub's merge queue again, with the async fixer (#7589) Co-authored-by: Claude Opus 5.5 --- quest/m0/ci/README.md | 14 +-- quest/m0/ci/five-minutes.md | 2 +- quest/m0/ci/land.md | 196 +++++++++++-------------------- quest/m0/ci/nightly-levers.md | 2 +- quest/m0/ci/read-only-secrets.md | 2 +- 5 files changed, 81 insertions(+), 135 deletions(-) diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index 22e65a862d1..4c3cb465c0e 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -15,12 +15,12 @@ or nightly run; measured low-value redundancy may be retired. Measured 2026-10-13 from the org billing API (Oct 1-13, all moq) and a 1-in-4 sample of job timings: -| Runner | Minutes | List price | -|---|---|---| -| macOS | 33k | $2,066 | -| ARM Linux | 98k | $492 | -| Windows | 34k | $344 | -| x86 Linux | 30k | $178 | +| Runner | Minutes | List price | +| --------- | ------- | ---------- | +| macOS | 33k | $2,066 | +| ARM Linux | 98k | $492 | +| Windows | 34k | $344 | +| x86 Linux | 30k | $178 | Public repositories are not billed, but the org is capped at 60 concurrent hosted jobs and 5 macOS jobs (Team plan), and that macOS cap is the queue. @@ -152,7 +152,7 @@ The historical samples locate candidates; they are not controlled benchmarks. ## Required - [Read-only pull-request CI](/quest/m0/ci/read-only-secrets.md) - write secrets and publisher identities reach only protected branches and tags, never a pushed branch -- [Land main through quest land](/quest/m0/ci/land.md) - a host daemon tests small batches of queued PRs once and lands each as its tested squash commit +- [Land main through GitHub's merge queue](/quest/m0/ci/land.md) - one PR at a time on the self-hosted runner; the host's Codex fixer fixes and re-enqueues every dropped PR - [A cache error is a miss](/quest/m0/ci/nix-cache-5xx.md) - a transient `nix.moq.dev` 5xx no longer fails `just ci` - [Every io_uring test in the io-uring group](/quest/m0/ci/io-uring-test-group.md) - the parallel tests of one `just ci` stop exhausting the shared `RLIMIT_MEMLOCK`, and a too-low limit fails clearly - [Cheap checks skip the queue](/quest/m0/ci/gate-quick.md) - condition: quest ships `quest gate --quick`; then cheap modules wait at most a minute diff --git a/quest/m0/ci/five-minutes.md b/quest/m0/ci/five-minutes.md index 624e4aed67e..ee8e0d6aaad 100644 --- a/quest/m0/ci/five-minutes.md +++ b/quest/m0/ci/five-minutes.md @@ -90,4 +90,4 @@ What remains for a moq-net edit, largest first: - [Halve the slowest tests' CPU](/quest/m0/ci/slow-tests.md) - the largest test-time lever - [Unify dependency features](/quest/m0/ci/hakari.md) - cache hits across feature shapes -- [Land main through quest land](/quest/m0/ci/land.md) - the queue gate that keeps checks moved out of the local gate +- [Land main through GitHub's merge queue](/quest/m0/ci/land.md) - the queue gate that keeps checks moved out of the local gate diff --git a/quest/m0/ci/land.md b/quest/m0/ci/land.md index cae62ea6646..f84301d9a14 100644 --- a/quest/m0/ci/land.md +++ b/quest/m0/ci/land.md @@ -1,141 +1,87 @@ -# [M] Land main through quest land +# [M] Land main through GitHub's merge queue with an async fixer ## Goal -`main` changes only through a batched gate: `quest land serve`, a daemon on -the maintainer's host, shared with moq-dev/quest. It takes the oldest PR whose -landing was requested plus up to floor(log₂ n) more that merge cleanly, in -enqueue order, and runs `just ci` once on the chain's tip in an isolated -container. A passing batch lands one squash commit per PR, each exactly the -tree the gate tested; a failing one is ejected together and handed to a -Codex fixer. GitHub's merge queue and the Land workflow are gone. Merged PRs -per hour on a busy day rise measurably over the baseline below. +`main` changes only through GitHub's merge queue, one PR at a time. Each +`merge_group` build runs `just ci` on the host's self-hosted runner (label +`moq-queue`), and the queue merges exactly the tree it tested. When the +queue drops a PR for a failure or a conflict, moq-dev/quest's queue watcher +on the host runs the Codex land fixer in the background and re-enqueues the +PR. Only a decision the fixer can't make reaches a person. The host +`quest land` gate stops landing moq. Merged PRs per hour on a busy day match +or beat both baselines below. ## Plan -Baseline, measured 2026-10-26 over 2026-10-23 to 10-26 (414 queue merges, -76.7 h): +Decided 2026-11-01 (maintainer, /quest-plan), reversing the 2026-10-26 +choice of a batched gate: -- Throughput: 5.5 merged PRs per hour overall, and a median of 6.0 in the - hours the queue never emptied. One PR at a time caps it at about 11. -- Latency: from the final enqueue to merged, a median of 87 minutes and a - p90 of 162. Waiting behind other entries is 98% of that. GitHub adds about - 1 minute per merge, and a job is a median of 4.7 minutes (p90 15.1). -- Depth: a new entry found a median of 18 entries ahead of it. -- Failures: 60 of about 470 runs failed (12.7%): - - 25 semantic conflicts with PRs that landed ahead. A PR's base was a - median of 22 commits behind the queue's. - - 10 flaky tests, since fixed or tracked in the - [flake epic](/quest/m0/test-flakes-2/README.md). - - 8 lint failures and 9 failures from the PR's own tests or compile - errors. A local `just ci` would have caught about 14 of these 17. - - 8 infra failures, from mbx cache eviction before - [moq-dev/moq#6709](https://github.com/moq-dev/moq/pull/6709). -- Conflicts: 246 merge-conflict removals. 71% touched `quest/` and 41 - touched a `MIGRATE.md` Unreleased section. +- ✅ Serialize through GitHub's merge queue again. The 2026-10-26 reason to + leave it, conflicts GitHub cannot resolve (71% of 246 removals touched + `quest/`), is now absorbed by the async fixer, which resolves them with the + quest merge driver and re-enqueues. Batching and pre-checks are dropped: + on moq a useful pre-check must compile, which is most of a check. +- ✅ moq moves first; moq-dev/quest, moq.pro, rust2 and rind stay on the + host gate until moq proves this. +- ✅ `merge_group` builds run on the self-hosted runner on nixos (warm caches, + no GitHub minutes), with build concurrency 1. Try 2 only with measured data. +- ✅ Host-gate work is frozen apart from what this switch needs. +- The watcher (re-enqueue at the back of the queue, unlimited attempts while + each fix makes progress, flaky re-enqueues, fixing a pick GitHub refuses to + enqueue) is planned in moq-dev/quest's m0 queue-watcher epic; moq waits on + it before the cutover. +- Superseded designs are in git history: the 2026-10-26 three-phase + `land.yml` and the 2026-10-30 host daemon (`quest land serve`). -A simulation of one-runner batching with bisection gave 1.25 merged PRs per -CI run at a 12.7% failure rate (best batch near 4), against 0.88 serial. At -5% it gave about 2.4. The failure rate is the lever, which is why the flake -epic moved to m0 and migration notes stay in PR Impact sections until the -[release migration audit](/quest/m2/release/migrate-audit.md). +Baselines: -Decided 2026-10-26 (maintainer): +- GitHub's merge queue, measured 2026-10-26 over 2026-10-23 to 10-26 (414 + queue merges, 76.7 h): -- GitHub's merge queue cannot do this. Its "merge limits do not combine - `merge_group` builds": every entry still runs its own CI. HEADGREEN with - more concurrent builds needs more runners. Mergify (free for open source, - with batching and bisection) was rejected: it merges through the API, so - it can neither guarantee the tested tree nor resolve quest-file conflicts. -- The engine is `quest land` in moq-dev/quest, shared with moq-dev/quest, - moq.pro, and moq-dev/rust2. -- The rule that quest, test, and CI PRs jump the queue is dropped. Order is - enqueue order. -- Decided 2026-10-29 (maintainer), answering the question raised in - [moq-dev/moq#7166](https://github.com/moq-dev/moq/pull/7166): the moq-bot - key moves to its own `bot` Environment, for `main`, `release`, and - `moq-ffi-v*`. Dependabot, the Smoke toolchain update, and the moq-bot-only - publishers name it. `publish` keeps the key too, for release-rs, which also needs the - crates.io token. Narrowing `publish` to release refs waits on - [its own quest](/quest/m0/ci/publish-release-only.md), since `workflow_run` - publishers run as `main`. -- Open, found 2026-10-29: `APP_CLIENT_ID` and `APP_PRIVATE_KEY` are moq-dev - organization secrets visible to this repository, so any workflow on a - pushed branch can read the moq-bot key, not only jobs in `bot`. Once - moq-bot bypasses `main`, that branch could push to `main`. Before cutover, - the maintainer deletes the organization secrets and this repository's own - copies; `bot` and `publish` already hold the key under the same names. -- The gate tests each PR on the latest `main`, not the PR's own base. On - 2026-10-29 [moq-dev/moq#7162](https://github.com/moq-dev/moq/pull/7162) - passed its own CI but broke in the queue on API renames that landed after - its last base merge; that is the case the gate must catch before `main`. - -Decided 2026-10-30 (maintainer, from moq-dev/quest's -[host gate epic](https://github.com/moq-dev/quest/blob/main/quest/m0/host-land/README.md) -and [moq-dev/quest#297](https://github.com/moq-dev/quest/pull/297)). This -replaces the 2026-10-26 design of a three-phase `land.yml` on the self-hosted -queue runner, and the 2026-10-30 `token.moq.dev` decision: + - Throughput: 5.5 merged PRs per hour overall, and a median of 6.0 in the + hours the queue never emptied. One PR at a time caps it at about 11. + - Latency: from the final enqueue to merged, a median of 87 minutes and a + p90 of 162. Waiting behind other entries is 98% of that. GitHub adds about + 1 minute per merge, and a job is a median of 4.7 minutes (p90 15.1). + - Depth: a new entry found a median of 18 entries ahead of it. + - Failures: 60 of about 470 runs failed (12.7%): + - 25 semantic conflicts with PRs that landed ahead. A PR's base was a + median of 22 commits behind the queue's. + - 10 flaky tests, since fixed or tracked in the + [flake epic](/quest/m0/test-flakes-2/README.md). + - 8 lint failures and 9 failures from the PR's own tests or compile + errors. A local `just ci` would have caught about 14 of these 17. + - 8 infra failures, from mbx cache eviction before + [moq-dev/moq#6709](https://github.com/moq-dev/moq/pull/6709). + - Conflicts: 246 merge-conflict removals. 71% touched `quest/` and 41 + touched a `MIGRATE.md` Unreleased section. -- **Host daemon.** `quest land serve` runs on the maintainer's NixOS host as - its own user, which loads moq-bot's App key through a systemd credential. - No GitHub Actions job lands anything: `land.yml` goes, and `dependabot.yml` - stops calling `quest land resolve`, requesting landing with - `quest merge --land` like any other PR. The `token.moq.dev` land endpoint - ([moq-dev/moq#7275](https://github.com/moq-dev/moq/pull/7275)) and the - rule that no other `land.yml` job may request an ID token go with them. -- **Check.** `just ci` runs once per batch on the chain's tip, in an - ephemeral nspawn container with no credentials, as moq's runners already - are on that host. The daemon fetches with a read-only token only once - private-repository support lands in moq-dev/quest; moq is public. -- **Batches** are the oldest queued PR plus up to floor(log₂ n) more that - merge cleanly with the quest merge driver. There is no bisection: a failing - batch is ejected together with the log's tail in each comment, and a Codex - fixer makes only mechanical fixes, requeues healthy PRs, and hands back the - rest. This supersedes the 2026-10-26 "every queued PR, test the first half" - rule. -- **Landing.** For each PR in order, the daemon merges the current `main` - into the PR's branch with the quest merge driver and pushes it, then - squash-merges the PR through GitHub's API with its head pinned. The squash - is the tested tree, quest-file conflicts are resolved by the driver rather - than GitHub, and the PR shows as merged. A fork branch the daemon cannot - push to lands by pushing its squash to `main` and closing the PR with a - link, so forks are no longer ejected as `no_maintainer_edits`. This - supersedes the 2026-10-26 force-push-and-fast-forward landing; Mergify's - rejection still stands, since it cannot resolve quest files. -- Decision prompts (maintainer, 2026-10-30): - - Landing: push `main` and close fork PRs / API squash merge per PR / - ✅ merge `main` into each PR with the driver, then API squash merge, and - push `main` and close the PR for a fork the daemon cannot push to - - moq's migration: ✅ rewrite this quest before the cutover / a separate - migration quest after it / wait for the daemon to run live first -- **Pin.** moq keeps its current moq-dev/quest flake input, whose - three-phase `quest land` this repository's draft still targets, until the - cutover bumps it to a release with `quest land serve`. + A simulation of one-runner batching with bisection gave 1.25 merged PRs per + CI run at a 12.7% failure rate (best batch near 4), against 0.88 serial. At + 5% it gave about 2.4. The failure rate is the lever, which is why the flake + epic moved to m0 and migration notes stay in PR Impact sections until the + [release migration audit](/quest/m2/release/migrate-audit.md). -Draft source in [moq-dev/moq#7024](https://github.com/moq-dev/moq/pull/7024) -prepared the three phases, trusted Quest installation, the read-only worker, -head-pinned Dependabot requests, bot-key Environment declarations, and the -event policy and setup tests. The host daemon supersedes its workflows; its -Dependabot and Environment pieces may still apply. No workflow was deployed, -queue entry changed, ruleset written, or credential moved. `release` keeps -its own PR checks. +- The host gate, measured 2026-11-01: about 4-6 merged PRs per hour at a 23% + per-PR failure rate, with batches shrinking to one PR and 10-17 minute + checks (moq-dev/quest#376's measurements). -Remaining before completion: +Remaining: -- **Host.** moq-dev/quest's - [gate host](https://github.com/moq-dev/quest/blob/main/quest/m0/host-land/host.md) - quest runs the daemon for moq-dev/quest first; add moq-dev/moq to it. -- **Maintainer cutover.** On 2026-10-27 the main ruleset still requires - `Check`, contains `merge_queue`, and admits only the administrator bypass. - Add the bot bypass and replace those two requirements with `quest land`, - pinned to an app that never reports it on PR heads. Coordinate the daemon's - start with that ruleset change: removing `merge_group` cannot pass the old - queue, and changing protection first stops that queue. -- Decided 2026-10-29 (maintainer): CONTRIBUTING no longer prescribes the - squash commit body; the engine's default message stands. -- **Live evidence.** Exercise the gate and its ejections after the - maintainer's cutover, including a fork that blocks maintainer edits, and - measure a busy day's merged PRs/hour and enqueue-to-merged latency against - the baseline. Local tests do not establish those outcomes. +- **Workflow.** A `merge_group` workflow runs `just ci` against the group's + base on `runs-on: [self-hosted, moq-queue]`, read-only, with no secrets. +- **Ruleset.** `main` requires that check and the merge queue (squash, build + concurrency 1, merge limit 1), drops the unreported `quest land` + requirement, and keeps only the administrator bypass. +- **Host.** The host config stops serving moq from `quest land` + (`services.quest-land.repositories.moq.enable = false`) at the cutover, + while the watcher and fixer keep serving moq. +- **Dependabot and picks** enqueue through the merge queue like any other PR. +- Open since 2026-10-29: `APP_CLIENT_ID` and `APP_PRIVATE_KEY` are moq-dev + organization secrets visible to this repository, so any workflow on a + pushed branch can read the moq-bot key. The maintainer deletes the + organization copies; `bot` and `publish` already hold the key. +- **Live evidence.** After the cutover, measure a busy day's merged PRs per + hour and enqueue-to-merged latency against both baselines. Public API: none. Wire: none. diff --git a/quest/m0/ci/nightly-levers.md b/quest/m0/ci/nightly-levers.md index fbbdc848854..2807dffd275 100644 --- a/quest/m0/ci/nightly-levers.md +++ b/quest/m0/ci/nightly-levers.md @@ -31,4 +31,4 @@ Public API: none. Wire: none. ## Required -- [Land main through quest land](/quest/m0/ci/land.md) - the structural baseline, replacing concurrent queue entries +- [Land main through GitHub's merge queue](/quest/m0/ci/land.md) - the structural baseline, replacing concurrent queue entries diff --git a/quest/m0/ci/read-only-secrets.md b/quest/m0/ci/read-only-secrets.md index 33344da5699..68fb68d7f34 100644 --- a/quest/m0/ci/read-only-secrets.md +++ b/quest/m0/ci/read-only-secrets.md @@ -69,4 +69,4 @@ publish, since jobs that do not name `publish` still read it: ## Related -- [Land main through quest land](/quest/m0/ci/land.md) - its gate credential also belongs in a protected environment +- [Land main through GitHub's merge queue](/quest/m0/ci/land.md) - its gate credential also belongs in a protected environment From 1eaff232aac7912012f70aab6431023920c2ccbf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:03:24 -0800 Subject: [PATCH 09/86] feat(qmux)!: a session is one caller-spawned driver that owns its socket (#7494) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- Cargo.lock | 39 +- Cargo.toml | 1 + quest/m2/perf/stream/README.md | 18 +- quest/m2/perf/stream/codec.md | 16 +- quest/m2/perf/stream/driver.md | 54 - quest/m2/perf/stream/relay.md | 8 + quest/m2/perf/stream/ring.md | 10 +- quest/m2/qmux-receive-lease.md | 1 - quest/m2/wire-stats/qmux.md | 12 +- rs/moq-quic/src/coding.rs | 18 + rs/moq-quic/src/connection/mux/mod.rs | 60 +- rs/moq-quic/src/connection/mux/wire.rs | 4 +- rs/moq-quic/src/connection/streams/state.rs | 6 +- rs/moq-quic/src/frame.rs | 4 +- rs/moq-relay/Cargo.toml | 7 +- rs/moq-relay/src/auth/refresh.rs | 7 +- rs/moq-relay/src/listener.rs | 2 +- rs/moq-relay/src/listener/idle.rs | 11 + rs/moq-relay/src/shutdown.rs | 3 +- rs/moq-relay/src/web.rs | 12 +- rs/moq-relay/src/websocket.rs | 455 +++--- .../tests/integration/auth_lifetime.rs | 15 +- .../tests/integration/implementation.rs | 3 +- rs/moq-tokio/src/client.rs | 1 + rs/moq-tokio/src/connection.rs | 2 + rs/moq-tokio/src/lib.rs | 9 + rs/moq-tokio/src/server.rs | 40 +- rs/moq-tokio/src/tcp.rs | 7 +- rs/moq-tokio/src/unix.rs | 2 + rs/moq-tokio/src/webrtc.rs | 17 +- rs/moq-tokio/src/webrtc/channel.rs | 126 +- rs/moq-tokio/src/webrtc/tests.rs | 8 +- rs/moq-tokio/src/websocket.rs | 7 +- rs/qmux/Cargo.toml | 8 +- rs/qmux/README.md | 4 + rs/qmux/examples/bench.rs | 34 +- rs/qmux/examples/interop-client.rs | 3 +- rs/qmux/examples/interop-server.rs | 3 +- rs/qmux/src/driver.rs | 446 ++++++ rs/qmux/src/lib.rs | 10 +- rs/qmux/src/session.rs | 343 +---- rs/qmux/src/session/tests.rs | 31 +- rs/qmux/src/tcp.rs | 18 +- rs/qmux/src/tls.rs | 29 +- rs/qmux/src/transport.rs | 1326 +---------------- rs/qmux/src/transport/stream.rs | 376 +++++ rs/qmux/src/transport/stream/tests.rs | 188 +++ rs/qmux/src/transport/websocket.rs | 186 +++ rs/qmux/src/transport/websocket/tests.rs | 148 ++ rs/qmux/src/uds.rs | 14 +- rs/qmux/src/ws.rs | 35 +- rs/qmux/tests/interop.rs | 10 +- 52 files changed, 2080 insertions(+), 2117 deletions(-) delete mode 100644 quest/m2/perf/stream/driver.md create mode 100644 rs/qmux/src/driver.rs create mode 100644 rs/qmux/src/transport/stream.rs create mode 100644 rs/qmux/src/transport/stream/tests.rs create mode 100644 rs/qmux/src/transport/websocket.rs create mode 100644 rs/qmux/src/transport/websocket/tests.rs diff --git a/Cargo.lock b/Cargo.lock index b30c0b4a72c..ec36bdacbb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -707,7 +707,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -726,10 +725,8 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1 0.10.7", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -5909,6 +5906,7 @@ dependencies = [ "http-body", "http-cache-reqwest", "humantime", + "hyper", "hyper-util", "kio 0.6.2", "libc", @@ -8535,7 +8533,7 @@ dependencies = [ "libc", "thiserror 2.0.21", "tokio", - "tokio-tungstenite 0.30.0", + "tokio-tungstenite", "tracing", "web-transport-proto", "web-transport-trait", @@ -8559,7 +8557,8 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tokio-rustls", - "tokio-tungstenite 0.30.0", + "tokio-tungstenite", + "tokio-util", "tracing", "web-transport-trait", ] @@ -11054,18 +11053,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.29.0", -] - [[package]] name = "tokio-tungstenite" version = "0.30.0" @@ -11079,7 +11066,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.30.0", + "tungstenite", ] [[package]] @@ -11442,22 +11429,6 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "sha1 0.10.7", - "thiserror 2.0.21", -] - [[package]] name = "tungstenite" version = "0.30.0" diff --git a/Cargo.toml b/Cargo.toml index 5b61f78cdd5..5d5a0e36474 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,6 +111,7 @@ hex = "0.4" hmac = "0.13" humantime = "2.3" humantime-serde = "1.1.1" +hyper = "1.11" hyper-util = { version = "0.1.21", features = ["tokio"] } # Interface addresses for WebRTC host candidates (moq-tokio's `webrtc` feature). if-addrs = "0.15" diff --git a/quest/m2/perf/stream/README.md b/quest/m2/perf/stream/README.md index 146f8449217..5923be8db29 100644 --- a/quest/m2/perf/stream/README.md +++ b/quest/m2/perf/stream/README.md @@ -88,6 +88,23 @@ What that says, layer by layer: costs a send and a read, and 14-16% of relay `chat`. That is the ring's target. +The driver, measured 2026-11-01 on the same host, oversubscribed throughout (load +17-34 on 24 cores), so CPU time per message swung 2-3x between rounds. The qmux +micro-bench therefore records user-space instructions per message, pinned to one +E-core under `perf stat`, differencing a 0.25 s and a 5 s run to cancel setup. +Against the base, every case runs fewer: tcp 13-18%, tls 6-14%, ws 17-21%, and wss +1-13%, the smaller numbers at 16000 B where TLS dominates. Syscalls per message +(strace, 16 sessions, 1024 B) stayed at about 2.55 on every transport. +Instructions undercount tungstenite's zero-fill (`rep stos` is one instruction), +so the [codec](/quest/m2/perf/stream/codec.md) gate must re-profile CPU time. Two +paired relay runs (`chat` and `fanout` on `ws`, `wss`, `tcp`, `tls`) were +inconclusive: the QUIC control, untouched by the driver, moved -8% to +25%, and +most cases changed sign between runs. Only `tls` `fanout` rose in all six pairs +(+7% to +44%). Re-measured the same day with three more interleaved runs of six +pairs each (`tls` only, load 9-17), that rise was noise: CPU per delivered +frame moved by a median of -16%, +1%, and -8% per run, 11 of 18 pairs fell, and +pairs ranged from -41% to +106%, while the QUIC control ranged from -12% to +41%. + Decisions (2026-10-22), each with its reason: - **Measure, then decide.** The bench lands first, and every quest records a @@ -128,7 +145,6 @@ Decided 2026-10-31 (maintainer), in ## Required -- [Driver](/quest/m2/perf/stream/driver.md) - a qmux session is one caller-spawned task polling the socket it owns, with no split, pump, channel, or per-record allocation - [Ring TCP ablation](/quest/m2/perf/stream/ablation.md) - measured ring TCP against tokio TCP under the qmux workload picks the mechanisms the port uses - [Ring TCP](/quest/m2/perf/stream/ring.md) - moq-uring serves TCP, and hyper (therefore axum) runs on it - [Relay listeners on the workers](/quest/m2/perf/stream/relay.md) - the relay's WebSocket, qmux TCP, and HTTP listeners run on the io_uring workers diff --git a/quest/m2/perf/stream/codec.md b/quest/m2/perf/stream/codec.md index 5023ad0d651..03bee6655d8 100644 --- a/quest/m2/perf/stream/codec.md +++ b/quest/m2/perf/stream/codec.md @@ -5,12 +5,12 @@ qmux implements the RFC 6455 subset it uses, and tungstenite and tokio-tungstenite leave the dependency tree, but only if the stream transport bench (see the [epic](/quest/m2/perf/stream/README.md)) attributes a -material share of WebSocket CPU to tungstenite once the -[driver](/quest/m2/perf/stream/driver.md) has landed. If the bench shows no such share, delete this quest. +material share of WebSocket CPU to tungstenite now that qmux's `Driver` +owns the socket. If the bench shows no such share, delete this quest. ## Plan -Gate: re-run the bench after the driver lands. If the profile no longer +Gate: re-run the bench against the driver's numbers in the epic README. If the profile no longer shows tungstenite's read-buffer zero-fill, its `out_buffer` copy, or its memmove after a partial write as a real share, abandon the quest. @@ -22,10 +22,10 @@ The subset to implement, from the 2026-10-22 audit: up to `max_record_size`. Ping gets a Pong. Close is echoed, then the session shuts down. Text is a protocol violation. Control frames are at most 125 bytes and never fragmented. -- **Inbound.** Unmask in place, then slice frames straight into the driver's - read buffer. +- **Inbound.** Unmask in place, then slice records straight out of the + transport's read buffer, as `transport::Stream` does for byte streams. - **Outbound.** Encode the frame header in front of each record in the - driver's write buffer. The client masks in place. + transport's write buffer. The client masks in place. - **Server handshake.** None: the relay keeps hyper's upgrade, so this covers only the moq-tokio standalone listener (`Sec-WebSocket-Key` to SHA-1/base64 accept, `Version: 13`, subprotocol pick, 400 when nothing matches). @@ -41,7 +41,3 @@ Public API: the `tokio_tungstenite` and `tungstenite` re-exports and the migrate in `qmux` and `moq-tokio`. The existing qmux interop tests (the `qmux06` dev-dependency) and the browser path in `js/qmux` must keep passing unchanged. - -## Required - -- [Driver](/quest/m2/perf/stream/driver.md) - the codec writes into the driver's buffers, and the bench re-runs after it diff --git a/quest/m2/perf/stream/driver.md b/quest/m2/perf/stream/driver.md deleted file mode 100644 index 8cce2eaa0ab..00000000000 --- a/quest/m2/perf/stream/driver.md +++ /dev/null @@ -1,54 +0,0 @@ -# [L] qmux drives its own socket - -## Goal - -A qmux session over TCP, TLS, Unix, or WebSocket is one task that owns its -socket. qmux spawns nothing and depends on no tokio runtime, so the same -session runs on tokio or on an io_uring worker. The bench shows the -before/after on every stream transport, with no wire change. - -## Plan - -Settled 2026-10-22: - -- **Driver API: constructors return `(Session, Driver)`.** `Driver` is a - future the caller spawns on its own runtime: `tokio::spawn` today, a - moq-uring local task later. This removes the `tokio::spawn` calls in - `rs/qmux/src/session.rs` and `transport.rs`, and follows the repo's - handle-over-callback rule. The driver does not need to be `Send`. -- **IO trait: `tokio::io::{AsyncRead, AsyncWrite}`.** They need no tokio - runtime, and today's callers already speak them: tokio-rustls, hyper's - `TokioIo`, and `tokio::net`. The ring's TCP stream implements them too. -- **Relay WebSocket: keep axum and hyper for routing and the 101 response, - then reclaim the IO.** Use `hyper::upgrade::on` and - `Upgraded::downcast` to recover the concrete TCP or TLS stream plus any - bytes hyper already read, and drop axum's `WebSocketUpgrade` and its - tungstenite 0.29 conversion (`rs/moq-relay/src/websocket.rs`). - -What goes, per the audit in the [epic](/quest/m2/perf/stream/README.md): - -- the `futures` split BiLock -- the `ws_pump` task and `mpsc(16)` -- the separate `run_reader` and `run_writer` tasks -- `tokio::io::split` and the `reader_loop` task on plain streams -- `tokio::select!` and `tokio::time`. The WebSocket keep-alive moves to - `kio::clock` like the mux timers. - -Buffers: parse inbound records from one reusable read buffer into `Bytes` -slices, with no zeroed allocation per record. Encode outbound records and -their size prefix into one reusable write buffer, with no `Vec` per record -and no `BytesMut` per varint. Take the session lock once per batch, not per -record. - -tungstenite stays in this quest, driven from the one task (it runs over any -`AsyncRead + AsyncWrite`). Replacing it is the separate -[codec](/quest/m2/perf/stream/codec.md) quest, so the bench attributes each -change on its own. - -Public API: the constructor shape changes in `qmux`, `moq-tokio`, and any -binding that starts a qmux session. The Impact bullet says how to migrate. - -## Related - -- [qmux byte counters](/quest/m2/wire-stats/qmux.md) - refreshes `Wire` counters on qmux's read/write path -- [qmux leases receive memory](/quest/m2/qmux-receive-lease.md) - changes qmux's `RecvStream` and window defaults diff --git a/quest/m2/perf/stream/relay.md b/quest/m2/perf/stream/relay.md index f2acd68b745..bf6e8e2c204 100644 --- a/quest/m2/perf/stream/relay.md +++ b/quest/m2/perf/stream/relay.md @@ -25,6 +25,14 @@ supervision run on the shared tokio runtime that owns the HTTP client, the timers, and the origins. A qmux session handle is `Send + Sync` however its transport is driven, which is what makes that handoff free here too. +moq-tokio spawns each qmux `Driver` on its own runtime (`spawn_qmux` in +`rs/moq-tokio/src/lib.rs`), so its `tcp::Listener::accept`, +`websocket::Listener::accept`, and `websocket::accept` return only the session. +A worker must run the driver on its own local task set instead, so this quest +reshapes `moq_tokio::websocket::accept` (or adds a sibling) to hand the driver +back; the qmux constructors already return it (decided 2026-11-01, maintainer, +in [moq-dev/moq#7494](https://github.com/moq-dev/moq/pull/7494)). + Keep the ops and web listeners' behavior identical: the same routes, the same CORS scoping, the same landing-page fallback, and the same `/certificate.sha256` fingerprint. Extend `rs/moq-relay/tests/integration/runtime_uring.rs` diff --git a/quest/m2/perf/stream/ring.md b/quest/m2/perf/stream/ring.md index b53f98fe084..b4235004404 100644 --- a/quest/m2/perf/stream/ring.md +++ b/quest/m2/perf/stream/ring.md @@ -16,16 +16,15 @@ provided buffers, registered fixed files), with the same borrow-the-pool receive backpressure the UDP path uses. Then `hyper::rt::{Read, Write, Executor}` implementations over those streams, -and `tokio::io::{AsyncRead, AsyncWrite}` for the qmux -[driver](/quest/m2/perf/stream/driver.md). hyper is runtime-agnostic by +and `tokio::io::{AsyncRead, AsyncWrite}` for qmux's `transport::Stream`, which +its `Driver` polls. hyper is runtime-agnostic by design, so axum's routers, extractors, and CORS keep working. The relay's WebSocket upgrade reclaims the ring stream through `Upgraded::downcast` and hands it to a qmux `Driver` spawned on the worker's local task set, so a connection never leaves the thread that accepted it. -hyper becomes a first direct dependency. Today it reaches the workspace only -through `axum` and `axum-server`; add it to `[workspace.dependencies]` at the -newest stable version, per `rs/AGENTS.md`. +hyper is already a workspace dependency: the relay reclaims the socket under +a WebSocket upgrade through `hyper::upgrade` (`rs/moq-relay/src/websocket.rs`). Cover it the way the UDP path is covered: tests that run against whichever backend is compiled, an HTTP round trip through hyper on the worker, and a @@ -34,5 +33,4 @@ existing suite does. ## Required -- [Driver](/quest/m2/perf/stream/driver.md) - qmux spawns nothing and polls a socket it owns, so a session can run on the worker - [Ring TCP ablation](/quest/m2/perf/stream/ablation.md) - picks the mechanisms before the worker implementation starts diff --git a/quest/m2/qmux-receive-lease.md b/quest/m2/qmux-receive-lease.md index 45ab72a40fb..f939a11ca4f 100644 --- a/quest/m2/qmux-receive-lease.md +++ b/quest/m2/qmux-receive-lease.md @@ -71,4 +71,3 @@ QMux MAX_DATA and STOP_SENDING only, no MoQ format change. - [WebSocket caps](/quest/m2/rate-websocket.md) - paced reads on the same sessions, inside the window - [Transport x version benches](/quest/m2/bench-transport-sweep.md) - where a per-session memory and throughput sweep belongs -- [qmux drives its own socket](/quest/m2/perf/stream/driver.md) - replaces qmux's reader and writer tasks and its constructors' return type diff --git a/quest/m2/wire-stats/qmux.md b/quest/m2/wire-stats/qmux.md index e5ded37a9ae..7525a8ffc1d 100644 --- a/quest/m2/wire-stats/qmux.md +++ b/quest/m2/wire-stats/qmux.md @@ -21,13 +21,9 @@ beside the field. pursued; this lands in-tree after qmux moves. - The transport pushes its counters: the stats tick never polls a session, and there is no per-connection timer. `TCP_INFO` is read on demand, so - decide how qmux's I/O path refreshes the `Wire` (for example, after each - socket write batch) without a syscall per write, in place of the - qmux record bytes `qmux::Session::wire` counts today, including the relay's - `SocketStats` wiring (`rs/moq-relay/src/web.rs`). + decide how qmux's `Driver` (`rs/qmux/src/driver.rs`), which sets the `Wire` + after each round of reading and writing, refreshes it without a syscall per + write, in place of the qmux record bytes `qmux::Session::wire` counts today, + including the relay's `SocketStats` wiring (`rs/moq-relay/src/web.rs`). - Test: a loopback WebSocket session's `sent_bytes` covers at least the payload it wrote. - -## Required - -- [qmux drives its own socket](/quest/m2/perf/stream/driver.md) - replaces qmux's read/write path, so the counters go on the new driver once (decided 2026-10-22, maintainer) diff --git a/rs/moq-quic/src/coding.rs b/rs/moq-quic/src/coding.rs index 97928cbd9d6..a08ff6f3a91 100644 --- a/rs/moq-quic/src/coding.rs +++ b/rs/moq-quic/src/coding.rs @@ -128,3 +128,21 @@ impl BufMutExt for T { VarInt::from_u64(x).unwrap().encode(self); } } + +/// A buffer frames are written into, which knows how much it already holds so a writer +/// can stop at the packet or record boundary +pub(crate) trait FrameBuf: BufMut { + fn len(&self) -> usize; +} + +impl FrameBuf for Vec { + fn len(&self) -> usize { + Vec::len(self) + } +} + +impl FrameBuf for bytes::BytesMut { + fn len(&self) -> usize { + bytes::BytesMut::len(self) + } +} diff --git a/rs/moq-quic/src/connection/mux/mod.rs b/rs/moq-quic/src/connection/mux/mod.rs index 89961c1cbce..d0de1852e72 100644 --- a/rs/moq-quic/src/connection/mux/mod.rs +++ b/rs/moq-quic/src/connection/mux/mod.rs @@ -14,7 +14,7 @@ use std::{collections::VecDeque, num::NonZeroU64, task::Poll}; -use bytes::{Buf, Bytes}; +use bytes::{Buf, Bytes, BytesMut}; use tracing::{debug, trace}; use super::{ @@ -173,6 +173,9 @@ pub struct Connection { events: VecDeque, /// Draft-00 frames already serialized, one per transport message queued: VecDeque, + /// Where records are serialized. Each one handed out is split off, so the allocation is + /// reused once the transport drops the records it holds. + out: BytesMut, } impl Connection { @@ -213,6 +216,7 @@ impl Connection { error: None, events: VecDeque::new(), queued: VecDeque::new(), + out: BytesMut::new(), } } @@ -334,7 +338,7 @@ impl Connection { if let Some(frame) = self.queued.pop_front() { return Some(frame); } - let buf = Bytes::from(self.fill(now)?); + let buf = self.fill(now)?; if self.config.version.uses_records() { return Some(buf); } @@ -449,18 +453,27 @@ impl Connection { } /// Serialize the next batch of frames, each counted as acknowledged on the spot - fn fill(&mut self, now: Instant) -> Option> { + fn fill(&mut self, now: Instant) -> Option { if self.close_sent { return None; } let budget = self.budget(); - let mut buf = Vec::new(); + let mut buf = std::mem::take(&mut self.out); + // Reuses the allocation in place once every record split from it was dropped. + buf.reserve(budget); + let filled = self.fill_into(&mut buf, budget, now); + let record = filled.then(|| buf.split().freeze()); + self.out = buf; + record + } + /// Serialize the next batch of frames into the empty `buf`, returning whether it wrote any + fn fill_into(&mut self, buf: &mut BytesMut, budget: usize, now: Instant) -> bool { // The transport parameters go first if !self.params_sent { let mut blob = Vec::new(); self.local_params().encode(&mut blob); - wire::encode_transport_parameters(&mut buf, &blob); + wire::encode_transport_parameters(buf, &blob); self.params_sent = true; } @@ -468,29 +481,29 @@ impl Connection { // Write the close frame, if any, and go quiet if let Some(close) = self.close.take() { let room = budget - buf.len(); - close.encode(&mut buf, room); + close.encode(buf, room); } self.close_sent = true; if buf.is_empty() { - return None; + return false; } self.timer.on_send(now); - return Some(buf); + return true; } if let Some(seq) = self.ping_response.take() { - wire::encode_ping(&mut buf, true, seq); + wire::encode_ping(buf, true, seq); } if std::mem::take(&mut self.ping_request) { let seq = VarInt::from_u64(self.next_ping).expect("QX_PING sequence overflow"); - wire::encode_ping(&mut buf, false, seq); + wire::encode_ping(buf, false, seq); self.timer.ping_sent(self.next_ping, now); self.next_ping += 1; } let mut written = ThinRetransmits::default(); self.streams - .write_control_frames(&mut buf, &mut self.pending, &mut written, &mut self.stats, budget); + .write_control_frames(buf, &mut self.pending, &mut written, &mut self.stats, budget); if let Some(written) = written.get() { for &(id, reliable_size) in &written.reset_stream { self.streams.reset_acked(id, reliable_size); @@ -499,9 +512,7 @@ impl Connection { // Stream data and datagrams last, so a STREAM frame without a Length field runs to the // end of the record - let written = self - .streams - .write_frames(&mut buf, 0..budget, self.config.send_fairness); + let written = self.streams.write_frames(buf, 0..budget, self.config.send_fairness); for meta in written.streams { let id = meta.id; // A reset held until the peer has the data below its Reliable Size goes out now @@ -513,10 +524,10 @@ impl Connection { self.streams.queue_max_stream_id(&mut self.pending); if buf.is_empty() { - return None; + return false; } self.timer.on_send(now); - Some(buf) + true } /// Queue a draft-00 batch one frame per message, each STREAM frame with its Length field @@ -558,6 +569,11 @@ impl Connection { if size as u64 > limit { return Err(self.oversized()); } + // What the record keeps allocated while a stream holds a slice of it, which bounds how + // much memory the peer's buffered data pins. A transport may slice records out of a + // shared read buffer of `MAX_FRAME_SIZE`, grown to fit the largest record allowed, so + // count at least that much. + let allocation = size.max(MAX_FRAME_SIZE).max(limit as usize); let mut frames = 0; loop { @@ -576,7 +592,7 @@ impl Connection { "draft-00 message carries more than one frame", )); } - self.on_frame(frame, size, frame_len, now)?; + self.on_frame(frame, allocation, frame_len, now)?; if self.error.is_some() { // The peer closed; ignore the rest return Ok(()); @@ -607,7 +623,7 @@ impl Connection { fn on_frame( &mut self, frame: wire::Frame, - payload_len: usize, + allocation: usize, frame_len: usize, now: Instant, ) -> Result<(), TransportError> { @@ -656,7 +672,7 @@ impl Connection { if let Some(offset) = self.streams.rx_offset(stream.id) { stream.offset = offset; } - return self.on_stream(stream, payload_len); + return self.on_stream(stream, allocation); } wire::Frame::Quic(frame) => frame, }; @@ -672,7 +688,7 @@ impl Connection { { return Err(TransportError::PROTOCOL_VIOLATION("stream data out of order")); } - self.on_stream(stream, payload_len)?; + self.on_stream(stream, allocation)?; } frame::Frame::ResetStream(reset) => { if reset.reliable_size.is_some() && self.config.version != Version::QMux02 { @@ -746,8 +762,8 @@ impl Connection { Ok(()) } - fn on_stream(&mut self, stream: frame::Stream, payload_len: usize) -> Result<(), TransportError> { - if self.streams.received(stream, payload_len)?.should_transmit() { + fn on_stream(&mut self, stream: frame::Stream, allocation: usize) -> Result<(), TransportError> { + if self.streams.received(stream, allocation)?.should_transmit() { self.pending.max_data = true; } Ok(()) diff --git a/rs/moq-quic/src/connection/mux/wire.rs b/rs/moq-quic/src/connection/mux/wire.rs index bc778dffed0..058b7312650 100644 --- a/rs/moq-quic/src/connection/mux/wire.rs +++ b/rs/moq-quic/src/connection/mux/wire.rs @@ -124,13 +124,13 @@ pub(super) fn next_frame(bytes: &mut Bytes) -> Result, TransportEr Ok(Some(frame)) } -pub(super) fn encode_transport_parameters(buf: &mut Vec, params: &[u8]) { +pub(super) fn encode_transport_parameters(buf: &mut impl BufMut, params: &[u8]) { buf.write_var(QX_TRANSPORT_PARAMETERS); buf.write_var(params.len() as u64); buf.put_slice(params); } -pub(super) fn encode_ping(buf: &mut Vec, response: bool, seq: VarInt) { +pub(super) fn encode_ping(buf: &mut impl BufMut, response: bool, seq: VarInt) { buf.write_var(match response { true => QX_PING_RESPONSE, false => QX_PING_REQUEST, diff --git a/rs/moq-quic/src/connection/streams/state.rs b/rs/moq-quic/src/connection/streams/state.rs index 49b2b12d126..400e7a10c6c 100644 --- a/rs/moq-quic/src/connection/streams/state.rs +++ b/rs/moq-quic/src/connection/streams/state.rs @@ -17,7 +17,7 @@ use super::{ }; use crate::{ Dir, Instant, MAX_STREAM_COUNT, Rate, Side, StreamId, TransportError, VarInt, - coding::BufMutExt, + coding::{BufMutExt, FrameBuf}, connection::ConnectionTag, connection::rate::Bucket, connection::stats::{CreditStats, FrameStats}, @@ -559,7 +559,7 @@ impl StreamsState { pub(in crate::connection) fn write_control_frames( &mut self, - buf: &mut Vec, + buf: &mut impl FrameBuf, pending: &mut Retransmits, retransmits: &mut ThinRetransmits, stats: &mut FrameStats, @@ -740,7 +740,7 @@ impl StreamsState { /// below the path MTU can be, steps aside for what fits behind it instead, since /// [`Self::can_send_frames`] promised a frame and control frames alone would leave the /// packet short. - pub(crate) fn write_frames(&mut self, buf: &mut Vec, payload: Range, fair: bool) -> WrittenFrames { + pub(crate) fn write_frames(&mut self, buf: &mut impl FrameBuf, payload: Range, fair: bool) -> WrittenFrames { let max_buf_size = payload.end; let mut written = WrittenFrames::default(); while buf.len() + frame::Stream::SIZE_BOUND < max_buf_size { diff --git a/rs/moq-quic/src/frame.rs b/rs/moq-quic/src/frame.rs index a58ee069ec3..679c37570da 100644 --- a/rs/moq-quic/src/frame.rs +++ b/rs/moq-quic/src/frame.rs @@ -1151,12 +1151,12 @@ impl OutgoingDatagram { } /// Write the length-prefixed frame. - pub(crate) fn encode(&self, out: &mut Vec) { + pub(crate) fn encode(&self, out: &mut W) { out.write(FrameType(*DATAGRAM_TYS.start() | 1)); // 1 byte // Safe to unwrap because we check length sanity before queueing datagrams out.write(VarInt::from_u64(self.len as u64).unwrap()); // <= 8 bytes for chunk in &self.chunks { - out.extend_from_slice(chunk); + out.put_slice(chunk); } } } diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 3ab582748c5..60efb09166a 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -43,7 +43,7 @@ _quic = [] # congestion-control testing. Covers the io_uring workers too, so one feature # means one thing whichever runtime is serving. qlog = ["moq-tokio/qlog", "moq-uring?/qlog"] -websocket = ["moq-tokio/websocket", "dep:qmux", "axum/ws"] +websocket = ["moq-tokio/websocket", "dep:qmux", "dep:hyper"] # Unix-domain-socket stream listener (`--server-unix-bind`), unix-only. In the # default set; drop via --no-default-features on platforms without Unix sockets. uds = ["moq-tokio/uds"] @@ -68,7 +68,10 @@ http-body = "1.0.1" # reqwest-middleware 0.4, while this line matches reqwest 0.13 below. http-cache-reqwest = { version = "1.0.0-alpha.6", features = ["manager-moka", "reqwest-middleware", "url-standard"], default-features = false } humantime = { workspace = true } -hyper-util = { workspace = true } +# Reclaims the socket under a WebSocket upgrade. +hyper = { workspace = true, optional = true } +# `server-auto` reclaims the socket from under the auto builder `axum_server` serves with. +hyper-util = { workspace = true, features = ["server-auto"] } kio = { workspace = true, features = ["runtime", "serde"] } moq-auth = { workspace = true, features = ["client"] } moq-net = { workspace = true } diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 02da98ed8d6..b05956c2f2a 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -514,11 +514,14 @@ mod tests { }; let stream = |io| qmux::transport::Stream::new(io); let (client, server) = tokio::io::duplex(64 * 1024); - tokio::try_join!( + let ((client, client_driver), (server, server_driver)) = tokio::try_join!( qmux::Session::connect(stream(client), config()), qmux::Session::accept(stream(server), config()) ) - .unwrap() + .unwrap(); + tokio::spawn(client_driver); + tokio::spawn(server_driver); + (client, server) } /// A client session and the server session `lease` was prepared for, before supervision. diff --git a/rs/moq-relay/src/listener.rs b/rs/moq-relay/src/listener.rs index 979b9b123a6..09d5a12fc2b 100644 --- a/rs/moq-relay/src/listener.rs +++ b/rs/moq-relay/src/listener.rs @@ -17,7 +17,7 @@ mod demux; mod idle; pub(crate) use demux::{Classified, Stream, Tls, classify}; -pub(crate) use idle::Admission; +pub(crate) use idle::{Admission, Io}; use std::{ io, diff --git a/rs/moq-relay/src/listener/idle.rs b/rs/moq-relay/src/listener/idle.rs index 4f98e763527..f7ab00d5f00 100644 --- a/rs/moq-relay/src/listener/idle.rs +++ b/rs/moq-relay/src/listener/idle.rs @@ -185,6 +185,17 @@ pub(crate) struct Io { timer: Pin>, } impl Io { + /// The stream underneath, once HTTP has handed it to another protocol and no longer + /// needs the idle deadline. + #[cfg(feature = "websocket")] + #[allow( + dead_code, + reason = "the http_idle benchmark includes this module without the WebSocket route" + )] + pub(crate) fn into_inner(self) -> I { + self.inner + } + fn poll_deadline(&mut self, cx: &mut Context<'_>) -> io::Result<()> { self.state.wake.register(cx.waker()); let activity = self.state.activity.lock().unwrap_or_else(PoisonError::into_inner); diff --git a/rs/moq-relay/src/shutdown.rs b/rs/moq-relay/src/shutdown.rs index 5af7feab077..bdba461a99b 100644 --- a/rs/moq-relay/src/shutdown.rs +++ b/rs/moq-relay/src/shutdown.rs @@ -455,11 +455,12 @@ mod tests { let running = tokio::spawn(relay.run()); // Upgraded, then silent: no MoQ SETUP ever follows. - let _session = qmux::ws::Client::new() + let (_session, driver) = qmux::ws::Client::new() .with_protocols(moq_net::ALPNS.iter().map(|&a| (a, &[] as &[qmux::Version]))) .connect(&format!("ws://{addr}/room")) .await .unwrap(); + tokio::spawn(driver); // Only timers decide what follows. tokio::time::pause(); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index f3763c9130a..7579027206d 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -2589,7 +2589,9 @@ mod tests { /// An upgraded WebSocket reads no more than one qmux record at a time: a frame /// declaring more is refused at its header, before SETUP and without buffering it, - /// even with no timeout to end the wait. Real sockets, so this runs on the wall clock. + /// even with no timeout to end the wait. The socket is the listener's own, reclaimed from + /// under hyper rather than read through its boxed IO. Real sockets, so this runs on the + /// wall clock. #[cfg(feature = "websocket")] #[tokio::test] async fn a_websocket_frame_past_one_record_is_refused_at_its_header() { @@ -2648,6 +2650,11 @@ mod tests { .await .expect("the socket waited to buffer a frame larger than a record") .ok(); + // The refusal came from the socket built after the downcast, so it already ran. + assert!( + crate::websocket::RECLAIMED.load(std::sync::atomic::Ordering::Relaxed) > 0, + "the upgrade fell back to hyper's boxed IO" + ); serving.abort(); } @@ -2835,9 +2842,10 @@ mod tests { None, ) .await; - let transport = qmux::ws::Upgraded::new(socket, "qmux-01.moq-lite-06") + let (transport, driver) = qmux::ws::Upgraded::new(socket, "qmux-01.moq-lite-06") .connect() .unwrap(); + tokio::spawn(driver); let origin = moq_tokio::origin::spawn(); let (session, driver) = moq_net::Client::new() .with_versions("moq-lite-06".parse::().unwrap().into()) diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 88c551952ef..1d49c95500b 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -1,17 +1,17 @@ -use futures::{Sink, Stream}; -use qmux::ws::tungstenite; -use std::{ - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; +use bytes::Bytes; +use hyper_util::rt::TokioIo; +use qmux::ws::tokio_tungstenite::WebSocketStream; +use qmux::ws::tungstenite::{self, protocol::Role}; +use std::sync::Arc; +use tokio::io::{AsyncRead, AsyncWrite}; use axum::{ - extract::{ConnectInfo, Extension, OriginalUri, State, WebSocketUpgrade, ws::rejection::WebSocketUpgradeRejection}, - http::{HeaderMap, HeaderValue, StatusCode, header::HOST}, + extract::{ConnectInfo, Extension, OriginalUri, Request, State}, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, response::Response, }; +use crate::listener::{Io, Stream}; use crate::{web::MtlsPeer, web::ServerName, web::WebState, web::landing_response}; #[expect( @@ -21,9 +21,7 @@ use crate::{web::MtlsPeer, web::ServerName, web::WebState, web::landing_response // The `Err` is axum's own `ErrorResponse`, so there is nothing here to box. #[expect(clippy::result_large_err, reason = "the error type is axum's, not ours")] pub(crate) async fn serve_ws( - ws: Result, OriginalUri(uri): OriginalUri, - headers: HeaderMap, mtls: Option>, server_name: Option>, ConnectInfo(remote): ConnectInfo, @@ -33,10 +31,11 @@ pub(crate) async fn serve_ws( Extension(extensions): Extension, Extension(crate::web::Implementation(implementation)): Extension, State(state): State>, + mut request: Request, ) -> axum::response::Result { // If this isn't a WebSocket upgrade (e.g. a plain browser visit), serve // the informational landing page instead of an error response. - let Ok(ws) = ws else { + let Some(key) = upgrade_key(&request) else { return Ok(landing_response()); }; @@ -47,14 +46,13 @@ pub(crate) async fn serve_ws( .or_else(|| state.shutdown.activity.enter()) .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let alpns = versions.alpns(); - // The socket buffers a message whole before qmux sees it, so it reads no more than a record. - let max = moq_tokio::websocket::max_message_size(); - let ws = negotiate_subprotocol(ws.max_message_size(max).max_frame_size(max), &alpns)?; + let protocol = negotiate_subprotocol(request.headers(), &alpns)?; + let headers = request.headers(); let host = uri .authority() .map(axum::http::uri::Authority::as_str) - .or_else(|| headers.get(HOST).and_then(|value| value.to_str().ok())) + .or_else(|| headers.get(header::HOST).and_then(|value| value.to_str().ok())) .ok_or(StatusCode::BAD_REQUEST)?; let url = url::Url::parse(&format!( "ws://{host}{}", @@ -69,41 +67,132 @@ pub(crate) async fn serve_ws( } let mut options = moq_tokio::websocket::Options::new(url, server, active); options.link.remote = Some(remote); - options.link.alpn = ws.selected_protocol().and_then(|p| p.to_str().ok()).map(str::to_owned); + options.link.alpn = Some(protocol.clone()); options.link.server_name = server_name.map(|Extension(ServerName(name))| name); options.identity = mtls.map(|Extension(MtlsPeer(identity))| identity); options.socket_stats = socket_stats.map(|Extension(stats)| stats.0); // The listener reads `None` as no deadline; `Options` reads it as the default. options.timeout = Some(state.timeout.unwrap_or_default()); - Ok(ws.on_upgrade(async move |socket| { - // Axum hides its Tungstenite socket to avoid exposing its dependency version. - let socket = WebSocketAdapter::new(socket); - let handshake = moq_tokio::websocket::accept(socket, options); - let Some(request) = state.shutdown.until_deadline(handshake).await else { - return; - }; - let request = match request { - Ok(request) => request, + let upgrade = hyper::upgrade::on(&mut request); + tokio::spawn(async move { + let upgraded = match upgrade.await { + Ok(upgraded) => upgraded, Err(err) => { - tracing::debug!(%err, "failed to accept WebSocket SETUP"); + tracing::debug!(%err, "WebSocket upgrade failed"); return; } }; - // Numbered like a QUIC accept, so its `conn{id=...}` never reuses a QUIC one's id. - crate::connection::Connection::new(request, state.cluster.clone(), state.auth.clone()) - .with_id(state.cluster.next_connection_id()) - .with_shutdown(state.shutdown.clone()) - .with_sessions(state.sessions.clone()) - .run() - .await; - })) + serve_upgraded(upgraded, options, state).await; + }); + Ok(switching_protocols(&key, &protocol)) +} + +/// The `Sec-WebSocket-Key` of an HTTP/1.1 WebSocket upgrade, or `None` for any other request. +fn upgrade_key(request: &Request) -> Option { + let headers = request.headers(); + // A comma-separated header that lists `token`, ignoring case. + let lists = |name: header::HeaderName, token: &str| { + headers.get_all(name).iter().any(|value| { + value + .as_bytes() + .split(|&b| b == b',') + .any(|item| item.trim_ascii().eq_ignore_ascii_case(token.as_bytes())) + }) + }; + let upgrade = request.method() == Method::GET + && lists(header::CONNECTION, "upgrade") + && lists(header::UPGRADE, "websocket") + && headers + .get(header::SEC_WEBSOCKET_VERSION) + .is_some_and(|version| version == "13") + // hyper hands this out only for a connection that can switch protocols. + && request.extensions().get::().is_some(); + upgrade + .then(|| headers.get(header::SEC_WEBSOCKET_KEY).cloned()) + .flatten() +} + +/// The 101 response accepting the upgrade keyed `key` with `protocol`. +fn switching_protocols(key: &HeaderValue, protocol: &str) -> Response { + Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS) + .header(header::CONNECTION, "upgrade") + .header(header::UPGRADE, "websocket") + .header( + header::SEC_WEBSOCKET_ACCEPT, + tungstenite::handshake::derive_accept_key(key.as_bytes()), + ) + .header(header::SEC_WEBSOCKET_PROTOCOL, protocol) + .body(axum::body::Body::empty()) + .expect("every header is a valid value") +} + +/// The socket config for an upgraded connection: a message is one qmux record, so the socket +/// buffers no more than one before qmux sees it. +fn socket_config() -> tungstenite::protocol::WebSocketConfig { + let max = moq_tokio::websocket::max_message_size(); + tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(max)) + .max_frame_size(Some(max)) +} + +/// How many upgrades reclaimed the listener's socket, so a test can tell the direct path ran. +#[cfg(test)] +pub(crate) static RECLAIMED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Reclaim the socket from under hyper's upgrade and serve a MoQ session on it. +/// +/// The relay's web listener hands hyper a classified plain or TLS stream, which comes back whole with +/// whatever hyper read past the request, so qmux polls the socket directly rather than through +/// hyper's boxed IO. `axum_server` serves it with hyper-util's auto builder, which wraps the stream +/// in a private rewind type, so only hyper-util's own downcast can see through it. A router served +/// by another HTTP server keeps the boxed IO. +async fn serve_upgraded( + upgraded: hyper::upgrade::Upgraded, + options: moq_tokio::websocket::Options, + state: Arc, +) { + match hyper_util::server::conn::auto::upgrade::downcast::>>(upgraded) { + Ok(parts) => { + #[cfg(test)] + RECLAIMED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + serve_socket(parts.io.into_inner().into_inner(), parts.read_buf, options, state).await + } + Err(upgraded) => serve_socket(TokioIo::new(upgraded), Bytes::new(), options, state).await, + } +} + +/// Run a MoQ session over the WebSocket on `io`, whose first bytes, `read`, were already read. +async fn serve_socket(io: S, read: Bytes, options: moq_tokio::websocket::Options, state: Arc) +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let socket = WebSocketStream::from_partially_read(io, read.to_vec(), Role::Server, Some(socket_config())).await; + let handshake = moq_tokio::websocket::accept(socket, options); + let Some(request) = state.shutdown.until_deadline(handshake).await else { + return; + }; + let request = match request { + Ok(request) => request, + Err(err) => { + tracing::debug!(%err, "failed to accept WebSocket SETUP"); + return; + } + }; + // Numbered like a QUIC accept, so its `conn{id=...}` never reuses a QUIC one's id. + crate::connection::Connection::new(request, state.cluster.clone(), state.auth.clone()) + .with_id(state.cluster.next_connection_id()) + .with_shutdown(state.shutdown.clone()) + .with_sessions(state.sessions.clone()) + .run() + .await; } /// Pick a subprotocol for the upgrade, or fail the handshake outright. /// /// We advertise the configured qmux × moq-net subprotocol matrix, with bare -/// qmux fallbacks last. axum picks the first entry that the client also offered, so +/// qmux fallbacks last, and pick the first entry that the client also offered, so /// a modern client lands on `qmux-01.moq-lite-06`; one that only offers a bare /// `qmux-0X` negotiates the moq version via SETUP. /// @@ -112,22 +201,27 @@ pub(crate) async fn serve_ws( /// client MUST treat a missing `Sec-WebSocket-Protocol` response header as a /// failure anyway, so upgrading only wastes a connection that has already agreed /// on nothing. A client that offers none at all names no QMux version either. -fn negotiate_subprotocol(ws: WebSocketUpgrade, alpns: &[&str]) -> Result { +fn negotiate_subprotocol(headers: &HeaderMap, alpns: &[&str]) -> Result { let supported = supported_subprotocols(alpns); - - if !subprotocols_acceptable(ws.requested_protocols().map(HeaderValue::as_bytes), &supported) { + let requested = headers + .get_all(header::SEC_WEBSOCKET_PROTOCOL) + .iter() + .flat_map(|value| value.as_bytes().split(|&b| b == b',')); + select_subprotocol(requested, &supported).cloned().ok_or_else(|| { tracing::debug!("rejecting WebSocket upgrade: no supported subprotocol offered"); - return Err(StatusCode::BAD_REQUEST); - } - - Ok(ws.protocols(supported)) + StatusCode::BAD_REQUEST + }) } -/// Whether the client offered a subprotocol we support. -fn subprotocols_acceptable<'a>(requested: impl IntoIterator, supported: &[String]) -> bool { - requested - .into_iter() - .any(|protocol| supported.iter().any(|s| s.as_bytes() == protocol.trim_ascii())) +/// Our most preferred subprotocol among those the client offered. +fn select_subprotocol<'a, 'b>( + requested: impl IntoIterator, + supported: &'b [String], +) -> Option<&'b String> { + let requested: Vec<&[u8]> = requested.into_iter().map(<[u8]>::trim_ascii).collect(); + supported + .iter() + .find(|supported| requested.contains(&supported.as_bytes())) } /// QMux wire-format versions that can ride under a `{prefix}.{alpn}` pair. @@ -165,203 +259,21 @@ fn supported_subprotocols(alpns: &[&str]) -> Vec { out } -// https://github.com/tokio-rs/axum/discussions/848#discussioncomment-11443587 - -struct WebSocketAdapter { - inner: T, -} - -impl WebSocketAdapter { - fn new(inner: T) -> Self { - Self { inner } - } -} - -impl Stream for WebSocketAdapter -where - T: Stream> + Unpin, -{ - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner) - .poll_next(cx) - .map(|message| message.map(|message| message.map(axum_to_tungstenite).map_err(map_axum_error))) - } -} - -impl Sink for WebSocketAdapter -where - T: Sink + Unpin, -{ - type Error = tungstenite::Error; - - fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_ready(cx).map_err(map_axum_error) - } - - fn start_send(mut self: Pin<&mut Self>, message: tungstenite::Message) -> Result<(), Self::Error> { - Pin::new(&mut self.inner) - .start_send(tungstenite_to_axum(message)) - .map_err(map_axum_error) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx).map_err(map_axum_error) - } - - fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_close(cx).map_err(map_axum_error) - } -} - -fn map_axum_error(err: axum::Error) -> tungstenite::Error { - tracing::warn!(%err, "WebSocket error"); - tungstenite::Error::ConnectionClosed -} - -fn axum_to_tungstenite(message: axum::extract::ws::Message) -> tungstenite::Message { - match message { - axum::extract::ws::Message::Text(text) => tungstenite::Message::Text(axum_text_to_tungstenite(text)), - axum::extract::ws::Message::Binary(bin) => tungstenite::Message::Binary(bin), - axum::extract::ws::Message::Ping(ping) => tungstenite::Message::Ping(ping), - axum::extract::ws::Message::Pong(pong) => tungstenite::Message::Pong(pong), - axum::extract::ws::Message::Close(close) => { - tungstenite::Message::Close(close.map(|c| tungstenite::protocol::CloseFrame { - code: c.code.into(), - reason: axum_text_to_tungstenite(c.reason), - })) - } - } -} - -fn tungstenite_to_axum(message: tungstenite::Message) -> axum::extract::ws::Message { - match message { - tungstenite::Message::Text(text) => axum::extract::ws::Message::Text(tungstenite_text_to_axum(text)), - tungstenite::Message::Binary(bin) => axum::extract::ws::Message::Binary(bin), - tungstenite::Message::Ping(ping) => axum::extract::ws::Message::Ping(ping), - tungstenite::Message::Pong(pong) => axum::extract::ws::Message::Pong(pong), - tungstenite::Message::Frame(_frame) => unreachable!(), - tungstenite::Message::Close(close) => { - axum::extract::ws::Message::Close(close.map(|c| axum::extract::ws::CloseFrame { - code: c.code.into(), - reason: tungstenite_text_to_axum(c.reason), - })) - } - } -} - -fn axum_text_to_tungstenite(text: axum::extract::ws::Utf8Bytes) -> tungstenite::Utf8Bytes { - axum::body::Bytes::from(text) - .try_into() - .expect("axum text is valid UTF-8") -} - -fn tungstenite_text_to_axum(text: tungstenite::Utf8Bytes) -> axum::extract::ws::Utf8Bytes { - axum::body::Bytes::from(text) - .try_into() - .expect("tungstenite text is valid UTF-8") -} - #[cfg(test)] mod tests { use super::*; - use axum::{Router, extract::WebSocketUpgrade, routing::any}; - use futures::SinkExt; + use axum::{Router, routing::any}; + use futures::{Sink, Stream}; use std::{ - io, + pin::Pin, sync::atomic::{AtomicBool, Ordering}, + task::{Context, Poll}, time::Duration, }; use tokio::sync::mpsc; // Brings `qmux::Session::protocol` and `::closed` into scope. use moq_net::transport::poll::Session as _; - struct DoubleErrorSocket; - - impl Stream for DoubleErrorSocket { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Pending - } - } - - impl Sink for DoubleErrorSocket { - type Error = axum::Error; - - fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn start_send(self: Pin<&mut Self>, _message: axum::extract::ws::Message) -> Result<(), Self::Error> { - Err(axum::Error::new(io::Error::from(io::ErrorKind::BrokenPipe))) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Err(axum::Error::new(io::Error::from(io::ErrorKind::BrokenPipe)))) - } - } - - #[tokio::test] - async fn websocket_adapter_maps_close_error_after_send_error() { - let mut socket = WebSocketAdapter::new(DoubleErrorSocket); - - let send = socket.send(tungstenite::Message::Binary(Vec::new().into())).await; - assert!(matches!(send, Err(tungstenite::Error::ConnectionClosed))); - - let close = socket.close().await; - assert!(matches!(close, Err(tungstenite::Error::ConnectionClosed))); - } - - #[test] - fn websocket_binary_conversion_is_zero_copy() { - let payload = axum::body::Bytes::from(vec![1, 2, 3]); - let retained = payload.clone(); - let tungstenite::Message::Binary(converted) = axum_to_tungstenite(axum::extract::ws::Message::Binary(payload)) - else { - panic!("expected binary message"); - }; - assert_eq!(converted, retained); - assert_eq!(converted.as_ptr(), retained.as_ptr()); - - let payload = axum::body::Bytes::from(vec![4, 5, 6]); - let retained = payload.clone(); - let axum::extract::ws::Message::Binary(converted) = tungstenite_to_axum(tungstenite::Message::Binary(payload)) - else { - panic!("expected binary message"); - }; - assert_eq!(converted, retained); - assert_eq!(converted.as_ptr(), retained.as_ptr()); - } - - #[test] - fn websocket_text_conversion_is_zero_copy() { - let payload = axum::body::Bytes::from("hello from axum"); - let retained = payload.clone(); - let text = axum::extract::ws::Utf8Bytes::try_from(payload).expect("valid UTF-8"); - let tungstenite::Message::Text(converted) = axum_to_tungstenite(axum::extract::ws::Message::Text(text)) else { - panic!("expected text message"); - }; - let converted = axum::body::Bytes::from(converted); - assert_eq!(converted, retained); - assert_eq!(converted.as_ptr(), retained.as_ptr()); - - let payload = axum::body::Bytes::from("hello from tungstenite"); - let retained = payload.clone(); - let text = tungstenite::Utf8Bytes::try_from(payload).expect("valid UTF-8"); - let axum::extract::ws::Message::Text(converted) = tungstenite_to_axum(tungstenite::Message::Text(text)) else { - panic!("expected text message"); - }; - let converted = axum::body::Bytes::from(converted); - assert_eq!(converted, retained); - assert_eq!(converted.as_ptr(), retained.as_ptr()); - } - /// The newest moq ALPN both sides agree on. Derived from the same source /// of truth that `supported_subprotocols` and `qmux::ws::Client::with_protocols` /// consume, so adding a new ALPN doesn't break these tests independently @@ -453,32 +365,34 @@ mod tests { } #[test] - fn subprotocols_acceptable_requires_a_match_when_any_are_offered() { + fn select_subprotocol_requires_a_match_when_any_are_offered() { let supported = supported_subprotocols(moq_net::ALPNS); let known = supported.first().expect("no supported subprotocols").clone(); + let select = |requested: &[&[u8]]| select_subprotocol(requested.iter().copied(), &supported).cloned(); // Offering nothing names no QMux version, and neither does a blank header. - assert!(!subprotocols_acceptable([], &supported)); - assert!(!subprotocols_acceptable([b"" as &[u8], b" "], &supported)); + assert_eq!(select(&[]), None); + assert_eq!(select(&[b"", b" "]), None); // The retired legacy wire format is no longer ours. - assert!(!subprotocols_acceptable([b"webtransport" as &[u8]], &supported)); + assert_eq!(select(&[b"webtransport"]), None); - // Something we know, alone or among identifiers we don't. - assert!(subprotocols_acceptable([known.as_bytes()], &supported)); - assert!(subprotocols_acceptable( - [b"bogus-99" as &[u8], known.as_bytes()], - &supported - )); + // Something we know, alone or among identifiers we don't, padded as a header list is. + assert_eq!(select(&[known.as_bytes()]), Some(known.clone())); + assert_eq!( + select(&[b"bogus-99", format!(" {known}").as_bytes()]), + Some(known.clone()) + ); // Nothing we know: the draft says fail the handshake. - assert!(!subprotocols_acceptable([b"bogus-99" as &[u8]], &supported)); - assert!(!subprotocols_acceptable([b"bogus-99" as &[u8], b"soap"], &supported)); + assert_eq!(select(&[b"bogus-99"]), None); + assert_eq!(select(&[b"bogus-99", b"soap"]), None); // Near misses must not sneak through a prefix or substring match. - assert!(!subprotocols_acceptable( - [format!("{known}-next").as_bytes()], - &supported - )); + assert_eq!(select(&[format!("{known}-next").as_bytes()]), None); + + // Our preference wins over the client's order. + let fallback = supported.last().expect("no supported subprotocols"); + assert_eq!(select(&[fallback.as_bytes(), known.as_bytes()]), Some(known.clone())); } /// Send a raw WebSocket handshake and return its HTTP status line. @@ -552,38 +466,42 @@ mod tests { /// What a single accepted WebSocket connection negotiated, as seen by the server. #[derive(Debug)] struct Observed { - /// Raw `Sec-WebSocket-Protocol` axum selected (keeps the `qmux-XX.` prefix). + /// Raw `Sec-WebSocket-Protocol` the upgrade selected (keeps the `qmux-XX.` prefix). wire: Option, /// The moq app ALPN qmux derived from it (prefix stripped, `None` for bare). app: Option, } - /// Spawn an axum server that mirrors `serve_ws`'s subprotocol wiring. + /// Spawn an axum server that mirrors `serve_ws`'s upgrade and subprotocol wiring. /// /// Returns its address and a receiver yielding one [`Observed`] per accepted /// connection, in acceptance order. async fn spawn_test_server() -> (std::net::SocketAddr, mpsc::UnboundedReceiver) { let (tx, rx) = mpsc::unbounded_channel::(); - let route = any(move |ws: WebSocketUpgrade| { + let route = any(move |mut request: Request| { let tx = tx.clone(); async move { - let ws = negotiate_subprotocol(ws, moq_net::ALPNS)?; - Ok::<_, StatusCode>(ws.on_upgrade(move |socket| async move { - let wire = socket.protocol().and_then(|h| h.to_str().ok()).map(str::to_owned); - let socket = WebSocketAdapter::new(socket); - - let session = qmux::ws::Upgraded::new(socket, wire.as_deref().unwrap_or_default()) + let key = upgrade_key(&request).ok_or(StatusCode::BAD_REQUEST)?; + let wire = negotiate_subprotocol(request.headers(), moq_net::ALPNS)?; + let upgrade = hyper::upgrade::on(&mut request); + let response = switching_protocols(&key, &wire); + tokio::spawn(async move { + let upgraded = upgrade.await.expect("upgrade"); + let socket = WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, None).await; + let (session, driver) = qmux::ws::Upgraded::new(socket, &wire) .accept() .expect("the upgrade selected a QMux subprotocol"); + tokio::spawn(driver); let _ = tx.send(Observed { - wire, + wire: Some(wire), app: session.protocol().map(str::to_owned), }); // Hold the session open so the client stays alive long enough // to observe the negotiated subprotocol. let _ = session.closed().await; - })) + }); + Ok::<_, StatusCode>(response) } }); @@ -621,11 +539,12 @@ mod tests { async fn axum_ws_negotiates_newest_moq_alpn() { let (addr, mut rx) = spawn_test_server().await; - let session = qmux::ws::Client::new() + let (session, driver) = qmux::ws::Client::new() .with_protocols(moq_net::ALPNS.iter().map(|&a| (a, &[] as &[qmux::Version]))) .connect(&format!("ws://{addr}/")) .await .expect("qmux client connect"); + tokio::spawn(driver); assert_eq!( session.protocol(), @@ -660,11 +579,12 @@ mod tests { continue; }; - let session = qmux::ws::Client::new() + let (session, driver) = qmux::ws::Client::new() .with_protocol(app, &[version]) .connect(&url) .await .unwrap_or_else(|e| panic!("server rejected advertised pair {entry}: {e}")); + tokio::spawn(driver); let observed = next_observed(&mut rx).await; assert_eq!( @@ -680,11 +600,12 @@ mod tests { // offering only `qmux-00.moqt-18` still carries qmux's default bare fallbacks // (`qmux-01`, etc.), so the server gracefully downgrades to a bare ALPN // rather than failing. It just never lands on `moqt-18`. - let session = qmux::ws::Client::new() + let (session, driver) = qmux::ws::Client::new() .with_protocol("moqt-18", &[qmux::Version::QMux00]) .connect(&url) .await .expect("qmux-00.moqt-18 should downgrade to a bare fallback, not fail"); + tokio::spawn(driver); let observed = next_observed(&mut rx).await; assert_eq!( @@ -792,12 +713,13 @@ mod tests { // timeout is out of the picture before we go silent. It never speaks moq, // so the server is parked awaiting SETUP -- exactly where an idle // publisher's session sits between groups. - let client = qmux::ws::Upgraded::new( + let (client, driver) = qmux::ws::Upgraded::new( Pipe::new(client_incoming, client_to_server, Arc::new(AtomicBool::new(false))), &alpn, ) .connect() .unwrap(); + tokio::spawn(driver); // Paused time only advances once every task is idle, so this resolves // exactly when both ends have settled. @@ -841,12 +763,13 @@ mod tests { )); // A live qmux peer that answers keep-alives but never speaks moq. - let client = qmux::ws::Upgraded::new( + let (client, driver) = qmux::ws::Upgraded::new( Pipe::new(client_incoming, client_to_server, Arc::new(AtomicBool::new(false))), &alpn, ) .connect() .unwrap(); + tokio::spawn(driver); let err = server .await diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index d41040861ce..4eca08a15ab 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -456,19 +456,23 @@ async fn forwards_the_setup_token() { let params = [&[0x03, encoded.len() as u8][..], &encoded].concat(); let setup = [&[0xaf, 0x00][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); let session = if websocket { - qmux::ws::Client::new() + let (session, driver) = qmux::ws::Client::new() .with_protocol("moqt-18", &[qmux::Version::QMux01]) .connect(&format!("ws://127.0.0.1:{port}/room?source=setup")) .await - .expect("connect WebSocket") + .expect("connect WebSocket"); + tokio::spawn(driver); + session } else { - qmux::tcp::connect(("127.0.0.1", port), { + let (session, driver) = qmux::tcp::connect(("127.0.0.1", port), { let mut config = qmux::Config::new(qmux::Version::QMux01); config.protocol = qmux::Protocol::Negotiate(vec!["moqt-18".into()]); config }) .await - .expect("connect TCP") + .expect("connect TCP"); + tokio::spawn(driver); + session }; let mut send = session .open_uni(Default::default()) @@ -1917,11 +1921,12 @@ async fn websocket_refusal_is_in_band() { let script = Script::new(grant(Duration::from_secs(3600))); script.on_connect(answer); let (port, relay) = spawn_ws_relay(build_auth(script.spawn().await)).await; - let session = qmux::ws::Client::new() + let (session, driver) = qmux::ws::Client::new() .with_protocol("moq-lite-06", &[qmux::Version::QMux01]) .connect(&format!("ws://127.0.0.1:{port}/room")) .await .expect("policy refusal must not reject the browser-opaque HTTP upgrade"); + tokio::spawn(driver); let mut send = session.open_uni(Default::default()).await.expect("open SETUP"); write_all(&mut send, &[1, 1, 0]).await; send.finish().expect("finish SETUP"); diff --git a/rs/moq-relay/tests/integration/implementation.rs b/rs/moq-relay/tests/integration/implementation.rs index 07e9e7238b8..716e51ceb67 100644 --- a/rs/moq-relay/tests/integration/implementation.rs +++ b/rs/moq-relay/tests/integration/implementation.rs @@ -55,13 +55,14 @@ async fn the_relay_and_client_exchange_implementations() { }) .await; - let transport = qmux::tcp::connect(("127.0.0.1", port), { + let (transport, driver) = qmux::tcp::connect(("127.0.0.1", port), { let mut config = qmux::Config::new(qmux::Version::QMux01); config.protocol = qmux::Protocol::Negotiate(vec!["moq-lite-07-wip".into()]); config }) .await .expect("connect TCP"); + tokio::spawn(driver); let (session, driver) = moq_net::Client::new() .with_versions("moq-lite-07-wip".parse::().expect("lite-07").into()) .with_implementation("app/1 moq-net-rs/9") diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 3a7cd4c0d7d..20288bae012 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -349,6 +349,7 @@ impl Client { let config = opened.config; let session = qmux::Session::connect(opened.channel, config) .await + .map(crate::spawn_qmux) .map_err(crate::webrtc::Error::from)?; Ok::<_, Error>(connect_session(&self.moq, session).await?) }; diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 9e75f10aff0..7d1744f6685 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -3550,6 +3550,7 @@ mod tests { qmux::Session::accept(stream(server), config()) ) .unwrap(); + let (client, server) = (crate::spawn_qmux(client), crate::spawn_qmux(server)); let now = moq_net::time::Instant::now(); let client = async { let (session, driver) = moq_net::Client::new() @@ -3638,6 +3639,7 @@ mod tests { qmux::Session::accept(qmux::transport::Stream::new(server), config()) ) .unwrap(); + let (client, server) = (crate::spawn_qmux(client), crate::spawn_qmux(server)); let now = moq_net::time::Instant::now(); let moq = moq_net::Client::new() .with_versions(versions.clone()) diff --git a/rs/moq-tokio/src/lib.rs b/rs/moq-tokio/src/lib.rs index cb40c44a014..67f83db7216 100644 --- a/rs/moq-tokio/src/lib.rs +++ b/rs/moq-tokio/src/lib.rs @@ -101,6 +101,15 @@ fn qmux_config() -> qmux::Config { config } +/// Run a qmux session's driver on the runtime, handing back the session. +#[cfg(any(feature = "websocket", feature = "tcp", feature = "webrtc"))] +fn spawn_qmux( + (session, driver): (qmux::Session, qmux::Driver), +) -> qmux::Session { + runtime::spawn(driver); + session +} + // Enumerated rather than globbed, so the root surface is a deliberate list and a // new `pub` item in these modules doesn't silently join it. #[cfg(feature = "net")] diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 97127e0e09a..a8c0d292852 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -968,7 +968,7 @@ impl Listener { } let config = opened.config; let protocol = opened.protocol; - let session = qmux::Session::accept(opened.channel, config).await?; + let session = crate::spawn_qmux(qmux::Session::accept(opened.channel, config).await?); Ok((session, protocol)) }; let (session, protocol) = within(deadline, handshake).await?; @@ -2049,7 +2049,7 @@ mod tests { biased; _ = stalled.read_to_end(&mut remaining) => panic!("first handshake ended before second completed"), result = qmux::tcp::connect(addr, { let mut config = qmux::Config::new(qmux::Version::QMux01); config.protocol = qmux::Protocol::Negotiate(vec!["moq-lite-06".into()]); config }) => { - result.expect("second handshake completes"); + let _session = result.expect("second handshake completes"); } } task.abort(); @@ -2126,9 +2126,11 @@ mod tests { if allowed { let mut config = qmux::Config::new(qmux::Version::QMux01); config.protocol = qmux::Protocol::Negotiate(vec!["moq-lite-06".into()]); - let session = qmux::uds::connect(&path, config) - .await - .expect("allowed peer negotiates"); + let session = crate::spawn_qmux( + qmux::uds::connect(&path, config) + .await + .expect("allowed peer negotiates"), + ); assert_eq!(session.protocol(), Some("moq-lite-06")); } else { let mut peer = tokio::net::UnixStream::connect(&path).await.unwrap(); @@ -2182,7 +2184,7 @@ mod tests { biased; _ = stalled.read_to_end(&mut remaining) => panic!("first handshake ended before second completed"), result = qmux::uds::connect(&path, { let mut config = qmux::Config::new(qmux::Version::QMux01); config.protocol = qmux::Protocol::Negotiate(vec!["moq-lite-06".into()]); config }) => { - result.expect("second handshake completes"); + let _session = result.expect("second handshake completes"); } } task.abort(); @@ -2264,8 +2266,8 @@ mod tests { let client = WebSocketStream::from_raw_socket(client, Role::Client, None).await; let server = WebSocketStream::from_raw_socket(server, Role::Server, None).await; ( - qmux::ws::Upgraded::new(client, ALPN).connect().unwrap(), - qmux::ws::Upgraded::new(server, ALPN).accept().unwrap(), + crate::spawn_qmux(qmux::ws::Upgraded::new(client, ALPN).connect().unwrap()), + crate::spawn_qmux(qmux::ws::Upgraded::new(server, ALPN).accept().unwrap()), ) } @@ -2290,9 +2292,11 @@ mod tests { }; options.identity = Some(crate::tls::PeerIdentity::from_chain(Vec::new())); let accept = tokio::spawn(crate::websocket::accept(server, options)); - let client = qmux::ws::Upgraded::new(client, "qmux-01.moq-lite-06") - .connect() - .unwrap(); + let client = crate::spawn_qmux( + qmux::ws::Upgraded::new(client, "qmux-01.moq-lite-06") + .connect() + .unwrap(), + ); let mut setup = client.open_uni(SendOptions::default()).await.unwrap(); assert_eq!(setup.write(&[1, 1, 0]).await.unwrap(), 3); setup.finish().unwrap(); @@ -2330,7 +2334,7 @@ mod tests { ); options.link.alpn = Some(protocol.into()); let accept = tokio::spawn(crate::websocket::accept(server, options)); - let client = qmux::ws::Upgraded::new(client, protocol).connect().unwrap(); + let client = crate::spawn_qmux(qmux::ws::Upgraded::new(client, protocol).connect().unwrap()); // A single Path parameter, including the empty value: lite uses a // counted parameter bag; IETF uses a u16-sized SETUP option block. let mut bytes = if protocol.contains("moq-lite") { @@ -2699,11 +2703,13 @@ mod tests { let _stalled = tokio::net::TcpStream::connect(addr).await.unwrap(); let client = tokio::spawn(async move { - let session = qmux::ws::Client::new() - .with_protocol("moq-lite-06", &[qmux::Version::QMux01]) - .connect(&format!("ws://{addr}/")) - .await - .expect("upgrade"); + let session = crate::spawn_qmux( + qmux::ws::Client::new() + .with_protocol("moq-lite-06", &[qmux::Version::QMux01]) + .connect(&format!("ws://{addr}/")) + .await + .expect("upgrade"), + ); let (_session, driver) = moq_net::Client::new() .connect(moq_net::time::Instant::now(), session) .await diff --git a/rs/moq-tokio/src/tcp.rs b/rs/moq-tokio/src/tcp.rs index ee2db381109..e0ddff55aef 100644 --- a/rs/moq-tokio/src/tcp.rs +++ b/rs/moq-tokio/src/tcp.rs @@ -206,6 +206,7 @@ pub(crate) async fn connect_tls( client .connect(addr, &name) .await + .map(crate::spawn_qmux) .map_err(|err| Error::Connect(crate::error::message(err))) } }) @@ -227,6 +228,7 @@ async fn connect_addrs( config.protocol = qmux::Protocol::Negotiate(protocols); qmux::tcp::connect(addr, config) .await + .map(crate::spawn_qmux) .map_err(|err| Error::Connect(crate::error::message(err))) } }) @@ -306,6 +308,7 @@ impl TlsAcceptor { self.qmux .accept_established(stream, stats) .await + .map(crate::spawn_qmux) .map_err(|err| Error::Accept(crate::error::message(err))) }; let session = crate::server::within(deadline, handshake).await?; @@ -406,8 +409,8 @@ impl Listener { let tls = self.tls.clone(); async move { let session = match tls { - Some(tls) => tls.accept(stream).await, - None => qmux::tcp::accept(stream, config).await, + Some(tls) => tls.accept(stream).await.map(crate::spawn_qmux), + None => qmux::tcp::accept(stream, config).await.map(crate::spawn_qmux), } .map_err(|err| Error::Accept(crate::error::message(err)))?; Ok((session, addr)) diff --git a/rs/moq-tokio/src/unix.rs b/rs/moq-tokio/src/unix.rs index b02bb67f6d1..4129849cf20 100644 --- a/rs/moq-tokio/src/unix.rs +++ b/rs/moq-tokio/src/unix.rs @@ -250,6 +250,7 @@ pub(crate) async fn connect(url: Url, protocols: &[&str]) -> Result>, + writes: Option, config: qmux::Config, /// The session's halves, until a session takes them. halves: Option, @@ -765,21 +765,14 @@ impl Driver { let config = session_config(&protocol, max_record_size); let (reads, reader) = channel::queue(RECEIVE_WINDOW + READ_SLACK); - let (tx, writes) = mpsc::channel(1); + let (writes, writer) = channel::outbox(usize::try_from(max_message).unwrap_or(usize::MAX)); Ok(Open { id, protocol, reads, writes: Some(writes), config, - halves: Some(channel::Channel { - writer: channel::Writer { - tx: Some(tx), - max: usize::try_from(max_message).unwrap_or(usize::MAX), - _driver: None, - }, - reader, - }), + halves: Some(channel::Channel { writer, reader }), }) } @@ -808,9 +801,9 @@ impl Driver { } /// The session's next record, or `None` when its writer closed. Pends without a channel. -async fn next_write(writes: Option<&mut mpsc::Receiver>) -> Option { +async fn next_write(writes: Option<&mut channel::Outbox>) -> Option { match writes { - Some(writes) => writes.recv().await, + Some(writes) => writes.next().await, None => std::future::pending().await, } } diff --git a/rs/moq-tokio/src/webrtc/channel.rs b/rs/moq-tokio/src/webrtc/channel.rs index 5ad7d22e87f..29efc11f548 100644 --- a/rs/moq-tokio/src/webrtc/channel.rs +++ b/rs/moq-tokio/src/webrtc/channel.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::{Poll, ready}; use bytes::Bytes; use tokio::sync::mpsc; @@ -17,38 +18,112 @@ pub(crate) struct Channel { } impl qmux::transport::Transport for Channel { - type Writer = Writer; - type Reader = Reader; + fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll> { + self.reader.poll_recv(waiter) + } + + fn poll_ready(&mut self, waiter: &kio::Waiter) -> Poll> { + self.writer.poll_ready(waiter) + } + + fn send(&mut self, record: Bytes) -> Result<(), qmux::Error> { + self.writer.send(record) + } + + fn poll_flush(&mut self, waiter: &kio::Waiter) -> Poll> { + self.writer.poll_ready(waiter) + } + + fn poll_close(&mut self, waiter: &kio::Waiter) -> Poll> { + ready!(self.writer.poll_ready(waiter))?; + self.writer.close(); + Poll::Ready(Ok(())) + } +} + +/// A queue of outbound records, holding one at a time: the driver waits for SCTP to drain +/// before taking more, so a full send window holds back qmux's writer. +/// +/// Closing it from either end ends the session's writes: the [`Writer`] closes it to have the +/// driver close the channel, and the driver drops its [`Outbox`] once the channel is gone. +pub(crate) fn outbox(max: usize) -> (Outbox, Writer) { + let queue = kio::Queue::bounded(1); + let writer = Writer { + queue: queue.clone(), + pending: None, + max, + _driver: None, + }; + (Outbox(queue), writer) +} - fn split(self, _config: &qmux::Config) -> (Writer, Reader) { - (self.writer, self.reader) +/// The driver's end of the outbound queue. +pub(crate) struct Outbox(kio::Queue); + +impl Outbox { + /// The session's next record, or `None` once its writer closed. + pub async fn next(&self) -> Option { + self.0.pop().await.ok() } } -/// Hands outbound records to the driver, which waits for SCTP to drain before -/// taking more, so a full send window holds back qmux's writer. +impl Drop for Outbox { + fn drop(&mut self) { + self.0.close(); + } +} + +/// Hands outbound records to the driver through its [`Outbox`]. pub(crate) struct Writer { - /// `None` once closed: dropping the sender is what tells the driver to close the channel. - pub tx: Option>, + queue: kio::Queue, + /// A record waiting for room in `queue`. + pending: Option, /// The largest message the peer accepts. - pub max: usize, + max: usize, /// Keeps the driver running while the session outlives the signaling handle. pub _driver: Option>, } -impl qmux::transport::Writer for Writer { - async fn send(&mut self, data: Bytes) -> Result<(), qmux::Error> { - if data.len() > self.max { - return Err(qmux::Error::FrameTooLarge); +impl Writer { + /// Wait until the pending record, if any, is in the queue. + pub fn poll_ready(&mut self, waiter: &kio::Waiter) -> Poll> { + if self.pending.is_some() { + let pending = &mut self.pending; + ready!( + self.queue + .poll_push_with(waiter, || pending.take().expect("checked above")) + ) + .map_err(|_| qmux::Error::Closed)?; } - let tx = self.tx.as_ref().ok_or(qmux::Error::Closed)?; - tx.send(data).await.map_err(|_| qmux::Error::Closed) + Poll::Ready(Ok(())) } - async fn close(&mut self) -> Result<(), qmux::Error> { - self.tx = None; + /// Queue a record, after [`Self::poll_ready`] found room. + pub fn send(&mut self, record: Bytes) -> Result<(), qmux::Error> { + if record.len() > self.max { + return Err(qmux::Error::FrameTooLarge); + } + self.pending = Some(record); Ok(()) } + + /// Queue a record and wait for the driver to take it. + #[cfg(test)] + pub async fn write(&mut self, record: Bytes) -> Result<(), qmux::Error> { + self.send(record)?; + kio::wait(|waiter| self.poll_ready(waiter)).await + } + + /// Close the channel once the driver takes what is queued. + pub fn close(&mut self) { + self.queue.close(); + } +} + +impl Drop for Writer { + fn drop(&mut self) { + self.close(); + } } /// A queue of inbound records bounded by bytes the session has yet to read. @@ -110,18 +185,23 @@ pub(crate) struct Reader { unread: Arc, } -impl qmux::transport::Reader for Reader { - async fn recv(&mut self) -> Result { - let record = self.rx.recv().await.unwrap_or(Err(qmux::Error::Closed))?; +impl Reader { + /// The next record the driver read off the channel. + pub fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll> { + let record = ready!(self.rx.poll_recv(&mut waiter.context())).unwrap_or(Err(qmux::Error::Closed))?; self.unread.fetch_sub(record.len() as u64, Ordering::Relaxed); - Ok(record) + Poll::Ready(Ok(record)) + } + + /// Wait for [`Self::poll_recv`]. + #[cfg(test)] + pub async fn recv(&mut self) -> Result { + kio::wait(|waiter| self.poll_recv(waiter)).await } } #[cfg(test)] mod tests { - use qmux::transport::Reader as _; - use super::*; #[tokio::test] diff --git a/rs/moq-tokio/src/webrtc/tests.rs b/rs/moq-tokio/src/webrtc/tests.rs index f35b8b85688..8446cc3e598 100644 --- a/rs/moq-tokio/src/webrtc/tests.rs +++ b/rs/moq-tokio/src/webrtc/tests.rs @@ -123,14 +123,12 @@ fn record(index: usize) -> Bytes { /// keeps acknowledging, so the remote writer gets well past the send window. #[tokio::test] async fn unread_records_do_not_stall_the_writer() { - use qmux::transport::{Reader as _, Writer as _}; - let (_offerer, _answerer, sent, received) = channels().await; let (mut writer, mut reader) = (sent.channel.writer, received.channel.reader); let count = 8 * SEND_WINDOW / RECORD_SIZE as usize; kio::clock::timeout(TIMEOUT, async { for index in 0..count { - writer.send(record(index)).await.unwrap(); + writer.write(record(index)).await.unwrap(); } }) .await @@ -145,15 +143,13 @@ async fn unread_records_do_not_stall_the_writer() { /// has the channel closed, after the records within the budget. #[tokio::test] async fn ignoring_flow_control_closes_the_channel() { - use qmux::transport::{Reader as _, Writer as _}; - let (_offerer, _answerer, sent, received) = channels().await; let budget = RECEIVE_WINDOW + READ_SLACK; let within = (budget / RECORD_SIZE) as usize; let (mut writer, mut reader) = (sent.channel.writer, received.channel.reader); kio::clock::timeout(TIMEOUT, async { for index in 0..within + 8 { - if writer.send(record(index)).await.is_err() { + if writer.write(record(index)).await.is_err() { break; } } diff --git a/rs/moq-tokio/src/websocket.rs b/rs/moq-tokio/src/websocket.rs index 2850c5d4de6..899680d4db4 100644 --- a/rs/moq-tokio/src/websocket.rs +++ b/rs/moq-tokio/src/websocket.rs @@ -181,7 +181,7 @@ where if let Some(stats) = &options.socket_stats { upgraded = upgraded.with_socket_stats(stats.clone()); } - let session = upgraded.accept().map_err(Error::accept)?; + let session = crate::spawn_qmux(upgraded.accept().map_err(Error::accept)?); crate::server::accept_websocket(session, options, deadline).await }) .await @@ -462,6 +462,7 @@ pub(crate) async fn connect( .with_session_config(crate::qmux_config()) .connect(url.as_str()) .await + .map(crate::spawn_qmux) .map_err(Error::connect)? } }; @@ -523,6 +524,7 @@ async fn connect_tls_override( .with_keep_alive(qmux::ws::KeepAlive::default()) .with_session_config(crate::qmux_config()) .connect() + .map(crate::spawn_qmux) .map_err(Error::connect) } @@ -736,6 +738,7 @@ impl Listener { .with_keep_alive(qmux::ws::KeepAlive::default()) .with_session_config(crate::qmux_config()) .accept() + .map(crate::spawn_qmux) .map_err(Error::accept)?; Ok((session, url, Accepted { remote: addr, protocol })) } @@ -1083,7 +1086,7 @@ mod timeout_tests { let peer = tokio_tungstenite::WebSocketStream::from_raw_socket(peer, tungstenite::protocol::Role::Client, None) .await; - let peer = qmux::ws::Upgraded::new(peer, ALPN).connect().unwrap(); + let peer = crate::spawn_qmux(qmux::ws::Upgraded::new(peer, ALPN).connect().unwrap()); let mut accept = Box::pin(accept(server, options)); assert!(accept.as_mut().now_or_never().is_none()); let _stream = moq_net::kio::wait(|waiter| peer.poll_open_bi(waiter, Default::default())) diff --git a/rs/qmux/Cargo.toml b/rs/qmux/Cargo.toml index 2ae6ef4927b..adeb0f1b039 100644 --- a/rs/qmux/Cargo.toml +++ b/rs/qmux/Cargo.toml @@ -14,9 +14,9 @@ categories = ["network-programming", "asynchronous"] [features] default = ["tls", "wss"] -tcp = ["tokio/net", "tokio/io-util"] +tcp = ["tokio/net"] tls = ["tcp", "dep:tokio-rustls", "dep:rustls"] -uds = ["tokio/net", "tokio/io-util"] +uds = ["tokio/net"] ws = ["dep:tokio-tungstenite", "dep:futures"] wss = ["ws", "dep:rustls", "tokio-tungstenite/rustls-tls-native-roots"] @@ -30,9 +30,11 @@ moq-quic = { workspace = true, features = ["mux"] } rustc-hash = { workspace = true } rustls = { version = "0.23", default-features = false, optional = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] } +tokio = { workspace = true } tokio-rustls = { workspace = true, optional = true } tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "handshake"], optional = true } +# `poll_read_buf`, which reads into a `BytesMut` without zeroing it first. +tokio-util = { workspace = true, features = ["io"] } tracing = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/rs/qmux/README.md b/rs/qmux/README.md index 9d22c57ae96..8b4051b084b 100644 --- a/rs/qmux/README.md +++ b/rs/qmux/README.md @@ -10,6 +10,10 @@ Use `Session::connect` or `Session::accept` with a transport. The `tcp`, `tls`, `transport::Stream` wraps another Tokio byte stream. Sessions implement `moq_net::transport::poll` for MoQ's protocol layer. +Every constructor returns the session with its `Driver`: one future that owns the +socket, reads and writes it, and runs the session's timers. Spawn it on any +executor, such as `tokio::spawn`; qmux spawns nothing and needs no Tokio runtime. + The default features are `tls` and `wss`. Opt into `tcp`, `uds`, or `ws` for the corresponding unencrypted transport, or disable defaults when supplying your own. Unix sockets require a Unix host. diff --git a/rs/qmux/examples/bench.rs b/rs/qmux/examples/bench.rs index 8d47db24607..f62e820ee27 100644 --- a/rs/qmux/examples/bench.rs +++ b/rs/qmux/examples/bench.rs @@ -158,41 +158,49 @@ impl Tls { async fn accept(transport: Transport, tls: &Tls, socket: TcpStream) -> anyhow::Result { Ok(match transport { - Transport::Tcp => qmux::tcp::accept(socket, qmux::Config::default()).await?, - Transport::Tls => qmux::tls::Server::new(tls.server.clone()).accept(socket).await?, + Transport::Tcp => spawn(qmux::tcp::accept(socket, qmux::Config::default()).await?), + Transport::Tls => spawn(qmux::tls::Server::new(tls.server.clone()).accept(socket).await?), Transport::Ws => { socket.set_nodelay(true)?; - qmux::ws::Server::new().accept(socket).await? + spawn(qmux::ws::Server::new().accept(socket).await?) } Transport::Wss => { socket.set_nodelay(true)?; let socket = tokio_rustls::TlsAcceptor::from(tls.server.clone()) .accept(socket) .await?; - qmux::ws::Server::new().accept(socket).await? + spawn(qmux::ws::Server::new().accept(socket).await?) } }) } async fn connect(transport: Transport, tls: &Tls, addr: SocketAddr) -> anyhow::Result { Ok(match transport { - Transport::Tcp => qmux::tcp::connect(addr, qmux::Config::default()).await?, - Transport::Tls => { + Transport::Tcp => spawn(qmux::tcp::connect(addr, qmux::Config::default()).await?), + Transport::Tls => spawn( qmux::tls::Client::new(tls.client.clone()) .connect(addr, "localhost") - .await? - } - Transport::Ws => qmux::ws::Client::new().connect(&format!("ws://{addr}")).await?, + .await?, + ), + Transport::Ws => spawn(qmux::ws::Client::new().connect(&format!("ws://{addr}")).await?), Transport::Wss => { let connector = qmux::ws::tokio_tungstenite::Connector::Rustls(tls.client.clone()); - qmux::ws::Client::new() - .with_connector(connector) - .connect(&format!("wss://localhost:{}", addr.port())) - .await? + spawn( + qmux::ws::Client::new() + .with_connector(connector) + .connect(&format!("wss://localhost:{}", addr.port())) + .await?, + ) } }) } +/// Spawn the session's driver on the bench's runtime, returning the session. +fn spawn((session, driver): (Session, qmux::Driver)) -> Session { + tokio::spawn(driver); + session +} + /// Echo every chunk back on the same stream until the client finishes it. async fn serve(session: Session) -> anyhow::Result<()> { let (mut send, mut recv) = session.accept_bi().await?; diff --git a/rs/qmux/examples/interop-client.rs b/rs/qmux/examples/interop-client.rs index 2409214b21f..87231ecbb60 100644 --- a/rs/qmux/examples/interop-client.rs +++ b/rs/qmux/examples/interop-client.rs @@ -81,11 +81,12 @@ async fn main() -> anyhow::Result<()> { .context("usage: interop-client ")?; let version = parse_version(&raw_version)?; - let session = qmux::ws::Client::new() + let (session, driver) = qmux::ws::Client::new() .with_protocol(PROTOCOL, &[version]) .require_protocol() .connect(&url) .await?; + tokio::spawn(driver); run(session, version).await } diff --git a/rs/qmux/examples/interop-server.rs b/rs/qmux/examples/interop-server.rs index 218d6e6aa9a..3bfb51d9767 100644 --- a/rs/qmux/examples/interop-server.rs +++ b/rs/qmux/examples/interop-server.rs @@ -86,11 +86,12 @@ async fn main() -> anyhow::Result<()> { let (socket, _) = listener.accept().await?; socket.set_nodelay(true)?; - let session = qmux::ws::Server::new() + let (session, driver) = qmux::ws::Server::new() .with_protocol(PROTOCOL, &[version]) .require_protocol() .accept(socket) .await?; + tokio::spawn(driver); run(session, version).await } diff --git a/rs/qmux/src/driver.rs b/rs/qmux/src/driver.rs new file mode 100644 index 00000000000..80cca80639f --- /dev/null +++ b/rs/qmux/src/driver.rs @@ -0,0 +1,446 @@ +//! The driver: one future that owns the transport and moves the session's records over it. + +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Wake, Waker}; +use std::time::Duration; + +use bytes::Bytes; +use kio::clock::Instant; +use moq_quic::{TransportError, TransportErrorCode}; + +use crate::Error; +use crate::session::{Inner, State}; +use crate::transport::Transport; + +/// How long a closing session keeps the transport open to deliver its close and finish graceful +/// shutdown, like QUIC's draining period. A peer that stopped reading or never finishes its send +/// half cannot hold the transport open past it. +pub(crate) const CLOSE_TIMEOUT: Duration = Duration::from_secs(3); + +/// The most bytes read, or queued for writing, before the driver turns to the other direction. +const BATCH_BYTES: usize = 64 * 1024; + +/// Rounds of reading and writing in one poll before the driver yields to its executor. +const ROUNDS: usize = 16; + +/// Runs a [`Session`](crate::Session) over the transport it owns. +/// +/// Returned beside the session by every constructor. Spawn it on any executor, such as +/// `tokio::spawn`, or a thread-local task set since it needs no `Send` beyond what the +/// transport needs. It reads and writes the transport, runs the session's timers, and resolves +/// once the session has closed and the transport shut down. Dropping it first ends the session +/// with [`Error::Closed`]. +#[must_use = "the session makes no progress unless its driver is polled"] +pub struct Driver { + park: kio::Park, + /// Polls the transport's receive side, so it is polled again only once it asks to be. + recv: kio::Waiter, + recv_wake: Arc, + run: Run, +} + +// The transport is only ever borrowed mutably, never pinned. +impl Unpin for Driver {} + +impl Driver { + pub(crate) fn new(inner: Arc, transport: T) -> Self { + let recv_wake = Arc::new(RecvWake { + // Nothing is known about the receive side until it is polled once. + woken: AtomicBool::new(true), + task: Mutex::new(None), + }); + Self { + park: kio::Park::default(), + recv: kio::Waiter::new(Waker::from(recv_wake.clone())), + recv_wake, + run: Run { + inner, + transport, + timer: Timer::default(), + inbound: Vec::new(), + reading: true, + unflushed: false, + room: true, + outbound: VecDeque::new(), + phase: Phase::Open, + sent_bytes: 0, + received_bytes: 0, + }, + } + } +} + +impl Future for Driver { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + let this = self.get_mut(); + this.recv_wake.track(cx.waker()); + let waiter = this.park.hold(cx); + let recv = Recv { + waiter: &this.recv, + wake: &this.recv_wake, + }; + waiter.batch(|| this.run.poll(waiter, &recv)) + } +} + +/// Wakes the driver's task for the transport's receive side, and records that it did. +/// +/// Polling the receive side is not free on every transport: tungstenite zeroes its whole +/// read buffer on each attempt, even one that finds nothing. So the driver polls it only +/// after it asked to be woken, rather than on every wakeup a write or a handle caused. +struct RecvWake { + woken: AtomicBool, + /// The task that polls the driver. + task: Mutex>, +} + +impl RecvWake { + /// Wake `waker` from now on. + fn track(&self, waker: &Waker) { + let mut task = self.task.lock().unwrap(); + if !task.as_ref().is_some_and(|task| task.will_wake(waker)) { + *task = Some(waker.clone()); + } + } +} + +impl Wake for RecvWake { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + self.woken.store(true, Ordering::Release); + if let Some(task) = self.task.lock().unwrap().as_ref() { + task.wake_by_ref(); + } + } +} + +/// The receive side's waiter, and whether it was woken. +struct Recv<'a> { + waiter: &'a kio::Waiter, + wake: &'a RecvWake, +} + +impl Recv<'_> { + /// Whether the receive side was woken since it was last found pending, clearing it. + fn take(&self) -> bool { + self.wake.woken.swap(false, Ordering::AcqRel) + } + + /// Poll the receive side again on the next round, since it stopped short of pending. + fn again(&self) { + self.wake.woken.store(true, Ordering::Release); + } +} + +impl Drop for Driver { + fn drop(&mut self) { + if !matches!(self.run.phase, Phase::Done) { + finish(&self.run.inner, Err(Error::Closed)); + } + } +} + +impl std::fmt::Debug for Driver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Driver").finish_non_exhaustive() + } +} + +enum Phase { + /// Moving records both ways. + Open, + /// The connection said everything; closing the transport until this deadline. + Closing(Instant), + /// The transport is closed and the session's end published. + Done, +} + +/// What one round of reading and writing came to. +enum Step { + /// Did something; go again. + Progress, + /// Parked on the transport, a handle, or a timer. + Idle, + /// The connection has nothing left to say and all of it was flushed. + Drained, + /// The transport failed, or a closing session ran out of time. + Failed(Error), +} + +struct Run { + inner: Arc, + transport: T, + timer: Timer, + /// Records read but not yet handed to the connection, kept to reuse its allocation. + inbound: Vec>, + /// The transport's receive side has not ended. + reading: bool, + /// The last flush did not finish. + unflushed: bool, + /// The transport took everything and flushed it, so the connection may hand over more. + room: bool, + /// Records taken from the connection but not yet handed to the transport, which had no room. + outbound: VecDeque, + phase: Phase, + sent_bytes: u64, + received_bytes: u64, +} + +impl Run { + fn poll(&mut self, waiter: &kio::Waiter, recv: &Recv<'_>) -> Poll<()> { + let mut rounds = 0; + loop { + match self.phase { + Phase::Done => return Poll::Ready(()), + Phase::Closing(deadline) => { + // A close that fails or runs out of time still ends the session; the close + // frame, if any, is already out. + if self.transport.poll_close(waiter).is_pending() && !self.timer.poll(waiter, Some(deadline)) { + return Poll::Pending; + } + self.finish(Ok(())); + } + Phase::Open => match self.step(waiter, recv) { + Step::Progress => { + rounds += 1; + if rounds == ROUNDS { + waiter.waker().wake_by_ref(); + return Poll::Pending; + } + } + Step::Idle => return Poll::Pending, + Step::Drained => { + let deadline = self.timer.closing.unwrap_or_else(|| kio::clock::now() + CLOSE_TIMEOUT); + self.phase = Phase::Closing(deadline); + } + Step::Failed(err) => self.finish(Err(err)), + }, + } + } + } + + /// Read a batch into the connection, write a batch out of it, and flush. + fn step(&mut self, waiter: &kio::Waiter, recv: &Recv<'_>) -> Step { + // Whether this step polled the receive side, and whether it stopped short of pending. + let polled = self.reading && recv.take(); + let mut more_to_read = false; + if polled { + let mut read = 0; + loop { + if read >= BATCH_BYTES { + recv.again(); + more_to_read = true; + break; + } + match self.transport.poll_recv(recv.waiter) { + Poll::Ready(Ok(record)) => { + read += record.len().max(1); + self.inbound.push(Ok(record)); + } + Poll::Ready(Err(err)) => { + self.reading = false; + self.inbound.push(Err(err)); + break; + } + Poll::Pending => break, + } + } + } + let now = kio::clock::now(); + let mut state = self.inner.lock(); + // Register first: a handle mutating the connection after this re-polls us. + waiter.register(&mut state.driver); + for record in self.inbound.drain(..) { + if let Ok(record) = &record { + self.received_bytes += record.len() as u64; + } + deliver(&mut state, record, now); + // Publish what this record did before the next one: a close followed by the + // transport ending must report the close. + state.sweep(); + } + if state.conn.poll_timeout().is_some_and(|at| now >= at) { + state.conn.handle_timeout(now); + } + + // Take a batch from the connection only while the transport has room for it, so a + // stalled transport leaves unreliable datagrams in the connection to be shed. + let mut gathered = 0; + if self.room && self.outbound.is_empty() { + while gathered < BATCH_BYTES { + let Some(record) = state.conn.poll_transmit(now) else { + break; + }; + gathered += record.len(); + self.outbound.push_back(record); + } + } + state.sweep(); + if state.conn.is_closed() && self.timer.closing.is_none() { + self.timer.closing = Some(now + CLOSE_TIMEOUT); + } + let drained = state.conn.is_drained(); + let deadline = state.conn.poll_timeout(); + // Hand the batch to the transport outside the lock: it copies each record, and + // tungstenite may write to the socket, while the handles on other threads wait. + drop(state); + + let mut written = 0; + let mut blocked = false; + while let Some(record) = self.outbound.front() { + match self.transport.poll_ready(waiter) { + Poll::Ready(Ok(())) => {} + Poll::Ready(Err(err)) => return Step::Failed(err), + Poll::Pending => { + blocked = true; + break; + } + } + written += record.len(); + let record = self.outbound.pop_front().expect("peeked above"); + if let Err(err) = self.transport.send(record) { + return Step::Failed(err); + } + } + + self.inner.wire.set_received_bytes(self.received_bytes); + self.sent_bytes += written as u64; + self.inner.wire.set_sent_bytes(self.sent_bytes); + + // Flush what this step queued, what an earlier flush left, and what the transport may + // have queued on its own while receiving, such as a WebSocket Pong. Skipping it + // otherwise matters: a flush through TLS and tungstenite is not free. + let flushed = match written > 0 || blocked || self.unflushed || polled { + true => match self.transport.poll_flush(waiter) { + Poll::Ready(Ok(())) => true, + Poll::Ready(Err(err)) => return Step::Failed(err), + Poll::Pending => false, + }, + false => true, + }; + self.unflushed = !flushed; + let had_room = std::mem::replace(&mut self.room, flushed && !blocked); + self.set_stalled(!self.room); + + if drained && self.outbound.is_empty() && self.room { + return Step::Drained; + } + if self.timer.closing.is_some_and(|at| now >= at) { + // The close never got through a stalled transport. + return Step::Failed(Error::Closed); + } + // Each step already wrote what the records it read made the connection say, so go again + // only when a batch cut a direction short, or the transport has room again for what the + // connection held back meanwhile. + if more_to_read || (self.room && (gathered >= BATCH_BYTES || !had_room)) { + return Step::Progress; + } + let deadline = match (deadline, self.timer.closing) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + match self.timer.poll(waiter, deadline) { + true => Step::Progress, + false => Step::Idle, + } + } + + /// Track whether writes are stalled on the transport. The peer is evidently there but slow, + /// which defers the idle timeout. + fn set_stalled(&mut self, stalled: bool) { + if self.timer.stalled != stalled { + self.timer.stalled = stalled; + self.inner.lock().conn.set_backpressured(stalled); + } + } + + fn finish(&mut self, result: Result<(), Error>) { + self.phase = Phase::Done; + finish(&self.inner, result); + } +} + +/// Feed one received record, or the transport's error, into the connection. +fn deliver(state: &mut State, record: Result, now: Instant) { + let violation = |code, reason: &str| TransportError::new(code, reason.to_string()); + match record { + Ok(record) => state.conn.handle_record(record, now), + Err(Error::FrameTooLarge) => state.conn.handle_oversized(), + Err(Error::FlowControlError) => state.conn.handle_violation(violation( + TransportErrorCode::FLOW_CONTROL_ERROR, + "transport buffer overrun", + )), + Err(Error::FrameEncoding) => state + .conn + .handle_violation(violation(TransportErrorCode::FRAME_ENCODING_ERROR, "transport framing")), + Err(Error::ProtocolViolation) => state + .conn + .handle_violation(violation(TransportErrorCode::PROTOCOL_VIOLATION, "transport framing")), + Err(err) => { + // The transport's own error says more than the connection's reset, unless this side + // is closing, when the peer hanging up is the expected answer and the driver + // publishes the close it asked for. + if state.local_close.is_none() { + state.fail(err); + } + state.conn.transport_closed(); + } + } +} + +/// The transport is done: publish a local close, or the failure that ended it. +fn finish(inner: &Inner, result: Result<(), Error>) { + let mut state = inner.lock(); + if let Some(err) = state.local_close.take() { + state.fail(err); + } + if let Err(err) = result { + state.fail(err); + } + state.conn.transport_closed(); + state.sweep(); + // A drained connection ended with a close from either side, already published; this only + // lands when the transport died under a live one. + state.fail(Error::Closed); +} + +/// The driver's timers. +#[derive(Default)] +struct Timer { + sleep: Option, + /// When a closing session gives up on delivering its close frame. + closing: Option, + /// Writes are stalled on the transport. + stalled: bool, +} + +impl Timer { + /// Wait for `deadline`, returning whether it already passed. + fn poll(&mut self, waiter: &kio::Waiter, deadline: Option) -> bool { + let Some(deadline) = deadline else { + return false; + }; + if kio::clock::now() >= deadline { + return true; + } + let sleep = match &mut self.sleep { + Some(sleep) => { + if sleep.deadline() != deadline { + sleep.reset(deadline); + } + sleep + } + None => self.sleep.insert(kio::clock::sleep_until(deadline)), + }; + waiter.poll_future(Pin::new(sleep)).is_ready() + } +} diff --git a/rs/qmux/src/lib.rs b/rs/qmux/src/lib.rs index cc2d9c0f18b..704fa9f6de0 100644 --- a/rs/qmux/src/lib.rs +++ b/rs/qmux/src/lib.rs @@ -9,8 +9,12 @@ //! //! The crate root holds the transport-independent session API: [`Session`] and its //! [`SendStream`] / [`RecvStream`] halves, which implement -//! [`moq_net::transport::poll`], plus the [`Config`], [`Version`], and [`Error`] types they -//! share. +//! [`moq_net::transport::poll`], the [`Driver`] that runs a session over the transport it +//! owns, plus the [`Config`], [`Version`], and [`Error`] types they share. +//! +//! Every constructor returns a `(Session, Driver)` pair. The driver is one future that reads +//! and writes the transport and runs the session's timers; spawn it on whatever executor +//! owns the socket. qmux spawns nothing itself and needs no tokio runtime. //! //! Everything tied to a specific transport lives in that transport's module: //! @@ -31,6 +35,7 @@ #[cfg(any(feature = "tls", feature = "ws"))] mod alpn; mod config; +mod driver; mod error; mod protocol; mod session; @@ -56,6 +61,7 @@ pub mod tls; pub mod ws; pub use config::{Config, Protocol}; +pub use driver::Driver; pub use error::Error; pub use moq_quic::mux::Version; pub use moq_quic::{Limits, LimitsError}; diff --git a/rs/qmux/src/session.rs b/rs/qmux/src/session.rs index 4017ffbf75f..ba67de8989a 100644 --- a/rs/qmux/src/session.rs +++ b/rs/qmux/src/session.rs @@ -1,52 +1,37 @@ -//! The session: shared state, the reader and writer tasks, and the handles. +//! The session: shared state and the handles. //! -//! [`mux::Connection`] owns the streams, flow control, and timers; this module moves its -//! records over the transport. A writer task drains [`mux::Connection::poll_transmit`] -//! into the transport and runs the timers, and a reader task feeds each received record -//! back in. The handles call straight into the connection under its lock and park on the -//! per-stream waiter lists the tasks wake, the way the QUIC drivers do. +//! [`mux::Connection`] owns the streams, flow control, and timers, and the +//! [`Driver`](crate::Driver) moves its records over the transport. The handles call straight +//! into the connection under its lock and park on the per-stream waiter lists the driver +//! wakes, the way the QUIC drivers do. -use std::pin::Pin; use std::sync::{Arc, Mutex, MutexGuard}; use std::task::Poll; use std::time::Duration; use bytes::{Buf, Bytes}; -use kio::clock::Instant; use moq_net::transport::{self as net, Address, poll}; use moq_quic::stream::{Dir, Id as StreamId}; -use moq_quic::{ - Side, TransportError, TransportErrorCode, VarInt, connection::Error as ConnectionError, mux, recv, send, -}; +use moq_quic::{Side, VarInt, connection::Error as ConnectionError, mux, recv, send}; use rustc_hash::FxHashMap; -use crate::transport::{Reader, Transport, Writer}; -use crate::{Config, Error, SharedSocketStats}; +use crate::transport::Transport; +use crate::{Config, Driver, Error, SharedSocketStats}; -/// How long a closing session keeps the transport open to deliver its close and finish graceful -/// shutdown, like QUIC's draining period. A peer that stopped reading or never finishes its send -/// half cannot hold the transport open past it. -const CLOSE_TIMEOUT: Duration = Duration::from_secs(3); - -/// The most bytes gathered before the writer flushes the transport. -const BATCH_BYTES: usize = 64 * 1024; - -/// Everything the handles and the tasks share. -struct Inner { +/// Everything the handles and the driver share. +pub(crate) struct Inner { state: Mutex, - /// Updated by the reader and writer, so stats never take the lock. - wire: net::Wire, + /// Updated by the driver, so stats never take the lock. + pub(crate) wire: net::Wire, socket_stats: Option, address: Address, } -struct State { - conn: mux::Connection, - /// Wakes the writer; handles kick it after mutating the connection so fresh egress +pub(crate) struct State { + pub(crate) conn: mux::Connection, + /// Wakes the driver; handles kick it after mutating the connection so fresh egress /// reaches the transport. - driver: kio::WaiterList, - /// Wakes the reader once the connection ends, so it stops reading. - reader: kio::WaiterList, + pub(crate) driver: kio::WaiterList, established: bool, establish_waiters: kio::WaiterList, @@ -66,24 +51,20 @@ struct State { /// The terminal error, set exactly once; everything fails with it after. closed: Option, closed_waiters: kio::WaiterList, - /// The error for a close this side asked for, published once the writer has delivered + /// The error for a close this side asked for, published once the driver has delivered /// the close frame, so a caller that exits the moment `poll_closed` resolves already /// handed it to the transport. - local_close: Option, - - sent_bytes: u64, - received_bytes: u64, + pub(crate) local_close: Option, } impl State { /// Terminate with `err` (the first one wins) and wake absolutely everyone. - fn fail(&mut self, err: Error) { + pub(crate) fn fail(&mut self, err: Error) { if self.closed.is_some() { return; } self.closed = Some(err); self.driver.wake(); - self.reader.wake(); self.closed_waiters.wake(); self.establish_waiters.wake(); self.accept_bi_waiters.wake(); @@ -102,7 +83,7 @@ impl State { } /// Apply every pending connection event to the parking tables. - fn sweep(&mut self) { + pub(crate) fn sweep(&mut self) { while let Some(event) = self.conn.poll() { match event { mux::Event::Connected => { @@ -112,11 +93,10 @@ impl State { mux::Event::Stream(event) => self.sweep_stream(event), mux::Event::DatagramReceived => self.datagram_waiters.wake(), mux::Event::ConnectionLost { reason } => { - self.reader.wake(); let local = matches!(reason, ConnectionError::LocallyClosed { .. }); let err = Error::lost(reason); match local { - // The writer publishes a local close once its frame is out. + // The driver publishes a local close once its frame is out. true => self.local_close = Some(err), false => self.fail(err), } @@ -162,7 +142,7 @@ fn wake(lists: &mut FxHashMap, id: StreamId) { } impl Inner { - fn lock(&self) -> MutexGuard<'_, State> { + pub(crate) fn lock(&self) -> MutexGuard<'_, State> { self.state.lock().unwrap() } @@ -177,237 +157,6 @@ impl Inner { .close(VarInt::from_u64(code).unwrap_or(VarInt::MAX), reason.to_string()); state.sweep(); state.driver.wake(); - // A closing connection ignores whatever else the peer sends. - state.reader.wake(); - } - - /// Gather the next records to write, running the timers while there are none, or `None` - /// once the connection has nothing left to say. - fn poll_batch(&self, waiter: &kio::Waiter, timer: &mut Timer) -> Poll>> { - let mut state = self.lock(); - // Register first: a handle mutating the connection after this sweep re-polls us. - waiter.register(&mut state.driver); - loop { - let now = kio::clock::now(); - if state.conn.poll_timeout().is_some_and(|at| now >= at) { - state.conn.handle_timeout(now); - } - - let mut batch = Vec::new(); - let mut size = 0; - while size < BATCH_BYTES { - let Some(record) = state.conn.poll_transmit(now) else { - break; - }; - size += record.len(); - batch.push(record); - } - state.sweep(); - if !batch.is_empty() { - return Poll::Ready(Some(batch)); - } - if state.conn.is_drained() { - return Poll::Ready(None); - } - if !timer.poll(waiter, state.conn.poll_timeout()) { - return Poll::Pending; - } - } - } - - /// Run the timers while a write is stalled, resolving once a closing session has spent - /// its [`CLOSE_TIMEOUT`] and should drop the transport. - fn poll_stalled(&self, waiter: &kio::Waiter, timer: &mut Timer) -> Poll<()> { - let mut state = self.lock(); - waiter.register(&mut state.driver); - if !timer.stalled { - // The peer is evidently there but slow, which defers the idle timeout. - timer.stalled = true; - state.conn.set_backpressured(true); - } - loop { - let now = kio::clock::now(); - if state.conn.is_closed() && timer.closing.is_none() { - timer.closing = Some(now + CLOSE_TIMEOUT); - } - if timer.closing.is_some_and(|at| now >= at) { - return Poll::Ready(()); - } - if state.conn.poll_timeout().is_some_and(|at| now >= at) { - state.conn.handle_timeout(now); - state.sweep(); - } - let deadline = match (state.conn.poll_timeout(), timer.closing) { - (Some(a), Some(b)) => Some(a.min(b)), - (a, b) => a.or(b), - }; - if !timer.poll(waiter, deadline) { - return Poll::Pending; - } - } - } - - /// Resolve once the connection ended, which is when the reader stops. - fn poll_ended(&self, waiter: &kio::Waiter) -> Poll<()> { - let mut state = self.lock(); - if state.conn.is_closed() || state.closed.is_some() { - return Poll::Ready(()); - } - waiter.register(&mut state.reader); - Poll::Pending - } - - /// The writer stopped: publish a local close, or the failure that stopped it. - fn writer_done(&self, result: Result<(), Error>) { - let mut state = self.lock(); - if let Some(err) = state.local_close.take() { - state.fail(err); - } - if let Err(err) = result { - state.fail(err); - } - state.conn.transport_closed(); - state.sweep(); - // A drained connection ended with a close from either side, already published; this - // only lands when the transport died under a live one. - state.fail(Error::Closed); - } -} - -/// The writer's timers. -#[derive(Default)] -struct Timer { - sleep: Option, - /// When a closing session gives up on delivering its close frame. - closing: Option, - /// The current write is stalled on the transport. - stalled: bool, -} - -impl Timer { - /// Wait for `deadline`, returning whether it already passed. - fn poll(&mut self, waiter: &kio::Waiter, deadline: Option) -> bool { - let Some(deadline) = deadline else { - return false; - }; - if kio::clock::now() >= deadline { - return true; - } - let sleep = match &mut self.sleep { - Some(sleep) => { - if sleep.deadline() != deadline { - sleep.reset(deadline); - } - sleep - } - None => self.sleep.insert(kio::clock::sleep_until(deadline)), - }; - waiter.poll_future(Pin::new(sleep)).is_ready() - } -} - -/// Drain the connection into `writer` until it has nothing left to say. -async fn run_writer(inner: Arc, mut writer: W, reader: tokio::task::JoinHandle<()>) { - let mut timer = Timer::default(); - let result = async { - loop { - let batch = tokio::select! { - biased; - // Transport-level maintenance, like a WebSocket keep-alive Ping. First, since a - // writer that always has a batch ready would otherwise never send one; it is - // ready only once per keep-alive interval. - result = writer.maintain() => { - result?; - continue; - } - batch = kio::wait(|waiter| inner.poll_batch(waiter, &mut timer)) => batch, - }; - let Some(batch) = batch else { - return Ok(()); - }; - - let size: usize = batch.iter().map(Bytes::len).sum(); - let write = async { - for record in batch { - writer.feed(record).await?; - } - writer.flush().await - }; - tokio::pin!(write); - let result = tokio::select! { - biased; - result = &mut write => result, - // Pending while the write runs; resolves once a close has waited long enough. - () = kio::wait(|waiter| inner.poll_stalled(waiter, &mut timer)) => Err(Error::Closed), - }; - if std::mem::take(&mut timer.stalled) { - inner.lock().conn.set_backpressured(false); - } - result?; - - let mut state = inner.lock(); - state.sent_bytes += size as u64; - inner.wire.set_sent_bytes(state.sent_bytes); - } - } - .await; - - // Shut the transport down gracefully, unless it already failed, within what is left of - // the close bound. The drained connection has already woken the reader; waiting for it to - // drop its half lets the close drain the peer instead of leaving that to the reader. - if result.is_ok() { - let bound = timer - .closing - .map_or(CLOSE_TIMEOUT, |at| at.saturating_duration_since(kio::clock::now())); - let close = async { - let _ = reader.await; - writer.close().await - }; - let _ = kio::clock::timeout(bound, close).await; - } - inner.writer_done(result); -} - -/// Feed every record from `reader` into the connection until it ends. -async fn run_reader(inner: Arc, mut reader: R) { - loop { - let record = tokio::select! { - biased; - () = kio::wait(|waiter| inner.poll_ended(waiter)) => return, - record = reader.recv() => record, - }; - - let mut state = inner.lock(); - let violation = |code, reason: &str| TransportError::new(code, reason.to_string()); - match record { - Ok(record) => { - state.received_bytes += record.len() as u64; - inner.wire.set_received_bytes(state.received_bytes); - state.conn.handle_record(record, kio::clock::now()); - } - Err(Error::FrameTooLarge) => state.conn.handle_oversized(), - Err(Error::FlowControlError) => state.conn.handle_violation(violation( - TransportErrorCode::FLOW_CONTROL_ERROR, - "transport buffer overrun", - )), - Err(Error::FrameEncoding) => state - .conn - .handle_violation(violation(TransportErrorCode::FRAME_ENCODING_ERROR, "transport framing")), - Err(Error::ProtocolViolation) => state - .conn - .handle_violation(violation(TransportErrorCode::PROTOCOL_VIOLATION, "transport framing")), - Err(err) => { - // The transport's own error says more than the connection's reset, unless - // this side is closing, when the peer hanging up is the expected answer and - // the writer publishes the close it asked for. - if state.local_close.is_none() { - state.fail(err); - } - state.conn.transport_closed(); - } - } - state.sweep(); - state.driver.wake(); } } @@ -439,32 +188,36 @@ pub struct Session { impl Session { /// Open a client-side session over the given transport, waiting until it is established - /// before returning. + /// before returning it with the [`Driver`] that runs it. /// /// "Established" means the peer's transport parameters have been received and applied, - /// so [`protocol`](Self::protocol) returns its final value. Bounded by - /// [`Config::handshake_timeout`]: if the peer completes the transport handshake but - /// never sends its parameters, this returns [`Error::HandshakeTimeout`] rather than - /// hanging. - pub async fn connect(transport: T, config: Config) -> Result { - Self::new(transport, Side::Client, config)?.established().await + /// so [`protocol`](Self::protocol) returns its final value. The handshake polls the driver + /// itself; spawn the driver once this returns. Bounded by [`Config::handshake_timeout`]: + /// if the peer completes the transport handshake but never sends its parameters, this + /// returns [`Error::HandshakeTimeout`] rather than hanging. + pub async fn connect(transport: T, config: Config) -> Result<(Session, Driver), Error> { + let (session, mut driver) = Self::new(transport, Side::Client, config)?; + let session = session.established(&mut driver).await?; + Ok((session, driver)) } /// Open a server-side session over the given transport, waiting until it is - /// established before returning. See [`Session::connect`] for the semantics. - pub async fn accept(transport: T, config: Config) -> Result { - Self::new(transport, Side::Server, config)?.established().await + /// established. See [`Session::connect`] for the semantics. + pub async fn accept(transport: T, config: Config) -> Result<(Session, Driver), Error> { + let (session, mut driver) = Self::new(transport, Side::Server, config)?; + let session = session.established(&mut driver).await?; + Ok((session, driver)) } /// Start a session over the transport without waiting for the handshake, for callers /// that resolved the protocol out of band (the WebSocket subprotocol, say). - pub(crate) fn new(transport: T, side: Side, config: Config) -> Result { + pub(crate) fn new(mut transport: T, side: Side, config: Config) -> Result<(Self, Driver), Error> { let conn = mux::Connection::new(config.to_mux()?, side, kio::clock::now()); + transport.bind(&config); let inner = Arc::new(Inner { state: Mutex::new(State { conn, driver: kio::WaiterList::new(), - reader: kio::WaiterList::new(), established: false, establish_waiters: kio::WaiterList::new(), accept_bi_waiters: kio::WaiterList::new(), @@ -477,31 +230,29 @@ impl Session { closed: None, closed_waiters: kio::WaiterList::new(), local_close: None, - sent_bytes: 0, - received_bytes: 0, }), wire: net::Wire::new(), socket_stats: transport.socket_stats(), address: transport.address(), }); - - let (writer, reader) = transport.split(&config); - let reader = tokio::spawn(run_reader(inner.clone(), reader)); - tokio::spawn(run_writer(inner.clone(), writer, reader)); - - Ok(Self { + let session = Self { _guard: Arc::new(Guard(inner.clone())), - inner, + inner: inner.clone(), protocol: config.negotiated_protocol(), #[cfg(feature = "tls")] tls: None, - }) + }; + Ok((session, Driver::new(inner, transport))) } /// Wait for the peer's transport parameters, then report the negotiated protocol. - async fn established(mut self) -> Result { + /// + /// Polls `driver` meanwhile, since nobody has spawned it yet. + async fn established(mut self, driver: &mut Driver) -> Result { let inner = self.inner.clone(); kio::wait(|waiter| { + // A finished driver already published why, so only the state needs checking. + let _ = waiter.poll_future(std::pin::Pin::new(&mut *driver)); let mut state = inner.lock(); if state.established { return Poll::Ready(Ok(())); diff --git a/rs/qmux/src/session/tests.rs b/rs/qmux/src/session/tests.rs index 5afd0f8dbfd..0183dd1985b 100644 --- a/rs/qmux/src/session/tests.rs +++ b/rs/qmux/src/session/tests.rs @@ -1,5 +1,6 @@ //! Session tests over an in-memory byte stream, on paused tokio time. +use std::pin::Pin; use std::time::Duration; use bytes::{Bytes, BytesMut}; @@ -8,6 +9,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; use tokio::sync::watch; use super::*; +use crate::driver::CLOSE_TIMEOUT; use crate::transport::Stream; use crate::{Protocol, Version}; @@ -18,6 +20,12 @@ fn config(version: Version) -> Config { Config::new(version) } +/// Spawn the session's driver, as every caller does. +fn spawn((session, driver): (Session, Driver)) -> Session { + tokio::spawn(driver); + session +} + /// Start a client and a server over `client`/`server`, waiting for both handshakes. async fn establish( client: DuplexStream, @@ -28,7 +36,10 @@ async fn establish( let client = Session::connect(Stream::new(client), client_config); let server = Session::accept(Stream::new(server), server_config); let (client, server) = tokio::join!(client, server); - (client.expect("client handshake"), server.expect("server handshake")) + ( + spawn(client.expect("client handshake")), + spawn(server.expect("server handshake")), + ) } async fn pair(client_config: Config, server_config: Config) -> (Session, Session) { @@ -267,16 +278,18 @@ async fn request_credit_is_refused_without_parking_an_open() { async fn a_request_before_the_handshake_waits_for_credit() { let (client, server) = tokio::io::duplex(PIPE); // Started like a WebSocket client, without waiting for the server's parameters. - let client = Session::new(Stream::new(client), Side::Client, config(Version::QMux02)).unwrap(); + let client = spawn(Session::new(Stream::new(client), Side::Client, config(Version::QMux02)).unwrap()); let mut opening = std::pin::pin!(kio::wait(|waiter| client.poll_open_request(waiter, Default::default()))); assert!( futures_poll_once(&mut opening).await.is_none(), "no credit is known before the handshake" ); - let _server = Session::accept(Stream::new(server), config(Version::QMux02)) - .await - .unwrap(); + let _server = spawn( + Session::accept(Stream::new(server), config(Version::QMux02)) + .await + .unwrap(), + ); let opened = opening.await.expect("request after the handshake"); assert!(opened.is_some(), "the server's initial credit admits the request"); } @@ -367,8 +380,8 @@ async fn websocket_keep_alive_runs_during_continuous_output() { .with_keep_alive(KeepAlive::new(Duration::from_millis(10), Duration::from_millis(500))) .unwrap(); let b = WsTransport::new(b, Address::Uri); - let sender = Session::new(a, Side::Client, config.clone()).unwrap(); - let _receiver = Session::new(b, Side::Server, config).unwrap(); + let sender = spawn(Session::new(a, Side::Client, config.clone()).unwrap()); + let _receiver = spawn(Session::new(b, Side::Server, config).unwrap()); while sender.max_datagram_size() == 0 { kio::clock::sleep(Duration::from_millis(1)).await; } @@ -504,7 +517,7 @@ async fn an_oversized_record_is_a_protocol_violation() { let (client, mut raw) = tokio::io::duplex(PIPE); let mut config = config(version); config.max_record_size = max_record_size; - let session = Session::new(Stream::new(client), Side::Client, config).unwrap(); + let session = spawn(Session::new(Stream::new(client), Side::Client, config).unwrap()); // A full-sized padding record proves that the configured limit, including // one larger than the default, is the reader's actual boundary. let mut record = BytesMut::new(); @@ -548,7 +561,7 @@ async fn an_application_close_drains_the_peer() { for &version in Version::ALL { let (client, mut raw) = tokio::io::duplex(64); let config = config(version); - let session = Session::new(Stream::new(client), Side::Client, config).unwrap(); + let session = spawn(Session::new(Stream::new(client), Side::Client, config).unwrap()); session.close(42, "bye"); let mut sent = Vec::new(); diff --git a/rs/qmux/src/tcp.rs b/rs/qmux/src/tcp.rs index da82d4b551d..c3d29b4c2c2 100644 --- a/rs/qmux/src/tcp.rs +++ b/rs/qmux/src/tcp.rs @@ -1,22 +1,26 @@ use tokio::net::{TcpStream, ToSocketAddrs}; -use crate::transport::build_stream_session; -use crate::{Config, Error, Session}; +use crate::transport::{Stream, build_stream_session}; +use crate::{Config, Driver, Error, Session}; /// Connect a QMux client over plain TCP with one session configuration. /// /// TCP has no ALPN; set `Config::protocol` to negotiate an application protocol. -pub async fn connect(addr: impl ToSocketAddrs, config: Config) -> Result { +pub async fn connect(addr: impl ToSocketAddrs, config: Config) -> Result<(Session, Driver>), Error> { let stream = TcpStream::connect(addr).await?; finish(stream, config, false).await } /// Accept a QMux session; drive each handshake independently of the listener loop. -pub async fn accept(stream: TcpStream, config: Config) -> Result { +pub async fn accept(stream: TcpStream, config: Config) -> Result<(Session, Driver>), Error> { finish(stream, config, true).await } -async fn finish(stream: TcpStream, config: crate::Config, is_server: bool) -> Result { +async fn finish( + stream: TcpStream, + config: crate::Config, + is_server: bool, +) -> Result<(Session, Driver>), Error> { let socket_stats = adopt(&stream)?; // `build_stream_session` awaits the peer's transport parameters before // returning, so `protocol()` is resolved on the session we hand back @@ -76,8 +80,8 @@ mod tests { finish(adopt(client), config.clone(), false), accept(adopt(server), config) ); - client.unwrap(); - server.unwrap(); + let _client = client.unwrap(); + let _server = server.unwrap(); for probe in probes { assert!(probe.nodelay().unwrap()); } diff --git a/rs/qmux/src/tls.rs b/rs/qmux/src/tls.rs index fa507621b73..d07eda93f8b 100644 --- a/rs/qmux/src/tls.rs +++ b/rs/qmux/src/tls.rs @@ -6,7 +6,7 @@ use tokio_rustls::TlsAcceptor; use tokio_rustls::TlsConnector; use crate::transport::Stream; -use crate::{Config, Error, Session, Version, alpn}; +use crate::{Config, Driver, Error, Session, Version, alpn}; /// A QMux client over TLS that advertises `(alpn, versions)` entries. /// @@ -68,7 +68,11 @@ impl Client { /// Connect to `addr` and start a client session. `server_name` is used for /// SNI and certificate verification. - pub async fn connect(&self, addr: impl ToSocketAddrs, server_name: &str) -> Result { + pub async fn connect( + &self, + addr: impl ToSocketAddrs, + server_name: &str, + ) -> Result<(Session, Driver>>), Error> { let stream = TcpStream::connect(&addr).await?; // Adopt the socket before TLS takes ownership; the kernel's view of the TCP // connection is the same either side of the encryption. @@ -109,7 +113,8 @@ impl Client { transport = transport.with_socket_stats(stats); } // `connect` awaits the peer's transport parameters so `protocol()` is resolved. - Ok(Session::connect(transport, session_config).await?.with_tls(handshake)) + let (session, driver) = Session::connect(transport, session_config).await?; + Ok((session.with_tls(handshake), driver)) } } @@ -146,14 +151,17 @@ impl Server { /// /// This awaits both the TLS handshake and the QMux handshake (the peer's /// transport parameters, bounded by the session's - /// [`handshake_timeout`](crate::Config::handshake_timeout)). Drive each - /// connection with `tokio::spawn` so a slow or non-cooperative peer can't - /// stall your `listener.accept()` loop. + /// [`handshake_timeout`](crate::Config::handshake_timeout)). Run each accept + /// on its own task so a slow or non-cooperative peer can't stall your + /// `listener.accept()` loop. /// /// The returned session reports the client's certificate chain, when the /// `rustls::ServerConfig` asked for one and the client presented it, and the /// SNI it sent (see [`Session::peer_certificates`] and [`Session::server_name`]). - pub async fn accept(&self, stream: TcpStream) -> Result { + pub async fn accept( + &self, + stream: TcpStream, + ) -> Result<(Session, Driver>>), Error> { let socket_stats = crate::tcp::adopt(&stream)?; let acceptor = TlsAcceptor::from(self.config.clone()); let tls_stream = acceptor.accept(stream).await?; @@ -172,9 +180,9 @@ impl Server { &self, stream: tokio_rustls::server::TlsStream, socket_stats: Option, - ) -> Result + ) -> Result<(Session, Driver>>), Error> where - T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { let conn = stream.get_ref().1; let negotiated = conn.alpn_protocol().and_then(|a| std::str::from_utf8(a).ok()); @@ -189,7 +197,8 @@ impl Server { transport = transport.with_socket_stats(stats); } // `accept` awaits the peer's transport parameters so `protocol()` is resolved. - Ok(Session::accept(transport, session_config).await?.with_tls(handshake)) + let (session, driver) = Session::accept(transport, session_config).await?; + Ok((session.with_tls(handshake), driver)) } } diff --git a/rs/qmux/src/transport.rs b/rs/qmux/src/transport.rs index f5984597272..b59c4453399 100644 --- a/rs/qmux/src/transport.rs +++ b/rs/qmux/src/transport.rs @@ -1,37 +1,63 @@ +use std::task::Poll; + use bytes::Bytes; -use crate::Error; +use crate::{Config, Error}; -/// Abstracts message I/O over a reliable transport. +/// Record I/O over a reliable transport, polled by a session's [`Driver`](crate::Driver). +/// +/// Each record is one complete QMux record, or one frame on draft-00: a WebSocket message +/// carries one, and a byte stream delimits them itself. Use [`Stream`] for an +/// [`AsyncRead`](tokio::io::AsyncRead) + [`AsyncWrite`](tokio::io::AsyncWrite) byte stream, +/// and implement this trait for a transport with its own message boundaries or lifecycle. /// -/// Each `send`/`recv` operates on a single complete message (frame). -/// For WebSocket, this maps to individual WS binary messages. -/// For TCP/TLS byte streams, the transport handles frame delimiting. +/// The driver polls receiving and sending from one task, so a write blocked on the +/// transport never stalls reads, and the session sheds unreliable datagrams rather than +/// buffering them behind a stalled transport. /// -/// Use [`Stream`] for arbitrary [`AsyncRead`](tokio::io::AsyncRead) + -/// [`AsyncWrite`](tokio::io::AsyncWrite) byte streams. Implement this trait when -/// integrating a transport with its own message boundaries, framing, or -/// lifecycle that the built-in byte-stream and WebSocket adapters do not cover. +/// # Wakeups /// -/// A transport splits into independently-owned send and receive halves so the -/// session can drive them on separate tasks: a write blocked on transport -/// backpressure must never stall reads (and vice versa). This decoupling is what -/// lets the session observe backpressure and shed unreliable datagrams instead -/// of buffering them behind a stalled socket. -pub trait Transport: Send + 'static { - /// The independently-owned send half. - type Writer: Writer; - /// The independently-owned receive half. - type Reader: Reader; +/// The driver polls a method again only after the `waiter` it passed was woken, so each +/// `poll_*` method must arrange that wakeup before it returns [`Poll::Pending`], as a future +/// does with its [`Context`](std::task::Context): poll the I/O underneath through +/// [`waiter.context()`](kio::Waiter::context), or register `waiter` on whatever will wake it. +/// A `Pending` without that wakeup stalls the session. +/// +/// [`poll_recv`](Self::poll_recv) gets a waiter of its own, and the driver polls it only +/// after that waiter is woken. [`poll_flush`](Self::poll_flush) runs only after a step +/// that received or queued a record, or whose last flush did not finish. So a transport that +/// writes on its own, such as a keep-alive Ping or a Pong reply, queues it from `poll_recv` +/// (waking that waiter from its timer), and the flush that follows sends it. +pub trait Transport { + /// Bind the session's framing, before the first poll. + fn bind(&mut self, _config: &Config) {} + + /// Receive the next complete record. + /// + /// An error ends the receive side; the driver does not poll it again. + fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll>; - /// Bind the session framing and split into send and receive halves. - fn split(self, config: &crate::Config) -> (Self::Writer, Self::Reader); + /// Wait for room to queue another record with [`send`](Self::send). + fn poll_ready(&mut self, waiter: &kio::Waiter) -> Poll>; + + /// Queue one record, after [`poll_ready`](Self::poll_ready) found room. + fn send(&mut self, record: Bytes) -> Result<(), Error>; + + /// Write out everything queued. + /// + /// The driver polls this after a step that received or queued a record, or whose last + /// flush did not finish, so it also sends control messages the transport queued while + /// receiving, such as a WebSocket Pong. + fn poll_flush(&mut self, waiter: &kio::Waiter) -> Poll>; + + /// Flush and gracefully close the transport. A byte stream also drains the peer until it + /// finishes, which only the peer bounds, so the driver bounds the wait. + fn poll_close(&mut self, waiter: &kio::Waiter) -> Poll>; /// Statistics for the connection underneath, if it exposes any. /// - /// Read before the split, since the halves are handed to separate tasks. A - /// transport that returns `None` leaves the session reporting only what it can - /// measure itself, i.e. the `QX_PING` round trip. + /// A transport that returns `None` leaves the session reporting only what it can measure + /// itself, i.e. the `QX_PING` round trip. fn socket_stats(&self) -> Option { None } @@ -43,1255 +69,15 @@ pub trait Transport: Send + 'static { } } -/// The send half of a [`Transport`]. -pub trait Writer: Send + 'static { - /// Send a single complete message. - fn send(&mut self, data: Bytes) -> impl std::future::Future> + Send; - - /// Queue a single complete message without flushing it. The session's writer - /// feeds every frame that is already queued and then calls [`Writer::flush`] - /// once, so a burst of small frames reaches the socket in one write instead of - /// one write (and one TCP segment) each. The default sends immediately. - fn feed(&mut self, data: Bytes) -> impl std::future::Future> + Send { - self.send(data) - } - - /// Write out everything queued by [`Writer::feed`]. The default has nothing to - /// flush because the default `feed` already sent. - fn flush(&mut self) -> impl std::future::Future> + Send { - std::future::ready(Ok(())) - } - - /// Gracefully close the transport. Drop the [`Reader`] first so a byte stream also drains - /// the peer until it finishes, which only the peer bounds, so bound or cancel the wait; - /// otherwise this closes only the send half. - fn close(&mut self) -> impl std::future::Future> + Send; - - /// Perform any timer-driven background work and resolve once it's done. The - /// session's writer loop selects on this alongside outbound frames, so a - /// transport can piggy-back periodic maintenance (e.g. a WebSocket keep-alive - /// Ping) on the same task that owns the send half. The default never - /// resolves — transports with nothing to do (TCP, Unix sockets) use it as-is. - fn maintain(&mut self) -> impl std::future::Future> + Send { - std::future::pending() - } -} - -/// The receive half of a [`Transport`]. -pub trait Reader: Send + 'static { - /// Receive the next complete message. - fn recv(&mut self) -> impl std::future::Future> + Send; -} - -// Stream: message I/O over a byte stream (TCP/TLS/Unix). -// Handles QMux frame delimiting to return complete frames as Bytes. -// -// Cancel safety: a dedicated reader task owns the read half and pushes complete -// frames into an `mpsc` channel. `recv()` is just `rx.recv().await`, which is -// cancel safe — if the future is dropped (e.g. a sibling `tokio::select!` branch -// wins), the buffered frame stays in the channel for the next call. The reader -// task itself never gets cancelled mid-parse, so the multi-step async reads in -// `recv_record`/`recv_qmux00_frame` are safe to keep as-is. -#[cfg(any(feature = "tcp", all(unix, feature = "uds")))] -mod stream_transport { - use std::sync::Arc; - - use bytes::{BufMut, Bytes, BytesMut}; - use moq_quic::VarInt; - use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, BufWriter}; - use tokio::sync::mpsc; - use tokio::task::{AbortHandle, JoinHandle}; - - use super::{Reader, Transport, Writer}; - use crate::{Error, MAX_FRAME_SIZE, Version}; - - /// Bound on queued frames waiting for the session to drain them. Bytes the - /// session hasn't picked up yet are also buffered in the OS receive window - /// once this fills; the channel just gives a small slack so each `recv()` - /// is a cheap hand-off rather than a syscall. - const RECV_CHANNEL_CAPACITY: usize = 16; - - /// QMux message I/O over any reliable byte stream (`AsyncRead + AsyncWrite`). - /// - /// Handles QMux frame/record delimiting so [`Session`](crate::Session) sees - /// complete frames. Pair it with [`Session::connect`](crate::Session::connect) - /// or [`Session::accept`](crate::Session::accept) to run QMux over a transport - /// the built-in `tcp`/`tls`/`ws` helpers don't cover — a Unix socket, a pipe, - /// an in-memory duplex, a custom tunnel, etc.: - /// - /// ```no_run - /// # async fn f(stream: tokio::net::TcpStream) -> Result<(), qmux::Error> { - /// use qmux::transport::Stream; - /// use qmux::{Config, Session, Version}; - /// - /// let config = Config::new(Version::QMux01); - /// let transport = Stream::new(stream); - /// let session = Session::connect(transport, config).await?; - /// # let _ = session; Ok(()) - /// # } - /// ``` - pub struct Stream { - stream: T, - socket_stats: Option, - } - - /// The send half of a byte-stream [`Stream`]. - pub struct StreamWriter { - writer: BufWriter>, - version: Version, - reader_task: Option>, - reader_lifetime: Arc, - } - - /// The receive half of a byte-stream [`Stream`]. - pub struct StreamReader { - rx: mpsc::Receiver>, - _reader_lifetime: Arc, - } - - // Either half can outlive the other; the last owner cancels the socket read. - struct ReaderTask(AbortHandle); - - impl Drop for ReaderTask { - fn drop(&mut self) { - self.0.abort(); - } - } - - impl Stream { - /// Wrap a byte stream; session establishment binds its framing and starts I/O. - pub fn new(stream: T) -> Self { - Self { - stream, - socket_stats: None, - } - } - - /// Report statistics for the connection underneath this stream. - /// - /// `Stream` is generic over the byte stream, so it can't discover the socket - /// on its own; the builder that knows the concrete type attaches it. See - /// `TcpStats` (Linux and macOS). - pub fn with_socket_stats(mut self, stats: crate::SharedSocketStats) -> Self { - self.socket_stats = Some(stats); - self - } - } - - impl Transport for Stream { - type Writer = StreamWriter; - type Reader = StreamReader; - - fn split(self, config: &crate::Config) -> (StreamWriter, StreamReader) { - let (read, write) = tokio::io::split(self.stream); - let (tx, rx) = mpsc::channel(RECV_CHANNEL_CAPACITY); - let reader_task = tokio::spawn(reader_loop( - BufReader::new(read), - config.version, - usize::try_from(config.max_record_size).unwrap_or(usize::MAX), - tx, - )); - let reader_lifetime = Arc::new(ReaderTask(reader_task.abort_handle())); - ( - StreamWriter { - writer: BufWriter::new(write), - version: config.version, - reader_task: Some(reader_task), - reader_lifetime: reader_lifetime.clone(), - }, - StreamReader { - rx, - _reader_lifetime: reader_lifetime, - }, - ) - } - - fn socket_stats(&self) -> Option { - self.socket_stats.clone() - } - } - - impl Writer for StreamWriter { - async fn send(&mut self, data: Bytes) -> Result<(), Error> { - self.feed(data).await?; - self.flush().await - } - - async fn feed(&mut self, data: Bytes) -> Result<(), Error> { - // Record-framed drafts (QMux01+) travel inside size-prefixed records - // on byte streams. (Records are implicit on WebSocket, where the - // message boundary delimits them.) Left in the BufWriter, which only - // writes to the socket when full; `flush` sends the rest. - if self.version.uses_records() { - let mut size_buf = BytesMut::with_capacity(8); - VarInt::try_from(data.len()) - .map_err(|_| Error::FrameTooLarge)? - .encode(&mut size_buf); - self.writer.write_all(&size_buf).await?; - } - self.writer.write_all(&data).await?; - Ok(()) - } - - async fn flush(&mut self) -> Result<(), Error> { - self.writer.flush().await?; - Ok(()) - } - - async fn close(&mut self) -> Result<(), Error> { - self.writer.shutdown().await?; - // A retained receive half owns frame delivery and can leave the channel full. - // Closing only the send half must neither wait for it nor discard its frames. - if Arc::strong_count(&self.reader_lifetime) > 1 { - return Ok(()); - } - // Closing a TCP socket with unread peer data can reset it and discard our close. - if let Some(task) = self.reader_task.as_mut() { - let result = task.await; - self.reader_task = None; - result.map_err(std::io::Error::other)?; - } - Ok(()) - } - } - - impl Reader for StreamReader { - async fn recv(&mut self) -> Result { - // mpsc::Receiver::recv is cancel safe, so dropping this future never - // loses a buffered frame. `None` means the reader task exited without - // sending — treat as a clean close. - self.rx.recv().await.unwrap_or(Err(Error::Closed)) - } - } - - /// Reader task: pull complete frames off the wire and ship them through `tx`. - /// After a parse error or receiver drop, drain the peer until EOF so the writer - /// can close gracefully. - async fn reader_loop( - mut reader: BufReader, - version: Version, - our_max_record_size: usize, - tx: mpsc::Sender>, - ) { - loop { - let result = match version.uses_records() { - true => recv_record(&mut reader, our_max_record_size).await, - false => recv_qmux00_frame(&mut reader).await, - }; - let stop = result.is_err(); - if tx.send(result).await.is_err() || stop { - drop(tx); - // The session no longer reads records, but the peer can still be finishing - // its send half. Discard without buffering; the writer owns cancellation. - let _ = tokio::io::copy(&mut reader, &mut tokio::io::sink()).await; - return; - } - } - } - - /// Read a varint from the stream, returning the decoded value. - /// If `buf` is provided, appends the raw bytes to it. - async fn read_varint_into(reader: &mut R, buf: &mut BytesMut) -> Result { - let first = reader.read_u8().await?; - buf.put_u8(first); - - let tag = first >> 6; - let len = 1usize << tag; - - if len == 1 { - return Ok(VarInt::from(first & 0x3f)); - } - - let start = buf.len(); - buf.resize(start + len - 1, 0); - reader.read_exact(&mut buf[start..]).await?; - - let mut raw = [0u8; 8]; - raw[0] = first & 0x3f; - raw[1..len].copy_from_slice(&buf[start..start + len - 1]); - - let value = match len { - 2 => u16::from_be_bytes([raw[0], raw[1]]) as u64, - 4 => u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as u64, - 8 => u64::from_be_bytes(raw), - _ => unreachable!(), - }; - - // Two tag bits leave 62 for the value, which is exactly a varint's range. - Ok(VarInt::from_u64(value).expect("62-bit value")) - } - - /// Read a varint from the stream without collecting raw bytes. - async fn read_varint(reader: &mut R) -> Result { - let first = reader.read_u8().await?; - let tag = first >> 6; - let len = 1usize << tag; - - if len == 1 { - return Ok(VarInt::from(first & 0x3f)); - } - - let mut raw = [0u8; 8]; - raw[0] = first & 0x3f; - reader.read_exact(&mut raw[1..len]).await?; - - let value = match len { - 2 => u16::from_be_bytes([raw[0], raw[1]]) as u64, - 4 => u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as u64, - 8 => u64::from_be_bytes(raw), - _ => unreachable!(), - }; - - // Two tag bits leave 62 for the value, which is exactly a varint's range. - Ok(VarInt::from_u64(value).expect("62-bit value")) - } - - /// Read exactly `len` bytes, appending to buf. - async fn read_bytes(reader: &mut R, len: usize, buf: &mut BytesMut) -> Result<(), Error> { - let start = buf.len(); - buf.resize(start + len, 0); - reader.read_exact(&mut buf[start..]).await?; - Ok(()) - } - - /// A peer-sent length as `usize`, refused where it does not fit (32-bit targets) - fn peer_len(value: VarInt) -> Result { - usize::try_from(value.into_inner()).map_err(|_| Error::FrameTooLarge) - } - - /// Read one QMux Record from the byte stream (draft-01). - /// Returns the record payload (frames concatenated). - async fn recv_record(reader: &mut R, our_max_record_size: usize) -> Result { - let size = peer_len(read_varint(reader).await?)?; - if size > our_max_record_size { - return Err(Error::FrameTooLarge); - } - let mut buf = BytesMut::zeroed(size); - reader.read_exact(&mut buf).await?; - Ok(buf.freeze()) - } - - /// Read one complete QMux frame from the byte stream (draft-00), returning raw bytes. - async fn recv_qmux00_frame(reader: &mut R) -> Result { - let mut buf = BytesMut::new(); - let frame_type = read_varint_into(reader, &mut buf).await?.into_inner(); - - // STREAM frames: 0x08-0x0f - if (0x08..=0x0f).contains(&frame_type) { - let has_off = frame_type & 0x04 != 0; - let has_len = frame_type & 0x02 != 0; - - read_varint_into(reader, &mut buf).await?; // stream id - - if has_off { - read_varint_into(reader, &mut buf).await?; // offset - } - - if has_len { - let len = peer_len(read_varint_into(reader, &mut buf).await?)?; - // draft-00 §5.2: `max_frame_size` bounds the whole frame, so the - // header bytes read so far count against it. A byte stream cannot - // resynchronize past an oversized frame, so this stays fatal — as - // the draft requires (FRAME_ENCODING_ERROR). - if buf.len().saturating_add(len) > MAX_FRAME_SIZE { - return Err(Error::FrameTooLarge); - } - read_bytes(reader, len, &mut buf).await?; - } else { - // A byte stream cannot delimit a STREAM frame without its length. - return Err(Error::FrameEncoding); - } - - return Ok(buf.freeze()); - } - - match frame_type { - // PADDING - 0x00 => {} - // RESET_STREAM - 0x04 => { - read_varint_into(reader, &mut buf).await?; // id - read_varint_into(reader, &mut buf).await?; // code - read_varint_into(reader, &mut buf).await?; // final_size - } - // STOP_SENDING - 0x05 => { - read_varint_into(reader, &mut buf).await?; // id - read_varint_into(reader, &mut buf).await?; // code - } - // CONNECTION_CLOSE (0x1c) carries the Frame Type field; APPLICATION_CLOSE - // (0x1d) omits it (RFC 9000 §19.19). - 0x1c | 0x1d => { - read_varint_into(reader, &mut buf).await?; // code - if frame_type == 0x1c { - read_varint_into(reader, &mut buf).await?; // frame_type - } - let reason_len = peer_len(read_varint_into(reader, &mut buf).await?)?; - if buf.len().saturating_add(reason_len) > MAX_FRAME_SIZE { - return Err(Error::FrameTooLarge); - } - read_bytes(reader, reason_len, &mut buf).await?; - } - // MAX_DATA - 0x10 => { - read_varint_into(reader, &mut buf).await?; - } - // MAX_STREAM_DATA - 0x11 => { - read_varint_into(reader, &mut buf).await?; // id - read_varint_into(reader, &mut buf).await?; // max - } - // MAX_STREAMS (bidi/uni) - 0x12 | 0x13 => { - read_varint_into(reader, &mut buf).await?; - } - // DATA_BLOCKED - 0x14 => { - read_varint_into(reader, &mut buf).await?; - } - // STREAM_DATA_BLOCKED - 0x15 => { - read_varint_into(reader, &mut buf).await?; // id - read_varint_into(reader, &mut buf).await?; // limit - } - // STREAMS_BLOCKED (bidi/uni) - 0x16 | 0x17 => { - read_varint_into(reader, &mut buf).await?; - } - // QX_TRANSPORT_PARAMETERS - 0x3f5153300d0a0d0a => { - let len = peer_len(read_varint_into(reader, &mut buf).await?)?; - if buf.len().saturating_add(len) > MAX_FRAME_SIZE { - return Err(Error::FrameTooLarge); - } - read_bytes(reader, len, &mut buf).await?; - } - // Draft-00 has no datagrams or QX_PING, and an unknown type cannot be skipped - // on a byte stream. - _ => return Err(Error::FrameEncoding), - } - - Ok(buf.freeze()) - } - - #[cfg(test)] - mod tests { - use super::*; - use crate::transport::{Reader, Transport}; - use tokio::io::AsyncWriteExt; - - #[test] - fn wrapping_a_stream_does_not_start_io() { - let (io, _peer) = tokio::io::duplex(64); - let _transport = Stream::new(io); - } - - #[tokio::test(start_paused = true)] - async fn graceful_close_drains_the_peer_after_our_send_half_ends() { - for &version in Version::ALL { - let (stream, mut peer) = tokio::io::duplex(64); - let (mut writer, reader) = Stream::new(stream).split(&crate::Config { - max_record_size: 16 * 1024, - ..crate::Config::new(version) - }); - drop(reader); - - let mut close = Box::pin(writer.close()); - let mut extra = [0]; - tokio::select! { - biased; - result = &mut close => panic!("{version:?}: closed before the peer finished: {result:?}"), - result = peer.read(&mut extra) => assert_eq!(result.unwrap(), 0), - } - - // The peer can still have data in flight after it reads our FIN. More than the - // pipe holds proves the receiver is draining it rather than merely staying alive. - peer.write_all(&[0; 1024]).await.expect("drain peer data"); - peer.shutdown().await.expect("peer FIN"); - close.as_mut().await.expect("graceful close"); - drop(close); - writer.close().await.expect("already closed"); - } - } - - #[tokio::test(start_paused = true)] - async fn closing_the_send_half_preserves_a_full_receive_channel() { - for &version in Version::ALL { - let (stream, mut peer) = tokio::io::duplex(1); - let (mut writer, mut reader) = Stream::new(stream).split(&crate::Config { - max_record_size: 16 * 1024, - ..crate::Config::new(version) - }); - let (frame, payload): (&[u8], &[u8]) = if version.uses_records() { - (&[1, 42], &[42]) - } else { - (&[0], &[0]) // PADDING - }; - // The one-byte pipe cannot accept the last frame until all preceding - // frames have filled the channel. The receive half stays unpolled. - for _ in 0..=RECV_CHANNEL_CAPACITY { - peer.write_all(frame).await.expect("peer frame"); - } - peer.shutdown().await.expect("peer FIN"); - assert_eq!(reader.rx.len(), RECV_CHANNEL_CAPACITY); - - tokio::select! { - biased; - result = writer.close() => result.expect("send half closed"), - _ = std::future::ready(()) => panic!("{version:?}: send close waited for the retained receive half"), - } - let mut extra = [0]; - assert_eq!(peer.read(&mut extra).await.unwrap(), 0); - drop(writer); - for _ in 0..=RECV_CHANNEL_CAPACITY { - assert_eq!(reader.recv().await.expect("preserved peer frame"), payload); - } - } - } - - #[tokio::test(start_paused = true)] - async fn a_framing_error_still_drains_peer_data_during_close() { - let (stream, mut peer) = tokio::io::duplex(64); - let (mut writer, mut reader) = Stream::new(stream).split(&crate::Config { - max_record_size: 4, - ..crate::Config::new(Version::QMux02) - }); - peer.write_all(&[5]).await.expect("oversized record header"); - assert!(matches!(reader.recv().await, Err(Error::FrameTooLarge))); - drop(reader); - - let close = writer.close(); - tokio::pin!(close); - let mut extra = [0]; - tokio::select! { - biased; - result = &mut close => panic!("closed before the malformed peer finished: {result:?}"), - result = peer.read(&mut extra) => assert_eq!(result.unwrap(), 0), - } - peer.write_all(&[0; 1024]).await.expect("drain malformed peer data"); - peer.shutdown().await.expect("peer FIN"); - close.await.expect("graceful close"); - } - - #[tokio::test(start_paused = true)] - async fn cancelling_graceful_close_releases_the_receive_half() { - let (stream, mut peer) = tokio::io::duplex(64); - let (mut writer, reader) = Stream::new(stream).split(&crate::Config { - max_record_size: 16 * 1024, - ..crate::Config::new(Version::QMux02) - }); - drop(reader); - - let mut close = Box::pin(writer.close()); - let mut extra = [0]; - tokio::select! { - biased; - result = &mut close => panic!("closed before the peer finished: {result:?}"), - result = peer.read(&mut extra) => assert_eq!(result.unwrap(), 0), - } - drop(close); - drop(writer); - - // A detached drain would let this write finish, or park it once the pipe fills. - peer.write_all(&[0; 1024]).await.expect_err("receive half released"); - } - - #[tokio::test(start_paused = true)] - async fn dropping_the_send_half_keeps_the_receive_half_usable() { - let (stream, mut peer) = tokio::io::duplex(64); - let (writer, mut reader) = Stream::new(stream).split(&crate::Config { - max_record_size: 16 * 1024, - ..crate::Config::new(Version::QMux02) - }); - drop(writer); - peer.write_all(&[1, 42]).await.expect("peer record"); - assert_eq!(reader.recv().await.expect("receive half remains open"), &[42][..]); - } - - // Drip a frame in one byte at a time, racing each `recv` against an - // immediate yield so the recv future is dropped every iteration. With - // a cancel-safe `recv`, the final call must still return the whole - // frame intact. - #[tokio::test] - async fn recv_is_cancel_safe_across_partial_writes() { - let (client, mut server) = tokio::io::duplex(64 * 1024); - let (_writer, mut transport) = Stream::new(client).split(&crate::Config { - max_record_size: 16 * 1024, - ..crate::Config::new(Version::QMux00) - }); - - // STREAM frame, type 0x0a (len bit set), id=4, length=5, payload="hello". - let mut frame = Vec::new(); - frame.push(0x0a); - frame.push(0x04); - frame.push(0x05); - frame.extend_from_slice(b"hello"); - - for chunk in frame.chunks(1).take(frame.len() - 1) { - server.write_all(chunk).await.unwrap(); - server.flush().await.unwrap(); - tokio::select! { - _ = transport.recv() => panic!("recv completed with a partial frame"), - _ = tokio::task::yield_now() => {} - } - } - - server.write_all(&frame[frame.len() - 1..]).await.unwrap(); - server.flush().await.unwrap(); - - let got = transport.recv().await.expect("frame should decode"); - assert_eq!(&got[..], frame.as_slice()); - } - - #[tokio::test] - async fn recv_qmux01_record_is_cancel_safe() { - let (client, mut server) = tokio::io::duplex(64 * 1024); - let (_writer, mut transport) = Stream::new(client).split(&crate::Config { - max_record_size: 16 * 1024, - ..crate::Config::new(Version::QMux01) - }); - - // 1-byte varint length (0x08) followed by 8 bytes of payload. - let mut record = vec![0x08]; - record.extend_from_slice(b"abcdefgh"); - - for chunk in record.chunks(1).take(record.len() - 1) { - server.write_all(chunk).await.unwrap(); - server.flush().await.unwrap(); - tokio::select! { - _ = transport.recv() => panic!("recv completed with a partial record"), - _ = tokio::task::yield_now() => {} - } - } - - server.write_all(&record[record.len() - 1..]).await.unwrap(); - server.flush().await.unwrap(); - - let got = transport.recv().await.expect("record should decode"); - assert_eq!(&got[..], b"abcdefgh"); - } - - // Two frames arrive in a single write. Each recv() must return one - // complete frame, in order. Exercises the channel queue + the reader - // task looping on a buffer that still has bytes after parsing. - #[tokio::test] - async fn recv_returns_consecutive_frames_in_order() { - let (client, mut server) = tokio::io::duplex(64 * 1024); - let (_writer, mut transport) = Stream::new(client).split(&crate::Config { - max_record_size: 16 * 1024, - ..crate::Config::new(Version::QMux00) - }); - - // Two STREAM frames (type 0x0a) for stream ids 4 and 8. - let frame_a: Vec = [0x0a, 0x04, 0x05].into_iter().chain(*b"hello").collect(); - let frame_b: Vec = [0x0a, 0x08, 0x05].into_iter().chain(*b"world").collect(); - let mut combined = frame_a.clone(); - combined.extend_from_slice(&frame_b); - - server.write_all(&combined).await.unwrap(); - server.flush().await.unwrap(); - - let got_a = transport.recv().await.expect("first frame should decode"); - let got_b = transport.recv().await.expect("second frame should decode"); - assert_eq!(&got_a[..], frame_a.as_slice()); - assert_eq!(&got_b[..], frame_b.as_slice()); - } - - // Reader task hits a parse error: `recv()` returns it, and the next - // `recv()` returns Error::Closed since the task has exited. - #[tokio::test] - async fn recv_propagates_parse_error_then_closes() { - let (client, mut server) = tokio::io::duplex(64 * 1024); - let (_writer, mut transport) = Stream::new(client).split(&crate::Config { - max_record_size: 16 * 1024, - ..crate::Config::new(Version::QMux00) - }); - - // Frame type 0x02 isn't a recognized QMux00 frame type. - server.write_all(&[0x02]).await.unwrap(); - server.flush().await.unwrap(); - - let err = transport.recv().await.expect_err("parse error expected"); - assert!(matches!(err, Error::FrameEncoding), "got {err:?}"); - - // Task has exited after sending the error; subsequent recv sees the - // closed channel and reports Error::Closed. - let err = transport.recv().await.expect_err("closed expected"); - assert!(matches!(err, Error::Closed), "got {err:?}"); - } - - // A record whose declared size exceeds `our_max_record_size` is - // rejected with FrameTooLarge before any payload is consumed. - #[tokio::test] - async fn recv_record_exceeding_max_returns_frame_too_large() { - let (client, mut server) = tokio::io::duplex(64 * 1024); - let (_writer, mut transport) = Stream::new(client).split(&crate::Config { - max_record_size: 4, - ..crate::Config::new(Version::QMux01) - }); - - // 1-byte varint length = 5, which exceeds the configured max of 4. - server.write_all(&[0x05]).await.unwrap(); - server.flush().await.unwrap(); - - let err = transport.recv().await.expect_err("FrameTooLarge expected"); - assert!(matches!(err, Error::FrameTooLarge), "got {err:?}"); - } - - /// draft-00 §5.2 bounds the whole frame at `max_frame_size`, header - /// included: a frame of exactly that size parses, and one byte more is a - /// frame-size violation rather than something to read into memory. - #[tokio::test] - async fn recv_qmux00_bounds_the_whole_frame() { - // STREAM with LEN (0x0a), stream id 2, a 2-byte length, then the payload. - let frame = |payload: usize| { - let mut frame = vec![0x0a, 0x02]; - frame.extend_from_slice(&(0x4000 | payload as u16).to_be_bytes()); - frame.resize(frame.len() + payload, 0x5a); - Bytes::from(frame) - }; - - let at_limit = frame(MAX_FRAME_SIZE - 4); - assert_eq!(at_limit.len(), MAX_FRAME_SIZE); - let over_limit = frame(MAX_FRAME_SIZE - 3); - assert_eq!(over_limit.len(), MAX_FRAME_SIZE + 1); - - let (client, mut server) = tokio::io::duplex(64 * 1024); - let (_writer, mut transport) = Stream::new(client).split(&crate::Config { - max_record_size: 0, - ..crate::Config::new(Version::QMux00) - }); - - server.write_all(&at_limit).await.unwrap(); - server.flush().await.unwrap(); - assert_eq!(transport.recv().await.unwrap(), at_limit); - - server.write_all(&over_limit).await.unwrap(); - server.flush().await.unwrap(); - let err = transport.recv().await.expect_err("FrameTooLarge expected"); - assert!(matches!(err, Error::FrameTooLarge), "got {err:?}"); - } - } -} - #[cfg(any(feature = "tcp", all(unix, feature = "uds")))] -pub use stream_transport::{Stream, StreamReader, StreamWriter}; - -// Shared plumbing for the byte-stream transports (TCP, Unix sockets). +mod stream; #[cfg(any(feature = "tcp", all(unix, feature = "uds")))] -mod stream_session { - use tokio::io::{AsyncRead, AsyncWrite}; - - use super::Stream; - use crate::protocol::validate_protocol; - use crate::{Config, Error, Protocol, Session}; - - /// Wrap a byte stream in a [`Stream`] and start a session, validating any - /// advertised protocol names first. Used by the `tcp`/`uds` builders. - pub(crate) async fn build( - stream: T, - config: Config, - is_server: bool, - socket_stats: Option, - ) -> Result { - if let Protocol::Negotiate(protocols) = &config.protocol { - for protocol in protocols { - validate_protocol(protocol)?; - } - } - let mut transport = Stream::new(stream); - if let Some(stats) = socket_stats { - transport = transport.with_socket_stats(stats); - } - if is_server { - Session::accept(transport, config).await - } else { - Session::connect(transport, config).await - } - } -} - +pub use stream::Stream; #[cfg(any(feature = "tcp", all(unix, feature = "uds")))] -pub(crate) use stream_session::build as build_stream_session; +pub(crate) use stream::build as build_stream_session; -// WsTransport: message I/O over WebSocket. #[cfg(feature = "ws")] -mod ws_transport { - use std::time::Duration; - - use bytes::Bytes; - use futures::stream::SplitSink; - use kio::clock::{Interval, MissedTickBehavior, Sleep}; - use tokio::sync::mpsc; - use tokio::task::JoinHandle; - use tokio_tungstenite::tungstenite; - - use super::{Reader, Transport, Writer}; - use crate::ws::KeepAlive; - use crate::{Error, MAX_FRAME_SIZE}; - - type Message = tungstenite::Message; - - /// Bound on WS frames the pump task reads ahead of the session. Small: the pump - /// only stages already-flow-control-covered frames; a full channel makes the - /// pump park on delivery, which is exactly the backpressure signal the deadline - /// logic keys off. Mirrors the byte-stream reader's `RECV_CHANNEL_CAPACITY`. - const WS_RECV_CHANNEL_CAPACITY: usize = 16; - - /// The combined `Stream + Sink` bound every WebSocket half requires. - pub(crate) trait WsStream: - futures::Stream> - + futures::Sink - + Unpin - + Send - + 'static - { - } - impl WsStream for T where - T: futures::Stream> - + futures::Sink - + Unpin - + Send - + 'static - { - } - - pub(crate) struct WsTransport { - ws: T, - keep_alive: Option, - socket_stats: Option, - address: moq_net::transport::Address, - } - - impl WsTransport { - pub fn new(ws: T, address: moq_net::transport::Address) -> Self { - Self { - ws, - keep_alive: None, - socket_stats: None, - address, - } - } - - /// Refuses a keep-alive that would close a healthy idle peer: a zero interval, or a - /// timeout no longer than the interval, which expires before the first Pong. - pub fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Result { - if keep_alive.interval.is_zero() { - return Err(Error::InvalidConfig("zero keep-alive interval")); - } - if keep_alive.timeout <= keep_alive.interval { - return Err(Error::InvalidConfig("keep-alive timeout not above its interval")); - } - self.keep_alive = Some(keep_alive); - Ok(self) - } - - pub fn with_socket_stats(mut self, stats: crate::SharedSocketStats) -> Self { - self.socket_stats = Some(stats); - self - } - } - - /// Writer-side keep-alive: emit a Ping every `interval`. - struct PingState { - // Fires on each interval; the writer sends a Ping when it does. - interval: Interval, - } - - impl PingState { - fn new(config: KeepAlive) -> Self { - // The first tick is one interval out, so we don't ping on connect. - let mut interval = kio::clock::interval_at(kio::clock::now() + config.interval, config.interval); - // Skip catch-up bursts after a long pause; we just want one Ping per tick. - interval.set_missed_tick_behavior(MissedTickBehavior::Delay); - Self { interval } - } - } - - /// Reader-side keep-alive: close the session if no frame arrives within `timeout`. - struct DeadlineState { - // Resets every time we receive a frame. If it elapses, the peer is gone. - deadline: Sleep, - timeout: Duration, - } - - impl DeadlineState { - fn new(config: KeepAlive) -> Self { - let timeout = config.timeout; - Self { - deadline: kio::clock::sleep(timeout), - timeout, - } - } - - fn observe_recv(&mut self) { - self.deadline.reset(kio::clock::now() + self.timeout); - } - } - - /// The send half of a [`WsTransport`]: owns the sink and drives keep-alive Pings. - pub(crate) struct WsWriter { - sink: SplitSink, - ping: Option, - } - - /// The receive half of a [`WsTransport`]. - /// - /// A dedicated pump task owns the socket and the idle deadline; `recv` is just a - /// hand-off from its channel. Reading on its own task is what makes the keep-alive - /// deadline robust: it advances (and resets on the peer's Pong replies) regardless - /// of where the *session* is parked — in particular while the session is wedged - /// handing a stream to a slow `accept_*`, which would otherwise starve a deadline - /// polled inline in `recv` and fire a spurious timeout. Mirrors [`StreamReader`]. - pub(crate) struct WsReader { - rx: mpsc::Receiver>, - /// Aborted on drop so the pump can't outlive the transport. - pump: JoinHandle<()>, - } - - impl Drop for WsReader { - fn drop(&mut self) { - self.pump.abort(); - } - } - - impl Transport for WsTransport { - type Writer = WsWriter; - type Reader = WsReader; - - fn socket_stats(&self) -> Option { - self.socket_stats.clone() - } - - fn address(&self) -> moq_net::transport::Address { - self.address - } - - fn split(self, config: &crate::Config) -> (WsWriter, WsReader) { - use futures::StreamExt; - // BiLock-backed halves: the sink and stream can be polled concurrently - // on separate tasks, briefly serializing on the shared socket. - let (sink, stream) = self.ws.split(); - let (ping, deadline) = match self.keep_alive { - Some(ka) => (Some(PingState::new(ka)), Some(DeadlineState::new(ka))), - None => (None, None), - }; - let (tx, rx) = mpsc::channel(WS_RECV_CHANNEL_CAPACITY); - let recv_limit = if config.version.uses_records() { - usize::try_from(config.max_record_size).unwrap_or(usize::MAX) - } else { - MAX_FRAME_SIZE - }; - let pump = tokio::spawn(ws_pump(stream, deadline, recv_limit, tx)); - (WsWriter { sink, ping }, WsReader { rx, pump }) - } - } - - impl Writer for WsWriter { - async fn send(&mut self, data: Bytes) -> Result<(), Error> { - use futures::SinkExt; - self.sink.send(Message::Binary(data)).await.map_err(|_| Error::Closed)?; - Ok(()) - } - - async fn feed(&mut self, data: Bytes) -> Result<(), Error> { - use futures::SinkExt; - // Still one WebSocket message per frame (the message boundary is the - // record boundary), but tungstenite only appends it to its write - // buffer. The socket write happens on `flush`, or early once the - // buffer passes `write_buffer_size` (128 KiB by default). - self.sink.feed(Message::Binary(data)).await.map_err(|_| Error::Closed)?; - Ok(()) - } - - async fn flush(&mut self) -> Result<(), Error> { - use futures::SinkExt; - self.sink.flush().await.map_err(|_| Error::Closed)?; - Ok(()) - } - - async fn close(&mut self) -> Result<(), Error> { - use futures::SinkExt; - self.sink.close().await.map_err(|_| Error::Closed)?; - Ok(()) - } - - async fn maintain(&mut self) -> Result<(), Error> { - use futures::SinkExt; - match &mut self.ping { - Some(ping) => { - // Wait for the next interval, then send one keep-alive Ping. The - // session's writer loop re-invokes this each time it resolves, so - // pings keep flowing without a dedicated task. tungstenite's - // auto-queued Pong replies (from the reader) also flush here. - ping.interval.tick().await; - self.sink - .send(Message::Ping(Bytes::new())) - .await - .map_err(|_| Error::Closed)?; - Ok(()) - } - // No keep-alive configured: never resolves, so the writer loop's - // select simply ignores this branch. - None => std::future::pending().await, - } - } - } - - impl Reader for WsReader { - async fn recv(&mut self) -> Result { - // The pump task pushes complete Binary frames (or a terminal error) - // here; `None` means it exited without sending — treat as a clean close. - self.rx.recv().await.unwrap_or(Err(Error::Closed)) - } - } - - /// Pump task: read WS messages off the socket independently of the session, - /// resetting the keep-alive deadline on every message (including the peer's Pong - /// replies) and shipping Binary frames to the session over `tx`. On a timeout, - /// clean close, or socket error it sends a terminal `Err` and exits; if `tx` is - /// closed (the transport was dropped) it exits silently. - /// - /// Why a task rather than an inline deadline in `recv`: the deadline must keep - /// advancing even while the session is parked (e.g. handing a stream to a slow - /// `accept_*`), and the peer's liveness must not be judged while we're merely - /// backpressured. So the deadline is reset both when a message arrives *and* - /// after each delivery to the session — a long delivery park (session - /// backpressure) therefore isn't charged against the peer. - async fn ws_pump( - mut stream: S, - mut deadline: Option, - recv_limit: usize, - tx: mpsc::Sender>, - ) where - S: futures::Stream> + Unpin + Send + 'static, - { - use futures::StreamExt; - - loop { - // Prefer a real message over the deadline (`biased`): if both are ready, - // the peer is alive, so don't spuriously time out. - let message = match &mut deadline { - Some(d) => tokio::select! { - biased; - msg = stream.next() => msg, - _ = &mut d.deadline => { - tracing::debug!("websocket keep_alive timeout"); - let _ = tx.send(Err(Error::Closed)).await; - return; - } - }, - None => stream.next().await, - }; - - // Any read — data or control — proves the peer is alive; reset the deadline. - if let Some(d) = deadline.as_mut() { - d.observe_recv(); - } - - let message = match message { - Some(Ok(message)) => message, - // The socket errored mid-stream: forward the specific WebSocket - // error (matching `StreamReader`, and preserving what the old inline - // `recv()` surfaced via `?`) rather than flattening it to a generic - // close, so callers can still distinguish e.g. a protocol fault. - Some(Err(err)) => { - let _ = tx.send(Err(err.into())).await; - return; - } - // Clean end of stream. - None => { - let _ = tx.send(Err(Error::Closed)).await; - return; - } - }; - - match message { - // The binding makes an empty message a no-op, unlike an empty record - Message::Binary(data) if data.is_empty() => continue, - Message::Binary(data) => { - if data.len() > recv_limit { - // Release the oversized allocation before waiting for room to - // report the terminal error. In particular, never let it enter - // the bounded record queue while the session is backpressured. - drop(data); - let _ = tx.send(Err(Error::FrameTooLarge)).await; - return; - } - if tx.send(Ok(data)).await.is_err() { - return; // session gone - } - // Delivered. Reset the deadline again so a long delivery park - // (the session backpressured on `accept_*`) isn't charged - // against the peer — we only judge liveness while actively - // reading, not while wedged handing data to the app. - if let Some(d) = deadline.as_mut() { - d.observe_recv(); - } - } - Message::Close(_) => { - let _ = tx.send(Err(Error::Closed)).await; - return; - } - // QMux rides binary messages only; text means the peer speaks something else - Message::Text(_) => { - let _ = tx.send(Err(Error::ProtocolViolation)).await; - return; - } - Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => { - // tungstenite auto-queues a Pong reply when it reads a Ping; the - // writer half flushes it on its next send/ping. The pump owns no - // sink, so there's nothing to reply with here. - continue; - } - } - } - } - - #[cfg(test)] - mod tests { - use std::time::Duration; - - use bytes::Bytes; - use tokio::sync::mpsc; - - use super::{DeadlineState, Message, ws_pump}; - use crate::Error; - use crate::ws::KeepAlive; - - type WsResult = Result; - - /// A `Stream` of WS messages backed by a `futures` channel. Holding the - /// returned sender keeps the stream "open but silent" (`next()` pends) when - /// empty; dropping it ends the stream. Standing in for a live peer. - fn mock_stream() -> ( - futures::channel::mpsc::UnboundedSender, - futures::channel::mpsc::UnboundedReceiver, - ) { - futures::channel::mpsc::unbounded() - } - - /// The pump must not spuriously time out a *healthy* peer just because the - /// session is too busy to drain it — the exact failure the split reader had, - /// where the keep-alive deadline was only polled inside `recv` and went stale - /// while the session parked handing a stream to a full `accept_*` channel. - /// - /// Here a capacity-1 channel stands in for that full `accept_*`: the pump - /// parks delivering the second frame while we sit idle well past the - /// keep-alive `timeout`, then we drain — and both frames must arrive intact, - /// with no `Error::Closed` from a phantom timeout. - #[tokio::test(start_paused = true)] - async fn survives_a_slow_consumer() { - let (feed, stream) = mock_stream(); - feed.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"one")))) - .unwrap(); - feed.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"two")))) - .unwrap(); - // Keep `feed` alive: the peer stays connected and silent after these two. - let _feed = feed; - - let ka = KeepAlive::new(Duration::from_millis(10), Duration::from_millis(50)); - let (tx, mut rx) = mpsc::channel(1); - let pump = tokio::spawn(ws_pump(stream, Some(DeadlineState::new(ka)), usize::MAX, tx)); - - // Model a session wedged on a full accept channel: don't read for well - // over the 50ms keep-alive timeout while the pump is parked on delivery. - kio::clock::sleep(Duration::from_millis(150)).await; - - assert_eq!( - rx.recv().await.expect("channel open").expect("no timeout"), - Bytes::from_static(b"one"), - ); - assert_eq!( - rx.recv().await.expect("channel open").expect("no timeout"), - Bytes::from_static(b"two"), - ); - - pump.abort(); - } - - /// The deadline must still fire for a genuinely silent peer: once the pump is - /// caught up and reading (not parked on delivery), `timeout` of silence is a - /// dead peer, surfaced as `Error::Closed`. - #[tokio::test(start_paused = true)] - async fn times_out_a_silent_peer() { - // Held open but never fed — `stream.next()` pends forever. - let (_feed, stream) = mock_stream(); - - let ka = KeepAlive::new(Duration::from_millis(10), Duration::from_millis(50)); - let (tx, mut rx) = mpsc::channel(4); - let pump = tokio::spawn(ws_pump(stream, Some(DeadlineState::new(ka)), usize::MAX, tx)); - - let result = kio::clock::timeout(Duration::from_secs(1), rx.recv()) - .await - .expect("keep-alive deadline should fire on a silent peer"); - assert!( - matches!(result, Some(Err(Error::Closed))), - "expected a keep-alive timeout, got {result:?}" - ); - - pump.abort(); - } - - /// With no keep-alive configured the pump never times out: a silent-but-open - /// peer just leaves `recv` pending rather than closing. - #[tokio::test(start_paused = true)] - async fn no_keep_alive_never_times_out() { - let (feed, stream) = mock_stream(); - let _feed = feed; - - let (tx, mut rx) = mpsc::channel(4); - let pump = tokio::spawn(ws_pump(stream, None, usize::MAX, tx)); - - // No deadline, so recv stays pending well past any keep-alive window. - let pending = kio::clock::timeout(Duration::from_millis(100), rx.recv()).await; - assert!(pending.is_err(), "recv must not resolve without activity"); - - pump.abort(); - } - - #[tokio::test] - async fn skips_empty_messages() { - let (feed, stream) = mock_stream(); - feed.unbounded_send(Ok(Message::Binary(Bytes::new()))).unwrap(); - feed.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"one")))) - .unwrap(); - let _feed = feed; - - let (tx, mut rx) = mpsc::channel(4); - let pump = tokio::spawn(ws_pump(stream, None, usize::MAX, tx)); - assert_eq!(rx.recv().await.unwrap().unwrap(), Bytes::from_static(b"one")); - pump.abort(); - } - - #[tokio::test] - async fn rejects_record_exceeding_max_before_queueing() { - let (feed, stream) = mock_stream(); - feed.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"oversized")))) - .unwrap(); - - let (tx, mut rx) = mpsc::channel(1); - let pump = tokio::spawn(ws_pump(stream, None, 4, tx)); - - assert!(matches!(rx.recv().await, Some(Err(Error::FrameTooLarge)))); - assert!(rx.recv().await.is_none(), "pump must stop after the violation"); - pump.await.unwrap(); - } - - #[tokio::test] - async fn rejects_a_text_message() { - let (feed, stream) = mock_stream(); - feed.unbounded_send(Ok(Message::Text("hello".into()))).unwrap(); - - let (tx, mut rx) = mpsc::channel(1); - let pump = tokio::spawn(ws_pump(stream, None, usize::MAX, tx)); - - assert!(matches!(rx.recv().await, Some(Err(Error::ProtocolViolation)))); - assert!(rx.recv().await.is_none(), "pump must stop after the violation"); - pump.await.unwrap(); - } - } -} - +mod websocket; +// Public for `Driver>`, though callers never need to name it. #[cfg(feature = "ws")] -pub(crate) use ws_transport::WsTransport; +pub(crate) use websocket::WsTransport; diff --git a/rs/qmux/src/transport/stream.rs b/rs/qmux/src/transport/stream.rs new file mode 100644 index 00000000000..8f6d32b88cd --- /dev/null +++ b/rs/qmux/src/transport/stream.rs @@ -0,0 +1,376 @@ +//! QMux record I/O over a reliable byte stream: TCP, TLS, or a Unix socket. + +use std::io; +use std::pin::Pin; +use std::task::{Poll, ready}; + +use bytes::{Buf, Bytes, BytesMut}; +use moq_quic::VarInt; +use tokio::io::{AsyncRead, AsyncWrite}; + +use super::Transport; +use crate::protocol::validate_protocol; +use crate::{Config, Driver, Error, MAX_FRAME_SIZE, Protocol, Session}; + +/// The size of each read buffer: one default record with its size field, or one draft-00 +/// frame. Records are sliced out of it rather than copied, and the session counts every slice +/// as pinning a whole buffer of this size. A larger record gets a buffer of its own size. +const READ_BUFFER: usize = MAX_FRAME_SIZE; + +/// The least room a read is worth a syscall for; a fuller buffer moves its partial record to +/// a fresh one first. +const MIN_READ: usize = 1024; + +/// Queued output past which [`Transport::poll_ready`] flushes before taking more. +const WRITE_BUFFER: usize = 64 * 1024; + +/// QMux record I/O over any reliable byte stream (`AsyncRead + AsyncWrite`). +/// +/// Delimits records itself, so the session sees each one whole. Pass it to +/// [`Session::connect`] or [`Session::accept`] to run QMux over a transport the built-in +/// `tcp`, `tls`, and `ws` helpers don't cover, such as a pipe, an in-memory duplex, or a +/// custom tunnel: +/// +/// ```no_run +/// # async fn f(stream: tokio::net::TcpStream) -> Result<(), qmux::Error> { +/// use qmux::transport::Stream; +/// use qmux::{Config, Session, Version}; +/// +/// let config = Config::new(Version::QMux01); +/// let (session, driver) = Session::connect(Stream::new(stream), config).await?; +/// tokio::spawn(driver); +/// # let _ = session; Ok(()) +/// # } +/// ``` +pub struct Stream { + io: T, + socket_stats: Option, + framing: Framing, + + /// Bytes read but not yet handed out; records are split off its front. + read: BytesMut, + /// How many bytes `read` must hold to complete the record at its front, once known. + need: usize, + /// The peer finished its send half, or reading it failed. + eof: bool, + + /// Records and their size fields, waiting for the socket. + write: Vec, + /// How much of `write` the socket already took. + written: usize, + /// Our send half is finished. + shutdown: bool, +} + +/// How records are delimited on the byte stream. +#[derive(Clone, Copy, Debug)] +enum Framing { + /// Records behind a Size field, refusing one larger than this (draft-01 and later) + Records(usize), + /// Self-delimiting frames (draft-00) + Frames, +} + +impl Framing { + fn new(config: &Config) -> Self { + match config.version.uses_records() { + true => Self::Records(usize::try_from(config.max_record_size).unwrap_or(usize::MAX)), + false => Self::Frames, + } + } +} + +impl Stream { + /// Wrap a byte stream; session establishment binds its framing and starts I/O. + pub fn new(io: T) -> Self { + Self { + io, + socket_stats: None, + framing: Framing::new(&Config::default()), + read: BytesMut::new(), + need: 0, + eof: false, + write: Vec::new(), + written: 0, + shutdown: false, + } + } + + /// Report statistics for the connection underneath this stream. + /// + /// `Stream` is generic over the byte stream, so it can't discover the socket on its own; + /// the builder that knows the concrete type attaches it. See `TcpStats` (Linux and macOS). + pub fn with_socket_stats(mut self, stats: crate::SharedSocketStats) -> Self { + self.socket_stats = Some(stats); + self + } + + /// Split the record at the front of the read buffer off, if it is all there. + fn parse(&mut self) -> Result, Error> { + let header = match self.framing { + Framing::Records(max) => peek_varint(&self.read) + .map(|(size, prefix)| { + let size = usize::try_from(size).map_err(|_| Error::FrameTooLarge)?; + if size > max { + return Err(Error::FrameTooLarge); + } + Ok((prefix + size, prefix)) + }) + .transpose()?, + Framing::Frames => frame_len(&self.read)?.map(|len| (len, 0)), + }; + let Some((total, prefix)) = header else { + self.need = self.read.len() + 1; + return Ok(None); + }; + if self.read.len() < total { + self.need = total; + return Ok(None); + } + let mut record = self.read.split_to(total); + record.advance(prefix); + Ok(Some(record.freeze())) + } + + /// Read more of the peer's bytes into the read buffer, setting `eof` once it finished. + fn poll_fill(&mut self, waiter: &kio::Waiter) -> Poll> { + // What the record at the front still needs, which this buffer must have room for. + let need = self.need.saturating_sub(self.read.len()).max(1); + let want = need.max(MIN_READ); + // Reuse the buffer in place once no record still points into it; otherwise move the + // partial record to a fresh one, sized to the record when it is larger than usual. + if self.read.capacity() - self.read.len() < want && !self.read.try_reclaim(want) { + let mut fresh = BytesMut::with_capacity(READ_BUFFER.max(self.read.len() + need)); + fresh.extend_from_slice(&self.read); + self.read = fresh; + } + let mut cx = waiter.context(); + match ready!(tokio_util::io::poll_read_buf( + Pin::new(&mut self.io), + &mut cx, + &mut self.read + )) { + Ok(0) => self.eof = true, + Ok(_) => {} + Err(err) => { + self.eof = true; + return Poll::Ready(Err(err.into())); + } + } + Poll::Ready(Ok(())) + } +} + +impl Transport for Stream { + fn bind(&mut self, config: &Config) { + self.framing = Framing::new(config); + } + + fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll> { + loop { + if let Some(record) = self.parse()? { + return Poll::Ready(Ok(record)); + } + if self.eof { + // The peer finished mid-record, or without the close frame that ends a session. + return Poll::Ready(Err(io::Error::from(io::ErrorKind::UnexpectedEof).into())); + } + ready!(self.poll_fill(waiter))?; + } + } + + fn poll_ready(&mut self, waiter: &kio::Waiter) -> Poll> { + if self.write.len() >= WRITE_BUFFER { + ready!(self.poll_flush(waiter))?; + } + Poll::Ready(Ok(())) + } + + fn send(&mut self, record: Bytes) -> Result<(), Error> { + // Record-framed drafts travel inside size-prefixed records on byte streams. (Records + // are implicit on WebSocket, where the message boundary delimits them.) + if let Framing::Records(_) = self.framing { + VarInt::try_from(record.len()) + .map_err(|_| Error::FrameTooLarge)? + .encode(&mut self.write); + } + self.write.extend_from_slice(&record); + Ok(()) + } + + fn poll_flush(&mut self, waiter: &kio::Waiter) -> Poll> { + let mut cx = waiter.context(); + while self.written < self.write.len() { + let n = ready!(Pin::new(&mut self.io).poll_write(&mut cx, &self.write[self.written..]))?; + if n == 0 { + return Poll::Ready(Err(io::Error::from(io::ErrorKind::WriteZero).into())); + } + self.written += n; + } + self.write.clear(); + self.written = 0; + ready!(Pin::new(&mut self.io).poll_flush(&mut cx))?; + Poll::Ready(Ok(())) + } + + fn poll_close(&mut self, waiter: &kio::Waiter) -> Poll> { + ready!(self.poll_flush(waiter))?; + if !self.shutdown { + ready!(Pin::new(&mut self.io).poll_shutdown(&mut waiter.context()))?; + self.shutdown = true; + } + // Closing a TCP socket with unread peer data can reset it and discard our close, so + // discard what the peer still sends until it finishes too. + while !self.eof { + self.read.clear(); + self.need = 0; + ready!(self.poll_fill(waiter))?; + } + Poll::Ready(Ok(())) + } + + fn socket_stats(&self) -> Option { + self.socket_stats.clone() + } +} + +/// The varint at the front of `buf` and its encoded length, or `None` while it is incomplete. +fn peek_varint(buf: &[u8]) -> Option<(u64, usize)> { + let first = *buf.first()?; + let len = 1usize << (first >> 6); + let bytes = buf.get(..len)?; + let mut raw = [0u8; 8]; + raw[8 - len..].copy_from_slice(bytes); + raw[8 - len] &= 0x3f; + Some((u64::from_be_bytes(raw), len)) +} + +/// A peer-sent length as `usize`, refused where it does not fit (32-bit targets) +fn peer_len(value: u64) -> Result { + usize::try_from(value).map_err(|_| Error::FrameTooLarge) +} + +/// Walks a draft-00 frame header. +struct Header<'a> { + buf: &'a [u8], + pos: usize, +} + +impl Header<'_> { + /// The next varint, or `None` while the header is incomplete. + fn varint(&mut self) -> Option { + let (value, len) = peek_varint(&self.buf[self.pos..])?; + self.pos += len; + Some(value) + } + + /// Skip `count` varints. + fn skip(&mut self, count: usize) -> Option<()> { + for _ in 0..count { + self.varint()?; + } + Some(()) + } + + /// A length-prefixed body: the frame's whole length, refused past `max_frame_size`. + /// + /// draft-00 §5.2 bounds the whole frame, header included. A byte stream cannot + /// resynchronize past an oversized frame, so this is fatal, as the draft requires + /// (FRAME_ENCODING_ERROR). + fn body(&mut self) -> Result, Error> { + let Some(len) = self.varint() else { + return Ok(None); + }; + let total = self.pos.saturating_add(peer_len(len)?); + if total > MAX_FRAME_SIZE { + return Err(Error::FrameTooLarge); + } + Ok(Some(total)) + } +} + +/// The whole length of the draft-00 frame at the front of `buf`, once its header is there. +fn frame_len(buf: &[u8]) -> Result, Error> { + let mut header = Header { buf, pos: 0 }; + let Some(frame_type) = header.varint() else { + return Ok(None); + }; + + // STREAM frames: 0x08-0x0f + if (0x08..=0x0f).contains(&frame_type) { + let has_off = frame_type & 0x04 != 0; + let has_len = frame_type & 0x02 != 0; + // A byte stream cannot delimit a STREAM frame without its length. + if !has_len { + return Err(Error::FrameEncoding); + } + if header.skip(1 + usize::from(has_off)).is_none() { + return Ok(None); + } + return header.body(); + } + + let fixed = match frame_type { + // PADDING + 0x00 => 0, + // RESET_STREAM: id, code, final size + 0x04 => 3, + // STOP_SENDING: id, code + 0x05 => 2, + // CONNECTION_CLOSE (0x1c) carries the Frame Type field; APPLICATION_CLOSE (0x1d) omits + // it (RFC 9000 §19.19). Then the reason phrase. + 0x1c | 0x1d => { + let fields = if frame_type == 0x1c { 2 } else { 1 }; + if header.skip(fields).is_none() { + return Ok(None); + } + return header.body(); + } + // MAX_DATA, MAX_STREAMS, DATA_BLOCKED, STREAMS_BLOCKED + 0x10 | 0x12 | 0x13 | 0x14 | 0x16 | 0x17 => 1, + // MAX_STREAM_DATA, STREAM_DATA_BLOCKED: id, limit + 0x11 | 0x15 => 2, + // QX_TRANSPORT_PARAMETERS + 0x3f5153300d0a0d0a => return header.body(), + // Draft-00 has no datagrams or QX_PING, and an unknown type cannot be skipped on a byte + // stream. + _ => return Err(Error::FrameEncoding), + }; + Ok(header.skip(fixed).map(|()| header.pos)) +} + +/// Wrap a byte stream in a [`Stream`] and start a session, validating any advertised protocol +/// names first. Used by the `tcp` and `uds` builders. +pub(crate) async fn build( + io: T, + config: Config, + is_server: bool, + socket_stats: Option, +) -> Result<(Session, Driver>), Error> { + if let Protocol::Negotiate(protocols) = &config.protocol { + for protocol in protocols { + validate_protocol(protocol)?; + } + } + let mut transport = Stream::new(io); + if let Some(stats) = socket_stats { + transport = transport.with_socket_stats(stats); + } + match is_server { + true => Session::accept(transport, config).await, + false => Session::connect(transport, config).await, + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +fn bound(io: T, version: crate::Version, max_record_size: u64) -> Stream { + let mut stream = Stream::new(io); + stream.bind(&Config { + max_record_size, + ..Config::new(version) + }); + stream +} diff --git a/rs/qmux/src/transport/stream/tests.rs b/rs/qmux/src/transport/stream/tests.rs new file mode 100644 index 00000000000..7b6420c77ea --- /dev/null +++ b/rs/qmux/src/transport/stream/tests.rs @@ -0,0 +1,188 @@ +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; + +use super::*; +use crate::Version; + +async fn recv(stream: &mut Stream) -> Result { + kio::wait(|waiter| stream.poll_recv(waiter)).await +} + +/// Poll `recv` once, for a record that must not be complete yet. +fn try_recv(stream: &mut Stream) -> Poll> { + stream.poll_recv(&kio::Waiter::noop()) +} + +#[test] +fn wrapping_a_stream_does_not_start_io() { + let (io, _peer) = tokio::io::duplex(64); + let _transport = Stream::new(io); +} + +#[tokio::test(start_paused = true)] +async fn records_carry_their_size_field() { + let (io, mut peer) = tokio::io::duplex(1024); + let mut stream = bound(io, Version::QMux02, 16 * 1024); + stream.send(Bytes::from_static(b"hello")).unwrap(); + stream.send(Bytes::from(vec![7; 100])).unwrap(); + kio::wait(|waiter| stream.poll_flush(waiter)).await.unwrap(); + + let mut wire = vec![0; 1 + 5 + 2 + 100]; + peer.read_exact(&mut wire).await.unwrap(); + assert_eq!(&wire[..6], b"\x05hello"); + assert_eq!(&wire[6..8], &[0x40, 100]); + assert!(wire[8..].iter().all(|&b| b == 7)); +} + +/// A record dripping in one byte at a time stays pending until its last byte, then arrives +/// whole. +#[tokio::test] +async fn a_record_split_across_reads_arrives_whole() { + for (version, wire, payload) in [ + // 1-byte size (0x08) followed by 8 bytes of payload. + (Version::QMux01, &b"\x08abcdefgh"[..], &b"abcdefgh"[..]), + // STREAM with LEN (0x0a), id 4, length 5, "hello": draft-00 hands over the frame. + (Version::QMux00, &b"\x0a\x04\x05hello"[..], &b"\x0a\x04\x05hello"[..]), + ] { + let (io, mut peer) = tokio::io::duplex(64 * 1024); + let mut stream = bound(io, version, 16 * 1024); + for byte in &wire[..wire.len() - 1] { + peer.write_all(&[*byte]).await.unwrap(); + tokio::task::yield_now().await; + assert!( + try_recv(&mut stream).is_pending(), + "{version:?}: a partial record arrived" + ); + } + peer.write_all(&wire[wire.len() - 1..]).await.unwrap(); + assert_eq!(recv(&mut stream).await.unwrap(), payload, "{version:?}"); + } +} + +/// Records that share one read come out in order, as slices of that read. +#[tokio::test] +async fn records_in_one_read_arrive_in_order() { + let (io, mut peer) = tokio::io::duplex(64 * 1024); + let mut stream = bound(io, Version::QMux02, 16 * 1024); + peer.write_all(b"\x05hello\x05world").await.unwrap(); + + let first = recv(&mut stream).await.unwrap(); + let second = recv(&mut stream).await.unwrap(); + assert_eq!(first, &b"hello"[..]); + assert_eq!(second, &b"world"[..]); + // Both point into the one read buffer rather than copies of it. + assert_eq!(first.as_ptr().wrapping_add(6), second.as_ptr()); +} + +/// A record larger than the read buffer, allowed by a raised `max_record_size`, gets a buffer +/// of its own. +#[tokio::test] +async fn a_record_larger_than_the_read_buffer_arrives_whole() { + let (io, mut peer) = tokio::io::duplex(1 << 20); + let size = 3 * READ_BUFFER; + let mut stream = bound(io, Version::QMux02, size as u64); + let payload: Vec = (0..size).map(|i| i as u8).collect(); + let mut wire = Vec::new(); + VarInt::try_from(size).unwrap().encode(&mut wire); + wire.extend_from_slice(&payload); + // A small record first, so the large one starts partway into a buffer. + peer.write_all(b"\x01x").await.unwrap(); + peer.write_all(&wire).await.unwrap(); + + assert_eq!(recv(&mut stream).await.unwrap(), &b"x"[..]); + assert_eq!(recv(&mut stream).await.unwrap(), payload); +} + +/// A record declaring more than `max_record_size` is refused at its header, before any of +/// its payload is read. +#[tokio::test] +async fn a_record_past_the_maximum_is_refused_at_its_header() { + let (io, mut peer) = tokio::io::duplex(64 * 1024); + let mut stream = bound(io, Version::QMux01, 16382); + peer.write_all(&[0x7f, 0xff]).await.unwrap(); + assert!(matches!(recv(&mut stream).await, Err(Error::FrameTooLarge))); +} + +/// draft-00 §5.2 bounds the whole frame at `max_frame_size`, header included: a frame of +/// exactly that size parses, and one byte more is a frame-size violation rather than something +/// to read into memory. +#[tokio::test] +async fn a_draft_00_frame_is_bounded_whole() { + // STREAM with LEN (0x0a), stream id 2, a 2-byte length, then the payload. + let frame = |payload: usize| { + let mut frame = vec![0x0a, 0x02]; + frame.extend_from_slice(&(0x4000 | payload as u16).to_be_bytes()); + frame.resize(frame.len() + payload, 0x5a); + Bytes::from(frame) + }; + let at_limit = frame(MAX_FRAME_SIZE - 4); + assert_eq!(at_limit.len(), MAX_FRAME_SIZE); + let over_limit = frame(MAX_FRAME_SIZE - 3); + + let (io, mut peer) = tokio::io::duplex(64 * 1024); + let mut stream = bound(io, Version::QMux00, 0); + peer.write_all(&at_limit).await.unwrap(); + assert_eq!(recv(&mut stream).await.unwrap(), at_limit); + + peer.write_all(&over_limit).await.unwrap(); + assert!(matches!(recv(&mut stream).await, Err(Error::FrameTooLarge))); +} + +/// draft-00 frames a byte stream cannot delimit are refused: an unknown type, and STREAM +/// without its Length field. +#[tokio::test] +async fn an_undelimited_draft_00_frame_is_refused() { + for wire in [&[0x02][..], &[0x08, 0x04]] { + let (io, mut peer) = tokio::io::duplex(64); + let mut stream = bound(io, Version::QMux00, 0); + peer.write_all(wire).await.unwrap(); + assert!(matches!(recv(&mut stream).await, Err(Error::FrameEncoding)), "{wire:?}"); + } +} + +/// Closing finishes our send half, then keeps reading until the peer finishes its own, since +/// closing a TCP socket with unread data can reset it and discard our close. +#[tokio::test(start_paused = true)] +async fn closing_drains_the_peer_until_it_finishes() { + for &version in Version::ALL { + let (io, mut peer) = tokio::io::duplex(64); + let mut stream = bound(io, version, 16 * 1024); + let mut close = std::pin::pin!(kio::wait(|waiter| stream.poll_close(waiter))); + let mut extra = [0]; + tokio::select! { + biased; + result = &mut close => panic!("{version:?}: closed before the peer finished: {result:?}"), + result = peer.read(&mut extra) => assert_eq!(result.unwrap(), 0), + } + + // More than the pipe holds proves the stream is draining rather than merely open. + let finish = async { + peer.write_all(&[0; 1024]).await.expect("drained"); + peer.shutdown().await.expect("peer FIN"); + }; + let (result, ()) = tokio::join!(close, finish); + result.expect("graceful close"); + } +} + +/// A framing error ends the receive side, but closing still drains what the peer sends. +#[tokio::test(start_paused = true)] +async fn closing_after_a_framing_error_still_drains_the_peer() { + let (io, mut peer) = tokio::io::duplex(64); + let mut stream = bound(io, Version::QMux02, 16382); + peer.write_all(&[0x7f, 0xff]).await.unwrap(); + assert!(matches!(recv(&mut stream).await, Err(Error::FrameTooLarge))); + + let mut close = std::pin::pin!(kio::wait(|waiter| stream.poll_close(waiter))); + let mut extra = [0]; + tokio::select! { + biased; + result = &mut close => panic!("closed before the malformed peer finished: {result:?}"), + result = peer.read(&mut extra) => assert_eq!(result.unwrap(), 0), + } + let finish = async { + peer.write_all(&[0; 1024]).await.expect("drained"); + peer.shutdown().await.expect("peer FIN"); + }; + let (result, ()) = tokio::join!(close, finish); + result.expect("graceful close"); +} diff --git a/rs/qmux/src/transport/websocket.rs b/rs/qmux/src/transport/websocket.rs new file mode 100644 index 00000000000..2a6c6180f45 --- /dev/null +++ b/rs/qmux/src/transport/websocket.rs @@ -0,0 +1,186 @@ +//! QMux record I/O over WebSocket: one Binary message per record. + +use std::future::Future; +use std::pin::Pin; +use std::task::{Poll, ready}; +use std::time::Duration; + +use bytes::Bytes; +use futures::{Sink, Stream}; +use kio::clock::{Interval, MissedTickBehavior, Sleep}; +use tokio_tungstenite::tungstenite; + +use super::Transport; +use crate::ws::KeepAlive; +use crate::{Config, Error, MAX_FRAME_SIZE}; + +type Message = tungstenite::Message; + +/// The `Stream + Sink` bound a WebSocket needs to carry a session. +pub trait WsStream: + Stream> + Sink + Unpin +{ +} +impl WsStream for T where + T: Stream> + Sink + Unpin +{ +} + +/// A WebSocket carrying a session, with its keep-alive. +pub struct WsTransport { + ws: T, + keep_alive: Option, + /// Sends a Ping on each tick, once bound. + ping: Option, + /// A Ping is due but the socket had no room for it yet. + ping_due: bool, + /// Reset by every message; the peer is gone once it elapses. + deadline: Option<(Sleep, Duration)>, + /// The largest message the session accepts. + recv_limit: usize, + socket_stats: Option, + address: moq_net::transport::Address, +} + +impl WsTransport { + pub(crate) fn new(ws: T, address: moq_net::transport::Address) -> Self { + Self { + ws, + keep_alive: None, + ping: None, + ping_due: false, + deadline: None, + recv_limit: MAX_FRAME_SIZE, + socket_stats: None, + address, + } + } + + /// Refuses a keep-alive that would close a healthy idle peer: a zero interval, or a + /// timeout no longer than the interval, which expires before the first Pong. + pub(crate) fn with_keep_alive(mut self, keep_alive: KeepAlive) -> Result { + if keep_alive.interval.is_zero() { + return Err(Error::InvalidConfig("zero keep-alive interval")); + } + if keep_alive.timeout <= keep_alive.interval { + return Err(Error::InvalidConfig("keep-alive timeout not above its interval")); + } + self.keep_alive = Some(keep_alive); + Ok(self) + } + + pub(crate) fn with_socket_stats(mut self, stats: crate::SharedSocketStats) -> Self { + self.socket_stats = Some(stats); + self + } + + /// Queue a keep-alive Ping once one is due and the socket has room; the driver's next flush + /// sends it. + fn poll_ping(&mut self, waiter: &kio::Waiter) -> Result<(), Error> { + let Some(ping) = &mut self.ping else { + return Ok(()); + }; + let mut cx = waiter.context(); + while ping.poll_tick(&mut cx).is_ready() { + self.ping_due = true; + } + if self.ping_due && Pin::new(&mut self.ws).poll_ready(&mut cx).is_ready() { + Pin::new(&mut self.ws) + .start_send(Message::Ping(Bytes::new())) + .map_err(|_| Error::Closed)?; + self.ping_due = false; + } + Ok(()) + } +} + +impl Transport for WsTransport { + fn bind(&mut self, config: &Config) { + self.recv_limit = match config.version.uses_records() { + true => usize::try_from(config.max_record_size).unwrap_or(usize::MAX), + false => MAX_FRAME_SIZE, + }; + if let Some(keep_alive) = self.keep_alive { + // The first tick is one interval out, so we don't ping on connect. A long pause + // sends one Ping, not a catch-up burst. + let mut ping = kio::clock::interval_at(kio::clock::now() + keep_alive.interval, keep_alive.interval); + ping.set_missed_tick_behavior(MissedTickBehavior::Delay); + self.ping = Some(ping); + self.deadline = Some((kio::clock::sleep(keep_alive.timeout), keep_alive.timeout)); + } + } + + fn poll_recv(&mut self, waiter: &kio::Waiter) -> Poll> { + self.poll_ping(waiter)?; + + let mut cx = waiter.context(); + // Prefer a message over the deadline: if both are ready, the peer is alive. + while let Poll::Ready(message) = Pin::new(&mut self.ws).poll_next(&mut cx) { + // Any message, data or control, proves the peer is alive. + if let Some((sleep, timeout)) = &mut self.deadline { + sleep.reset(kio::clock::now() + *timeout); + } + let message = match message { + Some(Ok(message)) => message, + // Keep the WebSocket error, so callers can still tell a protocol fault apart. + Some(Err(err)) => return Poll::Ready(Err(err.into())), + None => return Poll::Ready(Err(Error::Closed)), + }; + match message { + // The binding makes an empty message a no-op, unlike an empty record. + Message::Binary(data) if data.is_empty() => {} + Message::Binary(data) if data.len() > self.recv_limit => return Poll::Ready(Err(Error::FrameTooLarge)), + Message::Binary(data) => return Poll::Ready(Ok(data)), + Message::Close(_) => return Poll::Ready(Err(Error::Closed)), + // QMux rides binary messages only; text means the peer speaks something else. + Message::Text(_) => return Poll::Ready(Err(Error::ProtocolViolation)), + // tungstenite queues a Pong reply when it reads a Ping; the next flush sends it. + Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {} + } + } + + if let Some((sleep, _)) = &mut self.deadline { + ready!(Pin::new(sleep).poll(&mut cx)); + tracing::debug!("websocket keep_alive timeout"); + return Poll::Ready(Err(Error::Closed)); + } + Poll::Pending + } + + fn poll_ready(&mut self, waiter: &kio::Waiter) -> Poll> { + Pin::new(&mut self.ws) + .poll_ready(&mut waiter.context()) + .map_err(|_| Error::Closed) + } + + fn send(&mut self, record: Bytes) -> Result<(), Error> { + // Still one message per record, since the message boundary is the record boundary, but + // tungstenite only appends it to its write buffer until the flush. + Pin::new(&mut self.ws) + .start_send(Message::Binary(record)) + .map_err(|_| Error::Closed) + } + + fn poll_flush(&mut self, waiter: &kio::Waiter) -> Poll> { + Pin::new(&mut self.ws) + .poll_flush(&mut waiter.context()) + .map_err(|_| Error::Closed) + } + + fn poll_close(&mut self, waiter: &kio::Waiter) -> Poll> { + Pin::new(&mut self.ws) + .poll_close(&mut waiter.context()) + .map_err(|_| Error::Closed) + } + + fn socket_stats(&self) -> Option { + self.socket_stats.clone() + } + + fn address(&self) -> moq_net::transport::Address { + self.address + } +} + +#[cfg(test)] +mod tests; diff --git a/rs/qmux/src/transport/websocket/tests.rs b/rs/qmux/src/transport/websocket/tests.rs new file mode 100644 index 00000000000..1194fe65902 --- /dev/null +++ b/rs/qmux/src/transport/websocket/tests.rs @@ -0,0 +1,148 @@ +use std::time::Duration; + +use futures::StreamExt; + +use super::*; + +type WsResult = Result; + +/// A WebSocket whose peer is a channel. Holding the sender keeps it open but silent; dropping +/// it ends the stream. Sent messages are kept for inspection. +struct Mock { + rx: futures::channel::mpsc::UnboundedReceiver, + sent: Vec, +} + +impl Stream for Mock { + type Item = WsResult; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll> { + self.rx.poll_next_unpin(cx) + } +} + +impl Sink for Mock { + type Error = tungstenite::Error; + + fn poll_ready(self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(mut self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> { + self.sent.push(message); + Ok(()) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +fn transport( + keep_alive: Option, + max_record_size: u64, +) -> (futures::channel::mpsc::UnboundedSender, WsTransport) { + let (tx, rx) = futures::channel::mpsc::unbounded(); + let mut transport = WsTransport::new(Mock { rx, sent: Vec::new() }, moq_net::transport::Address::Uri); + if let Some(keep_alive) = keep_alive { + transport = transport.with_keep_alive(keep_alive).unwrap(); + } + transport.bind(&Config { + max_record_size, + ..Config::default() + }); + (tx, transport) +} + +async fn recv(transport: &mut WsTransport) -> Result { + kio::wait(|waiter| transport.poll_recv(waiter)).await +} + +fn try_recv(transport: &mut WsTransport) -> Poll> { + transport.poll_recv(&kio::Waiter::noop()) +} + +const KEEP_ALIVE: KeepAlive = KeepAlive { + interval: Duration::from_millis(10), + timeout: Duration::from_millis(50), +}; + +/// A silent peer is gone once the keep-alive timeout passes. +#[tokio::test(start_paused = true)] +async fn times_out_a_silent_peer() { + let (_peer, mut transport) = transport(Some(KEEP_ALIVE), 16382); + let result = kio::clock::timeout(Duration::from_secs(1), recv(&mut transport)) + .await + .expect("the keep-alive deadline should fire on a silent peer"); + assert!(matches!(result, Err(Error::Closed)), "got {result:?}"); +} + +/// Every message the peer sends, control included, restarts its deadline. +#[tokio::test(start_paused = true)] +async fn any_message_keeps_the_peer_alive() { + let (peer, mut transport) = transport(Some(KEEP_ALIVE), 16382); + for _ in 0..4 { + kio::clock::sleep(Duration::from_millis(40)).await; + peer.unbounded_send(Ok(Message::Pong(Bytes::new()))).unwrap(); + let pending = kio::clock::timeout(Duration::from_millis(1), recv(&mut transport)).await; + assert!(pending.is_err(), "a live peer timed out"); + } +} + +/// The keep-alive pings once per interval, the first one interval after start. +#[tokio::test(start_paused = true)] +async fn pings_each_interval() { + let (_peer, mut transport) = transport(Some(KEEP_ALIVE), 16382); + let pings = |transport: &WsTransport| { + transport + .ws + .sent + .iter() + .filter(|message| matches!(message, Message::Ping(_))) + .count() + }; + assert!(try_recv(&mut transport).is_pending()); + assert_eq!(pings(&transport), 0, "pinged on connect"); + kio::clock::sleep(Duration::from_millis(25)).await; + assert!(try_recv(&mut transport).is_pending()); + assert_eq!(pings(&transport), 1, "one Ping per missed tick, not a burst"); + kio::clock::sleep(Duration::from_millis(10)).await; + assert!(try_recv(&mut transport).is_pending()); + assert_eq!(pings(&transport), 2); +} + +/// Without a keep-alive, a silent but open peer just leaves `recv` pending. +#[tokio::test(start_paused = true)] +async fn no_keep_alive_never_times_out() { + let (_peer, mut transport) = transport(None, 16382); + let pending = kio::clock::timeout(Duration::from_secs(60), recv(&mut transport)).await; + assert!(pending.is_err(), "recv must not resolve without activity"); +} + +#[tokio::test] +async fn skips_empty_messages() { + let (peer, mut transport) = transport(None, 16382); + peer.unbounded_send(Ok(Message::Binary(Bytes::new()))).unwrap(); + peer.unbounded_send(Ok(Message::Binary(Bytes::from_static(b"one")))) + .unwrap(); + assert_eq!(recv(&mut transport).await.unwrap(), &b"one"[..]); +} + +#[tokio::test] +async fn refuses_a_message_past_one_record() { + let (peer, mut transport) = transport(None, 16382); + peer.unbounded_send(Ok(Message::Binary(Bytes::from(vec![0; 16383])))) + .unwrap(); + assert!(matches!(recv(&mut transport).await, Err(Error::FrameTooLarge))); +} + +#[tokio::test] +async fn refuses_a_text_message() { + let (peer, mut transport) = transport(None, 16382); + peer.unbounded_send(Ok(Message::Text("hello".into()))).unwrap(); + assert!(matches!(recv(&mut transport).await, Err(Error::ProtocolViolation))); +} diff --git a/rs/qmux/src/uds.rs b/rs/qmux/src/uds.rs index 643fdc5fef4..c9178c8f837 100644 --- a/rs/qmux/src/uds.rs +++ b/rs/qmux/src/uds.rs @@ -1,23 +1,27 @@ use std::path::Path; use tokio::net::UnixStream; -use crate::transport::build_stream_session; -use crate::{Config, Error, Session}; +use crate::transport::{Stream, build_stream_session}; +use crate::{Config, Driver, Error, Session}; /// Connect a QMux client over a Unix socket with one session configuration. /// /// A Unix socket negotiates its application protocol in-band through `Config::protocol`. -pub async fn connect(path: impl AsRef, config: Config) -> Result { +pub async fn connect(path: impl AsRef, config: Config) -> Result<(Session, Driver>), Error> { let stream = UnixStream::connect(path).await?; finish(stream, config, false).await } /// Accept a QMux session; drive each handshake independently of the listener loop. -pub async fn accept(stream: UnixStream, config: Config) -> Result { +pub async fn accept(stream: UnixStream, config: Config) -> Result<(Session, Driver>), Error> { finish(stream, config, true).await } -async fn finish(stream: UnixStream, config: crate::Config, is_server: bool) -> Result { +async fn finish( + stream: UnixStream, + config: crate::Config, + is_server: bool, +) -> Result<(Session, Driver>), Error> { // `build_stream_session` awaits the peer's transport parameters before // returning, so `protocol()` is resolved on the session we hand back // (bounded by the config's handshake timeout). diff --git a/rs/qmux/src/ws.rs b/rs/qmux/src/ws.rs index 1aeedd88928..6c3a56a12b0 100644 --- a/rs/qmux/src/ws.rs +++ b/rs/qmux/src/ws.rs @@ -6,8 +6,9 @@ use crate::protocol::validate_protocol; use crate::transport::WsTransport; use moq_net::transport::Address; use moq_quic::Side; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; -use crate::{Config, Error, Session, Version, alpn}; +use crate::{Config, Driver, Error, Session, Version, alpn}; /// The `tokio-tungstenite` version this crate is built against. /// @@ -102,9 +103,7 @@ impl Upgraded where T: futures::Stream> + futures::Sink - + Unpin - + Send - + 'static, + + Unpin, { /// Wrap `ws`, whose handshake negotiated the `Sec-WebSocket-Protocol` value `alpn`. pub fn new(ws: T, alpn: &str) -> Self { @@ -147,24 +146,24 @@ where self } - /// Wrap as a client-side session. + /// Wrap as a client-side session, returned with the [`Driver`] to spawn. /// /// The protocol is already known from the negotiated subprotocol (ALPN), so /// this returns synchronously without awaiting in-band parameters. Fails when /// the subprotocol names no QMux version. - pub fn connect(self) -> Result { + pub fn connect(self) -> Result<(Session, Driver>), Error> { self.start(Side::Client) } - /// Wrap as a server-side session. + /// Wrap as a server-side session, returned with the [`Driver`] to spawn. /// /// As with [`connect`](Self::connect), the protocol is known from the /// negotiated subprotocol, so this returns synchronously. - pub fn accept(self) -> Result { + pub fn accept(self) -> Result<(Session, Driver>), Error> { self.start(Side::Server) } - fn start(self, side: Side) -> Result { + fn start(self, side: Side) -> Result<(Session, Driver>), Error> { let (version, protocol) = alpn::parse(Some(&self.alpn))?; let config = self.session.clone().with_negotiated(version, protocol); let transport = self.into_transport()?; @@ -269,7 +268,16 @@ impl Client { } /// Connect to a WebSocket server, negotiating an advertised `(alpn, version)`. - pub async fn connect(&self, url: &str) -> Result { + pub async fn connect( + &self, + url: &str, + ) -> Result< + ( + Session, + Driver>>>, + ), + Error, + > { use tungstenite::{client::IntoClientRequest, http}; for (a, _) in &self.protocols { @@ -399,10 +407,10 @@ impl Server { /// /// Disable Nagle on a TCP `socket` first (`set_nodelay(true)`): records are /// already batched, so Nagle would only hold a batch's tail for the peer's ACK. - pub async fn accept( + pub async fn accept( &self, socket: T, - ) -> Result { + ) -> Result<(Session, Driver>>), Error> { use std::sync::{Arc, Mutex}; use tungstenite::{handshake::server, http}; @@ -514,7 +522,8 @@ mod tests { ) .await .unwrap(); - let session = accept.await.unwrap().unwrap(); + let (session, driver) = accept.await.unwrap().unwrap(); + tokio::spawn(driver); // A masked binary frame declaring 1 MiB, with none of its payload sent. let mut frame = vec![0x82, 0x80 | 127]; diff --git a/rs/qmux/tests/interop.rs b/rs/qmux/tests/interop.rs index 8b5200df27a..e1637f072b5 100644 --- a/rs/qmux/tests/interop.rs +++ b/rs/qmux/tests/interop.rs @@ -42,16 +42,22 @@ async fn ours_and_theirs(version: Version, ours_is_client: bool) -> (Session, qm Session::connect(ours_transport, ours), qmux06::Session::accept(theirs_transport, theirs) ); - (ours.unwrap(), theirs.unwrap()) + (spawn(ours.unwrap()), theirs.unwrap()) } else { let (ours, theirs) = tokio::join!( Session::accept(ours_transport, ours), qmux06::Session::connect(theirs_transport, theirs) ); - (ours.unwrap(), theirs.unwrap()) + (spawn(ours.unwrap()), theirs.unwrap()) } } +/// Spawn our session's driver, returning the session. +fn spawn((session, driver): (Session, qmux::Driver)) -> Session { + tokio::spawn(driver); + session +} + async fn write_all(send: &mut qmux::SendStream, mut data: Bytes) { while !data.is_empty() { send.write_buf(&mut data).await.unwrap(); From 075c410085d099fe6be1bdd79dd288565973459b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:24:17 -0800 Subject: [PATCH 10/86] bench(auth): attribute JWT verification cost and run it nightly (#7512) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- Cargo.lock | 1 + bench/README.md | 30 + quest/m2/audit-perf.md | 6 +- quest/m2/performance-survey/README.md | 1 - .../m2/performance-survey/jwt-verification.md | 32 - rs/moq-auth/Cargo.toml | 7 +- rs/moq-auth/benches/verify.rs | 642 ++++++++++++++++++ sh/gh/nightly.sh | 3 + sh/gh/nightly.test.py | 1 + 9 files changed, 687 insertions(+), 36 deletions(-) delete mode 100644 quest/m2/performance-survey/jwt-verification.md create mode 100644 rs/moq-auth/benches/verify.rs diff --git a/Cargo.lock b/Cargo.lock index ec36bdacbb1..226a8403257 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5388,6 +5388,7 @@ dependencies = [ "aws-lc-rs", "axum", "base64 0.23.1", + "bench-harness", "data-encoding", "jsonwebtoken", "kio 0.6.2", diff --git a/bench/README.md b/bench/README.md index 82427ba0cb9..7d28c3cb4d1 100644 --- a/bench/README.md +++ b/bench/README.md @@ -446,6 +446,36 @@ use the same fixtures, with byte rates counting the encoded stream size. Fixture generation, publisher setup and destruction stay outside timing. The shared Rust benchmark nightly invocation measures both targets in quick mode. +## Auth verification + +`moq-auth`'s `verify` target measures what `moq auth serve` spends on one JWT: + +```bash +nix develop --command cargo bench --locked --manifest-path rs/moq-auth/Cargo.toml --bench verify +``` + +`decide` runs `Policy::decide` end to end for every supported algorithm against a +key file, a key directory and a one-key set, rereading and importing the key as the +server does per request. `stage` times each part alone: the async file read beside a +blocking one, key and set parsing, header decoding, `kid` lookup, import, and +verification with a fresh (`verify_cold`) or already used (`verify_warm`) key. `set` +sweeps the set size (1, 16, 256) against a first, last or missing `kid`; `refuse` +covers expired, forged, unknown-`kid`, `kid`-less and malformed tokens. `clients` +runs 1, 16 or 64 concurrent requests on a four-worker runtime against one key, a +16-key directory or a 16-key set, plus a refusal burst and a key replaced and +removed by atomic rename during the requests; each prints its per-request +p50/p95/p99 after the run. `sessions` times a `connect` and `end` while the +server's session limits count 0, 1024 or 16384 other live sessions. `http` is one +HS256 or ES256 `connect` as a relay sends it, a `POST /` over loopback TCP, which +puts the verification in scale. + +Validity checks read a manual clock, so every run decides alike. Set +`VERIFY_FIXTURE` to a directory to reuse its keys and tokens across runs, and +`VERIFY_CASE` with `VERIFY_ITERATIONS=n` to skip Criterion and run one case exactly +`n` times, printing its allocations per iteration. Under `perf stat`, the +instruction difference between `n` and 0 iterations, divided by `n`, is that case's +deterministic cost on a loaded host. It runs nightly in the `rust-extra` group. + ## Nightly trends is one static table of every nightly series, diff --git a/quest/m2/audit-perf.md b/quest/m2/audit-perf.md index b0313979e76..bdbfa9387c6 100644 --- a/quest/m2/audit-perf.md +++ b/quest/m2/audit-perf.md @@ -176,8 +176,10 @@ follow-up quest. The m1 run added none of these: `Unlimited`; sweep controller x flight x loss x streams. 7. [Announcement snapshots](/quest/m2/performance-survey/announced-snapshots.md) owns HTTP snapshot/rendering costs across routes and pollers. -8. [JWT verification](/quest/m2/performance-survey/jwt-verification.md) owns - key-source I/O, import, lookup, parsing and crypto attribution. +8. JWT verification is covered: `moq-auth`'s `verify` bench runs nightly in + `rust-extra` (see [bench/README.md](/bench/README.md#auth-verification)). + Signature verification dominated every asymmetric algorithm; nothing + was changed. Track its `decide`, `set` and `sessions` series here. 9. MPEG-TS import with real repeated media (the CLI bench mostly skips null padding), and MKV, FLV, H.265, AV1 importers. 10. JS watch audio ring buffer and publish `Fanout` (lower impact). Include diff --git a/quest/m2/performance-survey/README.md b/quest/m2/performance-survey/README.md index c25cce3d3b9..7739cd159e9 100644 --- a/quest/m2/performance-survey/README.md +++ b/quest/m2/performance-survey/README.md @@ -189,7 +189,6 @@ No duplicate quest or workflow edit belongs here. - [Resume identity history](/quest/m2/performance-survey/resume-history.md) - historical source graphs held by first-seen track metadata - [UDP socket queues](/quest/m2/performance-survey/socket-queues.md) - drop attribution and queue-size tradeoffs across workers and load - [Session datagrams](/quest/m2/performance-survey/datagram-delivery.md) - complete per-object delivery across publishers and viewers -- [JWT verification](/quest/m2/performance-survey/jwt-verification.md) - key loading, lookup, parsing and crypto attribution - [Announcement snapshots](/quest/m2/performance-survey/announced-snapshots.md) - HTTP polling cost versus result size and unrelated routes - [Selective session operations](/quest/m2/performance-survey/session-selection.md) - exact-ID lookups and registry lock hold time - [Browser media copies](/quest/m2/performance-survey/browser-media-copies.md) - CMAF sample and LOC framing copies versus retention diff --git a/quest/m2/performance-survey/jwt-verification.md b/quest/m2/performance-survey/jwt-verification.md deleted file mode 100644 index 23be33be36e..00000000000 --- a/quest/m2/performance-survey/jwt-verification.md +++ /dev/null @@ -1,32 +0,0 @@ -# [M] Attribute authentication verification cost before caching - -## Goal - -Authentication cost is explained across key sources and concurrent clients, -and avoidable work is reduced without weakening key rotation or validation. - -## Plan - -[Serve::verify](/rs/moq-auth/src/serve.rs) rereads a key or whole key-set -file for each request, then imports it. A Key's cached crypto -material is therefore not shared across these requests. The -[KeySet lookup](/rs/moq-auth/src/set.rs) is linear, while header parsing also -occurs in [Key::decode](/rs/moq-auth/src/key.rs). Existing session admission -and relay lease-refresh benchmarks do not isolate actual JWT verification. - -Benchmark clients × keys, each supported algorithm, first/last/missing kid, -file/directory/set sources and cold/warm material separately. Attribute file -I/O, JSON/JWT parsing, lookup, key import and signature verification; record -CPU, allocations, throughput and p95/p99 request latency. Include concurrent -key replacement, removal, invalid keys, expired tokens and refusal bursts. -Use deterministic time in validity checks. - -Do not prescribe a TTL or token-result cache. Preserve current fresh-key and -revocation behavior; if sharing requires a new freshness contract, present -that decision with measurements before implementing it. This extracts and -sharpens the recurring audit's auth benchmark gap. - -Follow the [survey's benchmark contract](/quest/m2/performance-survey/README.md): -measure the baseline before optimizing, retain paired evidence, verify equivalent -work, schedule coverage nightly, and accept a measured no-win. Expected public -API and wire impact: none; report the actual impact in the implementation PR. diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 43eb004cf91..082ea4de2dd 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -42,7 +42,8 @@ url = { workspace = true, features = ["serde"] } [dev-dependencies] anyhow = { workspace = true } -kio = { path = "../kio", features = ["runtime", "sim"] } +criterion = { package = "bench-harness", path = "../bench-harness" } +kio = { path = "../kio", features = ["manual", "runtime", "sim"] } moq-auth = { path = ".", features = ["client", "serve"] } moq-auth-published = { package = "moq-auth", version = "=0.2.1", default-features = false } moq-token = "0.7" @@ -51,5 +52,9 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util" tracing-test = { workspace = true } wiremock = "0.6" +[[bench]] +name = "verify" +harness = false + [lints] workspace = true diff --git a/rs/moq-auth/benches/verify.rs b/rs/moq-auth/benches/verify.rs new file mode 100644 index 00000000000..8c9abbd0bd9 --- /dev/null +++ b/rs/moq-auth/benches/verify.rs @@ -0,0 +1,642 @@ +//! What one token costs `moq auth serve` to verify, split into the work it does. +//! +//! `decide` is [`Policy::decide`] end to end, as the server answers a `connect`: it +//! rereads the configured key source, imports the key, and verifies the token. The +//! `stage` cases time each part alone: reading the file, parsing a key or set, decoding +//! the header, looking up a `kid`, and verifying with a freshly imported (`cold`) or an +//! already used (`warm`) key. `set` sweeps a key set's size against where the `kid` +//! sits, and `refuse` times the refusals a client can provoke. +//! +//! `clients` runs concurrent requests on a multi-threaded runtime, including a burst of +//! refusals and a key replaced and removed while they run, and prints each case's +//! per-request latency percentiles after the run. `sessions` times a `connect` and its +//! `end` against a server whose session limits count other live sessions. `http` is one +//! `connect` as a relay sends it, a `POST /` over loopback TCP, for scale. +//! +//! Validity checks read a [`Manual`](kio::clock::Manual) clock, so every run decides the +//! same way. Keys and tokens are generated once into `VERIFY_FIXTURE` (a fresh temporary +//! directory by default); point repeated runs at one directory so they verify identical +//! signatures. With `VERIFY_ITERATIONS=n`, the bench skips Criterion and runs the case +//! named by `VERIFY_CASE` exactly `n` times, printing its allocations, so an external +//! instruction counter divides by a known count: +//! +//! ```bash +//! cargo bench --manifest-path rs/moq-auth/Cargo.toml --bench verify --no-run +//! VERIFY_FIXTURE=fixture VERIFY_CASE=verify/decide/ES256/file VERIFY_ITERATIONS=1000 \ +//! perf stat -e instructions:u target/release/deps/verify-* +//! ``` + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::collections::VecDeque; +use std::hint::black_box; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use criterion::Criterion; +use moq_auth::serve::{Denial, Keys, Policy, Server}; +use moq_auth::{Algorithm, Bytes, Claims, Event, Grant, Key, KeyId, KeySet, Request, Transport, lease}; + +/// Counts every allocation on every thread, including tokio's blocking pool that reads files, +/// while [`COUNTING`] is set. Only the fixed-iteration mode sets it, so timed runs share no +/// counter between threads. +struct Counter; + +static COUNTING: AtomicBool = AtomicBool::new(false); +static ALLOCATIONS: AtomicU64 = AtomicU64::new(0); +static ALLOCATED: AtomicU64 = AtomicU64::new(0); + +fn count(size: usize) { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + ALLOCATED.fetch_add(size as u64, Ordering::Relaxed); + } +} + +#[global_allocator] +static ALLOCATOR: Counter = Counter; + +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + count(layout.size()); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + count(layout.size()); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + count(new_size); + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + +const ALGORITHMS: [Algorithm; 12] = [ + Algorithm::HS256, + Algorithm::HS384, + Algorithm::HS512, + Algorithm::ES256, + Algorithm::ES384, + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + Algorithm::EdDSA, +]; + +/// Key set sizes, all ES256. +const SET_SIZES: [usize; 3] = [1, 16, 256]; + +/// Keys in the directory and set that concurrent clients spread their tokens over. +const SPREAD: usize = 16; + +/// Concurrent requests per iteration of a `clients` case. +const CLIENTS: [usize; 3] = [1, 16, 64]; + +/// Live sessions in the server's table, for the `sessions` cases. +const SESSIONS: [usize; 3] = [0, 1024, 16384]; + +/// Per-request latencies kept for each concurrent case's percentiles: the most recent ones. +const SAMPLES: usize = 1 << 16; + +/// Every token expires a year after the manual clock's start, or expired a second before it. +fn claims(expires: kio::clock::Wall) -> Claims { + Claims::default() + .with_root("demo") + .with_publish(["**".parse().unwrap()]) + .with_expires(expires) +} + +fn valid() -> Claims { + claims(kio::clock::Manual::WALL_START + Duration::from_secs(365 * 24 * 60 * 60)) +} + +fn kid(name: &str) -> Option { + Some(KeyId::decode(name).unwrap()) +} + +/// The half a server verifies with: the public key, or the shared secret. +fn verifier(key: &Key) -> Key { + key.to_public().unwrap_or_else(|_| key.clone()) +} + +fn write(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); +} + +/// Generate the keys and tokens, unless an earlier run left them in `root`. +fn generate(root: &Path) { + if root.join("ready").exists() { + return; + } + for algorithm in ALGORITHMS { + let name = format!("{algorithm:?}"); + let id = name.to_lowercase(); + let key = Key::generate(algorithm, kid(&id)).unwrap(); + let public = verifier(&key); + let dir = root.join(&name); + std::fs::create_dir_all(dir.join("dir")).unwrap(); + public.to_file(dir.join("key.jwk")).unwrap(); + public.to_file(dir.join("dir").join(format!("{id}.jwk"))).unwrap(); + KeySet { + keys: vec![Arc::new(public)], + } + .to_file(dir.join("set.jwks")) + .unwrap(); + write(&dir.join("token"), &key.sign(&valid()).unwrap()); + if algorithm == Algorithm::ES256 { + let expired = claims(kio::clock::Manual::WALL_START - Duration::from_secs(1)); + write(&root.join("refuse").join("expired"), &key.sign(&expired).unwrap()); + } + } + + let keys: Vec = (0..*SET_SIZES.last().unwrap()) + .map(|index| Key::generate(Algorithm::ES256, kid(&format!("k{index}"))).unwrap()) + .collect(); + let dir = root.join("set"); + std::fs::create_dir_all(&dir).unwrap(); + for size in SET_SIZES { + KeySet { + keys: keys[..size].iter().map(|key| Arc::new(verifier(key))).collect(), + } + .to_file(dir.join(format!("{size}.jwks"))) + .unwrap(); + write( + &dir.join(format!("{size}-last")), + &keys[size - 1].sign(&valid()).unwrap(), + ); + } + write(&root.join("set").join("first"), &keys[0].sign(&valid()).unwrap()); + // A directory of the first keys, each with a token, so concurrent clients spread over kids. + let spread = root.join("spread"); + std::fs::create_dir_all(&spread).unwrap(); + for (index, key) in keys[..SPREAD].iter().enumerate() { + verifier(key).to_file(spread.join(format!("k{index}.jwk"))).unwrap(); + write( + &root.join("tokens").join(format!("k{index}")), + &key.sign(&valid()).unwrap(), + ); + } + // Two versions of one kid, swapped in and out while clients present tokens from both. + std::fs::create_dir_all(root.join("rotate")).unwrap(); + for version in ["a", "b"] { + let key = Key::generate(Algorithm::ES256, kid("rotate")).unwrap(); + verifier(&key) + .to_file(root.join("rotate").join(format!("{version}.jwk"))) + .unwrap(); + write( + &root.join("rotate").join(format!("{version}.jwt")), + &key.sign(&valid()).unwrap(), + ); + } + let stranger = Key::generate(Algorithm::ES256, kid("missing")).unwrap(); + write(&root.join("set").join("missing"), &stranger.sign(&valid()).unwrap()); + + // The other refusals, against the ES256 directory. + let refuse = root.join("refuse"); + // The directory's kid, another key: the signature fails. + let forger = Key::generate(Algorithm::ES256, kid("es256")).unwrap(); + write(&refuse.join("forged"), &forger.sign(&valid()).unwrap()); + write(&refuse.join("unknown_kid"), &keys[0].sign(&valid()).unwrap()); + let bare = Key::generate(Algorithm::ES256, None).unwrap(); + write(&refuse.join("missing_kid"), &bare.sign(&valid()).unwrap()); + write(&refuse.join("malformed"), "not-a-jwt"); + + write(&root.join("ready"), ""); +} + +/// A benchmark case: a name and the work one iteration does. +type Case = (String, Box); + +/// The most recent per-request latencies of one concurrent case. +#[derive(Clone, Default)] +struct Latencies(Arc>>); + +impl Latencies { + fn record(&self, elapsed: Duration) { + let mut samples = self.0.lock().unwrap(); + if samples.len() == SAMPLES { + samples.pop_front(); + } + samples.push_back(elapsed); + } + + fn summary(&self) -> Option { + let mut samples: Vec = self.0.lock().unwrap().iter().copied().collect(); + if samples.is_empty() { + return None; + } + samples.sort_unstable(); + let at = |quantile: f64| samples[((samples.len() - 1) as f64 * quantile).round() as usize]; + Some(format!( + "{} requests: p50 {:?}, p95 {:?}, p99 {:?}, max {:?}", + samples.len(), + at(0.5), + at(0.95), + at(0.99), + at(1.0), + )) + } +} + +/// Which outcomes a concurrent case accepts from each request. +type Expect = fn(&Result) -> bool; + +struct Bench { + root: PathBuf, + runtime: Arc, + /// A multi-threaded runtime for the concurrent cases. + pool: Arc, + cases: Vec, + latencies: Vec<(String, Latencies)>, +} + +impl Bench { + fn read(&self, path: impl AsRef) -> String { + std::fs::read_to_string(self.root.join(path)).unwrap() + } + + fn add(&mut self, name: String, run: impl FnMut() + 'static) { + self.cases.push((name, Box::new(run))); + } + + /// Time `Policy::decide` for `token`, asserting it is admitted or refused as expected. + fn decide(&mut self, name: String, keys: Keys, token: String, admit: bool) { + let mut policy = Policy::default(); + policy.keys = Some(keys); + let mut request = Request::new("bench", Transport::Quic, "/demo"); + request.query = Some(format!("jwt={token}")); + let runtime = self.runtime.clone(); + let decided = runtime.block_on(policy.decide(&request)); + assert_eq!(decided.is_ok(), admit, "{name}: {decided:?}"); + self.add(name, move || { + black_box(runtime.block_on(policy.decide(black_box(&request))).is_ok()); + }); + } + + /// Time `clients` concurrent `Policy::decide` calls, presenting `tokens` in turn. With + /// `rotate`, each iteration also replaces or removes that directory's only key while + /// the requests run. + fn clients(&mut self, name: String, keys: Keys, tokens: &[String], rotate: Option, expect: Expect) { + let clients: usize = name.split('/').nth(2).unwrap().parse().unwrap(); + let mut policy = Policy::default(); + policy.keys = Some(keys); + let policy = Arc::new(policy); + let requests: Vec> = (0..clients) + .map(|client| { + let mut request = Request::new("bench", Transport::Quic, "/demo"); + request.query = Some(format!("jwt={}", tokens[client % tokens.len()])); + Arc::new(request) + }) + .collect(); + let latencies = Latencies::default(); + self.latencies.push((name.clone(), latencies.clone())); + let pool = self.pool.clone(); + let mut version = 0; + self.add(name, move || { + version += 1; + pool.block_on(async { + let rotation = rotate + .clone() + .map(|dir| tokio::task::spawn_blocking(move || rotate_key(&dir, version))); + let tasks: Vec<_> = requests + .iter() + .map(|request| { + let (policy, request) = (policy.clone(), request.clone()); + tokio::spawn(async move { timed(policy.decide(&request)).await }) + }) + .collect(); + if let Some(rotation) = rotation { + rotation.await.unwrap(); + } + for task in tasks { + let (decided, elapsed) = task.await.unwrap(); + assert!(expect(&decided), "{decided:?}"); + latencies.record(elapsed); + } + }); + }); + } + + /// Time one `connect` as a relay asks it: a `POST /` to the server over loopback TCP, + /// on a kept-alive connection, reading and decoding the grant it answers with. + fn http(&mut self, name: String, keys: Keys, token: String) { + let mut policy = Policy::default(); + policy.keys = Some(keys); + let server = Server::new(policy).unwrap(); + let runtime = self.runtime.clone(); + let listener = runtime.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")).unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + runtime.spawn(async move { server.serve(listener).await }); + let mut request = Request::new("bench", Transport::Quic, "/demo"); + request.query = Some(format!("jwt={token}")); + let client = reqwest::Client::new(); + let post = move || { + let response = client.post(&url).json(&request).send(); + async move { + let response = response.await.unwrap().error_for_status().unwrap(); + response.json::().await.unwrap() + } + }; + let grant = runtime.block_on(post()); + assert_eq!(grant.publish, ["**".parse().unwrap()].into_iter().collect(), "{name}"); + self.add(name, move || { + black_box(runtime.block_on(post())); + }); + } + + /// Time one `connect` and its `end` against a server whose session limits count `live` + /// other sessions, each from its own address with its own token. + fn sessions(&mut self, live: usize) { + let path = self.root.join("HS256").join("key.jwk"); + let key = Key::from_file(&path).unwrap(); + let mut policy = Policy::default(); + policy.keys = Some(Keys::File(path)); + policy.limits.token = Some(usize::MAX); + policy.limits.remote = Some(usize::MAX); + policy.revalidate = Some(Duration::from_secs(60)); + policy.expires = Some(Duration::from_secs(3600)); + let server = Server::new(policy).unwrap(); + let request = |index: usize| { + let [_, a, b, c] = (index as u32).to_be_bytes(); + let mut request = Request::new("bench", Transport::Quic, "/demo"); + request.remote = Some(([10, a, b, c], 4433).into()); + let claims = valid().with_publish([format!("s{index}/**").parse().unwrap()]); + request.query = Some(format!("jwt={}", key.sign(&claims).unwrap())); + request + }; + for index in 0..live { + self.runtime.block_on(server.answer(&request(index))).unwrap(); + } + let connect = request(live); + let mut end = connect.clone(); + end.event = Event::End { + reason: lease::Reason::Shutdown, + duration: Duration::ZERO, + bytes: Bytes::default(), + }; + let runtime = self.runtime.clone(); + self.add(format!("verify/sessions/{live}"), move || { + runtime.block_on(async { + black_box(server.answer(black_box(&connect)).await.unwrap()); + black_box(server.answer(black_box(&end)).await.unwrap()); + }); + }); + } + + fn cases(&mut self) { + for algorithm in ALGORITHMS { + let name = format!("{algorithm:?}"); + let dir = self.root.join(&name); + let token = self.read(dir.join("token")); + self.decide( + format!("verify/decide/{name}/file"), + Keys::File(dir.join("key.jwk")), + token.clone(), + true, + ); + self.decide( + format!("verify/decide/{name}/dir"), + Keys::Dir(dir.join("dir")), + token.clone(), + true, + ); + self.decide( + format!("verify/decide/{name}/set"), + Keys::Set(dir.join("set.jwks")), + token.clone(), + true, + ); + + let text = self.read(dir.join("key.jwk")); + self.add(format!("verify/stage/{name}/parse_key"), move || { + black_box(black_box(text.as_str()).parse::().unwrap()); + }); + + // A fresh import derives the crypto material on first use; a used key has it cached. + let key = Key::from_file(dir.join("key.jwk")).unwrap(); + key.verify(&token).unwrap(); + let jwk = key.export(); + let cold = token.clone(); + self.add(format!("verify/stage/{name}/verify_cold"), move || { + let key = black_box(jwk.clone()).import().unwrap(); + black_box(key.verify(black_box(&cold)).unwrap()); + }); + let jwk = key.export(); + self.add(format!("verify/stage/{name}/import"), move || { + black_box(black_box(jwk.clone()).import().unwrap()); + }); + self.add(format!("verify/stage/{name}/verify_warm"), move || { + black_box(key.verify(black_box(&token)).unwrap()); + }); + } + + let path = self.root.join("ES256").join("key.jwk"); + let runtime = self.runtime.clone(); + let tokio_path = path.clone(); + self.add("verify/stage/read_tokio".into(), move || { + black_box(runtime.block_on(tokio::fs::read_to_string(&tokio_path)).unwrap()); + }); + self.add("verify/stage/read_std".into(), move || { + black_box(std::fs::read_to_string(&path).unwrap()); + }); + let token = self.read("ES256/token"); + self.add("verify/stage/header".into(), move || { + black_box(jsonwebtoken::decode_header(black_box(&token)).unwrap()); + }); + + let first = self.read("set/first"); + let missing = self.read("set/missing"); + for size in SET_SIZES { + let path = self.root.join("set").join(format!("{size}.jwks")); + let last = self.read(format!("set/{size}-last")); + for (kid, token, admit) in [ + ("first", &first, true), + ("last", &last, true), + ("missing", &missing, false), + ] { + self.decide( + format!("verify/set/{size}/{kid}"), + Keys::Set(path.clone()), + token.clone(), + admit, + ); + } + + let text = self.read(&path); + let set: KeySet = text.parse().unwrap(); + self.add(format!("verify/stage/parse_set/{size}"), move || { + black_box(black_box(text.as_str()).parse::().unwrap()); + }); + let kid = format!("k{}", size - 1); + self.add(format!("verify/stage/lookup/{size}"), move || { + black_box(set.find_key(black_box(&kid)).unwrap()); + }); + } + + let dir = self.root.join("ES256").join("dir"); + let refusals = ["expired", "forged", "unknown_kid", "missing_kid", "malformed"]; + for refusal in refusals { + let token = self.read(format!("refuse/{refusal}")); + self.decide(format!("verify/refuse/{refusal}"), Keys::Dir(dir.clone()), token, false); + } + + let es256 = [self.read("ES256/token")]; + let spread: Vec = (0..SPREAD).map(|index| self.read(format!("tokens/k{index}"))).collect(); + for clients in CLIENTS { + let file = Keys::File(self.root.join("ES256").join("key.jwk")); + self.clients( + format!("verify/clients/{clients}/file"), + file, + &es256, + None, + Result::is_ok, + ); + let spread_dir = Keys::Dir(self.root.join("spread")); + let name = format!("verify/clients/{clients}/dir{SPREAD}"); + self.clients(name, spread_dir, &spread, None, Result::is_ok); + let set = Keys::Set(self.root.join("set").join(format!("{SPREAD}.jwks"))); + let name = format!("verify/clients/{clients}/set{SPREAD}"); + self.clients(name, set, &spread, None, Result::is_ok); + } + + // Tokens from both versions while the key is replaced and removed underneath them. + let live = self.root.join("rotate").join("live"); + rotate_key(&live, 0); + let versions = [self.read("rotate/a.jwt"), self.read("rotate/b.jwt")]; + self.clients( + "verify/clients/16/rotate".into(), + Keys::Dir(live.clone()), + &versions, + Some(live), + |decided| matches!(decided, Ok(_) | Err(Denial::InvalidToken(_) | Denial::UnknownKey)), + ); + let refusals: Vec = refusals + .iter() + .map(|refusal| self.read(format!("refuse/{refusal}"))) + .collect(); + self.clients( + "verify/clients/64/refuse".into(), + Keys::Dir(dir), + &refusals, + None, + Result::is_err, + ); + + for live in SESSIONS { + self.sessions(live); + } + + for algorithm in [Algorithm::HS256, Algorithm::ES256] { + let name = format!("{algorithm:?}"); + let keys = Keys::File(self.root.join(&name).join("key.jwk")); + let token = self.read(format!("{name}/token")); + self.http(format!("verify/http/{name}"), keys, token); + } + } +} + +/// Run `future`, returning its output and the host time it took. +#[expect( + clippy::disallowed_methods, + clippy::disallowed_types, + reason = "request latency is host time; the manual clock only decides validity" +)] +async fn timed(future: impl Future) -> (T, Duration) { + let start = std::time::Instant::now(); + let output = future.await; + (output, start.elapsed()) +} + +/// Install version `a` or `b` of the rotating key in `dir` by an atomic rename, or remove it, +/// in turn. +fn rotate_key(dir: &Path, version: usize) { + std::fs::create_dir_all(dir).unwrap(); + let live = dir.join("rotate.jwk"); + let source = match version % 3 { + 0 => "a.jwk", + 1 => "b.jwk", + _ => { + let _ = std::fs::remove_file(&live); + return; + } + }; + let staged = dir.join(format!("{version}.tmp")); + std::fs::copy(dir.parent().unwrap().join(source), &staged).unwrap(); + std::fs::rename(&staged, &live).unwrap(); +} + +fn main() { + let fixture = std::env::var_os("VERIFY_FIXTURE"); + let temporary = tempfile::tempdir().unwrap(); + let root = fixture.map(PathBuf::from).unwrap_or_else(|| temporary.path().into()); + generate(&root); + let root = root.canonicalize().unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _clock = kio::clock::Manual::new().install(runtime.handle()); + let pool = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .build() + .unwrap(); + let _pool_clock = kio::clock::Manual::new().install(pool.handle()); + // Hold the runtime context so validity checks outside `block_on` read the manual clock too. + let _enter = runtime.enter(); + let mut bench = Bench { + root, + runtime: Arc::new(runtime), + pool: Arc::new(pool), + cases: Vec::new(), + latencies: Vec::new(), + }; + bench.cases(); + + if let Some(iterations) = std::env::var_os("VERIFY_ITERATIONS") { + let iterations: u64 = iterations.to_str().unwrap().parse().unwrap(); + let name = std::env::var("VERIFY_CASE").expect("VERIFY_CASE names the case to run"); + let (_, run) = bench + .cases + .iter_mut() + .find(|(case, _)| *case == name) + .unwrap_or_else(|| panic!("no case {name}")); + // One untimed run first, so lazily initialized statics are not counted. + run(); + COUNTING.store(true, Ordering::Relaxed); + for _ in 0..iterations { + run(); + } + COUNTING.store(false, Ordering::Relaxed); + let per = |total: &AtomicU64| total.load(Ordering::Relaxed) as f64 / iterations.max(1) as f64; + println!( + "{name}: {:.1} allocations, {:.0} bytes allocated per iteration", + per(&ALLOCATIONS), + per(&ALLOCATED), + ); + } else { + let mut criterion = Criterion::default().configure_from_args(); + for (name, run) in &mut bench.cases { + criterion.bench_function(name, |b| b.iter(&mut *run)); + } + criterion.final_summary(); + } + + for (name, latencies) in &bench.latencies { + if let Some(summary) = latencies.summary() { + println!("{name}: {summary}"); + } + } +} diff --git a/sh/gh/nightly.sh b/sh/gh/nightly.sh index ae8312bc9dd..60088a65c8b 100755 --- a/sh/gh/nightly.sh +++ b/sh/gh/nightly.sh @@ -127,6 +127,9 @@ case "$1" in --bench protect --bench sign --bench codec --bench table --bench allocations --bench window \ --bench producer --bench play_buffer --bench jitter --bench decode_clock --bench ts_programs --bench ts_video \ --bench bus --bench parameter_sets --bench sent_packets + # moq-auth's published release is its own dev-dependency, so `-p moq-auth` is ambiguous. + # The concurrent cases' latency percentiles stay in the run's stdout. + criterion auth --manifest-path rs/moq-auth/Cargo.toml --bench verify ;; rust-uring) # One run per target makes an unsupported/skipped ring fail report diff --git a/sh/gh/nightly.test.py b/sh/gh/nightly.test.py index 7a9d459de35..d38d68f7341 100644 --- a/sh/gh/nightly.test.py +++ b/sh/gh/nightly.test.py @@ -158,6 +158,7 @@ def test_every_cargo_bench_target_runs_nightly(self): if args[:3] != ["just", "gh", "criterion"]: continue packages = [args[i + 1] for i, arg in enumerate(args) if arg == "-p"] + packages += [Path(args[i + 1]).parent.name for i, arg in enumerate(args) if arg == "--manifest-path"] targets = [args[i + 1] for i, arg in enumerate(args) if arg == "--bench"] names = set() for package in packages: From 58afd93827bc5c94e3366a74684b83392aee51be Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:24:41 -0800 Subject: [PATCH 11/86] perf(net): stop a front's first-seen track record holding retired routes (#7514) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot --- bench/README.md | 20 +- bench/browser/micro.ts | 42 ++- bench/browser/run.sh | 2 +- js/net/bench/resume-history.ts | 301 ++++++++++++++++++ js/net/src/resume.ts | 20 +- quest/m2/performance-survey/README.md | 9 +- quest/m2/performance-survey/resume-history.md | 31 -- sh/gh/nightly.test.py | 7 + 8 files changed, 382 insertions(+), 50 deletions(-) create mode 100644 js/net/bench/resume-history.ts delete mode 100644 quest/m2/performance-survey/resume-history.md diff --git a/bench/README.md b/bench/README.md index 7d28c3cb4d1..e976bc84dc2 100644 --- a/bench/README.md +++ b/bench/README.md @@ -409,12 +409,13 @@ Crashes, wrong bytes, missing frames, a window that presents nothing, or an unavailable CPU clock fail the run; timing never does. The nightly runs `--smoke` and keeps its run directory as the `bench-browser` artifact. -The in-memory frame, retained-history, and gap ledger paths also run directly in Chromium: +The in-memory frame, retained-history, gap ledger, and resume-history paths also run directly in Chromium: ```bash nix develop --command bun bench/browser/micro.ts --bench frames --out .tmp/frames.json base=/path/to/release current=. aa=. nix develop --command bun bench/browser/micro.ts --bench drift --out .tmp/drift.json before=/path/to/main current=. aa=. nix develop --command bun bench/browser/micro.ts --bench ledger --out .tmp/ledger.json before=/path/to/main current=. aa=. +nix develop --command bun bench/browser/micro.ts --bench resume-history --out .tmp/resume-history.json before=/path/to/main current=. aa=. ``` Each variant uses its own workspace source and a persistent page, with one full @@ -432,7 +433,22 @@ Its `time_mode` column is 0 for untimed, 1 for millisecond timestamps, and 2 for mixed millisecond/microsecond timestamps, which exercise exact cross-scale comparison. -The browser smoke lane preserves one measured round of all three fixtures, and drift +`resume-history` moves long-lived logical fronts across lite sessions over the +in-memory transport, make-before-break as a session draining after GOAWAY does. +It sweeps fronts, names read per route, and route count, with `stable` names +(the same ones on every route) against `churn` (new names first answered by each +route), each read by info lookup, fetch, or subscription and then closed. Once a +manual clock ages out every cache, full collections from Chromium's exposed `gc` +let weak references count the retired routes' sessions, transports, broadcast +handles, and delivered payload buffers still reachable. These counts are exact, +so any nonzero one fails the run once every sample is written, leaving a retaining +`before` revision's counts in the JSON. A concluded control must release everything, +or the fixture fails. `size_bytes` is the JS heap the fronts and their serving +route hold, from Chromium's precise memory info; `serve_us` (clamped to the +page's coarse timer) and `read_us` include no setup. Bun's collector scans its +stack conservatively, so the fixture refuses to run there. + +The browser smoke lane preserves one measured round of all four fixtures, and drift and ledger also run in the nightly JS benchmark leg. Timing is observational, with the A/A spread showing the host's noise floor. diff --git a/bench/browser/micro.ts b/bench/browser/micro.ts index 1bf4d138034..7988efd7f79 100644 --- a/bench/browser/micro.ts +++ b/bench/browser/micro.ts @@ -14,8 +14,8 @@ const { values, positionals } = parseArgs({ }, }); const benchmark = values.bench; -if (benchmark !== "frames" && benchmark !== "drift" && benchmark !== "ledger") - throw new Error("--bench must be frames, drift, or ledger"); +const expectedRows = ({ frames: 26, drift: 36, ledger: 12, "resume-history": 72 } as Record)[benchmark]; +if (expectedRows === undefined) throw new Error("--bench must be frames, drift, ledger, or resume-history"); const rounds = Number(values.rounds); if (!Number.isSafeInteger(rounds) || rounds < 1) throw new Error("--rounds must be a positive integer"); const roots = new Map(); @@ -39,11 +39,13 @@ for (const [name, root] of roots) { { name: "repeat-microbenchmark", setup(build) { - // The ledger is internal, so select it explicitly instead of a package export. - build.onResolve({ filter: /^\.\.\/src\/ledger\.ts$/ }, () => ({ - path: resolve(root, "js/net/src/ledger.ts"), - })); - build.onLoad({ filter: /\/bench\/(frames|drift|ledger)\.ts$/ }, async ({ path }) => { + // Internal modules have no package export, so select each variant's own explicitly. + build.onResolve({ filter: /^\.\.\/src\// }, ({ path, importer }) => + importer.endsWith(`/js/net/bench/${benchmark}.ts`) + ? { path: resolve(root, "js/net/bench", path) } + : undefined, + ); + build.onLoad({ filter: /\/bench\/(frames|drift|ledger|resume-history)\.ts$/ }, async ({ path }) => { const source = await Bun.file(path).text(); if (benchmark !== "frames") return { loader: "ts", contents: `${source}\nglobalThis.measure = run;` }; @@ -81,7 +83,12 @@ const server = Bun.serve({ }); }, }); -const browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_BIN }); +const browser = await chromium.launch({ + headless: true, + executablePath: process.env.BROWSER_BIN, + // Retention is counted after full collections the page asks for, against an unrounded heap size. + args: benchmark === "resume-history" ? ["--js-flags=--expose-gc", "--enable-precise-memory-info"] : [], +}); const samples: { round: number; variant: string; rows: string[] }[] = []; try { const pages = new Map(); @@ -109,8 +116,7 @@ try { rows.set(variant, []); await pages.get(variant)?.evaluate("globalThis.measure()"); const measured = rows.get(variant); - if (measured?.length !== (benchmark === "frames" ? 26 : benchmark === "drift" ? 36 : 12)) - throw new Error(`incomplete rows: ${variant}`); + if (measured?.length !== expectedRows) throw new Error(`incomplete rows: ${variant}`); samples.push({ round, variant, rows: measured }); await Bun.write( values.out, @@ -123,6 +129,22 @@ try { console.log(`measured ${round} ${variant}`); } } + // Reachability is exact here, so a retired route still reachable fails the run, once every + // variant's samples are written for comparison. + if (benchmark === "resume-history") { + const retaining = samples.filter(({ rows }) => + rows.some((row) => + row + .split(",") + .slice(5, 9) + .some((retired) => retired !== "0"), + ), + ); + if (retaining.length) + throw new Error( + `retired routes stayed reachable: ${[...new Set(retaining.map((s) => s.variant))].join(", ")}`, + ); + } } finally { await browser.close(); server.stop(true); diff --git a/bench/browser/run.sh b/bench/browser/run.sh index 6c16ba98a76..8ac635ddc72 100755 --- a/bench/browser/run.sh +++ b/bench/browser/run.sh @@ -88,7 +88,7 @@ bun install --frozen-lockfile cd "$HERE" # The in-memory paths share Chromium installation and artifact retention with this lane. -for benchmark in frames drift ledger; do +for benchmark in frames drift ledger resume-history; do bun micro.ts --bench "$benchmark" --rounds 1 --out "$HARNESS_RUN/$benchmark.json" \ current="$WORKSPACE" aa="$WORKSPACE" done diff --git a/js/net/bench/resume-history.ts b/js/net/bench/resume-history.ts new file mode 100644 index 00000000000..05e076e711e --- /dev/null +++ b/js/net/bench/resume-history.ts @@ -0,0 +1,301 @@ +/** + * Sweep fronts, first-seen track names, and route replacements for long-lived logical fronts, + * then count what each retired route leaves reachable once its readers closed, its session ended, + * and every cache aged out. + * + * Every route is a real lite session over the in-memory transport, so a retired route that is + * still reachable keeps its session, subscriber, transport, and broadcast handles. `stable` reads + * the same names on every route, so only their first route ever records them; `churn` reads names + * first answered by each route. Each case reads its names one way (info lookup, group fetch, or + * subscription) and closes every reader. A route is replaced make-before-break, as a session + * draining after GOAWAY is: the fronts serve the new route, then the old session ends. Only the + * serving route may stay reachable. + * + * Counts come from weak references after full collections, so they are exact only under a precise + * collector: run it in Chromium with `bench/browser/micro.ts --bench resume-history`, which + * exposes `gc` and fails once a retired route stays reachable. Bun scans its stack conservatively, + * so a dead reference there can survive a collection. + */ +import { Manual, Milli, Nano } from "@moq/clock"; +import type { Producer as BroadcastProducer } from "../src/broadcast.ts"; +import { accept } from "../src/connection/accept.ts"; +import { connect } from "../src/connection/connect.ts"; +import * as Epoch from "../src/epoch.ts"; +import { ALPN_06 } from "../src/lite/version.ts"; +import { createMockTransportPair } from "../src/mock.ts"; +import { Producer as OriginProducer } from "../src/origin.ts"; +import * as Path from "../src/path.ts"; +import { Front } from "../src/resume.ts"; +import { wireOf } from "../src/wire.ts"; + +const PAYLOAD = 4096; +const MAX_DELAY = Milli(1000); +/** Past every cache's age-out, including the 30 s window a closed track's cache drains over. */ +const EXPIRY = Nano.fromMilli(Milli(60_000)); +const url = new URL("https://localhost/resume-history"); +type History = "stable" | "churn"; +type Access = "lookup" | "fetch" | "subscribe"; + +/** Full collections, each from a fresh task so no frame of the caller keeps a target. */ +async function collect(): Promise { + const gc = (globalThis as { gc?: () => void }).gc; + if (!gc) throw new Error("resume-history needs a precise collector: run it with bench/browser/micro.ts"); + for (let pass = 0; pass < 3; pass++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + gc(); + } +} + +/** + * Age out every cache and collect until what is reachable stops changing: teardown runs on host + * tasks and clock timers, and either may arm the other. + */ +async function drain(clock: Manual, probes: Probe[]): Promise { + let last = ""; + for (let round = 0; round < 8; round++) { + await clock.advance(EXPIRY); + await collect(); + const reachable = JSON.stringify(count(probes)); + if (reachable === last) return; + last = reachable; + } + throw new Error(`reachable routes never settled: ${last}`); +} + +/** The JS heap in use, exact under Chromium's `--enable-precise-memory-info`. */ +function heap(): number { + const memory = (performance as { memory?: { usedJSHeapSize: number } }).memory; + if (!memory) throw new Error("resume-history needs performance.memory"); + return memory.usedJSHeapSize; +} + +/** What one route leaves behind, observed only weakly. */ +interface Probe { + session: WeakRef; + transport: WeakRef; + broadcasts: WeakRef[]; + payloads: WeakRef[]; +} + +interface Retained { + sessions: number; + transports: number; + broadcasts: number; + /** Delivered payload backing stores still reachable, each counted once. */ + bytes: number; +} + +function count(probes: Probe[]): Retained { + const buffers = new Set(); + let [sessions, transports, broadcasts] = [0, 0, 0]; + for (const probe of probes) { + if (probe.session.deref()) sessions++; + if (probe.transport.deref()) transports++; + for (const broadcast of probe.broadcasts) if (broadcast.deref()) broadcasts++; + for (const payload of probe.payloads) { + const buffer = payload.deref(); + if (buffer) buffers.add(buffer); + } + } + let bytes = 0; + for (const buffer of buffers) bytes += buffer.byteLength; + return { sessions, transports, broadcasts, bytes }; +} + +const names = (history: History, route: number, count: number) => + Array.from({ length: count }, (_, index) => (history === "stable" ? `t${index}` : `r${route}t${index}`)); + +/** Read every name through `front` the chosen way, closing each reader, and keep the payloads weakly. */ +async function read(front: Front, tracks: string[], access: Access, payloads: WeakRef[]): Promise { + for (const name of tracks) { + const track = front.consumer.track(name); + if (access === "lookup") { + await track.info(); + continue; + } + const subscriber = access === "subscribe" ? track.subscribe({ maxDelay: MAX_DELAY }) : undefined; + const group = subscriber ? await subscriber.recvGroup() : await track.fetchGroup(0); + const frame = await group?.readFrame(); + group?.close(); + subscriber?.close(); + if (frame?.payload.byteLength !== PAYLOAD || frame.payload.some((byte) => byte !== 7)) + throw new Error(`${name}: wrong payload`); + payloads.push(new WeakRef(frame.payload.buffer as ArrayBuffer)); + } +} + +interface Case { + fronts: number; + names: number; + routes: number; + history: History; + access: Access; +} + +interface Result { + /** Microseconds in `Front.serve` per front per route. */ + serveUs: number; + /** Microseconds per read, including the session round trips it waits on. */ + readUs: number; + retired: Retained; + serving: Retained; + /** JS heap the fronts and their serving route kept, against the same heap once concluded. */ + heldBytes: number; + concluded: Retained; +} + +/** + * The only strong hold on the serving route, so dropping it leaves that route to the fronts. + * Nothing about a route is returned: a suspended caller's dead locals can still pin a value. + */ +interface Routes { + /** Ends the serving route's session and drops the table's handles on it. */ + close?: () => void; + serveMs: number; + readMs: number; + probes: Probe[]; +} + +/** Connect a route, serve every front from it, end the previous one, and read through the fronts. */ +async function open( + routes: Routes, + publisher: OriginProducer, + paths: Path.Valid[], + fronts: Front[], + read: (front: Front, payloads: WeakRef[]) => Promise, +): Promise { + const pair = createMockTransportPair(ALPN_06); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ url, transport: pair.server, publish: publisher.consume() }), + ]); + const handles = paths.map((path) => wireOf(client).consume(path)); + const probe: Probe = { + session: new WeakRef(client), + transport: new WeakRef(pair.client), + broadcasts: handles.map((handle) => new WeakRef(handle)), + payloads: [], + }; + routes.probes.push(probe); + let start = performance.now(); + for (let index = 0; index < fronts.length; index++) fronts[index].serve(handles[index]); + routes.serveMs += performance.now() - start; + routes.close?.(); + routes.close = () => { + for (const handle of handles) handle.close(); + client.abort(); + server.abort(); + }; + start = performance.now(); + for (const front of fronts) await read(front, probe.payloads); + routes.readMs += performance.now() - start; +} + +/** Close every front and the serving route, in a frame of its own so no temporary outlives it. */ +function conclude(routes: Routes, fronts: Front[]): void { + for (const front of fronts.splice(0)) front.consumer.close(); + routes.close?.(); + routes.close = undefined; +} + +async function measure(shape: Case): Promise { + // Cache age-out runs on the installed clock, so moving it expires whatever only waited on it. + const clock = new Manual(); + const installed = clock.install(); + try { + return await measureOn(clock, shape); + } finally { + installed.close(); + } +} + +async function measureOn(clock: Manual, { fronts, names: perRoute, routes, history, access }: Case): Promise { + // One publisher instance per front: every route carries the same broadcasts and tracks. + const publisher = new OriginProducer(); + const paths = Array.from({ length: fronts }, (_, index) => Path.from(`room/${index}`)); + const broadcasts: BroadcastProducer[] = paths.map((path) => { + const publication = publisher.createBroadcast(path); + publication.announce({ epoch: Epoch.mint() }); + return publication.broadcast; + }); + const payload = new Uint8Array(PAYLOAD).fill(7); + for (const broadcast of broadcasts) { + const all = new Set(Array.from({ length: routes }, (_, route) => names(history, route, perRoute)).flat()); + for (const name of all) { + const track = broadcast.createTrack(name); + const group = track.appendGroup(); + group.writeFrame({ payload }); + group.finish(); + } + } + + const logical = paths.map(() => new Front()); + const state: Routes = { serveMs: 0, readMs: 0, probes: [] }; + const probes = state.probes; + for (let route = 0; route < routes; route++) { + const tracks = names(history, route, perRoute); + await open(state, publisher, paths, logical, (front, payloads) => read(front, tracks, access, payloads)); + } + + await drain(clock, probes); + const retired = count(probes.slice(0, -1)); + const serving = count(probes.slice(-1)); + const held = heap(); + + // Control: concluding the fronts and ending the last session releases everything, so a + // harness that could not see a release fails here rather than report zero. + conclude(state, logical); + for (const broadcast of broadcasts) broadcast.close(); + publisher.close(); + await drain(clock, probes); + const concluded = count(probes); + return { + serveUs: (state.serveMs * 1000) / (fronts * routes), + readUs: (state.readMs * 1000) / (fronts * routes * perRoute), + retired, + serving, + heldBytes: Math.max(0, held - heap()), + concluded, + }; +} + +/** Print every case; a reachable retired route shows in its row, for the harness to judge. */ +export async function run(): Promise { + // Keep protocol diagnostics out of the measured CSV. + console.debug = () => {}; + console.log( + "fronts,names,routes,history,access,retired_sessions,retired_transports,retired_broadcasts,retired_payload_bytes,serving_sessions,serve_us,read_us,size_bytes", + ); + for (const access of ["lookup", "fetch", "subscribe"] as const) + for (const history of ["stable", "churn"] as const) + for (const fronts of [1, 8]) + for (const perRoute of [1, 8]) + for (const routes of [2, 8, 32]) { + const result = await measure({ fronts, names: perRoute, routes, history, access }); + const { retired, serving, concluded } = result; + const label = `${fronts} fronts, ${perRoute} names, ${routes} routes, ${history}, ${access}`; + if (concluded.sessions || concluded.transports || concluded.broadcasts || concluded.bytes) + throw new Error(`${label}: the control kept ${JSON.stringify(concluded)} after concluding`); + if (serving.sessions !== 1 || serving.broadcasts !== fronts) + throw new Error(`${label}: the serving route was not observed`); + console.log( + [ + fronts, + perRoute, + routes, + history, + access, + retired.sessions, + retired.transports, + retired.broadcasts, + retired.bytes, + serving.sessions, + result.serveUs.toFixed(2), + result.readUs.toFixed(2), + result.heldBytes, + ].join(","), + ); + } +} + +if (import.meta.main) await run(); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index b7450b95376..7d28429123a 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -138,8 +138,15 @@ function ask(source: Source, name: string, retired: AbortSignal, hold?: AbortSig }); } +let sources = 0; + /** One route's broadcast, held while the front serves it or a reader still reads a copy of it. */ class Source { + /** + * Names this source where it outlives its route, as a front's record of who said what a + * track is: a reference there would keep a retired route's session reachable. + */ + readonly id = sources++; readonly wire: BroadcastWire; /** The table's handle this was served from, so serving it again is a no-op. */ readonly from: broadcast.Consumer; @@ -1516,8 +1523,11 @@ export class Front { readonly consumer: broadcast.Consumer; #source?: Source; #routes = new Map(); - /** Each track's properties as first committed, and the source that said so: every later copy must match. */ - #info = new Map(); + /** + * Each track's properties as first committed, and the {@link Source.id} of the source that said + * so: every later copy must match. Kept for the front's lifetime, so it holds no route. + */ + #info = new Map(); /** Bumped whenever the source changes, so an answer from a replaced one is dropped. */ #changed = new Signal(0); /** Aborted once the current source is replaced, retiring what was asked of it. */ @@ -1721,7 +1731,7 @@ export class Front { const previous = route.served; // The first copy of a track has nothing to match, and the source that said what it is // matches itself. - if ((!recorded && !previous) || recorded?.source === source) { + if ((!recorded && !previous) || recorded?.source === source.id) { route.serve(source); return; } @@ -1787,7 +1797,7 @@ export class Front { // never reads a copy a subscription would refuse. async #verify(name: string, source: Source): Promise { const recorded = this.#info.get(name); - if (recorded?.source === source) return; + if (recorded?.source === source.id) return; let verified = this.#verified.get(source); if (verified?.has(name)) return; const retired = this.#retire.signal; @@ -1818,7 +1828,7 @@ export class Front { } #record(name: string, info: track.Info, source: Source): void { - if (!this.#info.has(name)) this.#info.set(name, { info, source }); + if (!this.#info.has(name)) this.#info.set(name, { info, source: source.id }); } subscribe(name: string, options?: track.Subscription): track.Subscriber { diff --git a/quest/m2/performance-survey/README.md b/quest/m2/performance-survey/README.md index 7739cd159e9..7c5faa7e3d6 100644 --- a/quest/m2/performance-survey/README.md +++ b/quest/m2/performance-survey/README.md @@ -106,6 +106,14 @@ An operation-count probe imported the actual JS `bandwidth.allocate`, counted The bandwidth child must retain a reproducible benchmark, including the complete allocator and Rust consumers, before selecting a fix. +[Resume identity history](https://github.com/moq-dev/moq/pull/7514) found, in +Chromium after cache age-out, that a JS front's first-seen track record kept +every route that first answered a name reachable: 32 routes of churned names +left 31 sessions, transports and broadcast handles per front (7.7 MB of JS heap +for 8 fronts reading 8 names by lookup). Recording the answering source by id +releases them; the `resume-history` browser fixture now fails on any retired +route still reachable. + `python3 sh/gh/nightly.test.py NightlyTest.test_every_cargo_bench_target_runs_nightly` reproduced the missing `moq-archive/producer` selection. The existing [CI audit PR](https://github.com/moq-dev/moq/pull/6266) already fixes it. @@ -186,7 +194,6 @@ No duplicate quest or workflow edit belongs here. - [Buffered browser groups](/quest/m2/performance-survey/hang-buffered.md) - per-frame scans and bulk skip work - [Idle browser stats](/quest/m2/performance-survey/browser-idle-stats.md) - local transport polling without active publishing demand - [Transport preference history](/quest/m2/performance-survey/transport-history.md) - full URL retention across retired sessions -- [Resume identity history](/quest/m2/performance-survey/resume-history.md) - historical source graphs held by first-seen track metadata - [UDP socket queues](/quest/m2/performance-survey/socket-queues.md) - drop attribution and queue-size tradeoffs across workers and load - [Session datagrams](/quest/m2/performance-survey/datagram-delivery.md) - complete per-object delivery across publishers and viewers - [Announcement snapshots](/quest/m2/performance-survey/announced-snapshots.md) - HTTP polling cost versus result size and unrelated routes diff --git a/quest/m2/performance-survey/resume-history.md b/quest/m2/performance-survey/resume-history.md deleted file mode 100644 index ef5bc47230e..00000000000 --- a/quest/m2/performance-survey/resume-history.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] Resume identity retains only necessary state - -## Goal - -Long-lived browser fronts preserve track identity across routes without -unnecessarily retaining historical source object graphs. - -## Plan - -[Front metadata](/js/net/src/resume.ts) records the first `{info, source}` for -each track. A `Source` owns broadcast handles and a wire view; releasing its -last hold closes the cloned consumer but leaves those strong references. -This establishes a retained JS path, not that a closed socket or its native -buffers remain live. A fixed track set pins first-answer identities, not a -new source for every reconnect. Track-name churn plus route turnover is the -important adverse case. - -Sweep fronts × distinct first-seen track names, independently varying route -replacements. Compare one stable track across many routes against new names -first answered by each route. Close readers after lookup, fetch and subscribe; -after cache expiry and browser GC, inspect heap dominators, retained source/ -subscriber/transport objects and payload bytes. Measure transition CPU too. - -Keep the metadata proof: the same name must still mean compatible content. -Preserve incompatibility refusal, concurrent cancellation and current-source -reuse. Coordinate with [route health](/quest/m2/route-health/README.md) before -changing identity representation; do not delete history just to reduce heap. - -Follow the [survey benchmark contract](/quest/m2/performance-survey/README.md): -baseline first, paired evidence, equivalent work and nightly coverage. A -measured no-win is valid. No API or wire change is expected; report actual impact. diff --git a/sh/gh/nightly.test.py b/sh/gh/nightly.test.py index d38d68f7341..a3e88f90fe2 100644 --- a/sh/gh/nightly.test.py +++ b/sh/gh/nightly.test.py @@ -193,6 +193,13 @@ def test_every_js_bench_runs_nightly(self): continue for path in recipes[args[2]]: entries.add(ROOT / "js" / path.replace("{{ name }}", args[-1])) + # The browser smoke lane runs the in-memory net fixtures in Chromium. + self.assertIn("recipe: bench-browser --smoke", workflow) + smoke = re.search( + r"^for benchmark in (.+); do\n\s+bun micro\.ts ", (ROOT / "bench/browser/run.sh").read_text(), re.M + ) + self.assertIsNotNone(smoke) + entries |= {ROOT / "js/net/bench" / f"{name}.ts" for name in smoke[1].split()} # Bundled helpers only run through the entry that names them. helpers = { path for entry in entries for path in entry.parent.glob("*.ts") if f"/{path.name}" in entry.read_text() From f4cb3116f5d28356cc7d06755449a4e785c27ecd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:25:04 -0800 Subject: [PATCH 12/86] perf(net): cut the JS publisher's per-frame races and rank streams with one sort (#7526) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot --- js/net/bench/track.ts | 5 +- js/net/src/ietf/publisher.test.ts | 62 ++++++++++++-- js/net/src/ietf/publisher.ts | 32 +++---- js/net/src/lite/publisher.ts | 34 +++++--- js/net/src/priority.test.ts | 38 +++++++++ js/net/src/priority.ts | 45 +++++----- js/net/src/util/given_up.test.ts | 104 +++++++++++++++++++++++ js/net/src/util/given_up.ts | 98 ++++++++++++++++++--- quest/m2/perf/README.md | 1 - quest/m2/perf/js-publisher-frame-cost.md | 27 ------ 10 files changed, 352 insertions(+), 94 deletions(-) create mode 100644 js/net/src/util/given_up.test.ts delete mode 100644 quest/m2/perf/js-publisher-frame-cost.md diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 07c4dc2215e..7e4dee4b976 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -4,7 +4,7 @@ import { Milli } from "@moq/clock"; import type { Consumer as GroupConsumer, Producer as GroupProducer } from "../src/group.ts"; import { Timescale, Timestamp } from "../src/time.ts"; import { Producer, type Subscriber } from "../src/track.ts"; -import { untilGivenUp } from "../src/util/given_up.ts"; +import { GivenUp } from "../src/util/given_up.ts"; const retainedCounts = [25, 100, 400, 1500]; const subscriberCounts = [1, 4, 16]; @@ -84,7 +84,8 @@ async function measure( // The watch a publisher holds while a group stream waits for credit. const opening = Promise.withResolvers(); opened.push(opening.resolve); - reads.push(untilGivenUp(consumer, () => opening.promise)); + const givenUp = new GivenUp(consumer); + reads.push(givenUp.write(() => opening.promise).finally(() => givenUp.close())); } } } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 351c4059667..fb80ddaa63a 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -6,6 +6,7 @@ import { Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { error, + fromTransport, ProtocolViolation, SessionCode, SessionError, @@ -3608,17 +3609,22 @@ test("a paused SUBSCRIBE ends once its grant is revoked", async () => { }); // The track has no max age, so only the pause itself can release a write parked on flow control. -test("a pause resets a group stream parked on flow control", async () => { - const fx = fixture(V22); +// A group stream whose writes never complete, recording the reason it was reset with. +function stalledStream() { let writing!: () => void; const parked = new Promise((resolve) => { writing = resolve; }); - let reset = false; - const stalled = new WritableStream({ + const state: { reset?: unknown } = {}; + let abort!: (reason: unknown) => void; + const aborted = new Promise((resolve) => { + abort = resolve; + }); + const stream = new WritableStream({ start(controller) { controller.signal.addEventListener("abort", () => { - reset = true; + state.reset = controller.signal.reason; + abort(controller.signal.reason); }); }, write() { @@ -3626,6 +3632,13 @@ test("a pause resets a group stream parked on flow control", async () => { return new Promise(() => {}); }, }); + return { stream, parked, state, aborted }; +} + +// CANCELLED, as the draft asks of a reset the subscriber caused, whichever write it interrupts. +test("a pause resets a group stream parked on flow control", async () => { + const fx = fixture(V22); + const { stream: stalled, parked, state } = stalledStream(); spyOn(fx.pair.server, "createUnidirectionalStream").mockImplementationOnce(async () => stalled); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); const { client } = await runSubscribe( @@ -3646,7 +3659,8 @@ test("a pause resets a group stream parked on flow control", async () => { await client.writer.u53(SubscribeUpdate.id); await new SubscribeUpdate({ requestId: 2n, parameters }).encode(client.writer, V22); expect(await client.reader.u53()).toBe(RequestOk.id); - expect(reset).toBe(true); + expect(state.reset).toBeDefined(); + expect(fromTransport(state.reset, { version: V22 })).toMatchObject({ code: StreamCode.Cancel }); } finally { client.close(); track.finish(); @@ -3655,6 +3669,42 @@ test("a pause resets a group stream parked on flow control", async () => { } }); +// A range update that excludes a group mid-write resets its stream with CANCELLED too. +test("draft-22: narrowing resets a group stream parked on flow control", async () => { + const fx = fixture(V22); + const { stream: stalled, parked, aborted } = stalledStream(); + spyOn(fx.pair.server, "createUnidirectionalStream").mockImplementationOnce(async () => stalled); + const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); + const { client } = await runSubscribe( + fx, + new Subscribe({ + requestId: 0n, + trackNamespace: Path.from("test"), + trackName: "video", + subscriberPriority: 128, + filter: { kind: "nextObject" }, + }), + ); + const open = track.appendGroup(); + try { + open.writeFrame({ payload: new TextEncoder().encode("parked"), timestamp: Timestamp.now() }); + await parked; + const parameters = new Parameters(); + parameters.subscriptionFilter = { kind: "absolute", startGroup: 1n, startObject: 0n }; + await client.writer.u53(SubscribeUpdate.id); + await new SubscribeUpdate({ requestId: 2n, parameters }).encode(client.writer, V22); + expect(await client.reader.u53()).toBe(RequestOk.id); + const reason = await timeout(Nano.fromMilli(Milli(1000)), aborted); + expect(fromTransport(reason, { version: V22 })).toMatchObject({ code: StreamCode.Cancel }); + } finally { + open.finish(); + client.close(); + track.finish(); + track.close(); + fx.close(); + } +}); + test("namespace subscription survives requester FIN and stops on STOP_SENDING", async () => { const version = Version.DRAFT_22; const pair = createMockTransportPair(ALPN.DRAFT_22); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 5e81c425442..f03af9447fe 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -33,7 +33,7 @@ import type { Groups, Info, Position as TrackPosition, Subscriber as TrackSubscr import { untilAborted } from "../util/abort.ts"; import { nextChange } from "../util/change.ts"; import { Deadline, REQUEST_TIMEOUT_MS } from "../util/deadline.ts"; -import { untilGivenUp } from "../util/given_up.ts"; +import { GivenUp } from "../util/given_up.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import * as Varint from "../varint.ts"; import { type Advertised, type Advertisements, sameInstance, wireOf } from "../wire.ts"; @@ -1093,6 +1093,9 @@ export class Publisher { }; const changed = bounds.subscribe(narrowed); narrowed(); + // The subscription's budget, or an eviction of what is left to read, may give up on the + // group mid-write, which abandons the stream. A group already given up never starts the write. + const givenUp = new GivenUp(group); try { // One stream per group is faster than a peer at its limit can retire them, so this // is the one path that doesn't wait for a slot: the transport would serve the opens @@ -1102,9 +1105,11 @@ export class Publisher { // once the open settles. tryOpen owns resetting a slot that arrives too late. const opened = Promise.withResolvers(); let failure: Error | undefined; - const abandoned = untilGivenUp(group, () => opened.promise).then((stop) => { - failure = stop; - }); + const abandoned = givenUp + .write(() => opened.promise) + .then((stop) => { + failure = stop; + }); let stream: Writer | undefined; try { stream = await Writer.tryOpen(this.#quic, { @@ -1151,17 +1156,18 @@ export class Publisher { let left: StreamError | undefined; const cancelled = unsubscribed.then(() => { left = new StreamError(StreamCode.Cancel, { message: "unsubscribed while serving the group" }); - return undefined; }); - // The subscription's budget, or an eviction of what is left to read, may give up on the - // group mid-write, which abandons the stream. A group already given up never starts the write. + // Either ends serving the group: made once, so each read and write races one promise for both. + const stopped = race([cancelled, excluded.promise]).then(() => false as const); const write = async (op: () => Promise) => { - const stop = await untilGivenUp(group, op); + const stop = await givenUp.write(op); if (stop) throw stop; }; // Writing the group stops for the pause too; the drain below answers it without a reset. + // One race covers the budget and both, rather than racing the budget's race again. const send = async (op: () => Promise) => { - await race([write(op), cancelled, excluded.promise]); + const stop = await givenUp.write(op, stopped); + if (stop) throw stop; if (left) throw left; if (rangeError) throw rangeError; }; @@ -1184,12 +1190,7 @@ export class Publisher { // Reading from the filter's start drops the objects below it: they are outside // the requested range, so skipping them is not a gap. - const read = await race([ - hooks.readGroupFrame(group, false, slice.skip), - stream.closed, - cancelled, - excluded.promise, - ]); + const read = await race([hooks.readGroupFrame(group, false, slice.skip), stream.closed, stopped]); if (left) throw left; if (rangeError) throw rangeError; if (!read) break; @@ -1253,6 +1254,7 @@ export class Publisher { hooks.releaseGroupReader(group, error(err)); } finally { changed(); + givenUp.close(); group.close(); } } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 5ec1cff30d9..fc1edffd4cc 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -30,7 +30,7 @@ import { type Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; import { nextChange } from "../util/change.ts"; -import { untilGivenUp } from "../util/given_up.ts"; +import { GivenUp } from "../util/given_up.ts"; import { type Advertised, type Advertisements, sameInstance, wireOf } from "../wire.ts"; import { AnnounceInit, AnnounceOk, type AnnounceRequest, encodeAnnounceBroadcast } from "./announce.ts"; import { Datagram as DatagramMessage } from "./datagram.ts"; @@ -155,6 +155,8 @@ interface RunGroup { /** The group to serve. */ group: group.Consumer; + /** Why to stop writing {@link group}, watched while it is served. */ + givenUp: GivenUp; /** How every frame is stamped, or undefined to send no timestamps. */ stamp: Stamp | undefined; @@ -1482,6 +1484,7 @@ export class Publisher { const options: RunGroup = { sub, group, + givenUp: new GivenUp(group), stamp, compress, priority, @@ -1499,7 +1502,10 @@ export class Publisher { } }); groups.add(task); - void task.finally(() => groups.delete(task)); + void task.finally(() => { + options.givenUp.close(); + groups.delete(task); + }); } } finally { if (!finished) unsubscribe(); @@ -1785,7 +1791,7 @@ export class Publisher { * @internal */ async #openGroup(options: RunGroup): Promise { - const { group, priority, unsubscribed, start } = options; + const { group, givenUp, priority, unsubscribed, start } = options; try { // The transport drains streams by send order, so this is what makes a high-priority // track (and a newer group within it) win the link when there isn't room for both. @@ -1798,9 +1804,11 @@ export class Publisher { // once the open settles. tryOpen owns resetting a slot that arrives too late. const opened = Promise.withResolvers(); let failure: Error | undefined; - const abandoned = untilGivenUp(group, () => opened.promise).then((stop) => { - failure = stop; - }); + const abandoned = givenUp + .write(() => opened.promise) + .then((stop) => { + failure = stop; + }); let stream: Writer | undefined; try { stream = await Writer.tryOpen(this.#quic, { @@ -1831,13 +1839,13 @@ export class Publisher { * @internal */ async #serveGroup(stream: Writer, options: RunGroup) { - const { sub, group, stamp, compress, priority, start: startFrame, end: endFrame } = options; + const { sub, group, givenUp, stamp, compress, priority, start: startFrame, end: endFrame } = options; // The header names the requested position, so a copy missing it must be refused. const msg = new GroupMessage({ subscribe: sub, sequence: group.sequence, frameStart: startFrame }); // The subscription's budget, or an eviction of what is left to read, may give up on the // group mid-write, which abandons the stream. A group already given up never starts the write. const write = async (op: () => Promise) => { - const stop = await untilGivenUp(group, op); + const stop = await givenUp.write(op); if (stop) throw stop; }; // Everything past this point runs inside the cleanup scope, so a failure never leaves @@ -1885,10 +1893,14 @@ export class Publisher { } const header = frameHeader(stream.version, delta, read.inflated, readSize(read)); - await write(() => stream.write(header)); const chunks = read.chunks; - if (chunks) await write(() => sendPayload(chunks, (chunk) => stream.write(chunk), stream.closed)); - else if (read.frame.payload.length > 0) await write(() => stream.write(read.frame.payload)); + const payload = chunks ? undefined : read.frame.payload; + // One budget race covers the frame's header and payload. + await write(async () => { + await stream.write(header); + if (chunks) await sendPayload(chunks, (chunk) => stream.write(chunk), stream.closed); + else if (payload && payload.length > 0) await stream.write(payload); + }); } stream.close(); diff --git a/js/net/src/priority.test.ts b/js/net/src/priority.test.ts index 28f472e2aef..2b97a43826b 100644 --- a/js/net/src/priority.test.ts +++ b/js/net/src/priority.test.ts @@ -155,3 +155,41 @@ test.each(["ascending", "descending"] as const)("a fill outranks its group's liv priority.remove(other); broadcast.close(); }); + +// Every stream sits at the count of streams strictly ahead of it, the same count `rank` gives +// a stream about to open there, so identical starts share a position and the next one skips +// past both. Covers duplicates, fills within one group, and removals in both orders. +test.each(["ascending", "descending"] as const)("re-ranking matches rank for every stream in %s order", (order) => { + const { priority, open, streams, broadcast } = ranking({ priority: 7, order }); + const starts: [number, number][] = [ + [5, 0], + [3, 0], + [5, 0], + [5, 4], + [9, 0], + [3, 2], + [1, 0], + [9, 0], + ]; + const writers = starts.map(([sequence, object]) => { + const writer = open(); + priority.add(writer, sequence, object); + return writer; + }); + const check = (live: number[]) => { + for (const i of live) expect(streams[i].sendOrder).toBe(priority.rank(starts[i][0], starts[i][1])); + }; + const live = starts.map((_, i) => i); + check(live); + // Identical starts tie, and the next start counts both of them. + const lowest = order === "ascending" ? 6 : 4; + expect(streams[lowest].sendOrder).toBe(sendOrder({ priority: 7, position: 0 })); + for (const i of [2, 4, 0]) { + priority.remove(writers[i]); + live.splice(live.indexOf(i), 1); + check(live); + } + priority.close(); + for (const i of live) priority.remove(writers[i]); + broadcast.close(); +}); diff --git a/js/net/src/priority.ts b/js/net/src/priority.ts index 26879c007ba..e71a526a02b 100644 --- a/js/net/src/priority.ts +++ b/js/net/src/priority.ts @@ -60,6 +60,12 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(Math.trunc(value), min), max); } +// Where a group stream starts: its group, and its first object in that group. +interface Start { + sequence: number; + object: number; +} + /** * Ranks one subscription's group streams against each other, and re-ranks them whenever the * set changes or the subscription is updated. @@ -77,7 +83,7 @@ function clamp(value: number, min: number, max: number): number { export class Priority { #track: track.Subscriber; #publisher: number; - #streams = new Map(); + #streams = new Map(); #dispose: Dispose; #closed = false; @@ -133,34 +139,35 @@ export class Priority { if (this.#streams.size === 0) this.#dispose(); } - // Earlier objects win within one group, so a fill precedes its live rest. Group order - // still dominates, and identical starts tie without counting themselves. + // How many streams go ahead of one starting here. #position(sequence: number, object: number, order: track.Order = "descending"): number { + const start = { sequence, object }; let ahead = 0; for (const other of this.#streams.values()) { - if ( - other.sequence === sequence - ? other.object < object - : order === "ascending" - ? other.sequence < sequence - : other.sequence > sequence - ) - ahead++; + if (compareStarts(other, start, order) < 0) ahead++; } return ahead; } + // Sorted once, so ranking every stream costs one sort rather than a scan per stream: a + // stream's position is how many sort strictly ahead of it. #rerank() { const subscription = this.#track.subscription.peek(); const priority = subscription?.priority ?? 0; - for (const [stream, { sequence, object }] of this.#streams) { - stream.setPriority( - sendOrder({ - priority, - publisher: this.#publisher, - position: this.#position(sequence, object, subscription?.order), - }), - ); + const order = subscription?.order ?? "descending"; + const queue = [...this.#streams].sort(([, a], [, b]) => compareStarts(a, b, order)); + let position = 0; + for (let i = 0; i < queue.length; i++) { + const [stream, start] = queue[i]; + if (i > 0 && compareStarts(queue[i - 1][1], start, order) !== 0) position = i; + stream.setPriority(sendOrder({ priority, publisher: this.#publisher, position })); } } } + +// Negative when `a` goes before `b`. Group order dominates, earlier objects win within one +// group so a fill precedes its live rest, and identical starts tie. +function compareStarts(a: Start, b: Start, order: track.Order): number { + if (a.sequence !== b.sequence) return order === "ascending" ? a.sequence - b.sequence : b.sequence - a.sequence; + return a.object - b.object; +} diff --git a/js/net/src/util/given_up.test.ts b/js/net/src/util/given_up.test.ts new file mode 100644 index 00000000000..ca0a9bdc647 --- /dev/null +++ b/js/net/src/util/given_up.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import { yieldNow } from "@moq/clock"; +import { Signal } from "@moq/signals"; +import { StreamCode, StreamError } from "../error.ts"; +import { Producer } from "../group.ts"; +import { hooks } from "../internal.ts"; +import { GivenUp } from "./given_up.ts"; + +// A group whose budget verdict the test sets, and how many watches its drift input holds. +function served() { + const producer = new Producer(0); + const group = producer.consume(); + const expired = new Signal(false); + let watching = 0; + const subscribe = expired.subscribe.bind(expired); + expired.subscribe = (fn) => { + watching++; + const dispose = subscribe(fn); + return () => { + watching--; + dispose(); + }; + }; + hooks.expireGroup(group, { expired: () => expired.peek(), changed: [expired] }); + return { producer, group, expired, watching: () => watching }; +} + +test("a write that finishes first settles with no reason", async () => { + const { producer, group, watching } = served(); + const givenUp = new GivenUp(group); + expect(await givenUp.write(async () => {})).toBeUndefined(); + expect(await givenUp.write(async () => {})).toBeUndefined(); + // One watch for the group, not one per write. + expect(watching()).toBe(1); + givenUp.close(); + expect(watching()).toBe(0); + producer.finish(); +}); + +test("the budget giving up ends a parked write, and every later one before it starts", async () => { + const { producer, group, expired } = served(); + const givenUp = new GivenUp(group); + const parked = givenUp.write(() => new Promise(() => {})); + await yieldNow(); + expired.set(true); + const stop = await parked; + expect(stop).toBeInstanceOf(StreamError); + expect((stop as StreamError).code).toBe(StreamCode.Old); + + let started = false; + expect( + await givenUp.write(async () => { + started = true; + }), + ).toBe(stop); + expect(started).toBe(false); + givenUp.close(); + producer.finish(); +}); + +test("an aborted group ends a parked write with its error", async () => { + const { producer, group } = served(); + const givenUp = new GivenUp(group); + const parked = givenUp.write(() => new Promise(() => {})); + const failed = new Error("upstream failed"); + producer.abort(failed); + expect(await parked).toBe(failed); + givenUp.close(); +}); + +test("a clean close is no reason to stop", async () => { + const { producer, group } = served(); + const givenUp = new GivenUp(group); + let finish!: () => void; + const parked = givenUp.write(() => new Promise((resolve) => (finish = resolve))); + producer.finish(); + await yieldNow(); + finish(); + expect(await parked).toBeUndefined(); + givenUp.close(); +}); + +test("cancel ends a parked write with no reason", async () => { + const { producer, group } = served(); + const givenUp = new GivenUp(group); + const cancel = Promise.withResolvers(); + const parked = givenUp.write(() => new Promise(() => {}), cancel.promise); + cancel.resolve(false); + expect(await parked).toBeUndefined(); + expect(givenUp.peek()).toBeUndefined(); + givenUp.close(); + producer.finish(); +}); + +test("a closed watch hears no later verdict", async () => { + const { producer, group, expired, watching } = served(); + const givenUp = new GivenUp(group); + givenUp.close(); + expect(watching()).toBe(0); + expired.set(true); + await yieldNow(); + expect(givenUp.peek()).toBeUndefined(); + producer.finish(); +}); diff --git a/js/net/src/util/given_up.ts b/js/net/src/util/given_up.ts index 3c9db18a2aa..bd8acce971e 100644 --- a/js/net/src/util/given_up.ts +++ b/js/net/src/util/given_up.ts @@ -1,4 +1,4 @@ -import { race } from "@moq/signals"; +import { type Dispose, type GetPromise, race } from "@moq/signals"; import type { Consumer as GroupConsumer } from "../group.ts"; // Why to stop writing `group`: the subscription's budget gave it up, or it failed for good @@ -9,19 +9,91 @@ function reason(group: GroupConsumer): Error | undefined { } /** - * Run a publisher's write of `group`, settling with why to stop it instead if that comes - * first: `expired`, or `closed` with an error. A clean close is no reason, and a group - * already given up never starts the write. Rejects if the write does. Mirrors the Rust - * publishers' `poll_given_up`. + * Why a publisher should stop writing one group: `expired`, or `closed` with an error. A clean + * close is no reason. Settles with that reason, which latches. Mirrors the Rust publishers' + * `poll_given_up`. + * + * Watches the group once while it is served, until {@link close}, rather than once per write: + * a fresh watch per frame would subscribe to the track's drift inputs and judge the budget + * again each time. A served group spends its life parked on a read or a write, and a parked + * read watches the same inputs, so this judges no more often than a watch per write. + * + * @internal */ -export async function untilGivenUp(group: GroupConsumer, write: () => Promise): Promise { - let stop = reason(group); - if (stop) return stop; - const done = write().then(() => true as const); - for (;;) { - const closed = group.closed.peek() === undefined ? [group.closed] : []; - if ((await race([done, group.expired, ...closed])) === true) return undefined; - stop = reason(group); +export class GivenUp implements GetPromise { + #group: GroupConsumer; + #stop?: Error; + #listeners = new Set<(value: Error | undefined) => void>(); + #unwatch: Dispose[]; + + constructor(group: GroupConsumer) { + this.#group = group; + this.#unwatch = [group.expired.subscribe(() => this.#check()), group.closed.subscribe(() => this.#check())]; + } + + /** + * Run a write of the group, settling with why to stop it instead if that comes first. A + * group already given up never starts the write. Rejects if the write does. + * + * `cancel`, if given, also ends the wait, settling with `undefined` and leaving the write + * abandoned: the caller learns why from its own state. Racing it here saves a caller with + * its own reasons to stop a second race per write. Make it once per group and reuse it: a + * long-lived promise only gains and drops a listener per race. + */ + async write(write: () => Promise, cancel?: Promise): Promise { + // Judged afresh rather than read from the watch: a wall-clock deadline can pass before + // its timer wakes the watch. + const stop = this.#stop ?? reason(this.#group); if (stop) return stop; + const done = write().then(() => true as const); + const result = await race(cancel === undefined ? [done, this] : [done, this, cancel]); + return result === true || result === false ? undefined : result; + } + + /** Stop watching the group, once its publisher no longer writes it. */ + close() { + for (const dispose of this.#unwatch) dispose(); + this.#unwatch = []; + } + + peek(): Error | undefined { + return this.#stop; + } + + // It settles once, so every listener hears it at most once. + subscribe(fn: (value: Error | undefined) => void): Dispose { + if (this.#stop) return () => {}; + this.#listeners.add(fn); + return () => this.#listeners.delete(fn); + } + + changed(): Promise; + changed(fn: (value: Error | undefined) => void): Dispose; + changed(fn?: (value: Error | undefined) => void): Promise | Dispose { + if (!fn) return new Promise((resolve) => this.subscribe(resolve)); + return this.subscribe(fn); + } + + // biome-ignore lint/suspicious/noThenProperty: GivenUp is intentionally awaitable. + then( + onFulfilled?: ((value: Error) => R1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => R2 | PromiseLike) | null, + ): PromiseLike { + const stop = this.#stop; + const settled = stop + ? Promise.resolve(stop) + : new Promise((resolve) => this.subscribe((value) => value && resolve(value))); + return settled.then(onFulfilled, onRejected); + } + + #check(): void { + if (this.#stop) return; + const stop = reason(this.#group); + if (!stop) return; + this.#stop = stop; + this.close(); + const listeners = [...this.#listeners]; + this.#listeners.clear(); + for (const fn of listeners) fn(stop); } } diff --git a/quest/m2/perf/README.md b/quest/m2/perf/README.md index c6ce5757e0b..5465ca33590 100644 --- a/quest/m2/perf/README.md +++ b/quest/m2/perf/README.md @@ -101,5 +101,4 @@ revisions on the same host and worker count, and profiles with - [Lite request allocations](/quest/m2/perf/lite-request-allocs.md) - `Path::concat` allocates once and the lite request handler keeps one track-name copy - [Coverage before START](/quest/m2/perf/covered-before-start.md) - the lite-07 publisher's coverage check costs O(1) per wake before SUBSCRIBE_START - [Lock-free single-delivery writes](/quest/m2/perf/single-delivery-write.md) - a frame with one delivery writes without a lock, recovering #6922's remaining FETCH cost -- [JS publisher per-frame cost](/quest/m2/perf/js-publisher-frame-cost.md) - fewer promise races per frame, and a per-frame cost that stays flat as streams grow - [Small hot-path waste](/quest/m2/perf/hot-path-waste.md) - one track-name copy per lite subscription, no full-history `#drift` walk, and one header buffer per JS object diff --git a/quest/m2/perf/js-publisher-frame-cost.md b/quest/m2/perf/js-publisher-frame-cost.md deleted file mode 100644 index 6648271ed9a..00000000000 --- a/quest/m2/perf/js-publisher-frame-cost.md +++ /dev/null @@ -1,27 +0,0 @@ -# [M] The JS publisher's per-frame cost stays flat with stream count - -## Goal - -The js/net publisher spends less time per frame on promise races, and its -per-frame cost at 1024 concurrent streams is close to its cost at 128. - -## Plan - -Found 2026-10-28 by [moq-dev/moq#7133](https://github.com/moq-dev/moq/pull/7133), -which cut each object to one header write plus one payload write. - -- About 19% of the remaining time goes to three promise races per frame on the - IETF path. Merging two of them touches cancellation, so keep a cancelled - stream's behaviour and test it. -- Per frame, the cost is about 3x higher at 1024 streams than at 128. Find the - structure that grows with stream count. - -Decided 2026-10-28 (maintainer): leave out the timer-gap effect #7133 noted. -Browser timers get a turn less often because each stream now runs longer -between writes, and this bench's always-writable fake transport mostly causes -it. - -Measure with `just js serve-bench` in the browser; Bun or Node timing is not -acceptance evidence. - -Public API: none. Wire: none. From 42bf49af769f1989e843d148b94d7675e21322df Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:47:25 -0800 Subject: [PATCH 13/86] quest: keep @moq/qmux; npm refuses the unscoped qmux (#7584) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m1/shared-version.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/quest/m1/shared-version.md b/quest/m1/shared-version.md index 6909795887e..be93ef0e8ca 100644 --- a/quest/m1/shared-version.md +++ b/quest/m1/shared-version.md @@ -36,7 +36,10 @@ across the whole project. - Independent patches ✅ ("patch versions are not lockstep") - Exempt by name, keeping their own versions: `kio`, `kio-macros`, `qmux`, and npm `@moq/qmux`, because they are not MoQ-specific. `@moq/signals` - joins. Renaming `@moq/qmux` to a non-`@moq` name is a separate follow-up. + joins. `@moq/qmux` keeps its name and its own version line (decided + 2026-11-01): npm refused the unscoped `qmux` as too similar to the + unrelated `qm-ux`, and the maintainer chose to keep `@moq/qmux` over a + scoped or suffixed name. - Join the group - Exempt by name ✅ (Rust); npm: exempt `@moq/qmux` only ✅ - New packages start at `0.0.x`. A `0.0.x` package that a published `0.x.y` From e384cd59e8e7f139c6c51d8ddb56530bd3a31cc4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:47:58 -0800 Subject: [PATCH 14/86] perf(kio): split channel waiters off the value lock and add Watch (#7532) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- .../m2/perf/kio-channel-contention/README.md | 63 +- .../m2/perf/kio-channel-contention/version.md | 34 -- rs/kio/benches/contended.rs | 45 +- rs/kio/src/channel.rs | 556 ++++++++++++++++++ rs/kio/src/consumer.rs | 118 ++-- rs/kio/src/lib.rs | 80 +-- rs/kio/src/lock.rs | 53 +- rs/kio/src/producer.rs | 212 ++----- rs/kio/src/shared.rs | 29 +- rs/kio/src/waiter.rs | 39 +- rs/kio/src/watch.rs | 249 ++++++++ rs/kio/src/weak.rs | 95 +-- rs/moq-net/src/model/resume.rs | 60 +- rs/moq-net/src/model/track.rs | 25 +- 14 files changed, 1160 insertions(+), 498 deletions(-) delete mode 100644 quest/m2/perf/kio-channel-contention/version.md create mode 100644 rs/kio/src/channel.rs create mode 100644 rs/kio/src/watch.rs diff --git a/quest/m2/perf/kio-channel-contention/README.md b/quest/m2/perf/kio-channel-contention/README.md index ca1bdcac05e..745b5a5ed75 100644 --- a/quest/m2/perf/kio-channel-contention/README.md +++ b/quest/m2/perf/kio-channel-contention/README.md @@ -80,7 +80,7 @@ group it judges, the common verdict at the live edge (perf dropped events), so this README's end-to-end rerun is its measurement. Every hot site is a read or re-poll of a per-track channel that all of a track's subscribers share, which is the -[version counter](/quest/m2/perf/kio-channel-contention/version.md)'s target. +version counter's target (below). `GroupState` is not contended here because both shapes send one-frame groups; `group_contended` in `rs/moq-net/benches/group.rs` measures a multi-frame group instead. @@ -108,8 +108,7 @@ Decided (maintainer, 2026-10-14): mutex. - Breaking kio's API is allowed (e.g. a read becomes a snapshot or a cursor rather than a lock guard); moq-net updates in the same PR. -- Candidates kept: a version counter with split waiter lists - ([Version counter](/quest/m2/perf/kio-channel-contention/version.md)) and +- Candidates kept: a version counter with split waiter lists (landed below) and an append-only log primitive with front eviction (Append-only log (`kio::log`)), which much of moq-net could adopt @@ -228,9 +227,65 @@ DWARF stacks, 36% incomplete) put waiter registration at 1.97% self-time, the fourth-largest site, on a 501-connection chat shape. It agrees with this epic's targets but carries no lock-wait split. +Version counter (landed 2026-11-01, +[moq-dev/moq#7532](https://github.com/moq-dev/moq/pull/7532)). Every channel's +value sits under its own mutex beside an `AtomicU64` version (bumped per publish, +low bit closed), with the waiter lists under a second lock. `kio::Watch` owns a +`Consumer` and the version its one predicate was last pending at, so a re-poll at +that version parks without the value lock or the predicate (✅ maintainer +2026-11-01, over a caller-held cursor: it can't cross channels by mistake). A +noop waiter parks nowhere. moq-net watches `Subscriber::poll_finished` and +`Recover::wants`. `poll_recv_group` and `poll_recv_datagram` stay on +`Consumer::poll`: their predicates read the wall clock (a received copy's tail +grace, through `is_complete`), and their read paths lock the track in +`Cursor::sync` and `probe` anyway. + +The cost on paths that don't opt in, in instructions on one pinned core (one +codegen unit, 64 consumers, main `59e12a5bb9` to the PR): unchanged re-poll 84 +to 72, notify cycle per consumer 297 to 361, a write nobody is parked on 74 to +87, `is_closed` 21 to 7, `poll_closed` while parked 58 to 81, `read` 24 to 24. +An unchanged re-poll through a `Watch` is 25. The contended bench, ns per +consumer at 4,096 consumers and 1, 4, and 16 threads: unchanged re-poll 7.8, +63, and 70; through a `Watch` 0.84, 0.20, and 0.04; notify cycle 45, 274, and +430. Accepting that cost was the default taken for the PR's second decision +(the maintainer did not answer it separately): an unparked write skipping the +waiter lock (a SeqCst "maybe parked" flag, at a fence per park) waits for a +profile that names it. + +`just bench-profile lock fanout "1 4 16" tokio-workers` against main, three +rounds on a host at load 9 to 12, mutex wait in ms (wait/CPU). The first round +predates the noop waiter fix, whose waiter lock then held 30% of the wait at 4 +workers: + +| round | 4 workers, main | 4 workers, PR | 16 workers, main | 16 workers, PR | +| --- | --- | --- | --- | --- | +| 1 | 68 (22%) | 59 (23%) | 506 (151%) | 542 (182%) | +| 2 | 130 (56%) | 33 (13%) | 546 (172%) | 407 (165%) | +| 3 | 148 (59%) | 101 (47%) | 624 (164%) | 388 (126%) | + +At 16 workers `Subscriber::poll_finished` held 37 to 49% of main's wait and +2.6% of the PR's. The wait moved to `Cursor::sync`'s read of `TrackState` +(10 to 14% of main's, 52% of the PR's), with `probe` (11%) and +`GroupExpiry::is_expired` (8%) next: reads, which a `Watch` doesn't reach. + +`just bench-profile lock chat` against main (`88e6990626`), two rounds on a +host at load 6 to 15, 20,000 frames/s throughout: + +| round | 4 workers, main | 4 workers, PR | worker CPU, main to PR | +| --- | --- | --- | --- | +| 1 | 5,641 ms (70%) | 341 ms (5.2%) | 1.57 to 1.30 cores | +| 2 | 4,779 ms (59%) | 967 ms (13%) | 1.69 to 1.42 cores | + +1 worker measured 0 on both. At 4 workers `Cursor::poll` for +`Subscriber::poll_finished` held 48% of main's wait and none of the PR's; +`Cursor::sync` (38% of the PR's) and `probe` (15%) lead it. So the higher +notify and unparked-write costs above don't show end to end: worker CPU fell +at the same throughput. Main at 16 workers was refused twice (perf dropped +events); the PR alone measured 4,747 ms (61%) at 16 workers at load 17 to 19, +led by `Cursor::sync` (32%) and `probe` (17%), with no `poll_finished` wait. + ## Required -- [Version counter](/quest/m2/perf/kio-channel-contention/version.md) - an opted-in re-poll skips the mutex when nothing changed, and parking no longer contends with readers - [Replica spike](/quest/m2/perf/kio-channel-contention/spike-replica.md) - measures per-worker data-plane replicas linked over the in-memory transport, announcements shared - [Cluster spike](/quest/m2/perf/kio-channel-contention/spike-cluster.md) - measures a relay per thread clustered over the in-memory transport, nothing shared - [Log adoption](/quest/m2/perf/kio-channel-contention/log-adoption.md) - the rest of moq-net's log-shaped state moves onto the log where it measures as a win diff --git a/quest/m2/perf/kio-channel-contention/version.md b/quest/m2/perf/kio-channel-contention/version.md deleted file mode 100644 index 59aaf016e66..00000000000 --- a/quest/m2/perf/kio-channel-contention/version.md +++ /dev/null @@ -1,34 +0,0 @@ -# [M] kio version counter - -## Goal - -A `Consumer` re-poll that opts into version tracking and finds nothing -changed returns without taking the channel mutex, and parking a waiter no -longer contends with readers of the value. Lands with the contended bench (`rs/kio/benches/contended.rs`) and -`just bench-profile lock` before and after, against the -[epic](/quest/m2/perf/kio-channel-contention/README.md)'s baseline; a measured no-win abandons it. - -## Plan - -- An `AtomicU64` version beside the state, bumped under the lock when a `Mut` - drops modified (and on close), published with Release before the wake. -- The waiter lists move out from under the value mutex into their own lock. - Registration re-checks the version after registering, which is what keeps - wakeups from being lost now that the value check and the registration no - longer share one lock. -- `is_closed` and the closed check become atomic loads. -- This attacks spurious polls, not the wake-all herd: a woken consumer whose - data really changed still locks to read `T`. The - append-only log (`kio::log`) covers that. -- The skip is opt-in, never a silent change to `poll`: a predicate may read - state outside the channel. `Settle::poll` (`rs/moq-net/src/tail.rs`) - captures a deadline's `expired` in its closure, so skipping it on an - unchanged version after the deadline's wake would park the task forever. - Only callers whose predicate depends solely on `T` use the version-aware - path. -- Open: the shape of that path and where the last-seen version lives (the - `Consumer`, the `Waiter`, or a caller-held cursor). The `Consumer` is shared - by `&self` across waiters, which argues for a caller-held cursor. Settle it - first, with options and a recommendation. -- Generic across every `T`, with no moq-net restructure. - diff --git a/rs/kio/benches/contended.rs b/rs/kio/benches/contended.rs index 550ffea718c..ebb82b214c2 100644 --- a/rs/kio/benches/contended.rs +++ b/rs/kio/benches/contended.rs @@ -11,6 +11,8 @@ //! - `contended_repoll`: consumers re-poll a value that has not changed, as when a //! deadline or a second channel wakes their task. Workers run free, with no //! producer and no per-pass sync. +//! - `contended_repoll_watch`: the same re-poll through a [`kio::Watch`], which finds the +//! version unchanged and parks without the value lock or the predicate. //! - `contended_log`: a `GroupState`-shaped log. The producer appends a frame to a //! `VecDeque`; each consumer reads its cursor's frame, cloning it out under the //! lock, and parks again. @@ -261,6 +263,47 @@ fn bench_repoll(c: &mut Criterion) { group.finish(); } +fn bench_repoll_watch(c: &mut Criterion) { + let mut group = c.benchmark_group("contended_repoll_watch"); + for (threads, consumers) in sweep() { + group.throughput(Throughput::Elements(consumers as u64)); + let id = BenchmarkId::new(format!("threads_{threads}"), consumers); + group.bench_function(id, |b| { + let producer = Producer::new(0u64); + let work = readers(&producer, consumers, threads) + .into_iter() + .map(|set| -> Work { + let mut set: Vec<_> = set + .into_iter() + .map(|reader| (kio::Watch::new(reader.consumer), reader.waiter, reader.seen)) + .collect(); + Box::new(move || { + for (watch, waiter, seen) in &mut set { + let seen = *seen; + let changed = |state: &kio::Ref<'_, u64>| { + if **state != seen { + Poll::Ready(()) + } else { + Poll::Pending + } + }; + assert!(watch.poll(waiter, changed).is_pending()); + } + }) + }) + .collect(); + let crew = Crew::new(work); + + b.iter_custom(|iters| { + let start = Instant::now(); + crew.run(iters); + start.elapsed() + }); + }); + } + group.finish(); +} + /// A frame payload. Like `Bytes`, cloning it bumps a refcount shared by every reader. type Frame = Arc; @@ -395,6 +438,6 @@ fn bench_log_lockfree(c: &mut Criterion) { criterion_group! { name = benches; config = Criterion::default().warm_up_time(Duration::from_secs(1)).measurement_time(Duration::from_secs(3)); - targets = bench_notify, bench_repoll, bench_log, bench_log_lockfree + targets = bench_notify, bench_repoll, bench_repoll_watch, bench_log, bench_log_lockfree } criterion_main!(benches); diff --git a/rs/kio/src/channel.rs b/rs/kio/src/channel.rs new file mode 100644 index 00000000000..ec9534c06db --- /dev/null +++ b/rs/kio/src/channel.rs @@ -0,0 +1,556 @@ +//! The cell every [`Producer`](crate::Producer), [`Consumer`](crate::Consumer) and +//! [`Shared`](crate::Shared) handle points at. +//! +//! The value sits under its own mutex. Beside it, a version counts published writes and +//! carries the closed flag, so the closed checks are a load. The waiter lists sit under a +//! second lock, so parking a waiter never contends with a reader of the value. +//! +//! Splitting the locks is what lets a waiter park without the value's lock, so the check +//! and the registration no longer happen atomically. The version closes that gap: every +//! publish bumps it, under the value lock, before draining a list under the waiter lock. +//! A poll reads the version alongside the value it judged, registers, and reads it again +//! under the waiter lock. Either the drain came after the registration, and wakes it, or +//! it came before, and the second read sees its bump. +//! +//! A waiter kept across polls is usually still on the value list, so re-registering it +//! would take the waiter lock only to find it there. The list's tag, which changes on every +//! drain, is mirrored beside the version: a waiter that recorded the current tag, polled at +//! an unchanged version, skips the lock. No publish since means none it could miss, and the +//! next one's drain takes the entry the tag proves is there. +//! +//! A [`Watch`](crate::Watch) keeps the version its predicate was last pending at, so a +//! re-poll at that version skips the value lock and the predicate too, and only parks. + +use std::{ + fmt, + ops::{Deref, DerefMut}, + sync::{ + Mutex, MutexGuard, PoisonError, + atomic::{AtomicU64, Ordering}, + }, + task::Poll, +}; + +use crate::waiter::{Waiter, WaiterList}; + +/// Set in [`Channel::version`] once the channel closes. +const CLOSED: u64 = 1; + +/// What a publish adds to [`Channel::version`], leaving [`CLOSED`] alone. +const STEP: u64 = 2; + +pub(crate) struct Channel { + /// Locked by reads and writes of the value only, never to park. + value: Mutex, + /// [`STEP`] per publish, plus [`CLOSED`] once closed. Written only under `value`'s + /// lock, so a holder of that lock reads the version matching the value it sees. + version: AtomicU64, + /// The value list's [tag](WaiterList::tag), stored under the waiter lock whenever it + /// changes, so a poll can tell its waiter is still parked without that lock. + value_tag: AtomicU64, + waiters: Mutex, +} + +// SAFETY: every field but `value` is `Send + Sync` for any `T`, and `Mutex` is both +// when `T: Send`, so this is exactly the derived bound. Stating it here, like `Lock` does, +// keeps recursive channel graphs from expanding the same proof at every level. +unsafe impl Send for Channel {} + +// SAFETY: as above; `Mutex` is `Sync` when `T: Send`. +unsafe impl Sync for Channel {} + +/// Waiters split by what they're waiting on, so an event only wakes the +/// waiters that care about it. The big win is per-modification writes (the hot +/// path) waking only `value`, leaving the long-lived `closed` and `consumer` +/// waiters untouched. +#[derive(Default)] +pub(crate) struct Waiters { + /// Value changes (`poll`/`wait`). Woken on every modification. + pub value: WaiterList, + /// Closure (`closed`). Woken only when the channel closes. + pub closed: WaiterList, + /// Consumer-count changes (`wanted_changed`), in either direction. + pub consumer: WaiterList, +} + +impl Default for Channel { + fn default() -> Self { + Self::new(T::default()) + } +} + +impl Channel { + pub fn new(value: T) -> Self { + Self { + value: Mutex::new(value), + version: AtomicU64::new(0), + value_tag: AtomicU64::new(0), + waiters: Mutex::default(), + } + } + + /// Lock the value for reading. + /// + /// Poisoning is ignored: a panic while the lock is held must not cascade into every + /// other handle sharing this state, or into a double panic (and an abort) from a + /// `Drop` that locks it. + pub fn read(&self) -> Ref<'_, T> { + Ref { + value: self.value.lock().unwrap_or_else(PoisonError::into_inner), + channel: self, + } + } + + /// Lock the waiter lists. Nothing takes the value's lock while holding this one, so a + /// holder of the value's lock may take it. + pub fn waiters(&self) -> MutexGuard<'_, Waiters> { + self.waiters.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub fn version(&self) -> u64 { + self.version.load(Ordering::Acquire) + } + + pub fn is_closed(&self) -> bool { + self.version() & CLOSED != 0 + } + + /// Run a read-only predicate under the value lock, registering `waiter` for the next + /// change while it is pending, or handing back the guard once the channel closed. + #[inline] + #[cfg_attr(feature = "sim", track_caller)] + pub fn poll(&self, waiter: &Waiter, f: F) -> Poll>> + where + F: FnOnce(&Ref<'_, T>) -> Poll, + { + self.poll_judged(waiter, f, &mut 0) + } + + /// [`poll`](Self::poll), storing in `judged` the version `f` was pending at, an open + /// one, so [`repoll`](Self::repoll) can tell nothing changed since. Left alone when + /// `f` is ready or the channel closed. + #[cfg_attr(feature = "sim", track_caller)] + pub fn poll_judged(&self, waiter: &Waiter, f: F, judged: &mut u64) -> Poll>> + where + F: FnOnce(&Ref<'_, T>) -> Poll, + { + let guard = self.read(); + if let Poll::Ready(res) = f(&guard) { + return crate::coop::ready(Ok(res)); + } + + // Read under the lock, so it is the version of the value `f` just judged. + let seen = self.version(); + if seen & CLOSED != 0 { + return crate::coop::ready(Err(guard)); + } + + drop(guard); + *judged = seen; + self.park_value(waiter, seen); + Poll::Pending + } + + /// Park `waiter` again without the value lock, if nothing published since `judged`, + /// the version a pending predicate was judged at. Returns `false`, parking nothing, + /// once something did: the predicate has to judge the new value. + /// + /// A version that was open and is unchanged is still open, since closing bumps it. + #[inline] + pub fn repoll(&self, waiter: &Waiter, judged: u64) -> bool { + if self.version() != judged { + return false; + } + if !waiter.holds(self.value_tag.load(Ordering::Acquire)) { + self.park_value_cold(waiter, judged); + } + true + } + + /// [`park_value`](Self::park_value) kept out of line, so a [`repoll`](Self::repoll) + /// that skips the lock stays small. + #[inline(never)] + fn park_value_cold(&self, waiter: &Waiter, seen: u64) { + self.park_value(waiter, seen); + } + + /// Register `waiter` for the next publish after `seen`, the version a pending predicate + /// was judged at, outside the value lock. + /// + /// A publish that landed in between already drained the list without this waiter in it, + /// so wake it now: its task polls again and judges the new value. `f` was `FnOnce`, so + /// re-running it here is not an option. + /// + /// Always inlined: once [`park_value_cold`](Self::park_value_cold) calls it too, the + /// compiler would otherwise keep it out of line, a call on every pending poll. + #[inline(always)] + pub fn park_value(&self, waiter: &Waiter, seen: u64) { + // Still on the list, and nothing published since `f` judged the value. + if waiter.holds(self.value_tag.load(Ordering::Acquire)) && self.version() == seen { + return; + } + // Nothing would see a noop waiter's wake, so it needs no entry and no lock. + if waiter.is_noop() { + return; + } + + let changed = { + let mut waiters = self.waiters(); + waiter.register(&mut waiters.value); + self.sync_tag(&waiters); + self.version() != seen + }; + if changed { + waiter.waker().wake_by_ref(); + } + } + + /// Mirror the value list's tag, after anything that may have changed it. + fn sync_tag(&self, waiters: &Waiters) { + self.value_tag.store(waiters.value.tag(), Ordering::Release); + } + + /// Poll for closure, registering `waiter` while open. + #[cfg_attr(feature = "sim", track_caller)] + pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { + if self.is_closed() { + return crate::coop::ready(()); + } + + let mut waiters = self.waiters(); + waiter.register(&mut waiters.closed); + // Closure drains this list after setting the flag, so if the drain came first + // this sees the flag. + if self.is_closed() { + return crate::coop::ready(()); + } + + Poll::Pending + } + + /// Count a write, closing the channel too when `close`. Call it holding the value's + /// lock, on an open channel, before [`wake`](Self::wake). + fn publish(&self, close: bool) { + self.version.fetch_add(STEP | close as u64, Ordering::Release); + } + + /// Close the channel, from a holder of the value's lock. Caller wakes afterwards. + pub fn close(&self, _value: &Ref<'_, T>) { + self.publish(true); + } + + /// Wake the value waiters after a publish, and every waiter when it closed the channel. + /// Call it once the value's lock is released. + pub fn wake(&self, closed: bool) { + let mut lists = { + let mut waiters = self.waiters(); + if !closed && waiters.value.is_empty() { + // A write nobody is parked on wakes nobody, so skip moving the list out. + // Drop any dead entries in place, or every later write would rescan them. + waiters.value.clear(); + self.sync_tag(&waiters); + return; + } + + // A plain modification touches only the value waiters. Closure also resolves + // the closed and consumer-count waiters. + let value = waiters.value.take(); + self.sync_tag(&waiters); + let rest = closed.then(|| [waiters.closed.take(), waiters.consumer.take()]); + (value, rest) + }; + + lists.0.wake(); + if let Some(rest) = &mut lists.1 { + for list in rest { + list.wake(); + } + } + } + + /// Wake the consumer-count waiters, after the count moved. + pub fn wake_consumer(&self) { + let mut list = self.waiters().consumer.take(); + list.wake(); + } +} + +impl fmt::Debug for Channel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("Channel"); + match self.value.try_lock() { + Ok(value) => debug.field("value", &*value), + Err(_) => debug.field("value", &""), + }; + debug.field("closed", &self.is_closed()).finish() + } +} + +/// A mutable guard over the shared state. +/// +/// Derefs to `T` for direct access. Automatically notifies all waiting consumers +/// when dropped if the state was accessed mutably. +pub struct Mut<'a, T> { + // An option so it can drop, releasing the lock, before the wake. + value: Option>, + channel: &'a Channel, + modified: bool, + closing: bool, +} + +impl<'a, T> Mut<'a, T> { + pub(crate) fn new(guard: Ref<'a, T>) -> Self { + Self { + value: Some(guard.value), + channel: guard.channel, + modified: false, + closing: false, + } + } + + /// NOTE: This takes self so it's impossible to be in a closed state. + pub fn close(mut self) { + // Every Mut is made on an open channel, and nothing closes it while the lock is held. + self.closing = true; + self.modified = true; + } +} + +impl fmt::Debug for Mut<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Mut") + .field("value", &**self) + .field("modified", &self.modified) + .finish() + } +} + +impl Deref for Mut<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.value.as_ref().unwrap() + } +} + +impl DerefMut for Mut<'_, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + // If we use the &mut then notify on Drop. + self.modified = true; + self.value.as_mut().unwrap() + } +} + +impl Drop for Mut<'_, T> { + fn drop(&mut self) { + let value = self.value.take(); + if !self.modified { + return; + } + + self.channel.publish(self.closing); + // Release the value's lock before waking, so a woken reader doesn't block on it. + drop(value); + self.channel.wake(self.closing); + } +} + +/// A read-only guard over the shared state. +/// +/// Derefs to `T` for direct access. Does not notify consumers when dropped. +pub struct Ref<'a, T> { + value: MutexGuard<'a, T>, + channel: &'a Channel, +} + +impl Ref<'_, T> { + /// Returns `true` if the channel has been closed. + pub fn is_closed(&self) -> bool { + self.channel.is_closed() + } +} + +impl Deref for Ref<'_, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Wake, Waker}, + time::Duration, + }; + + use super::*; + use crate::Producer; + + struct Count(AtomicUsize); + + impl Wake for Count { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn counting() -> (Arc, Waiter) { + let count = Arc::new(Count(AtomicUsize::new(0))); + (count.clone(), Waiter::new(Waker::from(count))) + } + + fn woken(count: &Count) -> usize { + count.0.load(Ordering::SeqCst) + } + + fn write(channel: &Channel, value: u32) { + *Mut::new(channel.read()) = value; + } + + fn pending(_: &Ref<'_, u32>) -> Poll<()> { + Poll::Pending + } + + /// A waiter still on the list re-polls without registering again, and the next + /// publish wakes it all the same. + #[test] + fn a_parked_waiter_skips_registering_and_still_wakes() { + let channel = Channel::new(0u32); + let (count, waiter) = counting(); + + assert!(channel.poll(&waiter, pending).is_pending()); + let tag = channel.value_tag.load(Ordering::Relaxed); + assert!(waiter.holds(tag), "the first poll registers"); + + assert!(channel.poll(&waiter, pending).is_pending()); + assert_eq!(channel.waiters().value.tag(), tag, "re-polling drained nothing"); + + write(&channel, 1); + assert_eq!(woken(&count), 1); + assert!( + !waiter.holds(channel.value_tag.load(Ordering::Relaxed)), + "the drain moved the tag" + ); + + assert!(channel.poll(&waiter, pending).is_pending()); + write(&channel, 2); + assert_eq!(woken(&count), 2, "the re-registration was lost"); + } + + /// A noop waiter, polled only for the answer now, parks nowhere. + #[test] + fn a_noop_waiter_parks_nowhere() { + let channel = Channel::new(0u32); + assert!(channel.poll(&Waiter::noop(), pending).is_pending()); + let producer = crate::Producer::new(0u32); + let mut watch = crate::Watch::new(producer.consume()); + assert!(watch.poll(&Waiter::noop(), pending).is_pending()); + assert!(watch.poll(&Waiter::noop(), pending).is_pending()); + assert_eq!(channel.waiters().value.tag(), 0, "the list was registered on"); + assert_eq!( + watch.consumer().state.waiters().value.tag(), + 0, + "the watch's list was registered on" + ); + } + + /// A publish that lands after the predicate judged the value, but before the waiter + /// parks, drains a list the waiter is not on yet. Parking has to catch it. + #[test] + fn a_publish_before_parking_wakes_the_waiter() { + for parked in [false, true] { + let channel = Channel::new(0u32); + let (count, waiter) = counting(); + if parked { + // On the list already, so the stale tag is what sends it to the lock. + assert!(channel.poll(&waiter, pending).is_pending()); + write(&channel, 1); + assert!(channel.poll(&waiter, pending).is_pending()); + } + + let seen = channel.version(); + let before = woken(&count); + write(&channel, 5); + // Parking wakes it either way. A parked waiter was also woken by the drain, so it + // sees one spurious wake. + channel.park_value(&waiter, seen); + assert_eq!( + woken(&count), + before + 1 + usize::from(parked), + "a publish between judging and parking was lost (parked: {parked})" + ); + } + } + + /// Each publish waits for every reader to see the last, so the reader that saw it last + /// is parking again just as the next lands, the window a lost wake hangs in. Readers + /// keep their waiter across polls, as a task's [`Park`](crate::Park) does, and half of + /// them poll through a [`Watch`](crate::Watch). + #[test] + fn racing_publishes_and_closes_wake_every_waiter() { + const READERS: usize = 4; + const STEPS: u32 = 20_000; + + struct Unpark(std::thread::Thread); + impl Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } + } + + /// Spin until `done` holds, failing once a wake was evidently lost. + #[expect( + clippy::disallowed_methods, + reason = "real threads hang on real time, which no kio clock moves" + )] + fn until(done: impl Fn() -> bool, what: &str) { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while !done() { + assert!(std::time::Instant::now() < deadline, "{what} was never woken"); + std::thread::yield_now(); + } + } + + let producer = Producer::new(0u32); + let seen = Arc::new(AtomicUsize::new(0)); + let closed = Arc::new(AtomicUsize::new(0)); + for reader in 0..READERS { + // Half re-poll through a watch, which parks without judging an unchanged value. + let mut watch = (reader % 2 == 1).then(|| crate::Watch::new(producer.consume())); + let consumer = producer.consume(); + let (seen, closed) = (seen.clone(), closed.clone()); + std::thread::spawn(move || { + let waiter = Waiter::new(Waker::from(Arc::new(Unpark(std::thread::current())))); + for target in 1..=STEPS { + let reached = |v: &Ref<'_, u32>| if **v >= target { Poll::Ready(()) } else { Poll::Pending }; + let mut poll = || match &mut watch { + Some(watch) => watch.poll(&waiter, reached).is_pending(), + None => consumer.poll(&waiter, reached).is_pending(), + }; + while poll() { + std::thread::park(); + } + seen.fetch_add(1, Ordering::SeqCst); + } + while consumer.poll_closed(&waiter).is_pending() { + std::thread::park(); + } + closed.fetch_add(1, Ordering::SeqCst); + }); + } + + for value in 1..=STEPS { + *producer.write().ok().expect("open") = value; + let want = READERS * value as usize; + until(|| seen.load(Ordering::SeqCst) == want, "a value waiter"); + } + drop(producer); + until(|| closed.load(Ordering::SeqCst) == READERS, "a closed waiter"); + } +} diff --git a/rs/kio/src/consumer.rs b/rs/kio/src/consumer.rs index b55b0be7ee2..6c45c1d1de2 100644 --- a/rs/kio/src/consumer.rs +++ b/rs/kio/src/consumer.rs @@ -3,7 +3,12 @@ use std::{ task::Poll, }; -use crate::{Counts, State, lock::*, producer::Ref, waiter::*, weak::ConsumerWeak}; +use crate::{ + Closed, Counts, + channel::{Channel, Ref}, + waiter::*, + weak::ConsumerWeak, +}; /// The consuming side of a shared state channel. /// @@ -13,7 +18,7 @@ use crate::{Counts, State, lock::*, producer::Ref, waiter::*, weak::ConsumerWeak /// (e.g. [`Producer::poll_wanted_changed`](crate::Producer::poll_wanted_changed)) are notified. #[derive(Debug)] pub struct Consumer { - pub(crate) state: Lock>, + pub(crate) state: Arc>, pub(crate) counts: Arc, } @@ -21,8 +26,8 @@ pub struct Consumer { /// /// Zero-to-one transitions share the state lock with idle teardown. Existing /// demand can be incremented without relocking when closed-channel reads are allowed. -fn mint(state: &Lock>, counts: &Arc, only_open: bool) -> Option> { - let waiters = if !only_open +fn mint(state: &Arc>, counts: &Arc, only_open: bool) -> Option> { + let first = if !only_open && counts .consumers .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { @@ -32,23 +37,19 @@ fn mint(state: &Lock>, counts: &Arc, only_open: bool) -> Opt { // The successful increment keeps the count above zero, so an idle teardown // cannot commit. Callers may already hold the state lock on this path. - None + false } else { - let mut guard = state.lock(); - if only_open && guard.closed { + let guard = state.read(); + if only_open && guard.is_closed() { return None; } - - // Wake `wanted_changed()` waiters when the first consumer appears, after the lock is - // released. - match counts.consumers.fetch_add(1, Ordering::AcqRel) { - 0 => Some(guard.waiters_consumer.take()), - _ => None, - } + counts.consumers.fetch_add(1, Ordering::AcqRel) == 0 }; - if let Some(mut waiters) = waiters { - waiters.wake(); + // Wake `wanted_changed()` waiters when the first consumer appears, after the lock is + // released. + if first { + state.wake_consumer(); } Some(Consumer { @@ -59,15 +60,50 @@ fn mint(state: &Lock>, counts: &Arc, only_open: bool) -> Opt /// Mint a consumer, whether or not the channel is still open. A closed channel's /// consumer reads its final value and then reports the closure. -pub(crate) fn consume(state: &Lock>, counts: &Arc) -> Consumer { +pub(crate) fn consume(state: &Arc>, counts: &Arc) -> Consumer { mint(state, counts, false).expect("minting is infallible for an open-or-closed channel") } /// Mint a consumer only while the channel is open. -pub(crate) fn consume_open(state: &Lock>, counts: &Arc) -> Option> { +pub(crate) fn consume_open(state: &Arc>, counts: &Arc) -> Option> { mint(state, counts, true) } +/// Poll for the consumer count's demand to differ from `last`, for every handle that +/// offers [`Producer::poll_wanted_changed`](crate::Producer::poll_wanted_changed). +#[cfg_attr(feature = "sim", track_caller)] +pub(crate) fn poll_wanted_changed( + state: &Channel, + counts: &Counts, + waiter: &Waiter, + last: bool, +) -> Poll> { + // Closure is checked first, so a closed channel reports `Closed` from every handle + // rather than a consumer count that only dropped because it's dead. + let check = || { + if state.is_closed() { + return Some(Err(Closed)); + } + let wanted = counts.consumers.load(Ordering::Relaxed) > 0; + (wanted != last).then_some(Ok(wanted)) + }; + + if let Some(res) = check() { + return crate::coop::ready(res); + } + + waiter.register(&mut state.waiters().consumer); + + // Re-check after registration: the count or the closed flag may have moved between + // the first check and the registration, and its wake drained the list before this + // waiter joined it. Both wake the list after moving, under its lock, so this sees it. + if let Some(res) = check() { + return crate::coop::ready(res); + } + + Poll::Pending +} + impl Consumer { /// Poll the shared state with a closure. /// @@ -80,68 +116,45 @@ impl Consumer { where F: FnOnce(&Ref<'_, T>) -> Poll, { - let state = self.state.lock(); - let consumer_state = Ref { state }; - - if let Poll::Ready(res) = f(&consumer_state) { - return crate::coop::ready(Ok(res)); - } - - if consumer_state.state.closed { - return crate::coop::ready(Err(consumer_state)); - } - - // Re-extract state from consumer_state to register - let mut state = consumer_state.state; - waiter.register(&mut state.waiters_value); - - Poll::Pending + self.state.poll(waiter, f) } /// Resolves once the channel is closed. #[cfg_attr(feature = "async", kio_macros::wrapper)] #[cfg_attr(feature = "sim", track_caller)] pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { - let mut state = self.state.lock(); - if state.closed { - return crate::coop::ready(()); - } - - waiter.register(&mut state.waiters_closed); - Poll::Pending + self.state.poll_closed(waiter) } /// Wait for the closure to return [`Poll::Ready`], re-polling on each state change. /// - /// Returns `Ok(R)` when the closure returns [`Poll::Ready`], or [`Closed`](crate::Closed) if the + /// Returns `Ok(R)` when the closure returns [`Poll::Ready`], or [`Closed`] if the /// channel closes first. Unlike [`poll`](Self::poll) this hands back no [`Ref`], so /// no lock guard can be held across a later `.await`; call [`read`](Self::read) if /// you need the final state. #[cfg(feature = "async")] - pub async fn wait(&self, mut f: F) -> Result + pub async fn wait(&self, mut f: F) -> Result where F: FnMut(&Ref<'_, T>) -> Poll + Unpin, { // The `Ref` is dropped here inside the closure, releasing the lock before the // caller ever sees the result. - crate::wait(move |waiter| self.poll(waiter, &mut f).map(|res| res.map_err(|_| crate::Closed))).await + crate::wait(move |waiter| self.poll(waiter, &mut f).map(|res| res.map_err(|_| Closed))).await } /// Get read-only access to the shared state. pub fn read(&self) -> Ref<'_, T> { - Ref { - state: self.state.lock(), - } + self.state.read() } /// Returns `true` if the channel has been closed by the producer. pub fn is_closed(&self) -> bool { - self.state.lock().closed + self.state.is_closed() } /// Returns `true` if both consumers share the same underlying state. pub fn same_channel(&self, other: &Self) -> bool { - self.state.is_clone(&other.state) + Arc::ptr_eq(&self.state, &other.state) } /// Create a [`ConsumerWeak`] reference to this state. @@ -168,12 +181,7 @@ impl Drop for Consumer { // We were the last consumer, so wake the `wanted_changed()` waiters. The value // and closed waiters don't care about the consumer count, so leave // them alone. - let mut waiters = { - let mut state = self.state.lock(); - state.waiters_consumer.take() - }; - - waiters.wake(); + self.state.wake_consumer(); } } diff --git a/rs/kio/src/lib.rs b/rs/kio/src/lib.rs index 40cc551e23e..9152d3a9d71 100644 --- a/rs/kio/src/lib.rs +++ b/rs/kio/src/lib.rs @@ -9,6 +9,9 @@ //! [`Shared`] is a role-less sibling: every handle can lock, read, or park on a //! predicate, with no liveness of its own. //! +//! [`Watch`] wraps a [`Consumer`] for one predicate that reads only its value, so a +//! re-poll the channel hasn't changed since skips the predicate and the lock. +//! //! [`Queue`] is a poll-native FIFO queue built in the same style: role-less //! clone-able handles, bounded or unbounded, with separate wake lists for the //! push and pop sides. @@ -33,12 +36,9 @@ #![warn(missing_docs)] -use std::{ - fmt, - ops::{Deref, DerefMut}, - sync::atomic::AtomicUsize, -}; +use std::{fmt, sync::atomic::AtomicUsize}; +mod channel; mod lock; mod waiter; @@ -53,6 +53,7 @@ mod producer; mod queue; mod shared; mod task; +mod watch; mod weak; #[cfg(test)] @@ -62,14 +63,16 @@ mod tests; pub use kio_macros::{machine, wrapper}; pub use bridge::Bridge; +pub use channel::{Mut, Ref}; pub use consumer::Consumer; pub use lock::{Lock, LockGuard}; pub use pending::Pending; -pub use producer::{Mut, Producer, Ref, Unwanted}; +pub use producer::{Producer, Unwanted}; pub use queue::{PushError, Queue}; pub use shared::Shared; pub use task::{Task, TaskKey, Tasks}; pub use waiter::{Park, Waiter, WaiterList, wait}; +pub use watch::Watch; pub use weak::{ConsumerWeak, ProducerWeak, Weak}; /// Run an `async fn` test to completion on [`clock::sim::run`]. @@ -102,7 +105,7 @@ mod heap { use std::alloc::Layout; // Not a glob: it would import `test`, kio's attribute, over the built-in one. - use super::{State, arc_heap}; + use super::{arc_heap, channel::Channel}; /// [`arc_heap`] hand-rolls what `Layout` does, because `Layout` isn't `const`. Hold /// it to the real thing, including a `T` aligned past the reference counts. @@ -122,10 +125,7 @@ mod heap { assert_eq!(arc_heap::(), layout::()); assert_eq!(arc_heap::(), layout::()); - assert_eq!( - arc_heap::>>(), - layout::>>() - ); + assert_eq!(arc_heap::>(), layout::>()); } } @@ -148,64 +148,6 @@ impl fmt::Display for Closed { impl std::error::Error for Closed {} -/// Waiters split by what they're waiting on, so an event only wakes the -/// waiters that care about it. The big win is per-modification writes (the hot -/// path) waking only `value`, leaving the long-lived `closed` and `consumer` -/// waiters untouched. -#[derive(Debug)] -pub(crate) struct State { - pub value: T, - /// Value changes (`poll`/`wait`). Woken on every modification. - pub waiters_value: waiter::WaiterList, - /// Closure (`closed`). Woken only when the channel closes. - pub waiters_closed: waiter::WaiterList, - /// Consumer-count changes (`wanted_changed`), in either direction. - pub waiters_consumer: waiter::WaiterList, - pub closed: bool, -} - -impl Default for State { - fn default() -> Self { - Self::new(Default::default()) - } -} - -impl State { - pub fn new(value: T) -> Self { - Self { - value, - closed: false, - waiters_value: waiter::WaiterList::new(), - waiters_closed: waiter::WaiterList::new(), - waiters_consumer: waiter::WaiterList::new(), - } - } - - /// Drain every waiter list. Used on close, which all waiters react to. - /// Caller wakes the returned lists after releasing the lock. - pub fn take_close_waiters(&mut self) -> [waiter::WaiterList; 3] { - [ - self.waiters_value.take(), - self.waiters_closed.take(), - self.waiters_consumer.take(), - ] - } -} - -impl Deref for State { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.value - } -} - -impl DerefMut for State { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.value - } -} - #[derive(Debug)] pub(crate) struct Counts { pub producers: AtomicUsize, diff --git a/rs/kio/src/lock.rs b/rs/kio/src/lock.rs index e5d52cf83d4..d1999a256e0 100644 --- a/rs/kio/src/lock.rs +++ b/rs/kio/src/lock.rs @@ -1,13 +1,13 @@ use std::{ fmt, ops::{Deref, DerefMut}, - sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}, + sync::{Arc, Mutex, MutexGuard, PoisonError}, }; /// A cloneable mutex wrapper backed by `Arc>`. /// -/// Every kio channel keeps its state in one of these, with its [`WaiterList`]s inside, -/// so a state change and the wake it owes are decided under a single lock. +/// [`Queue`](crate::Queue) keeps its state in one of these, with its [`WaiterList`]s +/// inside, so a state change and the wake it owes are decided under a single lock. /// /// Poisoning is ignored: a panic while the lock is held must not cascade into every other /// handle sharing this state, or into a double panic (and an abort) from a `Drop` that locks it. @@ -56,47 +56,6 @@ impl Lock { pub fn is_clone(&self, other: &Self) -> bool { Arc::ptr_eq(&self.inner, &other.inner) } - - /// Create a handle that doesn't own the value, so it can be stored inside the - /// value itself without leaking the allocation. - pub(crate) fn downgrade(&self) -> WeakLock { - WeakLock { - inner: Arc::downgrade(&self.inner), - } - } -} - -/// A [`Lock`] that doesn't own its value, backed by `Weak>`. -pub(crate) struct WeakLock { - inner: Weak>, -} - -impl WeakLock { - /// A handle that never upgrades, for a placeholder before a value exists. - pub(crate) fn new() -> Self { - Self { inner: Weak::new() } - } - - /// Recover the owning [`Lock`], or `None` once the value has been dropped. - pub(crate) fn upgrade(&self) -> Option> { - Some(Lock { - inner: self.inner.upgrade()?, - }) - } -} - -impl Clone for WeakLock { - fn clone(&self) -> Self { - Self { - inner: self.inner.clone(), - } - } -} - -impl fmt::Debug for WeakLock { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("WeakLock").finish() - } } impl Clone for Lock { @@ -147,12 +106,6 @@ impl DerefMut for LockGuard<'_, T> { } } -impl Default for WeakLock { - fn default() -> Self { - Self::new() - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/rs/kio/src/producer.rs b/rs/kio/src/producer.rs index d376d33b0d3..0c16220840e 100644 --- a/rs/kio/src/producer.rs +++ b/rs/kio/src/producer.rs @@ -1,13 +1,12 @@ use std::{ - ops::{Deref, DerefMut}, sync::{Arc, atomic::Ordering}, task::Poll, }; use crate::{ - Closed, Counts, State, + Closed, Counts, + channel::{Channel, Mut, Ref}, consumer::{self, Consumer}, - lock::*, waiter::*, weak::{ProducerWeak, Weak}, }; @@ -20,14 +19,14 @@ use crate::{ /// producer is dropped, the channel is closed. #[derive(Debug)] pub struct Producer { - pub(crate) state: Lock>, + pub(crate) state: Arc>, pub(crate) counts: Arc, } impl Default for Producer { fn default() -> Self { Self { - state: Lock::new(State::default()), + state: Arc::default(), counts: Arc::new(Counts::default()), } } @@ -37,7 +36,8 @@ impl Producer { /// Heap bytes this channel occupies, for callers budgeting memory per channel. /// /// A channel owns two allocations regardless of how it is used: the state cell - /// holding `T`, its mutex and the three waiter lists, plus the reference counts + /// holding `T` and its mutex, the version, and the three waiter lists under their own + /// mutex, plus the reference counts /// beside it. Both are sized at compile time, so a caller charging per channel can /// derive its constant from this rather than measuring a process and pasting the /// number. @@ -47,12 +47,12 @@ impl Producer { /// channel costs this whether it has one consumer or a thousand. /// /// Excludes whatever `T` allocates on its own. - pub const HEAP: usize = crate::arc_heap::>>() + crate::arc_heap::(); + pub const HEAP: usize = crate::arc_heap::>() + crate::arc_heap::(); /// Create a new producer with the given initial value. pub fn new(value: T) -> Self { Self { - state: Lock::new(State::new(value)), + state: Arc::new(Channel::new(value)), counts: Arc::new(Counts::default()), } } @@ -75,8 +75,8 @@ impl Producer { /// a closed channel: between the two, a consumer either exists in time to /// decline the teardown or is never handed out at all. pub fn write_unwanted(&self) -> Unwanted<'_, T> { - let state = self.state.lock(); - if state.closed { + let state = self.state.read(); + if state.is_closed() { return Unwanted::Closed; } @@ -102,9 +102,9 @@ impl Producer { /// Returns `Ok(Mut)` if the channel is open, or `Err(Ref)` with /// read-only access if closed. Only locks once. pub fn write(&self) -> Result, Ref<'_, T>> { - let state = self.state.lock(); - if state.closed { - Err(Ref { state }) + let state = self.state.read(); + if state.is_closed() { + Err(state) } else { Ok(Mut::new(state)) } @@ -128,17 +128,19 @@ impl Producer { where F: FnOnce(&Ref<'_, T>) -> Poll<()>, { - let state = self.state.lock(); - if state.closed { - return crate::coop::ready(Err(Ref { state })); + let guard = self.state.read(); + // Read under the lock, so it is the version of the value `f` judges. + let seen = self.state.version(); + if guard.is_closed() { + return crate::coop::ready(Err(guard)); } - let mut guard = Ref { state }; match f(&guard) { // Upgrade the Ref to a Mut, keeping the same lock guard. - Poll::Ready(()) => crate::coop::ready(Ok(Mut::new(guard.state))), + Poll::Ready(()) => crate::coop::ready(Ok(Mut::new(guard))), Poll::Pending => { - waiter.register(&mut guard.state.waiters_value); + drop(guard); + self.state.park_value(waiter, seen); Poll::Pending } } @@ -155,19 +157,7 @@ impl Producer { where F: FnOnce(&Ref<'_, T>) -> Poll, { - let state = self.state.lock(); - let mut guard = Ref { state }; - - if let Poll::Ready(res) = f(&guard) { - return crate::coop::ready(Ok(res)); - } - - if guard.state.closed { - return crate::coop::ready(Err(guard)); - } - - waiter.register(&mut guard.state.waiters_value); - Poll::Pending + self.state.poll(waiter, f) } /// Wait until the read-only predicate holds, then acquire write access. @@ -190,13 +180,7 @@ impl Producer { #[cfg_attr(feature = "async", kio_macros::wrapper)] #[cfg_attr(feature = "sim", track_caller)] pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { - let mut state = self.state.lock(); - if state.closed { - return crate::coop::ready(()); - } - - waiter.register(&mut state.waiters_closed); - Poll::Pending + self.state.poll_closed(waiter) } /// Whether any consumer handle exists right now. @@ -212,38 +196,17 @@ impl Producer { #[cfg_attr(feature = "async", kio_macros::wrapper)] #[cfg_attr(feature = "sim", track_caller)] pub fn poll_wanted_changed(&self, waiter: &Waiter, last: bool) -> Poll> { - // Closure is checked first, so a closed channel reports `Closed` from every handle - // rather than a consumer count that only dropped because it's dead. - let mut state = self.state.lock(); - if state.closed { - return crate::coop::ready(Err(Closed)); - } - - if self.wanted() != last { - return crate::coop::ready(Ok(!last)); - } - - waiter.register(&mut state.waiters_consumer); - - // Re-check after registration to avoid a TOCTOU race where the count moves - // between the initial check and waiter registration. - if self.wanted() != last { - return crate::coop::ready(Ok(!last)); - } - - Poll::Pending + crate::consumer::poll_wanted_changed(&self.state, &self.counts, waiter, last) } /// Get read-only access to the shared state. pub fn read(&self) -> Ref<'_, T> { - Ref { - state: self.state.lock(), - } + self.state.read() } /// Returns `true` if both producers share the same underlying state. pub fn same_channel(&self, other: &Self) -> bool { - self.state.is_clone(&other.state) + Arc::ptr_eq(&self.state, &other.state) } /// Create a [`ProducerWeak`] reference that doesn't affect the producer/consumer ref counts. @@ -260,7 +223,7 @@ impl Producer { /// where a [`ProducerWeak`] would keep the allocation alive through its own value. pub fn downgrade(&self) -> Weak { Weak { - state: self.state.downgrade(), + state: Arc::downgrade(&self.state), counts: self.counts.clone(), } } @@ -279,29 +242,26 @@ impl Clone for Producer { impl Drop for Producer { fn drop(&mut self) { - let mut waiters = { + { // The count moves under the state lock, in step with the closed flag it // decides. Decrementing outside it would let `ProducerWeak::produce` slip // between the decrement and the close, handing back a producer for a // channel that is about to close. - let mut state = self.state.lock(); + let state = self.state.read(); if self.counts.producers.fetch_sub(1, Ordering::AcqRel) > 1 { return; } - if state.closed { + if state.is_closed() { return; } - // We were the last producer, so close. Every waiter reacts to closure - // (value/closed resolve, `wanted_changed` resolves to `Closed`), so drain - // every list and wake them once the lock is released. - state.closed = true; - state.take_close_waiters() - }; - - for list in &mut waiters { - list.wake(); + // We were the last producer, so close. + self.state.close(&state); } + + // Every waiter reacts to closure (value/closed resolve, `wanted_changed` resolves + // to `Closed`), so wake every list now the lock is released. + self.state.wake(true); } } @@ -319,106 +279,6 @@ pub enum Unwanted<'a, T> { Closed, } -/// A mutable guard over the shared state. -/// -/// Derefs to `T` for direct access. Automatically notifies all waiting consumers -/// when dropped if the state was accessed mutably. -#[derive(Debug)] -pub struct Mut<'a, T> { - // Its an option so we can drop it before notifying consumers. - pub(crate) state: Option>>, - pub(crate) modified: bool, -} - -impl<'a, T> Mut<'a, T> { - pub(crate) fn new(state: LockGuard<'a, State>) -> Self { - Self { - state: Some(state), - modified: false, - } - } - - /// NOTE: This takes self so it's impossible to be in a closed state. - pub fn close(mut self) { - let state = self.state.as_mut().unwrap(); - // We don't need to check for state.closed because we checked when making Mut - state.closed = true; - self.modified = true; - } -} - -impl Deref for Mut<'_, T> { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.state.as_ref().unwrap().value - } -} - -impl DerefMut for Mut<'_, T> { - fn deref_mut(&mut self) -> &mut Self::Target { - // If we use the &mut then notify on Drop. - self.modified = true; - &mut self.state.as_mut().unwrap().value - } -} - -impl Drop for Mut<'_, T> { - fn drop(&mut self) { - let mut state = self.state.take().unwrap(); - - if !self.modified { - return; - } - - if !state.closed && state.waiters_value.is_empty() { - // A write nobody is parked on wakes nobody, so skip moving the list out. Drop - // any dead entries in place, or every later write would rescan them. - state.waiters_value.clear(); - return; - } - - // Drain wakers while holding lock, then wake after releasing. - // A modification that also closed the channel (e.g. `close()`) must - // wake the closed and consumer-count waiters too, since they resolve - // on closure. A plain modification touches only the value waiters. - let mut waiters_value = state.waiters_value.take(); - let extra = state - .closed - .then(|| [state.waiters_closed.take(), state.waiters_consumer.take()]); - drop(state); // Release Mutex BEFORE waking - - waiters_value.wake(); - if let Some(mut extra) = extra { - for list in &mut extra { - list.wake(); - } - } - } -} - -/// A read-only guard over the shared state. -/// -/// Derefs to `T` for direct access. Does not notify consumers when dropped. -pub struct Ref<'a, T> { - pub(crate) state: LockGuard<'a, State>, -} - -impl Ref<'_, T> { - /// Returns `true` if the channel has been closed. - pub fn is_closed(&self) -> bool { - self.state.closed - } -} - -impl Deref for Ref<'_, T> { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.state.value - } -} - #[cfg(test)] mod test { use super::*; diff --git a/rs/kio/src/shared.rs b/rs/kio/src/shared.rs index 9728ff48019..3f003751777 100644 --- a/rs/kio/src/shared.rs +++ b/rs/kio/src/shared.rs @@ -1,9 +1,7 @@ -use std::task::Poll; +use std::{sync::Arc, task::Poll}; use crate::{ - State, - lock::Lock, - producer::{Mut, Ref}, + channel::{Channel, Mut, Ref}, waiter::*, }; @@ -21,7 +19,7 @@ use crate::{ /// flag inside `T`, maintained by the owning handles' `Clone`/`Drop` under the same lock). #[derive(Debug)] pub struct Shared { - state: Lock>, + state: Arc>, } impl Default for Shared { @@ -34,7 +32,7 @@ impl Shared { /// Create a new shared value. pub fn new(value: T) -> Self { Self { - state: Lock::new(State::new(value)), + state: Arc::new(Channel::new(value)), } } @@ -44,14 +42,12 @@ impl Shared { /// [`Mut`] wakes every parked [`poll`](Self::poll) on drop; a guard that was only /// read from wakes nobody. pub fn lock(&self) -> Mut<'_, T> { - Mut::new(self.state.lock()) + Mut::new(self.state.read()) } /// Lock the value for reading only, never waking anyone. pub fn read(&self) -> Ref<'_, T> { - Ref { - state: self.state.lock(), - } + self.state.read() } /// Poll a read-only predicate; once it holds, hand back a [`Mut`] with the lock @@ -66,14 +62,15 @@ impl Shared { where F: FnOnce(&Ref<'_, T>) -> Poll<()>, { - let mut guard = Ref { - state: self.state.lock(), - }; + let guard = self.state.read(); + // Read under the lock, so it is the version of the value `f` judges. + let seen = self.state.version(); match f(&guard) { // Upgrade the Ref to a Mut, keeping the same lock guard. - Poll::Ready(()) => crate::coop::ready(Mut::new(guard.state)), + Poll::Ready(()) => crate::coop::ready(Mut::new(guard)), Poll::Pending => { - waiter.register(&mut guard.state.waiters_value); + drop(guard); + self.state.park_value(waiter, seen); Poll::Pending } } @@ -97,7 +94,7 @@ impl Shared { /// Returns `true` if both handles share the same underlying state. pub fn same_channel(&self, other: &Self) -> bool { - self.state.is_clone(&other.state) + Arc::ptr_eq(&self.state, &other.state) } } diff --git a/rs/kio/src/waiter.rs b/rs/kio/src/waiter.rs index 1faf324ee90..2d2005cb616 100644 --- a/rs/kio/src/waiter.rs +++ b/rs/kio/src/waiter.rs @@ -41,6 +41,9 @@ pub struct Waiter { // A registration found no free slot in `parked`, so a list may hold this waiter // unrecorded and a later registration there would stack a duplicate. lost: AtomicBool, + + // Made by `noop`, so a channel parks it nowhere: nothing would ever see its wake. + noop: bool, } /// Lists a waiter remembers being parked on. A relay's busiest tasks park on up to 8. @@ -54,12 +57,25 @@ impl Waiter { shared: OnceLock::new(), parked: Default::default(), lost: AtomicBool::new(false), + noop: false, } } /// Create a no-op waiter that discards registrations. + /// + /// A channel's value poll parks it nowhere, taking no waiter lock, so polling one only + /// for the answer now costs no more than a read. pub fn noop() -> Self { - Self::new(Waker::noop().clone()) + Self { + noop: true, + ..Self::new(Waker::noop().clone()) + } + } + + /// Whether this is a [`noop`](Self::noop) waiter. + #[inline] + pub(crate) fn is_noop(&self) -> bool { + self.noop } /// Register this waiter with a [`WaiterList`] for future notification. @@ -122,6 +138,13 @@ impl Waiter { true } + /// Whether a registration under `tag` is recorded, which proves the list carrying + /// `tag` still holds this waiter. Zero, a list never registered on, matches nothing. + #[inline] + pub(crate) fn holds(&self, tag: u64) -> bool { + tag != 0 && self.parked.iter().any(|slot| slot.load(Ordering::Relaxed) == tag) + } + /// Whether every list holding this waiter is recorded, so registering it again /// cannot stack a duplicate. fn reusable(&self) -> bool { @@ -175,6 +198,7 @@ impl Clone for Waiter { shared: OnceLock::from(shared), parked: std::array::from_fn(|i| AtomicU64::new(self.parked[i].load(Ordering::Relaxed))), lost: AtomicBool::new(self.lost.load(Ordering::Relaxed)), + noop: self.noop, } } } @@ -276,6 +300,13 @@ impl WaiterList { self.entries.push(new_weak); } + /// The tag a registration records right now. It changes on every drain, so a waiter + /// that [holds](Waiter::holds) it is still here. + #[inline] + pub(crate) fn tag(&self) -> u64 { + self.tag + } + /// Start a new round, so no record of an entry made in the last one matches. fn drained(&mut self) { self.cursor = 0; @@ -474,9 +505,11 @@ mod tests { #[cfg(target_pointer_width = "64")] fn the_list_stays_small() { let list = std::mem::size_of::(); - let state = std::mem::size_of::>(); + let state = std::mem::size_of::>(); assert!(list <= 56, "WaiterList grew to {list} B"); - assert!(state <= 176, "State<()> grew to {state} B"); + // 184 B before the version counter, the value list's tag mirror (8 B each), and the + // waiter lists' own mutex moved the parking off the value's lock. + assert!(state <= 200, "Channel<()> grew to {state} B"); } #[test] diff --git a/rs/kio/src/watch.rs b/rs/kio/src/watch.rs new file mode 100644 index 00000000000..6c769d7d069 --- /dev/null +++ b/rs/kio/src/watch.rs @@ -0,0 +1,249 @@ +use std::{fmt, task::Poll}; + +use crate::{Consumer, Ref, Waiter}; + +/// No version [`Watch::judged`] can match: odd, so never an open one, and a closed one +/// only after 2^63 publishes. +const UNJUDGED: u64 = u64::MAX; + +/// A [`Consumer`] that remembers the version its predicate was last pending at, so a +/// re-poll the channel hasn't changed since skips the predicate and the value lock. +/// +/// A task re-polls every channel it waits on whenever any of them wakes it, and most of +/// those re-polls find nothing changed. [`Consumer::poll`] still locks the value to +/// judge it again. [`Self::poll`] instead compares the version: unchanged, it parks the +/// waiter again and returns [`Poll::Pending`] without calling `f`, and a waiter still +/// parked from the last poll usually takes no lock at all. +/// +/// The skip is only sound while `f` would answer the same, so a watch serves one +/// predicate, and that predicate may read only `T` and caller state that changes only +/// after it returns [`Poll::Ready`], or that the caller [`reset`](Self::reset)s it after +/// moving. A predicate that reads a clock or another channel would be skipped past the +/// change it is waiting for. Use [`Consumer::poll`] for those. +/// +/// The version is stored only when `f` returns [`Poll::Pending`], so a ready predicate is +/// judged again on every poll: being level-triggered, it stays ready. Cloning a watch +/// clones the consumer and starts it unjudged. +pub struct Watch { + consumer: Consumer, + /// The version `f` last returned [`Poll::Pending`] at, or [`UNJUDGED`]. + judged: u64, +} + +impl Watch { + /// Watch `consumer`, judging the predicate on the first poll. + pub fn new(consumer: Consumer) -> Self { + Self { + consumer, + judged: UNJUDGED, + } + } + + /// Poll the shared state with a closure, as [`Consumer::poll`] does, unless nothing + /// changed since `f` was last pending. + /// + /// Then `f` is not called: the waiter is registered for the next change and this + /// returns [`Poll::Pending`]. Any waiter will do, not only the one the last poll used. + #[cfg_attr(feature = "sim", track_caller)] + pub fn poll(&mut self, waiter: &Waiter, f: F) -> Poll>> + where + F: FnOnce(&Ref<'_, T>) -> Poll, + { + let channel = &*self.consumer.state; + if channel.repoll(waiter, self.judged) { + return Poll::Pending; + } + channel.poll_judged(waiter, f, &mut self.judged) + } + + /// Wait for the closure to return [`Poll::Ready`], as [`Consumer::wait`] does, judging + /// it only after the channel changed. + #[cfg(feature = "async")] + pub async fn wait(&mut self, mut f: F) -> Result + where + F: FnMut(&Ref<'_, T>) -> Poll + Unpin, + { + crate::wait(move |waiter| self.poll(waiter, &mut f).map(|res| res.map_err(|_| crate::Closed))).await + } + + /// Forget the version the predicate was pending at, so the next poll judges it. Call it + /// whenever caller state the predicate reads moves before it returned ready. + pub fn reset(&mut self) { + self.judged = UNJUDGED; + } + + /// The consumer being watched, for reads and polls of other predicates. + pub fn consumer(&self) -> &Consumer { + &self.consumer + } +} + +impl From> for Watch { + fn from(consumer: Consumer) -> Self { + Self::new(consumer) + } +} + +impl Clone for Watch { + fn clone(&self) -> Self { + Self::new(self.consumer.clone()) + } +} + +impl fmt::Debug for Watch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Watch") + .field("consumer", &self.consumer) + .field("judged", &self.judged) + .finish() + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Wake, Waker}, + }; + + use super::*; + use crate::Producer; + + struct Count(AtomicUsize); + + impl Wake for Count { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn counting() -> (Arc, Waiter) { + let count = Arc::new(Count(AtomicUsize::new(0))); + (count.clone(), Waiter::new(Waker::from(count))) + } + + fn woken(count: &Count) -> usize { + count.0.load(Ordering::SeqCst) + } + + /// Poll for the value to reach `target`, counting the calls of the predicate. + fn poll_at_least(watch: &mut Watch, waiter: &Waiter, target: u32, calls: &mut usize) -> Poll { + watch + .poll(waiter, |value| { + *calls += 1; + match **value >= target { + true => Poll::Ready(**value), + false => Poll::Pending, + } + }) + .map(|res| res.ok().expect("open")) + } + + /// An unchanged re-poll skips the predicate, and a publish brings it back. + #[test] + fn an_unchanged_repoll_skips_the_predicate() { + let producer = Producer::new(0u32); + let mut watch = Watch::new(producer.consume()); + let (count, waiter) = counting(); + let mut calls = 0; + + assert!(poll_at_least(&mut watch, &waiter, 2, &mut calls).is_pending()); + assert!(poll_at_least(&mut watch, &waiter, 2, &mut calls).is_pending()); + assert_eq!(calls, 1, "the re-poll judged an unchanged value"); + + *producer.write().ok().expect("open") = 1; + assert_eq!(woken(&count), 1); + assert!(poll_at_least(&mut watch, &waiter, 2, &mut calls).is_pending()); + assert_eq!(calls, 2, "the publish was skipped"); + + *producer.write().ok().expect("open") = 2; + assert_eq!(woken(&count), 2, "the skipped re-poll lost the registration"); + assert_eq!(poll_at_least(&mut watch, &waiter, 2, &mut calls), Poll::Ready(2)); + // Ready stores no version: a level-triggered predicate stays ready. + assert_eq!(poll_at_least(&mut watch, &waiter, 2, &mut calls), Poll::Ready(2)); + assert_eq!(calls, 4); + } + + /// A skipped re-poll parks whichever waiter it is given, not only the last one. + #[test] + fn a_skipped_repoll_parks_a_new_waiter() { + let producer = Producer::new(0u32); + let mut watch = Watch::new(producer.consume()); + let mut calls = 0; + + assert!(poll_at_least(&mut watch, &Waiter::noop(), 1, &mut calls).is_pending()); + let (count, waiter) = counting(); + assert!(poll_at_least(&mut watch, &waiter, 1, &mut calls).is_pending()); + assert_eq!(calls, 1); + + *producer.write().ok().expect("open") = 1; + assert_eq!(woken(&count), 1, "the new waiter was never parked"); + } + + /// Closing bumps the version, so a watch hands the closed value back. + #[test] + fn a_close_is_never_skipped() { + let producer = Producer::new(0u32); + let mut watch = Watch::new(producer.consume()); + let waiter = Waiter::noop(); + + assert!(watch.poll(&waiter, |_| Poll::<()>::Pending).is_pending()); + drop(producer); + let Poll::Ready(Err(closed)) = watch.poll(&waiter, |_| Poll::<()>::Pending) else { + panic!("the close was skipped"); + }; + assert!(closed.is_closed()); + } + + /// A reset judges the predicate again, as caller state it reads moved. + #[test] + fn a_reset_judges_again() { + let producer = Producer::new(1u32); + let mut watch = Watch::new(producer.consume()); + let waiter = Waiter::noop(); + let mut calls = 0; + + assert!(poll_at_least(&mut watch, &waiter, 2, &mut calls).is_pending()); + // The target moved below the value with no write: a skipped poll would miss it. + watch.reset(); + assert_eq!(poll_at_least(&mut watch, &waiter, 1, &mut calls), Poll::Ready(1)); + assert_eq!(calls, 2); + } + + /// A clone judges its own predicate, whatever the original last saw. + #[test] + fn a_clone_starts_unjudged() { + let producer = Producer::new(0u32); + let mut watch = Watch::new(producer.consume()); + let waiter = Waiter::noop(); + let mut calls = 0; + + assert!(poll_at_least(&mut watch, &waiter, 1, &mut calls).is_pending()); + let mut clone = watch.clone(); + assert_eq!(poll_at_least(&mut clone, &waiter, 0, &mut calls), Poll::Ready(0)); + assert_eq!(calls, 2); + } + + /// `wait` resolves on the change it waits for, past the ones it skips. + #[tokio::test] + async fn wait_resolves_on_a_later_change() { + let producer = Producer::new(0u32); + let mut watch = Watch::new(producer.consume()); + let writer = tokio::spawn(async move { + for value in 1..=3 { + *producer.write().ok().expect("open") = value; + tokio::task::yield_now().await; + } + }); + + let reached = |value: &Ref<'_, u32>| match **value { + 3 => Poll::Ready(3), + _ => Poll::Pending, + }; + assert_eq!(watch.wait(reached).await, Ok(3)); + writer.await.expect("the writer panicked"); + } +} diff --git a/rs/kio/src/weak.rs b/rs/kio/src/weak.rs index deab9d53500..6bda3eb0084 100644 --- a/rs/kio/src/weak.rs +++ b/rs/kio/src/weak.rs @@ -1,13 +1,13 @@ use std::{ - sync::{Arc, atomic::Ordering}, + sync::{self, Arc, atomic::Ordering}, task::Poll, }; use crate::{ - Closed, Counts, State, + Closed, Counts, + channel::{Channel, Ref}, consumer::Consumer, - lock::*, - producer::{Producer, Ref}, + producer::Producer, waiter::*, }; @@ -20,7 +20,7 @@ use crate::{ /// /// [`upgrade`](Self::upgrade) it back to a [`Producer`] while the channel is still live. pub struct Weak { - pub(crate) state: WeakLock>, + pub(crate) state: sync::Weak>, pub(crate) counts: Arc, } @@ -31,7 +31,7 @@ impl Weak { /// standalone (channel-less) value doesn't have. pub fn new() -> Self { Self { - state: WeakLock::new(), + state: sync::Weak::new(), counts: Arc::new(Counts::default()), } } @@ -55,8 +55,8 @@ impl Weak { /// closed. Counts as neither a producer nor a consumer. `None` once it was dropped. pub fn read(&self, f: impl FnOnce(&T) -> R) -> Option { let state = self.state.upgrade()?; - let state = Ref { state: state.lock() }; - Some(f(&state)) + let guard = state.read(); + Some(f(&guard)) } } @@ -78,7 +78,7 @@ impl Clone for Weak { impl std::fmt::Debug for Weak { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Weak") - .field("alive", &self.state.upgrade().is_some()) + .field("alive", &(self.state.strong_count() > 0)) .finish() } } @@ -92,7 +92,7 @@ impl std::fmt::Debug for Weak { /// value. Reach for [`Weak`] instead when the handle is stored inside that same state. #[derive(Debug)] pub struct ProducerWeak { - pub(crate) state: Lock>, + pub(crate) state: Arc>, pub(crate) counts: Arc, } @@ -103,8 +103,8 @@ impl ProducerWeak { // Registering under the same lock that guards `closed` is what makes the // returned producer keep the channel open: a concurrent last-producer drop // either closes first (and we bail) or sees our count and doesn't close. - let state = self.state.lock(); - if state.closed { + let state = self.state.read(); + if state.is_closed() { return None; } self.counts.producers.fetch_add(1, Ordering::Relaxed); @@ -140,27 +140,19 @@ impl ProducerWeak { /// Get read-only access to the shared state. pub fn read(&self) -> Ref<'_, T> { - Ref { - state: self.state.lock(), - } + self.state.read() } /// Returns `true` if the channel has been closed. pub fn is_closed(&self) -> bool { - self.state.lock().closed + self.state.is_closed() } /// Resolves once the channel is closed. #[cfg_attr(feature = "async", kio_macros::wrapper)] #[cfg_attr(feature = "sim", track_caller)] pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { - let mut state = self.state.lock(); - if state.closed { - return crate::coop::ready(()); - } - - waiter.register(&mut state.waiters_closed); - Poll::Pending + self.state.poll_closed(waiter) } /// Whether any consumer handle exists right now. @@ -175,31 +167,12 @@ impl ProducerWeak { /// it does, [`Closed`] if the channel closed first, else `Pending`. #[cfg_attr(feature = "sim", track_caller)] pub fn poll_wanted_changed(&self, waiter: &Waiter, last: bool) -> Poll> { - // Closure is checked first, so a closed channel reports `Closed` from every handle - // rather than a consumer count that only dropped because it's dead. - let mut state = self.state.lock(); - if state.closed { - return crate::coop::ready(Err(Closed)); - } - - if self.wanted() != last { - return crate::coop::ready(Ok(!last)); - } - - waiter.register(&mut state.waiters_consumer); - - // Re-check after registration to avoid a TOCTOU race where the count moves - // between the initial check and waiter registration. - if self.wanted() != last { - return crate::coop::ready(Ok(!last)); - } - - Poll::Pending + crate::consumer::poll_wanted_changed(&self.state, &self.counts, waiter, last) } /// Returns `true` if both handles share the same underlying state. pub fn same_channel(&self, other: &Self) -> bool { - self.state.is_clone(&other.state) + Arc::ptr_eq(&self.state, &other.state) } } @@ -218,7 +191,7 @@ impl Clone for ProducerWeak { /// only mint more [`Consumer`]s, so a read-only handle can never grow write access. #[derive(Debug)] pub struct ConsumerWeak { - pub(crate) state: Lock>, + pub(crate) state: Arc>, pub(crate) counts: Arc, } @@ -230,9 +203,7 @@ impl ConsumerWeak { /// Get read-only access to the shared state. pub fn read(&self) -> Ref<'_, T> { - Ref { - state: self.state.lock(), - } + self.state.read() } /// Poll the shared state with a closure, exactly as [`Consumer::poll`] does. @@ -245,44 +216,24 @@ impl ConsumerWeak { where F: FnOnce(&Ref<'_, T>) -> Poll, { - let state = self.state.lock(); - let consumer_state = Ref { state }; - - if let Poll::Ready(res) = f(&consumer_state) { - return crate::coop::ready(Ok(res)); - } - - if consumer_state.state.closed { - return crate::coop::ready(Err(consumer_state)); - } - - let mut state = consumer_state.state; - waiter.register(&mut state.waiters_value); - - Poll::Pending + self.state.poll(waiter, f) } /// Returns `true` if the channel has been closed. pub fn is_closed(&self) -> bool { - self.state.lock().closed + self.state.is_closed() } /// Resolves once the channel is closed. #[cfg_attr(feature = "async", kio_macros::wrapper)] #[cfg_attr(feature = "sim", track_caller)] pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { - let mut state = self.state.lock(); - if state.closed { - return crate::coop::ready(()); - } - - waiter.register(&mut state.waiters_closed); - Poll::Pending + self.state.poll_closed(waiter) } /// Returns `true` if both handles share the same underlying state. pub fn same_channel(&self, other: &Self) -> bool { - self.state.is_clone(&other.state) + Arc::ptr_eq(&self.state, &other.state) } } diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 5fbc5137807..7419d9fb8a2 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -77,6 +77,17 @@ struct Route { next_stage: u64, } +impl Route { + /// Ready once the route moved past `generation`, or the track ended. Reads only the + /// route and `generation`, so a [`kio::Watch`] may skip it while neither changed. + fn moved_past(&self, generation: u64) -> Poll<()> { + match self.generation != generation || self.phase.terminal() { + true => Poll::Ready(()), + false => Poll::Pending, + } + } +} + #[derive(Default)] enum Phase { #[default] @@ -405,15 +416,15 @@ impl Consumer { /// Poll for the route state to move past `generation`, or the track to end. fn poll_changed(&self, generation: u64, waiter: &kio::Waiter) -> Poll<()> { - match self.state.poll(waiter, |route| { - match route.generation != generation || route.phase.terminal() { - true => Poll::Ready(()), - false => Poll::Pending, - } - }) { - Poll::Pending => Poll::Pending, - Poll::Ready(_) => Poll::Ready(()), - } + self.state + .poll(waiter, |route| route.moved_past(generation)) + .map(|_| ()) + } + + /// A watch on the route, for a caller that re-polls [`Route::moved_past`] against a + /// generation it moves only once that is ready. + fn watch(&self) -> kio::Watch { + kio::Watch::new(self.state.clone()) } /// How the logical track ended: `None` while the front may still replace its copy, @@ -1372,7 +1383,7 @@ impl Reader { sequence, reader: Some(self.this.clone()), max_delay: None, - route: self.route.clone(), + route: self.route.watch(), generation: copy.generation, copy: copy.track.clone(), handoff, @@ -1907,7 +1918,10 @@ pub(super) struct Recover { /// A fetch's own age budget, asked of each route and judged while waiting on one. /// `None` leaves the publisher's retention alone; unused for a reader. max_delay: Option, - route: Consumer, + /// The route, watched for [`Self::wants`]: every read of the group that parks asks it + /// whether the route moved past [`Self::generation`]. Moving that generation any other + /// way resets the watch, or a read would skip the move. + route: kio::Watch, /// The route generation of the copy being read, and that copy. generation: u64, copy: track::Consumer, @@ -1960,10 +1974,14 @@ impl Recover { if err.is_some_and(|err| self.undelivered(err)) { return true; } - while self.route.poll_changed(self.generation, waiter).is_ready() { + while self + .route + .poll(waiter, |route| route.moved_past(self.generation)) + .is_ready() + { // A route that returned serves the copy being read again, which the reader reuses. // Closed routes stay ready forever, so they end the wait like a conclusion. - let route = self.route.state.read(); + let route = self.route.consumer().read(); if route.phase.terminal() || !route.copy.as_ref().is_some_and(|copy| copy.same_channel(&self.copy)) { return true; } @@ -2009,7 +2027,7 @@ impl Recover { } loop { let (generation, serving, end, terminal) = { - let route = self.route.state.read(); + let route = self.route.consumer().read(); ( route.generation, route.copy.clone(), @@ -2017,8 +2035,14 @@ impl Recover { route.phase.terminal(), ) }; - // Registered before looking, so a route change from here on wakes the reader. - if self.route.poll_changed(generation, waiter).is_ready() && !terminal { + // Registered before looking, so a route change from here on wakes the reader. Not + // through the watch: `generation` is the one just read, not `self.generation`. + let moved = self + .route + .consumer() + .poll(waiter, |route| route.moved_past(generation)) + .is_ready(); + if moved && !terminal { continue; } if let Some(Err(err)) = &end { @@ -2195,6 +2219,8 @@ impl Recover { /// Commit the replacement only once the caller has acquired its cursor or frame. pub(super) fn adopt(&mut self, replacement: &Replacement) { self.generation = replacement.generation; + // The generation moved without `wants` seeing it ready, so it judges the new one. + self.route.reset(); self.copy = replacement.copy.clone(); self.handoff = replacement.group.handoff(); // A later switch must keep this copy subscribed while the recovered group @@ -2378,7 +2404,7 @@ impl kio::Task for Fetching { sequence: self.sequence, reader: None, max_delay: self.options.options.max_delay, - route: self.route.clone(), + route: self.route.watch(), generation, copy: serving.clone(), handoff, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 81315adec95..288bbe11aaa 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -5740,6 +5740,10 @@ pub(super) struct Unread<'a> { } /// How a [`Subscriber`] reads: its track's own cache, or a front's routes. +#[expect( + clippy::large_enum_variant, + reason = "the plain cursor is the hot path, so it stays inline and the front's reader is boxed" +)] enum Inner { Plain(Cursor), /// A front's logical track, read straight from its routes' copies; see @@ -6195,6 +6199,9 @@ struct Cursor { /// The group read that last parked, answered again without the walk until something /// it parked on fires, once it first parks. idle: Option>, + /// [`Self::state`] for [`Self::poll_finished`], which a publisher re-polls on every + /// wake: one the track has not changed since skips the lock. Made on the first poll. + finished: Option>>, } /// A group a cursor found but has not moved past; see [`Cursor::take`]. @@ -6410,6 +6417,7 @@ impl Cursor { drops: None, misses: None, idle: None, + finished: None, } } @@ -6710,6 +6718,21 @@ impl Cursor { }) } + /// Poll for the track's declared end, or the error it was cut short with. + /// + /// Through a [`kio::Watch`]: the end and the abort are plain fields of the track, so an + /// unchanged version proves the answer unchanged. [`Self::poll`]'s tail watch is no loss + /// here: a pending answer means no end, and the tail only waits once one is declared. + fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll> { + let watch = self + .finished + .get_or_insert_with(|| Box::new(kio::Watch::new(self.state.clone()))); + Poll::Ready(match ready!(watch.poll(waiter, |state| state.poll_finished())) { + Ok(res) => res, + Err(state) => Err(state.abort.clone().unwrap_or(Error::Dropped)), + }) + } + /// Whether a group read in `walk` order would park again at once; see [`idle`]. fn parked(&self, walk: idle::Walk, waiter: &kio::Waiter) -> bool { idle::Idle::parked(&self.idle, walk, waiter) @@ -7916,7 +7939,7 @@ impl Subscriber { /// Poll for the track's declared exclusive end, without blocking. pub fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll> { match &mut self.inner { - Inner::Plain(cursor) => cursor.poll(waiter, |state| state.poll_finished()), + Inner::Plain(cursor) => cursor.poll_finished(waiter), Inner::Resume(resume, _) => resume.poll_finished(waiter), } } From d7f8841624430020175d81a9ea3466e35f20fd05 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:48:20 -0800 Subject: [PATCH 15/86] feat(bindings)!: exact {value, scale} timestamps in every binding (#6946) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol Co-authored-by: quest fixer Co-authored-by: moq-bot --- c/moq/test/probe.c | 8 +- cpp/moq/bench/main.cpp | 5 +- cpp/moq/test/probe.cpp | 54 +- cpp/obs/src/moq-output.cpp | 19 +- cpp/obs/src/moq-source.cpp | 30 +- cpp/obs/test/moq-audio-encoder-test.cpp | 28 +- cpp/obs/test/moq-output-test.cpp | 7 +- cpp/obs/test/moq-source-test.cpp | 69 ++- dart/moq/README.md | 4 +- dart/moq/bench/ffi.dart | 7 +- dart/moq/lib/audio.dart | 20 +- dart/moq/lib/json.dart | 8 +- dart/moq/lib/media.dart | 18 - dart/moq/lib/moq.dart | 2 +- dart/moq/lib/src/aliases.dart | 3 + dart/moq/lib/src/durations.dart | 20 - dart/moq/lib/src/timestamp.dart | 37 ++ dart/moq/lib/video.dart | 24 - dart/moq/test/docs/prelude.dart | 2 +- dart/moq/test/moq_test.dart | 115 ++++- dart/moq_ffi/lib/src/moq.dart | 488 +++++++++++------- dart/moq_ffi/test/leak_test.dart | 7 +- dart/moq_ffi/test/smoke_test.dart | 5 +- demo/carla/sim/moq_carla_sim/bridge.py | 3 +- demo/carla/sim/tests/conftest.py | 1 + demo/carla/sim/tests/test_bridge.py | 10 +- doc/lib/c/index.md | 3 +- doc/lib/cpp/index.md | 7 +- doc/lib/dart/index.md | 10 +- doc/lib/go/index.md | 15 +- doc/lib/kt/index.md | 8 +- doc/lib/py/index.md | 7 +- doc/lib/swift/index.md | 11 +- go/wrapper/README.md | 4 +- go/wrapper/audio/audio.go | 30 +- go/wrapper/bridge.go | 1 - go/wrapper/data_timestamps_test.go | 139 +++-- go/wrapper/doc.go | 2 +- go/wrapper/errors.go | 2 +- go/wrapper/example_test.go | 4 +- go/wrapper/internal/bridge/handles.go | 24 - go/wrapper/json/json.go | 54 +- go/wrapper/json/json_test.go | 9 +- go/wrapper/media/media.go | 41 +- go/wrapper/media/read_test.go | 17 +- go/wrapper/moq_test.go | 47 +- go/wrapper/publish.go | 24 +- go/wrapper/records.go | 69 +-- go/wrapper/records_internal_test.go | 55 +- go/wrapper/runtime_bench_test.go | 4 +- go/wrapper/subscribe.go | 15 +- go/wrapper/types.go | 6 +- go/wrapper/video/video.go | 53 +- kt/README.md | 6 +- .../kotlin/dev/moq/Aliases.kt | 5 +- .../kotlin/dev/moq/Durations.kt | 30 -- .../kotlin/dev/moq/Timestamp.kt | 34 ++ .../kotlin/dev/moq/audio/Audio.kt | 2 +- .../kotlin/dev/moq/json/Tracks.kt | 37 +- .../kotlin/dev/moq/media/Media.kt | 14 - .../kotlin/dev/moq/SmokeTest.kt | 117 ++++- .../kotlin/dev/moq/docs/Prelude.kt | 2 +- kt/moq/src/jvmTest/kotlin/dev/moq/FfiBench.kt | 8 +- py/moq-ffi/tests/exit_inside_callback.py | 6 +- py/moq-rs/README.md | 18 +- py/moq-rs/benches/ffi.py | 5 +- py/moq-rs/docs/index.md | 3 +- py/moq-rs/examples/clock.py | 6 +- py/moq-rs/examples/serve_clock.py | 6 +- py/moq-rs/moq/__init__.py | 2 + py/moq-rs/moq/_read.py | 16 +- py/moq-rs/moq/_records.py | 175 ++++++- py/moq-rs/moq/audio.py | 12 +- py/moq-rs/moq/json.py | 29 +- py/moq-rs/moq/media.py | 17 +- py/moq-rs/moq/publish.py | 9 +- py/moq-rs/moq/types.py | 17 +- py/moq-rs/moq/video.py | 22 +- py/moq-rs/tests/test_data_timestamps.py | 48 +- py/moq-rs/tests/test_local.py | 139 ++--- py/moq-rs/tests/test_media_reads.py | 22 +- py/moq-rs/tests/test_server.py | 4 +- quest/m1/README.md | 1 - quest/m1/binding-timestamps.md | 73 --- quest/m1/time-addressing/bindings.md | 4 +- rs/moq-c/README.md | 17 +- rs/moq-c/c-tests/audio-decode-output.c | 5 +- rs/moq-c/c-tests/decode-output.c | 2 +- rs/moq-c/c-tests/timestamps.c | 159 ++++++ rs/moq-c/src/api.rs | 171 +++--- rs/moq-c/src/audio.rs | 33 +- rs/moq-c/src/consume.rs | 156 +++--- rs/moq-c/src/decode.rs | 42 +- rs/moq-c/src/ffi.rs | 10 + rs/moq-c/src/publish.rs | 57 +- rs/moq-c/src/test.rs | 478 +++++++++++------ rs/moq-c/src/video.rs | 54 +- rs/moq-ffi/src/audio.rs | 47 +- rs/moq-ffi/src/bench.rs | 12 +- rs/moq-ffi/src/consumer.rs | 27 +- rs/moq-ffi/src/error.rs | 2 +- rs/moq-ffi/src/json.rs | 49 +- rs/moq-ffi/src/media.rs | 118 +++-- rs/moq-ffi/src/producer.rs | 12 +- rs/moq-ffi/src/test.rs | 367 ++++++++----- rs/moq-ffi/src/video.rs | 65 +-- swift/README.md | 4 +- swift/Sources/Moq/Aliases.swift | 4 +- swift/Sources/Moq/Json.swift | 30 +- swift/Sources/Moq/Media.swift | 10 +- swift/Sources/Moq/Timestamp.swift | 51 ++ swift/Sources/Moq/Track.swift | 22 +- swift/Tests/MoqTests/Docs/Prelude.swift | 2 +- swift/Tests/MoqTests/FfiBench.swift | 8 +- swift/Tests/MoqTests/SmokeTests.swift | 126 ++++- test/interop/clients/c/subscribe.c | 5 +- test/interop/clients/cpp/main.cpp | 12 +- test/interop/clients/go/main.go | 7 +- test/interop/clients/python/interop.py | 4 +- 119 files changed, 2821 insertions(+), 1891 deletions(-) delete mode 100644 dart/moq/lib/src/durations.dart create mode 100644 dart/moq/lib/src/timestamp.dart delete mode 100644 kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt create mode 100644 kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Timestamp.kt delete mode 100644 quest/m1/binding-timestamps.md create mode 100644 rs/moq-c/c-tests/timestamps.c create mode 100644 swift/Sources/Moq/Timestamp.swift diff --git a/c/moq/test/probe.c b/c/moq/test/probe.c index 045f6a960ac..e21c6057064 100644 --- a/c/moq/test/probe.c +++ b/c/moq/test/probe.c @@ -370,16 +370,16 @@ int main(void) moq_frame frame = moq_frame_default(); frame.payload.data = hello; frame.payload.len = sizeof(hello); - frame.timestamp_us.has_value = true; - frame.timestamp_us.value = 1000; + moq_timestamp timestamp = {1000, 1000000}; + frame.timestamp = ×tamp; ok(moq_track_producer_write_frame(track, &frame), "write_frame"); moq_frame *received = await(&reading, "read_frame"); moq_task_free(task); CHECK(received); CHECK(received->payload.len == sizeof(hello)); CHECK(memcmp(received->payload.data, hello, sizeof(hello)) == 0); - /* Lite-07 carries both the Implementation parameter and frame timestamps. */ - CHECK(received->timestamp_us.has_value && received->timestamp_us.value == 1000); + /* Lite-07 carries both the Implementation parameter and frame timestamps, at any scale. */ + CHECK(received->timestamp && received->timestamp->value * 1000000 == 1000 * received->timestamp->scale); moq_frame_free(received); CHECK(!cancelled.done); diff --git a/cpp/moq/bench/main.cpp b/cpp/moq/bench/main.cpp index c5accddfc2e..91653e4326c 100644 --- a/cpp/moq/bench/main.cpp +++ b/cpp/moq/bench/main.cpp @@ -90,7 +90,8 @@ Reader read(Case c, std::shared_ptr consumer, const std::vec require(group != nullptr); for (int f = 0; f < c.frames(); ++f) { auto frame = ok(co_await group->read_frame()); - require(frame && frame->payload == payload && frame->timestamp_us == n * c.frames() + f); + require(frame && frame->payload == payload && frame->timestamp && + frame->timestamp->value == n * c.frames() + f); } } @@ -119,7 +120,7 @@ void throughput(Case c, double budget, std::vector &results) for (auto &track : tracks) { auto group = ok(track->append_group()); for (int f = 0; f < c.frames(); ++f) - ok(group->write_frame({payload, n * c.frames() + f})); + ok(group->write_frame({payload, moq::Timestamp{n * c.frames() + f, 1000000}})); ok(group->finish()); } for (auto &reader : readers) { diff --git a/cpp/moq/test/probe.cpp b/cpp/moq/test/probe.cpp index 15f1c95510f..23e4aaacff3 100644 --- a/cpp/moq/test/probe.cpp +++ b/cpp/moq/test/probe.cpp @@ -105,6 +105,43 @@ Coroutine read_one(std::shared_ptr consumer, std::optionalpublish_track("timed", timed), "publish timed track"); + auto consumer = ok(track->consume(std::nullopt), "consume timed track"); + for (uint64_t value : {0, 1}) { + ok(track->write_frame({bytes("tick"), moq::Timestamp{value, 90000}}), "write tick"); + auto frame = ok(consumer->read_frame().get(), "read tick"); + CHECK(frame && frame->timestamp); + CHECK(frame->timestamp->value == value && frame->timestamp->scale == 90000); + } + for (const moq::Timestamp &invalid : + {moq::Timestamp{0, 0}, moq::Timestamp{0, uint64_t(1) << 62}, moq::Timestamp{uint64_t(1) << 61, 90000}}) { + auto refused = track->write_frame({bytes("tick"), invalid}); + CHECK(!refused); + CHECK(std::holds_alternative(refused.error().get_variant())); + } + + auto untimed = ok(broadcast->publish_track("untimed", std::nullopt), "publish untimed track"); + auto untimed_consumer = ok(untimed->consume(std::nullopt), "consume untimed track"); + ok(untimed->write_frame({bytes("untimed"), std::nullopt}), "write untimed"); + auto frame = ok(untimed_consumer->read_frame().get(), "read untimed"); + CHECK(frame && !frame->timestamp); + ok(broadcast->close(), "close timestamp broadcast"); +} + // Only the defaults carry moq-net's zero max delay; a bare record sets no subscriber limit. void subscription_defaults() { @@ -144,7 +181,7 @@ void audio_roundtrip() sizeof(value)); } } - ok(audio->write({250000 + packet * 20000, data}), "write audio"); + ok(audio->write({{250000 + packet * 20000, 1000000}, data}), "write audio"); } ok(audio->finish(), "finish audio"); size_t frames = 0; @@ -156,7 +193,7 @@ void audio_roundtrip() CHECK(!std::holds_alternative(read->get_variant())); // The finish ends the encoder where its eight 20 ms frames stop. if (const auto *end = std::get_if(&read->get_variant())) { - CHECK(end->end_us == 250000 + 8 * 20000); + CHECK(same_time(end->end, {250000 + 8 * 20000, 1000000})); continue; } const auto *variant = std::get_if(&read->get_variant()); @@ -164,7 +201,7 @@ void audio_roundtrip() const auto *next = &variant->frame; - CHECK(next->timestamp_us == 250000 + frames * 1000000 / 48000); + CHECK(same_time(next->timestamp, {250 * 48000 + frames * 1000, 48000000})); CHECK(next->data.size() % (2 * sizeof(float)) == 0); for (size_t sample = 0; sample < next->data.size() / (2 * sizeof(float)); sample++) { for (size_t channel = 0; channel < 2; channel++) { @@ -188,7 +225,7 @@ void audio_roundtrip() }; CHECK(power(channel) > power(1 - channel)); } - CHECK(!audio->write({0, std::vector(8)})); + CHECK(!audio->write({{0, 1000000}, std::vector(8)})); ok(broadcast->close(), "close audio broadcast"); } @@ -196,6 +233,7 @@ void audio_roundtrip() int main() { + timestamp_roundtrip(); subscription_defaults(); audio_roundtrip(); auto timebase_owner = ok(moq::BroadcastProducer::init(), "timebase owner"); @@ -296,14 +334,14 @@ int main() // The consumer survives the cancelled read and delivers the next frame. auto reading = consumer->read_frame(); - ok(track->write_frame({bytes("hello"), 1000}), "write_frame"); + ok(track->write_frame({bytes("hello"), moq::Timestamp{1000, 1000000}}), "write_frame"); auto frame = ok(reading.get(), "read_frame"); // get() consumed the state. CHECK(!reading.valid()); CHECK(frame.has_value()); CHECK(frame->payload == bytes("hello")); // Lite-07 carries both the Implementation parameter and frame timestamps. - CHECK(frame->timestamp_us == 1000); + CHECK(frame->timestamp && same_time(*frame->timestamp, {1000, 1000000})); // A continuation receives the next frame on the executor. { @@ -318,7 +356,7 @@ int main() }); // then() took the future's state. CHECK(!reading_next.valid()); - ok(track->write_frame({bytes("then"), 2000}), "write_frame"); + ok(track->write_frame({bytes("then"), moq::Timestamp{2000, 1000000}}), "write_frame"); CHECK(done.wait_for(5s)); CHECK(received && received->payload == bytes("then")); } @@ -338,7 +376,7 @@ int main() Latch done; std::optional received; auto reader = read_one(consumer, received, done); - ok(track->write_frame({bytes("co_await"), 3000}), "write_frame"); + ok(track->write_frame({bytes("co_await"), moq::Timestamp{3000, 1000000}}), "write_frame"); CHECK(done.wait_for(5s)); CHECK(received && received->payload == bytes("co_await")); (void)reader; diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index 2243dff672e..b6791870781 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -7,7 +7,6 @@ #include "moq-settings.h" #include "moq-url.h" #include "logger.h" -#include "util/util_uint64.h" #include #include @@ -605,20 +604,20 @@ void MoQOutput::Data(struct encoder_packet *packet) } // The packet's timestamp on the broadcast clock, or nothing when it falls before zero. -static std::optional PacketTimestamp(const struct encoder_packet *packet) +static std::optional PacketTimestamp(const struct encoder_packet *packet) { - // Add ~1 second offset to handle negative PTS from audio priming frames, and - // the same to video for A/V sync. - // TODO: This is slightly wrong when den is not evenly divisible by num, but close enough. - int64_t pts = packet->pts + packet->timebase_den / packet->timebase_num; - if (pts < 0) + // A tick is num/den seconds, so den units per second hold it exactly, 1001/30000 included. + // Offset by one second to cover the negative PTS of audio priming frames, and video by + // the same for A/V sync. + const int64_t value = packet->pts * packet->timebase_num + packet->timebase_den; + if (value < 0) return std::nullopt; - return util_mul_div64(pts, 1000000ULL * packet->timebase_num, packet->timebase_den); + return moq::Timestamp{static_cast(value), static_cast(packet->timebase_den)}; } -static moq::Frame PacketFrame(const struct encoder_packet *packet, uint64_t timestamp_us) +static moq::Frame PacketFrame(const struct encoder_packet *packet, const moq::Timestamp ×tamp) { - return moq::Frame{std::vector(packet->data, packet->data + packet->size), timestamp_us}; + return moq::Frame{std::vector(packet->data, packet->data + packet->size), timestamp}; } void MoQOutput::AudioData(struct encoder_packet *packet) diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index 1569b9874fc..fad23a832b9 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -76,6 +77,13 @@ namespace { struct moq_source; +// A track timestamp in the nanoseconds OBS stamps media with, floored when the scale does not +// divide a second evenly into nanoseconds. +static uint64_t moq_source_nanoseconds(const moq::Timestamp ×tamp) +{ + return util_mul_div64(timestamp.value, 1'000'000'000, timestamp.scale); +} + enum class Decoded { Pending, Ready, Failed }; // Full: the decoder refused the packet until its pending pictures are taken. enum class Sent { Accepted, Full, Failed }; @@ -87,7 +95,7 @@ struct VideoDecoder { SwsContext *sws_ctx = nullptr; bool got_keyframe = false; // End can precede the old encoder's final packets, whose timestamps are below it. - std::optional end_us; + std::optional end_ns; uint32_t frames_waiting_for_keyframe = 0; uint32_t consecutive_decode_errors = 0; obs_source_frame frame = {}; @@ -728,18 +736,18 @@ static void moq_source_on_frame(struct moq_source *ctx, const std::shared_ptrvideo_decoder; const auto &variant = (*result)->get_variant(); if (const auto *end = std::get_if(&variant)) { - decoder.end_us = end->end_us; + decoder.end_ns = moq_source_nanoseconds(end->end); moq_source_read_track(ctx, conn, track); return; } const auto *read = std::get_if(&variant); - if (!read || (decoder.end_us && read->frame.timestamp_us >= *decoder.end_us)) { + if (!read || (decoder.end_ns && moq_source_nanoseconds(read->frame.timestamp) >= *decoder.end_ns)) { // A gap loses references immediately. An end does so only at the fresh encoder, // after all old tail packets below its endpoint have entered the decoder. moq_source_drain_video(ctx, conn, track); avcodec_flush_buffers(decoder.codec_ctx); decoder.got_keyframe = false; - decoder.end_us.reset(); + decoder.end_ns.reset(); } if (!read) { moq_source_read_track(ctx, conn, track); @@ -814,7 +822,7 @@ static bool moq_source_on_audio_read(struct moq_source *ctx, const std::shared_p audio.format = AUDIO_FORMAT_FLOAT_PLANAR; audio.samples_per_sec = layout.sample_rate; // The same broadcast timeline video stamps, in the nanoseconds OBS expects. - audio.timestamp = frame.timestamp_us * 1000; + audio.timestamp = moq_source_nanoseconds(frame.timestamp); if (track == conn->pending_audio) conn->audio = std::move(conn->pending_audio); @@ -1146,9 +1154,9 @@ static std::unique_ptr moq_source_prepare_decoder(const moq::Video } } - // Packets carry the frame's microsecond timestamp, which the decoder hands back on the - // picture it belongs to. - decoder->codec_ctx->pkt_timebase = AVRational{1, 1'000'000}; + // Packets carry the frame's timestamp in OBS's nanoseconds, which the decoder hands back + // on the picture it belongs to. + decoder->codec_ctx->pkt_timebase = AVRational{1, 1'000'000'000}; // Open codec if (avcodec_open2(decoder->codec_ctx, codec, NULL) < 0) { @@ -1200,7 +1208,7 @@ static Sent moq_source_send_packet(VideoDecoder &decoder, const moq::MediaFrame packet->size = static_cast(frame_data.payload.size()); // The decoder hands this back on the picture it belongs to. Decode order has no // timestamp of its own here, so dts stays unset. - packet->pts = static_cast(frame_data.timestamp_us); + packet->pts = static_cast(moq_source_nanoseconds(frame_data.timestamp)); // Send packet to decoder int ret = avcodec_send_packet(decoder.codec_ctx, packet); @@ -1366,8 +1374,8 @@ static Decoded moq_source_receive_frame(VideoDecoder &decoder) return Decoded::Failed; } - // OBS expects nanoseconds; packets carry microseconds. - decoder.frame.timestamp = static_cast(frame->best_effort_timestamp) * 1000; + // Packets already carry the nanoseconds OBS expects. + decoder.frame.timestamp = static_cast(frame->best_effort_timestamp); decoder.frame.format = VIDEO_FORMAT_RGBA; av_frame_free(&frame); return Decoded::Ready; diff --git a/cpp/obs/test/moq-audio-encoder-test.cpp b/cpp/obs/test/moq-audio-encoder-test.cpp index 6c1f969702f..d8f3dc6c5dc 100644 --- a/cpp/obs/test/moq-audio-encoder-test.cpp +++ b/cpp/obs/test/moq-audio-encoder-test.cpp @@ -183,11 +183,11 @@ std::vector NoiseFrame(uint32_t &state, size_t samples) return pcm; } -// MoQOutput's conversion of an OBS packet's timestamp to the track's microseconds. -uint64_t PacketMicros(const encoder_packet &packet) +// MoQOutput's conversion of an OBS packet's timestamp to the track's. +moq::Timestamp PacketTimestamp(const encoder_packet &packet) { - const int64_t pts = packet.pts + packet.timebase_den / packet.timebase_num; - return static_cast(pts) * 1000000ULL * packet.timebase_num / packet.timebase_den; + const int64_t value = packet.pts * packet.timebase_num + packet.timebase_den; + return moq::Timestamp{static_cast(value), static_cast(packet.timebase_den)}; } // Publishes `count` tone packets from an encoder with `settings` the way MoQOutput @@ -242,8 +242,8 @@ void CheckSubscriberDecodes(const char *preset, size_t channels) const int count = 50; const int64_t start = 3 * RATE; // Where input sample `index` lands on the track: MoQOutput's one second offset on top. - auto micros = [&](int64_t index) { - return static_cast((start + RATE + index) * 1000000LL / RATE); + auto sample = [&](int64_t index) { + return static_cast(start + RATE + index); }; for (int i = 0; i < count; i++) { const int64_t pts = start + i * static_cast(encoder.frame_size); @@ -253,8 +253,8 @@ void CheckSubscriberDecodes(const char *preset, size_t channels) if (!packet) return; CHECK(packet->pts == pts && packet->dts == pts); - const uint64_t us = PacketMicros(*packet); - TestOk(track->write_frame({std::vector(packet->data, packet->data + packet->size), us}), + TestOk(track->write_frame({std::vector(packet->data, packet->data + packet->size), + PacketTimestamp(*packet)}), "write_frame"); TestOk(track->cut(), "cut"); } @@ -265,7 +265,7 @@ void CheckSubscriberDecodes(const char *preset, size_t channels) // with anything but its first input sample, shifts the phase and fails this. size_t decoded = 0; double error = 0, energy = 0; - std::optional first_decoded_us; + std::optional first_decoded; for (;;) { auto next = audio->next(); if (next.wait_for(std::chrono::seconds(10)) != std::future_status::ready) { @@ -281,11 +281,12 @@ void CheckSubscriberDecodes(const char *preset, size_t channels) if (!variant) break; const auto *frame = &variant->frame; - if (!first_decoded_us) - first_decoded_us = frame->timestamp_us; + if (!first_decoded) + first_decoded = frame->timestamp; const auto *samples = reinterpret_cast(frame->data.data()); const size_t frames = frame->data.size() / sizeof(float) / channels; - const int64_t offset = static_cast(frame->timestamp_us * RATE / 1000000ULL) - (start + RATE); + const int64_t offset = + static_cast(frame->timestamp.value * RATE / frame->timestamp.scale) - (start + RATE); for (size_t i = 0; i < frames; i++) { const double expected = Tone(offset + static_cast(i)); for (size_t c = 0; c < channels; c++) { @@ -299,7 +300,8 @@ void CheckSubscriberDecodes(const char *preset, size_t channels) // OBS never drains an audio encoder, so the decoder trims the 312-sample pre-skip // and nothing pads the tail: every input sample but that lookahead comes back. - CHECK(first_decoded_us == micros(0)); + // Compared as fractions of a second, since the track reads back at its own scale. + CHECK(first_decoded && first_decoded->value * RATE == sample(0) * first_decoded->scale); CHECK(decoded == count * encoder.frame_size - 312); CHECK(energy > 0 && error / energy < 0.05); printf(" %s, %zu channels: %zu samples, error %.4f\n", preset, channels, decoded, diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index dd758210273..49cd110f8b3 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -684,7 +684,8 @@ int main() // Everything up to the last frame, then the end of the track. bool ended = false; - uint64_t last_timestamp = first_frame ? first_frame->frame.timestamp_us : 0; + // Reads come back at the track's one scale, so values compare directly. + uint64_t last_timestamp = first_frame ? first_frame->frame.timestamp.value : 0; for (;;) { next = media->next(); if (next.wait_for(std::chrono::seconds(10)) != std::future_status::ready) @@ -698,10 +699,10 @@ int main() } CHECK(!std::holds_alternative((*result)->get_variant())); if (const auto *frame = std::get_if(&(*result)->get_variant())) - last_timestamp = frame->frame.timestamp_us; + last_timestamp = frame->frame.timestamp.value; } CHECK(ended); - CHECK(first_frame && last_timestamp > first_frame->frame.timestamp_us); + CHECK(first_frame && last_timestamp > first_frame->frame.timestamp.value); } } printf("stop drains the session: ok\n"); diff --git a/cpp/obs/test/moq-source-test.cpp b/cpp/obs/test/moq-source-test.cpp index 0a4fb13e0d7..85ce5d8943a 100644 --- a/cpp/obs/test/moq-source-test.cpp +++ b/cpp/obs/test/moq-source-test.cpp @@ -300,7 +300,8 @@ int g_decoded_width = 320; int g_decoded_height = 240; std::atomic g_last_decoder_extradata{-1}; std::atomic g_decoder_opens{0}; -// Only packets from the replacement use this timestamp; the old stream keeps decoding. +// Only packets from the replacement use this timestamp, in the nanoseconds packets carry; the +// old stream keeps decoding. std::atomic g_candidate_decode{0}; // 2: receive error, 3: needs more input std::atomic g_candidate_attempts{0}; // Holds packets' timestamps for reordering: each packet past the delay releases the @@ -376,7 +377,7 @@ int avcodec_send_packet(AVCodecContext *ctx, const AVPacket *packet) if (state.ready.size() >= g_decoder_capacity) return AVERROR(EAGAIN); state.last_pts = packet->pts; - if (state.last_pts == 1'000'000'000) + if (state.last_pts == 1'000'000'000'000) g_candidate_attempts++; state.held.push_back(packet->pts); if (state.held.size() > g_reorder_delay) { @@ -390,7 +391,7 @@ int avcodec_send_packet(AVCodecContext *ctx, const AVPacket *packet) int avcodec_receive_frame(AVCodecContext *ctx, AVFrame *frame) { auto &state = *static_cast(ctx->opaque); - if (state.last_pts == 1'000'000'000) { + if (state.last_pts == 1'000'000'000'000) { if (g_candidate_decode == 2) return AVERROR_INVALIDDATA; if (g_candidate_decode == 3) @@ -570,6 +571,12 @@ void report(const char *name) g_reported = g_failures; } +// A timestamp in microseconds, the scale most scenarios write at. +moq::Timestamp Micros(uint64_t us) +{ + return moq::Timestamp{us, 1'000'000}; +} + // A broadcast published on the relay, the way the MoQ output would publish it. class Publisher { public: @@ -606,9 +613,9 @@ class Publisher { { timestamp_us += 20'000; if (video) - TestOk(video->write_frame(moq::Frame{TestH264Keyframe(), timestamp_us}), "write video"); + TestOk(video->write_frame(moq::Frame{TestH264Keyframe(), Micros(timestamp_us)}), "write video"); if (audio) { - TestOk(audio->write_frame(moq::Frame{{0xfc, 0xff, 0xfe}, timestamp_us}), "write audio"); + TestOk(audio->write_frame(moq::Frame{{0xfc, 0xff, 0xfe}, Micros(timestamp_us)}), "write audio"); TestOk(audio->cut(), "cut audio"); } } @@ -807,33 +814,41 @@ int main() const auto *state = static_cast(held->video_decoder->codec_ctx->opaque); const auto pending = state->held.size(); CHECK(pending > 0); - const uint64_t end = static_cast(state->last_pts) + 1'000'000; + // The end and tail sit on a 90 kHz grid, which packets carry floored to nanoseconds. + const uint64_t end = static_cast(state->last_pts) * 9 / 100'000 + 90'000; + const auto ticks = [](uint64_t value) { + return moq::Timestamp{value, 90'000}; + }; + const auto nanos = [](uint64_t value) { + return value * 100'000 / 9; + }; auto inject = [&](moq::MediaRead read) { moq_source_on_frame(ctx, conn, held, std::optional{std::move(read)}); }; if (gap) { - inject(moq::MediaRead{moq::MediaRead::kGap{123456, std::chrono::microseconds(78901)}}); + inject(moq::MediaRead{ + moq::MediaRead::kGap{Micros(123456), std::chrono::microseconds(78901)}}); CHECK(g_output_frames == frames + static_cast(pending)); CHECK(!held->video_decoder->got_keyframe); } else { - inject(moq::MediaRead{moq::MediaRead::kEnd{end}}); + inject(moq::MediaRead{moq::MediaRead::kEnd{ticks(end)}}); CHECK(held->video_decoder->got_keyframe); // Non-key tail packets below the endpoint still depend on the old references. - inject(moq::MediaRead{ - moq::MediaRead::kFrame{moq::MediaFrame{TestH264Keyframe(), end - 1, false}}}); - CHECK(state->last_pts == static_cast(end - 1)); + inject(moq::MediaRead{moq::MediaRead::kFrame{ + moq::MediaFrame{TestH264Keyframe(), ticks(end - 1), false}}}); + CHECK(state->last_pts == static_cast(nanos(end - 1))); if (eof) { moq_source_on_frame(ctx, conn, held, std::optional{}); } else { inject(moq::MediaRead{moq::MediaRead::kFrame{ - moq::MediaFrame{TestH264Keyframe(), end, true}}}); - CHECK(state->last_pts == static_cast(end)); + moq::MediaFrame{TestH264Keyframe(), ticks(end), true}}}); + CHECK(state->last_pts == static_cast(nanos(end))); CHECK(state->held.size() == 1); } CHECK(g_output_frames == frames + static_cast(pending) + 1); std::lock_guard output_lock(g_video_mutex); CHECK(std::count(g_video_timestamps.begin(), g_video_timestamps.end(), - (end - 1) * 1000) == 1); + nanos(end - 1)) == 1); } CHECK(conn->video == held); } @@ -871,7 +886,8 @@ int main() }; for (int picture : decode_order) { const auto payload = picture == 0 ? TestH264Keyframe() : delta; - TestOk(publisher.video->write_frame(moq::Frame{payload, timestamp_us(picture)}), "write video"); + TestOk(publisher.video->write_frame(moq::Frame{payload, Micros(timestamp_us(picture))}), + "write video"); } TestOk(publisher.video->finish(), "finish video"); @@ -894,7 +910,7 @@ int main() const auto timestamp_us = [](int picture) { return 2'000'000 + uint64_t(picture) * 20'000; }; - g_unconverted_pts = int64_t(timestamp_us(2)); + g_unconverted_pts = int64_t(timestamp_us(2) * 1000); Publisher publisher(relay, "obs/test", true, false); void *source = createSource(); auto *ctx = static_cast(source); @@ -908,7 +924,8 @@ int main() std::vector expected; for (int picture = 0; picture < 6; picture++) { const auto payload = picture == 0 ? TestH264Keyframe() : delta; - TestOk(publisher.video->write_frame(moq::Frame{payload, timestamp_us(picture)}), "write video"); + TestOk(publisher.video->write_frame(moq::Frame{payload, Micros(timestamp_us(picture))}), + "write video"); expected.push_back(timestamp_us(picture) * 1000); } // Left unfinished: nothing is held back, and a finish appends an empty end-of-group frame. @@ -946,7 +963,8 @@ int main() }; CHECK(publisher.WriteUntil(pending)); // One picture, which the decoder holds until a drain. - TestOk(replacement->write_frame(moq::Frame{TestH264Keyframe(), 1'000'000'000}), "replacement frame"); + TestOk(replacement->write_frame(moq::Frame{TestH264Keyframe(), Micros(1'000'000'000)}), + "replacement frame"); TestOk(replacement->finish(), "finish replacement"); CHECK(publisher.WriteUntil([&] { return !pending(); })); const int frames = g_output_frames; @@ -973,7 +991,7 @@ int main() uint64_t samples = 0; CHECK(WaitFor([&] { for (const auto &frame : AAC_FRAMES) { - TestOk(aac->write_frame(moq::Frame{frame, 1'000'000 + samples * 1'000'000 / 44'100}), + TestOk(aac->write_frame(moq::Frame{frame, moq::Timestamp{44'100 + samples, 44'100}}), "write aac"); samples += 1024; } @@ -1389,7 +1407,7 @@ int main() const int frames = g_output_frames; CHECK(publisher.WriteUntil([&] { return g_output_frames >= frames + 10; })); CHECK(publisher.WriteUntil([&] { - TestOk(replacement->write_frame(moq::Frame{TestH264Keyframe(), 2'000'000}), + TestOk(replacement->write_frame(moq::Frame{TestH264Keyframe(), Micros(2'000'000)}), "replacement frame"); return g_last_timestamp == 2'000'000'000; })); @@ -1417,7 +1435,7 @@ int main() const auto preferring = CatalogPreferring(publisher, "!replacement"); DeliverCatalog(ctx, preferring); auto write = [&] { - TestOk(replacement->write_frame(moq::Frame{TestH264Keyframe(), 1'000'000'000}), + TestOk(replacement->write_frame(moq::Frame{TestH264Keyframe(), Micros(1'000'000'000)}), "replacement frame"); }; CHECK(publisher.WriteUntil([&] { @@ -1482,7 +1500,8 @@ int main() return true; })); CHECK(publisher.WriteUntil([&] { - TestOk(replacement->write_frame(moq::Frame{BAD_OPUS, 1'000'000'000}), "replacement frame"); + TestOk(replacement->write_frame(moq::Frame{BAD_OPUS, Micros(1'000'000'000)}), + "replacement frame"); TestOk(replacement->cut(), "replacement audio cut"); std::lock_guard lock(g_candidate_mutex); return g_candidate.failed && candidate.expired(); @@ -1523,7 +1542,7 @@ int main() pending = ctx->connection->pending_audio; return pending && pending->audio_consumer; })); - TestOk(publisher.audio->write_frame(moq::Frame{BAD_OPUS, 900'000'000}), "malformed frame"); + TestOk(publisher.audio->write_frame(moq::Frame{BAD_OPUS, Micros(900'000'000)}), "malformed frame"); TestOk(publisher.audio->cut(), "malformed cut"); CHECK(WaitFor([&] { std::lock_guard lock(ctx->mutex); @@ -1536,7 +1555,7 @@ int main() pending.reset(); uint64_t timestamp_us = 1'000'000'000; CHECK(WaitFor([&] { - TestOk(replacement->write_frame(moq::Frame{{0xfc, 0xff, 0xfe}, timestamp_us}), + TestOk(replacement->write_frame(moq::Frame{{0xfc, 0xff, 0xfe}, Micros(timestamp_us)}), "replacement frame"); TestOk(replacement->cut(), "replacement audio cut"); timestamp_us += 20'000; @@ -1563,7 +1582,7 @@ int main() CHECK(active != nullptr); obs_source_audio audio = {}; moq::expected> misaligned( - moq::AudioRead::kFrame{moq::AudioFrame{1, {0, 0, 0}}}); + moq::AudioRead::kFrame{moq::AudioFrame{Micros(1), {0, 0, 0}}}); CHECK(!moq_source_on_audio_read(ctx, conn, active, misaligned, audio)); CHECK(!conn->audio); } diff --git a/dart/moq/README.md b/dart/moq/README.md index 02fb4ec29a5..cff7d70ce40 100644 --- a/dart/moq/README.md +++ b/dart/moq/README.md @@ -45,8 +45,8 @@ await for (final request in server.requests()) { The package uses `Future` for asynchronous operations and `Stream` for announcements, and `connect` / `listen` take an options struct like Rust does. Types are spelled without the `Moq` prefix (`Session`, `BroadcastProducer`, -`Backoff`), and microsecond fields read back as a `Duration` (`stats.rtt`, -`frame.timestamp`). The lower-level generated API +`Backoff`). Spans cross as a `Duration` (`stats.rtt`), and media times as an +exact `Timestamp` with `toDuration()`. The lower-level generated API remains available through the re-exported `moq_ffi` package. Flutter Web is not supported. Browser applications should use the TypeScript diff --git a/dart/moq/bench/ffi.dart b/dart/moq/bench/ffi.dart index 0e55b1967f6..a4defdca85c 100644 --- a/dart/moq/bench/ffi.dart +++ b/dart/moq/bench/ffi.dart @@ -61,7 +61,7 @@ Future throughput( final frame = await group.readFrame(); require( frame != null && - frame.timestampUs == n * c.frames + f && + frame.timestamp?.value == n * c.frames + f && frame.payload.length == payload.length && memcmp(frame.payload.address, payload.address, payload.length) == 0, @@ -79,7 +79,10 @@ Future throughput( try { for (var f = 0; f < c.frames; f++) { group.writeFrame( - frame: Frame(payload: payload, timestampUs: n * c.frames + f), + frame: Frame( + payload: payload, + timestamp: Timestamp(value: n * c.frames + f, scale: 1000000), + ), ); } group.finish(); diff --git a/dart/moq/lib/audio.dart b/dart/moq/lib/audio.dart index da95989bec5..61d75cea7de 100644 --- a/dart/moq/lib/audio.dart +++ b/dart/moq/lib/audio.dart @@ -28,7 +28,7 @@ typedef ReadFrame = FrameMoqAudioRead; /// Media after what was already read was lost; what follows does not continue it. typedef ReadGap = GapMoqAudioRead; -/// The publisher stopped its encoder; frames starting before `endUs` are its remaining media. +/// The publisher stopped its encoder; frames starting before `end` are its remaining media. typedef ReadEnd = EndMoqAudioRead; /// The PCM layout written to a [Producer]. @@ -55,23 +55,5 @@ typedef Codec = MoqAudioCodec; /// A raw PCM sample layout. typedef SampleFormat = MoqAudioSampleFormat; -/// Duration view over a PCM frame's timestamp. -extension AudioFrameDuration on MoqAudioFrame { - /// Presentation timestamp of the first sample. - Duration get timestamp => Duration(microseconds: timestampUs); -} - -/// Duration view over a gap. -extension AudioReadGapDuration on GapMoqAudioRead { - /// The furthest presentation point already read. - Duration get timestamp => Duration(microseconds: timestampUs); -} - -/// Duration view over an encoder end. -extension AudioReadEndDuration on EndMoqAudioRead { - /// Where the stopped encoder's media stops. - Duration get end => Duration(microseconds: endUs); -} - /// Input, codec settings, and publication ownership for a producer. typedef ProducerOptions = MoqAudioProducerOptions; diff --git a/dart/moq/lib/json.dart b/dart/moq/lib/json.dart index a3df4dfb0c8..33c6efe5e47 100644 --- a/dart/moq/lib/json.dart +++ b/dart/moq/lib/json.dart @@ -41,9 +41,7 @@ typedef StreamProducer = MoqJsonStreamProducer; typedef StreamConsumer = MoqJsonStreamConsumer; /// A JSON string read from a track, with the timestamp of the frame that -/// carried it in microseconds, or null when untimed. Rounded down to a whole -/// microsecond, so a tick on a track like 90 kHz may not survive a read and -/// republish exactly. +/// carried it, or null when untimed. typedef Value = MoqJsonValue; /// Publishes a sliding window of JSON records on a track it takes over, pushed @@ -70,6 +68,6 @@ typedef WindowEventPop = PopMoqJsonWindowEvent; /// The records in `[start, end)` will never be delivered to this reader. typedef WindowEventSkip = SkipMoqJsonWindowEvent; -/// A [WindowEvent] and the timestamp of the frame that carried it, in -/// microseconds, or null when untimed. +/// A [WindowEvent] and the timestamp of the frame that carried it, or null +/// when untimed. typedef WindowTimed = MoqJsonWindowTimed; diff --git a/dart/moq/lib/media.dart b/dart/moq/lib/media.dart index 0238b8b70ef..aa03b240d80 100644 --- a/dart/moq/lib/media.dart +++ b/dart/moq/lib/media.dart @@ -110,21 +110,3 @@ typedef GroupConfig = MoqMediaGroupConfig; /// The packaging advertised for a media track. typedef Container = MoqContainer; - -/// Duration view over a media frame's presentation time. -extension MediaFrameDuration on MoqMediaFrame { - /// Presentation timestamp. - Duration get timestamp => Duration(microseconds: timestampUs); -} - -/// Duration view over a gap. -extension MediaReadGapDuration on GapMoqMediaRead { - /// The furthest presentation point already read. - Duration get timestamp => Duration(microseconds: timestampUs); -} - -/// Duration view over an encoder end. -extension MediaReadEndDuration on EndMoqMediaRead { - /// Where the stopped encoder's media stops. - Duration get end => Duration(microseconds: endUs); -} diff --git a/dart/moq/lib/moq.dart b/dart/moq/lib/moq.dart index 7bdaaaf25da..25bbc4f11f9 100644 --- a/dart/moq/lib/moq.dart +++ b/dart/moq/lib/moq.dart @@ -5,6 +5,6 @@ export 'package:moq_ffi/moq_ffi.dart'; export 'src/aliases.dart'; export 'src/client.dart'; -export 'src/durations.dart'; export 'src/epoch.dart'; export 'src/server.dart'; +export 'src/timestamp.dart'; diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 7610865c8db..b508a1c114e 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -131,6 +131,9 @@ typedef Datagram = MoqDatagram; /// A payload plus the timestamp it should be presented at. typedef Frame = MoqFrame; +/// An exact media time: `value` units at `scale` units per second. +typedef Timestamp = MoqTimestamp; + /// Tunes how a track subscription is delivered: priority, latency budget, ranges, and group ordering. typedef Subscription = MoqSubscription; diff --git a/dart/moq/lib/src/durations.dart b/dart/moq/lib/src/durations.dart deleted file mode 100644 index 386dd2ca476..00000000000 --- a/dart/moq/lib/src/durations.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:moq_ffi/moq_ffi.dart'; - -// The FFI surface carries media timestamps as microsecond integers; durations -// already cross as Dart's Duration. Read the timestamps back as a Duration. -// These are extensions, so the `*Us` fields stay available for anyone building -// a record to send back across the boundary. - -/// Duration view over a raw frame's presentation time. -extension FrameDuration on MoqFrame { - /// Presentation timestamp, or null for an untimed frame. - Duration? get timestamp => - timestampUs == null ? null : Duration(microseconds: timestampUs!); -} - -/// Duration view over a datagram's presentation time. -extension DatagramDuration on MoqDatagram { - /// Presentation timestamp, or null for an untimed datagram. - Duration? get timestamp => - timestampUs == null ? null : Duration(microseconds: timestampUs!); -} diff --git a/dart/moq/lib/src/timestamp.dart b/dart/moq/lib/src/timestamp.dart new file mode 100644 index 00000000000..67dcbe62a45 --- /dev/null +++ b/dart/moq/lib/src/timestamp.dart @@ -0,0 +1,37 @@ +import 'package:moq_ffi/moq_ffi.dart'; + +final _microsPerSecond = BigInt.from(Duration.microsecondsPerSecond); + +/// Converts a [MoqTimestamp] to Dart's [Duration]. +extension TimestampDuration on MoqTimestamp { + /// This timestamp as a [Duration], rounded down to a whole microsecond. + /// + /// The timestamp itself is exact; keep it, not the duration, to republish a + /// tick such as 90 kHz. Throws a [RangeError] when the result does not fit + /// in a [Duration]. + Duration toDuration() { + // value * 10^6 can exceed 64 bits at the allowed bounds. + final units = BigInt.from(scale); + final micros = BigInt.from(value) * _microsPerSecond ~/ units; + if (!micros.isValidInt) { + throw RangeError('timestamp $value/$scale s does not fit in a Duration'); + } + return Duration(microseconds: micros.toInt()); + } +} + +/// Converts a [Duration] to an exact [MoqTimestamp]. +extension DurationTimestamp on Duration { + /// This duration as an exact timestamp at microsecond scale. + /// + /// Throws an [ArgumentError] when negative. + MoqTimestamp toTimestamp() { + if (isNegative) { + throw ArgumentError.value(this, 'duration', 'must not be negative'); + } + return MoqTimestamp( + value: inMicroseconds, + scale: Duration.microsecondsPerSecond, + ); + } +} diff --git a/dart/moq/lib/video.dart b/dart/moq/lib/video.dart index 0d220a93aaa..1de2a0ac6d6 100644 --- a/dart/moq/lib/video.dart +++ b/dart/moq/lib/video.dart @@ -82,30 +82,6 @@ typedef SoftwareEncoder = SoftwareMoqVideoEncoderKind; /// A specific backend by name, such as `videotoolbox`, `mediafoundation`, or `openh264`. typedef NamedEncoder = NamedMoqVideoEncoderKind; -/// Duration view over a raw picture's timestamp. -extension VideoFrameDuration on MoqVideoFrame { - /// Presentation timestamp. - Duration get timestamp => Duration(microseconds: timestampUs); -} - -/// Duration view over a decoded picture's timestamp. -extension VideoDecodedFrameDuration on MoqVideoDecodedFrame { - /// Presentation timestamp. - Duration get timestamp => Duration(microseconds: timestampUs()); -} - -/// Duration view over a gap. -extension VideoReadGapDuration on GapMoqVideoRead { - /// The furthest presentation point already read. - Duration get timestamp => Duration(microseconds: timestampUs); -} - -/// Duration view over an encoder end. -extension VideoReadEndDuration on EndMoqVideoRead { - /// Where the stopped encoder's media stops. - Duration get end => Duration(microseconds: endUs); -} - /// Input, codec settings, and publication ownership for a producer. typedef ProducerOptions = MoqVideoProducerOptions; diff --git a/dart/moq/test/docs/prelude.dart b/dart/moq/test/docs/prelude.dart index 02c1f7b750b..a76e2c48b14 100644 --- a/dart/moq/test/docs/prelude.dart +++ b/dart/moq/test/docs/prelude.dart @@ -6,4 +6,4 @@ import 'package:moq/moq.dart'; Moq get moq => throw UnimplementedError(); Uint8List get bytes => throw UnimplementedError(); Uint8List get rgba => throw UnimplementedError(); -int get pts => throw UnimplementedError(); +Timestamp get pts => throw UnimplementedError(); diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 025d8538ae8..6360596ae93 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -61,7 +61,7 @@ void main() { final consumer = track.consume(subscription: null); track.writeFrame(frame: Frame(payload: utf8.encode('payload'))); final frame = await consumer.readFrame().timeout(timeout); - expect(frame?.timestampUs, isNull); + expect(frame?.timestamp, isNull); expect(consumer.info().timescale, isNull); track.finish(); broadcast.close(); @@ -95,7 +95,10 @@ void main() { for (var ms = 0; ms < 3; ms++) { final group = track.appendGroup(); group.writeFrame( - frame: Frame(payload: utf8.encode('x'), timestampUs: ms * 1000), + frame: Frame( + payload: utf8.encode('x'), + timestamp: Duration(milliseconds: ms).toTimestamp(), + ), ); group.finish(); } @@ -236,7 +239,10 @@ void main() { final producer = track.appendGroup(); producer.writeFrame( - frame: MoqFrame(payload: utf8.encode('dart round trip'), timestampUs: 0), + frame: MoqFrame( + payload: utf8.encode('dart round trip'), + timestamp: Duration.zero.toTimestamp(), + ), ); producer.finish(); @@ -387,14 +393,60 @@ void main() { expect(() => catalog.finish(), throwsA(isA())); }); - test('microsecond timestamps read back as Durations', () { + test('timestamps round-trip exactly at the track scale', () async { + final broadcast = BroadcastProducer(); + final track = broadcast.publishTrack( + name: 'video', + info: TrackInfo(timescale: 90000), + ); + final consumer = track.consume(subscription: null); + // A present zero stays present, and one 90 kHz tick reads back as itself + // rather than the 11 us a microsecond field floors it to. + for (final value in [0, 1]) { + track.writeFrame( + frame: Frame( + payload: utf8.encode('frame'), + timestamp: Timestamp(value: value, scale: 90000), + ), + ); + final frame = await consumer.readFrame().timeout(timeout); + expect(frame?.timestamp?.value, value); + expect(frame?.timestamp?.scale, 90000); + } + + for (final invalid in [ + Timestamp(value: 0, scale: 0), + Timestamp(value: 0, scale: 1 << 62), + Timestamp(value: 1 << 61, scale: 90000), + ]) { + expect( + () => track.writeFrame( + frame: Frame(payload: utf8.encode('frame'), timestamp: invalid), + ), + throwsA(isA()), + ); + } + track.finish(); + broadcast.close(); + }); + + test('timestamps convert to and from Durations', () { + final exact = const Duration(milliseconds: 20).toTimestamp(); + expect([exact.value, exact.scale], [20000, 1000000]); + expect(exact.toDuration(), const Duration(milliseconds: 20)); + // 5 ticks at 90 kHz is 55.6 us, and half a microsecond is 0.5: both round down. expect( - Frame(payload: Uint8List(0), timestampUs: 20000).timestamp, - const Duration(milliseconds: 20), + Timestamp(value: 5, scale: 90000).toDuration(), + const Duration(microseconds: 55), ); + expect(Timestamp(value: 1, scale: 2000000).toDuration(), Duration.zero); expect( - moq_video.Frame(timestampUs: 33333, data: Uint8List(0)).timestamp, - const Duration(microseconds: 33333), + () => Timestamp(value: (1 << 61) - 1, scale: 1).toDuration(), + throwsRangeError, + ); + expect( + () => const Duration(microseconds: -1).toTimestamp(), + throwsArgumentError, ); }); @@ -524,18 +576,21 @@ void main() { final consumer = moq_json.SnapshotConsumer(track: track); for (var n = 1; n <= 4; n++) { - producer.update(value: jsonEncode({'n': n}), timestampUs: n * 1000); + producer.update( + value: jsonEncode({'n': n}), + timestamp: Duration(milliseconds: n).toTimestamp(), + ); } // next() yields every state in order; latest() skips the buffered third. moq_json.Value? value = await consumer.next().timeout(timeout); expect(jsonDecode(value!.value), {'n': 1}); - expect(value.timestampUs, 1000); + expect(value.timestamp?.toDuration(), const Duration(milliseconds: 1)); value = await consumer.next().timeout(timeout); expect(jsonDecode(value!.value), {'n': 2}); value = await consumer.latest().timeout(timeout); expect(jsonDecode(value!.value), {'n': 4}); - expect(value.timestampUs, 4000); + expect(value.timestamp?.toDuration(), const Duration(milliseconds: 4)); consumer.cancel(); producer.finish(); @@ -558,29 +613,38 @@ void main() { final consumer = moq_json.WindowConsumer(track: track); for (var n = 0; n < 3; n++) { - producer.push(value: jsonEncode({'n': n}), timestampUs: n * 1000); + producer.push( + value: jsonEncode({'n': n}), + timestamp: Duration(milliseconds: n).toTimestamp(), + ); } - producer.pop(count: 2, timestampUs: 3000); + producer.pop( + count: 2, + timestamp: const Duration(milliseconds: 3).toTimestamp(), + ); for (var n = 0; n < 3; n++) { final timed = await consumer.next().timeout(timeout); final push = timed!.event as moq_json.WindowEventPush; expect(push.index, n); expect(jsonDecode(push.value), {'n': n}); - expect(timed.timestampUs, n * 1000); + expect(timed.timestamp?.toDuration(), Duration(milliseconds: n)); } final timed = await consumer.next().timeout(timeout); final pop = timed!.event as moq_json.WindowEventPop; expect([pop.start, pop.end], [0, 2]); - expect(timed.timestampUs, 3000); + expect(timed.timestamp?.toDuration(), const Duration(milliseconds: 3)); // The cut's new group restates record 2, which this reader already has. producer.cut(); - producer.push(value: jsonEncode({'n': 3}), timestampUs: 4000); + producer.push( + value: jsonEncode({'n': 3}), + timestamp: const Duration(milliseconds: 4).toTimestamp(), + ); final pushed = await consumer.next().timeout(timeout); final push = pushed!.event as moq_json.WindowEventPush; expect(push.index, 3); - expect(pushed.timestampUs, 4000); + expect(pushed.timestamp?.toDuration(), const Duration(milliseconds: 4)); producer.finish(); expect(await consumer.next().timeout(timeout), isNull); @@ -612,7 +676,10 @@ void main() { video.cut(); for (var i = 0; i < 10; i++) { video.write( - frame: moq_video.Frame(timestampUs: i * 33333, data: rgba), + frame: moq_video.Frame( + timestamp: Timestamp(value: i * 3003, scale: 90000), + data: rgba, + ), ); } @@ -631,7 +698,10 @@ void main() { // Keep the encoder fed so the decoder sees frames after it joined. for (var i = 10; i < 40; i++) { video.write( - frame: moq_video.Frame(timestampUs: i * 33333, data: rgba), + frame: moq_video.Frame( + timestamp: Timestamp(value: i * 3003, scale: 90000), + data: rgba, + ), ); } @@ -706,7 +776,7 @@ void main() { for (var i = 0; i < 2; i++) { audio.write( frame: moq_audio.Frame( - timestampUs: i * 20000, + timestamp: Duration(milliseconds: i * 20).toTimestamp(), data: Uint8List(960 * 4), ), ); @@ -742,7 +812,10 @@ void main() { ), ); producer.write( - frame: moq_audio.Frame(timestampUs: 0, data: Uint8List(960 * 4)), + frame: moq_audio.Frame( + timestamp: Duration.zero.toTimestamp(), + data: Uint8List(960 * 4), + ), ); expect(producer.demand().name(), 'mic'); producer.finish(); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 64ecbf455ab..f669299b304 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -382,9 +382,9 @@ class FfiConverterMoqAudioEncoderOutput { } class MoqAudioFrame { - final int timestampUs; + final MoqTimestamp timestamp; final Uint8List data; - MoqAudioFrame({required this.timestampUs, required this.data}); + MoqAudioFrame({required this.timestamp, required this.data}); } class FfiConverterMoqAudioFrame { @@ -394,25 +394,25 @@ class FfiConverterMoqAudioFrame { static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestamp_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; final data_lifted = FfiConverterUint8List.read( Uint8List.view(buf.buffer, new_offset), ); final data = data_lifted.value; new_offset += data_lifted.bytesRead; return LiftRetVal( - MoqAudioFrame(timestampUs: timestampUs, data: data), + MoqAudioFrame(timestamp: timestamp, data: data), new_offset - buf.offsetInBytes, ); } static RustBuffer lower(MoqAudioFrame value) { final total_length = - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterMoqTimestamp.allocationSize(value.timestamp) + FfiConverterUint8List.allocationSize(value.data) + 0; final buf = Uint8List(total_length); @@ -422,8 +422,8 @@ class FfiConverterMoqAudioFrame { static int write(MoqAudioFrame value, Uint8List buf) { int new_offset = buf.offsetInBytes; - new_offset += FfiConverterUInt64.write( - value.timestampUs, + new_offset += FfiConverterMoqTimestamp.write( + value.timestamp, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterUint8List.write( @@ -434,7 +434,7 @@ class FfiConverterMoqAudioFrame { } static int allocationSize(MoqAudioFrame value) { - return FfiConverterUInt64.allocationSize(value.timestampUs) + + return FfiConverterMoqTimestamp.allocationSize(value.timestamp) + FfiConverterUint8List.allocationSize(value.data) + 0; } @@ -998,8 +998,8 @@ class FfiConverterMoqJsonSnapshotConfig { class MoqJsonValue { final String value; - final int? timestampUs; - MoqJsonValue({required this.value, this.timestampUs}); + final MoqTimestamp? timestamp; + MoqJsonValue({required this.value, this.timestamp}); } class FfiConverterMoqJsonValue { @@ -1014,13 +1014,13 @@ class FfiConverterMoqJsonValue { ); final value = value_lifted.value; new_offset += value_lifted.bytesRead; - final timestampUs_lifted = FfiConverterOptionalUInt64.read( + final timestamp_lifted = FfiConverterOptionalMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; return LiftRetVal( - MoqJsonValue(value: value, timestampUs: timestampUs), + MoqJsonValue(value: value, timestamp: timestamp), new_offset - buf.offsetInBytes, ); } @@ -1028,7 +1028,7 @@ class FfiConverterMoqJsonValue { static RustBuffer lower(MoqJsonValue value) { final total_length = FfiConverterString.allocationSize(value.value) + - FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalMoqTimestamp.allocationSize(value.timestamp) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1041,8 +1041,8 @@ class FfiConverterMoqJsonValue { value.value, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalUInt64.write( - value.timestampUs, + new_offset += FfiConverterOptionalMoqTimestamp.write( + value.timestamp, Uint8List.view(buf.buffer, new_offset), ); return new_offset - buf.offsetInBytes; @@ -1050,7 +1050,7 @@ class FfiConverterMoqJsonValue { static int allocationSize(MoqJsonValue value) { return FfiConverterString.allocationSize(value.value) + - FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalMoqTimestamp.allocationSize(value.timestamp) + 0; } } @@ -1103,8 +1103,8 @@ class FfiConverterMoqJsonWindowConfig { class MoqJsonWindowTimed { final MoqJsonWindowEvent event; - final int? timestampUs; - MoqJsonWindowTimed({required this.event, this.timestampUs}); + final MoqTimestamp? timestamp; + MoqJsonWindowTimed({required this.event, this.timestamp}); } class FfiConverterMoqJsonWindowTimed { @@ -1119,13 +1119,13 @@ class FfiConverterMoqJsonWindowTimed { ); final event = event_lifted.value; new_offset += event_lifted.bytesRead; - final timestampUs_lifted = FfiConverterOptionalUInt64.read( + final timestamp_lifted = FfiConverterOptionalMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; return LiftRetVal( - MoqJsonWindowTimed(event: event, timestampUs: timestampUs), + MoqJsonWindowTimed(event: event, timestamp: timestamp), new_offset - buf.offsetInBytes, ); } @@ -1133,7 +1133,7 @@ class FfiConverterMoqJsonWindowTimed { static RustBuffer lower(MoqJsonWindowTimed value) { final total_length = FfiConverterMoqJsonWindowEvent.allocationSize(value.event) + - FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalMoqTimestamp.allocationSize(value.timestamp) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1146,8 +1146,8 @@ class FfiConverterMoqJsonWindowTimed { value.event, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalUInt64.write( - value.timestampUs, + new_offset += FfiConverterOptionalMoqTimestamp.write( + value.timestamp, Uint8List.view(buf.buffer, new_offset), ); return new_offset - buf.offsetInBytes; @@ -1155,7 +1155,7 @@ class FfiConverterMoqJsonWindowTimed { static int allocationSize(MoqJsonWindowTimed value) { return FfiConverterMoqJsonWindowEvent.allocationSize(value.event) + - FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalMoqTimestamp.allocationSize(value.timestamp) + 0; } } @@ -1634,11 +1634,11 @@ class FfiConverterMoqContainerInit { class MoqDatagram { final int sequence; - final int? timestampUs; + final MoqTimestamp? timestamp; final Uint8List payload; MoqDatagram({ this.sequence = 0, - this.timestampUs = null, + this.timestamp = null, required this.payload, }); } @@ -1655,22 +1655,18 @@ class FfiConverterMoqDatagram { ); final sequence = sequence_lifted.value; new_offset += sequence_lifted.bytesRead; - final timestampUs_lifted = FfiConverterOptionalUInt64.read( + final timestamp_lifted = FfiConverterOptionalMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; final payload_lifted = FfiConverterUint8List.read( Uint8List.view(buf.buffer, new_offset), ); final payload = payload_lifted.value; new_offset += payload_lifted.bytesRead; return LiftRetVal( - MoqDatagram( - sequence: sequence, - timestampUs: timestampUs, - payload: payload, - ), + MoqDatagram(sequence: sequence, timestamp: timestamp, payload: payload), new_offset - buf.offsetInBytes, ); } @@ -1678,7 +1674,7 @@ class FfiConverterMoqDatagram { static RustBuffer lower(MoqDatagram value) { final total_length = FfiConverterUInt64.allocationSize(value.sequence) + - FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalMoqTimestamp.allocationSize(value.timestamp) + FfiConverterUint8List.allocationSize(value.payload) + 0; final buf = Uint8List(total_length); @@ -1692,8 +1688,8 @@ class FfiConverterMoqDatagram { value.sequence, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalUInt64.write( - value.timestampUs, + new_offset += FfiConverterOptionalMoqTimestamp.write( + value.timestamp, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterUint8List.write( @@ -1705,7 +1701,7 @@ class FfiConverterMoqDatagram { static int allocationSize(MoqDatagram value) { return FfiConverterUInt64.allocationSize(value.sequence) + - FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalMoqTimestamp.allocationSize(value.timestamp) + FfiConverterUint8List.allocationSize(value.payload) + 0; } @@ -1831,8 +1827,8 @@ class FfiConverterMoqDimensions { class MoqFrame { final Uint8List payload; - final int? timestampUs; - MoqFrame({required this.payload, this.timestampUs = null}); + final MoqTimestamp? timestamp; + MoqFrame({required this.payload, this.timestamp = null}); } class FfiConverterMoqFrame { @@ -1847,13 +1843,13 @@ class FfiConverterMoqFrame { ); final payload = payload_lifted.value; new_offset += payload_lifted.bytesRead; - final timestampUs_lifted = FfiConverterOptionalUInt64.read( + final timestamp_lifted = FfiConverterOptionalMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; return LiftRetVal( - MoqFrame(payload: payload, timestampUs: timestampUs), + MoqFrame(payload: payload, timestamp: timestamp), new_offset - buf.offsetInBytes, ); } @@ -1861,7 +1857,7 @@ class FfiConverterMoqFrame { static RustBuffer lower(MoqFrame value) { final total_length = FfiConverterUint8List.allocationSize(value.payload) + - FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalMoqTimestamp.allocationSize(value.timestamp) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1874,8 +1870,8 @@ class FfiConverterMoqFrame { value.payload, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterOptionalUInt64.write( - value.timestampUs, + new_offset += FfiConverterOptionalMoqTimestamp.write( + value.timestamp, Uint8List.view(buf.buffer, new_offset), ); return new_offset - buf.offsetInBytes; @@ -1883,18 +1879,18 @@ class FfiConverterMoqFrame { static int allocationSize(MoqFrame value) { return FfiConverterUint8List.allocationSize(value.payload) + - FfiConverterOptionalUInt64.allocationSize(value.timestampUs) + + FfiConverterOptionalMoqTimestamp.allocationSize(value.timestamp) + 0; } } class MoqMediaFrame { final Uint8List payload; - final int timestampUs; + final MoqTimestamp timestamp; final bool keyframe; MoqMediaFrame({ required this.payload, - required this.timestampUs, + required this.timestamp, required this.keyframe, }); } @@ -1911,22 +1907,18 @@ class FfiConverterMoqMediaFrame { ); final payload = payload_lifted.value; new_offset += payload_lifted.bytesRead; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestamp_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; final keyframe_lifted = FfiConverterBool.read( Uint8List.view(buf.buffer, new_offset), ); final keyframe = keyframe_lifted.value; new_offset += keyframe_lifted.bytesRead; return LiftRetVal( - MoqMediaFrame( - payload: payload, - timestampUs: timestampUs, - keyframe: keyframe, - ), + MoqMediaFrame(payload: payload, timestamp: timestamp, keyframe: keyframe), new_offset - buf.offsetInBytes, ); } @@ -1934,7 +1926,7 @@ class FfiConverterMoqMediaFrame { static RustBuffer lower(MoqMediaFrame value) { final total_length = FfiConverterUint8List.allocationSize(value.payload) + - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterMoqTimestamp.allocationSize(value.timestamp) + FfiConverterBool.allocationSize(value.keyframe) + 0; final buf = Uint8List(total_length); @@ -1948,8 +1940,8 @@ class FfiConverterMoqMediaFrame { value.payload, Uint8List.view(buf.buffer, new_offset), ); - new_offset += FfiConverterUInt64.write( - value.timestampUs, + new_offset += FfiConverterMoqTimestamp.write( + value.timestamp, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterBool.write( @@ -1961,7 +1953,7 @@ class FfiConverterMoqMediaFrame { static int allocationSize(MoqMediaFrame value) { return FfiConverterUint8List.allocationSize(value.payload) + - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterMoqTimestamp.allocationSize(value.timestamp) + FfiConverterBool.allocationSize(value.keyframe) + 0; } @@ -2105,6 +2097,65 @@ class FfiConverterMoqMediaTrackConfig { } } +class MoqTimestamp { + final int value; + final int scale; + MoqTimestamp({required this.value, required this.scale}); +} + +class FfiConverterMoqTimestamp { + static MoqTimestamp lift(RustBuffer buf) { + return FfiConverterMoqTimestamp.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + int new_offset = buf.offsetInBytes; + final value_lifted = FfiConverterUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final value = value_lifted.value; + new_offset += value_lifted.bytesRead; + final scale_lifted = FfiConverterUInt64.read( + Uint8List.view(buf.buffer, new_offset), + ); + final scale = scale_lifted.value; + new_offset += scale_lifted.bytesRead; + return LiftRetVal( + MoqTimestamp(value: value, scale: scale), + new_offset - buf.offsetInBytes, + ); + } + + static RustBuffer lower(MoqTimestamp value) { + final total_length = + FfiConverterUInt64.allocationSize(value.value) + + FfiConverterUInt64.allocationSize(value.scale) + + 0; + final buf = Uint8List(total_length); + write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqTimestamp value, Uint8List buf) { + int new_offset = buf.offsetInBytes; + new_offset += FfiConverterUInt64.write( + value.value, + Uint8List.view(buf.buffer, new_offset), + ); + new_offset += FfiConverterUInt64.write( + value.scale, + Uint8List.view(buf.buffer, new_offset), + ); + return new_offset - buf.offsetInBytes; + } + + static int allocationSize(MoqTimestamp value) { + return FfiConverterUInt64.allocationSize(value.value) + + FfiConverterUInt64.allocationSize(value.scale) + + 0; + } +} + class MoqVideo { final String? label; final String? broadcast; @@ -4533,9 +4584,9 @@ class FfiConverterMoqVideoEncoderOutput { } class MoqVideoFrame { - final int timestampUs; + final MoqTimestamp timestamp; final Uint8List data; - MoqVideoFrame({required this.timestampUs, required this.data}); + MoqVideoFrame({required this.timestamp, required this.data}); } class FfiConverterMoqVideoFrame { @@ -4545,25 +4596,25 @@ class FfiConverterMoqVideoFrame { static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestamp_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; final data_lifted = FfiConverterUint8List.read( Uint8List.view(buf.buffer, new_offset), ); final data = data_lifted.value; new_offset += data_lifted.bytesRead; return LiftRetVal( - MoqVideoFrame(timestampUs: timestampUs, data: data), + MoqVideoFrame(timestamp: timestamp, data: data), new_offset - buf.offsetInBytes, ); } static RustBuffer lower(MoqVideoFrame value) { final total_length = - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterMoqTimestamp.allocationSize(value.timestamp) + FfiConverterUint8List.allocationSize(value.data) + 0; final buf = Uint8List(total_length); @@ -4573,8 +4624,8 @@ class FfiConverterMoqVideoFrame { static int write(MoqVideoFrame value, Uint8List buf) { int new_offset = buf.offsetInBytes; - new_offset += FfiConverterUInt64.write( - value.timestampUs, + new_offset += FfiConverterMoqTimestamp.write( + value.timestamp, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterUint8List.write( @@ -4585,7 +4636,7 @@ class FfiConverterMoqVideoFrame { } static int allocationSize(MoqVideoFrame value) { - return FfiConverterUInt64.allocationSize(value.timestampUs) + + return FfiConverterMoqTimestamp.allocationSize(value.timestamp) + FfiConverterUint8List.allocationSize(value.data) + 0; } @@ -4651,11 +4702,11 @@ class FfiConverterMoqVideoFrameRate { } class MoqVideoPacket { - final int timestampUs; + final MoqTimestamp timestamp; final Uint8List data; final bool keyframe; MoqVideoPacket({ - required this.timestampUs, + required this.timestamp, required this.data, required this.keyframe, }); @@ -4668,11 +4719,11 @@ class FfiConverterMoqVideoPacket { static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestamp_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; final data_lifted = FfiConverterUint8List.read( Uint8List.view(buf.buffer, new_offset), ); @@ -4684,14 +4735,14 @@ class FfiConverterMoqVideoPacket { final keyframe = keyframe_lifted.value; new_offset += keyframe_lifted.bytesRead; return LiftRetVal( - MoqVideoPacket(timestampUs: timestampUs, data: data, keyframe: keyframe), + MoqVideoPacket(timestamp: timestamp, data: data, keyframe: keyframe), new_offset - buf.offsetInBytes, ); } static RustBuffer lower(MoqVideoPacket value) { final total_length = - FfiConverterUInt64.allocationSize(value.timestampUs) + + FfiConverterMoqTimestamp.allocationSize(value.timestamp) + FfiConverterUint8List.allocationSize(value.data) + FfiConverterBool.allocationSize(value.keyframe) + 0; @@ -4702,8 +4753,8 @@ class FfiConverterMoqVideoPacket { static int write(MoqVideoPacket value, Uint8List buf) { int new_offset = buf.offsetInBytes; - new_offset += FfiConverterUInt64.write( - value.timestampUs, + new_offset += FfiConverterMoqTimestamp.write( + value.timestamp, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterUint8List.write( @@ -4718,7 +4769,7 @@ class FfiConverterMoqVideoPacket { } static int allocationSize(MoqVideoPacket value) { - return FfiConverterUInt64.allocationSize(value.timestampUs) + + return FfiConverterMoqTimestamp.allocationSize(value.timestamp) + FfiConverterUint8List.allocationSize(value.data) + FfiConverterBool.allocationSize(value.keyframe) + 0; @@ -4925,26 +4976,26 @@ class FrameMoqAudioRead extends MoqAudioRead { } class GapMoqAudioRead extends MoqAudioRead { - final int timestampUs; + final MoqTimestamp timestamp; final Duration duration; GapMoqAudioRead({ - required int this.timestampUs, + required MoqTimestamp this.timestamp, required Duration this.duration, }); - GapMoqAudioRead._(int this.timestampUs, Duration this.duration); + GapMoqAudioRead._(MoqTimestamp this.timestamp, Duration this.duration); static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestamp_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; final duration_lifted = FfiConverterDuration.read( Uint8List.view(buf.buffer, new_offset), ); final duration = duration_lifted.value; new_offset += duration_lifted.bytesRead; - return LiftRetVal(GapMoqAudioRead._(timestampUs, duration), new_offset); + return LiftRetVal(GapMoqAudioRead._(timestamp, duration), new_offset); } @override @@ -4956,7 +5007,7 @@ class GapMoqAudioRead extends MoqAudioRead { @override int allocationSize() { - return FfiConverterUInt64.allocationSize(timestampUs) + + return FfiConverterMoqTimestamp.allocationSize(timestamp) + FfiConverterDuration.allocationSize(duration) + 4; } @@ -4965,8 +5016,8 @@ class GapMoqAudioRead extends MoqAudioRead { int write(Uint8List buf) { buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 2); int new_offset = buf.offsetInBytes + 4; - new_offset += FfiConverterUInt64.write( - timestampUs, + new_offset += FfiConverterMoqTimestamp.write( + timestamp, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterDuration.write( @@ -4978,17 +5029,17 @@ class GapMoqAudioRead extends MoqAudioRead { } class EndMoqAudioRead extends MoqAudioRead { - final int endUs; - EndMoqAudioRead(int this.endUs); - EndMoqAudioRead._(int this.endUs); + final MoqTimestamp end; + EndMoqAudioRead(MoqTimestamp this.end); + EndMoqAudioRead._(MoqTimestamp this.end); static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final endUs_lifted = FfiConverterUInt64.read( + final end_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final endUs = endUs_lifted.value; - new_offset += endUs_lifted.bytesRead; - return LiftRetVal(EndMoqAudioRead._(endUs), new_offset); + final end = end_lifted.value; + new_offset += end_lifted.bytesRead; + return LiftRetVal(EndMoqAudioRead._(end), new_offset); } @override @@ -5000,15 +5051,15 @@ class EndMoqAudioRead extends MoqAudioRead { @override int allocationSize() { - return FfiConverterUInt64.allocationSize(endUs) + 4; + return FfiConverterMoqTimestamp.allocationSize(end) + 4; } @override int write(Uint8List buf) { buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 3); int new_offset = buf.offsetInBytes + 4; - new_offset += FfiConverterUInt64.write( - endUs, + new_offset += FfiConverterMoqTimestamp.write( + end, Uint8List.view(buf.buffer, new_offset), ); return new_offset; @@ -5203,17 +5254,17 @@ class RelativeMoqAnchor extends MoqAnchor { } class TimeMoqAnchor extends MoqAnchor { - final int timestampUs; - TimeMoqAnchor(int this.timestampUs); - TimeMoqAnchor._(int this.timestampUs); + final MoqTimestamp timestamp; + TimeMoqAnchor(MoqTimestamp this.timestamp); + TimeMoqAnchor._(MoqTimestamp this.timestamp); static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestamp_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; - return LiftRetVal(TimeMoqAnchor._(timestampUs), new_offset); + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; + return LiftRetVal(TimeMoqAnchor._(timestamp), new_offset); } @override @@ -5225,15 +5276,15 @@ class TimeMoqAnchor extends MoqAnchor { @override int allocationSize() { - return FfiConverterUInt64.allocationSize(timestampUs) + 4; + return FfiConverterMoqTimestamp.allocationSize(timestamp) + 4; } @override int write(Uint8List buf) { buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 3); int new_offset = buf.offsetInBytes + 4; - new_offset += FfiConverterUInt64.write( - timestampUs, + new_offset += FfiConverterMoqTimestamp.write( + timestamp, Uint8List.view(buf.buffer, new_offset), ); return new_offset; @@ -7397,26 +7448,26 @@ class FrameMoqMediaRead extends MoqMediaRead { } class GapMoqMediaRead extends MoqMediaRead { - final int timestampUs; + final MoqTimestamp timestamp; final Duration duration; GapMoqMediaRead({ - required int this.timestampUs, + required MoqTimestamp this.timestamp, required Duration this.duration, }); - GapMoqMediaRead._(int this.timestampUs, Duration this.duration); + GapMoqMediaRead._(MoqTimestamp this.timestamp, Duration this.duration); static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestamp_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; final duration_lifted = FfiConverterDuration.read( Uint8List.view(buf.buffer, new_offset), ); final duration = duration_lifted.value; new_offset += duration_lifted.bytesRead; - return LiftRetVal(GapMoqMediaRead._(timestampUs, duration), new_offset); + return LiftRetVal(GapMoqMediaRead._(timestamp, duration), new_offset); } @override @@ -7428,7 +7479,7 @@ class GapMoqMediaRead extends MoqMediaRead { @override int allocationSize() { - return FfiConverterUInt64.allocationSize(timestampUs) + + return FfiConverterMoqTimestamp.allocationSize(timestamp) + FfiConverterDuration.allocationSize(duration) + 4; } @@ -7437,8 +7488,8 @@ class GapMoqMediaRead extends MoqMediaRead { int write(Uint8List buf) { buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 2); int new_offset = buf.offsetInBytes + 4; - new_offset += FfiConverterUInt64.write( - timestampUs, + new_offset += FfiConverterMoqTimestamp.write( + timestamp, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterDuration.write( @@ -7450,17 +7501,17 @@ class GapMoqMediaRead extends MoqMediaRead { } class EndMoqMediaRead extends MoqMediaRead { - final int endUs; - EndMoqMediaRead(int this.endUs); - EndMoqMediaRead._(int this.endUs); + final MoqTimestamp end; + EndMoqMediaRead(MoqTimestamp this.end); + EndMoqMediaRead._(MoqTimestamp this.end); static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final endUs_lifted = FfiConverterUInt64.read( + final end_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final endUs = endUs_lifted.value; - new_offset += endUs_lifted.bytesRead; - return LiftRetVal(EndMoqMediaRead._(endUs), new_offset); + final end = end_lifted.value; + new_offset += end_lifted.bytesRead; + return LiftRetVal(EndMoqMediaRead._(end), new_offset); } @override @@ -7472,15 +7523,15 @@ class EndMoqMediaRead extends MoqMediaRead { @override int allocationSize() { - return FfiConverterUInt64.allocationSize(endUs) + 4; + return FfiConverterMoqTimestamp.allocationSize(end) + 4; } @override int write(Uint8List buf) { buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 3); int new_offset = buf.offsetInBytes + 4; - new_offset += FfiConverterUInt64.write( - endUs, + new_offset += FfiConverterMoqTimestamp.write( + end, Uint8List.view(buf.buffer, new_offset), ); return new_offset; @@ -8639,26 +8690,26 @@ class FrameMoqVideoRead extends MoqVideoRead { } class GapMoqVideoRead extends MoqVideoRead { - final int timestampUs; + final MoqTimestamp timestamp; final Duration duration; GapMoqVideoRead({ - required int this.timestampUs, + required MoqTimestamp this.timestamp, required Duration this.duration, }); - GapMoqVideoRead._(int this.timestampUs, Duration this.duration); + GapMoqVideoRead._(MoqTimestamp this.timestamp, Duration this.duration); static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final timestampUs_lifted = FfiConverterUInt64.read( + final timestamp_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final timestampUs = timestampUs_lifted.value; - new_offset += timestampUs_lifted.bytesRead; + final timestamp = timestamp_lifted.value; + new_offset += timestamp_lifted.bytesRead; final duration_lifted = FfiConverterDuration.read( Uint8List.view(buf.buffer, new_offset), ); final duration = duration_lifted.value; new_offset += duration_lifted.bytesRead; - return LiftRetVal(GapMoqVideoRead._(timestampUs, duration), new_offset); + return LiftRetVal(GapMoqVideoRead._(timestamp, duration), new_offset); } @override @@ -8670,7 +8721,7 @@ class GapMoqVideoRead extends MoqVideoRead { @override int allocationSize() { - return FfiConverterUInt64.allocationSize(timestampUs) + + return FfiConverterMoqTimestamp.allocationSize(timestamp) + FfiConverterDuration.allocationSize(duration) + 4; } @@ -8679,8 +8730,8 @@ class GapMoqVideoRead extends MoqVideoRead { int write(Uint8List buf) { buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 2); int new_offset = buf.offsetInBytes + 4; - new_offset += FfiConverterUInt64.write( - timestampUs, + new_offset += FfiConverterMoqTimestamp.write( + timestamp, Uint8List.view(buf.buffer, new_offset), ); new_offset += FfiConverterDuration.write( @@ -8692,17 +8743,17 @@ class GapMoqVideoRead extends MoqVideoRead { } class EndMoqVideoRead extends MoqVideoRead { - final int endUs; - EndMoqVideoRead(int this.endUs); - EndMoqVideoRead._(int this.endUs); + final MoqTimestamp end; + EndMoqVideoRead(MoqTimestamp this.end); + EndMoqVideoRead._(MoqTimestamp this.end); static LiftRetVal read(Uint8List buf) { int new_offset = buf.offsetInBytes; - final endUs_lifted = FfiConverterUInt64.read( + final end_lifted = FfiConverterMoqTimestamp.read( Uint8List.view(buf.buffer, new_offset), ); - final endUs = endUs_lifted.value; - new_offset += endUs_lifted.bytesRead; - return LiftRetVal(EndMoqVideoRead._(endUs), new_offset); + final end = end_lifted.value; + new_offset += end_lifted.bytesRead; + return LiftRetVal(EndMoqVideoRead._(end), new_offset); } @override @@ -8714,15 +8765,15 @@ class EndMoqVideoRead extends MoqVideoRead { @override int allocationSize() { - return FfiConverterUInt64.allocationSize(endUs) + 4; + return FfiConverterMoqTimestamp.allocationSize(end) + 4; } @override int write(Uint8List buf) { buf.buffer.asByteData(buf.offsetInBytes).setInt32(0, 3); int new_offset = buf.offsetInBytes + 4; - new_offset += FfiConverterUInt64.write( - endUs, + new_offset += FfiConverterMoqTimestamp.write( + end, Uint8List.view(buf.buffer, new_offset), ); return new_offset; @@ -10247,7 +10298,7 @@ class FfiConverterMoqJsonSnapshotConsumer { abstract class MoqJsonSnapshotProducerInterface { MoqTrackDemand demand(); void finish(); - void update({required String value, int? timestampUs = null}); + void update({required String value, MoqTimestamp? timestamp = null}); } final _MoqJsonSnapshotProducerFinalizer = Finalizer>((ptr) { @@ -10320,12 +10371,12 @@ class MoqJsonSnapshotProducer implements MoqJsonSnapshotProducerInterface { }, moqExceptionErrorHandler); } - void update({required String value, int? timestampUs = null}) { + void update({required String value, MoqTimestamp? timestamp = null}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqjsonsnapshotproducer_update( uniffiClonePointer(), FfiConverterString.lower(value), - FfiConverterOptionalUInt64.lower(timestampUs), + FfiConverterOptionalMoqTimestamp.lower(timestamp), status, ); }, moqExceptionErrorHandler); @@ -10459,7 +10510,7 @@ class FfiConverterMoqJsonStreamConsumer { } abstract class MoqJsonStreamProducerInterface { - void append({required String value, int? timestampUs = null}); + void append({required String value, MoqTimestamp? timestamp = null}); MoqTrackDemand demand(); void finish(); } @@ -10512,12 +10563,12 @@ class MoqJsonStreamProducer implements MoqJsonStreamProducerInterface { ); } - void append({required String value, int? timestampUs = null}) { + void append({required String value, MoqTimestamp? timestamp = null}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqjsonstreamproducer_append( uniffiClonePointer(), FfiConverterString.lower(value), - FfiConverterOptionalUInt64.lower(timestampUs), + FfiConverterOptionalMoqTimestamp.lower(timestamp), status, ); }, moqExceptionErrorHandler); @@ -10674,8 +10725,8 @@ abstract class MoqJsonWindowProducerInterface { void cut(); MoqTrackDemand demand(); void finish(); - void pop({required int count, int? timestampUs = null}); - void push({required String value, int? timestampUs = null}); + void pop({required int count, MoqTimestamp? timestamp = null}); + void push({required String value, MoqTimestamp? timestamp = null}); } final _MoqJsonWindowProducerFinalizer = Finalizer>((ptr) { @@ -10755,23 +10806,23 @@ class MoqJsonWindowProducer implements MoqJsonWindowProducerInterface { }, moqExceptionErrorHandler); } - void pop({required int count, int? timestampUs = null}) { + void pop({required int count, MoqTimestamp? timestamp = null}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqjsonwindowproducer_pop( uniffiClonePointer(), FfiConverterUInt64.lower(count), - FfiConverterOptionalUInt64.lower(timestampUs), + FfiConverterOptionalMoqTimestamp.lower(timestamp), status, ); }, moqExceptionErrorHandler); } - void push({required String value, int? timestampUs = null}) { + void push({required String value, MoqTimestamp? timestamp = null}) { return rustCall((status) { uniffi_moq_ffi_fn_method_moqjsonwindowproducer_push( uniffiClonePointer(), FfiConverterString.lower(value), - FfiConverterOptionalUInt64.lower(timestampUs), + FfiConverterOptionalMoqTimestamp.lower(timestamp), status, ); }, moqExceptionErrorHandler); @@ -10913,7 +10964,7 @@ class FfiConverterMoqMediaCatalogConsumer { abstract class MoqMediaCatalogProducerInterface { void finish(); - int now(); + MoqTimestamp now(); void removeSection({required String name}); void setSection({required String name, required String json}); void setVideoProperties({required MoqVideoProperties properties}); @@ -10973,13 +11024,13 @@ class MoqMediaCatalogProducer implements MoqMediaCatalogProducerInterface { }, moqExceptionErrorHandler); } - int now() { + MoqTimestamp now() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqmediacatalogproducer_now( uniffiClonePointer(), status, ), - FfiConverterUInt64.lift, + FfiConverterMoqTimestamp.lift, moqExceptionErrorHandler, ); } @@ -14253,7 +14304,7 @@ abstract class MoqVideoDecodedFrameInterface { int height(); Uint8List pixels({required MoqVideoPixelFormat format}); MoqVideoSurface? surface(); - int timestampUs(); + MoqTimestamp timestamp(); int width(); } @@ -14326,13 +14377,13 @@ class MoqVideoDecodedFrame implements MoqVideoDecodedFrameInterface { ); } - int timestampUs() { + MoqTimestamp timestamp() { return rustCallWithLifter( - (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_timestamp_us( + (status) => uniffi_moq_ffi_fn_method_moqvideodecodedframe_timestamp( uniffiClonePointer(), status, ), - FfiConverterUInt64.lift, + FfiConverterMoqTimestamp.lift, null, ); } @@ -16359,6 +16410,53 @@ class FfiConverterOptionalMoqSubscription { } } +class FfiConverterOptionalMoqTimestamp { + static MoqTimestamp? lift(RustBuffer buf) { + return FfiConverterOptionalMoqTimestamp.read(buf.asUint8List()).value; + } + + static LiftRetVal read(Uint8List buf) { + if (ByteData.view(buf.buffer, buf.offsetInBytes).getInt8(0) == 0) { + return LiftRetVal(null, 1); + } + final result = FfiConverterMoqTimestamp.read( + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ); + return LiftRetVal(result.value, result.bytesRead + 1); + } + + static int allocationSize([MoqTimestamp? value]) { + if (value == null) { + return 1; + } + return FfiConverterMoqTimestamp.allocationSize(value) + 1; + } + + static RustBuffer lower(MoqTimestamp? value) { + if (value == null) { + return toRustBuffer(Uint8List.fromList([0])); + } + final buf = Uint8List( + FfiConverterOptionalMoqTimestamp.allocationSize(value), + ); + FfiConverterOptionalMoqTimestamp.write(value, buf); + return toRustBuffer(buf); + } + + static int write(MoqTimestamp? value, Uint8List buf) { + if (value == null) { + buf[0] = 0; + return 1; + } + buf[0] = 1; + return FfiConverterMoqTimestamp.write( + value, + Uint8List.view(buf.buffer, buf.offsetInBytes + 1), + ) + + 1; + } +} + class FfiConverterOptionalMoqTokens { static MoqTokens? lift(RustBuffer buf) { return FfiConverterOptionalMoqTokens.read(buf.asUint8List()).value; @@ -18001,7 +18099,7 @@ external void uniffi_moq_ffi_fn_method_moqjsonsnapshotproducer_finish( external void uniffi_moq_ffi_fn_method_moqjsonsnapshotproducer_update( Pointer ptr, RustBuffer value, - RustBuffer timestamp_us, + RustBuffer timestamp, Pointer uniffiStatus, ); @@ -18073,7 +18171,7 @@ external Pointer uniffi_moq_ffi_fn_constructor_moqjsonstreamproducer_new( external void uniffi_moq_ffi_fn_method_moqjsonstreamproducer_append( Pointer ptr, RustBuffer value, - RustBuffer timestamp_us, + RustBuffer timestamp, Pointer uniffiStatus, ); @@ -18185,7 +18283,7 @@ external void uniffi_moq_ffi_fn_method_moqjsonwindowproducer_finish( external void uniffi_moq_ffi_fn_method_moqjsonwindowproducer_pop( Pointer ptr, int count, - RustBuffer timestamp_us, + RustBuffer timestamp, Pointer uniffiStatus, ); @@ -18195,7 +18293,7 @@ external void uniffi_moq_ffi_fn_method_moqjsonwindowproducer_pop( external void uniffi_moq_ffi_fn_method_moqjsonwindowproducer_push( Pointer ptr, RustBuffer value, - RustBuffer timestamp_us, + RustBuffer timestamp, Pointer uniffiStatus, ); @@ -18267,10 +18365,10 @@ external void uniffi_moq_ffi_fn_method_moqmediacatalogproducer_finish( Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqmediacatalogproducer_now( +external RustBuffer uniffi_moq_ffi_fn_method_moqmediacatalogproducer_now( Pointer ptr, Pointer uniffiStatus, ); @@ -19796,10 +19894,10 @@ external RustBuffer uniffi_moq_ffi_fn_method_moqvideodecodedframe_surface( Pointer uniffiStatus, ); -@Native, Pointer)>( +@Native, Pointer)>( assetId: _uniffiAssetId, ) -external int uniffi_moq_ffi_fn_method_moqvideodecodedframe_timestamp_us( +external RustBuffer uniffi_moq_ffi_fn_method_moqvideodecodedframe_timestamp( Pointer ptr, Pointer uniffiStatus, ); @@ -20880,7 +20978,7 @@ external int uniffi_moq_ffi_checksum_method_moqvideodecodedframe_pixels(); external int uniffi_moq_ffi_checksum_method_moqvideodecodedframe_surface(); @Native(assetId: _uniffiAssetId) -external int uniffi_moq_ffi_checksum_method_moqvideodecodedframe_timestamp_us(); +external int uniffi_moq_ffi_checksum_method_moqvideodecodedframe_timestamp(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqvideodecodedframe_width(); @@ -21179,7 +21277,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqjsonsnapshotproducer_update() != - 17590) { + 41112) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_cancel() != 29308) { @@ -21188,7 +21286,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_next() != 21257) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqjsonstreamproducer_append() != 36078) { + if (uniffi_moq_ffi_checksum_method_moqjsonstreamproducer_append() != 57310) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqjsonstreamproducer_demand() != 52854) { @@ -21212,10 +21310,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqjsonwindowproducer_finish() != 49018) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqjsonwindowproducer_pop() != 62669) { + if (uniffi_moq_ffi_checksum_method_moqjsonwindowproducer_pop() != 58245) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqjsonwindowproducer_push() != 19295) { + if (uniffi_moq_ffi_checksum_method_moqjsonwindowproducer_push() != 47237) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediacatalogconsumer_cancel() != @@ -21229,7 +21327,7 @@ void _checkApiChecksums() { 62353) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqmediacatalogproducer_now() != 14140) { + if (uniffi_moq_ffi_checksum_method_moqmediacatalogproducer_now() != 17945) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediacatalogproducer_remove_section() != @@ -21437,7 +21535,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence() != 21067) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 42053) { + if (uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame() != 38774) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackproducer_abort() != 37537) { @@ -21475,7 +21573,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqtrackproducer_requests() != 60252) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame() != 32196) { + if (uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame() != 46891) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqtrackrequest_abort() != 62713) { @@ -21600,8 +21698,8 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqvideodecodedframe_surface() != 3872) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqvideodecodedframe_timestamp_us() != - 18681) { + if (uniffi_moq_ffi_checksum_method_moqvideodecodedframe_timestamp() != + 47810) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqvideodecodedframe_width() != 30916) { diff --git a/dart/moq_ffi/test/leak_test.dart b/dart/moq_ffi/test/leak_test.dart index bbbb3ccd1d0..b415f69aa23 100644 --- a/dart/moq_ffi/test/leak_test.dart +++ b/dart/moq_ffi/test/leak_test.dart @@ -85,7 +85,12 @@ void main() { ); final consumer = track.consume(subscription: null); final producer = track.appendGroup(); - producer.writeFrame(frame: MoqFrame(payload: payload, timestampUs: 0)); + producer.writeFrame( + frame: MoqFrame( + payload: payload, + timestamp: MoqTimestamp(value: 0, scale: 1000000), + ), + ); producer.finish(); final group = await consumer.nextGroup(); final frame = await group!.readFrame(); diff --git a/dart/moq_ffi/test/smoke_test.dart b/dart/moq_ffi/test/smoke_test.dart index d7aec01bc79..ad52d65f94f 100644 --- a/dart/moq_ffi/test/smoke_test.dart +++ b/dart/moq_ffi/test/smoke_test.dart @@ -54,7 +54,10 @@ void main() { final producer = track.appendGroup(); producer.writeFrame( - frame: MoqFrame(payload: utf8.encode('dart'), timestampUs: 0), + frame: MoqFrame( + payload: utf8.encode('dart'), + timestamp: MoqTimestamp(value: 0, scale: 1000000), + ), ); producer.finish(); diff --git a/demo/carla/sim/moq_carla_sim/bridge.py b/demo/carla/sim/moq_carla_sim/bridge.py index 98b8aa5c7aa..b49411c490d 100644 --- a/demo/carla/sim/moq_carla_sim/bridge.py +++ b/demo/carla/sim/moq_carla_sim/bridge.py @@ -16,7 +16,6 @@ import logging import struct from dataclasses import dataclass -from datetime import timedelta from typing import Callable import moq @@ -371,7 +370,7 @@ def publish(self, frame: Frame) -> None: raise BridgeError(f"{self.name}: frame before config for rendition {frame.rendition}") if self._origin_us is None: self._origin_us = frame.timestamp_us - timestamp = timedelta(microseconds=max(0, frame.timestamp_us - self._origin_us)) + timestamp = moq.Timestamp(max(0, frame.timestamp_us - self._origin_us), 1_000_000) track.write_frame(frame.payload, timestamp) self.published[frame.rendition] += 1 diff --git a/demo/carla/sim/tests/conftest.py b/demo/carla/sim/tests/conftest.py index 2c0dd55e273..db43ae77ac6 100644 --- a/demo/carla/sim/tests/conftest.py +++ b/demo/carla/sim/tests/conftest.py @@ -67,6 +67,7 @@ async def __aiter__(self): moq.AnnounceEventRestart = AnnounceEventRestart moq.AnnounceRoute = lambda **kwargs: SimpleNamespace(**kwargs) moq.mint_epoch = lambda: str(uuid.uuid4()) +moq.Timestamp = lambda value, scale: SimpleNamespace(value=value, scale=scale) moq.TrackInfo = lambda **kwargs: SimpleNamespace(**kwargs) moq_json = ModuleType("moq.json") diff --git a/demo/carla/sim/tests/test_bridge.py b/demo/carla/sim/tests/test_bridge.py index 628f307751a..1a60d4dd17c 100644 --- a/demo/carla/sim/tests/test_bridge.py +++ b/demo/carla/sim/tests/test_bridge.py @@ -1,6 +1,5 @@ import asyncio import struct -from datetime import timedelta from types import SimpleNamespace import moq @@ -117,10 +116,6 @@ async def wanted_changed(self) -> int | None: return self._wanted -def microseconds(timestamp: timedelta) -> int: - return timestamp // timedelta(microseconds=1) - - class FakeTrack: def __init__(self, init: bytes, name: str, hint) -> None: self.init = init @@ -133,8 +128,9 @@ def __init__(self, init: bytes, name: str, hint) -> None: def demand(self) -> FakeDemand: return self.demand_handle - def write_frame(self, payload: bytes, timestamp: timedelta) -> None: - self.frames.append((payload, microseconds(timestamp))) + def write_frame(self, payload: bytes, timestamp: SimpleNamespace) -> None: + assert timestamp.scale == 1_000_000 + self.frames.append((payload, timestamp.value)) def finish(self) -> None: self.finished = True diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 7ff1a06b29d..be8e7e1d4c9 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -106,7 +106,8 @@ inside. The header is the reference: each function carries a doc comment. - **Catalog hints.** Give the catalog what the encoder knows instead of having it estimated: `moq_video_init.hint` (coded size, bitrate, frame rate, group duration) and `moq_audio_init.hint` (bitrate, group duration). A supplied bitrate is never re-estimated. `group_duration_us` is the longest span between two group starts: the GOP, or your cut cadence. Leave a field's `has_*` flag false to have it measured. A bitrate or `group_duration_us` flag set with a 0 value returns `MOQ_ERROR_INVALID_CONFIG`. - **Ending cleanly.** When the input ends, finish every track, then call `moq_publish_catalog_finish()` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `moq_publish_close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close ends with `UNROUTABLE`, which readers take as the publisher leaving, and its handle is released. - **Timeline breaks.** On a seek or pause, call `moq_publish_media_discontinuity`, then keep timestamps moving forward and resume video on a keyframe. -- **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's `timestamp_us`, rounded down to a whole microsecond; `timestamp_present` is false on an untimed track. A snapshot consumer's callback fires for each state in order, but a group superseded before it is read is skipped. +- **Timestamps.** A `moq_timestamp` is exact: `value` units at `scale` units per second, so a 90 kHz tick survives a read and republish. Frames, datagrams, and JSON values point `timestamp` at theirs, NULL on an untimed track and valid as long as the payload. Tracks moq-c creates are timed, so their raw writes need one. JSON and binary writes take one too, converted to their track's milliseconds; NULL stamps the broadcast clock's current time. A zero scale or an out-of-range value fails with `MOQ_ERROR_TIMESTAMP_OVERFLOW`. +- **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's timestamp. A snapshot consumer's callback fires for each state in order, but a group superseded before it is read is skipped. - **JSON windows.** `moq_publish_json_window` publishes a sliding run of records: `moq_publish_json_window_push` appends at the back, `moq_publish_json_window_pop` drops from the front, and `moq_publish_json_window_cut` closes the open group at a natural boundary. The catalog has no window mode, so a reader names the track. `moq_consume_json_window_event` fills `moq_json_window_event` with a push (the record at `start`, in `json`), a pop of `[start, end)`, or a skip of `[start, end)` that this reader joined too late to receive. Set `checkpoint_records` to bound what each new group restates for a window you never pop. - **Decoded reads.** `moq_decode_audio` and `moq_decode_video` deliver read ids in order: a frame, a gap where media was lost, or an end where the publisher stopped its encoder. Check `kind` on what `moq_decode_audio_read` or `moq_decode_video_read` writes. Free each id with `moq_decode_audio_read_free` or `moq_decode_video_read_free` promptly: a held video frame holds a decoder buffer, and the decoder stalls. A decoder reads everything the track still holds unless a live player sets `start` to `MOQ_DECODE_START_LATEST`, which joins at the newest group and skips the cached backlog. - **Stats.** `moq_session_stats()` fills `moq_connection_stats` with `rtt_us`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). Each has a `_valid` flag, false when the transport does not report it, which is not the same as zero. `moq_session_snapshot()` adds the negotiated `protocol`. A failed dial or dropped connection reaches the session's `on_state` with its `failure` code, and `moq_error()` gives the reason during that callback. Hold the handle returned by `moq_session_recv_bandwidth()` to request receive estimates. `moq_bandwidth_consumer_peek()` reads the estimate and `moq_bandwidth_consumer_changed()` waits for a change. `moq_bandwidth_consumer_close()` releases demand; the last consumer stops PROBE. Stats snapshots do not request it. diff --git a/doc/lib/cpp/index.md b/doc/lib/cpp/index.md index e18071c50c1..a1f585d6871 100644 --- a/doc/lib/cpp/index.md +++ b/doc/lib/cpp/index.md @@ -122,6 +122,9 @@ use one source timeline; omitting it keeps an independent offset. A timebase bel its broadcast and does not keep it open. `MediaContainerProducer` takes chunks split anywhere, so a raw byte stream needs no other importer. `moq::ContainerFormat::kFmp4` splits each fragment into one frame per sample. +Media times are exact `moq::Timestamp{value, scale}` records, `value` units at `scale` units +per second, and read back at the track's timescale, so a 90 kHz tick republishes unchanged. +A raw `moq::Frame` with `std::nullopt` is untimed. ```cpp // Publish encoded frames, or raw pixels with the codec inside the binding. @@ -130,12 +133,12 @@ anywhere, so a raw byte stream needs no other importer. auto publication = ok(session->publish()->create_broadcast("my-stream.hang")); auto broadcast = publication->broadcast(); // fill tracks here; announce on the publication auto audio = ok(moq::MediaTrackProducer::audio(broadcast, moq::MediaTarget::kNamed{}, {moq::AudioFormat::kOpus, opus_init})); -ok(audio->write_frame({packet, 20'000})); +ok(audio->write_frame({packet, moq::Timestamp{960, 48'000}})); // 20 ms moq::VideoEncoderOutput output{moq::VideoCodec::kH264, std::nullopt, std::nullopt, moq::VideoEncoderKind::kAuto{}}; auto catalog = ok(moq::MediaCatalogProducer::init(broadcast)); auto video = ok(moq::VideoProducer::init(catalog, {{moq::VideoPixelFormat::kRgba, 1280, 720, {30000, 1001}}, output, "camera", nullptr})); -ok(video->write({0, rgba})); +ok(video->write({{0, 30'000}, rgba})); moq::AnnounceRoute route; route.epoch = moq::mint_epoch(); // a fresh epoch per run ok(publication->announce(route)); diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index c0e8137e9f3..3a8341a1fb8 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -19,8 +19,10 @@ and a `Named` or `Requested` target. `CatalogProducer(broadcast: ...)` owns cata and sections; it holds the broadcast weakly and fails after the broadcast closes. Publish JSON tracks with `MoqTrackInfo(compress: true)`; a `package:moq/json.dart` consumer reads one with no option of its own. A missing data timestamp stays untimed; the library never substitutes zero or now. -`package:moq/json.dart` consumers return `Value` (`value`, `timestampUs`) from `next()`; a snapshot consumer -yields each state in order, and `latest()` skips to the newest. Read timestamps are rounded down to a whole microsecond. +`package:moq/json.dart` consumers return `Value` (`value`, `timestamp`) from `next()`; a snapshot consumer +yields each state in order, and `latest()` skips to the newest. +Media times are exact `Timestamp` records, `value` units at `scale` units per second, so a 90 kHz tick survives a +read and republish. `Duration.toTimestamp()` converts exactly; `Timestamp.toDuration()` rounds down to a whole microsecond. Raw tracks with a `null` timescale are untimed on both publish and receive. `MoqTrackInfo.compress` on a raw track sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. Whole-frame media writes require caller time; container imports keep their own timeline handling. @@ -95,7 +97,7 @@ final encoder = video.Producer( track: 'camera', ), ); -encoder.write(frame: video.Frame(timestampUs: pts, data: rgba)); +encoder.write(frame: video.Frame(timestamp: pts, data: rgba)); camera.announce(route: AnnounceRoute(epoch: mintEpoch())); encoder.finish(); ``` @@ -127,7 +129,7 @@ final publication = moq.createBroadcast('live/camera'); final mine = publication.broadcast(); final track = mine.publishTrack(name: 'video', info: null); final group = track.appendGroup(); -group.writeFrame(frame: Frame(payload: bytes, timestampUs: null)); +group.writeFrame(frame: Frame(payload: bytes, timestamp: null)); group.finish(); publication.announce(route: AnnounceRoute(epoch: mintEpoch())); // a fresh epoch per run track.finish(); diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index abbd367e135..9b33db5e99f 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -16,13 +16,15 @@ linux/amd64, linux/arm64, darwin/arm64 (macOS 12.3+), windows/amd64. `moq.dev/moq/media` owns catalogs, encoded imports, and container consumers. Construct `NewAudioTrackProducer` / `NewVideoTrackProducer` from a broadcast, an init record, and a `Named` or `Requested` target. `NewCatalogProducer` holds its broadcast weakly; -catalog writes fail with `ErrClosed` after the broadcast closes. Media timestamps use `time.Duration`. +catalog writes fail with `ErrClosed` after the broadcast closes. Media timestamps are exact +`moq.Timestamp` records (`Value` at `Scale` units per second), so a 90 kHz tick survives a read and republish; +`TimestampFromDuration` converts exactly; `TimestampToDuration` rounds down to a whole nanosecond. A missing data timestamp stays untimed; the library never substitutes zero or now. Raw tracks with a `nil` timescale are untimed on both publish and receive. `TrackInfo.Compress` sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. Set it on JSON tracks. -The JSON producers' `Update` / `Append` take a final `*time.Duration` timestamp argument (`nil` for untimed). +The JSON producers' `Update` / `Append` take a final `*moq.Timestamp` argument (`nil` for untimed). JSON consumers return a `*Timed` holding the value and that timestamp; `SnapshotConsumer.Latest` -skips to the newest state where `Next` yields each one. Read timestamps are rounded down to a whole microsecond. +skips to the newest state where `Next` yields each one. A read timestamp is at the track's timescale. Whole-frame media writes require caller time; container imports keep their own timeline handling. Timed raw/data tracks require caller-supplied timestamps. Use `CatalogProducer.Now()` to stamp a locally created event on the broadcast clock; reading it fixes that clock for later media producers. @@ -91,16 +93,15 @@ for event, err := range announced.All(ctx) { ```go import ( - "time" moqvideo "moq.dev/moq/video" ) // Publish encoded frames, or raw pixels with the codec inside the binding. -// opusInit, packet, pts, and rgba come from your encoder or capture source. +// opusInit, packet, pts (90 kHz ticks), and rgba come from your encoder or capture source. publication, _ := client.CreateBroadcast("my-stream.hang") broadcast := publication.Broadcast() audio, _ := moqmedia.NewAudioTrackProducer(broadcast, moqmedia.Named{}, moqmedia.AudioInit{Format: moqmedia.AudioFormatOpus, Data: opusInit}) -timestamp := 20 * time.Millisecond +timestamp := moq.Timestamp{Value: 960, Scale: 48_000} // 20 ms on the Opus sample clock _ = audio.WriteFrame(moq.Frame{Payload: packet, Timestamp: ×tamp}) _ = audio.Cut() // audio has no keyframes, so this is what gives it groups @@ -111,7 +112,7 @@ video, _ := moqvideo.NewProducer(catalogProducer, moqvideo.ProducerOptions{ Output: moqvideo.EncoderOutput{Codec: moq.VideoCodecH264, Kind: moq.AutoEncoder()}, Track: &track, }) -_ = video.Write(moqvideo.Frame{Timestamp: time.Duration(pts) * time.Microsecond, Data: rgba}) +_ = video.Write(moqvideo.Frame{Timestamp: moq.Timestamp{Value: pts, Scale: 90_000}, Data: rgba}) epoch := moq.MintEpoch() // a fresh epoch per run _ = publication.Announce(moq.AnnounceRoute{Epoch: &epoch}) _ = audio.Finish() diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index f4c5271ea27..579f49b0c47 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -19,7 +19,9 @@ x86\_64) and desktop JVM (Linux x86\_64/aarch64, macOS arm64, Windows x64). Publish JSON tracks with `TrackInfo(compress = true)`; a `dev.moq.json` consumer reads one with no option of its own. A missing data timestamp stays untimed; the library never substitutes zero or now. `dev.moq.json` consumers' `values()` emits `Value` (`value`, `timestamp`) and `valuesAs()` emits `Timed`; -a snapshot consumer yields each state in order, and `latest()` skips to the newest. Read timestamps are rounded down to a whole microsecond. +a snapshot consumer yields each state in order, and `latest()` skips to the newest. +A `Timestamp(value, scale)` is exact and reads back at the track's timescale, so a 90 kHz tick republishes unchanged. +`Duration.toTimestamp()` is exact at nanosecond scale; `Timestamp.toDuration()` rounds down to a whole nanosecond. Raw tracks with a `null` timescale are untimed on both publish and receive. `MoqTrackInfo.compress` on a raw track sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. Whole-frame media writes require caller time; container imports keep their own timeline handling. @@ -74,7 +76,7 @@ Moq.connect("https://relay.example.com").use { moq -> val publication = moq.createBroadcast("my-stream.hang") val broadcast = publication.broadcast() val audio = dev.moq.media.TrackProducer.audio(broadcast, Named(null), AudioInit(format = AudioFormat.OPUS, data = opusInit)) - audio.writeFrame(Frame(payload = packet, timestampUs = 20_000u)) + audio.writeFrame(Frame(payload = packet, timestamp = Timestamp(960u, 48_000u))) audio.cut() // audio has no keyframes, so this is what gives it groups val video = dev.moq.video.Producer( @@ -82,7 +84,7 @@ Moq.connect("https://relay.example.com").use { moq -> dev.moq.video.ProducerOptions(input = VideoEncoderInput(format = VideoPixelFormat.RGBA, width = 1280u, height = 720u, framerate = dev.moq.video.FrameRate(30000u, 1001u)), output = VideoEncoderOutput(codec = VideoCodec.H264, bitrate = null, gop = null, kind = autoEncoder), track = "camera"), ) - video.write(VideoFrame(timestampUs = pts, data = rgba)) + video.write(VideoFrame(timestamp = pts, data = rgba)) publication.announce(AnnounceRoute(epoch = mintEpoch())) // a fresh epoch per run audio.finish() video.finish() diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index f6aead8f1ce..64235560695 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -22,13 +22,14 @@ source timeline; omitting it keeps an independent offset. A timebase belongs to broadcast and does not keep it open. `ContainerProducer` takes chunks split anywhere, so a raw byte stream needs no other importer. `ContainerFormat.FMP4` splits each fragment into one frame per sample. -`MediaFrame.timestamp`, media writes, and `flush` use `datetime.timedelta`. +Media timestamps are exact `moq.Timestamp(value, scale)` records, so a 90 kHz tick survives a read and republish. +`Timestamp.from_timedelta` is exact; `.to_timedelta()` rounds down to a whole microsecond. A missing data timestamp stays untimed; the library never substitutes zero or now. JSON consumers yield `moq.json.Timed(value, timestamp)`; a snapshot consumer yields each state in order, -and `await consumer.latest()` skips to the newest. A window consumer's value is a `WindowPush`, `WindowPop`, or `WindowSkip`. Read timestamps are rounded down to a whole microsecond. +and `await consumer.latest()` skips to the newest. A window consumer's value is a `WindowPush`, `WindowPop`, or `WindowSkip`. Raw tracks with a `None` timescale are untimed on both publish and receive. `TrackInfo(compress=True)` sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. Set it on JSON tracks. -The JSON producers' `update` / `append` accept `timestamp=timedelta(...)` (`None` for untimed). +The JSON producers' `update` / `append` accept `timestamp=moq.Timestamp(...)` (`None` for untimed). Whole-frame media writes require caller time; container imports keep their own timeline handling. Timed raw/data tracks require caller-supplied timestamps. Use `CatalogProducer.now()` to stamp a locally created event on the broadcast clock; reading it fixes that clock for later media producers. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index cb7d732b8e5..454ea1e9772 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -17,10 +17,13 @@ slices for iOS 16+, the iOS Simulator, and macOS 12.3+. `.named(name:)` or `.requested(request)` target. `Media.CatalogProducer(broadcast:)` updates catalog properties and sections without keeping the broadcast open. Its writes fail after closing or releasing the broadcast. +Media timestamps are exact `Timestamp` records, `value` units at `scale` units per second, carried at the track's timescale +(a write at another scale rounds down to it). On macOS 13+ and iOS, `try Timestamp(duration)` converts a `Duration` exactly, +and `try timestamp.duration` rounds down to a whole attosecond. Publish JSON tracks with `TrackInfo(compress: true)`; a `Json` consumer reads one with no option of its own. A missing data timestamp stays untimed; the library never substitutes zero or now. -`Json` consumers yield `Json.Timed` (`value`, `timestampUs`); a snapshot consumer yields each state in order, -and `latest()` skips to the newest. Read timestamps are rounded down to a whole microsecond. +`Json` consumers yield `Json.Timed` (`value`, `timestamp`); a snapshot consumer yields each state in order, +and `latest()` skips to the newest. Raw tracks with a `nil` timescale are untimed on both publish and receive. `MoqTrackInfo.compress` on a raw track sends each group DEFLATE-compressed to opt-in `moq-lite-07-wip` peers; other peers, and every reader here, get the original frames. Whole-frame media writes require caller time; container imports keep their own timeline handling. @@ -73,7 +76,7 @@ for try await event in try session.consume.announced(prefix: "live/", filter: "* let publication = try session.publish.createBroadcast(path: "my-stream.hang") let broadcast = publication.broadcast let audio = try Media.TrackProducer.audio(broadcast: broadcast, initData: Media.AudioInit(format: .opus, data: opusInit)) -try audio.writeFrame(packet, timestampUs: 20_000) +try audio.writeFrame(packet, timestamp: Timestamp(value: 20, scale: 1_000)) try audio.cut() // audio has no keyframes, so this is what gives it groups let video = try Video.Producer( @@ -82,7 +85,7 @@ let video = try Video.Producer( output: VideoEncoderOutput(codec: .h264, kind: .auto), track: "camera" ) -try video.write(VideoFrame(timestampUs: pts, data: rgba)) +try video.write(VideoFrame(timestamp: pts, data: rgba)) try publication.announce(route: AnnounceRoute(epoch: mintEpoch())) // a fresh epoch per run try audio.finish() try video.finish() diff --git a/go/wrapper/README.md b/go/wrapper/README.md index 42bfdb5079e..dc8f4550fba 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -99,7 +99,7 @@ catalog snapshots, live media, and one fetched media group. The last two take a (`VideoRendition` or `AudioRendition`: a catalog ID and its config) and follow its broadcast and track themselves. `NewGroupConsumer` decodes a raw group, such as one from `moq.TrackConsumer.RecvGroup` for arrival-order reads. -Frames and `Flush` use `time.Duration`; `Demand()` owns track names and subscriber waits. +Frames carry exact `moq.Timestamp` records; `Demand()` owns track names and subscriber waits. `BroadcastProducer.Requests()` accepts subscriber-requested tracks. Call `TrackRequest.Accept()` for raw tracks, or pass the request as `media.Requested` @@ -114,7 +114,7 @@ carry every record in order. `NewWindowProducer` / `NewWindowConsumer` carry a s of records a reader can join at any point; the catalog has no window mode, so the producer takes only the track. Producers accept any `encoding/json` value; consumers return `json.RawMessage` so callers choose their own decoded type. -`Update` / `Append` / `Push` / `Pop` take a final `*time.Duration` on the broadcast clock; nil publishes untimed. +`Update` / `Append` / `Push` / `Pop` take a final `*moq.Timestamp` on the broadcast clock; nil publishes untimed. Raw tracks with a nil timescale are untimed; timed tracks require matching timestamps. `media.CatalogProducer.Now()` samples and fixes the catalog clock for explicit local-event stamps. Opaque bytes go on a plain `TrackProducer`. diff --git a/go/wrapper/audio/audio.go b/go/wrapper/audio/audio.go index 432ff028329..1ccf0c68989 100644 --- a/go/wrapper/audio/audio.go +++ b/go/wrapper/audio/audio.go @@ -88,19 +88,7 @@ func delay(d *media.Delay, kind string) error { } // Frame is interleaved PCM with its presentation timestamp. -type Frame struct { - Timestamp time.Duration - Data []byte -} - -func (f Frame) ffi() (ffi.MoqAudioFrame, error) { - us, err := bridge.Duration(f.Timestamp, "audio timestamp") - return ffi.MoqAudioFrame{TimestampUs: us, Data: f.Data}, err -} - -func frame(raw ffi.MoqAudioFrame) Frame { - return Frame{Timestamp: time.Duration(raw.TimestampUs) * time.Microsecond, Data: raw.Data} -} +type Frame = ffi.MoqAudioFrame // Read is one item a Consumer reads, in delivery order: a ReadFrame, ReadGap, or ReadEnd. type Read interface{ read() } @@ -113,7 +101,7 @@ type ReadFrame struct { // ReadGap is media lost after what was already read: what follows does not continue it. type ReadGap struct { // Timestamp is the furthest presentation point already read. - Timestamp time.Duration + Timestamp moq.Timestamp // Duration is an upper bound on how long until the next frame resumes. Duration time.Duration } @@ -123,7 +111,7 @@ type ReadGap struct { // comes from a fresh encoder. type ReadEnd struct { // End is where the stopped encoder's media stops. - End time.Duration + End moq.Timestamp } func (ReadFrame) read() {} @@ -133,11 +121,11 @@ func (ReadEnd) read() {} func audioRead(raw ffi.MoqAudioRead) (Read, error) { switch read := raw.(type) { case ffi.MoqAudioReadFrame: - return ReadFrame{Frame: frame(read.Frame)}, nil + return ReadFrame{Frame: read.Frame}, nil case ffi.MoqAudioReadGap: - return ReadGap{Timestamp: time.Duration(read.TimestampUs) * time.Microsecond, Duration: read.Duration}, nil + return ReadGap{Timestamp: read.Timestamp, Duration: read.Duration}, nil case ffi.MoqAudioReadEnd: - return ReadEnd{End: time.Duration(read.EndUs) * time.Microsecond}, nil + return ReadEnd{End: read.End}, nil } return nil, fmt.Errorf("unknown audio read: %T", raw) } @@ -189,11 +177,7 @@ func (p *Producer) Reanchor() error { return p.inner.Reanchor() } // Write encodes and publishes one PCM frame in the configured input layout. func (p *Producer) Write(value Frame) error { - raw, err := value.ffi() - if err != nil { - return err - } - return p.inner.Write(raw) + return p.inner.Write(value) } // Reservation returns this track's bandwidth reservation, or nil without one. diff --git a/go/wrapper/bridge.go b/go/wrapper/bridge.go index 116ac0f1392..3209b931ca7 100644 --- a/go/wrapper/bridge.go +++ b/go/wrapper/bridge.go @@ -10,7 +10,6 @@ import ( func init() { bridge.BroadcastConsumer = func(v any) *ffi.MoqBroadcastConsumer { return v.(*BroadcastConsumer).inner } bridge.TrackRequest = func(v any) *ffi.MoqTrackRequest { return v.(*TrackRequest).inner } - bridge.Frame = func(v any) (ffi.MoqFrame, error) { return v.(Frame).ffi() } bridge.Subscription = func(v any) (*ffi.MoqSubscription, error) { return subscriptionFFI(v.(*Subscription)) } bridge.BroadcastProducer = func(v any) *ffi.MoqBroadcastProducer { return v.(*BroadcastProducer).inner } diff --git a/go/wrapper/data_timestamps_test.go b/go/wrapper/data_timestamps_test.go index 054ab009672..ea45972d2d5 100644 --- a/go/wrapper/data_timestamps_test.go +++ b/go/wrapper/data_timestamps_test.go @@ -3,21 +3,35 @@ package moq_test import ( "context" "encoding/json" + "errors" "testing" - "time" "moq.dev/moq" moqjson "moq.dev/moq/json" ) +// A 90 kHz tick has no exact microsecond count, so it survives only as a record. +var tick = moq.Timestamp{Value: 1, Scale: 90_000} + +func ninetyKHz() *moq.TrackInfo { + scale := uint64(90_000) + return &moq.TrackInfo{Timescale: &scale} +} + +// Every producer carries an exact timestamp, a present zero, or none at all, and +// the raw reader gets back exactly what was written. func TestDataTimestamps(t *testing.T) { - for _, mode := range []string{"json snapshot", "json stream"} { - for _, timed := range []bool{false, true} { - name := mode + "/untimed" - if timed { - name = mode + "/timed" - } - t.Run(name, func(t *testing.T) { + timestamps := []struct { + name string + at *moq.Timestamp + }{ + {"untimed", nil}, + {"zero", &moq.Timestamp{Value: 0, Scale: 90_000}}, + {"tick", &tick}, + } + for _, mode := range []string{"frame", "json snapshot", "json stream"} { + for _, timestamp := range timestamps { + t.Run(mode+"/"+timestamp.name, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() broadcast, err := moq.NewBroadcastProducer() @@ -25,12 +39,9 @@ func TestDataTimestamps(t *testing.T) { t.Fatal(err) } defer broadcast.Close() - var timestamp *time.Duration var info *moq.TrackInfo - if timed { - at := 123456 * time.Microsecond - timestamp = &at - info = timedInfo() + if timestamp.at != nil { + info = ninetyKHz() } track, err := broadcast.PublishTrack("data", info) if err != nil { @@ -41,31 +52,9 @@ func TestDataTimestamps(t *testing.T) { t.Fatal(err) } defer consumer.Cancel() - switch mode { - case "json snapshot": - producer, err := moqjson.NewSnapshotProducer(broadcast, track, moqjson.SnapshotOptions{}) - if err != nil { - t.Fatal(err) - } - err = producer.Update(map[string]int{"n": 1}, timestamp) - if err != nil { - t.Fatal(err) - } - if err := producer.Finish(); err != nil { - t.Fatal(err) - } - case "json stream": - producer, err := moqjson.NewStreamProducer(broadcast, track) - if err != nil { - t.Fatal(err) - } - err = producer.Append(map[string]int{"n": 1}, timestamp) - if err != nil { - t.Fatal(err) - } - if err := producer.Finish(); err != nil { - t.Fatal(err) - } + payload := []byte("opaque payload") + if err := publishData(mode, broadcast, track, payload, timestamp.at); err != nil { + t.Fatal(err) } frame, err := consumer.ReadFrame(ctx) if err != nil { @@ -74,15 +63,77 @@ func TestDataTimestamps(t *testing.T) { if frame == nil { t.Fatal("missing frame") } - if (frame.Timestamp == nil) != (timestamp == nil) || timestamp != nil && *frame.Timestamp != *timestamp { - t.Fatalf("timestamp = %v, want %v", frame.Timestamp, timestamp) + want := timestamp.at + if (frame.Timestamp == nil) != (want == nil) || want != nil && *frame.Timestamp != *want { + t.Fatalf("timestamp = %+v, want %+v", frame.Timestamp, want) } - var value map[string]int - if err := json.Unmarshal(frame.Payload, &value); err != nil { + if mode == "json snapshot" || mode == "json stream" { + var value map[string]int + if err := json.Unmarshal(frame.Payload, &value); err != nil { + t.Fatal(err) + } + if value["n"] != 1 { + t.Fatalf("payload = %s", frame.Payload) + } + } else if string(frame.Payload) != string(payload) { + t.Fatalf("payload = %q", frame.Payload) + } + }) + } + } +} + +// publishData writes one value at timestamp through the producer mode names, then finishes. +func publishData(mode string, broadcast *moq.BroadcastProducer, track *moq.TrackProducer, payload []byte, timestamp *moq.Timestamp) error { + switch mode { + case "frame": + return track.WriteFrame(moq.Frame{Payload: payload, Timestamp: timestamp}) + case "json snapshot": + producer, err := moqjson.NewSnapshotProducer(broadcast, track, moqjson.SnapshotOptions{}) + if err != nil { + return err + } + if err := producer.Update(map[string]int{"n": 1}, timestamp); err != nil { + return err + } + return producer.Finish() + case "json stream": + producer, err := moqjson.NewStreamProducer(broadcast, track) + if err != nil { + return err + } + if err := producer.Append(map[string]int{"n": 1}, timestamp); err != nil { + return err + } + return producer.Finish() + } + panic("unknown mode " + mode) +} + +// A zero scale, or a value or scale past what the wire carries, is refused rather +// than clamped, from a raw frame and a data producer alike. +func TestTimestampRefused(t *testing.T) { + for _, bad := range []struct { + name string + at moq.Timestamp + }{ + {"zero scale", moq.Timestamp{Value: 1, Scale: 0}}, + {"scale 2^62", moq.Timestamp{Value: 1, Scale: 1 << 62}}, + {"value 2^61", moq.Timestamp{Value: 1 << 61, Scale: 90_000}}, + } { + for _, mode := range []string{"frame", "json stream"} { + t.Run(mode+"/"+bad.name, func(t *testing.T) { + broadcast, err := moq.NewBroadcastProducer() + if err != nil { + t.Fatal(err) + } + defer broadcast.Close() + track, err := broadcast.PublishTrack("data", ninetyKHz()) + if err != nil { t.Fatal(err) } - if value["n"] != 1 { - t.Fatalf("payload = %s", frame.Payload) + if err := publishData(mode, broadcast, track, []byte("x"), &bad.at); !errors.Is(err, moq.ErrTimeOverflow) { + t.Fatalf("err = %v, want ErrTimeOverflow", err) } }) } diff --git a/go/wrapper/doc.go b/go/wrapper/doc.go index 336714fb0db..e4dba0028ac 100644 --- a/go/wrapper/doc.go +++ b/go/wrapper/doc.go @@ -5,7 +5,7 @@ // idiomatic Go: context.Context cancellation, Go error returns, and Go 1.23 // range-over-func iterators (iter.Seq2) for live streams. The raw record, // enum, and small object types are re-exported here without the Moq prefix (see types.go), -// records with timestamps carry time.Duration (see records.go), so +// media timestamps are exact Timestamp records (see records.go), so // most programs never need to import the ffi package directly. // // A typical full-duplex client wires a single origin as both publish source diff --git a/go/wrapper/errors.go b/go/wrapper/errors.go index f873990bcb4..92606424e51 100644 --- a/go/wrapper/errors.go +++ b/go/wrapper/errors.go @@ -41,7 +41,7 @@ var ( ErrVideo = ffi.ErrMoqErrorVideo // ErrURL matches a malformed URL passed when connecting or publishing. ErrURL = ffi.ErrMoqErrorUrl - // ErrTimeOverflow matches a timestamp outside the supported range. + // ErrTimeOverflow matches a timestamp value or scale outside the supported range, or a zero scale. ErrTimeOverflow = ffi.ErrMoqErrorTimeOverflow // ErrLogLevel matches an unparseable log level string passed to LogLevel. ErrLogLevel = ffi.ErrMoqErrorLogLevel diff --git a/go/wrapper/example_test.go b/go/wrapper/example_test.go index 88e6494ea15..da49aff1e08 100644 --- a/go/wrapper/example_test.go +++ b/go/wrapper/example_test.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "time" "moq.dev/moq" moqmedia "moq.dev/moq/media" @@ -62,7 +61,8 @@ func ExampleClient_CreateBroadcast() { } _ = publication.Announce(moq.AnnounceRoute{}) - pts := 20 * time.Millisecond + // 20 ms in, counted on the Opus 48 kHz sample clock. + pts := moq.Timestamp{Value: 960, Scale: 48_000} if err := media.WriteFrame(moq.Frame{Payload: []byte("opus frame"), Timestamp: &pts}); err != nil { log.Fatal(err) } diff --git a/go/wrapper/internal/bridge/handles.go b/go/wrapper/internal/bridge/handles.go index 39dc92d85e8..1ed1fbdfeed 100644 --- a/go/wrapper/internal/bridge/handles.go +++ b/go/wrapper/internal/bridge/handles.go @@ -38,21 +38,9 @@ var BroadcastConsumer func(any) *ffi.MoqBroadcastConsumer // TrackRequest returns the handle behind a *moq.TrackRequest. var TrackRequest func(any) *ffi.MoqTrackRequest -// Frame converts a moq.Frame to the generated record. -var Frame func(any) (ffi.MoqFrame, error) - // Subscription converts a *moq.Subscription to the generated record. var Subscription func(any) (*ffi.MoqSubscription, error) -// Duration converts d to microseconds, refusing a negative duration rather -// than letting it wrap when cast to uint64. -func Duration(d time.Duration, name string) (uint64, error) { - if d < 0 { - return 0, fmt.Errorf("negative %s: %v", name, d) - } - return uint64(d.Microseconds()), nil -} - // NonNegative refuses a negative duration, which the generated converter // would panic on rather than return an error. func NonNegative(value *time.Duration, name string) error { @@ -69,15 +57,3 @@ func Rendition(rendition ffi.MoqRendition) error { } return nil } - -// OptionalDuration converts a caller's time to microseconds, preserving absence. -func OptionalDuration(value *time.Duration, name string) (*uint64, error) { - if value == nil { - return nil, nil - } - us, err := Duration(*value, name) - if err != nil { - return nil, err - } - return &us, nil -} diff --git a/go/wrapper/json/json.go b/go/wrapper/json/json.go index 3406efdd7f5..138f045919e 100644 --- a/go/wrapper/json/json.go +++ b/go/wrapper/json/json.go @@ -16,7 +16,6 @@ import ( stdjson "encoding/json" "fmt" "iter" - "time" "moq.dev/moq" ffi "moq.dev/moq-ffi/moq" @@ -70,16 +69,12 @@ func NewSnapshotProducer(broadcast *moq.BroadcastProducer, track *moq.TrackProdu // Update publishes value at timestamp on the broadcast clock; nil is untimed. // It is a no-op when unchanged. -func (p *SnapshotProducer) Update(value any, timestamp *time.Duration) error { +func (p *SnapshotProducer) Update(value any, timestamp *moq.Timestamp) error { encoded, err := stdjson.Marshal(value) if err != nil { return err } - at, err := bridge.OptionalDuration(timestamp, "timestamp") - if err != nil { - return err - } - return p.inner.Update(string(encoded), at) + return p.inner.Update(string(encoded), timestamp) } // Demand returns a watch-only handle to whether the track has subscribers. @@ -113,16 +108,12 @@ func NewStreamProducer(broadcast *moq.BroadcastProducer, track *moq.TrackProduce } // Append adds one JSON record at timestamp on the broadcast clock; nil is untimed. -func (p *StreamProducer) Append(value any, timestamp *time.Duration) error { +func (p *StreamProducer) Append(value any, timestamp *moq.Timestamp) error { encoded, err := stdjson.Marshal(value) if err != nil { return err } - at, err := bridge.OptionalDuration(timestamp, "timestamp") - if err != nil { - return err - } - return p.inner.Append(string(encoded), at) + return p.inner.Append(string(encoded), timestamp) } // Demand returns a watch-only handle to whether the track has subscribers. @@ -143,22 +134,15 @@ func (p *StreamProducer) Finish() error { type Timed struct { // Value is the JSON document. Value stdjson.RawMessage - // Timestamp is on the broadcast clock, or nil on an untimed track. It is rounded down - // to a whole microsecond, so a tick on a track like 90 kHz may not survive a read and - // republish exactly. - Timestamp *time.Duration + // Timestamp is on the broadcast clock, or nil on an untimed track. + Timestamp *moq.Timestamp } func timed(value *ffi.MoqJsonValue) *Timed { if value == nil { return nil } - out := &Timed{Value: stdjson.RawMessage(value.Value)} - if value.TimestampUs != nil { - at := time.Duration(*value.TimestampUs) * time.Microsecond - out.Timestamp = &at - } - return out + return &Timed{Value: stdjson.RawMessage(value.Value), Timestamp: value.Timestamp} } // SnapshotConsumer receives each reconstructed JSON state. @@ -255,26 +239,18 @@ func NewWindowProducer(track *moq.TrackProducer, options WindowOptions) (*Window } // Push appends one JSON record at timestamp on the broadcast clock; nil is untimed. -func (p *WindowProducer) Push(value any, timestamp *time.Duration) error { +func (p *WindowProducer) Push(value any, timestamp *moq.Timestamp) error { encoded, err := stdjson.Marshal(value) if err != nil { return err } - at, err := bridge.OptionalDuration(timestamp, "timestamp") - if err != nil { - return err - } - return p.inner.Push(string(encoded), at) + return p.inner.Push(string(encoded), timestamp) } // Pop drops count records from the front of the window, clamped to what it holds, // at timestamp on the broadcast clock; nil is untimed. -func (p *WindowProducer) Pop(count uint64, timestamp *time.Duration) error { - at, err := bridge.OptionalDuration(timestamp, "timestamp") - if err != nil { - return err - } - return p.inner.Pop(count, at) +func (p *WindowProducer) Pop(count uint64, timestamp *moq.Timestamp) error { + return p.inner.Pop(count, timestamp) } // Cut closes the open group, so the next edit opens one restating the retained @@ -340,14 +316,14 @@ type TimedWindowEvent struct { // Timestamp is on the broadcast clock, or nil on an untimed track. A record // first learned of from a group's restatement carries that frame's time, not // its original push time. - Timestamp *time.Duration + Timestamp *moq.Timestamp } func timedWindowEvent(value *ffi.MoqJsonWindowTimed) (*TimedWindowEvent, error) { if value == nil { return nil, nil } - out := &TimedWindowEvent{} + out := &TimedWindowEvent{Timestamp: value.Timestamp} switch event := value.Event.(type) { case ffi.MoqJsonWindowEventPush: out.Event = WindowPush{Index: event.Index, Value: stdjson.RawMessage(event.Value)} @@ -358,10 +334,6 @@ func timedWindowEvent(value *ffi.MoqJsonWindowTimed) (*TimedWindowEvent, error) default: return nil, fmt.Errorf("unknown window event: %T", event) } - if value.TimestampUs != nil { - at := time.Duration(*value.TimestampUs) * time.Microsecond - out.Timestamp = &at - } return out, nil } diff --git a/go/wrapper/json/json_test.go b/go/wrapper/json/json_test.go index d22d13612c6..1cee5ca3a71 100644 --- a/go/wrapper/json/json_test.go +++ b/go/wrapper/json/json_test.go @@ -70,7 +70,7 @@ func TestTracks(t *testing.T) { } defer snapshotConsumer.Cancel() for viewers := 1; viewers <= 4; viewers++ { - at := time.Duration(viewers) * time.Millisecond + at := moq.Timestamp{Value: uint64(viewers) * 1000, Scale: scale} if err := snapshot.Update(map[string]any{"viewers": viewers}, &at); err != nil { t.Fatal(err) } @@ -89,7 +89,7 @@ func TestTracks(t *testing.T) { if decoded["viewers"] != float64(viewers) { t.Fatalf("snapshot = %s, want viewers %d", value.Value, viewers) } - if want := time.Duration(viewers) * time.Millisecond; value.Timestamp == nil || *value.Timestamp != want { + if want := (moq.Timestamp{Value: uint64(viewers) * 1000, Scale: scale}); value.Timestamp == nil || *value.Timestamp != want { t.Fatalf("timestamp = %v, want %v", value.Timestamp, want) } } @@ -149,9 +149,8 @@ func TestWindow(t *testing.T) { } defer windowConsumer.Cancel() - ms := func(n int) *time.Duration { - at := time.Duration(n) * time.Millisecond - return &at + ms := func(n int) *moq.Timestamp { + return &moq.Timestamp{Value: uint64(n) * 1000, Scale: scale} } for n := range 3 { if err := window.Push(map[string]any{"n": n}, ms(n)); err != nil { diff --git a/go/wrapper/media/media.go b/go/wrapper/media/media.go index 2c769bc6860..08a82cd39f1 100644 --- a/go/wrapper/media/media.go +++ b/go/wrapper/media/media.go @@ -91,11 +91,7 @@ type ContainerInit struct { } // MediaFrame is a container-decoded payload, presentation timestamp, and keyframe flag. -type MediaFrame struct { - Payload []byte - Timestamp time.Duration - Keyframe bool -} +type MediaFrame = ffi.MoqMediaFrame // Target is the named or requested track an importer publishes. type Target interface{ target() } @@ -143,9 +139,8 @@ func NewCatalogProducer(broadcast *moq.BroadcastProducer) (*CatalogProducer, err } // Now reads the broadcast clock, fixing it for subsequent media producers. -func (c *CatalogProducer) Now() (time.Duration, error) { - us, err := c.inner.Now() - return time.Duration(us) * time.Microsecond, err +func (c *CatalogProducer) Now() (moq.Timestamp, error) { + return c.inner.Now() } // SetVideoProperties replaces the video properties shared by all renditions. @@ -388,11 +383,7 @@ func (m *TrackProducer) Demand() (*moq.TrackDemand, error) { // WriteFrame appends frame to the media track. The importer derives keyframe status from // the bitstream, so a Frame carries only the payload and its timestamp. func (m *TrackProducer) WriteFrame(frame moq.Frame) error { - f, err := bridge.Frame(frame) - if err != nil { - return err - } - return m.inner.WriteFrame(f) + return m.inner.WriteFrame(frame) } // Discontinuity marks a timeline break, preserving the advertised jitter and delay. @@ -477,14 +468,6 @@ func (m *TrackStreamProducer) Finish() error { return m.inner.Finish() } -// mediaFrame converts the result of a Next call, keeping (nil, nil) for the end. -func mediaFrame(frame *ffi.MoqMediaFrame, err error) (*MediaFrame, error) { - if frame == nil || err != nil { - return nil, err - } - return &MediaFrame{Payload: frame.Payload, Timestamp: time.Duration(frame.TimestampUs) * time.Microsecond, Keyframe: frame.Keyframe}, nil -} - // Read is one encoded frame, a ReadGap, or a ReadEnd, in delivery order. type Read interface{ read() } @@ -493,13 +476,13 @@ type ReadFrame struct{ Frame MediaFrame } // ReadGap is lost media; what follows does not continue it. type ReadGap struct { - Timestamp time.Duration + Timestamp moq.Timestamp Duration time.Duration } // ReadEnd is where a stopped encoder's media stops. Following frames before End // are its remaining media; the first at or past End comes from a fresh encoder. -type ReadEnd struct{ End time.Duration } +type ReadEnd struct{ End moq.Timestamp } func (ReadFrame) read() {} func (ReadGap) read() {} @@ -508,15 +491,11 @@ func (ReadEnd) read() {} func mediaRead(raw ffi.MoqMediaRead) (Read, error) { switch read := raw.(type) { case ffi.MoqMediaReadFrame: - frame, err := mediaFrame(&read.Frame, nil) - if err != nil { - return nil, err - } - return ReadFrame{Frame: *frame}, nil + return ReadFrame{Frame: read.Frame}, nil case ffi.MoqMediaReadGap: - return ReadGap{Timestamp: time.Duration(read.TimestampUs) * time.Microsecond, Duration: read.Duration}, nil + return ReadGap{Timestamp: read.Timestamp, Duration: read.Duration}, nil case ffi.MoqMediaReadEnd: - return ReadEnd{End: time.Duration(read.EndUs) * time.Microsecond}, nil + return ReadEnd{End: read.End}, nil } return nil, fmt.Errorf("unknown media read: %T", raw) } @@ -558,7 +537,7 @@ func (m *GroupConsumer) Sequence() uint64 { // Next returns the next decoded frame, or (nil, nil) when the group ends. func (m *GroupConsumer) Next(ctx context.Context) (*MediaFrame, error) { - return mediaFrame(bridge.Call(ctx, m.inner.Cancel, m.inner.Next)) + return bridge.Call(ctx, m.inner.Cancel, m.inner.Next) } // All ranges over decoded frames until the group ends or the loop breaks. diff --git a/go/wrapper/media/read_test.go b/go/wrapper/media/read_test.go index 1f837a4d5ed..b284cd71e03 100644 --- a/go/wrapper/media/read_test.go +++ b/go/wrapper/media/read_test.go @@ -1,32 +1,35 @@ package media import ( - ffi "moq.dev/moq-ffi/moq" "testing" "time" + + "moq.dev/moq" + ffi "moq.dev/moq-ffi/moq" ) func TestMediaReadPreservesFramesAndBreaks(t *testing.T) { - frame, err := mediaRead(ffi.MoqMediaReadFrame{Frame: ffi.MoqMediaFrame{Payload: []byte("encoded"), TimestampUs: 123456, Keyframe: true}}) + tick := moq.Timestamp{Value: 1, Scale: 90_000} + frame, err := mediaRead(ffi.MoqMediaReadFrame{Frame: ffi.MoqMediaFrame{Payload: []byte("encoded"), Timestamp: tick, Keyframe: true}}) if err != nil { t.Fatal(err) } value, ok := frame.(ReadFrame) - if !ok || string(value.Frame.Payload) != "encoded" || value.Frame.Timestamp != 123456*time.Microsecond || !value.Frame.Keyframe { + if !ok || string(value.Frame.Payload) != "encoded" || value.Frame.Timestamp != tick || !value.Frame.Keyframe { t.Fatalf("frame: %#v", frame) } - gap, err := mediaRead(ffi.MoqMediaReadGap{TimestampUs: 123456, Duration: 78901 * time.Microsecond}) + gap, err := mediaRead(ffi.MoqMediaReadGap{Timestamp: tick, Duration: 78901 * time.Microsecond}) if err != nil { t.Fatal(err) } - if gap != (ReadGap{Timestamp: 123456 * time.Microsecond, Duration: 78901 * time.Microsecond}) { + if gap != (ReadGap{Timestamp: tick, Duration: 78901 * time.Microsecond}) { t.Fatalf("gap: %#v", gap) } - end, err := mediaRead(ffi.MoqMediaReadEnd{EndUs: 202357}) + end, err := mediaRead(ffi.MoqMediaReadEnd{End: tick}) if err != nil { t.Fatal(err) } - if end != (ReadEnd{End: 202357 * time.Microsecond}) { + if end != (ReadEnd{End: tick}) { t.Fatalf("end: %#v", end) } } diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index dd927abf536..39f9bff32ce 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -111,7 +111,7 @@ func TestDynamicBroadcastRequest(t *testing.T) { if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != string(payload) || frame.Timestamp == nil || *frame.Timestamp != 0 { + if frame == nil || string(frame.Payload) != string(payload) || frame.Timestamp == nil || *frame.Timestamp != *ts(0) { t.Fatalf("frame = %+v, want payload=%q ts=0", frame, payload) } @@ -136,7 +136,7 @@ func TestPublishAudioLifecycle(t *testing.T) { if err != nil { t.Fatal(err) } - if err := media.WriteFrame(moq.Frame{Payload: []byte("opus frame"), Timestamp: ts(1000 * time.Microsecond)}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: []byte("opus frame"), Timestamp: ts(1000)}); err != nil { t.Fatal(err) } if err := media.Finish(); err != nil { @@ -226,7 +226,7 @@ func TestEncodeAudioWithOpusObject(t *testing.T) { codec.Destroy() } // One 20 ms Opus frame of silence at 48 kHz mono. - if err := producer.Write(moqaudio.Frame{Data: make([]byte, 960*4)}); err != nil { + if err := producer.Write(moqaudio.Frame{Timestamp: moq.Timestamp{Scale: 48_000}, Data: make([]byte, 960*4)}); err != nil { t.Fatal(err) } if !codecFirst { @@ -311,7 +311,7 @@ func TestAudioConsumerDelay(t *testing.T) { // Two 20 ms Opus frames of silence at 48 kHz mono. for i := range 2 { - if err := audio.Write(moqaudio.Frame{Timestamp: time.Duration(i) * 20 * time.Millisecond, Data: make([]byte, 960*4)}); err != nil { + if err := audio.Write(moqaudio.Frame{Timestamp: moq.Timestamp{Value: uint64(i) * 960, Scale: 48_000}, Data: make([]byte, 960*4)}); err != nil { t.Fatal(err) } } @@ -363,7 +363,7 @@ func TestEncodeAudioFrameDurations(t *testing.T) { t.Fatal(err) } // 2.5 ms of silence at 48 kHz mono f32: exactly one encoded frame. - if err := fine.Write(moqaudio.Frame{Data: make([]byte, 120*4)}); err != nil { + if err := fine.Write(moqaudio.Frame{Timestamp: moq.Timestamp{Scale: 48_000}, Data: make([]byte, 120*4)}); err != nil { t.Fatal(err) } if err := fine.Finish(); err != nil { @@ -450,7 +450,7 @@ func testDecodeVideoFrame(t *testing.T, surface bool) { t.Fatal(err) } for i := range 10 { - if err := video.Write(moqvideo.Frame{Timestamp: time.Duration(i) * 33333 * time.Microsecond, Data: rgba}); err != nil { + if err := video.Write(moqvideo.Frame{Timestamp: moq.Timestamp{Value: uint64(i) * 1001, Scale: 30_000}, Data: rgba}); err != nil { t.Fatal(err) } } @@ -481,7 +481,7 @@ func testDecodeVideoFrame(t *testing.T, surface bool) { // Keep the encoder fed so the decoder sees frames after it joined. for i := 10; i < 40; i++ { - if err := video.Write(moqvideo.Frame{Timestamp: time.Duration(i) * 33333 * time.Microsecond, Data: rgba}); err != nil { + if err := video.Write(moqvideo.Frame{Timestamp: moq.Timestamp{Value: uint64(i) * 1001, Scale: 30_000}, Data: rgba}); err != nil { t.Fatal(err) } } @@ -673,7 +673,7 @@ func TestFetchGroupAndServeDynamicMiss(t *testing.T) { if err != nil { t.Fatal(err) } - if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: ts(7 * 20_000 * time.Microsecond)}); err != nil { + if err := produced.WriteFrame(moq.Frame{Payload: []byte("archive"), Timestamp: ts(7 * 20_000)}); err != nil { t.Fatal(err) } if err := produced.Finish(); err != nil { @@ -763,7 +763,7 @@ func TestLocalPublishConsumeAudio(t *testing.T) { defer mediaConsumer.Cancel() payload := []byte("opus audio payload data") - if err := media.WriteFrame(moq.Frame{Payload: payload, Timestamp: ts(1_000_000 * time.Microsecond)}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: payload, Timestamp: ts(1_000_000)}); err != nil { t.Fatal(err) } @@ -776,7 +776,7 @@ func TestLocalPublishConsumeAudio(t *testing.T) { t.Fatalf("expected a media frame, got %T", read) } frame := &value.Frame - if string(frame.Payload) != string(payload) || uint64(frame.Timestamp.Microseconds()) != 1_000_000 { + if string(frame.Payload) != string(payload) || moq.TimestampToDuration(frame.Timestamp) != time.Second { t.Fatalf("frame = %+v, want payload=%q ts=1000000", frame, payload) } @@ -802,7 +802,7 @@ func TestLocalPublishConsumeAudio(t *testing.T) { if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != string(payload) || uint64(frame.Timestamp.Microseconds()) != 1_000_000 { + if frame == nil || string(frame.Payload) != string(payload) || moq.TimestampToDuration(frame.Timestamp) != time.Second { t.Fatalf("decoded frame = %+v, want payload=%q ts=1000000", frame, payload) } } @@ -825,7 +825,7 @@ func TestTrackPublishConsume(t *testing.T) { } defer consumer.Cancel() - if err := track.WriteFrame(moq.Frame{Payload: []byte("hello"), Timestamp: ts(12_345 * time.Microsecond)}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: []byte("hello"), Timestamp: ts(12_345)}); err != nil { t.Fatal(err) } @@ -836,7 +836,7 @@ func TestTrackPublishConsume(t *testing.T) { if frame == nil { t.Fatal("expected a frame") } - if string(frame.Payload) != "hello" || frame.Timestamp == nil || *frame.Timestamp != 12_345*time.Microsecond { + if string(frame.Payload) != "hello" || frame.Timestamp == nil || *frame.Timestamp != *ts(12_345) { t.Fatalf("frame = %+v, want payload=hello ts=12345", frame) } @@ -849,7 +849,7 @@ func TestTrackPublishConsume(t *testing.T) { t.Fatal(err) } defer groupConsumer.Cancel() - if err := group.WriteFrame(moq.Frame{Payload: []byte("group"), Timestamp: ts(23_456 * time.Microsecond)}); err != nil { + if err := group.WriteFrame(moq.Frame{Payload: []byte("group"), Timestamp: ts(23_456)}); err != nil { t.Fatal(err) } if err := group.Finish(); err != nil { @@ -862,7 +862,7 @@ func TestTrackPublishConsume(t *testing.T) { if frame == nil { t.Fatal("expected a group frame") } - if string(frame.Payload) != "group" || frame.Timestamp == nil || *frame.Timestamp != 23_456*time.Microsecond { + if string(frame.Payload) != "group" || frame.Timestamp == nil || *frame.Timestamp != *ts(23_456) { t.Fatalf("frame = %+v, want payload=group ts=23456", frame) } } @@ -884,7 +884,7 @@ func TestAscendingSubscriptionReadsCachedBacklogOldestFirst(t *testing.T) { if err != nil { t.Fatal(err) } - if err := group.WriteFrame(moq.Frame{Payload: []byte("x"), Timestamp: ts(time.Duration(i) * time.Millisecond)}); err != nil { + if err := group.WriteFrame(moq.Frame{Payload: []byte("x"), Timestamp: ts(uint64(i) * 1000)}); err != nil { t.Fatal(err) } if err := group.Finish(); err != nil { @@ -945,7 +945,7 @@ func TestReadFrameSkipsEmptyThenPopulatedGroups(t *testing.T) { if err := empty.Finish(); err != nil { t.Fatal(err) } - if err := track.WriteFrame(moq.Frame{Payload: []byte("populated"), Timestamp: ts(2_000 * time.Microsecond)}); err != nil { + if err := track.WriteFrame(moq.Frame{Payload: []byte("populated"), Timestamp: ts(2_000)}); err != nil { t.Fatal(err) } @@ -953,7 +953,7 @@ func TestReadFrameSkipsEmptyThenPopulatedGroups(t *testing.T) { if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != "populated" || frame.Timestamp == nil || *frame.Timestamp != 2_000*time.Microsecond { + if frame == nil || string(frame.Payload) != "populated" || frame.Timestamp == nil || *frame.Timestamp != *ts(2_000) { t.Fatalf("frame = %+v, want payload=populated ts=2000", frame) } } @@ -1063,7 +1063,7 @@ func TestDynamicTrackRequest(t *testing.T) { if err != nil { t.Fatal(err) } - if frame == nil || string(frame.Payload) != string(payload) || frame.Timestamp == nil || *frame.Timestamp != 0 { + if frame == nil || string(frame.Payload) != string(payload) || frame.Timestamp == nil || *frame.Timestamp != *ts(0) { t.Fatalf("frame = %+v, want payload=%q ts=0", frame, payload) } if err := track.Finish(); err != nil { @@ -1143,7 +1143,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { defer mediaConsumer.Cancel() payload := []byte("dynamic opus frame") - if err := media.WriteFrame(moq.Frame{Payload: payload, Timestamp: ts(20_000 * time.Microsecond)}); err != nil { + if err := media.WriteFrame(moq.Frame{Payload: payload, Timestamp: ts(20_000)}); err != nil { t.Fatal(err) } @@ -1156,7 +1156,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { t.Fatalf("expected a media frame, got %T", read) } frame := &value.Frame - if string(frame.Payload) != string(payload) || uint64(frame.Timestamp.Microseconds()) != 20_000 { + if string(frame.Payload) != string(payload) || moq.TimestampToDuration(frame.Timestamp) != 20*time.Millisecond { t.Fatalf("frame = %+v, want payload=%q ts=20000", frame, payload) } // The explicit pause marker is an encoder end even while the track stays open. @@ -1170,7 +1170,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { if err != nil { t.Fatal(err) } - if value, ok := end.(moqmedia.ReadEnd); !ok || value.End != 20_000*time.Microsecond { + if value, ok := end.(moqmedia.ReadEnd); !ok || moq.TimestampToDuration(value.End) != 20*time.Millisecond { t.Fatalf("expected encoder end, got %T", end) } if next, err := mediaConsumer.Next(ctx); next != nil || err != nil { @@ -1811,7 +1811,8 @@ func TestClosingAPreparedAnswerRefusesTheRequest(t *testing.T) { // us is a raw frame's optional timestamp in microseconds. func us(v uint64) *uint64 { return &v } -func ts(d time.Duration) *time.Duration { return &d } +// ts is a presentation time in microseconds, the scale timedInfo declares. +func ts(us uint64) *moq.Timestamp { return &moq.Timestamp{Value: us, Scale: 1_000_000} } // timedInfo declares the microsecond timeline used by timed raw fixtures. func timedInfo() *moq.TrackInfo { diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index a179024d830..aa808095ccc 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -206,31 +206,19 @@ func (t *TrackProducer) CreateGroup(sequence uint64) (*GroupProducer, error) { // WriteFrame writes frame as a single-frame group. func (t *TrackProducer) WriteFrame(frame Frame) error { - f, err := frame.ffi() - if err != nil { - return err - } - return t.inner.WriteFrame(f) + return t.inner.WriteFrame(frame) } // AppendDatagram sends frame as a best-effort datagram and returns the sequence number // assigned to it. Payloads are capped at 1200 bytes. There is no stream fallback. func (t *TrackProducer) AppendDatagram(frame Frame) (uint64, error) { - f, err := frame.ffi() - if err != nil { - return 0, err - } - return t.inner.AppendDatagram(f) + return t.inner.AppendDatagram(frame) } // InsertDatagram sends frame as a best-effort datagram at an explicit sequence, for a // caller that allocates its own. AppendDatagram continues after the largest one. func (t *TrackProducer) InsertDatagram(sequence uint64, frame Frame) error { - f, err := frame.ffi() - if err != nil { - return err - } - return t.inner.InsertDatagram(sequence, f) + return t.inner.InsertDatagram(sequence, frame) } // Largest reports the newest group or datagram written, at its newest frame, or nil @@ -290,11 +278,7 @@ func (g *GroupProducer) Consume() (*GroupConsumer, error) { // WriteFrame appends frame to the group. func (g *GroupProducer) WriteFrame(frame Frame) error { - f, err := frame.ffi() - if err != nil { - return err - } - return g.inner.WriteFrame(f) + return g.inner.WriteFrame(frame) } // Finish marks the group complete. The handle remains so Abort can still run. diff --git a/go/wrapper/records.go b/go/wrapper/records.go index 70bf887b02b..5ce0c0d6305 100644 --- a/go/wrapper/records.go +++ b/go/wrapper/records.go @@ -1,66 +1,53 @@ package moq import ( + "fmt" "math" + "math/bits" "time" ffi "moq.dev/moq-ffi/moq" "moq.dev/moq/internal/bridge" ) -// moq-ffi spells media timestamps as integer microseconds. These records mirror -// the generated ones with time.Duration timestamps and convert at the boundary. +// Timestamp is an exact media time: Value units at Scale units per second, with Value +// at most 2^61 - 1 and Scale non-zero and at most 2^62 - 1. A read carries the track's +// scale, so a tick such as 90 kHz survives a read and republish exactly. +type Timestamp = ffi.MoqTimestamp -// fromMicros saturates past time.Duration's ~292 years rather than wrapping to -// a negative duration. -func fromMicros(us uint64) time.Duration { - if us > uint64(math.MaxInt64/int64(time.Microsecond)) { - return time.Duration(math.MaxInt64) +// TimestampFromDuration converts d exactly, at nanosecond scale, refusing a negative d. +func TimestampFromDuration(d time.Duration) (Timestamp, error) { + if d < 0 { + return Timestamp{}, fmt.Errorf("negative timestamp: %v", d) } - return time.Duration(us) * time.Microsecond + return Timestamp{Value: uint64(d), Scale: uint64(time.Second)}, nil } -func optFromMicros(us *uint64) *time.Duration { - if us == nil { - return nil +// TimestampToDuration converts t to a time.Duration, rounded down to a whole +// nanosecond, saturating past time.Duration's ~292 years. It panics on a zero +// Scale, which no read returns. +func TimestampToDuration(t Timestamp) time.Duration { + if t.Scale == 0 { + panic("moq: timestamp with a zero scale") } - d := fromMicros(*us) - return &d + hi, lo := bits.Mul64(t.Value, uint64(time.Second)) + if hi >= t.Scale { + return time.Duration(math.MaxInt64) + } + ns, _ := bits.Div64(hi, lo, t.Scale) + if ns > math.MaxInt64 { + return time.Duration(math.MaxInt64) + } + return time.Duration(ns) } // Frame is a raw track frame: a payload and its presentation timestamp. // Timestamp is nil on an untimed track, on both publish and receive. -type Frame struct { - Payload []byte - Timestamp *time.Duration -} - -func (f Frame) ffi() (ffi.MoqFrame, error) { - us, err := bridge.OptionalDuration(f.Timestamp, "timestamp") - return ffi.MoqFrame{Payload: f.Payload, TimestampUs: us}, err -} - -func frameFromFFI(f *ffi.MoqFrame) *Frame { - if f == nil { - return nil - } - return &Frame{Payload: f.Payload, Timestamp: optFromMicros(f.TimestampUs)} -} +type Frame = ffi.MoqFrame // Datagram is a best-effort track datagram as received: sequence number, timestamp // (nil from an untimed track), and payload. -type Datagram struct { - Sequence uint64 - Timestamp *time.Duration - Payload []byte -} - -func datagramFromFFI(d *ffi.MoqDatagram) *Datagram { - if d == nil { - return nil - } - return &Datagram{Sequence: d.Sequence, Timestamp: optFromMicros(d.TimestampUs), Payload: d.Payload} -} +type Datagram = ffi.MoqDatagram // Subscription holds subscriber-side delivery preferences: priority, the max delay // a non-latest group may fall behind before it is skipped (zero skips at once, nil diff --git a/go/wrapper/records_internal_test.go b/go/wrapper/records_internal_test.go index b9e613fad0d..725d10d15d1 100644 --- a/go/wrapper/records_internal_test.go +++ b/go/wrapper/records_internal_test.go @@ -6,26 +6,59 @@ import ( "time" ) -// A peer's delay or max age can pass time.Duration's ~292 years, which must read as -// the longest duration rather than wrap to a negative one that is already expired. -func TestFromMicrosSaturates(t *testing.T) { - limit := uint64(math.MaxInt64 / int64(time.Microsecond)) +// A duration converts at its own nanosecond scale, so nothing rounds on the way in. +func TestTimestampFromDuration(t *testing.T) { + got, err := TimestampFromDuration(1500 * time.Millisecond) + if err != nil { + t.Fatal(err) + } + if want := (Timestamp{Value: 1_500_000_000, Scale: 1_000_000_000}); got != want { + t.Fatalf("TimestampFromDuration(1.5s) = %+v, want %+v", got, want) + } + if got := TimestampToDuration(got); got != 1500*time.Millisecond { + t.Fatalf("round trip = %v, want 1.5s", got) + } + if _, err := TimestampFromDuration(-time.Nanosecond); err == nil { + t.Fatal("negative duration converted") + } +} + +// Converting to a duration rounds down to a whole nanosecond, and a time past +// time.Duration's ~292 years reads as the longest duration rather than wrapping. +func TestTimestampToDuration(t *testing.T) { cases := []struct { - us uint64 + in Timestamp want time.Duration }{ - {us: 1, want: time.Microsecond}, - {us: limit, want: time.Duration(limit) * time.Microsecond}, - {us: limit + 1, want: time.Duration(math.MaxInt64)}, - {us: math.MaxUint64, want: time.Duration(math.MaxInt64)}, + {Timestamp{Value: 0, Scale: 90_000}, 0}, + // 11111.1 ns, 666666666.6 ns, and half a nanosecond all round down. + {Timestamp{Value: 1, Scale: 90_000}, 11_111}, + {Timestamp{Value: 2, Scale: 3}, 666_666_666}, + {Timestamp{Value: 1, Scale: 2_000_000_000}, 0}, + {Timestamp{Value: 1001, Scale: 30_000}, 33_366_666}, + {Timestamp{Value: math.MaxInt64, Scale: 1_000_000_000}, math.MaxInt64}, + {Timestamp{Value: math.MaxInt64/1000 + 1, Scale: 1_000_000}, math.MaxInt64}, + {Timestamp{Value: 1<<61 - 1, Scale: 1}, math.MaxInt64}, + {Timestamp{Value: math.MaxUint64, Scale: math.MaxUint64}, time.Second}, } for _, c := range cases { - if got := fromMicros(c.us); got != c.want { - t.Errorf("fromMicros(%d) = %v, want %v", c.us, got, c.want) + if got := TimestampToDuration(c.in); got != c.want { + t.Errorf("TimestampToDuration(%+v) = %d, want %d", c.in, got, c.want) } } } +// A zero scale, such as an unset Timestamp{}, panics rather than reading as the +// longest duration. +func TestTimestampToDurationZeroScale(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("TimestampToDuration(Timestamp{}) did not panic") + } + }() + TimestampToDuration(Timestamp{}) +} + // DefaultSubscription carries moq-net's zero max delay, while the zero Subscription // leaves it nil, which sets no subscriber limit. func TestDefaultSubscriptionMaxDelay(t *testing.T) { diff --git a/go/wrapper/runtime_bench_test.go b/go/wrapper/runtime_bench_test.go index b3f78bdebd5..5507f55d70c 100644 --- a/go/wrapper/runtime_bench_test.go +++ b/go/wrapper/runtime_bench_test.go @@ -199,7 +199,7 @@ func benchThroughput(b *testing.B, c benchCase) { done <- err return } - want := time.Duration(n*frames+f) * time.Microsecond + want := moq.Timestamp{Value: uint64(n*frames + f), Scale: 1_000_000} if frame == nil || !bytes.Equal(frame.Payload, payload) || frame.Timestamp == nil || *frame.Timestamp != want { done <- fmt.Errorf("missing, reordered, or corrupt frame %d of round %d", f, n) return @@ -214,7 +214,7 @@ func benchThroughput(b *testing.B, c benchCase) { return err } for f := range frames { - timestamp := time.Duration(n*frames+f) * time.Microsecond + timestamp := moq.Timestamp{Value: uint64(n*frames + f), Scale: 1_000_000} if err := group.WriteFrame(moq.Frame{Payload: payload, Timestamp: ×tamp}); err != nil { return err } diff --git a/go/wrapper/subscribe.go b/go/wrapper/subscribe.go index 1b4ed49485b..a7d0465e245 100644 --- a/go/wrapper/subscribe.go +++ b/go/wrapper/subscribe.go @@ -92,8 +92,7 @@ func (g *GroupConsumer) Sequence() uint64 { // ReadFrame returns the next raw frame, or (nil, nil) when the group ends. func (g *GroupConsumer) ReadFrame(ctx context.Context) (*Frame, error) { - frame, err := bridge.Call(ctx, g.inner.Cancel, g.inner.ReadFrame) - return frameFromFFI(frame), err + return bridge.Call(ctx, g.inner.Cancel, g.inner.ReadFrame) } // All ranges over raw frames until the group ends or the loop breaks. @@ -147,15 +146,13 @@ func (t *TrackConsumer) group(ctx context.Context, next func(context.Context) (* // Cancelling the context cancels this consumer, not just this call: see // package docs. func (t *TrackConsumer) ReadFrame(ctx context.Context) (*Frame, error) { - frame, err := bridge.Call(ctx, t.inner.Cancel, t.inner.ReadFrame) - return frameFromFFI(frame), err + return bridge.Call(ctx, t.inner.Cancel, t.inner.ReadFrame) } // RecvDatagram returns the next best-effort datagram in arrival order, or // (nil, nil) when the track ends. func (t *TrackConsumer) RecvDatagram(ctx context.Context) (*Datagram, error) { - datagram, err := bridge.Call(ctx, t.inner.Cancel, t.inner.RecvDatagram) - return datagramFromFFI(datagram), err + return bridge.Call(ctx, t.inner.Cancel, t.inner.RecvDatagram) } // Info returns the publisher-side track properties learned during subscription. @@ -201,9 +198,9 @@ type VideoDecodedFrame struct { inner *ffi.MoqVideoDecodedFrame } -// TimestampUs is the presentation timestamp in microseconds. -func (f *VideoDecodedFrame) TimestampUs() uint64 { - return f.inner.TimestampUs() +// Timestamp is the presentation timestamp. +func (f *VideoDecodedFrame) Timestamp() Timestamp { + return f.inner.Timestamp() } // Width is the decoded width in pixels. diff --git a/go/wrapper/types.go b/go/wrapper/types.go index 18012e03b85..47a52311321 100644 --- a/go/wrapper/types.go +++ b/go/wrapper/types.go @@ -19,7 +19,7 @@ type ( AudioEncoderOutput = ffi.MoqAudioEncoderOutput // AudioSampleFormat is a raw PCM sample layout, mirroring WebCodecs AudioData.format. AudioSampleFormat = ffi.MoqAudioSampleFormat - // AudioFrame is one audio frame: PCM payload plus a presentation timestamp in microseconds. + // AudioFrame is one audio frame: PCM payload plus a presentation Timestamp. AudioFrame = ffi.MoqAudioFrame // ConnectionPhase is a connection lifecycle transition reported by Session.State. ConnectionPhase = ffi.MoqConnectionPhase @@ -66,7 +66,7 @@ type ( AnchorAbsolute = ffi.MoqAnchorAbsolute // AnchorRelative is the group Groups before the latest one, clamped to group 0: 0 is the latest. AnchorRelative = ffi.MoqAnchorRelative - // AnchorTime is the group containing TimestampUs, a presentation time in microseconds. + // AnchorTime is the group containing a presentation Timestamp. AnchorTime = ffi.MoqAnchorTime // Order is which of a subscription's pending groups the publisher sends first. Order = ffi.MoqOrder @@ -86,7 +86,7 @@ type ( VideoEncoderInput = ffi.MoqVideoEncoderInput // VideoEncoderOutput configures the video codec, bitrate, keyframe interval, and backend preference. VideoEncoderOutput = ffi.MoqVideoEncoderOutput - // VideoFrame is one raw video frame: pixels in the configured layout plus a presentation timestamp in microseconds. + // VideoFrame is one raw video frame: pixels in the configured layout plus a presentation Timestamp. VideoFrame = ffi.MoqVideoFrame // VideoEncoderKind selects the encoder implementation. Build one with // AutoEncoder, HardwareEncoder, SoftwareEncoder, or NamedEncoder. diff --git a/go/wrapper/video/video.go b/go/wrapper/video/video.go index 17eb552ce7e..fd078739104 100644 --- a/go/wrapper/video/video.go +++ b/go/wrapper/video/video.go @@ -38,26 +38,10 @@ type Gop = ffi.MoqVideoGop func Keyframe(interval uint32) Gop { return ffi.MoqVideoGopKeyframe{Interval: interval} } // Frame is one raw picture with its presentation timestamp. -type Frame struct { - Timestamp time.Duration - Data []byte -} - -func (f Frame) ffi() (ffi.MoqVideoFrame, error) { - us, err := bridge.Duration(f.Timestamp, "video timestamp") - return ffi.MoqVideoFrame{TimestampUs: us, Data: f.Data}, err -} +type Frame = ffi.MoqVideoFrame // Packet is one encoded access unit from an Encoder. -type Packet struct { - Timestamp time.Duration - Data []byte - Keyframe bool -} - -func packet(raw ffi.MoqVideoPacket) Packet { - return Packet{Timestamp: time.Duration(raw.TimestampUs) * time.Microsecond, Data: raw.Data, Keyframe: raw.Keyframe} -} +type Packet = ffi.MoqVideoPacket // DecoderOutput configures an optional resize, a delay, whether frames keep the decoder's surface (macOS only), and where to start. type DecoderOutput struct { @@ -135,11 +119,7 @@ func (p *Producer) Demand() (*moq.TrackDemand, error) { // Write encodes and publishes one raw picture in the configured input layout. func (p *Producer) Write(value Frame) error { - raw, err := value.ffi() - if err != nil { - return err - } - return p.inner.Write(raw) + return p.inner.Write(value) } // Cut starts a new group at the next written picture. @@ -172,22 +152,9 @@ func NewEncoder(input EncoderInput, output EncoderOutput) (*Encoder, error) { return &Encoder{inner}, nil } -func packets(raw []ffi.MoqVideoPacket) []Packet { - out := make([]Packet, len(raw)) - for i := range raw { - out[i] = packet(raw[i]) - } - return out -} - // Encode encodes one picture, returning any access units now available. func (e *Encoder) Encode(value Frame) ([]Packet, error) { - raw, err := value.ffi() - if err != nil { - return nil, err - } - result, err := e.inner.Encode(raw) - return packets(result), err + return e.inner.Encode(value) } // Cut forces the next encoded picture to start a new group. @@ -197,10 +164,10 @@ func (e *Encoder) Cut() error { return e.inner.Cut() } func (e *Encoder) SetBitrate(value uint64) error { return e.inner.SetBitrate(value) } // Flush drains buffered access units and keeps the encoder open. -func (e *Encoder) Flush() ([]Packet, error) { raw, err := e.inner.Flush(); return packets(raw), err } +func (e *Encoder) Flush() ([]Packet, error) { return e.inner.Flush() } // Finish drains and closes the encoder. -func (e *Encoder) Finish() ([]Packet, error) { raw, err := e.inner.Finish(); return packets(raw), err } +func (e *Encoder) Finish() ([]Packet, error) { return e.inner.Finish() } // Consumer subscribes to a video rendition and yields decoded frames. type Consumer struct{ inner *ffi.MoqVideoConsumer } @@ -231,7 +198,7 @@ type ReadFrame struct { // loss precedes it, and what follows does not continue it. type ReadGap struct { // Timestamp is the furthest presentation point already read. - Timestamp time.Duration + Timestamp moq.Timestamp // Duration is an upper bound on how long until the next picture resumes. Duration time.Duration } @@ -241,7 +208,7 @@ type ReadGap struct { // fresh encoder. type ReadEnd struct { // End is where the stopped encoder's media stops. - End time.Duration + End moq.Timestamp } func (ReadFrame) read() {} @@ -269,9 +236,9 @@ func (d *Consumer) Next(ctx context.Context) (Read, error) { case ffi.MoqVideoReadFrame: return ReadFrame{Frame: bridge.VideoDecodedFrame(read.Frame).(*moq.VideoDecodedFrame)}, nil case ffi.MoqVideoReadGap: - return ReadGap{Timestamp: time.Duration(read.TimestampUs) * time.Microsecond, Duration: read.Duration}, nil + return ReadGap{Timestamp: read.Timestamp, Duration: read.Duration}, nil case ffi.MoqVideoReadEnd: - return ReadEnd{End: time.Duration(read.EndUs) * time.Microsecond}, nil + return ReadEnd{End: read.End}, nil } return nil, fmt.Errorf("unknown video read: %T", *raw) } diff --git a/kt/README.md b/kt/README.md index 1531c2b0efa..1fb202ee284 100644 --- a/kt/README.md +++ b/kt/README.md @@ -60,9 +60,9 @@ The `dev.moq` package is intentionally thin: Kotlin has extension functions, so - **Flow extensions** (`Flows.kt`): `updates()`, `groups()`, and `frames()` turn the pull-based consumers into coroutine `Flow`s with cancellation wired through. - **Media** (`dev.moq.media`): catalog, importers, and container consumers, with `catalog(broadcast)` for a one-shot catalog read and `frames()` for container-decoded media. - **Fetched media**: `dev.moq.media.GroupConsumer.fetch(...).frames()` streams the decoded frames of one retained group, then completes. `dev.moq.media.GroupConsumer(group, container)` decodes a raw group instead, such as one from `recvGroup()` for arrival-order reads. -- **Timestamp extensions** (`Durations.kt`): the FFI carries media timestamps as microsecond integers, so `frame.timestamp` and its siblings read back as a `kotlin.time.Duration`. Durations such as `stats.rtt` are already `java.time.Duration`. +- **Timestamp conversions** (`Timestamp.kt`): media timestamps are exact `Timestamp(value, scale)` records. `Duration.toTimestamp()` builds one exactly at nanosecond scale, and `Timestamp.toDuration()` reads one back as a `kotlin.time.Duration`, rounded down to a whole nanosecond. Durations such as `stats.rtt` are already `java.time.Duration`. - **`logLevel(...)`**: configures native Rust tracing without importing the raw bindings package. -- **Raw datagrams**: `TrackProducer.appendDatagram(Frame(payload, timestampUs))` sends one best-effort frame and returns its sequence, and `insertDatagram(sequence, frame)` takes the caller's own; `TrackConsumer.recvDatagram()` and `datagrams()` receive them. Payloads are capped at 1200 bytes, require a datagram-capable transport plus lite-05 or newer moq-lite or moq-transport, and have no stream fallback. +- **Raw datagrams**: `TrackProducer.appendDatagram(Frame(payload, timestamp))` sends one best-effort frame and returns its sequence, and `insertDatagram(sequence, frame)` takes the caller's own; `TrackConsumer.recvDatagram()` and `datagrams()` receive them. Payloads are capped at 1200 bytes, require a datagram-capable transport plus lite-05 or newer moq-lite or moq-transport, and have no stream fallback. - **`MoqException.isShutdown`** (`Errors.kt`): true for the graceful `Cancelled`/`Closed` cases. ## Versioning @@ -97,7 +97,7 @@ kt/ moq/ build.gradle.kts Pure-Kotlin wrapper; coordinates dev.moq:moq src/ - jvmAndAndroidMain/kotlin/dev/moq/ Wrapper sources (Moq, Server, Aliases, Flows, Durations, Errors) + jvmAndAndroidMain/kotlin/dev/moq/ Wrapper sources (Moq, Server, Aliases, Flows, Timestamp, Errors) jvmAndAndroidTest/ Facade smoke test ``` diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index f52c572e76b..1074bf998b3 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -162,6 +162,9 @@ typealias Datagram = uniffi.moq.MoqDatagram /** A payload plus the timestamp it should be presented at. */ typealias Frame = uniffi.moq.MoqFrame +/** An exact media timestamp: `value` units at `scale` units per second. */ +typealias Timestamp = uniffi.moq.MoqTimestamp + /** * How a broadcast advertises its exact path: its static production cost (lower wins) and the * publisher epoch it serves (null when unknown). @@ -200,7 +203,7 @@ typealias AnchorAbsolute = uniffi.moq.MoqAnchor.Absolute /** The group `groups` before the latest one, clamped to group 0: 0 is the latest. */ typealias AnchorRelative = uniffi.moq.MoqAnchor.Relative -/** The group containing `timestampUs`, a presentation time in microseconds. */ +/** The group containing `timestamp`, a presentation time. */ typealias AnchorTime = uniffi.moq.MoqAnchor.Time /** Which pending group the publisher sends first: `ASCENDING` or `DESCENDING`. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt deleted file mode 100644 index dc34a9985a0..00000000000 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Durations.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.moq - -import kotlin.time.Duration -import kotlin.time.Duration.Companion.microseconds - -// The FFI surface carries media timestamps as microsecond integers; durations -// already cross as java.time.Duration. Read the timestamps back as a Duration. -// These are extension properties on the typealiased records, so the `*Us` -// fields stay available for anyone building a record to send back across the -// boundary. - -/** Presentation timestamp, or null for an untimed frame. */ -val Frame.timestamp: Duration? - get() = timestampUs?.toLong()?.microseconds - -/** Presentation timestamp, or null for an untimed datagram. */ -val Datagram.timestamp: Duration? - get() = timestampUs?.toLong()?.microseconds - -/** Presentation timestamp of the first sample. */ -val AudioFrame.timestamp: Duration - get() = timestampUs.toLong().microseconds - -/** Presentation timestamp. */ -val VideoFrame.timestamp: Duration - get() = timestampUs.toLong().microseconds - -/** Presentation timestamp. */ -val VideoDecodedFrame.timestamp: Duration - get() = timestampUs().toLong().microseconds diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Timestamp.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Timestamp.kt new file mode 100644 index 00000000000..e6675856781 --- /dev/null +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Timestamp.kt @@ -0,0 +1,34 @@ +package dev.moq + +import java.math.BigInteger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds + +/** + * This timestamp as a [Duration], rounded down to a whole nanosecond. + * + * The [Timestamp] itself is exact; keep it, not the duration, to republish a tick such as 90 kHz. + * Throws [IllegalArgumentException] past [Duration]'s range, about 146 million years. + */ +fun Timestamp.toDuration(): Duration { + // The sub-second remainder times a billion can exceed 64 bits. + val remainder = (value % scale).toString().toBigInteger() + val divisor = scale.toString().toBigInteger() + val nanos = remainder * BigInteger.valueOf(1_000_000_000L) / divisor + val duration = (value / scale).toLong().seconds + nanos.toLong().nanoseconds + // Duration saturates to infinite instead of failing. + require(duration.isFinite()) { "timestamp $value/$scale s does not fit in a Duration" } + return duration +} + +/** + * This duration as an exact [Timestamp] at nanosecond scale. + * + * Throws [IllegalArgumentException] when negative; a write refuses one past the value bound with + * `MoqException.TimeOverflow`. + */ +fun Duration.toTimestamp(): Timestamp { + require(!isNegative()) { "a timestamp cannot be negative: $this" } + return Timestamp(value = inWholeNanoseconds.toULong(), scale = 1_000_000_000uL) +} diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/audio/Audio.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/audio/Audio.kt index c39e89751ca..b7a7ffb3e31 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/audio/Audio.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/audio/Audio.kt @@ -36,7 +36,7 @@ typealias ReadFrame = uniffi.moq.MoqAudioRead.Frame /** Media after what was already read was lost; what follows does not continue it. */ typealias ReadGap = uniffi.moq.MoqAudioRead.Gap -/** The publisher stopped its encoder; frames starting before `endUs` are its remaining media. */ +/** The publisher stopped its encoder; frames starting before `end` are its remaining media. */ typealias ReadEnd = uniffi.moq.MoqAudioRead.End /** One encoded payload from an [Encoder]. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt index b0bade746de..36ef697f80d 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/json/Tracks.kt @@ -11,9 +11,8 @@ package dev.moq.json // val reader = SnapshotConsumer(consumer.subscribeTrack("status", null)) import dev.moq.MoqJson +import dev.moq.Timestamp import dev.moq.pull -import kotlin.time.Duration -import kotlin.time.Duration.Companion.microseconds import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.serializer @@ -62,22 +61,8 @@ typealias WindowEventSkip = uniffi.moq.MoqJsonWindowEvent.Skip /** A [WindowEvent] and the timestamp of the frame that carried it. */ typealias WindowTimed = uniffi.moq.MoqJsonWindowTimed -/** - * The frame's timestamp on the broadcast clock, or null when untimed. Rounded down to a whole - * microsecond, so a tick on a track like 90 kHz may not survive a read and republish exactly. - */ -val Value.timestamp: Duration? - get() = timestampUs?.toLong()?.microseconds - -/** - * The frame's timestamp on the broadcast clock, or null when untimed. A record first learned of - * from a group's restatement carries that frame's time, not its original push time. - */ -val WindowTimed.timestamp: Duration? - get() = timestampUs?.toLong()?.microseconds - /** A decoded JSON value and its frame timestamp on the broadcast clock, or null when untimed. */ -data class Timed(val value: T, val timestamp: Duration?) +data class Timed(val value: T, val timestamp: Timestamp?) /** Decode [Value.value] as [T], keeping the timestamp. */ inline fun Value.decode(): Timed = @@ -87,21 +72,21 @@ inline fun Value.decode(): Timed = * Publish [value] as a JSON snapshot, superseding the last. A no-op when unchanged. * * The `@Serializable` type is encoded with [MoqJson]. Pass an already-encoded `String` to serialize - * with another library: the member overload takes it unchanged. [timestampUs] is on the broadcast + * with another library: the member overload takes it unchanged. [timestamp] is on the broadcast * clock; null is untimed. */ -inline fun SnapshotProducer.update(value: T, timestampUs: ULong? = null) { - update(MoqJson.encodeToString(serializer(), value), timestampUs) +inline fun SnapshotProducer.update(value: T, timestamp: Timestamp? = null) { + update(MoqJson.encodeToString(serializer(), value), timestamp) } -/** Append [value] at [timestampUs] on the broadcast clock; null is untimed. */ -inline fun StreamProducer.append(value: T, timestampUs: ULong? = null) { - append(MoqJson.encodeToString(serializer(), value), timestampUs) +/** Append [value] at [timestamp] on the broadcast clock; null is untimed. */ +inline fun StreamProducer.append(value: T, timestamp: Timestamp? = null) { + append(MoqJson.encodeToString(serializer(), value), timestamp) } -/** Push [value] onto the window at [timestampUs] on the broadcast clock; null is untimed. */ -inline fun WindowProducer.push(value: T, timestampUs: ULong? = null) { - push(MoqJson.encodeToString(serializer(), value), timestampUs) +/** Push [value] onto the window at [timestamp] on the broadcast clock; null is untimed. */ +inline fun WindowProducer.push(value: T, timestamp: Timestamp? = null) { + push(MoqJson.encodeToString(serializer(), value), timestamp) } /** Decode the pushed record as [T]. */ diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/media/Media.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/media/Media.kt index 5efd41eb105..38d759acc47 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/media/Media.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/media/Media.kt @@ -3,8 +3,6 @@ package dev.moq.media import dev.moq.Frame import dev.moq.MoqJson import dev.moq.pull -import kotlin.time.Duration -import kotlin.time.Duration.Companion.microseconds import kotlinx.coroutines.flow.Flow import kotlinx.serialization.serializer import uniffi.moq.MoqException @@ -158,10 +156,6 @@ suspend fun catalog(broadcast: dev.moq.BroadcastConsumer): Catalog { /** Stream of decoded frames from one finite media group. */ fun GroupConsumer.frames(): Flow = pull({ cancel() }) { next() } -/** Presentation timestamp of a container-decoded frame. */ -val MediaFrame.timestamp: Duration - get() = timestampUs.toLong().microseconds - /** * Set an application catalog section from a serializable value, encoded with [MoqJson]. * @@ -171,11 +165,3 @@ val MediaFrame.timestamp: Duration inline fun CatalogProducer.setSection(name: String, value: T) { setSection(name, MoqJson.encodeToString(serializer(), value)) } - -/** The furthest presentation point already read. */ -val ReadGap.timestamp: Duration - get() = timestampUs.toLong().microseconds - -/** Where the stopped encoder's media stops. */ -val ReadEnd.end: Duration - get() = endUs.toLong().microseconds diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 3d4efe82682..c82ead5ca1a 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -16,7 +16,6 @@ import dev.moq.json.WindowProducer import dev.moq.json.decode import dev.moq.json.events import dev.moq.json.push -import dev.moq.json.timestamp import dev.moq.json.update import dev.moq.json.valuesAs import dev.moq.media.* @@ -33,6 +32,8 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -273,7 +274,12 @@ class SmokeTest { BroadcastProducer().use { broadcast -> val track = broadcast.publishTrack("events", TrackInfo(timescale = 1_000_000uL)) val group = track.appendGroup() - group.writeFrame(Frame(payload = "cached".encodeToByteArray(), timestampUs = 0uL)) + group.writeFrame( + Frame( + payload = "cached".encodeToByteArray(), + timestamp = Timestamp(0uL, 1_000_000uL), + ) + ) group.finish() val fetched = @@ -303,7 +309,7 @@ class SmokeTest { for (ms in 0uL until 3uL) { val group = track.appendGroup() group.writeFrame( - Frame(payload = "x".encodeToByteArray(), timestampUs = ms * 1_000uL) + Frame(payload = "x".encodeToByteArray(), timestamp = Timestamp(ms, 1_000uL)) ) group.finish() } @@ -328,11 +334,14 @@ class SmokeTest { track.appendGroup().finish() track.appendGroup().finish() track.writeFrame( - Frame(payload = "populated".encodeToByteArray(), timestampUs = 2_000uL) + Frame( + payload = "populated".encodeToByteArray(), + timestamp = 2.milliseconds.toTimestamp(), + ) ) val frame = consumer.readFrame() assertEquals("populated", frame?.payload?.decodeToString()) - assertEquals(2_000uL, frame?.timestampUs) + assertEquals(Timestamp(2_000uL, 1_000_000uL), frame?.timestamp) } } @@ -344,7 +353,7 @@ class SmokeTest { track.writeFrame(Frame(payload = "payload".encodeToByteArray())) val frame = consumer.readFrame() assertEquals("payload", frame?.payload?.decodeToString()) - assertNull(frame?.timestampUs) + assertNull(frame?.timestamp) assertNull(consumer.info().timescale) track.finish() } @@ -376,7 +385,10 @@ class SmokeTest { val (name, audio) = catalog(consumer).audio.entries.single() media.writeFrame( - Frame(payload = "opus frame".encodeToByteArray(), timestampUs = 5_000_000uL) + Frame( + payload = "opus frame".encodeToByteArray(), + timestamp = 5.seconds.toTimestamp(), + ) ) // Fetch while the track is still published: finishing the media producer @@ -400,7 +412,7 @@ class SmokeTest { assertEquals(1, frames.size) val frame = frames.single() assertEquals("opus frame", frame.payload.decodeToString()) - assertEquals(5_000_000uL, frame.timestampUs) + assertEquals(5.seconds, frame.timestamp.toDuration()) } } } @@ -422,13 +434,62 @@ class SmokeTest { assertNotNull(audio) } - /** Microsecond timestamps read back as a Duration. */ + /** A 90 kHz tick, a present zero, and an untimed frame survive a write and read exactly. */ + @Test + fun `timestamps round-trip exactly`() = runTest { + for (timestamp in listOf(Timestamp(1uL, 90_000uL), Timestamp(0uL, 90_000uL), null)) { + BroadcastProducer().use { broadcast -> + // A timed track refuses an untimed frame, so null goes to an untimed one. + val track = + broadcast.publishTrack( + "ticks", + timestamp?.let { TrackInfo(timescale = 90_000uL) }, + ) + val consumer = track.consume(null) + track.writeFrame(Frame(payload = ByteArray(0), timestamp = timestamp)) + assertEquals(timestamp, consumer.readFrame()?.timestamp) + } + } + } + + /** A zero scale, a scale past 2^62 - 1, and a value past 2^61 - 1 are refused at the write. */ @Test - fun `microsecond timestamps read back as durations`() { + fun `out of range timestamps are refused`() { + BroadcastProducer().use { broadcast -> + val track = broadcast.publishTrack("ticks", TrackInfo(timescale = 90_000uL)) + for (timestamp in + listOf( + Timestamp(1uL, 0uL), + Timestamp(1uL, 1uL shl 62), + Timestamp(1uL shl 61, 90_000uL), + )) { + assertFailsWith { + track.writeFrame(Frame(payload = ByteArray(0), timestamp = timestamp)) + } + } + } + } + + /** + * From a duration is exact at nanosecond scale; to a duration rounds down to a whole + * nanosecond. + */ + @Test + fun `timestamps convert to and from durations`() { + assertEquals(Timestamp(20_000_000uL, 1_000_000_000uL), 20.milliseconds.toTimestamp()) + assertFailsWith { (-1).nanoseconds.toTimestamp() } + assertEquals(11_111.nanoseconds, Timestamp(1uL, 90_000uL).toDuration()) + assertEquals(666_666_666.nanoseconds, Timestamp(2uL, 3uL).toDuration()) + assertEquals(0.nanoseconds, Timestamp(1uL, 2_000_000_000uL).toDuration()) + // The sub-second remainder times a billion overflows 64 bits at the largest scale, and the + // result is just under half a second. assertEquals( - 20.milliseconds, - Frame(payload = ByteArray(0), timestampUs = 20_000uL).timestamp, + 499_999_999.nanoseconds, + Timestamp((1uL shl 61) - 1uL, (1uL shl 62) - 1uL).toDuration(), ) + assertFailsWith { + Timestamp((1uL shl 61) - 1uL, 1uL).toDuration() + } assertNull(AudioEncoderOutput(codec = AudioCodec.opus()).frameDuration) } @@ -441,20 +502,20 @@ class SmokeTest { val consumer = SnapshotConsumer(broadcast.consume().subscribeTrack("status", null)) for (n in 1..4) producer.update( Status(state = "live$n"), - timestampUs = n.toULong() * 1_000uL, + timestamp = n.milliseconds.toTimestamp(), ) // `next` yields every state in order; `latest` skips the buffered third. assertEquals( - Timed(Status(state = "live1"), 1.milliseconds), + Timed(Status(state = "live1"), Timestamp(1_000uL, 1_000_000uL)), consumer.next()?.decode(), ) assertEquals( - Timed(Status(state = "live2"), 2.milliseconds), + Timed(Status(state = "live2"), Timestamp(2_000uL, 1_000_000uL)), consumer.next()?.decode(), ) assertEquals( - Timed(Status(state = "live4"), 4.milliseconds), + Timed(Status(state = "live4"), Timestamp(4_000uL, 1_000_000uL)), consumer.latest()?.decode(), ) } @@ -470,24 +531,24 @@ class SmokeTest { val consumer = WindowConsumer(broadcast.consume().subscribeTrack("log", null)) for (n in 1..3) producer.push( Status(state = "live$n"), - timestampUs = n.toULong() * 1_000uL, + timestamp = Timestamp(n.toULong(), 1_000uL), ) - producer.pop(2uL, 4_000uL) + producer.pop(2uL, Timestamp(4uL, 1_000uL)) for (n in 0..2) { val timed = checkNotNull(consumer.next()) val push = assertIs(timed.event) assertEquals(n.toULong(), push.index) assertEquals(Status(state = "live${n + 1}"), push.decode()) - assertEquals((n + 1).milliseconds, timed.timestamp) + assertEquals((n + 1).milliseconds, timed.timestamp?.toDuration()) } val pop = checkNotNull(consumer.next()) assertEquals(WindowEventPop(0uL, 2uL), pop.event) - assertEquals(4.milliseconds, pop.timestamp) + assertEquals(4.milliseconds, pop.timestamp?.toDuration()) // The cut's new group restates record 2, which this reader already has. producer.cut() - producer.push(Status(state = "live4"), timestampUs = 5_000uL) + producer.push(Status(state = "live4"), timestamp = 5.milliseconds.toTimestamp()) producer.finish() val events = consumer.events().toList() assertEquals(1, events.size) @@ -771,7 +832,7 @@ class SmokeTest { ), ) // 2.5 ms of silence at 48 kHz mono f32: exactly one encoded frame. - fine.write(AudioFrame(timestampUs = 0uL, data = ByteArray(120 * 4))) + fine.write(AudioFrame(timestamp = Timestamp(0uL, 48_000uL), data = ByteArray(120 * 4))) fine.finish() assertFailsWith { @@ -912,7 +973,7 @@ class SmokeTest { for (i in 0 until 2) { audio.write( AudioFrame( - timestampUs = i.toULong() * 20_000uL, + timestamp = Timestamp(i.toULong() * 960uL, 48_000uL), data = ByteArray(960 * 4), ) ) @@ -965,7 +1026,9 @@ class SmokeTest { val rgba = ByteArray(320 * 240 * 4) { 0x80.toByte() } video.cut() for (i in 0 until 10) { - video.write(VideoFrame(timestampUs = i.toULong() * 33_333uL, data = rgba)) + video.write( + VideoFrame(timestamp = Timestamp(i.toULong(), 30uL), data = rgba) + ) } val consumer = origin.consume().request("video-decode-frame") @@ -977,7 +1040,9 @@ class SmokeTest { // Keep the encoder fed so the decoder sees frames after it joined. for (i in 10 until 40) { - video.write(VideoFrame(timestampUs = i.toULong() * 33_333uL, data = rgba)) + video.write( + VideoFrame(timestamp = Timestamp(i.toULong(), 30uL), data = rgba) + ) } assertIs(decoder.next()).frame.use { frame -> @@ -1013,7 +1078,7 @@ class SmokeTest { fun `encode audio with opus object`() { val input = AudioEncoderInput(format = AudioSampleFormat.F32, sampleRate = 48_000u, channels = 1u) - val silence = AudioFrame(timestampUs = 0uL, data = ByteArray(960 * 4)) + val silence = AudioFrame(timestamp = Timestamp(0uL, 48_000uL), data = ByteArray(960 * 4)) // The producer retains the codec, so releasing the codec and the // config in either order must still encode. diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/docs/Prelude.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/docs/Prelude.kt index b4b908fb23d..8276d948267 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/docs/Prelude.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/docs/Prelude.kt @@ -9,7 +9,7 @@ val opusInit: ByteArray get() = TODO() val packet: ByteArray get() = TODO() -val pts: ULong +val pts: dev.moq.Timestamp get() = TODO() val rgba: ByteArray get() = TODO() diff --git a/kt/moq/src/jvmTest/kotlin/dev/moq/FfiBench.kt b/kt/moq/src/jvmTest/kotlin/dev/moq/FfiBench.kt index 0e6e45da1d1..1fa325f5e59 100644 --- a/kt/moq/src/jvmTest/kotlin/dev/moq/FfiBench.kt +++ b/kt/moq/src/jvmTest/kotlin/dev/moq/FfiBench.kt @@ -106,7 +106,10 @@ class FfiBench { frame.payload.contentEquals(payload), "corrupt frame $f of round $n", ) - assertEquals((n * case.frames + f).toULong(), frame.timestampUs) + assertEquals( + Timestamp((n * case.frames + f).toULong(), 1_000_000uL), + frame.timestamp, + ) } } } @@ -117,7 +120,8 @@ class FfiBench { group.writeFrame( Frame( payload = payload, - timestampUs = (n * case.frames + f).toULong(), + timestamp = + Timestamp((n * case.frames + f).toULong(), 1_000_000uL), ) ) } diff --git a/py/moq-ffi/tests/exit_inside_callback.py b/py/moq-ffi/tests/exit_inside_callback.py index 6abe1fa4042..9e2ec2038de 100644 --- a/py/moq-ffi/tests/exit_inside_callback.py +++ b/py/moq-ffi/tests/exit_inside_callback.py @@ -63,7 +63,11 @@ async def main() -> None: subscriber = await consumer.subscribe_track("data", None) # Written once the read is parked, so the runtime thread delivers the wake. - asyncio.get_running_loop().call_later(0.05, track.write_frame, moq_ffi.MoqFrame(payload=b"hello", timestamp_us=0)) + asyncio.get_running_loop().call_later( + 0.05, + track.write_frame, + moq_ffi.MoqFrame(payload=b"hello", timestamp=moq_ffi.MoqTimestamp(value=0, scale=1_000_000)), + ) frame = await subscriber.read_frame() assert frame is not None # Flushed now: a write left for exit would hand the GIL over early. diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 1ecfbba6f71..1a818fc109c 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -54,7 +54,6 @@ asyncio.run(main()) ```python import asyncio -from datetime import timedelta import moq @@ -70,9 +69,9 @@ async def main(): # Write frames # Audio has no keyframes, so `cut` is what gives it group boundaries. - audio.write_frame(payload, timestamp=timedelta(0)) + audio.write_frame(payload, timestamp=moq.Timestamp(0, 1_000)) audio.cut() - audio.write_frame(payload, timestamp=timedelta(milliseconds=20)) + audio.write_frame(payload, timestamp=moq.Timestamp(20, 1_000)) audio.cut() # A fresh epoch per run tells viewers a restart is a new broadcast. @@ -203,7 +202,7 @@ client = moq.Client( and do not keep it open. - `media.CatalogProducer(broadcast)` owns `set_video_properties`, `set_section`, and `remove_section`. It holds the broadcast weakly; writes fail with `Error.Closed` after closing - or releasing the broadcast. `.now()` returns its current media time as `timedelta` and fixes + or releasing the broadcast. `.now()` returns its current media time as a `Timestamp` and fixes the clock for subsequent producers. - `await media.CatalogConsumer.subscribe(broadcast)` streams catalog snapshots; `await media.catalog(broadcast)` reads one snapshot and releases its subscription. @@ -217,8 +216,8 @@ client = moq.Client( `TrackConsumer.recv_group()` for arrival-order reads, with container media time on each frame as it lands. -`media.TrackProducer.write_frame(payload, timestamp)` requires the caller's capture time as -`timedelta`; returned `media.MediaFrame.timestamp` uses it too. `demand()` owns the track name and +`media.TrackProducer.write_frame(payload, timestamp)` requires the caller's capture time as a +`Timestamp`; returned `media.MediaFrame.timestamp` uses it too. `demand()` owns the track name and subscriber waits. `.cut()` / `.seek(sequence)` draw group boundaries; `.finish()` ends the import. ### Data @@ -270,9 +269,10 @@ Every handle whose cleanup is `cancel()` is an async context manager, so exiting ### Types - **`media.Catalog`**. `.audio: dict[str, Audio]`, `.video: dict[str, Video]`, `.display`, `.rotation`, `.flip`. -- **`Frame`**. `.payload: bytes`, `.timestamp: timedelta | None`. The unit of every write and every raw read; `None` means untimed when publishing or reading. -- **`media.MediaFrame`**. `.payload: bytes`, `.timestamp: timedelta`, `.keyframe: bool`. The frame variant of a media read. `keyframe` marks a group start or video keyframe; for audio it is true only at a group start. -- **`Datagram`**. `.sequence: int`, `.timestamp: timedelta | None`, `.payload: bytes`. Delivered only on datagram-capable transports with lite-05 or newer moq-lite, or moq-transport. +- **`Timestamp`**. `.value: int` units at `.scale: int` units per second, exact at any scale such as 90 kHz. `Timestamp.from_timedelta(duration)` is exact at microsecond scale; `.to_timedelta()` rounds down to a whole microsecond. A write with a scale of 0 or past `2**62 - 1`, or a value past `2**61 - 1`, raises `Error.TimeOverflow`. +- **`Frame`**. `.payload: bytes`, `.timestamp: Timestamp | None`. The unit of every write and every raw read; `None` means untimed when publishing or reading. +- **`media.MediaFrame`**. `.payload: bytes`, `.timestamp: Timestamp`, `.keyframe: bool`. The frame variant of a media read. `keyframe` marks a group start or video keyframe; for audio it is true only at a group start. +- **`Datagram`**. `.sequence: int`, `.timestamp: Timestamp | None`, `.payload: bytes`. Delivered only on datagram-capable transports with lite-05 or newer moq-lite, or moq-transport. - **`media.Audio`**. `.codec`, `.sample_rate`, `.channel_count`, `.bitrate`, `.enabled`, `.description`. - **`media.Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.enabled`, `.framerate`, `.description`. A false `.enabled` means no frames are coming, so don't select the rendition. - **`Subscription`**. Subscriber delivery preferences: priority, staleness, and optional group range. diff --git a/py/moq-rs/benches/ffi.py b/py/moq-rs/benches/ffi.py index eeb1d1275fa..f5049c4cad2 100644 --- a/py/moq-rs/benches/ffi.py +++ b/py/moq-rs/benches/ffi.py @@ -10,7 +10,6 @@ import resource import time from dataclasses import dataclass -from datetime import timedelta from pathlib import Path from typing import Callable @@ -66,7 +65,7 @@ async def read(case: Case, consumer: moq.TrackConsumer, payload: bytes, n: int) frame = await group.read_frame() assert frame is not None, "group ended" assert frame.payload == payload, f"corrupt frame {f} of round {n}" - assert frame.timestamp == timedelta(microseconds=n * case.frames + f) + assert frame.timestamp == moq.Timestamp(n * case.frames + f, 1_000_000) async def throughput(case: Case, results: list[dict]) -> None: @@ -83,7 +82,7 @@ async def step(n: int) -> None: for track in tracks: group = track.append_group() for f in range(case.frames): - group.write_frame(payload, timedelta(microseconds=n * case.frames + f)) + group.write_frame(payload, moq.Timestamp(n * case.frames + f, 1_000_000)) group.finish() await asyncio.wait_for(asyncio.gather(*readers), ROUND_TIMEOUT) diff --git a/py/moq-rs/docs/index.md b/py/moq-rs/docs/index.md index 3c4f4b370cd..0db419d3370 100644 --- a/py/moq-rs/docs/index.md +++ b/py/moq-rs/docs/index.md @@ -182,7 +182,7 @@ broadcast, and producers advertise it in the catalog. ## Data types These records, enums, and objects are re-exported from the native `moq_ffi` bindings; the -wrapper groups them under `moq` and `moq.media`, with owned duration records at the boundary. Their fields are defined on the +wrapper groups them under `moq` and `moq.media`, with owned duration and timestamp records at the boundary. Their fields are defined on the Rust side ([`moq-ffi`](https://crates.io/crates/moq-ffi)). ```{eval-rst} @@ -194,6 +194,7 @@ Rust side ([`moq-ffi`](https://crates.io/crates/moq-ffi)). media.Catalog media.Container + Timestamp Frame media.MediaFrame Datagram diff --git a/py/moq-rs/examples/clock.py b/py/moq-rs/examples/clock.py index d7a89720d7c..7fd03a5a569 100644 --- a/py/moq-rs/examples/clock.py +++ b/py/moq-rs/examples/clock.py @@ -10,7 +10,7 @@ import argparse import asyncio -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import moq @@ -24,13 +24,13 @@ async def publish(url: str, broadcast_name: str, track_name: str, insecure: bool while True: now = datetime.now(timezone.utc).replace(microsecond=0) - timestamp = timedelta(seconds=int(now.timestamp())) + timestamp = moq.Timestamp(int(now.timestamp()), 1) group = track.append_group() group.write_frame(now.strftime("%Y-%m-%d %H:%M:").encode(), timestamp) current_minute = now.minute while now.minute == current_minute: - timestamp = timedelta(seconds=int(now.timestamp())) + timestamp = moq.Timestamp(int(now.timestamp()), 1) group.write_frame(now.strftime("%S").encode(), timestamp) await asyncio.sleep(1 - datetime.now(timezone.utc).microsecond / 1_000_000) now = datetime.now(timezone.utc).replace(microsecond=0) diff --git a/py/moq-rs/examples/serve_clock.py b/py/moq-rs/examples/serve_clock.py index 6554372b081..5df6325cb1a 100644 --- a/py/moq-rs/examples/serve_clock.py +++ b/py/moq-rs/examples/serve_clock.py @@ -13,7 +13,7 @@ import argparse import asyncio -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import moq @@ -31,13 +31,13 @@ async def run(bind: str, broadcast_name: str, track_name: str, host: str) -> Non try: while True: now = datetime.now(timezone.utc).replace(microsecond=0) - timestamp = timedelta(seconds=int(now.timestamp())) + timestamp = moq.Timestamp(int(now.timestamp()), 1) group = track.append_group() group.write_frame(now.strftime("%Y-%m-%d %H:%M:").encode(), timestamp) current_minute = now.minute while now.minute == current_minute: - timestamp = timedelta(seconds=int(now.timestamp())) + timestamp = moq.Timestamp(int(now.timestamp()), 1) group.write_frame(now.strftime("%S").encode(), timestamp) await asyncio.sleep(1 - datetime.now(timezone.utc).microsecond / 1_000_000) now = datetime.now(timezone.utc).replace(microsecond=0) diff --git a/py/moq-rs/moq/__init__.py b/py/moq-rs/moq/__init__.py index a992becf09f..9c25041308d 100644 --- a/py/moq-rs/moq/__init__.py +++ b/py/moq-rs/moq/__init__.py @@ -72,6 +72,7 @@ Range, Route, Subscription, + Timestamp, TrackInfo, VideoCodec, VideoDecodedFrame, @@ -137,6 +138,7 @@ "Server", "Session", "Subscription", + "Timestamp", "Tokens", "TrackConsumer", "TrackDemand", diff --git a/py/moq-rs/moq/_read.py b/py/moq-rs/moq/_read.py index 0a244d0c286..8fb88218b8b 100644 --- a/py/moq-rs/moq/_read.py +++ b/py/moq-rs/moq/_read.py @@ -5,7 +5,9 @@ from dataclasses import dataclass from datetime import timedelta -from ._records import _from_us +from moq_ffi import MoqTimestamp + +from ._records import Timestamp @dataclass(frozen=True) @@ -17,7 +19,7 @@ class Gap: furthest presentation point already read. """ - timestamp: timedelta + timestamp: Timestamp duration: timedelta @@ -29,12 +31,12 @@ class End: remaining media; the first at or past it comes from a fresh encoder. """ - end: timedelta + end: Timestamp -def _gap(timestamp_us: int, duration: timedelta) -> Gap: - return Gap(timestamp=_from_us(timestamp_us), duration=duration) +def _gap(timestamp: MoqTimestamp, duration: timedelta) -> Gap: + return Gap(timestamp=Timestamp._from_ffi(timestamp), duration=duration) -def _end(end_us: int) -> End: - return End(end=_from_us(end_us)) +def _end(end: MoqTimestamp) -> End: + return End(end=Timestamp._from_ffi(end)) diff --git a/py/moq-rs/moq/_records.py b/py/moq-rs/moq/_records.py index 3ee125b765c..45a2bebfd46 100644 --- a/py/moq-rs/moq/_records.py +++ b/py/moq-rs/moq/_records.py @@ -1,8 +1,7 @@ """Root records the wrapper owns, as frozen dataclasses with Python defaults. -moq-ffi spells media timestamps as integer microseconds, so ``Frame`` and -``Datagram`` convert them to ``timedelta`` at the boundary. Durations already -cross as ``timedelta``. ``ClientTls`` lives here with the rest of the client +Media timestamps cross as exact :class:`Timestamp` records; durations cross as +``timedelta``. ``ClientTls`` lives here with the rest of the client config, so a bare string passed for a list is refused instead of splitting into characters. """ @@ -13,8 +12,10 @@ from dataclasses import dataclass from datetime import timedelta from importlib import metadata +from typing import ClassVar, cast from moq_ffi import ( + MoqAnchor, MoqBackoff, MoqClientTls, MoqCongestionControl, @@ -26,7 +27,11 @@ MoqQuicConfig, MoqRange, MoqSubscription, + MoqTimestamp, MoqTrackInfo, + MoqVideoDecodedFrame, + MoqVideoPixelFormat, + MoqVideoSurface, MoqWebSocketConfig, moq_subscription_defaults, ) @@ -43,29 +48,53 @@ def _implementation() -> str: return "moq-py/dev" -def _to_us(value: timedelta, name: str) -> int: - if value < timedelta(0): - raise ValueError(f"{name} must not be negative: {value}") - return value // _MICROSECOND +def _strs(value: Sequence[str], name: str) -> list[str]: + # A str is itself a Sequence[str], so list() would split it into characters. + if isinstance(value, str): + raise TypeError(f"{name} takes a sequence of strings, not a str: {value!r}") + return list(value) -def _opt_us(value: timedelta | None, name: str) -> int | None: - return None if value is None else _to_us(value, name) +@dataclass(frozen=True) +class Timestamp: + """An exact media timestamp: ``value`` units at ``scale`` units per second. + ``value`` is at most ``2**61 - 1`` and ``scale`` is non-zero and at most ``2**62 - 1``; + a write past either bound raises ``moq.Error.TimeOverflow``. A read carries the + track's timescale, and a write at another scale is floored to it. + """ -def _from_us(us: int) -> timedelta: - return timedelta(microseconds=us) + value: int + scale: int + @staticmethod + def from_timedelta(duration: timedelta) -> Timestamp: + """Exactly ``duration``, at microsecond scale since that is a ``timedelta``'s own unit.""" + if duration < timedelta(0): + raise ValueError(f"timestamp must not be negative: {duration}") + return Timestamp(duration // _MICROSECOND, 1_000_000) -def _strs(value: Sequence[str], name: str) -> list[str]: - # A str is itself a Sequence[str], so list() would split it into characters. - if isinstance(value, str): - raise TypeError(f"{name} takes a sequence of strings, not a str: {value!r}") - return list(value) + def to_timedelta(self) -> timedelta: + """This timestamp as a ``timedelta``, rounded down to a whole microsecond. + The timestamp itself is exact; keep it, not the ``timedelta``, to republish a tick such as 90 kHz. + """ + return timedelta(microseconds=self.value * 1_000_000 // self.scale) -def _opt_from_us(us: int | None) -> timedelta | None: - return None if us is None else _from_us(us) + def _ffi(self) -> MoqTimestamp: + return MoqTimestamp(value=self.value, scale=self.scale) + + @staticmethod + def _from_ffi(value: MoqTimestamp) -> Timestamp: + return Timestamp(value.value, value.scale) + + +def _opt_ts(value: Timestamp | None) -> MoqTimestamp | None: + return None if value is None else value._ffi() + + +def _opt_from_ts(value: MoqTimestamp | None) -> Timestamp | None: + return None if value is None else Timestamp._from_ffi(value) @dataclass(frozen=True) @@ -76,14 +105,14 @@ class Frame: """ payload: bytes - timestamp: timedelta | None = None + timestamp: Timestamp | None = None def _ffi(self) -> MoqFrame: - return MoqFrame(payload=self.payload, timestamp_us=_opt_us(self.timestamp, "timestamp")) + return MoqFrame(payload=self.payload, timestamp=_opt_ts(self.timestamp)) @staticmethod def _from_ffi(frame: MoqFrame) -> Frame: - return Frame(payload=frame.payload, timestamp=_opt_from_us(frame.timestamp_us)) + return Frame(payload=frame.payload, timestamp=_opt_from_ts(frame.timestamp)) @dataclass(frozen=True) @@ -94,18 +123,84 @@ class Datagram: """ sequence: int - timestamp: timedelta | None + timestamp: Timestamp | None payload: bytes @staticmethod def _from_ffi(datagram: MoqDatagram) -> Datagram: return Datagram( sequence=datagram.sequence, - timestamp=_opt_from_us(datagram.timestamp_us), + timestamp=_opt_from_ts(datagram.timestamp), payload=datagram.payload, ) +class Anchor: + """Where one end of a :class:`Range` falls, mirroring moq-net's ``track::Anchor``. + + Build one with ``Anchor.ABSOLUTE(group=...)``, ``Anchor.RELATIVE(groups=...)``, or + ``Anchor.TIME(timestamp=...)``. + """ + + ABSOLUTE: ClassVar[type[_AnchorAbsolute]] + RELATIVE: ClassVar[type[_AnchorRelative]] + TIME: ClassVar[type[_AnchorTime]] + + def _ffi(self) -> MoqAnchor: + raise TypeError("build an Anchor with Anchor.ABSOLUTE, Anchor.RELATIVE, or Anchor.TIME") + + +# The generated variants subclass MoqAnchor only at runtime, so each _ffi casts for pyright. +@dataclass(frozen=True) +class _AnchorAbsolute(Anchor): + """This group, delivered from its first frame.""" + + group: int + + def _ffi(self) -> MoqAnchor: + return cast(MoqAnchor, MoqAnchor.ABSOLUTE(group=self.group)) + + +@dataclass(frozen=True) +class _AnchorRelative(Anchor): + """The group this many before the latest, clamped to group 0: ``0`` is the latest group.""" + + groups: int + + def _ffi(self) -> MoqAnchor: + return cast(MoqAnchor, MoqAnchor.RELATIVE(groups=self.groups)) + + +@dataclass(frozen=True) +class _AnchorTime(Anchor): + """The group containing this presentation time. An untimed track refuses it.""" + + timestamp: Timestamp + + def _ffi(self) -> MoqAnchor: + return cast(MoqAnchor, MoqAnchor.TIME(timestamp=self.timestamp._ffi())) + + +Anchor.ABSOLUTE = _AnchorAbsolute +Anchor.RELATIVE = _AnchorRelative +Anchor.TIME = _AnchorTime + + +@dataclass(frozen=True) +class Range: + """A span of a track to deliver, from ``start`` to ``end``, ``None`` to keep following the live edge. + + A relative or time start is the first frame of its group, and a relative or time end + includes its whole group. An absolute end is exclusive. + """ + + start: Anchor + end: Anchor | None = None + + def _ffi(self) -> MoqRange: + return MoqRange(start=self.start._ffi(), end=None if self.end is None else self.end._ffi()) + + @dataclass(frozen=True) class Subscription: """Subscriber-side delivery preferences, mirroring moq-net's ``track::Subscription``. @@ -122,7 +217,7 @@ class Subscription: priority: int = 0 max_delay: timedelta | None = timedelta(0) - ranges: Sequence[MoqRange] | None = None + ranges: Sequence[Range] | None = None order: MoqOrder | None = None @classmethod @@ -136,7 +231,8 @@ def default(cls) -> Subscription: return cls( priority=defaults.priority, max_delay=defaults.max_delay, - ranges=defaults.ranges, + # The FFI defaults never restrict ranges, so there is no record to convert. + ranges=None, order=defaults.order, ) @@ -144,7 +240,7 @@ def _ffi(self) -> MoqSubscription: return MoqSubscription( priority=self.priority, max_delay=self.max_delay, - ranges=None if self.ranges is None else list(self.ranges), + ranges=None if self.ranges is None else [r._ffi() for r in self.ranges], order=self.order, ) @@ -360,3 +456,30 @@ def _from_ffi(grant: MoqGrant | None) -> Grant | None: subscribe=tuple(grant.subscribe), expires=grant.expires, ) + + +class VideoDecodedFrame: + """One decoded picture. It owns the decoder's buffer until garbage collected, so drop it promptly.""" + + def __init__(self, inner: MoqVideoDecodedFrame) -> None: + self._inner = inner + + def timestamp(self) -> Timestamp: + """The presentation timestamp, at the track's timescale.""" + return Timestamp._from_ffi(self._inner.timestamp()) + + def width(self) -> int: + """The picture's width in pixels.""" + return self._inner.width() + + def height(self) -> int: + """The picture's height in pixels.""" + return self._inner.height() + + def pixels(self, format: MoqVideoPixelFormat) -> bytes: + """The picture converted to tightly packed CPU pixels in ``format``.""" + return self._inner.pixels(format) + + def surface(self) -> MoqVideoSurface | None: + """The decoder's platform surface, when the decoder output asked for one.""" + return self._inner.surface() diff --git a/py/moq-rs/moq/audio.py b/py/moq-rs/moq/audio.py index 25e607111f6..79eeddeb41c 100644 --- a/py/moq-rs/moq/audio.py +++ b/py/moq-rs/moq/audio.py @@ -19,7 +19,7 @@ ) from ._read import End, Gap, _end, _gap -from ._records import _from_us, _to_us +from ._records import Timestamp from ._stream import _Stream from .media import Audio, AudioInit, CatalogProducer, DecodeStart, Delay from .publish import TrackDemand @@ -40,15 +40,15 @@ class Frame: """Interleaved PCM with its presentation timestamp.""" - timestamp: timedelta + timestamp: Timestamp data: bytes def _ffi(self) -> MoqAudioFrame: - return MoqAudioFrame(timestamp_us=_to_us(self.timestamp, "timestamp"), data=self.data) + return MoqAudioFrame(timestamp=self.timestamp._ffi(), data=self.data) @staticmethod def _from_ffi(value: MoqAudioFrame) -> Frame: - return Frame(timestamp=_from_us(value.timestamp_us), data=value.data) + return Frame(timestamp=Timestamp._from_ffi(value.timestamp), data=value.data) Read = Frame | Gap | End @@ -185,9 +185,9 @@ async def _next(self) -> Read | None: if isinstance(value, MoqAudioRead.FRAME): return Frame._from_ffi(value.frame) if isinstance(value, MoqAudioRead.GAP): - return _gap(value.timestamp_us, value.duration) + return _gap(value.timestamp, value.duration) if isinstance(value, MoqAudioRead.END): - return _end(value.end_us) + return _end(value.end) raise TypeError(f"unknown audio read: {value!r}") @property diff --git a/py/moq-rs/moq/json.py b/py/moq-rs/moq/json.py index 51a0c9daa5c..e8a029e3dff 100644 --- a/py/moq-rs/moq/json.py +++ b/py/moq-rs/moq/json.py @@ -14,7 +14,6 @@ import json from dataclasses import dataclass -from datetime import timedelta from typing import Any from moq_ffi import ( @@ -32,7 +31,7 @@ ) from . import publish, subscribe -from ._records import _opt_from_us, _opt_us +from ._records import Timestamp, _opt_from_ts, _opt_ts from ._stream import _Stream from .publish import BroadcastProducer, TrackDemand @@ -55,19 +54,17 @@ class Timed: """A parsed JSON value and the timestamp of the frame that carried it. - ``timestamp`` is on the broadcast clock, and ``None`` on an untimed track. It is - rounded down to a whole microsecond, so a tick on a track like 90 kHz may not - survive a read and republish exactly. + ``timestamp`` is on the broadcast clock, and ``None`` on an untimed track. """ value: Any - timestamp: timedelta | None + timestamp: Timestamp | None @staticmethod def _from_ffi(value: MoqJsonValue | None) -> Timed | None: if value is None: return None - return Timed(value=json.loads(value.value), timestamp=_opt_from_us(value.timestamp_us)) + return Timed(value=json.loads(value.value), timestamp=_opt_from_ts(value.timestamp)) class SnapshotProducer: @@ -94,9 +91,9 @@ def demand(self) -> TrackDemand: """A watch-only handle to whether this track has subscribers.""" return TrackDemand(self._inner.demand()) - def update(self, value: Any, *, timestamp: timedelta | None = None) -> None: + def update(self, value: Any, *, timestamp: Timestamp | None = None) -> None: """Publish a new value. A no-op if unchanged from the previous update.""" - self._inner.update(json.dumps(value), _opt_us(timestamp, "timestamp")) + self._inner.update(json.dumps(value), _opt_ts(timestamp)) def finish(self) -> None: """Finish the track, closing any open group.""" @@ -117,9 +114,9 @@ def demand(self) -> TrackDemand: """A watch-only handle to whether this track has subscribers.""" return TrackDemand(self._inner.demand()) - def append(self, value: Any, *, timestamp: timedelta | None = None) -> None: + def append(self, value: Any, *, timestamp: Timestamp | None = None) -> None: """Append one record to the log.""" - self._inner.append(json.dumps(value), _opt_us(timestamp, "timestamp")) + self._inner.append(json.dumps(value), _opt_ts(timestamp)) def finish(self) -> None: """Finish the track, closing the group.""" @@ -194,13 +191,13 @@ def demand(self) -> TrackDemand: """A watch-only handle to whether this track has subscribers.""" return TrackDemand(self._inner.demand()) - def push(self, value: Any, *, timestamp: timedelta | None = None) -> None: + def push(self, value: Any, *, timestamp: Timestamp | None = None) -> None: """Append one record to the back of the window.""" - self._inner.push(json.dumps(value), _opt_us(timestamp, "timestamp")) + self._inner.push(json.dumps(value), _opt_ts(timestamp)) - def pop(self, count: int, *, timestamp: timedelta | None = None) -> None: + def pop(self, count: int, *, timestamp: Timestamp | None = None) -> None: """Drop ``count`` records from the front of the window, clamped to what it holds.""" - self._inner.pop(count, _opt_us(timestamp, "timestamp")) + self._inner.pop(count, _opt_ts(timestamp)) def cut(self) -> None: """Close the open group, so the next edit opens one restating the retained window. @@ -258,7 +255,7 @@ def _window_event(timed: MoqJsonWindowTimed | None) -> Timed | None: value = WindowSkip(range(event.start, event.end)) else: raise TypeError(f"unknown window event: {event!r}") - return Timed(value=value, timestamp=_opt_from_us(timed.timestamp_us)) + return Timed(value=value, timestamp=_opt_from_ts(timed.timestamp)) class WindowConsumer(_Stream[Timed]): diff --git a/py/moq-rs/moq/media.py b/py/moq-rs/moq/media.py index bf06e6e8228..d82b757c691 100644 --- a/py/moq-rs/moq/media.py +++ b/py/moq-rs/moq/media.py @@ -4,7 +4,6 @@ import json from dataclasses import dataclass, field -from datetime import timedelta from typing import Any, cast from moq_ffi import MoqAudio as Audio @@ -54,7 +53,7 @@ from . import subscribe as _subscribe from ._read import End, Gap, _end, _gap -from ._records import Frame, Subscription, _from_us, _subscription +from ._records import Frame, Subscription, Timestamp, _subscription from ._stream import _Stream from .publish import BroadcastProducer, TrackDemand, TrackRequest from .subscribe import BroadcastConsumer @@ -111,12 +110,12 @@ class MediaFrame: """A container-decoded payload, presentation timestamp, and keyframe flag.""" payload: bytes - timestamp: timedelta + timestamp: Timestamp keyframe: bool @staticmethod def _from_ffi(frame: MoqMediaFrame) -> MediaFrame: - return MediaFrame(frame.payload, _from_us(frame.timestamp_us), frame.keyframe) + return MediaFrame(frame.payload, Timestamp._from_ffi(frame.timestamp), frame.keyframe) Read = MediaFrame | Gap | End @@ -129,9 +128,9 @@ class CatalogProducer: def __init__(self, broadcast: BroadcastProducer) -> None: self._inner = MoqMediaCatalogProducer(broadcast._inner) - def now(self) -> timedelta: + def now(self) -> Timestamp: """Read the broadcast clock, fixing it for subsequent media producers.""" - return _from_us(self._inner.now()) + return Timestamp._from_ffi(self._inner.now()) def set_video_properties(self, properties: VideoProperties) -> None: """Replace the video properties shared by every rendition.""" @@ -179,7 +178,7 @@ def demand(self) -> TrackDemand: """A watch-only handle to whether this media track has subscribers.""" return TrackDemand(self._inner.demand()) - def write_frame(self, payload: bytes, timestamp: timedelta) -> None: + def write_frame(self, payload: bytes, timestamp: Timestamp) -> None: """Write one encoded frame with a presentation timestamp.""" self._inner.write_frame(Frame(payload, timestamp)._ffi()) @@ -312,9 +311,9 @@ async def _next(self) -> Read | None: if isinstance(value, MoqMediaRead.FRAME): return MediaFrame._from_ffi(value.frame) if isinstance(value, MoqMediaRead.GAP): - return _gap(value.timestamp_us, value.duration) + return _gap(value.timestamp, value.duration) if isinstance(value, MoqMediaRead.END): - return _end(value.end_us) + return _end(value.end) raise TypeError(f"unknown media read: {value!r}") def cancel(self) -> None: diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 1dd057f7f0b..bb48b38ae2a 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -23,6 +23,7 @@ Frame, Position, Subscription, + Timestamp, TrackInfo, ) @@ -47,7 +48,7 @@ def consume(self) -> GroupConsumer: return GroupConsumer(self._inner.consume()) - def write_frame(self, payload: bytes, timestamp: timedelta | None = None) -> None: + def write_frame(self, payload: bytes, timestamp: Timestamp | None = None) -> None: """Write a frame with its presentation timestamp.""" self._inner.write_frame(Frame(payload, timestamp)._ffi()) @@ -117,11 +118,11 @@ def create_group(self, sequence: int) -> GroupProducer: """Create a group with an explicit sequence number.""" return GroupProducer(self._inner.create_group(sequence)) - def write_frame(self, payload: bytes, timestamp: timedelta | None = None) -> None: + def write_frame(self, payload: bytes, timestamp: Timestamp | None = None) -> None: """Write a single-frame group with its presentation timestamp.""" self._inner.write_frame(Frame(payload, timestamp)._ffi()) - def append_datagram(self, payload: bytes, timestamp: timedelta | None = None) -> int: + def append_datagram(self, payload: bytes, timestamp: Timestamp | None = None) -> int: """Send a best-effort datagram and return its sequence number. Payloads are capped at 1200 bytes. Datagram delivery requires a datagram-capable @@ -129,7 +130,7 @@ def append_datagram(self, payload: bytes, timestamp: timedelta | None = None) -> """ return self._inner.append_datagram(Frame(payload, timestamp)._ffi()) - def insert_datagram(self, sequence: int, payload: bytes, timestamp: timedelta | None = None) -> None: + def insert_datagram(self, sequence: int, payload: bytes, timestamp: Timestamp | None = None) -> None: """Send a best-effort datagram at an explicit sequence number. For a caller that allocates its own sequences; :meth:`append_datagram` diff --git a/py/moq-rs/moq/types.py b/py/moq-rs/moq/types.py index 2615ec6453a..ff6dc5dc3dc 100644 --- a/py/moq-rs/moq/types.py +++ b/py/moq-rs/moq/types.py @@ -1,11 +1,9 @@ """Re-export moq-ffi record types without the Moq prefix. -Records the wrapper gives Python defaults or timestamp conversions are owned in `_records` instead. +Records the wrapper gives Python defaults or timestamp conversions, and handles that return a +timestamp, are owned in `_records` instead. """ -from moq_ffi import ( - MoqAnchor as Anchor, -) from moq_ffi import ( MoqAnnounceRoute as AnnounceRoute, ) @@ -42,18 +40,12 @@ from moq_ffi import ( MoqProtocolKind as ProtocolKind, ) -from moq_ffi import ( - MoqRange as Range, -) from moq_ffi import ( MoqRoute as Route, ) from moq_ffi import ( MoqVideoCodec as VideoCodec, ) -from moq_ffi import ( - MoqVideoDecodedFrame as VideoDecodedFrame, -) from moq_ffi import ( MoqVideoEncoderInput as VideoEncoderInput, ) @@ -71,6 +63,7 @@ ) from ._records import ( + Anchor, Backoff, ClientTls, ConnectionStats, @@ -78,8 +71,11 @@ Frame, Grant, QuicConfig, + Range, Subscription, + Timestamp, TrackInfo, + VideoDecodedFrame, WebSocketConfig, ) @@ -107,6 +103,7 @@ "Range", "Route", "Subscription", + "Timestamp", "TrackInfo", "VideoCodec", "VideoEncoderInput", diff --git a/py/moq-rs/moq/video.py b/py/moq-rs/moq/video.py index d96f782461b..2c7c8ad88cd 100644 --- a/py/moq-rs/moq/video.py +++ b/py/moq-rs/moq/video.py @@ -3,8 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta -from typing import Union from moq_ffi import ( MoqVideoConsumer, @@ -19,22 +17,20 @@ from moq_ffi import MoqVideoFrameRate as FrameRate from ._read import End, Gap, _end, _gap -from ._records import _from_us, _to_us +from ._records import Timestamp, VideoDecodedFrame from ._stream import _Stream from .media import CatalogProducer, DecodeStart, Delay, Dimensions, Video from .publish import TrackDemand from .session import Allocator, Reservation from .subscribe import BroadcastConsumer from .types import VideoCodec as Codec -from .types import VideoDecodedFrame from .types import VideoEncoderInput as EncoderInput from .types import VideoEncoderKind as EncoderKind from .types import VideoEncoderOutput as EncoderOutput from .types import VideoGop as Gop from .types import VideoPixelFormat as PixelFormat -# A Union rather than `|`: the generated frame class does not support `|` with other types. -Read = Union[VideoDecodedFrame, Gap, End] +Read = VideoDecodedFrame | Gap | End """One item a :class:`Consumer` reads, in delivery order: a decoded picture, or a break between them.""" @@ -42,24 +38,24 @@ class Frame: """One raw picture in the configured input layout, with its presentation timestamp.""" - timestamp: timedelta + timestamp: Timestamp data: bytes def _ffi(self) -> MoqVideoFrame: - return MoqVideoFrame(timestamp_us=_to_us(self.timestamp, "timestamp"), data=self.data) + return MoqVideoFrame(timestamp=self.timestamp._ffi(), data=self.data) @dataclass(frozen=True) class Packet: """One encoded access unit from an :class:`Encoder`.""" - timestamp: timedelta + timestamp: Timestamp data: bytes keyframe: bool @staticmethod def _from_ffi(value: MoqVideoPacket) -> Packet: - return Packet(timestamp=_from_us(value.timestamp_us), data=value.data, keyframe=value.keyframe) + return Packet(timestamp=Timestamp._from_ffi(value.timestamp), data=value.data, keyframe=value.keyframe) @dataclass(frozen=True) @@ -183,11 +179,11 @@ async def _next(self) -> Read | None: if value is None: return None if isinstance(value, MoqVideoRead.FRAME): - return value.frame + return VideoDecodedFrame(value.frame) if isinstance(value, MoqVideoRead.GAP): - return _gap(value.timestamp_us, value.duration) + return _gap(value.timestamp, value.duration) if isinstance(value, MoqVideoRead.END): - return _end(value.end_us) + return _end(value.end) raise TypeError(f"unknown video read: {value!r}") def cancel(self) -> None: diff --git a/py/moq-rs/tests/test_data_timestamps.py b/py/moq-rs/tests/test_data_timestamps.py index 1f829db647b..4cfe802a8a8 100644 --- a/py/moq-rs/tests/test_data_timestamps.py +++ b/py/moq-rs/tests/test_data_timestamps.py @@ -1,4 +1,4 @@ -"""Data publishers preserve the caller's capture time, including absence.""" +"""Publishers preserve the caller's exact capture time, including absence.""" import asyncio import json @@ -9,12 +9,48 @@ import moq_ffi import pytest +TIMESTAMPS = [None, moq.Timestamp(0, 90_000), moq.Timestamp(1, 90_000)] + + +async def test_raw_timestamps_are_exact(): + """A present zero stays present, and one 90 kHz tick reads back as itself, not the 11 µs it floors to.""" + broadcast = moq.BroadcastProducer() + track = broadcast.publish_track("video", moq.TrackInfo(timescale=90_000)) + consumer = track.consume() + for value in [0, 1]: + track.write_frame(b"frame", moq.Timestamp(value, 90_000)) + frame = await asyncio.wait_for(consumer.read_frame(), timeout=5.0) + assert frame is not None + assert frame.timestamp == moq.Timestamp(value, 90_000) + + untimed = broadcast.publish_track("events", moq.TrackInfo(timescale=None)) + untimed_consumer = untimed.consume() + untimed.write_frame(b"event") + frame = await asyncio.wait_for(untimed_consumer.read_frame(), timeout=5.0) + assert frame is not None + assert frame.timestamp is None + + for invalid in [moq.Timestamp(0, 0), moq.Timestamp(0, 1 << 62), moq.Timestamp(1 << 61, 90_000)]: + with pytest.raises(moq.Error.TimeOverflow): + track.write_frame(b"frame", invalid) + broadcast.close() + + +def test_timedelta_conversions(): + assert moq.Timestamp.from_timedelta(timedelta(seconds=1, microseconds=5)) == moq.Timestamp(1_000_005, 1_000_000) + with pytest.raises(ValueError): + moq.Timestamp.from_timedelta(timedelta(microseconds=-1)) + # Rounded down, like moq-net: half a microsecond and 2/3 of one are both 0, and 11.1 µs is 11 µs. + assert moq.Timestamp(1, 2_000_000).to_timedelta() == timedelta(0) + assert moq.Timestamp(2, 3_000_000).to_timedelta() == timedelta(0) + assert moq.Timestamp(1, 90_000).to_timedelta() == timedelta(microseconds=11) + @pytest.mark.parametrize("mode", ["snapshot", "stream"]) -@pytest.mark.parametrize("timestamp", [None, timedelta(microseconds=123_456)]) -async def test_json_timestamp_round_trip(mode: str, timestamp: timedelta | None): +@pytest.mark.parametrize("timestamp", TIMESTAMPS) +async def test_json_timestamp_round_trip(mode: str, timestamp: moq.Timestamp | None): broadcast = moq.BroadcastProducer() - track = broadcast.publish_track("value", moq.TrackInfo(timescale=None if timestamp is None else 1_000_000)) + track = broadcast.publish_track("value", moq.TrackInfo(timescale=None if timestamp is None else timestamp.scale)) consumer = track.consume() value = {"n": 1} if mode == "snapshot": @@ -35,7 +71,7 @@ async def test_json_timestamp_round_trip(mode: str, timestamp: timedelta | None) def test_catalog_now_uses_a_weak_broadcast_handle(): broadcast = moq.BroadcastProducer() catalog = moq.media.CatalogProducer(broadcast) - assert catalog.now() >= timedelta(0) + assert catalog.now().scale == 1_000_000 broadcast.close() with pytest.raises(moq.Error.Closed): catalog.now() @@ -55,7 +91,7 @@ def test_whole_media_frames_require_the_callers_timestamp(): broadcast, moq.media.AudioInit(format=moq.media.AudioFormat.OPUS, data=head) ) with pytest.raises(moq.Error.Codec, match="timestamp"): - producer._inner.write_frame(moq_ffi.MoqFrame(payload=b"opus frame", timestamp_us=None)) + producer._inner.write_frame(moq_ffi.MoqFrame(payload=b"opus frame", timestamp=None)) # Exercise the runtime refusal when a caller omits the required timestamp. with pytest.raises(TypeError): producer.write_frame(b"opus frame") # type: ignore[call-arg] diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index f208690971a..0d66c327bdb 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -12,7 +12,7 @@ import pytest # Every group from the first, rather than only what the live edge still reaches. -FROM_GROUP_0 = [moq.Range(start=cast(moq.Anchor, moq.Anchor.ABSOLUTE(group=0)))] +FROM_GROUP_0 = [moq.Range(start=moq.Anchor.ABSOLUTE(group=0))] async def until_wanted(demand: moq.TrackDemand, wanted: bool = True) -> None: @@ -177,7 +177,7 @@ def test_publish_media_lifecycle(): media = moq.media.TrackProducer.audio( broadcast, moq.media.AudioInit(format=moq.media.AudioFormat.OPUS, data=opus_head()) ) - media.write_frame(b"opus frame", timedelta(microseconds=1000)) + media.write_frame(b"opus frame", moq.Timestamp(1000, 1_000_000)) media.finish() broadcast.close() @@ -194,14 +194,14 @@ def test_publish_media_cut_and_seek(): ) for i in range(3): - media.write_frame(b"opus frame", timedelta(microseconds=i * 20_000)) + media.write_frame(b"opus frame", moq.Timestamp(i * 20_000, 1_000_000)) media.cut() # The same boundary, with the next group explicitly numbered. - media.write_frame(b"opus frame", timedelta(microseconds=60_000)) + media.write_frame(b"opus frame", moq.Timestamp(60_000, 1_000_000)) media.seek(42) media.discontinuity() - media.write_frame(b"resumed opus frame", timedelta(microseconds=100_000)) + media.write_frame(b"resumed opus frame", moq.Timestamp(100_000, 1_000_000)) media.finish() broadcast.close() @@ -251,12 +251,12 @@ async def test_local_publish_consume_audio(): media_consumer = await moq.media.TrackConsumer.subscribe(broadcast_consumer, track_name, audio) payload = b"opus audio payload data" - media.write_frame(payload, timedelta(microseconds=1_000_000)) + media.write_frame(payload, moq.Timestamp(1_000_000, 1_000_000)) async for frame in media_consumer: assert isinstance(frame, moq.media.MediaFrame) assert frame.payload == payload - assert frame.timestamp // timedelta(microseconds=1) == 1_000_000 + assert frame.timestamp == moq.Timestamp(1_000_000, 1_000_000) break break @@ -288,11 +288,11 @@ async def test_video_publish_consume(): media_consumer = await moq.media.TrackConsumer.subscribe(broadcast_consumer, track_name, video) keyframe = bytes([0x00, 0x00, 0x00, 0x01, 0x65, 0xAA, 0xBB, 0xCC]) - media.write_frame(keyframe, timedelta(microseconds=0)) + media.write_frame(keyframe, moq.Timestamp(0, 1_000_000)) async for frame in media_consumer: assert isinstance(frame, moq.media.MediaFrame) - assert frame.timestamp // timedelta(microseconds=1) == 0 + assert frame.timestamp == moq.Timestamp(0, 1_000_000) assert len(frame.payload) > 0 break @@ -336,12 +336,12 @@ async def test_multiple_frames_ordering(): timestamps = [0, 20_000, 40_000, 60_000, 80_000] for i, ts in enumerate(timestamps): - media.write_frame(f"frame-{i}".encode(), timedelta(microseconds=ts)) + media.write_frame(f"frame-{i}".encode(), moq.Timestamp(ts, 1_000_000)) for i, expected_ts in enumerate(timestamps): async for frame in media_consumer: assert isinstance(frame, moq.media.MediaFrame) - assert frame.timestamp // timedelta(microseconds=1) == expected_ts + assert frame.timestamp == moq.Timestamp(expected_ts, 1_000_000) assert frame.payload == f"frame-{i}".encode() break @@ -503,7 +503,7 @@ async def test_fetch_group_and_serve_dynamic_miss(): consumer = broadcast.consume() cached = track.append_group() - cached.write_frame(b"cached", timedelta(0)) + cached.write_frame(b"cached", moq.Timestamp(0, 1_000_000)) cached.finish() fetched = await consumer.fetch_group("events", 0, moq.FetchGroupOptions(priority=3)) @@ -520,7 +520,7 @@ async def test_fetch_group_and_serve_dynamic_miss(): assert not fill.is_wanted(8) produced = fill.accept(7) - produced.write_frame(b"archive", timedelta(microseconds=140_000)) + produced.write_frame(b"archive", moq.Timestamp(140_000, 1_000_000)) produced.finish() fetched = await asyncio.wait_for(pending, timeout=5.0) @@ -534,15 +534,15 @@ async def test_json_snapshot_roundtrip(): consumer = moq.json.SnapshotConsumer(await broadcast.consume().subscribe_track("status")) for viewers in range(1, 5): - producer.update({"viewers": viewers}, timestamp=timedelta(milliseconds=viewers)) + producer.update({"viewers": viewers}, timestamp=moq.Timestamp(viewers, 1_000)) # Iteration yields every state in order; latest() skips the buffered third. value = await asyncio.wait_for(anext(consumer), timeout=5.0) - assert value == moq.json.Timed({"viewers": 1}, timedelta(milliseconds=1)) + assert value == moq.json.Timed({"viewers": 1}, moq.Timestamp(1_000, 1_000_000)) value = await asyncio.wait_for(anext(consumer), timeout=5.0) - assert value == moq.json.Timed({"viewers": 2}, timedelta(milliseconds=2)) + assert value == moq.json.Timed({"viewers": 2}, moq.Timestamp(2_000, 1_000_000)) value = await asyncio.wait_for(consumer.latest(), timeout=5.0) - assert value == moq.json.Timed({"viewers": 4}, timedelta(milliseconds=4)) + assert value == moq.json.Timed({"viewers": 4}, moq.Timestamp(4_000, 1_000_000)) producer.finish() assert await asyncio.wait_for(consumer.latest(), timeout=5.0) is None @@ -570,18 +570,18 @@ async def test_json_window_roundtrip(): await asyncio.wait_for(until_wanted(demand), timeout=5.0) for n in range(3): - producer.push({"n": n}, timestamp=timedelta(milliseconds=n)) - producer.pop(2, timestamp=timedelta(milliseconds=3)) + producer.push({"n": n}, timestamp=moq.Timestamp(n, 1_000)) + producer.pop(2, timestamp=moq.Timestamp(3, 1_000)) for n in range(3): event = await asyncio.wait_for(anext(consumer), timeout=5.0) - assert event == moq.json.Timed(moq.json.WindowPush(n, {"n": n}), timedelta(milliseconds=n)) + assert event == moq.json.Timed(moq.json.WindowPush(n, {"n": n}), moq.Timestamp(n * 1_000, 1_000_000)) event = await asyncio.wait_for(anext(consumer), timeout=5.0) - assert event == moq.json.Timed(moq.json.WindowPop(range(0, 2)), timedelta(milliseconds=3)) + assert event == moq.json.Timed(moq.json.WindowPop(range(0, 2)), moq.Timestamp(3_000, 1_000_000)) producer.finish() with pytest.raises(moq.Error.Closed): - producer.push({"n": 3}, timestamp=timedelta(milliseconds=4)) + producer.push({"n": 3}, timestamp=moq.Timestamp(4, 1_000)) assert [event async for event in consumer] == [] @@ -755,12 +755,12 @@ async def test_dynamic_track_request_can_publish_media(): assert audio.channel_count == 2 payload = b"dynamic opus frame" - media.write_frame(payload, timedelta(microseconds=20_000)) + media.write_frame(payload, moq.Timestamp(20_000, 1_000_000)) async for frame in media_consumer: assert isinstance(frame, moq.media.MediaFrame) assert frame.payload == payload - assert frame.timestamp // timedelta(microseconds=1) == 20_000 + assert frame.timestamp == moq.Timestamp(20_000, 1_000_000) break media.finish() @@ -786,12 +786,12 @@ async def test_dynamic_broadcast_request(): broadcast = await asyncio.wait_for(requesting, timeout=5.0) track_consumer = await broadcast.subscribe_track("status") payload = b"served dynamically" - track.write_frame(payload, timedelta(microseconds=20_000)) + track.write_frame(payload, moq.Timestamp(20_000, 1_000_000)) frame = await asyncio.wait_for(track_consumer.read_frame(), timeout=5.0) assert frame is not None assert frame.payload == payload - assert frame.timestamp == timedelta(microseconds=20_000) + assert frame.timestamp == moq.Timestamp(20_000, 1_000_000) track.finish() served.broadcast.close() @@ -834,7 +834,7 @@ def test_raw_group_write_multiple_frames(): group = track.append_group() for i in range(10): - group.write_frame(f"frame-{i}".encode(), timedelta(microseconds=i)) + group.write_frame(f"frame-{i}".encode(), moq.Timestamp(i, 1_000_000)) group.finish() @@ -911,9 +911,9 @@ def test_raw_parallel_groups(): assert g0.sequence == 0 assert g1.sequence == 1 - g0.write_frame(b"a0", timedelta(0)) - g1.write_frame(b"b0", timedelta(0)) - g0.write_frame(b"a1", timedelta(microseconds=1)) + g0.write_frame(b"a0", moq.Timestamp(0, 1_000_000)) + g1.write_frame(b"b0", moq.Timestamp(0, 1_000_000)) + g0.write_frame(b"a1", moq.Timestamp(1, 1_000_000)) g0.finish() g1.finish() @@ -960,7 +960,7 @@ async def test_container_consumer_default_latency_and_context_manager(): # No container argument, no explicit latency. payload = b"opus audio payload data" - media.write_frame(payload, timedelta(microseconds=1_000_000)) + media.write_frame(payload, moq.Timestamp(1_000_000, 1_000_000)) async with await moq.media.TrackConsumer.subscribe(broadcast_consumer, track_name, audio) as media_consumer: async for frame in media_consumer: @@ -1052,13 +1052,13 @@ async def test_ascending_subscription_reads_a_cached_backlog_oldest_first(): track = broadcast.publish_track("backlog", moq.TrackInfo(timescale=1_000_000)) for ms in range(3): group = track.append_group() - group.write_frame(b"x", timedelta(milliseconds=ms)) + group.write_frame(b"x", moq.Timestamp(ms, 1_000)) group.finish() consumer = track.consume( moq.Subscription( max_delay=timedelta(seconds=10), - ranges=[moq.Range(start=cast(moq.Anchor, moq.Anchor.RELATIVE(groups=2)))], + ranges=[moq.Range(start=moq.Anchor.RELATIVE(groups=2))], order=moq.Order.ASCENDING, ) ) @@ -1070,6 +1070,28 @@ async def test_ascending_subscription_reads_a_cached_backlog_oldest_first(): assert sequences == [0, 1, 2] +async def test_time_anchor_starts_at_the_group_containing_it(): + """A time anchor takes a wrapper Timestamp and starts at the group containing that time.""" + broadcast = moq.BroadcastProducer() + track = broadcast.publish_track("timed", moq.TrackInfo(timescale=1_000)) + for second in range(3): + group = track.append_group() + group.write_frame(b"x", moq.Timestamp(second, 1)) + group.finish() + + start = moq.Anchor.TIME(timestamp=moq.Timestamp(1_500, 1_000)) + consumer = track.consume( + moq.Subscription( + max_delay=timedelta(seconds=10), + ranges=[moq.Range(start=start)], + order=moq.Order.ASCENDING, + ) + ) + group = await asyncio.wait_for(consumer.recv_group(), timeout=5.0) + assert group is not None + assert group.sequence == 1 + + async def test_raw_group_producer_consume_direct(): """Consume a single group directly from the group producer.""" broadcast = moq.BroadcastProducer() @@ -1120,7 +1142,7 @@ async def test_raw_group_sequence(): for i in range(3): group = raw.append_group() sent_sequences.append(group.sequence) - group.write_frame(f"msg-{i}".encode(), timedelta(microseconds=i)) + group.write_frame(f"msg-{i}".encode(), moq.Timestamp(i, 1_000_000)) group.finish() received_sequences = [] @@ -1199,23 +1221,23 @@ async def test_container_group_decodes_arrival_order(): consumer = await broadcast.consume().subscribe_track("audio") first = track.append_group() - first.write_frame(_legacy(1_000_000, b"a"), timedelta(milliseconds=1)) + first.write_frame(_legacy(1_000_000, b"a"), moq.Timestamp(1, 1_000)) first_media = await _recv_media(consumer) frame = await asyncio.wait_for(anext(first_media), timeout=5.0) - assert (frame.payload, frame.timestamp, frame.keyframe) == (b"a", timedelta(seconds=1), True) + assert (frame.payload, frame.timestamp, frame.keyframe) == (b"a", moq.Timestamp(1_000_000, 1_000_000), True) # A newer group lands while the first is stalled mid-way. second = track.append_group() - second.write_frame(_legacy(1_040_000, b"c"), timedelta(milliseconds=2)) + second.write_frame(_legacy(1_040_000, b"c"), moq.Timestamp(2, 1_000)) second_media = await _recv_media(consumer) assert second_media.sequence == 1 frame = await asyncio.wait_for(anext(second_media), timeout=5.0) - assert (frame.payload, frame.timestamp) == (b"c", timedelta(microseconds=1_040_000)) + assert (frame.payload, frame.timestamp) == (b"c", moq.Timestamp(1_040_000, 1_000_000)) # The stalled group resumes and is still delivered. - first.write_frame(_legacy(1_020_000, b"b"), timedelta(milliseconds=3)) + first.write_frame(_legacy(1_020_000, b"b"), moq.Timestamp(3, 1_000)) frame = await asyncio.wait_for(anext(first_media), timeout=5.0) - assert (frame.payload, frame.timestamp, frame.keyframe) == (b"b", timedelta(microseconds=1_020_000), False) + assert (frame.payload, frame.timestamp, frame.keyframe) == (b"b", moq.Timestamp(1_020_000, 1_000_000), False) first.finish() second.finish() @@ -1230,20 +1252,20 @@ async def test_container_group_takes_over_an_unread_group(): consumer = await broadcast.consume().subscribe_track("audio") group = track.append_group() - group.write_frame(_legacy(1_000_000, b"a"), timedelta(milliseconds=1)) + group.write_frame(_legacy(1_000_000, b"a"), moq.Timestamp(1, 1_000)) raw = await _recv_raw(consumer) assert await raw.read_frame() is not None with pytest.raises(moq.Error.AlreadyCommitted): moq.media.GroupConsumer(raw, _LEGACY) group = track.append_group() - group.write_frame(_legacy(1_020_000, b"b"), timedelta(milliseconds=2)) + group.write_frame(_legacy(1_020_000, b"b"), moq.Timestamp(2, 1_000)) raw = await _recv_raw(consumer) media = moq.media.GroupConsumer(raw, _LEGACY) with pytest.raises(moq.Error.Cancelled): await raw.read_frame() frame = await asyncio.wait_for(anext(media), timeout=5.0) - assert frame.timestamp == timedelta(microseconds=1_020_000) + assert frame.timestamp == moq.Timestamp(1_020_000, 1_000_000) async def test_container_group_fails_loudly(): @@ -1254,7 +1276,7 @@ async def test_container_group_fails_loudly(): group = track.append_group() # A truncated varint timestamp: the 2-byte form with only its first byte. - group.write_frame(b"\x40", timedelta(milliseconds=1)) + group.write_frame(b"\x40", moq.Timestamp(1, 1_000)) raw = await _recv_raw(consumer) with pytest.raises(moq.Error.Codec): moq.media.GroupConsumer(raw, cast(moq.media.Container, moq.media.Container.CMAF(init=b""))) @@ -1317,21 +1339,21 @@ async def test_raw_read_frame_preserves_timestamp(): track = broadcast.publish_track("status", moq.TrackInfo(timescale=1_000_000)) consumer = track.consume() - track.write_frame(b"ready", timedelta(microseconds=12_345)) + track.write_frame(b"ready", moq.Timestamp(12_345, 1_000_000)) frame = await consumer.read_frame() assert frame is not None assert frame.payload == b"ready" - assert frame.timestamp == timedelta(microseconds=12_345) + assert frame.timestamp == moq.Timestamp(12_345, 1_000_000) group = track.append_group() group_consumer = group.consume() - group.write_frame(b"group", timedelta(microseconds=23_456)) + group.write_frame(b"group", moq.Timestamp(23_456, 1_000_000)) group.finish() frame = await group_consumer.read_frame() assert frame is not None assert frame.payload == b"group" - assert frame.timestamp == timedelta(microseconds=23_456) + assert frame.timestamp == moq.Timestamp(23_456, 1_000_000) async def test_read_frame_skips_remaining_frames_in_group(): @@ -1382,11 +1404,11 @@ async def test_read_frame_skips_empty_group_on_open_track(): await asyncio.sleep(0.05) assert not read.done(), "empty group must not end an open track" - track.write_frame(b"after-empty", timedelta(microseconds=1_000)) + track.write_frame(b"after-empty", moq.Timestamp(1_000, 1_000_000)) frame = await asyncio.wait_for(read, timeout=5.0) assert frame is not None assert frame.payload == b"after-empty" - assert frame.timestamp == timedelta(microseconds=1_000) + assert frame.timestamp == moq.Timestamp(1_000, 1_000_000) async def test_read_frame_skips_empty_then_populated_groups(): @@ -1397,12 +1419,12 @@ async def test_read_frame_skips_empty_then_populated_groups(): track.append_group().finish() track.append_group().finish() - track.write_frame(b"populated", timedelta(microseconds=2_000)) + track.write_frame(b"populated", moq.Timestamp(2_000, 1_000_000)) frame = await asyncio.wait_for(consumer.read_frame(), timeout=5.0) assert frame is not None assert frame.payload == b"populated" - assert frame.timestamp == timedelta(microseconds=2_000) + assert frame.timestamp == moq.Timestamp(2_000, 1_000_000) async def test_read_frame_keeps_group_across_cancelled_call(): @@ -1419,14 +1441,14 @@ async def test_read_frame_keeps_group_across_cancelled_call(): with pytest.raises(asyncio.CancelledError): await read - group.write_frame(b"kept", timedelta(microseconds=3_000)) + group.write_frame(b"kept", moq.Timestamp(3_000, 1_000_000)) group.finish() track.finish() frame = await asyncio.wait_for(consumer.read_frame(), timeout=5.0) assert frame is not None assert frame.payload == b"kept" - assert frame.timestamp == timedelta(microseconds=3_000) + assert frame.timestamp == moq.Timestamp(3_000, 1_000_000) assert await asyncio.wait_for(consumer.read_frame(), timeout=5.0) is None @@ -1488,7 +1510,7 @@ def test_encode_audio_with_opus_object(): del output del codec # One 20 ms Opus frame of silence at 48 kHz mono. - producer.write(moq.audio.Frame(timestamp=timedelta(0), data=bytes(960 * 4))) + producer.write(moq.audio.Frame(timestamp=moq.Timestamp(0, 1_000_000), data=bytes(960 * 4))) assert producer.demand().name == "mic" producer.finish() broadcast.close() @@ -1525,7 +1547,7 @@ def output(delay: timedelta | None) -> moq.audio.DecoderOutput: # Two 20 ms Opus frames of silence at 48 kHz mono. for i in range(2): - audio.write(moq.audio.Frame(timestamp=timedelta(milliseconds=20 * i), data=bytes(960 * 4))) + audio.write(moq.audio.Frame(timestamp=moq.Timestamp(20 * i, 1_000), data=bytes(960 * 4))) assert isinstance(await asyncio.wait_for(anext(fixed), timeout=5.0), moq.audio.Frame) assert isinstance(await asyncio.wait_for(anext(estimated), timeout=5.0), moq.audio.Frame) @@ -1564,7 +1586,7 @@ async def test_decode_video_frame(): rgba = bytes([0x80]) * (320 * 240 * 4) video.cut() for i in range(10): - video.write(moq.video.Frame(timestamp=timedelta(microseconds=i * 33_333), data=rgba)) + video.write(moq.video.Frame(timestamp=moq.Timestamp(i * 33_333, 1_000_000), data=rgba)) consumer = origin.consume() broadcast_consumer = await asyncio.wait_for(consumer.request("video-decode-frame"), timeout=5.0) @@ -1576,11 +1598,12 @@ async def test_decode_video_frame(): # Keep the encoder fed so the decoder sees frames after it joined. for i in range(10, 40): - video.write(moq.video.Frame(timestamp=timedelta(microseconds=i * 33_333), data=rgba)) + video.write(moq.video.Frame(timestamp=moq.Timestamp(i * 33_333, 1_000_000), data=rgba)) frame = await asyncio.wait_for(anext(decoder), timeout=5.0) decoder.cancel() assert isinstance(frame, moq.VideoDecodedFrame) + assert frame.timestamp().scale == 1_000_000 i420 = frame.pixels(moq.VideoPixelFormat.I420) diff --git a/py/moq-rs/tests/test_media_reads.py b/py/moq-rs/tests/test_media_reads.py index 9e583c2d6f0..1199a68904e 100644 --- a/py/moq-rs/tests/test_media_reads.py +++ b/py/moq-rs/tests/test_media_reads.py @@ -3,18 +3,20 @@ from datetime import timedelta from typing import Any, cast -from moq import media -from moq_ffi import MoqMediaFrame, MoqMediaRead +from moq import Timestamp, media +from moq_ffi import MoqMediaFrame, MoqMediaRead, MoqTimestamp async def test_tagged_media_reads_preserve_frames_breaks_and_eof(): payload = b"encoded packet" + first = MoqTimestamp(value=11_111, scale=90_000) + end = MoqTimestamp(value=18_212, scale=90_000) records = iter( [ - MoqMediaRead.FRAME(frame=MoqMediaFrame(payload=payload, timestamp_us=123_456, keyframe=True)), - MoqMediaRead.GAP(timestamp_us=123_456, duration=timedelta(microseconds=78_901)), - MoqMediaRead.END(end_us=202_357), - MoqMediaRead.FRAME(frame=MoqMediaFrame(payload=b"resumed", timestamp_us=202_357, keyframe=False)), + MoqMediaRead.FRAME(frame=MoqMediaFrame(payload=payload, timestamp=first, keyframe=True)), + MoqMediaRead.GAP(timestamp=first, duration=timedelta(microseconds=78_901)), + MoqMediaRead.END(end=end), + MoqMediaRead.FRAME(frame=MoqMediaFrame(payload=b"resumed", timestamp=end, keyframe=False)), None, ] ) @@ -24,8 +26,8 @@ async def next(self): return next(records) reader = media.TrackConsumer(cast(Any, Inner())) - assert await reader._next() == media.MediaFrame(payload, timedelta(microseconds=123_456), True) - assert await reader._next() == media.Gap(timedelta(microseconds=123_456), timedelta(microseconds=78_901)) - assert await reader._next() == media.End(timedelta(microseconds=202_357)) - assert await reader._next() == media.MediaFrame(b"resumed", timedelta(microseconds=202_357), False) + assert await reader._next() == media.MediaFrame(payload, Timestamp(11_111, 90_000), True) + assert await reader._next() == media.Gap(Timestamp(11_111, 90_000), timedelta(microseconds=78_901)) + assert await reader._next() == media.End(Timestamp(18_212, 90_000)) + assert await reader._next() == media.MediaFrame(b"resumed", Timestamp(18_212, 90_000), False) assert await reader._next() is None diff --git a/py/moq-rs/tests/test_server.py b/py/moq-rs/tests/test_server.py index c8f3f7f664c..11297357e69 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq-rs/tests/test_server.py @@ -82,12 +82,12 @@ async def accept_loop() -> None: media_consumer = await moq.media.TrackConsumer.subscribe(broadcast_consumer, track_name, audio) payload = b"hello over the wire" - media.write_frame(payload, timedelta(microseconds=1_000_000)) + media.write_frame(payload, moq.Timestamp(1_000_000, 1_000_000)) async for frame in media_consumer: assert isinstance(frame, moq.media.MediaFrame) assert frame.payload == payload - assert frame.timestamp // timedelta(microseconds=1) == 1_000_000 + assert frame.timestamp == moq.Timestamp(1_000_000, 1_000_000) break break diff --git a/quest/m1/README.md b/quest/m1/README.md index 577c6cc0b56..978ec2bf2a2 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -143,7 +143,6 @@ per-frame CPU growth since #5926. - [Cluster routing](/quest/m1/cluster-routing/README.md) - prove shared routing contracts, implement per-origin routes, and connect outbound local clusters - [Routing guide](/quest/m1/routing-guide.md) - concepts and practical outbound federation setup - [moq-c at parity](/quest/m1/c/README.md) - the hand-written `moq-c` ships at the cut with every m1 binding feature moq-ffi gains -- [Exact timestamps in every binding](/quest/m1/binding-timestamps.md) - bindings carry `{value, scale}` timestamps instead of lossy microseconds - [Bindings see a moq-transport run end as an end](/quest/m1/ffi-ietf-run-end.md) - an IETF publisher ending its run reaches moq-ffi consumers as an end, not `Internal` - [Byte-stream renditions form a ladder](/quest/m1/stream-ladder.md) - moq-ffi stream producers link as `lower` and disable unfundable rungs like frame producers - [A broadcast stamps everything from one clock](/quest/m1/broadcast-clock.md) - catalog, MSF, and HLS record groups are stamped from the broadcast's clock, never the ambient one diff --git a/quest/m1/binding-timestamps.md b/quest/m1/binding-timestamps.md deleted file mode 100644 index 65a84f2c3aa..00000000000 --- a/quest/m1/binding-timestamps.md +++ /dev/null @@ -1,73 +0,0 @@ -# [L] Exact timestamps in every binding - -## Goal - -Every binding (moq-ffi, moq-c, cpp and cpp/obs, and the py, swift, kt, go and -dart wrappers) carries media timestamps as an exact `{value, scale}` record -mirroring `moq_net::Timestamp`, with untimed as absent. It replaces the lossy -microsecond fields on frames, datagrams, media and audio frames, producer -arguments, and the JSON data values (`MoqJsonValue`, `moq_json_value`, and each -wrapper's `Timed`). - -The Rust core and `@moq/net` already carry exact timestamps. Wire: none. -Public API: breaking in every binding. - -## Plan - -Today every binding speaks microseconds: moq-ffi `timestamp_us: Option` -(about 160 sites in 9 files: `MoqFrame`, `MoqDatagram`, `MoqMediaFrame`, audio -frames, json/flate producer `update`/`append`, media `flush`, the -subscription start `MoqAnchor::Time`), moq-c `timestamp_us` plus -`timestamp_present` pairs (about 140 sites in 6 files, plus -`moq_anchor.timestamp_us` read as `MOQ_ANCHOR_TIME`), and -each wrapper's native type (Python `timedelta`, Go `*time.Duration`, Swift -`UInt64`, Kotlin `Duration`, Dart `int`, C++ `std::optional`, OBS -microseconds). Reads floor through `as_micros()`, so any scale that does not -divide a microsecond loses: a 90 kHz tick reads as 11 µs, and republishing it -onto a 90 kHz track floors again to tick 0. - -Decided with the maintainer on 2026-10-18: - -- **Scope is the bindings only.** The core and JS types stay as they are. -- **m1**, beside data consumer timestamps. -- **#5677 shipped** the data-value timestamps with the interim microsecond - fields; this quest converts every site at once, `MoqJsonValue` included. -- **Shape mirrors the core:** one `{value, scale}` per value, `value` bounded - as `Timestamp::new` bounds it (`2^61 - 1` since - [#6168](https://github.com/moq-dev/moq/pull/6168), decided 2026-10-22) - and `scale` non-zero and at most `2^62 - 1`, matching `Timescale::new`. The FFI boundary refuses anything else with - an error. Rejected: raw ticks at the track scale beside - `MoqTrackInfo.timescale`, because a producer argument needs its own scale - and per-value records match the core and JS. -- **Names:** UniFFI record `MoqTimestamp`, C `moq_timestamp`, wrappers - `Timestamp`. -- **C untimed is a nullable pointer:** `const moq_timestamp *`, `NULL` for - untimed, in arguments and callback structs. No sentinel scale and no - `_present` flag; the existing `timestamp_present` pairs go. -- **Wrappers expose the record plus duration helpers** to and from the - language's duration type (`timedelta`, `Duration`, `time.Duration`, ...). - From a duration is exact at the duration's own scale. To a duration rounds - to nearest and says so in its doc; the record itself stays exact. -- **One PR across every binding**, per the cross-package sync table, so each - binding breaks once. No shim: the microsecond fields are removed, not kept - beside the record. The Impact bullet says how to migrate each package, and `doc/lib/*` - and `doc/bin/obs.md` update in the same PR. - -Verification: round-trip a 90 kHz tick, a present zero, and untimed through -moq-ffi, moq-c, C++ and every wrapper's tests; refuse scale 0, an -out-of-range scale, and an out-of-range value; `just test interop --all`. - -Decided 2026-10-25 (maintainer), reversing the 2026-10-20 decision to wait -on the generated C package: the hand-written `moq-c` ships at this cut with -parity to moq-ffi for every m1 binding feature, so the moq-c half lands -there, and the generated C line follows after the cut. - -Decided 2026-10-27 (maintainer): producer arguments keep an optional -timestamp until the cut, so producers may still write untimed frames. Reads -keep "untimed as absent" until then too. At the cut -[time addressing](/quest/m1/time-addressing/README.md) makes every track -timed, and [bindings by start](/quest/m1/time-addressing/bindings.md) drops -that `Option` from reads only. - -Wall and monotonic times stay `kio::clock::Wall` and `kio::clock::Instant`, not -media timestamps; keep the types distinct. diff --git a/quest/m1/time-addressing/bindings.md b/quest/m1/time-addressing/bindings.md index 67efa3eef4c..e86a0684aa2 100644 --- a/quest/m1/time-addressing/bindings.md +++ b/quest/m1/time-addressing/bindings.md @@ -9,8 +9,7 @@ failover, with each `doc/lib` page and the PR's Impact section saying how to mig ## Plan Follow the cross-package sync table from `rs/moq-ffi` outward. Timestamps -already cross the bindings as `{value, scale}` -([exact timestamps](/quest/m1/binding-timestamps.md)), so a start reuses that +already cross the bindings as `{value, scale}`, so a start reuses that type. Decided 2026-10-27 (maintainer): every track is timed after the cut, so reads drop the `Option` that exact timestamps keeps for untimed. Only reads drop it here; producer arguments keep their optional timestamp. @@ -20,4 +19,3 @@ Public API: breaking in every binding. Wire: none. ## Required - [Rust groups by start](/quest/m1/time-addressing/model.md) - the API these wrap -- [Exact timestamps in every binding](/quest/m1/binding-timestamps.md) - the timestamp type a start uses diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index b295f9c5855..bf97b97f2b8 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -80,11 +80,11 @@ int32_t moq_publish_container(uint32_t broadcast, const moq_container_init *conf int32_t moq_publish_container_write(uint32_t container, const uint8_t *payload, uintptr_t payload_size); int32_t moq_publish_container_finish(uint32_t container); int32_t moq_publish_media_finish(uint32_t media); -int32_t moq_publish_media_frame(uint32_t media, const uint8_t *payload, uintptr_t payload_size, uint64_t timestamp_us); +int32_t moq_publish_media_frame(uint32_t media, const uint8_t *payload, uintptr_t payload_size, moq_timestamp timestamp); int32_t moq_publish_track(uint32_t broadcast, const char *name, uintptr_t name_len, const moq_track_info *info); int32_t moq_publish_track_group(uint32_t track); -int32_t moq_publish_track_frame(uint32_t track, const uint8_t *payload, uintptr_t payload_size, uint64_t timestamp_us); -int32_t moq_publish_group_frame(uint32_t group, const uint8_t *payload, uintptr_t payload_size, uint64_t timestamp_us); +int32_t moq_publish_track_frame(uint32_t track, const uint8_t *payload, uintptr_t payload_size, const moq_timestamp *timestamp); +int32_t moq_publish_group_frame(uint32_t group, const uint8_t *payload, uintptr_t payload_size, const moq_timestamp *timestamp); int32_t moq_publish_group_finish(uint32_t group); int32_t moq_publish_track_finish(uint32_t track); @@ -143,8 +143,9 @@ int32_t moq_consume_track_frame_free(uint32_t frame); int32_t moq_consume_track_cancel(uint32_t track); ``` -Raw track frames use the same `moq_frame` record as media frames. Use -`moq_publish_track_frame` or `moq_publish_group_frame` to provide microsecond -presentation timestamps; `moq_consume_track_frame` returns that timestamp in -`moq_frame.timestamp_us`. moq-c creates raw tracks with a microsecond -timescale, matching the C ABI's timestamp units. +Raw track frames use the same `moq_frame` record as media frames. Timestamps +are exact `moq_timestamp` records, `value` units at `scale` units per second. +`moq_publish_track_frame` and `moq_publish_group_frame` convert theirs to the +track's timescale, a microsecond one unless `moq_track_info.timescale` sets +another, and `moq_consume_track_frame` points `moq_frame.timestamp` at the +timestamp read, valid until the frame is freed, or NULL on an untimed track. diff --git a/rs/moq-c/c-tests/audio-decode-output.c b/rs/moq-c/c-tests/audio-decode-output.c index 377916abfaa..140af5e753a 100644 --- a/rs/moq-c/c-tests/audio-decode-output.c +++ b/rs/moq-c/c-tests/audio-decode-output.c @@ -51,7 +51,8 @@ static void on_catalog(void *data, int32_t id) { } static void check_frame(Context *c, const moq_audio_frame *frame) { - CHECK(frame->timestamp_us == 123000 + c->frames * 1000000 / 48000); + CHECK(frame->timestamp.scale == 1000000); + CHECK(frame->timestamp.value == 123000 + c->frames * 1000000 / 48000); size_t bytes = c->format == MOQ_AUDIO_SAMPLE_FORMAT_S16 ? 2 : 4; CHECK(frame->data_size % (bytes * c->channels) == 0); size_t count = frame->data_size / (bytes * c->channels); @@ -114,7 +115,7 @@ int main(void) { CHECK(producer > 0); float pcm[960 * 2]; for (size_t sample = 0; sample < 960; sample++) { pcm[sample * 2] = 0.25f; pcm[sample * 2 + 1] = -0.5f; } - moq_audio_frame frame = {123000, (const uint8_t *)pcm, sizeof(pcm)}; + moq_audio_frame frame = {{123000, 1000000}, (const uint8_t *)pcm, sizeof(pcm)}; CHECK(moq_encode_audio_frame((uint32_t)producer, &frame) == 0); CHECK(moq_encode_audio_finish((uint32_t)producer) == 0); diff --git a/rs/moq-c/c-tests/decode-output.c b/rs/moq-c/c-tests/decode-output.c index 0ac2272e112..42f998cc526 100644 --- a/rs/moq-c/c-tests/decode-output.c +++ b/rs/moq-c/c-tests/decode-output.c @@ -187,7 +187,7 @@ static uint64_t next_ts; static void publish_frames(int32_t producer, int count) { for (int i = 0; i < count; i++) { - moq_video_encoder_frame frame = {next_ts, gray_rgba, sizeof(gray_rgba)}; + moq_video_encoder_frame frame = {{next_ts, 1000000}, gray_rgba, sizeof(gray_rgba)}; next_ts += 33333; int32_t rc = moq_encode_video_frame((uint32_t)producer, &frame); if (rc < 0) fail("error: moq_encode_video_frame failed: %d (%s)\n", rc, moq_error()); diff --git a/rs/moq-c/c-tests/timestamps.c b/rs/moq-c/c-tests/timestamps.c new file mode 100644 index 00000000000..9cc7185e86b --- /dev/null +++ b/rs/moq-c/c-tests/timestamps.c @@ -0,0 +1,159 @@ +// Exact timestamps across the installed C ABI: a 90 kHz tick and a present zero +// read back unchanged from raw frames and datagrams, a JSON value carries its +// frame's timestamp, and a write refuses an out-of-range timestamp or an +// untimed frame on a timed track. moq-c only creates timed tracks, so the +// untimed read path is covered by the Rust tests. +#include + +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { \ + fprintf(stderr, "%s:%d: %s (%s)\n", __FILE__, __LINE__, #expr, moq_error()); \ + abort(); \ +} } while (0) + +// Wait up to 10 seconds for `cond`, evaluated under the context lock. +#define WAIT_FOR(c, cond) do { \ + struct timespec deadline; \ + CHECK(clock_gettime(CLOCK_REALTIME, &deadline) == 0); \ + deadline.tv_sec += 10; \ + pthread_mutex_lock(&(c)->mutex); \ + while (!(cond)) CHECK(pthread_cond_timedwait(&(c)->changed, &(c)->mutex, &deadline) == 0); \ + pthread_mutex_unlock(&(c)->mutex); \ +} while (0) + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t changed; + int32_t broadcast, frames[2], datagram, value; + size_t frame_count; + int request_done, frames_done, datagrams_done, values_done; +} Context; + +// Record a delivered id in `slot`, or the terminal status in `done`. +static void deliver(Context *c, int32_t id, int32_t *slot, int *done) { + pthread_mutex_lock(&c->mutex); + if (id > 0) *slot = id; + else { CHECK(id == 0); *done = 1; } + pthread_cond_broadcast(&c->changed); + pthread_mutex_unlock(&c->mutex); +} + +static void on_broadcast(void *data, int32_t id) { + Context *c = data; + deliver(c, id, &c->broadcast, &c->request_done); +} + +static void on_frame(void *data, int32_t id) { + Context *c = data; + pthread_mutex_lock(&c->mutex); + if (id > 0) { CHECK(c->frame_count < 2); c->frames[c->frame_count++] = id; } + else { CHECK(id == 0); c->frames_done = 1; } + pthread_cond_broadcast(&c->changed); + pthread_mutex_unlock(&c->mutex); +} + +static void on_datagram(void *data, int32_t id) { + Context *c = data; + deliver(c, id, &c->datagram, &c->datagrams_done); +} + +static void on_value(void *data, int32_t id) { + Context *c = data; + deliver(c, id, &c->value, &c->values_done); +} + +static bool same(const moq_timestamp *got, moq_timestamp want) { + return got != NULL && got->value == want.value && got->scale == want.scale; +} + +int main(void) { + Context c = {.mutex = PTHREAD_MUTEX_INITIALIZER, .changed = PTHREAD_COND_INITIALIZER}; + int32_t origin = moq_origin_create(); + CHECK(origin > 0); + const char *path = "timestamps"; + uint32_t broadcast = 0; + int32_t publication = moq_origin_create_broadcast((uint32_t)origin, path, strlen(path), &broadcast); + CHECK(publication > 0); + CHECK(moq_publication_announce((uint32_t)publication, NULL) == 0); + CHECK(moq_publication_free((uint32_t)publication) == 0); + moq_track_info info = {0}; + info.timescale = 90000; + info.timescale_present = true; + int32_t track = moq_publish_track((uint32_t)broadcast, "ticks", 5, &info); + CHECK(track > 0); + int32_t json = moq_publish_json_stream((uint32_t)broadcast, "json", 4); + CHECK(json > 0); + + const uint8_t payload[] = {1, 2, 3}; + const char *doc = "{\"n\":1}"; + const moq_timestamp invalid[] = {{0, 0}, {0, 1ull << 62}, {1ull << 61, 1000000}}; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + CHECK(moq_publish_track_frame((uint32_t)track, payload, sizeof(payload), &invalid[i]) == + MOQ_ERROR_TIMESTAMP_OVERFLOW); + CHECK(moq_publish_json_stream_append((uint32_t)json, doc, strlen(doc), &invalid[i]) == + MOQ_ERROR_TIMESTAMP_OVERFLOW); + } + CHECK(moq_publish_track_frame((uint32_t)track, payload, sizeof(payload), NULL) == MOQ_ERROR_MOQ); + + CHECK(moq_origin_request((uint32_t)origin, path, strlen(path), on_broadcast, &c) > 0); + WAIT_FOR(&c, c.broadcast); + int32_t frames = moq_consume_track((uint32_t)c.broadcast, "ticks", 5, NULL, on_frame, &c); + CHECK(frames > 0); + int32_t datagrams = moq_consume_datagrams((uint32_t)c.broadcast, "ticks", 5, on_datagram, &c); + CHECK(datagrams > 0); + int32_t values = moq_consume_json_stream((uint32_t)c.broadcast, "json", 4, on_value, &c); + CHECK(values > 0); + + // One group at a time, so the default subscription never skips the older one, and in + // time order, since a group may not start before the one preceding it. + const moq_timestamp zero = {0, 90000}, tick = {1, 90000}; + CHECK(moq_publish_track_frame((uint32_t)track, payload, sizeof(payload), &zero) == 0); + WAIT_FOR(&c, c.frame_count == 1); + CHECK(moq_publish_track_frame((uint32_t)track, payload, sizeof(payload), &tick) == 0); + WAIT_FOR(&c, c.frame_count == 2); + CHECK(moq_publish_track_datagram((uint32_t)track, payload, sizeof(payload), &tick, NULL) == 0); + WAIT_FOR(&c, c.datagram); + // JSON tracks moq-c creates count milliseconds. + const moq_timestamp stamped = {1500, 1000}; + CHECK(moq_publish_json_stream_append((uint32_t)json, doc, strlen(doc), &stamped) == 0); + WAIT_FOR(&c, c.value); + + // Each timestamp points into its own read, so both stay intact until freed. + moq_frame first, second; + CHECK(moq_consume_track_frame((uint32_t)c.frames[0], &first) == 0); + CHECK(moq_consume_track_frame((uint32_t)c.frames[1], &second) == 0); + CHECK(same(first.timestamp, zero)); + CHECK(same(second.timestamp, tick)); + CHECK(moq_consume_track_frame_free((uint32_t)c.frames[0]) == 0); + CHECK(moq_consume_track_frame_free((uint32_t)c.frames[1]) == 0); + + moq_datagram datagram; + CHECK(moq_consume_datagram((uint32_t)c.datagram, &datagram) == 0); + CHECK(same(datagram.timestamp, tick)); + CHECK(moq_consume_datagram_free((uint32_t)c.datagram) == 0); + + moq_json_value value; + CHECK(moq_consume_json_value((uint32_t)c.value, &value) == 0); + CHECK(same(value.timestamp, stamped)); + CHECK(moq_consume_json_value_free((uint32_t)c.value) == 0); + + CHECK(moq_consume_track_cancel((uint32_t)frames) == 0); + CHECK(moq_consume_datagrams_cancel((uint32_t)datagrams) == 0); + CHECK(moq_consume_json_cancel((uint32_t)values) == 0); + WAIT_FOR(&c, c.frames_done && c.datagrams_done && c.values_done); + CHECK(moq_consume_close((uint32_t)c.broadcast) == 0); + CHECK(moq_publish_json_stream_finish((uint32_t)json) == 0); + CHECK(moq_publish_track_finish((uint32_t)track) == 0); + CHECK(moq_publish_close((uint32_t)broadcast) == 0); + CHECK(moq_origin_close((uint32_t)origin) == 0); + WAIT_FOR(&c, c.request_done); + fprintf(stderr, "C exact timestamps passed\n"); + return 0; +} diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 925255b262d..f75dc024e8b 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -15,8 +15,7 @@ use tracing::Level; #[expect(non_camel_case_types, reason = "C ABI type names match moq.h")] #[derive(Clone, Copy, Debug)] pub enum moq_container_kind { - /// A QUIC varint timestamp prefix followed by the raw codec payload. - /// Timestamps are in microseconds. + /// A QUIC varint timestamp prefix in microseconds followed by the raw codec payload. MOQ_CONTAINER_KIND_LEGACY = 0, /// Fragmented MP4: each frame is a complete moof+mdat fragment, described by /// the init segment in `moq_container::init`. @@ -503,6 +502,37 @@ pub struct moq_json_snapshot_config { pub deltas: bool, } +/// An exact media timestamp: `value` units at `scale` units per second. +/// +/// Reads carry the scale the publisher wrote, so a tick such as 90 kHz survives a read and +/// republish exactly. A write with a zero scale, a scale above `2^62 - 1`, or a value above +/// `2^61 - 1` fails with `MOQ_ERROR_TIMESTAMP_OVERFLOW`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct moq_timestamp { + /// The time in `scale` units, at most `2^61 - 1`. + pub value: u64, + /// Units per second, non-zero and at most `2^62 - 1`. + pub scale: u64, +} + +impl From for moq_timestamp { + fn from(timestamp: moq_net::Timestamp) -> Self { + Self { + value: timestamp.value(), + scale: timestamp.scale().as_u64(), + } + } +} + +impl TryFrom for moq_net::Timestamp { + type Error = moq_net::TimeOverflow; + + fn try_from(timestamp: moq_timestamp) -> Result { + Self::from_scale(timestamp.value, timestamp.scale) + } +} + /// Options for a JSON window producer (a sliding run of records). #[repr(C)] pub struct moq_json_window_config { @@ -520,13 +550,9 @@ pub struct moq_json_value { /// The length in bytes of `json`. pub json_len: usize, - /// The timestamp of the frame that carried the value in microseconds, or 0 when - /// `timestamp_present` is false. Rounded down to a whole microsecond, so a tick on a track - /// like 90 kHz may not survive a read and republish exactly. - pub timestamp_us: u64, - - /// Whether the frame carries a timestamp. A value read from an untimed track does not. - pub timestamp_present: bool, + /// The timestamp of the frame that carried the value, or NULL on an untimed track. + /// Valid as long as `json`. + pub timestamp: *const moq_timestamp, } /// Which change a [moq_json_window_event] reports. @@ -562,13 +588,10 @@ pub struct moq_json_window_event { /// The length in bytes of `json`. pub json_len: usize, - /// The timestamp of the frame that carried the change in microseconds, or 0 when - /// `timestamp_present` is false. A record first learned of from a group's restatement (after - /// a join, or while behind) carries that frame's time, not its original push time. - pub timestamp_us: u64, - - /// Whether the frame carries a timestamp. A change read from an untimed track does not. - pub timestamp_present: bool, + /// The timestamp of the frame that carried the change, or NULL on an untimed track. Valid + /// until the event is freed. A record first learned of from a group's restatement (after a + /// join, or while behind) carries that frame's time, not its original push time. + pub timestamp: *const moq_timestamp, } /// Information about a frame of media. @@ -579,13 +602,9 @@ pub struct moq_frame { /// The length in bytes of `payload`. pub payload_size: usize, - /// The presentation timestamp of the frame in microseconds, or 0 when - /// `timestamp_present` is false. - pub timestamp_us: u64, - - /// Whether the frame carries a timestamp. Media frames always do; a raw frame read - /// from an untimed track does not. - pub timestamp_present: bool, + /// The presentation timestamp of the frame, or NULL on an untimed track. Media frames + /// always have one. Valid as long as `payload`. + pub timestamp: *const moq_timestamp, /// Whether this frame opens a group or is a video keyframe; audio is true only at a group start. pub keyframe: bool, @@ -600,9 +619,9 @@ pub struct moq_media_read { pub frame: moq_frame, /// Lost media for a gap read; what follows does not continue it. pub gap: crate::moq_decode_gap, - /// Where a stopped encoder's media stops, in microseconds. Following frames before - /// this point are its remaining media; the first at or past it comes from a fresh encoder. - pub end_us: u64, + /// Where a stopped encoder's media stops. Following frames before this point are its + /// remaining media; the first at or past it comes from a fresh encoder. + pub end: moq_timestamp, } /// A best-effort raw track datagram delivered via [moq_consume_datagrams]. @@ -613,12 +632,9 @@ pub struct moq_datagram { /// The length in bytes of `payload`. pub payload_size: usize, - /// The presentation timestamp of the datagram in microseconds, or 0 when - /// `timestamp_present` is false. - pub timestamp_us: u64, - - /// Whether the datagram carries a timestamp. One read from an untimed track does not. - pub timestamp_present: bool, + /// The presentation timestamp of the datagram, or NULL on an untimed track. Valid as + /// long as `payload`. + pub timestamp: *const moq_timestamp, /// Per-track sequence number, drawn from the same namespace as groups. pub sequence: u64, @@ -642,8 +658,7 @@ pub struct moq_track_info { /// Per-frame timescale in ticks per second. pub timescale: u64, - /// Whether `timescale` is set. When false, the default microsecond timescale - /// applies, matching the `timestamp_us` units used everywhere else in this ABI. + /// Whether `timescale` is set. When false, a microsecond timescale applies. pub timescale_present: bool, /// A joining subscriber asks for a new group once the newest group's bytes after its @@ -661,8 +676,7 @@ impl TryFrom<&moq_track_info> for moq_net::track::Info { type Error = Error; fn try_from(info: &moq_track_info) -> Result { - // Raw tracks default to a microsecond timescale, matching the C ABI's - // timestamp_us units. An explicit timescale below overrides it. + // Raw tracks default to a microsecond timescale; an explicit one below overrides it. let mut out = moq_net::track::Info::default() .with_timescale(moq_net::Timescale::MICRO) .with_priority(info.priority); @@ -788,8 +802,8 @@ pub struct moq_anchor { /// The group, or with `MOQ_ANCHOR_RELATIVE` how many groups before the latest one /// (0 is the latest, and an offset past the first group clamps to it). pub group: u64, - /// The presentation time in microseconds, read only with `MOQ_ANCHOR_TIME`. - pub timestamp_us: u64, + /// The presentation time, read only with `MOQ_ANCHOR_TIME`. + pub timestamp: moq_timestamp, } /// How a [moq_anchor] names its position. @@ -820,9 +834,7 @@ fn parse_anchor(anchor: &moq_anchor) -> Result { Ok(match anchor.kind { v if v == moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32 => moq_net::track::Anchor::Absolute(anchor.group), v if v == moq_anchor_kind::MOQ_ANCHOR_RELATIVE as u32 => moq_net::track::Anchor::Relative(anchor.group), - v if v == moq_anchor_kind::MOQ_ANCHOR_TIME as u32 => { - moq_net::track::Anchor::Time(moq_net::Timestamp::from_micros(anchor.timestamp_us)?) - } + v if v == moq_anchor_kind::MOQ_ANCHOR_TIME as u32 => moq_net::track::Anchor::Time(anchor.timestamp.try_into()?), _ => return Err(Error::InvalidCode), }) } @@ -2800,7 +2812,6 @@ pub extern "C" fn moq_publish_container_finish(container: u32) -> i32 { /// Write data to a track. /// /// The encoding of `data` depends on the track `format`. -/// The timestamp is in microseconds. /// /// Returns a zero on success, or a negative code on failure. /// @@ -2811,13 +2822,12 @@ pub unsafe extern "C" fn moq_publish_media_frame( media: u32, payload: *const u8, payload_size: usize, - timestamp_us: u64, + timestamp: moq_timestamp, ) -> i32 { ffi::enter(move || { let media = ffi::parse_id(media)?; let payload = unsafe { ffi::parse_slice(payload, payload_size)? }; - let timestamp = hang::container::Timestamp::from_micros(timestamp_us)?; - State::lock().publish.media_frame(media, payload, timestamp) + State::lock().publish.media_frame(media, payload, timestamp.try_into()?) }) } @@ -3121,31 +3131,33 @@ pub extern "C" fn moq_publish_track_group_at(track: u32, sequence: u64) -> i32 { /// Write a single-frame group to a raw track with a timestamp. /// /// Convenience for the common one-frame-per-group pattern. Equivalent to -/// appending a group, writing one frame, and finishing it. -/// The timestamp is in microseconds. +/// appending a group, writing one frame, and finishing it. The timestamp is +/// converted to the track's timescale; NULL writes an untimed frame, which only +/// an untimed track accepts. /// /// Returns a zero on success, or a negative code on failure. /// /// # Safety /// - The caller must ensure that payload is a valid pointer to payload_size bytes of data. +/// - `timestamp` must be NULL or a valid pointer to a [moq_timestamp]. #[unsafe(no_mangle)] pub unsafe extern "C" fn moq_publish_track_frame( track: u32, payload: *const u8, payload_size: usize, - timestamp_us: u64, + timestamp: *const moq_timestamp, ) -> i32 { ffi::enter(move || { let track = ffi::parse_id(track)?; let payload = unsafe { ffi::parse_slice(payload, payload_size)? }; - let timestamp = moq_net::Timestamp::from_micros(timestamp_us)?; + let timestamp = unsafe { ffi::parse_timestamp(timestamp)? }; State::lock().publish.track_frame(track, timestamp, payload) }) } /// Send a best-effort datagram on a raw track created by [moq_publish_track]. /// -/// Takes `payload` then `timestamp_us`, matching [moq_publish_track_frame]. The payload must +/// Takes `payload` then `timestamp`, matching [moq_publish_track_frame]. The payload must /// be at most 1200 bytes. On success the datagram's per-track sequence number (shared with the /// group namespace) is written to `out_sequence` when it is non-NULL. Datagrams are /// delivered only on transports and wire versions with a datagram channel; there is no @@ -3155,19 +3167,21 @@ pub unsafe extern "C" fn moq_publish_track_frame( /// /// # Safety /// - The caller must ensure that payload is a valid pointer to payload_size bytes of data. +/// - `timestamp` must be NULL or a valid pointer to a [moq_timestamp]. /// - `out_sequence` must be NULL or a valid pointer to a `uint64_t`. #[unsafe(no_mangle)] pub unsafe extern "C" fn moq_publish_track_datagram( track: u32, payload: *const u8, payload_size: usize, - timestamp_us: u64, + timestamp: *const moq_timestamp, out_sequence: *mut u64, ) -> i32 { ffi::enter(move || { let track = ffi::parse_id(track)?; let payload = unsafe { ffi::parse_slice(payload, payload_size)? }; - let sequence = State::lock().publish.track_datagram(track, timestamp_us, payload)?; + let timestamp = unsafe { ffi::parse_timestamp(timestamp)? }; + let sequence = State::lock().publish.track_datagram(track, timestamp, payload)?; if let Some(out) = unsafe { out_sequence.as_mut() } { *out = sequence; } @@ -3512,23 +3526,25 @@ pub extern "C" fn moq_group_request_free(request: u32) -> i32 { /// Write a frame into a raw group created by [moq_publish_track_group]. /// -/// The timestamp is in microseconds. +/// The timestamp is converted to the track's timescale; NULL writes an untimed +/// frame, which only an untimed track accepts. /// /// Returns a zero on success, or a negative code on failure. /// /// # Safety /// - The caller must ensure that payload is a valid pointer to payload_size bytes of data. +/// - `timestamp` must be NULL or a valid pointer to a [moq_timestamp]. #[unsafe(no_mangle)] pub unsafe extern "C" fn moq_publish_group_frame( group: u32, payload: *const u8, payload_size: usize, - timestamp_us: u64, + timestamp: *const moq_timestamp, ) -> i32 { ffi::enter(move || { let group = ffi::parse_id(group)?; let payload = unsafe { ffi::parse_slice(payload, payload_size)? }; - let timestamp = moq_net::Timestamp::from_micros(timestamp_us)?; + let timestamp = unsafe { ffi::parse_timestamp(timestamp)? }; State::lock().publish.group_frame(group, timestamp, payload) }) } @@ -3585,17 +3601,27 @@ pub unsafe extern "C" fn moq_publish_json_snapshot( /// Publish a new value to a JSON snapshot track. `value` is a UTF-8 JSON document. A no-op if /// unchanged from the previous update. /// +/// `timestamp` is when the value was captured, converted to the track's millisecond timescale; +/// NULL stamps the broadcast clock's current time. +/// /// Returns a zero on success, or a negative code on failure. /// /// # Safety /// - The caller must ensure `value` is a valid pointer to `value_len` bytes. +/// - `timestamp` must be NULL or a valid pointer to a [moq_timestamp]. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_publish_json_snapshot_update(json: u32, value: *const c_char, value_len: usize) -> i32 { +pub unsafe extern "C" fn moq_publish_json_snapshot_update( + json: u32, + value: *const c_char, + value_len: usize, + timestamp: *const moq_timestamp, +) -> i32 { ffi::enter(move || { let json = ffi::parse_id(json)?; let value = unsafe { ffi::parse_slice(value.cast::(), value_len)? }; let value = serde_json::from_slice(value)?; - State::lock().publish.json_snapshot_update(json, value) + let timestamp = unsafe { ffi::parse_timestamp(timestamp)? }; + State::lock().publish.json_snapshot_update(json, value, timestamp) }) } @@ -3634,17 +3660,27 @@ pub unsafe extern "C" fn moq_publish_json_stream(broadcast: u32, name: *const c_ /// Append one record to a JSON stream track. `value` is a UTF-8 JSON document. /// +/// `timestamp` is when the value was captured, converted to the track's millisecond timescale; +/// NULL stamps the broadcast clock's current time. +/// /// Returns a zero on success, or a negative code on failure. /// /// # Safety /// - The caller must ensure `value` is a valid pointer to `value_len` bytes. +/// - `timestamp` must be NULL or a valid pointer to a [moq_timestamp]. #[unsafe(no_mangle)] -pub unsafe extern "C" fn moq_publish_json_stream_append(stream: u32, value: *const c_char, value_len: usize) -> i32 { +pub unsafe extern "C" fn moq_publish_json_stream_append( + stream: u32, + value: *const c_char, + value_len: usize, + timestamp: *const moq_timestamp, +) -> i32 { ffi::enter(move || { let stream = ffi::parse_id(stream)?; let value = unsafe { ffi::parse_slice(value.cast::(), value_len)? }; let value = serde_json::from_slice(value)?; - State::lock().publish.json_stream_append(stream, value) + let timestamp = unsafe { ffi::parse_timestamp(timestamp)? }; + State::lock().publish.json_stream_append(stream, value, timestamp) }) } @@ -4052,7 +4088,7 @@ pub extern "C" fn moq_consume_audio_cancel(track: u32) -> i32 { } /// Read an encoded media frame, gap, or encoder end delivered by a media callback. -/// Payload pointers borrow the read until [`moq_consume_read_free`]. +/// Payload and timestamp pointers borrow the read until [`moq_consume_read_free`]. /// /// # Safety /// - `dst` must point to a writable [`moq_media_read`]. @@ -4150,10 +4186,9 @@ pub unsafe extern "C" fn moq_consume_track_update(track: u32, subscription: *con /// Read a raw frame's payload delivered via the [moq_consume_track] callback. /// /// Fills `dst.payload` / `dst.payload_size`; the pointer is valid until the -/// frame is released with [moq_consume_track_frame_free]. `dst.timestamp_us` is the -/// frame presentation timestamp in microseconds, when `dst.timestamp_present` says it -/// has one. `dst.keyframe` is reported as -/// false because raw tracks do not parse codec metadata. +/// frame is released with [moq_consume_track_frame_free], and so is `dst.timestamp`, +/// NULL on an untimed track. `dst.keyframe` is reported as false because raw tracks +/// do not parse codec metadata. /// /// Returns a zero on success, or a negative code on failure. /// @@ -4229,8 +4264,8 @@ pub unsafe extern "C" fn moq_consume_datagrams( /// Read a datagram delivered via the [moq_consume_datagrams] callback. /// /// Fills `dst.payload` / `dst.payload_size` (valid until the datagram is released with -/// [moq_consume_datagram_free]), plus `dst.timestamp_us` (when `dst.timestamp_present`) -/// and `dst.sequence`. +/// [moq_consume_datagram_free]), plus `dst.timestamp` (NULL on an untimed track, and valid +/// as long as the payload) and `dst.sequence`. /// /// Returns a zero on success, or a negative code on failure. /// @@ -4386,8 +4421,8 @@ pub extern "C" fn moq_consume_json_window_event_free(event: u32) -> i32 { /// Read a JSON value delivered via a [moq_consume_json_snapshot] or [moq_consume_json_stream] callback. /// -/// Fills `dst.json` / `dst.json_len` and the frame timestamp; the pointer is valid until the value is released with -/// [moq_consume_json_value_free]. +/// Fills `dst.json` / `dst.json_len` and the frame timestamp; both pointers are valid until the value is released +/// with [moq_consume_json_value_free]. /// /// Returns a zero on success, or a negative code on failure. /// diff --git a/rs/moq-c/src/audio.rs b/rs/moq-c/src/audio.rs index 39ff8596705..db120d8b4ae 100644 --- a/rs/moq-c/src/audio.rs +++ b/rs/moq-c/src/audio.rs @@ -20,7 +20,7 @@ use tokio::sync::oneshot; use crate::decode::Read; use crate::ffi::OnStatus; -use crate::{moq_decode_gap, moq_decode_read_kind, moq_decode_start}; +use crate::{moq_decode_gap, moq_decode_read_kind, moq_decode_start, moq_timestamp}; use crate::task::Task; use crate::{Error, Id, NonZeroSlab, Shared, State, ffi}; @@ -137,8 +137,8 @@ pub struct moq_audio_decoder_output { /// (the publisher copies before returning). #[repr(C)] pub struct moq_audio_frame { - /// The presentation timestamp in microseconds. - pub timestamp_us: u64, + /// The presentation timestamp. + pub timestamp: moq_timestamp, /// The raw frame bytes in the configured layout. pub data: *const u8, /// The length in bytes of `data`. @@ -156,11 +156,11 @@ pub struct moq_audio_read { /// The lost span, for [moq_decode_read_kind::MOQ_DECODE_READ_GAP]. What follows does /// not continue what came before. pub gap: moq_decode_gap, - /// Where the stopped encoder's media stops in microseconds, for + /// Where the stopped encoder's media stops, for /// [moq_decode_read_kind::MOQ_DECODE_READ_END]. Frames that follow and start before it /// are the stopped encoder's remaining media; the first at or past it comes from a fresh /// encoder. - pub end_us: u64, + pub end: moq_timestamp, } // ---- State extensions (used internally by lib.rs) ---- @@ -291,7 +291,7 @@ impl Audio { read = consumer.read() => match read { Ok(Some(moq_audio::decode::Read::Frame(frame))) => Read::Frame(frame), Ok(Some(moq_audio::decode::Read::Gap { timestamp, duration })) => Read::gap(timestamp, duration)?, - Ok(Some(moq_audio::decode::Read::End { end })) => Read::end(end)?, + Ok(Some(moq_audio::decode::Read::End { end })) => Read::end(end), Ok(None) => return Ok(()), // One packet the codec rejected is that packet's problem: the // decoder stays usable, so drop it and keep the subscription rather @@ -318,29 +318,20 @@ impl Audio { /// Borrow a delivered read, and a frame's samples, until its handle is freed. pub fn read_info(&self, id: Id, dst: &mut moq_audio_read) -> Result<(), Error> { let read = self.reads.get(id).ok_or(Error::FrameNotFound)?; - let (kind, gap, end_us) = read.info(); + let (kind, gap, end) = read.info(); let frame = match read { Read::Frame(frame) => moq_audio_frame { - // The C ABI carries plain microseconds, so flatten the scaled - // `Timestamp` here at the boundary. Saturating rather than erroring: - // this is a getter on a frame we already decoded, and a u64 overflow - // needs a timestamp ~580,000 years out. - timestamp_us: u64::try_from(frame.timestamp.as_micros()).unwrap_or(u64::MAX), + timestamp: frame.timestamp.into(), data: frame.data.as_ptr(), data_size: frame.data.len(), }, _ => moq_audio_frame { - timestamp_us: 0, + timestamp: moq_timestamp::default(), data: std::ptr::null(), data_size: 0, }, }; - *dst = moq_audio_read { - kind, - frame, - gap, - end_us, - }; + *dst = moq_audio_read { kind, frame, gap, end }; Ok(()) } @@ -479,9 +470,7 @@ pub unsafe extern "C" fn moq_encode_audio_frame(producer: u32, frame: *const moq let frame = unsafe { frame.as_ref() }.ok_or(Error::InvalidPointer)?; let data = unsafe { ffi::parse_slice(frame.data, frame.data_size)? }; - // The C ABI carries plain microseconds; scale them at the boundary. - let timestamp = moq_net::Timestamp::from_micros(frame.timestamp_us).map_err(moq_audio::Error::from)?; - let owned = moq_audio::Frame::new(Bytes::copy_from_slice(data), timestamp); + let owned = moq_audio::Frame::new(Bytes::copy_from_slice(data), frame.timestamp.try_into()?); let producer = State::lock().audio.producer(producer)?; producer diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index b187e78da9f..86ad1f72299 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -12,7 +12,7 @@ use crate::ffi::OnStatus; use crate::task::Task; use crate::{ Error, Id, NonZeroSlab, State, moq_audio_config, moq_datagram, moq_frame, moq_json_value, moq_json_window_event, - moq_json_window_kind, moq_section, moq_string, moq_video_config, moq_video_properties, + moq_json_window_kind, moq_section, moq_string, moq_timestamp, moq_video_config, moq_video_properties, }; struct ConsumeCatalog { @@ -146,43 +146,47 @@ enum RawStep { Stop, } -/// A raw frame, datagram, or JSON value timestamp in microseconds, `None` when untimed. -fn timestamp_us(timestamp: Option) -> Result, Error> { - timestamp - .map(|timestamp| timestamp.as_micros().try_into()) - .transpose() - .map_err(|_| Error::TimestampOverflow(moq_net::TimeOverflow)) +/// A buffered read plus the timestamp C borrows through a pointer, `None` when untimed. +/// +/// Boxed because the slab moves its entries as it grows, and the pointer must stay valid +/// until the read is freed. +struct Timed { + item: T, + timestamp: Option>, } -/// A JSON value pre-serialized to a string, with its frame timestamp. -struct JsonValue { - json: String, - timestamp_us: Option, -} +impl Timed { + fn new(item: T, timestamp: Option) -> Self { + Self { + item, + timestamp: timestamp.map(|timestamp| Box::new(timestamp.into())), + } + } -impl JsonValue { - fn new(value: moq_net::Timed) -> Result { - Ok(Self { - json: serde_json::to_string(&value.value)?, - timestamp_us: timestamp_us(value.at)?, - }) + /// The timestamp pointer handed to C, NULL when untimed. + fn timestamp(&self) -> *const moq_timestamp { + self.timestamp.as_deref().map_or(std::ptr::null(), std::ptr::from_ref) } } -/// A JSON window event, its record pre-serialized to a string, with its frame timestamp. +/// A JSON value pre-serialized to a string, with its frame timestamp. +fn json_value(value: moq_net::Timed) -> Result, Error> { + Ok(Timed::new(serde_json::to_string(&value.value)?, value.at)) +} + +/// A JSON window event, its record pre-serialized to a string. struct JsonWindowEvent { kind: moq_json_window_kind, range: std::ops::Range, /// The pushed record, `None` for a pop or skip. json: Option, - timestamp_us: Option, } impl JsonWindowEvent { - fn new(event: moq_net::Timed>) -> Result { + /// Pre-serialize an event, keeping its frame timestamp. + fn new(event: moq_net::Timed>) -> Result, Error> { use moq_json::window::Event; - let timestamp_us = timestamp_us(event.at)?; let (kind, range, json) = match event.value { Event::Push { index, value } => ( moq_json_window_kind::MOQ_JSON_WINDOW_KIND_PUSH, @@ -193,12 +197,7 @@ impl JsonWindowEvent { Event::Skip(range) => (moq_json_window_kind::MOQ_JSON_WINDOW_KIND_SKIP, range, None), event => return Err(Error::Json(format!("unsupported window event: {event:?}"))), }; - Ok(Self { - kind, - range, - json, - timestamp_us, - }) + Ok(Timed::new(Self { kind, range, json }, event.at)) } } @@ -217,28 +216,28 @@ pub struct Consume { track_task: NonZeroSlab, /// Buffered frames ready for consumption. - reads: NonZeroSlab>, + reads: NonZeroSlab>>, /// Raw track consumer tasks (no media/container framing). raw_task: NonZeroSlab, /// Buffered raw frames ready for consumption. - raw_frame: NonZeroSlab, + raw_frame: NonZeroSlab>, /// Raw track datagram consumer tasks (best-effort, parallel to `raw_task`). datagram_task: NonZeroSlab, /// Buffered datagrams ready for consumption. - datagram: NonZeroSlab, + datagram: NonZeroSlab>, /// JSON consumer tasks (snapshot, stream, and window share this slab). json_task: NonZeroSlab, /// Buffered JSON values ready for consumption. - json_value: NonZeroSlab, + json_value: NonZeroSlab>, /// Buffered JSON window events ready for consumption. - json_window_event: NonZeroSlab, + json_window_event: NonZeroSlab>, } impl Consume { @@ -550,9 +549,12 @@ impl Consume { biased; _ = &mut close => return Ok(()), read = track.read() => match read? { - Some(moq_mux::container::Read::Frame { frame, .. }) => Read::Frame(frame), + Some(moq_mux::container::Read::Frame { frame, .. }) => { + let timestamp = frame.timestamp; + Read::Frame(Timed::new(frame, Some(timestamp))) + } Some(moq_mux::container::Read::Gap { timestamp, duration }) => Read::gap(timestamp, duration)?, - Some(moq_mux::container::Read::End { end, .. }) => Read::end(end)?, + Some(moq_mux::container::Read::End { end, .. }) => Read::end(end), None => return Ok(()), }, }; @@ -570,29 +572,22 @@ impl Consume { /// Read the tagged media item; payload pointers borrow it until [`Self::read_free`]. pub fn read(&self, id: Id, dst: &mut crate::moq_media_read) -> Result<(), Error> { let read = self.reads.get(id).ok_or(Error::FrameNotFound)?; - let (kind, gap, end_us) = read.info(); + let (kind, gap, end) = read.info(); let frame = match read { Read::Frame(f) => moq_frame { - payload: f.payload.as_ptr(), - payload_size: f.payload.len(), - timestamp_us: f.timestamp.as_micros().try_into().map_err(|_| moq_net::TimeOverflow)?, - timestamp_present: true, - keyframe: f.keyframe, + payload: f.item.payload.as_ptr(), + payload_size: f.item.payload.len(), + timestamp: f.timestamp(), + keyframe: f.item.keyframe, }, _ => moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }, }; - *dst = crate::moq_media_read { - kind, - frame, - gap, - end_us, - }; + *dst = crate::moq_media_read { kind, frame, gap, end }; Ok(()) } @@ -730,6 +725,7 @@ impl Consume { }; // Hold the lock only to buffer the read; release it before the callback. + let frame = Timed::new(frame.payload, frame.timestamp); let frame_id = State::lock().consume.raw_frame.insert(frame)?; callback.call(Ok(frame_id)); } @@ -762,13 +758,11 @@ impl Consume { /// frame is released with [`Self::raw_frame_close`]. pub fn raw_frame(&self, frame: Id, dst: &mut moq_frame) -> Result<(), Error> { let frame = self.raw_frame.get(frame).ok_or(Error::FrameNotFound)?; - let timestamp_us = timestamp_us(frame.timestamp)?; *dst = moq_frame { - payload: frame.payload.as_ptr(), - payload_size: frame.payload.len(), - timestamp_us: timestamp_us.unwrap_or_default(), - timestamp_present: timestamp_us.is_some(), + payload: frame.item.as_ptr(), + payload_size: frame.item.len(), + timestamp: frame.timestamp(), keyframe: false, }; @@ -827,7 +821,8 @@ impl Consume { }; // Hold the lock only to buffer the datagram; release it before the callback. - let id = State::lock().consume.datagram.insert(datagram)?; + let timestamp = datagram.timestamp; + let id = State::lock().consume.datagram.insert(Timed::new(datagram, timestamp))?; callback.call(Ok(id)); } } @@ -841,14 +836,11 @@ impl Consume { pub fn datagram(&self, datagram: Id, dst: &mut moq_datagram) -> Result<(), Error> { let value = self.datagram.get(datagram).ok_or(Error::FrameNotFound)?; - let timestamp_us = timestamp_us(value.timestamp)?; - *dst = moq_datagram { - payload: value.payload.as_ptr(), - payload_size: value.payload.len(), - timestamp_us: timestamp_us.unwrap_or_default(), - timestamp_present: timestamp_us.is_some(), - sequence: value.sequence, + payload: value.item.payload.as_ptr(), + payload_size: value.item.payload.len(), + timestamp: value.timestamp(), + sequence: value.item.sequence, }; Ok(()) @@ -927,7 +919,7 @@ impl Consume { // Serialize before locking, hold the lock only to buffer the value, and release it // before the callback. - let value = JsonValue::new(value)?; + let value = json_value(value)?; let value_id = State::lock().consume.json_value.insert(value)?; callback.call(Ok(value_id)); } @@ -981,7 +973,7 @@ impl Consume { }, }; - let value = JsonValue::new(value)?; + let value = json_value(value)?; let value_id = State::lock().consume.json_value.insert(value)?; callback.call(Ok(value_id)); } @@ -1053,10 +1045,9 @@ impl Consume { let value = self.json_value.get(value).ok_or(Error::FrameNotFound)?; *dst = moq_json_value { - json: value.json.as_ptr() as *const c_char, - json_len: value.json.len(), - timestamp_us: value.timestamp_us.unwrap_or_default(), - timestamp_present: value.timestamp_us.is_some(), + json: value.item.as_ptr() as *const c_char, + json_len: value.item.len(), + timestamp: value.timestamp(), }; Ok(()) @@ -1072,19 +1063,18 @@ impl Consume { /// until the event is released with [`Self::json_window_event_close`]. pub fn json_window_event(&self, event: Id, dst: &mut moq_json_window_event) -> Result<(), Error> { let event = self.json_window_event.get(event).ok_or(Error::FrameNotFound)?; - let (json, json_len) = match &event.json { + let (json, json_len) = match &event.item.json { Some(json) => (json.as_ptr() as *const c_char, json.len()), None => (std::ptr::null(), 0), }; *dst = moq_json_window_event { - kind: event.kind, - start: event.range.start, - end: event.range.end, + kind: event.item.kind, + start: event.item.range.start, + end: event.item.range.end, json, json_len, - timestamp_us: event.timestamp_us.unwrap_or_default(), - timestamp_present: event.timestamp_us.is_some(), + timestamp: event.timestamp(), }; Ok(()) @@ -1203,6 +1193,10 @@ mod read_tests { track.finish().unwrap(); run.await.unwrap(); assert_eq!(ids.len(), 3); + let micros = |value| moq_timestamp { + value, + scale: 1_000_000, + }; let mut read = std::mem::MaybeUninit::::uninit(); for (index, id) in ids.into_iter().enumerate() { assert!(id > 0); @@ -1215,20 +1209,20 @@ mod read_tests { unsafe { std::slice::from_raw_parts(read.frame.payload, read.frame.payload_size) }, b"encoded" ); - assert_eq!(read.frame.timestamp_us, 10_000); - assert!(read.frame.timestamp_present && read.frame.keyframe); + assert_eq!(unsafe { *read.frame.timestamp }, micros(10_000)); + assert!(read.frame.keyframe); assert_eq!(read.gap.duration_us, 0); - assert_eq!(read.end_us, 0); + assert_eq!(read.end, moq_timestamp::default()); } 1 => { assert_eq!(read.kind, crate::moq_decode_read_kind::MOQ_DECODE_READ_GAP); - assert_eq!((read.gap.timestamp_us, read.gap.duration_us), (10_000, 10_000)); - assert!(read.frame.payload.is_null()); - assert_eq!(read.end_us, 0); + assert_eq!((read.gap.timestamp, read.gap.duration_us), (micros(10_000), 10_000)); + assert!(read.frame.payload.is_null() && read.frame.timestamp.is_null()); + assert_eq!(read.end, moq_timestamp::default()); } 2 => { assert_eq!(read.kind, crate::moq_decode_read_kind::MOQ_DECODE_READ_END); - assert_eq!(read.end_us, 20_000); + assert_eq!(read.end, micros(20_000)); assert!(read.frame.payload.is_null()); assert_eq!(read.gap.duration_us, 0); } diff --git a/rs/moq-c/src/decode.rs b/rs/moq-c/src/decode.rs index 2c19d46ea1f..a7bedc6f41f 100644 --- a/rs/moq-c/src/decode.rs +++ b/rs/moq-c/src/decode.rs @@ -3,7 +3,7 @@ //! Encoded media and both decoders hand over the same three kinds of read, so a C player //! handles loss and encoder ends alike for every media type. -use crate::Error; +use crate::{Error, moq_timestamp}; /// Which kind of media read was delivered: a frame, or a break between frames. #[repr(C)] @@ -52,53 +52,53 @@ impl moq_decode_start { #[repr(C)] #[derive(Clone, Copy, Debug, Default)] pub struct moq_decode_gap { - /// The furthest presentation point already read, in microseconds. - pub timestamp_us: u64, + /// The furthest presentation point already read. + pub timestamp: moq_timestamp, /// An upper bound on how long until the next frame resumes, in microseconds. pub duration_us: u64, } -/// A delivered read held by its id, with its timestamps already in the C ABI's microseconds. +/// A delivered read held by its id. #[derive(Clone)] pub(crate) enum Read { Frame(F), Gap(moq_decode_gap), - End { end_us: u64 }, + End(moq_timestamp), } impl Read { pub(crate) fn gap(timestamp: moq_net::Timestamp, duration: moq_net::Timestamp) -> Result { Ok(Self::Gap(moq_decode_gap { - timestamp_us: micros(timestamp)?, - duration_us: micros(duration)?, + timestamp: timestamp.into(), + duration_us: duration + .as_micros() + .try_into() + .map_err(|_| Error::TimestampOverflow(moq_net::TimeOverflow))?, })) } - pub(crate) fn end(end: moq_net::Timestamp) -> Result { - Ok(Self::End { end_us: micros(end)? }) + pub(crate) fn end(end: moq_net::Timestamp) -> Self { + Self::End(end.into()) } /// The C kind, plus the gap and end fields a non-frame read fills. - pub(crate) fn info(&self) -> (moq_decode_read_kind, moq_decode_gap, u64) { + pub(crate) fn info(&self) -> (moq_decode_read_kind, moq_decode_gap, moq_timestamp) { match self { Self::Frame(_) => ( moq_decode_read_kind::MOQ_DECODE_READ_FRAME, moq_decode_gap::default(), - 0, + moq_timestamp::default(), ), - Self::Gap(gap) => (moq_decode_read_kind::MOQ_DECODE_READ_GAP, *gap, 0), - Self::End { end_us } => ( + Self::Gap(gap) => ( + moq_decode_read_kind::MOQ_DECODE_READ_GAP, + *gap, + moq_timestamp::default(), + ), + Self::End(end) => ( moq_decode_read_kind::MOQ_DECODE_READ_END, moq_decode_gap::default(), - *end_us, + *end, ), } } } - -fn micros(timestamp: moq_net::Timestamp) -> Result { - timestamp - .as_micros() - .try_into() - .map_err(|_| Error::TimestampOverflow(moq_net::TimeOverflow)) -} diff --git a/rs/moq-c/src/ffi.rs b/rs/moq-c/src/ffi.rs index e4a03aef315..be21fc414ee 100644 --- a/rs/moq-c/src/ffi.rs +++ b/rs/moq-c/src/ffi.rs @@ -508,6 +508,16 @@ pub unsafe fn parse_slice<'a>(data: *const u8, size: usize) -> Result<&'a [u8], Ok(data) } +/// Parse an optional timestamp, NULL meaning untimed. +/// +/// # Safety +/// `timestamp` must be NULL or a valid pointer to a [crate::moq_timestamp]. +pub unsafe fn parse_timestamp(timestamp: *const crate::moq_timestamp) -> Result, Error> { + Ok(unsafe { timestamp.as_ref() } + .map(|×tamp| timestamp.try_into()) + .transpose()?) +} + #[cfg(test)] mod tests { diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index 0f2bf7defb7..810ca261283 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -252,8 +252,8 @@ impl std::ops::DerefMut for Kept { } } -// Legacy C data methods stamp writes on one fixed broadcast clock without retaining -// another catalog handle after a failed stream retires its listing. +// C data methods stamp a write the caller left unstamped on one fixed broadcast clock, +// without retaining another catalog handle after a failed stream retires its listing. struct Data { producer: T, clock: moq_mux::Clock, @@ -1134,8 +1134,13 @@ impl Publish { self.groups.insert(group) } - /// Write a single-frame group to a raw track with an explicit timestamp. - pub fn track_frame(&mut self, track: Id, timestamp: moq_net::Timestamp, payload: &[u8]) -> Result<(), Error> { + /// Write a single-frame group to a raw track, `None` when untimed. + pub fn track_frame( + &mut self, + track: Id, + timestamp: Option, + payload: &[u8], + ) -> Result<(), Error> { let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; track.write_frame(timestamp, bytes::Bytes::copy_from_slice(payload))?; Ok(()) @@ -1146,9 +1151,13 @@ impl Publish { /// The payload must be at most [`moq_net::MAX_DATAGRAM_PAYLOAD`] bytes. Datagrams are /// delivered only on transports and wire versions with a datagram channel; there is no /// group fallback. - pub fn track_datagram(&mut self, track: Id, timestamp_us: u64, payload: &[u8]) -> Result { + pub fn track_datagram( + &mut self, + track: Id, + timestamp: Option, + payload: &[u8], + ) -> Result { let track = self.tracks.get_mut(track).ok_or(Error::TrackNotFound)?; - let timestamp = moq_net::Timestamp::from_micros(timestamp_us)?; Ok(track.append_datagram(timestamp, bytes::Bytes::copy_from_slice(payload))?) } @@ -1280,10 +1289,15 @@ impl Publish { } /// Publish a new value to a JSON snapshot track. A no-op if unchanged. - pub fn json_snapshot_update(&mut self, json: Id, value: serde_json::Value) -> Result<(), Error> { + pub fn json_snapshot_update( + &mut self, + json: Id, + value: serde_json::Value, + timestamp: Option, + ) -> Result<(), Error> { let json = &mut self.json_snapshot.get_mut(json).ok_or(Error::TrackNotFound)?.data; - json.producer - .update(moq_net::Timed::from(&value).at(json.clock.now()))?; + let at = timestamp.unwrap_or_else(|| json.clock.now()); + json.producer.update(moq_net::Timed::from(&value).at(at))?; Ok(()) } @@ -1309,11 +1323,15 @@ impl Publish { } /// Append one record to a JSON stream track. - pub fn json_stream_append(&mut self, stream: Id, value: serde_json::Value) -> Result<(), Error> { + pub fn json_stream_append( + &mut self, + stream: Id, + value: serde_json::Value, + timestamp: Option, + ) -> Result<(), Error> { let producer = self.json_stream.get_mut(stream).ok_or(Error::TrackNotFound)?; - producer - .producer - .append(moq_net::Timed::from(&value).at(producer.clock.now()))?; + let at = timestamp.unwrap_or_else(|| producer.clock.now()); + producer.producer.append(moq_net::Timed::from(&value).at(at))?; Ok(()) } @@ -1390,8 +1408,13 @@ impl Publish { Ok(()) } - /// Write a frame into a raw group with an explicit timestamp. - pub fn group_frame(&mut self, group: Id, timestamp: moq_net::Timestamp, payload: &[u8]) -> Result<(), Error> { + /// Write a frame into a raw group, `None` when untimed. + pub fn group_frame( + &mut self, + group: Id, + timestamp: Option, + payload: &[u8], + ) -> Result<(), Error> { let group = self.groups.get_mut(group).ok_or(Error::GroupNotFound)?; group.write_frame(timestamp, bytes::Bytes::copy_from_slice(payload))?; Ok(()) @@ -1551,11 +1574,11 @@ mod children_tests { } let json = serde_json::Value::Null; assert!(matches!( - state.publish.json_snapshot_update(json_snapshot, json.clone()), + state.publish.json_snapshot_update(json_snapshot, json.clone(), None), Err(Error::TrackNotFound) )); assert!(matches!( - state.publish.json_stream_append(json_stream, json), + state.publish.json_stream_append(json_stream, json, None), Err(Error::TrackNotFound) )); } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 2a89a0732df..90edcc5228a 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -7,6 +7,19 @@ use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(10); +/// A timestamp of `value` microseconds. +fn micros(value: u64) -> moq_timestamp { + moq_timestamp { + value, + scale: 1_000_000, + } +} + +/// The timestamp a read points at, `None` when untimed. +fn read_timestamp(timestamp: *const moq_timestamp) -> Option { + unsafe { timestamp.as_ref() }.copied() +} + /// A ten-second max delay, the media consumers' usual budget here. const TEN_SECONDS: moq_delay = moq_delay { min_us: 0, @@ -319,14 +332,14 @@ fn video_read(id: u32) -> (i32, moq_video_read) { let mut read = moq_video_read { kind: moq_decode_read_kind::MOQ_DECODE_READ_END, frame: moq_video_frame { - timestamp_us: 0, + timestamp: moq_timestamp::default(), width: 0, height: 0, data: std::ptr::null(), data_size: 0, }, gap: moq_decode_gap::default(), - end_us: 0, + end: moq_timestamp::default(), }; let code = unsafe { moq_decode_video_read(id, &mut read) }; (code, read) @@ -1307,7 +1320,7 @@ fn raw_loc_video_uses_the_declared_catalog_container() { let loc = moq_loc::encode(timestamp_us, payload).unwrap(); let group = id(moq_publish_track_group(track)); assert_eq!( - unsafe { moq_publish_group_frame(group, loc.as_ptr(), loc.len(), timestamp_us) }, + unsafe { moq_publish_group_frame(group, loc.as_ptr(), loc.len(), µs(timestamp_us)) }, 0 ); assert_eq!(moq_publish_group_finish(group), 0); @@ -1316,13 +1329,15 @@ fn raw_loc_video_uses_the_declared_catalog_container() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { encoded_frame(frame_id, &mut frame) }, 0); - assert_eq!(frame.timestamp_us, timestamp_us); - assert!(frame.timestamp_present, "a media frame is always timed"); + assert_eq!( + read_timestamp(frame.timestamp), + Some(micros(timestamp_us)), + "a media frame is always timed" + ); assert!(frame.keyframe); assert_eq!( unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }, @@ -1637,8 +1652,8 @@ fn publish_track_invalid_broadcast() { }; assert!(unsafe { moq_publish_track(0, name.as_ptr() as *const c_char, name.len(), &info) } < 0); assert!(moq_publish_track_group(9999) < 0); - assert!(unsafe { moq_publish_track_frame(9999, name.as_ptr(), name.len(), 0) } < 0); - assert!(unsafe { moq_publish_group_frame(9999, name.as_ptr(), name.len(), 0) } < 0); + assert!(unsafe { moq_publish_track_frame(9999, name.as_ptr(), name.len(), µs(0)) } < 0); + assert!(unsafe { moq_publish_group_frame(9999, name.as_ptr(), name.len(), µs(0)) } < 0); assert!(moq_publish_track_finish(9999) < 0); assert!(moq_publish_group_finish(9999) < 0); @@ -1673,63 +1688,177 @@ fn publish_track_with_info_rejects_invalid_timescale() { assert_eq!(moq_publish_close(broadcast), 0); } -/// A raw frame read from an untimed track reports no timestamp. The C API only publishes -/// timed tracks, so the track and its frame come from moq-net. +/// Raw frames, datagrams, and JSON values read back exactly the timestamp written: a 90 kHz +/// tick that microseconds cannot express, a present zero, and untimed. The C API only creates +/// timed tracks, so the tracks and the JSON producer come from moq-net. #[test] -fn an_untimed_raw_frame_reads_untimed() { +fn timestamps_round_trip_exactly() { let origin = id(moq_origin_create()); - let path = b"raw-untimed"; + let path = b"exact-timestamps"; let broadcast = publish_broadcast(origin, path); + let consume = request_broadcast(origin, path); - let track_name = b"data"; - let track = State::lock() - .publish - .track( - Id::try_from(broadcast).unwrap(), - "data", - Some(moq_net::track::Info::default().with_timescale(None)), - ) - .unwrap(); + let tick = moq_timestamp { + value: 1, + scale: 90_000, + }; + let zero = moq_timestamp { + value: 0, + scale: 90_000, + }; + for (name, written) in [("tick", Some(tick)), ("zero", Some(zero)), ("untimed", None)] { + let info = + moq_net::track::Info::default().with_timescale(written.map(|_| moq_net::Timescale::new(90_000).unwrap())); + let create = |name: &str| -> u32 { + let mut state = State::lock(); + state + .publish + .track(Id::try_from(broadcast).unwrap(), name, Some(info.clone())) + .unwrap() + .into() + }; + let track = create(name); + let json_name = format!("{name}.json"); + let json_track = create(&json_name); - let consume = request_broadcast(origin, path); - let frame_cb = Callback::new(); - let consumer = id(unsafe { - moq_consume_track( - consume, - track_name.as_ptr() as *const c_char, - track_name.len(), - std::ptr::null(), - Some(channel_callback), - frame_cb.ptr, - ) - }); + let frames = Callback::new(); + let frame_consumer = id(unsafe { + moq_consume_track( + consume, + name.as_ptr().cast(), + name.len(), + std::ptr::null(), + Some(channel_callback), + frames.ptr, + ) + }); + let datagrams = Callback::new(); + let datagram_consumer = id(unsafe { + moq_consume_datagrams( + consume, + name.as_ptr().cast(), + name.len(), + Some(channel_callback), + datagrams.ptr, + ) + }); + let values = Callback::new(); + let value_consumer = id(unsafe { + moq_consume_json_stream( + consume, + json_name.as_ptr().cast(), + json_name.len(), + Some(channel_callback), + values.ptr, + ) + }); - State::lock() - .publish - .track_producer(track) - .unwrap() - .write_frame(None, &b"untimed"[..]) + let timestamp = written.as_ref().map_or(std::ptr::null(), std::ptr::from_ref); + let payload = b"payload"; + assert_eq!( + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), timestamp) }, + 0 + ); + let frame_id = id(frames.recv()); + let mut frame = moq_frame { + payload: std::ptr::null(), + payload_size: 0, + timestamp: &tick, + keyframe: false, + }; + assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); + assert_eq!(read_timestamp(frame.timestamp), written, "{name} frame"); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + + // The datagram subscription is live once the frame arrived on the same track. + assert_eq!( + unsafe { + moq_publish_track_datagram(track, payload.as_ptr(), payload.len(), timestamp, std::ptr::null_mut()) + }, + 0 + ); + let datagram_id = id(datagrams.recv()); + let mut datagram = moq_datagram { + payload: std::ptr::null(), + payload_size: 0, + timestamp: &tick, + sequence: 0, + }; + assert_eq!(unsafe { moq_consume_datagram(datagram_id, &mut datagram) }, 0); + assert_eq!(read_timestamp(datagram.timestamp), written, "{name} datagram"); + assert_eq!(moq_consume_datagram_free(datagram_id), 0); + + let producer = State::lock() + .publish + .track_producer(Id::try_from(json_track).unwrap()) + .unwrap() + .clone(); + let mut json = moq_json::stream::Producer::::new(producer, Default::default()); + json.append(moq_net::Timed { + value: &serde_json::json!(1), + at: written.map(|written| written.try_into().unwrap()), + }) .unwrap(); - let frame_id = id(frame_cb.recv()); - let mut frame = moq_frame { - payload: std::ptr::null(), - payload_size: 0, - timestamp_us: 7, - timestamp_present: true, - keyframe: false, - }; - assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); - assert!(!frame.timestamp_present, "an untimed frame reads untimed"); - assert_eq!(frame.timestamp_us, 0); - assert_eq!(moq_consume_track_frame_free(frame_id), 0); + let value_id = id(values.recv()); + let mut value = moq_json_value { + json: std::ptr::null(), + json_len: 0, + timestamp: &tick, + }; + assert_eq!(unsafe { moq_consume_json_value(value_id, &mut value) }, 0); + assert_eq!(read_timestamp(value.timestamp), written, "{name} JSON value"); + assert_eq!(moq_consume_json_value_free(value_id), 0); + + assert_eq!(moq_consume_track_cancel(frame_consumer), 0); + assert_eq!(frames.recv_terminal(), 0); + assert_eq!(moq_consume_datagrams_cancel(datagram_consumer), 0); + assert_eq!(datagrams.recv_terminal(), 0); + assert_eq!(moq_consume_json_cancel(value_consumer), 0); + assert_eq!(values.recv_terminal(), 0); + } - assert_eq!(moq_consume_track_cancel(consumer), 0); - assert_eq!(frame_cb.recv_terminal(), 0, "clean close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } +/// A write refuses a timestamp outside `moq_net::Timestamp`'s range, and an untimed write on a +/// timed track. +#[test] +fn timestamp_writes_refuse_out_of_range_and_mismatch() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"timestamp-refusals"); + let name = b"data"; + let track = + id(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), std::ptr::null()) }); + + for invalid in [ + moq_timestamp { value: 0, scale: 0 }, + moq_timestamp { + value: 0, + scale: 1 << 62, + }, + moq_timestamp { + value: 1 << 61, + scale: 1_000_000, + }, + ] { + assert_eq!( + unsafe { moq_publish_track_frame(track, name.as_ptr(), name.len(), &invalid) }, + MOQ_ERROR_TIMESTAMP_OVERFLOW, + "{invalid:?}" + ); + } + assert_eq!( + unsafe { moq_publish_track_frame(track, name.as_ptr(), name.len(), std::ptr::null()) }, + MOQ_ERROR_MOQ + ); + + assert_eq!(moq_publish_track_finish(track), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn raw_track_publish_consume() { let origin = id(moq_origin_create()); @@ -1775,7 +1904,7 @@ fn raw_track_publish_consume() { let payload = b"hello raw track"; let timestamp_us = 12_345; assert_eq!( - unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), timestamp_us) }, + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), µs(timestamp_us)) }, 0 ); @@ -1783,15 +1912,13 @@ fn raw_track_publish_consume() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: true, // should be overwritten with false }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; assert_eq!(received, payload); - assert_eq!(frame.timestamp_us, timestamp_us); - assert!(frame.timestamp_present); + assert_eq!(read_timestamp(frame.timestamp), Some(micros(timestamp_us))); assert!(!frame.keyframe, "raw frames have no keyframe flag"); assert_eq!(moq_consume_track_frame_free(frame_id), 0); @@ -1800,7 +1927,7 @@ fn raw_track_publish_consume() { let parts: [(&[u8], u64); 2] = [(b"part-0", 20_000), (b"part-1", 30_000)]; for (part, timestamp_us) in parts { assert_eq!( - unsafe { moq_publish_group_frame(group, part.as_ptr(), part.len(), timestamp_us) }, + unsafe { moq_publish_group_frame(group, part.as_ptr(), part.len(), µs(timestamp_us)) }, 0 ); } @@ -1811,14 +1938,13 @@ fn raw_track_publish_consume() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; assert_eq!(received, expected); - assert_eq!(frame.timestamp_us, timestamp_us); + assert_eq!(read_timestamp(frame.timestamp), Some(micros(timestamp_us))); assert_eq!(moq_consume_track_frame_free(frame_id), 0); } @@ -1866,7 +1992,7 @@ fn raw_track_datagram_publish_consume() { let payload = b"hello datagram"; let mut sequence: u64 = u64::MAX; assert_eq!( - unsafe { moq_publish_track_datagram(track, payload.as_ptr(), payload.len(), 120_000, &mut sequence) }, + unsafe { moq_publish_track_datagram(track, payload.as_ptr(), payload.len(), µs(120_000), &mut sequence) }, 0 ); @@ -1874,14 +2000,13 @@ fn raw_track_datagram_publish_consume() { let mut datagram = moq_datagram { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), sequence: 0, }; assert_eq!(unsafe { moq_consume_datagram(dg_id, &mut datagram) }, 0); let received = unsafe { std::slice::from_raw_parts(datagram.payload, datagram.payload_size) }; assert_eq!(received, payload); - assert_eq!(datagram.timestamp_us, 120_000); + assert_eq!(read_timestamp(datagram.timestamp), Some(micros(120_000))); assert_eq!(datagram.sequence, sequence); assert_eq!(moq_consume_datagram_free(dg_id), 0); @@ -1952,7 +2077,7 @@ fn raw_track_subscription_options_and_update() { let payloads: [&[u8]; 3] = [b"zero", b"one", b"two"]; for (i, payload) in payloads.into_iter().enumerate() { assert_eq!( - unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), i as u64 * 20_000) }, + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), µs(i as u64 * 20_000)) }, 0 ); } @@ -1964,12 +2089,12 @@ fn raw_track_subscription_options_and_update() { start: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 1, - timestamp_us: 0, + timestamp: moq_timestamp::default(), }, end: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 2, - timestamp_us: 0, + timestamp: moq_timestamp::default(), }, end_present: true, }]; @@ -1996,14 +2121,13 @@ fn raw_track_subscription_options_and_update() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; assert_eq!(received, b"one"); - assert_eq!(frame.timestamp_us, 20_000); + assert_eq!(read_timestamp(frame.timestamp), Some(micros(20_000))); assert_eq!(moq_consume_track_frame_free(frame_id), 0); let widened = [moq_range { @@ -2023,14 +2147,13 @@ fn raw_track_subscription_options_and_update() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; assert_eq!(received, b"two"); - assert_eq!(frame.timestamp_us, 40_000); + assert_eq!(read_timestamp(frame.timestamp), Some(micros(40_000))); assert_eq!(moq_consume_track_frame_free(frame_id), 0); assert_eq!(moq_consume_track_cancel(consumer), 0); @@ -2065,7 +2188,7 @@ fn raw_track_time_anchor() { let payloads: [&[u8]; 3] = [b"zero", b"one", b"two"]; for (i, payload) in payloads.into_iter().enumerate() { assert_eq!( - unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), i as u64 * 20_000) }, + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), µs(i as u64 * 20_000)) }, 0 ); } @@ -2077,12 +2200,12 @@ fn raw_track_time_anchor() { start: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_TIME as u32, group: 0, - timestamp_us: 30_000, + timestamp: micros(30_000), }, end: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 0, - timestamp_us: 0, + timestamp: moq_timestamp::default(), }, end_present: false, }]; @@ -2110,8 +2233,7 @@ fn raw_track_time_anchor() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -2176,12 +2298,12 @@ fn raw_track_update_refuses_time_on_untimed() { start: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_TIME as u32, group: 0, - timestamp_us: 0, + timestamp: moq_timestamp::default(), }, end: moq_anchor { kind: moq_anchor_kind::MOQ_ANCHOR_ABSOLUTE as u32, group: 0, - timestamp_us: 0, + timestamp: moq_timestamp::default(), }, end_present: false, }]; @@ -2201,8 +2323,7 @@ fn raw_track_update_refuses_time_on_untimed() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -2234,14 +2355,14 @@ fn raw_track_relative_update_counts_from_the_track_edge() { }); let publish = |payload: &[u8]| { assert_eq!( - unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), 0) }, + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), µs(0)) }, 0 ); }; let anchor = |kind: moq_anchor_kind, group: u64| moq_anchor { kind: kind as u32, group, - timestamp_us: 0, + timestamp: moq_timestamp::default(), }; let subscribe = |ranges: &[moq_range]| moq_subscription { priority: 0, @@ -2256,8 +2377,7 @@ fn raw_track_relative_update_counts_from_the_track_edge() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -2338,24 +2458,30 @@ fn json_snapshot_publish_consume() { ) }); - let mut last_us = 0; + let mut last = 0; for expected in [r#"{"a":1}"#, r#"{"a":2}"#] { assert_eq!( - unsafe { moq_publish_json_snapshot_update(producer, expected.as_ptr() as *const c_char, expected.len()) }, + unsafe { + moq_publish_json_snapshot_update( + producer, + expected.as_ptr() as *const c_char, + expected.len(), + std::ptr::null(), + ) + }, 0 ); let value_id = id(value_cb.recv()); let mut value = moq_json_value { json: std::ptr::null(), json_len: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), }; assert_eq!(unsafe { moq_consume_json_value(value_id, &mut value) }, 0); // The producer stamps each value with the broadcast clock. - assert!(value.timestamp_present); - assert!(value.timestamp_us >= last_us); - last_us = value.timestamp_us; + let at = read_timestamp(value.timestamp).expect("a stamped value is timed"); + assert!(at.value >= last); + last = at.value; let received = unsafe { std::slice::from_raw_parts(value.json.cast::(), value.json_len) }; assert_eq!( serde_json::from_slice::(received).unwrap(), @@ -2400,24 +2526,30 @@ fn json_stream_publish_consume() { ) }); - let mut last_us = 0; + let mut last = 0; for expected in [r#"{"n":0}"#, r#"{"n":1}"#, r#"{"n":2}"#] { assert_eq!( - unsafe { moq_publish_json_stream_append(producer, expected.as_ptr() as *const c_char, expected.len()) }, + unsafe { + moq_publish_json_stream_append( + producer, + expected.as_ptr() as *const c_char, + expected.len(), + std::ptr::null(), + ) + }, 0 ); let value_id = id(value_cb.recv()); let mut value = moq_json_value { json: std::ptr::null(), json_len: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), }; assert_eq!(unsafe { moq_consume_json_value(value_id, &mut value) }, 0); // The producer stamps each value with the broadcast clock. - assert!(value.timestamp_present); - assert!(value.timestamp_us >= last_us); - last_us = value.timestamp_us; + let at = read_timestamp(value.timestamp).expect("a stamped value is timed"); + assert!(at.value >= last); + last = at.value; let received = unsafe { std::slice::from_raw_parts(value.json.cast::(), value.json_len) }; assert_eq!( serde_json::from_slice::(received).unwrap(), @@ -2426,6 +2558,27 @@ fn json_stream_publish_consume() { assert_eq!(moq_consume_json_value_free(value_id), 0); } + // A caller's timestamp replaces the clock's, floored to the track's millisecond scale. + let expected = r#"{"n":3}"#; + let at = moq_timestamp { + value: (last + 1_000) * 90 + 45, + scale: 90_000, + }; + assert_eq!( + unsafe { moq_publish_json_stream_append(producer, expected.as_ptr() as *const c_char, expected.len(), &at) }, + 0 + ); + let value_id = id(value_cb.recv()); + let mut value = moq_json_value { + json: std::ptr::null(), + json_len: 0, + timestamp: std::ptr::null(), + }; + assert_eq!(unsafe { moq_consume_json_value(value_id, &mut value) }, 0); + let read = read_timestamp(value.timestamp).expect("a stamped value is timed"); + assert_eq!((read.value, read.scale), (last + 1_000, 1_000)); + assert_eq!(moq_consume_json_value_free(value_id), 0); + assert_eq!(moq_consume_json_cancel(consumer), 0); assert_eq!(value_cb.recv_terminal(), 0, "clean close delivers terminal 0"); assert!(moq_consume_json_cancel(consumer) < 0, "double-close should fail"); @@ -2442,7 +2595,7 @@ type WindowEvent = ( moq_json_window_kind, std::ops::Range, Option, - Option, + Option, ); /// Read and free the next event delivered to a JSON window consumer. @@ -2454,15 +2607,14 @@ fn recv_window(cb: &Callback) -> WindowEvent { end: 0, json: std::ptr::null(), json_len: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), }; assert_eq!(unsafe { moq_consume_json_window_event(event_id, &mut event) }, 0); let json = (!event.json.is_null()).then(|| { let json = unsafe { std::slice::from_raw_parts(event.json.cast::(), event.json_len) }; serde_json::from_slice(json).unwrap() }); - let timestamp = event.timestamp_present.then_some(event.timestamp_us); + let timestamp = read_timestamp(event.timestamp).map(|timestamp| timestamp.try_into().unwrap()); assert_eq!(moq_consume_json_window_event_free(event_id), 0); assert!( moq_consume_json_window_event_free(event_id) < 0, @@ -2519,7 +2671,7 @@ fn json_window_publish_consume() { } assert_eq!(moq_publish_json_window_pop(producer, 2), 0); - let mut last_us = 0; + let mut last = moq_net::Timestamp::ZERO; for n in 0..3 { let (kind, range, json, timestamp) = recv_window(&event_cb); assert_eq!( @@ -2527,12 +2679,12 @@ fn json_window_publish_consume() { (MOQ_JSON_WINDOW_KIND_PUSH, n..n + 1, Some(serde_json::json!({ "n": n }))) ); let timestamp = timestamp.expect("the producer stamps each edit with the broadcast clock"); - assert!(timestamp >= last_us); - last_us = timestamp; + assert!(timestamp >= last); + last = timestamp; } let (kind, range, json, timestamp) = recv_window(&event_cb); assert_eq!((kind, range, json), (MOQ_JSON_WINDOW_KIND_POP, 0..2, None)); - assert!(timestamp.expect("the pop is stamped too") >= last_us); + assert!(timestamp.expect("the pop is stamped too") >= last); // Closing the broadcast ends the open window and releases its handle. assert_eq!(moq_publish_close(broadcast), 0); @@ -2584,8 +2736,7 @@ fn json_window_refreshes_a_bounded_checkpoint() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -2704,7 +2855,7 @@ fn publish_finish_serves_finished_tracks_until_closed() { let track = id(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), &info) }); let payload = b"tail"; assert_eq!( - unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), 1_000) }, + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), µs(1_000)) }, 0 ); assert_eq!(moq_publish_track_finish(track), 0); @@ -2717,7 +2868,7 @@ fn publish_finish_serves_finished_tracks_until_closed() { )); let frame = b"opus frame"; assert_eq!( - unsafe { moq_publish_media_frame(media, frame.as_ptr(), frame.len(), 1_000) }, + unsafe { moq_publish_media_frame(media, frame.as_ptr(), frame.len(), micros(1_000)) }, 0 ); assert_eq!(moq_publish_media_finish(media), 0); @@ -2748,8 +2899,7 @@ fn publish_finish_serves_finished_tracks_until_closed() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -2839,7 +2989,7 @@ fn double_close_all_resource_types() { let payload = b"test"; assert_eq!( - unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), 1_000_000) }, + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(1_000_000)) }, 0 ); let frame_id = id(frame_cb.recv()); @@ -2878,7 +3028,7 @@ fn media_cut_bounds_audio_groups() { let payload = b"test"; for i in 0..3u64 { assert_eq!( - unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), i * 20_000) }, + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(i * 20_000)) }, 0 ); assert_eq!(moq_publish_media_cut(media), 0, "each packet is its own group"); @@ -2886,7 +3036,7 @@ fn media_cut_bounds_audio_groups() { // The same boundary, with the next group explicitly numbered. assert_eq!( - unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), 60_000) }, + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(60_000)) }, 0 ); assert_eq!(moq_publish_media_seek(media, 42), 0); @@ -3452,7 +3602,7 @@ fn track_demand_reports_the_wanted_group() { let group = id(moq_publish_track_group(track)); for timestamp_us in [0, 1_000] { assert_eq!( - unsafe { moq_publish_group_frame(group, name.as_ptr(), name.len(), timestamp_us) }, + unsafe { moq_publish_group_frame(group, name.as_ptr(), name.len(), µs(timestamp_us)) }, 0 ); } @@ -3611,15 +3761,14 @@ fn dynamic_serves_track_requests() { let payload = b"hello dynamic track"; assert_eq!( - unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), 42) }, + unsafe { moq_publish_track_frame(track, payload.as_ptr(), payload.len(), µs(42)) }, 0 ); let frame_id = id(frame_cb.recv()); let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); @@ -3627,7 +3776,7 @@ fn dynamic_serves_track_requests() { unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }, payload ); - assert_eq!(frame.timestamp_us, 42); + assert_eq!(read_timestamp(frame.timestamp), Some(micros(42))); assert_eq!(moq_consume_track_frame_free(frame_id), 0); // A second request is aborted, and its subscriber sees the application code. @@ -3707,7 +3856,7 @@ fn dynamic_track_request_publishes_media() { let payload = b"opus frame"; assert_eq!( - unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), 1000) }, + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(1000)) }, 0 ); let frame_id = id(frame_cb.recv()); @@ -3806,7 +3955,7 @@ fn track_dynamic_serves_a_fetch_miss() { assert!(moq_group_request_free(request) < 0, "accept consumes the request"); let payload = b"fetched"; assert_eq!( - unsafe { moq_publish_group_frame(group, payload.as_ptr(), payload.len(), 100_000) }, + unsafe { moq_publish_group_frame(group, payload.as_ptr(), payload.len(), µs(100_000)) }, 0 ); assert_eq!(moq_publish_group_finish(group), 0); @@ -3936,7 +4085,7 @@ fn a_rejected_group_request_binds_only_its_readers() { let group = id(moq_group_request_accept(request)); let payload = b"fetched"; assert_eq!( - unsafe { moq_publish_group_frame(group, payload.as_ptr(), payload.len(), 6_000) }, + unsafe { moq_publish_group_frame(group, payload.as_ptr(), payload.len(), µs(6_000)) }, 0 ); assert_eq!(moq_publish_group_finish(group), 0); @@ -3974,7 +4123,7 @@ fn track_request_dynamic_survives_accept() { let group = id(moq_group_request_accept(group_request)); let payload = b"archive"; assert_eq!( - unsafe { moq_publish_group_frame(group, payload.as_ptr(), payload.len(), 180_000) }, + unsafe { moq_publish_group_frame(group, payload.as_ptr(), payload.len(), µs(180_000)) }, 0 ); assert_eq!(moq_publish_group_finish(group), 0); @@ -4055,7 +4204,7 @@ fn local_publish_consume() { let payload = b"opus audio payload data"; let timestamp_us: u64 = 1_000_000; assert_eq!( - unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), timestamp_us) }, + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(timestamp_us)) }, 0 ); @@ -4064,13 +4213,12 @@ fn local_publish_consume() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { encoded_frame(frame_id, &mut frame) }, 0); assert_eq!(frame.payload_size, payload.len()); - assert_eq!(frame.timestamp_us, timestamp_us); + assert_eq!(read_timestamp(frame.timestamp), Some(micros(timestamp_us))); let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; assert_eq!(received, payload, "frame payload should match"); @@ -4221,7 +4369,7 @@ fn consume_audio_follows_a_sibling_broadcast_reference() { let payload = b"opus audio payload data"; let timestamp_us: u64 = 1_000_000; assert_eq!( - unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), timestamp_us) }, + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(timestamp_us)) }, 0 ); @@ -4229,14 +4377,13 @@ fn consume_audio_follows_a_sibling_broadcast_reference() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { encoded_frame(frame_id, &mut frame) }, 0); let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; assert_eq!(received, payload, "the sibling broadcast's frame should arrive"); - assert_eq!(frame.timestamp_us, timestamp_us); + assert_eq!(read_timestamp(frame.timestamp), Some(micros(timestamp_us))); assert_eq!(moq_consume_read_free(frame_id), 0); assert_eq!(moq_consume_audio_cancel(track), 0); @@ -4680,7 +4827,7 @@ fn video_publish_consume() { let keyframe = [0x00, 0x00, 0x00, 0x01, 0x65, 0xAA, 0xBB, 0xCC]; assert_eq!( - unsafe { moq_publish_media_frame(media, keyframe.as_ptr(), keyframe.len(), 0) }, + unsafe { moq_publish_media_frame(media, keyframe.as_ptr(), keyframe.len(), micros(0)) }, 0 ); @@ -4688,12 +4835,11 @@ fn video_publish_consume() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { encoded_frame(frame_id, &mut frame) }, 0); - assert_eq!(frame.timestamp_us, 0); + assert_eq!(read_timestamp(frame.timestamp), Some(micros(0))); assert!(frame.payload_size > 0, "frame should have payload data"); assert_eq!(moq_consume_read_free(frame_id), 0); @@ -4746,7 +4892,7 @@ fn audio_raw_publish() { let samples = vec![0.0f32; 960 * 2]; let pcm = unsafe { std::slice::from_raw_parts(samples.as_ptr().cast::(), std::mem::size_of_val(&samples[..])) }; let frame = moq_audio_frame { - timestamp_us: 0, + timestamp: micros(0), data: pcm.as_ptr(), data_size: pcm.len(), }; @@ -4804,7 +4950,7 @@ fn audio_raw_publish_frame_durations() { let samples = vec![0.0f32; 120 * 2]; let pcm = unsafe { std::slice::from_raw_parts(samples.as_ptr().cast::(), std::mem::size_of_val(&samples[..])) }; let frame = moq_audio_frame { - timestamp_us: 0, + timestamp: micros(0), data: pcm.as_ptr(), data_size: pcm.len(), }; @@ -4854,7 +5000,7 @@ fn audio_raw_decode_reads_the_encoder_end() { .flat_map(|i| (i as f32 / SAMPLES as f32).to_le_bytes()) .collect(); let frame = moq_audio_frame { - timestamp_us: START_US, + timestamp: micros(START_US), data: pcm.as_ptr(), data_size: pcm.len(), }; @@ -4889,12 +5035,12 @@ fn audio_raw_decode_reads_the_encoder_end() { let mut read = moq_audio_read { kind: moq_decode_read_kind::MOQ_DECODE_READ_GAP, frame: moq_audio_frame { - timestamp_us: 0, + timestamp: moq_timestamp::default(), data: std::ptr::null(), data_size: 0, }, gap: moq_decode_gap::default(), - end_us: 0, + end: moq_timestamp::default(), }; assert_eq!(unsafe { moq_decode_audio_read(id(code), &mut read) }, 0); match read.kind { @@ -4904,14 +5050,14 @@ fn audio_raw_decode_reads_the_encoder_end() { } moq_decode_read_kind::MOQ_DECODE_READ_END => { assert!(read.frame.data.is_null(), "an end carries no samples"); - ends.push(read.end_us); + ends.push(read.end); } moq_decode_read_kind::MOQ_DECODE_READ_GAP => panic!("nothing was lost"), } assert_eq!(moq_decode_audio_read_free(id(code)), 0); } assert!(frames > 0, "a finished track decodes from its start"); - assert_eq!(ends, [START_US + SAMPLES as u64 * 1_000_000 / 48_000]); + assert_eq!(ends, [micros(START_US + SAMPLES as u64 * 1_000_000 / 48_000)]); assert_eq!(moq_consume_catalog_free(catalog_id), 0); assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); @@ -4954,7 +5100,7 @@ fn video_raw_publish_consume() { let rgba = gray_rgba(320, 240); let publish = |index: u64| { let frame = moq_video_encoder_frame { - timestamp_us: index * 1_001_000 / 30, + timestamp: micros(index * 1_001_000 / 30), data: rgba.as_ptr(), data_size: rgba.len(), }; @@ -5135,7 +5281,7 @@ fn decode_first_frame(output: &moq_video_decoder_output) -> (u32, u32, usize) { let rgba = gray_rgba(320, 240); let publish = |index: u64| { let frame = moq_video_encoder_frame { - timestamp_us: index * 33_333, + timestamp: micros(index * 33_333), data: rgba.as_ptr(), data_size: rgba.len(), }; @@ -5260,7 +5406,7 @@ fn video_raw_publish_from_many_threads() { assert_eq!(moq_encode_video_cut(producer), 0); } let frame = moq_video_encoder_frame { - timestamp_us: i * 33_333, + timestamp: micros(i * 33_333), data: rgba.as_ptr(), data_size: rgba.len(), }; @@ -5283,7 +5429,7 @@ fn video_raw_publish_from_many_threads() { /// Publish one mid-gray frame to a raw video producer, returning the status code. fn publish_gray(producer: u32, rgba: &[u8]) -> i32 { let frame = moq_video_encoder_frame { - timestamp_us: 0, + timestamp: micros(0), data: rgba.as_ptr(), data_size: rgba.len(), }; @@ -5460,7 +5606,7 @@ fn video_raw_publish_rejects_frame_size_mismatch() { // of its own, so this is caught as a wrong-sized picture. let rgba = gray_rgba(640, 480); let frame = moq_video_encoder_frame { - timestamp_us: 0, + timestamp: micros(0), data: rgba.as_ptr(), data_size: rgba.len(), }; @@ -5630,7 +5776,14 @@ fn video_raw_decode() { for (i, frame) in frames.iter().enumerate() { assert_eq!( - unsafe { moq_publish_media_frame(media, frame.payload.as_ptr(), frame.payload.len(), (i as u64) * 33_000) }, + unsafe { + moq_publish_media_frame( + media, + frame.payload.as_ptr(), + frame.payload.len(), + micros((i as u64) * 33_000), + ) + }, 0 ); } @@ -5697,7 +5850,7 @@ fn multiple_frames_ordering() { for (i, &ts) in timestamps.iter().enumerate() { let payload = format!("frame-{i}"); assert_eq!( - unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), ts) }, + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(ts)) }, 0 ); } @@ -5707,12 +5860,15 @@ fn multiple_frames_ordering() { let mut frame = moq_frame { payload: std::ptr::null(), payload_size: 0, - timestamp_us: 0, - timestamp_present: false, + timestamp: std::ptr::null(), keyframe: false, }; assert_eq!(unsafe { encoded_frame(frame_id, &mut frame) }, 0); - assert_eq!(frame.timestamp_us, expected_ts, "frame {i} has wrong timestamp"); + assert_eq!( + read_timestamp(frame.timestamp), + Some(micros(expected_ts)), + "frame {i} has wrong timestamp" + ); let received = unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }; let expected = format!("frame-{i}"); @@ -6780,13 +6936,13 @@ fn server_accepts_a_session() { let (consumer, frames) = subscribe_named_track(consumed, b"tail"); let first = b"ready"; assert_eq!( - unsafe { moq_publish_track_frame(track, first.as_ptr(), first.len(), 0) }, + unsafe { moq_publish_track_frame(track, first.as_ptr(), first.len(), µs(0)) }, 0 ); assert_eq!(moq_consume_track_frame_free(id(frames.recv())), 0); let tail = vec![42; 64 * 1024]; assert_eq!( - unsafe { moq_publish_track_frame(track, tail.as_ptr(), tail.len(), 1) }, + unsafe { moq_publish_track_frame(track, tail.as_ptr(), tail.len(), µs(1)) }, 0 ); assert_eq!(moq_publish_track_finish(track), 0); @@ -7049,7 +7205,7 @@ fn write_opus(media: u32, start_us: u64, count: u64, size: usize) { for i in 0..count { let ts = start_us + i * 20_000; assert_eq!( - unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), ts) }, + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(ts)) }, 0 ); assert_eq!(moq_publish_media_cut(media), 0); @@ -7379,7 +7535,7 @@ fn group_duration_hint_lists_hls_up_front() { let payload = [0u8; 100]; for i in 0..20u64 { assert_eq!( - unsafe { moq_publish_media_frame(hinted, payload.as_ptr(), payload.len(), i * 20_000) }, + unsafe { moq_publish_media_frame(hinted, payload.as_ptr(), payload.len(), micros(i * 20_000)) }, 0 ); if i % 5 == 4 { diff --git a/rs/moq-c/src/video.rs b/rs/moq-c/src/video.rs index 0076486281a..99ba0c12a73 100644 --- a/rs/moq-c/src/video.rs +++ b/rs/moq-c/src/video.rs @@ -20,7 +20,9 @@ use tokio::sync::oneshot; use crate::decode::Read; use crate::ffi::OnStatus; use crate::task::Task; -use crate::{Error, Id, NonZeroSlab, Shared, State, ffi, moq_decode_gap, moq_decode_read_kind, moq_decode_start}; +use crate::{ + Error, Id, NonZeroSlab, Shared, State, ffi, moq_decode_gap, moq_decode_read_kind, moq_decode_start, moq_timestamp, +}; // ---- C-visible types ---- @@ -146,8 +148,8 @@ pub struct moq_video_encode_options { /// dimensions, since there they are what the stream turned out to be. #[repr(C)] pub struct moq_video_encoder_frame { - /// Presentation timestamp, in microseconds. - pub timestamp_us: u64, + /// The presentation timestamp. + pub timestamp: moq_timestamp, /// The raw frame bytes in the configured layout. pub data: *const u8, /// The length in bytes of `data`. @@ -201,8 +203,8 @@ pub struct moq_video_decoder_output { /// dimensions because the encoder already fixed them. #[repr(C)] pub struct moq_video_frame { - /// The presentation timestamp in microseconds. - pub timestamp_us: u64, + /// The presentation timestamp. + pub timestamp: moq_timestamp, /// The decoded width in pixels. pub width: u32, /// The decoded height in pixels. @@ -224,10 +226,10 @@ pub struct moq_video_read { /// The lost span, for [moq_decode_read_kind::MOQ_DECODE_READ_GAP]. Every picture from /// before the loss precedes it, and what follows does not continue what came before. pub gap: moq_decode_gap, - /// Where the stopped encoder's media stops in microseconds, for + /// Where the stopped encoder's media stops, for /// [moq_decode_read_kind::MOQ_DECODE_READ_END]. The stopped encoder's last pictures /// precede it; what follows comes from a fresh encoder. - pub end_us: u64, + pub end: moq_timestamp, } // ---- State extension (used internally by lib.rs) ---- @@ -540,7 +542,7 @@ fn follow_grant(shared: &Shared) -> Result<(), Error> { impl VideoEncoder { /// Encode and publish one picture, returning whether it moved the rendition to a new track. - fn publish_frame(&mut self, timestamp_us: u64, data: &[u8]) -> Result { + fn publish_frame(&mut self, timestamp: moq_net::Timestamp, data: &[u8]) -> Result { if let Some(followed) = &self.followed { followed.check()?; } @@ -558,7 +560,7 @@ impl VideoEncoder { let Some(encoder) = self.encoder.as_mut() else { return Ok(false); }; - let frame = moq_video::Frame::new(surface, moq_net::Timestamp::from_micros(timestamp_us)?); + let frame = moq_video::Frame::new(surface, timestamp); // A backend that pipelines hands back an earlier frame's output, so this is // zero or more access units rather than one per call. let encoded = block_on(encoder.encode(frame))?; @@ -835,7 +837,7 @@ impl Video { pixels: OnceLock::new(), })), Some(moq_video::decode::Read::Gap { timestamp, duration }) => Read::gap(timestamp, duration)?, - Some(moq_video::decode::Read::End { end }) => Read::end(end)?, + Some(moq_video::decode::Read::End { end }) => Read::end(end), None => return Ok(()), }, }; @@ -1069,7 +1071,7 @@ pub unsafe extern "C" fn moq_encode_video_frame(producer: u32, frame: *const moq .lock() .as_mut() .ok_or(Error::MediaNotFound)? - .publish_frame(frame.timestamp_us, data)?; + .publish_frame(frame.timestamp.try_into()?, data)?; // The follower takes the encoder's lock, so it moves with the track after the encode. match moved { true => crate::video::follow_grant(&producer), @@ -1257,13 +1259,12 @@ pub unsafe extern "C" fn moq_decode_video_read(id: u32, dst: *mut moq_video_read let id = ffi::parse_id(id)?; let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?; let read = State::lock().video.read(id)?; - let (kind, gap, end_us) = read.info(); + let (kind, gap, end) = read.info(); let frame = match &read { Read::Frame(frame) => { let pixels = frame.pixels()?; moq_video_frame { - // The decoded Timestamp is bounded by a QUIC varint, so its microseconds fit. - timestamp_us: frame.frame.timestamp.as_micros() as u64, + timestamp: frame.frame.timestamp.into(), width: pixels.width, height: pixels.height, // The slab keeps its `Arc` until the id is freed, and the pixels are @@ -1273,19 +1274,14 @@ pub unsafe extern "C" fn moq_decode_video_read(id: u32, dst: *mut moq_video_read } } _ => moq_video_frame { - timestamp_us: 0, + timestamp: moq_timestamp::default(), width: 0, height: 0, data: std::ptr::null(), data_size: 0, }, }; - *dst = moq_video_read { - kind, - frame, - gap, - end_us, - }; + *dst = moq_video_read { kind, frame, gap, end }; Ok(()) }) } @@ -1357,7 +1353,14 @@ mod tests { follow_grant(&shared).unwrap(); let before = shared.lock().as_ref().unwrap().reservation.clone().unwrap(); let small = vec![0x80; 320 * 240 * 4]; - assert!(!shared.lock().as_mut().unwrap().publish_frame(0, &small).unwrap()); + assert!( + !shared + .lock() + .as_mut() + .unwrap() + .publish_frame(moq_net::Timestamp::ZERO, &small) + .unwrap() + ); // Disabled, then resumed onto the larger picture. { @@ -1370,7 +1373,12 @@ mod tests { .unwrap(); } let large = vec![0x80; 640 * 480 * 4]; - let moved = shared.lock().as_mut().unwrap().publish_frame(66_666, &large).unwrap(); + let moved = shared + .lock() + .as_mut() + .unwrap() + .publish_frame(moq_net::Timestamp::from_micros(66_666).unwrap(), &large) + .unwrap(); assert!(moved, "the larger picture is a new codec string"); follow_grant(&shared).unwrap(); let guard = shared.lock(); diff --git a/rs/moq-ffi/src/audio.rs b/rs/moq-ffi/src/audio.rs index f0cd65ff311..bf1913b453f 100644 --- a/rs/moq-ffi/src/audio.rs +++ b/rs/moq-ffi/src/audio.rs @@ -14,6 +14,7 @@ use crate::consumer::MoqBroadcastConsumer; use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::Task; +use crate::media::MoqTimestamp; /// Raw PCM sample format, mirroring WebCodecs `AudioData.format`. /// @@ -193,8 +194,8 @@ pub struct MoqAudioDecoderOutput { /// PCM in the configured `output_format`. #[derive(uniffi::Record)] pub struct MoqAudioFrame { - /// Presentation timestamp of the first sample, in microseconds. - pub timestamp_us: u64, + /// Presentation timestamp of the first sample. + pub timestamp: MoqTimestamp, /// The samples, in the configured PCM layout. pub data: Vec, } @@ -215,36 +216,31 @@ pub enum MoqAudioRead { /// or a packet's timestamp did not continue the previous one. What follows does not /// continue what came before. Gap { - /// The furthest presentation point already read, in microseconds. - timestamp_us: u64, + /// The furthest presentation point already read. + timestamp: MoqTimestamp, /// An upper bound on how long until the next frame resumes. duration: Duration, }, /// The publisher stopped its encoder: it paused, switched source, or finished. /// - /// Frames that follow and start before `end_us` are the stopped encoder's remaining + /// Frames that follow and start before `end` are the stopped encoder's remaining /// media; the first frame at or past it comes from a fresh encoder. End { - /// Where the stopped encoder's media stops, in microseconds. - end_us: u64, + /// Where the stopped encoder's media stops. + end: MoqTimestamp, }, } -impl TryFrom for MoqAudioRead { - type Error = MoqError; - - fn try_from(read: moq_audio::decode::Read) -> Result { - use crate::consumer::timestamp_us; - Ok(match read { +impl From for MoqAudioRead { + fn from(read: moq_audio::decode::Read) -> Self { + match read { moq_audio::decode::Read::Frame(frame) => Self::Frame { frame: frame.into() }, moq_audio::decode::Read::Gap { timestamp, duration } => Self::Gap { - timestamp_us: timestamp_us(timestamp)?, + timestamp: timestamp.into(), duration: crate::ffi::duration(duration.into()), }, - moq_audio::decode::Read::End { end } => Self::End { - end_us: timestamp_us(end)?, - }, - }) + moq_audio::decode::Read::End { end } => Self::End { end: end.into() }, + } } } @@ -362,11 +358,7 @@ impl MoqAudioEncoder { impl From for MoqAudioFrame { fn from(f: moq_audio::Frame) -> Self { Self { - // The binding surface carries plain microseconds, so flatten the - // scaled `Timestamp` here. Saturating rather than erroring: this is a - // frame we already decoded, and a u64 overflow needs a timestamp - // ~580,000 years out. - timestamp_us: u64::try_from(f.timestamp.as_micros()).unwrap_or(u64::MAX), + timestamp: f.timestamp.into(), data: f.data.to_vec(), } } @@ -376,10 +368,7 @@ impl TryFrom for moq_audio::Frame { type Error = moq_audio::Error; fn try_from(f: MoqAudioFrame) -> Result { - Ok(Self::new( - f.data.into(), - moq_net::Timestamp::from_micros(f.timestamp_us)?, - )) + Ok(Self::new(f.data.into(), f.timestamp.try_into()?)) } } @@ -570,7 +559,7 @@ impl ConsumerInner { async fn next(&mut self) -> Result, MoqError> { let read = self.consumer.read().await; self.delay.store(nanos(self.consumer.delay()), Ordering::Relaxed); - read?.map(MoqAudioRead::try_from).transpose() + Ok(read?.map(MoqAudioRead::from)) } } @@ -794,7 +783,7 @@ mod tests { // A partial codec frame makes finish publish the final packet and lookahead. audio .write(MoqAudioFrame { - timestamp_us: 0, + timestamp: MoqTimestamp { value: 0, scale: 1 }, data: vec![0.1_f32; 860].into_iter().flat_map(f32::to_ne_bytes).collect(), }) .unwrap(); diff --git a/rs/moq-ffi/src/bench.rs b/rs/moq-ffi/src/bench.rs index b2751380a2e..3c05c5af5bb 100644 --- a/rs/moq-ffi/src/bench.rs +++ b/rs/moq-ffi/src/bench.rs @@ -5,7 +5,7 @@ //! runs the same rounds on moq-net, below moq-ffi's handles and runtime. use crate::consumer::MoqTrackConsumer; -use crate::media::MoqFrame; +use crate::media::{MoqFrame, MoqTimestamp}; use crate::origin::{MoqOriginConfig, MoqOriginProducer}; use crate::producer::{MoqBroadcastProducer, MoqTrackInfo, MoqTrackProducer}; @@ -165,7 +165,10 @@ async fn ffi_round( let mut read = Vec::with_capacity(frames); for _ in 0..frames { let frame = group.read_frame().await.unwrap().expect("group ended"); - read.push((frame.payload, frame.timestamp_us)); + let timestamp = frame + .timestamp + .map(|t| moq_net::Timestamp::try_from(t).unwrap().as_micros() as u64); + read.push((frame.payload, timestamp)); } read }) @@ -176,7 +179,10 @@ async fn ffi_round( for f in 0..case.frames() { let frame = MoqFrame { payload: payload.to_vec(), - timestamp_us: Some(n * case.frames() as u64 + f as u64), + timestamp: Some(MoqTimestamp { + value: n * case.frames() as u64 + f as u64, + scale: 1_000_000, + }), }; group.write_frame(frame).unwrap(); } diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index eb0077e77bd..543db83ea1f 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -10,16 +10,11 @@ use crate::ffi::{Background, Guard, Task}; use crate::media::*; use crate::producer::MoqTrackInfo; -pub(crate) fn timestamp_us(timestamp: moq_net::Timestamp) -> Result { - timestamp.as_micros().try_into().map_err(|_| MoqError::TimeOverflow) -} - -fn raw_frame(frame: moq_net::frame::Frame) -> Result { - let timestamp_us = frame.timestamp.map(timestamp_us).transpose()?; - Ok(MoqFrame { +fn raw_frame(frame: moq_net::frame::Frame) -> MoqFrame { + MoqFrame { payload: frame.payload.to_vec(), - timestamp_us, - }) + timestamp: frame.timestamp.map(Into::into), + } } /// Subscriber-side delivery preferences, mirroring [`moq_net::track::Subscription`]. @@ -96,8 +91,8 @@ pub enum MoqAnchor { }, /// The group containing this presentation time. An untimed track refuses it. Time { - /// The presentation time in microseconds. - timestamp_us: u64, + /// The presentation time. + timestamp: MoqTimestamp, }, } @@ -118,9 +113,7 @@ impl TryFrom for moq_net::track::Anchor { Ok(match anchor { MoqAnchor::Absolute { group } => Self::Absolute(group), MoqAnchor::Relative { groups } => Self::Relative(groups), - MoqAnchor::Time { timestamp_us } => { - Self::Time(moq_net::Timestamp::from_micros(timestamp_us).map_err(|_| MoqError::TimeOverflow)?) - } + MoqAnchor::Time { timestamp } => Self::Time(timestamp.try_into()?), }) } } @@ -521,7 +514,7 @@ impl TrackInner { }; Poll::Ready(Ok(Some(MoqDatagram { sequence: datagram.sequence, - timestamp_us: datagram.timestamp.map(timestamp_us).transpose()?, + timestamp: datagram.timestamp.map(Into::into), payload: datagram.payload.to_vec(), }))) } @@ -567,7 +560,7 @@ impl TrackShared { return Ok(None); } if let Some(frame) = kio::wait(|waiter| self.poll(|inner| inner.poll_pending_frame(waiter))).await? { - return Ok(Some(raw_frame(frame)?)); + return Ok(Some(raw_frame(frame))); } } } @@ -769,7 +762,7 @@ struct GroupInner { impl GroupInner { async fn read_frame(&mut self) -> Result, MoqError> { - self.group.read_frame().await?.map(raw_frame).transpose() + Ok(self.group.read_frame().await?.map(raw_frame)) } } diff --git a/rs/moq-ffi/src/error.rs b/rs/moq-ffi/src/error.rs index 8299a70020d..255b7ad0797 100644 --- a/rs/moq-ffi/src/error.rs +++ b/rs/moq-ffi/src/error.rs @@ -252,7 +252,7 @@ pub enum MoqError { reason: String, }, - /// A timestamp is outside the supported range. + /// A timestamp value or scale is outside the supported range, or the scale is zero. #[error("timestamp overflow")] TimeOverflow, diff --git a/rs/moq-ffi/src/json.rs b/rs/moq-ffi/src/json.rs index 7efb874fc37..0deb1e44df8 100644 --- a/rs/moq-ffi/src/json.rs +++ b/rs/moq-ffi/src/json.rs @@ -11,10 +11,11 @@ use std::sync::Arc; use serde_json::Value; -use crate::consumer::{MoqTrackConsumer, timestamp_us}; +use crate::consumer::MoqTrackConsumer; use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::Task; +use crate::media::MoqTimestamp; use crate::producer::{Child, MoqBroadcastProducer, MoqTrackProducer}; /// Options for a JSON snapshot producer (lossy latest-value mode). @@ -171,16 +172,16 @@ impl MoqJsonSnapshotProducer { /// Publish a new value, encoded as a snapshot or delta automatically. `value` is a JSON /// document. A no-op if unchanged from the previous update. - /// `timestamp_us` is on the broadcast clock; null publishes untimed. - #[uniffi::method(default(timestamp_us = None))] - pub fn update(&self, value: String, timestamp_us: Option) -> Result<(), MoqError> { + /// `timestamp` is on the broadcast clock; null publishes untimed. + #[uniffi::method(default(timestamp = None))] + pub fn update(&self, value: String, timestamp: Option) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let value: Value = serde_json::from_str(&value)?; let mut guard = self.inner.lock().unwrap(); let producer = guard.as_mut().ok_or(MoqError::Closed)?; producer.update(moq_net::Timed { value: &value, - at: timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?, + at: timestamp.map(TryInto::try_into).transpose()?, })?; Ok(()) } @@ -206,10 +207,8 @@ impl MoqJsonSnapshotProducer { pub struct MoqJsonValue { /// The JSON document. pub value: String, - /// The frame's timestamp on the broadcast clock in microseconds, or null when untimed. - /// Rounded down to a whole microsecond, so a tick on a track like 90 kHz may not survive a - /// read and republish exactly. - pub timestamp_us: Option, + /// The frame's timestamp on the broadcast clock, or null when untimed. + pub timestamp: Option, } impl MoqJsonValue { @@ -217,7 +216,7 @@ impl MoqJsonValue { let Some(timed) = timed else { return Ok(None) }; Ok(Some(Self { value: serde_json::to_string(&timed.value)?, - timestamp_us: timed.at.map(timestamp_us).transpose()?, + timestamp: timed.at.map(Into::into), })) } } @@ -325,16 +324,16 @@ impl MoqJsonStreamProducer { } /// Append one record to the log. `value` is a JSON document. - /// `timestamp_us` is on the broadcast clock; null publishes untimed. - #[uniffi::method(default(timestamp_us = None))] - pub fn append(&self, value: String, timestamp_us: Option) -> Result<(), MoqError> { + /// `timestamp` is on the broadcast clock; null publishes untimed. + #[uniffi::method(default(timestamp = None))] + pub fn append(&self, value: String, timestamp: Option) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let value: Value = serde_json::from_str(&value)?; let mut guard = self.inner.lock().unwrap(); let producer = guard.as_mut().ok_or(MoqError::Closed)?; producer.append(moq_net::Timed { value: &value, - at: timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?, + at: timestamp.map(TryInto::try_into).transpose()?, })?; Ok(()) } @@ -495,19 +494,19 @@ impl MoqJsonWindowProducer { } /// Append one record to the back of the window. `value` is a JSON document. - /// `timestamp_us` is on the broadcast clock; null publishes untimed. - #[uniffi::method(default(timestamp_us = None))] - pub fn push(&self, value: String, timestamp_us: Option) -> Result<(), MoqError> { + /// `timestamp` is on the broadcast clock; null publishes untimed. + #[uniffi::method(default(timestamp = None))] + pub fn push(&self, value: String, timestamp: Option) -> Result<(), MoqError> { let value: Value = serde_json::from_str(&value)?; - let at = timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; + let at = timestamp.map(TryInto::try_into).transpose()?; self.edit(|producer| producer.push(moq_net::Timed { value: &value, at })) } /// Drop `count` records from the front of the window, clamped to what it holds. - /// `timestamp_us` is on the broadcast clock; null publishes untimed. - #[uniffi::method(default(timestamp_us = None))] - pub fn pop(&self, count: u64, timestamp_us: Option) -> Result<(), MoqError> { - let at = timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; + /// `timestamp` is on the broadcast clock; null publishes untimed. + #[uniffi::method(default(timestamp = None))] + pub fn pop(&self, count: u64, timestamp: Option) -> Result<(), MoqError> { + let at = timestamp.map(TryInto::try_into).transpose()?; self.edit(|producer| producer.pop(moq_net::Timed { value: count, at })) } @@ -570,10 +569,10 @@ pub enum MoqJsonWindowEvent { pub struct MoqJsonWindowTimed { /// The change to the window. pub event: MoqJsonWindowEvent, - /// The frame's timestamp on the broadcast clock in microseconds, or null when untimed. + /// The frame's timestamp on the broadcast clock, or null when untimed. /// A record first learned of from a group's restatement (after a join, or while behind) /// carries that frame's time, not its original push time. - pub timestamp_us: Option, + pub timestamp: Option, } impl MoqJsonWindowTimed { @@ -602,7 +601,7 @@ impl MoqJsonWindowTimed { }; Ok(Some(Self { event, - timestamp_us: timed.at.map(timestamp_us).transpose()?, + timestamp: timed.at.map(Into::into), })) } } diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 3da7421872b..f1391f0e72e 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -6,9 +6,7 @@ use std::sync::{Arc, Weak}; use bytes::Buf; use moq_mux::catalog::hang::Extra; -use crate::consumer::{ - MoqBroadcastConsumer, MoqFetchGroupOptions, MoqGroupConsumer, MoqSubscription, map_fetch_error, timestamp_us, -}; +use crate::consumer::{MoqBroadcastConsumer, MoqFetchGroupOptions, MoqGroupConsumer, MoqSubscription, map_fetch_error}; use crate::demand::MoqTrackDemand; use crate::error::MoqError; use crate::ffi::Task; @@ -222,6 +220,35 @@ pub struct MoqAudio { pub jitter: Option, } +/// An exact media timestamp, `value` units at `scale` units per second, mirroring [`moq_net::Timestamp`]. +/// +/// Reads carry the scale the publisher wrote, so a tick such as 90 kHz survives a read and +/// republish exactly. Writing one past the bounds below is refused with [`MoqError::TimeOverflow`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, uniffi::Record)] +pub struct MoqTimestamp { + /// The time in `scale` units, at most `2^61 - 1`. + pub value: u64, + /// Units per second, non-zero and at most `2^62 - 1`. + pub scale: u64, +} + +impl From for MoqTimestamp { + fn from(timestamp: moq_net::Timestamp) -> Self { + Self { + value: timestamp.value(), + scale: timestamp.scale().as_u64(), + } + } +} + +impl TryFrom for moq_net::Timestamp { + type Error = moq_net::TimeOverflow; + + fn try_from(timestamp: MoqTimestamp) -> Result { + Self::from_scale(timestamp.value, timestamp.scale) + } +} + /// A payload and the time it should be presented. /// /// The unit of both writing and raw reading: every producer write takes one of these, and a @@ -231,9 +258,9 @@ pub struct MoqAudio { pub struct MoqFrame { /// The frame payload. pub payload: Vec, - /// Presentation timestamp in microseconds, or null on an untimed track, on both publish and receive. + /// Presentation timestamp, or null on an untimed track, on both publish and receive. #[uniffi(default = None)] - pub timestamp_us: Option, + pub timestamp: Option, } /// A [`MoqFrame`] plus the codec metadata a media track carries. @@ -241,8 +268,8 @@ pub struct MoqFrame { pub struct MoqMediaFrame { /// The frame payload. pub payload: Vec, - /// Presentation timestamp in microseconds. - pub timestamp_us: u64, + /// Presentation timestamp. + pub timestamp: MoqTimestamp, /// Whether this frame opens a group or is a video keyframe; audio is true only at a group start. pub keyframe: bool, } @@ -256,9 +283,9 @@ pub struct MoqDatagram { /// Per-track sequence number, shared with groups. #[uniffi(default = 0)] pub sequence: u64, - /// Presentation timestamp in microseconds, or null on an untimed track, on both publish and receive. + /// Presentation timestamp, or null on an untimed track, on both publish and receive. #[uniffi(default = None)] - pub timestamp_us: Option, + pub timestamp: Option, /// Datagram payload, capped at 1200 bytes. pub payload: Vec, } @@ -756,14 +783,13 @@ impl MoqMediaCatalogProducer { })) } - /// Read the broadcast clock in microseconds, fixing it for subsequent media producers. - pub fn now(&self) -> Result { + /// Read the broadcast clock, fixing it for subsequent media producers. + pub fn now(&self) -> Result { let _guard = crate::ffi::enter(); - self.broadcast.upgrade().ok_or(MoqError::Closed)?.with_state(|state| { - u64::try_from(state.catalog.clock().now().as_micros()).map_err(|_| MoqError::Codec { - reason: "broadcast clock exceeds microsecond timestamp range".into(), - }) - }) + self.broadcast + .upgrade() + .ok_or(MoqError::Closed)? + .with_state(|state| Ok(state.catalog.clock().now().into())) } /// Replace shared video properties, clearing fields that are absent. @@ -1074,10 +1100,12 @@ impl MoqMediaTrackProducer { let mut guard = self.inner.lock().unwrap(); let media = guard.as_mut().ok_or(MoqError::Closed)?; - let timestamp = - hang::container::Timestamp::from_micros(frame.timestamp_us.ok_or_else(|| MoqError::Codec { + let timestamp = frame + .timestamp + .ok_or_else(|| MoqError::Codec { reason: "whole media frames require a timestamp".into(), - })?)?; + })? + .try_into()?; media .import .decode(&frame.payload, Some(timestamp)) @@ -1349,15 +1377,14 @@ pub struct MoqMediaGroupConfig { pub options: Option, } -fn media_frame(mut frame: moq_mux::container::Frame) -> Result { - let timestamp_us = timestamp_us(frame.timestamp)?; +fn media_frame(mut frame: moq_mux::container::Frame) -> MoqMediaFrame { let payload = frame.payload.copy_to_bytes(frame.payload.remaining()).to_vec(); - Ok(MoqMediaFrame { + MoqMediaFrame { payload, - timestamp_us, + timestamp: frame.timestamp.into(), keyframe: frame.keyframe, - }) + } } fn media_container( @@ -1402,37 +1429,32 @@ pub enum MoqMediaRead { }, /// Media after what was already read was lost; what follows does not continue it. Gap { - /// The furthest presentation point already read, in microseconds. - timestamp_us: u64, + /// The furthest presentation point already read. + timestamp: MoqTimestamp, /// An upper bound on how long until the next frame resumes. duration: std::time::Duration, }, /// The publisher stopped its encoder: it paused, switched source, or finished. - /// Frames starting before `end_us` are its remaining media; the first at or past it + /// Frames starting before `end` are its remaining media; the first at or past it /// comes from a fresh encoder. End { - /// Where the stopped encoder's media stops, in microseconds. - end_us: u64, + /// Where the stopped encoder's media stops. + end: MoqTimestamp, }, } -impl TryFrom for MoqMediaRead { - type Error = MoqError; - - fn try_from(read: moq_mux::container::Read) -> Result { - use crate::consumer::timestamp_us; - Ok(match read { +impl From for MoqMediaRead { + fn from(read: moq_mux::container::Read) -> Self { + match read { moq_mux::container::Read::Frame { frame, .. } => Self::Frame { - frame: media_frame(frame)?, + frame: media_frame(frame), }, moq_mux::container::Read::Gap { timestamp, duration } => Self::Gap { - timestamp_us: timestamp_us(timestamp)?, + timestamp: timestamp.into(), duration: crate::ffi::duration(duration.into()), }, - moq_mux::container::Read::End { end, .. } => Self::End { - end_us: timestamp_us(end)?, - }, - }) + moq_mux::container::Read::End { end, .. } => Self::End { end: end.into() }, + } } } @@ -1448,7 +1470,7 @@ struct Media { impl Media { async fn next(&mut self) -> Result, MoqError> { - self.inner.read().await?.map(MoqMediaRead::try_from).transpose() + Ok(self.inner.read().await?.map(MoqMediaRead::from)) } } @@ -1491,6 +1513,10 @@ mod read_tests { .await .unwrap(); let timestamp = |us| moq_net::Timestamp::from_micros(us).unwrap(); + let micros = |value| MoqTimestamp { + value, + scale: 1_000_000, + }; let mut first = track.create_group(0u64.into()).unwrap(); format() .write( @@ -1508,7 +1534,7 @@ mod read_tests { panic!("expected frame") }; assert_eq!(frame.payload, b"encoded"); - assert_eq!(frame.timestamp_us, 10_000); + assert_eq!(frame.timestamp, micros(10_000)); assert!(frame.keyframe); let mut marker = track.create_group(2u64.into()).unwrap(); format() @@ -1524,11 +1550,11 @@ mod read_tests { .unwrap(); marker.finish().unwrap(); assert!( - matches!(reader.next().await.unwrap(), Some(MoqMediaRead::Gap { timestamp_us: 10_000, duration }) if duration == Duration::from_millis(10)) + matches!(reader.next().await.unwrap(), Some(MoqMediaRead::Gap { timestamp, duration }) if timestamp == micros(10_000) && duration == Duration::from_millis(10)) ); assert!(matches!( reader.next().await.unwrap(), - Some(MoqMediaRead::End { end_us: 20_000 }) + Some(MoqMediaRead::End { end }) if end == micros(20_000) )); let mut resumed = track.create_group(3u64.into()).unwrap(); format() @@ -1560,7 +1586,7 @@ struct MediaGroupInner { impl MediaGroupInner { async fn next(&mut self) -> Result, MoqError> { - self.inner.read().await?.map(media_frame).transpose() + Ok(self.inner.read().await?.map(media_frame)) } } diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index db3e02b6c2d..59fe0df5703 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -770,12 +770,12 @@ impl MoqTrackProducer { /// Write `frame` as a single-frame group. /// - /// Custom timescales may round the timestamp during conversion. The frame must + /// A timestamp at another scale than the track's is converted to it, rounding down. The frame must /// match its track's timedness, otherwise it is refused with /// [`MoqProtocolKind::TimestampMismatch`](crate::error::MoqProtocolKind::TimestampMismatch). pub fn write_frame(&self, frame: MoqFrame) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let timestamp = frame.timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; + let timestamp = frame.timestamp.map(TryInto::try_into).transpose()?; let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; track.write_frame(timestamp, frame.payload)?; @@ -789,7 +789,7 @@ impl MoqTrackProducer { /// its timestamp must match the track's timedness. pub fn append_datagram(&self, frame: MoqFrame) -> Result { let _guard = crate::ffi::enter(); - let timestamp = frame.timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; + let timestamp = frame.timestamp.map(TryInto::try_into).transpose()?; let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; Ok(track.append_datagram(timestamp, frame.payload)?) @@ -803,7 +803,7 @@ impl MoqTrackProducer { /// past a [`finish_at`](Self::finish_at) boundary is refused with `Closed`. pub fn insert_datagram(&self, sequence: u64, frame: MoqFrame) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let timestamp = frame.timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; + let timestamp = frame.timestamp.map(TryInto::try_into).transpose()?; let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; Ok(track.insert_datagram(sequence, timestamp, frame.payload)?) @@ -886,12 +886,12 @@ impl MoqGroupProducer { /// Write `frame` into this group. /// - /// Custom timescales may round the timestamp during conversion. The frame must + /// A timestamp at another scale than the track's is converted to it, rounding down. The frame must /// match its track's timedness, otherwise it is refused with /// [`MoqProtocolKind::TimestampMismatch`](crate::error::MoqProtocolKind::TimestampMismatch). pub fn write_frame(&self, frame: MoqFrame) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); - let timestamp = frame.timestamp_us.map(moq_net::Timestamp::from_micros).transpose()?; + let timestamp = frame.timestamp.map(TryInto::try_into).transpose()?; let mut guard = self.inner.lock().unwrap(); let group = guard.as_mut().ok_or(MoqError::Closed)?; group.write_frame(timestamp, frame.payload)?; diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index f68658f3143..419ca4cd434 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -23,6 +23,19 @@ use std::future::Future; use std::sync::Arc; use std::time::Duration; +/// `value` microseconds. +fn us(value: u64) -> MoqTimestamp { + MoqTimestamp { + value, + scale: 1_000_000, + } +} + +/// `timestamp` in whole microseconds, whatever scale it was read at. +fn micros(timestamp: MoqTimestamp) -> u64 { + moq_net::Timestamp::try_from(timestamp).unwrap().as_micros() as u64 +} + /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) @@ -592,7 +605,7 @@ async fn raw_audio_activity() { .unwrap(); audio .write(MoqAudioFrame { - timestamp_us: FIRST_TIMESTAMP_US, + timestamp: us(FIRST_TIMESTAMP_US), data: vec![0; FRAME_SAMPLES * std::mem::size_of::()], }) .unwrap(); @@ -601,7 +614,7 @@ async fn raw_audio_activity() { .expect("timed out waiting for raw audio frame") .unwrap() .expect("expected a raw audio frame"); - assert_eq!(first.timestamp_us, FIRST_TIMESTAMP_US); + assert_eq!(micros(first.timestamp), FIRST_TIMESTAMP_US); drop(subscription); kio::clock::timeout(TIMEOUT, audio.demand().unwrap().until_unwanted()) @@ -625,7 +638,7 @@ async fn raw_audio_activity() { audio.reanchor().unwrap(); audio .write(MoqAudioFrame { - timestamp_us: RESUMED_TIMESTAMP_US, + timestamp: us(RESUMED_TIMESTAMP_US), data: vec![0; FRAME_SAMPLES * std::mem::size_of::()], }) .unwrap(); @@ -634,14 +647,14 @@ async fn raw_audio_activity() { .expect("timed out waiting for resumed raw audio frame") .unwrap() .expect("expected a resumed raw audio frame"); - if resumed.timestamp_us == first.timestamp_us { + if resumed.timestamp == first.timestamp { resumed = kio::clock::timeout(TIMEOUT, next_encoded_frame(&subscription)) .await .expect("timed out waiting for re-anchored raw audio frame") .unwrap() .expect("expected a re-anchored raw audio frame"); } - assert_eq!(resumed.timestamp_us, RESUMED_TIMESTAMP_US); + assert_eq!(micros(resumed.timestamp), RESUMED_TIMESTAMP_US); audio.finish().unwrap(); broadcast.close().unwrap(); @@ -723,7 +736,7 @@ async fn audio_subscribes_a_held_disabled_rendition() { for index in 0..4 { audio .write(MoqAudioFrame { - timestamp_us: index * 20_000, + timestamp: us(index * 20_000), data: audio_tone(index as usize * 960, 960, 2), }) .unwrap(); @@ -790,7 +803,7 @@ async fn audio_raw_publish_from_many_threads() { let producer = audio.clone(); std::thread::spawn(move || { producer.write(MoqAudioFrame { - timestamp_us: 250_000 + index * 20_000, + timestamp: us(250_000 + index * 20_000), data: audio_tone(index as usize * 960, 960, 2), }) }) @@ -804,13 +817,20 @@ async fn audio_raw_publish_from_many_threads() { while let Some(read) = kio::clock::timeout(TIMEOUT, decoder.next()).await.unwrap().unwrap() { let frame = match read { MoqAudioRead::Frame { frame } => frame, - MoqAudioRead::End { end_us } => { - assert_eq!(end_us, 250_000 + 8 * 20_000, "the finish ends after the last frame"); + MoqAudioRead::End { end } => { + assert_eq!( + micros(end), + 250_000 + 8 * 20_000, + "the finish ends after the last frame" + ); continue; } MoqAudioRead::Gap { .. } => panic!("nothing was lost"), }; - assert_eq!(frame.timestamp_us, 250_000 + pcm.len() as u64 / 2 * 1_000_000 / 48_000); + assert_eq!( + micros(frame.timestamp), + 250_000 + pcm.len() as u64 / 2 * 1_000_000 / 48_000 + ); pcm.extend(frame.data.as_chunks::<4>().0.iter().map(|s| f32::from_le_bytes(*s))); } assert_eq!(pcm.len(), 8 * 960 * 2, "finish must preserve the entire PCM duration"); @@ -823,7 +843,7 @@ async fn audio_raw_publish_from_many_threads() { } assert!(matches!( audio.write(MoqAudioFrame { - timestamp_us: 0, + timestamp: us(0), data: vec![0; 8] }), Err(MoqError::Closed) @@ -862,7 +882,7 @@ async fn audio_raw_publish_rejects_bad_frames() { assert!( audio .write(MoqAudioFrame { - timestamp_us: 0, + timestamp: us(0), data: vec![0; size] }) .is_err() @@ -943,7 +963,7 @@ async fn raw_audio_decode_delay() { for i in 0..2 { audio .write(MoqAudioFrame { - timestamp_us: i * FRAME_US, + timestamp: us(i * FRAME_US), data: audio_tone(i as usize * FRAME_SAMPLES, FRAME_SAMPLES, 1), }) .unwrap(); @@ -1035,7 +1055,7 @@ async fn raw_audio_decode_reports_encoder_ends() { } audio .write(MoqAudioFrame { - timestamp_us, + timestamp: us(timestamp_us), data: audio_tone(i * FRAME_SAMPLES, FRAME_SAMPLES, 1), }) .unwrap(); @@ -1046,9 +1066,9 @@ async fn raw_audio_decode_reports_encoder_ends() { let mut frames = Vec::new(); while let Some(read) = kio::clock::timeout(TIMEOUT, decoder.next()).await.unwrap().unwrap() { match read { - MoqAudioRead::Frame { frame } => frames.push((ends.len(), frame.timestamp_us)), + MoqAudioRead::Frame { frame } => frames.push((ends.len(), micros(frame.timestamp))), MoqAudioRead::Gap { .. } => panic!("nothing was lost"), - MoqAudioRead::End { end_us } => ends.push(end_us), + MoqAudioRead::End { end } => ends.push(micros(end)), } } // The reanchor drops whatever the first encoder's lookahead still held, while the @@ -1105,7 +1125,7 @@ async fn raw_audio_frame_durations() { // 2.5 ms of silence at 48 kHz, mono f32: exactly one encoded frame. audio .write(MoqAudioFrame { - timestamp_us: 0, + timestamp: us(0), data: vec![0; 120 * std::mem::size_of::()], }) .unwrap(); @@ -1222,7 +1242,7 @@ async fn raw_track_datagram_roundtrip() { let sequence = track .append_datagram(MoqFrame { payload: payload.clone(), - timestamp_us: Some(123_456), + timestamp: Some(us(123_456)), }) .unwrap(); let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) @@ -1232,7 +1252,7 @@ async fn raw_track_datagram_roundtrip() { .expect("expected a datagram"); assert_eq!(datagram.sequence, sequence); - assert_eq!(datagram.timestamp_us, Some(123_456)); + assert_eq!(datagram.timestamp.map(micros), Some(123_456)); assert_eq!(datagram.payload, payload); } @@ -1284,7 +1304,7 @@ async fn raw_track_update_does_not_wait_for_pending_read() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(20_000), + timestamp: Some(us(20_000)), }) .unwrap(); @@ -1295,7 +1315,7 @@ async fn raw_track_update_does_not_wait_for_pending_read() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(20_000)); + assert_eq!(frame.timestamp.map(micros), Some(20_000)); } /// Subscribe to `name` on `broadcast` as a raw track, for a typed consumer to take over. @@ -1310,7 +1330,14 @@ async fn subscribe(broadcast: &MoqBroadcastProducer, name: &str) -> Arc { let producer = MoqJsonStreamProducer::new(&broadcast, &track).unwrap(); producer - .append(String::from_utf8(payload.to_vec()).unwrap(), timestamp_us) + .append(String::from_utf8(payload.to_vec()).unwrap(), timestamp) .unwrap(); producer.finish().unwrap(); } @@ -1348,8 +1375,8 @@ async fn data_writes_preserve_optional_timestamps() { } let frame = consumer.read_frame().await.unwrap().unwrap(); assert_eq!(frame.payload, payload, "{kind}"); - assert_eq!(frame.timestamp_us, timestamp_us, "{kind}"); - assert_eq!(consumer.info().timescale, timestamp_us.map(|_| 1_000_000)); + assert_eq!(frame.timestamp, timestamp, "{kind}"); + assert_eq!(consumer.info().timescale, timestamp.map(|timestamp| timestamp.scale)); broadcast.close().unwrap(); } } @@ -1367,14 +1394,14 @@ async fn whole_media_frames_require_the_callers_timestamp() { assert!(matches!( media.write_frame(MoqFrame { payload: b"opus frame".to_vec(), - timestamp_us: None + timestamp: None }), Err(MoqError::Codec { .. }) )); media .write_frame(MoqFrame { payload: b"opus frame".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); media.finish().unwrap(); @@ -1397,11 +1424,11 @@ async fn catalog_now_fixes_the_clock_for_later_media() { ) .unwrap(); let now = catalog.now().unwrap(); - assert!(now > 0); + assert!(now.value > 0); media .write_frame(MoqFrame { payload: b"opus frame".to_vec(), - timestamp_us: Some(1_000_000), + timestamp: Some(us(1_000_000)), }) .unwrap(); assert_eq!( @@ -1437,7 +1464,7 @@ async fn json_snapshot_roundtrip() { let consumer = MoqJsonSnapshotConsumer::new(&*subscribe(&broadcast, "meta").await).unwrap(); for n in 1..=4 { - producer.update(format!(r#"{{"a":{n}}}"#), Some(n * 1000)).unwrap(); + producer.update(format!(r#"{{"a":{n}}}"#), Some(us(n * 1000))).unwrap(); } let value = kio::clock::timeout(TIMEOUT, consumer.next()) @@ -1449,7 +1476,7 @@ async fn json_snapshot_roundtrip() { serde_json::from_str::(&value.value).unwrap(), serde_json::json!({ "a": 1 }) ); - assert_eq!(value.timestamp_us, Some(1000)); + assert_eq!(value.timestamp.map(micros), Some(1000)); let value = kio::clock::timeout(TIMEOUT, consumer.next()) .await @@ -1460,7 +1487,7 @@ async fn json_snapshot_roundtrip() { serde_json::from_str::(&value.value).unwrap(), serde_json::json!({ "a": 2 }) ); - assert_eq!(value.timestamp_us, Some(2000)); + assert_eq!(value.timestamp.map(micros), Some(2000)); // `latest` skips the buffered third state. let value = kio::clock::timeout(TIMEOUT, consumer.latest()) @@ -1472,11 +1499,11 @@ async fn json_snapshot_roundtrip() { serde_json::from_str::(&value.value).unwrap(), serde_json::json!({ "a": 4 }) ); - assert_eq!(value.timestamp_us, Some(4000)); + assert_eq!(value.timestamp.map(micros), Some(4000)); producer.finish().unwrap(); assert!(matches!( - producer.update(r#"{"a":5}"#.into(), Some(5000)), + producer.update(r#"{"a":5}"#.into(), Some(us(5000))), Err(MoqError::Closed) )); let end = kio::clock::timeout(TIMEOUT, consumer.next()) @@ -1615,9 +1642,9 @@ async fn json_window_roundtrip() { let consumer = MoqJsonWindowConsumer::new(&*subscribe(&broadcast, "log").await).unwrap(); for n in 0..3 { - producer.push(format!(r#"{{"n":{n}}}"#), Some(n * 1000)).unwrap(); + producer.push(format!(r#"{{"n":{n}}}"#), Some(us(n * 1000))).unwrap(); } - producer.pop(2, Some(3000)).unwrap(); + producer.pop(2, Some(us(3000))).unwrap(); for n in 0..3 { let event = next_window(&consumer).await.expect("expected a push"); @@ -1625,7 +1652,7 @@ async fn json_window_roundtrip() { event, MoqJsonWindowTimed { event: window_push(n, n), - timestamp_us: Some(n * 1000), + timestamp: Some(us(n * 1000)), } ); } @@ -1633,16 +1660,16 @@ async fn json_window_roundtrip() { next_window(&consumer).await, Some(MoqJsonWindowTimed { event: MoqJsonWindowEvent::Pop { start: 0, end: 2 }, - timestamp_us: Some(3000), + timestamp: Some(us(3000)), }) ); producer.finish().unwrap(); assert!(matches!( - producer.push(r#"{"n":3}"#.into(), Some(4000)), + producer.push(r#"{"n":3}"#.into(), Some(us(4000))), Err(MoqError::Closed) )); - assert!(matches!(producer.pop(1, Some(4000)), Err(MoqError::Closed))); + assert!(matches!(producer.pop(1, Some(us(4000))), Err(MoqError::Closed))); assert_eq!(next_window(&consumer).await, None); } @@ -1672,10 +1699,7 @@ async fn json_window_cut_restates_the_window() { for event in events { assert_eq!( next_window(&consumer).await, - Some(MoqJsonWindowTimed { - event, - timestamp_us: None, - }) + Some(MoqJsonWindowTimed { event, timestamp: None }) ); } @@ -1705,7 +1729,7 @@ async fn json_window_cut_restates_the_window() { next_window(&consumer).await, Some(MoqJsonWindowTimed { event: window_push(3, 3), - timestamp_us: None, + timestamp: None, }) ); @@ -1755,10 +1779,7 @@ async fn json_window_refreshes_a_bounded_checkpoint_for_a_joiner() { for event in events { assert_eq!( next_window(&consumer).await, - Some(MoqJsonWindowTimed { - event, - timestamp_us: None, - }) + Some(MoqJsonWindowTimed { event, timestamp: None }) ); } @@ -1866,7 +1887,7 @@ async fn json_stream_roundtrip() { serde_json::from_str::(&value.value).unwrap(), serde_json::json!({ "n": n }) ); - assert_eq!(value.timestamp_us, None, "an untimed track yields no timestamp"); + assert_eq!(value.timestamp, None, "an untimed track yields no timestamp"); } producer.finish().unwrap(); } @@ -2004,7 +2025,7 @@ async fn dynamic_track_request() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); @@ -2021,7 +2042,7 @@ async fn dynamic_track_request() { .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(0)); + assert_eq!(frame.timestamp.map(micros), Some(0)); track.finish().unwrap(); } @@ -2034,33 +2055,33 @@ async fn raw_untimed_frames_and_datagrams_roundtrip() { track .write_frame(MoqFrame { payload: b"frame".to_vec(), - timestamp_us: None, + timestamp: None, }) .unwrap(); let frame = consumer.read_frame().await.unwrap().unwrap(); assert_eq!(frame.payload, b"frame"); - assert_eq!(frame.timestamp_us, None); + assert_eq!(frame.timestamp, None); let group = track.append_group().unwrap(); let reader = group.consume().unwrap(); group .write_frame(MoqFrame { payload: b"group".to_vec(), - timestamp_us: None, + timestamp: None, }) .unwrap(); group.finish().unwrap(); let frame = reader.read_frame().await.unwrap().unwrap(); assert_eq!(frame.payload, b"group"); - assert_eq!(frame.timestamp_us, None); + assert_eq!(frame.timestamp, None); track .append_datagram(MoqFrame { payload: b"datagram".to_vec(), - timestamp_us: None, + timestamp: None, }) .unwrap(); let datagram = consumer.recv_datagram().await.unwrap().unwrap(); assert_eq!(datagram.payload, b"datagram"); - assert_eq!(datagram.timestamp_us, None); + assert_eq!(datagram.timestamp, None); track.finish().unwrap(); } @@ -2071,7 +2092,7 @@ fn raw_writes_need_a_timestamp() { let track = broadcast.publish_track("status".into(), Some(timed_info())).unwrap(); let untimed = || MoqFrame { payload: b"ready".to_vec(), - timestamp_us: None, + timestamp: None, }; let refused = |err: MoqError| { matches!( @@ -2100,7 +2121,7 @@ async fn raw_frame_timestamps() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(12_345), + timestamp: Some(us(12_345)), }) .unwrap(); @@ -2110,7 +2131,7 @@ async fn raw_frame_timestamps() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(12_345)); + assert_eq!(frame.timestamp.map(micros), Some(12_345)); let group = track.append_group().unwrap(); let group_consumer = group.consume().unwrap(); @@ -2118,7 +2139,7 @@ async fn raw_frame_timestamps() { group .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(23_456), + timestamp: Some(us(23_456)), }) .unwrap(); group.finish().unwrap(); @@ -2129,11 +2150,91 @@ async fn raw_frame_timestamps() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(23_456)); + assert_eq!(frame.timestamp.map(micros), Some(23_456)); track.finish().unwrap(); } +#[tokio::test] +async fn timestamps_are_exact() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let info = MoqTrackInfo { + timescale: Some(90_000), + ..timed_info() + }; + let track = broadcast.publish_track("video".into(), Some(info)).unwrap(); + let consumer = track.consume(None).unwrap(); + + // A present zero stays present, and one 90 kHz tick reads back as itself rather than + // the 11 µs it floors to. + for value in [0, 1] { + let timestamp = MoqTimestamp { value, scale: 90_000 }; + track + .write_frame(MoqFrame { + payload: b"frame".to_vec(), + timestamp: Some(timestamp), + }) + .unwrap(); + let frame = kio::clock::timeout(TIMEOUT, consumer.read_frame()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(frame.timestamp, Some(timestamp)); + } + + // An inserted datagram keeps the exact tick too. + let tick = MoqTimestamp { + value: 1, + scale: 90_000, + }; + track + .insert_datagram( + 7, + MoqFrame { + payload: b"datagram".to_vec(), + timestamp: Some(tick), + }, + ) + .unwrap(); + let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(datagram.sequence, 7); + assert_eq!(datagram.timestamp, Some(tick)); + + for invalid in [ + MoqTimestamp { value: 0, scale: 0 }, + MoqTimestamp { + value: 0, + scale: 1 << 62, + }, + MoqTimestamp { + value: 1 << 61, + scale: 90_000, + }, + ] { + let frame = MoqFrame { + payload: b"frame".to_vec(), + timestamp: Some(invalid), + }; + assert!( + matches!(track.write_frame(frame), Err(MoqError::TimeOverflow)), + "{invalid:?}" + ); + let range = MoqRange { + start: MoqAnchor::Time { timestamp: invalid }, + end: None, + }; + assert!(matches!( + moq_net::track::Range::try_from(range), + Err(MoqError::TimeOverflow) + )); + } +} + #[test] fn raw_track_supports_sparse_groups_and_known_end() { let broadcast = MoqBroadcastProducer::new().unwrap(); @@ -2159,7 +2260,7 @@ async fn abort_after_finish_keeps_the_track_handle() { track .write_frame(MoqFrame { payload: b"late abort".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); track.finish().unwrap(); @@ -2177,7 +2278,7 @@ async fn abort_after_finish_reaches_group_consumer() { group .write_frame(MoqFrame { payload: b"late abort".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); group.finish().unwrap(); @@ -2233,13 +2334,13 @@ async fn fetches_cached_group_without_subscribing() { group .write_frame(MoqFrame { payload: b"first".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); group .write_frame(MoqFrame { payload: b"second".to_vec(), - timestamp_us: Some(20_000), + timestamp: Some(us(20_000)), }) .unwrap(); group.finish().unwrap(); @@ -2260,10 +2361,10 @@ async fn fetches_cached_group_without_subscribing() { assert_eq!(fetched.sequence(), 0); let frame = fetched.read_frame().await.unwrap().expect("expected first frame"); assert_eq!(frame.payload, b"first".to_vec()); - assert_eq!(frame.timestamp_us, Some(0)); + assert_eq!(frame.timestamp.map(micros), Some(0)); let frame = fetched.read_frame().await.unwrap().expect("expected second frame"); assert_eq!(frame.payload, b"second".to_vec()); - assert_eq!(frame.timestamp_us, Some(20_000)); + assert_eq!(frame.timestamp.map(micros), Some(20_000)); assert!(fetched.read_frame().await.unwrap().is_none()); } @@ -2312,11 +2413,11 @@ async fn fetches_cached_media_group_and_decodes_container() { assert_eq!(fetched.sequence(), 0); let frame = fetched.next().await.unwrap().expect("expected keyframe"); assert_eq!(frame.payload, b"keyframe"); - assert_eq!(frame.timestamp_us, 1_000_000); + assert_eq!(micros(frame.timestamp), 1_000_000); assert!(frame.keyframe); let frame = fetched.next().await.unwrap().expect("expected delta frame"); assert_eq!(frame.payload, b"delta"); - assert_eq!(frame.timestamp_us, 1_020_000); + assert_eq!(micros(frame.timestamp), 1_020_000); assert!(!frame.keyframe); assert!(fetched.next().await.unwrap().is_none()); } @@ -2394,7 +2495,7 @@ async fn fetch_media_group_decodes_multiple_cmaf_samples() { .unwrap() .expect("expected first sample"); assert_eq!(first.payload, b"keyframe"); - assert_eq!(first.timestamp_us, 2_000_000); + assert_eq!(micros(first.timestamp), 2_000_000); assert!(first.keyframe); let second = kio::clock::timeout(TIMEOUT, fetched.next()) .await @@ -2402,7 +2503,7 @@ async fn fetch_media_group_decodes_multiple_cmaf_samples() { .unwrap() .expect("expected second sample"); assert_eq!(second.payload, b"delta"); - assert_eq!(second.timestamp_us, 2_020_000); + assert_eq!(micros(second.timestamp), 2_020_000); assert!(!second.keyframe); assert!( kio::clock::timeout(TIMEOUT, fetched.next()) @@ -2479,7 +2580,7 @@ async fn media_group_decodes_arrival_order_groups() { assert_eq!(first_media.sequence(), 0); let frame = next_media(&first_media).await.unwrap().expect("expected a frame"); assert_eq!(frame.payload, b"a"); - assert_eq!(frame.timestamp_us, 1_000_000); + assert_eq!(micros(frame.timestamp), 1_000_000); assert!(frame.keyframe); // A newer group lands while the first is stalled mid-way. @@ -2489,7 +2590,7 @@ async fn media_group_decodes_arrival_order_groups() { assert_eq!(second_media.sequence(), 1); let frame = next_media(&second_media).await.unwrap().expect("expected a frame"); assert_eq!(frame.payload, b"c"); - assert_eq!(frame.timestamp_us, 1_040_000); + assert_eq!(micros(frame.timestamp), 1_040_000); // The stalled group resumes and is still delivered. write_legacy(&mut first, 3, 1_020_000, b"b"); @@ -2498,7 +2599,7 @@ async fn media_group_decodes_arrival_order_groups() { .unwrap() .expect("expected the stalled group's frame"); assert_eq!(frame.payload, b"b"); - assert_eq!(frame.timestamp_us, 1_020_000); + assert_eq!(micros(frame.timestamp), 1_020_000); assert!(!frame.keyframe); first.finish().unwrap(); @@ -2592,7 +2693,7 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { group .write_frame(MoqFrame { payload: b"fetched".to_vec(), - timestamp_us: Some(100_000), + timestamp: Some(us(100_000)), }) .unwrap(); group.finish().unwrap(); @@ -2605,7 +2706,7 @@ async fn dynamic_track_serves_fetch_miss_and_priority() { assert_eq!(fetched.sequence(), 5); let frame = fetched.read_frame().await.unwrap().expect("expected fetched frame"); assert_eq!(frame.payload, b"fetched".to_vec()); - assert_eq!(frame.timestamp_us, Some(100_000)); + assert_eq!(frame.timestamp.map(micros), Some(100_000)); } #[tokio::test] @@ -2738,7 +2839,7 @@ async fn requested_track_dynamic_survives_accept() { group .write_frame(MoqFrame { payload: b"archive".to_vec(), - timestamp_us: Some(180_000), + timestamp: Some(us(180_000)), }) .unwrap(); group.finish().unwrap(); @@ -2750,7 +2851,7 @@ async fn requested_track_dynamic_survives_accept() { .unwrap(); let frame = fetched.read_frame().await.unwrap().expect("expected archive frame"); assert_eq!(frame.payload, b"archive".to_vec()); - assert_eq!(frame.timestamp_us, Some(180_000)); + assert_eq!(frame.timestamp.map(micros), Some(180_000)); } #[tokio::test] @@ -2824,13 +2925,13 @@ async fn video_track_demand_follows_a_new_level() { }; hd.write_frame(MoqFrame { payload: keyframe(0x1f), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); assert_eq!(hd.demand().unwrap().name(), "hd"); hd.write_frame(MoqFrame { payload: keyframe(0x28), - timestamp_us: Some(40_000), + timestamp: Some(us(40_000)), }) .unwrap(); assert_eq!(hd.demand().unwrap().name(), "hd/1"); @@ -2964,7 +3065,7 @@ async fn dynamic_track_request_can_publish_media() { media .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(20_000), + timestamp: Some(us(20_000)), }) .unwrap(); @@ -2974,7 +3075,7 @@ async fn dynamic_track_request_can_publish_media() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 20_000); + assert_eq!(micros(frame.timestamp), 20_000); media.discontinuity().unwrap(); media.finish().unwrap(); @@ -3681,7 +3782,7 @@ async fn local_publish_consume_audio() { media .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(1_000_000), + timestamp: Some(us(1_000_000)), }) .unwrap(); @@ -3692,7 +3793,7 @@ async fn local_publish_consume_audio() { .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 1_000_000); + assert_eq!(micros(frame.timestamp), 1_000_000); broadcast.close().unwrap(); } @@ -3752,7 +3853,7 @@ async fn video_publish_consume() { media .write_frame(MoqFrame { payload: keyframe, - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); @@ -3762,7 +3863,7 @@ async fn video_publish_consume() { .unwrap() .expect("expected frame"); - assert_eq!(frame.timestamp_us, 0); + assert_eq!(micros(frame.timestamp), 0); assert!(!frame.payload.is_empty(), "frame should have payload data"); broadcast.close().unwrap(); @@ -3825,7 +3926,7 @@ async fn video_raw_publish_consume() { for i in 0..5u64 { video .write(MoqVideoFrame { - timestamp_us: i * 1_001_000 / 30, + timestamp: us(i * 1_001_000 / 30), data: rgba.clone(), }) .unwrap(); @@ -3875,7 +3976,7 @@ async fn video_raw_publish_consume() { for i in 5..20u64 { video .write(MoqVideoFrame { - timestamp_us: i * 1_001_000 / 30, + timestamp: us(i * 1_001_000 / 30), data: rgba.clone(), }) .unwrap(); @@ -3937,7 +4038,7 @@ async fn video_decode_frame_ownership() { for i in 0..10u64 { video .write(MoqVideoFrame { - timestamp_us: i * 33_333, + timestamp: us(i * 33_333), data: rgba.clone(), }) .unwrap(); @@ -3985,7 +4086,7 @@ async fn video_decode_frame_ownership() { for i in 10..40u64 { video .write(MoqVideoFrame { - timestamp_us: i * 33_333, + timestamp: us(i * 33_333), data: rgba.clone(), }) .unwrap(); @@ -4090,7 +4191,7 @@ async fn video_decode_start() { } video .write(MoqVideoFrame { - timestamp_us: i * 33_333, + timestamp: us(i * 33_333), data: rgba.clone(), }) .unwrap(); @@ -4142,7 +4243,7 @@ async fn video_decode_start() { let mut timestamps = Vec::new(); while let Some(read) = kio::clock::timeout(TIMEOUT, decoder.next()).await.unwrap().unwrap() { match read { - MoqVideoRead::Frame { frame } => timestamps.push(frame.timestamp_us()), + MoqVideoRead::Frame { frame } => timestamps.push(micros(frame.timestamp())), MoqVideoRead::End { .. } => break, MoqVideoRead::Gap { .. } => panic!("nothing was lost"), } @@ -4215,7 +4316,7 @@ async fn video_raw_publish_from_many_threads() { } video .write(MoqVideoFrame { - timestamp_us: i * 33_333, + timestamp: us(i * 33_333), data: rgba.as_ref().clone(), }) .unwrap(); @@ -4314,7 +4415,7 @@ async fn video_raw_publish_rejects_bad_frames() { assert!( video .write(MoqVideoFrame { - timestamp_us: 0, + timestamp: us(0), data: vec![0x80u8; 640 * 480 * 4], }) .is_err() @@ -4323,7 +4424,7 @@ async fn video_raw_publish_rejects_bad_frames() { video.finish().unwrap(); assert!(matches!( video.write(MoqVideoFrame { - timestamp_us: 0, + timestamp: us(0), data: vec![0x80u8; 320 * 240 * 4], }), Err(MoqError::Closed) @@ -4376,7 +4477,7 @@ async fn multiple_frames_ordering() { media .write_frame(MoqFrame { payload: payload.into_bytes(), - timestamp_us: Some(ts), + timestamp: Some(us(ts)), }) .unwrap(); } @@ -4388,7 +4489,7 @@ async fn multiple_frames_ordering() { .unwrap() .unwrap_or_else(|| panic!("expected frame {i}")); - assert_eq!(frame.timestamp_us, expected_ts, "frame {i} has wrong timestamp"); + assert_eq!(micros(frame.timestamp), expected_ts, "frame {i} has wrong timestamp"); let expected = format!("frame-{i}"); assert_eq!(frame.payload, expected.as_bytes(), "frame {i} has wrong payload"); } @@ -4487,7 +4588,7 @@ async fn finish_serves_finished_tracks_until_close() { audio .write_frame(MoqFrame { payload: b"tail".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); audio.finish().unwrap(); @@ -4561,7 +4662,7 @@ async fn closing_a_broadcast_ends_its_open_tracks() { let frame = MoqFrame { payload: b"late".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }; assert!(matches!(raw.write_frame(frame.clone()), Err(MoqError::Closed))); assert!(matches!(raw.finish(), Err(MoqError::Closed))); @@ -4796,7 +4897,7 @@ async fn dynamic_broadcast_request() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(20_000), + timestamp: Some(us(20_000)), }) .unwrap(); @@ -4806,7 +4907,7 @@ async fn dynamic_broadcast_request() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(20_000)); + assert_eq!(frame.timestamp.map(micros), Some(20_000)); track.finish().unwrap(); served.close().unwrap(); @@ -4925,7 +5026,7 @@ async fn raw_track_next_group_is_repeatable() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); @@ -4963,7 +5064,7 @@ async fn raw_track_group_order_commits_on_first_read() { track .write_frame(MoqFrame { payload: b"one".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); @@ -4981,7 +5082,7 @@ async fn raw_track_group_order_commits_on_first_read() { track .write_frame(MoqFrame { payload: b"two".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); let group = kio::clock::timeout(TIMEOUT, consumer.recv_group()) @@ -4995,7 +5096,7 @@ async fn raw_track_group_order_commits_on_first_read() { let sequence = track .append_datagram(MoqFrame { payload: b"beep".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); let datagram = kio::clock::timeout(TIMEOUT, consumer.recv_datagram()) @@ -5021,14 +5122,14 @@ fn raw_track() -> ( fn datagram_frame(payload: &[u8]) -> MoqFrame { MoqFrame { payload: payload.to_vec(), - timestamp_us: Some(1), + timestamp: Some(us(1)), } } fn group_frame(payload: &[u8]) -> MoqFrame { MoqFrame { payload: payload.to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), } } @@ -5111,7 +5212,7 @@ async fn raw_track_insert_datagram_keeps_its_sequence() { .expect("expected a datagram"); assert_eq!(datagram.sequence, 7); assert_eq!(datagram.payload, b"seven".to_vec()); - assert_eq!(datagram.timestamp_us, Some(1)); + assert_eq!(datagram.timestamp.map(micros), Some(1)); assert_eq!(track.append_datagram(datagram_frame(b"next")).unwrap(), 8); } @@ -5372,7 +5473,7 @@ async fn raw_read_frame_skips_empty_group_on_open_track() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(1_000), + timestamp: Some(us(1_000)), }) .unwrap(); @@ -5383,7 +5484,7 @@ async fn raw_read_frame_skips_empty_group_on_open_track() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(1_000)); + assert_eq!(frame.timestamp.map(micros), Some(1_000)); } /// Empty groups already in the cursor are skipped, then the next populated @@ -5401,7 +5502,7 @@ async fn raw_read_frame_skips_empty_then_populated_groups() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(2_000), + timestamp: Some(us(2_000)), }) .unwrap(); @@ -5411,7 +5512,7 @@ async fn raw_read_frame_skips_empty_then_populated_groups() { .unwrap() .expect("expected the populated group's first frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(2_000)); + assert_eq!(frame.timestamp.map(micros), Some(2_000)); } /// A group a hop reset as expired is skipped like an empty one, as a locally expired @@ -5487,7 +5588,7 @@ async fn raw_read_frame_keeps_group_across_cancelled_call() { group .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(3_000), + timestamp: Some(us(3_000)), }) .unwrap(); group.finish().unwrap(); @@ -5499,7 +5600,7 @@ async fn raw_read_frame_keeps_group_across_cancelled_call() { .unwrap() .expect("cancelled read_frame must not lose the group's first frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(3_000)); + assert_eq!(frame.timestamp.map(micros), Some(3_000)); assert!( kio::clock::timeout(TIMEOUT, consumer.read_frame()) @@ -5545,7 +5646,7 @@ async fn raw_read_frame_drops_aborted_pending_group() { track .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(4_000), + timestamp: Some(us(4_000)), }) .unwrap(); let frame = kio::clock::timeout(TIMEOUT, consumer.read_frame()) @@ -5554,7 +5655,7 @@ async fn raw_read_frame_drops_aborted_pending_group() { .unwrap() .expect("an aborted pending group must not block later groups"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, Some(4_000)); + assert_eq!(frame.timestamp.map(micros), Some(4_000)); } /// `next_group` and `read_frame` share one ordered cursor. A cancelled @@ -5571,7 +5672,7 @@ async fn raw_read_frame_and_next_group_share_the_cursor() { first .write_frame(MoqFrame { payload: b"first".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); first.finish().unwrap(); @@ -5591,7 +5692,7 @@ async fn raw_read_frame_and_next_group_share_the_cursor() { track .write_frame(MoqFrame { payload: b"second".to_vec(), - timestamp_us: Some(0), + timestamp: Some(us(0)), }) .unwrap(); let frame = kio::clock::timeout(TIMEOUT, consumer.read_frame()) @@ -5709,7 +5810,7 @@ fn without_runtime() { media .write_frame(MoqFrame { payload: b"hello".to_vec(), - timestamp_us: Some(1000), + timestamp: Some(us(1000)), }) .unwrap(); @@ -5837,7 +5938,7 @@ async fn server_client_roundtrip() { media .write_frame(MoqFrame { payload: payload.clone(), - timestamp_us: Some(1_000_000), + timestamp: Some(us(1_000_000)), }) .unwrap(); @@ -5847,7 +5948,7 @@ async fn server_client_roundtrip() { .unwrap() .expect("expected a frame"); assert_eq!(frame.payload, payload); - assert_eq!(frame.timestamp_us, 1_000_000); + assert_eq!(micros(frame.timestamp), 1_000_000); // Clean up. Exercise `shutdown()` on the client side and the underlying // `cancel(code)` on the server side, so both shutdown paths run. @@ -5888,7 +5989,7 @@ async fn roundtrip(media: &MoqMediaTrackProducer, track: &MoqMediaTrackConsumer, media .write_frame(MoqFrame { payload: payload.to_vec(), - timestamp_us: Some(timestamp_us), + timestamp: Some(us(timestamp_us)), }) .unwrap(); loop { @@ -8027,7 +8128,7 @@ async fn shutdown_without_waiter_survives_session_disposal() { let group = track.append_group().unwrap(); group .write_frame(MoqFrame { - timestamp_us: Some(1), + timestamp: Some(us(1)), payload: vec![42; 64 * 1024], }) .unwrap(); @@ -8111,7 +8212,7 @@ async fn shutdown_ready(track: &MoqTrackProducer, reader: &MoqTrackConsumer) { let group = track.append_group().unwrap(); group .write_frame(MoqFrame { - timestamp_us: Some(0), + timestamp: Some(us(0)), payload: b"ready".to_vec(), }) .unwrap(); @@ -8152,7 +8253,7 @@ async fn shutdown_delivers_the_finished_track() { crate::ffi::detached(async move { let group = track.append_group()?; group.write_frame(MoqFrame { - timestamp_us: Some(0), + timestamp: Some(us(0)), payload: b"last".to_vec(), })?; group.finish()?; @@ -8237,7 +8338,7 @@ async fn ascending_subscription_reads_a_cached_backlog_oldest_first() { group .write_frame(MoqFrame { payload: b"x".to_vec(), - timestamp_us: Some(timestamp_us), + timestamp: Some(us(timestamp_us)), }) .unwrap(); group.finish().unwrap(); @@ -8446,7 +8547,7 @@ async fn time_anchors_start_at_the_containing_group() { group .write_frame(MoqFrame { payload: b"x".to_vec(), - timestamp_us: Some(timestamp_us), + timestamp: Some(us(timestamp_us)), }) .unwrap(); group.finish().unwrap(); @@ -8456,7 +8557,9 @@ async fn time_anchors_start_at_the_containing_group() { priority: 0, max_delay: Some(Duration::from_secs(10)), ranges: Some(vec![MoqRange { - start: MoqAnchor::Time { timestamp_us }, + start: MoqAnchor::Time { + timestamp: us(timestamp_us), + }, end: None, }]), order: Some(MoqOrder::Ascending), diff --git a/rs/moq-ffi/src/video.rs b/rs/moq-ffi/src/video.rs index 6dafb4fc784..0d502e2412e 100644 --- a/rs/moq-ffi/src/video.rs +++ b/rs/moq-ffi/src/video.rs @@ -19,6 +19,7 @@ use crate::bandwidth::{MoqAllocator, MoqReservation}; use crate::consumer::MoqBroadcastConsumer; use crate::demand::MoqTrackDemand; use crate::error::MoqError; +use crate::media::MoqTimestamp; /// A CPU pixel layout: what [`MoqVideoProducer::write`] is fed, and what /// [`MoqVideoDecodedFrame::pixels`] converts to. @@ -188,8 +189,8 @@ impl From for moq_video::encode::Gop { /// that layout. #[derive(uniffi::Record)] pub struct MoqVideoFrame { - /// Presentation timestamp, in microseconds. - pub timestamp_us: u64, + /// Presentation timestamp. + pub timestamp: MoqTimestamp, /// The pixels, in the configured layout. pub data: Vec, } @@ -197,8 +198,8 @@ pub struct MoqVideoFrame { /// One access unit emitted by a codec-only video encoder. #[derive(uniffi::Record)] pub struct MoqVideoPacket { - /// Presentation timestamp in microseconds. - pub timestamp_us: u64, + /// Presentation timestamp. + pub timestamp: MoqTimestamp, /// Encoded access-unit payload. pub data: Vec, /// Whether the access unit starts from a keyframe. @@ -209,7 +210,7 @@ fn video_packets(packets: Vec) -> Vec moq_video::Surface::I420(moq_video::I420::new(self.size, frame.data)?), MoqVideoPixelFormat::Rgba => moq_video::Surface::rgba(&frame.data, self.size)?, }; - let frame = moq_video::Frame::new(surface, moq_net::Timestamp::from_micros(frame.timestamp_us)?); + let frame = moq_video::Frame::new(surface, frame.timestamp.try_into()?); let mut guard = self.inner.lock().map_err(|_| panic_error())?; Ok(video_packets(block_on( guard.as_mut().ok_or(MoqError::Closed)?.encode(frame), @@ -420,7 +421,7 @@ impl VideoProducer { let Some(encoder) = self.encoder.as_mut() else { return Ok(()); }; - let frame = moq_video::Frame::new(surface, moq_net::Timestamp::from_micros(frame.timestamp_us)?); + let frame = moq_video::Frame::new(surface, frame.timestamp.try_into()?); // A backend that pipelines hands back an earlier frame's output, so this is // zero or more access units rather than one per call. let encoded = block_on(encoder.encode(frame))?; @@ -1096,10 +1097,9 @@ pub struct MoqVideoDecodedFrame { #[uniffi::export] impl MoqVideoDecodedFrame { - /// Presentation timestamp, in microseconds. - pub fn timestamp_us(&self) -> u64 { - // A decoded Timestamp is bounded by a QUIC varint, so its microseconds fit. - self.frame.timestamp.as_micros() as u64 + /// Presentation timestamp. + pub fn timestamp(&self) -> MoqTimestamp { + self.frame.timestamp.into() } /// Frame width in pixels: what the stream decoded to, since `resize` is only @@ -1163,8 +1163,8 @@ pub enum MoqVideoRead { /// Every picture from before the loss precedes it, and what follows does not continue /// what came before. Gap { - /// The furthest presentation point already read, in microseconds. - timestamp_us: u64, + /// The furthest presentation point already read. + timestamp: MoqTimestamp, /// An upper bound on how long until the next picture resumes. duration: std::time::Duration, }, @@ -1173,28 +1173,23 @@ pub enum MoqVideoRead { /// The stopped encoder's last pictures precede it; what follows comes from a fresh /// encoder. End { - /// Where the stopped encoder's media stops, in microseconds. - end_us: u64, + /// Where the stopped encoder's media stops. + end: MoqTimestamp, }, } -impl TryFrom for MoqVideoRead { - type Error = MoqError; - - fn try_from(read: moq_video::decode::Read) -> Result { - use crate::consumer::timestamp_us; - Ok(match read { +impl From for MoqVideoRead { + fn from(read: moq_video::decode::Read) -> Self { + match read { moq_video::decode::Read::Frame(frame) => Self::Frame { frame: Arc::new(MoqVideoDecodedFrame { frame }), }, moq_video::decode::Read::Gap { timestamp, duration } => Self::Gap { - timestamp_us: timestamp_us(timestamp)?, + timestamp: timestamp.into(), duration: crate::ffi::duration(duration.into()), }, - moq_video::decode::Read::End { end } => Self::End { - end_us: timestamp_us(end)?, - }, - }) + moq_video::decode::Read::End { end } => Self::End { end: end.into() }, + } } } @@ -1204,7 +1199,7 @@ struct VideoConsumerInner { impl VideoConsumerInner { async fn next(&mut self) -> Result, MoqError> { - self.consumer.read().await?.map(MoqVideoRead::try_from).transpose() + Ok(self.consumer.read().await?.map(MoqVideoRead::from)) } } @@ -1447,8 +1442,13 @@ mod finish_tests { }, ) .unwrap(); + // NTSC frame times, which no microsecond count holds exactly. + let ntsc = |i: u64| MoqTimestamp { + value: i * 1001, + scale: 30_000, + }; let picture = |i: u64| MoqVideoFrame { - timestamp_us: i * 1_001_000 / 30, + timestamp: ntsc(i), data: vec![(i * 40) as u8; (width * height * 3 / 2) as usize], }; @@ -1462,9 +1462,9 @@ mod finish_tests { packets.extend(encoder.encode(picture(3)).unwrap()); packets.extend(encoder.finish().unwrap()); - let keyframes: Vec = packets.iter().filter(|p| p.keyframe).map(|p| p.timestamp_us).collect(); + let keyframes: Vec = packets.iter().filter(|p| p.keyframe).map(|p| p.timestamp).collect(); assert_eq!(packets.len(), 4, "one access unit per picture"); - assert_eq!(keyframes, [0, 3 * 1_001_000 / 30], "keyframes at the start and the cut"); + assert_eq!(keyframes, [ntsc(0), ntsc(3)], "keyframes at the start and the cut"); assert!(packets.iter().all(|p| !p.data.is_empty()), "an empty access unit"); assert!(matches!(encoder.encode(picture(4)), Err(MoqError::Closed))); } @@ -1689,7 +1689,10 @@ mod rung_tests { fn frame(i: u64) -> MoqVideoFrame { MoqVideoFrame { - timestamp_us: i * 33_333, + timestamp: MoqTimestamp { + value: i * 33_333, + scale: 1_000_000, + }, data: vec![(i * 40) as u8; 320 * 240 * 3 / 2], } } diff --git a/swift/README.md b/swift/README.md index d87418f0d62..1c9c107e9b1 100644 --- a/swift/README.md +++ b/swift/README.md @@ -78,8 +78,8 @@ Every consumer conforms to `AsyncSequence`, so `for try await x in consumer` wor Incoming server `Request` values expose the query-free `path` before acceptance. It is consistent across transports and is empty for the root or missing path. The encoded `query` may contain credentials. -Raw tracks also expose best-effort datagrams: `TrackProducer.appendDatagram(_:timestampUs:)` -returns the assigned sequence number (`insertDatagram(_:sequence:timestampUs:)` takes the +Raw tracks also expose best-effort datagrams: `TrackProducer.appendDatagram(_:timestamp:)` +returns the assigned sequence number (`insertDatagram(_:sequence:timestamp:)` takes the caller's own), `TrackConsumer.recvDatagram()` receives one datagram, and `TrackConsumer.datagrams` streams them in arrival order. Payloads are capped at 1200 bytes. Datagrams require a datagram-capable transport and lite-05 or newer moq-lite, or moq-transport; diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift index 64608e2b215..4de395ddb1a 100644 --- a/swift/Sources/Moq/Aliases.swift +++ b/swift/Sources/Moq/Aliases.swift @@ -7,6 +7,8 @@ import MoqFFI // instead (see Client.swift, Broadcast.swift, etc.), so MoqFFI's `Moq`-prefixed // classes never appear in the public API. +/// An exact media timestamp: `value` units at `scale` units per second. +public typealias Timestamp = MoqFFI.MoqTimestamp /// A payload plus the presentation timestamp it should play at. The unit of /// every raw write and raw read. public typealias Frame = MoqFFI.MoqFrame @@ -125,7 +127,7 @@ extension Subscription { /// A span of a track to deliver: from `start` up to `end`, or onward when `end` is `nil`. public typealias Range = MoqFFI.MoqRange /// Where one end of a `Range` falls: `.absolute(group:)`, `.relative(groups:)` - /// back from the latest group, or `.time(timestampUs:)` for the group containing it. + /// back from the latest group, or `.time(timestamp:)` for the group containing it. public typealias Anchor = MoqFFI.MoqAnchor /// Which pending group the publisher sends first: `.ascending` or `.descending`. public typealias Order = MoqFFI.MoqOrder diff --git a/swift/Sources/Moq/Json.swift b/swift/Sources/Moq/Json.swift index 3e04d6579fb..c4717bb1c89 100644 --- a/swift/Sources/Moq/Json.swift +++ b/swift/Sources/Moq/Json.swift @@ -19,20 +19,18 @@ extension Json { public struct Timed { /// The decoded value. public let value: Value - /// The frame's timestamp on the broadcast clock in microseconds, or `nil` when untimed. - /// Rounded down to a whole microsecond, so a tick on a track like 90 kHz may not survive - /// a read and republish exactly. - public let timestampUs: UInt64? + /// The frame's timestamp on the broadcast clock, or `nil` when untimed. + public let timestamp: Timestamp? /// Pair `value` with its frame timestamp. - public init(value: Value, timestampUs: UInt64?) { + public init(value: Value, timestamp: Timestamp?) { self.value = value - self.timestampUs = timestampUs + self.timestamp = timestamp } init?(_ ffi: MoqJsonValue?) throws where Value: Decodable { guard let ffi else { return nil } - self.init(value: try decodeJson(ffi.value), timestampUs: ffi.timestampUs) + self.init(value: try decodeJson(ffi.value), timestamp: ffi.timestamp) } } @@ -63,8 +61,8 @@ extension Json { /// Publish a new value, encoded as a snapshot or merge-patch delta automatically. A no-op /// if unchanged from the previous update. - public func update(_ value: Value, timestampUs: UInt64? = nil) throws { - try ffi.update(value: encodeJson(value), timestampUs: timestampUs) + public func update(_ value: Value, timestamp: Timestamp? = nil) throws { + try ffi.update(value: encodeJson(value), timestamp: timestamp) } /// A watch-only handle to whether the track has subscribers. @@ -131,8 +129,8 @@ extension Json { } /// Append one record to the log. - public func append(_ value: Value, timestampUs: UInt64? = nil) throws { - try ffi.append(value: encodeJson(value), timestampUs: timestampUs) + public func append(_ value: Value, timestamp: Timestamp? = nil) throws { + try ffi.append(value: encodeJson(value), timestamp: timestamp) } /// A watch-only handle to whether the track has subscribers. @@ -202,13 +200,13 @@ extension Json { } /// Append one record to the back of the window. - public func push(_ value: Value, timestampUs: UInt64? = nil) throws { - try ffi.push(value: encodeJson(value), timestampUs: timestampUs) + public func push(_ value: Value, timestamp: Timestamp? = nil) throws { + try ffi.push(value: encodeJson(value), timestamp: timestamp) } /// Drop `count` records from the front of the window, clamped to what it holds. - public func pop(_ count: UInt64, timestampUs: UInt64? = nil) throws { - try ffi.pop(count: count, timestampUs: timestampUs) + public func pop(_ count: UInt64, timestamp: Timestamp? = nil) throws { + try ffi.pop(count: count, timestamp: timestamp) } /// Close the open group, so the next edit opens one restating the retained window. A no-op @@ -263,7 +261,7 @@ extension Json { case .pop(let start, let end): event = .pop(start.. UInt64 { try ffi.now() } + /// Read the broadcast clock, fixing it for subsequent media producers. + public func now() throws -> Timestamp { try ffi.now() } /// Replace the video properties shared by every rendition. public func setVideoProperties(_ properties: VideoProperties) throws { try ffi.setVideoProperties(properties: properties) @@ -251,12 +251,12 @@ public enum Media { TrackDemand(try ffi.demand()) } - /// Write a frame with the given presentation timestamp (microseconds). + /// Write a frame with the given presentation timestamp. /// /// The importer derives keyframe status from the bitstream, so only the payload and its /// timestamp cross the boundary. - public func writeFrame(_ payload: Data, timestampUs: UInt64) throws { - try ffi.writeFrame(frame: Frame(payload: payload, timestampUs: timestampUs)) + public func writeFrame(_ payload: Data, timestamp: Timestamp) throws { + try ffi.writeFrame(frame: Frame(payload: payload, timestamp: timestamp)) } /// Mark a timeline break, preserving the advertised jitter and delay. diff --git a/swift/Sources/Moq/Timestamp.swift b/swift/Sources/Moq/Timestamp.swift new file mode 100644 index 00000000000..c5be8733cf4 --- /dev/null +++ b/swift/Sources/Moq/Timestamp.swift @@ -0,0 +1,51 @@ +import MoqFFI + +// The bounds moq-ffi enforces on a write, so a conversion refuses what a write would. +private let maxValue: UInt64 = (1 << 61) - 1 +private let maxScale: UInt64 = (1 << 62) - 1 +private let attosecondsPerSecond: UInt64 = 1_000_000_000_000_000_000 + +@available(macOS 13, iOS 16, *) +extension Timestamp { + /// The timestamp exactly equal to `duration`, at the finest power-of-ten scale that fits. + /// + /// `Duration` counts attoseconds, so the scale starts at 10^18 and steps down one power of ten + /// each time the value would pass `2^61 - 1` (about 2.3 s of attoseconds, 73 years of + /// nanoseconds), down to whole seconds. Throws `MoqError.TimeOverflow` for a negative duration, + /// or one with finer precision than the largest scale whose value fits. + public init(_ duration: Duration) throws { + guard duration >= .zero else { throw MoqError.TimeOverflow } + let (seconds, attoseconds) = duration.components + var scale = attosecondsPerSecond + var divisor: UInt64 = 1 + while scale > 0 { + let (whole, overflow) = UInt64(seconds).multipliedReportingOverflow(by: scale) + let (fraction, remainder) = UInt64(attoseconds).quotientAndRemainder(dividingBy: divisor) + // `fraction` is below `scale`, which is below `maxValue`, so the subtraction can't wrap. + if !overflow, whole <= maxValue - fraction { + // A coarser scale can't hold what this one drops, so stop here either way. + guard remainder == 0 else { throw MoqError.TimeOverflow } + self.init(value: whole + fraction, scale: scale) + return + } + scale /= 10 + divisor *= 10 + } + throw MoqError.TimeOverflow + } + + /// This timestamp as a `Duration`, rounded down to a whole attosecond. + /// + /// Exact when `scale` divides 10^18, such as microseconds or nanoseconds; a 90 kHz tick rounds, + /// so keep the record itself to republish a read exactly. Throws `MoqError.TimeOverflow` for a + /// record outside the bounds a write accepts. + public var duration: Duration { + get throws { + guard scale != 0, scale <= maxScale, value <= maxValue else { throw MoqError.TimeOverflow } + let (seconds, remainder) = value.quotientAndRemainder(dividingBy: scale) + // `remainder` is below `scale`, so the quotient is below 10^18 and fits. + let (attoseconds, _) = scale.dividingFullWidth(remainder.multipliedFullWidth(by: attosecondsPerSecond)) + return Duration(secondsComponent: Int64(seconds), attosecondsComponent: Int64(attoseconds)) + } + } +} diff --git a/swift/Sources/Moq/Track.swift b/swift/Sources/Moq/Track.swift index 6f2426ecc49..aafee311f61 100644 --- a/swift/Sources/Moq/Track.swift +++ b/swift/Sources/Moq/Track.swift @@ -176,23 +176,23 @@ public final class TrackProducer: Sendable { GroupProducer(try ffi.createGroup(sequence: sequence)) } - /// Write a single-frame group with a timestamp in microseconds. - public func writeFrame(_ payload: Data, timestampUs: UInt64? = nil) throws { - try ffi.writeFrame(frame: Frame(payload: payload, timestampUs: timestampUs)) + /// Write a single-frame group with a presentation timestamp, or untimed when nil. + public func writeFrame(_ payload: Data, timestamp: Timestamp? = nil) throws { + try ffi.writeFrame(frame: Frame(payload: payload, timestamp: timestamp)) } /// Send a best-effort datagram and return its sequence number. /// - /// `timestampUs` is the presentation timestamp in microseconds. Payloads are + /// `timestamp` is the presentation timestamp, or untimed when nil. Payloads are /// capped at 1200 bytes. There is no stream fallback. - public func appendDatagram(_ payload: Data, timestampUs: UInt64? = nil) throws -> UInt64 { - try ffi.appendDatagram(frame: Frame(payload: payload, timestampUs: timestampUs)) + public func appendDatagram(_ payload: Data, timestamp: Timestamp? = nil) throws -> UInt64 { + try ffi.appendDatagram(frame: Frame(payload: payload, timestamp: timestamp)) } /// Send a best-effort datagram at an explicit sequence number, for a caller that /// allocates its own. `appendDatagram` continues after the largest one. - public func insertDatagram(_ payload: Data, sequence: UInt64, timestampUs: UInt64? = nil) throws { - try ffi.insertDatagram(sequence: sequence, frame: Frame(payload: payload, timestampUs: timestampUs)) + public func insertDatagram(_ payload: Data, sequence: UInt64, timestamp: Timestamp? = nil) throws { + try ffi.insertDatagram(sequence: sequence, frame: Frame(payload: payload, timestamp: timestamp)) } /// The newest group or datagram written, at its newest frame, or `nil` before any. @@ -235,9 +235,9 @@ public final class GroupProducer: Sendable { GroupConsumer(try ffi.consume()) } - /// Write a frame with a timestamp in microseconds. - public func writeFrame(_ payload: Data, timestampUs: UInt64? = nil) throws { - try ffi.writeFrame(frame: Frame(payload: payload, timestampUs: timestampUs)) + /// Write a frame with a presentation timestamp, or untimed when nil. + public func writeFrame(_ payload: Data, timestamp: Timestamp? = nil) throws { + try ffi.writeFrame(frame: Frame(payload: payload, timestamp: timestamp)) } /// Mark the group complete. No more frames can be written. diff --git a/swift/Tests/MoqTests/Docs/Prelude.swift b/swift/Tests/MoqTests/Docs/Prelude.swift index f900542855a..a8516215bde 100644 --- a/swift/Tests/MoqTests/Docs/Prelude.swift +++ b/swift/Tests/MoqTests/Docs/Prelude.swift @@ -6,6 +6,6 @@ import Moq var opusInit: Data { fatalError() } var packet: Data { fatalError() } -var pts: UInt64 { fatalError() } +var pts: Timestamp { fatalError() } var rgba: Data { fatalError() } var session: Session { fatalError() } diff --git a/swift/Tests/MoqTests/FfiBench.swift b/swift/Tests/MoqTests/FfiBench.swift index ffa6690c873..205c02660a4 100644 --- a/swift/Tests/MoqTests/FfiBench.swift +++ b/swift/Tests/MoqTests/FfiBench.swift @@ -89,13 +89,17 @@ final class FfiBench: XCTestCase { let received = try await group.readFrame() let frame = try XCTUnwrap(received, "group ended") XCTAssertEqual(frame.payload, payload) - XCTAssertEqual(frame.timestampUs, UInt64(n * c.frames + f)) + XCTAssertEqual( + frame.timestamp, Timestamp(value: UInt64(n * c.frames + f), scale: 1_000_000)) } } } for track in tracks { let group = try track.appendGroup() - for f in 0.. AnnounceEv return try XCTUnwrap(next, "announce stream ended") } +/// A timestamp in microseconds. +private func micros(_ value: UInt64) -> Timestamp { + Timestamp(value: value, scale: 1_000_000) +} + +/// Assert `body` throws `MoqError.TimeOverflow`. +private func assertTimeOverflow( + _ body: @autoclosure () throws -> T, file: StaticString = #filePath, line: UInt = #line +) { + XCTAssertThrowsError(try body(), file: file, line: line) { error in + guard case MoqError.TimeOverflow = error else { + return XCTFail("expected TimeOverflow, got \(error)", file: file, line: line) + } + } +} + final class SmokeTests: XCTestCase { func testDefaultSubscriptionSkipsSupersededGroupsAtOnce() { XCTAssertEqual(Subscription.default.maxDelay, 0) @@ -50,7 +66,7 @@ final class SmokeTests: XCTestCase { let track = try broadcast.publishTrack(name: "backlog", info: TrackInfo(timescale: 1_000_000)) for ms: UInt64 in 0..<3 { let group = try track.appendGroup() - try group.writeFrame(Data("x".utf8), timestampUs: ms * 1_000) + try group.writeFrame(Data("x".utf8), timestamp: micros(ms * 1_000)) try group.finish() } let consumer = try track.consume( @@ -365,7 +381,7 @@ final class SmokeTests: XCTestCase { let broadcast = try BroadcastProducer() let track = try broadcast.publishTrack(name: "events", info: TrackInfo(timescale: 1_000_000)) let group = try track.appendGroup() - try group.writeFrame(Data("cached".utf8), timestampUs: 0) + try group.writeFrame(Data("cached".utf8), timestamp: micros(0)) try group.finish() let consumer = try broadcast.consume() @@ -395,16 +411,16 @@ final class SmokeTests: XCTestCase { track: try await broadcast.consume().subscribeTrack(name: "status")) for viewers in 1...4 { - try producer.update(Status(state: "live", viewers: viewers), timestampUs: UInt64(viewers) * 1000) + try producer.update(Status(state: "live", viewers: viewers), timestamp: micros(UInt64(viewers) * 1000)) } // `next` yields every state in order; `latest` skips the buffered third. let first = try await consumer.next() - XCTAssertEqual(first, Json.Timed(value: Status(state: "live", viewers: 1), timestampUs: 1000)) + XCTAssertEqual(first, Json.Timed(value: Status(state: "live", viewers: 1), timestamp: micros(1000))) let second = try await consumer.next() - XCTAssertEqual(second, Json.Timed(value: Status(state: "live", viewers: 2), timestampUs: 2000)) + XCTAssertEqual(second, Json.Timed(value: Status(state: "live", viewers: 2), timestamp: micros(2000))) let latest = try await consumer.latest() - XCTAssertEqual(latest, Json.Timed(value: Status(state: "live", viewers: 4), timestampUs: 4000)) + XCTAssertEqual(latest, Json.Timed(value: Status(state: "live", viewers: 4), timestamp: micros(4000))) consumer.cancel() try producer.finish() @@ -425,7 +441,7 @@ final class SmokeTests: XCTestCase { for n in 0..<3 { try producer.append(Event(n: n)) let record = try await consumer.next() - XCTAssertEqual(record, Json.Timed(value: Event(n: n), timestampUs: nil)) + XCTAssertEqual(record, Json.Timed(value: Event(n: n), timestamp: nil)) } consumer.cancel() @@ -445,23 +461,24 @@ final class SmokeTests: XCTestCase { track: try await broadcast.consume().subscribeTrack(name: "log")) for n in 0..<3 { - try producer.push(Event(n: n), timestampUs: UInt64(n) * 1000) + try producer.push(Event(n: n), timestamp: micros(UInt64(n) * 1000)) } - try producer.pop(2, timestampUs: 3000) + try producer.pop(2, timestamp: micros(3000)) for n in 0..<3 { let event = try await consumer.next() XCTAssertEqual( - event, Json.Timed(value: .push(index: UInt64(n), value: Event(n: n)), timestampUs: UInt64(n) * 1000)) + event, + Json.Timed(value: .push(index: UInt64(n), value: Event(n: n)), timestamp: micros(UInt64(n) * 1000))) } let pop = try await consumer.next() - XCTAssertEqual(pop, Json.Timed(value: .pop(0..<2), timestampUs: 3000)) + XCTAssertEqual(pop, Json.Timed(value: .pop(0..<2), timestamp: micros(3000))) // The cut's new group restates record 2, which this reader already has. try producer.cut() - try producer.push(Event(n: 3), timestampUs: 4000) + try producer.push(Event(n: 3), timestamp: micros(4000)) let pushed = try await consumer.next() - XCTAssertEqual(pushed, Json.Timed(value: .push(index: 3, value: Event(n: 3)), timestampUs: 4000)) + XCTAssertEqual(pushed, Json.Timed(value: .push(index: 3, value: Event(n: 3)), timestamp: micros(4000))) try producer.finish() let end = try await consumer.next() @@ -505,7 +522,7 @@ final class SmokeTests: XCTestCase { let received = try await consumer.readFrame() let frame = try XCTUnwrap(received) XCTAssertEqual(frame.payload, Data("payload".utf8)) - XCTAssertNil(frame.timestampUs) + XCTAssertNil(frame.timestamp) XCTAssertNil(consumer.info().timescale) try track.finish() try broadcast.close() @@ -517,26 +534,85 @@ final class SmokeTests: XCTestCase { let consumer = try track.consume() let payload = Data("ready".utf8) - try track.writeFrame(payload, timestampUs: 12_345) + try track.writeFrame(payload, timestamp: micros(12_345)) let frame = try await consumer.readFrame() XCTAssertEqual(frame?.payload, payload) - XCTAssertEqual(frame?.timestampUs, 12_345) + XCTAssertEqual(frame?.timestamp, micros(12_345)) let group = try track.appendGroup() let groupConsumer = try group.consume() let groupPayload = Data("group".utf8) - try group.writeFrame(groupPayload, timestampUs: 23_456) + try group.writeFrame(groupPayload, timestamp: micros(23_456)) try group.finish() let groupFrame = try await groupConsumer.readFrame() XCTAssertEqual(groupFrame?.payload, groupPayload) - XCTAssertEqual(groupFrame?.timestampUs, 23_456) + XCTAssertEqual(groupFrame?.timestamp, micros(23_456)) + + try track.finish() + try broadcast.close() + } + + func testTimestampsAreExact() async throws { + let broadcast = try BroadcastProducer() + let track = try broadcast.publishTrack(name: "video", info: TrackInfo(timescale: 90_000)) + let consumer = try track.consume() + + // A present zero stays present, and one 90 kHz tick reads back as itself rather than + // the 11 µs it floors to. + for value: UInt64 in [0, 1] { + let timestamp = Timestamp(value: value, scale: 90_000) + try track.writeFrame(Data("frame".utf8), timestamp: timestamp) + let frame = try await consumer.readFrame() + XCTAssertEqual(frame?.timestamp, timestamp) + } + + for invalid in [ + Timestamp(value: 0, scale: 0), + Timestamp(value: 0, scale: 1 << 62), + Timestamp(value: 1 << 61, scale: 90_000), + ] { + assertTimeOverflow(try track.writeFrame(Data("frame".utf8), timestamp: invalid)) + } try track.finish() try broadcast.close() } + @available(macOS 13, iOS 16, *) + func testTimestampDurations() throws { + // Exact at the finest power-of-ten scale whose value fits. + let atto: UInt64 = 1_000_000_000_000_000_000 + XCTAssertEqual(try Timestamp(.zero), Timestamp(value: 0, scale: atto)) + XCTAssertEqual(try Timestamp(.milliseconds(20)), Timestamp(value: atto / 50, scale: atto)) + XCTAssertEqual(try Timestamp(.seconds(3_600)), Timestamp(value: 3_600 * (atto / 10_000), scale: atto / 10_000)) + XCTAssertEqual(try Timestamp(.seconds((1 << 61) - 1)), Timestamp(value: (1 << 61) - 1, scale: 1)) + assertTimeOverflow(try Timestamp(.seconds(1 << 61))) + assertTimeOverflow(try Timestamp(.seconds(-1))) + // Three seconds of attoseconds passes 2^61, and the attosecond doesn't survive a coarser scale. + assertTimeOverflow(try Timestamp(.seconds(3) + Duration(secondsComponent: 0, attosecondsComponent: 1))) + + // Back down to a whole attosecond: a 90 kHz tick is 11_111_111_111_111.1 as, 2/3 s ends in + // ...6.6 as, and half an attosecond is 0. + let tick = Duration(secondsComponent: 0, attosecondsComponent: 11_111_111_111_111) + XCTAssertEqual(try Timestamp(value: 1, scale: 90_000).duration, tick) + let twoThirds = Duration(secondsComponent: 0, attosecondsComponent: 666_666_666_666_666_666) + XCTAssertEqual(try Timestamp(value: 2, scale: 3).duration, twoThirds) + XCTAssertEqual(try Timestamp(value: 1, scale: 2 * atto).duration, .zero) + XCTAssertEqual(try micros(1_500_000).duration, .milliseconds(1_500)) + XCTAssertEqual(try Timestamp(.seconds(3_600)).duration, .seconds(3_600)) + + // A record outside what a write accepts is refused rather than converted. + for invalid in [ + Timestamp(value: 0, scale: 0), + Timestamp(value: 0, scale: 1 << 62), + Timestamp(value: 1 << 61, scale: 90_000), + ] { + assertTimeOverflow(try invalid.duration) + } + } + func testReadFrameSkipsEmptyThenPopulatedGroups() async throws { let broadcast = try BroadcastProducer() let track = try broadcast.publishTrack(name: "status", info: TrackInfo(timescale: 1_000_000)) @@ -544,11 +620,11 @@ final class SmokeTests: XCTestCase { try track.appendGroup().finish() try track.appendGroup().finish() - try track.writeFrame(Data("populated".utf8), timestampUs: 2_000) + try track.writeFrame(Data("populated".utf8), timestamp: micros(2_000)) let frame = try await consumer.readFrame() XCTAssertEqual(frame?.payload, Data("populated".utf8)) - XCTAssertEqual(frame?.timestampUs, 2_000) + XCTAssertEqual(frame?.timestamp, micros(2_000)) try track.finish() try broadcast.close() @@ -582,7 +658,7 @@ final class SmokeTests: XCTestCase { track: "fine" ) // 2.5 ms of silence at 48 kHz mono f32: exactly one encoded frame. - try fine.write(AudioFrame(timestampUs: 0, data: Data(count: 120 * 4))) + try fine.write(AudioFrame(timestamp: micros(0), data: Data(count: 120 * 4))) try fine.finish() XCTAssertThrowsError( @@ -634,7 +710,7 @@ final class SmokeTests: XCTestCase { // Two 20 ms Opus frames of silence at 48 kHz mono. for i in 0..<2 { - try audio.write(AudioFrame(timestampUs: UInt64(i) * 20_000, data: Data(count: 960 * 4))) + try audio.write(AudioFrame(timestamp: micros(UInt64(i) * 20_000), data: Data(count: 960 * 4))) } let fixedFrame = try await fixed.next() XCTAssertNotNil(fixedFrame) @@ -669,7 +745,7 @@ final class SmokeTests: XCTestCase { let rgba = Data(repeating: 0x80, count: 320 * 240 * 4) try video.cut() for i in 0..<10 { - try video.write(VideoFrame(timestampUs: UInt64(i) * 33_333, data: rgba)) + try video.write(VideoFrame(timestamp: micros(UInt64(i) * 33_333), data: rgba)) } let consumer = try await origin.consume().request(path: "video-decode-frame") @@ -683,7 +759,7 @@ final class SmokeTests: XCTestCase { // Keep the encoder fed so the decoder sees frames after it joined. for i in 10..<40 { - try video.write(VideoFrame(timestampUs: UInt64(i) * 33_333, data: rgba)) + try video.write(VideoFrame(timestamp: micros(UInt64(i) * 33_333), data: rgba)) } let next = try await decoder.next() @@ -706,7 +782,7 @@ final class SmokeTests: XCTestCase { func testEncodeAudioWithOpusObject() throws { // The config retains the codec, so releasing either first must still encode. let input = AudioEncoderInput(format: .f32, sampleRate: 48_000, channels: 1) - let silence = AudioFrame(timestampUs: 0, data: Data(count: 960 * 4)) + let silence = AudioFrame(timestamp: micros(0), data: Data(count: 960 * 4)) // Release the codec before encoding: `output` retains it. do { diff --git a/test/interop/clients/c/subscribe.c b/test/interop/clients/c/subscribe.c index cabba3f28d4..cc4ff43f0da 100644 --- a/test/interop/clients/c/subscribe.c +++ b/test/interop/clients/c/subscribe.c @@ -171,7 +171,10 @@ static void on_audio(void *ud, int32_t id) { moq_audio_frame f = read.frame; if (status == 0 && read.kind == MOQ_DECODE_READ_FRAME && a->samples < a->want) { uint64_t samples = f.data_size / a->frame_bytes; - fprintf(a->log, "%llu %llu\n", (unsigned long long)f.timestamp_us, (unsigned long long)samples); + // tone.py reads whole microseconds; splitting off the seconds keeps the product in range. + moq_timestamp ts = f.timestamp; + unsigned long long us = ts.value / ts.scale * 1000000 + ts.value % ts.scale * 1000000 / ts.scale; + fprintf(a->log, "%llu %llu\n", us, (unsigned long long)samples); fwrite(f.data, 1, f.data_size, a->pcm); a->samples += samples; if (a->samples >= a->want) { diff --git a/test/interop/clients/cpp/main.cpp b/test/interop/clients/cpp/main.cpp index 4771c32061d..37a3fda1adf 100644 --- a/test/interop/clients/cpp/main.cpp +++ b/test/interop/clients/cpp/main.cpp @@ -168,8 +168,7 @@ void publish_tone( ) { const auto started = Clock::now(); std::vector data(AUDIO_BATCH_SAMPLES * tones.size() * sizeof(float)); - uint64_t timestamp_us = 0; - size_t phase = 0; + uint64_t phase = 0; while (!stop.load()) { for (size_t i = 0; i < AUDIO_BATCH_SAMPLES; i++) { for (size_t c = 0; c < tones.size(); c++) { @@ -180,11 +179,10 @@ void publish_tone( std::memcpy(&data[(i * tones.size() + c) * sizeof(float)], &sample, sizeof(float)); } } - ok(audio->write({timestamp_us, data}), "tone write"); + ok(audio->write({{phase, AUDIO_RATE}, data}), "tone write"); phase += AUDIO_BATCH_SAMPLES; - timestamp_us += AUDIO_BATCH_US; // Pace against the start, so encoding cost doesn't accumulate as drift. - std::this_thread::sleep_until(started + std::chrono::microseconds(timestamp_us)); + std::this_thread::sleep_until(started + std::chrono::microseconds(phase * 1'000'000 / AUDIO_RATE)); } } @@ -319,7 +317,9 @@ void decode_audio( const auto *frame = &variant->frame; const size_t samples = frame->data.size() / frame_bytes; - log << frame->timestamp_us << " " << samples << "\n"; + // tone.py reads whole microseconds; splitting off the seconds keeps the product in range. + const auto &ts = frame->timestamp; + log << ts.value / ts.scale * 1'000'000 + ts.value % ts.scale * 1'000'000 / ts.scale << " " << samples << "\n"; pcm.write(reinterpret_cast(frame->data.data()), static_cast(frame->data.size())); decoded += samples; } diff --git a/test/interop/clients/go/main.go b/test/interop/clients/go/main.go index 8082754527c..8ab4f683d5d 100644 --- a/test/interop/clients/go/main.go +++ b/test/interop/clients/go/main.go @@ -73,7 +73,6 @@ func publishTone(ctx context.Context, audio *moqaudio.Producer, tones []float64) defer ticker.Stop() data := make([]byte, audioBatchSamples*len(tones)*4) - timestampUs := uint64(0) phase := 0 for { select { @@ -87,12 +86,12 @@ func publishTone(ctx context.Context, audio *moqaudio.Producer, tones []float64) binary.LittleEndian.PutUint32(data[(i*len(tones)+c)*4:], math.Float32bits(float32(sample))) } } - if err := audio.Write(moqaudio.Frame{Timestamp: time.Duration(timestampUs) * time.Microsecond, Data: data}); err != nil { + // Stamped on the sample clock, so every batch time is exact. + if err := audio.Write(moqaudio.Frame{Timestamp: moq.Timestamp{Value: uint64(phase), Scale: audioRate}, Data: data}); err != nil { fmt.Fprintf(os.Stderr, "tone: %v\n", err) return } phase += audioBatchSamples - timestampUs += audioBatchUs } } @@ -504,7 +503,7 @@ func decodeAudio(ctx context.Context, consumer *moq.BroadcastConsumer, id string } samples := len(frame.Data) / frameBytes - fmt.Fprintf(log, "%d %d\n", frame.Timestamp.Microseconds(), samples) + fmt.Fprintf(log, "%d %d\n", moq.TimestampToDuration(frame.Timestamp).Microseconds(), samples) if _, err := pcm.Write(frame.Data); err != nil { return err } diff --git a/test/interop/clients/python/interop.py b/test/interop/clients/python/interop.py index 6e663509991..cf0a899a87f 100644 --- a/test/interop/clients/python/interop.py +++ b/test/interop/clients/python/interop.py @@ -65,7 +65,7 @@ async def _publish_tone(audio: moq.audio.Producer, tones: list[int]) -> None: ] audio.write( moq.audio.Frame( - timestamp=timedelta(microseconds=timestamp_us), data=struct.pack(f"<{len(samples)}f", *samples) + timestamp=moq.Timestamp(timestamp_us, 1_000_000), data=struct.pack(f"<{len(samples)}f", *samples) ) ) phase += AUDIO_BATCH_SAMPLES @@ -275,7 +275,7 @@ async def _decode_audio( if isinstance(frame, moq.audio.End): raise RuntimeError("audio track ended early") samples = len(frame.data) // frame_bytes - log.write(f"{frame.timestamp // timedelta(microseconds=1)} {samples}\n") + log.write(f"{frame.timestamp.to_timedelta() // timedelta(microseconds=1)} {samples}\n") pcm.write(frame.data) decoded += samples if decoded * 1000 >= audio_ms * audio.sample_rate: From 7c2d1dd8781e7c18c4d0077a8932554f91cfb033 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:48:43 -0800 Subject: [PATCH 16/86] fix(mux,hang): a backlog join starts within the max delay in Rust and JS (#7518) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- doc/lib/rs/hang.md | 4 +- js/hang/MIGRATE.md | 2 +- js/hang/src/container/consumer.skips.test.ts | 89 ++++ js/hang/src/container/consumer.ts | 20 +- quest/m1/cut-audit/README.md | 1 - quest/m1/cut-audit/catalog-parity.md | 34 -- rs/moq-mux/CHANGELOG.md | 1 + rs/moq-mux/MIGRATE.md | 2 +- rs/moq-mux/src/container/consumer.rs | 419 +++++++++++++++---- rs/moq-video/src/decode/consumer.rs | 5 +- 10 files changed, 453 insertions(+), 124 deletions(-) delete mode 100644 quest/m1/cut-audit/catalog-parity.md diff --git a/doc/lib/rs/hang.md b/doc/lib/rs/hang.md index d56435f89b7..1350f66a161 100644 --- a/doc/lib/rs/hang.md +++ b/doc/lib/rs/hang.md @@ -27,7 +27,9 @@ the live edge to play: it skips groups that fall past `max`, and starts at the group covering the live edge less `min` (your playout delay), plus a rendition's catalog `warmup` passed to `with_warmup`, waiting up to that long for it when the newest group arrives first. Both default to zero, which starts at the newest -group at once; `max` never moves the start. +group at once. A backlog that landed before the first read starts within `max` +of the newest content, keeping the group covering the live edge less `min` and +the warmup. [`moq-video`](/lib/rs/moq-video) and [`moq-audio`](/lib/rs/moq-audio) publish from a device. Examples: [`video.rs`](https://github.com/moq-dev/moq/blob/main/rs/hang/examples/video.rs) diff --git a/js/hang/MIGRATE.md b/js/hang/MIGRATE.md index ba441db6466..c54c268de42 100644 --- a/js/hang/MIGRATE.md +++ b/js/hang/MIGRATE.md @@ -8,7 +8,7 @@ How to update your code for each breaking change, newest first. `ConsumerProps.maxDelay` is `delay: GetterInit`, where `Delay` is `{ min?: Milli; max?: Milli | null }` and an omitted field is zero. Replace `maxDelay: d` with `delay: { max: d }`, or derive it from a signal. `max: null` is no limit. -`min` is the playout delay: the consumer starts at the group covering the live edge less `min` plus any `warmup`, waiting for it when the newest group arrives first. `max` never moves the start. +`min` is the playout delay: the consumer starts at the group covering the live edge less `min` plus any `warmup`, waiting for it when the newest group arrives first. Before the first read, a backlog that already landed starts at the first group whose first frame is within `max`, plus the warmup, of the newest content, keeping the group covering the live edge less `min` and the warmup. ### The catalog rendition cap is removed diff --git a/js/hang/src/container/consumer.skips.test.ts b/js/hang/src/container/consumer.skips.test.ts index 33a29afae63..a7316087d1b 100644 --- a/js/hang/src/container/consumer.skips.test.ts +++ b/js/hang/src/container/consumer.skips.test.ts @@ -50,3 +50,92 @@ for (const count of [1, 16, 256]) { } }); } + +// Pairs `rs/moq-mux`'s `a_buffered_backlog_starts_within_the_budget`: a backlog that lands whole before +// the first read starts at the first group whose first frame is within the budget of the newest one. +// Five one-second groups of ten frames and a one-frame sixth at 5 s, with a 1.5 s budget, start at the +// 4 s group, whether the sixth is open or finished. +for (const open of [true, false]) { + test(`a buffered backlog starts within the budget (sixth group ${open ? "open" : "finished"})`, async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const track = new Track.Producer("backlog"); + const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + format: new Format("data"), + delay: { max: Milli(1500) }, + }); + const frame = (micros: number) => ({ payload: Varint.encode(micros), timestamp: Time.Timestamp.now() }); + try { + for (let g = 0; g < 5; g++) { + const group = track.createGroup(g); + for (let f = 0; f < 10; f++) group.writeFrame(frame(g * 1_000_000 + f * 100_000)); + group.finish(); + } + const last = track.createGroup(5); + last.writeFrame(frame(5_000_000)); + if (!open) last.finish(); + while (consumer.buffered.peek().at(-1)?.end !== 5000) await consumer.buffered.changed(); + + const read: number[] = []; + for (let i = 0; i < 11; i++) { + const next = await consumer.next(); + if (next?.kind !== "frame") throw new Error(`unexpected read: ${JSON.stringify(next)}`); + read.push(next.frame.timestamp); + } + const expected = [...Array.from({ length: 10 }, (_, f) => 4_000_000 + f * 100_000), 5_000_000]; + expect(read).toEqual(expected); + } finally { + consumer.close(); + track.finish(); + track.close(); + warn.mockRestore(); + } + }); +} + +// The start waits on the next group's first frame, which may land within the budget, rather than +// dropping that group. Here the fifth group (4 s) arrived with its frames still in flight, so the start +// keeps the fourth and plays on into the fifth. Rust's +// `a_backlog_start_waits_on_a_successor_without_frames` pairs this case. +test("a backlog start waits on a successor without frames", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const track = new Track.Producer("backlog"); + const consumer = new Consumer(track.subscribe({ maxDelay: Milli(30_000) }), { + format: new Format("data"), + delay: { max: Milli(1500) }, + }); + const frame = (micros: number) => ({ payload: Varint.encode(micros), timestamp: Time.Timestamp.now() }); + const frames = (g: number) => Array.from({ length: 10 }, (_, f) => g * 1_000_000 + f * 100_000); + const read: number[] = []; + const readN = async (n: number) => { + for (let i = 0; i < n; i++) { + const next = await consumer.next(); + if (next?.kind !== "frame") throw new Error(`unexpected read: ${JSON.stringify(next)}`); + read.push(next.frame.timestamp); + } + }; + try { + for (let g = 0; g < 4; g++) { + const group = track.createGroup(g); + for (const micros of frames(g)) group.writeFrame(frame(micros)); + group.finish(); + } + const fifth = track.createGroup(4); + // The fifth group's frames left before the sixth's, though they land after it. + const sent = Time.Timestamp.now(); + const last = track.createGroup(5); + last.writeFrame(frame(5_000_000)); + last.finish(); + while (consumer.buffered.peek().at(-1)?.end !== 5000) await consumer.buffered.changed(); + + await readN(10); + for (const micros of frames(4)) fifth.writeFrame({ payload: Varint.encode(micros), timestamp: sent }); + fifth.finish(); + await readN(11); + expect(read).toEqual([...frames(3), ...frames(4), 5_000_000]); + } finally { + consumer.close(); + track.finish(); + track.close(); + warn.mockRestore(); + } +}); diff --git a/js/hang/src/container/consumer.ts b/js/hang/src/container/consumer.ts index d89bfae392d..2836e9c254a 100644 --- a/js/hang/src/container/consumer.ts +++ b/js/hang/src/container/consumer.ts @@ -23,7 +23,9 @@ export interface Delay { * late group is waited for. A missing sequence also expires this much monotonic time after * the first group past it arrived, even when the next group is a marker on a paused track. * The local half of the subscription's `maxDelay`; both measure the same budget, one on the wire - * and one as frames are read. It bounds lateness only: it never moves the start. + * and one as frames are read. It bounds a join's lateness too: a backlog that landed before the + * first read starts at the first group whose first frame is within it, plus any warmup, of the + * newest content, though the start keeps the group covering the live edge less `min` and the warmup. */ max?: Milli | null; } @@ -511,11 +513,21 @@ export class Consumer { const age = max - min; if (age <= threshold) break; - // Until the reader warms, a group may cover the start's reach though it began before it: it is - // stale only once its successor starts a whole budget behind the newest content. + // Until the reader warms, a group may cover the start's reach though it began before it. if (this.#warming()) { const nextStart = this.#groups[1].start; - if (nextStart === undefined || max - nextStart < threshold) break; + if (nextStart === undefined) break; + if (this.#edge === undefined) { + // Before the first item, a backlog that landed whole starts within the budget, so only + // the group covering the live edge less the reach is kept: one whose successor starts + // past that point. Rust's start skip, in `Consumer::poll_event_once`, measures the same. + const target = this.#startTarget(this.#reach(this.#delay.peek())); + if (target !== undefined && nextStart > target) break; + } else if (max - nextStart < threshold) { + // Once reading began, it is stale only once its successor starts a whole budget behind + // the newest content. + break; + } } this.#groups.shift(); diff --git a/quest/m1/cut-audit/README.md b/quest/m1/cut-audit/README.md index 7b71567913c..c944b27ed38 100644 --- a/quest/m1/cut-audit/README.md +++ b/quest/m1/cut-audit/README.md @@ -54,7 +54,6 @@ leaving JS-only redesigns of `js/net` to [rs2ts](/quest/m2/rs2ts-js.md). ## Required -- [Rust and JS skip a backlog by the same measure](/quest/m1/cut-audit/catalog-parity.md) - a backlog join starts at the same group in every player - [Text and data sections keep a prototype-named ID](/quest/m1/cut-audit/catalog-proto-ids.md) - text and data entries keep the ID JS's `z.record` drops ## Related diff --git a/quest/m1/cut-audit/catalog-parity.md b/quest/m1/cut-audit/catalog-parity.md deleted file mode 100644 index d78e3a4ea54..00000000000 --- a/quest/m1/cut-audit/catalog-parity.md +++ /dev/null @@ -1,34 +0,0 @@ -# [S] Rust and JS skip a backlog by the same measure - -## Goal - -A player joining a backlog starts at the same group in moq-cli, moqsrc, and -the browser. If they start at different groups, align the two container -consumers' skip measurements, with a test in each. - -## Plan - -Found by the 2026-10-24 pre-cut audit at 151a529670. The catalog half of -this quest (Rust and JS accept the same catalogs) landed separately in -[moq-dev/moq#7035](https://github.com/moq-dev/moq/pull/7035): any malformed -rendition refuses the whole catalog in both languages (maintainer, -2026-10-30). - -Rust measures newest content minus the next group's start -(`rs/moq-mux/src/container/consumer.rs`, the `should_skip` check in the -blocking-group path). JS measures from the oldest unread frame -(`js/hang/src/container/consumer.ts`, `#checkMaxDelay`). The Rust comment -argues for its measure: a group's own first frame is no bound, since a group -longer than the budget would be cut short whenever it blocks behind a newer -one, as a join's backlog does. - -They diverge, reproduced by a throwaway probe in each consumer: five -finished one-second groups of ten frames plus a one-frame sixth group, all -landing before the first read, with a 1500 ms budget. JS starts at the 4.0 s -group, while Rust starts at 0 and plays the whole backlog, whether the sixth -group is open or finished. Rust's skip only runs while the current group -blocks, so a fully buffered backlog never triggers it. - -Decide which start is right (likely JS's, which keeps the join within the -budget), align the other consumer, and keep the scenario as a paired test in -both. diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index a850cefe1a5..b2e93a15cb5 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `container::Consumer` no longer plays a backlog that landed before its first read from the oldest group: until the first item, the start moves past each group whose first frame trails the newest content by more than the max delay plus the warmup, as `@moq/hang`'s consumer does, keeping the group covering the live edge less `Delay::min` and the warmup. A join starts at the same group in Rust and the browser. Once reading begins, only a blocking group is skipped, as before. - fMP4 import applies each track's edit list to its frame timestamps and drops the samples before the edit, such as AAC priming, along with the video samples decoding from a dropped sync sample, and a passthrough init no longer carries the `edts`. A consumer that offsets AAC priming itself must stop, or it shifts the track twice. An edit list it can't reduce to one shift (several media edits, or a rate other than 1) is refused with `Error::UnsupportedEditList`. ## [0.10.11](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.10...moq-mux-v0.10.11) - 2026-10-05 diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index 9abb9fd4679..92d25498a72 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -8,7 +8,7 @@ How to update your code for each breaking change, newest first. `container::Consumer::set_max_delay(Duration)` is `set_delay(Delay)`, and `with_delay(Delay)` is its builder. `container::Delay { min, max }` defaults to zero for both: `max` (`None` for no limit) is the max delay that `set_max_delay` took, and the subscription still carries it. -`min` is the playout delay. An unfloored consumer starts at the group covering the live edge less `min` plus any `with_warmup`, waiting for it when the newest group arrives first; the max delay never moves the start. +`min` is the playout delay. An unfloored consumer starts at the group covering the live edge less `min` plus any `with_warmup`, waiting for it when the newest group arrives first. Before the first read, a backlog that already landed starts at the first group whose first frame is within the max delay, plus the warmup, of the newest content, keeping the group covering the live edge less `min` and the warmup. Replace `consumer.set_max_delay(d)` with `consumer.set_delay(Delay { max: Some(d), ..Delay::default() })`, and set `min` to your playout delay. Call `with_warmup` before `with_delay`. ### Compression moves to the track diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index 2481abd1063..13a04065fa5 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -75,7 +75,11 @@ pub(crate) enum Item { /// [`Subscription::ranges`](moq_net::track::Subscription::ranges) when every range names one, waiting for that group under the budget; without one it /// starts at the lowest group that arrived, waiting for one covering the live edge less /// [`Delay::min`] plus any [`with_warmup`](Self::with_warmup). Both default to zero, so it waits -/// for nothing. The max delay never moves the start. From there +/// for nothing. Until the first item, the start also moves past each group whose first frame +/// trails the newest content by more than the max delay plus the warmup, as `@moq/hang`'s +/// consumer measures, unless that group covers the live edge less the min delay and the warmup: +/// a backlog that landed whole would otherwise play from its oldest group, since nothing +/// blocks, and stay that far behind. From there /// the same budget is the one rule that catches the consumer up to the live edge, and /// history the publisher no longer serves expires like any other gap, since its reach /// sits a full budget behind the newest content. @@ -220,8 +224,10 @@ pub struct Delay { pub min: std::time::Duration, /// How far a group may fall behind the live edge before it is skipped, or `None` for no limit. /// - /// The subscription carries it as [`moq_net::track::Subscription::max_delay`]. It bounds - /// lateness only: it never moves the start. + /// The subscription carries it as [`moq_net::track::Subscription::max_delay`]. It bounds a + /// join's lateness too: a backlog that landed before the first read starts at the first group + /// whose first frame is within it, plus any warmup, of the newest content, though the start + /// keeps the group covering the live edge less [`min`](Self::min) and the warmup. pub max: Option, } @@ -562,6 +568,15 @@ impl Consumer { self.hole_timer = None; } + // Until the first item, a backlog that landed whole starts within the budget: nothing + // blocks, so the skip below would play it from its oldest group and stay that far behind + // for good. The start moves past each group whose first frame trails the newest content + // by more than the budget, as `@moq/hang`'s consumer measures, unless that group covers + // the live edge less the reach (see `with_warmup`), which the start promises. + if self.edge.is_none() && self.start_skip(waiter)? { + continue; + } + // Return the next frame from the current group if possible. // If the current group is finished or errored, advance to the next group. if let Some(group) = self.pending.front_mut() @@ -681,31 +696,10 @@ impl Consumer { None }; - // Find the first newer group with data (our skip target) and where it starts. - let mut next_group = None; - for (i, group) in self.pending.iter_mut().enumerate() { - if group.sequence <= self.current { - continue; - } - - if let Poll::Ready(Some(ts)) = group.poll_min_timestamp(waiter, &self.format, false) { - next_group = Some((i, std::time::Duration::from(ts))); - break; - } - } - - // Find the max timestamp across all newer groups. - let mut max_timestamp = std::time::Duration::ZERO; - for group in self.pending.iter_mut().rev() { - if group.sequence <= self.current { - break; - } - - if let Poll::Ready(Some(ts)) = group.poll_max_timestamp(waiter, &self.format) { - max_timestamp = max_timestamp.max(ts.into()); - break; // We know older groups won't be newer than this. - } - } + // The first newer group with data (our skip target) and where it starts, and the newest + // content past the cursor. + let next_group = self.poll_next_group(waiter); + let max_timestamp = self.poll_newest(waiter); // Walk the cursor over missing sequences below the first arrived group. // Groups race on independent QUIC streams (newer ones at higher priority), @@ -762,9 +756,9 @@ impl Consumer { // The current group is blocking. Everything it could still present ends // where the next group begins, so skip once that reach falls a full budget // behind the newest content, the same measure as a missing group. Its own - // first frame is no bound: a group longer than the budget would be cut - // short whenever it blocks behind a newer one, as a join's backlog does - // while the newer groups race ahead on their own streams. Skip early too + // first frame is no bound once reading began: a group longer than the budget + // would be cut short whenever it blocks behind a newer one racing ahead on its + // own stream. Only the start, above, measures the first frame. Skip early too // once it has presented up to where the next group begins (duration // coverage), since nothing is left worth waiting for. // While the reader warms, the start reached the warmup further back than the max delay. @@ -789,26 +783,7 @@ impl Consumer { }; if let Some((new_idx, next_start)) = target { - // Without a start read yet, nothing proves the timeline continues. - let hole = next_start.is_none_or(|start| !pts_contiguous(current_end.or(self.presented_end), start)); - let had_marker = self.pending.iter().take(new_idx).any(GroupBuffer::marker); - // Buffered frames are thrown away, and `current_end` counts them, so contiguity alone would hide them. - let discarded = self - .pending - .iter() - .take(new_idx) - .any(|group| !group.buffered.is_empty()); - let target = self.pending[new_idx].sequence; - self.discard(|group| group.sequence < target)?; - if hole || had_marker || discarded { - self.lose(); - } - let new_current = self.pending.front().map(|g| g.sequence).unwrap(); - - tracing::debug!(old = self.current, new = new_current, "skipping slow groups"); - - self.current = new_current; - self.note_group_edge(); + self.skip_to(new_idx, next_start, current_end)?; continue; } @@ -825,6 +800,113 @@ impl Consumer { } } + // Before the first item, move the cursor past its group when that group's first frame trails the + // newest content by more than the budget and the next group still starts at or before the live + // edge less the reach, so it is not what the start covers, and repeat for the group it lands on. + // Returns whether it moved. + fn start_skip(&mut self, waiter: &kio::Waiter) -> Result { + // The newest content lies in the highest group with a timestamp, which a skip never drops, so + // one read serves every skip and a long backlog is walked once. + let mut newest = None; + let mut moved = false; + while let Some(group) = self.pending.front_mut() { + if group.sequence > self.current { + break; + } + let _ = group.poll_min_timestamp(waiter, &self.format, true); + let Some((frame, ..)) = group.buffered.front() else { + break; + }; + let first = std::time::Duration::from(frame.timestamp); + let current_end = group.max_end; + // The next group that arrived, even one whose first frame is still in flight: that frame + // may land within the budget, so skipping on to a later group could drop the one the start + // belongs on. Until it lands, nothing is skipped, as `@moq/hang`'s consumer waits on its + // successor's start. + let Some(idx) = self.pending.iter().position(|group| group.sequence > self.current) else { + break; + }; + let Poll::Ready(Some(next_start)) = self.pending[idx].poll_min_timestamp(waiter, &self.format, false) + else { + break; + }; + let next_start = std::time::Duration::from(next_start); + let newest = *newest.get_or_insert_with(|| self.poll_newest(waiter)); + // Nothing has been read, so the reader still warms. + if newest.saturating_sub(first) <= self.max_delay.saturating_add(self.warmup) { + break; + } + // The group the start reaches back to is kept: the next one begins past that point. + let reach = self.min_delay.min(self.max_delay).saturating_add(self.warmup); + if !reach.is_zero() && next_start > newest.saturating_sub(reach) { + break; + } + self.skip_to(idx, Some(next_start), current_end)?; + moved = true; + } + Ok(moved) + } + + // The first group past the cursor with a timestamp, the skip target, and where it starts. + fn poll_next_group(&mut self, waiter: &kio::Waiter) -> Option<(usize, std::time::Duration)> { + for (i, group) in self.pending.iter_mut().enumerate() { + if group.sequence <= self.current { + continue; + } + + if let Poll::Ready(Some(ts)) = group.poll_min_timestamp(waiter, &self.format, false) { + return Some((i, ts.into())); + } + } + None + } + + // The max timestamp across the groups past the cursor, or zero without one. + fn poll_newest(&mut self, waiter: &kio::Waiter) -> std::time::Duration { + for group in self.pending.iter_mut().rev() { + if group.sequence <= self.current { + break; + } + + // Older groups won't be newer than this. + if let Poll::Ready(Some(ts)) = group.poll_max_timestamp(waiter, &self.format) { + return ts.into(); + } + } + std::time::Duration::ZERO + } + + // Move the cursor to the pending group at `new_idx`, dropping what lies below it. `next_start` is + // where that group begins, if read, and `current_end` how far the group left behind presented. + fn skip_to( + &mut self, + new_idx: usize, + next_start: Option, + current_end: Option, + ) -> Result<(), F::Error> { + // Without a start read yet, nothing proves the timeline continues. + let hole = next_start.is_none_or(|start| !pts_contiguous(current_end.or(self.presented_end), start)); + let had_marker = self.pending.iter().take(new_idx).any(GroupBuffer::marker); + // Buffered frames are thrown away, and `current_end` counts them, so contiguity alone would hide them. + let discarded = self + .pending + .iter() + .take(new_idx) + .any(|group| !group.buffered.is_empty()); + // `pending` is in sequence order, so what lies below the target is its prefix. + self.discard_front(new_idx)?; + if hole || had_marker || discarded { + self.lose(); + } + let new_current = self.pending.front().map(|g| g.sequence).unwrap(); + + tracing::debug!(old = self.current, new = new_current, "skipping slow groups"); + + self.current = new_current; + self.note_group_edge(); + Ok(()) + } + // Where an unfloored start begins, the lowest group that arrived, or `None` while it waits for // one covering the reach (see `with_warmup`), registering the waiter on whatever would end the wait. fn reach_start(&mut self, waiter: &kio::Waiter, finished: bool) -> Option { @@ -1021,17 +1103,37 @@ impl Consumer { return true; } if fault.is_none() { - fault = match (start, group.min_timestamp) { - (Some((prev, floor)), Some(min)) - if group.sequence > prev && min.as_micros() < floor.as_micros() => - { - Some(TimestampRewind { timestamp: min, floor }.into()) - } - _ => group.fault.take(), - }; + fault = Self::discard_fault(start, group); } false }); + self.discarded(fault) + } + + // `discard` of the lowest `count` pending groups, in time proportional to them rather than to every + // pending group, so a start that skips a long backlog one group at a time stays linear. + fn discard_front(&mut self, count: usize) -> Result<(), F::Error> { + let start = self.start; + let mut fault = None; + for mut group in self.pending.drain(..count) { + if fault.is_none() { + fault = Self::discard_fault(start, &mut group); + } + } + self.discarded(fault) + } + + // The fault a discarded group would hide: its own, or frames below the latest delivered start. + fn discard_fault(start: Option<(u64, Timestamp)>, group: &mut GroupBuffer) -> Option { + match (start, group.min_timestamp) { + (Some((prev, floor)), Some(min)) if group.sequence > prev && min.as_micros() < floor.as_micros() => { + Some(TimestampRewind { timestamp: min, floor }.into()) + } + _ => group.fault.take(), + } + } + + fn discarded(&self, fault: Option) -> Result<(), F::Error> { match fault { Some(err) => { tracing::warn!(track = &*self.name, error = ?err, "discarded a malformed group; ending the reader"); @@ -1970,10 +2072,23 @@ mod tests { track.subscribe(moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10))); let mut reader = Consumer::new(subscription, Container::Legacy(crate::container::Kind::Audio)); reader.max_delay = Duration::ZERO; - write_group(&mut track, 0, &[ts(0)]); + // Group 0 stays open, and is read before the newer group lands: the skip comes after the start. + let mut group = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); + Container::Legacy(crate::container::Kind::Audio) + .write( + &mut group, + &[Frame { + timestamp: ts(0), + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: false, + duration: None, + }], + ) + .unwrap(); + assert!(matches!(reader.read().await.unwrap(), Some(Read::Frame { .. }))); write_group(&mut track, 3, &[ts(1_000_000)]); track.finish().unwrap(); - assert_eq!(items(&mut reader).await, ["0", "gap 0+1000000", "1000000"]); + assert_eq!(items(&mut reader).await, ["gap 0+1000000", "1000000"]); } /// A video group's trailing duration marker is that frame's end, not a declared stop. @@ -2120,11 +2235,24 @@ mod tests { // Keep transport filtering out of this test so it isolates the mux skip logic. consumer.max_delay = Duration::ZERO; - write_group(&mut track, 0, &[ts(0)]); + // Group 0 stays open, and is read before the newer group lands: the skip comes after the start. + let mut group = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); + Container::Legacy(crate::container::Kind::Audio) + .write( + &mut group, + &[Frame { + timestamp: ts(0), + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: false, + duration: None, + }], + ) + .unwrap(); + assert!(matches!(consumer.read().await.unwrap(), Some(Read::Frame { .. }))); write_group(&mut track, 3, &[ts(1_000_000)]); track.finish().unwrap(); - assert_eq!(items(&mut consumer).await, ["0", "gap 0+1000000", "1000000"]); + assert_eq!(items(&mut consumer).await, ["gap 0+1000000", "1000000"]); } #[tokio::test] @@ -2230,6 +2358,11 @@ mod tests { ) .unwrap(); + // Read group 0's frame before the later groups land, so they skip a stalled group rather + // than move the start. + let first = consumer.read_frame().await.unwrap().unwrap(); + assert_eq!(first.timestamp, ts(0)); + for g in 1..10u64 { let timestamps: Vec<_> = (0..3).map(|f| ts(g * 50_000 + f * 5_000)).collect(); write_group(&mut track, g, ×tamps); @@ -2242,7 +2375,7 @@ mod tests { }); let frames = read_all(&mut consumer).await.unwrap(); - assert_eq!(frames.len(), 28, "Expected group 0 frame + groups 1-9"); + assert_eq!(frames.len(), 27, "Expected groups 1-9 after group 0's frame"); assert!(!frames.is_empty(), "Expected at least some frames"); finisher.await.expect("finisher task panicked"); } @@ -2267,6 +2400,11 @@ mod tests { ) .unwrap(); + // Read group 0's frame before the later groups land, so they skip a stalled group rather + // than move the start. + let first = consumer.read_frame().await.unwrap().unwrap(); + assert_eq!(first.timestamp, ts(0)); + for g in 1..10u64 { write_group(&mut track, g, &[ts(g * 30_000)]); } @@ -2278,20 +2416,17 @@ mod tests { }); let frames = read_all(&mut consumer).await.unwrap(); - assert!(!frames.is_empty(), "Expected at least some frames"); - assert_eq!(frames.len(), 10, "Expected group 0 frame + groups 1-9"); - assert_eq!(frames[0].timestamp, ts(0)); - - for i in 1..10u64 { - assert_eq!(frames[i as usize].timestamp, ts(i * 30_000)); + assert_eq!(frames.len(), 9, "Expected groups 1-9 after group 0's frame"); + for (i, frame) in (1..10u64).zip(&frames) { + assert_eq!(frame.timestamp, ts(i * 30_000)); } finisher.await.expect("finisher task panicked"); } /// A group longer than the budget that blocks for a moment behind a newer group is /// not cut short while what it could still present (up to where the newer group - /// begins) is within the budget. A join's backlog does exactly this: the newer group - /// races ahead on its own stream while the older one is still arriving. + /// begins) is within the budget: the newer group races ahead on its own stream while + /// the older one is still arriving. #[test] fn a_long_group_blocked_behind_a_newer_one_is_not_cut_short() { let waiter = kio::Waiter::noop(); @@ -2317,15 +2452,17 @@ mod tests { } }; - // A 2.5 s group, half arrived, and the next one already 0.5 s in. + // A 2.5 s group, half arrived, and the next one already 0.5 s in. The reader started + // before the next one landed, so this is a stall, not a join's backlog, which starts + // within the budget instead (see `a_buffered_backlog_starts_within_the_budget`). let mut group0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); write(&mut group0, ts(0)); write(&mut group0, ts(500_000)); + assert_eq!(read(&mut consumer), Some(ts(0))); let mut group1 = track.create_group(moq_net::group::Info { sequence: 1 }).unwrap(); write(&mut group1, ts(2_500_000)); write(&mut group1, ts(3_000_000)); - assert_eq!(read(&mut consumer), Some(ts(0))); assert_eq!(read(&mut consumer), Some(ts(500_000))); // Group 0's reach (2.5 s) is 0.5 s behind the newest frame: wait for it. assert_eq!(read(&mut consumer), None, "cut group 0 short"); @@ -2344,6 +2481,98 @@ mod tests { group2.finish().unwrap(); } + /// A backlog that lands whole before the first read starts at the first group whose first + /// frame is within the budget of the newest one, though nothing blocks. Five one-second + /// groups of ten frames and a one-frame sixth at 5 s, with a 1.5 s budget, start at the 4 s + /// group, whether the sixth is open or finished. `js/hang`'s `consumer.skips.test.ts` pairs + /// this case. + #[test] + fn a_buffered_backlog_starts_within_the_budget() { + for open in [true, false] { + let waiter = kio::Waiter::noop(); + let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); + let mut consumer = container_max_delay_only(track.subscribe(None), Duration::from_millis(1500)); + for g in 0..5u64 { + let timestamps: Vec<_> = (0..10).map(|f| ts(g * 1_000_000 + f * 100_000)).collect(); + write_group(&mut track, g, ×tamps); + } + let mut last = track.create_group(moq_net::group::Info { sequence: 5 }).unwrap(); + Container::Legacy(crate::container::Kind::Data) + .write( + &mut last, + &[Frame { + timestamp: ts(5_000_000), + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: false, + duration: None, + }], + ) + .unwrap(); + if !open { + last.finish().unwrap(); + } + + let mut read = Vec::new(); + while let Poll::Ready(Ok(Some(item))) = consumer.poll_read(&waiter) { + match item { + Read::Frame { frame, .. } => read.push(frame.timestamp), + other => panic!("unexpected read: {other:?}"), + } + } + let expected: Vec<_> = (0..10) + .map(|f| ts(4_000_000 + f * 100_000)) + .chain([ts(5_000_000)]) + .collect(); + assert_eq!(read, expected, "sixth group open: {open}"); + } + } + + /// A backlog join's start skip waits on the next group's first frame, which may land within the + /// budget, rather than skipping on to a later group with data and dropping it. Here the fifth + /// group (4.0 s) arrived with its frames still in flight, so the start keeps the fourth and + /// plays on into the fifth. `js/hang`'s `consumer.skips.test.ts` pairs this case. + #[test] + fn a_backlog_start_waits_on_a_successor_without_frames() { + let waiter = kio::Waiter::noop(); + let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); + let mut consumer = container_max_delay_only(track.subscribe(None), Duration::from_millis(1500)); + let frames = |g: u64| -> Vec<_> { (0..10).map(|f| ts(g * 1_000_000 + f * 100_000)).collect() }; + for g in 0..4u64 { + write_group(&mut track, g, &frames(g)); + } + let mut fifth = track.create_group(moq_net::group::Info { sequence: 4 }).unwrap(); + write_group(&mut track, 5, &[ts(5_000_000)]); + + let mut read = Vec::new(); + let mut drain = |consumer: &mut Consumer| { + while let Poll::Ready(Ok(Some(item))) = consumer.poll_read(&waiter) { + match item { + Read::Frame { frame, .. } => read.push(frame.timestamp), + other => panic!("unexpected read: {other:?}"), + } + } + }; + drain(&mut consumer); + for frame in frames(4) { + Container::Legacy(crate::container::Kind::Data) + .write( + &mut fifth, + &[Frame { + timestamp: frame, + payload: Bytes::from_static(&[0xDE, 0xAD]), + keyframe: false, + duration: None, + }], + ) + .unwrap(); + } + fifth.finish().unwrap(); + drain(&mut consumer); + + let expected: Vec<_> = frames(3).into_iter().chain(frames(4)).chain([ts(5_000_000)]).collect(); + assert_eq!(read, expected); + } + // ---- Malformed rewind ---- /// The track refuses a group whose first frame rewinds, but not a later frame that does, @@ -3272,6 +3501,31 @@ mod tests { assert_eq!(micros, vec![4_950_000, 5_000_000], "the warmup is not skipped as stale"); } + /// The start keeps the group covering the reach though its first frame trails the newest + /// content by more than the max delay plus the warmup. `js/hang`'s + /// `consumer.warmup.test.ts` pairs this case. + #[tokio::test] + async fn the_start_keeps_a_group_that_began_before_the_reach_but_covers_it() { + tokio::time::pause(); + let mut track = track_producer("test", hang::container::track_info(hang::catalog::PRIORITY.video)); + let mut consumer = reach_consumer( + &mut track, + Duration::from_millis(200), + Duration::from_millis(200), + Duration::from_millis(300), + ); + + write_group(&mut track, 5, &[ts(5_000_000)]); + // Group 4 begins 600ms before the newest content, past the 500ms reach, yet covers its 4.5s target. + write_group(&mut track, 4, &[ts(4_400_000), ts(4_600_000), ts(4_900_000)]); + let frame = kio::clock::timeout(Duration::from_millis(50), consumer.read_frame()) + .await + .expect("group 4 covers the reach") + .unwrap() + .unwrap(); + assert_eq!(frame.timestamp, ts(4_400_000)); + } + /// The warmup is carved out of the requested budget before the retention clamp, so a max delay /// the track can keep stays whole. #[tokio::test] @@ -4104,13 +4358,14 @@ mod tests { ); // Group 9 pushes the newest content a full budget past what group 5 could still - // present (bounded by group 7's start), expiring it. - write_group(&mut track, 9, &[ts(800_000)]); + // present (bounded by group 7's start), expiring it. Exactly a budget, so group 7's + // first frame is not past it and the start stays there. + write_group(&mut track, 9, &[ts(710_000)]); track.finish().unwrap(); let frames = read_all(&mut consumer).await.unwrap(); let micros: Vec = frames.iter().map(|f| f.timestamp.as_micros()).collect(); - assert_eq!(micros, vec![210_000, 800_000], "the expired frameless group is skipped"); + assert_eq!(micros, vec![210_000, 710_000], "the expired frameless group is skipped"); } /// An aborted frameless group beyond a sequence gap is reaped rather than parked on @@ -4378,6 +4633,9 @@ mod tests { ) .unwrap(); + // Read before group 1 lands, so the skip comes after the start. + assert_eq!(consumer.read_frame().await.unwrap().unwrap().timestamp, ts(0)); + // Group 1: finished, 200ms ahead (well beyond the 100ms max delay) write_group(&mut track, 1, &[ts(200_000)]); track.finish().unwrap(); @@ -4388,7 +4646,7 @@ mod tests { }); let frames = read_all(&mut consumer).await.unwrap(); - assert_eq!(frames.len(), 2, "Expected group 0 frame + group 1 frame"); + assert_eq!(frames.len(), 1, "Expected group 1's frame after group 0's"); finisher.await.unwrap(); } @@ -4402,14 +4660,15 @@ mod tests { let consumer_track = track.subscribe(None); let mut consumer = container_max_delay_only(consumer_track, Duration::from_millis(100)); - // Group 0: finished normally + // Group 0: finished normally, its first frame read before group 2 lands. write_group(&mut track, 0, &[ts(0), ts(20_000)]); + assert_eq!(consumer.read_frame().await.unwrap().unwrap().timestamp, ts(0)); // Group 2: finished (group 1 is missing — sequence gap) write_group(&mut track, 2, &[ts(200_000)]); track.finish().unwrap(); let frames = read_all(&mut consumer).await.unwrap(); - assert_eq!(frames.len(), 3, "Expected group 0 (2 frames) + group 2 (1 frame)"); + assert_eq!(frames.len(), 2, "Expected group 0's second frame + group 2 (1 frame)"); } #[tokio::test] diff --git a/rs/moq-video/src/decode/consumer.rs b/rs/moq-video/src/decode/consumer.rs index 32deba77a08..70b285e6889 100644 --- a/rs/moq-video/src/decode/consumer.rs +++ b/rs/moq-video/src/decode/consumer.rs @@ -1390,8 +1390,9 @@ mod tests { group(&mut track, 1, &[4, 5]).finish().unwrap(); track.finish().unwrap(); - // A budget that keeps every cached group, so both decoders read them all. - let mut consumer = dual(&broadcast, Start::Oldest, WARMUP, probe::BUFFERED_NAME).await; + // A budget past the whole 5 s backlog, so the start keeps every cached group and both + // decoders read them all. + let mut consumer = dual(&broadcast, Start::Oldest, 2 * WARMUP, probe::BUFFERED_NAME).await; let mut shown = Vec::new(); while let Some(frame) = consumer.next_frame().await.unwrap() { shown.push(u64::try_from(frame.timestamp.as_micros()).unwrap() / SECOND); From 3e1137daad3c937623c05a433c646bc92ddeb550 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 17:55:51 -0800 Subject: [PATCH 17/86] quest: delete adapter-waker-alloc, measured no-win (#7556) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m2/perf/README.md | 1 - quest/m2/perf/adapter-waker-alloc.md | 36 ---------------------------- 2 files changed, 37 deletions(-) delete mode 100644 quest/m2/perf/adapter-waker-alloc.md diff --git a/quest/m2/perf/README.md b/quest/m2/perf/README.md index 5465ca33590..35f1970a844 100644 --- a/quest/m2/perf/README.md +++ b/quest/m2/perf/README.md @@ -97,7 +97,6 @@ revisions on the same host and worker count, and profiles with - [An idle export poll is cheap](/quest/m2/perf/export-idle-poll.md) - an export poll nothing woke skips the track fill and catalog work it repeats today - [Mesh session cost](/quest/m2/perf/mesh-session-cost.md) - moq-net's per-session announce, route sync, and scope work drops, swept over sessions and announces - [Cluster route memory](/quest/m2/perf/cluster-route-memory.md) - a full-mesh cluster's ~3.7 KB per route per broadcast shrinks, the in-memory hop is profiled, and `bench-profile lock` tolerates lost records -- [Adapter reads reuse their waker](/quest/m2/perf/adapter-waker-alloc.md) - a measured verdict on the per-read waker allocation moq-tokio and moq-uring reads pay since #7042, fixed if real - [Lite request allocations](/quest/m2/perf/lite-request-allocs.md) - `Path::concat` allocates once and the lite request handler keeps one track-name copy - [Coverage before START](/quest/m2/perf/covered-before-start.md) - the lite-07 publisher's coverage check costs O(1) per wake before SUBSCRIBE_START - [Lock-free single-delivery writes](/quest/m2/perf/single-delivery-write.md) - a frame with one delivery writes without a lock, recovering #6922's remaining FETCH cost diff --git a/quest/m2/perf/adapter-waker-alloc.md b/quest/m2/perf/adapter-waker-alloc.md deleted file mode 100644 index e8a3ffce37f..00000000000 --- a/quest/m2/perf/adapter-waker-alloc.md +++ /dev/null @@ -1,36 +0,0 @@ -# [S] Adapter reads reuse their waker - -## Goal - -A measured verdict on what a parking read costs in the production adapters -(moq-tokio, moq-uring) since -[moq-dev/moq#7042](https://github.com/moq-dev/moq/pull/7042), and a fix if -the cost is real: a stream read that has to wait stops allocating a fresh -shared waker each time. - -## Plan - -Decided 2026-11-01 (maintainer, `/quest-plan`): measure before fixing, since -no bench has shown the cost yet. - -Since #7042, transport polls take a `kio::Waiter` through `&self`, and every -`kio::wait` future (`rs/kio/src/waiter.rs`) holds a waiter of its own. The -moq-net coding layer (`rs/moq-net/src/coding/reader.rs`, `writer.rs`) wraps -each decode or flush in one, so a read that parks allocates the waiter's -shared `Arc` every time, where a per-stream `kio::Park` used to reuse -one. - -[moq-dev/moq#7262](https://github.com/moq-dev/moq/pull/7262) fixed the -in-memory transport's version (it registered on the session close per read). -The memory-transport benches rarely park, so they cannot see the adapters' -cost. - -Measure first: user-space instruction counts and allocations, pinned to one -core, on moq-uring's `session_lite` bench (`just rs bench-session`) and, for -moq-tokio, which has no crate-level session bench, the relay's -`rs/moq-relay/benches/session.rs` or `just bench BASE`. Measure a revision -before #7042 next to main. A measured no-win deletes this quest. - -If it is real, let a stream keep one waiter (or `kio::Park`) across reads -again, for example in `coding::Reader`, instead of one per `kio::wait` -future. From 1f27d416dac77f07a3a957276f755f5b1aa51359 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 18:07:45 -0800 Subject: [PATCH 18/86] chore(quest): plan dispatch.sh's tests passing without KVM (#7587) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m0/ci/README.md | 1 + quest/m0/ci/dispatch-test-kvm.md | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 quest/m0/ci/dispatch-test-kvm.md diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index 4c3cb465c0e..241019846d3 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -166,4 +166,5 @@ The historical samples locate candidates; they are not controlled benchmarks. - [The queue host keeps the queue's disk](/quest/m0/ci/queue-host-disk.md) - agent sessions can't starve the queue runner of disk, and a crashed runner restarts - [The next test-CPU cuts](/quest/m0/ci/test-cost-followups.md) - the slow-test leftovers from #6987, cut at their source - [Biome checks every file it can parse](/quest/m0/ci/biome-whole-repo.md) - Biome runs over the whole repository and main is clean under it +- [dispatch.sh's tests pass without KVM](/quest/m0/ci/dispatch-test-kvm.md) - `sh/dispatch-query.test.py` fakes the KVM probe instead of failing 20 tests on hosts without `/dev/kvm` - [mbx launches Cargo with any argument length](/quest/m0/ci/mbx-arg-max.md) - no "Argument list too long" in `sh/rs/select.test.py`'s recipe test diff --git a/quest/m0/ci/dispatch-test-kvm.md b/quest/m0/ci/dispatch-test-kvm.md new file mode 100644 index 00000000000..e80d80efb04 --- /dev/null +++ b/quest/m0/ci/dispatch-test-kvm.md @@ -0,0 +1,22 @@ +# [XS] dispatch.sh's tests pass without KVM + +## Goal + +`sh/dispatch-query.test.py` passes on a Linux x86_64 host without +`/dev/kvm`, as it does on CI's KVM runners. dispatch.sh still refuses +`check flake` on a host without KVM, the refusal that +[moq-dev/moq#7281](https://github.com/moq-dev/moq/pull/7281) added. + +## Plan + +Decided by the maintainer on 2026-11-01: plan it in m0 ✅ / m1 / drop. + +- Today 20 of its 30 tests fail on such a host. Every run whose selection + includes `check flake` reaches the real `/dev/kvm` probe in + `sh/dispatch.sh`, so the mocked CI run exits 1. `just check` on main + fails locally there too. +- The test harness owns the fix: let it fake the probe's answer, for example + by pointing dispatch.sh at a probe path the test sets. Don't loosen the + refusal, and don't skip the tests when KVM is missing. +- Add a case for the refusal itself: with the probe reporting no KVM, a run + selecting `check flake` fails and names the missing `/dev/kvm`. From 44979f49a867a4a869827342596cf480f7b1c20b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 18:08:17 -0800 Subject: [PATCH 19/86] ci: pin binding generators by commit; pin NASM and nextest; tighten winget and ts grants (#7217) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- .github/actions/nasm/action.yml | 13 +++++++++ .github/actions/uniffi-bindgen/action.yml | 35 +++++++++++++++++++++++ .github/workflows/c.yml | 5 ++-- .github/workflows/cpp.yml | 5 ++-- .github/workflows/platform.yml | 18 ++++++------ .github/workflows/release-binary.yml | 6 +--- .github/workflows/release-c.yml | 13 +++------ .github/workflows/release-cpp.yml | 13 +++------ .github/workflows/release-ffi.yml | 13 ++------- .github/workflows/release-gate.yml | 5 +++- .github/workflows/release-shared-ffi.yml | 5 ++-- .github/workflows/release-winget.yml | 7 +++-- .oss-scanner/Dockerfile | 4 ++- flake.nix | 13 +++++---- quest/m1/security-audit/README.md | 1 - quest/m1/security-audit/ci-pins.md | 35 ----------------------- 16 files changed, 96 insertions(+), 95 deletions(-) create mode 100644 .github/actions/nasm/action.yml create mode 100644 .github/actions/uniffi-bindgen/action.yml delete mode 100644 quest/m1/security-audit/ci-pins.md diff --git a/.github/actions/nasm/action.yml b/.github/actions/nasm/action.yml new file mode 100644 index 00000000000..2fa0c54c48f --- /dev/null +++ b/.github/actions/nasm/action.yml @@ -0,0 +1,13 @@ +name: Install NASM +description: Install the pinned NASM on a Windows runner, where aws-lc-rs assembles its x86_64 crypto with it. + +runs: + using: composite + steps: + # The Chocolatey package version, not NASM's own (3.2.0 installs NASM 3.02). + # GITHUB_PATH gets a constant directory, nothing an event can steer. + - name: Install NASM + shell: pwsh + run: | # zizmor: ignore[github-env] + choco install nasm --version=3.2.0 -y --no-progress + "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append diff --git a/.github/actions/uniffi-bindgen/action.yml b/.github/actions/uniffi-bindgen/action.yml new file mode 100644 index 00000000000..199b198d400 --- /dev/null +++ b/.github/actions/uniffi-bindgen/action.yml @@ -0,0 +1,35 @@ +name: Install a uniffi binding generator +description: Build the pinned uniffi-bindgen-cpp or uniffi-bindgen-go for jobs outside the nix dev shell, which already provides both. + +inputs: + generator: + description: "`cpp` for uniffi-bindgen-cpp (which also renders C) or `go` for uniffi-bindgen-go." + required: true + +runs: + using: composite + steps: + # The one CI pin for each generator. A tag can be moved and `--locked` pins + # only the dependencies, so each pin is the commit its flake.nix tag peels + # to: `git ls-remote 'refs/tags/^{}'`. Bump it with the tag in + # flake.nix; see the comments there. + - name: Install uniffi-bindgen-${{ inputs.generator }} + shell: bash + env: + GENERATOR: ${{ inputs.generator }} + run: | + case "$GENERATOR" in + cpp) + repo=https://github.com/kixelated/uniffi-bindgen-cpp + rev=a5a1ac411772b3f589859edf50aef7bc7538100b # v0.11.0-kixelated.7+v0.32.2 + ;; + go) + repo=https://github.com/kixelated/uniffi-bindgen-go + rev=374a887fea3cde238ac3c5646dbd2963de7a6a83 # v0.10.0-kixelated.1+v0.32.0 + ;; + *) + echo "::error::unknown uniffi generator: $GENERATOR" + exit 1 + ;; + esac + cargo install --locked "uniffi-bindgen-$GENERATOR" --git "$repo" --rev "$rev" diff --git a/.github/workflows/c.yml b/.github/workflows/c.yml index 484bd5b8698..6c5fb67685e 100644 --- a/.github/workflows/c.yml +++ b/.github/workflows/c.yml @@ -35,8 +35,9 @@ jobs: - uses: ./.github/actions/just - - name: Install uniffi-bindgen-cpp - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.7+v0.32.2 + - uses: ./.github/actions/uniffi-bindgen + with: + generator: cpp - name: Check run: just c check diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml index 6933677393f..c73bf234e87 100644 --- a/.github/workflows/cpp.yml +++ b/.github/workflows/cpp.yml @@ -35,8 +35,9 @@ jobs: - uses: ./.github/actions/just - - name: Install uniffi-bindgen-cpp - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.7+v0.32.2 + - uses: ./.github/actions/uniffi-bindgen + with: + generator: cpp - name: Check run: just cpp check diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index 78c6691b21b..e4128dc052b 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -87,12 +87,9 @@ jobs: fetch-depth: 0 - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Install NASM - shell: pwsh - run: | - choco install nasm -y --no-progress - "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - uses: ./.github/actions/nasm - uses: ./.github/actions/just + # Bump with the macOS job's copy and .oss-scanner/Dockerfile's NEXTEST_VERSION. - name: Install nextest if: needs.scope.outputs.rs == 'true' uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31 @@ -104,10 +101,10 @@ jobs: with: access-key-id: ${{ secrets.MBX_R2_ACCESS_KEY_ID }} secret-access-key: ${{ secrets.MBX_R2_SECRET_ACCESS_KEY }} - - name: Install uniffi-bindgen-cpp + - uses: ./.github/actions/uniffi-bindgen if: needs.scope.outputs.c == 'true' || needs.scope.outputs.cpp == 'true' || needs.scope.outputs.obs == 'true' - shell: bash - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.7+v0.32.2 + with: + generator: cpp - name: Rust compile and tests if: needs.scope.outputs.rs == 'true' shell: bash @@ -155,9 +152,10 @@ jobs: with: access-key-id: ${{ secrets.MBX_R2_ACCESS_KEY_ID }} secret-access-key: ${{ secrets.MBX_R2_SECRET_ACCESS_KEY }} - - name: Install uniffi-bindgen-cpp + - uses: ./.github/actions/uniffi-bindgen if: needs.scope.outputs.obs == 'true' - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.7+v0.32.2 + with: + generator: cpp - name: Rust compile and tests if: needs.scope.outputs.rs == 'true' env: diff --git a/.github/workflows/release-binary.yml b/.github/workflows/release-binary.yml index 3e17ef804a7..0bd4034068c 100644 --- a/.github/workflows/release-binary.yml +++ b/.github/workflows/release-binary.yml @@ -182,11 +182,7 @@ jobs: targets: ${{ matrix.target }} # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. - - name: Install NASM - shell: pwsh - run: | - choco install nasm -y --no-progress - "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - uses: ./.github/actions/nasm - name: Parse version id: parse diff --git a/.github/workflows/release-c.yml b/.github/workflows/release-c.yml index 7ec826f4cb2..010f1b26084 100644 --- a/.github/workflows/release-c.yml +++ b/.github/workflows/release-c.yml @@ -50,17 +50,12 @@ jobs: uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. - - name: Install NASM + - uses: ./.github/actions/nasm if: runner.os == 'Windows' - shell: pwsh - run: | - choco install nasm -y --no-progress - "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - # The tag flake.nix pins; see the comment there. - - name: Install uniffi-bindgen-cpp - shell: bash - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.7+v0.32.2 + - uses: ./.github/actions/uniffi-bindgen + with: + generator: cpp # The package version lives in c/moq/VERSION; a tag that disagrees is a mistake. - name: Check version diff --git a/.github/workflows/release-cpp.yml b/.github/workflows/release-cpp.yml index 62fa3a44fc5..a9cabd443d2 100644 --- a/.github/workflows/release-cpp.yml +++ b/.github/workflows/release-cpp.yml @@ -64,17 +64,12 @@ jobs: uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable # aws-lc-rs assembles its x86_64 crypto with NASM on Windows. - - name: Install NASM + - uses: ./.github/actions/nasm if: runner.os == 'Windows' - shell: pwsh - run: | - choco install nasm -y --no-progress - "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - # The tag flake.nix pins; see the comment there. - - name: Install uniffi-bindgen-cpp - shell: bash - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.7+v0.32.2 + - uses: ./.github/actions/uniffi-bindgen + with: + generator: cpp # The package version lives in cpp/moq/VERSION; a tag that disagrees is a mistake. - name: Check version diff --git a/.github/workflows/release-ffi.yml b/.github/workflows/release-ffi.yml index e67c121789b..6f994e3c940 100644 --- a/.github/workflows/release-ffi.yml +++ b/.github/workflows/release-ffi.yml @@ -143,17 +143,10 @@ jobs: # whatever the shared R2 cache holds. # Go uses a third-party generator; build.sh skips Go when it is absent. - - name: Install uniffi-bindgen-go + - uses: ./.github/actions/uniffi-bindgen if: matrix.language == 'go' - env: - # A fork until NordSecurity ships a uniffi 0.32 generator; see flake.nix. - # The revision is v0.10.0-kixelated.1+v0.32.0. - UNIFFI_BINDGEN_GO_REPO: https://github.com/kixelated/uniffi-bindgen-go - UNIFFI_BINDGEN_GO_REV: v0.10.0-kixelated.1+v0.32.0 - run: | - cargo install --locked uniffi-bindgen-go \ - --git "$UNIFFI_BINDGEN_GO_REPO" \ - --rev "$UNIFFI_BINDGEN_GO_REV" + with: + generator: go - name: Download binding metadata if: contains(fromJSON(inputs.targets), 'x86_64-unknown-linux-gnu') diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index da023d2d95b..9e0d2aaa3c4 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -117,6 +117,7 @@ jobs: - "cpp/moq/cmake/moq-ffi.cmake" - "Cargo.lock" - ".github/workflows/c.yml" + - ".github/actions/uniffi-bindgen/**" cpp: - "cpp/moq/**" - "cpp/justfile" @@ -128,6 +129,7 @@ jobs: - "doc/lib/samples.sh" - "Cargo.lock" - ".github/workflows/cpp.yml" + - ".github/actions/uniffi-bindgen/**" # A backported change to the interop harness itself; nightly covers main. interop: - "test/lib/**" @@ -200,6 +202,8 @@ jobs: - "sh/go/publish-ffi.sh" - "rs/moq-ffi/build.sh" - ".cargo/config.toml" + # release-ffi.yml installs the Go generator through this action. + - ".github/actions/uniffi-bindgen/**" release-js: - "js/**" - ".github/workflows/release-js.yml" @@ -394,7 +398,6 @@ jobs: secrets: inherit permissions: contents: read - id-token: write uring: name: io_uring diff --git a/.github/workflows/release-shared-ffi.yml b/.github/workflows/release-shared-ffi.yml index 60a885953ee..690a44fa993 100644 --- a/.github/workflows/release-shared-ffi.yml +++ b/.github/workflows/release-shared-ffi.yml @@ -90,8 +90,9 @@ jobs: - uses: ./.github/actions/just - name: Install Rust uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - name: Install uniffi-bindgen-cpp - run: cargo install uniffi-bindgen-cpp --locked --git https://github.com/kixelated/uniffi-bindgen-cpp --tag v0.11.0-kixelated.7+v0.32.2 + - uses: ./.github/actions/uniffi-bindgen + with: + generator: cpp - name: Download default-feature input uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: diff --git a/.github/workflows/release-winget.yml b/.github/workflows/release-winget.yml index 7e09809eca0..2b7d8723d1d 100644 --- a/.github/workflows/release-winget.yml +++ b/.github/workflows/release-winget.yml @@ -54,11 +54,14 @@ env: jobs: publish: name: Submit winget manifest - # workflow_run: only after a successful release of a tag the `release tags` - # ruleset protects. workflow_dispatch: always (it's an explicit dry run). + # workflow_run: only after a successful tag push in this repository, of a + # tag the `release tags` ruleset protects; a fork PR can rename a workflow + # and its branch to match. workflow_dispatch: always (an explicit dry run). if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_repository.full_name == github.repository && (startsWith(github.event.workflow_run.head_branch, 'moq-relay-v') || startsWith(github.event.workflow_run.head_branch, 'moq-cli-v'))) runs-on: windows-latest diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile index 99ffd13a511..b16764819e2 100644 --- a/.oss-scanner/Dockerfile +++ b/.oss-scanner/Dockerfile @@ -15,9 +15,11 @@ WORKDIR /src # rust-toolchain.toml pins the toolchain; rustup installs it on first use. # CI runs tests with nextest, one process per test, and some tests rely on that isolation. +# The version is the one .github/workflows/platform.yml installs; bump them together. +ARG NEXTEST_VERSION=0.9.146 RUN rustup show active-toolchain || rustup toolchain install \ && case "$(uname -m)" in aarch64) nextest=linux-arm ;; *) nextest=linux ;; esac \ - && curl -LsSf "https://get.nexte.st/latest/$nextest" | tar zxf - -C "$CARGO_HOME/bin" \ + && curl -LsSf "https://get.nexte.st/$NEXTEST_VERSION/$nextest" | tar zxf - -C "$CARGO_HOME/bin" \ && cargo nextest --version ARG CRATES="kio hang moq-auth moq-e2ee moq-hls moq-ice moq-json moq-loc moq-msf moq-mux moq-net moq-pattern moq-quic moq-relay moq-rtc moq-rtmp moq-rtsp moq-sock moq-srt moq-stats moq-tokio moq-uring moq-cli" diff --git a/flake.nix b/flake.nix index 2a6b998f86d..4a5b5039eb3 100644 --- a/flake.nix +++ b/flake.nix @@ -328,8 +328,9 @@ # targets (v0.10.0-kixelated.1+v0.32.0 -> uniffi 0.32), and it only # understands metadata emitted by that uniffi, so it moves with the `uniffi` # dependency in rs/moq-ffi/Cargo.toml. Five other places name the same - # generator version and must be bumped together: the repo and revision - # in release-ffi.yml, and the `cargo install` line in + # generator version and must be bumped together: the commit the tag + # peels to in .github/actions/uniffi-bindgen, which CI jobs outside + # the dev shell build, and the `cargo install` line in # rs/moq-ffi/build.sh, go/ffi/README.md, sh/go/check.sh, and # sh/go/stage.sh. # @@ -413,10 +414,10 @@ # Like uniffi-bindgen-go, the tag pairs the generator's version with the # uniffi release it reads, so it moves with the `uniffi` dependency in # rs/moq-ffi/Cargo.toml. The other places name the same tag and must be - # bumped together: the `cargo install` lines in rs/moq-ffi/build.sh, - # cpp/moq/README.md, .github/workflows/{c,cpp}.yml, - # .github/workflows/platform.yml, .github/workflows/release-{c,cpp}.yml, - # and .github/workflows/release-shared-ffi.yml. + # bumped together: the commit the tag peels to in + # .github/actions/uniffi-bindgen, which CI jobs outside the dev shell + # (Windows among them) build, and the `cargo install` lines in + # rs/moq-ffi/build.sh and cpp/moq/README.md. # # This points at a fork of LiveKit's async branch (livekit/uniffi-bindgen-cpp # PR #1): neither LiveKit nor NordSecurity has a uniffi 0.32 generator, diff --git a/quest/m1/security-audit/README.md b/quest/m1/security-audit/README.md index 3e68e4393ca..5c393b16329 100644 --- a/quest/m1/security-audit/README.md +++ b/quest/m1/security-audit/README.md @@ -55,4 +55,3 @@ Dropped, with reasons: ## Required - [Browser input checks](/quest/m1/security-audit/js-input.md) - room metadata is schema-validated and the http:// fingerprint fetch is aborted and length-checked -- [CI tool pins](/quest/m1/security-audit/ci-pins.md) - binding generators are pinned by commit in one place, and the remaining unpinned installs and broad token grants are fixed diff --git a/quest/m1/security-audit/ci-pins.md b/quest/m1/security-audit/ci-pins.md deleted file mode 100644 index f08522445ad..00000000000 --- a/quest/m1/security-audit/ci-pins.md +++ /dev/null @@ -1,35 +0,0 @@ -# [S] CI tool pins - -## Goal - -Every CI and release job that installs a binding generator from git pins it -by full commit in one shared place, and the remaining loose supply-chain ends -in workflows are tightened: Windows `nasm` and the scanner's nextest are -version-pinned, `release-winget.yml` checks its trigger like -`release-brew.yml`, and `id-token: write` is granted only to jobs that use -OIDC. - -## Plan - -From the 2026-10-27 audit (SUP-1, SUP-2, SUP-4, SUP-6, SUP-7), verified: - -- `cargo install uniffi-bindgen-cpp --git ... --tag v0.11.0-kixelated.7+v0.32.2` - is copied 7 times (`c.yml`, `cpp.yml`, `platform.yml` twice, `release-c.yml`, - `release-cpp.yml`, `release-shared-ffi.yml`), and `release-ffi.yml` passes a - tag as `--rev` for uniffi-bindgen-go. `--locked` pins dependencies, not the - tag. Replace the copies with one composite action carrying the peeled commit - (`a5a1ac411772b3f589859edf50aef7bc7538100b` for the cpp tag at audit time; - re-check with `git ls-remote`), and keep it in the flake.nix pin-together - list. Windows release jobs cannot use the Nix-built tool. -- `choco install nasm` is unpinned in `release-binary.yml`, `release-c.yml`, - `release-cpp.yml`, and `platform.yml`. -- `.oss-scanner/Dockerfile` pipes `get.nexte.st/latest`; pin the nextest - version CI uses. The base image stays a tag (no secrets, nothing shipped). -- `release-winget.yml` gates `workflow_run` on success and a branch prefix - only; add `release-brew.yml`'s `event == 'push'` and same-repo checks. -- `check.yml`, `ts.yml`, and `interop.yml` grant `id-token: write` - workflow-wide with no OIDC consumer; drop those grants and the ts caller's - in `release-gate.yml`, keeping interop's Pages job grant and the - release-gate grant it needs. - -Public API: none. Wire: none. From 78d0305848fa9f1c62a9616b5aa0a9c915aaf1ec Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 18:23:05 -0800 Subject: [PATCH 20/86] perf(quic): send groups leave their round robin without a scan (#7527) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m2/performance-survey/README.md | 1 - .../performance-survey/quic-schedule-churn.md | 30 ---- rs/moq-quic/benches/streams.rs | 119 +++++++++++++- .../src/connection/streams/schedule.rs | 146 ++++++++++++------ 4 files changed, 215 insertions(+), 81 deletions(-) delete mode 100644 quest/m2/performance-survey/quic-schedule-churn.md diff --git a/quest/m2/performance-survey/README.md b/quest/m2/performance-survey/README.md index 7c5faa7e3d6..08475164a41 100644 --- a/quest/m2/performance-survey/README.md +++ b/quest/m2/performance-survey/README.md @@ -189,7 +189,6 @@ No duplicate quest or workflow edit belongs here. - [Cached JS group joins](/quest/m2/performance-survey/js-cached-groups.md) - repeated sorting while replaying retained groups - [PCM batch processing](/quest/m2/performance-survey/audio-batches.md) - repeated tail movement during encoding and resampling - [Fragmented MKV import](/quest/m2/performance-survey/mkv-fragments.md) - accumulated-buffer copies and parser replay -- [QUIC scheduling churn](/quest/m2/performance-survey/quic-schedule-churn.md) - priority and retirement work across unrelated groups - [HLS request fan-out](/quest/m2/performance-survey/hls-serving.md) - repeated fetch, remux and playlist construction per viewer - [Buffered browser groups](/quest/m2/performance-survey/hang-buffered.md) - per-frame scans and bulk skip work - [Idle browser stats](/quest/m2/performance-survey/browser-idle-stats.md) - local transport polling without active publishing demand diff --git a/quest/m2/performance-survey/quic-schedule-churn.md b/quest/m2/performance-survey/quic-schedule-churn.md deleted file mode 100644 index 2f05c9d4b4e..00000000000 --- a/quest/m2/performance-survey/quic-schedule-churn.md +++ /dev/null @@ -1,30 +0,0 @@ -# [M] QUIC priority changes touch only relevant scheduling state - -## Goal - -Changing or retiring queued scheduling groups does not impose unnecessary -work on unrelated groups sharing a connection. - -## Plan - -[The QUIC scheduler](/rs/moq-quic/src/connection/streams/schedule.rs) -linearly searches round-robin VecDeques in `unring_group` and retirement. -Priority changes call that path. On shared relay sessions, -repeated updates or unfavorable retirement order can multiply changed groups -by unrelated queued groups. Existing dispatch/stream-churn benches do not -isolate scheduling-group reprioritization and retirement. - -Sweep queued groups × changed/retired groups, and domains × retired domains. -Measure front/middle/tail and mixed-order operations, priority-update CPU, -allocations and packet-dispatch tail latency during churn. Confirm through -publisher × subscriber traffic on a shared session. Preserve byte fairness, -priority ordering and eventual progress rather than changing scheduler policy. - -Benchmark the priority semantics from -[moq-dev/moq#6142](https://github.com/moq-dev/moq/pull/6142). No new -priority contract is selected by this performance quest. - -Follow the [survey's benchmark contract](/quest/m2/performance-survey/README.md): -measure the baseline before optimizing, retain paired evidence, verify equivalent -work, schedule coverage nightly, and accept a measured no-win. Expected public -API and wire impact: none; report the actual impact in the implementation PR. diff --git a/rs/moq-quic/benches/streams.rs b/rs/moq-quic/benches/streams.rs index d5fef4f2f68..37f36bb8247 100644 --- a/rs/moq-quic/benches/streams.rs +++ b/rs/moq-quic/benches/streams.rs @@ -4,7 +4,8 @@ //! finish them, then exchange packets until the server has read them and the path is idle. //! The zero-credit case isolates blocked writes and resets across live-writer counts, with //! connection setup and dropping the remaining state outside the CPU measurement. The -//! reprioritize case moves streams that stay queued, never sending them. +//! reprioritize case moves streams that stay queued, never sending them. The group churn case +//! moves and cancels send groups, one per subscription, while the rest have data queued. use bytes::Bytes; use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; @@ -65,6 +66,57 @@ fn bench_schedule(c: &mut Criterion) { ); } } + + // Moving or retiring some queued groups, as subscription updates and cancels do, while the + // rest stay queued. Groups are picked from the front of the round robin, its tail, or spread + // across it, so a cost that grows with the untouched groups shows as a slope. + let queued_groups = |groups: usize| { + let mut schedule = Schedule::::new(); + let ids: Vec<_> = (0..groups).map(|_| schedule.open_group(0)).collect(); + let mut slots: Vec<_> = (0..groups).map(|_| Slot::default()).collect(); + for (id, slot) in slots.iter_mut().enumerate() { + schedule.join(id, slot, Some(ids[id]), 0); + schedule.push(id, slot, 0); + } + (schedule, ids, slots) + }; + for (queued, changed) in [(16, 16), (256, 16), (4096, 16), (4096, 256)] { + for order in ["front", "tail", "mixed"] { + let picked: Vec = match order { + "front" => (0..changed).collect(), + "tail" => (queued - changed..queued).rev().collect(), + // An odd stride visits distinct groups of a power-of-two count. + _ => (0..changed).map(|i| i * 2_654_435_761 % queued).collect(), + }; + let id = format!("queued_{queued}_changed_{changed}_{order}"); + group.throughput(Throughput::Elements(changed as u64)); + group.bench_function(BenchmarkId::new("group_priority", &id), |b| { + b.iter_batched( + || queued_groups(queued), + |(mut schedule, ids, slots)| { + for &index in &picked { + schedule.set_group_priority(ids[index], 1); + } + (schedule, slots) + }, + BatchSize::LargeInput, + ) + }); + group.bench_function(BenchmarkId::new("group_retire", &id), |b| { + b.iter_batched( + || queued_groups(queued), + |(mut schedule, ids, mut slots)| { + for &index in &picked { + schedule.close_group(ids[index]); + schedule.leave(&mut slots[index]); + } + (schedule, slots) + }, + BatchSize::LargeInput, + ) + }); + } + } group.finish(); } @@ -143,6 +195,69 @@ fn bench_reprioritize(c: &mut Criterion) { group.finish(); } +/// Subscription churn on a shared connection: each round writes one small stream in each of +/// `subs` send groups, then, with all of it queued, moves `churn` groups spread across the +/// round robin to another priority and back and cancels `churn` others (reset the stream, +/// close the group, open its replacement), and drives until the server has seen every stream. +fn bench_group_churn(c: &mut Criterion) { + use moq_quic::send; + const BYTES: usize = 256; + let data = payload(BYTES); + let mut group = c.benchmark_group("group_churn"); + for (subs, churn) in [(16_usize, 4), (256, 16), (4096, 16), (4096, 256)] { + // The moved and cancelled groups stay distinct, below. + assert!(2 * churn <= subs && subs.is_power_of_two()); + let mut pair = Pair::new(Setup::default()); + let mut groups: Vec<_> = (0..subs).map(|_| pair.client().send_groups().open(0)).collect(); + let mut round = 0; + group.throughput(Throughput::Elements(subs as u64)); + group.bench_function( + BenchmarkId::new(format!("subs_{subs}"), format!("churn_{churn}")), + |b| { + b.iter(|| { + round += 1; + let finished = pair.server.received.finished; + let reset = pair.server.received.reset; + let conn = pair.client(); + let ids: Vec<_> = groups + .iter() + .map(|group| { + let options = send::Options { + group: Some(group), + priority: 0, + }; + let id = conn.streams().open(Dir::Uni, options).expect("stream limit"); + let mut stream = conn.send_stream(id); + assert_eq!(stream.write(&mut data.clone()).unwrap(), BYTES); + stream.finish().unwrap(); + id + }) + .collect(); + // An odd stride permutes a power-of-two count, spreading the touched groups over + // the round robin and shifting them each round. + let spread = |i: usize| (round + i * 2_654_435_761) % subs; + for index in (0..churn).map(spread) { + conn.send_groups().set_priority(&groups[index], 1); + conn.send_groups().set_priority(&groups[index], 0); + } + for index in (churn..2 * churn).map(spread) { + conn.send_stream(ids[index]).reset(0u32.into()).unwrap(); + let fresh = conn.send_groups().open(0); + conn.send_groups().close(std::mem::replace(&mut groups[index], fresh)); + } + let target = (finished + (subs - churn) as u64, reset + churn as u64); + pair.drive_until(|pair| { + let received = &pair.server.received; + received.finished >= target.0 && received.reset >= target.1 + }); + pair.settle(); + }) + }, + ); + } + group.finish(); +} + /// Writes `total` bytes to one stream in `size`-byte writes, exchanging packets whenever the /// client's send window is full, and drives until the server has read them all. fn send_windowed(pair: &mut Pair, total: usize, size: usize) { @@ -296,6 +411,6 @@ fn bench_probe(c: &mut Criterion) { criterion_group! { name = benches; config = common::config(); - targets = bench_schedule, bench_priority, bench_reprioritize, bench_write_size, bench_connection_blocked, bench_probe + targets = bench_schedule, bench_priority, bench_reprioritize, bench_group_churn, bench_write_size, bench_connection_blocked, bench_probe } criterion_main!(benches); diff --git a/rs/moq-quic/src/connection/streams/schedule.rs b/rs/moq-quic/src/connection/streams/schedule.rs index d92f6e8559f..3684ba9e6c7 100644 --- a/rs/moq-quic/src/connection/streams/schedule.rs +++ b/rs/moq-quic/src/connection/streams/schedule.rs @@ -20,7 +20,7 @@ use std::{ cmp::Reverse, - collections::{BTreeMap, BTreeSet, VecDeque}, + collections::{BTreeMap, BTreeSet, btree_map}, }; use slab::Slab; @@ -147,10 +147,10 @@ struct Group { /// Whether the group has queued items, or is being served a stream. It stays set between /// [`Schedule::pop`] and [`Schedule::sent`], so requeueing the stream is not a new turn. active: bool, - /// Whether an entry for this group sits in its level's round robin. Usually equal to - /// having queued items, but a group that runs dry away from the front of the round - /// robin keeps its entry until the round robin reaches it. - ringed: bool, + /// The group's neighbors in its level's round robin, if it is in one. Usually present + /// exactly when the group has queued items, but a group that runs dry away from the front + /// of the round robin keeps its place until the round robin reaches it. + ring: Option, /// Streams that name this group, queued or not. members: usize, /// Whether the owner released the group, which is freed once no stream names it and its @@ -158,6 +158,14 @@ struct Group { closed: bool, } +/// A group's neighbors in its level's round robin: a circular list threaded through the +/// groups, so a group leaves it from anywhere without a search. +#[derive(Debug, Clone, Copy)] +struct Ring { + prev: usize, + next: usize, +} + /// A queue holding datagrams, keyed for eviction: whether it is the ungrouped queue, its /// group's priority, its oldest datagram, and the group. type Victim = (bool, i64, Reverse, Option); @@ -167,8 +175,9 @@ type Victim = (bool, i64, Reverse, Option); pub(super) struct Schedule { ungrouped: Queue, groups: Slab>, - /// Group round robins by priority, with only the highest backlogged level served. - levels: BTreeMap>, + /// The group heading each priority's round robin, with only the highest backlogged level + /// served. A level goes with its last group. + levels: BTreeMap, /// Groups with queued items. backlogged: usize, /// Decreases with every push, so a requeued stream goes behind its equal-priority peers. @@ -236,7 +245,7 @@ impl Schedule { queue: Queue::new(), deficit: 0, active: false, - ringed: false, + ring: None, members: 0, closed: false, }) @@ -259,15 +268,14 @@ impl Schedule { } self.unindex(Some(group)); + let ringed = self.groups[group].ring.is_some(); + if ringed { + self.unring(group); + } let state = &mut self.groups[group]; - let old = std::mem::replace(&mut state.priority, priority); - if state.ringed { - self.unring_group(group, old); - let state = &mut self.groups[group]; - state.ringed = !state.queue.is_empty(); - if state.ringed { - self.levels.entry(priority).or_default().push_back(group); - } + state.priority = priority; + if ringed && !state.queue.is_empty() { + self.ring(group); } self.index(Some(group)); } @@ -478,19 +486,15 @@ impl Schedule { loop { let mut level = self.levels.last_entry()?; - let Some(&group) = level.get().front() else { - level.remove(); - continue; - }; + let group = *level.get(); let group_state = &mut self.groups[group]; if !group_state.active { - level.get_mut().pop_front(); - group_state.ringed = false; + self.unring(group); continue; } if group_state.deficit <= 0 { group_state.deficit += QUANTUM; - level.get_mut().rotate_left(1); + *level.get_mut() = group_state.ring.expect("a level's head is ringed").next; continue; } @@ -530,7 +534,7 @@ impl Schedule { self.unindex(group); let (datagram, frame) = self.queue(group).take_datagram(recency); self.datagram_bytes -= frame; - if let std::collections::btree_map::Entry::Occupied(mut count) = self.frames.entry(frame) { + if let btree_map::Entry::Occupied(mut count) = self.frames.entry(frame) { *count.get_mut() -= 1; if *count.get() == 0 { count.remove(); @@ -585,9 +589,8 @@ impl Schedule { // Debt carries over, but unused credit does not, so going idle saves nothing up. state.deficit = state.deficit.min(0) + QUANTUM; - if !state.ringed { - state.ringed = true; - self.levels.entry(state.priority).or_default().push_back(group); + if state.ring.is_none() { + self.ring(group); } self.backlogged += 1; } @@ -595,8 +598,8 @@ impl Schedule { /// Account for a group whose queue may have run dry. /// /// A group heading its round robin, as one just served does, leaves it now. - /// Anywhere else its entry stays until the round robin reaches it, since finding it - /// would mean a scan. + /// Anywhere else it keeps its place until the round robin reaches it, so a group that + /// refills before then is not sent to the back. fn deactivate(&mut self, group: Option) { let Some(group) = group else { return; @@ -606,18 +609,11 @@ impl Schedule { return; } state.active = false; + self.backlogged -= 1; - if let Some(level) = self.levels.get_mut(&state.priority) - && level.front() == Some(&group) - { - level.pop_front(); - if level.is_empty() { - self.levels.remove(&state.priority); - } - state.ringed = false; + if self.levels.get(&state.priority) == Some(&group) { + self.unring(group); } - - self.backlogged -= 1; } /// Drop a stream's membership of its group. @@ -634,20 +630,55 @@ impl Schedule { return; } - // Nothing names or fills the group, but a stale round-robin entry may remain. - let state = self.groups.remove(group); - if state.ringed { - self.unring_group(group, state.priority); + // Nothing names or fills the group, but it may still hold its round-robin place. + if state.ring.is_some() { + self.unring(group); } + self.groups.remove(group); } - fn unring_group(&mut self, group: usize, priority: i64) { - let level = self.levels.get_mut(&priority).expect("ringed group has a level"); - let index = level.iter().position(|&g| g == group); - level.remove(index.expect("ringed group is in its level")); - if level.is_empty() { + /// Add a group to the back of its level's round robin. + fn ring(&mut self, group: usize) { + debug_assert!(self.groups[group].ring.is_none(), "group ringed twice"); + let ring = match self.levels.entry(self.groups[group].priority) { + btree_map::Entry::Vacant(level) => { + level.insert(group); + Ring { + prev: group, + next: group, + } + } + btree_map::Entry::Occupied(level) => { + let head = *level.get(); + let tail = self.link(head).prev; + self.link(tail).next = group; + self.link(head).prev = group; + Ring { prev: tail, next: head } + } + }; + self.groups[group].ring = Some(ring); + } + + /// Take a group out of its level's round robin, wherever it sits. + fn unring(&mut self, group: usize) { + let state = &mut self.groups[group]; + let Ring { prev, next } = state.ring.take().expect("group is ringed"); + let priority = state.priority; + if next == group { self.levels.remove(&priority); + return; } + self.link(prev).next = next; + self.link(next).prev = prev; + let head = self.levels.get_mut(&priority).expect("ringed group has a level"); + if *head == group { + *head = next; + } + } + + /// The round-robin neighbors of a ringed group. + fn link(&mut self, group: usize) -> &mut Ring { + self.groups[group].ring.as_mut().expect("group is ringed") } } @@ -814,6 +845,25 @@ mod tests { assert!(ids.contains(&first) && ids.contains(&second), "{ids:?}"); } + #[test] + fn groups_leave_the_round_robin_from_anywhere() { + let mut sim = Sim::new(); + let groups: Vec<_> = (0..4).map(|_| sim.schedule.open_group(0)).collect(); + let ids: Vec<_> = groups.iter().map(|&g| sim.stream(Some(g), 0, 100_000)).collect(); + + // Retire the third group from the middle of the round robin. + let mut stream = sim.streams.remove(&ids[2]).unwrap(); + sim.schedule.close_group(groups[2]); + sim.schedule.leave(&mut stream.slot); + // The second leaves for a level of its own, then returns behind the rest. + sim.schedule.set_group_priority(groups[1], 1); + sim.schedule.set_group_priority(groups[1], 0); + + let order: Vec<_> = (0..6).map(|_| sim.step(QUANTUM as usize).unwrap()).collect(); + assert_eq!(order, [ids[0], ids[3], ids[1], ids[0], ids[3], ids[1]]); + assert!(sim.schedule.is_clean()); + } + #[test] fn datagrams_follow_their_order_and_win_a_tie() { let mut sim = Sim::new(); From 7274ba2b07bb0251efc669190e15deb055e9f585 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 18:23:36 -0800 Subject: [PATCH 21/86] Probe scenario seeds randomize the media's start (#7538) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m1/README.md | 1 - quest/m1/sim-media-offset.md | 45 ---------------------------------- rs/moq-quic/src/tests/probe.rs | 16 +++++++++--- 3 files changed, 12 insertions(+), 50 deletions(-) delete mode 100644 quest/m1/sim-media-offset.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 978ec2bf2a2..fab8bc3fc05 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -179,7 +179,6 @@ per-frame CPU growth since #5926. - [Main is no slower than release](/quest/m1/release-perf/README.md) - every benchmark where main is slower than release is fixed or accepted with a reason, checked by a final paired run before the cut - [Interop coverage](/quest/m1/interop-coverage/README.md) - the riskiest moq-transport features moq-net exposes get cross-implementation tests over drafts 18 and 22 - [Calibrate the paired nightly benchmarks](/quest/m1/bench-ci.md) - A/A and deliberately slowed Nightly runs set the paired comparison's threshold, so its regression alerts can be trusted -- [Probe scenario seeds randomize the media's start](/quest/m1/sim-media-offset.md) - each seed draws its own media start offset against the handshake and competitor, so probe budgets are not judged against one shared timing alignment - [The shared pacer holds its rate under 2 Mbit/s](/quest/m1/pacer-low-rate.md) - a rate-paced controller stops sending up to twice its pacing rate at low rates, landed with a BBR3 retune that keeps the probe budgets - [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - nightly ffprobe checks that advertised timing, sync flags, and bytes match the media served - [Unbounded groups](/quest/m1/unbounded-groups/README.md) - groups and frames lose their size caps; a group keeps a 32 MB tail, and readers behind it replay through paced flights or refresh diff --git a/quest/m1/sim-media-offset.md b/quest/m1/sim-media-offset.md deleted file mode 100644 index 5a9eaf8040a..00000000000 --- a/quest/m1/sim-media-offset.md +++ /dev/null @@ -1,45 +0,0 @@ -# [S] Probe scenario seeds randomize the media's start - -## Goal - -Each seed of the probe acceptance suite (`rs/moq-quic/src/tests/probe.rs`, -run nightly by `just rs quic-sim`) draws its own media start offset relative -to the handshake and the competing upload, so a scenario's mean across seeds -no longer carries one shared timing alignment as a bias. -The probing run and its baseline on a seed keep the same offset, so the pair -still differs only in the target. - -## Plan - -Decided 2026-10-31 (maintainer, via `/quest-plan`): plan it in m1 at [S]. -Alternatives considered: m2, or drop it. -It came out of -[moq-dev/moq#7429](https://github.com/moq-dev/moq/pull/7429), which fixed -`competing_cubic`. - -`run` in `probe.rs` takes the media's `start` from `pair.time` right after the -handshake and any warmup, and `frame_at(n)` schedules frame `n` at -`start + n / FPS`. The competitor's upload starts in the same loop iteration. -`entropy::reseed(scenario.seed)` varies keys, connection IDs, and the `Link` -impairments per seed, but not this phase, so every seed shares the same media -timing. With no probe at all, shifting competing_cubic's media by 7 µs moved -the 1024-seed mean p99 by −37 ± 6 ms, and 1 ms later moved it by -+19 ± 5 ms. That noise is the size of the probe budgets (40 ms of p99), so any -change that sends early in such a scenario is judged against an artifact of -the alignment. - -- Draw the offset per seed, for example uniform over one frame interval - (0 to 1/`FPS`, about 33 ms), and delay the media's first frame by it - relative to the handshake and the competitor. That PR measured this version - as a temporary harness change, and its result - held (+2.3 ± 0.2 late frames, +0.1 ms p99). -- Draw it from the seed without shifting the endpoints' or links' later - draws, such as from its own generator seeded from `scenario.seed`, so a - seed's other randomness and its reproduction stay as they are. -- Keep `start`, the samples, and the rate steps measured from the media's - start, so the rise, fall, and idle windows still mean the same thing. -- Re-run the acceptance suite over seeds 1-8 and a larger sweep. If a scenario's - pass or fail on seeds 1-8 changes, report it with the paired spread rather - than retuning budgets in this quest. - -Public API: none. Wire: none. diff --git a/rs/moq-quic/src/tests/probe.rs b/rs/moq-quic/src/tests/probe.rs index ad1dbae270c..63472854211 100644 --- a/rs/moq-quic/src/tests/probe.rs +++ b/rs/moq-quic/src/tests/probe.rs @@ -6,12 +6,17 @@ //! are held to the budgets below, which were set before the probe was tuned. The merge gate runs //! the short cases on one seed; the `#[ignore]`d acceptance suite runs nightly in release with //! `just rs quic-sim`, holding each budget on the mean across [`SEEDS`], since one seed's outcome -//! beside a competing upload is a coin flip. +//! beside a competing upload is a coin flip. Each seed also draws when its media starts, up to a +//! frame interval after the handshake, any warmup, and the competing upload's start, so that mean +//! carries no bias from one timing alignment shared by every seed. //! //! The same media trace without a target also checks keyframe delivery deadlines. use std::collections::HashMap; +use rand::{RngExt, SeedableRng}; +use rand_pcg::Pcg32; + use super::*; use crate::congestion::cubic; @@ -65,6 +70,7 @@ fn frame_size(keyframe: u64, index: u64) -> u64 { /// One run's network and workload #[derive(Clone)] struct Scenario { + /// Seeds the run's entropy and, from its own generator, the media's start offset seed: u64, /// One-way propagation delay latency: Duration, @@ -282,7 +288,10 @@ fn run(scenario: &Scenario) -> Outcome { super::upload(&mut pair, client_ch, server_ch, scenario.warmup, |_| {}); } - let start = pair.time; + // The seed delays the media up to a frame interval past the handshake, any warmup, and the + // competing upload's start. Its own generator leaves the run's other draws as they were. + let offset = Pcg32::seed_from_u64(scenario.seed).random_range(Duration::ZERO..Duration::from_secs(1) / FPS as u32); + let start = pair.time + offset; let end = start + scenario.duration; let mut outcome = Outcome::default(); let mut groups: Vec = Vec::new(); @@ -297,8 +306,7 @@ fn run(scenario: &Scenario) -> Outcome { loop { let now = pair.time; - let elapsed = now - start; - if next_rate < scenario.rates.len() && elapsed >= scenario.rates[next_rate].0 { + if next_rate < scenario.rates.len() && now >= start + scenario.rates[next_rate].0 { let rate = scenario.rates[next_rate].1; pair.to_server.bottleneck.as_mut().unwrap().set_rate(rate); next_rate += 1; From 21d6da9fc108b48373a961f34e43c3fcd38b0146 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 18:24:10 -0800 Subject: [PATCH 22/86] fix(moq-audio): keep a track's start when decoding from its first packet (#7384) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m1/README.md | 1 - quest/m1/opus-preskip.md | 17 ---- rs/moq-audio/src/decode/conformance.rs | 9 +- rs/moq-audio/src/decode/consumer.rs | 94 +++++++++++++++++++-- rs/moq-audio/src/decode/test_data/README.md | 4 +- rs/moq-audio/tests/integration/roundtrip.rs | 12 ++- rs/moq-c/src/test.rs | 8 +- 7 files changed, 102 insertions(+), 43 deletions(-) delete mode 100644 quest/m1/opus-preskip.md diff --git a/quest/m1/README.md b/quest/m1/README.md index fab8bc3fc05..55660210155 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -166,7 +166,6 @@ per-frame CPU growth since #5926. - [Native rung disable](/quest/m1/simulcast-rung-disable.md) - OBS stops sending the top renditions its grant cannot fund, as the Rust/FFI publishers do, and a shaped uplink through moq-ffi proves the resume - [Audio jitter target](/quest/m1/audio-jitter-target/README.md) - the playout target is default in both languages; a manual browser proof and a native trace replay remain - [A/V clock](/quest/m1/av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles -- [Decoding from a track's first packet keeps its start](/quest/m1/opus-preskip.md) - moq-audio drops only OpusHead's pre-skip from a track's first packet, keeping the 80 ms warmup for mid-stream joins - [Container reads drop backfill](/quest/m1/read-backfill.md) - the JS container consumer drops groups below its read cursor like Rust - [Buffered playback subscribes in ascending order](/quest/m1/buffered-ascending.md) - a buffered `` asks for the oldest group first, so a voice reply's opening arrives first, and audio's head hold shrinks to the floor - [Push-to-talk loses no speech](/quest/m1/push-to-talk/README.md) - consumers hold a disabled rendition, and an unmute encodes at once with the mic still open diff --git a/quest/m1/opus-preskip.md b/quest/m1/opus-preskip.md deleted file mode 100644 index 55fe1a2316b..00000000000 --- a/quest/m1/opus-preskip.md +++ /dev/null @@ -1,17 +0,0 @@ -# [S] Decoding from a track's first packet keeps its start - -## Goal - -moq-audio decoding an Opus track from its first packet outputs everything but -the codec's pre-skip, so recordings keep their first 80 ms and a track shorter -than that decodes to something, in every binding. - -## Plan - -Decided 2026-10-30 (maintainer, `/quest-plan`): from the track's first packet, -drop only the OpusHead pre-skip, as the codec defines. A decoder that joins -mid-stream keeps today's 80 ms warmup, since it has no history to warm up -from. Found in [moq-dev/moq#7033](https://github.com/moq-dev/moq/pull/7033). - -Test both cases: a short finished track decodes to its length minus -pre-skip, and a mid-stream join still drops its warmup. diff --git a/rs/moq-audio/src/decode/conformance.rs b/rs/moq-audio/src/decode/conformance.rs index efa28af0fde..541b0f00f18 100644 --- a/rs/moq-audio/src/decode/conformance.rs +++ b/rs/moq-audio/src/decode/conformance.rs @@ -242,12 +242,7 @@ async fn consumer_preserves_reference_samples_and_timeline() { pcm.extend(samples(&frame.data)); } } - let warmup = config.warmup.unwrap_or_default(); - let skip = (warmup.as_nanos() * vector.rate as u128 / 1_000_000_000) as usize * vector.channels as usize; - let reference = Vector { - reference: &vector.reference[skip * 4..], - ..*vector - }; - compare(&reference, "consumer", &pcm); + // Decoded from the track's first packet, so no warmup is trimmed. + compare(vector, "consumer", &pcm); } } diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index d40957e9004..9323e8eb52b 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -125,9 +125,12 @@ pub struct Consumer { remix: Option, options: Options, max_delay: std::time::Duration, - /// Prime after a join or loss, never after a declared endpoint. + /// Prime after a join or loss, never after a declared endpoint or at the track's + /// first packet, which the codec's own delay already primes. warmup: Duration, warmup_pending: bool, + /// Whether a packet was read or lost yet, so the track's first packet can be told apart. + started: bool, warmup_until: Option, resolved_sample_rate: u32, resolved_layout: Layout, @@ -248,6 +251,7 @@ impl Consumer { warmup, warmup_pending: !warmup.is_zero(), warmup_until: None, + started: false, resolved_sample_rate: sample_rate, resolved_layout: layout, next_start: None, @@ -319,17 +323,22 @@ impl Consumer { return Ok(Some(frame)); } - let mux_frame = match self.track.read().await? { + let (mux_frame, track_start) = match self.track.read().await? { Some(Encoded::Frame { group, frame }) => { + let first = !std::mem::replace(&mut self.started, true); // A later group after a declared end comes from a fresh encoder; the end's // own group carries the stopped encoder's final packets, trimmed below. if self.end.is_some() && group != self.end_group { self.restart()?; } - frame + // Nothing precedes group 0, and a group delivers from its frame 0 or not at + // all, so reading it first means decoding from the encoder's first packet. + // A finished group's end marker may come first; it carries no media. + (frame, first && group == 0) } // Behind whatever the resampler held from before the loss. Some(Encoded::Gap { timestamp, duration }) => { + self.started = true; self.restart()?; self.warmup_pending = !self.warmup.is_zero(); self.ready.push_back(Read::Gap { timestamp, duration }); @@ -384,11 +393,12 @@ impl Consumer { let epoch = *self.epoch.get_or_insert(mux_frame.timestamp); let delay = self.decoder.delay_remaining(); let decoded = self.decoder.decode(&mux_frame.payload)?; - // Anchored only once a packet decodes: a refused one primed nothing. - if self.warmup_pending { + // Anchored only once a packet decodes: a refused one primed nothing. The track's + // first packet has no history to warm up from: the codec's pre-skip, trimmed by + // the decoder, is all the priming its encoder declared. + if std::mem::take(&mut self.warmup_pending) && !track_start { let warmup = moq_net::Timestamp::try_from(self.warmup)?.convert(timestamp.scale())?; self.warmup_until = Some(timestamp.checked_add(warmup)?); - self.warmup_pending = false; } // Codec delay trimmed off the front is media this packet covered even // though no samples came out, so it still moves the packet after it along. @@ -820,6 +830,8 @@ mod tests { track, moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio), ); + // Joined mid-stream: the track's first packet needs no warmup. + producer.seek(1).unwrap(); for epoch in 0..2 { if epoch != 0 && declared { producer.discontinuity().unwrap(); @@ -991,6 +1003,8 @@ mod tests { track, moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio), ); + // Joined mid-stream: the track's first packet needs no warmup. + producer.seek(1).unwrap(); // One 20 ms packet per group. let mut write = |packet: u64| { producer @@ -1135,6 +1149,8 @@ mod tests { track, moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio), ); + // Joined mid-stream: the track's first packet needs no warmup. + producer.seek(1).unwrap(); producer .write(moq_mux::container::Frame { timestamp: Timestamp::from_micros(1_000_000).unwrap(), @@ -1171,6 +1187,72 @@ mod tests { ); } + /// Decodes a finished track of `packets` 20 ms Opus packets starting at group `group`, + /// keeping the encoder's declared warmup, and returns the first frame's stamp and the + /// frames decoded. + async fn opus_track_output(group: u64, packets: u64) -> (Option, usize) { + let mut encoder = Encoder::new(&EncodeConfig::new(48_000, Layout::Mono)).unwrap(); + let catalog = encoder.catalog(); + assert_eq!(catalog.warmup, Some(Duration::from_millis(80))); + + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast + .create_track("audio", hang::container::track_info(hang::catalog::PRIORITY.audio)) + .unwrap(); + let mut producer = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio), + ); + producer.seek(group).unwrap(); + let pcm = vec![0.25f32; encoder.frame_size()]; + for packet in 0..packets { + producer + .write(moq_mux::container::Frame { + timestamp: Timestamp::from_scale(packet * encoder.frame_size() as u64, 48_000).unwrap(), + duration: None, + payload: encoder.encode(&pcm).unwrap().payload, + keyframe: true, + }) + .unwrap(); + } + producer.finish().unwrap(); + + let mut consumer = Consumer::new( + &broadcast.consume(), + &catalog, + "audio", + Options { + delay: Delay { + max: Some(Duration::from_secs(10)), + ..Delay::default() + }, + ..Options::new() + }, + ) + .await + .unwrap(); + let (mut first, mut frames) = (None, 0); + while let Some(frame) = next_frame(&mut consumer).await.unwrap() { + first.get_or_insert(frame.timestamp.as_micros()); + frames += frame.data.len() / size_of::(); + } + (first, frames) + } + + /// Decoding from the track's first packet drops only the codec's pre-skip: the encoder + /// started there, so there is no history to warm up from. A track shorter than the + /// warmup still decodes. + #[tokio::test(start_paused = true)] + async fn the_tracks_first_packet_keeps_all_but_pre_skip() { + assert_eq!(opus_track_output(0, 2).await, (Some(0), 2 * 960 - 312)); + } + + /// A join past the track's first group still drops its warmup after the pre-skip. + #[tokio::test(start_paused = true)] + async fn a_mid_stream_join_drops_its_warmup() { + assert_eq!(opus_track_output(1, 6).await, (Some(80_000), 6 * 960 - 312 - 3_840)); + } + /// A refused packet primed nothing, so the window starts at the first one that decodes. #[tokio::test(start_paused = true)] async fn warmup_starts_at_the_first_decoded_packet() { diff --git a/rs/moq-audio/src/decode/test_data/README.md b/rs/moq-audio/src/decode/test_data/README.md index d5f9c5f1fb6..cd2f39e6ec6 100644 --- a/rs/moq-audio/src/decode/test_data/README.md +++ b/rs/moq-audio/src/decode/test_data/README.md @@ -44,8 +44,8 @@ the same. This tests the supported ingest without expanding the container API. Reference PCM includes all decoded AAC padding and excludes the Opus `dOps` pre-skip. Backend tests remove exactly the backend's declared delay. Consumer -tests additionally remove the catalog's 80 ms Opus warmup from the reference; -AAC declares no warmup. No correlation search, gain fitting, channel permutation, +tests decode from each track's first packet, which trims no warmup, so they +compare against the whole reference. No correlation search, gain fitting, channel permutation, or per-run tolerance adjustment is allowed. The following maxima were measured on x86-64 Linux with the pinned Nix toolchain, diff --git a/rs/moq-audio/tests/integration/roundtrip.rs b/rs/moq-audio/tests/integration/roundtrip.rs index ff4fcbbf9b3..706f6d246ba 100644 --- a/rs/moq-audio/tests/integration/roundtrip.rs +++ b/rs/moq-audio/tests/integration/roundtrip.rs @@ -95,12 +95,10 @@ async fn opus_roundtrip(case: Case) { .await .unwrap(); producer.finish().unwrap(); - let warmup = rendition.warmup.unwrap_or_default(); - let skipped = (warmup.as_nanos() * case.rate as u128 / 1_000_000_000) as usize; + // Decoded from the track's first packet, so only the codec's pre-skip is dropped. let mut pcm = Vec::new(); while let Some(frame) = next_frame(&mut consumer).await.unwrap() { - let expected = - 123_000 + warmup.as_micros() + pcm.len() as u128 / channels as u128 * 1_000_000 / case.rate as u128; + let expected = 123_000 + pcm.len() as u128 / channels as u128 * 1_000_000 / case.rate as u128; // Microsecond stamping rounds each chunk independently. assert!( frame.timestamp.as_micros().abs_diff(expected) <= 1, @@ -111,12 +109,12 @@ async fn opus_roundtrip(case: Case) { } assert_eq!( pcm.len(), - (expected_frames - skipped) * channels as usize, + expected_frames * channels as usize, "pre-skip, resampling and final padding preserve duration" ); for step in 0..2 { - let start = (step * expected_frames / 2).max(skipped) - skipped; - let end = (step + 1) * expected_frames / 2 - skipped; + let start = step * expected_frames / 2; + let end = (step + 1) * expected_frames / 2; let half = &pcm[start * channels as usize..end * channels as usize]; for channel in 0..channels as usize { let samples: Vec = half.iter().skip(channel).step_by(channels as usize).copied().collect(); diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index 90edcc5228a..ab0a0ce1129 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -4972,12 +4972,14 @@ fn gray_rgba(width: u32, height: u32) -> Vec { /// A finished encoder reaches a C decoder as an end read in microseconds rather /// than vanishing: Opus pads a partial last frame, so the finish publishes where /// the source really stopped. The default start decodes the finished track from -/// its first group rather than waiting at its end. +/// its first group rather than waiting at its end, even when it is shorter than +/// the warmup a mid-stream join trims. #[test] fn audio_raw_decode_reads_the_encoder_end() { const START_US: u64 = 123_000; - // Past the 80 ms Opus warmup the decoder trims, ending on a partial frame. - const SAMPLES: usize = 4_700; + // One partial frame, shorter than the 80 ms warmup a mid-stream join trims: decoded + // from the track's first packet, only the codec's pre-skip is dropped. + const SAMPLES: usize = 860; let origin = id(moq_origin_create()); let path = b"audio-raw-end-test"; From e3bd4b288e01f0c2e7fbe1e6a362d6f3d58057c8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 19:35:08 -0800 Subject: [PATCH 23/86] fix(js): validate room metadata; bound and cancel the fingerprint fetch (#7216) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- bun.lock | 2 + js/net/src/connection/connect.test.ts | 132 ++++++++++++++++++++++++++ js/net/src/connection/connect.ts | 54 +++++++++-- js/room/package.json | 4 +- js/room/src/metadata.test.ts | 45 ++++++++- js/room/src/metadata.ts | 46 ++++++++- quest/m1/security-audit/README.md | 5 +- quest/m1/security-audit/js-input.md | 30 ------ 8 files changed, 271 insertions(+), 47 deletions(-) delete mode 100644 quest/m1/security-audit/js-input.md diff --git a/bun.lock b/bun.lock index 436ff2f339d..91a99485f37 100644 --- a/bun.lock +++ b/bun.lock @@ -371,6 +371,8 @@ "@moq/publish": "workspace:^", "@moq/signals": "workspace:^", "@moq/watch": "workspace:^", + "@zod/mini": "^4.6.7", + "zod": "^4.6.7", }, "devDependencies": { "@types/audioworklet": "^0.0.100", diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index e2c329abf96..69390cd6e3e 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -117,6 +117,138 @@ test("a raw QUIC URL dials WebTransport over https", async () => { expect(dialed).toBe("https://example.com:4443/anon?jwt=x"); }); +// Serve `/certificate.sha256` from `respond`, recording when a request's client goes away. +function fingerprintServer(respond: (request: Request) => Response | Promise) { + let requests = 0; + let aborted = 0; + const server = Bun.serve({ + port: 0, + fetch(request) { + requests++; + request.signal.addEventListener("abort", () => aborted++, { once: true }); + return respond(request); + }, + }); + return { + url: new URL(`http://127.0.0.1:${server.port}/anon`), + requests: () => requests, + aborted: () => aborted, + stop: () => server.stop(true), + }; +} + +// Wait, bounded, for `ready` to hold; a real socket needs more than a microtask turn. +async function until(ready: () => boolean) { + for (let i = 0; i < 200 && !ready(); i++) await sleep(Nano.fromMilli(Milli(5))); +} + +test("an http:// URL pins the fetched fingerprint and dials https", async () => { + const fingerprint = "ab".repeat(32); + const server = fingerprintServer(() => new Response(fingerprint)); + const pair = createMockTransportPair(ALPN_06); + const original = globalThis.WebTransport; + let dialed: string | undefined; + let hashes: WebTransportHash[] | undefined; + + function StubWebTransport(this: unknown, target: string | URL, options: WebTransportOptions) { + dialed = target.toString(); + hashes = options.serverCertificateHashes; + return pair.client; + } + globalThis.WebTransport = StubWebTransport as unknown as typeof WebTransport; + + try { + const connection = await connect(server.url, { websocket: { enabled: false } }); + connection.abort(); + } finally { + globalThis.WebTransport = original; + server.stop(); + } + + expect(dialed).toBe(`https://127.0.0.1:${server.url.port}/anon`); + expect(hashes?.map((hash) => [hash.algorithm, [...(hash.value as Uint8Array)]])).toEqual([ + ["sha-256", Array(32).fill(0xab)], + ]); +}); + +test("an http:// fingerprint longer than a SHA-256 is refused without reading the rest", async () => { + // A chunked body with no Content-Length that never ends: only a bounded read can refuse it. + const chunk = new TextEncoder().encode("a".repeat(1024)); + const server = fingerprintServer( + () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + }), + ), + ); + const original = globalThis.WebTransport; + let dials = 0; + function CountingWebTransport(this: unknown): never { + dials++; + throw new Error("unexpected dial"); + } + globalThis.WebTransport = CountingWebTransport as unknown as typeof WebTransport; + + try { + await expect(connect(server.url, { websocket: { enabled: false } })).rejects.toThrow( + "fingerprint is longer than 64 hex characters", + ); + expect(dials).toBe(0); + } finally { + globalThis.WebTransport = original; + server.stop(); + } +}); + +test("an http:// fingerprint of the wrong length or status is refused", async () => { + const original = globalThis.WebTransport; + globalThis.WebTransport = function Unused(this: unknown): never { + throw new Error("unexpected dial"); + } as unknown as typeof WebTransport; + + const short = fingerprintServer(() => new Response("ab".repeat(31))); + const missing = fingerprintServer(() => new Response("no certificate\n", { status: 404 })); + try { + await expect(connect(short.url, { websocket: { enabled: false } })).rejects.toThrow( + "fingerprint is 62 characters, expected 64 hex characters", + ); + await expect(connect(missing.url, { websocket: { enabled: false } })).rejects.toThrow( + "fingerprint fetch failed: HTTP 404", + ); + } finally { + globalThis.WebTransport = original; + short.stop(); + missing.stop(); + } +}); + +test("aborting connect aborts a pending http:// fingerprint fetch", async () => { + // Answer nothing, so only the abort ends the request. + const server = fingerprintServer(() => new Promise(() => {})); + const original = globalThis.WebTransport; + globalThis.WebTransport = function Unused(this: unknown): never { + throw new Error("unexpected dial"); + } as unknown as typeof WebTransport; + + const controller = new AbortController(); + try { + const pending = connect(server.url, { websocket: { enabled: false }, signal: controller.signal }); + await until(() => server.requests() > 0); + expect(server.requests()).toBe(1); + + controller.abort(new Error("stop")); + await expect(pending).rejects.toThrow("stop"); + await until(() => server.aborted() > 0); + expect(server.aborted()).toBe(1); + } finally { + globalThis.WebTransport = original; + server.stop(); + } +}); + test("a failed connect names why the transport failed", async () => { const original = globalThis.WebTransport; function FailingWebTransport(this: unknown): never { diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 2fac875e451..62444dbd087 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -698,15 +698,10 @@ async function connectWebTransport( // and never reaches here. Keep this at debug so it doesn't read as a problem. console.debug(redact(fingerprintUrl), "performing an insecure fingerprint fetch; use https:// in production"); - // Fetch the fingerprint from the server. - // TODO cancel the request if the effect is cancelled. - const fingerprint = await Promise.race([fetch(fingerprintUrl), cancel]); + const fingerprint = await fetchFingerprint(fingerprintUrl, cancel); if (!fingerprint) return undefined; - const fingerprintText = await Promise.race([fingerprint.text(), cancel]); - if (fingerprintText === undefined) return undefined; - - hashes.push({ algorithm: "sha-256", value: Hex.toBytes(fingerprintText) }); + hashes.push({ algorithm: "sha-256", value: fingerprint }); finalUrl = new URL(url); finalUrl.protocol = "https:"; @@ -732,6 +727,51 @@ async function connectWebTransport( return quic; } +/** A SHA-256 certificate fingerprint is 32 bytes, served as this many hex characters. */ +const FINGERPRINT_HEX_LENGTH = 64; + +/** + * Fetch the SHA-256 certificate fingerprint served at `url`, or `undefined` once `cancel` resolves. + * + * Resolving `cancel` aborts the request. The body is read chunk by chunk and refused at the first + * chunk that takes it past a fingerprint, so a reply with no `Content-Length` cannot accumulate: + * it holds at most a fingerprint plus the one chunk the network stack handed over. + */ +async function fetchFingerprint(url: URL, cancel: Promise): Promise | undefined> { + const controller = new AbortController(); + void cancel.then(() => controller.abort()); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) throw new Error(`fingerprint fetch failed: HTTP ${response.status}`); + + const body = new Uint8Array(FINGERPRINT_HEX_LENGTH); + let length = 0; + const reader = response.body?.getReader(); + for (;;) { + const chunk = await reader?.read(); + if (!chunk || chunk.done) break; + if (length + chunk.value.byteLength > body.length) { + throw new Error(`fingerprint is longer than ${FINGERPRINT_HEX_LENGTH} hex characters`); + } + body.set(chunk.value, length); + length += chunk.value.byteLength; + } + if (length !== body.length) { + throw new Error(`fingerprint is ${length} characters, expected ${FINGERPRINT_HEX_LENGTH} hex characters`); + } + + return Hex.toBytes(new TextDecoder().decode(body)); + } catch (err) { + // Cancelled: another transport won or the caller aborted, which is no failure of this dial. + if (controller.signal.aborted) return undefined; + throw err; + } finally { + // Releases the request and cancels any unread body, whichever way this ended. + controller.abort(); + } +} + // TODO accept arguments to control the port/path used. /** * Dial the WebSocket fallback after `delay`, and if a dial has not opened within diff --git a/js/room/package.json b/js/room/package.json index 7139168f430..4e7c634d5c5 100644 --- a/js/room/package.json +++ b/js/room/package.json @@ -26,7 +26,9 @@ "@moq/net": "workspace:^", "@moq/publish": "workspace:^", "@moq/signals": "workspace:^", - "@moq/watch": "workspace:^" + "@moq/watch": "workspace:^", + "@zod/mini": "^4.6.7", + "zod": "^4.6.7" }, "devDependencies": { "@types/audioworklet": "^0.0.100", diff --git a/js/room/src/metadata.test.ts b/js/room/src/metadata.test.ts index 20fcd9ee911..040db2017bd 100644 --- a/js/room/src/metadata.test.ts +++ b/js/room/src/metadata.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import * as Json from "@moq/json"; import * as Net from "@moq/net"; import type * as Publish from "@moq/publish"; @@ -76,3 +76,46 @@ test("metadata clears when entries disappear or broadcast becomes inactive", asy net.close(); } }); + +test("metadata refuses values that do not match their schema and keeps the last valid one", async () => { + const net = new Net.Broadcast.Producer(); + const catalog = new Signal({ + hang: { user: { track: TRACK.user }, preview: { track: TRACK.preview } }, + }); + const active = new Signal(net.consume()); + // Unvalidated producers, standing in for a remote publisher that sends anything. + const user = new Json.Snapshot.Producer({ + track: net.createTrack(TRACK.user, { timescale: Net.Time.Timescale.MILLI }), + }); + const preview = new Json.Snapshot.Producer({ + track: net.createTrack(TRACK.preview, { timescale: Net.Time.Timescale.MILLI }), + }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const consumed = consume({ out: { catalog, active } } as unknown as Watch.Broadcast); + try { + // A `null` used to throw at `value.id` and end the update loop. + user.update({ value: null, at: Net.Time.Timestamp.now() }); + preview.update({ value: { info: { video: "yes" } }, at: Net.Time.Timestamp.now() }); + await flush(); + expect(consumed.user.name.peek()).toBeUndefined(); + expect(consumed.preview.peek()).toEqual({}); + + // The subscription survives a refused value. + user.update({ value: { name: "Alice" }, at: Net.Time.Timestamp.now() }); + preview.update({ value: { info: { video: true } }, at: Net.Time.Timestamp.now() }); + await flush(); + expect(consumed.user.name.peek()).toBe("Alice"); + expect(consumed.preview.peek()).toEqual({ video: true }); + + user.update({ value: { name: 42 }, at: Net.Time.Timestamp.now() }); + await flush(); + expect(consumed.user.name.peek()).toBe("Alice"); + expect(warn).toHaveBeenCalledTimes(3); + } finally { + consumed.close(); + warn.mockRestore(); + user.finish(); + preview.finish(); + net.close(); + } +}); diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts index d597c050206..9f450420753 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -18,6 +18,7 @@ import * as Moq from "@moq/net"; import type * as Publish from "@moq/publish"; import { Effect, type Getter, type Readonlys, readonlys, Signal } from "@moq/signals"; import type * as Watch from "@moq/watch"; +import * as z from "@zod/mini"; /** Delivery priority for hang metadata tracks: below the catalog, above audio. */ export const PRIORITY = 90; @@ -54,6 +55,28 @@ export type Preview = { typing?: boolean; }; +// A remote publisher's values are untrusted, so a consumer validates each against these. Keep them +// in step with the types above, which stay spelled out for the API docs. +const UserSchema: z.ZodMiniType = z.object({ + id: z.optional(z.string()), + name: z.optional(z.string()), + avatar: z.optional(z.string()), + color: z.optional(z.string()), +}); + +const PreviewSchema: z.ZodMiniType = z.object({ + audio: z.optional(z.boolean()), + video: z.optional(z.boolean()), + screen: z.optional(z.boolean()), + name: z.optional(z.string()), + avatar: z.optional(z.string()), + chat: z.optional(z.boolean()), + typing: z.optional(z.boolean()), +}); + +// `hang/preview.json` wraps the presence in `info`. +const PreviewTrackSchema = z.object({ info: z.optional(PreviewSchema) }); + /** Points a `hang` catalog section at a track. */ export type TrackRef = { /** The track name. */ @@ -209,7 +232,7 @@ export function consume(broadcast: Watch.Broadcast): Consumed { if (!active || !hang) return; if (hang.user) { - subscribeJson(active, hang.user.track, effect, (value) => { + subscribeJson(active, hang.user.track, UserSchema, effect, (value) => { user.id.set(value.id); user.name.set(value.name); user.avatar.set(value.avatar); @@ -218,7 +241,7 @@ export function consume(broadcast: Watch.Broadcast): Consumed { } if (hang.preview) { - subscribeJson<{ info?: Preview }>(active, hang.preview.track, effect, (value) => { + subscribeJson(active, hang.preview.track, PreviewTrackSchema, effect, (value) => { preview.set(value.info ?? {}); }); } @@ -231,19 +254,34 @@ export function consume(broadcast: Watch.Broadcast): Consumed { }; } +/** + * Feed each value on `name` that matches `schema` to `update`. + * + * A value that does not match is refused with a warning and the last valid one stays, so a + * malformed publish neither reaches the UI nor ends the subscription. + */ function subscribeJson( broadcast: Moq.Broadcast.Consumer, name: string, + schema: z.ZodMiniType, effect: Effect, update: (value: T) => void, ): void { const track = broadcast.track(name).subscribe({ ...Moq.Track.Subscription.default(), priority: PRIORITY }); effect.cleanup(() => track.close()); - const consumer = new Json.Snapshot.Consumer({ track }); + const consumer = new Json.Snapshot.Consumer({ track, schema }); effect.spawn(async () => { for (;;) { - const state = await effect.race(consumer.latest()); + let state: Moq.Timed | undefined; + try { + state = await effect.race(consumer.latest()); + } catch (err) { + // The value was decoded and only failed the schema, so the next frame still applies. + if (!(err instanceof z.core.$ZodError)) throw err; + console.warn(`refused invalid ${name}:`, err); + continue; + } if (state === undefined) break; update(state.value); } diff --git a/quest/m1/security-audit/README.md b/quest/m1/security-audit/README.md index 5c393b16329..bdbc2c75a2e 100644 --- a/quest/m1/security-audit/README.md +++ b/quest/m1/security-audit/README.md @@ -1,4 +1,4 @@ -# 2026-10-27 security audit: pre-cut findings +# [M] 2026-10-27 security audit: pre-cut findings ## Goal @@ -52,6 +52,3 @@ Dropped, with reasons: (a random epoch is copied by a fork too) were wrong; the children use better ones. -## Required - -- [Browser input checks](/quest/m1/security-audit/js-input.md) - room metadata is schema-validated and the http:// fingerprint fetch is aborted and length-checked diff --git a/quest/m1/security-audit/js-input.md b/quest/m1/security-audit/js-input.md deleted file mode 100644 index 80ce32440b8..00000000000 --- a/quest/m1/security-audit/js-input.md +++ /dev/null @@ -1,30 +0,0 @@ -# [S] Browser input checks - -## Goal - -`@moq/room` refuses `hang/user.json` and `hang/preview.json` values that do -not match their schema instead of casting them, and the `http://` certificate -fingerprint fetch in `@moq/net` is aborted when the connection is cancelled -and reads no more than a 64-hex-character SHA-256 fingerprint. - -## Plan - -From the 2026-10-27 audit (JS-4, JS-2). `js/room/src/metadata.ts` casts parsed -JSON to `User`/`Preview`; a `null` throws at `value.id` and ends that update -loop, and wrong-typed fields reach the UI. Use zod schemas through the JSON -stream decoder, which already takes one (`@moq/json`), so a malformed value is -refused loudly. - -`js/net/src/connection/connect.ts` fetches the fingerprint with no abort -signal (a TODO marks it) and reads an unbounded `.text()`. Wire the -connection's cancel signal into the fetch, and read the body incrementally, -cancelling the stream as soon as it exceeds the fixed fingerprint -representation (64 hex characters), so the format bounds memory rather than -the read's completion. A test serves an oversized chunked body with no -`Content-Length`. - -Decided 2026-10-27 (maintainer): keep the `http://` fingerprint path for any -host, not just loopback (JS-3), so LAN device testing keeps working; its -on-path risk is the documented dev-only tradeoff. - -Public API: none expected. Wire: none. From d1a438246afcda5fec9d4adbfb162ed4a04f551c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 19:49:48 -0800 Subject: [PATCH 24/86] refactor(gst): moqsrc playback helpers share one session context (#7558) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot --- quest/m2/README.md | 1 - quest/m2/gst-session-context.md | 17 - rs/moq-gst/src/source/imp.rs | 551 +++++++++++++++----------------- 3 files changed, 259 insertions(+), 310 deletions(-) delete mode 100644 quest/m2/gst-session-context.md diff --git a/quest/m2/README.md b/quest/m2/README.md index d4de0062915..19b24ca7d95 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -229,7 +229,6 @@ lite-07; bitrate enforcement is not part of this release. - [Clean ALSA playback onset](/quest/m2/audio-alsa-onset.md) - no onset restarts or clicks over PulseAudio - [Interop audit](/quest/m2/audit-interop.md) - our interop runner entries match what we ship, and every red interop cell with a moq.dev side is fixed, reported, or noted as down or unsupported - [The HLS server follows each path once](/quest/m2/hls-follow-once.md) - `Following` reports synced absence, so moq-hls resolves with one follow -- [moq-gst playback shares one session context](/quest/m2/gst-session-context.md) - the playback helpers take one context instead of six arguments - [moq-bench pins the instances it measures](/quest/m2/bench-epoch-pin.md) - pooled requests pin their announced epoch, so a takeover is not counted as the same broadcast - [The memory bench finishes at 1 MiB frames](/quest/m2/memory-bench-hang.md) - the allocation mode's park at `size=1048576` is root-caused and fixed - [Audio's Latest start reaches back](/quest/m2/audio-latest-reach-back.md) - moq_audio's start covers min plus warmup like other consumers diff --git a/quest/m2/gst-session-context.md b/quest/m2/gst-session-context.md deleted file mode 100644 index d4699d7fa95..00000000000 --- a/quest/m2/gst-session-context.md +++ /dev/null @@ -1,17 +0,0 @@ -# [S] moq-gst playback shares one session context - -## Goal - -moq-gst's playback helpers take one session context instead of passing six -arguments of session plumbing to each other. - -## Plan - -Suggested by [moq-dev/moq#7084](https://github.com/moq-dev/moq/pull/7084). -The prerequisite pad-actor refactor landed in -[moq-dev/moq#7368](https://github.com/moq-dev/moq/pull/7368), reshaping the -same code. - -Decided 2026-10-30 (maintainer, /quest-plan of the 2026-10-30 /quest-complete follow-ups): ✅ new m2 quest, after moqsrc-pad-actor / fold into it / drop. - -Public API: none. Wire: none. diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 51023cf9340..70110b5a57c 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -136,15 +136,9 @@ impl SessionController { let task_connection = connection.clone(); let task_element = element.clone(); Ok(Self::spawn(client, connection, element, move |shutdown| async move { - run_session( - &task_connection, - origin, - settings.broadcast, - settings.max_delay, - task_element, - shutdown, - ) - .await + Session::new(task_element, shutdown, settings.max_delay) + .run(&task_connection, &origin, &settings.broadcast) + .await })) } @@ -775,119 +769,224 @@ impl PadActor { } } -async fn run_session( - connection: &moq_tokio::Connection, - origin: moq_net::origin::Consumer, - broadcast: String, - max_delay: Duration, +/// What a session shares with every run, follower, and reader it starts, handed down as one +/// context rather than argument by argument. +#[derive(Clone)] +struct Session { element: glib::WeakRef, - mut shutdown: watch::Receiver, -) -> Result<()> { - // Stop closes the connection only after the session ends, so the dial races shutdown here. - tokio::select! { - established = moq_net::kio::wait(|waiter| connection.poll_established(waiter)) => established?, - _ = shutdown.changed() => return Ok(()), + /// How far a group may trail the live edge before a reader skips it. + max_delay: Duration, + /// Outlive every run, so a pipeline linked by name (`s.video_0 ! ...`) keeps flowing across a + /// restart. + pads: Pads, + /// Set by [`SessionController::stop`] before it flushes the pads. + shutdown: watch::Receiver, +} + +impl Session { + /// A session for `element`, with no pads yet. + fn new(element: glib::WeakRef, shutdown: watch::Receiver, max_delay: Duration) -> Self { + Self { + element, + max_delay, + pads: Pads::default(), + shutdown, + } } - // A temporary disconnect keeps the origin alive; only a terminal failure closes the loop. - let lost = async { - match connection.closed().await { - Ok(()) => anyhow::anyhow!("connection closed"), - Err(err) => anyhow::Error::from(err).context("connection closed"), + /// Wait for the dial, then follow `path` until the connection is lost for good or `shutdown` + /// fires. + async fn run( + &self, + connection: &moq_tokio::Connection, + origin: &moq_net::origin::Consumer, + path: &str, + ) -> Result<()> { + // Stop closes the connection only after the session ends, so the dial races shutdown here. + let mut shutdown = self.shutdown.clone(); + tokio::select! { + established = moq_net::kio::wait(|waiter| connection.poll_established(waiter)) => established?, + _ = shutdown.changed() => return Ok(()), } - }; - follow_path(&origin, &broadcast, max_delay, element, shutdown, lost).await -} -/// Follow the path's announcements for the whole session: play on a start, switch to the new -/// broadcast on a restart, and hold the pads from an end until the next start. Returns once -/// `shutdown` fires, `lost` resolves, or the origin closes. Every track is read with `max_delay`. -async fn follow_path( - origin: &moq_net::origin::Consumer, - path: &str, - max_delay: Duration, - element: glib::WeakRef, - mut shutdown: watch::Receiver, - lost: impl Future, -) -> Result<()> { - use moq_mux::follow::{Event, Instances}; - - let mut follow = Instances::new(origin, path, None)?; - let pads = Pads::default(); - let mut runs = tokio::task::JoinSet::new(); - // Cancels the latest run. - let mut latest: Option> = None; - let mut lost = std::pin::pin!(lost); - - tracing::info!(%path, "waiting for broadcast to be announced"); - let result = loop { - let broadcast = tokio::select! { - _ = shutdown.changed() => break Ok(()), - err = &mut lost => break Err(err), - event = follow.next() => match event { - Ok(Event::Start(broadcast)) => { - tracing::info!(%path, epoch = broadcast.info().epoch.as_ref().map(tracing::field::display), "online"); - Some(broadcast) + // A temporary disconnect keeps the origin alive; only a terminal failure closes the loop. + let lost = async { + match connection.closed().await { + Ok(()) => anyhow::anyhow!("connection closed"), + Err(err) => anyhow::Error::from(err).context("connection closed"), + } + }; + self.follow_path(origin, path, lost).await + } + + /// Follow the path's announcements for the whole session: play on a start, switch to the new + /// broadcast on a restart, and hold the pads from an end until the next start. Returns once + /// `shutdown` fires, `lost` resolves, or the origin closes, after releasing the pads. + async fn follow_path( + &self, + origin: &moq_net::origin::Consumer, + path: &str, + lost: impl Future, + ) -> Result<()> { + use moq_mux::follow::{Event, Instances}; + + let mut follow = Instances::new(origin, path, None)?; + let mut shutdown = self.shutdown.clone(); + let mut runs = tokio::task::JoinSet::new(); + // Cancels the latest run. + let mut latest: Option> = None; + let mut lost = std::pin::pin!(lost); + + tracing::info!(%path, "waiting for broadcast to be announced"); + let result = loop { + let broadcast = tokio::select! { + _ = shutdown.changed() => break Ok(()), + err = &mut lost => break Err(err), + event = follow.next() => match event { + Ok(Event::Start(broadcast)) => { + tracing::info!(%path, epoch = broadcast.info().epoch.as_ref().map(tracing::field::display), "online"); + Some(broadcast) + } + Ok(Event::End) => { + tracing::info!(%path, "offline, holding pads until it returns"); + None + } + Err(moq_mux::Error::Moq(moq_net::Error::Closed)) => break Ok(()), + Err(err) => break Err(anyhow::Error::from(err).context("broadcast refused")), + }, + joined = runs.join_next(), if !runs.is_empty() => match joined.expect("guarded by is_empty") { + Ok(Ok(())) => None, + Ok(Err(err)) => break Err(err), + Err(err) => break Err(anyhow::Error::from(err).context("run panicked")), + }, + }; + + if let Some(broadcast) = broadcast { + // Cut over at once: the old run's readers hand their pads back without waiting for their + // subscriptions to end. + if let Some(cancel) = latest.take() { + let _ = cancel.send(true); } - Ok(Event::End) => { - tracing::info!(%path, "offline, holding pads until it returns"); - None + latest = Some(self.play(&mut runs, moq_mux::Source::new(origin.clone(), broadcast))); + } + }; + + if let Some(cancel) = latest.take() { + let _ = cancel.send(true); + } + while runs.join_next().await.is_some() {} + // A stop removes the pads; anything else ends the stream downstream first. + let eos = !*shutdown.borrow(); + self.pads.release(eos).await; + result + } + + /// Start the instance handed out by the follower until it ends or is replaced, returning its + /// cancel handle. + fn play(&self, runs: &mut tokio::task::JoinSet>, source: moq_mux::Source) -> watch::Sender { + let (cancel, mut cancelled) = watch::channel(false); + let session = self.clone(); + runs.spawn_on( + STREAMING.scope(self.element.clone(), async move { + session.follow_catalog(source, &mut cancelled).await + }), + RUNTIME.handle(), + ); + cancel + } + + /// Follow one broadcast's catalog, with one [`Follower`] per broadcast its renditions reference. + /// Returns once the catalog closes and the last follower drains, or `cancel` fires. The pads stay + /// with the session, except those of renditions the catalog retired. + async fn follow_catalog(&self, source: moq_mux::Source, cancel: &mut watch::Receiver) -> Result<()> { + let broadcast = source.broadcast(); + let catalog_track = broadcast.track(hang::catalog::Catalog::DEFAULT_NAME)?; + // A publisher that never answers would otherwise hold the run until it is cancelled. + let catalog_track = tokio::select! { + track = catalog_track.subscribe(hang::catalog::Catalog::default_subscription()) => match track { + Ok(track) => track, + Err(err) if source_lost(&err) => { + tracing::warn!(%err, "catalog unavailable, holding pads"); + return Ok(()); } - Err(moq_mux::Error::Moq(moq_net::Error::Closed)) => break Ok(()), - Err(err) => break Err(anyhow::Error::from(err).context("broadcast refused")), - }, - joined = runs.join_next(), if !runs.is_empty() => match joined.expect("guarded by is_empty") { - Ok(Ok(())) => None, - Ok(Err(err)) => break Err(err), - Err(err) => break Err(anyhow::Error::from(err).context("run panicked")), + Err(err) => return Err(anyhow::Error::from(err).context("catalog refused")), }, + _ = cancel.changed() => return Ok(()), + }; + let mut catalog_consumer = moq_mux::catalog::hang::Consumer::new(catalog_track); + + // Follow the catalog for the whole run and reconcile against every update, rather than + // building the renditions once from the first frame. This covers reactive publishers (the + // browser via @moq/hang) that announce an empty catalog before their encoder configures, then + // add renditions a beat later, as well as renditions appearing, disappearing, or changing + // codec/resolution mid-stream. + let (stop, stopped) = watch::channel(false); + let playback = Playback { + session: self.clone(), + broadcast: broadcast.clone(), + timeline: Arc::default(), + stop: stopped, }; + let mut followers: HashMap = HashMap::new(); + // Woken when a follower runs out of renditions, so the run can let it go without waiting for + // another catalog update. + let emptied = Arc::new(tokio::sync::Notify::new()); + // The followers, and the retirements of pads the catalog no longer lists. + let mut tasks = tokio::task::JoinSet::new(); + let mut catalog_closed = false; + let mut result = Ok(()); - if let Some(broadcast) = broadcast { - // Cut over at once: the old run's readers hand their pads back without waiting for their - // subscriptions to end. - if let Some(cancel) = latest.take() { - let _ = cancel.send(true); + loop { + if catalog_closed && tasks.is_empty() { + break; + } + + tokio::select! { + biased; + // Ended by a restart, or the session ending. + _ = cancel.changed() => break, + _ = tasks.join_next(), if !tasks.is_empty() => {} + _ = emptied.notified() => followers.retain(|_, follower| !follower.plan.is_closed() && !follower.idle()), + // The guard stops us polling a closed catalog track (which would spin the loop + // returning None) while the followers drain. + next = catalog_consumer.next(), if !catalog_closed => match next { + Ok(Some(catalog)) => reconcile(&catalog, &source, &playback, &mut followers, &mut tasks, &emptied, cancel), + // Catalog track closed. Don't stop the followers: let each reader reach its natural + // end, and just stop reconciling while they drain. + // + // That includes a reader still resolving its subscription, which is + // indistinguishable here from one that has simply not been polled yet: a final + // snapshot naming a track the publisher does serve arrives this way, and stopping on + // "not live yet" would drop it. Whether a rendition will ever be served is the + // publisher's to answer, and ending the broadcast is where it does: every name it + // never served resolves with an error, which fails that reader's subscribe and ends + // it. + Ok(None) => catalog_closed = true, + Err(err) if catalog_lost(&err) => { + tracing::warn!(%err, "catalog lost, holding pads"); + catalog_closed = true; + } + Err(err) => { + result = Err(err.into()); + break; + } + }, + } + + if catalog_closed { + for (_, follower) in followers.drain() { + follower.plan.send_modify(|plan| plan.closed = true); + } } - latest = Some(play( - &mut runs, - moq_mux::Source::new(origin.clone(), broadcast), - max_delay, - &pads, - &element, - &shutdown, - )); } - }; - if let Some(cancel) = latest.take() { - let _ = cancel.send(true); - } - while runs.join_next().await.is_some() {} - // A stop removes the pads; anything else ends the stream downstream first. - let eos = !*shutdown.borrow(); - pads.release(eos).await; - result -} + // Stop every follower, then wait for each to hand its pads back. On the clean catalog-closed + // exit the tasks already drained, so this is a no-op. + let _ = stop.send(true); + while tasks.join_next().await.is_some() {} -/// Start the instance handed out by the follower until it ends or is replaced, returning its cancel handle. -fn play( - runs: &mut tokio::task::JoinSet>, - source: moq_mux::Source, - max_delay: Duration, - pads: &Pads, - element: &glib::WeakRef, - shutdown: &watch::Receiver, -) -> watch::Sender { - let (cancel, mut cancelled) = watch::channel(false); - let (pads, task_element, shutdown) = (pads.clone(), element.clone(), shutdown.clone()); - runs.spawn_on( - STREAMING.scope(element.clone(), async move { - follow_catalog(source, max_delay, &pads, task_element, &shutdown, &mut cancelled).await - }), - RUNTIME.handle(), - ); - cancel + result + } } /// Whether a failure is the source going away (its session closing, or its route going), which @@ -918,109 +1017,6 @@ fn catalog_lost(err: &moq_mux::Error) -> bool { } } -/// Follow one broadcast's catalog, with one [`Follower`] per broadcast its renditions reference. -/// Returns once the catalog closes and the last follower drains, or `cancel` fires. The pads stay -/// with the session, except those of renditions the catalog retired. -async fn follow_catalog( - source: moq_mux::Source, - max_delay: Duration, - pads: &Pads, - element: glib::WeakRef, - shutdown: &watch::Receiver, - cancel: &mut watch::Receiver, -) -> Result<()> { - let broadcast = source.broadcast(); - let catalog_track = broadcast.track(hang::catalog::Catalog::DEFAULT_NAME)?; - // A publisher that never answers would otherwise hold the run until it is cancelled. - let catalog_track = tokio::select! { - track = catalog_track.subscribe(hang::catalog::Catalog::default_subscription()) => match track { - Ok(track) => track, - Err(err) if source_lost(&err) => { - tracing::warn!(%err, "catalog unavailable, holding pads"); - return Ok(()); - } - Err(err) => return Err(anyhow::Error::from(err).context("catalog refused")), - }, - _ = cancel.changed() => return Ok(()), - }; - let mut catalog_consumer = moq_mux::catalog::hang::Consumer::new(catalog_track); - - // Follow the catalog for the whole run and reconcile against every update, rather than - // building the renditions once from the first frame. This covers reactive publishers (the - // browser via @moq/hang) that announce an empty catalog before their encoder configures, then - // add renditions a beat later, as well as renditions appearing, disappearing, or changing - // codec/resolution mid-stream. - let (stop, stopped) = watch::channel(false); - let playback = Playback { - element, - broadcast: broadcast.clone(), - max_delay, - pads: pads.clone(), - timeline: Arc::default(), - shutdown: shutdown.clone(), - stop: stopped, - }; - let mut followers: HashMap = HashMap::new(); - // Woken when a follower runs out of renditions, so the run can let it go without waiting for - // another catalog update. - let emptied = Arc::new(tokio::sync::Notify::new()); - // The followers, and the retirements of pads the catalog no longer lists. - let mut tasks = tokio::task::JoinSet::new(); - let mut catalog_closed = false; - let mut result = Ok(()); - - loop { - if catalog_closed && tasks.is_empty() { - break; - } - - tokio::select! { - biased; - // Ended by a restart, or the session ending. - _ = cancel.changed() => break, - _ = tasks.join_next(), if !tasks.is_empty() => {} - _ = emptied.notified() => followers.retain(|_, follower| !follower.plan.is_closed() && !follower.idle()), - // The guard stops us polling a closed catalog track (which would spin the loop - // returning None) while the followers drain. - next = catalog_consumer.next(), if !catalog_closed => match next { - Ok(Some(catalog)) => reconcile(&catalog, &source, &playback, &mut followers, &mut tasks, &emptied, cancel), - // Catalog track closed. Don't stop the followers: let each reader reach its natural - // end, and just stop reconciling while they drain. - // - // That includes a reader still resolving its subscription, which is - // indistinguishable here from one that has simply not been polled yet: a final - // snapshot naming a track the publisher does serve arrives this way, and stopping on - // "not live yet" would drop it. Whether a rendition will ever be served is the - // publisher's to answer, and ending the broadcast is where it does: every name it - // never served resolves with an error, which fails that reader's subscribe and ends - // it. - Ok(None) => catalog_closed = true, - Err(err) if catalog_lost(&err) => { - tracing::warn!(%err, "catalog lost, holding pads"); - catalog_closed = true; - } - Err(err) => { - result = Err(err.into()); - break; - } - }, - } - - if catalog_closed { - for (_, follower) in followers.drain() { - follower.plan.send_modify(|plan| plan.closed = true); - } - } - } - - // Stop every follower, then wait for each to hand its pads back. On the clean catalog-closed - // exit the tasks already drained, so this is a no-op. - let _ = stop.send(true); - while tasks.join_next().await.is_some() {} - - result -} - /// Bring the run in line with `catalog`: list its renditions on the pads, retiring the pads of /// those it dropped, and hand each referenced broadcast's follower the renditions that read it. /// @@ -1117,7 +1113,7 @@ fn reconcile( .cloned() .chain(disabled.iter().map(|(rendition, _)| rendition.clone())) .collect(); - playback.pads.list(listed, cancel); + playback.session.pads.list(listed, cancel); let mut plans: HashMap = HashMap::new(); for (rendition, shape) in desired { @@ -1179,7 +1175,7 @@ fn reconcile( } .run(instances, plan); tasks.spawn_on( - STREAMING.scope(playback.element.clone(), follower_task), + STREAMING.scope(playback.session.element.clone(), follower_task), RUNTIME.handle(), ); followers.insert( @@ -1211,20 +1207,15 @@ impl Followed { } } -/// What one broadcast run shares with every follower and reader it starts. Pads survive a -/// broadcast switch; its timing does not. +/// What one broadcast run shares with every follower and reader it starts. The session survives a +/// broadcast switch; the run's timing does not. #[derive(Clone)] struct Playback { - element: glib::WeakRef, + session: Session, /// The catalog's broadcast. A sibling outlives its closed catalog until it ends. broadcast: moq_net::broadcast::Consumer, - /// How far a group may trail the live edge before a reader skips it. - max_delay: Duration, - pads: Pads, /// Shared by the catalog's own renditions and the first instance of each sibling. timeline: Arc, - /// The session's shutdown, which [`SessionController::stop`] sets before it flushes the pads. - shutdown: watch::Receiver, /// Set once the run ends, stopping every follower. stop: watch::Receiver, } @@ -1400,7 +1391,7 @@ impl Follower { /// Bring the renditions in line with `wanted`, stopping each one reshaped or gone, and return /// those added or reshaped. [`Self::play`] starts the rest. fn apply(&mut self, mut wanted: HashMap) -> HashSet { - let pads = &self.playback.pads; + let pads = &self.playback.session.pads; let mut fresh = HashSet::new(); self.renditions .retain(|rendition, entry| match wanted.remove(rendition) { @@ -1499,7 +1490,7 @@ impl Follower { .map(|(serial, _)| *serial) .chain(self.current.as_ref().map(|current| current.serial)) .collect(); - let pads = &self.playback.pads; + let pads = &self.playback.session.pads; self.finished .retain(|rendition, serial| pads.is_listed(rendition) && serials.contains(&serial)); } @@ -1551,7 +1542,7 @@ impl Follower { }; let (rendition, id) = (rendition.clone(), self.next_id); self.readers.spawn_on( - STREAMING.scope(self.playback.element.clone(), async move { + STREAMING.scope(self.playback.session.element.clone(), async move { let exit = reader.run().await; (rendition, id, exit) }), @@ -1574,7 +1565,7 @@ impl Follower { if entry.draining { self.renditions.remove(&rendition); } else if let Exit::Unlisted = exit - && self.playback.pads.is_listed(&rendition) + && self.playback.session.pads.is_listed(&rendition) { // Delisted and listed again while it went to claim its pad, which the plan coalesced. self.play(); @@ -1631,13 +1622,12 @@ impl Reader { live, mut cancel, } = self; - let Playback { + let Session { element, max_delay, pads, shutdown, - .. - } = playback; + } = playback.session; let (kind, name) = (rendition.0, &rendition.1); // An instance that already played this track to its end on the pad would replay its last @@ -2289,10 +2279,7 @@ mod session_tests { use hang::catalog::{AudioCodec, AudioConfig, Container, H264, VideoCodec, VideoConfig}; use tokio::sync::watch; - use super::{ - DEFAULT_MAX_DELAY, NEXT_VIDEO_PAD_ID, Pads, ResolvedSettings, SessionController, TrackKind, follow_catalog, - follow_path, - }; + use super::{DEFAULT_MAX_DELAY, NEXT_VIDEO_PAD_ID, Pads, ResolvedSettings, Session, SessionController, TrackKind}; /// The pad-id counters are process-global, so a test reading one has to be the only test /// allocating while it runs. `cargo test` shares a process across tests (nextest doesn't), @@ -2329,15 +2316,9 @@ mod session_tests { let stopping = shutdown.clone(); let (origin, _driver) = moq_net::origin::Producer::new(Default::default()); let source = moq_mux::Source::new(origin.consume(), broadcast); - follow_catalog( - source, - DEFAULT_MAX_DELAY, - &Pads::default(), - element, - &stopping, - &mut shutdown, - ) - .await + Session::new(element, stopping, DEFAULT_MAX_DELAY) + .follow_catalog(source, &mut shutdown) + .await }) } @@ -2370,12 +2351,9 @@ mod session_tests { let source = moq_mux::Source::new(origin.consume(), broadcast.consume()); let (_stop, stopped) = watch::channel(false); let playback = super::Playback { - element: element.downgrade(), + session: Session::new(element.downgrade(), stopped.clone(), DEFAULT_MAX_DELAY), broadcast: broadcast.consume(), - max_delay: DEFAULT_MAX_DELAY, - pads: Pads::default(), timeline: Arc::default(), - shutdown: stopped.clone(), stop: stopped.clone(), }; let (plan, received) = watch::channel(super::Plan::default()); @@ -2540,9 +2518,14 @@ mod session_tests { let weak = element.downgrade(); let pads = pads.clone(); let source = moq_mux::Source::new(origin().consume(), broadcast.consume()); - let task = super::RUNTIME.spawn(async move { - follow_catalog(source, DEFAULT_MAX_DELAY, &pads, weak, &shutdown, &mut cancelled).await - }); + let session = Session { + max_delay: DEFAULT_MAX_DELAY, + element: weak, + pads, + shutdown, + }; + let task = + super::RUNTIME.spawn(async move { session.follow_catalog(source, &mut cancelled).await }); let first = usize::from(audio_first); let other = 1 - first; let mut write = |index: usize, seconds: u64| { @@ -2638,15 +2621,9 @@ mod session_tests { let consumer = origin.consume(); let weak = element.downgrade(); let following = super::RUNTIME.spawn(async move { - follow_path( - &consumer, - "room/catalog", - DEFAULT_MAX_DELAY, - weak, - shutdown, - std::future::pending(), - ) - .await + Session::new(weak, shutdown, DEFAULT_MAX_DELAY) + .follow_path(&consumer, "room/catalog", std::future::pending()) + .await }); let (path, request) = super::RUNTIME.block_on(async { tokio::select! { @@ -2774,7 +2751,9 @@ mod session_tests { let source = moq_mux::Source::new(origin().consume(), broadcast.consume()); let session = super::RUNTIME.spawn(async move { let stopping = cancelled.clone(); - follow_catalog(source, max_delay, &Pads::default(), weak, &stopping, &mut cancelled).await + Session::new(weak, stopping, max_delay) + .follow_catalog(source, &mut cancelled) + .await }); let request = await_request(&mut dynamic); @@ -3279,9 +3258,13 @@ mod session_tests { let pads = pads.clone(); let weak = element.downgrade(); let source = moq_mux::Source::new(origin().consume(), broadcast.consume()); - super::RUNTIME.spawn(async move { - follow_catalog(source, DEFAULT_MAX_DELAY, &pads, weak, &shutdown, &mut cancelled).await - }) + let session = Session { + max_delay: DEFAULT_MAX_DELAY, + element: weak, + pads, + shutdown, + }; + super::RUNTIME.spawn(async move { session.follow_catalog(source, &mut cancelled).await }) }; let pad = super::RUNTIME.block_on(adds.recv()).unwrap(); assert!(pad.starts_with("video_"), "{pad} was added first"); @@ -3442,15 +3425,9 @@ mod session_tests { connection.clone(), element.downgrade(), move |shutdown| async move { - follow_path( - &origin, - "room", - DEFAULT_MAX_DELAY, - weak, - shutdown, - std::future::pending(), - ) - .await + Session::new(weak, shutdown, DEFAULT_MAX_DELAY) + .follow_path(&origin, "room", std::future::pending()) + .await }, ); (session, connection) @@ -3646,12 +3623,14 @@ mod session_tests { let instance = publisher.broadcast.consume(); let reader = super::Reader { playback: super::Playback { - element: element.downgrade(), + session: Session { + max_delay: DEFAULT_MAX_DELAY, + element: element.downgrade(), + pads, + shutdown, + }, broadcast: instance.clone(), - max_delay: DEFAULT_MAX_DELAY, - pads, timeline: Arc::default(), - shutdown, stop, }, rendition, @@ -3831,15 +3810,9 @@ mod session_tests { let (shutdown, stopping) = watch::channel(false); let (origin, weak) = (origin.consume(), element.downgrade()); let task = super::RUNTIME.spawn(async move { - follow_path( - &origin, - "room", - DEFAULT_MAX_DELAY, - weak, - stopping, - std::future::pending(), - ) - .await + Session::new(weak, stopping, DEFAULT_MAX_DELAY) + .follow_path(&origin, "room", std::future::pending()) + .await }); (shutdown, task) } @@ -4141,15 +4114,9 @@ mod session_tests { let weak = element.downgrade(); let source = moq_mux::Source::new(origin.consume(), room.broadcast.consume()); super::RUNTIME.spawn(async move { - follow_catalog( - source, - DEFAULT_MAX_DELAY, - &Pads::default(), - weak, - &shutdown, - &mut cancelled, - ) - .await + Session::new(weak, shutdown, DEFAULT_MAX_DELAY) + .follow_catalog(source, &mut cancelled) + .await }) }; From f45f5cd54f2bcdb7ffe3cb00433c41c94d0290a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 19:50:18 -0800 Subject: [PATCH 25/86] fix(watch): accept structural read-side origins (#7251) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- js/net/src/origin.ts | 3 +- js/watch/api/element.api.md | 2 +- js/watch/api/index.api.md | 4 +-- js/watch/src/broadcast.test.ts | 2 +- js/watch/src/broadcast.ts | 20 ++++++------ js/watch/src/player.test.ts | 36 ++++++++++++++++++++++ js/watch/src/player.ts | 6 ++-- quest/m1/api-audit/resolve/README.md | 1 - quest/m1/api-audit/resolve/watch-origin.md | 22 ------------- quest/m1/api-audit/route/handle.md | 1 - 10 files changed, 54 insertions(+), 43 deletions(-) delete mode 100644 quest/m1/api-audit/resolve/watch-origin.md diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 772b34f40a4..8af6989d3a0 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -1105,7 +1105,8 @@ class OriginState { * * What a shared connection lends out. {@link Producer} implements it, so code that is * handed an origin rather than owning one should accept this type: closing the origin - * stays the owner's alone, and a borrower cannot express it. + * stays the owner's alone, and a borrower cannot express it. A borrower that only reads + * should accept just the {@link Consumer} members it needs, so `producer.consume()` fits too. * * @public */ diff --git a/js/watch/api/element.api.md b/js/watch/api/element.api.md index e3ea8a99964..3c3ed50a4ec 100644 --- a/js/watch/api/element.api.md +++ b/js/watch/api/element.api.md @@ -16,7 +16,7 @@ import { Video } from "@moq/watch"; export default class extends HTMLElement { constructor(); - attributeChangedCallback(name: "delay" | "buffer" | "url" | "name" | "follow" | "volume" | "muted" | "paused" | "visible" | "backend" | "versions" | "announced" | "catalog-format" | "captions", oldValue: string | null, newValue: string | null): void; + attributeChangedCallback(name: "delay" | "buffer" | "follow" | "announced" | "url" | "name" | "volume" | "muted" | "paused" | "visible" | "backend" | "versions" | "catalog-format" | "captions", oldValue: string | null, newValue: string | null): void; readonly audio: Audio.Decoder; get backend(): Video.Backend; set backend(value: Video.Backend); diff --git a/js/watch/api/index.api.md b/js/watch/api/index.api.md index 4e444f96155..16172b792d3 100644 --- a/js/watch/api/index.api.md +++ b/js/watch/api/index.api.md @@ -107,7 +107,7 @@ export type BroadcastInput = { enabled: Signals.Getter; follow: Signals.Getter; name: Signals.Getter; - origin: Signals.Getter; + origin: Signals.Getter | undefined>; }; export type BroadcastOutput = { @@ -161,7 +161,7 @@ export type PlayerInput = { enabled: Signals.Getter; follow: Signals.Getter; name: Signals.Getter; - origin: Signals.Getter; + origin: BroadcastInput["origin"]; paused: Signals.Getter; probe: Signals.Getter; text: { diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index d2792e8d3d4..6401527af72 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -27,7 +27,7 @@ function origin(paths: string[]): Origin.Producer { function broadcast(name: string, paths: string[] = [name]): { source: Broadcast; owner: Origin.Producer } { const owner = origin(paths); const source = new Broadcast({ - origin: owner, + origin: owner.consume(), name: Path.from(name), enabled: true, follow: false, diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index af29ac561be..eba038b74d0 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -46,7 +46,7 @@ function provesOther(active: Moq.Broadcast.Consumer | undefined, epoch: Moq.Epoc // Whether an announced route covers `path`, until closed. Hidden routes count: a service claim // under a `.`-named prefix is kept out of listings, but it still covers the renditions it would // produce, and this is never shown to anyone. -function watchCovered(origin: Moq.Origin.Table, path: Moq.Path.Valid): { covered: Getter; close: () => void } { +function watchCovered(origin: Origin, path: Moq.Path.Valid): { covered: Getter; close: () => void } { const announced = origin.announced(Path.Pattern.literal(path), { hidden: true, covering: true }); const covered = new Signal(false); const active = new Set(); @@ -101,9 +101,9 @@ export type CatalogFormat = (typeof CATALOG_FORMATS)[number]; * another component whose output is wired in) does the writing. */ export type BroadcastInput = { - // The origin to consume from. Independent of any connection: whichever sessions feed + // The read-side origin to borrow. Independent of any connection: whichever sessions feed // the origin resolve the broadcast, and the handle spans their reconnects. - origin: Getter; + origin: Getter | undefined>; // Whether to start downloading the broadcast. Defaults to true. enabled: Getter; @@ -127,6 +127,8 @@ export type BroadcastInput = { catalog: Getter; }; +type Origin = NonNullable>; + /** Signals the component derives. */ export type BroadcastOutput = { // "error" means the origin refused the broadcast; `error` says why. @@ -164,13 +166,13 @@ export class Broadcast { // its relay about the broadcasts a catalog actually references. readonly #watches = new Map< Moq.Path.Valid, - { origin: Moq.Origin.Table; covered: Getter; close: () => void; users: number } + { origin: Origin; covered: Getter; close: () => void; users: number } >(); // Requests are shared by callers; each owns its restart watch in the origin. readonly #references = new Map< Moq.Path.Valid, - { origin: Moq.Origin.Table; follow: boolean; request: Moq.Origin.Requesting; users: number } + { origin: Origin; follow: boolean; request: Moq.Origin.Requesting; users: number } >(); // The catalog as published, before renditions this consumer cannot use are dropped. Kept @@ -226,7 +228,7 @@ export class Broadcast { // gets selected: its service claims a covering prefix, and nothing announces the exact path // until this subscribes. Watches exactly `path`, shared by every caller asking about it. // The blind cases (announcement gate off, no discovery) never reach here; see `#relativeTarget`. - #isPathAnnounced(effect: Effect, origin: Moq.Origin.Table, path: Moq.Path.Valid): boolean { + #isPathAnnounced(effect: Effect, origin: Origin, path: Moq.Path.Valid): boolean { let entry = this.#watches.get(path); if (!entry || entry.origin !== origin) { entry?.close(); @@ -249,11 +251,7 @@ export class Broadcast { } // Share one origin request across callers reading the same referenced broadcast. - #requestBroadcast( - effect: Effect, - origin: Moq.Origin.Table, - path: Moq.Path.Valid, - ): Moq.Broadcast.Consumer | undefined { + #requestBroadcast(effect: Effect, origin: Origin, path: Moq.Path.Valid): Moq.Broadcast.Consumer | undefined { const follow = effect.get(this.in.follow); let entry = this.#references.get(path); if (!entry || entry.origin !== origin || entry.follow !== follow) { diff --git a/js/watch/src/player.test.ts b/js/watch/src/player.test.ts index 4e593d23a01..d4f131d6a78 100644 --- a/js/watch/src/player.test.ts +++ b/js/watch/src/player.test.ts @@ -1,4 +1,5 @@ import { expect, mock, test } from "bun:test"; +import { Origin, Path } from "@moq/net"; import { Signal } from "@moq/signals"; // Bun does not run Vite's worklet loader; these tests never create an AudioContext. @@ -15,6 +16,41 @@ test("Player refuses the renamed reload and announced inputs", () => { expect(() => new Player({ announced: true } as never)).toThrow("`announced` was renamed to `follow`"); }); +test.each(["table", "consumer", "structural"] as const)("Player borrows a %s origin", async (role) => { + const producer = new Origin.Producer(); + const consumer = producer.consume(); + const table: Origin.Table = producer; + const origins = { + table, + consumer, + structural: { + closed: consumer.closed, + discovery: consumer.discovery, + request: consumer.request.bind(consumer), + follow: consumer.follow.bind(consumer), + announced: consumer.announced.bind(consumer), + }, + }; + const name = Path.from("live.hang"); + const player = new Player({ origin: origins[role], name, catalogFormat: "manual", catalog: {} }); + try { + const active = player.broadcast.out.active.changed(); + const publication = producer.createBroadcast(name); + publication.announce(); + expect((await active)?.path).toBe(name); + expect(player.in.origin.peek()).toBe(origins[role]); + expect(player.broadcast.in.origin.peek()).toBe(origins[role]); + + player.close(); + await flush(); + expect(producer.closed.peek()).toBeUndefined(); + expect(publication.broadcast.closed.peek()).toBeUndefined(); + } finally { + player.close(); + producer.close(); + } +}); + test("Player owns the shared pipeline and feeds output policy into decoders", async () => { const enabled = new Signal(true); const paused = new Signal(false); diff --git a/js/watch/src/player.ts b/js/watch/src/player.ts index b31ac65fb28..245837bba15 100644 --- a/js/watch/src/player.ts +++ b/js/watch/src/player.ts @@ -4,15 +4,15 @@ import type * as Moq from "@moq/net"; import { Path } from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, Signal } from "@moq/signals"; import * as Audio from "./audio"; -import { Broadcast, type CatalogFormat } from "./broadcast"; +import { Broadcast, type BroadcastInput, type CatalogFormat } from "./broadcast"; import { type Delay, Sync } from "./sync"; import * as Text from "./text"; import * as Video from "./video"; /** Inputs for a complete broadcast playback pipeline. */ export type PlayerInput = { - /** Origin that supplies the broadcast. */ - origin: Getter; + /** Read-side origin that supplies the broadcast, borrowed without its lifecycle. */ + origin: BroadcastInput["origin"]; /** Broadcast path relative to the origin. */ name: Getter; /** Whether the pipeline may subscribe. */ diff --git a/quest/m1/api-audit/resolve/README.md b/quest/m1/api-audit/resolve/README.md index 87d53d5d5e1..a4f8fcf08ce 100644 --- a/quest/m1/api-audit/resolve/README.md +++ b/quest/m1/api-audit/resolve/README.md @@ -56,7 +56,6 @@ Wire: none. ## Required -- [The player takes any read-side origin](/quest/m1/api-audit/resolve/watch-origin.md) - `@moq/watch`'s `Player` and `Broadcast` accept a read-only `Origin.Consumer` as well as a `Table` - [JS follow returns a Following](/quest/m1/api-audit/resolve/js-following.md) - `follow` and `request` get their own JS types, matching Rust and the bindings - [JS follow takes FollowOptions](/quest/m1/api-audit/resolve/follow-options.md) - `follow` and `request` get their own JS options types diff --git a/quest/m1/api-audit/resolve/watch-origin.md b/quest/m1/api-audit/resolve/watch-origin.md deleted file mode 100644 index 553aaa0e2f9..00000000000 --- a/quest/m1/api-audit/resolve/watch-origin.md +++ /dev/null @@ -1,22 +0,0 @@ -# [S] The player takes any read-side origin - -## Goal - -`@moq/watch`'s `Player` and `Broadcast` accept a read-only `Origin.Consumer` -as well as an `Origin.Table`, so `new Player({ origin: producer.consume() })` -type-checks. - -## Plan - -Decided 2026-10-27 (maintainer): both take a structural read-side origin type -(`closed`, `discovery`, `request`, `follow`, `announced`), not -`Origin.Table`. Today the call above fails to type-check because `Table` also -requires `createBroadcast` and `requests`. Retype every `Moq.Origin.Table` -input in `js/watch/src/player.ts` and `broadcast.ts`. Do not add an adapter -or a parallel player class. - -The `request(path, { announced })` calls already map onto `follow` and -`request` ([moq-dev/moq#7173](https://github.com/moq-dev/moq/pull/7173)). - -Public API: widens `@moq/watch`'s accepted input; not breaking. -Wire: none. diff --git a/quest/m1/api-audit/route/handle.md b/quest/m1/api-audit/route/handle.md index c29ab9ad2cb..b43378746ba 100644 --- a/quest/m1/api-audit/route/handle.md +++ b/quest/m1/api-audit/route/handle.md @@ -113,5 +113,4 @@ wrappers. Wire: none. ## Related -- [The player takes any read-side origin](/quest/m1/api-audit/resolve/watch-origin.md) - also retypes JS `Table` - No constructible internal handles - the same JS hygiene rule From ce5977241636293f5f8dfb338335a113830503ba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:14:02 -0800 Subject: [PATCH 26/86] feat: one UDP port for QUIC and the WebRTC media path (#7095) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot --- Cargo.lock | 2 + doc/bin/relay/config.md | 7 + doc/bin/relay/index.md | 7 + quest/m2/one-port/README.md | 36 +- quest/m2/one-port/rtc-feed.md | 14 +- quest/m2/one-port/srt-demux.md | 11 +- quest/m2/p2p/README.md | 1 - quest/m2/p2p/native-quic.md | 6 +- quest/m2/p2p/udp-demux.md | 63 --- quest/m2/perf/shard-default.md | 1 - quest/m2/quic-sansio-driver.md | 4 - quest/m2/uring-demux.md | 10 +- rs/moq-relay/src/internal.rs | 99 ++++ rs/moq-relay/src/relay.rs | 40 ++ rs/moq-relay/tests/integration/embed.rs | 68 +++ rs/moq-sock/Cargo.toml | 6 + rs/moq-sock/README.md | 4 + rs/moq-sock/benches/demux.rs | 69 +++ rs/moq-sock/src/demux.rs | 466 ++++++++++++++++++ rs/moq-sock/src/lib.rs | 4 +- rs/moq-tokio/src/quic.rs | 2 + rs/moq-tokio/src/quic/backend.rs | 112 ++++- rs/moq-tokio/src/quic/demux.rs | 497 ++++++++++++++++++++ rs/moq-tokio/src/quic/driver/endpoint.rs | 4 +- rs/moq-tokio/src/quic/driver/mod.rs | 4 +- rs/moq-tokio/src/quic/driver/runtime/mod.rs | 6 + rs/moq-tokio/src/server.rs | 9 + sh/gh/nightly.sh | 4 +- 28 files changed, 1448 insertions(+), 108 deletions(-) delete mode 100644 quest/m2/p2p/udp-demux.md create mode 100644 rs/moq-sock/benches/demux.rs create mode 100644 rs/moq-sock/src/demux.rs create mode 100644 rs/moq-tokio/src/quic/demux.rs diff --git a/Cargo.lock b/Cargo.lock index 226a8403257..bb38ef4b3b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6093,6 +6093,8 @@ dependencies = [ name = "moq-sock" version = "0.2.0" dependencies = [ + "arc-swap", + "bench-harness", "cfg_aliases", "core_affinity", "libc", diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 8351e75d9f5..b06ee08fe40 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -54,6 +54,13 @@ alike. A client that advertises its own name (moq-lite-05+ or any moq-transport reaches the [auth server](/bin/relay/auth#the-contract) with it as `implementation`, which is where to count client versions; older clients arrive without one. +`bind` is the relay's one UDP port. Beside QUIC it carries WebRTC media for an +[embedder](/bin/relay/#embed) that takes it; otherwise that traffic is dropped, +as are STUN Binding queries ([`[stun]`](#stun) has its own port) and anything +else. `/metrics` counts the drops in `moq_relay_demux_dropped_total` by `reason`: +`unclaimed` (WebRTC with no embedder), `overflow`, `stun`, and `unknown`. Not +with `runtime.io_uring`, which serves QUIC alone. + `reset.secret` lets a relay restarted on the same address reset its predecessor's QUIC connections, so clients reconnect at once instead of waiting out the idle timeout. Keep it secret, and give it to one relay diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index 2551a588057..624ea816578 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -99,6 +99,13 @@ relay.with_web(web).run().await?; send its path in the SETUP. - To decide admissions yourself, leave `[auth]` empty and answer `relay.admissions()`; see [Authentication](/bin/relay/auth#in-process). +- To serve WebRTC media (WHIP, WHEP) on the QUIC port, take `relay.webrtc()`: + ICE, DTLS, and RTP arrive on it, and its `send` answers from the same + port. On a wildcard bind with several local addresses, set the reply's + `Transmit.src_ip` to the `Datagram.local` it answers; left `None`, the kernel + picks the source address, which the peer may not accept. It fails unless the + tokio runtime serves QUIC: on a stream-only relay, and with `runtime.io_uring`, + whose workers do not split the port yet. - To serve RTMP on the web ports, take `relay.rtmp()` and pass each accepted stream to `moq_rtmp::accept_stream`. The HTTP and HTTPS listeners hand it plaintext RTMP, and RTMPS (TLS with no ALPN) already decrypted; untaken, the diff --git a/quest/m2/one-port/README.md b/quest/m2/one-port/README.md index 3fe9ba70501..091ce16bcb0 100644 --- a/quest/m2/one-port/README.md +++ b/quest/m2/one-port/README.md @@ -32,12 +32,21 @@ Decided 2026-10-05, from moq.pro's audit: `moq-rtc` taking a fed socket m2 rather than m3, since moq.pro's edge cutover and its uring rollout wait on them. -Decided 2026-10-15: [UDP demux](/quest/m2/p2p/udp-demux.md) remains -under P2P, now deferred to m2, -because [native pairs](/quest/m2/p2p/native-quic.md) run QUIC and ICE's -STUN on one socket through the same demux. The rest of this line builds on -that shared prerequisite in m2. QUIC shard steering landed in #5638; -reuse its filter rather than recreating that completed work. +The UDP demux has landed: `moq_sock::demux` classifies, and the tokio QUIC +listener's socket diverts the WebRTC media path inline in its receive to a +`moq_tokio::quic::demux::WebRtc` feed, which `moq_relay::Relay::webrtc` +hands an embedder. [Native pairs](/quest/m2/p2p/native-quic.md) reuse the +classifier. QUIC shard steering landed in #5638; reuse its filter rather than +recreating that completed work. + +Decided 2026-10-31 by the maintainer, in +[moq-dev/moq#7095](https://github.com/moq-dev/moq/pull/7095): the split runs +inline in the QUIC socket's `poll_recv`, not in a task owning the socket and +fanning out to per-stack virtual sockets, so a QUIC datagram keeps its +zero-copy, same-task path and its GRO batch. The API stays as shipped: +`moq_tokio::quic::demux::Demux` in `server::Config::demux`, `Demux::webrtc()` +returning the `WebRtc` feed, and `moq_relay::Relay::webrtc()`. SRT adds its +class to the same inline split. ### Classifying a datagram @@ -63,11 +72,11 @@ unknown tuple is not SRT; it is dropped or given to the WebRTC mux. ### Virtual sockets -One OS socket, or one shard of a reuseport group, is read by the demux and -fanned into virtual sockets, one per stack, each with the `AsyncUdpSocket` -shape. Sends go straight to the shared socket, so every stack answers from -the same address and port. moq-tokio's `quic::driver` accepts the virtual socket through -`new_with_abstract_socket`. `moq-rtc`'s `Mux` already demuxes STUN by ufrag +One OS socket, or one shard of a reuseport group, is read by the QUIC +endpoint's own socket, which keeps QUIC in place and copies every other +class into that stack's bounded feed, shared by every shard. Sends go +straight to the shared socket, so every stack answers from the same address +and port. `moq-rtc`'s `Mux` already demuxes STUN by ufrag internally and takes a fed socket in [WebRTC on the shared socket](/quest/m2/one-port/rtc-feed.md). SRT skips `srt-tokio`, which accepts a `tokio::net::UdpSocket` but no abstraction, and feeds @@ -78,7 +87,9 @@ decided. Decided 2026-10-08: the public STUN responder is left to P2P (m2), its only consumer. Here, a Binding request carrying USERNAME is an ICE check -and goes to WebRTC; any other is dropped and counted. Decided 2026-10-13: +and goes to WebRTC, as does every Binding response, since the WebRTC agent +runs full ICE and only it sends requests from this port. Any other STUN is +dropped and counted. Decided 2026-10-13: the responder binds its own port ([relay STUN](/doc/bin/relay/config.md#stun)), so the shared port never answers public Binding requests. @@ -89,6 +100,5 @@ so the shared port never answers public Binding requests. ## Related -- [UDP demux](/quest/m2/p2p/udp-demux.md) - one socket carries QUIC, with a WebRTC hook for embedders; owned by deferred native P2P in m2 - [One port on the io_uring workers](/quest/m2/uring-demux.md) - the io_uring workers host the UDP demux - [Stream transports](/quest/m2/perf/stream/README.md) - the io_uring workers that would host the TCP acceptor later diff --git a/quest/m2/one-port/rtc-feed.md b/quest/m2/one-port/rtc-feed.md index 2f846076181..667799c2c00 100644 --- a/quest/m2/one-port/rtc-feed.md +++ b/quest/m2/one-port/rtc-feed.md @@ -60,6 +60,16 @@ demux routes that tuple to WebRTC before any SRT test (see the released when its session ends or ICE moves to another pair: either an unpin message or idle expiry in the flow table, as SRT's pins have. +The demux side has landed: `moq_tokio::quic::demux::WebRtc` (from +`Demux::webrtc`, or `moq_relay::Relay::webrtc` for an embedded relay) yields +ICE checks and responses, DTLS, and RTP as `Datagram`s whose `remote` is already in +`moq_ice::canonical` form, sends a `Transmit` from the shared port (mapping an +IPv4 destination for an AF_INET6 socket), and pins a remote with `pin`, which +returns a guard that releases the pin on drop. So the fed `Socket` takes a +`WebRtc` rather than the channels in the sketch, and the mux holds a `Pin` per +selected pair, dropped when its session ends or ICE moves. A feed's `recv` +returns `None` once the listener is gone. + Things to watch: the server config is `Clone` today and a receiver is not, so the fed socket may belong in the server's constructor rather than the config. The fed mux has nothing to bind lazily, and a dropped feed should @@ -69,7 +79,3 @@ Tests: a fed mux completes ICE and DTLS with a client through an in-memory `Transmit`, reports the selected tuple on `pinned`, and releases it when the session closes. A fed dual-stack socket serves an IPv4 peer. The existing bound tests keep passing. - -## Required - -- [UDP demux](/quest/m2/p2p/udp-demux.md) - the WebRTC hook and flow table this feeds from and pins into diff --git a/quest/m2/one-port/srt-demux.md b/quest/m2/one-port/srt-demux.md index 645641f02eb..06086824fd8 100644 --- a/quest/m2/one-port/srt-demux.md +++ b/quest/m2/one-port/srt-demux.md @@ -17,8 +17,11 @@ demuxed packets. That is more code, but it removes the dependency on upstream (now rosalyntg/srt-rs) has not been pushed since 2024-05, and its maintainer said they do not actively support it. -The demux side is the flow table from [UDP demux](/quest/m2/p2p/udp-demux.md): -a 4-tuple already pinned to WebRTC (ICE succeeded) is never tested for SRT. +The demux side extends `moq_sock::demux`, whose `Flows` table today pins a +remote to WebRTC only, and the tokio listener's `quic::demux::Demux`, which +diverts inline in the QUIC socket's receive: add an SRT class with its own +feed and bounded `Queue`, and an owner per pin. A 4-tuple already pinned to +WebRTC (ICE succeeded) is never tested for SRT. An unknown 4-tuple whose full SRT handshake header matches (control bit, type 0, and the SRT magic, not merely `80 00`) pins to SRT provisionally: the provisional table is small and bounded, per-source rate limited, and an @@ -43,7 +46,3 @@ clock, so `tokio::time::pause` cannot drive its idle timeout. Tests: `moq-srt`'s existing integration test runs against the shared socket with a QUIC client active on the same port; a QUIC short header from a new 4-tuple during an SRT session still reaches QUIC. - -## Required - -- [UDP demux](/quest/m2/p2p/udp-demux.md) diff --git a/quest/m2/p2p/README.md b/quest/m2/p2p/README.md index 1c935cc9975..3ba0af6db5c 100644 --- a/quest/m2/p2p/README.md +++ b/quest/m2/p2p/README.md @@ -157,7 +157,6 @@ Extractor declaration checks, which analyze only published packages since - [Peer grants](/quest/m2/p2p/peer-grant.md) - the relay issues a key-bound, asymmetrically signed grant a browser can verify; HS256 keys issue none - [Signaling and policy](/quest/m2/p2p/signal.md) - opted-in peers find each other under the prefix, the application picks who to dial, and the roster-size gate decides whether STUN is used -- [UDP demux](/quest/m2/p2p/udp-demux.md) - one classifier splits QUIC from STUN and WebRTC on the relay's socket and on a native pair's - [Native pairs over ICE and QUIC](/quest/m2/p2p/native-quic.md) - two native peers connect through ICE, then `moq-quic` over the nominated path with fingerprints pinned from the offer - [moq-cli joins](/quest/m2/p2p/cli.md) - `--p2p` publishes a roster entry, dials native peers over ICE and QUIC, and serves browsers as a transit hop - [Peer route policy](/quest/m2/p2p/cost-scopes.md) - application priorities and total cost choose eligible routes, with epoch-aware handover diff --git a/quest/m2/p2p/native-quic.md b/quest/m2/p2p/native-quic.md index 6c17e237e7c..7ad753f0edd 100644 --- a/quest/m2/p2p/native-quic.md +++ b/quest/m2/p2p/native-quic.md @@ -30,7 +30,10 @@ Decided with the maintainer on 2026-10-13: in SDP. Both sides pin it with the existing `tls.fingerprint` check. MoQ AUTH with a [peer grant](/quest/m2/p2p/peer-grant.md) still runs on top. - After nomination the session's socket carries ICE consent checks and - QUIC, split by the [UDP demux](/quest/m2/p2p/udp-demux.md) classifier. + QUIC, split by `moq_sock::demux::classify`: `Ice` and `Stun` go to the + ICE agent and `Quic` to the endpoint. The tokio listener's `quic::demux` + routes for a relay (STUN queries dropped, no ICE agent), so the session + socket needs its own small policy over the same classifier. Roles are deterministic: the ICE controlling side is the QUIC client, so a simultaneous dial resolves the same way [signaling](/quest/m2/p2p/signal.md) @@ -62,5 +65,4 @@ covers LAN and NAT pairs between two `moq-cli --p2p` nodes. ## Required -- [UDP demux](/quest/m2/p2p/udp-demux.md) - the classifier that splits STUN from QUIC - [Signaling and policy](/quest/m2/p2p/signal.md) - the offer schema and roster this extends diff --git a/quest/m2/p2p/udp-demux.md b/quest/m2/p2p/udp-demux.md deleted file mode 100644 index b12cb04a508..00000000000 --- a/quest/m2/p2p/udp-demux.md +++ /dev/null @@ -1,63 +0,0 @@ -# [M] UDP demux - -## Goal - -`moq-relay` reads one UDP socket and serves QUIC from it. The WebRTC media -path (DTLS, RTP, and ICE's STUN checks) is an embedder hook: the demux hands -its class to an embedder such as moq.pro's edge, which feeds it to -`moq-rtc`, since `moq-relay` serves no WHIP or WHEP. The same classifier -splits a native P2P session's socket into ICE's STUN and QUIC. - -## Plan - -Decided 2026-10-15: deferred to m2 with P2P as a whole, so [native P2P pairs](/quest/m2/p2p/native-quic.md) share one demux with the -relay rather than a P2P-only classifier (maintainer's choice over splitting -the classifier out). A native P2P session's socket uses the same classifier: -STUN goes to its ICE agent and QUIC to its endpoint; it has no flow table -and no WebRTC hook. - -`moq-sock` gains the demux: a task owning the OS socket (or shard) that -classifies each datagram by the rules in the -[one-port README](/quest/m2/one-port/README.md) and hands it to the matching -virtual socket, plus the flow table keyed by 4-tuple that WebRTC and SRT pin -into. Keep the classifier and flow table runtime-agnostic, apart from the -tokio task that drives them, because -[the io_uring demux](/quest/m2/uring-demux.md) runs the same piece inside -`moq-uring`'s endpoint. Each virtual socket implements -`AsyncUdpSocket`: `poll_recv` drains its queue, `poll_send` writes through -the shared socket with GSO and ECN passed along, `local_addr` is the shared -one. Bound queues per stack with a per-source drop rather than unbounded -growth; report drops as a counter. - -QUIC: the server takes the virtual socket through whatever the QUIC stack -accepts: the driver's `new_with_abstract_socket` on `moq-quic`. QUIC-bit -greasing is already off for sharded endpoints. - -STUN: an ICE connectivity check is a Binding request carrying USERNAME, so -it goes to the WebRTC hook, whose mux reads the local ufrag from it. A -Binding request without USERNAME is a public query; drop and count it. -Decided 2026-10-08: the public STUN responder is left to the P2P line, -so `moq-sock` takes no STUN codec dependency (str0m included). Decided -2026-10-13: it binds its own port ([relay STUN](/doc/bin/relay/config.md#stun)). -That listener and its settings are behind the relay's non-default `stun` -feature; consuming it must enable that feature explicitly. - -WebRTC: DTLS, RTP, and USERNAME-carrying STUN go to a `webrtc` hook that -`moq-relay` leaves unconsumed and an embedder takes: the datagrams, a send -path through the shared socket, and a way to pin a 4-tuple to WebRTC in the -flow table and release it. [WebRTC on the shared socket](/quest/m2/one-port/rtc-feed.md) -makes `moq-rtc` consume it. - -Decided in the 2026-09-30 audit: the relay itself serves only QUIC here, -because `moq-relay` has no `moq-rtc` dependency and serves no WHIP or WHEP. -Decided 2026-10-05: feeding `moq-rtc` moved from the embedder to -rtc-feed, because `moq-rtc` is an upstream, generic crate. - -Tests: a unit test per first-byte class routes to the right virtual socket, -including the WebRTC hook; an integration test runs a QUIC client against -the demuxed port. `moq-relay` docs list the port once. - -## Related - -- [One port](/quest/m2/one-port/README.md) - the line this came from, which builds the rest of one-port on it -- [One port on the io_uring workers](/quest/m2/uring-demux.md) - runs this classifier and flow table on the io_uring workers diff --git a/quest/m2/perf/shard-default.md b/quest/m2/perf/shard-default.md index 4adea1eafdd..579a142a859 100644 --- a/quest/m2/perf/shard-default.md +++ b/quest/m2/perf/shard-default.md @@ -62,6 +62,5 @@ Update `doc/bin/relay/` for the default change. ## Related - [kio channel contention](/quest/m2/perf/kio-channel-contention/README.md) - its per-thread spikes run on the workers -- [UDP demux](/quest/m2/p2p/udp-demux.md) - demuxes each shard's socket - [Performance survey](/quest/m2/performance-survey/README.md) - paired benchmark evidence and memory/settings controls - [Socket queues](/quest/m2/performance-survey/socket-queues.md) - queue sizing and local drop attribution diff --git a/quest/m2/quic-sansio-driver.md b/quest/m2/quic-sansio-driver.md index d862cc45a92..5b83430074a 100644 --- a/quest/m2/quic-sansio-driver.md +++ b/quest/m2/quic-sansio-driver.md @@ -51,7 +51,3 @@ impl. - [Waits](/quest/m1/quic-api/waits.md) - moq-quic owns every wait, so the driver keeps no waiter table - [Poll forms](/quest/m1/quic-api/poll.md) - the drivers' poll shape this one mirrors - -## Related - -- [UDP demux](/quest/m2/p2p/udp-demux.md) - also feeds datagrams from a socket the endpoint doesn't own diff --git a/quest/m2/uring-demux.md b/quest/m2/uring-demux.md index 1b4b45a65fc..f58d10f8b32 100644 --- a/quest/m2/uring-demux.md +++ b/quest/m2/uring-demux.md @@ -23,10 +23,11 @@ STUN responder class either. An ICE Binding request is WebRTC traffic and follows that class. Each worker's endpoint receives a batch and feeds every segment straight to -the QUIC endpoint. The classifier and flow table from the -[UDP demux](/quest/m2/p2p/udp-demux.md), split there into a -runtime-agnostic piece, run first on each segment; only QUIC continues into -the endpoint. The worker is thread-per-core and `!Send`, while the consumers +the QUIC endpoint. The classifier, pin table, per-source `Queue`, and +`Counters` in `moq_sock::demux` run first on each segment, as the tokio +listener's socket does inline in its receive; only QUIC continues into the +endpoint. Until this lands, `moq_relay::Relay::webrtc` refuses an io_uring +relay. The worker is thread-per-core and `!Send`, while the consumers of the other classes (`moq-rtc`, `moq-srt`) run on tokio, so non-QUIC datagrams cross to them on a `Send` channel, and their replies come back through a remote-send path into the owning worker's ring, @@ -49,7 +50,6 @@ off on the ring, from shard steering. ## Required -- [UDP demux](/quest/m2/p2p/udp-demux.md) - the classifier and flow table this runs on the ring - [WebRTC on the shared socket](/quest/m2/one-port/rtc-feed.md) - the fed RTC socket and transmit contract the ring integrates - [SRT on the shared socket](/quest/m2/one-port/srt-demux.md) - the SRT state machines fed through the shared demux diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index 180fbb669f0..4fe9fc0d986 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -103,6 +103,8 @@ pub struct Internal { uring: Vec, #[cfg(any(feature = "stun", test))] stun: Option, + #[cfg(feature = "_quic")] + demux: Option, shutdown: Option, listener: Option, addr: Option, @@ -118,6 +120,8 @@ struct InternalState { uring: Vec, #[cfg(any(feature = "stun", test))] stun: Option, + #[cfg(feature = "_quic")] + demux: Option, shutdown: Option, } @@ -147,6 +151,8 @@ impl Internal { uring: Vec::new(), #[cfg(any(feature = "stun", test))] stun: None, + #[cfg(feature = "_quic")] + demux: None, shutdown: None, listener: None, addr: None, @@ -159,6 +165,12 @@ impl Internal { self } + #[cfg(feature = "_quic")] + pub(crate) fn with_demux(mut self, counters: Option) -> Self { + self.demux = counters; + self + } + pub(crate) fn with_timeout(mut self, timeout: Option) -> Self { self.timeout = timeout; self @@ -276,6 +288,8 @@ impl Internal { uring: self.uring.clone(), #[cfg(any(feature = "stun", test))] stun: self.stun.clone(), + #[cfg(feature = "_quic")] + demux: self.demux.clone(), shutdown: self.shutdown.clone(), }) } @@ -445,6 +459,10 @@ async fn serve_metrics(State(state): State) -> Response { { scrape.stun = state.stun; } + #[cfg(feature = "_quic")] + { + scrape.demux = state.demux; + } scrape.draining = state.shutdown.map(|shutdown| shutdown.tally().draining); scrape.refusals = state.refusals.map(|refusals| refusals.snapshot()); ( @@ -600,11 +618,28 @@ struct Scrape { uring: Vec, #[cfg(any(feature = "stun", test))] stun: Option, + #[cfg(feature = "_quic")] + demux: Option, draining: Option, refusals: Option, panics: u64, } +/// Datagrams the QUIC port dropped instead of handing to QUIC or the WebRTC feed, +/// one row per reason from zero. `unclaimed` is WebRTC traffic while no embedder +/// takes the feed, which is every WebRTC datagram on a bare relay. +#[cfg(feature = "_quic")] +fn encode_demux(encoder: &mut DescriptorEncoder, counters: &moq_tokio::quic::demux::Counters) -> std::fmt::Result { + counter( + encoder, + "moq_relay_demux_dropped", + "Datagrams the QUIC port dropped, by reason.", + moq_tokio::quic::demux::Discard::ALL + .into_iter() + .map(|reason| ([("reason", Label(reason.as_str()))], counters.get(reason))), + ) +} + impl Scrape { fn new(stats: moq_net::stats::Snapshot, listeners: &[moq_tokio::accept::Health], uring: &[UringWorker]) -> Self { Self { @@ -613,6 +648,8 @@ impl Scrape { uring: uring.to_vec(), #[cfg(any(feature = "stun", test))] stun: None, + #[cfg(feature = "_quic")] + demux: None, draining: None, refusals: None, panics: moq_tokio::panic::count(), @@ -632,6 +669,10 @@ impl Scrape { if let Some(stun) = &self.stun { stun.encode(encoder)?; } + #[cfg(feature = "_quic")] + if let Some(demux) = &self.demux { + encode_demux(encoder, demux)?; + } // The sessions a shutdown drain is still waiting on: 0 until the drain // starts, and back to 0 when every session has left, which is when the @@ -1225,6 +1266,8 @@ mod tests { listeners: Vec::new(), uring: Vec::new(), stun, + #[cfg(feature = "_quic")] + demux: None, shutdown: None, }; let response = serve_metrics(State(state)).await; @@ -1235,6 +1278,45 @@ mod tests { } } + /// Every drop reason renders from zero, and only for a relay whose QUIC port + /// runs the demux. + #[cfg(feature = "_quic")] + #[tokio::test] + async fn demux_metrics_list_every_reason_from_zero() { + use moq_tokio::quic::demux::{Counters, Discard}; + + for demux in [None, Some(Counters::default())] { + let configured = demux.clone(); + if let Some(counters) = &demux { + counters.discard(Discard::Stun); + } + let state = InternalState { + stats: moq_net::stats::Registry::disabled(), + nodes: None, + refusals: None, + sessions: crate::session::Registry::new(), + listeners: Vec::new(), + uring: Vec::new(), + stun: None, + demux, + shutdown: None, + }; + let response = serve_metrics(State(state)).await; + let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = std::str::from_utf8(&body).unwrap(); + match configured { + None => assert!(!body.contains("moq_relay_demux_dropped_total")), + Some(_) => { + assert!(body.contains("moq_relay_demux_dropped_total{reason=\"stun\"} 1")); + for reason in Discard::ALL { + let row = format!("moq_relay_demux_dropped_total{{reason=\"{}\"}}", reason.as_str()); + assert!(body.contains(&row), "{row} missing"); + } + } + } + } + } + /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) @@ -1523,6 +1605,8 @@ mod tests { uring: Vec::new(), #[cfg(any(feature = "stun", test))] stun: None, + #[cfg(feature = "_quic")] + demux: None, shutdown: None, }; @@ -1543,6 +1627,8 @@ mod tests { uring: Vec::new(), #[cfg(any(feature = "stun", test))] stun: None, + #[cfg(feature = "_quic")] + demux: None, shutdown: None, }; @@ -1913,6 +1999,8 @@ mod tests { #[cfg(not(all(target_os = "linux", feature = "_uring")))] uring: Vec::new(), stun: Some(crate::stun::Counters::default()), + #[cfg(feature = "_quic")] + demux: Some(moq_tokio::quic::demux::Counters::default()), shutdown: Some(shutdown), }; let response = serve_metrics(State(state)).await; @@ -1930,6 +2018,8 @@ mod tests { let mut expected: Vec = EXPECTED_SERIES.trim().lines().map(str::to_string).collect(); #[cfg(all(target_os = "linux", feature = "_uring"))] expected.extend(EXPECTED_URING_SERIES.trim().lines().map(str::to_string)); + #[cfg(feature = "_quic")] + expected.extend(EXPECTED_DEMUX_SERIES.trim().lines().map(str::to_string)); expected.sort(); assert_eq!(actual, expected, "{body}"); } @@ -2094,5 +2184,14 @@ histogram moq_relay_uring_sqes_per_enter_bucket{le="8",worker="0"} 0 histogram moq_relay_uring_sqes_per_enter_bucket{le="inf",worker="0"} 0 histogram moq_relay_uring_sqes_per_enter_count{worker="0"} 0 histogram moq_relay_uring_sqes_per_enter_sum{worker="0"} 0 +"#; + + #[cfg(feature = "_quic")] + const EXPECTED_DEMUX_SERIES: &str = r#" +# TYPE moq_relay_demux_dropped_total counter +counter moq_relay_demux_dropped_total{reason="overflow"} 0 +counter moq_relay_demux_dropped_total{reason="stun"} 0 +counter moq_relay_demux_dropped_total{reason="unclaimed"} 0 +counter moq_relay_demux_dropped_total{reason="unknown"} 0 "#; } diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index fbe07646d15..8f305867373 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -108,6 +108,10 @@ pub struct Relay { /// they own the QUIC listen address instead of `workers`. #[cfg(all(target_os = "linux", feature = "_uring"))] uring: Option, + /// The tokio QUIC listener's demux, which every worker shares. `None` when + /// nothing on tokio serves QUIC: a stream-only relay, or the io_uring workers. + #[cfg(feature = "_quic")] + demux: Option, } impl Relay { @@ -215,6 +219,11 @@ impl Relay { listen.tls = Default::default(); } + // Shared by whichever tokio listener serves QUIC, so one feed and one set of + // counters covers every worker. + #[cfg(feature = "_quic")] + let demux = moq_tokio::quic::demux::Demux::default(); + #[cfg(feature = "_quic")] let workers = match config.runtime.workers() { Some(worker) if !io_uring => { @@ -223,6 +232,7 @@ impl Relay { server.tls = served.clone(); server.quic = config.quic.clone(); server.activity = shutdown.activity.clone(); + server.demux = demux.clone(); Some(moq_tokio::worker::Workers::bind(server, worker).context("failed to start the QUIC workers")?) } _ => None, @@ -264,6 +274,10 @@ impl Relay { server_config.tls = served; server_config.quic = config.quic.clone(); server_config.activity = shutdown.activity.clone(); + #[cfg(feature = "_quic")] + { + server_config.demux = demux.clone(); + } #[cfg(feature = "iroh")] { server_config.iroh = iroh.clone(); @@ -288,6 +302,13 @@ impl Relay { }, }; + // The io_uring workers serve QUIC without the demux, and a stream-only relay + // has no UDP port at all. + #[cfg(all(feature = "_quic", target_os = "linux", feature = "_uring"))] + let demux = (addr.is_some() && uring.is_none()).then_some(demux); + #[cfg(all(feature = "_quic", not(all(target_os = "linux", feature = "_uring"))))] + let demux = addr.is_some().then_some(demux); + let cache = config.cache.init()?; // Whichever worker group owns QUIC holds the certificates; the shared // server then has none of its own. @@ -357,6 +378,8 @@ impl Relay { .bind()?; #[cfg(feature = "stun")] let internal = internal.with_stun(stun.as_ref().map(|stun| stun.counters.clone())); + #[cfg(feature = "_quic")] + let internal = internal.with_demux(demux.as_ref().map(|demux| demux.counters().clone())); // Bound but not yet serving: registering here (rather than after the // threads start) is what gives every worker a series from the first // scrape, including one that is about to fail setup. @@ -398,6 +421,8 @@ impl Relay { workers, #[cfg(all(target_os = "linux", feature = "_uring"))] uring, + #[cfg(feature = "_quic")] + demux, }) } @@ -447,6 +472,21 @@ impl Relay { self.admissions.take() } + /// The WebRTC media path (ICE, DTLS, and RTP) arriving on the QUIC port, + /// for an embedder's WebRTC stack to serve and answer through the same port. + /// + /// Until a feed is taken, and once every feed is dropped, that traffic is + /// dropped and counted. Fails when no tokio listener serves QUIC: a stream-only + /// relay, or one on the io_uring workers, which do not split the port yet. + #[cfg(feature = "_quic")] + pub fn webrtc(&self) -> anyhow::Result { + let demux = self + .demux + .as_ref() + .context("the WebRTC media path needs QUIC served by the tokio runtime")?; + Ok(demux.webrtc()) + } + /// The RTMP and RTMPS connections the public web listeners classify, for the /// embedder to serve; see [`web::Web::rtmp`]. Untaken, they are logged and closed. pub fn rtmp(&mut self) -> web::rtmp::Incoming { diff --git a/rs/moq-relay/tests/integration/embed.rs b/rs/moq-relay/tests/integration/embed.rs index 005f8ff41bf..6d0b7d74db5 100644 --- a/rs/moq-relay/tests/integration/embed.rs +++ b/rs/moq-relay/tests/integration/embed.rs @@ -425,6 +425,74 @@ async fn uring_custom_route_and_quic() { embed_and_stop(config).await; } +/// The WebRTC media path on the QUIC port reaches the embedder's feed, whichever +/// worker read it, and the feed's reply leaves from the QUIC port. +async fn webrtc_feed(mut config: Config) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + config.drain_timeout = Some((Duration::ZERO).into()); + let relay = Relay::load(config).await.expect("load relay").with_signals(false); + let quic = relay.quic_addr().expect("quic listener bound"); + let feed = relay.webrtc().expect("tokio serves QUIC"); + let trigger = relay.shutdown_trigger().clone(); + let running = tokio::spawn(relay.run()); + + // Enough sources that a reuseport group's 4-tuple hash spreads them over every member. + let mut peers = Vec::new(); + for i in 0..16u8 { + let peer = tokio::net::UdpSocket::bind("127.0.0.1:0").await.expect("bind peer"); + peer.send_to(&[0x16, 0xfe, 0xfd, i], quic).await.expect("send DTLS"); + peers.push(peer); + } + let mut seen = std::collections::HashSet::new(); + kio::clock::timeout(TIMEOUT, async { + while seen.len() < peers.len() { + let datagram = feed.recv().await.expect("feed open"); + assert_eq!(datagram.data[..3], [0x16, 0xfe, 0xfd]); + seen.insert(datagram.remote); + } + }) + .await + .expect("every peer's datagram reached the feed"); + + let peer = &peers[0]; + feed.send(&moq_tokio::quic::demux::Transmit { + destination: peer.local_addr().expect("peer addr"), + ecn: None, + contents: b"reply", + segment_size: None, + src_ip: None, + }) + .await + .expect("send reply"); + let mut buf = [0; 16]; + let (len, from) = kio::clock::timeout(TIMEOUT, peer.recv_from(&mut buf)) + .await + .expect("reply arrived") + .expect("recv reply"); + assert_eq!(&buf[..len], b"reply"); + assert_eq!(from.port(), quic.port()); + + stop(trigger, running).await; +} + +#[tokio::test] +async fn shared_tokio_webrtc_feed() { + let dir = tempfile::tempdir().expect("tempdir"); + let (cert, key) = certificate(dir.path()); + webrtc_feed(http_and_quic(&cert, &key, "127.0.0.1:0".into())).await; +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn worker_tokio_webrtc_feed() { + let dir = tempfile::tempdir().expect("tempdir"); + let (cert, key) = certificate(dir.path()); + let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); + config.runtime.workers = Some(2); + config.runtime.pin = false; + webrtc_feed(config).await; +} + /// An embedder-owned accept loop joins the relay's own /metrics exposition. #[tokio::test] async fn embedded_listener_health_reaches_metrics() { diff --git a/rs/moq-sock/Cargo.toml b/rs/moq-sock/Cargo.toml index 7485a1a1d02..7c01e824ef4 100644 --- a/rs/moq-sock/Cargo.toml +++ b/rs/moq-sock/Cargo.toml @@ -17,6 +17,7 @@ categories = ["network-programming"] fast-apple-datapath = [] [dependencies] +arc-swap = "1.9" core_affinity = "0.8" libc = { workspace = true } socket2 = { version = "0.6", features = ["all"] } @@ -30,11 +31,16 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ cfg_aliases = "0.2" [dev-dependencies] +criterion = { package = "bench-harness", path = "../bench-harness" } rand = "0.10" tracing-test = "0.2" [target.'cfg(target_vendor = "apple")'.dev-dependencies] nix = { workspace = true } +[[bench]] +name = "demux" +harness = false + [lints] workspace = true diff --git a/rs/moq-sock/README.md b/rs/moq-sock/README.md index 7cab2a2101a..68a6b81d279 100644 --- a/rs/moq-sock/README.md +++ b/rs/moq-sock/README.md @@ -16,6 +16,10 @@ runtimes (`moq-tokio` and `moq-uring`). carries (`moq_quic::RandomConnectionIdGenerator::set_shard`). Anything else on the port, including other protocols, keeps the kernel's 4-tuple hash. - **`cpu`**: pinning worker threads to cores. +- **`demux`**: classifying each datagram on a shared port by its first byte + (RFC 7983), so QUIC and the WebRTC media path (ICE, DTLS, RTP) share + one socket, plus the pin table and per-source queue the listeners divert + through. Every QUIC endpoint behind it turns QUIC-bit greasing off. - **`udp`**: sending and receiving with GSO, GRO, and ECN, a hard fork of [quinn-udp](https://github.com/quinn-rs/quinn). All credit for it goes to the quinn developers; it stays under quinn's MIT or Apache-2.0 license. See diff --git a/rs/moq-sock/benches/demux.rs b/rs/moq-sock/benches/demux.rs new file mode 100644 index 00000000000..85d23de0a38 --- /dev/null +++ b/rs/moq-sock/benches/demux.rs @@ -0,0 +1,69 @@ +//! Routing on a shared port: what every QUIC datagram pays to be told it is QUIC. +//! +//! Swept over the pins held (WebRTC sessions) and the shards routing at once, +//! since every shard of a worker group reads the same pin table. + +#![allow( + clippy::disallowed_methods, + clippy::disallowed_types, + reason = "a benchmark times itself on the host clock" +)] + +use std::{ + hint::black_box, + net::SocketAddr, + time::{Duration, Instant}, +}; + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_sock::demux::{Flows, Route}; + +/// Datagrams one shard routes per iteration, as one receive batch would hold. +const BATCH: usize = 64; + +fn remote(i: usize) -> SocketAddr { + SocketAddr::from(([10, (i >> 16) as u8, (i >> 8) as u8, i as u8], 4433)) +} + +fn bench_route(c: &mut Criterion) { + let mut group = c.benchmark_group("demux_route"); + let quic = [0x40u8; 1200]; + for pins in [0, 1, 1024] { + let flows = Flows::default(); + // Pinned addresses never match the senders, so every datagram stays QUIC. + let _pins: Vec<_> = (0..pins).map(|i| flows.pin(remote(1 << 20 | i))).collect(); + for shards in [1, 4] { + group.throughput(Throughput::Elements((BATCH * shards) as u64)); + group.bench_function( + BenchmarkId::new(format!("pins_{pins}"), format!("shards_{shards}")), + |b| { + b.iter_custom(|iters| { + let start = Instant::now(); + std::thread::scope(|scope| { + for shard in 0..shards { + let flows = &flows; + scope.spawn(move || { + for _ in 0..iters { + for i in 0..BATCH { + let route = flows.route(remote(shard * BATCH + i), &quic); + assert_eq!(black_box(route), Route::Quic); + } + } + }); + } + }); + start.elapsed() + }) + }, + ); + } + } + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default().measurement_time(Duration::from_secs(2)); + targets = bench_route +} +criterion_main!(benches); diff --git a/rs/moq-sock/src/demux.rs b/rs/moq-sock/src/demux.rs new file mode 100644 index 00000000000..37617b4c9cb --- /dev/null +++ b/rs/moq-sock/src/demux.rs @@ -0,0 +1,466 @@ +//! Splitting one UDP socket between QUIC and the WebRTC media path. +//! +//! [`classify`] reads a datagram's first byte by RFC 7983's partition, which QUIC +//! fills as long as its fixed bit is set: every endpoint served through this must +//! turn QUIC-bit greasing (RFC 9287) off. [`Flows`] pins a remote address to a +//! stack regardless of what it sends, and [`Queue`] buffers one stack's datagrams +//! with a per-source share. None of it touches a runtime, so the tokio and +//! io_uring listeners run the same rules. + +use std::{ + collections::{HashMap, HashSet, VecDeque}, + net::SocketAddr, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; + +use arc_swap::ArcSwap; + +/// What a datagram carries, judged by its first byte and, for STUN, its attributes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Class { + /// A QUIC packet: the fixed bit is set, in a long (192 to 255) or short (64 to 127) header. + Quic, + /// ICE: a STUN Binding request carrying USERNAME (a connectivity check), or a + /// Binding response, which answers a request only the WebRTC agent sends from + /// this port, since a full ICE agent needs its own checks answered. + Ice, + /// Any other STUN message, such as a public Binding query or an indication. + Stun, + /// A DTLS record (20 to 63). + Dtls, + /// An RTP or RTCP packet (128 to 191). + Rtp, + /// Nothing this socket serves, including an empty datagram. + Unknown, +} + +const STUN_HEADER: usize = 20; +const STUN_COOKIE: [u8; 4] = [0x21, 0x12, 0xA4, 0x42]; +const STUN_BINDING_REQUEST: [u8; 2] = [0x00, 0x01]; +const STUN_BINDING_SUCCESS: [u8; 2] = [0x01, 0x01]; +const STUN_BINDING_ERROR: [u8; 2] = [0x01, 0x11]; +const STUN_USERNAME: u16 = 0x0006; + +/// Classify a datagram by RFC 7983's first-byte ranges. +pub fn classify(datagram: &[u8]) -> Class { + match datagram.first() { + Some(0..=3) if is_ice(datagram) => Class::Ice, + Some(0..=3) => Class::Stun, + Some(20..=63) => Class::Dtls, + Some(64..=127 | 192..=255) => Class::Quic, + Some(128..=191) => Class::Rtp, + _ => Class::Unknown, + } +} + +/// Whether `datagram` is a well-formed STUN Binding response, or Binding request +/// with a USERNAME attribute. +fn is_ice(datagram: &[u8]) -> bool { + let Some((header, mut attributes)) = datagram.split_at_checked(STUN_HEADER) else { + return false; + }; + if header[4..8] != STUN_COOKIE || usize::from(u16::from_be_bytes([header[2], header[3]])) != attributes.len() { + return false; + } + match [header[0], header[1]] { + STUN_BINDING_SUCCESS | STUN_BINDING_ERROR => return true, + STUN_BINDING_REQUEST => {} + _ => return false, + } + + while let Some((attribute, rest)) = attributes.split_at_checked(4) { + let kind = u16::from_be_bytes([attribute[0], attribute[1]]); + let len = usize::from(u16::from_be_bytes([attribute[2], attribute[3]])); + if kind == STUN_USERNAME { + return len <= rest.len(); + } + // Values are padded to a multiple of four. + let Some(rest) = rest.get(len.next_multiple_of(4)..) else { + return false; + }; + attributes = rest; + } + false +} + +/// Where a datagram goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Route { + /// The QUIC endpoint. + Quic, + /// The WebRTC media path: ICE, DTLS, and RTP. + WebRtc, + /// Nowhere; counted under this reason. + Discard(Discard), +} + +/// Why a datagram was dropped instead of delivered. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Discard { + /// STUN that is not ICE, such as a public Binding query; the public + /// responder binds its own port. + Stun, + /// Nothing this socket serves. + Unknown, + /// WebRTC traffic while nothing consumes it. + Unclaimed, + /// WebRTC traffic that found its queue full, or its source over its share. + Overflow, +} + +impl Discard { + /// Every reason, in [`Counters`] order. + pub const ALL: [Self; 4] = [Self::Stun, Self::Unknown, Self::Unclaimed, Self::Overflow]; + + /// A stable lowercase label, for metrics. + pub fn as_str(self) -> &'static str { + match self { + Self::Stun => "stun", + Self::Unknown => "unknown", + Self::Unclaimed => "unclaimed", + Self::Overflow => "overflow", + } + } +} + +/// Datagrams dropped per [`Discard`] reason. Clones share the counts. +#[derive(Clone, Debug, Default)] +pub struct Counters(Arc<[AtomicU64; Discard::ALL.len()]>); + +impl Counters { + /// Count one datagram dropped for `reason`. + pub fn discard(&self, reason: Discard) { + self.0[reason as usize].fetch_add(1, Ordering::Relaxed); + } + + /// How many datagrams were dropped for `reason`. + pub fn get(&self, reason: Discard) -> u64 { + self.0[reason as usize].load(Ordering::Relaxed) + } +} + +/// Remote addresses pinned to WebRTC, whatever they send. Clones share the table. +/// +/// Keyed by the remote address alone: the local port is the socket's, and a +/// remote address reaching two of a wildcard socket's local addresses is +/// treated as one flow. IPv4-mapped addresses are folded to IPv4, as +/// `moq_ice::canonical` does, so a dual-stack socket's pins match. +#[derive(Clone, Debug, Default)] +pub struct Flows(Arc); + +#[derive(Debug, Default)] +struct FlowsState { + /// Whether anything is pinned, so an unpinned listener skips the set entirely. + any: AtomicBool, + /// The pinned addresses, read without a lock on every datagram by every shard + /// and replaced whole on the rare pin or release. + pinned: ArcSwap>, + /// How many [`Pin`]s hold each address, which only pinning and releasing touch. + pins: Mutex>, +} + +impl FlowsState { + fn publish(&self, pins: &HashMap) { + self.pinned.store(Arc::new(pins.keys().copied().collect())); + self.any.store(!pins.is_empty(), Ordering::Release); + } +} + +impl Flows { + /// Route `datagram` from `remote`: a pin wins, otherwise its [`Class`] decides. + pub fn route(&self, remote: SocketAddr, datagram: &[u8]) -> Route { + if self.is_pinned(remote) { + return Route::WebRtc; + } + match classify(datagram) { + Class::Quic => Route::Quic, + Class::Ice | Class::Dtls | Class::Rtp => Route::WebRtc, + Class::Stun => Route::Discard(Discard::Stun), + Class::Unknown => Route::Discard(Discard::Unknown), + } + } + + /// Pin `remote` to WebRTC until the returned [`Pin`] drops. + pub fn pin(&self, remote: SocketAddr) -> Pin { + let remote = canonical(remote); + let mut pins = self.0.pins.lock().unwrap(); + let count = pins.entry(remote).or_default(); + *count += 1; + if *count == 1 { + self.0.publish(&pins); + } + Pin { + flows: self.clone(), + remote, + } + } + + fn is_pinned(&self, remote: SocketAddr) -> bool { + self.0.any.load(Ordering::Acquire) && self.0.pinned.load().contains(&canonical(remote)) + } +} + +fn canonical(addr: SocketAddr) -> SocketAddr { + SocketAddr::new(addr.ip().to_canonical(), addr.port()) +} + +/// Keeps a remote address pinned to WebRTC in its [`Flows`]; dropping it releases the pin. +#[derive(Debug)] +pub struct Pin { + flows: Flows, + remote: SocketAddr, +} + +impl Drop for Pin { + fn drop(&mut self) { + let mut pins = self.flows.0.pins.lock().unwrap(); + if let Some(count) = pins.get_mut(&self.remote) { + *count -= 1; + if *count == 0 { + pins.remove(&self.remote); + self.flows.0.publish(&pins); + } + } + } +} + +/// A FIFO of one stack's datagrams, bounded by cost with a per-source share. +/// +/// A source may hold at most [`Queue::SHARE`] of the capacity, so one flooding +/// sender is refused while the rest still get in; filling the queue takes that +/// many sources at once. +#[derive(Debug)] +pub struct Queue { + items: VecDeque<(SocketAddr, usize, T)>, + /// The cost each source holds in `items`; a source holding nothing has no entry. + sources: HashMap, + cost: usize, + capacity: usize, +} + +impl Queue { + /// The fraction of the capacity, as a divisor, one source may hold. + pub const SHARE: usize = 8; + + /// An empty queue holding up to `capacity` in total cost. + pub fn new(capacity: usize) -> Self { + Self { + items: VecDeque::new(), + sources: HashMap::new(), + cost: 0, + capacity, + } + } + + /// Append `item` from `source`, charged `cost`, or hand it back when the queue + /// is full or `source` already holds its share. + pub fn push(&mut self, source: SocketAddr, cost: usize, item: T) -> Result<(), T> { + let held = self.sources.get(&source).copied().unwrap_or(0); + if self.cost + cost > self.capacity || held + cost > self.capacity / Self::SHARE { + return Err(item); + } + self.cost += cost; + *self.sources.entry(source).or_default() += cost; + self.items.push_back((source, cost, item)); + Ok(()) + } + + /// Remove the oldest item. + pub fn pop(&mut self) -> Option { + let (source, cost, item) = self.items.pop_front()?; + self.cost -= cost; + if let Some(held) = self.sources.get_mut(&source) { + *held -= cost; + if *held == 0 { + self.sources.remove(&source); + } + } + Some(item) + } + + /// Drop every item. + pub fn clear(&mut self) { + self.items.clear(); + self.sources.clear(); + self.cost = 0; + } + + /// Whether nothing is queued. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A Binding request: header, then the given attributes. + fn binding(attributes: &[(u16, &[u8])]) -> Vec { + stun(STUN_BINDING_REQUEST, attributes) + } + + /// A STUN message of `kind`: header, then the given attributes. + fn stun(kind: [u8; 2], attributes: &[(u16, &[u8])]) -> Vec { + let mut body = Vec::new(); + for (kind, value) in attributes { + body.extend_from_slice(&kind.to_be_bytes()); + body.extend_from_slice(&(value.len() as u16).to_be_bytes()); + body.extend_from_slice(value); + body.resize(body.len().next_multiple_of(4), 0); + } + let mut out = Vec::new(); + out.extend_from_slice(&kind); + out.extend_from_slice(&(body.len() as u16).to_be_bytes()); + out.extend_from_slice(&STUN_COOKIE); + out.extend_from_slice(&[7; 12]); + out.extend_from_slice(&body); + out + } + + const PRIORITY: u16 = 0x0024; + const XOR_MAPPED_ADDRESS: u16 = 0x0020; + + fn addr(port: u16) -> SocketAddr { + SocketAddr::from(([192, 0, 2, 1], port)) + } + + #[test] + fn first_byte_classes() { + let check = binding(&[(PRIORITY, &[0, 0, 0, 1]), (STUN_USERNAME, b"abc:def")]); + let cases: &[(&[u8], Class)] = &[ + (&check, Class::Ice), + ( + &stun(STUN_BINDING_SUCCESS, &[(XOR_MAPPED_ADDRESS, &[0; 8])]), + Class::Ice, + ), + (&stun(STUN_BINDING_ERROR, &[]), Class::Ice), + (&binding(&[(PRIORITY, &[0, 0, 0, 1])]), Class::Stun), + // A Binding indication, which no ICE agent here waits on. + (&stun([0x00, 0x11], &[]), Class::Stun), + (&[0x01, 0x01, 0, 0], Class::Stun), + (&[3], Class::Stun), + (&[4], Class::Unknown), + (&[19], Class::Unknown), + (&[20], Class::Dtls), + (&[0x16, 0xfe, 0xfd], Class::Dtls), + (&[63], Class::Dtls), + (&[64], Class::Quic), + (&[127], Class::Quic), + (&[128], Class::Rtp), + (&[0x80, 0x00], Class::Rtp), + (&[191], Class::Rtp), + (&[192], Class::Quic), + (&[255], Class::Quic), + (&[], Class::Unknown), + ]; + for (datagram, class) in cases { + assert_eq!(classify(datagram), *class, "{datagram:02x?}"); + } + } + + #[test] + fn malformed_checks_are_plain_stun() { + let check = binding(&[(STUN_USERNAME, b"abc:def")]); + + let mut cookie = check.clone(); + cookie[4] ^= 1; + assert_eq!(classify(&cookie), Class::Stun); + + // A response is held to the same framing as a check. + let mut response = stun(STUN_BINDING_SUCCESS, &[(XOR_MAPPED_ADDRESS, &[0; 8])]); + response.push(0); + assert_eq!(classify(&response), Class::Stun); + + // The header's length must cover the attributes exactly. + let mut long = check.clone(); + long.push(0); + assert_eq!(classify(&long), Class::Stun); + assert_eq!(classify(&check[..check.len() - 4]), Class::Stun); + + // An attribute running past the message hides any USERNAME after it. + let mut truncated = binding(&[(PRIORITY, &[0, 0, 0, 1]), (STUN_USERNAME, b"abc:def")]); + truncated[STUN_HEADER + 3] = 200; + assert_eq!(classify(&truncated), Class::Stun); + } + + #[test] + fn routes_follow_the_class() { + let flows = Flows::default(); + let check = binding(&[(STUN_USERNAME, b"abc:def")]); + assert_eq!(flows.route(addr(1), &check), Route::WebRtc); + assert_eq!(flows.route(addr(1), &stun(STUN_BINDING_SUCCESS, &[])), Route::WebRtc); + assert_eq!(flows.route(addr(1), &[0x16]), Route::WebRtc); + assert_eq!(flows.route(addr(1), &[0x80]), Route::WebRtc); + assert_eq!(flows.route(addr(1), &[0x40]), Route::Quic); + assert_eq!(flows.route(addr(1), &[0xc0]), Route::Quic); + assert_eq!(flows.route(addr(1), &binding(&[])), Route::Discard(Discard::Stun)); + assert_eq!(flows.route(addr(1), &[5]), Route::Discard(Discard::Unknown)); + } + + #[test] + fn a_pin_wins_until_every_holder_releases_it() { + let flows = Flows::default(); + let mapped = SocketAddr::from((std::net::Ipv4Addr::new(192, 0, 2, 1).to_ipv6_mapped(), 1)); + + let first = flows.pin(addr(1)); + let second = flows.pin(mapped); + assert_eq!(flows.route(addr(1), &[0x40]), Route::WebRtc); + assert_eq!(flows.route(mapped, &[5]), Route::WebRtc); + assert_eq!(flows.route(addr(2), &[0x40]), Route::Quic); + + drop(first); + assert_eq!(flows.route(addr(1), &[0x40]), Route::WebRtc); + drop(second); + assert_eq!(flows.route(addr(1), &[0x40]), Route::Quic); + } + + #[test] + fn counters_count_each_reason() { + let counters = Counters::default(); + counters.clone().discard(Discard::Overflow); + counters.discard(Discard::Overflow); + counters.discard(Discard::Stun); + assert_eq!(counters.get(Discard::Overflow), 2); + assert_eq!(counters.get(Discard::Stun), 1); + assert_eq!(counters.get(Discard::Unknown), 0); + } + + #[test] + fn a_flooding_source_is_held_to_its_share() { + let mut queue = Queue::new(16); + queue.push(addr(1), 1, 0).unwrap(); + queue.push(addr(1), 1, 1).unwrap(); + assert_eq!(queue.push(addr(1), 1, 2), Err(2)); + + // Its share is spent, not the queue: everyone else still gets in. + for port in 2..9 { + queue.push(addr(port), 2, 10 + port).unwrap(); + } + // Now the queue is full, whatever the source. + assert_eq!(queue.push(addr(9), 1, 19), Err(19)); + + // Popping refunds the oldest item's source. + assert_eq!(queue.pop(), Some(0)); + queue.push(addr(1), 1, 2).unwrap(); + let drained: Vec<_> = std::iter::from_fn(|| queue.pop()).collect(); + assert_eq!(drained, [1, 12, 13, 14, 15, 16, 17, 18, 2]); + assert!(queue.is_empty()); + assert!(queue.sources.is_empty()); + } + + #[test] + fn clear_forgets_every_source() { + let mut queue = Queue::new(16); + queue.push(addr(1), 2, ()).unwrap(); + queue.clear(); + assert!(queue.is_empty()); + queue.push(addr(1), 2, ()).unwrap(); + } +} diff --git a/rs/moq-sock/src/lib.rs b/rs/moq-sock/src/lib.rs index e5ff6593a56..6074e1c7fda 100644 --- a/rs/moq-sock/src/lib.rs +++ b/rs/moq-sock/src/lib.rs @@ -5,11 +5,13 @@ //! [`shard::bind`] forms and steers an `SO_REUSEPORT` group by QUIC connection //! id, so a worker-per-core listener keeps each connection on the socket that //! owns it; [`cpu`] pins those workers; [`udp`] (forked from quinn-udp) sends -//! and receives with GSO, GRO, and ECN. Both `moq-tokio` and `moq-uring` build +//! and receives with GSO, GRO, and ECN; [`demux`] splits one socket's datagrams +//! between QUIC and the WebRTC media path. Both `moq-tokio` and `moq-uring` build //! their worker groups on this crate, so the group-formation invariants live //! here once, in the group itself rather than in each caller. pub mod bind; pub mod cpu; +pub mod demux; pub mod shard; pub mod udp; diff --git a/rs/moq-tokio/src/quic.rs b/rs/moq-tokio/src/quic.rs index d09ed2bfd7e..e558ab5c7cf 100644 --- a/rs/moq-tokio/src/quic.rs +++ b/rs/moq-tokio/src/quic.rs @@ -20,6 +20,8 @@ mod backend; pub use backend::Error; #[cfg(feature = "quic")] pub(crate) use backend::{Client, Closer, Options, Server, accept}; +#[cfg(feature = "quic")] +pub mod demux; #[cfg(feature = "driver")] pub mod driver; diff --git a/rs/moq-tokio/src/quic/backend.rs b/rs/moq-tokio/src/quic/backend.rs index f7e20e97aec..7a338e03c85 100644 --- a/rs/moq-tokio/src/quic/backend.rs +++ b/rs/moq-tokio/src/quic/backend.rs @@ -561,6 +561,7 @@ pub(crate) struct Options { pub quic: crate::quic::Config, pub member: Option, pub tls: crate::tls::Serve, + pub demux: super::demux::Demux, } impl Server { @@ -570,6 +571,7 @@ impl Server { quic, member, tls: served, + demux, } = options; let quic = quic.resolve(); let transport = transport(&quic, "server")?; @@ -612,6 +614,9 @@ impl Server { let load_balancer = config.load_balancer(); let mut endpoint_config = driver::endpoint::Config::default().with_server(server); + // The demux, and the shard steering filter, tell QUIC from the WebRTC media + // path by the fixed bit, so peers must never be invited to clear it. + endpoint_config.grease_quic_bit = false; // It also keys the default connection ID generator. The ones below validate a // predecessor's IDs without it. if let Some(secret) = &config.reset.secret { @@ -624,8 +629,6 @@ impl Server { if load_balancer.is_some() { return Err(Error::ShardWithQuicLb); } - // The steering filter recognizes short headers by their fixed bit. - endpoint_config.grease_quic_bit = false; tracing::debug!( index = shard.index(), count = shard.count(), @@ -665,7 +668,10 @@ impl Server { None => crate::bind::udp(crate::bind::Udp::new(listen)).map_err(Error::BindSocket)?, }; - let quic = driver::endpoint::Endpoint::new(endpoint_config, socket).map_err(Error::CreateEndpoint)?; + let socket = driver::wrap_udp_socket(socket).map_err(|err| Error::CreateEndpoint(err.into()))?; + let quic = + driver::endpoint::Endpoint::new(endpoint_config, demux.wrap(socket)).map_err(Error::CreateEndpoint)?; + demux.attach(&quic); Ok(Self { quic, tls: served }) } @@ -899,6 +905,104 @@ mod tests { ); } + /// QUIC and the WebRTC media path share the listening port: a QUIC session + /// connects while a WebRTC peer's ICE check, DTLS, and RTP reach the feed and + /// its reply leaves from the same port, and a public STUN query is dropped. + /// The feed lives as long as the listener, without a clone of its demux kept. + #[tokio::test] + async fn one_port_serves_quic_and_webrtc() { + let demux = crate::quic::demux::Demux::default(); + let feed = demux.webrtc(); + let counters = demux.counters().clone(); + let quic = crate::quic::Config::default(); + let config = listen::Config { + bind: Some("[::]:0".parse().unwrap()), + tls: crate::tls::Listen { + generate: vec!["localhost".into()], + ..Default::default() + }, + ..Default::default() + }; + let tls = config.tls.load().unwrap(); + let server = Server::new(Options { + config, + quic: quic.clone(), + member: None, + tls, + demux, + }) + .expect("server init"); + let port = server.local_addr().port(); + let addr = net::SocketAddr::from((net::Ipv4Addr::LOCALHOST, port)); + + let peer = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let check: &[u8] = &[ + 0x00, 0x01, 0x00, 0x08, 0x21, 0x12, 0xa4, 0x42, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 0x00, 0x06, 0x00, + 0x03, b'a', b':', b'b', 0, + ]; + let query = &check[..20]; + let query = [&[0x00, 0x01, 0x00, 0x00][..], &query[4..]].concat(); + let sent: [&[u8]; 3] = [check, &[0x16, 0xfe, 0xfd, 1], &[0x80, 0x00, 0, 1]]; + peer.send_to(&query, addr).await.unwrap(); + for datagram in sent { + peer.send_to(datagram, addr).await.unwrap(); + } + + let mut tls_config = crate::tls::Connect::default(); + tls_config.fingerprint = server.certificates().fingerprints(); + let config = connect::Config { + bind: Some("127.0.0.1:0".parse().unwrap()), + tls: tls_config, + ..Default::default() + }; + let tls = config.tls.build().expect("tls config"); + let client = Client::new(&config, &quic).expect("client init"); + let url: Url = format!("moqt://localhost:{port}/test").parse().unwrap(); + let versions = moq_net::Versions::default(); + let dial = client.connect(&tls, url.into(), &versions); + let accept = async { + let incoming = server.accept().await.expect("incoming connection"); + super::accept(incoming, versions.alpns()).await + }; + let webrtc = async { + let mut received = Vec::new(); + for _ in sent { + let datagram = feed.recv().await.expect("feed open"); + assert_eq!(datagram.remote, peer.local_addr().unwrap()); + received.push(datagram.data.to_vec()); + } + feed.send(&moq_sock::udp::Transmit { + destination: peer.local_addr().unwrap(), + ecn: None, + contents: b"reply", + segment_size: None, + src_ip: None, + }) + .await + .unwrap(); + let mut buf = [0; 16]; + let (len, from) = peer.recv_from(&mut buf).await.unwrap(); + (received, buf[..len].to_vec(), from.port()) + }; + let (session, received) = kio::clock::timeout(Duration::from_secs(5), async { + let (session, received) = tokio::join!(async { tokio::try_join!(dial, accept) }, webrtc); + (session, received) + }) + .await + .expect("timed out"); + session.expect("QUIC beside WebRTC"); + + let (received, reply, from) = received; + assert_eq!(received, sent.map(<[u8]>::to_vec)); + assert_eq!(reply, b"reply"); + assert_eq!(from, port); + assert_eq!(counters.get(crate::quic::demux::Discard::Stun), 1); + + server.shutdown().await; + let end = kio::clock::timeout(Duration::from_secs(5), feed.recv()).await; + assert!(end.expect("the feed outlived the listener").is_none()); + } + async fn pinned_connection(bind: &str, host_name: Option<&str>) { let quic = crate::quic::Config::default(); let config = listen::Config { @@ -915,6 +1019,7 @@ mod tests { quic: quic.clone(), member: None, tls, + demux: Default::default(), }) .expect("server init"); let addr = server.local_addr(); @@ -1016,6 +1121,7 @@ mod tests { quic: quic.clone(), member: None, tls, + demux: Default::default(), }) .expect("server init"); let addr = server.local_addr(); diff --git a/rs/moq-tokio/src/quic/demux.rs b/rs/moq-tokio/src/quic/demux.rs new file mode 100644 index 00000000000..fc2af50649a --- /dev/null +++ b/rs/moq-tokio/src/quic/demux.rs @@ -0,0 +1,497 @@ +//! One UDP port shared by QUIC and the WebRTC media path. +//! +//! A QUIC listener's socket classifies every datagram it reads with +//! [`moq_sock::demux`]: QUIC stays on the endpoint, and ICE, DTLS, and RTP +//! are diverted to a [`WebRtc`] feed that an embedder hands to its WebRTC stack. +//! Anything else is dropped and counted. The split runs inline in the endpoint's +//! receive, so a QUIC datagram is never copied or handed across tasks. + +use std::{ + io::{self, IoSliceMut}, + net::{IpAddr, SocketAddr}, + sync::{Arc, Mutex}, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use moq_sock::{ + demux::{Flows, Queue, Route}, + udp::RecvMeta, +}; + +pub use moq_sock::{ + demux::{Counters, Discard, Pin}, + udp::Transmit, +}; + +use super::driver::{ + Slot, + endpoint::{AsyncUdpSocket, Endpoint}, +}; + +/// The bytes a [`WebRtc`] feed may hold before it drops, shared by every source. +const CAPACITY: usize = 4 << 20; + +/// A QUIC listener's demux: what its socket diverts, pins, and drops. +/// +/// Clones share one state, so every shard of a worker group feeds one [`WebRtc`] +/// and counts into one [`Counters`]. Set it as +/// [`server::Config::demux`](crate::server::Config::demux) and keep a clone. +#[derive(Clone, Debug, Default)] +pub struct Demux { + /// Liveness: a [`WebRtc`] consumes it, and closes once every clone and every + /// listening socket's [`Divert`] is gone. + live: kio::Producer<()>, + shared: Arc, + /// The listening sockets a [`WebRtc`] answers through. + sockets: Arc>>, +} + +#[derive(Debug)] +struct Shared { + flows: Flows, + counters: Counters, + queue: kio::Shared>, +} + +impl Default for Shared { + fn default() -> Self { + Self { + flows: Flows::default(), + counters: Counters::default(), + queue: kio::Shared::new(Queue::new(CAPACITY)), + } + } +} + +impl Demux { + /// The datagrams this listener dropped, per reason. + pub fn counters(&self) -> &Counters { + &self.shared.counters + } + + /// Start diverting the WebRTC media path to a feed. + /// + /// Until a feed exists, and again once every feed is dropped, that traffic is + /// dropped as [`Discard::Unclaimed`]. Feeds share one queue. + pub fn webrtc(&self) -> WebRtc { + // The first feed starts clean rather than reading what a previous one left. + if !self.live.wanted() { + self.shared.queue.lock().clear(); + } + WebRtc { + live: self.live.consume(), + shared: self.shared.clone(), + sockets: self.sockets.clone(), + } + } + + /// The receive half a listening socket runs, which keeps the feed open until the + /// endpoint releases the socket, so the feed ends with the listener even when no + /// clone of this is kept. + pub(crate) fn divert(&self) -> Divert { + Divert { + live: self.live.clone(), + shared: self.shared.clone(), + } + } + + /// Run the split on `socket`, leaving only QUIC for the endpoint that reads it. + pub(crate) fn wrap(&self, socket: Box) -> Box { + Box::new(Split { + inner: socket, + divert: self.divert(), + }) + } + + /// Answer through `endpoint`'s socket, one this demux [`wrap`](Self::wrap)ped. + /// + /// Only its slot is kept, which the endpoint empties when it releases the port. + pub(crate) fn attach(&self, endpoint: &Endpoint) { + self.sockets.lock().unwrap().push(Answer { + slot: endpoint.inner.slot.clone(), + ipv6: endpoint.local_addr().is_ipv6(), + }); + } +} + +/// A listening socket a [`WebRtc`] answers through. +#[derive(Debug)] +struct Answer { + slot: Arc, + /// Whether the socket is AF_INET6, so an IPv4 destination needs its mapped form. + ipv6: bool, +} + +/// A datagram diverted to the WebRTC media path. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct Datagram { + /// The sender, with an IPv4-mapped address folded to IPv4. + pub remote: SocketAddr, + /// The local address it arrived on, where the platform reports it. + pub local: Option, + /// The whole datagram. + pub data: Bytes, +} + +/// The WebRTC media path of a [`Demux`]: ICE, DTLS, and RTP from its +/// socket, and a way to answer through it. +/// +/// Diverting runs while any feed is alive; drop it to stop. +#[derive(Debug)] +pub struct WebRtc { + live: kio::Consumer<()>, + shared: Arc, + sockets: Arc>>, +} + +impl WebRtc { + /// Poll for the next datagram; `None` once every [`Demux`] clone is gone and + /// every listening socket it split is released. + pub fn poll_recv(&self, waiter: &kio::Waiter) -> Poll> { + if let Poll::Ready(mut queue) = self.shared.queue.poll(waiter, |queue| match queue.is_empty() { + true => Poll::Pending, + false => Poll::Ready(()), + }) { + return Poll::Ready(queue.pop()); + } + // Nothing queued; nothing more arrives once the listener is gone. + match self.live.poll_closed(waiter) { + Poll::Ready(()) => Poll::Ready(self.shared.queue.lock().pop()), + Poll::Pending => Poll::Pending, + } + } + + /// Receive the next datagram; `None` once the listener and every [`Demux`] + /// clone are gone; see [`Self::poll_recv`]. + pub async fn recv(&self) -> Option { + kio::wait(|waiter| self.poll_recv(waiter)).await + } + + /// Poll to send `transmit` from the shared port, with GSO and ECN as it asks. + /// + /// Its `src_ip` is kept as given. On a wildcard bind with several local + /// addresses, set it to the [`Datagram::local`] being answered; `None` lets the + /// kernel pick the source address, which the peer may not accept. + /// + /// A datagram sent once the listener has released its socket is lost, as UDP + /// allows. Errors are those of the endpoint's own sends: an unreachable destination + /// comes back, as [`AsyncUdpSocket::try_send`] describes, and anything else is logged. + pub fn poll_send(&self, waiter: &kio::Waiter, transmit: &Transmit<'_>) -> Poll> { + // Any member of a reuseport group answers from the same port. + let sockets = self.sockets.lock().unwrap(); + let Some(socket) = sockets.iter().find(|socket| !socket.slot.is_released()) else { + return Poll::Ready(Ok(())); + }; + let destination = match (transmit.destination, socket.ipv6) { + (SocketAddr::V4(v4), true) => SocketAddr::new(v4.ip().to_ipv6_mapped().into(), v4.port()), + (destination, _) => destination, + }; + let transmit = Transmit { + destination, + ..*transmit + }; + socket.slot.poll_send(&transmit, &mut waiter.context()) + } + + /// Send `transmit` from the shared port; see [`Self::poll_send`]. + pub async fn send(&self, transmit: &Transmit<'_>) -> io::Result<()> { + kio::wait(|waiter| self.poll_send(waiter, transmit)).await + } + + /// Route every datagram from `remote` to this feed, whatever its first byte, + /// until the returned [`Pin`] drops. + pub fn pin(&self, remote: SocketAddr) -> Pin { + self.shared.flows.pin(remote) + } +} + +/// A listening socket that runs its [`Divert`] on every receive. +#[derive(Debug)] +struct Split { + inner: Box, + divert: Divert, +} + +impl AsyncUdpSocket for Split { + fn poll_recv( + &self, + cx: &mut Context<'_>, + bufs: &mut [IoSliceMut<'_>], + meta: &mut [RecvMeta], + ) -> Poll> { + self.inner + .poll_recv(cx, bufs, meta) + .map_ok(|received| self.divert.split(bufs, meta, received)) + } + + fn try_send(&self, transmit: &Transmit<'_>) -> io::Result<()> { + self.inner.try_send(transmit) + } + + fn poll_writable(&self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_writable(cx) + } + + fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } + + fn max_receive_segments(&self) -> usize { + self.inner.max_receive_segments() + } + + fn max_transmit_segments(&self) -> usize { + self.inner.max_transmit_segments() + } + + fn may_fragment(&self) -> bool { + self.inner.may_fragment() + } +} + +/// The receive half of a [`Demux`], run by each listening socket. +#[derive(Clone, Debug)] +pub(crate) struct Divert { + live: kio::Producer<()>, + shared: Arc, +} + +impl Divert { + /// Route the `received` batches in `bufs` and `metas`, keeping only QUIC. + /// + /// QUIC segments are compacted to the front of each batch and the batches + /// emptied by the split are removed, so the endpoint reads the first + /// returned-count entries as usual. That count may be zero. + pub(crate) fn split(&self, bufs: &mut [IoSliceMut<'_>], metas: &mut [RecvMeta], received: usize) -> usize { + let mut kept = 0; + for i in 0..received { + let mut meta = metas[i]; + meta.len = self.split_batch(&mut bufs[i][..meta.len], &meta); + if meta.len == 0 { + continue; + } + if kept != i { + let (head, tail) = bufs.split_at_mut(i); + head[kept][..meta.len].copy_from_slice(&tail[0][..meta.len]); + } + metas[kept] = meta; + kept += 1; + } + kept + } + + /// Route each `meta.stride` segment of one batch, returning the length of the + /// QUIC segments left at its front. + /// + /// Only the last segment of a batch may be short, and the QUIC ones keep their + /// order, so the compacted batch keeps the same stride. + fn split_batch(&self, buf: &mut [u8], meta: &RecvMeta) -> usize { + let mut kept = 0; + let mut start = 0; + while start < buf.len() { + let end = buf.len().min(start + meta.stride.max(1)); + match self.shared.flows.route(meta.addr, &buf[start..end]) { + Route::Quic => { + buf.copy_within(start..end, kept); + kept += end - start; + } + Route::WebRtc => self.push(meta, &buf[start..end]), + Route::Discard(reason) => self.shared.counters.discard(reason), + // A stack this listener does not serve yet. + _ => self.shared.counters.discard(Discard::Unknown), + } + start = end; + } + kept + } + + fn push(&self, meta: &RecvMeta, data: &[u8]) { + if !self.live.wanted() { + self.shared.counters.discard(Discard::Unclaimed); + return; + } + let remote = SocketAddr::new(meta.addr.ip().to_canonical(), meta.addr.port()); + let datagram = Datagram { + remote, + local: meta.dst_ip.map(|ip| ip.to_canonical()), + data: Bytes::copy_from_slice(data), + }; + let cost = data.len() + size_of::(); + if self.shared.queue.lock().push(remote, cost, datagram).is_err() { + self.shared.counters.discard(Discard::Overflow); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CHECK: &[u8] = &[ + 0x00, 0x01, 0x00, 0x08, 0x21, 0x12, 0xa4, 0x42, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, // header + 0x00, 0x06, 0x00, 0x03, b'a', b':', b'b', 0, // USERNAME + ]; + const QUERY: &[u8] = &[ + 0x00, 0x01, 0x00, 0x00, 0x21, 0x12, 0xa4, 0x42, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + ]; + + fn remote(port: u16) -> SocketAddr { + SocketAddr::from(([192, 0, 2, 1], port)) + } + + /// Split `batches`, each one GRO batch of equal-stride segments from one source, + /// and return what the endpoint would read. + fn split(divert: &Divert, batches: &[(SocketAddr, &[&[u8]])]) -> Vec<(SocketAddr, usize, Vec)> { + let mut storage = vec![[0u8; 1024]; batches.len()]; + let mut metas = vec![RecvMeta::default(); batches.len()]; + for ((buf, meta), (addr, segments)) in storage.iter_mut().zip(&mut metas).zip(batches) { + let stride = segments[0].len(); + let mut len = 0; + for segment in *segments { + buf[len..len + segment.len()].copy_from_slice(segment); + len += stride; + } + len -= stride - segments.last().unwrap().len(); + meta.addr = *addr; + meta.len = len; + meta.stride = stride; + } + let mut bufs: Vec<_> = storage.iter_mut().map(|buf| IoSliceMut::new(buf)).collect(); + let kept = divert.split(&mut bufs, &mut metas, batches.len()); + metas[..kept] + .iter() + .zip(&bufs) + .map(|(meta, buf)| (meta.addr, meta.stride, buf[..meta.len].to_vec())) + .collect() + } + + fn drain(feed: &WebRtc) -> Vec<(SocketAddr, Vec)> { + std::iter::from_fn(|| match feed.poll_recv(&kio::Waiter::noop()) { + Poll::Ready(Some(datagram)) => Some((datagram.remote, datagram.data.to_vec())), + _ => None, + }) + .collect() + } + + #[test] + fn each_class_reaches_its_stack() { + let demux = Demux::default(); + let feed = demux.webrtc(); + let kept = split( + &demux.divert(), + &[ + (remote(1), &[&[0x40, 1]]), + (remote(2), &[CHECK]), + (remote(3), &[&[0x16, 0xfe, 0xfd]]), + (remote(4), &[&[0x80, 0x00]]), + (remote(5), &[&[0xc0, 5]]), + (remote(6), &[QUERY]), + (remote(7), &[&[5]]), + ], + ); + + // QUIC compacts to the front; the diverted batches leave no gap. + assert_eq!(kept, [(remote(1), 2, vec![0x40, 1]), (remote(5), 2, vec![0xc0, 5])]); + assert_eq!( + drain(&feed), + [ + (remote(2), CHECK.to_vec()), + (remote(3), vec![0x16, 0xfe, 0xfd]), + (remote(4), vec![0x80, 0x00]), + ] + ); + assert_eq!(demux.counters().get(Discard::Stun), 1); + assert_eq!(demux.counters().get(Discard::Unknown), 1); + } + + #[test] + fn a_mixed_batch_keeps_its_stride() { + let demux = Demux::default(); + let feed = demux.webrtc(); + let kept = split( + &demux.divert(), + &[(remote(1), &[&[0x40, 1, 1], &[0x80, 2, 2], &[0x41, 3, 3], &[0x42, 4]])], + ); + + assert_eq!(kept, [(remote(1), 3, vec![0x40, 1, 1, 0x41, 3, 3, 0x42, 4])]); + assert_eq!(drain(&feed), [(remote(1), vec![0x80, 2, 2])]); + } + + #[test] + fn a_pinned_remote_reaches_webrtc_whatever_it_sends() { + let demux = Demux::default(); + let feed = demux.webrtc(); + let pin = feed.pin(remote(1)); + let kept = split( + &demux.divert(), + &[(remote(1), &[&[0x40, 1]]), (remote(2), &[&[0x40, 2]])], + ); + assert_eq!(kept, [(remote(2), 2, vec![0x40, 2])]); + assert_eq!(drain(&feed), [(remote(1), vec![0x40, 1])]); + + drop(pin); + let kept = split(&demux.divert(), &[(remote(1), &[&[0x40, 1]])]); + assert_eq!(kept.len(), 1); + } + + #[test] + fn webrtc_traffic_waits_for_a_feed() { + let demux = Demux::default(); + let divert = demux.divert(); + let rtp: &[(SocketAddr, &[&[u8]])] = &[(remote(1), &[&[0x80, 0]])]; + + // Nobody reads it yet, so nothing is held for later. + assert!(split(&divert, rtp).is_empty()); + assert_eq!(demux.counters().get(Discard::Unclaimed), 1); + + let feed = demux.webrtc(); + split(&divert, rtp); + drop(feed); + split(&divert, rtp); + assert_eq!(demux.counters().get(Discard::Unclaimed), 2); + + // A new feed starts clean, without the datagram the dropped one left. + let feed = demux.webrtc(); + assert!(drain(&feed).is_empty()); + } + + #[test] + fn a_flooding_source_overflows_alone() { + let demux = Demux::default(); + let feed = demux.webrtc(); + let divert = demux.divert(); + let big = [0x80; 1024]; + let share = CAPACITY / Queue::::SHARE / big.len(); + for _ in 0..=share { + split(&divert, &[(remote(1), &[&big])]); + } + let overflow = demux.counters().get(Discard::Overflow); + assert!(overflow > 0); + + split(&divert, &[(remote(2), &[&big])]); + assert_eq!(demux.counters().get(Discard::Overflow), overflow); + assert_eq!(drain(&feed).last().map(|(remote, _)| *remote), Some(remote(2))); + } + + #[test] + fn the_feed_ends_with_the_listener() { + let demux = Demux::default(); + let feed = demux.webrtc(); + let divert = demux.divert(); + split(&divert, &[(remote(1), &[&[0x80, 0]])]); + drop(demux); + + // The listening socket still diverts, so the feed stays open. + let waiter = kio::Waiter::noop(); + assert!(matches!(feed.poll_recv(&waiter), Poll::Ready(Some(_)))); + assert!(feed.poll_recv(&waiter).is_pending()); + + // What was queued still arrives, then the end. + split(&divert, &[(remote(1), &[&[0x80, 0]])]); + drop(divert); + assert!(matches!(feed.poll_recv(&waiter), Poll::Ready(Some(_)))); + assert!(matches!(feed.poll_recv(&waiter), Poll::Ready(None))); + } +} diff --git a/rs/moq-tokio/src/quic/driver/endpoint.rs b/rs/moq-tokio/src/quic/driver/endpoint.rs index 2bf0f71daf5..67b7bcf874c 100644 --- a/rs/moq-tokio/src/quic/driver/endpoint.rs +++ b/rs/moq-tokio/src/quic/driver/endpoint.rs @@ -889,7 +889,9 @@ impl RecvState { loop { match recv(cx, &mut iovs, &mut metas) { Poll::Ready(Ok(msgs)) => { - self.recv_limiter.record_work(msgs); + // A demuxing socket can hand back an empty batch after diverting every + // datagram it read; that read is still work, or a flood of it never yields. + self.recv_limiter.record_work(msgs.max(1)); let received = Instant::now(); self.wall.sample(received, kio::clock::Wall::now()); // Copy the received batch into `datagrams` and split each datagram off as diff --git a/rs/moq-tokio/src/quic/driver/mod.rs b/rs/moq-tokio/src/quic/driver/mod.rs index af14c97f844..f88d0fe1f42 100644 --- a/rs/moq-tokio/src/quic/driver/mod.rs +++ b/rs/moq-tokio/src/quic/driver/mod.rs @@ -66,8 +66,8 @@ pub use moq_quic::{ VarInt, VarIntBoundsExceeded, Version, client, crypto, server, stream, transport, }; -#[cfg(all(test, feature = "quic"))] -pub(crate) use crate::quic::driver::runtime::wrap_udp_socket; +#[cfg(feature = "quic")] +pub(crate) use crate::quic::driver::runtime::{Slot, wrap_udp_socket}; #[cfg(test)] mod tests; diff --git a/rs/moq-tokio/src/quic/driver/runtime/mod.rs b/rs/moq-tokio/src/quic/driver/runtime/mod.rs index 326b13b4367..494172a4633 100644 --- a/rs/moq-tokio/src/quic/driver/runtime/mod.rs +++ b/rs/moq-tokio/src/quic/driver/runtime/mod.rs @@ -135,6 +135,12 @@ impl Slot { self.blocked.wake_all(); } + /// Whether [`Self::release`] closed the socket + #[cfg(feature = "quic")] + pub(crate) fn is_released(&self) -> bool { + self.socket.read().unwrap().is_none() + } + /// See [`AsyncUdpSocket::max_transmit_segments`] pub(crate) fn max_transmit_segments(&self) -> usize { self.socket diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index a8c0d292852..d7b5396fe2e 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -193,6 +193,11 @@ pub struct Config { /// Per-connection statistics context. pub stats: moq_net::stats::Session, + + /// What the QUIC socket diverts from the shared port and drops; keep a clone to + /// take its [`webrtc`](crate::quic::demux::Demux::webrtc) feed or read its counters. + #[cfg(feature = "quic")] + pub demux: crate::quic::demux::Demux, } impl Config { @@ -222,6 +227,7 @@ impl Config { subscriber: self.subscriber.clone(), stats: self.stats.clone(), activity: self.activity.clone(), + demux: self.demux.clone(), } } } @@ -285,6 +291,8 @@ impl Server { publisher, subscriber, stats, + #[cfg(feature = "quic")] + demux, } = config; // Refuse here rather than in `init`, so a caller that skipped its own check @@ -384,6 +392,7 @@ impl Server { quic: quic.clone(), member, tls: tls.clone().expect("QUIC needs TLS"), + demux: demux.clone(), }) }) .transpose()?; diff --git a/sh/gh/nightly.sh b/sh/gh/nightly.sh index 60088a65c8b..bfc59cd6b8c 100755 --- a/sh/gh/nightly.sh +++ b/sh/gh/nightly.sh @@ -123,10 +123,10 @@ case "$1" in # The allocation/window/play-buffer tables keep their stdout in the run; # only Criterion estimates enter the trend report. criterion extra --no-fail-fast -p moq-e2ee -p moq-sign -p moq-json -p moq-archive -p moq-cli -p moq-mux -p moq-audio \ - -p moq-quic --features moq-quic/fuzz \ + -p moq-quic -p moq-sock --features moq-quic/fuzz \ --bench protect --bench sign --bench codec --bench table --bench allocations --bench window \ --bench producer --bench play_buffer --bench jitter --bench decode_clock --bench ts_programs --bench ts_video \ - --bench bus --bench parameter_sets --bench sent_packets + --bench bus --bench parameter_sets --bench sent_packets --bench demux # moq-auth's published release is its own dev-dependency, so `-p moq-auth` is ambiguous. # The concurrent cases' latency percentiles stay in the run's stdout. criterion auth --manifest-path rs/moq-auth/Cargo.toml --bench verify From 8f830b47130282c52d54f9f3ea4ef493b2605cbe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:14:28 -0800 Subject: [PATCH 27/86] feat(c)!: close epoch and audio query gaps (#7246) Co-authored-by: GPT-6.1 Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot --- doc/lib/c/index.md | 2 +- quest/m1/api-audit/route/handle.md | 2 +- quest/m1/c/README.md | 2 +- quest/m1/c/api-gaps.md | 29 +-- rs/moq-c/README.md | 1 + rs/moq-c/c-tests/audio-decode-output.c | 7 + rs/moq-c/c-tests/timestamps.c | 2 +- rs/moq-c/src/api.rs | 49 +++-- rs/moq-c/src/consume.rs | 17 +- rs/moq-c/src/test.rs | 237 +++++++++++++++++++++++-- 10 files changed, 289 insertions(+), 59 deletions(-) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index be8e7e1d4c9..09a6da3e104 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -112,7 +112,7 @@ inside. The header is the reference: each function carries a doc comment. - **Decoded reads.** `moq_decode_audio` and `moq_decode_video` deliver read ids in order: a frame, a gap where media was lost, or an end where the publisher stopped its encoder. Check `kind` on what `moq_decode_audio_read` or `moq_decode_video_read` writes. Free each id with `moq_decode_audio_read_free` or `moq_decode_video_read_free` promptly: a held video frame holds a decoder buffer, and the decoder stalls. A decoder reads everything the track still holds unless a live player sets `start` to `MOQ_DECODE_START_LATEST`, which joins at the newest group and skips the cached backlog. - **Stats.** `moq_session_stats()` fills `moq_connection_stats` with `rtt_us`, `estimated_send_rate_bps`, and the byte and packet counters (`bytes_sent`, `bytes_received`, `bytes_lost`, `packets_sent`, `packets_received`, `packets_lost`). Each has a `_valid` flag, false when the transport does not report it, which is not the same as zero. `moq_session_snapshot()` adds the negotiated `protocol`. A failed dial or dropped connection reaches the session's `on_state` with its `failure` code, and `moq_error()` gives the reason during that callback. Hold the handle returned by `moq_session_recv_bandwidth()` to request receive estimates. `moq_bandwidth_consumer_peek()` reads the estimate and `moq_bandwidth_consumer_changed()` waits for a change. `moq_bandwidth_consumer_close()` releases demand; the last consumer stops PROBE. Stats snapshots do not request it. - **Demand.** A watcher from `moq_publish_track_demand()` (or the media, video, and audio variants) calls back `MOQ_DEMAND_WANTED` or `MOQ_DEMAND_UNWANTED`, again whenever the wanted group moves. `moq_publish_demand_wanted()` reads that group: start a new one once it passes your largest. Set `refresh` in `moq_track_info` to have a joining subscriber ask for a new group once the newest has grown past `refresh` times its first frame. A producer publishes everything you write, subscribed or not, so a late subscriber can fetch the recent past. Capturing only while the watcher reports `MOQ_DEMAND_WANTED` is an opt-in that saves the encode but leaves nothing from before the first subscriber. -- **Disabled renditions.** A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Select an enabled rendition from the latest catalog before opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. +- **Disabled renditions.** A rendition with `enabled: false` has no frames coming until its publisher re-enables it. Query the latest catalog with `moq_consume_audio_enabled` or `moq_consume_video_enabled` before selecting a rendition and opening a decoder. Keep one already playing while every rendition of its kind is disabled: the convenience decoders accept a disabled configuration, and a re-enable, such as an unmute, then plays without a resubscribe. Among the video renditions you can decode, prefer the highest `preference`; `0` is the default and `-1` marks a compatibility transcode. ## Reference diff --git a/quest/m1/api-audit/route/handle.md b/quest/m1/api-audit/route/handle.md index b43378746ba..69ba37e336c 100644 --- a/quest/m1/api-audit/route/handle.md +++ b/quest/m1/api-audit/route/handle.md @@ -90,7 +90,7 @@ JS and the bindings identical. `moq_broadcast_prepared_*`, `moq_publication_*` and `moq_mint_epoch`. - The py, kt, swift, dart and go wrappers. - [moq-c's API gaps](/quest/m1/c/api-gaps.md) error-code fix applies to + moq-c's API gaps error-code fix applies to whichever functions remain. - **Callers.** Migrate every caller: about 25 crates use `create_broadcast` or `publish`, about 19 set an epoch, plus `@moq/publish`, moq-boy, carla diff --git a/quest/m1/c/README.md b/quest/m1/c/README.md index 8c65a7e221e..c36fabbb527 100644 --- a/quest/m1/c/README.md +++ b/quest/m1/c/README.md @@ -29,5 +29,5 @@ Public API: moq-c gains each m1 binding feature. Wire: none. ## Required -- [API gaps](/quest/m1/c/api-gaps.md) - one malformed-epoch error code, an audio enabled query, and a relay hops test +- [API gaps](/quest/m1/c/api-gaps.md) - caller timestamps for JSON windows and disabling pre-encoded simulcast rungs - [Every consumer cancels while pending](/quest/m1/c/consumer-cancel.md) - catalog, raw, datagram, and media consumers end cleanly on cancel before their subscription is accepted diff --git a/quest/m1/c/api-gaps.md b/quest/m1/c/api-gaps.md index 36433e189a5..d53adbd9a99 100644 --- a/quest/m1/c/api-gaps.md +++ b/quest/m1/c/api-gaps.md @@ -2,30 +2,11 @@ ## Goal -moq-c reports a malformed epoch the same way everywhere, lets a C consumer see -that audio is disabled as it can for video, has a test of the hops on a route -received through a relay, stamps a JSON window entry with a caller's timestamp, -and can disable a simulcast rung on pre-encoded media. +moq-c stamps a JSON window entry with a caller's timestamp and can disable a +simulcast rung on pre-encoded media. ## Plan -Found 2026-10-28 by [moq-dev/moq#7151](https://github.com/moq-dev/moq/pull/7151) -and [moq-dev/moq#7156](https://github.com/moq-dev/moq/pull/7156). - -Decided 2026-10-28 (maintainer): one PR for all of these. moq-c already breaks -at this cut (0.6 to 0.7), so the error-code change rides that break. - -- `moq_broadcast_request_prepare` reports a malformed epoch as - `MOQ_ERROR_INVALID_CODE`, while `moq_publication_announce`, which takes an - epoch in `moq_announce_route` since - [moq-dev/moq#7151](https://github.com/moq-dev/moq/pull/7151), reports a - malformed one as `MOQ_ERROR_INVALID_CONFIG`. Use - `MOQ_ERROR_INVALID_CONFIG` for both, with an Impact migration note. -- Add an audio counterpart to `moq_consume_video_enabled`, so a C app can tell - that a held audio rendition is disabled. -- Add a moq-c test that announces through a relay session and checks the - received route's `hops`. - - `moq_publish_json_window_push` and `_pop` always stamp the broadcast clock. Let them take an optional timestamp like the JSON snapshot and stream writers do since [moq-dev/moq#6946](https://github.com/moq-dev/moq/pull/6946). @@ -40,7 +21,5 @@ at this cut (0.6 to 0.7), so the error-code change rides that break. Update `moq.h`, `rs/moq-c/README.md` and `doc/lib/c` as needed. -Public API: moq-c gains an audio enabled query, an optional caller timestamp -on JSON window push and pop, and a call to disable a rung of pre-encoded -media, and changes one error code. Wire: none. - +Public API: moq-c gains an optional caller timestamp on JSON window push and +pop, and a call to disable a rung of pre-encoded media. Wire: none. diff --git a/rs/moq-c/README.md b/rs/moq-c/README.md index bf97b97f2b8..3f1690b3811 100644 --- a/rs/moq-c/README.md +++ b/rs/moq-c/README.md @@ -124,6 +124,7 @@ int32_t moq_consume_catalog_free(uint32_t catalog); int32_t moq_consume_video_config(uint32_t catalog, uint32_t index, moq_video_config *dst); int32_t moq_consume_video_enabled(uint32_t catalog, uint32_t index, bool *dst); int32_t moq_consume_audio_config(uint32_t catalog, uint32_t index, moq_audio_config *dst); +int32_t moq_consume_audio_enabled(uint32_t catalog, uint32_t index, bool *dst); // Consuming: Video int32_t moq_consume_video(uint32_t catalog, uint32_t index, const moq_delay *delay, moq_status_callback on_frame, void *user_data); diff --git a/rs/moq-c/c-tests/audio-decode-output.c b/rs/moq-c/c-tests/audio-decode-output.c index 140af5e753a..73e0364d40a 100644 --- a/rs/moq-c/c-tests/audio-decode-output.c +++ b/rs/moq-c/c-tests/audio-decode-output.c @@ -129,6 +129,13 @@ int main(void) { wait_until(&c, &c.catalog); pthread_mutex_unlock(&c.mutex); + bool enabled = false; + CHECK(moq_consume_audio_enabled((uint32_t)c.catalog, 0, &enabled) == 0); + CHECK(enabled); + CHECK(moq_consume_audio_enabled((uint32_t)c.catalog, 1, &enabled) == MOQ_ERROR_NO_INDEX); + CHECK(enabled); // An error preserves the destination. + CHECK(moq_consume_audio_enabled((uint32_t)c.catalog, 0, NULL) == MOQ_ERROR_INVALID_POINTER); + // Each decode reads the finished track from the start. Its budget is the track's whole // retention, since the budget ages groups by wall clock and a stalled host would skip them. const uint32_t formats[] = {MOQ_AUDIO_SAMPLE_FORMAT_F32, MOQ_AUDIO_SAMPLE_FORMAT_F32_PLANAR, MOQ_AUDIO_SAMPLE_FORMAT_S16}; diff --git a/rs/moq-c/c-tests/timestamps.c b/rs/moq-c/c-tests/timestamps.c index 9cc7185e86b..e25f5ff598f 100644 --- a/rs/moq-c/c-tests/timestamps.c +++ b/rs/moq-c/c-tests/timestamps.c @@ -102,7 +102,7 @@ int main(void) { } CHECK(moq_publish_track_frame((uint32_t)track, payload, sizeof(payload), NULL) == MOQ_ERROR_MOQ); - CHECK(moq_origin_request((uint32_t)origin, path, strlen(path), on_broadcast, &c) > 0); + CHECK(moq_origin_request((uint32_t)origin, path, strlen(path), NULL, 0, on_broadcast, &c) > 0); WAIT_FOR(&c, c.broadcast); int32_t frames = moq_consume_track((uint32_t)c.broadcast, "ticks", 5, NULL, on_frame, &c); CHECK(frames > 0); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index f75dc024e8b..643eb028fad 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -960,10 +960,7 @@ unsafe fn parse_announce_route(route: *const moq_announce_route) -> Result i32 { ffi::enter(move || { let request = ffi::parse_id(request)?; - let epoch = unsafe { ffi::parse_str(epoch, epoch_len)? } - .parse() - .map_err(|_| Error::InvalidCode)?; + let epoch = epoch_from_str(unsafe { ffi::parse_str(epoch, epoch_len)? })?; let broadcast_out = unsafe { broadcast.as_mut() }.ok_or(Error::InvalidPointer)?; let mut state = State::lock(); let (pending, driver) = state.origin.broadcast_request_take(request)?; @@ -2318,7 +2314,7 @@ pub extern "C" fn moq_origin_announced_cancel(announced: u32) -> i32 { /// announced, like a remote one. /// /// `epoch` is the UUIDv7 text of the only publisher instance to follow, or NULL (or empty) to -/// follow every instance. A pinned follow never delivers another instance taking over, and +/// follow every instance. Malformed epoch text returns `MOQ_ERROR_INVALID_CONFIG`. A pinned follow never delivers another instance taking over, and /// delivers its own instance again if it returns after an end. /// /// `on_broadcast` is invoked with a positive broadcast handle once announced, and again with a @@ -2381,6 +2377,7 @@ pub extern "C" fn moq_origin_follow_cancel(task: u32) -> i32 { /// /// `epoch` is the UUIDv7 text of the only publisher instance to accept, or NULL (or empty) for /// any instance. A request pinned to an instance that does not serve the path is unroutable. +/// Malformed epoch text returns `MOQ_ERROR_INVALID_CONFIG`. /// /// `on_broadcast` is invoked with a positive broadcast handle once served, then exactly once more /// with a terminal code: `0` (finished, including after [moq_origin_request_cancel]) or a negative @@ -2433,10 +2430,18 @@ pub extern "C" fn moq_origin_request_cancel(task: u32) -> i32 { /// - `epoch` may be NULL, or must point at `epoch_len` readable bytes. unsafe fn parse_epoch(epoch: *const c_char, epoch_len: usize) -> Result, Error> { unsafe { ffi::parse_str_optional(epoch, epoch_len)? } - .map(|epoch| epoch.parse().map_err(|_| Error::InvalidCode)) + .map(epoch_from_str) .transpose() } +/// Parse canonical UUIDv7 epoch text, refusing anything else as `MOQ_ERROR_INVALID_CONFIG` +/// at every API that takes an epoch. +fn epoch_from_str(epoch: &str) -> Result { + epoch + .parse() + .map_err(|_| Error::InvalidConfig(format!("epoch {epoch:?} is not canonical UUIDv7 text"))) +} + /// Close an origin and clean up its resources. /// /// The origin keeps running while a broadcast published on it or a @@ -2456,7 +2461,7 @@ pub extern "C" fn moq_origin_close(origin: u32) -> i32 { /// Announcing again re-prices the route in place, or restarts it under a new epoch. A /// NULL `route` uses the default (cost 0, no epoch). Until announced, the broadcast is /// invisible and unroutable for local consumers and peers alike. Fails once the -/// broadcast closed, or when `epoch` is not UUIDv7 text. +/// broadcast closed, or with `MOQ_ERROR_INVALID_CONFIG` when `epoch` is not UUIDv7 text. /// /// Returns a zero on success, or a negative code on failure. /// @@ -3926,6 +3931,30 @@ pub unsafe extern "C" fn moq_consume_audio_config(catalog: u32, index: u32, dst: }) } +/// Query whether the publisher is serving an audio rendition. +/// +/// False means no frames are coming until it re-enables. Pick an enabled rendition, but keep one +/// already playing while every audio rendition is disabled, so a re-enable plays without a +/// resubscribe. A catalog that omits the optional field reads as true. +/// +/// Returns zero on success, or a negative code on failure. +/// +/// # Safety +/// - The caller must ensure that `dst` points to properly aligned, writable storage for a `bool`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_consume_audio_enabled(catalog: u32, index: u32, dst: *mut bool) -> i32 { + ffi::enter(move || { + let catalog = ffi::parse_id(catalog)?; + if dst.is_null() { + return Err(Error::InvalidPointer); + } + + let enabled = State::lock().consume.audio_enabled(catalog, index as usize)?; + unsafe { dst.write(enabled) }; + Ok(()) + }) +} + /// Number of untyped application catalog sections in a catalog snapshot. /// /// These are the top-level catalog keys beyond `video`/`audio`, carried through diff --git a/rs/moq-c/src/consume.rs b/rs/moq-c/src/consume.rs index 86ad1f72299..c21a8300ee4 100644 --- a/rs/moq-c/src/consume.rs +++ b/rs/moq-c/src/consume.rs @@ -372,16 +372,15 @@ impl Consume { Ok(()) } - /// Return whether a video rendition may be selected. + /// Return whether the publisher is serving a video rendition. pub fn video_enabled(&self, catalog: Id, index: usize) -> Result { - let consume = self.catalog.get(catalog).ok_or(Error::CatalogNotFound)?; - let (_, config) = consume - .catalog - .video - .renditions - .iter() - .nth(index) - .ok_or(Error::NoIndex)?; + let (_, _, config) = self.select::(catalog, index)?; + Ok(config.enabled) + } + + /// Return whether the publisher is serving an audio rendition. + pub fn audio_enabled(&self, catalog: Id, index: usize) -> Result { + let (_, _, config) = self.select::(catalog, index)?; Ok(config.enabled) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index ab0a0ce1129..64127f96dde 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -966,6 +966,118 @@ fn publish_catalog_config_null_pointer() { assert_eq!(moq_publish_close(broadcast), 0); } +/// Enablement belongs to each catalog snapshot; holding a disabled rendition keeps its subscription usable. +#[test] +fn audio_enabled_snapshots_preserve_a_held_consumer() { + let origin = id(moq_origin_create()); + let path = b"audio-enabled"; + let broadcast = publish_broadcast(origin, path); + let media = id(publish_audio( + broadcast, + moq_audio_format::MOQ_AUDIO_FORMAT_OPUS, + &opus_head(), + None, + )); + let consume = request_broadcast(origin, path); + let catalogs = Callback::new(); + let catalog_task = id(unsafe { moq_consume_catalog(consume, Some(channel_callback), catalogs.ptr) }); + let initial = id(catalogs.recv()); + let mut enabled = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { moq_consume_audio_enabled(initial, 0, enabled.as_mut_ptr()) }, + 0 + ); + assert!(unsafe { enabled.assume_init() }, "omitted enabled defaults to true"); + + let set_enabled = |enabled| { + let mut state = State::lock(); + let catalog = state.publish.target(Id::try_from(broadcast).unwrap()).unwrap().catalog; + catalog + .modify() + .unwrap() + .audio + .renditions + .values_mut() + .next() + .unwrap() + .enabled = enabled; + }; + set_enabled(false); + let disabled = id(catalogs.recv()); + assert_eq!( + unsafe { moq_consume_audio_enabled(disabled, 0, enabled.as_mut_ptr()) }, + 0 + ); + assert!(!unsafe { enabled.assume_init() }); + assert_eq!( + unsafe { moq_consume_audio_enabled(initial, 0, enabled.as_mut_ptr()) }, + 0 + ); + assert!(unsafe { enabled.assume_init() }, "an older snapshot remains unchanged"); + + let mut preserved = true; + assert_eq!( + unsafe { moq_consume_audio_enabled(disabled, 1, &mut preserved) }, + MOQ_ERROR_NO_INDEX + ); + assert!(preserved); + assert_eq!( + unsafe { moq_consume_audio_enabled(disabled, 0, std::ptr::null_mut()) }, + MOQ_ERROR_INVALID_POINTER + ); + + // A held rendition may be opened from a disabled snapshot, then re-enabled without resubscribing. + let frames = Callback::new(); + let track = id(unsafe { moq_consume_audio(disabled, 0, &TEN_SECONDS, Some(channel_callback), frames.ptr) }); + set_enabled(true); + let resumed = id(catalogs.recv()); + assert_eq!( + unsafe { moq_consume_audio_enabled(resumed, 0, enabled.as_mut_ptr()) }, + 0 + ); + assert!(unsafe { enabled.assume_init() }); + assert_eq!( + unsafe { moq_consume_audio_enabled(disabled, 0, enabled.as_mut_ptr()) }, + 0 + ); + assert!(!unsafe { enabled.assume_init() }); + let payload = b"resumed-audio"; + assert_eq!( + unsafe { moq_publish_media_frame(media, payload.as_ptr(), payload.len(), micros(0)) }, + 0 + ); + let read = id(frames.recv()); + let mut frame: moq_frame = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { encoded_frame(read, &mut frame) }, 0); + assert_eq!( + unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }, + payload + ); + assert_eq!(moq_consume_read_free(read), 0); + + assert_eq!(moq_consume_audio_cancel(track), 0); + assert_eq!(frames.recv_terminal(), 0); + for catalog in [initial, disabled, resumed] { + assert_eq!(moq_consume_catalog_free(catalog), 0); + } + assert_eq!( + unsafe { moq_consume_audio_enabled(i32::MAX as u32, 0, &mut preserved) }, + MOQ_ERROR_CATALOG_NOT_FOUND + ); + assert!(preserved); + assert_eq!( + unsafe { moq_consume_audio_enabled(0, 0, &mut preserved) }, + MOQ_ERROR_INVALID_ID + ); + assert!(preserved); + assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); + assert_eq!(catalogs.recv_catalog_terminal(), 0); + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_media_finish(media), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + #[test] fn publish_catalog_roundtrip() { let origin = id(moq_origin_create()); @@ -3306,11 +3418,15 @@ fn announce_route_carries_cost_and_epoch_without_hops() { assert_eq!(moq_origin_announced_free(announced), 0); // Anything but canonical UUIDv7 text is refused. - let upper = mint_epoch().to_uppercase(); - for bad in ["6ba7b810-9dad-41d1-80b4-00c04fd430c8", upper.as_str(), "not-an-epoch"] { + for bad in [ + "6ba7b810-9dad-41d1-80b4-00c04fd430c8", + "00000000-0000-7000-8000-00000000000A", + "not-an-epoch", + ] { let route = announce_route(0, bad); - assert!( - unsafe { moq_publication_announce(publication, &route) } < 0, + assert_eq!( + unsafe { moq_publication_announce(publication, &route) }, + MOQ_ERROR_INVALID_CONFIG, "{bad:?} is not an epoch" ); } @@ -3398,8 +3514,22 @@ fn dynamic_serves_a_request_under_a_prefix() { let got = unsafe { std::str::from_utf8(std::slice::from_raw_parts(info.data.cast::(), info.len)).unwrap() }; assert_eq!(got, "live/cam"); - let epoch = moq_net::Epoch::mint().to_string(); - let mut served = 0; + // Invalid input leaves the request and destination intact, so it can still be answered. + let mut served = u32::MAX; + for bad in [ + "", + "not-an-epoch", + "6ba7b810-9dad-41d1-80b4-00c04fd430c8", + "00000000-0000-7000-8000-00000000000A", + ] { + assert_eq!( + unsafe { moq_broadcast_request_prepare(request, bad.as_ptr().cast(), bad.len(), &mut served) }, + MOQ_ERROR_INVALID_CONFIG, + "{bad:?} is not an epoch" + ); + assert_eq!(served, u32::MAX); + } + let epoch = mint_epoch(); let prepared = id(unsafe { moq_broadcast_request_prepare(request, epoch.as_ptr().cast(), epoch.len(), &mut served) }); let publication = id(moq_broadcast_prepared_publish(prepared)); @@ -4578,7 +4708,12 @@ fn an_epoch_pins_request_and_follow() { &cb, )); assert!(cb.recv() < 0, "another instance resolved"); - assert!(resolve_pinned(moq_origin_follow, origin, "not-an-epoch", &Callback::new()) < 0); + for resolve in [moq_origin_request, moq_origin_follow] { + assert_eq!( + resolve_pinned(resolve, origin, "not-an-epoch", &Callback::new()), + MOQ_ERROR_INVALID_CONFIG + ); + } // Another instance takes over before the follow starts. An unpinned request resolves it and // holds the answer, so a follower resolving it joins that answer within its first pass. @@ -6839,8 +6974,9 @@ fn listen(cb: &Callback) -> (u32, String, String) { (server, addr, fingerprint) } -/// Dial `url`, pinning `fingerprint`, with `consume` receiving the peer's broadcasts. -fn dial_pinned(url: &str, fingerprint: &str, consume: u32, user_data: *mut c_void) -> u32 { +/// Dial `url`, pinning `fingerprint`, announcing `publish` to the peer and with `consume` +/// receiving the peer's broadcasts. +fn dial_pinned(url: &str, fingerprint: &str, publish: u32, consume: u32, user_data: *mut c_void) -> u32 { let fingerprints = [moq_str(fingerprint)]; let mut config = client_config(); config.tls_fingerprints = fingerprints.as_ptr(); @@ -6852,7 +6988,7 @@ fn dial_pinned(url: &str, fingerprint: &str, consume: u32, user_data: *mut c_voi url.as_ptr() as *const c_char, url.len(), &config, - 0, + publish, consume, Some(state_callback), user_data, @@ -6860,6 +6996,84 @@ fn dial_pinned(url: &str, fingerprint: &str, consume: u32, user_data: *mut c_voi }) } +/// Received routes expose each traversed origin in order, rather than an empty local path. +/// +/// The relay accepts both sessions, as a relay server does: a server asks its peer about +/// everything, while a client session asks only about what a local reader watches, which +/// a session forwarding the relay's origin downstream is not. +#[test] +fn announce_route_hops_cross_a_relay_session() { + let source = id(moq_origin_create()); + let relay = id(moq_origin_create()); + let sink = id(moq_origin_create()); + let hops = { + let state = State::lock(); + [source, relay].map(|origin| state.origin.get(Id::try_from(origin).unwrap()).unwrap().hop().id()) + }; + let server_cb = Callback::new(); + let (server, addr, fingerprint) = listen(&server_cb); + let url = format!("https://{addr}/"); + + let upstream_client_cb = States::new(); + let upstream_client = dial_pinned(&url, &fingerprint, source, 0, upstream_client_cb.ptr); + let upstream_cb = States::new(); + let upstream = id(unsafe { + moq_session_request_accept(id(server_cb.recv()), 0, relay, Some(state_callback), upstream_cb.ptr) + }); + let downstream_client_cb = States::new(); + let downstream_client = dial_pinned(&url, &fingerprint, 0, sink, downstream_client_cb.ptr); + let downstream_cb = States::new(); + let downstream = id(unsafe { + moq_session_request_accept(id(server_cb.recv()), relay, 0, Some(state_callback), downstream_cb.ptr) + }); + for cb in [&upstream_client_cb, &upstream_cb, &downstream_client_cb, &downstream_cb] { + assert_eq!(cb.recv().phase, moq_connection_phase::MOQ_CONNECTION_PHASE_CONNECTED); + } + + let announced_cb = Callback::new(); + let task = id(unsafe { moq_origin_announced(sink, std::ptr::null(), Some(channel_callback), announced_cb.ptr) }); + let (publication, broadcast) = create_broadcast(source, b"relayed"); + let route = moq_announce_route { + cost: 7, + epoch: std::ptr::null(), + epoch_len: 0, + }; + assert_eq!(unsafe { moq_publication_announce(publication, &route) }, 0); + let (announced, info) = announced_cb.recv_route(); + assert_eq!(info.kind, moq_announce_kind::MOQ_ANNOUNCE_KIND_START); + assert_eq!( + borrowed_string(info.prefix, info.prefix_len).as_deref(), + Some("relayed") + ); + assert_eq!(info.route.hops_len, hops.len()); + assert_eq!( + unsafe { std::slice::from_raw_parts(info.route.hops, info.route.hops_len) }, + hops + ); + assert_eq!(info.route.cost, 9, "each session adds its default cost of one"); + assert_eq!(route_epoch(&info.route), None); + assert_eq!(moq_origin_announced_free(announced), 0); + + assert_eq!(moq_origin_announced_cancel(task), 0); + assert_eq!(announced_cb.recv_announce_terminal(), 0); + for (session, cb) in [ + (downstream, &downstream_cb), + (downstream_client, &downstream_client_cb), + (upstream, &upstream_cb), + (upstream_client, &upstream_client_cb), + ] { + assert_eq!(moq_session_close(session), 0); + cb.recv_terminal(); + } + assert_eq!(moq_server_close(server), 0); + assert_eq!(server_cb.recv_terminal(), 0); + assert_eq!(moq_publication_free(publication), 0); + assert_eq!(moq_publish_close(broadcast), 0); + for origin in [source, relay, sink] { + assert_eq!(moq_origin_close(origin), 0); + } +} + #[test] fn server_accepts_a_session() { let served = id(moq_origin_create()); @@ -6873,6 +7087,7 @@ fn server_accepts_a_session() { let client = dial_pinned( &format!("https://{addr}/room?jwt=abc"), &fingerprint, + 0, received, client_cb.ptr, ); @@ -6984,7 +7199,7 @@ fn server_rejects_a_session() { let (server, addr, fingerprint) = listen(&server_cb); let client_cb = States::new(); - let client = dial_pinned(&format!("https://{addr}/"), &fingerprint, 0, client_cb.ptr); + let client = dial_pinned(&format!("https://{addr}/"), &fingerprint, 0, 0, client_cb.ptr); let request = id(server_cb.recv()); let mut path = moq_str("x"); From d8288e69344c18787255673b796100ae0324556b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:14:49 -0800 Subject: [PATCH 28/86] fix(mux)!: a broadcast stamps everything from one clock (#7218) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- doc/lib/rs/kio.md | 5 + doc/lib/rs/moq-mux.md | 9 +- quest/m1/README.md | 1 - quest/m1/broadcast-clock.md | 45 ---- rs/kio/src/clock/captured.rs | 217 +++++++++++++++++++ rs/kio/src/clock/installed.rs | 5 + rs/kio/src/clock/mod.rs | 4 + rs/kio/src/clock/runtime.rs | 12 + rs/kio/src/clock/sim.rs | 22 ++ rs/moq-audio/src/encode/capture.rs | 2 +- rs/moq-ffi/src/media.rs | 35 ++- rs/moq-hls/src/export/archive_tests.rs | 2 +- rs/moq-hls/src/export/mod.rs | 13 +- rs/moq-hls/src/server/routes.rs | 2 +- rs/moq-mux/src/catalog/data.rs | 3 +- rs/moq-mux/src/catalog/producer.rs | 213 ++++++++++++++---- rs/moq-mux/src/clock.rs | 87 +++++--- rs/moq-mux/src/container/fmp4/import_test.rs | 2 +- rs/moq-mux/src/container/producer.rs | 4 +- rs/moq-mux/src/hls.rs | 56 +++-- rs/moq-transcode/src/catalog.rs | 7 +- rs/moq-video/src/encode/producer.rs | 5 +- 22 files changed, 574 insertions(+), 177 deletions(-) delete mode 100644 quest/m1/broadcast-clock.md create mode 100644 rs/kio/src/clock/captured.rs diff --git a/doc/lib/rs/kio.md b/doc/lib/rs/kio.md index 551b21bded1..0a70ca5e6ca 100644 --- a/doc/lib/rs/kio.md +++ b/doc/lib/rs/kio.md @@ -41,6 +41,11 @@ and `Instant::from_std()` convert at a third-party API that takes `std::time::Instant`, through the runtime clock's origin. That is exact on a running clock, and keeps only durations on paused tokio or the simulator. +`kio::clock::Clock::current()` captures the ambient clock, and the handle keeps +reading that clock from any thread or runtime. Stamp with it when one timeline +is shared by calls that may come from different contexts. A moq-mux broadcast +does this: it stamps every track on the clock it was created under. + ## Caller-driven code A state machine polled as `poll(now, waiter)` never reads a clock. It owns a diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index 1ba340321d0..c7f17da59d4 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -24,9 +24,12 @@ publish the source's own timestamps, mapped to the wall time the first frame arrived. The catalog clock never moves once something uses it: an importer that starts after a data track, a capture, another importer, or a pinned clock shifts its timestamps onto that clock instead, landing its first frame at now. -A `catalog::Timebase` gives renditions on one timestamp base one shared shift. A -stream whose decoder identity changes, such as an SPS with a new profile or level -or an audio header with a new sample rate, continues under a new track name +The media, the hang and MSF catalog groups, and the HLS records are all stamped +on that one clock. It keeps reading the clock the broadcast was created under, +even when a write comes from another runtime or thread. A `catalog::Timebase` +gives renditions on one timestamp base one shared shift. A stream whose decoder +identity changes, such as an SPS with a new profile or level or an audio header +with a new sample rate, continues under a new track name (`/` for the codec importers) while the old track ends; everything else, such as the coded size, updates the rendition in place. A timeline that rewinds ends the import. The MPEG-TS import ends at a clock step diff --git a/quest/m1/README.md b/quest/m1/README.md index 55660210155..fc584f46f23 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -145,7 +145,6 @@ per-frame CPU growth since #5926. - [moq-c at parity](/quest/m1/c/README.md) - the hand-written `moq-c` ships at the cut with every m1 binding feature moq-ffi gains - [Bindings see a moq-transport run end as an end](/quest/m1/ffi-ietf-run-end.md) - an IETF publisher ending its run reaches moq-ffi consumers as an end, not `Internal` - [Byte-stream renditions form a ladder](/quest/m1/stream-ladder.md) - moq-ffi stream producers link as `lower` and disable unfundable rungs like frame producers -- [A broadcast stamps everything from one clock](/quest/m1/broadcast-clock.md) - catalog, MSF, and HLS record groups are stamped from the broadcast's clock, never the ambient one - [moq export ends when the broadcast ends](/quest/m1/export-broadcast-end.md) - the export finishes on its own, proven without a give-up timeout - [A relay answers a SUBSCRIBE on a copy it kept after a fetch](/quest/m1/relay-fetched-copy-subscribe.md) - the downstream SUBSCRIBE_OK waits for the upstream edge instead of hanging - [A job's exact route continues its claim's readers](/quest/m1/announce-takeover.md) - a job's route inherits its claim's identity, replacing the ANNOUNCE_TAKEOVER predecessor design diff --git a/quest/m1/broadcast-clock.md b/quest/m1/broadcast-clock.md deleted file mode 100644 index 2e39a443f6f..00000000000 --- a/quest/m1/broadcast-clock.md +++ /dev/null @@ -1,45 +0,0 @@ -# [S] A broadcast stamps everything from one clock - -## Goal - -Every group a moq-mux broadcast writes, its media and also its hang and MSF -catalog groups and HLS records, is stamped from the broadcast's own clock, -captured once when it is created. None reads the ambient `Timestamp::now()`, -so a call made from another runtime or clock context cannot move a track's -timestamps backwards. - -## Plan - -Decided 2026-10-29 (maintainer), a follow-up of -[moq-dev/moq#7161](https://github.com/moq-dev/moq/pull/7161): one clock for -the whole broadcast is the fix, not stamping with `max(now, last)`, which -would hide the second clock. - -Found 2026-10-29: media is stamped on the broadcast timeline -(`moq_mux::Clock`, held by the catalog producer), while the catalog's hang and -MSF groups (`rs/moq-mux/src/catalog/producer.rs`) and the HLS record track -(`rs/moq-mux/src/hls.rs`) stamp with ambient `Timestamp::now()`, which reads -whichever runtime or `Manual` clock is current and anchors on its origin. The -`Clock` itself also reads its instant from the ambient clock. In moq-ffi's -`containers_of_one_source_keep_their_relative_media_time`, the broadcast is -built under a paused runtime while some calls enter the FFI worker's -runtime, so one catalog track got groups from two anchors and was refused -with `TimestampMismatch` (moq-net checks group order per track). - -- Find every stamp a broadcast writes that reads the ambient clock, and route - it through the broadcast's clock. -- The catalog's timestamps change meaning if they move onto the media - timeline. Check that against - [Address groups by timestamp](/quest/m1/time-addressing/README.md) and - record the choice. -- Fix the moq-ffi test's mixed clock contexts, or make it prove the new - guarantee. - -Tests: a broadcast written from two clock contexts keeps every track's -timestamps non-decreasing. - -Public API: none expected; record any change. Wire: none. - -## Related - -- [Address groups by timestamp](/quest/m1/time-addressing/README.md) - defines what a group's timestamp means diff --git a/rs/kio/src/clock/captured.rs b/rs/kio/src/clock/captured.rs new file mode 100644 index 00000000000..6410fdbabdf --- /dev/null +++ b/rs/kio/src/clock/captured.rs @@ -0,0 +1,217 @@ +//! The ambient clock, captured so it reads the same from anywhere. + +use crate::clock::{Instant, Wall}; + +/// The ambient clock as it was when [`current`](Self::current) captured it. +/// +/// It keeps reading that clock from any thread, runtime, or clock context, so stamps taken +/// through it share one timeline wherever they are taken. The ambient [`now`](crate::clock::now) +/// reads whichever clock the caller happens to be in, and instants from different clocks don't +/// compare. Clones read the same clock. +#[derive(Clone)] +pub struct Clock(Source); + +#[derive(Clone)] +enum Source { + /// The host clock every unpaused runtime and plain thread shares. + Host, + /// A tokio runtime's own clock, which differs from the host's while it is paused. + #[cfg(feature = "runtime")] + Runtime(tokio::runtime::Handle), + /// A manual clock installed on a tokio runtime. + #[cfg(feature = "manual")] + Installed(crate::clock::Installed), + /// A simulator run, by id: only a thread inside that run can read it. + #[cfg(feature = "sim")] + Sim(u64), +} + +impl Clock { + /// Capture the ambient clock, in the order [`now`](crate::clock::now) picks it. + /// + /// An entered [`Deadlines`](crate::clock::Deadlines) queue is a view of the clock it is + /// advanced from, so the capture is that clock: the installed or simulated one, else the host's. + pub fn current() -> Self { + let entered = super::deadline::entered().is_some(); + #[cfg(feature = "manual")] + if entered && let Some(clock) = super::deadline::installed() { + return Self(Source::Installed(clock)); + } + #[cfg(feature = "sim")] + if let Some(id) = super::sim::run_id() { + return Self(Source::Sim(id)); + } + if entered { + return Self(Source::Host); + } + #[cfg(feature = "manual")] + if let Some(clock) = super::installed::current() { + return Self(Source::Installed(clock)); + } + #[cfg(feature = "runtime")] + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + return Self(Source::Runtime(runtime)); + } + Self(Source::Host) + } + + /// The current instant on this clock. + /// + /// # Panics + /// + /// When captured inside a simulator run and read outside it. + pub fn now(&self) -> Instant { + match &self.0 { + Source::Host => super::runtime::host_now(), + #[cfg(feature = "runtime")] + Source::Runtime(runtime) => { + let _entered = runtime.enter(); + super::runtime::now() + } + #[cfg(feature = "manual")] + Source::Installed(clock) => clock.now(), + #[cfg(feature = "sim")] + Source::Sim(id) => super::sim::now_in(*id), + } + } + + /// The current wall time on this clock. + /// + /// # Panics + /// + /// When captured inside a simulator run and read outside it, or when the host's wall clock + /// reads before 1970. + pub fn wall(&self) -> Wall { + match &self.0 { + // Paused tokio does not pause wall time. + Source::Host => super::runtime::wall(), + #[cfg(feature = "runtime")] + Source::Runtime(_) => super::runtime::wall(), + #[cfg(feature = "manual")] + Source::Installed(clock) => clock.wall(), + #[cfg(feature = "sim")] + Source::Sim(id) => super::sim::wall_in(*id), + } + } +} + +/// Two captures are equal when they read the same clock. +impl PartialEq for Clock { + fn eq(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (Source::Host, Source::Host) => true, + #[cfg(feature = "runtime")] + (Source::Runtime(a), Source::Runtime(b)) => a.id() == b.id(), + #[cfg(feature = "manual")] + (Source::Installed(a), Source::Installed(b)) => a.same(b), + #[cfg(feature = "sim")] + (Source::Sim(a), Source::Sim(b)) => a == b, + #[allow(unreachable_patterns, reason = "only the host clock exists without features")] + _ => false, + } + } +} + +impl Eq for Clock {} + +impl std::fmt::Debug for Clock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + Source::Host => f.write_str("Host"), + #[cfg(feature = "runtime")] + Source::Runtime(runtime) => f.debug_tuple("Runtime").field(&runtime.id()).finish(), + #[cfg(feature = "manual")] + Source::Installed(clock) => std::fmt::Debug::fmt(clock, f), + #[cfg(feature = "sim")] + Source::Sim(id) => f.debug_tuple("Sim").field(id).finish(), + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::Clock; + + fn paused() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_time() + .start_paused(true) + .build() + .unwrap() + } + + /// A clock captured in a paused runtime keeps reading it from another runtime and a plain + /// thread, where the ambient clock is a different one. + #[test] + fn a_paused_runtime_reads_the_same_everywhere() { + let first = paused(); + let (clock, at) = first.block_on(async { + tokio::time::advance(Duration::from_secs(60)).await; + let clock = Clock::current(); + (clock.clone(), clock.now()) + }); + assert_eq!(clock.now(), at, "the paused clock did not move"); + + let second = paused(); + second.block_on(async { + tokio::time::advance(Duration::from_secs(3600)).await; + assert_eq!(clock.now(), at, "another runtime's time is not this clock's"); + assert_ne!(Clock::current(), clock); + }); + std::thread::scope(|scope| { + scope.spawn(|| assert_eq!(clock.now(), at)); + }); + + first.block_on(async { + tokio::time::advance(Duration::from_secs(5)).await; + assert_eq!(Clock::current(), clock); + }); + assert_eq!(clock.now(), at + Duration::from_secs(5)); + } + + /// An installed manual clock is read wherever its capture goes. + #[test] + fn an_installed_clock_reads_the_same_everywhere() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let manual = crate::clock::Manual::new().install(runtime.handle()); + let clock = runtime.block_on(async { Clock::current() }); + manual.advance(Duration::from_secs(7)); + assert_eq!(clock.now(), manual.now()); + assert_eq!(clock.wall(), manual.wall()); + paused().block_on(async { assert_eq!(clock.now(), manual.now()) }); + } + + /// A capture inside the simulator reads simulated time, and one on a plain thread reads the + /// host's even from inside the simulator. + #[test] + fn the_simulator_and_the_host_stay_apart() { + let host = Clock::current(); + crate::clock::sim::run(async move { + let sim = Clock::current(); + crate::clock::sleep(Duration::from_secs(10)).await; + assert_eq!(sim.now(), crate::clock::Instant::ZERO + Duration::from_secs(10)); + assert_eq!(sim.wall(), crate::clock::Manual::WALL_START + Duration::from_secs(10)); + assert!(host.now() > crate::clock::Instant::ZERO + Duration::from_secs(1 << 31)); + assert_ne!(sim, host); + }); + } + + /// A queue entered on a thread is read through the clock it is advanced from. + #[test] + fn an_entered_queue_captures_its_clock() { + crate::clock::sim::run(async { + let deadlines = crate::clock::Deadlines::new(crate::clock::now()); + crate::clock::sim::attach(&deadlines); + let clock = { + let _entered = deadlines.enter(); + Clock::current() + }; + assert_eq!(clock, Clock::current()); + }); + } +} diff --git a/rs/kio/src/clock/installed.rs b/rs/kio/src/clock/installed.rs index f4757354e44..222e255fcec 100644 --- a/rs/kio/src/clock/installed.rs +++ b/rs/kio/src/clock/installed.rs @@ -120,6 +120,11 @@ impl Installed { self.0.lock().unwrap().clock.step_wall(wall); } + /// Whether `other` is a clone of this clock. + pub(crate) fn same(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } + pub(crate) fn anchor(&self) -> (Instant, Wall) { let mut state = self.0.lock().unwrap(); if let Some(anchor) = state.anchor { diff --git a/rs/kio/src/clock/mod.rs b/rs/kio/src/clock/mod.rs index bd16fd7e327..802e7f7c274 100644 --- a/rs/kio/src/clock/mod.rs +++ b/rs/kio/src/clock/mod.rs @@ -13,6 +13,8 @@ //! simulator inside `sim::run`, and a [`Deadlines`] queue on a thread that //! [entered](Deadlines::enter) it, which is how a runtime that reads the host //! clock itself (an io_uring worker) becomes a backend. +//! - [`Clock::current`] captures the ambient clock, so stamps taken through it +//! keep one timeline whichever thread or runtime takes them. //! //! The `runtime` feature arms timers on tokio's clock. Without it, reads fall back //! to std, and waiting on the host clock panics. @@ -32,6 +34,7 @@ //! The `serde` feature adds `serde::UnixSeconds`, matching existing integer //! Unix-second JSON encodings for wall times. +mod captured; mod deadline; #[cfg(feature = "manual")] mod installed; @@ -46,6 +49,7 @@ pub mod sim; mod timer; mod wall; +pub use captured::Clock; pub use deadline::{Deadline, Deadlines, Entered}; #[cfg(feature = "manual")] pub use installed::Installed; diff --git a/rs/kio/src/clock/runtime.rs b/rs/kio/src/clock/runtime.rs index 97e7a15f70a..0beec1e1403 100644 --- a/rs/kio/src/clock/runtime.rs +++ b/rs/kio/src/clock/runtime.rs @@ -73,6 +73,18 @@ fn real_now() -> Instant { Instant::from_origin(LEAD + std::time::Instant::now().saturating_duration_since(origin.into_std())) } +/// The host clock every unpaused runtime and plain thread shares, whichever runtime reads it. +#[cfg(feature = "runtime")] +pub(crate) fn host_now() -> Instant { + real_now() +} + +/// Without the runtime there are no paused runtimes, so the clock is the host's. +#[cfg(not(feature = "runtime"))] +pub(crate) fn host_now() -> Instant { + now() +} + /// The std instant the runtime clock counts from, as [`read`] picks it. #[cfg(feature = "runtime")] fn std_base() -> std::time::Instant { diff --git a/rs/kio/src/clock/sim.rs b/rs/kio/src/clock/sim.rs index ffd9dee61c2..4cdb20b036d 100644 --- a/rs/kio/src/clock/sim.rs +++ b/rs/kio/src/clock/sim.rs @@ -233,6 +233,28 @@ fn seed_from_env() -> u64 { } } +/// The run the calling thread is inside, if any. +pub(crate) fn run_id() -> Option { + current().map(|runtime| runtime.id) +} + +/// Run `id`, which the calling thread must be inside. +fn expect_run(id: u64) -> Rc { + current() + .filter(|runtime| runtime.id == id) + .expect("a clock captured in a kio::clock::sim::run read outside it") +} + +/// The simulated instant of run `id`. +pub(crate) fn now_in(id: u64) -> Instant { + expect_run(id).now() +} + +/// The simulated wall time of run `id`. +pub(crate) fn wall_in(id: u64) -> Wall { + expect_run(id).time.borrow().clock.wall() +} + /// Whether the calling thread is inside [`run`]. pub fn is_running() -> bool { CURRENT.with(|current| current.borrow().is_some()) diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index 066f31df8ff..f160750205b 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -2104,7 +2104,7 @@ mod tests { let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); let config = moq_mux::catalog::Config::default() - .with_clock(clock) + .with_clock(clock.clone()) .with_max_age(RETAIN); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config).unwrap(); let mut options = Capture::default(); diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index f1391f0e72e..704592a6b94 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -1898,26 +1898,11 @@ mod timebase_tests { .unwrap(); tokio::time::advance(Duration::from_secs(1)).await; let first = clock.now().as_micros(); - // Drive the private importers on the paused clock, without entering the unpaused FFI worker. - chunks - .inner - .lock() - .unwrap() - .as_mut() - .unwrap() - .import - .decode(&fragment(&source, 3600, 1)) - .unwrap(); + // Each write enters the unpaused FFI worker, whose ambient clock is the host's. The + // broadcast still stamps on the paused clock it was built under. + chunks.write(fragment(&source, 3600, 1).to_vec()).unwrap(); tokio::time::advance(Duration::from_secs(5)).await; - audio_chunks - .inner - .lock() - .unwrap() - .as_mut() - .unwrap() - .import - .decode(&fragment(&audio, 3602, 2)) - .unwrap(); + audio_chunks.write(fragment(&audio, 3602, 2).to_vec()).unwrap(); let mut readers = Vec::new(); let snapshot = catalog.snapshot(); let containers = @@ -1956,6 +1941,18 @@ mod timebase_tests { first + if shared { 2_000_000 } else { 5_000_000 }, "shared={shared}" ); + + // The catalog the second write published is on the same timeline as the media. + let mut catalogs = consumer + .track(hang::catalog::Catalog::DEFAULT_NAME) + .unwrap() + .subscribe(moq_net::track::Subscription::default()) + .await + .unwrap() + .ordered(); + let mut group = catalogs.next_group().await.unwrap().unwrap(); + let stamp = group.read_frame().await.unwrap().unwrap().timestamp.unwrap(); + assert_eq!(stamp.as_micros(), first + 5_000_000, "shared={shared}"); } } diff --git a/rs/moq-hls/src/export/archive_tests.rs b/rs/moq-hls/src/export/archive_tests.rs index 2184c22c3c5..39c23cd136c 100644 --- a/rs/moq-hls/src/export/archive_tests.rs +++ b/rs/moq-hls/src/export/archive_tests.rs @@ -149,7 +149,7 @@ impl Recording { .unwrap(); let source = Source { media, - records: moq_mux::hls::Producer::new(track.clone()), + records: moq_mux::hls::Producer::new(track.clone(), moq_mux::Clock::new()), track, record: 0, }; diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 789ef2334c9..05da992c727 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -1061,7 +1061,7 @@ mod tests { let track = broadcast .create_track(hang::hls::default_name(name), moq_mux::hls::Producer::info()) .unwrap(); - (track.clone(), moq_mux::hls::Producer::new(track)) + (track.clone(), moq_mux::hls::Producer::new(track, moq_mux::Clock::new())) }) .collect(); for sequence in 0..4 { @@ -1279,7 +1279,7 @@ mod tests { let track = broadcast .create_track(hang::hls::default_name(name), moq_mux::hls::Producer::info()) .unwrap(); - moq_mux::hls::Producer::new(track) + moq_mux::hls::Producer::new(track, moq_mux::Clock::new()) }); (origin, broadcast, catalog, records) } @@ -1414,7 +1414,7 @@ mod tests { let track = broadcast .create_track(hang::hls::default_name(name), moq_mux::hls::Producer::info()) .unwrap(); - (track.clone(), moq_mux::hls::Producer::new(track)) + (track.clone(), moq_mux::hls::Producer::new(track, moq_mux::Clock::new())) }); for sequence in 0..4 { video0.push(&gop(sequence)).unwrap(); @@ -2154,6 +2154,7 @@ mod tests { broadcast .create_track(hang::hls::default_name("video0"), moq_mux::hls::Producer::info()) .unwrap(), + moq_mux::Clock::new(), ); let media = Records::write_media(&broadcast, "video0", 3); for sequence in 0..3 { @@ -2603,7 +2604,7 @@ mod tests { let track = broadcast .create_track(hang::hls::default_name("video0"), moq_mux::hls::Producer::info()) .unwrap(); - (track.clone(), moq_mux::hls::Producer::new(track)) + (track.clone(), moq_mux::hls::Producer::new(track, moq_mux::Clock::new())) } /// Write three keyframes of `payload` into `broadcast`'s `video0` track, 2s apart from @@ -3438,7 +3439,7 @@ mod tests { .broadcast .create_track(hang::hls::default_name(track), moq_mux::hls::Producer::info()) .unwrap(); - (track.clone(), moq_mux::hls::Producer::new(track)) + (track.clone(), moq_mux::hls::Producer::new(track, moq_mux::Clock::new())) } /// Publish `name`'s media: one keyframe group per [`gop`] record `0..groups`, so a cursor @@ -3651,7 +3652,7 @@ mod tests { let track = broadcast .create_track(hang::hls::default_name(name), moq_mux::hls::Producer::info()) .unwrap(); - (track.clone(), moq_mux::hls::Producer::new(track)) + (track.clone(), moq_mux::hls::Producer::new(track, moq_mux::Clock::new())) }; let (_video0_track, mut video0) = publish("video0"); let (video1_track, mut video1) = publish("video1"); diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index 6390d794b43..5ff5e6caf27 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -582,7 +582,7 @@ mod tests { }) .unwrap(); let track = broadcast.create_track(name, moq_mux::hls::Producer::info()).unwrap(); - let mut records = moq_mux::hls::Producer::new(track.clone()); + let mut records = moq_mux::hls::Producer::new(track.clone(), moq_mux::Clock::new()); for sequence in 0..4 { let record = hang::hls::Record::new(sequence, sequence, sequence * 2_000); records.push(&record).unwrap(); diff --git a/rs/moq-mux/src/catalog/data.rs b/rs/moq-mux/src/catalog/data.rs index 7279138e414..a0783866366 100644 --- a/rs/moq-mux/src/catalog/data.rs +++ b/rs/moq-mux/src/catalog/data.rs @@ -88,8 +88,9 @@ impl Listing { if clock != self.clock { self.estimator.discontinuity(); } + let now = clock.now(); self.clock = clock; - self.record_at(clock.now(), bytes, captured) + self.record_at(now, bytes, captured) } /// [`record`](Self::record) at a chosen time, which a test needs since the broadcast clock only diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 5741ef5981b..10f705878a8 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -215,22 +215,27 @@ impl Declared { } } -/// The producer's `clock` under a root `clock` of `advertised`: re-based on a changed mapping while -/// nothing has `anchored` the clock, and refused once something has. Clearing it is always refused. -fn adopt(clock: crate::Clock, anchored: bool, advertised: Option) -> crate::Result { +/// The producer's `clock` re-based on a root `clock` of `advertised`, or `None` when that is the +/// mapping it already has. A changed mapping is adopted while nothing has `anchored` the clock and +/// refused once something has. Clearing it is always refused. +fn adopt( + clock: &crate::Clock, + anchored: bool, + advertised: Option, +) -> crate::Result> { if advertised == clock.wall() { - return Ok(clock); + return Ok(None); } let refuse = |why: &str| crate::Error::UnmappableTimestamp(format!("root clock {advertised:?} {why}")); let wall = crate::Clock::exact(advertised.ok_or_else(|| refuse("clears the producer's clock"))?)?; // The same mapping in other units needs no host time, which may be unset or behind PTS zero. if Some(wall) == clock.wall() { - return Ok(clock); + return Ok(None); } if anchored { return Err(refuse("changes the producer's clock after it was fixed")); } - crate::Clock::adopted(wall) + Ok(Some(clock.adopted(wall)?)) } /// Take the shared state, ignoring a poisoned lock. @@ -325,15 +330,18 @@ impl Outputs { std::borrow::Cow::Owned(catalog) } - /// Emit the catalog to hang and MSF. Neither writes a catalog identical to its last. - fn emit(&mut self, catalog: &Catalog, msf: &mut Msf) -> crate::Result<()> { + /// Emit the catalog to hang and MSF, stamped now on `clock`. Neither writes a catalog identical + /// to its last. + fn emit(&mut self, catalog: &Catalog, msf: &mut Msf, clock: &crate::Clock) -> crate::Result<()> { let catalog = self.advertise(catalog); let catalog = catalog.as_ref(); // Every consumer refuses a catalog that fails this, so refuse it before it reaches one. catalog.check_renditions()?; + // The media timeline, so the catalog in effect at a media timestamp is the one stamped at or + // before it, whichever thread or runtime publishes. + let at = clock.now(); // One snapshot per group while deltas are disabled. - self.hang - .update(moq_net::Timed::from(catalog).at(moq_net::Timestamp::now()))?; + self.hang.update(moq_net::Timed::from(catalog).at(at))?; // `abort` below is what keeps the tracks consistent if this fails after the hang track was // updated. @@ -341,7 +349,7 @@ impl Outputs { return Ok(()); }; let mut group = self.msf_track.append_group()?; - group.write_frame(moq_net::Timestamp::now(), msf)?; + group.write_frame(at, msf)?; group.finish()?; Ok(()) @@ -517,10 +525,11 @@ impl Producer { // Resolved before any track exists, so a refused seed leaves the broadcast untouched. let anchored = config.clock.is_some(); let mut catalog = config.catalog; + // An unseeded catalog runs on the configured clock, or a fresh one. + let clock = config.clock.unwrap_or_default(); let clock = match catalog.clock { - // An unseeded catalog runs on the configured clock, or a fresh one. - None => config.clock.unwrap_or_default(), - seeded => adopt(config.clock.unwrap_or_default(), anchored, seeded)?, + None => clock, + seeded => adopt(&clock, anchored, seeded)?.unwrap_or(clock), }; catalog.clock = clock.wall(); @@ -537,7 +546,10 @@ impl Producer { let json_config = moq_json::snapshot::Config::default().with_deltas(false); let hang = moq_json::snapshot::Producer::new(hang_track.clone(), json_config); - let hls = config.hls.as_ref().map(|_| crate::hls::Tracks::new(broadcast)); + let hls = config + .hls + .as_ref() + .map(|_| crate::hls::Tracks::new(broadcast, clock.clone())); Ok(Self { outputs: Outputs { @@ -650,18 +662,18 @@ impl Producer { /// The broadcast's shared clock: the timeline producers stamp against and the wall mapping /// advertised at the catalog root. /// - /// Copies share the timeline, so handing them to concurrent producers keeps every track on + /// Clones share the timeline, so handing them to concurrent producers keeps every track on /// one clock. Taking the clock fixes it: an importer whose first frame arrives later shifts its - /// timestamps onto this clock rather than moving it, so a copy never goes stale. + /// timestamps onto this clock rather than moving it, so a clone never goes stale. pub fn clock(&self) -> crate::Clock { let mut state = take(&self.current); state.anchored = true; - state.clock + state.clock.clone() } /// The clock as it stands, without fixing it: for readers that follow it on every use. pub(super) fn current_clock(&self) -> crate::Clock { - take(&self.current).clock + take(&self.current).clock.clone() } /// The config `name` holds in `C`'s section, cloned without copying the rest of the catalog. @@ -694,7 +706,7 @@ impl Producer { } let offset = if guard.state.anchored { - let clock = guard.state.clock; + let clock = &guard.state.clock; let at = match wall { Some(wall) => clock.reading(wall)?, None => clock.now().as_micros() as i128, @@ -702,17 +714,19 @@ impl Producer { super::Offset::from_micros(at - pts.as_micros() as i128)? } else { let since = std::time::Duration::from_micros(u64::try_from(pts.as_micros()).unwrap_or(u64::MAX)); + // Moved, not replaced: it keeps reading the clock the broadcast was created under. let clock = match wall { - Some(wall) => crate::Clock::placed(since, wall)?, - None => crate::Clock::arrival(since)?, + Some(wall) => guard.state.clock.placed(since, wall)?, + None => guard.state.clock.arrival(since)?, }; - guard.state.clock = clock; + let advertised = clock.wall(); + guard.retime(clock); guard.state.anchored = true; match wall { // A placement can precede every track. It stages no publish, so the catalog first // goes out listing them rather than carrying a clock alone. - Some(_) => guard.state.catalog.clock = clock.wall(), - None => guard.clock = clock.wall(), + Some(_) => guard.state.catalog.clock = advertised, + None => guard.clock = advertised, } super::Offset::default() }; @@ -882,7 +896,7 @@ impl Producer { state.estimates.fold(); // Emitted under the lock, as a guard's publish is, since the MSF state belongs to it. let state = &mut *state; - if let Err(err) = self.outputs.emit(&state.catalog, &mut state.msf) { + if let Err(err) = self.outputs.emit(&state.catalog, &mut state.msf, &state.clock) { tracing::error!(%err, "failed to publish the catalog, aborting its tracks"); self.outputs.abort(&err); state.closed = Some(err); @@ -1032,7 +1046,7 @@ impl Producer { return; } let state = &mut *state; - if let Err(err) = outputs.emit(&state.catalog, &mut state.msf) { + if let Err(err) = outputs.emit(&state.catalog, &mut state.msf, &state.clock) { tracing::error!(%err, "failed to publish an HLS declaration, aborting catalog tracks"); outputs.abort(&err); state.closed = Some(err); @@ -1189,6 +1203,14 @@ impl Guard<'_, E> { self.publish() } + /// Move the producer onto `clock`, a re-basing of the one it has, before anything fixed it. + fn retime(&mut self, clock: crate::Clock) { + if let Some(hls) = &self.outputs.hls { + hls.retime(&clock); + } + self.state.clock = clock; + } + /// Publish a mutated catalog once, clearing the flag so it isn't published again. fn publish(&mut self) -> crate::Result<()> { if !self.updated { @@ -1197,16 +1219,12 @@ impl Guard<'_, E> { self.updated = false; // The root `clock` is the producer's clock: never advertise one it disagrees with. - match adopt(self.state.clock, self.state.anchored, self.state.catalog.clock) { - Ok(clock) => { - self.state.clock = clock; - self.state.catalog.clock = clock.wall(); - } - Err(err) => { - self.state.catalog.clock = self.state.clock.wall(); - return Err(err); - } + let adopted = adopt(&self.state.clock, self.state.anchored, self.state.catalog.clock); + if let Ok(Some(clock)) = &adopted { + self.retime(clock.clone()); } + self.state.catalog.clock = self.state.clock.wall(); + adopted?; { // Withhold every emit while still buffering the initial reserved set, or until a reserved @@ -1227,7 +1245,7 @@ impl Guard<'_, E> { // Folded only once it is on the wire, so a failed emit leaves the estimate held. let state = &mut *self.state; - self.outputs.emit(&state.catalog, &mut state.msf)?; + self.outputs.emit(&state.catalog, &mut state.msf, &state.clock)?; self.state.estimates.fold(); Ok(()) } @@ -2285,8 +2303,16 @@ mod test { let front = origin.create_broadcast("robot/front.hang").unwrap(); let rear = origin.create_broadcast("robot/rear.hang").unwrap(); let catalogs = [ - Producer::new(&mut front.broadcast().clone(), Config::default().with_clock(clock)).unwrap(), - Producer::new(&mut rear.broadcast().clone(), Config::default().with_clock(clock)).unwrap(), + Producer::new( + &mut front.broadcast().clone(), + Config::default().with_clock(clock.clone()), + ) + .unwrap(), + Producer::new( + &mut rear.broadcast().clone(), + Config::default().with_clock(clock.clone()), + ) + .unwrap(), ]; let mut broadcasts = [front, rear]; for (catalog, broadcast) in catalogs.iter().zip(&mut broadcasts) { @@ -2324,8 +2350,107 @@ mod test { } } + /// A broadcast built under one clock and written from others, a paused runtime a minute behind + /// it and a plain thread on the host's, stamps every track on the clock it was built under, so + /// none of them goes back: media, both catalogs, and the HLS records. + #[test] + fn a_broadcast_written_from_two_clock_contexts_never_rewinds() { + use hang::catalog::{JsonConfig, Mode}; + use std::time::Duration; + + let paused = || { + tokio::runtime::Builder::new_current_thread() + .enable_time() + .start_paused(true) + .build() + .unwrap() + }; + let (home, away) = (paused(), paused()); + let mut broadcast = moq_net::broadcast::Info::default().produce(); + let (mut catalog, mut video, readers) = home.block_on(async { + tokio::time::advance(Duration::from_secs(60)).await; + let catalog = Producer::new( + &mut broadcast, + Config::default().with_max_age(Duration::from_secs(3600)), + ) + .unwrap(); + let video = video0(&mut broadcast, &catalog, &[]); + let consumer = broadcast.consume(); + let mut readers = Vec::new(); + for name in [ + hang::Catalog::DEFAULT_NAME, + moq_msf::DEFAULT_NAME, + "video0", + "video0.hls", + ] { + let all = moq_net::track::Subscription::default().with_max_delay(None); + let track = consumer.track(name).unwrap().subscribe(all).await.unwrap(); + readers.push((name, track.ordered())); + } + (catalog, video, readers) + }); + + // One keyframe group, so one HLS segment, and one catalog change, so one hang catalog group. + let write = |catalog: &mut Producer, video: &mut crate::container::Producer<_, VideoConfig>, n: u64| { + let timestamp = catalog.timestamp(None).unwrap(); + video + .write(crate::container::Frame { + timestamp, + duration: None, + payload: Bytes::from_static(b"keyframe"), + keyframe: true, + }) + .unwrap(); + catalog + .mutate(|catalog| { + catalog + .json + .tracks + .insert(format!("json{n}"), JsonConfig::new(Mode::Snapshot)); + }) + .unwrap(); + }; + for n in 0..4 { + // Only the clock the broadcast was built under moves it. + home.block_on(tokio::time::advance(Duration::from_secs(4))); + match n % 2 { + 0 => away.block_on(async { write(&mut catalog, &mut video, n) }), + _ => std::thread::scope(|scope| { + scope.spawn(|| write(&mut catalog, &mut video, n)); + }), + } + } + away.block_on(async { + video.finish().unwrap(); + catalog.finish().unwrap(); + }); + + home.block_on(async { + for (name, mut track) in readers { + let (mut stamps, mut starts) = (Vec::new(), Vec::::new()); + while let Some(mut group) = track.next_group().await.unwrap() { + let first = stamps.len(); + while let Some(frame) = group.read_frame().await.unwrap() { + stamps.push(frame.timestamp.unwrap().as_micros()); + } + starts.extend(stamps.get(first).copied()); + } + assert!(stamps.is_sorted(), "{name} went back: {stamps:?}"); + // The clock read ten seconds when built, and only `home` advanced it, to 26. + let timeline = 10_000_000..=26_000_000; + assert!( + stamps.iter().all(|stamp| timeline.contains(stamp)), + "{name}: {stamps:?}" + ); + if name == "video0" { + assert_eq!(starts, [14, 18, 22, 26].map(|secs| secs * 1_000_000), "{name}"); + } + } + }); + } + /// How far `pts` lands from `clock`'s reading now, which is zero for a frame live on arrival. - fn lateness(clock: crate::Clock, pts: moq_net::Timestamp) -> std::time::Duration { + fn lateness(clock: &crate::Clock, pts: moq_net::Timestamp) -> std::time::Duration { let now = clock.now().as_micros(); std::time::Duration::from_micros(now.abs_diff(pts.as_micros()) as u64) } @@ -2350,7 +2475,7 @@ mod test { let first = moq_net::Timestamp::from_micros(3_600_000_000).unwrap(); let shifted = catalog.timebase().shift(first).unwrap(); assert!( - lateness(catalog.clock(), shifted) < Duration::from_secs(1), + lateness(&catalog.clock(), shifted) < Duration::from_secs(1), "{shifted:?}" ); assert_eq!(catalog.snapshot().clock.map(|clock| clock.wall), advertised); @@ -2388,7 +2513,7 @@ mod test { .timebase() .shift(moq_net::Timestamp::from_micros(0).unwrap()) .unwrap(); - assert!(lateness(clock, other) < Duration::from_secs(1), "{other:?}"); + assert!(lateness(&clock, other) < Duration::from_secs(1), "{other:?}"); assert_eq!( catalog.clock().wall(), clock.wall(), @@ -2409,7 +2534,7 @@ mod test { .unwrap(); assert_eq!(catalog.clock().wall(), clock.wall()); assert!( - lateness(clock, shifted) < std::time::Duration::from_secs(1), + lateness(&clock, shifted) < std::time::Duration::from_secs(1), "{shifted:?}" ); } @@ -2567,7 +2692,7 @@ mod test { let fixed = crate::Clock::at(kio::clock::now(), zero).unwrap(); seed.clock = Some(an_hour_ago(moq_net::Timescale::MILLI)); crate::clock::fake_host_wall(kio::clock::Wall::UNIX_EPOCH + Duration::from_secs(86_400)); - let config = Config::default().with_catalog(seed.clone()).with_clock(fixed); + let config = Config::default().with_catalog(seed.clone()).with_clock(fixed.clone()); let mut broadcast = moq_net::broadcast::Info::new().produce(); let mut catalog = Producer::new(&mut broadcast, config).unwrap(); assert_eq!(catalog.clock(), fixed); diff --git a/rs/moq-mux/src/clock.rs b/rs/moq-mux/src/clock.rs index 142ffeba6a4..d6f4e3aa0ab 100644 --- a/rs/moq-mux/src/clock.rs +++ b/rs/moq-mux/src/clock.rs @@ -1,9 +1,13 @@ //! The broadcast's shared clock: one monotonic timeline plus its wall mapping. //! -//! Create one [`Clock`] per broadcast and hand copies to every producer: because they share a +//! Create one [`Clock`] per broadcast and hand clones to every producer: because they share a //! timeline, frames captured at the same instant get the same timestamp, keeping concurrently -//! produced tracks (e.g. audio and video capture on separate threads) in sync. It is `Copy`, so -//! handing it out is cheap. Taking the catalog's clock fixes it, so a copy never goes stale. +//! produced tracks (e.g. audio and video capture on separate threads) in sync. Cloning is cheap. +//! Taking the catalog's clock fixes it, so a clone never goes stale. +//! +//! A clock reads the ambient clock it was created under (see [`kio::clock::Clock`]), wherever it +//! is read from later. A broadcast built under one runtime and written from another, or from a +//! plain thread, stays on one timeline instead of jumping between the two clocks. //! //! The clock also owns the broadcast's wall mapping, advertised at the catalog root as //! `clock: { wall, timescale }`: `wall` is the wall-clock time of PTS zero in @@ -31,7 +35,11 @@ fn moq_micros(wall: Wall) -> i128 { /// The host's wall clock, or `None` while it reads before the moq epoch (2020): unset. pub(crate) fn host_wall() -> Option { - let now = kio::clock::wall(); + set(kio::clock::wall()) +} + +/// `now`, or `None` while it reads before the moq epoch (2020): a wall clock not yet set. +fn set(now: Wall) -> Option { #[cfg(test)] let now = HOST_WALL.get().unwrap_or(now); (moq_micros(now) >= 0).then_some(now) @@ -67,11 +75,13 @@ fn wall_clock(wall: Wall) -> crate::Result { /// concurrently, e.g. an audio and a video capture running on separate /// threads, land on a single timeline. /// -/// Copies share the timeline and the wall mapping, so handing them to several producers keeps +/// Clones share the timeline and the wall mapping, so handing them to several producers keeps /// the broadcast on one clock. Two clocks are equal only when they are one timeline. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Clock { - /// A monotonic instant, and what the clock read then in micros. + /// The ambient clock captured where this one was created, read wherever it is read. + source: kio::clock::Clock, + /// An instant on `source`, and what the clock read then in micros. instant: Instant, reading: u64, /// `None` when the host's wall clock was unset at construction. @@ -97,33 +107,38 @@ impl Clock { /// Without a wall mapping when the host's wall clock is unset, or reads a time the mapping can't /// name: media needs only the monotonic timeline. pub fn new() -> Self { + let source = kio::clock::Clock::current(); Self { - instant: kio::clock::now(), + instant: source.now(), reading: Self::LEAD.as_micros() as u64, - wall: host_wall().and_then(|now| wall_clock(now.checked_sub(Self::LEAD)?).ok()), + wall: set(source.wall()).and_then(|now| wall_clock(now.checked_sub(Self::LEAD)?).ok()), + source, } } /// Start a clock at an explicit monotonic epoch and wall time: PTS zero at both. /// /// The deterministic constructor: synthetic sources and fixtures pin both ends instead of - /// sampling. Refuses an unrepresentable wall. + /// sampling. `epoch` is an instant on the ambient clock, which the clock keeps reading wherever + /// it is read from. Refuses an unrepresentable wall. pub fn at(epoch: Instant, wall: Wall) -> crate::Result { Ok(Self { + source: kio::clock::Clock::current(), instant: epoch, reading: 0, wall: Some(wall_clock(wall)?), }) } - /// A clock that reads `since` now: a source whose first timestamp is `since` is live on arrival. + /// This clock moved to read `since` now: a source whose first timestamp is `since` is live on + /// arrival. /// /// Refuses a `since` so large that PTS zero lands before the moq epoch (2020), which the wall /// mapping cannot name. On a host whose wall clock is unset, the clock has no wall mapping. - pub(crate) fn arrival(since: Duration) -> crate::Result { - let instant = kio::clock::now(); + pub(crate) fn arrival(&self, since: Duration) -> crate::Result { + let instant = self.source.now(); let unmappable = || crate::Error::UnmappableTimestamp(format!("{since:?} puts PTS zero before 2020")); - let wall = match host_wall() { + let wall = match set(self.source.wall()) { Some(now) => { let zero = now.checked_sub(since).ok_or_else(unmappable)?; Some(wall_clock(zero).map_err(|_| unmappable())?) @@ -134,24 +149,25 @@ impl Clock { None => None, }; Ok(Self { + source: self.source.clone(), instant, reading: u64::try_from(since.as_micros()).map_err(|_| unmappable())?, wall, }) } - /// A clock on which `pts` is the instant `wall`: a source that knows when its timestamps + /// This clock moved so `pts` is the instant `wall`: a source that knows when its timestamps /// happened places them there rather than at their arrival. /// /// Refuses a mapping whose PTS zero lands before the moq epoch (2020) or after now, and a host /// whose wall clock is unset, which has no now to place it against. - pub(crate) fn placed(pts: Duration, wall: Wall) -> crate::Result { + pub(crate) fn placed(&self, pts: Duration, wall: Wall) -> crate::Result { let unmappable = |why: &str| crate::Error::UnmappableTimestamp(format!("{pts:?} at {wall:?} {why}")); let zero = wall .checked_sub(pts) .and_then(|zero| wall_clock(zero).ok()) .ok_or_else(|| unmappable("puts PTS zero before 2020"))?; - Self::mapped(zero, unmappable) + self.mapped(zero, unmappable) } /// A catalog root `clock` in [`TIMESCALE`](Self::TIMESCALE), the units [`wall`](Self::wall) @@ -169,26 +185,28 @@ impl Clock { Ok(hang::catalog::Clock::new(moq_net::Timestamp::from_micros(micros)?)?) } - /// A clock on an [`exact`](Self::exact) catalog root `clock`: a derivative republishing its - /// input's timestamps runs on its input's mapping. + /// This clock moved onto an [`exact`](Self::exact) catalog root `clock`: a derivative + /// republishing its input's timestamps runs on its input's mapping. /// /// Refuses a PTS zero after now, and a host whose wall clock is unset. - pub(crate) fn adopted(wall: hang::catalog::Clock) -> crate::Result { - Self::mapped(wall, |why| { + pub(crate) fn adopted(&self, wall: hang::catalog::Clock) -> crate::Result { + self.mapped(wall, |why| { crate::Error::UnmappableTimestamp(format!("root clock {wall:?} {why}")) }) } - /// A clock whose PTS zero is `zero`, anchored now at however long ago that was on the host. + /// This clock moved so its PTS zero is `zero`, anchored now at however long ago that was on the + /// host. /// /// No `Instant` arithmetic, so a PTS zero before this host booted still maps. - fn mapped(zero: hang::catalog::Clock, unmappable: impl Fn(&str) -> crate::Error) -> crate::Result { - let instant = kio::clock::now(); - let now = host_wall().ok_or_else(|| unmappable("needs the host wall clock, which is unset"))?; + fn mapped(&self, zero: hang::catalog::Clock, unmappable: impl Fn(&str) -> crate::Error) -> crate::Result { + let instant = self.source.now(); + let now = set(self.source.wall()).ok_or_else(|| unmappable("needs the host wall clock, which is unset"))?; // Saturating would stamp everything early by the skew without saying so. let reading = u64::try_from(moq_micros(now) - zero.wall.as_micros() as i128) .map_err(|_| unmappable("puts PTS zero after now"))?; Ok(Self { + source: self.source.clone(), instant, reading, wall: Some(zero), @@ -211,17 +229,18 @@ impl Clock { /// The current timestamp on this clock. pub fn now(&self) -> moq_net::Timestamp { // u128 -> u64 truncation is unreachable: u64 microseconds is ~584,000 years. - let elapsed = self.instant.elapsed().as_micros() as u64; + let elapsed = self.source.now().saturating_duration_since(self.instant).as_micros() as u64; moq_net::Timestamp::from_micros(self.reading + elapsed) .expect("an instant elapsed duration fits in a timestamp") } /// Map the instant a payload was captured (a datagram's arrival, a sensor read) onto this clock. /// - /// Refuses an instant ahead of now, which would claim the payload reached the transport before - /// it existed, and one before PTS zero, which no timestamp can name. + /// `at` is an instant on the ambient clock this one was created under, which is the host's + /// outside tests. Refuses an instant ahead of now, which would claim the payload reached the + /// transport before it existed, and one before PTS zero, which no timestamp can name. pub fn capture(&self, at: Instant) -> crate::Result { - if at > kio::clock::now() { + if at > self.source.now() { return Err(crate::Error::InvalidCapture); } let micros = match at.checked_duration_since(self.instant) { @@ -376,7 +395,7 @@ mod tests { async fn arrival_maps_the_first_timestamp_to_now() { kio::clock::sleep(Duration::from_secs(1)).await; let since = Duration::from_secs(10 * 3600); - let clock = Clock::arrival(since).unwrap(); + let clock = Clock::new().arrival(since).unwrap(); let now = clock.now(); assert!(now.as_micros() >= since.as_micros()); let actual = clock.wall_clock(now).unwrap(); @@ -390,7 +409,7 @@ mod tests { // PTS zero before 2020 cannot be named on the wire. assert!(matches!( - Clock::arrival(Duration::from_secs(100 * 365 * 86_400)), + Clock::new().arrival(Duration::from_secs(100 * 365 * 86_400)), Err(crate::Error::UnmappableTimestamp(_)) )); } @@ -414,13 +433,13 @@ mod tests { )); // An importer arriving an hour in still gets a timeline, and placing one has no wall to use. - assert_eq!(Clock::arrival(Duration::from_secs(3600)).unwrap().wall(), None); + assert_eq!(clock.arrival(Duration::from_secs(3600)).unwrap().wall(), None); assert!(matches!( - Clock::arrival(Duration::from_micros(MAX_SAFE_INTEGER + 1)), + clock.arrival(Duration::from_micros(MAX_SAFE_INTEGER + 1)), Err(crate::Error::UnmappableTimestamp(_)) )); assert!(matches!( - Clock::placed(Duration::from_secs(1), moq_epoch() + Duration::from_secs(60)), + clock.placed(Duration::from_secs(1), moq_epoch() + Duration::from_secs(60)), Err(crate::Error::UnmappableTimestamp(_)) )); diff --git a/rs/moq-mux/src/container/fmp4/import_test.rs b/rs/moq-mux/src/container/fmp4/import_test.rs index cf4192072c9..8feb3363323 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -2780,7 +2780,7 @@ async fn an_import_shifts_onto_an_explicit_clock() { let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); let clock = crate::Clock::new(); - let config = crate::catalog::Config::default().with_clock(clock); + let config = crate::catalog::Config::default().with_clock(clock.clone()); let catalog = crate::catalog::Producer::new(&mut broadcast, config).unwrap(); let mut fmp4 = crate::container::fmp4::Import::new(broadcast, catalog.reserve()); diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index 420dff6f233..e7465ce816a 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -1741,7 +1741,7 @@ mod tests { #[tokio::test] async fn a_wholly_buffered_group_records_its_end() { let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = crate::hls::Tracks::new(&broadcast); + let tracks = crate::hls::Tracks::new(&broadcast, crate::Clock::new()); let recorder = tracks.track("video", crate::hls::Config::default()).unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); let mut producer = Producer::new(track, Container::Legacy(crate::container::Kind::Video)) @@ -1760,7 +1760,7 @@ mod tests { #[tokio::test] async fn a_discontinuity_stops_the_segment_at_the_marker() { let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = crate::hls::Tracks::new(&broadcast); + let tracks = crate::hls::Tracks::new(&broadcast, crate::Clock::new()); let recorder = tracks.track("video", crate::hls::Config::default()).unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); let mut producer = diff --git a/rs/moq-mux/src/hls.rs b/rs/moq-mux/src/hls.rs index a10420ea1e8..d929dbbace1 100644 --- a/rs/moq-mux/src/hls.rs +++ b/rs/moq-mux/src/hls.rs @@ -377,6 +377,11 @@ pub struct Producer { sink: moq_json::window::Producer, /// The record track, to abort. track: moq_net::track::Producer, + /// What each record is stamped on. A record's own pts can't be the stamp: a keyframe's pts may + /// dip below earlier content, and a group may not start before the one it follows. + clock: crate::Clock, + /// Whether a record was stamped on `clock`, which then can't be swapped for another. + stamped: bool, } impl Producer { @@ -391,12 +396,15 @@ impl Producer { .with_compress(true) } - /// Publish onto `track`, starting at record zero. - pub fn new(track: moq_net::track::Producer) -> Self { + /// Publish onto `track`, starting at record zero, stamping each record on `clock`: the + /// broadcast's [`Clock`](crate::Clock). + pub fn new(track: moq_net::track::Producer, clock: crate::Clock) -> Self { let config = moq_json::window::ProducerConfig::default().with_checkpoint_records(CHECKPOINT_RECORDS); Self { sink: moq_json::window::Producer::new(track.clone(), config), track, + clock, + stamped: false, } } @@ -410,8 +418,9 @@ impl Producer { }); } self.refresh()?; + self.stamped = true; self.sink - .push(moq_net::Timed::from(record).at(moq_net::Timestamp::now())) + .push(moq_net::Timed::from(record).at(self.clock.now())) .map_err(json) } @@ -523,6 +532,8 @@ struct State { /// recorder drops, so a re-enrolled track continues its numbering. tracks: BTreeMap>)>, finished: bool, + /// The broadcast clock every record is stamped on. + clock: crate::Clock, } /// A broadcast's HLS record tracks, one per enrolled rendition. @@ -536,17 +547,34 @@ pub(crate) struct Tracks { } impl Tracks { - /// Record tracks for tracks of `broadcast`. - pub fn new(broadcast: &moq_net::broadcast::Producer) -> Self { + /// Record tracks for tracks of `broadcast`, stamped on its `clock`. + pub fn new(broadcast: &moq_net::broadcast::Producer, clock: crate::Clock) -> Self { Self { broadcast: broadcast.clone(), state: Arc::new(Mutex::new(State { tracks: BTreeMap::new(), finished: false, + clock, })), } } + /// Stamp later records on `clock`, which the catalog moved to before anything fixed it. + /// + /// A record track that already stamped one keeps its clock: the move may read earlier, and a + /// track's timestamps never go back. + pub fn retime(&self, clock: &crate::Clock) { + let mut state = self.state.lock().unwrap(); + state.clock = clock.clone(); + for (_, live) in state.tracks.values() { + if let Some(output) = live.lock().unwrap().output.as_mut() + && !output.stamped + { + output.clock = clock.clone(); + } + } + } + /// Enroll the track `name`, its segments cut by `config`. /// /// Creates its record track, named by [`hang::hls::default_name`], on first enrollment. @@ -584,7 +612,7 @@ impl Tracks { let live = Arc::new(Mutex::new(Live { segmenter: segmenter(0), on_declare: None, - output: Some(Producer::new(track)), + output: Some(Producer::new(track, state.clock.clone())), recording: true, })); state @@ -1164,7 +1192,7 @@ mod test { #[tokio::test] async fn renditions_cut_alike_without_sharing_state() { let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = Tracks::new(&broadcast); + let tracks = Tracks::new(&broadcast, crate::Clock::new()); let mut video = tracks.track("video", hinted()).unwrap(); let mut audio = tracks.track("audio", hinted()).unwrap(); let mut late = tracks.track("late", hinted()).unwrap(); @@ -1212,7 +1240,7 @@ mod test { #[test] fn an_unhinted_track_is_advertised_once_declared() { let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = Tracks::new(&broadcast); + let tracks = Tracks::new(&broadcast, crate::Clock::new()); let mut recorder = tracks.track("video", Config::default()).unwrap(); recorder.frame(0, ms(0)); assert_eq!(tracks.hls("video"), None); @@ -1226,7 +1254,7 @@ mod test { #[tokio::test] async fn a_rejected_report_fails_the_record_track() { let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = Tracks::new(&broadcast); + let tracks = Tracks::new(&broadcast, crate::Clock::new()); let mut recorder = tracks.track("video", hinted()).unwrap(); let hls = tracks.hls("video").unwrap(); let mut consumer = Consumer::subscribe(&broadcast.consume(), &hls).await.unwrap(); @@ -1248,7 +1276,7 @@ mod test { #[tokio::test] async fn re_enrolling_continues_the_numbering() { let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = Tracks::new(&broadcast); + let tracks = Tracks::new(&broadcast, crate::Clock::new()); let mut recorder = tracks.track("video", hinted()).unwrap(); assert!(tracks.track("video", hinted()).is_err(), "its recorder is still alive"); recorder.frame(0, ms(0)); @@ -1275,7 +1303,7 @@ mod test { let broadcast = moq_net::broadcast::Info::new().produce(); let media = broadcast.create_track("video", None).unwrap(); let demand = media.demand(); - let tracks = Tracks::new(&broadcast); + let tracks = Tracks::new(&broadcast, crate::Clock::new()); let mut recorder = tracks.track("video", hinted()).unwrap(); recorder.lend(&media); assert_eq!(demand.wanted(), None, "nobody reads the records yet"); @@ -1303,7 +1331,7 @@ mod test { let broadcast = moq_net::broadcast::Info::new().produce(); let media = broadcast.create_track("video", None).unwrap(); let demand = media.demand(); - let tracks = Tracks::new(&broadcast); + let tracks = Tracks::new(&broadcast, crate::Clock::new()); let mut recorder = tracks.track("video", hinted()).unwrap(); recorder.lend(&media); @@ -1330,14 +1358,14 @@ mod test { fn a_taken_record_track_name_fails_enrollment() { let broadcast = moq_net::broadcast::Info::new().produce(); let _taken = broadcast.create_track("video.hls", None).unwrap(); - let tracks = Tracks::new(&broadcast); + let tracks = Tracks::new(&broadcast, crate::Clock::new()); assert!(tracks.track("video", Config::default()).is_err()); } #[test] fn enrollment_after_finish_is_refused() { let broadcast = moq_net::broadcast::Info::new().produce(); - let tracks = Tracks::new(&broadcast); + let tracks = Tracks::new(&broadcast, crate::Clock::new()); tracks.finish(); assert!(tracks.track("video", Config::default()).is_err()); } diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index 79d6a64541d..5ed28433e10 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -1013,8 +1013,11 @@ mod tests { tokio::time::advance(ago).await; let clock = moq_mux::Clock::at(moq_net::time::Instant::now() - ago, kio::clock::wall() - ago).unwrap(); let mut input = moq_net::broadcast::Info::default().produce(); - let source = - moq_mux::catalog::Producer::new(&mut input, moq_mux::catalog::Config::default().with_clock(clock)).unwrap(); + let source = moq_mux::catalog::Producer::new( + &mut input, + moq_mux::catalog::Config::default().with_clock(clock.clone()), + ) + .unwrap(); let mut output = moq_net::broadcast::Info::default().produce(); let mut derived = moq_mux::catalog::Producer::new(&mut output, moq_mux::catalog::Config::default()).unwrap(); diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index 885349fdfd8..ec80446f92a 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -1147,7 +1147,7 @@ mod tests { let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); let config = moq_mux::catalog::Config::default() - .with_clock(clock) + .with_clock(clock.clone()) .with_max_age(RETAIN); let catalog = moq_mux::catalog::Producer::new(&mut broadcast, config).unwrap(); let track = broadcast @@ -1169,6 +1169,7 @@ mod tests { let (opens, rx) = tokio::sync::mpsc::unbounded_channel(); let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); // Local: a capture stream is `!Send` on macOS, so each test runs on a `LocalSet`. + let capturing = clock.clone(); let task = tokio::task::spawn_local(async move { let mut source = Opens(rx); // Each subscription opens the camera, so a fixture can idle and restart it. @@ -1178,7 +1179,7 @@ mod tests { ..Capture::default() }; tokio::select! { - res = capture_loop(&mut producer, &mut source, &options, &clock) => res?, + res = capture_loop(&mut producer, &mut source, &options, &capturing) => res?, _ = stopped => {} } producer.finish() From 5c1ffe0b02f0d3184c30765322f2b8f6fd8ea46a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:37:18 -0800 Subject: [PATCH 29/86] ci(queue): test merge groups on the self-hosted runner (#7612) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- .github/actionlint.yaml | 4 ++ .github/actions/rust-cache/action.yml | 10 ++-- .github/workflows/check.yml | 2 +- .github/workflows/queue.yml | 71 +++++++++++++++++++++++++++ quest/m0/ci/land.md | 11 +++-- sh/gh/queue.test.ts | 27 ++++++++-- sh/gh/setup.test.ts | 19 ++++--- 7 files changed, 124 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/queue.yml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 469f8339cd5..12a24e22ec7 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,3 +1,7 @@ +self-hosted-runner: + # The host's merge queue runner (kixelated/nixos modules/ci-runners.nix). + labels: + - moq-queue paths: # GitHub's `queue: max` on the shared Pages writers. actionlint learns it in # https://github.com/rhysd/actionlint/pull/661, unreleased after 1.7.12. diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml index 2cf2bb358be..87cc9958ddb 100644 --- a/.github/actions/rust-cache/action.yml +++ b/.github/actions/rust-cache/action.yml @@ -12,14 +12,16 @@ inputs: runs: using: composite steps: - - name: Require the landing gate on self-hosted runners - if: runner.environment == 'self-hosted' && (github.workflow != 'Land' || github.ref != 'refs/heads/main') + # Only main's merge queue builds on the self-hosted runner: a maintainer + # picked each group's PR, so nothing unreviewed writes its persistent caches. + - name: Require main's merge queue on self-hosted runners + if: runner.environment == 'self-hosted' && !(github.workflow == 'Queue' && github.event_name == 'merge_group' && startsWith(github.ref, 'refs/heads/gh-readonly-queue/main/')) shell: bash run: | - echo "::error::Persistent self-hosted caches require Land on main." >&2 + echo "::error::Persistent self-hosted caches require Queue on main's merge queue." >&2 exit 1 - # Hosted runs on `main` write; everything else reads. The landing worker + # Hosted runs on `main` write; everything else reads. The merge queue # uses only its local store and receives no remote credentials. # # The write key is the boundary. It lives in the `cache-write` diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 250b5662827..ea92d9af1c6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -3,7 +3,7 @@ name: Check permissions: contents: read -# Main PRs report Check as skipped; Land tests their combined prefix. +# Main PRs report Check as skipped; the merge queue tests each one (queue.yml). # Release PRs run the same gate over every package. on: pull_request: diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml new file mode 100644 index 00000000000..b4cb104eb18 --- /dev/null +++ b/.github/workflows/queue.yml @@ -0,0 +1,71 @@ +name: Queue + +# GitHub's merge queue tests each picked PR on top of main, one at a time, on +# the host's self-hosted runner, and merges exactly the tree it tested. When the +# queue drops a PR, moq-dev/quest's queue watcher on the host runs the land +# fixer and re-enqueues it (quest/m0/ci/land.md). +# +# `main`'s ruleset requires this job's `Queue` context. GitHub admits a PR to +# the queue only once its required checks pass on the PR itself, so the job +# also runs, as skipped, on main pull requests; a skipped job satisfies the +# requirement. Only the merge group build runs code, and only a maintainer's +# pick enters the queue. +on: + pull_request: + branches: [main] + # `edited` covers a PR retargeted to main, which needs the context too. + types: [opened, synchronize, reopened, edited] + merge_group: + types: [checks_requested] + +permissions: + contents: read + +# Each group has its own ref, so a group never cancels another; a pull request's +# skipped runs replace their own. +concurrency: + group: queue-${{ github.ref }} + cancel-in-progress: true + +jobs: + queue: + name: Queue + if: github.event_name == 'merge_group' + runs-on: [self-hosted, moq-queue] + # A merge group is one PR at build concurrency 1; `just ci` takes about 5-15 + # minutes. Backstop a hang instead of holding the queue. + timeout-minutes: 90 + env: + # The diff the dispatcher selects from: the group's base, as the host gate + # passed its own BASE. + BASE: ${{ github.event.merge_group.base_sha }} + # A failure goes to the fixer whatever else fails, so stop at the first + # failed module (moq-dev/moq#7466). + CHECK_FAIL_FAST: "1" + NEXTEST_TEST_THREADS: 24 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Full history for the dispatcher's base comparison. + fetch-depth: 0 + + - uses: ./.github/actions/just + + # Local storage only: a self-hosted runner gets no remote cache key. + - name: Rust cache + uses: ./.github/actions/rust-cache + + - name: Check + run: | + rm -rf target/cargo-timings + nix develop --command just ci "$BASE" + + - name: Upload build timings + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: cargo-timings + path: target/cargo-timings/cargo-timing-*.html + if-no-files-found: ignore + retention-days: 14 diff --git a/quest/m0/ci/land.md b/quest/m0/ci/land.md index f84301d9a14..055eb6a3ebf 100644 --- a/quest/m0/ci/land.md +++ b/quest/m0/ci/land.md @@ -68,11 +68,12 @@ Baselines: Remaining: -- **Workflow.** A `merge_group` workflow runs `just ci` against the group's - base on `runs-on: [self-hosted, moq-queue]`, read-only, with no secrets. -- **Ruleset.** `main` requires that check and the merge queue (squash, build - concurrency 1, merge limit 1), drops the unreported `quest land` - requirement, and keeps only the administrator bypass. +- **Ruleset.** `main` requires the `Queue` context, which + `.github/workflows/queue.yml` reports from its `merge_group` build on + `[self-hosted, moq-queue]` (skipped on pull requests, so a PR can enter + the queue), and the merge queue (squash, build concurrency 1, merge limit + 1). It drops the unreported `quest land` requirement and keeps only the + administrator bypass. - **Host.** The host config stops serving moq from `quest land` (`services.quest-land.repositories.moq.enable = false`) at the cutover, while the watcher and fixer keep serving moq. diff --git a/sh/gh/queue.test.ts b/sh/gh/queue.test.ts index 12a79f301d4..68a6541e1dd 100644 --- a/sh/gh/queue.test.ts +++ b/sh/gh/queue.test.ts @@ -1,4 +1,4 @@ -// Main PRs land through the host's batched gate; release PRs retain their own checks. +// Main PRs land through GitHub's merge queue (queue.yml); release PRs retain their own checks. import assert from "node:assert/strict"; import { readdir } from "node:fs/promises"; @@ -38,15 +38,34 @@ for (const step of check.jobs.check.steps) assert.equal(step.env?.CI_BASE, undef assert.equal(check.concurrency.group, "check-${{ github.ref }}"); assert.equal(check.concurrency["cancel-in-progress"], true); -// The land gate runs on the maintainer's host (`quest land serve`), not in Actions. -assert.equal(await Bun.file(".github/workflows/land.yml").exists(), false, "no workflow may land around the host gate"); +// No workflow lands around the merge queue. +assert.equal(await Bun.file(".github/workflows/land.yml").exists(), false, "no workflow may land around the merge queue"); + +// Queue is the one merge group build: main's ruleset requires its context, so +// it reports (skipped) on main PRs and runs only in the merge queue, on the +// host's runner, read-only and without secrets. +const queue = await workflow("queue"); +assert.deepEqual(queue.on.merge_group, { types: ["checks_requested"] }); +assert.deepEqual(queue.on.pull_request.branches, ["main"]); +assert.ok(queue.on.pull_request.types.includes("edited"), "retargeting to main must report the context"); +assert.equal(queue.on.pull_request.paths, undefined, "the required context must always report"); +assert.deepEqual(queue.permissions, { contents: "read" }); +assert.equal(queue.jobs.queue.name, "Queue"); +assert.equal(queue.jobs.queue.if, "github.event_name == 'merge_group'"); +assert.deepEqual(queue.jobs.queue["runs-on"], ["self-hosted", "moq-queue"]); +assert.equal(queue.jobs.queue.permissions, undefined, "the job keeps the workflow's read-only token"); +assert.ok(!JSON.stringify(queue).includes("secrets."), "the merge queue build gets no secrets"); +const checkout = queue.jobs.queue.steps.find((step: { uses?: string }) => step.uses?.startsWith("actions/checkout@")); +assert.equal(checkout.with["persist-credentials"], false); +// biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub expression is literal YAML. +assert.equal(queue.jobs.queue.env.BASE, "${{ github.event.merge_group.base_sha }}"); // Every other workflow stays out of main PRs and main's merge groups: optional // checks run nightly on main, and on release PRs through the Release gate. const files = (await readdir(".github/workflows")).filter((file) => file.endsWith(".yml")); for (const file of files) { const name = file.replace(/\.yml$/, ""); - if (name === "check" || name === "release-gate") continue; + if (name === "check" || name === "release-gate" || name === "queue") continue; const config = await workflow(name); assert.equal(config.on.merge_group, undefined, `${file} must not run in the merge queue`); assert.equal(config.on.pull_request, undefined, `${file} must run on release PRs through release-gate.yml`); diff --git a/sh/gh/setup.test.ts b/sh/gh/setup.test.ts index 9472f446577..2bf8471d5b6 100644 --- a/sh/gh/setup.test.ts +++ b/sh/gh/setup.test.ts @@ -60,12 +60,19 @@ test("self-hosted queue jobs use local caches without R2 credentials", async () .replaceAll("inputs.secret-access-key", 'inputs["secret-access-key"]') .replace(/^\$\{\{\s*|\s*\}\}$/g, ""); const backend = new Function("runner", "inputs", `return (${expression});`); - const guard = new Function("runner", "github", `return (${action.runs.steps[0].if});`); - for (const name of ["Land", "Check", "Untrusted"]) { - for (const ref of ["refs/heads/main", "refs/heads/feature", "refs/pull/1/merge"]) { - const github = { workflow: name, ref }; - expect(guard({ environment: "self-hosted" }, github)).toBe(name !== "Land" || ref !== "refs/heads/main"); - expect(guard({ environment: "github-hosted" }, github)).toBe(false); + // GitHub's expression `startsWith`, so the guard evaluates as written. + const startsWith = (value: string, prefix: string) => value.startsWith(prefix); + const guard = new Function("runner", "github", "startsWith", `return (${action.runs.steps[0].if});`); + const queue = "refs/heads/gh-readonly-queue/main/pr-1-0123456789abcdef0123456789abcdef01234567"; + const refs = ["refs/heads/main", "refs/heads/feature", "refs/pull/1/merge", queue, "refs/heads/gh-readonly-queue/release/pr-1-0"]; + for (const name of ["Queue", "Land", "Check", "Untrusted"]) { + for (const event of ["merge_group", "pull_request", "push"]) { + for (const ref of refs) { + const github = { workflow: name, event_name: event, ref }; + const trusted = name === "Queue" && event === "merge_group" && ref === queue; + expect(guard({ environment: "self-hosted" }, github, startsWith)).toBe(!trusted); + expect(guard({ environment: "github-hosted" }, github, startsWith)).toBe(false); + } } } for (const credentials of [ From 76a659cd55825aacd22f3110660870cd3f36b347 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:37:44 -0800 Subject: [PATCH 30/86] fix(net): key the WebSocket preference by server and clear it on a WebTransport win (#7525) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- bench/README.md | 26 ++- bench/browser/build.ts | 2 +- bench/browser/driver.ts | 185 +++++++++++++++++- bench/browser/page/racing.ts | 113 +++++++++++ bench/browser/run.sh | 2 +- doc/concept/transport.md | 7 + js/net/src/connection/connect.test.ts | 87 +++++++- js/net/src/connection/connect.ts | 31 ++- quest/m2/performance-survey/README.md | 1 - .../performance-survey/transport-history.md | 28 --- 10 files changed, 432 insertions(+), 50 deletions(-) create mode 100644 bench/browser/page/racing.ts delete mode 100644 quest/m2/performance-survey/transport-history.md diff --git a/bench/README.md b/bench/README.md index e976bc84dc2..549d6131e01 100644 --- a/bench/README.md +++ b/bench/README.md @@ -355,7 +355,7 @@ extra copy of every received stream chunk injected into `@moq/net`, the control that shows the suite can see a copy-heavy change), and `base` when a revision is given. Each variant resolves `@moq/*` from its own checkout. Variants alternate within each round, with the starting variant rotated, after one warmup round -(`--rounds`, `--warmup`, `--scenarios transport,cmaf,media`). +(`--rounds`, `--warmup`, `--scenarios transport,cmaf,media,racing`). - `transport`: a publisher page writes 160 B through 1 MiB frames, a consumer page subscribes and reads them all. Connection setup and fixture generation stay @@ -371,6 +371,23 @@ within each round, with the starting variant rotated, after one warmup round window's edges, so decoded-but-unpainted can be off by a frame or two; decoder queue depth (mean, and maximum within the window) comes from a thin `VideoDecoder` wrapper present in every pass. +- `racing`: one page dials `Connection.connect` repeatedly, closing each session + before the next, to measure what the WebSocket fallback's win history keeps and + what it saves. Each URL's reconnects dial in order. From a 16 URL x 4 reconnect + x 256 character base, dialed one at a time, the sweep varies one axis at a + time: 1 and 128 URLs, 1 and 16 reconnects, 64 and 2048 characters, and a query + rotated on every reconnect, as a refreshed token would be. The 128 URL shape + dials 8 URLs at once to finish; the browser opens one WebSocket to an address + at a time, so only its retention compares. Modes refuse WebTransport so + WebSocket wins (`websocket`, and `alternate` with its own fallback URL), leave + it open (`webtransport`, and `disabled` without a fallback), or refuse only each + URL's first dial and then race twice more with it open (`sticky`). The refusal + throws from the page's `WebTransport` constructor, as a blocked network fails, + so the WebSocket head start still runs. Each sample reports dial latency per + reconnect and the WebSocket win fraction. After the pool's linger and a forced + collection, a heap snapshot counts the flat strings still holding the run's URL + marker, with their V8 object bytes and characters. A forced arm's winner is + checked; an open race's is only reported. Costs are split by where they run. Main-thread CPU is CDP `TaskDuration` in thread ticks; process CPU is each page's renderer (every thread, including @@ -405,9 +422,12 @@ check sampled bytes, the counted pass checks every byte, and frame counts and an order-independent digest must match, so skipped work cannot read as a speedup. CMAF output is decoded and checked byte for byte in a separate call after each phase's CPU and trace snapshots. -Crashes, wrong bytes, missing frames, a window that presents nothing, or an +Crashes, wrong bytes, missing frames, a window that presents nothing, a racing +dial that fails or a forced racing arm won by the other transport, or an unavailable CPU clock fail the run; timing never does. The nightly runs -`--smoke` and keeps its run directory as the `bench-browser` artifact. +`--smoke`, whose racing sweep runs every mode and a rotated query at 4 URLs x 2 +reconnects x 64 characters, and keeps its run directory as the `bench-browser` +artifact. The in-memory frame, retained-history, gap ledger, and resume-history paths also run directly in Chromium: diff --git a/bench/browser/build.ts b/bench/browser/build.ts index 32e2342e395..2d675c7f377 100644 --- a/bench/browser/build.ts +++ b/bench/browser/build.ts @@ -13,7 +13,7 @@ import { basename, join, resolve } from "node:path"; import type { BunPlugin } from "bun"; import { workspace } from "./workspace.ts"; -const PAGES = ["transport", "cmaf", "media"].map((page) => join(import.meta.dir, "page", `${page}.ts`)); +const PAGES = ["transport", "cmaf", "media", "racing"].map((page) => join(import.meta.dir, "page", `${page}.ts`)); // The one statement every received byte passes through; the control copies it. const CHUNK = "this.#chunks.push(result.value);"; diff --git a/bench/browser/driver.ts b/bench/browser/driver.ts index 174910381db..8f74dc838b3 100644 --- a/bench/browser/driver.ts +++ b/bench/browser/driver.ts @@ -14,6 +14,7 @@ import { install, read } from "./counters.ts"; import { check, digest, payload } from "./fixture.ts"; import type { Cmaf, Phase } from "./page/cmaf.ts"; import type { Media } from "./page/media.ts"; +import type { Dial, Mode, Racing } from "./page/racing.ts"; import type { Delivery, Transport } from "./page/transport.ts"; import { compare, type Row, summarize } from "./summary.ts"; @@ -25,7 +26,7 @@ const { values } = parseArgs({ variants: { type: "string" }, rounds: { type: "string", default: "5" }, warmup: { type: "string", default: "1" }, - scenarios: { type: "string", default: "transport,cmaf,media" }, + scenarios: { type: "string", default: "transport,cmaf,media,racing" }, smoke: { type: "boolean", default: false }, timeout: { type: "string", default: "60" }, }, @@ -52,7 +53,7 @@ const warmup = smoke ? 0 : integer("warmup", values.warmup, 0); const timeout = integer("timeout", values.timeout, 1) * 1000; const scenarios = new Set(values.scenarios.split(",")); for (const scenario of scenarios) - if (!["transport", "cmaf", "media"].includes(scenario)) throw new Error(`unknown scenario: ${scenario}`); + if (!["transport", "cmaf", "media", "racing"].includes(scenario)) throw new Error(`unknown scenario: ${scenario}`); // Fixed so a run on another host or day compares like with like. Software rendering and decode: // a hardware path appearing or disappearing would change what is measured. @@ -73,6 +74,8 @@ const HEADLINE = new Set([ "watcher_ms_per_s", "publisher_ms_per_s", "presented_fps", + "connect_p50_ms", + "retained_strings", ]); // Deterministic or one-off metrics from the traced, counted and probed passes. const INSTRUMENTED = /^(copied|allocated|gc_|frame_delay)/; @@ -93,6 +96,46 @@ const CMAF: Cmaf[] = [ { kind: "audio", sizes: Array(50).fill(160), iterations: smoke ? 2 : 200 }, { kind: "video", sizes: [65536, ...Array(29).fill(4096)], iterations: smoke ? 2 : 100 }, ]; +// One axis varies from the 16 URL x 4 reconnect x 256 character base at a time. One lane keeps +// each dial's latency its own; the 128 URL shape takes 8 lanes to finish, so its latency is +// contended and only its retention compares. +type RacingShape = Pick; +const racingShape = (mode: Mode, shape: Partial = {}): RacingShape => ({ + mode, + urls: smoke ? 4 : 16, + reconnects: smoke ? 2 : 4, + length: smoke ? 64 : 256, + rotate: false, + lanes: 1, + ...shape, + // A sticky URL races a WebSocket win, then two dials with WebTransport open again. + ...(mode === "sticky" && { reconnects: 3 }), +}); +const RACING: RacingShape[] = smoke + ? [ + racingShape("websocket"), + racingShape("websocket", { rotate: true }), + racingShape("webtransport"), + racingShape("sticky"), + racingShape("alternate"), + racingShape("disabled"), + ] + : [ + racingShape("websocket"), + racingShape("websocket", { urls: 1 }), + racingShape("websocket", { urls: 128, lanes: 8 }), + racingShape("websocket", { reconnects: 1 }), + racingShape("websocket", { reconnects: 16 }), + racingShape("websocket", { length: 64 }), + racingShape("websocket", { length: 2048 }), + racingShape("websocket", { rotate: true }), + racingShape("webtransport"), + racingShape("sticky"), + racingShape("alternate"), + racingShape("disabled"), + ]; +// Past the pool's two-second linger, so nothing a closed session scheduled still holds a URL. +const RACING_LINGER_MS = 2500; const MEDIA = { width: 1280, height: 720, fps: 30, codec: "avc1", bitrate: 2_500_000, keyframe_ms: 1000 }; const MEDIA_WARMUP_MS = smoke ? 1000 : 3000; const MEDIA_WINDOW_MS = smoke ? 3000 : 10000; @@ -104,7 +147,7 @@ const server = Bun.serve({ const path = new URL(request.url).pathname; if (path === "/cmaf-fixtures.json") return new Response(Bun.file(new URL("../../js/hang/src/cmaf/fixtures.json", import.meta.url))); - const page = /^\/([a-z0-9-]+)\/(transport|cmaf|media)$/.exec(path); + const page = /^\/([a-z0-9-]+)\/(transport|cmaf|media|racing)$/.exec(path); if (page) return new Response( ``, @@ -143,6 +186,8 @@ const report = { "GC pauses and reclaimed JS heap come from a separate traced pass; tracing overhead never enters a timed sample.", "Copied and allocated bytes count JS-visible typed-array copies only, from a separate counted pass. Native copies (WebTransport, WebCodecs) are unavailable.", "Software decode and rendering (--disable-gpu). Hardware paths are not measured.", + "Racing retention counts flat heap-snapshot strings holding the run's URL marker after every session closed, the pool linger passed and V8 collected. Heap growth is JSHeapUsedSize across the run and includes unrelated state V8 kept.", + "Racing refuses WebTransport in the page's constructor, as a blocked network fails it; the WebSocket head start still runs, so the fallback's latency matches a silent UDP drop.", "Frame delay joins publisher paint time with watcher presentation time across pages on one host clock; only the probed pass reads frame ids back.", ], samples: [] as Row[], @@ -178,6 +223,8 @@ try { if (scenarios.has("cmaf")) for (const shape of CMAF) for (const variant of order) await sample(cmaf(variant, shape, round, "timed")); if (scenarios.has("media")) for (const variant of order) await sample(media(variant, round, "timed")); + if (scenarios.has("racing")) + for (const shape of RACING) for (const variant of order) await sample(racing(variant, shape, round)); } console.log("── traced and counted passes"); for (const variant of variants) { @@ -513,6 +560,138 @@ async function cmaf(variant: string, shape: Cmaf, round: number, pass: Pass) { } } +function racingKey(shape: RacingShape) { + const lanes = shape.lanes > 1 ? ` ${shape.lanes} lanes` : ""; + return `racing ${shape.mode} ${shape.urls}u x${shape.reconnects} ${shape.length}c${shape.rotate ? " rotated" : ""}${lanes}`; +} + +/** + * Flat strings in the page's heap holding `marker` after a full collection: how many, their V8 + * object bytes, and the characters of their snapshot names, which V8 truncates at 1024. + */ +async function retained(cdp: CDPSession, marker: string) { + const chunks: string[] = []; + const onChunk = ({ chunk }: { chunk: string }) => chunks.push(chunk); + cdp.on("HeapProfiler.addHeapSnapshotChunk", onChunk); + try { + await cdp.send("HeapProfiler.collectGarbage"); + await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false }); + } finally { + cdp.off("HeapProfiler.addHeapSnapshotChunk", onChunk); + } + const snapshot = JSON.parse(chunks.join("")) as { + snapshot: { meta: { node_fields: string[]; node_types: [string[], ...unknown[]] } }; + nodes: number[]; + strings: string[]; + }; + const fields = snapshot.snapshot.meta.node_fields; + const stride = fields.length; + const [typeAt, nameAt, sizeAt] = ["type", "name", "self_size"].map((field) => fields.indexOf(field)); + // Flat strings only: a cons or sliced string's name repeats characters a flat string owns. + const flat = snapshot.snapshot.meta.node_types[0].indexOf("string"); + let strings = 0; + let bytes = 0; + let chars = 0; + for (let node = 0; node < snapshot.nodes.length; node += stride) { + if (snapshot.nodes[node + typeAt] !== flat) continue; + const name = snapshot.strings[snapshot.nodes[node + nameAt]]; + if (!name.includes(marker)) continue; + strings++; + bytes += snapshot.nodes[node + sizeAt]; + chars += name.length; + } + return { strings, bytes, chars }; +} + +async function racing(variant: string, shape: RacingShape, round: number) { + const key = racingKey(shape); + const marker = `rh${crypto.randomUUID().replaceAll("-", "")}`; + const config: Racing = { url: relay, marker, ...shape }; + const opened = await open(variant, "racing"); + try { + await opened.cdp.send("HeapProfiler.enable"); + await opened.cdp.send("HeapProfiler.collectGarbage"); + const before = await metrics(opened); + const start = performance.now(); + const dials: Dial[] = await bounded( + opened.page.evaluate((config) => window.racing.run(config), config), + `${key} dials`, + ); + const wall_ms = performance.now() - start; + await Bun.sleep(RACING_LINGER_MS); + // Every dialed URL extends the marker with `-`, so the bare marker the page was handed never counts. + const kept = await retained(opened.cdp, `${marker}-`); + const after = await metrics(opened); + if (opened.errors.length) throw new Error(opened.errors.join("; ")); + if (dials.length !== shape.urls * shape.reconnects) + throw new Error(`${key} ${variant}: ${dials.length} dials, expected ${shape.urls * shape.reconnects}`); + // Only a refused or disabled arm fixes the winner; an open race is reported, never asserted. + const forced = (dial: Dial) => + shape.mode === "websocket" || shape.mode === "alternate" || (shape.mode === "sticky" && dial.attempt === 0) + ? "websocket" + : shape.mode === "disabled" + ? "webtransport" + : undefined; + for (const dial of dials) { + const expected = forced(dial); + if (expected && dial.transport !== expected) + throw new Error( + `${key} ${variant}: dial ${dial.attempt} won over ${dial.transport}, expected ${expected}`, + ); + } + const summary = (subset: Dial[]) => { + const ms = subset.map((dial) => dial.ms).sort((a, b) => a - b); + const at = (q: number) => ms[Math.min(ms.length - 1, Math.floor(q * ms.length))]; + return { + websocket: subset.filter((dial) => dial.transport === "websocket").length / subset.length, + p50: at(0.5), + p95: at(0.95), + }; + }; + const all = summary(dials); + const first = summary(dials.filter((dial) => dial.attempt === 0)); + // Per later attempt, such as sticky's second and third dials, which race with WebTransport open again. + const attempts = Object.fromEntries( + Array.from({ length: shape.reconnects - 1 }, (_, index) => index + 1).flatMap((attempt) => { + const phase = summary(dials.filter((dial) => dial.attempt === attempt)); + return [ + [`dial${attempt + 1}_p50_ms`, phase.p50], + [`dial${attempt + 1}_websocket_fraction`, phase.websocket], + ]; + }), + ); + const wins = dials.filter((dial) => dial.transport === "websocket").length; + const row: Row = { + scenario: "racing", + key, + shape: { ...shape, linger_ms: RACING_LINGER_MS }, + variant, + round, + pass: "timed", + delivered: { frames: dials.length, bytes: kept.bytes, digest: wins }, + metrics: { + wall_ms, + connect_p50_ms: all.p50, + connect_p95_ms: all.p95, + dial1_p50_ms: first.p50, + ...attempts, + websocket_fraction: all.websocket, + url_chars: Math.max(...dials.map((dial) => dial.chars)), + retained_strings: kept.strings, + retained_v8_bytes: kept.bytes, + retained_chars: kept.chars, + ...(before.heap !== null && after.heap !== null && { heap_growth_bytes: after.heap - before.heap }), + }, + heap: { before: before.heap, after: after.heap }, + load: loadavg(), + }; + record(row); + log(row); + } finally { + await opened.close(); + } +} + async function media(variant: string, round: number, pass: Pass) { const config: Media = { url: relay, path: `media/${crypto.randomUUID()}`, ...MEDIA, probe: pass === "probed" }; const key = `media ${MEDIA.width}x${MEDIA.height}@${MEDIA.fps}`; diff --git a/bench/browser/page/racing.ts b/bench/browser/page/racing.ts new file mode 100644 index 00000000000..0bd604cd881 --- /dev/null +++ b/bench/browser/page/racing.ts @@ -0,0 +1,113 @@ +/** + * Repeated connection racing through the production `@moq/net` connect path. + * + * WebTransport is gated rather than mocked: when refused, the page's `WebTransport` throws as a + * blocked network does, so the WebSocket fallback wins after whatever head start the racing + * policy grants. Every dialed URL carries `marker` in its query, which the driver counts in a + * heap snapshot once every session has closed. `redact` logs drop the query, so no console + * message can hold a marker. + */ +import * as Moq from "@moq/net"; +import "./observe.ts"; + +/** + * What the WebTransport arm can do and which fallback the dial uses. + * + * - `websocket`: WebTransport refused; the default fallback wins every dial. + * - `webtransport`: WebTransport open, fallback enabled; the control with no WebSocket win. + * - `sticky`: per URL, a WebSocket win, then two dials with WebTransport open again. + * - `alternate`: WebTransport refused; the fallback dials its own URL. + * - `disabled`: WebTransport open, fallback disabled. + */ +export type Mode = "websocket" | "webtransport" | "sticky" | "alternate" | "disabled"; + +export interface Racing { + url: string; + marker: string; + mode: Mode; + /** Distinct URLs, spread over `lanes`. */ + urls: number; + /** Sequential dials per URL, each closing before the next. `sticky` takes 3. */ + reconnects: number; + /** Padding characters in each URL's query. */ + length: number; + /** Whether every reconnect changes the query, as a rotated token does. */ + rotate: boolean; + /** + * URLs dialed at once. More than one lets a large sweep finish, but its dials queue on one + * another (a browser opens one WebSocket to an address at a time), so their latency is not + * a single dial's. + */ + lanes: number; +} + +/** One dial: which reconnect of its URL it was, the transport that won, and how long it took. */ +export interface Dial { + attempt: number; + transport: string; + ms: number; + /** Characters in the dialed URL. */ + chars: number; +} + +// URL indices whose WebTransport dials are refused. Numbers, so the gate holds no marker string. +const refused = new Set(); +const Native = globalThis.WebTransport; +class Gated extends Native { + constructor(url: string | URL, options?: WebTransportOptions) { + const index = /\/racing\/(\d+)$/.exec(new URL(url).pathname); + if (index && refused.has(Number(index[1]))) throw new TypeError("WebTransport refused by the benchmark"); + super(url, options); + } +} +globalThis.WebTransport = Gated; + +function address(config: Racing, index: number, attempt: number): URL { + const url = new URL(`racing/${index}`, `${config.url}/`); + // The marker leads the query so a heap snapshot's string names, which V8 truncates, still hold it. + url.searchParams.set("m", `${config.marker}-${index}-${config.rotate ? attempt : 0}`); + url.searchParams.set("pad", "x".repeat(config.length)); + return url; +} + +async function dial(config: Racing, index: number, attempt: number): Promise { + const refuse = + config.mode === "websocket" || config.mode === "alternate" || (config.mode === "sticky" && attempt === 0); + if (refuse) refused.add(index); + else refused.delete(index); + const url = address(config, index, attempt); + const websocket: Moq.Connection.WebSocketProps = + config.mode === "disabled" + ? { enabled: false } + : config.mode === "alternate" + ? { url: new URL(`fallback/${index}`, `${config.url}/`) } + : {}; + const start = performance.now(); + const connection = await Moq.Connection.connect({ url, websocket }); + const ms = performance.now() - start; + const transport = connection.transport; + await connection.close(); + await connection.closed; + return { attempt, transport, ms, chars: url.href.length }; +} + +/** Dials every URL `reconnects` times, URLs spread over parallel lanes and each URL's dials in order. */ +async function run(config: Racing): Promise { + const { reconnects } = config; + const dials: Dial[] = []; + let next = 0; + const lane = async () => { + for (let index = next++; index < config.urls; index = next++) + for (let attempt = 0; attempt < reconnects; attempt++) dials.push(await dial(config, index, attempt)); + }; + await Promise.all(Array.from({ length: Math.min(config.lanes, config.urls) }, lane)); + refused.clear(); + return dials; +} + +declare global { + interface Window { + racing: { run: typeof run }; + } +} +window.racing = { run }; diff --git a/bench/browser/run.sh b/bench/browser/run.sh index 8ac635ddc72..ce7f918ab4f 100755 --- a/bench/browser/run.sh +++ b/bench/browser/run.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Real-browser transport and media benchmarks against a temporary relay. # -# bench/browser/run.sh [BASE] [--smoke] [--rounds N] [--scenarios transport,cmaf,media] +# bench/browser/run.sh [BASE] [--smoke] [--rounds N] [--scenarios transport,cmaf,media,racing] # # Variants, each bundled from the same page sources: # current this checkout, the reference diff --git a/doc/concept/transport.md b/doc/concept/transport.md index 86b929c9069..6aaf21c096b 100644 --- a/doc/concept/transport.md +++ b/doc/concept/transport.md @@ -45,6 +45,13 @@ only help once bytes leave the TCP queue. The fallback is automatic in every client; the relay serves it on its `[web.http]` or `[web.https]` listener. A session that lands on WebSocket stays there until it ends; the next connect races again. +QUIC gets a head start in the race: 500 ms in `@moq/net` and 200 ms in +`moq-tokio`. Once WebSocket wins against a server, identified by the host and +port the fallback dials, later connects to that server race both transports at +once. The path and query, including a rotating `?jwt=` token, don't matter. A +QUIC win against that server restores the head start, so a client returns to +QUIC once UDP gets through again. + ## Raw QUIC (native) Native clients dial the relay directly with the MoQ ALPN, skipping the HTTP/3 diff --git a/js/net/src/connection/connect.test.ts b/js/net/src/connection/connect.test.ts index 69390cd6e3e..bcd398beca6 100644 --- a/js/net/src/connection/connect.test.ts +++ b/js/net/src/connection/connect.test.ts @@ -938,7 +938,8 @@ test("when the WebSocket wins, every WebTransport dial is closed", async () => { }; const controller = new AbortController(); try { - const pending = connect(new URL("https://example.com/websocket-wins"), { signal: controller.signal }); + // Its own host: a WebSocket win drops WebTransport's head start for that host and port afterwards. + const pending = connect(new URL("https://websocket-wins.example.com/"), { signal: controller.signal }); await clock.advance(Nano.fromMilli(Milli(700))); expect(plan.dials.map((dial) => dial.at)).toEqual([0, 500]); expect(plan.dials.map((dial) => dial.closes)).toEqual([1, 1]); @@ -981,8 +982,8 @@ test("when WebTransport and WebSocket open in the same turn, the one not chosen }; const controller = new AbortController(); try { - // Its own URL: a WebSocket win drops WebTransport's head start for that URL afterwards. - const pending = connect(new URL("https://example.com/same-turn"), { + // Its own host: a WebSocket win drops WebTransport's head start for that host and port afterwards. + const pending = connect(new URL("https://same-turn.example.com/"), { websocket: { delay: Milli(0) }, signal: controller.signal, }); @@ -1091,8 +1092,8 @@ test("an invalid timeout throws before touching the caller's signal", async () = type WebSocketDial = "hang" | "open" | { failAfter: Milli } | { openAfter: Milli }; -// Their own URL: a WebSocket that wins drops WebTransport's head start for its URL afterwards. -const websocketUrl = new URL("https://example.com/websocket-only"); +// Their own host: a WebSocket that wins drops WebTransport's head start for its host and port afterwards. +const websocketUrl = new URL("https://websocket-only.example.com/"); // Stub a native WebSocketStream so each dial behaves as `plan` says, recording when it was // dialed (on the Manual clock) and how often it was closed. WebTransport is removed, as in @@ -1236,3 +1237,79 @@ test("with the default timeout, a silent WebSocket is redialed once and the atte installed.close(); } }); + +// One race on the Manual clock: WebTransport dials as `webtransport` says and the WebSocket as +// `websocket` says. Returns when each WebSocket dial started, relative to the connect. +async function raceOnce( + clock: Manual, + target: URL, + webtransport: Dial, + websocket: WebSocketDial, + props: Omit = {}, +): Promise { + // `webSocketPlan` removes WebTransport; `webTransportPlan` puts a stub back over it. + const sockets = webSocketPlan(clock, () => websocket); + const transports = webTransportPlan(clock, () => webtransport); + const start = Nano.toMilli(Nano(clock.now())); + const controller = new AbortController(); + try { + const pending = connect(target, { signal: controller.signal, ...props }); + await clock.advance(Nano.fromMilli(Milli(600))); + // A WebSocket winner is still in SETUP, which nothing answers; a WebTransport one connected. + controller.abort(); + const connection = await pending.catch(() => undefined); + connection?.abort(); + return sockets.dials.map((dial) => dial.at - start); + } finally { + transports.restore(); + sockets.restore(); + } +} + +// Each test below uses its own hosts, since the preference outlives the test. +test("a WebSocket win skips the head start for any path or query on that host and port", async () => { + const clock = new Manual(); + const installed = clock.install(); + const at = (path: string) => new URL(path, "https://preferred.example.com"); + try { + expect(await raceOnce(clock, at("/a?jwt=one"), "hang", "open")).toEqual([500]); + // Another path and a rotated token reach the same server. + expect(await raceOnce(clock, at("/b?jwt=two"), "hang", "open")).toEqual([0]); + expect(await raceOnce(clock, at("https://preferred.example.com:443/c"), "hang", "open")).toEqual([0]); + // Another port is another server. + expect(await raceOnce(clock, at("https://preferred.example.com:4443/a"), "hang", "open")).toEqual([500]); + } finally { + installed.close(); + } +}); + +test("the WebSocket preference is keyed by the server the fallback dials", async () => { + const clock = new Manual(); + const installed = clock.install(); + const websocket = { url: new URL("https://fallback.example.com/ws") }; + try { + const primary = new URL("https://primary.example.com/"); + expect(await raceOnce(clock, primary, "hang", "open", { websocket })).toEqual([500]); + // Another WebTransport server with the same fallback skips the head start. + const secondary = new URL("https://secondary.example.com/"); + expect(await raceOnce(clock, secondary, "hang", "open", { websocket })).toEqual([0]); + // Without the fallback URL, the WebSocket dials the primary, which never lost a race. + expect(await raceOnce(clock, primary, "hang", "open")).toEqual([500]); + } finally { + installed.close(); + } +}); + +test("a WebTransport win restores the head start", async () => { + const clock = new Manual(); + const installed = clock.install(); + const target = new URL("https://recovered.example.com/"); + try { + expect(await raceOnce(clock, target, "hang", "open")).toEqual([500]); + // WebSocket won last, so it dials at once, but WebTransport opens first and wins. + expect(await raceOnce(clock, target, { openWhen: sleep(Nano.fromMilli(Milli(100))) }, "hang")).toEqual([0]); + expect(await raceOnce(clock, target, "hang", "open")).toEqual([500]); + } finally { + installed.close(); + } +}); diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 62444dbd087..824c0140e4d 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -48,7 +48,8 @@ export interface WebSocketProps { /** * The delay in milliseconds before attempting the WebSocket fallback (default: 500). - * If WebSocket won the previous race for a given URL, this is 0. + * It is 0 while WebSocket holds the last win against the host and port the fallback dials, + * until WebTransport wins a race there again. */ delay?: Milli; } @@ -282,9 +283,20 @@ type SessionProps = { implementation: string; }; -// Save if WebSocket won the last race, so we won't give QUIC a head start next time. +/** + * Servers (host and port) the WebSocket fallback dials where WebSocket won the last race, so the + * next dial there skips QUIC's head start. Both transports still race, and a WebTransport win + * deletes the entry, since UDP gets through again. Keyed like moq-tokio's: whether UDP reaches a + * server does not depend on the path or query, which may hold a rotating credential. + */ const websocketWon = new Set(); +/** The {@link websocketWon} key for the URL the WebSocket fallback dials. */ +function websocketKey(url: URL): string { + const port = url.port || (url.protocol === "http:" || url.protocol === "ws:" ? "80" : "443"); + return `${url.hostname}:${port}`; +} + /** The default connect signal: never aborts. */ const NEVER_ABORTED = new AbortController().signal; @@ -387,13 +399,15 @@ async function connectInner( ? hedgeWebTransport(url, cancel, offered.alpns, props.webtransport) : undefined; - // Give QUIC a head start to connect before trying WebSocket, unless WebSocket has won in the past. - // NOTE that QUIC should be faster because it involves 1/2 fewer RTTs. + // Give QUIC a head start to connect before trying WebSocket, unless WebSocket won the last race + // to this server. NOTE that QUIC should be faster because it involves 1/2 fewer RTTs. + const websocketUrl = fallbackDial?.url ?? url; + const server = websocketKey(websocketUrl); const headstart = - !webtransport || websocketWon.has(url.toString()) ? 0 : (props.websocket?.delay ?? DEFAULT_WEBSOCKET_DELAY_MS); + !webtransport || websocketWon.has(server) ? 0 : (props.websocket?.delay ?? DEFAULT_WEBSOCKET_DELAY_MS); const websocket = props.websocket?.enabled !== false - ? connectWebSocket(fallbackDial?.url ?? url, headstart, cancel, offered.alpns) + ? connectWebSocket(websocketUrl, headstart, cancel, offered.alpns) : undefined; if (!websocket && !webtransport) { @@ -422,12 +436,13 @@ async function connectInner( // Abort the setup handshake without leaking the selected transport. void abort.then(() => session.close()); - // Save if WebSocket won the last race, so we won't give QUIC a head start next time. + // Record who won the race to this server, so QUIC's next head start follows the last result. if (session instanceof Session) { console.debug(redact(url), "connected via WebSocket"); - websocketWon.add(url.toString()); + websocketWon.add(server); } else { console.debug(redact(url), "connected via WebTransport"); + websocketWon.delete(server); } // The remaining setup is identical whether the transport was raced or supplied. diff --git a/quest/m2/performance-survey/README.md b/quest/m2/performance-survey/README.md index 08475164a41..70c09dbe0c1 100644 --- a/quest/m2/performance-survey/README.md +++ b/quest/m2/performance-survey/README.md @@ -192,7 +192,6 @@ No duplicate quest or workflow edit belongs here. - [HLS request fan-out](/quest/m2/performance-survey/hls-serving.md) - repeated fetch, remux and playlist construction per viewer - [Buffered browser groups](/quest/m2/performance-survey/hang-buffered.md) - per-frame scans and bulk skip work - [Idle browser stats](/quest/m2/performance-survey/browser-idle-stats.md) - local transport polling without active publishing demand -- [Transport preference history](/quest/m2/performance-survey/transport-history.md) - full URL retention across retired sessions - [UDP socket queues](/quest/m2/performance-survey/socket-queues.md) - drop attribution and queue-size tradeoffs across workers and load - [Session datagrams](/quest/m2/performance-survey/datagram-delivery.md) - complete per-object delivery across publishers and viewers - [Announcement snapshots](/quest/m2/performance-survey/announced-snapshots.md) - HTTP polling cost versus result size and unrelated routes diff --git a/quest/m2/performance-survey/transport-history.md b/quest/m2/performance-survey/transport-history.md deleted file mode 100644 index 581f8addded..00000000000 --- a/quest/m2/performance-survey/transport-history.md +++ /dev/null @@ -1,28 +0,0 @@ -# [M] Transport preference has a justified lifetime - -## Goal - -Connection racing retains useful fallback knowledge without accumulating -unneeded URL history for the whole page lifetime. - -## Plan - -[Connection racing](/js/net/src/connection/connect.ts) stores each WebSocket -winning full URL in module-level `websocketWon`, with no deletion. A later -WebTransport win does not clear it. Unique paths or rotating query strings -remain after pooled connections expire. Comments call this the last race, -while current behavior remembers whether WebSocket ever won. - -Sweep unique URLs × reconnects per URL, varying URL length and synthetic -query rotation. Close sessions, advance beyond pool linger and force GC in -the browser harness. Measure retained strings/heap, connection latency and -transport selection. Include WebSocket then WebTransport wins, alternate -fallback URLs and disabled fallback. Never put real credentials in reports. - -Establish the useful ownership lifetime before changing it. Do not silently -collapse endpoint-specific behavior into origin-only keys or pick an -arbitrary TTL; bring a necessary racing-policy decision to the maintainer. - -Follow the [survey benchmark contract](/quest/m2/performance-survey/README.md): -baseline first, paired evidence, equivalent work and nightly coverage. A -measured no-win is valid. No API or wire change is expected; report actual impact. From 000938997c6ad77826c1eee38951a7425c480a82 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:48:19 -0800 Subject: [PATCH 31/86] fix(net): finish closed JS readers within their range (#7486) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- js/net/src/track.test.ts | 78 +++++++++++++++++++------------ js/net/src/track.ts | 22 ++++----- quest/m1/README.md | 1 - quest/m1/js-close-range-finish.md | 13 ------ rs/moq-net/src/model/track.rs | 15 ++++-- 5 files changed, 70 insertions(+), 59 deletions(-) delete mode 100644 quest/m1/js-close-range-finish.md diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 25a2d0602a4..75377bdec6d 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -1805,6 +1805,34 @@ test("withRange with an excluded end of 0 is the empty range and does not ride a expect((await empty.recvGroup())?.sequence).toBe(2); }); +// Completion depends on unread groups inside the range, even when the cache holds more. +for (const ordered of [false, true]) { + for (const end of [0, 3, 4]) { + test(`a clean close finishes a capped ${ordered ? "ordered" : "arrival"} reader at ${end}`, () => { + const producer = new TrackProducer("test"); + const subscriber = producer.subscribe({ maxDelay: null }).withRange({ end: { excluded: end } }); + if (ordered) subscriber.ordered(); + const read = () => (ordered ? hooks.tryNextGroup(subscriber) : hooks.tryRecvGroup(subscriber, false)); + for (const sequence of [0, 2, 4]) producer.createGroup(sequence).finish(); + producer.finish(); + producer.close(); + + for (const sequence of [0, 2].filter((sequence) => sequence < end)) { + expect(read()).toMatchObject({ kind: "group", group: { sequence } }); + } + expect(read()).toEqual({ kind: "done" }); + + // Ending a read preserves the cache for a later widened range. + subscriber.withRange({}); + for (const sequence of [0, 2, 4].filter((sequence) => sequence >= end)) { + expect(read()).toMatchObject({ kind: "group", group: { sequence } }); + } + expect(read()).toEqual({ kind: "done" }); + subscriber.close(); + }); + } +} + test("withRange caps the live edge used by the latency budget", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); const track = producer.subscribe(); @@ -1820,7 +1848,7 @@ test("withRange caps the live edge used by the latency budget", async () => { }); // The frame helpers ride the group cursor, so its bounds apply to them too: a group -// above the cap parks (surviving a clean close) until the cap admits it. +// above the cap stays cached across a clean close until the cap admits it. test("withRange caps frame-level reads like the group cursor", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI, maxAge: Milli(5000) }); const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); @@ -1833,14 +1861,10 @@ test("withRange caps frame-level reads like the group cursor", async () => { expect((await track.readFrame())?.group).toBe(0); - // Group 1 parks above the cap; a clean close must not resolve it as finished. - const parked = track.readFrame(); - const timeout = sleep(Nano.fromMilli(Milli(10))).then(() => "pending" as const); - expect(await Promise.race([parked, timeout])).toBe("pending"); - - // Raising the cap releases the parked group, frames intact. + // The current range is finished; widening it still offers the cached frames. + expect(await track.readFrame()).toBeUndefined(); track.withRange({ end: { excluded: 2 } }); - expect((await parked)?.group).toBe(1); + expect((await track.readFrame())?.group).toBe(1); expect(await track.readFrame()).toBeUndefined(); }); @@ -1882,8 +1906,7 @@ test("recvGroup serves a late arrival after a newer group", async () => { expect(await track.recvGroup()).toBeUndefined(); }); -// recvGroup honors the withRange cap by parking, like nextGroup: beyond-cap groups are -// held, not dropped, and a raised cap re-offers them, even after a clean close. +// Both cursors retain beyond-cap groups for widening, even after a clean close. test("withRange parks recvGroup beyond the cap and a raised cap re-offers", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }); @@ -1897,15 +1920,13 @@ test("withRange parks recvGroup beyond the cap and a raised cap re-offers", asyn const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); - // A clean close keeps the parked group claimable: the cap may still rise. + // A clean close ends the pending read without discarding the cached group. producer.finish(); producer.close(); - expect(await Promise.race([pending, sleep(Nano.fromMilli(Milli(10))).then(() => "parked" as const)])).toBe( - "parked", - ); + expect(await pending).toBeUndefined(); track.withRange({}); - expect((await pending)?.sequence).toBe(2); + expect((await track.recvGroup())?.sequence).toBe(2); expect(await track.recvGroup()).toBeUndefined(); }); @@ -1978,23 +1999,25 @@ test("createGroup rejects a duplicate live sequence", async () => { expect(await got?.readString()).toBe("retry"); }); -// A group parked at the cap outlives a clean producer close on purpose, so the -// subscriber leaving is what must release it: close() drops the parked group and -// settles the pending read instead of leaving it hanging forever. -test("closing the subscriber releases a recvGroup parked after a clean close", async () => { +// A beyond-cap group outlives a clean close for a later widening, so the subscriber +// leaving is what releases it: widening after close() finds nothing. +test("closing the subscriber releases a group retained across a clean close", async () => { const producer = new TrackProducer("test"); - const track = producer.subscribe(); + const track = producer.subscribe({ maxDelay: Milli(5000) }); track.withRange({ end: { excluded: 1 } }); - producer.createGroup(1); + producer.createGroup(1).finish(); const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); producer.finish(); producer.close(); - track.close(); expect(await pending).toBeUndefined(); + + track.close(); + track.withRange({}); + expect(await track.recvGroup()).toBeUndefined(); }); // Same, but with the producer still live: the first close() must also clear the @@ -2076,9 +2099,7 @@ test("nextGroup returns undefined when track closes", async () => { expect(await track.nextGroup()).toBeUndefined(); }); -// Close doesn't erase buffered frames: a group parked above the cap stays readable, so -// nextGroup must keep waiting for a cap raise rather than fake an end-of-track and lose -// the data (mirrors the Rust subscriber). Only a drained closed track reports finished. +// A clean close ends a pending ordered read without erasing beyond-cap frames. test("a closed track still delivers a group parked above the cap once the cap is raised", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe({ maxDelay: Milli(5000) }).ordered(); @@ -2093,16 +2114,15 @@ test("a closed track still delivers a group parked above the cap once the cap is expect((await track.nextGroup())?.sequence).toBe(0); expect((await track.nextGroup())?.sequence).toBe(1); - // Group 2 parks above the cap; a clean close must not resolve it as finished. + // Group 2 parks while open; a clean close completes the current range. const parked = track.nextGroup(); producer.finish(); producer.close(); - const timeout = sleep(Nano.fromMilli(Milli(10))).then(() => "pending" as const); - expect(await Promise.race([parked, timeout])).toBe("pending"); + expect(await parked).toBeUndefined(); // Raising the cap after close releases the buffered group, frames intact. track.withRange({ end: { excluded: 3 } }); - const released = await parked; + const released = await track.nextGroup(); expect(released?.sequence).toBe(2); expect(await released?.readString()).toBe("frame-2"); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 00644fb663f..d26162c0a70 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -2523,6 +2523,7 @@ export class Subscriber { * Honors the range set by {@link withRange}: a group * beyond the cap stays buffered (not dropped) and is offered once the cap rises, even * after a clean close, without blocking in-range groups that arrive behind it. + * Once the track closes cleanly, a reader with nothing left in range finishes. * A group whose presentation time is further behind the live edge than this * subscriber's `maxDelay` is skipped. The default subscription's zero takes the live edge. * The budget remains attached after return, so a pending frame read rejects if a stalled @@ -2546,7 +2547,7 @@ export class Subscriber { throw recv.error; } - // Idle, or parked at the boundary waiting for the cap to rise. + // While open, wait for more groups or a cap raise. await this.#wait(); } } @@ -2625,13 +2626,12 @@ export class Subscriber { return { kind: "group", group: this.#guard(group) }; } - const group = groups[index]; const closed = this.#state.closed.peek(); if (closed instanceof Error) return { kind: "error", error: closed }; - if (closed === undefined && !this.#complete()) return { kind: "idle" }; - // A group beyond the cap outlives a clean close: it becomes deliverable if - // the cap rises, so the track isn't over while any are held. - return group ? { kind: "boundary" } : { kind: "done" }; + if (closed !== undefined) return { kind: "done" }; + if (!this.#complete()) return { kind: "idle" }; + // An open track can still widen its range. Keep the groups beyond its cap. + return groups[index] ? { kind: "boundary" } : { kind: "done" }; } // The first part of `[from, to)` this cursor has not reported yet, now reported, with why. @@ -2863,14 +2863,10 @@ export class Subscriber { const closed = this.#state.closed.peek(); if (closed instanceof Error) return { kind: "error", error: closed }; - // A group parked above the cap stays deliverable even after a clean close - // (its frames remain buffered), so keep waiting for a cap raise. Only a - // drained track reports finished. + // A clean close completes this range, retaining groups a later cap raise can read. + if (closed !== undefined) return { kind: "done" }; const end = this.#state.end.peek(); - if ( - !groups[0] && - (closed !== undefined || this.#complete() || (end !== undefined && start >= finalSequence(end))) - ) { + if (!groups[0] && (this.#complete() || (end !== undefined && start >= finalSequence(end)))) { return { kind: "done" }; } return { kind: "idle" }; diff --git a/quest/m1/README.md b/quest/m1/README.md index fc584f46f23..a77d1ff18b8 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -187,7 +187,6 @@ per-frame CPU growth since #5926. - [JS ordered reads wait only on owed groups](/quest/m1/js-ordered-owed.md) - @moq/net matches Rust's owed-group wait - [Binding announcements always carry captures](/quest/m1/ffi-required-captures.md) - moq-ffi and its generated bindings make `captures` a required list, as #7266 always sets it - [HLS export follows a resolution change](/quest/m1/hls-reinit-same-codec.md) - a decoder-identity change under the same codec string starts a new init and segment tag -- [A JS reader finishes after a clean close](/quest/m1/js-close-range-finish.md) - a range ending below the cache delivers what's held and then ends - [fMP4 decode times keep increasing](/quest/m1/fmp4-bframe-decode-time.md) - fragments shorter than a GOP of B-frame video never step `tfdt` backwards - [A truncated MKV ends its tracks at finish](/quest/m1/mkv-truncated-finish.md) - `finish()` refusing truncated input still ends the tracks, so subscribers stop waiting - [moq-quic deletes its remaining 0-RTT code](/quest/m1/quic-zero-rtt-leftovers.md) - only the code that drops a 0-RTT packet remains, with a test diff --git a/quest/m1/js-close-range-finish.md b/quest/m1/js-close-range-finish.md deleted file mode 100644 index 401705a72a0..00000000000 --- a/quest/m1/js-close-range-finish.md +++ /dev/null @@ -1,13 +0,0 @@ -# [S] A JS reader whose range ends below the cache finishes after a clean close - -## Goal - -In `@moq/net`, after a track closes cleanly, a reader whose range ends below groups the cache still holds receives those groups and then finishes, as in Rust, instead of waiting forever for the range to widen. - -## Plan - -Found by the agent on [moq-dev/moq#7349](https://github.com/moq-dev/moq/pull/7349) while writing its tests; the bug is already on main. - -Decided 2026-10-30 (maintainer, follow-up planning after the /quest-afk run). Reproduce with a test in `js/net` first, then fix the reader's end condition: once the track is closed, a range that can no longer grow is complete when every group in it is delivered or known missing. Check Rust's equivalent reader with the same scenario and add the test there too if it lacks one. - -Public API: none. Wire: none. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 288bbe11aaa..3392bdda9ab 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -13692,13 +13692,14 @@ mod test { assert!(done.is_none(), "missing groups below the boundary are not an error"); } - /// A finished track ends a sequence reader with nothing left in its range, as it ends - /// an arrival reader: whether the range ends at the cursor or the rest was given up. + /// A finished track ends both readers with nothing left in range, whether the range + /// ends at the cursor or the rest was given up. #[test] - fn ordered_ends_once_a_finished_track_leaves_nothing_in_range() { + fn readers_end_once_a_finished_track_leaves_nothing_in_range() { for given_up in [false, true] { let producer = track_producer("test", Info::timed()); let end = if given_up { 4 } else { 3 }; + let mut arrival = producer.subscribe(replay().with_range(0..end)); let mut ordered = producer.subscribe(replay().with_range(0..end)).ordered(); if given_up { producer.drop_groups(3..4, StreamError::DeliveryTimeout); @@ -13712,11 +13713,19 @@ mod test { panic!("group {sequence}, given_up={given_up}") }; assert_eq!(group.sequence, sequence); + let Poll::Ready(Ok(Some(group))) = arrival.poll_recv_group(&kio::Waiter::noop()) else { + panic!("arrival group {sequence}, given_up={given_up}") + }; + assert_eq!(group.sequence, sequence); } assert!( matches!(ordered.poll_next_group(&kio::Waiter::noop()), Poll::Ready(Ok(None))), "given_up={given_up}" ); + assert!( + matches!(arrival.poll_recv_group(&kio::Waiter::noop()), Poll::Ready(Ok(None))), + "arrival, given_up={given_up}" + ); } } From 071211f2ad1cb1d1aa5a47fbefcb17623e579af5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:48:45 -0800 Subject: [PATCH 32/86] perf(net): lite coverage checks before START cost what changed (#7499) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m2/perf/README.md | 1 - quest/m2/perf/covered-before-start.md | 20 --- rs/moq-net/Cargo.toml | 9 +- rs/moq-net/benches/publisher.rs | 39 ++++++ rs/moq-net/src/fuzz.rs | 3 + rs/moq-net/src/lite/mod.rs | 2 + rs/moq-net/src/lite/publisher.rs | 131 +++++++++++++++++++- rs/moq-net/src/model/resume.rs | 4 +- rs/moq-net/src/model/subscription.rs | 1 + rs/moq-net/src/model/subscription/window.rs | 71 +++++++++-- rs/moq-net/src/model/track.rs | 4 +- 11 files changed, 246 insertions(+), 39 deletions(-) delete mode 100644 quest/m2/perf/covered-before-start.md create mode 100644 rs/moq-net/benches/publisher.rs diff --git a/quest/m2/perf/README.md b/quest/m2/perf/README.md index 35f1970a844..42a9608cce8 100644 --- a/quest/m2/perf/README.md +++ b/quest/m2/perf/README.md @@ -98,6 +98,5 @@ revisions on the same host and worker count, and profiles with - [Mesh session cost](/quest/m2/perf/mesh-session-cost.md) - moq-net's per-session announce, route sync, and scope work drops, swept over sessions and announces - [Cluster route memory](/quest/m2/perf/cluster-route-memory.md) - a full-mesh cluster's ~3.7 KB per route per broadcast shrinks, the in-memory hop is profiled, and `bench-profile lock` tolerates lost records - [Lite request allocations](/quest/m2/perf/lite-request-allocs.md) - `Path::concat` allocates once and the lite request handler keeps one track-name copy -- [Coverage before START](/quest/m2/perf/covered-before-start.md) - the lite-07 publisher's coverage check costs O(1) per wake before SUBSCRIBE_START - [Lock-free single-delivery writes](/quest/m2/perf/single-delivery-write.md) - a frame with one delivery writes without a lock, recovering #6922's remaining FETCH cost - [Small hot-path waste](/quest/m2/perf/hot-path-waste.md) - one track-name copy per lite subscription, no full-history `#drift` walk, and one header buffer per JS object diff --git a/quest/m2/perf/covered-before-start.md b/quest/m2/perf/covered-before-start.md deleted file mode 100644 index d7348da88eb..00000000000 --- a/quest/m2/perf/covered-before-start.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Lite coverage checks before START are cheap - -## Goal - -Before SUBSCRIBE_START, the lite-07 publisher's coverage check stops -rebuilding a `RangeSet` from every skipped range and rescanning the covered -runs on each wake. A wake costs work proportional to what changed since the -last one, not to the skipped record's size. - -## Plan - -Found by [moq-dev/moq#6995](https://github.com/moq-dev/moq/pull/6995), which -made `covered` incremental after the start resolves. `self.skipped` is already -updated incrementally; the cost is the set rebuilt from it and the scan in -`track::Subscriber::covered`. Keep an incremental coverage summary or cursor -across wakes instead. `track_range_filling` never builds a `TrackRun` or -reaches this branch, so add a benchmark that drives the lite publisher with -no start resolved, swept over skipped ranges. - -Public API: none. diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 405dc55c83e..fb55be30215 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -18,7 +18,8 @@ default = ["async"] async = ["dep:futures", "kio/async", "kio/runtime"] # Exposes the wire codecs to the `fuzz/` harness and the announce, codec, path, varint, frame, and canonical storage benches, -# and session views of an origin to the viewers bench, through the hidden `fuzz` module. Off by +# session views of an origin to the viewers bench, and the lite publisher's run loop to the +# publisher bench, through the hidden `fuzz` module. Off by # default and hidden from the docs. fuzz = ["async"] @@ -143,6 +144,12 @@ name = "tail" harness = false required-features = ["fuzz"] +# Drives the lite publisher's run loop through the hidden `fuzz` module. +[[bench]] +name = "publisher" +harness = false +required-features = ["fuzz"] + [[bench]] name = "priority" harness = false diff --git a/rs/moq-net/benches/publisher.rs b/rs/moq-net/benches/publisher.rs new file mode 100644 index 00000000000..8babc54acc2 --- /dev/null +++ b/rs/moq-net/benches/publisher.rs @@ -0,0 +1,39 @@ +//! The lite publisher's coverage check before SUBSCRIBE_START. +//! +//! `publisher_covered_before_start` holds a bounded lite-07 subscription to `RANGES` ranges +//! whose source gave up `skipped` of them and has yet to resolve a start, then wakes it with +//! nothing new to serve. Each wake ends in the check of whether every range was given up. +//! The range count stays fixed, so a cost that grows with the ranges already given up shows +//! up as a slope over `skipped`. `adjacent` gives up the groups of one range instead, each +//! with a different error from its neighbor, so the record holds one entry per group. +//! +//! Run with `cargo bench -p moq-net --features fuzz --bench publisher`. + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_net::fuzz::CoverageBench; + +/// Ranges the source gave up before the wake. +const SKIPPED: [u64; 4] = [1, 32, 1024, 8192]; + +/// Ranges subscribed: one more than the most given up, so one stays open. +const RANGES: u64 = 8193; + +fn bench_covered_before_start(c: &mut Criterion) { + let mut group = c.benchmark_group("publisher_covered_before_start"); + group.throughput(Throughput::Elements(1)); + for skipped in SKIPPED { + group.bench_function(BenchmarkId::new("wake", skipped), |b| { + let mut run = CoverageBench::new(RANGES, skipped); + b.iter(|| run.wake()); + }); + // The same record within one range, each group its own entry. + group.bench_function(BenchmarkId::new("adjacent", skipped), |b| { + let mut run = CoverageBench::adjacent(skipped); + b.iter(|| run.wake()); + }); + } + group.finish(); +} + +criterion_group!(benches, bench_covered_before_start); +criterion_main!(benches); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index c223068bb1b..0e2f00310aa 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -16,6 +16,9 @@ mod request; pub use ingest::ingest; pub use request::request; +/// A lite-07 publisher run loop waiting on SUBSCRIBE_START, for the publisher bench. +pub use crate::lite::CoverageBench; + use bytes::Bytes; use crate::{ diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index 651b06abca6..97cd9aae167 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -30,6 +30,8 @@ pub(crate) mod priority; mod probe; #[cfg(feature = "async")] mod publisher; +#[cfg(all(feature = "async", any(test, feature = "fuzz")))] +pub use publisher::CoverageBench; #[cfg(feature = "async")] mod replay; #[cfg(feature = "async")] diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 1393f68957c..28c335ade3a 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -4334,10 +4334,7 @@ impl TrackRun { // `SubStream::owed`), so only a range given up whole is covered. Datagrams // resolve no start, and a FIN would overtake them, so a range they served // keeps the subscription open. - (None, _) => { - let skipped: RangeSet = self.skipped.iter().map(|(range, _)| range.clone()).collect(); - self.track.covered(self.floor, &skipped) - } + (None, _) => self.track.covered(self.floor, &self.skipped), (Some(_), None) => false, } } @@ -4568,6 +4565,112 @@ impl TrackRun { } } +/// A bounded lite-07 subscription's run loop still waiting on SUBSCRIBE_START, for the +/// `publisher` bench: a wake that finds nothing new ends in the coverage check. +#[cfg(any(test, feature = "fuzz"))] +pub struct CoverageBench { + run: TrackRun, + writer: Writer, + track: track::Producer, + _peer: crate::transport::memory::Session, +} + +#[cfg(any(test, feature = "fuzz"))] +impl CoverageBench { + /// Subscribe to `ranges` one-group ranges, then give up the first `skipped` of them, one + /// wake each. A range is left open, so the start never resolves and nothing is covered. + pub fn new(ranges: u64, skipped: u64) -> Self { + assert!(skipped < ranges, "a range must stay open"); + // Every other group, so no two ranges merge into one run. + let wire = (0..ranges).map(|range| 2 * range..2 * range + 1); + let drops = (0..skipped) + .map(|range| (2 * range..2 * range + 1, StreamError::NotFound)) + .collect(); + Self::build(wire, drops) + } + + /// Subscribe to one range of `skipped + 1` groups, then give up all but the last, one + /// wake each. Alternating errors keep each group its own entry in the skipped record. + pub fn adjacent(skipped: u64) -> Self { + let drops = (0..skipped) + .map(|group| { + let err = match group % 2 { + 0 => StreamError::NotFound, + _ => StreamError::Cancel, + }; + (group..group + 1, err) + }) + .collect(); + Self::build(std::iter::once(0..skipped + 1), drops) + } + + fn build(wire: impl Iterator>, drops: Vec<(Range, StreamError)>) -> Self { + let version = Version::Lite07; + let (local, peer) = crate::transport::memory::pair(Default::default()); + let session = Transport::new(local); + let Poll::Ready(Ok(stream)) = session.poll_open_uni(&kio::Waiter::noop(), SendOptions::default()) else { + panic!("a memory stream opens at once"); + }; + let group = |group| Some(lite::Anchor::Absolute(track::Position::group(group))); + let wire: Vec<_> = wire + .map(|range| lite::Range { + start: group(range.start), + end: group(range.end), + }) + .collect(); + let bounds = Bounds::new(&wire, None).unwrap(); + let track = track::Producer::new( + Arc::new(crate::broadcast::Info::default()), + "bench", + track::Info::default(), + ); + let subscriber = track.subscribe(bounds.groups()); + let steer = kio::Producer::new(Steer::default()); + let ctx = Subscription { + session, + id: 0, + track_name: "bench".into(), + priority: PriorityQueue::default(), + publisher: 0, + group: None, + steer: steer.consume(), + steer_seen: Steer::default(), + version, + stamp: None, + runtime: crate::time::Deadlines::new(kio::clock::now()), + outcomes: Default::default(), + ietf: false, + }; + let mut this = Self { + run: TrackRun::new(ctx, subscriber, bounds, 0, steer), + writer: Writer::new(stream, version), + track, + _peer: peer, + }; + let skipped = drops.len(); + for (groups, err) in drops { + this.track.drop_groups(groups, err); + while this.step().is_ready() {} + } + assert!(this.run.start.is_none(), "no group resolves a start"); + assert_eq!(this.run.skipped.iter().count(), skipped, "each drop is its own entry"); + this + } + + /// One wake with nothing new to serve. + pub fn wake(&mut self) { + assert!(self.step().is_pending(), "the open range keeps the subscription parked"); + } + + fn step(&mut self) -> Poll<()> { + match self.run.poll_step(&mut self.writer, &kio::Waiter::noop()) { + Poll::Ready(Ok(ControlFlow::Continue(()))) => Poll::Ready(()), + Poll::Ready(res) => panic!("the subscription ended: {res:?}"), + Poll::Pending => Poll::Pending, + } + } +} + /// Where a group stream is ranked. enum Rank { /// In the subscription's send group, by its sequence in the subscription's order. @@ -6941,6 +7044,26 @@ mod serve_group_test { } } + /// A bounded lite-07 subscription ends once its source gives up every range, even with no + /// start resolved, and not while one range is still open. + #[kio::test] + async fn giving_up_every_range_before_the_start_ends_it() { + // Disjoint ranges, then one range given up a group at a time with alternating errors. + for (mut bench, last) in [(CoverageBench::new(3, 2), 4), (CoverageBench::adjacent(4), 4)] { + bench.wake(); + bench.wake(); + bench.track.drop_groups(last..last + 1, StreamError::NotFound); + loop { + match bench.run.poll_step(&mut bench.writer, &kio::Waiter::noop()) { + Poll::Ready(Ok(ControlFlow::Continue(()))) => continue, + Poll::Ready(Ok(ControlFlow::Break(()))) => break, + other => panic!("the subscription should end, got {other:?}"), + } + } + assert!(bench.run.start.is_none()); + } + } + /// A SUBSCRIBE_UPDATE that lowers the start below SUBSCRIBE_START asks for those groups /// too, so the drops at the end count from there. #[kio::test] diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 7419d9fb8a2..c5a05bdedc9 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -51,7 +51,7 @@ use crate::time::Instant; use crate::{Error, Result, StreamError, group, stats, track}; use super::delivered::Delivered; -use super::subscription::{Anchor, Position, Resolved, Subscription, Unheld, Window}; +use super::subscription::{Accounted, Anchor, Position, Resolved, Subscription, Unheld, Window}; use crate::ledger; /// What the front says about a logical track: the copy to read now, and how it ended. @@ -797,7 +797,7 @@ impl Subscriber { /// Whether `accounted` holds every group the reader's bounded ranges name from `from` /// up to `cap`; see [`track::Subscriber::covered`]. - pub(super) fn covered(&self, from: u64, cap: Option, accounted: &RangeSet) -> bool { + pub(super) fn covered(&self, from: u64, cap: Option, accounted: &impl Accounted) -> bool { let mut reader = self.reader(); reader.window(); reader.window.covered_by(from, cap, accounted) diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index c75b4945d55..addaeb24cdd 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -6,6 +6,7 @@ use std::{ use crate::Timestamp; mod window; +pub(crate) use window::Accounted; pub(super) use window::{Containing, Held, Holdings, Resolved, Unheld, Window, later}; /// Subscriber-side preferences for receiving a track. diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 4a237cefe92..48683800611 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -19,6 +19,25 @@ pub(in crate::model) struct Resolved { pub end: Option, } +/// The groups a publisher accounted for, which [`Window::covered_by`] checks against: a +/// set, or a map keyed by them, read in place so a check copies nothing. +pub(crate) trait Accounted { + /// The first group in `range` not accounted for, `None` when every one is. + fn first_gap(&self, range: &ops::Range) -> Option; +} + +impl Accounted for rangemap::RangeSet { + fn first_gap(&self, range: &ops::Range) -> Option { + self.gaps(range).next().map(|gap| gap.start) + } +} + +impl Accounted for rangemap::RangeMap { + fn first_gap(&self, range: &ops::Range) -> Option { + self.gaps(range).next().map(|gap| gap.start) + } +} + /// When a serving hop's groups start, for resolving time anchors. /// /// Each query answers from ordered indexes, so a time end that walks them forward a step @@ -510,11 +529,12 @@ impl Window { /// Whether `accounted` holds every group in range from `from` up to the exclusive `cap`, /// `false` while a range stays open (see [`Self::end`]). /// - /// The caller only grows `accounted` between checks, so each resumes where the last - /// stopped unless `from` fell below its floor: one made on every wake visits each range - /// once over the subscription, not once per wake. The last stop never passes its cap, so - /// a wider one is checked past it anyway. - pub fn covered_by(&mut self, from: u64, cap: Option, accounted: &rangemap::RangeSet) -> bool { + /// The caller only grows `accounted` between checks, so each resumes at the first group + /// the last found missing unless `from` fell below its floor: one made on every wake + /// visits each accounted entry once over the subscription, not once per wake, even + /// within one range. The last stop never passes its cap, so a wider one is checked past + /// it anyway. + pub fn covered_by(&mut self, from: u64, cap: Option, accounted: &impl Accounted) -> bool { let Some(end) = self.end() else { return false; }; @@ -525,11 +545,16 @@ impl Window { }; let covered = self.runs(through).take_while(|run| *run.start() < end).all(|run| { let run_end = run.end().checked_add(1).map_or(end, |next| next.min(end)); - let whole = accounted.gaps(&(*run.start()..run_end)).next().is_none(); - if whole { - through = run_end; + match accounted.first_gap(&(*run.start()..run_end)) { + None => { + through = run_end; + true + } + Some(gap) => { + through = gap; + false + } } - whole }); self.covered = Some((from, through)); covered @@ -1144,6 +1169,34 @@ mod tests { assert!(window.covered_by(1, Some(5), &accounted), "a raised floor resumes"); } + /// A check that stops partway into a range resumes there, and still sees a gap filled + /// behind where it stopped. + #[test] + fn coverage_resumes_within_a_range() { + let mut window = Window::new(&[Range::groups(0..8)], None, &Untimed).unwrap(); + let mut accounted = rangemap::RangeSet::from_iter(std::iter::once(0..3)); + assert!(!window.covered_by(0, None, &accounted)); + assert_eq!(window.covered, Some((0, 3)), "stops at the first missing group"); + accounted.insert(3..5); + accounted.insert(6..8); + assert!(!window.covered_by(0, None, &accounted)); + assert_eq!(window.covered, Some((0, 5))); + accounted.insert(5..6); + assert!(window.covered_by(0, None, &accounted)); + } + + /// A map keyed by the groups accounted for counts as a set of them, whatever its values. + #[test] + fn coverage_reads_a_map_of_the_groups_accounted_for() { + let mut window = Window::new(&[Range::groups(0..2), Range::groups(4..6)], None, &Untimed).unwrap(); + let mut accounted = rangemap::RangeMap::new(); + accounted.insert(0..2, 'a'); + accounted.insert(4..5, 'b'); + assert!(!window.covered_by(0, None, &accounted), "group 5 is owed"); + accounted.insert(5..6, 'a'); + assert!(window.covered_by(0, None, &accounted)); + } + #[test] fn an_untimed_track_refuses_time_anchors() { assert!(matches!( diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 3392bdda9ab..88ff29b4c7f 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -22,8 +22,8 @@ use super::delivered::Delivered; use super::skipped::Skipped; use crate::ledger::{self, Ledger}; +use super::subscription::{Accounted, Containing, Held, Holdings, Resolved, Window, later}; pub use super::subscription::{Anchor, Order, Position, Range, Subscription}; -use super::subscription::{Containing, Held, Holdings, Resolved, Window, later}; use rangemap::{RangeInclusiveSet, RangeMap, RangeSet}; mod demand; @@ -7854,7 +7854,7 @@ impl Subscriber { /// A publisher that accounts for each group it serves or drops learns from it that a /// bounded subscription has nothing more to deliver. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn covered(&mut self, from: u64, accounted: &rangemap::RangeSet) -> bool { + pub(crate) fn covered(&mut self, from: u64, accounted: &impl Accounted) -> bool { let bounds = self.bounds(); let from = from.max(bounds.start); match &mut self.inner { From a546cac082fdf6c8e12c924755e95b585adbb962 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:49:06 -0800 Subject: [PATCH 33/86] A quick finish keeps its pre-draft-22 join (#7306) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot --- quest/m2/README.md | 1 - quest/m2/audit-ci.md | 6 +- quest/m2/ietf-fill-refill.md | 2 +- quest/m2/ietf-finish.md | 8 +- quest/m2/ietf-join-after-finish.md | 84 ---- quest/m2/ietf-js-joining-fetch.md | 12 +- rs/moq-net/src/ietf/publisher.rs | 477 ++++++++++++++++++++- rs/moq-net/src/lite/test_transport.rs | 5 + rs/moq-net/tests/integration/track_tail.rs | 57 +++ 9 files changed, 534 insertions(+), 118 deletions(-) delete mode 100644 quest/m2/ietf-join-after-finish.md diff --git a/quest/m2/README.md b/quest/m2/README.md index 19b24ca7d95..331c25308bf 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -198,7 +198,6 @@ lite-07; bitrate enforcement is not part of this release. - [Auth session caps cost O(1)](/quest/m2/auth-session-index.md) - per-token and per-remote counts and a time-ordered expiry queue replace the scans, after #7211 - [H.264 parsing with maintained crates](/quest/m2/h264-reader.md) - moq-mux parses SPS VUI with h264-reader and shares one bit reader across codec config parsers - [moq-transport finish interop](/quest/m2/ietf-finish.md) - the interop `finish` case runs on every even moq-transport draft instead of being skipped -- [Join after a quick finish](/quest/m2/ietf-join-after-finish.md) - a pre-draft-22 joining FETCH that arrives after its subscription finished is still answered, within a bound the peer already negotiates - [JS joining FETCH](/quest/m2/ietf-js-joining-fetch.md) - the JS publisher answers a joining FETCH that arrives after a quick finish, mirroring the Rust bound - [ashpd uses maintained D-Bus crates](/quest/m2/ashpd-maintained.md) - wait for a stable zbus 6 port, then remove the portal dependency exceptions - [Askama uses a maintained TOML parser](/quest/m2/askama-maintained.md) - wait for a stable parser migration, then remove the generator dependency exception diff --git a/quest/m2/audit-ci.md b/quest/m2/audit-ci.md index 76c59213adf..844ab887f52 100644 --- a/quest/m2/audit-ci.md +++ b/quest/m2/audit-ci.md @@ -66,7 +66,7 @@ verdicts: reads a published broadcast` (`remote error: code=0`). Bisected to [moq-dev/moq#6348](https://github.com/moq-dev/moq/pull/6348): the harness finishes its track before the pre-draft-22 joining FETCH arrives, which - [join after a quick finish](/quest/m2/ietf-join-after-finish.md) owns. + [moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306) fixes on the Rust publisher. - Audio quality: two Chromium Opus `wide` rows exceed their `target_ms_p95` budget. The [audio jitter target](/quest/m1/audio-jitter-target/README.md) owns the budgets. @@ -89,8 +89,8 @@ verdicts: [#6317](https://github.com/moq-dev/moq/pull/6317)). The moq-mux large-enum and moq-net minimal-feature lints pass on main `f42b336f0e`. - Interop's finite-track tails fail before draft 22 (`Error: old`, a JS - `RESET_STREAM`); [join after a quick finish](/quest/m2/ietf-join-after-finish.md) - owns them. TS fails T-STD overflow and late audio starts; the TS reliability + `RESET_STREAM`); [moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306) fixes the Rust publisher's + half, and [JS joining FETCH](/quest/m2/ietf-js-joining-fetch.md) owns the JS one. TS fails T-STD overflow and late audio starts; the TS reliability quests own them. The relay-log `FAIL` lines in Interop's self-test are negative controls, not failures. - Infrastructure flakes in the sample: DNS failures installing Nix diff --git a/quest/m2/ietf-fill-refill.md b/quest/m2/ietf-fill-refill.md index c193dbbcb84..c8af7d782fd 100644 --- a/quest/m2/ietf-fill-refill.md +++ b/quest/m2/ietf-fill-refill.md @@ -21,7 +21,7 @@ Group stitching across two streams drew repeated fixes: [#6348](https://github.com/moq-dev/moq/pull/6348), [#6407](https://github.com/moq-dev/moq/pull/6407), plus [#7322](https://github.com/moq-dev/moq/pull/7322) (headless groups) and -[join after finish](/quest/m2/ietf-join-after-finish.md). The `Fill` enum +join after finish ([moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306)). The `Fill` enum and its handoff (`rs/moq-net/src/ietf/subscriber.rs` about lines 273-396 and 4712-4862) exist only because the model has one producer per group. diff --git a/quest/m2/ietf-finish.md b/quest/m2/ietf-finish.md index 9c214702090..9109cbd1bcd 100644 --- a/quest/m2/ietf-finish.md +++ b/quest/m2/ietf-finish.md @@ -17,8 +17,8 @@ Observed on 2026-10-22 with `just test interop --finish --all --versions ietf` - Draft 18: late readers see the finished catalog end empty, a short tail, or a refused joining FETCH (`publish done: internal error`). This is the pre-draft-22 join after a finish, which - [join after a quick finish](/quest/m2/ietf-join-after-finish.md) fixes; rerun - these drafts once it lands. + [moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306) fixes for Rust publishers; rerun + these drafts. - Draft 22: several readers time out after the withdrawal (C and C++ on the health broadcast), JS readers see `publish done: internal error`, and native JS refuses a "malformed request stream message" from the relay. @@ -29,7 +29,3 @@ every reader's acknowledgement, so judge each cell from its reader's log. Public API: none. Wire: none expected; any fix that changes framing updates its draft. - -## Required - -- [Join after a quick finish](/quest/m2/ietf-join-after-finish.md) - the pre-draft-22 half of this, without which draft 18 cannot pass diff --git a/quest/m2/ietf-join-after-finish.md b/quest/m2/ietf-join-after-finish.md deleted file mode 100644 index 94ca706ed1b..00000000000 --- a/quest/m2/ietf-join-after-finish.md +++ /dev/null @@ -1,84 +0,0 @@ -# [M] A quick finish keeps its pre-draft-22 join - -## Goal - -On draft 18, a joining FETCH that reaches our publisher after its -subscription finished cleanly is still answered from the subscription's -saved Largest group. A track that ends within about one RTT of SUBSCRIBE_OK -then finishes cleanly at the subscriber, head included, instead of losing -its last group. -What the publisher retains for this is bounded by design (the session, the -subscription's request, or a limit the peer already negotiates), never by a -timer constant. - -## Plan - -Every pre-draft-22 join is a SUBSCRIBE followed, after SUBSCRIBE_OK, by a -`RelativeJoining{0}` FETCH ([moq-dev/moq#6348](https://github.com/moq-dev/moq/pull/6348)). -A refused one costs the subscriber that group's head and nothing more -([moq-dev/moq#6407](https://github.com/moq-dev/moq/pull/6407), decided with -the maintainer on 2026-10-22): a stream starting mid-group is dropped and -the subscription goes on. - -State: `rs/moq-net/src/ietf/publisher.rs` registers a `Join` entry in -`Publisher::joins` when it dispatches the SUBSCRIBE, fills it with a `Joined` -snapshot as SUBSCRIBE_OK goes out, and drops it when the subscription ends, -before PUBLISH_DONE. A joining FETCH that arrives later finds no entry, waits -out the registration wait in `joined()`, and is refused as an unknown -subscription, so the subscriber loses the Largest group. Short or -already-finished tracks hit this often. - -It flakes the pinned-pass interop `tail` cells with a Rust publisher, about -one run in six (seen 2026-10-22 while landing -[moq-dev/moq#6407](https://github.com/moq-dev/moq/pull/6407)): the tail test -finishes its track before SUBSCRIBE_OK, so PUBLISH_DONE follows within -milliseconds. A FETCH that already had FETCH_OK being reset with its -subscription is not the cause: it has finished since -[moq-dev/moq#6999](https://github.com/moq-dev/moq/pull/6999), and neither -lifetime failed in six runs of the draft-18 Rust-publisher `tail` cells on -2026-10-28. The relay's settle grace (about a second) ends long before the -10 s registration wait refuses the FETCH, and the empty subscription stream -for the Largest group then becomes an empty group, which `rust -> rust` and -`rust -> js-native-*` read as "empty group". - -Fix it at the publisher: the subscriber cannot recover a head nobody serves, -and other publishers may refuse the same way. - -A finished entry holds a `track::Consumer`, which keeps the track's cache -alive, so retaining it costs memory a peer controls. Candidate bounds: - -- **The request.** Draft 18 gives each SUBSCRIBE its own request stream; - keep the entry until the subscriber closes its side, with our subscriber - holding it open until its joining FETCH resolves. A peer that never sends - the FETCH costs no more than an open subscription, which stream credit - already bounds. -- **The session alone.** Grows with every subscription a long-lived relay - session ever makes, so probably not bounded enough. - -Recommendation: the request close. Keep only entries that can be answered (`Joined::Group`); an -unsubscribed or refused subscription still drops its entry at once. The -entry must not outlive what authorized the subscription: when its request -token's grant expires or is revoked, or the session grant no longer covers -the track, drop it, since a late FETCH reads the cache on that authority. A head -evicted from the cache meanwhile stays a loud refusal. The existing wait in -`joined()` bounds a FETCH whose subscription has not arrived yet; do not -stretch it to cover this. - -Tests, on mocked time with no sleeps, on draft 18: - -- A track that finishes right after SUBSCRIBE_OK, with the joining FETCH - delivered an RTT after PUBLISH_DONE: the FETCH returns the Largest group's - head and the subscriber's track finishes cleanly with it. Today the FETCH - is refused and the group is lost. -- Closing the subscription's request drops its entry. -- A request token that expires (by mocked time) or is revoked after the - finish drops the entry, and the late FETCH is refused. -- An unsubscribed subscription's FETCH is refused, and session close frees - every entry. - -Public API: none. Wire: none; a late joining FETCH is answered where it was -refused. - -## Related - -- [JS joining FETCH](/quest/m2/ietf-js-joining-fetch.md) - the JS publisher mirrors the bound settled here diff --git a/quest/m2/ietf-js-joining-fetch.md b/quest/m2/ietf-js-joining-fetch.md index e8965b687a0..18d9ed2a675 100644 --- a/quest/m2/ietf-js-joining-fetch.md +++ b/quest/m2/ietf-js-joining-fetch.md @@ -16,9 +16,11 @@ in m2. A FETCH that resolves before PUBLISH_DONE already finishes its head. In the tail test the track is finished before SUBSCRIBE_OK goes out, so PUBLISH_DONE follows at once and the FETCH arrives to no subscription. Keep a -finished subscription's join answerable under the bound -[Join after a quick finish](/quest/m2/ietf-join-after-finish.md) settles for -Rust, and mirror it on the joinable-SUBSCRIBE registry js-fetch adds. +finished subscription's join answerable under the bound Rust settled in +[moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306), and mirror it on the joinable-SUBSCRIBE registry js-fetch adds: +only a track that ended cleanly keeps its join, until the subscriber closes the +SUBSCRIBE's request stream, the session closes, or the request token's grant +or the session grant stops covering the track. Tests: a unit test for a FETCH that arrives after PUBLISH_DONE, the bound's refusals mirrored from Rust, and the tail cells @@ -27,7 +29,3 @@ plus the browser publisher. Public API: none. Wire: none; a late joining FETCH is answered where it was refused. - -## Required - -- [Join after a quick finish](/quest/m2/ietf-join-after-finish.md) - settles the bound this reuses diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 03ad691880e..42a3a59d8fb 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -891,7 +891,8 @@ enum Joined { }, } -/// One subscription's entry in [`Publisher::joins`], removed when the subscription ends. +/// One subscription's entry in [`Publisher::joins`], removed when the subscription ends, or +/// for a track that ended cleanly, once its [`Retain`] lets go. /// /// A fetch that arrives afterwards then finds nothing and is refused, rather than being /// answered from a group that is no longer the edge of anything. @@ -900,6 +901,44 @@ struct Join { request_id: RequestId, } +impl Join { + /// Whether a joining FETCH would be answered from this entry rather than refused. + fn answers(&self) -> bool { + matches!( + self.joins.read().get(&self.request_id), + Some(Some(Joined::Group { .. })) + ) + } +} + +/// A cleanly finished draft-18 subscription's join, kept past PUBLISH_DONE. +/// +/// The subscriber sends its joining FETCH once SUBSCRIBE_OK arrives, so on a track that ends +/// within about an RTT of it the FETCH reaches us after PUBLISH_DONE, and would lose the +/// Largest group's head. The entry holds the track's cache, which a peer controls, so it is +/// bounded by the request: kept until the subscriber closes its side (ours holds it open until +/// the FETCH's head lands or its settle grace ends), and no longer than what authorized the +/// subscription, since a late FETCH reads the cache on that authority. The open request still takes its stream +/// credit and subscription slot meanwhile. +struct Retain { + _join: Join>, + grant: Option, + gate: crate::auth::Gate, + _slot: crate::auth::Hold, +} + +impl Retain { + /// Ready once what authorized the subscription no longer covers it. + fn poll_lapsed(&mut self, waiter: &kio::Waiter) -> Poll<()> { + if let Some(grant) = &self.grant + && grant.poll_ended(waiter).is_ready() + { + return Poll::Ready(()); + } + self.gate.poll_denied(waiter) + } +} + impl Drop for Join { fn drop(&mut self) { self.joins.lock().remove(&self.request_id); @@ -1359,6 +1398,8 @@ impl Publisher { // releasing what it holds upstream, and only then is the peer told. `acked` holds the // group stream count once SUBSCRIBE_OK is buffered, which decides how. let mut acked: Option> = None; + // Set once the track ran to its end, which is all that leaves a join worth keeping. + let mut track_ended = false; let rendezvous = msg.rendezvous; let subscribed = { let serve = async { @@ -1551,6 +1592,7 @@ impl Publisher { let served = served.unwrap_or(Served::Track(Ok(()))); if completed && matches!(served, Served::Track(Ok(()))) { + track_ended = true; self.write_end_of_track(&mut stream, &mut track_serve, &mut request_finished, &mut goaways) .await; } @@ -1578,10 +1620,24 @@ impl Publisher { }) .await }; - // A FETCH joining this subscription is refused if it has not answered yet. - drop(_join); - // The subscription's demand is gone; only its credit may still wait on a verification. - drop((request_grant, _slot)); + // A track that ended cleanly keeps what a joining FETCH needs (see [`Retain`]); any + // other end drops the join, refusing a joining FETCH that has not answered yet. + let retain = match (&subscribed, _join) { + (Ok(Ok(Subscribed::Served(Served::Track(Ok(())), _))), Some(join)) if track_ended && join.answers() => { + Some(Retain { + _join: join, + grant: request_grant, + gate, + _slot, + }) + } + _ => { + // The subscription's demand is gone; only its credit may still wait on a + // verification. + drop((request_grant, _slot)); + None + } + }; let (served, streams) = match subscribed { Ok(Ok(Subscribed::Served(Served::RenewalRefused(refusal), streams))) => { @@ -1610,7 +1666,8 @@ impl Publisher { } }, }; - self.write_publish_done(stream, served, streams, Some(&credit)).await + self.write_publish_done(stream, served, streams, Some(&credit), retain) + .await } } @@ -1693,13 +1750,14 @@ impl Publisher { /// End a subscription on the wire with the PUBLISH_DONE `served` reports, after `streams` /// group streams, returning how serving ended. The stream closes only once no verification - /// holds `credit`'s request, if it has one. + /// holds `credit`'s request, if it has one, and once `retain` lets go of its join. async fn write_publish_done( &self, mut stream: Stream, served: Served, streams: u64, credit: Option<&kio::Producer<()>>, + retain: Option, ) -> Result<(), Error> { let (status, reason) = served.status(); let res = match served { @@ -1709,14 +1767,31 @@ impl Publisher { Served::Paused(_) => Ok(()), }; - let _ = stream - .writer - .encode_message(&ietf::PublishDone { + let mut retain = retain; + { + let done = ietf::PublishDone { status_code: status.code(), stream_count: streams, reason_phrase: reason.into(), + }; + // A peer holding back flow control parks PUBLISH_DONE, and the kept join must still + // go once the request closes or its authorization lapses meanwhile. + let mut encode = std::pin::pin!(stream.writer.encode_message(&done)); + let reader = &mut stream.reader; + let _ = kio::wait(|waiter| { + if let Some(kept) = &mut retain + && (reader.poll_discard(waiter).is_ready() || kept.poll_lapsed(waiter).is_ready()) + { + retain = None; + } + waiter.poll_future(encode.as_mut()) }) .await; + } + + if let Some(retain) = retain { + self.retain_join(&mut stream, retain).await; + } // The peer learns the subscription ended at once, but the stream holds its credit until // the application lets go of every token it took for it. @@ -1729,6 +1804,24 @@ impl Publisher { res } + /// Keep a finished subscription's join until the subscriber closes the request, the + /// session closes, or what authorized the subscription stops covering it. + async fn retain_join(&self, stream: &mut Stream, mut retain: Retain) { + let session = self.session.clone(); + kio::wait(|waiter| { + if session.poll_closed(waiter).is_ready() || stream.writer.poll_closed(waiter).is_ready() { + return Poll::Ready(()); + } + // An update that crossed PUBLISH_DONE is skipped, since nothing answers it now; the + // subscriber's FIN or a reset ends the request. + if stream.reader.poll_discard(waiter).is_ready() { + return Poll::Ready(()); + } + retain.poll_lapsed(waiter) + }) + .await; + } + async fn run_subscription( &self, stream: &mut Stream, @@ -6766,7 +6859,7 @@ mod serve_tests { use super::*; use crate::coding::{Decode, Decoder}; use crate::ietf::GroupOrder; - use crate::lite::test_transport::{Log, ScriptedSession, Sent, SinkSession}; + use crate::lite::test_transport::{Close, Log, ScriptedSession, Sent, SinkSession}; use crate::model::ProduceTest; #[kio::test] @@ -8449,7 +8542,25 @@ mod serve_tests { } h.track.finish().unwrap(); - serve.await.unwrap(); + served(h, serve).await; + h.session.reopen(); + } + + /// Drive `serving` to a clean end once its track has finished. A draft-18 subscription + /// whose track ended keeps its join until the subscriber closes the request, so close it + /// once PUBLISH_DONE is out. + async fn served(h: &Serve, mut serving: std::pin::Pin<&mut impl Future>>) { + while futures::poll!(serving.as_mut()).map(Result::unwrap).is_pending() { + if publish_done_sent(h) { + h.session.close(Close::Fin); + } + kio::clock::sim::yield_now().await; + } + } + + /// Whether the subscription's request stream carries PUBLISH_DONE yet. + fn publish_done_sent(h: &Serve) -> bool { + !h.log.trail().is_empty() && Wire::read(&h.log, h.publisher.version).publish_done.is_some() } /// A subscribe for a broadcast we do not serve is refused with the negotiated draft's own @@ -9742,8 +9853,13 @@ mod serve_tests { /// Drive a subscription until its snapshot is available, failing if it ends first. async fn registered(h: &Serve, serving: impl std::future::Future>) { + registered_as(h, RequestId(REQUEST_ID), serving).await + } + + /// [`registered`], for the subscription with request id `id`. + async fn registered_as(h: &Serve, id: RequestId, serving: impl std::future::Future>) { let joined = h.publisher.joins.wait(|joins| { - if matches!(joins.get(&RequestId(REQUEST_ID)), Some(Some(_))) { + if matches!(joins.get(&id), Some(Some(_))) { Poll::Ready(()) } else { Poll::Pending @@ -9814,7 +9930,7 @@ mod serve_tests { } frame.finish().unwrap().finish().unwrap(); h.track.finish().unwrap(); - serving.await.unwrap(); + served(&h, serving.as_mut()).await; assert_eq!(occurrences(&h.log, payload), 1, "{version} streamed={streamed}"); assert!(h.log.resets().is_empty(), "{version} streamed={streamed}"); @@ -10158,8 +10274,9 @@ mod serve_tests { assert!(h.log.resets().is_empty()); } - /// A joining FETCH that arrives after its subscription ended has no live edge to name, so - /// it is refused rather than answered from a group that is no longer the edge of anything. + /// A joining FETCH that arrives after its subscription ended and the subscriber closed the + /// request has no live edge to name, so it is refused rather than answered from a group + /// that is no longer the edge of anything. #[kio::test] async fn a_joining_fetch_without_its_subscription_is_refused() { for version in JOINING_DRAFTS { @@ -10182,6 +10299,334 @@ mod serve_tests { } } + /// The refusal code of the REQUEST_ERROR a joining FETCH is answered with, failing on a + /// FETCH_OK. + async fn joining_refusal(h: &Serve, version: Version) -> u64 { + let mark = h.log.writes.lock().unwrap().len(); + let mut buf = bytes::Bytes::from(joining_fetch(h, mark).await.unwrap()); + assert_eq!( + crate::coding::decode_varint(&mut buf, version).unwrap(), + ietf::RequestError::ID, + "{version}: the joining FETCH was answered" + ); + crate::coding::decode_buf(&mut buf, version, |r, v| { + Ok(ietf::RequestError::decode(r, v)?.error_code) + }) + .unwrap() + } + + /// Finish the track and drive `serving` until its PUBLISH_DONE is out, failing if the + /// request ends with it: a finished subscription keeps its join until the subscriber + /// closes the request. + async fn finish_and_retain(h: &Serve, mut serving: std::pin::Pin<&mut impl Future>>) { + h.track.finish().unwrap(); + for _ in 0..200 { + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the request ended with the subscription" + ); + if publish_done_sent(h) { + return; + } + kio::clock::sim::yield_now().await; + } + panic!("PUBLISH_DONE never went out"); + } + + /// A track that ends within an RTT of SUBSCRIBE_OK ends its subscription before the + /// joining FETCH sent on that SUBSCRIBE_OK arrives. The FETCH is still answered with the + /// Largest group's head, until the subscriber closes the request. + #[kio::test] + async fn a_joining_fetch_after_the_track_ends_is_answered() { + for version in JOINING_DRAFTS { + let mut h = serve(version); + publish_groups(&mut h, 4); + let mut group = h.track.create_group(group::Info { sequence: 5 }).unwrap(); + group.write_frame(timestamp(), b"the-head".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) + .await + .unwrap(); + let mut serving = std::pin::pin!( + h.publisher + .clone() + .run_subscribe_stream(stream, subscribe(Filter::NextObject, None)) + ); + registered(&h, serving.as_mut()).await; + finish_and_retain(&h, serving.as_mut()).await; + + // The FETCH lands an RTT after PUBLISH_DONE. + kio::clock::sleep(Duration::from_millis(100)).await; + let mark = h.log.writes.lock().unwrap().len(); + let mut buf = bytes::Bytes::from(joining_fetch(&h, mark).await.unwrap()); + assert_eq!( + crate::coding::decode_varint(&mut buf, version).unwrap(), + ietf::FetchOk::ID, + "{version}: the joining FETCH was refused" + ); + let ok = crate::coding::decode_buf(&mut buf, version, |r, v| ietf::FetchOk::decode(r, v)).unwrap(); + assert_eq!(ok.end_location, Location { group: 5, object: 1 }, "{version}"); + assert_eq!( + occurrences(&h.log, b"the-head"), + 1, + "{version}: the head was not served" + ); + assert!(h.log.resets().is_empty(), "{version}"); + + // Closing the request drops the join, and a FETCH after that is refused. + assert!(futures::poll!(serving.as_mut()).is_pending(), "{version}"); + assert!(!h.publisher.joins.read().is_empty(), "{version}"); + h.session.close(Close::Fin); + serving.await.unwrap(); + assert!( + h.publisher.joins.read().is_empty(), + "{version}: the join outlived its request" + ); + h.session.reopen(); + assert_eq!(joining_refusal(&h, version).await, INVALID_JOINING_REQUEST_ID); + } + } + + /// A REQUEST_UPDATE that crossed PUBLISH_DONE is skipped, not taken as a protocol + /// violation, and the join stays until the subscriber's FIN. + #[kio::test] + async fn an_update_after_publish_done_keeps_the_join() { + for version in JOINING_DRAFTS { + // One script shared by every stream, so the update can arrive after PUBLISH_DONE. + let mut h = serve_session( + version, + crate::track::Info::timed(), + crate::origin::Route::default(), + ScriptedSession::new(Vec::new()), + ); + publish_groups(&mut h, 5); + settle().await; + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) + .await + .unwrap(); + let mut serving = std::pin::pin!( + h.publisher + .clone() + .run_subscribe_stream(stream, subscribe(Filter::NextObject, None)) + ); + registered(&h, serving.as_mut()).await; + finish_and_retain(&h, serving.as_mut()).await; + + // REQUEST_UPDATE, Request ID 2, no parameters. + h.session.push(&[0x02, 0, 2, 2, 0]); + for _ in 0..10 { + assert!(futures::poll!(serving.as_mut()).is_pending(), "{version}"); + kio::clock::sim::yield_now().await; + } + assert!(h.log.closes().is_empty(), "{version}: the update closed the session"); + assert!( + !h.publisher.joins.read().is_empty(), + "{version}: the update dropped the join" + ); + + h.session.close(Close::Fin); + serving.await.unwrap(); + assert!(h.publisher.joins.read().is_empty(), "{version}"); + } + } + + /// A peer holding back flow control parks PUBLISH_DONE, and the kept join still goes + /// once the subscription's request token is revoked meanwhile. + #[kio::test] + async fn a_kept_join_ends_with_its_token_while_publish_done_waits() { + for version in JOINING_DRAFTS { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let track = broadcast.create_track("video", crate::track::Info::timed()).unwrap(); + let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".to_vec()).unwrap(); + group.finish().unwrap(); + settle().await; + + let bi = kio::Producer::new(true); + let session = SinkSession::gated_bi(bi.consume()); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Link::new(false); + let mut requests = auth.handle().requests(); + // A limit of nothing, so only the token admits the subscription. + auth.handle().authorize(&crate::auth::Grant::default()); + let publisher = Publisher::new( + crate::time::sim(), + crate::driver::Transport::new(session.clone()), + origin.consume(), + Control::new(false), + None, + peer_setup, + version, + ) + .with_auth(auth.clone()); + + let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), version) + .await + .unwrap(); + let mut serving = std::pin::pin!(publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let accepted = async { requests.next().await.expect("a request") }; + let request = match futures::future::select(std::pin::pin!(accepted), serving.as_mut()).await { + futures::future::Either::Left((request, _)) => request, + futures::future::Either::Right((res, _)) => panic!("{version}: ended unverified: {res:?}"), + }; + let enforced = request.accept(crate::auth::Grant::all()); + let joined = publisher.joins.wait(|joins| match joins.get(&RequestId(REQUEST_ID)) { + Some(Some(_)) => Poll::Ready(()), + _ => Poll::Pending, + }); + match futures::future::select(std::pin::pin!(joined), serving.as_mut()).await { + futures::future::Either::Left(_) => {} + futures::future::Either::Right((res, _)) => panic!("{version}: subscription ended: {res:?}"), + } + + // The request stream takes nothing more, so PUBLISH_DONE parks. + let Ok(mut writable) = bi.write() else { + panic!("request stream gate closed"); + }; + *writable = false; + drop(writable); + track.finish().unwrap(); + for _ in 0..50 { + assert!(futures::poll!(serving.as_mut()).is_pending(), "{version}"); + settle().await; + } + assert!( + !publisher.joins.read().is_empty(), + "{version}: the finished join was not kept" + ); + + enforced.revoke(crate::SessionError::Unauthorized, "revoked"); + for _ in 0..10 { + let _ = futures::poll!(serving.as_mut()); + settle().await; + } + assert!( + publisher.joins.read().is_empty(), + "{version}: the join outlived its grant while PUBLISH_DONE waited" + ); + } + } + + /// A subscription the subscriber cancels before the track ends keeps nothing for a later + /// joining FETCH. + #[kio::test] + async fn an_unsubscribed_join_is_not_kept() { + for version in JOINING_DRAFTS { + let mut h = serve(version); + publish_groups(&mut h, 5); + settle().await; + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) + .await + .unwrap(); + let mut serving = std::pin::pin!( + h.publisher + .clone() + .run_subscribe_stream(stream, subscribe(Filter::NextObject, None)) + ); + registered(&h, serving.as_mut()).await; + h.session.close(Close::Fin); + serving.await.unwrap(); + assert!(h.publisher.joins.read().is_empty(), "{version}"); + h.session.reopen(); + assert_eq!(joining_refusal(&h, version).await, INVALID_JOINING_REQUEST_ID); + } + } + + /// The session closing frees every join a finished subscription kept. + #[kio::test] + async fn a_session_close_frees_every_kept_join() { + for version in JOINING_DRAFTS { + let mut h = serve(version); + publish_groups(&mut h, 5); + settle().await; + let mut servings = Vec::new(); + for id in [REQUEST_ID, REQUEST_ID + 2] { + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) + .await + .unwrap(); + let mut msg = subscribe(Filter::NextObject, None); + msg.request_id = RequestId(id); + let mut serving = Box::pin(h.publisher.clone().run_subscribe_stream(stream, msg)); + registered_as(&h, RequestId(id), serving.as_mut()).await; + servings.push(serving); + } + h.track.finish().unwrap(); + for _ in 0..50 { + for serving in &mut servings { + assert!(futures::poll!(serving.as_mut()).is_pending(), "{version}"); + } + kio::clock::sim::yield_now().await; + } + assert_eq!( + h.publisher.joins.read().len(), + 2, + "{version}: the finished joins were not kept" + ); + + crate::transport::poll::Session::close(&h.session, 0, "done"); + for serving in servings { + serving.await.unwrap(); + } + assert!(h.publisher.joins.read().is_empty(), "{version}"); + } + } + + /// How a token subscription's grant ends after its track did. + #[derive(Clone, Copy, Debug)] + enum GrantEnd { + Expired, + Revoked, + } + + /// A join kept past the track's end reads the cache on the subscription's request token, + /// so it goes once that grant expires or is revoked, and a late FETCH is refused. + #[kio::test] + async fn a_kept_join_ends_with_its_request_token() { + const VERSION: Version = Version::Draft18; + for end in [GrantEnd::Expired, GrantEnd::Revoked] { + let (auth, mut requests, _cred) = auth_covering_other(); + let mut h = serve_with_auth(VERSION, auth, Vec::new()); + let rt = h.publisher.runtime.clone(); + publish_groups(&mut h, 5); + settle().await; + + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), VERSION) + .await + .unwrap(); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let accepted = async { requests.next().await.expect("a request") }; + let request = match futures::future::select(std::pin::pin!(accepted), serving.as_mut()).await { + futures::future::Either::Left((request, _)) => request, + futures::future::Either::Right((res, _)) => panic!("{end:?}: ended unverified: {res:?}"), + }; + let enforced = request.accept(grant_all_expiring(&rt, Some(60))); + registered(&h, serving.as_mut()).await; + finish_and_retain(&h, serving.as_mut()).await; + + match end { + GrantEnd::Expired => kio::clock::sleep(Duration::from_secs(61)).await, + GrantEnd::Revoked => { + kio::clock::sleep(Duration::from_secs(1)).await; + assert!(futures::poll!(serving.as_mut()).is_pending(), "{end:?}"); + enforced.revoke(crate::SessionError::Unauthorized, "revoked"); + } + } + let _ = kio::clock::timeout(Duration::from_secs(1), serving.as_mut()) + .await + .unwrap_or_else(|_| panic!("{end:?}: the join outlived its grant")); + assert!(h.publisher.joins.read().is_empty(), "{end:?}"); + assert_eq!( + joining_refusal(&h, VERSION).await, + INVALID_JOINING_REQUEST_ID, + "{end:?}" + ); + } + } + #[kio::test] async fn a_joining_fetch_rejects_unsupported_filters() { for version in JOINING_DRAFTS { diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 60455c595ed..231559b8777 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -1114,6 +1114,11 @@ impl ScriptedSession { } } + /// Undo [`Self::close`], so streams opened from here park once their script runs out. + pub fn reopen(&self) { + *self.close.lock().unwrap() = None; + } + /// Answer each stream from `scripts`, but only once the gate opens: a peer that /// replies normally after its transport header gets send capacity. pub fn gated_open(scripts: Vec>, gate: kio::Consumer) -> Self { diff --git a/rs/moq-net/tests/integration/track_tail.rs b/rs/moq-net/tests/integration/track_tail.rs index 35a6ad81d30..511472fd864 100644 --- a/rs/moq-net/tests/integration/track_tail.rs +++ b/rs/moq-net/tests/integration/track_tail.rs @@ -288,6 +288,63 @@ async fn lite07_empty_track_ends_without_the_grace() { assert!(outcome.elapsed < GRACE / 10, "ended after {:?}", outcome.elapsed); } +/// Before draft 22, a group written before SUBSCRIBE_OK sits below its Largest Object, so +/// its head arrives on the joining FETCH sent once SUBSCRIBE_OK lands. A track that ends as +/// the subscription starts has ended it by the time that FETCH reaches the publisher, an RTT +/// later, and still ends cleanly with the head. +#[kio::test] +async fn draft18_a_join_reaching_a_finished_track_keeps_its_head() { + const RTT: Duration = Duration::from_millis(100); + + let Pair { + pair, + track, + remote, + _keep, + } = connect("moq-transport-18").await; + pair.client_transport.set_latency(RTT / 2); + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, PAYLOAD).unwrap(); + group.finish().unwrap(); + + let reader = kio::clock::sim::spawn(async move { + let mut sub = remote + .track("video") + .unwrap() + .subscribe(moq_net::track::Subscription::default().with_range(0..)) + .await + .expect("subscribe"); + let mut frames = Vec::new(); + let err = loop { + let mut group = match sub.recv_group().await { + Ok(Some(group)) => group, + Ok(None) => break None, + Err(err) => break Some(err), + }; + while let Some(frame) = group.read_frame().await.expect("group failed") { + frames.push(frame.payload.to_vec()); + } + }; + (frames, err) + }); + + kio::clock::timeout(TIMEOUT, support::harness::subscribed(&track)) + .await + .expect("no subscriber appeared"); + // Kept alive, as a publisher draining its session does: a finished track whose producer + // is gone lets its cache reclaim the head, and that FETCH is refused. + track.finish().unwrap(); + let _track = track; + + let (frames, err) = kio::clock::timeout(TIMEOUT, reader) + .await + .expect("the subscription never ended") + .expect("reader panicked"); + assert!(err.is_none(), "{err:?}"); + assert_eq!(frames, [PAYLOAD], "the head was lost"); + drop((pair, _keep)); +} + /// IETF drafts with a joining FETCH (18) and with subscription fills (22). const IETF: &[&str] = &["moq-transport-18", "moq-transport-22"]; From 19772726efc868d026b5f60c64417dfba95bf6cb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:59:01 -0800 Subject: [PATCH 34/86] chore(quest): complete the recorded pre-cut security audit (#7611) Co-authored-by: GPT-6.1 Sol Co-authored-by: moq-bot --- quest/m0/security-audit/README.md | 6 ++-- quest/m1/README.md | 1 - quest/m1/security-audit/README.md | 54 ------------------------------- 3 files changed, 3 insertions(+), 58 deletions(-) delete mode 100644 quest/m1/security-audit/README.md diff --git a/quest/m0/security-audit/README.md b/quest/m0/security-audit/README.md index e1cfb56a07f..3bf3c7ec74e 100644 --- a/quest/m0/security-audit/README.md +++ b/quest/m0/security-audit/README.md @@ -12,9 +12,9 @@ backports to `release`. An external AI audit at [651c30fee9](https://github.com/moq-dev/moq/commit/651c30fee9) reported 8 Medium and 28 Low findings. Every claim was re-checked against source before -planning; the pre-cut remainder is the -[m1 epic](/quest/m1/security-audit/README.md), whose README records what was -dropped and why. +planning. The completed pre-cut epic's +[triage record](https://github.com/moq-dev/moq/blob/e3bd4b288e01f0c2e7fbe1e6a362d6f3d58057c8/quest/m1/security-audit/README.md) +preserves the maintainer's dropped findings and external owners. Decided 2026-10-27 (maintainer): these two land in m0 because `release` carries the same bugs. Each quest owns its own backport PR rather than a diff --git a/quest/m1/README.md b/quest/m1/README.md index a77d1ff18b8..6d724504d5a 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -135,7 +135,6 @@ per-frame CPU growth since #5926. - [Address groups by timestamp](/quest/m1/time-addressing/README.md) - group start timestamps replace sequence numbers in the moq-net API and lite-07 wire, while old subscribers, old publishers, and published JS clients keep working - [Pre-cut audit findings](/quest/m1/cut-audit/README.md) - fix the crashes, hostile-input costs, and correctness bugs the 2026-10-24 audit of main found - [Vendored code has every upstream fix it needs](/quest/m1/vendor-sync/README.md) - quinn, libv4l-rs, nvidia-video-codec-sdk, rust-media-libs, cros-codecs, and UniFFI generator fixes are ported or planned, once per release cycle -- [Security audit findings](/quest/m1/security-audit/README.md) - fix the verified relay, importer, moq-c, browser, and CI findings of the 2026-10-27 external security audit - [Delete the tests that don't earn their cost](/quest/m1/prune-tests.md) - an off-cycle test cost sweep deletes tests whose cost outweighs their signal and makes the rest cheaper - [Remove the dependencies that don't earn their weight](/quest/m1/prune-deps.md) - Rust, JS, and vendored dependencies that are trivial, duplicated, or serve features not worth keeping are removed - [Remove what dominates build time](/quest/m1/prune-build-time.md) - the crates, features, and code that cost the most build time are removed or made cheaper diff --git a/quest/m1/security-audit/README.md b/quest/m1/security-audit/README.md deleted file mode 100644 index bdbc2c75a2e..00000000000 --- a/quest/m1/security-audit/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# [M] 2026-10-27 security audit: pre-cut findings - -## Goal - -Fix the 2026-10-27 external security audit's verified findings that are not -urgent for `release`, before main is promoted: relay and wire boundaries, -importer memory, moq-c handle exhaustion, browser input checks, and CI -supply-chain pins. - -## Plan - -An external AI audit at -[651c30fee9](https://github.com/moq-dev/moq/commit/651c30fee9) reported 8 -Medium and 28 Low findings from source review (no fuzzing or live attack). -Every claim was re-checked against -[8d2fa6653a](https://github.com/moq-dev/moq/commit/8d2fa6653a) before -planning; several were wrong or proposed fixes this repository forbids. - -Placed elsewhere (decided 2026-10-27, maintainer): - -- Credential leaks also in `release`: - [m0 security audit](/quest/m0/security-audit/README.md). -- lite-07 range-list scan (NET-1): owned by - [fill-linear](/quest/m1/subscribe-ranges/fill-linear.md). -- qmux buffering a peer-declared size (NET-3): owned by - [qmux receive lease](/quest/m2/qmux-receive-lease.md). -- Media parser fuzzing: owned by [media fuzz](/quest/m2/media-fuzz.md). -- E2EE cloned-process rule and CryptoKey length: documented in the - E2EE draft and the `moq-e2ee` and `@moq/e2ee` docs. - -Dropped, with reasons: - -- SUP-3 (`||` in release gates): wrong; deliberate, and `just gh ffi - completed` checks each language's own publisher. -- RELAY-9 (Unix socket 0666): documented, sessions still authenticate. -- RELAY-10 (pinned peers ignored by `client_ca`): unreachable from config. -- RELAY-4 (no Origin check): no ambient credentials to ride. -- RELAY-5 (connection, handshake, and auth caps): refusal constants, which - AGENTS.md forbids; handshakes already time out. -- MUX-1 (HLS whole-resource fetch): the origin is operator-chosen, a byte cap - is a refusal constant, and whole segments are buffered on purpose. -- MUX-4 (`file:` playlists read any local file): intended; confining to the - playlist directory breaks ffmpeg layouts. -- JS-1 (demo player per announcement): the demo's purpose. -- AUTH-2 (no required expiry): an opt-in policy by design. -- AUTH-5 (tokens in URL before lite-07): inherent until lite-07 publishes; - its log exposure is fixed by [log secrets](/quest/m0/security-audit/log-secrets.md). -- SUP-5 (libav integrity), SUP-10 (CODEOWNERS, pin lint): no real gain. -- SUP-9 (one GPG key): moving `SIGNING_KEY` into a protected Environment is - a maintainer settings change, not a quest. -- The audit's own fixes for MUX-3 (would break large MKV imports) and E2EE-A - (a random epoch is copied by a fork too) were wrong; the children use - better ones. - From 2cdde8166ec5ef4d3566c1b5ef643a0407fe5063 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:59:32 -0800 Subject: [PATCH 35/86] fix(mux): finish accepted MKV tracks after truncated input (#7608) Co-authored-by: GPT-6.1 Sol Co-authored-by: moq-bot --- quest/m1/README.md | 1 - quest/m1/mkv-truncated-finish.md | 19 ------ rs/moq-mux/src/container/mkv/import.rs | 94 +++++++++++++++++++++++--- 3 files changed, 83 insertions(+), 31 deletions(-) delete mode 100644 quest/m1/mkv-truncated-finish.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 6d724504d5a..492020cee7c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -187,7 +187,6 @@ per-frame CPU growth since #5926. - [Binding announcements always carry captures](/quest/m1/ffi-required-captures.md) - moq-ffi and its generated bindings make `captures` a required list, as #7266 always sets it - [HLS export follows a resolution change](/quest/m1/hls-reinit-same-codec.md) - a decoder-identity change under the same codec string starts a new init and segment tag - [fMP4 decode times keep increasing](/quest/m1/fmp4-bframe-decode-time.md) - fragments shorter than a GOP of B-frame video never step `tfdt` backwards -- [A truncated MKV ends its tracks at finish](/quest/m1/mkv-truncated-finish.md) - `finish()` refusing truncated input still ends the tracks, so subscribers stop waiting - [moq-quic deletes its remaining 0-RTT code](/quest/m1/quic-zero-rtt-leftovers.md) - only the code that drops a 0-RTT packet remains, with a test - [Gate the no-em-dash rule](/quest/m1/em-dash-gate.md) - `just check` fails on an em dash in any tracked text file - [Every package shares one minor version](/quest/m1/shared-version.md) - a recipe sets every published package to one minor at the cut (next: 0.22.0), and `just check` enforces it and the `0.0.x` rule for experiments diff --git a/quest/m1/mkv-truncated-finish.md b/quest/m1/mkv-truncated-finish.md deleted file mode 100644 index a11ea1e5c73..00000000000 --- a/quest/m1/mkv-truncated-finish.md +++ /dev/null @@ -1,19 +0,0 @@ -# [XS] A truncated MKV ends its tracks at finish - -## Goal - -When the MKV importer's `finish()` refuses truncated input (`Mkv(Truncated)`), it still ends its tracks with what was accepted, as it does after a refused `decode`, so a subscriber's `read()` returns instead of waiting until the importer is dropped. `finish()` still returns the error, and the frame cut short still fails its group with the reason. - -## Plan - -Found by the verification in [moq-dev/moq#7515](https://github.com/moq-dev/moq/pull/7515), which fed the live `opus.webm` truncated at every length and with every byte flipped: 1462 truncation lengths and 29 byte flips ended with tracks neither finished nor aborted after `finish()` returned `Truncated`, at every chunk size. Callers that drop the importer after an error were unaffected. - -In `Import::finish` (`rs/moq-mux/src/container/mkv/import.rs`, about lines 838-852), a failed `drain_eof` aborts only the streaming block's group and returns early, skipping the per-track finish. A `decode` that already failed (`self.failed`) skips `drain_eof` and still reaches the track loop, so it ends its tracks. - -Decided 2026-11-01 (maintainer, follow-up planning after the 2026-11-01 /quest-afk run): m1, a small fix in an importer the cut ships. End the tracks on the truncation path the way a refused `decode` already does (`decode`'s docs: "`finish` ends the tracks with what was accepted"), so the two refusals behave alike. #7515 also floated aborting every track with the truncation error instead; matching the refused `decode` keeps one rule and keeps the groups that did arrive. Test with #7515's harness shape: a truncated `opus.webm` whose subscriber's read ends after `finish()` while the importer is still held. - -Public API: none. Wire: none. - -## Related - -- [Untrusted media parsers have fuzz targets](/quest/m2/media-fuzz.md) - could assert that every track ends after `finish()`, whatever the input diff --git a/rs/moq-mux/src/container/mkv/import.rs b/rs/moq-mux/src/container/mkv/import.rs index 431ec0720cf..4d301b2badd 100644 --- a/rs/moq-mux/src/container/mkv/import.rs +++ b/rs/moq-mux/src/container/mkv/import.rs @@ -834,22 +834,24 @@ impl Import { /// Finish all tracks, flushing current groups. /// /// Input that ends inside an element other than an unknown-size Segment or Cluster is refused as - /// truncated. + /// truncated, but the tracks still end with what was accepted. pub fn finish(&mut self) -> Result<()> { self.finished = true; - if self.failed.is_none() - && let Err(err) = self.drain_eof() - { + let mut result = match self.failed { + None => self.drain_eof(), + Some(_) => Ok(()), + }; + if let Err(err) = &result { // A frame cut short fails its group with the reason, rather than waiting for the rest. self.abort_block(err.reason()); - return Err(err); } // No frame follows to anchor the clock, so publish the declared track set now. self.initial_reservation = None; for track in self.tracks.values_mut() { - track.track.finish()?; + // Finish every track even on refusal, keeping the first error. + result = result.and(track.track.finish()); } - Ok(()) + result } /// Abort all tracks with `err` instead of finishing, so subscribers see the real @@ -1801,17 +1803,87 @@ mod tests { import.finish().unwrap(); } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn truncation_fails_the_frame_cut_short() { + use futures::FutureExt; + let (mut import, catalog, consumer) = import(); import.decode(&live(vec![entry(1, 1, "V_VP9")])).unwrap(); import.decode(&simple_block(1, 1024)).unwrap(); import.decode(&[4; 512]).unwrap(); let mut frame = first_frame(&catalog, &consumer).await; - assert!(matches!(import.finish(), Err(crate::Error::Mkv(Error::Truncated)))); + let err = import.finish().unwrap_err(); + assert!(matches!(err, crate::Error::Mkv(Error::Truncated))); // The importer is still owned, and the frame fails rather than waiting on it. - let read = kio::clock::timeout(WAIT, frame.read_all()).await.unwrap(); - assert!(read.is_err(), "a truncated frame must not read as complete: {read:?}"); + let read = frame.read_all().now_or_never().expect("the cut-short frame must fail"); + assert!(matches!( + read, + Err(moq_net::Error::Stream(moq_net::StreamError::Internal)) + )); + drop(import); + } + + #[tokio::test(start_paused = true)] + async fn truncated_finish_ends_tracks_with_accepted_content() { + use futures::FutureExt; + + let data = include_bytes!("test_data/opus.webm"); + // The last packet is followed by 10 bytes of BlockGroup metadata: cut inside its payload, + // or inside the metadata after all six packets were accepted. + for (missing, expected) in [(11, 5), (1, 6)] { + let data = &data[..data.len() - missing]; + for chunk in [1, 7, 64, data.len()] { + let (mut import, catalog, consumer) = import(); + for piece in data.chunks(chunk) { + import.decode(piece).unwrap(); + } + assert!(matches!(import.finish(), Err(crate::Error::Mkv(Error::Truncated)))); + let snapshot = catalog.snapshot(); + let (name, config) = snapshot.audio.renditions.iter().next().unwrap(); + let subscription = + moq_net::track::Subscription::default().with_max_delay(std::time::Duration::from_secs(1)); + let track = consumer.track(name).unwrap().subscribe(subscription).await.unwrap(); + let mut reader = + crate::container::Consumer::new(track, crate::catalog::hang::Container::try_from(config).unwrap()); + let mut frames = 0; + while reader + .read_frame() + .now_or_never() + .expect("finish must end the track while the importer is held") + .unwrap() + .is_some() + { + frames += 1; + } + assert_eq!(frames, expected, "chunk {chunk}"); + drop(import); + } + } + } + + #[tokio::test(start_paused = true)] + async fn truncated_finish_publishes_and_ends_empty_tracks() { + let (mut import, catalog, consumer) = import(); + let mut clocks = crate::container::test_util::Clocks::subscribe(&consumer).await; + import + .decode(&live(vec![entry(1, 1, "V_VP9"), entry(2, 1, "V_VP8")])) + .unwrap(); + import.decode(&[0xec, 0x81]).unwrap(); + assert!(clocks.drain().is_empty(), "no block has anchored the catalog yet"); + assert!(matches!(import.finish(), Err(crate::Error::Mkv(Error::Truncated)))); + assert!( + !clocks.drain().is_empty(), + "finish must release the initial catalog reservation" + ); + let snapshot = catalog.snapshot(); + assert_eq!(snapshot.video.renditions.len(), 2); + for name in snapshot.video.renditions.keys() { + let mut track = consumer.track(name).unwrap().subscribe(None).await.unwrap(); + assert!(matches!( + track.poll_recv_group(&kio::Waiter::noop()), + std::task::Poll::Ready(Ok(None)) + )); + } drop(import); } From c4cfcd52f718574617ce9f2e5b857c96dd2367af Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 20:59:57 -0800 Subject: [PATCH 36/86] fix(relay): keep the resolved configuration off the stack (#7595) Co-authored-by: GPT-6.1 Sol Co-authored-by: moq-bot --- quest/m0/test-flakes-2/README.md | 1 - .../test-flakes-2/relay-stack-all-features.md | 24 ------------------- rs/moq-relay/src/relay.rs | 5 ++-- 3 files changed, 3 insertions(+), 27 deletions(-) delete mode 100644 quest/m0/test-flakes-2/relay-stack-all-features.md diff --git a/quest/m0/test-flakes-2/README.md b/quest/m0/test-flakes-2/README.md index 2952bb400e5..f2b20490eed 100644 --- a/quest/m0/test-flakes-2/README.md +++ b/quest/m0/test-flakes-2/README.md @@ -59,4 +59,3 @@ Public API: none. Wire: none. - [IETF22 fallback](/quest/m0/test-flakes-2/ietf22-fallback.md) - an IETF22 browser viewer follows a fallback within the media lane's bound, keeping its session and its audio - [moq-relay's auth-trust test survives load](/quest/m0/test-flakes-2/relay-auth-trust.md) - it stops timing out on a busy host - [Draft-22 interop with a native publisher](/quest/m0/test-flakes-2/draft22-interop.md) - no "invalid fetch range" or audio gap with a Rust or C++ publisher -- [The relay fits a Windows stack under every feature set](/quest/m0/test-flakes-2/relay-stack-all-features.md) - the 1 MiB stack test passes under `--all-features` too diff --git a/quest/m0/test-flakes-2/relay-stack-all-features.md b/quest/m0/test-flakes-2/relay-stack-all-features.md deleted file mode 100644 index 278370c4879..00000000000 --- a/quest/m0/test-flakes-2/relay-stack-all-features.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] The relay fits a Windows stack under every feature set - -## Goal - -`stack::debug_relay_load_and_run_fit_windows_stack` -(`rs/moq-relay/tests/integration/stack.rs`) passes under -`cargo nextest --all-features` as well as in `just ci`. The relay's -`Relay::load` and `Relay::run` futures fit a 1 MiB thread stack under every -feature set the test builds with. - -## Plan - -Planned 2026-11-01 (maintainer, follow-up planning). Seen while iterating -[moq-dev/moq#7211](https://github.com/moq-dev/moq/pull/7211): on main -(da0a9e1d8a) the test overflows its stack under `--all-features`, and passes -with the gate's feature set. - -- Find which feature makes the future grow past the stack, for example by - bisecting features and checking future sizes with `-Zprint-type-sizes` or - `std::mem::size_of_val`. -- Shrink it at its source, for example by boxing a large state or moving a - large local out of the async body. -- Don't raise the stack size, and don't narrow the test's features to hide - the overflow: the test exists because Windows' main thread really has 1 MiB. diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index 8f305867373..36a5451e2ae 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -70,7 +70,8 @@ impl Ready { /// ``` pub struct Relay { ready: tokio::sync::watch::Sender, - config: Config, + // Keep the inspection snapshot out of every future that moves a Relay. + config: Box, server: moq_tokio::Listener, auth: auth::Auth, /// The sessions the embedder decides, until it takes them. `None` when the @@ -144,7 +145,7 @@ impl Relay { // Every TLS listener serves the ACME certificate, so install it before any is built. #[cfg(feature = "acme")] let acme = config.acme.init(&config.listen.tls, &config.web.https)?; - let resolved_config = config.clone(); + let resolved_config = Box::new(config.clone()); let drain_timeout = config.drain_timeout(); // The name this relay reports in every auth request: the stats node label, // else the cluster node URL, else nothing. From d52d55ac69e273a69906287bc2e3ef13f22bb7aa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 21:00:21 -0800 Subject: [PATCH 37/86] chore(quest): finish paired benchmark baseline wait (#7592) Co-authored-by: GPT-6.1 Sol Co-authored-by: moq-bot --- quest/m0/nightly/README.md | 1 - quest/m0/nightly/bench-baseline-build.md | 35 ------------------------ 2 files changed, 36 deletions(-) delete mode 100644 quest/m0/nightly/bench-baseline-build.md diff --git a/quest/m0/nightly/README.md b/quest/m0/nightly/README.md index 292cf384c17..4f4ffdee91c 100644 --- a/quest/m0/nightly/README.md +++ b/quest/m0/nightly/README.md @@ -35,5 +35,4 @@ Public API: none. Wire: none. ## Required -- [The paired bench baseline builds](/quest/m0/nightly/bench-baseline-build.md) - the next scheduled Nightly's paired bench jobs build their baseline and compare, or the baseline moves outside the checkout - [The browser backend baseline completes on IETF22](/quest/m0/nightly/browser-baseline.md) - the WASM and UniFFI backend baseline completes every supported transport case without an internal error diff --git a/quest/m0/nightly/bench-baseline-build.md b/quest/m0/nightly/bench-baseline-build.md deleted file mode 100644 index a2e0914292d..00000000000 --- a/quest/m0/nightly/bench-baseline-build.md +++ /dev/null @@ -1,35 +0,0 @@ -# [XS] The paired bench baseline builds - -## Goal - -The scheduled Nightly's paired bench jobs build their baseline and get to the -comparison. Check with `gh run list --workflow nightly.yml --event schedule` -and the "Rust bench targets" step of the newest run (the other paired jobs, -such as "io_uring benchmarks", share the cause). Once a scheduled run gets past -the base build, delete this quest. - -## Plan - -Decided 2026-11-01 (maintainer, `/quest-plan`): a wait-and-check quest that -stays ready, since #7245 likely fixed it already and only the next scheduled -run can tell. - -Scheduled run -[41204380716](https://github.com/moq-dev/moq/actions/runs/41204380716) -(2026-10-31) failed every paired bench job before comparing: `bench/run.sh` -checks the baseline out to `$RUN/base`, under `target/bench/nightly/` inside -the main checkout, and Cargo could not merge that checkout's -`.cargo/config.toml` `build-ffi` alias with the current one ("failed to merge -key `build-ffi`"). - -That run's head already had -[moq-dev/moq#7245](https://github.com/moq-dev/moq/pull/7245), which makes the -aliases strings, so a nested checkout's alias overrides instead of merging. -Its baseline (`previous-nightly`) predated #7245 and still had the list form. -The next scheduled run's baseline includes #7245, so it should build. - -If it still fails on the merge, build the baseline outside the checkout (a -`git worktree` beside the repository instead of under `target/`), so no -enclosing `.cargo/config.toml` applies. - -Found by [moq-dev/moq#7253](https://github.com/moq-dev/moq/pull/7253). From fd0b0cb9e48293a387c59680ce3f66df9c1ac888 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 21:20:22 -0800 Subject: [PATCH 38/86] quest: report the replica spike, a conditional win (#7533) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- .../m2/perf/kio-channel-contention/README.md | 95 +++++++++++++++++-- .../kio-channel-contention/log-adoption.md | 9 +- .../kio-channel-contention/spike-cluster.md | 6 +- .../kio-channel-contention/spike-replica.md | 39 -------- 4 files changed, 98 insertions(+), 51 deletions(-) delete mode 100644 quest/m2/perf/kio-channel-contention/spike-replica.md diff --git a/quest/m2/perf/kio-channel-contention/README.md b/quest/m2/perf/kio-channel-contention/README.md index 745b5a5ed75..fad845acb5e 100644 --- a/quest/m2/perf/kio-channel-contention/README.md +++ b/quest/m2/perf/kio-channel-contention/README.md @@ -139,8 +139,7 @@ Decided (maintainer, 2026-10-14, per-thread follow-up): the thread-per-core workers (`--runtime-workers`, the default once [Shard by default](/quest/m2/perf/shard-default.md) lands) and link workers over an in-memory transport (`moq_net::transport::memory`), - never real QUIC. The - [replica spike](/quest/m2/perf/kio-channel-contention/spike-replica.md) + never real QUIC. The replica spike (results below) replicates the data plane and shares announcements; the [cluster spike](/quest/m2/perf/kio-channel-contention/spike-cluster.md) shares nothing. A measured win reopens sharding through `/quest-plan`; a @@ -207,10 +206,12 @@ median): deque 30, runs 30, log 46, ring 42. Draining it: 14, 15, 12, 19. growing stops every reader or needs epochs. Its per-slot locks cost 40% more for a burst on one thread and lose to the log across cores. It wins one-frame groups only because its reader holds no channel handle. -- Not run: `just bench-profile lock` on a multi-frame shape +- Not run on this host: `just bench-profile lock` on a multi-frame shape (`bench/workloads/video-heavy.toml` is one), which needs - `kernel.perf_event_paranoid=-1` and this host has 4. So a relay's share of - `GroupState` wait with multi-frame groups is still unmeasured. + `kernel.perf_event_paranoid=-1` and this host has 4. The replica spike + below captured video-heavy's total lock wait but doesn't split it by + channel, so a relay's share of `GroupState` wait with multi-frame groups + is still unmeasured. Decided (maintainer, 2026-10-24): Front eviction lands on the lock-free append-only log (`kio::log`) itself, a tail run from @@ -284,8 +285,90 @@ at the same throughput. Main at 16 workers was refused twice (perf dropped events); the PR alone measured 4,747 ms (61%) at 16 workers at load 17 to 19, led by `Cursor::sync` (32%) and `probe` (17%), with no `poll_finished` wait. +Replica spike (2026-11-01, +[moq-dev/moq#7533](https://github.com/moq-dev/moq/pull/7533)): the code is +`Cluster::spike_replica` in `rs/moq-relay/src/cluster.rs` at +[`0636821`](https://github.com/moq-dev/moq/blob/0636821358e42363ba9b2a4f05e07f0f85ccb1a8/rs/moq-relay/src/cluster.rs), +switched on by `SPIKE_REPLICA=1`, and did not merge. Each tokio worker +builds a second origin and links the shared one into it with one moq-lite-07 +session over `moq_net::transport::memory` (default 1 MiB stream buffer), +both halves on that worker. Publishers still publish into the shared origin; +the worker's subscribers read its replica. It departs from the plan twice: + +- Announcements cross the hop too, so each replica keeps its own announce + table. These shapes announce 1 to 256 broadcasts, so it doesn't show. +- Every subscription takes the hop, even on the publisher's own worker. So + the 1-worker rows are the hop's whole cost, and the 4-worker rows overstate + it by up to a quarter. + +Method: `just bench-profile lock`, tokio workers over QUIC, with `perf stat` +counting the relay's instructions over the unprofiled run's 5 s steady +window. The relay was pinned to the 16 E-cores and the generator to the 8 +P-cores. The harness pins everything to the P-cores by default, so these +numbers don't compare with the baseline above. Load average 5 to 21. Each +cell is the median of 3 runs (chat at 16 workers: 9 base, 5 replica; +video-heavy: 2). Mutex wait is the median of the runs whose lock capture +the harness accepted, which is fewer where noted below. Instructions are +user and kernel per delivered frame, in thousands; CPU and RSS come from the host sampler; latency is the +generator's p50 in whole milliseconds. Each cell reads base → replica: + +| shape | workers | CPU cores | user instr | kernel instr | mutex wait (share) | RSS MiB | p50 ms | +| --- | --- | --- | --- | --- | --- | --- | --- | +| fanout | 1 | 0.06 → 0.05 | 167 → 170 | 20 → 21 | 0 → 0 | 36 → 36 | 3 → 4 | +| fanout | 4 | 0.15 → 0.06 | 170 → 177 | 52 → 30 | 39 ms (12%) → 9 ms (3.1%) | 37 → 38 | 3 → 3 | +| fanout | 16 | 0.45 → 0.16 | 182 → 211 | 344 → 123 | 807 ms (52%) → 256 ms (40%) | 43 → 47 | 3 → 2 | +| chat | 1 | 1.00 → 1.00 | 185 → 184 | 18 → 18 | 0 → 0 | 576 → 577 | 966 → 1,273 | +| chat | 4 | 1.91 → 1.80 | 168 → 171 | 40 → 20 | 50 ms (0.5%) → 18 ms (0.2%) | 499 → 507 | 31 → 68 | +| chat | 16 | 9.5 → 2.2 | 180 → 170 | 427 → 28 | not captured → 793 ms (7.9%) | 500 → 518 | 34 → 18 | +| video-heavy | 1 | 0.97 → 1.00 | 112 → 125 | 25 → 16 | 0 → 0 | 320 → 357 | 2.5 → 227 | +| video-heavy | 4 | 1.22 → 1.87 | 116 → 207 | 32 → 31 | 27 ms (0.4%) → 16 ms (0.2%) | 324 → 381 | 1 → 1 | +| video-heavy | 16 | 1.84 → 2.59 | 123 → 213 | 51 → 50 | 137 ms (1.5%) → 208 ms (1.6%) | 330 → 424 | 0 → 0 | + +`bench/workloads/video-heavy.toml` is the low-fan-out shape: 256 +connections each publish one 60 fps track of 60-frame groups and subscribe +to two others. Chat at 1 worker saturates the worker either way (about +15,000 of 20,000 messages/s delivered), so its latency is queueing. +Video-heavy at 1 worker saturates only with the replica: 28,400 instead of +31,400 frames/s. The harness refused all nine lock captures of base chat at +16 workers, at least seven of them for a lost perf record (2, 8, and 32 MiB +buffers), so its wait is missing. Its 400,000 +kernel instructions per frame are the contention: futex waits and wakes +around one track with 2,000 readers. Base video-heavy at 16 workers is one +capture; the other was refused for nonalternating switch records. + +- High fan-out wins at 16 workers. Chat drops from 9.5 to 2.2 cores, kernel + instructions per frame from 427k to 28k, and p50 from 34 to 18 ms. + Fanout drops from 0.45 to 0.16 cores while user instructions rise 16%, + the hop's own session work. At 4 workers the gain is small. +- What stays contended with replicas is mostly the shared track, whose + readers are now the hops, one per worker. In chat at 16 workers (four + captures), `TrackState` is 86% of the wait: `Subscriber::poll_drop` 28%, + `Subscriber::poll_finished` via `resume::Reader::learn_end` 26%, + `GroupExpiry::is_expired` 10%, and the rest 21%. Another 13% is the + per-track cache mutex in `cache::Track::watch_wall`, also under + `GroupExpiry::is_expired`. The version counter targets the `TrackState` + reads, and only the ones that find nothing changed: a read of new data + still locks, and the cache mutex isn't a channel, so it stays. +- Low fan-out loses. Video-heavy pays the hop on nearly every subscription. + At 4 and 16 workers that is 73 to 78% more user instructions per frame, + 41 to 53% more CPU, and 18 to 28% more memory, with no lock-wait gain at + 16 workers. At 1 worker the hop saturates the worker instead: 12% more + user instructions and 12% more memory, CPU pinned at one core, fewer + frames delivered, and p50 from 2.5 to 227 ms. +- Memory: fanout and chat grow 0 to 11%. Replicated one-frame groups are + small, and multi-frame groups cost the most. + +Recommendation: a conditional win. Replicas pay off only on high-fan-out +tracks. Applied to every track, they cost low fan-out about 1.8 times the +user instructions per frame at 4 and 16 workers. If sharding reopens, +replicate per track on demand: never on the publisher's own worker, and only above a per-worker fan-out +threshold, serving the rest from the shared origin. Rerun this comparison +with the version counter (landed above), since it attacks the same 16-worker chat `TrackState` waits without +the hop. Weigh it against the +[Cluster spike](/quest/m2/perf/kio-channel-contention/spike-cluster.md) +before `/quest-plan`. + ## Required -- [Replica spike](/quest/m2/perf/kio-channel-contention/spike-replica.md) - measures per-worker data-plane replicas linked over the in-memory transport, announcements shared - [Cluster spike](/quest/m2/perf/kio-channel-contention/spike-cluster.md) - measures a relay per thread clustered over the in-memory transport, nothing shared - [Log adoption](/quest/m2/perf/kio-channel-contention/log-adoption.md) - the rest of moq-net's log-shaped state moves onto the log where it measures as a win diff --git a/quest/m2/perf/kio-channel-contention/log-adoption.md b/quest/m2/perf/kio-channel-contention/log-adoption.md index afe533d180c..0f2e656724f 100644 --- a/quest/m2/perf/kio-channel-contention/log-adoption.md +++ b/quest/m2/perf/kio-channel-contention/log-adoption.md @@ -18,7 +18,11 @@ stays on its mutex. - Waits on the spikes (decided 2026-10-14 for the log, kept here): with per-thread sessions every consumer of a replica is on one thread, so its mutex is uncontended and a lock-free multi-reader log only pays off if the - shared model survives. + shared model survives. The replica spike (2026-11-01, in the + [epic](/quest/m2/perf/kio-channel-contention/README.md)) does not settle + it: replicas lose on low fan-out, so shared tracks would stay, and the + shared track it keeps is still the most contended channel, with one reader + per worker. - Audit moq-net (and moq-relay's `critical.rs`) for other state that is appended at the back, read by cursor, and evicted from the front, beyond `GroupState` frames, which the log quest already moves. List the @@ -29,5 +33,4 @@ stays on its mutex. ## Required -- [Replica spike](/quest/m2/perf/kio-channel-contention/spike-replica.md) - if per-worker replicas win, each channel's cross-core readers collapse to one flow-controlled stream per worker and adoption is likely abandoned -- [Cluster spike](/quest/m2/perf/kio-channel-contention/spike-cluster.md) - the same question with nothing shared +- [Cluster spike](/quest/m2/perf/kio-channel-contention/spike-cluster.md) - if a relay per thread wins, each channel's cross-core readers collapse to one flow-controlled stream per worker and adoption is likely abandoned diff --git a/quest/m2/perf/kio-channel-contention/spike-cluster.md b/quest/m2/perf/kio-channel-contention/spike-cluster.md index 6235cc431dc..384c500c372 100644 --- a/quest/m2/perf/kio-channel-contention/spike-cluster.md +++ b/quest/m2/perf/kio-channel-contention/spike-cluster.md @@ -5,9 +5,9 @@ Measure a relay per worker thread, each with its own `Cluster` and origin and nothing shared, clustered over the in-memory transport (`moq_net::transport::memory`) instead -of QUIC. Compare it with the -[replica spike](/quest/m2/perf/kio-channel-contention/spike-replica.md), which -shares the control plane. The spike is a draft PR that never merges; its +of QUIC. Compare it with the replica spike, which shares the control plane; +its numbers are in the +[epic](/quest/m2/perf/kio-channel-contention/README.md). The spike is a draft PR that never merges; its numbers and a recommendation go in the [epic](/quest/m2/perf/kio-channel-contention/README.md). diff --git a/quest/m2/perf/kio-channel-contention/spike-replica.md b/quest/m2/perf/kio-channel-contention/spike-replica.md deleted file mode 100644 index 2bd5933c621..00000000000 --- a/quest/m2/perf/kio-channel-contention/spike-replica.md +++ /dev/null @@ -1,39 +0,0 @@ -# [M] Spike: per-worker replicas - -## Goal - -Measure whether per-worker data-plane replicas cut lock wait on the -thread-per-core relay (`--runtime-workers`). Each worker serves tracks and -groups from its own origin, fed once per worker over the -in-memory transport (`moq_net::transport::memory`), while -announcements and routes stay in one shared origin. The spike is a draft PR -that never merges; its numbers and a recommendation go in the -[epic](/quest/m2/perf/kio-channel-contention/README.md). - -## Plan - -- Today every worker's sessions share one `origin::Producer`, built in - `Cluster::new` (`rs/moq-relay/src/cluster.rs`) and cloned per worker - (`rs/moq-relay/src/relay.rs`), so a subscriber on worker B reads state - written on worker A. -- With replicas, a track subscribed on worker B is subscribed once over a - memory session to the worker that holds it, then fanned out locally. Every - consumer of a replica is on one thread, so its kio mutex is uncontended, and - cross-core readers per frame drop from the subscriber count to the worker - count. -- Control plane shared, data plane replicated: the shared origin still - carries announcements, so announce churn is not paid once per worker. -- Flow control throttles only the hop (decided 2026-10-14): a slow worker's - stream lags and its stale groups reset; the publisher and other workers - never wait. -- Measure against the [epic](/quest/m2/perf/kio-channel-contention/README.md)'s - baseline: `just bench-profile lock` fanout and chat at 1, 4, and 16 workers, plus - relay CPU, resident memory (replicated group state), and glass-to-glass - latency added by the hop. Lower lock wait alone is not a win; weigh it - against the CPU, memory, and latency. Chat (`bench/workloads/chat.toml`) - is one broadcast to 2,000 viewers with one-frame groups, so it stresses - per-group track writes rather than frame fan-out. Replicas give little to - low-fanout load; if that matters, name a separate low-fanout workload - instead of reading it from chat. -- A win goes to `/quest-plan` for a real implementation quest; a no-win - restores the perf epic's no-sharding rule. From bcaaef15217767660691743de630783461e232e8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 21:46:02 -0800 Subject: [PATCH 39/86] quest: plan Check on ready main PRs as a signal (#7572) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- quest/m0/ci/README.md | 1 + quest/m0/ci/pr-check.md | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 quest/m0/ci/pr-check.md diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index 241019846d3..d7a6897dee1 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -152,6 +152,7 @@ The historical samples locate candidates; they are not controlled benchmarks. ## Required - [Read-only pull-request CI](/quest/m0/ci/read-only-secrets.md) - write secrets and publisher identities reach only protected branches and tags, never a pushed branch +- [Check on ready PRs](/quest/m0/ci/pr-check.md) - Check runs again on non-draft main PRs as a signal for `/quest-merge` and the land fixer, never as a gate - [Land main through GitHub's merge queue](/quest/m0/ci/land.md) - one PR at a time on the self-hosted runner; the host's Codex fixer fixes and re-enqueues every dropped PR - [A cache error is a miss](/quest/m0/ci/nix-cache-5xx.md) - a transient `nix.moq.dev` 5xx no longer fails `just ci` - [Every io_uring test in the io-uring group](/quest/m0/ci/io-uring-test-group.md) - the parallel tests of one `just ci` stop exhausting the shared `RLIMIT_MEMLOCK`, and a too-low limit fails clearly diff --git a/quest/m0/ci/pr-check.md b/quest/m0/ci/pr-check.md new file mode 100644 index 00000000000..229d89179b9 --- /dev/null +++ b/quest/m0/ci/pr-check.md @@ -0,0 +1,48 @@ +# [S] Check runs on ready main PRs as a signal + +## Goal + +The Check workflow runs on every non-draft PR against `main` again, and on +each push to one. It runs the same changed-lane selection the land gate runs +(`just ci` against the PR's base) and cancels a superseded run. It is a signal +only: it is not a required check, and it never blocks the land gate. The gate +stays the only thing that decides a landing. Whoever picks the PR with +`/quest-merge` sees the result, and the land fixer reads it as context when +the PR's batch fails. + +## Plan + +Decided 2026-11-01 (maintainer, /quest-plan): "I think for moq we should +re-enable Check being run on PRs. The problem was that Github was throttling +us and there was a lot of churn. But I think it's still a useful signal [...] +(open source repos do this because it's free, closed source do not)." + +- Check's role: + - ✅ A signal only + - Gate the fixer: a queued PR sits out batches until Check finishes + - A required check in the ruleset +- Which PRs trigger it, to keep churn and throttling down: + - ✅ Ready-for-review PRs and their pushes; drafts don't run + - Every PR push + - Only queued PRs +- How much it runs: + - ✅ The gate's selection: `just ci` against the PR's base + - Fast lanes only + - Everything (`CI_BASE: --all`, as release PRs do) + +Today `check.yml`'s job has `if: github.base_ref == 'release'`, so main PRs +report Check as skipped +([moq-dev/moq#7024](https://github.com/moq-dev/moq/pull/7024)). Add `main` +for non-draft PRs (`github.event.pull_request.draft == false`, plus the +`ready_for_review` trigger), with `CI_BASE` set to the PR's base for main and +`--all` kept for release. Keep `cancel-in-progress`. The repository's +`MBX_R2_*` pair is already read-only +([Read-only pull-request CI](/quest/m0/ci/read-only-secrets.md)), so a PR +run restores the cache without write credentials. Keep Check out of the +`main` ruleset's required checks. moq.pro stays without PR CI: it's private, +where Actions minutes cost money. + +## Related + +- [Read-only pull-request CI](/quest/m0/ci/read-only-secrets.md) - what keeps a PR run's secrets read-only +- [Land main through quest land](/quest/m0/ci/land.md) - the gate that stays authoritative From 4372e25b03d6a7341fcf43b0b56cb1c1c8f6fff9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 22:10:38 -0800 Subject: [PATCH 40/86] test(bindings): cover MoqBroadcastConsumer.closed in Kotlin, Swift, and Dart (#7540) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- dart/moq/test/moq_test.dart | 10 ++++++ .../kotlin/dev/moq/SmokeTest.kt | 11 +++++++ quest/m2/README.md | 1 - quest/m2/binding-broadcast-end.md | 33 ------------------- swift/Tests/MoqTests/SmokeTests.swift | 8 +++++ 5 files changed, 29 insertions(+), 34 deletions(-) delete mode 100644 quest/m2/binding-broadcast-end.md diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 6360596ae93..d13f5c72f20 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -393,6 +393,16 @@ void main() { expect(() => catalog.finish(), throwsA(isA())); }); + test('a broadcast consumer\'s closed() resolves once it closes', () async { + final broadcast = BroadcastProducer(); + var resolved = false; + final closed = broadcast.consume().closed().then((_) => resolved = true); + await Future.delayed(const Duration(milliseconds: 10)); + expect(resolved, isFalse, reason: 'resolved while the broadcast is live'); + broadcast.close(); + await closed.timeout(timeout); + }); + test('timestamps round-trip exactly at the track scale', () async { final broadcast = BroadcastProducer(); final track = broadcast.publishTrack( diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index c82ead5ca1a..1abb447d9bc 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -262,6 +262,17 @@ class SmokeTest { } } + @Test + fun `a broadcast consumer's closed returns once the broadcast ends`() = runTest { + BroadcastProducer().use { broadcast -> + broadcast.consume().use { consumer -> + val closed = async { consumer.closed() } + broadcast.end() + closed.await() + } + } + } + @Test fun `broadcast updates shared video properties`() { BroadcastProducer().use { broadcast -> diff --git a/quest/m2/README.md b/quest/m2/README.md index 331c25308bf..5f4097b4874 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -233,7 +233,6 @@ lite-07; bitrate enforcement is not part of this release. - [Audio's Latest start reaches back](/quest/m2/audio-latest-reach-back.md) - moq_audio's start covers min plus warmup like other consumers - [moq-lite tails count drops](/quest/m2/lite-tail-drop-count.md) - SUBSCRIBE_DROP accounting replaces the session-tail grace from lite-05 - [Re-run the SPS fuzzer on h264-reader](/quest/m2/sps-fuzz-h264-reader.md) - the H.264 SPS overflow is rechecked on the new parser -- [Bindings see a broadcast end](/quest/m2/binding-broadcast-end.md) - `MoqBroadcastConsumer` exposes the close, and a moq-transport run end is a protocol error - [Audio track changes are gapless](/quest/m2/gapless-audio-switch.md) - no silence beyond a frame on audio format or rendition changes, enforced by media QA - [PCM encodes in one pass](/quest/m2/pcm-bulk-encode.md) - the PCM encoder converts a frame in bulk instead of one sample at a time - [We own our WebRTC stack](/quest/m2/webrtc/README.md) - moq-rtc becomes moq-webrtc with moq-ice, the data-channel transport, and a vendored, trimmed str0m/dimpl/is/sctp-proto diff --git a/quest/m2/binding-broadcast-end.md b/quest/m2/binding-broadcast-end.md deleted file mode 100644 index ce167c0c7eb..00000000000 --- a/quest/m2/binding-broadcast-end.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] Bindings see a broadcast end - -## Goal - -A moq-ffi binding (Go, Kotlin, Swift, Python, Dart) can tell that a -broadcast it is reading has ended, apart from a refusal, without matching -error types. `MoqBroadcastConsumer` exposes the broadcast closing, as the -Rust consumer does. A publisher's run ending reaches bindings the same way -over moq-transport as over moq-lite: as a protocol error, not -`MoqProtocolKind::Internal`. - -## Plan - -Decided 2026-10-30 (moq.pro maintainer, planning the follow-ups of -[moq-dev/moq.pro#3169](https://github.com/moq-dev/moq.pro/pull/3169)). That -PR's Go and Kotlin watch samples tell a run's end from a permanent catalog -refusal only by the error kind: a lite run's end arrives as a protocol -reset. Without a way to see the broadcast close, they can print the old -run's catalog once more during a replacement. A moq-transport run's end -arrives as an internal error, so those samples stop instead of following it. - -- Add a `closed()` (or equivalent) to `MoqBroadcastConsumer` in - `rs/moq-ffi/src/consumer.rs`, resolving when the underlying - `moq_net::broadcast::Consumer` closes. Cover it in each binding's tests. -- Find where a moq-transport publisher's run end becomes - `SessionError::Internal` / `StreamError::Internal` for a subscriber, and - give it the same code lite uses, in both directions of a relay. Cover it in - the session harness for lite-06, lite-07-wip, and moq-transport-22, direct - and through a relay. -- Adding `closed()` is additive. If the error-kind change is a wire or API - break, bring it to the maintainer first, as this milestone's Plan says. -- Downstream, moq.pro's Go and Kotlin samples switch to `closed()` with the - pin bump that brings this in. diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index 922c50a7e0f..dfd88b59d58 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -347,6 +347,14 @@ final class SmokeTests: XCTestCase { XCTAssertThrowsError(try catalog.finish()) } + func testBroadcastConsumerClosedReturnsOnClose() async throws { + let broadcast = try BroadcastProducer() + let consumer = try broadcast.consume() + let closed = Task { try await consumer.closed() } + try broadcast.close() + try await closed.value + } + func testVideoHintsReachMediaPublishApi() throws { let broadcast = try BroadcastProducer() let hint = Media.VideoHint( From da5c2afade8d39b2105f47fb2a490dc6b7fb1022 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 22:11:02 -0800 Subject: [PATCH 41/86] test(rs): run kio and moq-net's in-memory unsafe under Miri nightly (#7315) Co-authored-by: Claude Opus 5.5 Co-authored-by: moq-bot --- .config/nextest.toml | 9 ++++ .github/workflows/nightly.yml | 11 ++++ quest/m2/README.md | 1 - quest/m2/miri-memory.md | 36 ------------- quest/m2/wire-fuzz-coverage.md | 5 +- rs/justfile | 8 +++ rs/kio/src/channel.rs | 2 +- rs/kio/src/clock/runtime.rs | 2 + rs/kio/src/log.rs | 6 ++- rs/kio/src/waiter.rs | 5 +- rs/moq-net/AGENTS.md | 1 + rs/moq-net/src/model/frame.rs | 19 +++++-- rs/moq-net/src/model/group.rs | 10 +++- rs/moq-net/src/path/mod.rs | 5 +- sh/rs/miri.sh | 93 ++++++++++++++++++++++++++++++++++ 15 files changed, 162 insertions(+), 51 deletions(-) delete mode 100644 quest/m2/miri-memory.md create mode 100755 sh/rs/miri.sh diff --git a/.config/nextest.toml b/.config/nextest.toml index 1a5f8ed28b2..63ba5a810e1 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -60,3 +60,12 @@ slow-timeout = { period = "60s", terminate-after = 2 } retries = 0 # Repeat each failure at the end of the run, so it isn't buried in the log. failure-output = "immediate-final" + +# `just rs miri` interprets every instruction, orders of magnitude slower than +# native, so a test that takes a second here takes minutes there. The cap still +# turns a lost wakeup into a TIMEOUT; keep a test under it by shrinking its +# workload under `cfg(miri)`, as with the native ceiling above. +[profile.miri] +slow-timeout = { period = "300s", terminate-after = 3 } +retries = 0 +failure-output = "immediate-final" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index eba11f633a8..3e5a5483061 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -369,6 +369,11 @@ jobs: # virtual camera, a module load no pull request job should make. # `js worklet-matrix` loads `?worklet` output through the bundlers pinned in test/worklet. recipe: ["gate", "rs doctest --workspace", "rs send-depth", "test drill-sensitivity", "rs uring", "test drain", "test json-properties", "test resume-model", "rs quic-sim", "test drill impaired", "rs cli-export", "rs quic-stress", "rs audio-devices", "rs multicast", "rs render-sw", "test qmux", "rs v4l2", "js worklet-matrix"] + include: + # Miri interprets kio's and moq-net's in-memory unsafe; too slow for the merge gate. + - recipe: rs miri + nightly: true + components: miri steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -380,6 +385,12 @@ jobs: with: access-key-id: ${{ secrets.MBX_R2_ACCESS_KEY_ID }} secret-access-key: ${{ secrets.MBX_R2_SECRET_ACCESS_KEY }} + - name: Install nightly Rust + if: matrix.nightly + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: nightly + components: ${{ matrix.components }} - name: Add a second IPv6 loopback address if: matrix.recipe == 'rs uring' # moq-uring's local address test reaches a wildcard socket on it. diff --git a/quest/m2/README.md b/quest/m2/README.md index 5f4097b4874..deb0d525d3a 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -29,7 +29,6 @@ lite-07; bitrate enforcement is not part of this release. - [Untrusted media parsers have fuzz targets](/quest/m2/media-fuzz.md) - fMP4, MKV, FLV, TS, Annex-B, catalog, and AMF0 parsing fuzzed with a bounded-work invariant - [One policy for oversized container metadata](/quest/m2/import-metadata-policy.md) - fMP4 and MKV refuse or stream huge metadata by one documented rule - [Wire fuzz coverage](/quest/m2/wire-fuzz-coverage.md) - the cmsg walks in moq-sock and moq-uring have invariant fuzz targets, and the unsafe peer input reaches runs under Miri -- [In-memory unsafe runs under Miri](/quest/m2/miri-memory.md) - kio's and moq-net's memory-only unsafe runs under a nightly Miri job - [VA-API renderer tests](/quest/m2/vaapi-render-tests.md) - the VA-API renderer, decode, and transcode tests run in `just rs gpu` on AMD and Intel and fail instead of skipping, and hosted CI reports them ignored - [Weekly mutation testing on critical modules](/quest/m2/mutants.md) - cargo-mutants runs weekly on moq-auth and moq-net's permission, resume, and expiry code - [Video quality](/quest/m2/video-quality/README.md) - a nightly VMAF harness grades every encoder backend and on-demand GoP pops against floors, and moq-boy's encode is fixed under it diff --git a/quest/m2/miri-memory.md b/quest/m2/miri-memory.md deleted file mode 100644 index b691394bac6..00000000000 --- a/quest/m2/miri-memory.md +++ /dev/null @@ -1,36 +0,0 @@ -# [M] In-memory unsafe runs under Miri - -## Goal - -The hand-written unsafe in `kio` and `moq-net` that touches only memory (no -sockets, GPU, or FFI) runs under Miri nightly, so a use-after-free, data race, -or aliasing violation fails a test instead of waiting for production. - -## Plan - -From the 2026-10-27 coverage audit. No Miri job exists anywhere in the repo. -[Wire fuzz coverage](/quest/m2/wire-fuzz-coverage.md) covers the -peer-reachable unsafe in `moq-c`, `moq-uring`, and `moq-sock`; this quest -covers the in-memory primitives it leaves out. `kio` has 33 `unsafe` sites -and `moq-net` 21 (2026-10-27), and Loom flagged concurrent `UnsafeCell` -access in kio's `a_log_reader_racing_eviction_never_touches_freed_memory` -that night, so start with kio's log and channels. - -Decided 2026-10-27 (maintainer): - -- Nightly only, never the merge gate: Miri is orders of magnitude slower than - native tests. -- Run kio's unit tests whole, and the `moq-net` tests that reach its unsafe, - selected by filter. Skip a test Miri cannot run (foreign calls, real - sockets, wall-clock time) with `#[cfg_attr(miri, ignore = "...")]` naming - why, not by narrowing the filter silently. -- Share one `just rs miri` recipe and nightly job with wire fuzz coverage: - whichever lands first creates it, the other adds crates. -- Fix what it finds at the cause. - -Public API: none. Wire: none. - -## Related - -- [Wire fuzz coverage](/quest/m2/wire-fuzz-coverage.md) - Miri for the peer-reachable unsafe -- [Nightly passes on main](/quest/m0/nightly/README.md) - the nightly this job joins diff --git a/quest/m2/wire-fuzz-coverage.md b/quest/m2/wire-fuzz-coverage.md index b3fabbdf864..0da12bd6d36 100644 --- a/quest/m2/wire-fuzz-coverage.md +++ b/quest/m2/wire-fuzz-coverage.md @@ -11,7 +11,7 @@ The moq-net half is done: the `ingest` and `request` targets cover the increment Decided 2026-10-22 from the security audit, with the maintainer: - Add a target for `moq-sock`'s cmsg walk (`cmsg::Iter` and `ControlMetadata::decode` in `rs/moq-sock/src/udp/unix.rs`) and for `moq-uring`'s `RecvMeta::parse` (`rs/moq-uring/src/udp.rs`). Both walks must stop at a malformed length and keep what came before it: build well-formed control messages from the input, append a malformed header and arbitrary bytes, and assert the walk reads the same metadata as the well-formed prefix, and the metadata a reference model expects from it. -- Add a Miri job for the crates whose unsafe is reachable from a peer. +- Add the crates whose unsafe is reachable from a peer to `just rs miri` (`sh/rs/miri.sh`), which Nightly already runs for `kio` and `moq-net`. - Wire every target and the Miri run into the nightly gate, and keep the merge-gate replay of committed regressions. The point is the invariant, not the coverage number: a target that only checks for a panic would miss the bugs this audit is about. @@ -21,7 +21,7 @@ Each crate follows moq-net's shape (`rs/moq-net/fuzz/README.md`): the target bod Ordering: - `moq-sock`'s `CMsg::decode` still reads `size_of::()` past a short message (only a `debug_assert_eq!` checks it), which [Bound cmsg payload reads](https://github.com/moq-dev/moq/pull/7394) fixes. A moq-sock target landing first would fail nightly on that known overread, so land it after, or with, that fix. The moq-uring target has no such dependency. -- [In-memory unsafe under Miri](/quest/m2/miri-memory.md) shares one `just rs miri` recipe and nightly job with this quest: whichever lands first creates it, and the other adds its crates. +- `just rs miri` and its nightly job already exist ([moq-dev/moq#7315](https://github.com/moq-dev/moq/pull/7315)); this quest adds its crates to them. Public API: none. Wire: none. @@ -29,5 +29,4 @@ Public API: none. Wire: none. - [CI cost and latency](/quest/m0/ci/README.md) - where the nightly budget lives - [Bound cmsg payload reads](https://github.com/moq-dev/moq/pull/7394) - fixes the moq-sock overread a cmsg target would hit -- [In-memory unsafe under Miri](/quest/m2/miri-memory.md) - shares the Miri recipe and nightly job - [Untrusted media parsers have fuzz targets](/quest/m2/media-fuzz.md) - the media parser targets beside these diff --git a/rs/justfile b/rs/justfile index 019382be7a0..eb5b90ec00d 100644 --- a/rs/justfile +++ b/rs/justfile @@ -302,6 +302,14 @@ play *args: doctest *args: bash sh/local.sh cargo test --locked --doc "$@" +# Needs nightly with the miri component; executes only in Nightly. See sh/rs/miri.sh +# for what runs and why it splits in two. + +# Run kio's and moq-net's hand-written in-memory unsafe under Miri. +[positional-arguments] +miri *args: + bash sh/rs/miri.sh "$@" + # Run the moq-quic simulation's random workloads from a fresh seed, printing the settings that replay it, then the bandwidth probe's acceptance suite and the receive-timestamps comparison. quic-sim: #!/usr/bin/env bash diff --git a/rs/kio/src/channel.rs b/rs/kio/src/channel.rs index ec9534c06db..ed2f57a0359 100644 --- a/rs/kio/src/channel.rs +++ b/rs/kio/src/channel.rs @@ -495,7 +495,7 @@ mod tests { #[test] fn racing_publishes_and_closes_wake_every_waiter() { const READERS: usize = 4; - const STEPS: u32 = 20_000; + const STEPS: u32 = if cfg!(miri) { 1_000 } else { 20_000 }; struct Unpark(std::thread::Thread); impl Wake for Unpark { diff --git a/rs/kio/src/clock/runtime.rs b/rs/kio/src/clock/runtime.rs index 0beec1e1403..37eb777174a 100644 --- a/rs/kio/src/clock/runtime.rs +++ b/rs/kio/src/clock/runtime.rs @@ -284,6 +284,7 @@ mod tests { /// A paused runtime counting from its own origin anchors on its own clock, not /// on one another runtime advanced. #[test] + #[cfg_attr(miri, ignore = "re-runs itself in a fresh process, which Miri cannot spawn")] fn paused_runtimes_own_their_anchors() { // A fresh process keeps other tests from taking the shared origin first. if !test_env::Env::default().set("MOQ_CLOCK_TEST_ORIGIN", "fresh").isolate() { @@ -316,6 +317,7 @@ mod tests { /// A paused runtime sharing the origin cannot skew the anchor of unpaused /// callers by advancing its clock before the anchor is first taken. #[test] + #[cfg_attr(miri, ignore = "reads the wall clock, which Miri's isolation refuses")] fn a_later_paused_runtime_does_not_skew_the_anchor() { let _ = crate::clock::now(); // Built after the origin, so it counts from the shared origin. diff --git a/rs/kio/src/log.rs b/rs/kio/src/log.rs index fad879f74f4..4578d14b97e 100644 --- a/rs/kio/src/log.rs +++ b/rs/kio/src/log.rs @@ -977,6 +977,8 @@ mod test { /// it evicts behind them. #[test] fn concurrent_readers_see_every_entry() { + // Miri takes minutes over the full run; a tenth still evicts under the readers. + const ENTRIES: u64 = if cfg!(miri) { 1_000 } else { 10_000 }; let _collector = COLLECTOR.lock().unwrap(); let mut writer = Writer::new(0); let readers: Vec<_> = (0..4) @@ -985,7 +987,7 @@ mod test { std::thread::spawn(move || { let mut index = 0; let mut evicted = 0; - while index < 10_000 { + while index < ENTRIES { match reader.read().get(index) { Ok(Some(value)) => assert_eq!(*value, index), Ok(None) => { @@ -1000,7 +1002,7 @@ mod test { }) }) .collect(); - for i in 0..10_000u64 { + for i in 0..ENTRIES { writer.push(i); if i % 100 == 0 { writer.evict(i.saturating_sub(500)); diff --git a/rs/kio/src/waiter.rs b/rs/kio/src/waiter.rs index 2d2005cb616..30f58a8c8ca 100644 --- a/rs/kio/src/waiter.rs +++ b/rs/kio/src/waiter.rs @@ -607,8 +607,11 @@ mod tests { waiter.register(&mut list); } + // Miri takes over twenty minutes for the full run; a thousand rounds still far + // outnumber the bound. + let rounds = if cfg!(miri) { 1_000 } else { 100_000 }; let mut seed = 1u64; - for _ in 0..100_000 { + for _ in 0..rounds { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); let i = (seed >> 33) as usize % LIVE; waiters[i] = Waiter::noop(); diff --git a/rs/moq-net/AGENTS.md b/rs/moq-net/AGENTS.md index 89318f865fd..667e94c04b5 100644 --- a/rs/moq-net/AGENTS.md +++ b/rs/moq-net/AGENTS.md @@ -19,3 +19,4 @@ The wire layer. Generic over the transport and media-agnostic: the relay never i - Tests never use tokio or the wall clock. Prefer a plain `#[test]` that polls with explicit instants; async bodies use `#[kio::test]`, whose simulated time jumps only when every task is stalled. tokio is for the benches. - `just rs fuzz ` (one per file in `fuzz/fuzz_targets/`) needs nightly. The target bodies live in `src/fuzz.rs`; `fuzz/regressions//` replays under `just check`, so commit every crash input there. +- `just rs miri` runs kio's and this crate's model tests under Miri, in Nightly (`sh/rs/miri.sh` lists them). A test Miri cannot run (foreign calls, sockets, the wall clock) carries `#[cfg_attr(miri, ignore = "...")]` naming why; a heavy workload shrinks under `cfg(miri)` instead. A test whose point is a full-size production limit (such as filling `MAX_CACHE_BYTES`) and which can't finish under the `miri` profile's 15-minute cap is ignored the same way, with the reason. diff --git a/rs/moq-net/src/model/frame.rs b/rs/moq-net/src/model/frame.rs index 2b78458e3dd..497243e03e9 100644 --- a/rs/moq-net/src/model/frame.rs +++ b/rs/moq-net/src/model/frame.rs @@ -89,6 +89,17 @@ impl Frame { /// one, so a whole read of them never copies. pub(crate) const SEGMENT: usize = 1024 * 1024; +/// `len` test bytes counting up from `seed` modulo 251, a prime, so the pattern never +/// lines up with a [`SEGMENT`] and a segment out of place shows. Repeated rather than +/// generated byte by byte, which takes Miri minutes for a few segments. +#[cfg(test)] +pub(crate) fn pattern(seed: usize, len: usize) -> Vec { + let period: Vec = (0..251).map(|i| ((seed + i) % 251) as u8).collect(); + let mut bytes = period.repeat(len.div_ceil(period.len())); + bytes.truncate(len); + bytes +} + /// A completed frame's payload as its group stores it: one buffer, or the sealed /// segments of a frame larger than [`SEGMENT`] written in pieces. /// @@ -1680,7 +1691,7 @@ mod tests { #[test] fn a_conflicting_delivery_leaves_shared_content_and_progress_unchanged() { let size = SEGMENT + 8; - let payload: Vec<_> = (0..size).map(|i| (i % 251) as u8).collect(); + let payload = pattern(0, size); let a = FrameBuf::growing(size); a.append(&payload[..SEGMENT + 4]).unwrap(); let b = a.fork(); @@ -1725,7 +1736,7 @@ mod tests { fn simultaneous_deliveries_publish_one_segmented_payload() { const SIZE: usize = 2 * SEGMENT + 37; const ROUNDS: usize = 12; - let payload: Vec<_> = (0..SIZE).map(|i| (i % 251) as u8).collect(); + let payload = pattern(0, SIZE); for grow in [false, true] { let a = FrameBuf::with_growth(SIZE, grow); let b = a.fork(); @@ -1944,7 +1955,7 @@ mod tests { } }); - let payload: Vec = (0..SIZE).map(|i| (i % 251) as u8).collect(); + let payload = pattern(0, SIZE); for chunk in payload.chunks(1000) { buf.append(chunk).unwrap(); } @@ -1960,7 +1971,7 @@ mod tests { const SIZE: usize = 2 * SEGMENT + 100; // Straddles every segment boundary. const CHUNK: usize = 300_000; - let payload: Vec = (0..SIZE).map(|i| (i % 251) as u8).collect(); + let payload = pattern(0, SIZE); for grow in [false, true] { let buf = FrameBuf::with_growth(SIZE, grow); diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index d66c6450ca9..9660405062d 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -4812,6 +4812,7 @@ mod test { /// A head's slots count against the live tail, so a head of empty frames that would /// take the group's slots past it is not adopted. #[test] + #[cfg_attr(miri, ignore = "fills the 32 MiB group tail, past Miri's 15-minute cap")] fn an_adopted_head_counts_its_slots() { let pool = cache::Pool::unbounded(); let cache = cache::Track::new(pool, Default::default(), kio::Weak::new()); @@ -5026,6 +5027,7 @@ mod test { /// A group grows past its live tail by shedding whole frames from its front: a reader /// that keeps up reads every frame, a late one gets `Evicted`, and nothing aborts. #[test] + #[cfg_attr(miri, ignore = "fills the 32 MiB group tail, past Miri's 15-minute cap")] #[cfg(feature = "async")] fn a_long_group_sheds_its_front() { let pool = cache::Pool::unbounded(); @@ -5061,6 +5063,7 @@ mod test { /// Empty frames still cost their slot, so a group of them sheds its front too. #[test] + #[cfg_attr(miri, ignore = "fills the 32 MiB group tail, past Miri's 15-minute cap")] fn empty_frames_shed_by_their_slots() { let mut producer = Info { sequence: 0 }.produce(); let slot = size_of::(); @@ -5077,6 +5080,7 @@ mod test { /// Batches of empty frames count their new slots too, so they shed the front as /// single writes do. #[test] + #[cfg_attr(miri, ignore = "fills the 32 MiB group tail, past Miri's 15-minute cap")] fn empty_frame_batches_shed_by_their_slots() { let mut producer = Info { sequence: 0 }.produce(); let count = 2 * MAX_CACHE_BYTES as usize / size_of::(); @@ -5318,6 +5322,7 @@ mod test { /// Frames a cursor already prefetched are its own, so shedding them from the group /// does not make `finished` report the cursor evicted. #[test] + #[cfg_attr(miri, ignore = "fills the 32 MiB group tail, past Miri's 15-minute cap")] #[cfg(feature = "async")] fn finished_counts_prefetched_frames_as_read() { let mut producer = Info { sequence: 0 }.produce(); @@ -5519,6 +5524,7 @@ mod test { /// An explicit current timestamp is converted to the group's scale. #[test] + #[cfg_attr(miri, ignore = "reads the wall clock, which Miri's isolation refuses")] fn create_frame_converts_current_timestamp() { use crate::Timescale; @@ -5913,7 +5919,7 @@ mod test { let payloads: Vec = (0..Prefetch::CAP) .map(|i| match i % 2 { 0 => Bytes::from(vec![i as u8; 3]), - _ => Bytes::from((0..2 * frame::SEGMENT + 7).map(|b| (b + i) as u8).collect::>()), + _ => Bytes::from(frame::pattern(i, 2 * frame::SEGMENT + 7)), }) .collect(); for payload in &payloads { @@ -5993,6 +5999,7 @@ mod test { /// front, and sheds its own front past the tail; a plain or raw reader that starts after /// frame 0 was shed gets `Evicted`. #[test] + #[cfg_attr(miri, ignore = "fills the 32 MiB group tail, past Miri's 15-minute cap")] #[cfg(feature = "async")] fn a_long_compressed_group_sheds_its_front() { let pool = cache::Pool::unbounded(); @@ -6039,6 +6046,7 @@ mod test { /// A batch of large frames that deflate to almost nothing stays within one tail of /// inflated bytes, and the copy sheds what the reader took past its tail. #[test] + #[cfg_attr(miri, ignore = "fills the 32 MiB group tail, past Miri's 15-minute cap")] #[cfg(feature = "async")] fn a_batch_of_large_compressible_frames_stays_in_the_tail() { let pool = cache::Pool::unbounded(); diff --git a/rs/moq-net/src/path/mod.rs b/rs/moq-net/src/path/mod.rs index 44d1050126e..2da24fa99d5 100644 --- a/rs/moq-net/src/path/mod.rs +++ b/rs/moq-net/src/path/mod.rs @@ -1065,8 +1065,9 @@ mod tests { #[test] #[should_panic(expected = "longer than")] fn test_from_static_too_long() { - let literal: &'static str = Box::leak("a".repeat(Path::MAX_BYTES + 1).into_boxed_str()); - Path::from_static(literal); + // A static rather than a leaked string, which Miri would report. + static LITERAL: [u8; Path::MAX_BYTES + 1] = [b'a'; Path::MAX_BYTES + 1]; + Path::from_static(std::str::from_utf8(&LITERAL).unwrap()); } #[test] diff --git a/sh/rs/miri.sh b/sh/rs/miri.sh new file mode 100755 index 00000000000..32cdbf903bb --- /dev/null +++ b/sh/rs/miri.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Run the hand-written in-memory unsafe under Miri: sh/rs/miri.sh [NEXTEST_ARGS...] +# +# Nightly only: Miri interprets every instruction, orders of magnitude slower than a +# native test, so the `miri` nextest profile gives each test longer. A test Miri cannot +# run (foreign calls, real sockets, the wall clock) carries +# `#[cfg_attr(miri, ignore = "...")]` rather than leaving a filter here. +# +# Two passes, by whether a test reaches crossbeam-epoch's global collector, which +# `kio::log` frees segments through: +# +# - strict: Stacked Borrows and the leak check. +# - epoch: Tree Borrows, because the collector's intrusive list breaks Stacked Borrows +# (https://github.com/crossbeam-rs/crossbeam/issues/545), and no leak check, because +# the global collector never runs at exit, so whatever it still holds then leaks. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +if [[ "$(rustc --version)" == *-nightly* ]]; then + sysroot=$(rustc --print sysroot) +elif command -v rustup >/dev/null 2>&1; then + sysroot=$(rustup run nightly rustc --print sysroot) +else + echo "rs: nightly Rust is missing; select it or run rustup toolchain install nightly --component miri" >&2 + exit 1 +fi + +# Nix puts its pinned stable compiler first; cargo and rustc must both be nightly. +export PATH="$sysroot/bin:$PATH" +if ! cargo miri --version >/dev/null 2>&1; then + echo "rs: Miri is missing: rustup component add --toolchain nightly miri" >&2 + exit 1 +fi +rustc --version + +# Nextest unions repeated filtersets, so a caller's `-E` would add tests to a pass +# rather than narrow it; intersect it with each pass's filter instead. +args=() +filters=() +while (($#)); do + case $1 in + --) + args+=("$@") + break + ;; + -E | --filterset) + filters+=("${2:?$1 needs a filterset}") + shift 2 + ;; + -E*) + filters+=("${1#-E}") + shift + ;; + --filterset=*) + filters+=("${1#*=}") + shift + ;; + *) + args+=("$1") + shift + ;; + esac +done +caller='all()' +if ((${#filters[@]})); then + caller=$(printf ' | (%s)' "${filters[@]}") + caller=${caller# | } +fi + +# Nextest exits 4 when a pass selects no tests. Without arguments that means a filter +# below went stale; with them, the caller may have narrowed the run to one pass. +ran=0 +pass() { + local status=0 + cargo miri nextest run --locked --profile miri --no-fail-fast -p kio -p moq-net --lib \ + -E "($1) & ($caller)" ${args[@]+"${args[@]}"} || status=$? + case $status in + 0) ran=1 ;; + 4) ((${#args[@]} + ${#filters[@]})) || exit 4 ;; + *) exit "$status" ;; + esac +} + +pass '(package(kio) & not test(/^log::/)) | (package(moq-net) & (test(/^model::frame::/) | test(/^path::/)))' + +MIRIFLAGS="${MIRIFLAGS:+$MIRIFLAGS }-Zmiri-tree-borrows -Zmiri-ignore-leaks" \ + pass '(package(kio) & test(/^log::/)) | (package(moq-net) & test(/^model::group::/))' + +if ((! ran)); then + echo "rs: no test matched in either Miri pass" >&2 + exit 4 +fi From bc2468e8be563c0d2c2796e815f5744a1ebc120c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 22:26:58 -0800 Subject: [PATCH 42/86] quest: plan follow-ups from the 2026-10-31 /quest-complete requeue session (#7468) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot --- quest/m0/ci/README.md | 1 + quest/m0/ci/dispatch-selection-gaps.md | 36 ++++++++++++++++++ quest/m0/test-flakes-2/README.md | 1 + quest/m0/test-flakes-2/kotlin-js-flakes.md | 29 ++++++++++++++ quest/m1/README.md | 3 ++ quest/m1/announce-takeover.md | 22 +++++++++++ quest/m1/catalog-jitter-floor.md | 44 ++++++++++++++++++++++ quest/m1/fmp4-refusal-framing.md | 43 +++++++++++++++++++++ quest/m1/hls-conformance.md | 6 +++ quest/m1/mkv-leaf-limits.md | 35 +++++++++++++++++ quest/m2/README.md | 9 ++++- quest/m2/audit-lints.md | 14 +++++++ quest/m2/auth-drop-iroh.md | 30 +++++++++++++++ quest/m2/c-origin-relay.md | 34 +++++++++++++++++ quest/m2/dash-reconfigure.md | 44 ++++++++++++++++++++++ quest/m2/format-gate-followups.md | 27 +++++++++++++ quest/m2/import-metadata-policy.md | 2 +- quest/m2/lint-examples.md | 10 ++++- quest/m2/media-fuzz.md | 11 ++++++ quest/m2/net-test-subscriber.md | 31 +++++++++++++++ quest/m2/net-wrap/e2ee.md | 32 ++++++++++++++++ quest/m2/noq-congestion.md | 31 --------------- quest/m2/relay-undialable-peers.md | 40 ++++++++++++++++++++ 23 files changed, 500 insertions(+), 35 deletions(-) create mode 100644 quest/m0/ci/dispatch-selection-gaps.md create mode 100644 quest/m0/test-flakes-2/kotlin-js-flakes.md create mode 100644 quest/m1/catalog-jitter-floor.md create mode 100644 quest/m1/fmp4-refusal-framing.md create mode 100644 quest/m1/mkv-leaf-limits.md create mode 100644 quest/m2/auth-drop-iroh.md create mode 100644 quest/m2/c-origin-relay.md create mode 100644 quest/m2/dash-reconfigure.md create mode 100644 quest/m2/format-gate-followups.md create mode 100644 quest/m2/net-test-subscriber.md delete mode 100644 quest/m2/noq-congestion.md create mode 100644 quest/m2/relay-undialable-peers.md diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index d7a6897dee1..6f7f7f1d6cc 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -167,5 +167,6 @@ The historical samples locate candidates; they are not controlled benchmarks. - [The queue host keeps the queue's disk](/quest/m0/ci/queue-host-disk.md) - agent sessions can't starve the queue runner of disk, and a crashed runner restarts - [The next test-CPU cuts](/quest/m0/ci/test-cost-followups.md) - the slow-test leftovers from #6987, cut at their source - [Biome checks every file it can parse](/quest/m0/ci/biome-whole-repo.md) - Biome runs over the whole repository and main is clean under it +- [Dispatcher tool changes run the dispatcher tests](/quest/m0/ci/dispatch-selection-gaps.md) - a tool-list change runs `sh/dispatch-query.test.py` in CI, and `sh/rs/uring.test.py` stops printing jq errors - [dispatch.sh's tests pass without KVM](/quest/m0/ci/dispatch-test-kvm.md) - `sh/dispatch-query.test.py` fakes the KVM probe instead of failing 20 tests on hosts without `/dev/kvm` - [mbx launches Cargo with any argument length](/quest/m0/ci/mbx-arg-max.md) - no "Argument list too long" in `sh/rs/select.test.py`'s recipe test diff --git a/quest/m0/ci/dispatch-selection-gaps.md b/quest/m0/ci/dispatch-selection-gaps.md new file mode 100644 index 00000000000..430879cc10c --- /dev/null +++ b/quest/m0/ci/dispatch-selection-gaps.md @@ -0,0 +1,36 @@ +# [S] Dispatcher tool changes run the dispatcher tests + +## Goal + +A change to the dispatcher's tool lists (`sh/dispatch.sh` and the files it +reads them from) runs `sh/dispatch-query.test.py` in CI, so a tool added to a +module can't break that suite on `main` unnoticed. `sh/rs/uring.test.py` +runs without the repeated `jq: error ... null (null) cannot be matched` +lines. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), from +[moq-dev/moq#7432](https://github.com/moq-dev/moq/pull/7432). + +- **Selection.** [6633d2f2e2](https://github.com/moq-dev/moq/commit/6633d2f2e2) + added `zizmor` to the `gh` module's tools in `sh/dispatch.sh`, and every + `CI_STRICT` case of `sh/dispatch-query.test.py` then failed with "these + tools are missing: zizmor" on `main`; #7432 fixes the stub. `run_lane` in + `sh/dispatch.sh` says gh's tests run on every `check` diff, and a change to + `sh/dispatch.sh` selects everything, so find why the commit's CI didn't run + or didn't fail on the suite (`.github/justfile` `test`, the CI lane that + runs it, the land gate) and fix that gap rather than adding a path rule. +- **jq noise.** `python3 sh/rs/uring.test.py` passes but prints + `jq: error (at :36): null (null) cannot be matched, as it is not a + string` four times. Its cargo-metadata fixture has no `workspace_root` or + `targets`, which the seed filter in `sh/rs/select.sh` (around line 66) + reads. Make the fixture complete, or make the filter tolerate their + absence, whichever matches real `cargo metadata` output. + +Public API: none. Wire: none. + +## Related + +- [workspace.test.py ignores the caller's gate filter](https://github.com/moq-dev/moq/pull/7432) - #7432's quest, the same dispatcher test environment +- [moq-dev/moq#7440](https://github.com/moq-dev/moq/pull/7440) - plans `quest/m0/ci/check-selection.md`, which makes a jq error in Rust selection fail the run; that turns this jq noise into a failure, so land the fixture fix first or together. It does not cover the tool-list selection gap diff --git a/quest/m0/test-flakes-2/README.md b/quest/m0/test-flakes-2/README.md index f2b20490eed..effc428413c 100644 --- a/quest/m0/test-flakes-2/README.md +++ b/quest/m0/test-flakes-2/README.md @@ -59,3 +59,4 @@ Public API: none. Wire: none. - [IETF22 fallback](/quest/m0/test-flakes-2/ietf22-fallback.md) - an IETF22 browser viewer follows a fallback within the media lane's bound, keeping its session and its audio - [moq-relay's auth-trust test survives load](/quest/m0/test-flakes-2/relay-auth-trust.md) - it stops timing out on a busy host - [Draft-22 interop with a native publisher](/quest/m0/test-flakes-2/draft22-interop.md) - no "invalid fetch range" or audio gap with a Rust or C++ publisher +- [A Kotlin cancel test and a JS late-publisher test hold up](/quest/m0/test-flakes-2/kotlin-js-flakes.md) - the Kotlin flow-cancel test stops failing `unroutable` and the IETF blind-handle test passes under load diff --git a/quest/m0/test-flakes-2/kotlin-js-flakes.md b/quest/m0/test-flakes-2/kotlin-js-flakes.md new file mode 100644 index 00000000000..67a43838498 --- /dev/null +++ b/quest/m0/test-flakes-2/kotlin-js-flakes.md @@ -0,0 +1,29 @@ +# [S] A Kotlin cancel test and a JS late-publisher test hold up + +## Goal + +Two intermittent failures are fixed at their cause: the Kotlin test +"cancelling a flow collection cancels its native handle" no longer fails +with `unroutable`, and the JS integration test "ietf blind handle picks up a +publisher that arrives late" passes under a heavily loaded `just check`. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session): +root-cause both in one quest, since each is small and they surfaced +together in that session's merge runs. + +- **Kotlin.** `kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt:579`. + An `unroutable` answer means no route served the path when the request + resolved. A request made before the publisher's route reached the origin + returns it, and so does one made after that route was withdrawn or the + broadcast closed. Check both a wait on announcement that the test skips + and a route withdrawal or close during the collection it cancels. +- **JS.** `js/net/src/integration.test.ts:1994`, the moq-transport variant + of the lite test at `:1929`. Under load the late publisher's arrival and + the blind handle's wait race some deadline; find which, and replace the + wall-time dependency with an event or a controlled clock. + +Follow the epic's rules: no raised timeout and no retry. + +Public API: none. Wire: none. diff --git a/quest/m1/README.md b/quest/m1/README.md index 492020cee7c..39e5be624b6 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -135,6 +135,8 @@ per-frame CPU growth since #5926. - [Address groups by timestamp](/quest/m1/time-addressing/README.md) - group start timestamps replace sequence numbers in the moq-net API and lite-07 wire, while old subscribers, old publishers, and published JS clients keep working - [Pre-cut audit findings](/quest/m1/cut-audit/README.md) - fix the crashes, hostile-input costs, and correctness bugs the 2026-10-24 audit of main found - [Vendored code has every upstream fix it needs](/quest/m1/vendor-sync/README.md) - quinn, libv4l-rs, nvidia-video-codec-sdk, rust-media-libs, cros-codecs, and UniFFI generator fixes are ported or planned, once per release cycle +- [fMP4 refusals keep framing](/quest/m1/fmp4-refusal-framing.md) - a box refused at its header stops dropping the rest of its call's input, and the interleaved import benchmark is re-measured +- [MKV leaf element limits](/quest/m1/mkv-leaf-limits.md) - `CodecID`, `DocType`, and `CodecPrivate` are refused past per-element size limits instead of read whole - [Delete the tests that don't earn their cost](/quest/m1/prune-tests.md) - an off-cycle test cost sweep deletes tests whose cost outweighs their signal and makes the rest cheaper - [Remove the dependencies that don't earn their weight](/quest/m1/prune-deps.md) - Rust, JS, and vendored dependencies that are trivial, duplicated, or serve features not worth keeping are removed - [Remove what dominates build time](/quest/m1/prune-build-time.md) - the crates, features, and code that cost the most build time are removed or made cheaper @@ -177,6 +179,7 @@ per-frame CPU growth since #5926. - [Interop coverage](/quest/m1/interop-coverage/README.md) - the riskiest moq-transport features moq-net exposes get cross-implementation tests over drafts 18 and 22 - [Calibrate the paired nightly benchmarks](/quest/m1/bench-ci.md) - A/A and deliberately slowed Nightly runs set the paired comparison's threshold, so its regression alerts can be trusted - [The shared pacer holds its rate under 2 Mbit/s](/quest/m1/pacer-low-rate.md) - a rate-paced controller stops sending up to twice its pacing rate at low rates, landed with a BBR3 retune that keeps the probe budgets +- [A reconfigure keeps the published jitter and delay floor](/quest/m1/catalog-jitter-floor.md) - `Rendition::set` floors unsupplied jitter and delay at the catalog's values, so a same-codec reconfigure after a jitter rise no longer aborts TS import - [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - nightly ffprobe checks that advertised timing, sync flags, and bytes match the media served - [Unbounded groups](/quest/m1/unbounded-groups/README.md) - groups and frames lose their size caps; a group keeps a 32 MB tail, and readers behind it replay through paced flights or refresh - [Legacy lite close drain](/quest/m1/lite-legacy-close-drain.md) - a lite-01..06 graceful close never cuts a final group the subscriber has not read diff --git a/quest/m1/announce-takeover.md b/quest/m1/announce-takeover.md index b65fb866c63..c6e8fcc3c62 100644 --- a/quest/m1/announce-takeover.md +++ b/quest/m1/announce-takeover.md @@ -116,6 +116,28 @@ Remove the cold-start warm-up reads (`claimed` in assert that the first subscription succeeds. Keep them for moq-transport. Run `just check`, `just test interop --all` and `just drafts check`. +### Once identity replaces ANNOUNCE_TAKEOVER + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session). Kept +separate from the identity rewrite of this Plan, which landed with +[moq-dev/moq#7420](https://github.com/moq-dev/moq/pull/7420). +"We're replacing EPOCH with IDENTITY soon" (maintainer, 2026-10-31). + +- Reword "takeover" as an event or operation, in terms of identity + continuity, in [moq-bench pins the instances it measures](/quest/m2/bench-epoch-pin.md) + (Goal and Plan), [the HLS server follows each path once](/quest/m2/hls-follow-once.md) + (Goal and Tests), and [routes and announces](/quest/m1/cluster-routing/routes.md) + (around line 37, "the separately proven Takeover operation"). +- Rewrite the EPOCH references in `drafts/draft-lcurley-moq-cluster.md` and + `drafts/draft-lcurley-moq-epoch.md` in terms of IDENTITY. +- Add a regression test where a publisher restart's new announcement + arrives before the old one's reset, on `solicit = false` moq-transport + sessions. [moq-dev/moq#7239](https://github.com/moq-dev/moq/pull/7239) + found that the subscriber (`rs/moq-net/src/ietf/subscriber.rs`, `attach`) + folds such a PUBLISH_NAMESPACE into the same epochless route as an update + instead of End then Start; per-announcement identities resolve it, and + the test proves they do. + Public API: breaking; removes `Takeover` and the predecessor fields in every language. Wire: removes lite-07's ANNOUNCE_TAKEOVER (0x5), adding nothing beyond [Identity and epoch](/quest/m1/api-audit/route/identity.md)'s fields. diff --git a/quest/m1/catalog-jitter-floor.md b/quest/m1/catalog-jitter-floor.md new file mode 100644 index 00000000000..1979c48bab2 --- /dev/null +++ b/quest/m1/catalog-jitter-floor.md @@ -0,0 +1,44 @@ +# [S] A reconfigure keeps the published jitter and delay floor + +## Goal + +Re-setting an already-published rendition, such as an H.264 or H.265 +same-codec resolution change, never lowers the jitter or delay its catalog +already advertises for a field the new config leaves unset. The reconfigure +publishes with the higher of the two, as a measurement does, instead of +failing with `JitterDecreased` and aborting the TS import. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), found +by the sessions of [moq-dev/moq#7436](https://github.com/moq-dev/moq/pull/7436) +and [moq-dev/moq#7387](https://github.com/moq-dev/moq/pull/7387). Planned +here in m1 as its own [S] rather than fixed inside #7436 (no test there needs +it) or dropped: it blocks the conformance test in #7387 once that fixture is +repaired. + +`Rendition::set` (`rs/moq-mux/src/catalog/tracks.rs:655`) resolves the new +config from what it supplies plus the last measurement (`Self::resolved`), +then `check_decrease` refuses anything below the catalog's current jitter or +delay. `Rendition::estimate` (`:711`) already floors its result at the +published values (`resolved.jitter.max(published.jitter)`, `:725-726`). +After a measurement raises jitter mid-run, a reconfigure that supplies no +jitter resolves to the lower detected value and fails. Floor the unsupplied +jitter and delay in `set` at the catalog's published values the same way, so +`check_decrease` still refuses only a value the caller explicitly lowers. + +`published_text_jitter_never_decreases` (`:1078`) expects a `None` re-set +after a supplied 100 ms to fail; with the floor it publishes 100 ms. Keep +the explicit-lower case failing and update the `None` case to the floor. + +Regression test: a unit test in `tracks.rs` that publishes a video config, +raises its jitter with `estimate`, then `set`s a config with new dimensions +and no jitter, and asserts it succeeds with the raised jitter still +published. + +Public API: none. Wire: none. + +## Related + +- [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - the conformance test this unblocks +- [HLS export follows a resolution change](/quest/m1/hls-reinit-same-codec.md) - the same-codec reconfigure that hits it diff --git a/quest/m1/fmp4-refusal-framing.md b/quest/m1/fmp4-refusal-framing.md new file mode 100644 index 00000000000..ca06ec8e596 --- /dev/null +++ b/quest/m1/fmp4-refusal-framing.md @@ -0,0 +1,43 @@ +# [S] fMP4 refusals keep framing + +## Goal + +A box the fMP4 importer refuses at its header no longer leaves a caller +feeding arbitrary chunks out of frame. Either the importer reads the rest of +that decode call's input past the refused box, so later calls stay +correctly framed whatever chunk boundaries the caller picks, or the refusal +is terminal and every later call fails, so no input is silently dropped +mid-stream. The 4-track +interleaved import benchmark is no slower than before the streaming change, +or the cost is measured and accepted. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), from +the follow-ups of [moq-dev/moq#7232](https://github.com/moq-dev/moq/pull/7232) +(fMP4 streams mdat) and [moq-dev/moq#7234](https://github.com/moq-dev/moq/pull/7234) +(MKV streams blocks). Both have landed. #7234 landed first, so #7232 already +streams the importer's samples through `Producer::open`/`commit`; the +original open/commit bullet is done, and this quest keeps the other two +([noted on moq-dev/moq#7468](https://github.com/moq-dev/moq/pull/7468#issuecomment-6554670247)). + +- **A refused box.** `Import::decode` (`rs/moq-mux/src/container/fmp4/import.rs`) + documents it: a refused box or fragment discards the rest of its body, and + the rest of the refusing call's input is not read. That suits box-aligned + callers (HLS ingest feeds one segment per call) but loses framing for + arbitrary chunking. Either continue past the refused box, or make a + refusal terminal for the importer as the MKV importer's is. Choose during + implementation the option that keeps the importer's state simpler while + meeting HLS ingest's needs, and record the choice in the PR. +- **Benchmark.** #7232 measured a possible ~25% slowdown in the 4-track + 16-run whole-input `cmaf-interleaved-import` case, in a noisy run where the + 4-track cases swung ±40% between runs of one source. Re-measure it with the + paired comparison on a quiet host, against the commit before #7232, and + fix it or record why it is accepted. + +Public API: none expected. Wire: none. + +## Related + +- [MKV leaf element limits](/quest/m1/mkv-leaf-limits.md) - the other leftover of the importer streaming work +- [Media fuzz](/quest/m2/media-fuzz.md) - would catch a dropped-input bug like the refused-box one diff --git a/quest/m1/hls-conformance.md b/quest/m1/hls-conformance.md index 72a06a67c69..ca4c6426ff2 100644 --- a/quest/m1/hls-conformance.md +++ b/quest/m1/hls-conformance.md @@ -33,9 +33,15 @@ Decided 2026-10-30 (maintainer): fix first. The two bugs the test found are their own quests, required below, and the test lands once it passes with both checks intact. +Added 2026-10-31 (maintainer, the /quest-complete requeue session): once +the fixture is repaired, the test's TS import hits a third bug, a +reconfigure failing `JitterDecreased` after a mid-run jitter rise, also its +own quest below. + ## Required - [HLS export follows a resolution change](/quest/m1/hls-reinit-same-codec.md) - the conformance test's same-codec switch fails until it lands +- [A reconfigure keeps the published jitter and delay floor](/quest/m1/catalog-jitter-floor.md) - the conformance test's TS import aborts with `JitterDecreased` at its reconfigure until it lands ## Related diff --git a/quest/m1/mkv-leaf-limits.md b/quest/m1/mkv-leaf-limits.md new file mode 100644 index 00000000000..0c9ddfc782b --- /dev/null +++ b/quest/m1/mkv-leaf-limits.md @@ -0,0 +1,35 @@ +# [XS] MKV leaf element limits + +## Goal + +The MKV importer refuses a `CodecID`, `DocType`, or `CodecPrivate` element +whose declared size is past a limit specific to that element, so a header +declaring a huge one cannot pin importer memory. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), a +follow-up of [moq-dev/moq#7234](https://github.com/moq-dev/moq/pull/7234), +which streams blocks and discards ignored elements but still reads these +three leaf elements whole (`rs/moq-mux/src/container/mkv/import.rs`, where +the track entry collects `CodecID` and `CodecPrivate`). #7234 has landed. + +The maintainer earlier ruled out a blanket tag-size cap (it would refuse +large Segments and Clusters). These limits are per element +and bound only what the importer must hold whole: a `CodecID` and `DocType` +are short ASCII strings, and `CodecPrivate` is the decoder config of a +codec the importer supports: avcC (`V_MPEG4/ISO/AVC`), hvcC +(`V_MPEGH/ISO/HEVC`), av1C (`V_AV1`), an OpusHead (`A_OPUS`), an AAC +AudioSpecificConfig (`A_AAC`), or a FLAC header (`A_FLAC`). The FLAC header +carries every metadata block, so it can hold embedded cover art; size its +limit for that or refuse such art explicitly. Pick each limit from its spec +and the largest real decoder config, check them against the MKV fixtures +for each codec, and return the +importer's existing error for a refused element. Test each element declared +past its limit, fed in small chunks. + +Public API: none. Wire: none. + +## Related + +- [fMP4 refusals keep framing](/quest/m1/fmp4-refusal-framing.md) - the fMP4 leftover of the same streaming work diff --git a/quest/m2/README.md b/quest/m2/README.md index deb0d525d3a..3687d0395c0 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -30,14 +30,15 @@ lite-07; bitrate enforcement is not part of this release. - [One policy for oversized container metadata](/quest/m2/import-metadata-policy.md) - fMP4 and MKV refuse or stream huge metadata by one documented rule - [Wire fuzz coverage](/quest/m2/wire-fuzz-coverage.md) - the cmsg walks in moq-sock and moq-uring have invariant fuzz targets, and the unsafe peer input reaches runs under Miri - [VA-API renderer tests](/quest/m2/vaapi-render-tests.md) - the VA-API renderer, decode, and transcode tests run in `just rs gpu` on AMD and Intel and fail instead of skipping, and hosted CI reports them ignored +- [One Ruff pin and Swift formatting on aarch64-linux](/quest/m2/format-gate-followups.md) - only the dev shell pins Ruff, and aarch64-linux checks Swift formatting once nixpkgs builds `swiftc` there - [Weekly mutation testing on critical modules](/quest/m2/mutants.md) - cargo-mutants runs weekly on moq-auth and moq-net's permission, resume, and expiry code - [Video quality](/quest/m2/video-quality/README.md) - a nightly VMAF harness grades every encoder backend and on-demand GoP pops against floors, and moq-boy's encode is fixed under it -- [noq adopts moq-congestion](/quest/m2/noq-congestion.md) - noq's BBR3 and Cubic delegate to the published crate behind noq's unchanged API - [moq-relay forwards QUIC packets by QUIC-LB server ID](/quest/m2/relay-quic-lb-forward.md) - an established connection that lands on the wrong instance behind an anycast IP or VIP is forwarded to its owner, which replies from the shared address - [P2P](/quest/m2/p2p/README.md) - application-selected peer sessions use shared priorities, routing, and same-epoch handover - [rust2 publishes its UniFFI fork](/quest/m2/rust2-uniffi.md) - waiting on moq-dev/rust2 to import UniFFI and moq's generators and publish them - [Switch to rust2's UniFFI](/quest/m2/uniffi-fork.md) - moq-ffi and every wrapper build from moq-dev/rust2's fork under one pin, and moq's generator code is deleted - [Generated C bindings](/quest/m2/c/README.md) - C generated from moq-ffi ships as `moq-c` 0.8.0 and replaces the hand-written crate +- [A C origin can discover eagerly, like a relay](/quest/m2/c-origin-relay.md) - the C API puts an origin in relay mode, so forwarding a client session's routes needs no local announce watcher - [A runtime-free QUIC driver](/quest/m2/quic-sansio-driver.md) - a caller's own loop moves UDP datagrams and polls moq sessions over moq-quic, for rust2's bindings - [MoQ on rind](/quest/m2/rind/README.md) - every MoQ crate takes its crypto from rind, a pure-Rust graviola fork, so no build compiles C crypto - [Benchmark sources](/quest/m2/bench-sources.md) - every measuring nightly benchmark feeds the regression table, and none is a smoke run @@ -45,6 +46,7 @@ lite-07; bitrate enforcement is not part of this release. - [QoS](/quest/m2/qos/README.md) - the relay's egress `lag` histogram and `dropped` counters on the stats totals and prefix tracks - [Lazy retained HLS instances](/quest/m2/hls-lazy-instances.md) - a replaced HLS instance starts its watchers only when a request reaches it - [HLS and DASH browser playback](/quest/m2/hls-player-test.md) - hls.js and dash.js play, reload, switch, and survive reconfiguration nightly +- [The DASH manifest keeps segments across a reconfigure](/quest/m2/dash-reconfigure.md) - a same-codec reconfigure keeps the old instance's segments listed in DASH, as HLS does - [Re-init retires timelines](/quest/m2/reinit-retire.md) - fMP4 and MKV re-inits retire the old timeline, so import state stays bounded - [Replaced producers retire their records](/quest/m2/producer-retire.md) - encoders and bindings retire a replaced producer's HLS records - [Broadcast fairness](/quest/m2/broadcast-fairness.md) - broadcasts share a cluster link fairly, so one tenant's priority never starves another's @@ -62,6 +64,7 @@ lite-07; bitrate enforcement is not part of this release. - [Native synchronous codecs](/quest/m2/native-compression-sync.md) - report the stateful small-frame synchronous API requirement - [Native browser codecs](/quest/m2/native-compression-adopt.md) - adopt native operations when timing, frame and resource contracts hold +- [moq-net's library tests pass under cargo test](/quest/m2/net-test-subscriber.md) - two moq-net tests stop each installing a global tracing subscriber, so plain `cargo test -p moq-net --lib` passes - [OBS native codecs](/quest/m2/obs-moq-video/README.md) - replace FFmpeg video and audio decoding with moq-video and moq-audio, deliver GPU frames, and use native audio/video encoders - [MoQ in obs-studio](/quest/m2/obs-studio/README.md) - a native MoQ output and service merged into obs-studio, once its maintainers agree; the plugin carries it until then @@ -195,6 +198,8 @@ lite-07; bitrate enforcement is not part of this release. - [Publisher timeliness at relay ingest](/quest/m2/qos-publisher-timeliness.md) - follow-on capability after the release contract is stable - [Auth session caps cost O(1)](/quest/m2/auth-session-index.md) - per-token and per-remote counts and a time-ordered expiry queue replace the scans, after #7211 +- [The relay refuses a cluster peer it cannot dial](/quest/m2/relay-undialable-peers.md) - moq-tokio reports the schemes it can dial, and the relay refuses an undialable peer at startup instead of retrying forever +- [Drop moq-auth's iroh transport](/quest/m2/auth-drop-iroh.md) - the next breaking moq-auth release drops `Transport::Iroh` and the JS `"iroh"` value - [H.264 parsing with maintained crates](/quest/m2/h264-reader.md) - moq-mux parses SPS VUI with h264-reader and shares one bit reader across codec config parsers - [moq-transport finish interop](/quest/m2/ietf-finish.md) - the interop `finish` case runs on every even moq-transport draft instead of being skipped - [JS joining FETCH](/quest/m2/ietf-js-joining-fetch.md) - the JS publisher answers a joining FETCH that arrives after a quick finish, mirroring the Rust bound @@ -220,7 +225,7 @@ lite-07; bitrate enforcement is not part of this release. - [Compile time audit](/quest/m2/audit-compile-time.md) - build-time hotspots are measured against the m1 baselines, starting with the transport share now that moq-net compiles once, and the worst are removed, made cheaper, or planned - [Lint override and deprecation audit](/quest/m2/audit-lints.md) - repeat the suppression and deprecation audit after the release ships, starting with clippy on the macOS and Windows targets - [Archive reads against a real store](/quest/m2/archive-store-bench.md) - LIST/GET counts and latency over track count -- [Branch checks lint examples](/quest/m2/lint-examples.md) - `just check` runs clippy on the selected crates' examples +- [Branch checks lint examples](/quest/m2/lint-examples.md) - `just check` runs clippy on the selected crates' examples, and PR CI builds the C++, Go, and Python interop clients - [Drop kio's epoch fence](/quest/m2/crossbeam-pin-fence.md) - waiting on crossbeam#1352, then the `Read::drop` fence goes - [A cancelled miss stops waiting](/quest/m2/fill-wait-cancel-test.md) - a test proves a relay miss waiting on its own fill ends when no reader wants it - [No MOQ_* knobs outside the binaries](/quest/m2/env-knobs.md) - test, bench, and library knobs leave the `MOQ_*` prefix diff --git a/quest/m2/audit-lints.md b/quest/m2/audit-lints.md index 280543d688c..881e58c306a 100644 --- a/quest/m2/audit-lints.md +++ b/quest/m2/audit-lints.md @@ -74,6 +74,20 @@ Python wrapper. This run only reviews what those gates can't see. enable `reportUnnecessaryTypeIgnoreComment` without falsely flagging its three needed ignores. Audit it with the bindings installed. +- `set_var`/`remove_var` (decided 2026-10-31, maintainer, the + /quest-complete requeue session): replace `rs lint`'s `git grep` text + check (`rs/justfile`, the `lint` recipe) with a `disallowed-methods` entry + for `std::env::set_var` and `std::env::remove_var` in the root + `clippy.toml`, which arrives with + [moq-dev/moq#7329](https://github.com/moq-dev/moq/pull/7329) (queued). The + recipe's comment says why it is text today: the clock lint's test + allowances (`expect(clippy::disallowed_methods)`) would also silence an + environment write, and a specific `reason` does not narrow what an + expectation silences. Move crate- and module-wide test expectations (such + as the one on `rs/moq-ffi/src/lib.rs`'s test-only `mod bench`) onto the + items that read the clock, and drop the text check only once none remain, so the + clippy entry still catches a write in a test. + ### Results (m1, 2026-10-24) - Rust: removed the Apple-only `msghdr_x` `non_camel_case_types` (Rust diff --git a/quest/m2/auth-drop-iroh.md b/quest/m2/auth-drop-iroh.md new file mode 100644 index 00000000000..9de43fbdf5c --- /dev/null +++ b/quest/m2/auth-drop-iroh.md @@ -0,0 +1,30 @@ +# [XS] Drop moq-auth's iroh transport + +## Goal + +The next breaking moq-auth release drops `Transport::Iroh` +(`rs/moq-auth/src/request.rs`) and the `"iroh"` transport value in +`js/auth/src/contract.ts`, which nothing sends once the iroh backend is gone. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), a +follow-up of [moq-dev/moq#7345](https://github.com/moq-dev/moq/pull/7345). +That PR kept both because the enum is the relay-to-auth-server JSON contract +and its quest said "Wire: none"; the relay just never sends `iroh` now. + +Starts after #7345 lands; check with `gh pr view 7345`. Land it with the +first moq-auth release that already breaks for another reason, rather than +cutting a breaking release for this alone. The relay sends the transport +name and the auth server reads it. An old auth server is unaffected, since +a new relay never sends `iroh`. A new auth server still accepts `iroh` from +an older relay: an unrecognized name deserializes to `Unknown` +(`#[serde(other)]` in Rust, `z.catch` in JS). Update the auth contract docs +and any test or fixture naming `iroh`. + +Public API: breaking in moq-auth and `@moq/auth` (one enum variant). Wire: +the auth JSON contract loses the `iroh` transport value. + +## Related + +- [The relay refuses a cluster peer it cannot dial](/quest/m2/relay-undialable-peers.md) - the other iroh leftover diff --git a/quest/m2/c-origin-relay.md b/quest/m2/c-origin-relay.md new file mode 100644 index 00000000000..1fbf9e18586 --- /dev/null +++ b/quest/m2/c-origin-relay.md @@ -0,0 +1,34 @@ +# [S] A C origin can discover eagerly, like a relay + +## Goal + +The C API (`rs/moq-c`, `moq.h`) can put an origin in relay mode, with the +eager discovery moq-net gives an origin handle on a relay link, so a C +application that forwards a client session's routes needs no local +announce watcher of its own to make discovery run. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), a +follow-up of [moq-dev/moq#7246](https://github.com/moq-dev/moq/pull/7246) +(moq-c API gaps), whose follow-ups note that C has no way to make an origin +relay-mode for a client-ingest relay. + +In moq-net, a handle's link decides it: `origin::Producer::with_link` +(`rs/moq-net/src/model/origin.rs`) takes `Link::Local`, `Link::Peer`, or +`Link::Upstream`, and a non-local link's sessions discover everything they +may (`relays()`). `moq_origin_create` (`rs/moq-c/src/api.rs`) always makes a +local one. Expose the choice in C, by a create option or a setter mirroring +`with_link`, following whatever moq-ffi exposes if it has gained the same +by then, and document it in `moq.h` and `doc/lib/c`. If the +[generated C line](/quest/m2/c/README.md) has replaced the hand-written crate +before this starts, add it to moq-ffi instead so every binding gets it. +Test a C origin forwarding a client session's routes with no announce +watcher. + +Public API: additive in moq-c, or in moq-ffi if the generated C line has +replaced it. Wire: none. + +## Related + +- [moq-c at parity](/quest/m1/c/README.md) - the hand-written crate's m1 parity work, which this does not block diff --git a/quest/m2/dash-reconfigure.md b/quest/m2/dash-reconfigure.md new file mode 100644 index 00000000000..757651ccdec --- /dev/null +++ b/quest/m2/dash-reconfigure.md @@ -0,0 +1,44 @@ +# [M] The DASH manifest keeps segments across a reconfigure + +## Goal + +After a same-codec reconfigure (such as a resolution change) replaces a +rendition's instance, the DASH manifest keeps listing the old instance's +segments, each under the init it was muxed for, until the window slides past +them, as the HLS media playlist does. A DASH player loses no listed segment +at the switch. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), a +follow-up of [moq-dev/moq#7436](https://github.com/moq-dev/moq/pull/7436). +That PR keeps a replaced instance (`Rendition::previous`) and lists its +segments in the HLS playlist behind `EXT-X-DISCONTINUITY`, each with its own +`EXT-X-MAP`. `Rendition::segment` and `Rendition::init` already resolve a +predecessor's URLs. The DASH manifest (`rs/moq-hls/src/export/mpd.rs`, +`render_manifest`) still renders one `Period id="0"` with only the current +instance's segments, and `doc/bin/hls.md` says so. + +Represent the predecessor in DASH by either: + +- **multiple Periods**, one per instance, each with its own + `SegmentTemplate` and initialization URL; or +- **a per-segment init** in one Period. + +Choose during implementation by which dash.js and the conformance test +accept without a reload, and record the choice in the PR. Reuse the +predecessor window #7436 adds rather than tracking a second one, and update +`doc/bin/hls.md`. Test that a reconfigure keeps the old segments listed in +the manifest, and extend the DASH checks of +[HLS and DASH segment conformance](/quest/m1/hls-conformance.md) if it has +landed. + +Public API: none. Wire: none. + +## Required + +- [HLS export follows a resolution change](/quest/m1/hls-reinit-same-codec.md) - the instance hand-over (#7436) this extends to DASH + +## Related + +- [HLS and DASH browser playback](/quest/m2/hls-player-test.md) - dash.js survives reconfiguration nightly diff --git a/quest/m2/format-gate-followups.md b/quest/m2/format-gate-followups.md new file mode 100644 index 00000000000..41cddd6a133 --- /dev/null +++ b/quest/m2/format-gate-followups.md @@ -0,0 +1,27 @@ +# [XS] One Ruff pin and Swift formatting on aarch64-linux + +## Goal + +Only the dev shell pins Ruff: `ruff` leaves the uv dev group in +`pyproject.toml` (and `uv.lock`). The Linux gate checks Swift formatting on +aarch64-linux too, once nixpkgs builds `swiftc` there. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), the +follow-ups of [moq-dev/moq#7319](https://github.com/moq-dev/moq/pull/7319) +(format gates), which has landed. + +- **Ruff.** #7319 makes the repo-wide `python` check module run the dev + shell's Ruff and keeps `ruff` in the uv dev group (`pyproject.toml:61`) + only for editors, so two Ruff versions are pinned. Drop it from the dev + group and `uv.lock`, and point editor setup at the dev shell's Ruff. +- **Swift on aarch64-linux.** #7319 adds `swift-format` to the dev shell + only off aarch64-linux (`flake.nix`, around line 306), because nixpkgs + marks its `swiftc` broken there, so `sh/swift/format.sh` skips and + release-PR Check (`ubuntu-24.04-arm`) checks no Swift formatting. When + nixpkgs builds it, add `swift-format` there and drop the skip. Until then + this half waits; check nixpkgs' `swift` package `meta.broken` for + aarch64-linux. + +Public API: none. Wire: none. diff --git a/quest/m2/import-metadata-policy.md b/quest/m2/import-metadata-policy.md index b0aa9d63bed..7b8c83c8ed6 100644 --- a/quest/m2/import-metadata-policy.md +++ b/quest/m2/import-metadata-policy.md @@ -23,4 +23,4 @@ Public API: possibly new `pub const` bounds and a shared refusal error in moq-mu ## Related - [Untrusted media parsers have fuzz targets](/quest/m2/media-fuzz.md) - would measure parsed-state amplification under either policy -- [MKV leaf limits](https://github.com/moq-dev/moq/pull/7468) - planned in that open PR as `quest/m1/security-audit/mkv-leaf-limits.md`: per-element limits on the three MKV fields read whole, in m1. This quest starts from whatever it lands and aligns the two importers' rules, not the limits it picked +- [MKV leaf limits](/quest/m1/mkv-leaf-limits.md) - per-element limits on the three MKV fields read whole, in m1. This quest starts from whatever it lands and aligns the two importers' rules, not the limits it picked diff --git a/quest/m2/lint-examples.md b/quest/m2/lint-examples.md index e580d988493..7f8b02bb825 100644 --- a/quest/m2/lint-examples.md +++ b/quest/m2/lint-examples.md @@ -4,7 +4,7 @@ On Linux, `just check` runs clippy on the example targets of each crate it selects, so a broken or lint-failing example fails the branch rather than the -next Nightly. +next Nightly. PR CI also builds the C++, Go, and Python interop clients. ## Plan @@ -18,4 +18,12 @@ Decided 2026-10-28 (maintainer): examples only. The 68 benches stay with Nightly, since they cost more to build. Measure the added build time on a moq-cli change and record it in the PR. +Decided 2026-10-31 (maintainer, the /quest-complete requeue session): PR CI +also builds the C++, Go, and Python interop clients +(`test/interop/clients/{cpp,go,python}`) when their bindings or the client +change. They went stale unnoticed while +[moq-dev/moq#7311](https://github.com/moq-dev/moq/pull/7311) changed the +decoder API, since only the interop runs build them. A build (and Python +import or type check) is enough; running them stays with interop. + Public API: none. Wire: none. diff --git a/quest/m2/media-fuzz.md b/quest/m2/media-fuzz.md index aa8ea8fbcc9..64248d4af3c 100644 --- a/quest/m2/media-fuzz.md +++ b/quest/m2/media-fuzz.md @@ -24,4 +24,15 @@ the existing fixtures. Assert a work and allocation bound proportional to input size. Wire the targets into the nightly gate next to moq-net's, and replay committed regressions in the merge gate. +Measure parse amplification too (decided 2026-10-31, maintainer, from +[moq-dev/moq#7232](https://github.com/moq-dev/moq/pull/7232)). That PR bounds +a held `moov` or `moof` at 32 MiB (`fmp4::MAX_HELD_BYTES`) and a `moof` at +2^21 samples (`fmp4::MAX_FRAGMENT_SAMPLES`), but a body at the bound +decodes into larger structures: an explicit `trun` entry costs 4 to 16 +input bytes and about 40 parsed, so 2.5x to 10x. A `trun` without +per-sample fields parses to no entries, but planning still resolves every +sample it declares, up to the 2^21 bound. Record the peak allocation for a +maximal `moov`, a maximal `moof` of 4-byte entries, and a field-less `moof` +at the sample bound, and fix each or accept it with the number. + Public API: none. Wire: none. diff --git a/quest/m2/net-test-subscriber.md b/quest/m2/net-test-subscriber.md new file mode 100644 index 00000000000..cb99ac19a5f --- /dev/null +++ b/quest/m2/net-test-subscriber.md @@ -0,0 +1,31 @@ +# [XS] moq-net's library tests pass under cargo test + +## Goal + +`cargo test -p moq-net --lib` passes, not only under nextest: +`setup::tests::credentials_stay_out_of_traces` and the model tests using +`model/test_tracing.rs` no longer each install a process-global tracing +subscriber, so whichever runs second in the shared process still captures +its events. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session). +Nextest runs each test in its own process, which hides the clash. + +`credentials_stay_out_of_traces` (`rs/moq-net/src/setup.rs:306`) uses +`#[tracing_test::traced_test]`, and `count_drop_warnings` +(`rs/moq-net/src/model/test_tracing.rs:39`) calls +`tracing::subscriber::set_global_default` and expects no other global +subscriber. Its doc explains why it is global: tracing caches callsite +interest process-wide, so a scoped dispatcher misses events another thread +cached as disabled. Keep that property. Likely one shared global test +subscriber that both use (`count_drop_warnings`' thread-local capture slot +extended to the credential check), or the credential test moved onto +`count_drop_warnings`' capture. Confirm with `cargo test -p moq-net --lib`. + +Public API: none. Wire: none. + +## Related + +- [moq-dev/moq#7243](https://github.com/moq-dev/moq/pull/7243) - fixed the same nextest-only pass in moq-clock (now `kio::clock`) by isolating its process state diff --git a/quest/m2/net-wrap/e2ee.md b/quest/m2/net-wrap/e2ee.md index cb62ab2bf20..cbc3814c939 100644 --- a/quest/m2/net-wrap/e2ee.md +++ b/quest/m2/net-wrap/e2ee.md @@ -58,6 +58,38 @@ the reverse (decided 2026-10-20, maintainer): without changing moq-net's `track(name)`; propose the encoding in the PR. Record it in `draft-lcurley-moq-e2ee.md`, replacing the Catalogs section's `.z` sibling rule. +- Datagram header allowance (decided 2026-10-31, maintainer, a follow-up + of [moq-dev/moq#7435](https://github.com/moq-dev/moq/pull/7435)): + `MAX_DATAGRAM_HEADER` (`rs/moq-e2ee/src/limits.rs`, + `js/e2ee/src/limits.ts`; 28 after #7435, lite-07's Type plus three 9-byte + varints) also counts the optional lite datagram fields: the relayed fields + the lite `ietf` extension adds, and moq-net's Inflated Size header field, + which a compressed physical track's datagram carries outside the AEAD + (`rs/moq-net/src/lite/datagram.rs`). Without them a maximum-size protected + datagram can encode past the 1200-byte body and be dropped. That header + field is not the wrapper's own inflated size above, which sits inside the + authenticated payload and counts against the plaintext budget instead. + Today a caller's `track::Info` reaches the physical track unchanged, so it + can be compressed. Once the wrapper creates the physical track + uncompressed, the header field never appears and the allowance needs only + the `ietf` fields. Of those, the relay state, object ID, priority, and + status have a fixed worst case, but `encode_datagram` + (`rs/moq-net/src/lite/ietf.rs`) also writes the forwarded properties + block, which may run to `MAX_OBJECT_EXTENSIONS` (64 KiB, + `rs/moq-net/src/ietf/group.rs`), so no fixed allowance covers it, and the + lite publisher drops a datagram whose body exceeds 1200 bytes + (`rs/moq-net/src/lite/publisher.rs`). Choose during implementation between + capping the properties a hop forwards on a protected datagram (and + counting that cap) or documenting that relay properties are outside the + allowance, so such a datagram may be dropped, and record the choice in the + PR. Sealing and opening share + `MAX_DATAGRAM_PAYLOAD` today (`rs/moq-e2ee/src/track.rs`, + `js/e2ee/src/track.ts`), so a wider allowance would also make `open` + refuse a datagram an older sender sealed under the narrower one. Split + the seal and open limits, or pick the limit by wire version, so a + receiver still opens anything that fits the body. Update the e2ee + draft's plaintext bound and the `moq-e2ee-00` vectors with it, covering + both limits and a datagram with non-empty relay properties. - Ranges: every moq-net range starts at a group's first frame ([moq-dev/moq#6465](https://github.com/moq-dev/moq/pull/6465)), so the wrapper reads each physical group whole and inflates from frame 0. diff --git a/quest/m2/noq-congestion.md b/quest/m2/noq-congestion.md deleted file mode 100644 index 5936dd8ae36..00000000000 --- a/quest/m2/noq-congestion.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] noq adopts moq-congestion - -## Goal - -noq's BBR3 and Cubic run `moq-congestion`, its own copies are deleted, and -noq's public congestion API is unchanged. The PR merges in -[n0-computer/noq](https://github.com/n0-computer/noq) with its maintainers' -approval. - -## Plan - -Decided with the maintainer on 2026-10-23, answering the noq maintainers' -offer: moq owns BBR3 as an external crate rather than maintaining it inside -noq. - -- Starts once the m1 cut publishes `moq-congestion` to crates.io (decided: - no backport or manual publish). Check with `cargo search moq-congestion`. -- noq keeps its `Controller` trait public and stable; its `Bbr3`/`Cubic` - configs become adapters that delegate to `moq-congestion`. noq does not - re-export `moq-congestion` types, so each crate bump is an explicit noq - change that wires any new signal. -- noq has no `SpaceId` and no `on_app_limited`; the adapter supplies what - noq can and states what BBR3 loses without it. noq keeps its pacer and - reads the controller's rate. -- Check against noq's open BBR3 reports before claiming them fixed: - [n0-computer/noq#800](https://github.com/n0-computer/noq/issues/800), - [n0-computer/noq#852](https://github.com/n0-computer/noq/issues/852), and - [n0-computer/noq#819](https://github.com/n0-computer/noq/issues/819). -- Once iroh ships it, `moq-tokio`'s iroh path (its noq `ControllerFactory`) - runs the same controllers; bumping that pin is a separate quest if it - needs code. diff --git a/quest/m2/relay-undialable-peers.md b/quest/m2/relay-undialable-peers.md new file mode 100644 index 00000000000..2b89bd63295 --- /dev/null +++ b/quest/m2/relay-undialable-peers.md @@ -0,0 +1,40 @@ +# [S] The relay refuses a cluster peer it cannot dial + +## Goal + +moq-tokio exposes which URL schemes its build can dial, and moq-relay +refuses to start when a configured cluster peer uses a scheme it can't +dial (for example `iroh://` once the iroh backend is gone, or `ftp://`), +naming the peer, instead of retrying it forever. + +## Plan + +Decided 2026-10-31 (maintainer, the /quest-complete requeue session), a +follow-up of [moq-dev/moq#7345](https://github.com/moq-dev/moq/pull/7345), +which drops moq-tokio's iroh backend. A CodeRabbit thread there +([discussion](https://github.com/moq-dev/moq/pull/7345#discussion_r4432489908)) +found the gap; the behavior predates that PR and covers every unsupported +scheme. + +`rs/moq-tokio/src/client.rs` (`connect_inner`) picks a transport by scheme +under feature gates (`tcp`, `tls`, `unix`, `ws`/`wss`), and every other +scheme falls through to QUIC when it is compiled (raced with WebSocket when +that is too) or to WebSocket alone. Those reject what they can't carry +only at dial time: QUIC takes `http`, `https`, `moqt` and `moql` +(`rs/moq-tokio/src/quic/backend.rs`), WebSocket `http`, `https`, `ws` and +`wss` (`rs/moq-tokio/src/websocket.rs`), so a peer like `ftp://` fails each +attempt and is retried. So which schemes dial depends on the build's +features and on that fallthrough. Expose a dialability check from moq-tokio +(a function or method beside `Client`, derived from the same dispatch and +`cfg`s so it cannot drift and never rejects a scheme that dials today), and +check every peer in `Config::connect` +(`rs/moq-relay/src/cluster.rs`) against it at startup. A peer returned later +by an `api.url` endpoint can't fail startup; decide in the PR what it does +(recommended: log once and skip it rather than retry). Test a config naming +an unsupported scheme. + +Public API: additive in moq-tokio. Wire: none. + +## Related + +- [Drop moq-auth's iroh transport](/quest/m2/auth-drop-iroh.md) - the other iroh leftover From f6de61215739aa005d80a4614aaadd5adbc66038 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 23:03:29 -0800 Subject: [PATCH 43/86] ci: land main through GitHub's merge queue (#7613) Co-authored-by: Claude Opus 5.5 --- .github/workflows/dependabot.yml | 6 ++--- CONTRIBUTING.md | 14 ++++++------ README.md | 2 +- doc/setup/dev.md | 4 ++-- flake.lock | 38 ++++++++++++++++++++++++++++++-- flake.nix | 2 +- infra/README.md | 27 +++++++++++++---------- nix/runner/README.md | 5 +++-- quest/m0/ci/README.md | 2 +- quest/m0/ci/land.md | 11 +++++---- sh/gh/queue.test.ts | 6 ++--- 11 files changed, 79 insertions(+), 38 deletions(-) diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml index 46987aee57b..f972978d7a8 100644 --- a/.github/workflows/dependabot.yml +++ b/.github/workflows/dependabot.yml @@ -65,9 +65,10 @@ jobs: private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: moq-dev repositories: moq - # Only the permissions needed to pin and request a landing. + # Only the permissions needed to pin and enqueue a landing. permission-contents: write permission-pull-requests: write + permission-merge-queues: write - name: Request checked landing env: @@ -77,5 +78,4 @@ jobs: PR_HEAD: ${{ github.event.pull_request.head.sha }} run: | export GH_TOKEN="$BOT_TOKEN" - hold=$(quest merge "$PR_URL") - quest merge "$PR_URL" "$hold" --land "$PR_HEAD" + quest merge "$PR_URL" --land "$PR_HEAD" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 174f45da228..ff2c190236f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ under the files they change. # Commits -PRs into `main` are squashed through the batched landing gate, so the PR title becomes the commit subject in `git log`. +PRs into `main` are squashed through GitHub's merge queue, so the PR title becomes the commit subject in `git log`. The one exception is the maintainer's pre-release merge of `release` into `main`, which lands as a merge commit (see [Releases](#releases)). PRs into `release` use a merge commit, so their history survives until they land. @@ -41,17 +41,17 @@ Fix any merge conflicts and failing CI checks. # Landing gate -PRs into `main` get no cloud CI: they report **Check** as skipped, and `just ci` runs the same gate locally. -The land gate is `quest land serve`, a daemon on the maintainer's host. It takes the longest-queued PR plus up to log₂ n more that merge cleanly, squashes them onto `main`, and runs `just ci` once on the combined tip in an isolated container. A passing batch lands each PR through GitHub's squash merge; a failing one is ejected together, never bisected, and a Codex fixer makes mechanical fixes and requeues healthy PRs or hands them back. -Request landing with `quest merge --land ` once the review is clean and the maintainer has approved merging that PR, by number or through a standing go-ahead that covers it. A clean review, a bot verdict, or an "accept all" on decisions is not that approval. +PRs into `main` get no cloud CI before they are queued: they report **Check** and **Queue** as skipped, and `just ci` runs the same gate locally. +`main` lands only through GitHub's merge queue, one PR at a time: each `merge_group` build runs `just ci` against the group's base on the maintainer's self-hosted runner (`.github/workflows/queue.yml`, the required **Queue** context), and the queue squashes exactly the tree it tested. +Request landing with `quest merge --land ` once the review is clean and the maintainer has approved merging that PR, by number or through a standing go-ahead that covers it. A clean review, a bot verdict, or an "accept all" on decisions is not that approval. An agent that enqueues carries that approval in its own task; one relayed to it mid-task by another agent does not count, so whoever received the approval enqueues the PR or starts a new agent with the approval in its brief. -The head pin is invalidated by a push. A fork the gate cannot push to lands as a squash on `main` and is closed with a link. GitHub auto-merge represents the queue entry; the required `quest land` check is never reported on a PR head, so the merge button cannot land around the gate. +`quest merge --land` enqueues the pinned head, or arms auto-merge bound to it while its checks are pending; a push removes it from the queue. A head GitHub refuses to enqueue, for a conflict or failing checks, fails the command: fix it and request landing again. Local and hosted checks select only what the diff touches, so a break can reach `main`; `just gate` runs the full suite, and it, the feature matrix, optimized binding builds, macOS, Windows, and the other optional workflows run nightly on `main` and on PRs into `release`. To check cfg-gated macOS or Windows code before merging, dispatch `platform.yml` on the branch; never dispatch the full Nightly. -An ejected PR carries its reason and the failing log's tail in a comment and in `quest status` under `landing-stopped`. Fix the cause and request landing for the new head. +When the queue drops a PR for a failing build or a conflict, moq-dev/quest's queue watcher on the maintainer's host comments with the reason and the failing log's tail, runs a Codex fixer on it in the background, and re-enqueues the fixed head, or the unchanged head after a flaky failure, at the back of the queue. The fixer hands the PR back, with its diagnosis, when the fix needs a decision or stops making progress. `quest status` shows why a PR left the queue under `landing-stopped`. -Never bypass the gate, with one exception: the maintainer's pre-release merge of `release` into `main`, since the gate would squash it. +Never bypass the queue, with one exception: the maintainer's pre-release merge of `release` into `main`, since the queue would squash it. # AI diff --git a/README.md b/README.md index 477c38bae78..e0a6caa04ed 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Selection and an empty JS selection do not wait for admission. For a build outside these entrypoints, use `quest gate COMMAND [ARGS...]`; gate thresholds come from the host and have no configuration. -PRs into `main` get no cloud CI until the batched landing gate, so run `just ci` before pushing. It runs what the landing gate runs, the same ground as `just check`, plus two settings: `CI_STRICT=1`, which turns a missing tool into an error instead of a skipped check, and `NEXTEST_PROFILE=ci`, which allows a longer hang timeout. +PRs into `main` get no cloud CI until GitHub's merge queue tests them, so run `just ci` before pushing. It runs what the merge queue runs, the same ground as `just check`, plus two settings: `CI_STRICT=1`, which turns a missing tool into an error instead of a skipped check, and `NEXTEST_PROFILE=ci`, which allows a longer hang timeout. See the [development guide](https://doc.moq.dev/setup/dev) and the [justfile](justfile) for more. diff --git a/doc/setup/dev.md b/doc/setup/dev.md index 30c3c8cc1cd..0a0b96c103c 100644 --- a/doc/setup/dev.md +++ b/doc/setup/dev.md @@ -17,7 +17,7 @@ match CI. `just check` and `just fix` refuse to run outside it; set | `just ci-select` | Report whether CI and Rust build setup are needed, without running checks. | | `just fix` | Format and lint the packages this branch changed, and regenerate their committed JS API reports (`js/*/api/`). | | `just check` | Compile, lint, and test the same scope. | -| `just ci` | Run the batched landing gate locally, strictly. PRs into `main` get no other CI, so run it before every push. | +| `just ci` | Run the merge queue's gate locally, strictly. PRs into `main` get no other CI before the queue, so run it before every push. | | `just gate` | `just ci` over every package, as nightly and release PRs run it. `just gate rs` runs only the named module. | | `just fix --all`, `just check --all` | The same, over every package. | | `just api-diff` | Diff every published API against its last release, the JS reports against ones rendered from `origin/release`. PRs into `release` run it; `--deny` fails a backport on any break. | @@ -125,7 +125,7 @@ just fix just ci ``` -`just ci` runs the batched landing gate for the selected scope. See +`just ci` runs the merge queue's gate for the selected scope. See [Checks and tests](#checks-and-tests) for targeted checks while iterating. See [CONTRIBUTING.md](https://github.com/moq-dev/moq/blob/main/CONTRIBUTING.md) diff --git a/flake.lock b/flake.lock index b523103f396..fd19ebafbe3 100644 --- a/flake.lock +++ b/flake.lock @@ -49,6 +49,22 @@ "type": "github" } }, + "nixpkgs-opencode": { + "locked": { + "lastModified": 1792116957, + "narHash": "sha256-EjZqHKObBfIOeHZnMRVV87HB3pPq/WBkmRJWyFxmZu8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f20cd90e28fe0403873f473b29e42662cd86e8c4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f20cd90e28fe0403873f473b29e42662cd86e8c4", + "type": "github" + } + }, "quest": { "inputs": { "flake-utils": [ @@ -56,8 +72,27 @@ ], "nixpkgs": [ "nixpkgs" - ] + ], + "nixpkgs-opencode": "nixpkgs-opencode", + "quest-pinned": "quest-pinned" }, + "locked": { + "lastModified": 1793591035, + "narHash": "sha256-Tnxa9YzSoaD6Q7ijDcUgkNLK7CAFhEoOcDoVFhgHevI=", + "owner": "moq-dev", + "repo": "quest", + "rev": "d003102129236c88edbfec11f001e8efeaf9b5fd", + "type": "github" + }, + "original": { + "owner": "moq-dev", + "repo": "quest", + "rev": "d003102129236c88edbfec11f001e8efeaf9b5fd", + "type": "github" + } + }, + "quest-pinned": { + "flake": false, "locked": { "lastModified": 1793505952, "narHash": "sha256-jDq0fsxzLGIu6FsFfIsnUgvb23Pb+Rja4KZwIxGa34I=", @@ -69,7 +104,6 @@ "original": { "owner": "moq-dev", "repo": "quest", - "rev": "7a191e6197c691f477b4c55edba34cc425c3e77c", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 4a5b5039eb3..6666e66fb0d 100644 --- a/flake.nix +++ b/flake.nix @@ -32,7 +32,7 @@ # The quest CLI, which also serves the quest guide and skills the stubs in # .claude/skills call. Bump the rev to upgrade them. quest = { - url = "github:moq-dev/quest/7a191e6197c691f477b4c55edba34cc425c3e77c"; + url = "github:moq-dev/quest/d003102129236c88edbfec11f001e8efeaf9b5fd"; inputs.nixpkgs.follows = "nixpkgs"; inputs.flake-utils.follows = "flake-utils"; }; diff --git a/infra/README.md b/infra/README.md index d94a6dc0b86..b101e9b7dc3 100644 --- a/infra/README.md +++ b/infra/README.md @@ -63,8 +63,8 @@ names the Environment, and a branch that is not on the list fails to start it. `main` is on the `publish` list for `workflow_run`, schedule, and dispatch jobs, which run there, until [those publishers run on their tag](../quest/m0/ci/publish-release-only.md). -A job that needs only the moq-bot key, such as Dependabot's, names `bot`; the land gate runs on the -maintainer's host, outside Actions. A job that also runs dry on other refs names the Environment +A job that needs only the moq-bot key, such as Dependabot's, names `bot`; the merge queue's +build holds no secrets, and the queue watcher runs on the maintainer's host, outside Actions. A job that also runs dry on other refs names the Environment only when it publishes, as in `environment: ${{ github.ref_type == 'tag' && 'publish' || '' }}`. Read-only secrets, such as the mbx read key, stay at the repository level. @@ -144,20 +144,23 @@ regenerates the repository metadata, signs it, and uploads the diff. ## The landing gate -PRs land through `quest land serve`, a daemon on the maintainer's NixOS host -([kixelated/nixos](https://github.com/kixelated/nixos), `modules/quest-land.nix`), -not a workflow. It holds moq-bot's key there, checks each batch with -`just ci "$BASE"` in an isolated container with the former queue runner's -caches and `/dev/kvm`, and lands each PR through GitHub's squash merge. The -main ruleset requires the `quest land` check pinned to moq-bot, which never -reports it, so only the gate (or the administrator bypass) lands on `main`. +PRs land through GitHub's merge queue, one at a time with squash merges. Each +`merge_group` build runs `just ci "$BASE"` against the group's base on the +self-hosted runner `nixos-moq-ci` (label `moq-queue`, with its local caches and +`/dev/kvm`), read-only and without secrets (`.github/workflows/queue.yml`). The +main ruleset requires its `Queue` context and the merge queue, so only the +queue (or the administrator bypass) lands on `main`. moq-dev/quest's queue +watcher, `quest land watch`, runs on the maintainer's NixOS host +([kixelated/nixos](https://github.com/kixelated/nixos), `modules/quest-land.nix`) +with moq-bot's key: it comments on each PR the queue drops, runs the Codex +fixer, and re-enqueues the result. See [`CONTRIBUTING.md`](../CONTRIBUTING.md#landing-gate) for requesting a landing. ## Bootstrapping the Rust build cache `.github/actions/rust-cache` points mbx at the private `mbx-moq-dev` bucket, namespaced per runner OS and architecture. Hosted jobs on `main` write what -they compile. The landing worker uses its persistent local store and has no R2 +they compile. The merge queue's self-hosted build uses its persistent local store and has no R2 credentials. Release pull request jobs only read (main pull requests run no cloud CI), and fork pull requests, which get no secrets, build cold. @@ -204,8 +207,8 @@ is done for `main`. -f name=main -f type=branch ``` - Remove an existing `gh-readonly-queue/main/*` branch policy after the - batched landing gate replaces GitHub's queue. + The merge queue's `gh-readonly-queue/main/*` builds need no branch policy: + they run on the self-hosted runner and never deploy `cache-write`. 4. **Store the keys under the same two names at both levels.** An Environment secret shadows a repository secret of the same name, so writers get the diff --git a/nix/runner/README.md b/nix/runner/README.md index 5d46a437b2b..1a20ce2dfce 100644 --- a/nix/runner/README.md +++ b/nix/runner/README.md @@ -134,8 +134,9 @@ job runs, so an idle VM holds little more than its running services; the next jo reads its caches from disk. The guest has no swap, so earlyoom kills the largest process once available memory drops below 10%, rather than letting a job that fills memory thrash the disk. -MoQ's main pull requests land through the host's `quest land serve` gate, not a -runner; release pull requests use hosted runners. +MoQ's main pull requests land through GitHub's merge queue, whose `merge_group` +builds run on the host's `moq-queue` runner; release pull requests use hosted +runners. The hosted workflow uses `just gh runner-vm-ci` to load a temporary AppArmor profile for the exact pinned `unshare` while building the image. The recipe diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index 6f7f7f1d6cc..4542d9ac160 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -3,7 +3,7 @@ ## Goal Pull requests are validated locally with `just ci` and get their cloud verdict -from the [batched gate](/quest/m0/ci/land.md) without waiting behind the +from the [merge queue](/quest/m0/ci/land.md) without waiting behind the hosted runner limits, and every job spends its minutes on the work its diff selected rather than on setup, cold caches, or repeated builds. Concurrent local agents share expensive-command capacity, and retained tests earn their cost through useful diff --git a/quest/m0/ci/land.md b/quest/m0/ci/land.md index 055eb6a3ebf..ee0d655ee17 100644 --- a/quest/m0/ci/land.md +++ b/quest/m0/ci/land.md @@ -74,10 +74,13 @@ Remaining: the queue), and the merge queue (squash, build concurrency 1, merge limit 1). It drops the unreported `quest land` requirement and keeps only the administrator bypass. -- **Host.** The host config stops serving moq from `quest land` - (`services.quest-land.repositories.moq.enable = false`) at the cutover, - while the watcher and fixer keep serving moq. -- **Dependabot and picks** enqueue through the merge queue like any other PR. +- **Host.** At the cutover the host's moq daemon switches from + `quest land serve` to the watcher + (`services.quest-land.repositories.moq.watch = true` in kixelated/nixos, + same user and state), and the fixer keeps serving moq. +- **Open PRs.** A PR opened before `queue.yml` reports `Queue` only after + its next push, reopen, or edit; until then GitHub refuses to enqueue it, + and `quest merge --land` says so. - Open since 2026-10-29: `APP_CLIENT_ID` and `APP_PRIVATE_KEY` are moq-dev organization secrets visible to this repository, so any workflow on a pushed branch can read the moq-bot key. The maintainer deletes the diff --git a/sh/gh/queue.test.ts b/sh/gh/queue.test.ts index 68a6541e1dd..6e5073d6168 100644 --- a/sh/gh/queue.test.ts +++ b/sh/gh/queue.test.ts @@ -86,9 +86,9 @@ const request = dependabot.jobs["request-landing"]; const enqueue = request.steps.find((step: { name?: string }) => step.name === "Request checked landing"); // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub expression is literal YAML. assert.equal(enqueue.env.PR_HEAD, "${{ github.event.pull_request.head.sha }}"); -assert.ok(enqueue.run.includes('quest merge "$PR_URL" "$hold" --land "$PR_HEAD"')); -// Requesting a landing only pins the head; the host gate reads the queue itself. -assert.ok(!enqueue.run.includes("quest land"), "Dependabot must not run the gate's own commands"); +assert.ok(enqueue.run.includes('quest merge "$PR_URL" --land "$PR_HEAD"')); +// Requesting a landing only enqueues the pinned head; the queue and its watcher do the rest. +assert.ok(!enqueue.run.includes("quest land"), "Dependabot must not run the land daemons' commands"); assert.ok(!JSON.stringify(request).includes("gh pr merge")); // Release PRs report one context for every optional workflow, retargeted PRs included. From a82c6731d8dad72552152836fcc2460c0f5839a8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 07:10:08 +0000 Subject: [PATCH 44/86] feat(tokio)!: forward QUIC packets to their owner by QUIC-LB server ID (#7539) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/config.md | 30 ++ quest/m2/README.md | 1 - quest/m2/relay-quic-lb-forward.md | 69 ---- rs/moq-quic/src/endpoint.rs | 14 + rs/moq-quic/src/tests/mod.rs | 7 + rs/moq-relay/src/uring.rs | 8 +- rs/moq-tokio/Cargo.toml | 2 +- rs/moq-tokio/MIGRATE.md | 6 + rs/moq-tokio/src/listen.rs | 141 ++++++++ rs/moq-tokio/src/quic.rs | 16 +- rs/moq-tokio/src/quic/backend.rs | 164 +++++++-- rs/moq-tokio/src/quic/driver/endpoint.rs | 158 +++++++-- rs/moq-tokio/src/quic/driver/forward.rs | 422 +++++++++++++++++++++++ rs/moq-tokio/src/quic/driver/mod.rs | 1 + rs/moq-tokio/src/quic/driver/tests.rs | 138 +++++++- rs/moq-tokio/src/settings.rs | 17 + 16 files changed, 1062 insertions(+), 132 deletions(-) delete mode 100644 quest/m2/relay-quic-lb-forward.md create mode 100644 rs/moq-tokio/src/quic/driver/forward.rs diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index b06ee08fe40..441352ce03b 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -24,6 +24,15 @@ implementation = "edge/1" # SETUP name for the relay. Omit for moq-re [listen.reset] secret = "/etc/moq/reset.key" # File holding 64 hex chars. Omit for a random key. +[listen.lb] # QUIC-LB: name this relay in its connection IDs. +id = "01" # Server ID, hex. The same length on every relay. +nonce = 8 # Random bytes after it. Default. + +[listen.lb.forward] # Forward packets that land on the wrong relay to their owner. +bind = "10.0.0.1:4443" # This relay's forward socket, a specific IP its peers list. +key = "/etc/moq/forward.key" # File holding 64 hex chars, the same on every relay. +peers = "/etc/moq/peers.toml" # Server ID to forward socket. Reloaded on change. + [listen.tls] cert = "cert.pem" # Certificate chain and key. Reloaded on change. key = "key.pem" @@ -80,6 +89,27 @@ use the tokio runtime when the listener needs these settings. The `--listen-quic-lb-id` and `--listen-quic-lb-nonce` flags update that table. `unix.allow` needs `unix.bind`. +`[listen.lb.forward]` keeps a connection alive when its packets land on another relay behind +the same anycast address or VIP, after a route change or a rebalance. That relay reads the +server ID in the connection ID and forwards the packet to the owner's forward socket, which +handles it as if the client had sent it there and keeps replying from the shared address. +Handshakes are not forwarded, so the front end must keep each new flow on one relay until it +completes. + +The peers file maps each relay's server ID to its forward `bind`, and may list this relay too, +so one file serves the whole cluster: + +```toml +"01" = "10.0.0.1:4443" +"02" = "10.0.0.2:4443" +``` + +A packet for an unlisted server ID is dropped. A forward is accepted only from a listed address +and under the shared `key` (`openssl rand -hex 32 > forward.key`). A changed file replaces the +map; an invalid one is logged and the last valid map kept, or stops startup. Forwarding needs +`lb.id` and all three settings, which the `--listen-quic-lb-forward-bind`, `-key`, and `-peers` +flags update. + ## \[quic] Transport tuning, applied to accepted and dialed connections alike. diff --git a/quest/m2/README.md b/quest/m2/README.md index 3687d0395c0..a5077df3886 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -33,7 +33,6 @@ lite-07; bitrate enforcement is not part of this release. - [One Ruff pin and Swift formatting on aarch64-linux](/quest/m2/format-gate-followups.md) - only the dev shell pins Ruff, and aarch64-linux checks Swift formatting once nixpkgs builds `swiftc` there - [Weekly mutation testing on critical modules](/quest/m2/mutants.md) - cargo-mutants runs weekly on moq-auth and moq-net's permission, resume, and expiry code - [Video quality](/quest/m2/video-quality/README.md) - a nightly VMAF harness grades every encoder backend and on-demand GoP pops against floors, and moq-boy's encode is fixed under it -- [moq-relay forwards QUIC packets by QUIC-LB server ID](/quest/m2/relay-quic-lb-forward.md) - an established connection that lands on the wrong instance behind an anycast IP or VIP is forwarded to its owner, which replies from the shared address - [P2P](/quest/m2/p2p/README.md) - application-selected peer sessions use shared priorities, routing, and same-epoch handover - [rust2 publishes its UniFFI fork](/quest/m2/rust2-uniffi.md) - waiting on moq-dev/rust2 to import UniFFI and moq's generators and publish them - [Switch to rust2's UniFFI](/quest/m2/uniffi-fork.md) - moq-ffi and every wrapper build from moq-dev/rust2's fork under one pin, and moq's generator code is deleted diff --git a/quest/m2/relay-quic-lb-forward.md b/quest/m2/relay-quic-lb-forward.md deleted file mode 100644 index 7c4476ee0a1..00000000000 --- a/quest/m2/relay-quic-lb-forward.md +++ /dev/null @@ -1,69 +0,0 @@ -# [L] moq-relay forwards QUIC packets by QUIC-LB server ID - -## Goal - -A moq-relay QUIC listener with a QUIC-LB server ID and a peer map forwards -every short-header packet whose connection ID names another server to that -server's unicast address, carrying the client's address. The owning relay -processes it as if it came from the client, and sends that connection's -packets with a configured return source address (direct server return), so -an established connection survives landing on the wrong instance behind an -anycast IP or a load-balancing VIP. Long-header packets are never forwarded: -the handshake relies on the front end keeping a flow on one instance. The -flags are documented inline, with MIGRATE notes. - -## Plan - -Settled with the maintainer, 2026-10-30, for moq.pro's anycast edge cluster -on Cloudflare: - -- Why: Chrome's WebTransport never follows `preferred_address`. Chromium - sends every `https` WebTransport through `DedicatedWebTransportHttp3Client`, - a bare `QuicSpdyClientSession` with no migration path, while its pooled - HTTP/3 client validates the server preferred address. Measured locally - against a moq-relay advertising `--listen-preferred-v4` (Chrome 156: the - net-log shows the address received and never acted on; a forced-QUIC - `fetch()` sends `PATH_CHALLENGE` to it). moq-cli does not migrate either. - So a browser stays on the anycast or VIP address for a connection's life, - and a route change or a VIP rebalance lands its packets on another - instance. -- Scope: established connections only. A handshake split across instances - fails and the client retries. Rejected: a Retry carrying the server ID - (one extra round trip per connection) and a consistent hash of the - client's ID (needs an identical membership view everywhere). -- Connection IDs: the existing plaintext QUIC-LB generator (`listen.lb.id`, - `listen.lb.nonce`, [#848](https://github.com/moq-dev/moq/pull/848)), not the - draft's encrypted form. -- Return path: direct server return. The owner sends a forwarded - connection's packets with the configured return address (the anycast or - VIP address) as source, so only the client-to-server direction takes the - extra hop. Whether the platform allows that source is the deployment's - concern; moq.pro asks Cloudflare. -- Peer map: server ID to unicast address, updated at runtime (the deployment - feeds it, moq.pro from its own API), through config the relay hot-reloads - or the library API; the relay refuses a forward to an unknown ID. - -Open for implementation: - -- The forwarding encapsulation: the original client address and the - address the packet arrived on, prepended to the datagram and sent to the - peer's QUIC port or a dedicated one. Forwards are accepted only from - peer-map addresses and authenticated (a shared key), since a forged - client address would let anyone inject packets or reflect traffic. -- Sending from the return address: a socket that may use a non-local - source (`IP_FREEBIND` or `IP_TRANSPARENT` on Linux) versus binding the - return address where the platform routes it. -- Tests: three relays on loopback addresses, a connection established on - one, its short-header packets delivered to another, and the session - continuing. - -Interactions: per-core shard steering already refuses QUIC-LB connection -IDs (`ShardWithQuicLb`), so if -[Shard the relay by default](/quest/m2/perf/shard-default.md) flips the -default, a listener with `lb.id` stays unsharded. The io_uring listener's -`lb.id` refusal ([io_uring package](/quest/m2/relay-io-uring-package.md)) -can stand: forwarding lives on the tokio backend unless a consumer asks. - -Public API: new listener settings (peer map, return address, forward key) -and a library entry point to update the peer map. Wire: none toward clients; -the peer-to-peer forward format is internal. diff --git a/rs/moq-quic/src/endpoint.rs b/rs/moq-quic/src/endpoint.rs index 4c417fac361..dcf994331ab 100644 --- a/rs/moq-quic/src/endpoint.rs +++ b/rs/moq-quic/src/endpoint.rs @@ -1027,6 +1027,20 @@ impl Endpoint { self.connections.len() } + /// Whether `datagram` from `remote` ends in the stateless reset token a connection's peer + /// issued, so [`handle`](Self::handle) would route it to that connection + /// + /// A stateless reset carries a random connection ID, so a caller that routes datagrams by + /// connection ID before handing them over checks this first. + pub fn is_stateless_reset(&self, remote: SocketAddr, datagram: &[u8]) -> bool { + datagram.len().checked_sub(RESET_TOKEN_SIZE).is_some_and(|start| { + self.index + .connection_reset_tokens + .get(remote, &datagram[start..]) + .is_some() + }) + } + /// Counter for the number of bytes currently used /// in the buffers for Initial messages for pending incoming connections /// and accepts that are still being finalized diff --git a/rs/moq-quic/src/tests/mod.rs b/rs/moq-quic/src/tests/mod.rs index 1398d7bcf57..3b85b6dd901 100644 --- a/rs/moq-quic/src/tests/mod.rs +++ b/rs/moq-quic/src/tests/mod.rs @@ -544,6 +544,13 @@ fn server_stateless_reset() { // Force the server to generate the smallest possible stateless reset pair.client.connections.get_mut(&client_ch).unwrap().ping(); info!("resetting"); + pair.drive_client(); + pair.drive_server(); + // The client recognizes the reset before handling it, and not just any short header. + let (_, _, reset) = pair.client.inbound.back().unwrap(); + assert!(pair.client.endpoint.is_stateless_reset(pair.server.addr, reset)); + assert!(!pair.client.endpoint.is_stateless_reset(pair.server.addr, &[0x40; 64])); + assert!(!pair.client.endpoint.is_stateless_reset(pair.client.addr, reset)); pair.drive(); assert_matches!( pair.client_conn_mut(client_ch).poll(), diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index 217e2432788..96cd539073f 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -147,7 +147,13 @@ impl Workers { tls: listen_tls, preferred_v4, preferred_v6, - lb: moq_tokio::listen::LoadBalancer { id: lb_id, nonce: _ }, + lb: + moq_tokio::listen::LoadBalancer { + id: lb_id, + nonce: _, + // Needs an id, refused below. + forward: _, + }, reset_secret, } = server.listen.resolve()?; let quic = &server.quic; diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index e11723b83f6..194e2dfce93 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -19,7 +19,7 @@ default = ["net", "quic", "aws-lc-rs", "websocket", "tcp", "uds"] net = ["dep:moq-net", "dep:web-transport-trait"] # The async QUIC driver alone (`quic::driver`): moq-quic on tokio, without moq-net or # the MoQ backend. Needs a crypto-provider feature. -driver = ["dep:moq-congestion", "dep:moq-quic", "dep:pin-project-lite", "dep:rustc-hash"] +driver = ["dep:hmac", "dep:moq-congestion", "dep:moq-quic", "dep:pin-project-lite", "dep:rustc-hash", "dep:sha2"] # QUIC and WebTransport over moq-quic, for `https://`, `moqt://`, and `moql://`. quic = ["_transport", "_certs", "driver", "dep:http", "dep:web-transport-proto", "dep:reqwest", "watch", "dep:dns-lookup"] # Server-side certificate handling: chains loaded from disk, self-signed generation, diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index ffbe2a04c7f..1cabb27479f 100644 --- a/rs/moq-tokio/MIGRATE.md +++ b/rs/moq-tokio/MIGRATE.md @@ -4,6 +4,12 @@ How to update your code for each breaking change, newest first. ## Unreleased +### `listen::LoadBalancer` has a `forward` field ([#7539](https://github.com/moq-dev/moq/pull/7539)) + +`listen::LoadBalancer` gained `forward: listen::Forward`, the `[listen.lb.forward]` settings that forward QUIC packets to the server their connection ID names. +The struct is not `#[non_exhaustive]`, so add `..Default::default()` to a `LoadBalancer { id, nonce }` literal, which leaves forwarding off. +An exhaustive destructure, such as a QUIC runtime mapping the resolved listener settings, must name `forward`: honor it, or ignore it with `forward: _` only where `lb.id` is already refused. + ### `tls://` listeners verify client certificates ([#7358](https://github.com/moq-dev/moq/pull/7358)) A `listen.tcp.tls` listener serves the listen policy's client-certificate verifier, so `--listen-tls-root` and pinned `tls.peers` apply to it as they do to QUIC, and a stream-only server with that listener accepts them instead of returning `Error::MtlsUnsupported`. diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index a5d25359d17..c2b4dc2e5bd 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -447,6 +447,34 @@ impl Config { "--listen-quic-lb-nonce requires --listen-quic-lb-id", )); } + let forward = &self.lb.forward; + if forward.configured() { + let refused = [ + ( + self.lb.id.is_none(), + "--listen-quic-lb-forward-* requires --listen-quic-lb-id", + ), + ( + forward.bind.is_none(), + "--listen-quic-lb-forward-* requires --listen-quic-lb-forward-bind", + ), + ( + forward.key.is_none(), + "--listen-quic-lb-forward-bind requires --listen-quic-lb-forward-key", + ), + ( + forward.peers.is_none(), + "--listen-quic-lb-forward-bind requires --listen-quic-lb-forward-peers", + ), + ( + forward.bind.is_some_and(|bind| bind.ip().is_unspecified()), + "--listen-quic-lb-forward-bind needs a specific IP, which its peers list", + ), + ]; + if let Some((_, reason)) = refused.into_iter().find(|(set, _)| *set) { + return Err(crate::Error::NoBackend(reason)); + } + } Ok(()) } @@ -562,9 +590,18 @@ impl Config { #[cfg(feature = "quic")] /// Return the effective QUIC-LB connection-ID encoding. pub(crate) fn load_balancer(&self) -> Option { + let forward = &self.lb.forward; self.lb.id.clone().map(|id| crate::quic::LoadBalancer { id, nonce: self.lb.nonce.unwrap_or(8), + forward: match (forward.bind, &forward.key, &forward.peers) { + (Some(bind), Some(key), Some(peers)) => Some(crate::quic::Forward { + bind, + key: key.clone(), + peers: peers.clone(), + }), + _ => None, + }, }) } } @@ -706,6 +743,7 @@ mod tests { Some(crate::quic::LoadBalancer { id: "ab".parse().unwrap(), nonce: 9, + forward: None, }) ); } @@ -741,6 +779,11 @@ mod tests { [lb] id = "ab" nonce = 8 + +[lb.forward] +bind = "10.0.0.1:4443" +key = "forward.key" +peers = "peers.toml" "#, ) .unwrap(); @@ -749,10 +792,46 @@ nonce = 8 Some(crate::quic::LoadBalancer { id: "ab".parse().unwrap(), nonce: 8, + forward: Some(crate::quic::Forward { + bind: "10.0.0.1:4443".parse().unwrap(), + key: "forward.key".into(), + peers: "peers.toml".into(), + }), }) ); } + /// Forwarding needs a server ID and all three of its settings, and a bind its peers can name. + #[test] + fn quic_lb_forward_refuses_a_partial_config() { + let complete = "[lb]\nid = \"ab\"\n[lb.forward]\nbind = \"10.0.0.1:4443\"\nkey = \"k\"\npeers = \"p\"\n"; + let config: Config = toml::from_str(complete).unwrap(); + config.validate().expect("complete"); + + for (body, needle) in [ + (complete.replace("id = \"ab\"\n", ""), "requires --listen-quic-lb-id"), + ( + complete.replace("bind = \"10.0.0.1:4443\"\n", ""), + "requires --listen-quic-lb-forward-bind", + ), + ( + complete.replace("key = \"k\"\n", ""), + "requires --listen-quic-lb-forward-key", + ), + ( + complete.replace("peers = \"p\"\n", ""), + "requires --listen-quic-lb-forward-peers", + ), + (complete.replace("10.0.0.1", "0.0.0.0"), "needs a specific IP"), + ] { + let config: Config = toml::from_str(&body).unwrap(); + match config.validate() { + Err(crate::Error::NoBackend(reason)) => assert!(reason.contains(needle), "{body}: {reason}"), + other => panic!("{body}: {other:?}"), + } + } + } + /// A stream-only server opens no QUIC listener even with a bind configured, /// which is what lets a worker group hold that address instead. #[tokio::test] @@ -902,4 +981,66 @@ pub struct LoadBalancer { )] #[serde(default, skip_serializing_if = "Option::is_none")] pub nonce: Option, + + /// Forwarding to the other servers behind this one's address (`[listen.lb.forward]`). + #[usage(flatten)] + #[serde(default)] + pub forward: Forward, +} + +/// QUIC-LB forwarding (`[listen.lb.forward]`, `--listen-quic-lb-forward-*`). +/// +/// Behind an anycast address or a load-balanced VIP, a route change or a rebalance can land an +/// established connection's packets on another server. With forwarding, that server hands each +/// packet whose connection ID names another server to the owner, which processes it as if the +/// client had sent it there and keeps replying from the shared address. Only packets after the +/// handshake are forwarded, so the front end must still keep a handshake on one server. +#[derive(Clone, Debug, Default, usage_rs::Args, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct Forward { + /// The UDP address this server receives forwards on, and the one its peers name it by. + /// Needs a specific IP, since a forward is accepted only from a peer's listed address. + #[usage( + name = "listen-quic-lb-forward-bind", + long = "listen-quic-lb-forward-bind", + env = "MOQ_LISTEN_QUIC_LB_FORWARD_BIND", + setting = "listen.lb.forward.bind" + )] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bind: Option, + + /// A file holding the key every server forwarding to this one shares, as 64 hexadecimal + /// characters (`openssl rand -hex 32`). It authenticates each forward, which carries a + /// client address the receiver would otherwise take on trust. + #[usage( + name = "listen-quic-lb-forward-key", + long = "listen-quic-lb-forward-key", + env = "MOQ_LISTEN_QUIC_LB_FORWARD_KEY", + setting = "listen.lb.forward.key" + )] + #[usage(value_hint = usage_rs::ValueHint::FilePath)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + + /// A TOML file naming each server's forward address by its hex server ID, such as + /// `"02" = "10.0.0.2:4443"`. Reloaded when it changes, keeping the last valid map. + /// This server's own entry is skipped, so every server can share one file. A packet for + /// a server missing from it is dropped. + #[usage( + name = "listen-quic-lb-forward-peers", + long = "listen-quic-lb-forward-peers", + env = "MOQ_LISTEN_QUIC_LB_FORWARD_PEERS", + setting = "listen.lb.forward.peers" + )] + #[usage(value_hint = usage_rs::ValueHint::FilePath)] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub peers: Option, +} + +impl Forward { + /// Whether any forwarding setting is present. + pub fn configured(&self) -> bool { + self.bind.is_some() || self.key.is_some() || self.peers.is_some() + } } diff --git a/rs/moq-tokio/src/quic.rs b/rs/moq-tokio/src/quic.rs index e558ab5c7cf..30a55aa6515 100644 --- a/rs/moq-tokio/src/quic.rs +++ b/rs/moq-tokio/src/quic.rs @@ -37,7 +37,7 @@ pub mod web; /// Parsed from, and serialized as, a hex string. Its length must match the load /// balancer's configured server-ID length. #[serde_with::serde_as] -#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct ServerId(#[serde_as(as = "serde_with::hex::Hex")] pub(crate) Vec); impl ServerId { @@ -69,6 +69,20 @@ pub(crate) struct LoadBalancer { pub id: ServerId, /// Number of random nonce bytes appended to each connection ID. pub nonce: usize, + /// Forwarding to the other servers behind this one's address. + pub forward: Option, +} + +/// Resolved QUIC-LB forwarding, every setting present. +#[cfg(feature = "quic")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Forward { + /// The forward socket's address. + pub bind: std::net::SocketAddr, + /// The shared key file. + pub key: PathBuf, + /// The peer map file. + pub peers: PathBuf, } /// The congestion control family for a QUIC connection. diff --git a/rs/moq-tokio/src/quic/backend.rs b/rs/moq-tokio/src/quic/backend.rs index 7a338e03c85..fc648f13639 100644 --- a/rs/moq-tokio/src/quic/backend.rs +++ b/rs/moq-tokio/src/quic/backend.rs @@ -239,6 +239,27 @@ pub enum Error { /// The flow-control limits are ones a peer would refuse, so none were applied. #[error("invalid quic limits: {0}")] Limits(#[from] moq_quic::LimitsError), + + /// The QUIC-LB forward socket failed to bind. + #[error("failed to bind the QUIC-LB forward socket")] + BindForward(#[source] std::io::Error), + + /// The QUIC-LB forward key file could not be read or does not hold 64 hexadecimal characters. + #[error("invalid QUIC-LB forward key")] + ForwardKey(#[source] crate::secret::Error), + + /// The QUIC-LB peers file could not be read, or names a peer this server cannot forward to. + #[error("invalid QUIC-LB peers file {}: {reason}", path.display())] + ForwardPeers { + /// The peers file. + path: std::path::PathBuf, + /// What is wrong with it. + reason: String, + }, + + /// The QUIC-LB peers file could not be watched for changes. + #[error("failed to watch the QUIC-LB peers file: {0}")] + WatchPeers(String), } impl crate::failover::Aggregate for Error { @@ -554,6 +575,8 @@ fn proto_status(err: &web::proto::ConnectError) -> Option { pub(crate) struct Server { pub quic: driver::endpoint::Endpoint, tls: crate::tls::Serve, + /// Reloads the QUIC-LB peers for as long as the server runs. + _peers: Option, } pub(crate) struct Options { @@ -624,6 +647,7 @@ impl Server { endpoint_config.reset_secret = Some(*secret.as_bytes()); } + let mut forward = None; // Configure connection ID generator with server ID if provided if let Some(shard) = member.as_ref().map(listen::Socket::shard) { if load_balancer.is_some() { @@ -656,8 +680,15 @@ impl Server { nonce_len, "using QUIC-LB compatible connection ID generation" ); + if let Some(config) = &load_balancer.forward { + forward = Some(Forwarding::start( + config, + &server_id, + endpoint_config.max_udp_payload_size, + )?); + } endpoint_config.cid_generator = Some(Arc::new(move || { - Box::new(ServerIdGenerator::new(server_id.clone(), nonce_len)) + Box::new(driver::forward::ServerIdGenerator::new(&server_id.0, nonce_len)) })); } @@ -669,11 +700,22 @@ impl Server { }; let socket = driver::wrap_udp_socket(socket).map_err(|err| Error::CreateEndpoint(err.into()))?; - let quic = - driver::endpoint::Endpoint::new(endpoint_config, demux.wrap(socket)).map_err(Error::CreateEndpoint)?; + let socket = demux.wrap(socket); + let (quic, peers) = match forward { + Some(Forwarding { forward, watcher }) => ( + driver::endpoint::Endpoint::forwarding(endpoint_config, socket, forward), + Some(watcher), + ), + None => (driver::endpoint::Endpoint::new(endpoint_config, socket), None), + }; + let quic = quic.map_err(Error::CreateEndpoint)?; demux.attach(&quic); - Ok(Self { quic, tls: served }) + Ok(Self { + quic, + tls: served, + _peers: peers, + }) } pub fn accept(&self) -> impl std::future::Future> + '_ { @@ -817,38 +859,73 @@ pub(crate) async fn accept( } } -// ── ServerIdGenerator ─────────────────────────────────────────────── +// ── Forwarding ────────────────────────────────────────────────────── -struct ServerIdGenerator { - server_id: ServerId, - nonce_len: usize, +/// A forward socket, and the watcher that keeps its peers current. +struct Forwarding { + forward: driver::forward::Forward, + watcher: notify::RecommendedWatcher, } -impl ServerIdGenerator { - fn new(server_id: ServerId, nonce_len: usize) -> Self { - Self { server_id, nonce_len } - } -} - -impl driver::ConnectionIdGenerator for ServerIdGenerator { - fn generate_cid(&mut self) -> driver::ConnectionId { - use rand::RngExt; - let cid_len = self.cid_len(); - let mut cid = Vec::with_capacity(cid_len); - // First byte has "self-encoded length" of server ID + nonce - cid.push((cid_len - 1) as u8); - cid.extend(self.server_id.0.iter()); - cid.extend(rand::rng().random_iter::().take(self.nonce_len)); - driver::ConnectionId::new(cid.as_slice()) - } +impl Forwarding { + fn start(config: &crate::quic::Forward, id: &ServerId, max_udp_payload_size: u16) -> Result { + let key = crate::secret::Secret::read(&config.key).map_err(Error::ForwardKey)?; + let peers = driver::forward::Peers::default(); + peers.set(read_peers(&config.peers, id, config.bind)?); + + let reload = peers.clone(); + let (path, own, bind) = (config.peers.clone(), id.clone(), config.bind); + let watcher = crate::watch::callback(std::slice::from_ref(&config.peers), move || { + match read_peers(&path, &own, bind) { + Ok(map) => { + reload.set(map); + tracing::info!(path = %path.display(), "reloaded QUIC-LB peers"); + } + Err(err) => tracing::warn!(%err, "failed to reload QUIC-LB peers; retaining the previous ones"), + } + }) + .map_err(|err| Error::WatchPeers(err.to_string()))?; - fn cid_len(&self) -> usize { - 1 + self.server_id.len() + self.nonce_len + let socket = std::net::UdpSocket::bind(config.bind).map_err(Error::BindForward)?; + let forward = driver::forward::Forward::new(socket, &id.0, peers, key.as_bytes(), max_udp_payload_size) + .map_err(Error::BindForward)?; + tracing::info!(bind = %config.bind, "forwarding QUIC-LB packets"); + Ok(Self { forward, watcher }) } +} - fn cid_lifetime(&self) -> Option { - None +/// Read the peers file, skipping this server's own entry. +/// +/// Refuses the whole file for one bad entry, so a typo cannot quietly strand that server's +/// clients: a server ID of another length, which this server's connection IDs could not carry, +/// or an address it cannot send to or accept from. +fn read_peers(path: &std::path::Path, own: &ServerId, bind: net::SocketAddr) -> Result { + let invalid = |reason: String| Error::ForwardPeers { + path: path.to_owned(), + reason, + }; + let text = std::fs::read_to_string(path).map_err(|err| invalid(err.to_string()))?; + let peers: std::collections::HashMap = + toml::from_str(&text).map_err(|err| invalid(err.to_string()))?; + + let mut map = driver::forward::Map::default(); + for (id, addr) in peers { + if id.len() != own.len() { + return Err(invalid(format!( + "{id:?} is not {} bytes like this server's ID", + own.len() + ))); + } + if addr.ip().is_unspecified() || addr.is_ipv4() != bind.is_ipv4() { + return Err(invalid(format!( + "{id:?} has an address {bind} cannot forward to: {addr}" + ))); + } + if id != *own { + map.insert(&id.0, addr); + } } + Ok(map) } #[cfg(test)] @@ -1168,4 +1245,33 @@ mod tests { .await .expect("test timed out"); } + + /// The peers file skips this server's own entry, so every server can share it, and refuses + /// the whole file for one entry this server could not forward to. + #[test] + fn peers_file_is_shared_and_refused_whole() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("peers.toml"); + let own: ServerId = "01".parse().unwrap(); + let bind: net::SocketAddr = "10.0.0.1:4443".parse().unwrap(); + let read = |body: &str| { + std::fs::write(&path, body).unwrap(); + read_peers(&path, &own, bind) + }; + + let map = read("\"01\" = \"10.0.0.1:4443\"\n\"02\" = \"10.0.0.2:4443\"\n").unwrap(); + let expected: std::collections::HashMap, net::SocketAddr> = + [(Box::from(&[2u8][..]), "10.0.0.2:4443".parse().unwrap())].into(); + assert_eq!(map.peers(), &expected); + + for (body, needle) in [ + ("\"0203\" = \"10.0.0.2:4443\"\n", "not 1 bytes"), + ("\"02\" = \"[2001:db8::2]:4443\"\n", "cannot forward to"), + ("\"02\" = \"0.0.0.0:4443\"\n", "cannot forward to"), + ("\"zz\" = \"10.0.0.2:4443\"\n", "Invalid character"), + ] { + let err = read(body).expect_err(body).to_string(); + assert!(err.contains(needle), "{body}: {err}"); + } + } } diff --git a/rs/moq-tokio/src/quic/driver/endpoint.rs b/rs/moq-tokio/src/quic/driver/endpoint.rs index 67b7bcf874c..bf9cf4fceea 100644 --- a/rs/moq-tokio/src/quic/driver/endpoint.rs +++ b/rs/moq-tokio/src/quic/driver/endpoint.rs @@ -31,6 +31,7 @@ pub use crate::quic::driver::runtime::AsyncUdpSocket; use crate::quic::driver::{ ConnectionEvent, IO_LOOP_BOUND, Instant, RECV_TIME_BOUND, VarInt, connection::Connecting, + forward::Forward, runtime::{Slot, wrap_udp_socket}, udp_transmit, work_limiter::WorkLimiter, @@ -100,8 +101,19 @@ impl Endpoint { /// The endpoint's tasks run on the caller's tokio runtime, else moq-tokio's fallback. With /// the `sim` feature, an endpoint made inside `kio::clock::sim::run` runs them on the simulator. pub fn new(config: Config, socket: impl Into) -> Result { + Self::start(config, socket.into(), None) + } + + /// [`new`](Self::new), forwarding each packet whose QUIC-LB server ID names another server + /// and accepting the packets those servers forward here. + #[cfg_attr(not(feature = "quic"), allow(dead_code, reason = "only the QUIC backend forwards"))] + pub(crate) fn forwarding(config: Config, socket: impl Into, forward: Forward) -> Result { + Self::start(config, socket.into(), Some(forward)) + } + + fn start(config: Config, socket: Socket, forward: Option) -> Result { let runtime = crate::runtime::Spawner::current(); - let socket = socket.into().into_async()?; + let socket = socket.into_async()?; let addr = socket.local_addr()?; let allow_mtud = !socket.may_fragment(); let max_receive_segments = socket.max_receive_segments(); @@ -113,6 +125,7 @@ impl Endpoint { max_udp_payload_size, max_receive_segments, runtime.clone(), + forward, ); let driver = EndpointDriver(rc.clone()); runtime.spawn( @@ -230,8 +243,11 @@ impl Endpoint { async move { endpoint.wait_all_draining().await; endpoint.inner.slot.release(); + let state = &mut *endpoint.inner.state.lock().unwrap(); // A rebind keeps the old socket until traffic arrives on the new one. - endpoint.inner.state.lock().unwrap().prev_socket = None; + state.prev_socket = None; + // Nothing reaches the endpoint to forward once its socket is gone. + state.forward = None; } } @@ -381,6 +397,7 @@ impl Drop for EndpointDriver { self.0.slot.release(); let endpoint = &mut *self.0.state.lock().unwrap(); endpoint.prev_socket = None; + endpoint.forward = None; endpoint.driver_lost = true; self.0.shared.incoming.notify_waiters(); // Drop all outgoing channels, signaling the termination of the endpoint to the associated @@ -503,6 +520,8 @@ pub(crate) struct State { driver_lost: bool, /// Connections in the process of being accepted pending_accepts: usize, + /// QUIC-LB forwarding to and from the other servers behind this one's address + forward: Option, } #[derive(Debug)] @@ -523,6 +542,7 @@ impl State { let poll_res = self.recv_state.poll_socket( cx, &mut self.inner, + self.forward.as_mut(), &mut |cx, bufs, meta| socket.poll_recv(cx, bufs, meta), slot, now, @@ -534,10 +554,19 @@ impl State { let poll_res = self.recv_state.poll_socket( cx, &mut self.inner, + self.forward.as_mut(), &mut |cx, bufs, meta| slot.poll_recv(cx, bufs, meta), slot, now, ); + // Polled even when the socket used up the budget, which still admits one forward, so a + // busy socket cannot starve them. + let forwards = match self.forward.as_mut() { + Some(forward) => self + .recv_state + .poll_forwards(cx, &mut self.inner, forward, self.ipv6, slot, now), + None => Ok(false), + }; self.recv_state.recv_limiter.finish_cycle(get_time); let poll_res = poll_res?; if poll_res.received_connection_packet { @@ -545,7 +574,7 @@ impl State { // one anymore. TODO: Account for multiple outgoing connections. self.prev_socket = None; } - Ok(poll_res.keep_going) + Ok(poll_res.keep_going || forwards?) } fn handle_events(&mut self, cx: &mut Context<'_>, shared: &Shared) -> bool { @@ -769,6 +798,7 @@ impl EndpointRef { max_udp_payload_size: u16, max_receive_segments: usize, runtime: crate::runtime::Spawner, + forward: Option, ) -> Self { let (sender, events) = mpsc::unbounded_channel(); let recv_state = RecvState::new(sender, max_receive_segments, max_udp_payload_size); @@ -793,6 +823,7 @@ impl EndpointRef { driver_lost: false, recv_state, pending_accepts: 0, + forward, }), })) } @@ -869,6 +900,7 @@ impl RecvState { &mut self, cx: &mut Context<'_>, endpoint: &mut moq_quic::endpoint::Endpoint, + mut forward: Option<&mut Forward>, recv: &mut PollRecv<'_>, slot: &Slot, now: Instant, @@ -918,7 +950,12 @@ impl RecvState { ); while !data.is_empty() { let buf = data.split_to(meta.stride.min(data.len())); - let mut response_buffer = Vec::new(); + if let Some(forward) = forward.as_deref_mut() + && forward.route(&buf, meta.addr, meta.dst_ip, meta.ecn, || { + endpoint.is_stateless_reset(meta.addr, &buf) + }) { + continue; + } let received = Received { remote: meta.addr, local_ip: meta.dst_ip, @@ -926,30 +963,8 @@ impl RecvState { data: buf, arrival: arrival.take(), }; - match endpoint.handle(now, received, &mut response_buffer) { - Some(DatagramEvent::NewConnection(incoming)) => { - if self.connections.close.is_none() { - self.incoming.push_back(incoming); - } else { - let transmit = endpoint.refuse(incoming, &mut response_buffer); - respond(transmit, &response_buffer, slot); - } - } - Some(DatagramEvent::ConnectionEvent(handle, event)) => { - // Ignoring errors from dropped connections that haven't yet been cleaned up - received_connection_packet = true; - let _ = self - .connections - .senders - .get_mut(&handle) - .unwrap() - .send(ConnectionEvent::Proto(event)); - } - Some(DatagramEvent::Response(transmit)) => { - respond(transmit, &response_buffer, slot); - } - None => {} - } + received_connection_packet |= + Self::handle(&mut self.incoming, &mut self.connections, endpoint, now, received, slot); } } } @@ -985,6 +1000,92 @@ impl RecvState { } } +impl RecvState { + /// Hand one received datagram to the endpoint, returning whether it reached a connection. + fn handle( + incoming_queue: &mut VecDeque, + connections: &mut ConnectionSet, + endpoint: &mut moq_quic::endpoint::Endpoint, + now: Instant, + received: Received, + slot: &Slot, + ) -> bool { + let mut response_buffer = Vec::new(); + match endpoint.handle(now, received, &mut response_buffer) { + Some(DatagramEvent::NewConnection(incoming)) => { + if connections.close.is_none() { + incoming_queue.push_back(incoming); + } else { + let transmit = endpoint.refuse(incoming, &mut response_buffer); + respond(transmit, &response_buffer, slot); + } + } + Some(DatagramEvent::ConnectionEvent(handle, event)) => { + // Ignoring errors from dropped connections that haven't yet been cleaned up + let _ = connections + .senders + .get_mut(&handle) + .unwrap() + .send(ConnectionEvent::Proto(event)); + return true; + } + Some(DatagramEvent::Response(transmit)) => { + respond(transmit, &response_buffer, slot); + } + None => {} + } + false + } + + /// Process the packets other servers forwarded here, returning whether the work limiter + /// stopped before they ran out. + fn poll_forwards( + &mut self, + cx: &mut Context<'_>, + endpoint: &mut moq_quic::endpoint::Endpoint, + forward: &mut Forward, + ipv6: bool, + slot: &Slot, + now: Instant, + ) -> Result { + loop { + match forward.poll_recv(cx) { + Poll::Ready(Ok(Some(unwrapped))) => { + // The client's address as this socket would have reported it, so the + // connection sees the path its handshake took. An IPv6 client cannot have + // shaken hands on an IPv4 socket. + let remote = match (ipv6, unwrapped.remote) { + (true, remote) => Some(SocketAddr::V6(ensure_ipv6(remote))), + (false, remote @ SocketAddr::V4(_)) => Some(remote), + (false, SocketAddr::V6(_)) => None, + }; + if let Some(remote) = remote { + self.datagrams.extend_from_slice(unwrapped.packet); + let received = Received { + remote, + local_ip: unwrapped.local_ip, + ecn: unwrapped.ecn.map(proto_ecn), + data: self.datagrams.split(), + // When the forward reached this server: the hop between servers is + // part of the client's path now, and their clocks are not shared. + arrival: Some(Instant::now()), + }; + Self::handle(&mut self.incoming, &mut self.connections, endpoint, now, received, slot); + } + } + Poll::Ready(Ok(None)) => {} + Poll::Pending => return Ok(false), + Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::ConnectionReset || is_msg_size_err(&e) => {} + Poll::Ready(Err(e)) => return Err(e), + } + self.recv_limiter.record_work(1); + if !self.recv_limiter.allow_work(Instant::now) { + return Ok(true); + } + } + } +} + impl fmt::Debug for RecvState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RecvState") @@ -1043,6 +1144,7 @@ mod tests { .poll_socket( &mut cx, &mut endpoint, + None, &mut |cx, bufs, meta| socket.poll_recv(cx, bufs, meta), &slot, now, diff --git a/rs/moq-tokio/src/quic/driver/forward.rs b/rs/moq-tokio/src/quic/driver/forward.rs new file mode 100644 index 00000000000..55dce4bcaec --- /dev/null +++ b/rs/moq-tokio/src/quic/driver/forward.rs @@ -0,0 +1,422 @@ +//! QUIC-LB forwarding between the servers behind one address. +//! +//! A server whose connection IDs carry a QUIC-LB server ID hands each short-header packet naming +//! another server to that server's forward socket, wrapped with the client's address and the +//! address the packet arrived on. The owner unwraps it and processes it as if the client had sent +//! it there. Replies need nothing more: a connection sends from the address its handshake arrived +//! on, which is the shared one, so only the client-to-server direction takes the extra hop. +//! +//! Long headers are never forwarded: a client picks the destination ID of its handshake, so the +//! handshake relies on the front end keeping a flow on one server. +#![cfg_attr(not(feature = "quic"), allow(dead_code, reason = "only the QUIC backend forwards"))] + +use std::{ + collections::{HashMap, HashSet}, + io, + net::{IpAddr, Ipv6Addr, SocketAddr}, + sync::{Arc, PoisonError, RwLock}, + task::{Context, Poll, ready}, +}; + +use hmac::{KeyInit, Mac}; +use moq_sock::udp::EcnCodepoint; + +type Hmac = hmac::Hmac; + +/// Where a QUIC-LB connection ID carries its server ID: after the byte encoding its length. +pub(crate) const ID_OFFSET: usize = 1; + +/// The forward header's version, so a mismatched peer's forwards are dropped rather than misread. +const VERSION: u8 = 0; +/// The truncated HMAC-SHA256 tag that authenticates a forward. +const TAG_LEN: usize = 16; +/// The flag marking a forward that knows the address its packet arrived on. +const ARRIVAL: u8 = 0b100; +/// The header ahead of a forwarded packet: version, flags (ECN and [`ARRIVAL`]), the client's +/// port and IP, the arrival IP, and the tag over all of it and the packet. +pub(crate) const OVERHEAD: usize = 1 + 1 + 2 + 16 + 16 + TAG_LEN; + +/// Generates QUIC-LB connection IDs: a byte encoding the length, the server ID, then a random nonce. +pub(crate) struct ServerIdGenerator { + id: Box<[u8]>, + nonce_len: usize, +} + +impl ServerIdGenerator { + /// Name `id` in every connection ID, ahead of `nonce_len` random bytes. + pub(crate) fn new(id: &[u8], nonce_len: usize) -> Self { + Self { + id: id.into(), + nonce_len, + } + } +} + +impl moq_quic::ConnectionIdGenerator for ServerIdGenerator { + fn generate_cid(&mut self) -> moq_quic::ConnectionId { + use rand::RngExt; + let cid_len = self.cid_len(); + let mut cid = Vec::with_capacity(cid_len); + // The "self-encoded length" of server ID and nonce, which puts the server ID at `ID_OFFSET`. + cid.push((cid_len - 1) as u8); + cid.extend(self.id.iter()); + cid.extend(rand::rng().random_iter::().take(self.nonce_len)); + moq_quic::ConnectionId::new(cid.as_slice()) + } + + fn cid_len(&self) -> usize { + ID_OFFSET + self.id.len() + self.nonce_len + } + + fn cid_lifetime(&self) -> Option { + None + } +} + +/// The other servers behind the shared address, by server ID, replaced whole on each update. +#[derive(Clone, Debug, Default)] +pub(crate) struct Peers(Arc>); + +/// A snapshot of [`Peers`]. +#[derive(Debug, Default)] +pub(crate) struct Map { + by_id: HashMap, SocketAddr>, + /// The forward sockets accepted as sources. + addrs: HashSet, +} + +impl Map { + /// Name `id`'s forward socket. + pub(crate) fn insert(&mut self, id: &[u8], addr: SocketAddr) { + self.by_id.insert(id.into(), addr); + self.addrs.insert(addr); + } + + /// Every peer, by server ID. + #[cfg(test)] + pub(crate) fn peers(&self) -> &HashMap, SocketAddr> { + &self.by_id + } +} + +impl Peers { + /// Replace every peer at once. + pub(crate) fn set(&self, map: Map) { + *self.0.write().unwrap_or_else(PoisonError::into_inner) = map; + } + + fn get(&self, id: &[u8]) -> Option { + self.0 + .read() + .unwrap_or_else(PoisonError::into_inner) + .by_id + .get(id) + .copied() + } + + fn contains(&self, addr: &SocketAddr) -> bool { + self.0 + .read() + .unwrap_or_else(PoisonError::into_inner) + .addrs + .contains(addr) + } +} + +/// A forwarded packet, as the client sent it. +pub(crate) struct Unwrapped<'a> { + pub remote: SocketAddr, + pub local_ip: Option, + pub ecn: Option, + pub packet: &'a [u8], +} + +/// One server's forwarding: its server ID, its peers, and the socket forwards travel on. +#[derive(Debug)] +pub(crate) struct Forward { + socket: tokio::net::UdpSocket, + /// The same socket, sending without waiting on the reactor to report it writable, which a + /// fresh socket is not yet. + send: std::net::UdpSocket, + id: Box<[u8]>, + peers: Peers, + key: Hmac, + /// Scratch for one forward, sent or received. + buf: Box<[u8]>, +} + +impl Forward { + /// Forward on `socket` between the servers in `peers`, this one named `id` in its connection IDs. + /// + /// `max_udp_payload_size` bounds the packets a client sends, so a forward never exceeds it + /// plus [`OVERHEAD`]. The socket may fragment: a full-size packet plus the header can exceed + /// the path between servers, which carries it anyway. + pub(crate) fn new( + socket: std::net::UdpSocket, + id: &[u8], + peers: Peers, + key: &[u8; 32], + max_udp_payload_size: u16, + ) -> io::Result { + socket.set_nonblocking(true)?; + let send = socket.try_clone()?; + let socket = { + let _runtime = crate::runtime::enter(); + tokio::net::UdpSocket::from_std(socket)? + }; + Ok(Self { + socket, + send, + id: id.into(), + peers, + key: Hmac::new_from_slice(key).expect("HMAC accepts any key length"), + buf: vec![0; usize::from(max_udp_payload_size) + OVERHEAD].into(), + }) + } + + /// Forward `packet` if its connection ID names another server, returning whether it was taken. + /// + /// A packet naming a server missing from the peers is taken and dropped: answering it here + /// would reset a connection another server owns. One that `is_local_reset` recognizes stays, + /// since a stateless reset's connection ID is random and may seem to name another server. + pub(crate) fn route( + &mut self, + packet: &[u8], + remote: SocketAddr, + local_ip: Option, + ecn: Option, + is_local_reset: impl FnOnce() -> bool, + ) -> bool { + let Some(id) = self.foreign(packet) else { + return false; + }; + if is_local_reset() { + return false; + } + let Some(peer) = self.peers.get(id) else { + tracing::trace!(id = %hex::encode(id), "dropping a packet for an unknown server"); + return true; + }; + // A client never sends more than the advertised payload size, so a longer datagram is + // not a packet for one of our connections. + let Some(len) = self.seal(packet, remote, local_ip, ecn) else { + return true; + }; + match self.send.send_to(&self.buf[..len], peer) { + Ok(_) => {} + // Lost like any datagram on a full socket; the client retransmits. + Err(err) if err.kind() == io::ErrorKind::WouldBlock => {} + Err(err) => tracing::debug!(%err, %peer, "failed to forward a packet"), + } + true + } + + /// Wrap `packet` into the scratch buffer, returning the forward's length, unless it would not fit. + fn seal( + &mut self, + packet: &[u8], + remote: SocketAddr, + local_ip: Option, + ecn: Option, + ) -> Option { + let buf = self.buf.get_mut(..OVERHEAD + packet.len())?; + buf[0] = VERSION; + buf[1] = ecn.map_or(0, |ecn| ecn as u8) | local_ip.map_or(0, |_| ARRIVAL); + buf[2..4].copy_from_slice(&remote.port().to_be_bytes()); + buf[4..20].copy_from_slice(&ipv6(remote.ip()).octets()); + buf[20..36].copy_from_slice(&local_ip.map_or(Ipv6Addr::UNSPECIFIED, ipv6).octets()); + buf[OVERHEAD..].copy_from_slice(packet); + let tag = tag(&self.key, buf).finalize().into_bytes(); + buf[OVERHEAD - TAG_LEN..OVERHEAD].copy_from_slice(&tag[..TAG_LEN]); + Some(buf.len()) + } + + /// Receive the next authentic forward, or `None` for a datagram that is not one. + pub(crate) fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll>>> { + let mut read = tokio::io::ReadBuf::new(&mut self.buf); + let from = ready!(self.socket.poll_recv_from(cx, &mut read))?; + let len = read.filled().len(); + Poll::Ready(Ok(self.open(from, len))) + } + + /// Authenticate the forward in the first `len` bytes of the scratch buffer. + fn open(&self, from: SocketAddr, len: usize) -> Option> { + // Checked before the tag, so a stranger costs a lookup and never a hash. + if !self.peers.contains(&from) { + return None; + } + let buf = &self.buf[..len]; + if len < OVERHEAD || buf[0] != VERSION { + return None; + } + tag(&self.key, buf) + .verify_truncated_left(&buf[OVERHEAD - TAG_LEN..OVERHEAD]) + .ok()?; + + let packet = &buf[OVERHEAD..]; + // Never forward again, and never answer for a connection another server owns: a peer + // with a stale view of who owns an ID would otherwise loop or reset it. + if self.foreign(packet).is_some() || packet.first().is_none_or(|first| first & 0x80 != 0) { + return None; + } + + let flags = buf[1]; + let port = u16::from_be_bytes([buf[2], buf[3]]); + let ip = |at: usize| canonical(Ipv6Addr::from(<[u8; 16]>::try_from(&buf[at..at + 16]).unwrap())); + Some(Unwrapped { + remote: SocketAddr::new(ip(4), port), + local_ip: (flags & ARRIVAL != 0).then(|| ip(20)), + ecn: EcnCodepoint::from_bits(flags), + packet, + }) + } + + /// The server ID in `packet`'s connection ID, when it is a short header naming another server. + fn foreign<'a>(&self, packet: &'a [u8]) -> Option<&'a [u8]> { + if packet.first()? & 0x80 != 0 { + return None; + } + let start = 1 + ID_OFFSET; + let id = packet.get(start..start + self.id.len())?; + (id != &*self.id).then_some(id) + } +} + +/// The MAC over `forward`'s header, short of the tag itself, and its packet. +fn tag(key: &Hmac, forward: &[u8]) -> Hmac { + let mut mac = key.clone(); + mac.update(&forward[..OVERHEAD - TAG_LEN]); + mac.update(&forward[OVERHEAD..]); + mac +} + +fn ipv6(ip: IpAddr) -> Ipv6Addr { + match ip { + IpAddr::V4(ip) => ip.to_ipv6_mapped(), + IpAddr::V6(ip) => ip, + } +} + +fn canonical(ip: Ipv6Addr) -> IpAddr { + match ip.to_ipv4_mapped() { + Some(ip) => IpAddr::V4(ip), + None => IpAddr::V6(ip), + } +} + +#[cfg(test)] +mod tests { + use std::{future::poll_fn, net::Ipv4Addr}; + + use super::*; + + const KEY: [u8; 32] = [7; 32]; + + type Opened = (SocketAddr, Option, Option, Vec); + + fn socket() -> std::net::UdpSocket { + std::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap() + } + + /// The server named `id`, forwarding on `socket` under `key`. + fn server(id: u8, socket: std::net::UdpSocket, peers: &Peers, key: &[u8; 32]) -> Forward { + Forward::new(socket, &[id], peers.clone(), key, 1200).unwrap() + } + + /// A short header whose connection ID names server `id`. + fn short(id: u8) -> Vec { + vec![0x40, 5, id, 0xa, 0xb, 0xc, 0xd, 0x01, 0x02] + } + + /// The next datagram `forward` receives, opened. + async fn recv(forward: &mut Forward) -> Option { + let next = poll_fn(|cx| { + forward + .poll_recv(cx) + .map_ok(|opened| opened.map(|o| (o.remote, o.local_ip, o.ecn, o.packet.to_vec()))) + }); + kio::clock::timeout(std::time::Duration::from_secs(5), next) + .await + .expect("a datagram") + .unwrap() + } + + /// Servers 1 and 2, each listing both. + fn pair() -> (Forward, Forward, Peers) { + let (a, b) = (socket(), socket()); + let peers = Peers::default(); + let mut map = Map::default(); + map.insert(&[1], a.local_addr().unwrap()); + map.insert(&[2], b.local_addr().unwrap()); + peers.set(map); + (server(1, a, &peers, &KEY), server(2, b, &peers, &KEY), peers) + } + + #[tokio::test] + async fn a_forward_arrives_as_the_client_sent_it() { + let (mut a, mut b, _) = pair(); + let client: SocketAddr = "192.0.2.7:5000".parse().unwrap(); + let arrival: IpAddr = "198.51.100.1".parse().unwrap(); + + assert!(a.route(&short(2), client, Some(arrival), Some(EcnCodepoint::Ce), || false)); + assert_eq!( + recv(&mut b).await, + Some((client, Some(arrival), Some(EcnCodepoint::Ce), short(2))) + ); + + // An IPv6 client, through a server that never learned the arrival address. + let client: SocketAddr = "[2001:db8::7]:5000".parse().unwrap(); + assert!(a.route(&short(2), client, None, None, || false)); + assert_eq!(recv(&mut b).await, Some((client, None, None, short(2)))); + } + + /// What stays: this server's own connection IDs, long headers, anything too short to carry a + /// server ID, and a stateless reset for one of this server's connections, whose random + /// connection ID may seem to name another. A short header for a server nobody listed is taken + /// and dropped. + #[tokio::test] + async fn only_another_servers_short_headers_leave() { + let (mut a, _b, _) = pair(); + let client: SocketAddr = "192.0.2.7:5000".parse().unwrap(); + assert!(!a.route(&short(1), client, None, None, || false)); + let mut long = short(2); + long[0] = 0xc0; + assert!(!a.route(&long, client, None, None, || false)); + assert!(!a.route(&[0x40, 5], client, None, None, || false)); + assert!(a.route(&short(3), client, None, None, || false)); + assert!(!a.route(&short(2), client, None, None, || true)); + } + + /// A forward opens only from a listed peer, under the shared key, and only for this server: a + /// peer with a stale map must not make it forward again or answer for another server. + #[tokio::test] + async fn a_forward_needs_a_peer_the_key_and_this_server() { + let (mut a, mut b, peers) = pair(); + let client: SocketAddr = "192.0.2.7:5000".parse().unwrap(); + let b_addr = b.socket.local_addr().unwrap(); + + // Not listed, with the right key. + let mut stranger = server(1, socket(), &peers, &KEY); + assert!(stranger.route(&short(2), client, None, None, || false)); + assert_eq!(recv(&mut b).await, None); + + // Listed, with the wrong key. + let mut forged = server(3, socket(), &peers, &[8; 32]); + let mut map = Map::default(); + map.insert(&[1], a.socket.local_addr().unwrap()); + map.insert(&[2], b_addr); + map.insert(&[3], forged.socket.local_addr().unwrap()); + peers.set(map); + assert!(forged.route(&short(2), client, None, None, || false)); + assert_eq!(recv(&mut b).await, None); + + // Server 1 sending server 2 a packet of its own, as a stale map would. + let len = a.seal(&short(1), client, None, None).unwrap(); + a.send.send_to(&a.buf[..len], b_addr).unwrap(); + assert_eq!(recv(&mut b).await, None); + + // Still open to a real forward. + assert!(a.route(&short(2), client, None, None, || false)); + assert!(recv(&mut b).await.is_some()); + } +} diff --git a/rs/moq-tokio/src/quic/driver/mod.rs b/rs/moq-tokio/src/quic/driver/mod.rs index f88d0fe1f42..f559fbb5f26 100644 --- a/rs/moq-tokio/src/quic/driver/mod.rs +++ b/rs/moq-tokio/src/quic/driver/mod.rs @@ -47,6 +47,7 @@ pub mod connection; pub mod endpoint; +pub(crate) mod forward; mod incoming; mod mutex; pub mod recv; diff --git a/rs/moq-tokio/src/quic/driver/tests.rs b/rs/moq-tokio/src/quic/driver/tests.rs index da2a32cbd10..64da9a12878 100755 --- a/rs/moq-tokio/src/quic/driver/tests.rs +++ b/rs/moq-tokio/src/quic/driver/tests.rs @@ -330,11 +330,16 @@ impl EndpointFactory { self.endpoint_with_config(transport::Config::default()) } - fn endpoint_with_config(&self, transport_config: transport::Config) -> Endpoint { + fn server_config(&self, transport_config: transport::Config) -> server::Config { let key = PrivateKeyDer::Pkcs8(self.cert.signing_key.serialize_der().into()); let mut server_config = crate::quic::driver::server::Config::with_single_cert(vec![self.cert.cert.der().clone()], key).unwrap(); - server_config.transport = transport_config.clone(); + server_config.transport = transport_config; + server_config + } + + fn endpoint_with_config(&self, transport_config: transport::Config) -> Endpoint { + let server_config = self.server_config(transport_config.clone()); let mut roots = RootCertStore::empty(); roots.add(self.cert.cert.der().clone()).unwrap(); @@ -1372,6 +1377,135 @@ async fn remote_address_follows_migration() { client_conn.close(0u32.into(), "done"); } +/// Three servers sit behind one front address. A connection whose handshake reached the first +/// keeps working while the front sends its packets to the second, then the third: each forwards +/// them to the first, which replies to the client directly. +#[tokio::test] +async fn forwarded_packets_reach_their_owner() { + use crate::quic::driver::forward::{Forward, Map, Peers, ServerIdGenerator}; + + let _guard = subscribe(); + let localhost = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + let factory = EndpointFactory::new(); + let key = [7; 32]; + + let sockets: Vec = (0..3).map(|_| UdpSocket::bind(localhost).unwrap()).collect(); + let peers = Peers::default(); + let mut map = Map::default(); + for (id, socket) in (1u8..).zip(&sockets) { + map.insert(&[id], socket.local_addr().unwrap()); + } + peers.set(map); + let servers: Vec = (1u8..) + .zip(sockets) + .map(|(id, socket)| { + let mut config = factory.endpoint_config.clone(); + config.cid_generator = Some(Arc::new(move || Box::new(ServerIdGenerator::new(&[id], 4)))); + let forward = Forward::new(socket, &[id], peers.clone(), &key, config.max_udp_payload_size).unwrap(); + Endpoint::forwarding( + config.with_server(factory.server_config(transport::Config::default())), + UdpSocket::bind(localhost).unwrap(), + forward, + ) + .unwrap() + }) + .collect(); + + // The front end: the client's packets go to whichever server it names, from one address, and + // a reply from any server goes back to the client. + let front = tokio::net::UdpSocket::bind(localhost).await.unwrap(); + let front_addr = front.local_addr().unwrap(); + let back = tokio::net::UdpSocket::bind(localhost).await.unwrap(); + let back_addr = back.local_addr().unwrap(); + let target = Arc::new(Mutex::new(servers[0].local_addr())); + let front_task = tokio::spawn({ + let target = target.clone(); + async move { + let (mut up, mut down) = ([0; 1500], [0; 1500]); + let mut client = None; + loop { + tokio::select! { + received = front.recv_from(&mut up) => { + let (len, from) = received.unwrap(); + client = Some(from); + let to = *target.lock().unwrap(); + back.send_to(&up[..len], to).await.unwrap(); + } + received = back.recv_from(&mut down) => { + let (len, _) = received.unwrap(); + front.send_to(&down[..len], client.unwrap()).await.unwrap(); + } + } + } + } + }); + + let owner = servers[0].clone(); + let server_task = tokio::spawn(async move { + let conn = owner.accept().await.unwrap().accept().unwrap().await.unwrap(); + loop { + let Ok(stream) = conn.accept_bi().await else { break }; + // The client's packets keep arriving from the address its handshake took, whichever + // server they reached first. + assert_eq!(conn.remote_address(), back_addr); + echo(stream).await; + } + }); + + let client = factory.endpoint(); + let conn = client.dial(front_addr, "localhost").unwrap().await.unwrap(); + for (round, server) in [0, 1, 2, 0].into_iter().enumerate() { + *target.lock().unwrap() = servers[server].local_addr(); + let message = format!("round {round} through server {server}"); + let (mut send, mut recv) = conn.open_bi(Default::default()).await.unwrap(); + send.write_all(message.as_bytes()).await.unwrap(); + let _ = send.finish(); + let echoed = timeout(Duration::from_secs(10), recv.read_to_end(1024)) + .await + .expect("the owner answered") + .unwrap(); + assert_eq!(echoed, message.as_bytes()); + } + assert_eq!(servers[1].open_connections(), 0); + assert_eq!(servers[2].open_connections(), 0); + + conn.close(0u32.into(), "done"); + server_task.await.unwrap(); + front_task.abort(); +} + +/// Closing a forwarding endpoint frees its forward port with its main one, even while a handle +/// keeps the endpoint alive, so a listener restarted on the same configuration can bind both. +#[tokio::test] +async fn close_releases_the_forward_socket() { + use crate::quic::driver::forward::{Forward, Peers}; + + let _guard = subscribe(); + let localhost = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + let factory = EndpointFactory::new(); + let forward_socket = UdpSocket::bind(localhost).unwrap(); + let forward_addr = forward_socket.local_addr().unwrap(); + let config = factory.endpoint_config.clone(); + let forward = Forward::new( + forward_socket, + &[1], + Peers::default(), + &[7; 32], + config.max_udp_payload_size, + ) + .unwrap(); + let endpoint = Endpoint::forwarding( + config.with_server(factory.server_config(transport::Config::default())), + UdpSocket::bind(localhost).unwrap(), + forward, + ) + .unwrap(); + + endpoint.close(0u32.into(), "done").await; + UdpSocket::bind(forward_addr).expect("the forward port is free once close returns"); + drop(endpoint); +} + /// A send group from another connection panics where it is passed in, without poisoning the /// connection's lock: a poisoned lock panics again in every handle's drop, aborting the process. #[tokio::test] diff --git a/rs/moq-tokio/src/settings.rs b/rs/moq-tokio/src/settings.rs index 3b2c565674f..4a51f388826 100644 --- a/rs/moq-tokio/src/settings.rs +++ b/rs/moq-tokio/src/settings.rs @@ -321,4 +321,21 @@ struct ListenLb { #[usage(env = "MOQ_LISTEN_QUIC_LB_NONCE", cli("--listen-quic-lb-nonce"))] nonce: Option, + + #[usage(flatten)] + forward: ListenLbForward, +} + +/// `[listen.lb.forward]` / `--listen-quic-lb-forward-*`. +#[derive(usage_rs::Config)] +#[usage(prefix = "listen.lb.forward")] +struct ListenLbForward { + #[usage(env = "MOQ_LISTEN_QUIC_LB_FORWARD_BIND", cli("--listen-quic-lb-forward-bind"))] + bind: Option, + + #[usage(env = "MOQ_LISTEN_QUIC_LB_FORWARD_KEY", cli("--listen-quic-lb-forward-key"))] + key: Option, + + #[usage(env = "MOQ_LISTEN_QUIC_LB_FORWARD_PEERS", cli("--listen-quic-lb-forward-peers"))] + peers: Option, } From 1afff757477e7de3ac07987f4ba29a943a976a21 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 07:44:59 +0000 Subject: [PATCH 45/86] ci(queue): dispatch Queue onto a branch head (#7617) Co-authored-by: Claude Opus 5.5 --- .github/workflows/queue.yml | 5 +++++ sh/gh/alert.sh | 3 ++- sh/gh/queue.test.ts | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml index b4cb104eb18..925431dfcbc 100644 --- a/.github/workflows/queue.yml +++ b/.github/workflows/queue.yml @@ -17,6 +17,11 @@ on: types: [opened, synchronize, reopened, edited] merge_group: types: [checks_requested] + # Reports the skipped context on a branch's head without a pull_request + # event. A PR whose test merge commit predates this workflow gets no + # pull_request run from it (GitHub runs PR workflows from that commit), so + # `gh workflow run queue.yml --ref ` gives it the context. + workflow_dispatch: permissions: contents: read diff --git a/sh/gh/alert.sh b/sh/gh/alert.sh index 246c4c63725..0ee5447a7c4 100755 --- a/sh/gh/alert.sh +++ b/sh/gh/alert.sh @@ -116,7 +116,8 @@ const files = (await Bun.stdin.text()).split("\n").filter(Boolean); // only by these needs no entry. const UNWATCHED = new Set(["pull_request", "pull_request_target", "merge_group", "workflow_call"]); // These workflows validate branches, warm caches, or run an agent on request; none publishes. -const CI_ONLY = new Set(["Cache", "OBS", "Platform", "Pullfrog", "Quest", "Runner VM"]); +// A Queue dispatch only reports the skipped merge queue context on a branch head. +const CI_ONLY = new Set(["Cache", "OBS", "Platform", "Pullfrog", "Quest", "Queue", "Runner VM"]); const names = []; for (const file of files) { const doc = Bun.YAML.parse(await Bun.file(file).text()); diff --git a/sh/gh/queue.test.ts b/sh/gh/queue.test.ts index 6e5073d6168..274edac4667 100644 --- a/sh/gh/queue.test.ts +++ b/sh/gh/queue.test.ts @@ -46,6 +46,8 @@ assert.equal(await Bun.file(".github/workflows/land.yml").exists(), false, "no w // host's runner, read-only and without secrets. const queue = await workflow("queue"); assert.deepEqual(queue.on.merge_group, { types: ["checks_requested"] }); +// A dispatch only reports the skipped context on a branch head. +assert.ok("workflow_dispatch" in queue.on); assert.deepEqual(queue.on.pull_request.branches, ["main"]); assert.ok(queue.on.pull_request.types.includes("edited"), "retargeting to main must report the context"); assert.equal(queue.on.pull_request.paths, undefined, "the required context must always report"); From 0fb841697cadf082c474b6b5091b7b4faadddf18 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 07:53:21 +0000 Subject: [PATCH 46/86] ci(queue): a dispatch on main reports Queue on a PR's head (#7619) Co-authored-by: Claude Opus 5.5 --- .github/workflows/queue.yml | 33 +++++++++++++++++++++++++++++---- sh/gh/queue.test.ts | 7 +++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml index 925431dfcbc..dc55e61547e 100644 --- a/.github/workflows/queue.yml +++ b/.github/workflows/queue.yml @@ -17,11 +17,16 @@ on: types: [opened, synchronize, reopened, edited] merge_group: types: [checks_requested] - # Reports the skipped context on a branch's head without a pull_request - # event. A PR whose test merge commit predates this workflow gets no - # pull_request run from it (GitHub runs PR workflows from that commit), so - # `gh workflow run queue.yml --ref ` gives it the context. + # Reports the skipped context on a PR's head without a pull_request event. + # A PR whose test merge commit predates this workflow gets no pull_request + # run from it (GitHub runs PR workflows from that commit, and a dispatch reads + # the workflow from the ref it runs on), so + # `gh workflow run queue.yml -f sha=` on main reports it there. workflow_dispatch: + inputs: + sha: + description: The PR head commit to report the skipped Queue context on. + required: true permissions: contents: read @@ -74,3 +79,23 @@ jobs: path: target/cargo-timings/cargo-timing-*.html if-no-files-found: ignore retention-days: 14 + + # The merge queue still builds every group with the job above; this only + # satisfies the PR-side requirement, as a pull_request run's skipped job does. + report: + name: Report Queue + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + checks: write + steps: + - name: Report the skipped Queue context + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ inputs.sha }} + run: | + [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::sha must be a full commit SHA" >&2; exit 1; } + gh api "repos/$REPO/check-runs" -f name=Queue -f head_sha="$SHA" -f status=completed -f conclusion=skipped \ + -f 'output[title]=Queue' -f 'output[summary]=Skipped on the pull request; the merge queue builds the group.' --silent diff --git a/sh/gh/queue.test.ts b/sh/gh/queue.test.ts index 274edac4667..35607d74995 100644 --- a/sh/gh/queue.test.ts +++ b/sh/gh/queue.test.ts @@ -46,8 +46,11 @@ assert.equal(await Bun.file(".github/workflows/land.yml").exists(), false, "no w // host's runner, read-only and without secrets. const queue = await workflow("queue"); assert.deepEqual(queue.on.merge_group, { types: ["checks_requested"] }); -// A dispatch only reports the skipped context on a branch head. -assert.ok("workflow_dispatch" in queue.on); +// A dispatch only reports the skipped context on a PR head, with nothing but `checks: write`. +assert.ok(queue.on.workflow_dispatch.inputs.sha.required); +assert.equal(queue.jobs.report.if, "github.event_name == 'workflow_dispatch'"); +assert.deepEqual(queue.jobs.report.permissions, { checks: "write" }); +assert.equal(queue.jobs.report.steps.length, 1, "the report job checks nothing out and runs no code from a PR"); assert.deepEqual(queue.on.pull_request.branches, ["main"]); assert.ok(queue.on.pull_request.types.includes("edited"), "retargeting to main must report the context"); assert.equal(queue.on.pull_request.paths, undefined, "the required context must always report"); From 4fb80d85d72168a462b9ff8cfb54f6a3876dcc8f Mon Sep 17 00:00:00 2001 From: David von Wrangel <49842806+wrangelvid@users.noreply.github.com> Date: Mon, 2 Nov 2026 07:58:47 +0000 Subject: [PATCH 47/86] feat(net): expose a one-shot session's GOAWAY as `draining` (#7457) Co-authored-by: Claude Opus 5.5 Co-authored-by: Luke Curley --- doc/lib/js/net.md | 2 +- js/net/api/index.api.md | 5 +++++ js/net/bench/forward.ts | 2 -- js/net/src/connection/established.ts | 13 ++++++++++++- js/net/src/connection/forward.test.ts | 2 -- js/net/src/connection/pool.ts | 4 +++- js/net/src/connection/reload.ts | 2 +- js/net/src/goaway-requests.test.ts | 4 ++-- js/net/src/ietf/connection.ts | 8 ++++++-- js/net/src/ietf/goaway.test.ts | 7 +++---- js/net/src/lite/connection.test.ts | 6 +++--- js/net/src/lite/connection.ts | 8 ++++++-- js/net/src/wire.ts | 3 --- test/interop/lite-padding.ts | 3 +-- 14 files changed, 43 insertions(+), 26 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index b926782ff40..1ec7b482b1f 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -45,7 +45,7 @@ publication.announce(); - **Follow restarts.** `origin.follow(path)` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close it when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends it. Pass `{ epoch }` to pin one instance, which it waits for again after an end. `request(path)` instead keeps its accepted instance and ends once that instance stops serving; while pending, it fails `Unroutable` once discovery of its path has synced without a route for it. To list what is live, iterate `announced(scope)`. See [identity and recovery](/concept/identity) to choose between following and holding one instance. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)`, `request(path)`, or `follow(path)`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. Every request and follower watches its path from creation until closed, so it outlives the listing that found it. A relay that refuses an announce request downgrades that session: requests subscribe blind through it, and followers keep waiting for a session that can discover. - **Connections share by URL and versions.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. An ordered `versions` list, spelled like `moq-lite-06` as in Rust, applies to both WebTransport and WebSocket; equivalent lists share, while a different order or an omitted list gets its own pool entry. Supplying your own transport options or origin selects a private loop. Receive estimates are on demand: observe `connection.probe` reactively or subscribe while you need them; `peek()` alone opens no PROBE stream. -- **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, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`, which also presents the tokens on the connection's `auth` handle to the new host. +- **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, which is a restart without an epoch: an unpinned announced request follows the replacement, while a blind or epoch-pinned request stays on its accepted instance. With an epoch, which travels only when `moq-lite-07-wip` is offered, the replacement serves the same broadcast, so open subscriptions move across without missing a group. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`, which also presents the tokens on the connection's `auth` handle to the new host. A one-shot session from `Connection.connect` never moves on its own; its `draining` settles with the GOAWAY, so you can connect again and close it. Its URI is the peer's to choose and `goaway.redirect` does not apply, so validate it before dialing it or presenting tokens to it. - **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing, though its reservation still reads the share it would get. Publishers reserve against it so their targets sum to the estimate. - **List a broadcast's tracks.** `for await (const event of broadcast.tracks())` reports each track the broadcast lists (`added`, with its `Track.Info`), one `synced`, then `added` and `removed` as tracks come and go, until the broadcast ends; leaving the loop stops listing. Over a session it needs `moq-lite-07-wip`, syncs at once with what this side holds, and the peer's tracks follow as `added`. A track served only on request is never listed. See [discovery](/concept/moq-lite#discovery). - **One track per name.** Concurrent subscriptions share one request and producer. `createTrack` on a name with a request still queued answers it. The application owns sequence numbers: the broadcast keeps nothing of an ended track, so a track that replaces it, created or requested, starts at 0. A name always means the same content, so the replacement continues past its predecessor's groups by writing its first group with an explicit sequence. diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 5794f3acbe0..2ed6c4facaa 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -151,12 +151,17 @@ export namespace Connection { websocket?: Connection.WebSocketProps; webtransport?: Connection.WebTransportProps; } + export interface Drain { + readonly timeout?: Milli; + readonly uri: string; + } export interface Established { abort(): void; announced(scope?: Path.Pattern, options?: Announce.Options): Announce.Consumer; readonly auth: Auth.Handle; close(): Promise; closed: Promise; + readonly draining: Signals.GetPromise; readonly peerImplementation: Promise; readonly probe: Signals.Getter; stats(): Promise; diff --git a/js/net/bench/forward.ts b/js/net/bench/forward.ts index 270205ccc1d..c0aaa6f030f 100644 --- a/js/net/bench/forward.ts +++ b/js/net/bench/forward.ts @@ -4,7 +4,6 @@ */ import { Manual } from "@moq/clock"; -import { Once } from "@moq/signals"; import { announcedView } from "../src/announced.test-util"; import * as Announce from "../src/announced.ts"; import { AuthSession } from "../src/auth_session.ts"; @@ -35,7 +34,6 @@ class FakeSession { constructor() { registerWire(this, { consume: () => new BroadcastProducer().consume(), - goaway: new Once(), exclusive: false, auth: new AuthSession({ peerGrant: { publish: new Path.Patterns(), subscribe: new Path.Patterns() } }), }); diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index 3ca53f9af98..1c77c1234b1 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -1,7 +1,8 @@ -import type { Getter } from "@moq/signals"; +import type { GetPromise, Getter } from "@moq/signals"; import type * as announce from "../announce.ts"; import type * as Auth from "../auth.ts"; import type * as Path from "../path.ts"; +import type { Drain } from "./goaway.ts"; import type { Probe, Stats } from "./stats.ts"; import type { Transport } from "./transport.ts"; @@ -77,6 +78,16 @@ export interface Established { /** End the session immediately without waiting for delivery. */ abort(): void; + /** + * Settles with the peer's GOAWAY, its request that this session move elsewhere. The session + * keeps serving until it closes, and a one-shot session never moves on its own, so answer it + * by connecting again and closing this one. Stays pending if the session closes without one. + * + * The URI is peer-controlled: apply a redirect policy you trust before dialing it or presenting + * tokens to it, as the reconnecting `Connection` does with `goaway.redirect`. + */ + readonly draining: GetPromise; + /** * Resolves when the session closes: `null` for a clean close, an `Error.Session` when the * peer closed with a code (e.g. `SessionCode.Unauthorized` for an auth rejection), or the diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 83aadc9305b..ef7360b4509 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, expect, jest, spyOn, test } from "bun:test"; import { type Installed, Manual, Milli, Nano } from "@moq/clock"; -import { Once } from "@moq/signals"; import * as Announce from "../announced.ts"; import { AuthSession } from "../auth_session.ts"; import { type Consumer as BroadcastConsumer, Producer as BroadcastProducer } from "../broadcast.ts"; @@ -72,7 +71,6 @@ class FakeSession { constructor({ exclusive = false } = {}) { registerWire(this, { consume: (path) => this.consume(path), - goaway: new Once(), exclusive, auth: new AuthSession({ peerGrant: { publish: new Path.Patterns(), subscribe: new Path.Patterns() } }), }); diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 3c9f18757d0..6e0d499a16d 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -30,7 +30,7 @@ import { import type { Established as EstablishedType } from "./established.ts"; import type { Extensions as ExtensionsType } from "./extensions.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; -import type { GoawayProps, Redirect as RedirectType } from "./goaway.ts"; +import type { Drain as DrainType, GoawayProps, Redirect as RedirectType } from "./goaway.ts"; import { pumpAnnounced, Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; import type { Transport as TransportType } from "./transport.ts"; @@ -402,6 +402,8 @@ export namespace Connection { export type Goaway = GoawayProps; /** What to do with the URI a relay names in its GOAWAY. @reexport */ export type Redirect = RedirectType; + /** A relay's GOAWAY as a one-shot session received it. @reexport */ + export type Drain = DrainType; /** Current state of a {@link Connection}. @reexport */ export type Status = ReloadStatus; /** The current connection's PROBE estimates. @reexport */ diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 92326220384..243fb0b7862 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -564,7 +564,7 @@ export class Reload { // A cancelled effect resolves undefined, so the sentinel tells the session // closing (null for clean, an Error otherwise) apart from this run being // torn down. Anything else is the peer's GOAWAY. - const ended = await effect.race(connection.closed, wireOf(connection).goaway); + const ended = await effect.race(connection.closed, connection.draining); if (ended === undefined) return; if (ended === null || ended instanceof Error) { console.warn(`connection closed, reconnecting: ${ended ? reason(ended) : "no error"}`); diff --git a/js/net/src/goaway-requests.test.ts b/js/net/src/goaway-requests.test.ts index 203a8f944a9..844b0ba952d 100644 --- a/js/net/src/goaway-requests.test.ts +++ b/js/net/src/goaway-requests.test.ts @@ -162,7 +162,7 @@ async function handover(kind: "lite" | "ietf"): Promise { if (inject && goawayBytes) await inject.inject(goawayBytes); else await sendLiteGoaway(pair.server); - await wireOf(client).goaway; + await client.draining; const update = await announced.next(); expect(update?.kind).toBe("update"); @@ -333,7 +333,7 @@ async function routeDeath(kind: "lite" | "ietf", after: boolean): Promise if (inject && goawayBytes) await inject.inject(goawayBytes); else await sendLiteGoaway(pair.server); - await wireOf(client).goaway; + await client.draining; routes = consume.announced(); expect((await routes.next())?.kind).toBe("start"); diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index f8f67b9e022..f21f6e3dc76 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -1,5 +1,5 @@ import { Nano, Second } from "@moq/clock"; -import { type Getter, Once, race, Signal } from "@moq/signals"; +import { type GetPromise, type Getter, Once, race, Signal } from "@moq/signals"; import type * as announce from "../announced.ts"; import type * as Auth from "../auth.ts"; import { AuthSession } from "../auth_session.ts"; @@ -228,7 +228,6 @@ export class Connection implements Established { }); registerWire(this, { consume: (path) => this.#subscriber.consume(path), - goaway: this.#goaway, // The drafts refuse overlapping SUBSCRIBE_NAMESPACEs (PREFIX_OVERLAP). A peer that // declared MoQ Solicit is ours and holds both; any other may enforce it. exclusive: solicit === undefined, @@ -588,6 +587,11 @@ export class Connection implements Established { return GoAway.decode(controlStream.reader, version); } + /** Settles with the peer's GOAWAY; see {@link Established.draining}. */ + get draining(): GetPromise { + return this.#goaway; + } + /** Resolves when the session closes, decoding the peer's close code; see {@link Established.closed}. */ get closed(): Promise { return this.#quic.closed.then(fromClose, (err: unknown) => error(err)); diff --git a/js/net/src/ietf/goaway.test.ts b/js/net/src/ietf/goaway.test.ts index d4bde757c3d..c93c19eb45d 100644 --- a/js/net/src/ietf/goaway.test.ts +++ b/js/net/src/ietf/goaway.test.ts @@ -5,7 +5,6 @@ import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Stream } from "../stream.ts"; -import { wireOf } from "../wire.ts"; import { AuthMessage, AuthOk, IetfAuthWire } from "./auth.ts"; import { Connection } from "./connection.ts"; import { GoAway } from "./goaway.ts"; @@ -44,7 +43,7 @@ for (const [version, alpn, requestId] of [ await peer.writer.u53(GoAway.id); await new GoAway({ newSessionUri: "", timeout: 5000n, requestId }).encode(peer.writer, version); - const drain = await wireOf(connection).goaway; + const drain = await connection.draining; expect(drain.uri).toBe(""); expect(drain.timeout).toBe(Milli(5000)); @@ -137,12 +136,12 @@ for (const [version, alpn] of [ client: true, }); // A caller may close immediately on a redirect, so the protocol error must win. - void wireOf(connection).goaway.then(() => connection.abort()); + void connection.draining.then(() => connection.abort()); const frame = version === Version.DRAFT_18 ? [0x10, 0, 3, 0, 0, 0] : [0x10, 0, 2, 0, 0]; try { await peer.writer.write(new Uint8Array([...frame, ...frame])); expect((await pair.server.closed).closeCode).toBe(SessionCode.ProtocolViolation); - expect(wireOf(connection).goaway.peek()).toBeUndefined(); + expect(connection.draining.peek()).toBeUndefined(); } finally { connection.abort(); } diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index c3883f7092d..90d848a32c7 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -101,7 +101,7 @@ test("a lite GOAWAY keeps the session open, and a second one closes it", async ( try { await sendGoaway(pair.server, ""); - const drain = await wireOf(connection).goaway; + const drain = await connection.draining; expect(drain.uri).toBe(""); await sleep(Nano.fromMilli(Milli(20))); @@ -835,7 +835,7 @@ for (const [alpn, version] of [ await goaway.writer.u53(StreamId.Goaway); await new Goaway("").encode(goaway.writer, version); goaway.writer.close(); - expect((await wireOf(connection).goaway).uri).toBe(""); + expect((await connection.draining).uri).toBe(""); } finally { connection.abort(); } @@ -902,7 +902,7 @@ for (const violation of ["a partial group", "a group past the declared end"] as await goaway.writer.u53(StreamId.Goaway); await new Goaway("").encode(goaway.writer, version); goaway.writer.close(); - expect((await wireOf(connection).goaway).uri).toBe(""); + expect((await connection.draining).uri).toBe(""); } else { expect((await pair.client.closed).closeCode).toBe(SessionCode.ProtocolViolation); } diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 4e01c5fb2fd..245d2b7ca48 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -1,5 +1,5 @@ import { Milli, Nano } from "@moq/clock"; -import { Effect, type Getter, Once, Signal } from "@moq/signals"; +import { Effect, type GetPromise, type Getter, Once, Signal } from "@moq/signals"; import type * as announce from "../announced.ts"; import type * as Auth from "../auth.ts"; import { AuthSession } from "../auth_session.ts"; @@ -232,7 +232,6 @@ export class Connection implements Established { if (!hasSetupStream(version)) this.#peerImplementation.resolve(undefined); registerWire(this, { consume: (path) => this.#subscriber.consume(path), - goaway: this.#goaway, exclusive: false, auth: this.#auth, }); @@ -515,6 +514,11 @@ export class Connection implements Established { return transportStats(this.#quic); } + /** Settles with the peer's GOAWAY; see {@link Established.draining}. */ + get draining(): GetPromise { + return this.#goaway; + } + /** Resolves when the session closes, decoding the peer's close code; see {@link Established.closed}. */ get closed(): Promise { return this.#quic.closed.then(fromClose, (err: unknown) => error(err)); diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 9d16631d831..2e310a01d6e 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -9,7 +9,6 @@ */ import type { Dispose, GetPromise, Getter } from "@moq/signals"; import type * as broadcast from "./broadcast.ts"; -import type { Drain } from "./connection/goaway.ts"; import type * as Epoch from "./epoch.ts"; import type { Consumer as GroupConsumer } from "./group.ts"; import type { Route } from "./hop.ts"; @@ -115,8 +114,6 @@ export type Advertisements = ReadonlyMap; /** The protocol-facing operations behind an established session. */ export interface Established { consume(path: Path.Valid): broadcast.Consumer; - /** Settles with the peer's GOAWAY; the session keeps serving until it closes. */ - readonly goaway: GetPromise; /** * Whether the peer refuses an announce request overlapping one it still holds, so * discovery never holds two where one covers the other. diff --git a/test/interop/lite-padding.ts b/test/interop/lite-padding.ts index 596ec4bf7f0..1a911c704cc 100644 --- a/test/interop/lite-padding.ts +++ b/test/interop/lite-padding.ts @@ -6,7 +6,6 @@ import { DataType, StreamId } from "../../js/net/src/lite/stream.ts"; import { ALPN_05, ALPN_06, ALPN_07_WIP, Version } from "../../js/net/src/lite/version.ts"; import { createMockTransportPair } from "../../js/net/src/mock.ts"; import { Stream, Writer } from "../../js/net/src/stream.ts"; -import { wireOf } from "../../js/net/src/wire.ts"; console.debug = console.error; const input: { alpn: string; bytes: number[] } = JSON.parse(process.argv[2]); @@ -38,7 +37,7 @@ try { await goaway.writer.u53(StreamId.Goaway); await new Goaway("").encode(goaway.writer, version); goaway.writer.close(); - assert.equal((await wireOf(connection).goaway).uri, ""); + assert.equal((await connection.draining).uri, ""); assert.equal(closed, false); const bytes: number[] = []; const output = new Writer( From a09f7c7a8db2928454e80cada48de5a3532a460a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:00:02 +0000 Subject: [PATCH 48/86] quest: only library dependents promote a 0.0.x crate (#7585) Co-authored-by: Claude Opus 5.5 --- quest/m1/shared-version.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/quest/m1/shared-version.md b/quest/m1/shared-version.md index be93ef0e8ca..6741315b0ea 100644 --- a/quest/m1/shared-version.md +++ b/quest/m1/shared-version.md @@ -43,11 +43,16 @@ across the whole project. - Join the group - Exempt by name ✅ (Rust); npm: exempt `@moq/qmux` only ✅ - New packages start at `0.0.x`. A `0.0.x` package that a published `0.x.y` - package depends on (normal or build dependency, same ecosystem) must join - the shared version. Today that promotes `moq-archive` (moq-cli depends on it) - and `moq-uring` (moq-relay's optional dependency). Every other `0.1+` crate - already has a `0.x.y` dependent. Unpublished packages (`publish = false`, - npm `private`) are exempt from both rules. + library depends on (normal or build dependency, same ecosystem) must join + the shared version. Only library dependents count, because a binary-only + package exposes no API downstream (decided 2026-11-01). Today that promotes + `moq-uring` (the `moq_relay` library's optional dependency). `moq-archive` + stays `0.0.x`: its only dependents are the binary-only moq-cli and a moq-hls + dev-dependency. Every other `0.1+` crate already has a `0.x.y` dependent. + Unpublished packages (`publish = false`, npm `private`) are exempt from + both rules. + - Only library deps count ✅ + - Any dependent counts - Enforcement: a CI check in `just check`. - CI check ✅ - Docs only @@ -71,7 +76,7 @@ Implementation guidance: root file, for the check to read. - The check reads Cargo metadata and the JS workspace, along with the other version files. It fails when an eligible package's minor differs from the - recorded one, when a `0.0.x` package has a published `0.x.y` dependent, or + recorded one, when a `0.0.x` package has a published `0.x.y` library dependent, or when an exemption names a package that no longer exists. Until the cut records the shared minor, the check passes without enforcing (the bump waits for the cut). Add a regression test for each failure. From 0f27edb8cac0219631f2c1497c848fd91a5524e5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:00:23 +0000 Subject: [PATCH 49/86] docs: allow bench-only backports to release (#7581) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 2 +- CONTRIBUTING.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2ca01aa1b6a..b0fd87c1b2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ The boundary between packages is extremely important to keep things modular and # Development PRs target `main`, the trunk, where breaking API changes are allowed. -`release` is what ships; touch it only to cut a release or backport a fix (see `CONTRIBUTING.md`). +`release` is what ships; touch it only to cut a release, backport a fix, or backport benchmarks (see `CONTRIBUTING.md`). Wire changes should be backwards compatible for any *published* drafts/versions. Between our own builds only moq-lite compatibility is guarded, since our implementations always prefer it; moq-transport compatibility matters against third-party implementations. An unpublished `-wip` version changes freely, so never defer a better wire design to keep it compatible. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff2c190236f..1b6fe2f99f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,7 @@ For an advisory against Quinn, follow the advisory triage in [`rs/moq-quic/READM - A release is cut by hand: a PR merging `main` into `release`, with a merge commit. - A breaking cut first writes, on `main`, each changed package's version-transition section in its `MIGRATE.md` (`## 0.3 -> 0.4`) from the migration audit: the `!` subjects since the `release` merge base, each of those PRs' Impact section, and the API diff. - Suggest a `release` backport for any serious bug fix (crash, security, data loss, broken interop), whether found on `main` or reported against a release. Land it on `main` first, then port it onto `release` as a separate PR that keeps the published API, so it ships as a patch. The Release gate's API diff (`just api-diff --deny`) fails a backport that changes one; on the cut PR, whose head is `main`, it only reports. +- A bench-only backport ports `main`'s benchmarks onto `release`, so a pairing against `release` (`just bench origin/release`) compares them. It carries only benchmarks and their harness: bench targets, bench-only dev-dependencies, and `bench/`. It changes no shipped code, and it shims locally any API that `release` lacks, keeping each case's ID the same as on `main`. A case that measures code `release` doesn't have is left out, and the PR lists it. Merging one makes release-plz propose patch bumps for the crates it touches, though no shipped code changed. - After any release publishes, patch or breaking, run `just test wire-compat --remeasure` on `main` and land the rewritten `test/interop/compat/planned-breaks.json` with refreshed reasons. Otherwise the nightly wire-compat check fails on the stale pins. - Nothing merges `release` into `main` automatically. Before cutting a minor or breaking release, the maintainer merges `release` into `main` once, so trunk carries the published versions, CHANGELOGs, and any `release`-only commits. It lands as a merge commit with `--admin`, never squashed, so the cut starts from an advanced merge base. From 54f0c3515a5187b22e7de45d3a4e0d5ac7d54600 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:01:30 +0000 Subject: [PATCH 50/86] docs(quest): settle 30 s authored max-age defaults (#7567) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 2 +- quest/m1/max-age-defaults.md | 94 +++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/quest/m1/README.md b/quest/m1/README.md index 39e5be624b6..e24b335d82e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -185,7 +185,7 @@ per-frame CPU growth since #5926. - [Legacy lite close drain](/quest/m1/lite-legacy-close-drain.md) - a lite-01..06 graceful close never cuts a final group the subscriber has not read - [JS join tests cover draft 20](/quest/m1/join-tests-draft20.md) - join.test.ts runs on every supported draft, not just 18 and 22 - [Poll-first async](/quest/m1/poll-first/README.md) - library crates compile no coroutines by default: async wrappers are generated from `poll_*` and gated behind an `async` feature -- [Audit retention defaults](/quest/m1/max-age-defaults.md) - every max-age default listed, with a proposed finite default for the maintainer +- [Authored tracks default to a 30 s max-age](/quest/m1/max-age-defaults.md) - raw and media tracks advertise 30 s unless their author chooses otherwise, with explicit unlimited in every binding - [JS ordered reads wait only on owed groups](/quest/m1/js-ordered-owed.md) - @moq/net matches Rust's owed-group wait - [Binding announcements always carry captures](/quest/m1/ffi-required-captures.md) - moq-ffi and its generated bindings make `captures` a required list, as #7266 always sets it - [HLS export follows a resolution change](/quest/m1/hls-reinit-same-codec.md) - a decoder-identity change under the same codec string starts a new init and segment tag diff --git a/quest/m1/max-age-defaults.md b/quest/m1/max-age-defaults.md index 52fcb3a82b8..091579dfa29 100644 --- a/quest/m1/max-age-defaults.md +++ b/quest/m1/max-age-defaults.md @@ -1,15 +1,95 @@ -# [S] Audit retention defaults and propose a finite max-age +# [L] Authored tracks default to a 30 s max-age, with explicit unlimited ## Goal -Every max-age and cache-retention default in Rust, JS, and FFI is listed with where it applies (producers, relay cache, the IETF absent-MAX_AGE meaning), and the maintainer has a concrete proposal for a finite default to decide on, so no retention is unlimited by accident. +A newly authored track, raw or media, advertises a 30 s publisher max-age unless its author chooses otherwise, in Rust, JS, FFI, C, and every hand-written binding. Unlimited stays expressible explicitly in each of them. Received metadata keeps its absence semantics, the relay keeps its unset (no) local age ceiling, and no wire value or wire absence changes meaning. ## Plan -Decided 2026-10-30 (maintainer): asked whether max-age should default to 0 instead of infinite, like max_delay, which [moq-dev/moq#7403](https://github.com/moq-dev/moq/pull/7403) settled (JS and Rust default 0; unlimited is explicit `None`/`null`). For max-age, audit first and propose. +Decided 2026-10-30 (maintainer): audit before deciding whether max-age should default to 0 instead of unlimited, following the subscriber max-delay decision in [moq-dev/moq#7403](https://github.com/moq-dev/moq/pull/7403). The audit landed in [moq-dev/moq#7567](https://github.com/moq-dev/moq/pull/7567), and the maintainer settled its three proposals: -- Inventory each default with path:line: `track::Producer`/catalog/media producers, the relay cache's eviction and pool, JS `maxAge` (omitted means no publisher limit in `@moq/net`'s `Info`, while `@moq/hang`'s `trackInfo` substitutes 30 s, matching `hang::container::MAX_AGE`), FFI, and the IETF publisher treating an absent MAX_AGE as unlimited (wire semantics; likely kept). -- Say what 0 would break: late joiners, fetch of a non-latest group, DVR/archive. -- Propose a default with its reason and the migration; the maintainer decides, then a follow-up implements it. +- Decided 2026-11-01 (maintainer): ✅ **30 s for both raw and media authored retention**, over 0 raw with 30 s media, or keeping raw unlimited. Thirty seconds reuses the existing media policy and exceeds the recorder's default 10 s reorder allowance without making live viewers wait; zero would drop superseded-group overlap and past fetch. +- Decided 2026-11-01 (maintainer): ✅ **the relay keeps its current unset ceiling**, over defaulting relay `duration` to 30 s with an operator opt-out. A finite relay ceiling would restrict explicit long-DVR and archive publishers too; the existing 30 s idle expiry is a different policy and stays as is. +- Decided 2026-11-01 (maintainer): ✅ **the authoring API applies the default at construction, with an explicit unlimited and unchanged decoded metadata**, over a new default/unlimited/finite retention type across every authoring API. Changing `Info::default` / `infoDefaults` alone is ruled out: it would silently give incoming tracks 30 s and break explicit unlimited through the current conversions. -Public API: none in this quest. Wire: none. +The audit below is the implementation's map of every surface; the Implementation section at the end is the work. + +### Audit scope and overlapping work + +Audited 2026-11-01 at [c95a9b1201](https://github.com/moq-dev/moq/commit/c95a9b1201), after pulling `origin/main`. Source links below pin that revision. Defaults are construction policy; received metadata and subscriber parameters have separate absence semantics. + +Before claiming, checked ready state with `quest unblocked` and searched open PRs; `quest start` accepted the claim. Related open work does not choose defaults: + +- [moq-dev/moq#7446](https://github.com/moq-dev/moq/pull/7446): cache debt reclamation under pressure. +- [moq-dev/moq#7219](https://github.com/moq-dev/moq/pull/7219) and [moq-dev/moq#7516](https://github.com/moq-dev/moq/pull/7516): JS cache-aging test and cache insertion performance. +- [moq-dev/moq#7545](https://github.com/moq-dev/moq/pull/7545): bounded reads and cached backfill; [moq-dev/moq#7310](https://github.com/moq-dev/moq/pull/7310): archive time lookup. +- [moq-dev/moq#7518](https://github.com/moq-dev/moq/pull/7518): unresolved Rust/JS container backlog-start policy. This audit does not promise identical playback start or settle that policy. +- [moq-dev/moq#7522](https://github.com/moq-dev/moq/pull/7522): cached receive-buffer accounting. + +### Current defaults + +Publisher max-age is a freshness bound on **superseded groups**. A group expires when either its successor's arrival is old enough at this hop or the successor's first timestamp trails the live edge enough. Untimed tracks use the wall clock. The newest group is exempt. Byte reclamation has a separate cache policy. See [rs/moq-net/src/model/track.rs:120](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/track.rs#L120) and [js/net/src/track.ts:137](https://github.com/moq-dev/moq/blob/c95a9b1201/js/net/src/track.ts#L137). + +| Surface | Current default and meaning | Source | +| --- | --- | --- | +| Rust raw tracks | `track::Info::default().max_age = None`; no publisher limit. `with_max_age(None)` explicitly requests that meaning. Producers use the supplied info, not an independent finite default. | [rs/moq-net/src/model/track.rs:210](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/track.rs#L210) | +| Rust local origin ceiling | `cache_duration = Duration::MAX`. Effective retention is the minimum of the publisher limit and local ceiling; metadata stays unchanged. | [rs/moq-net/src/model/origin.rs:164](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/origin.rs#L164); [rs/moq-net/src/model/track.rs:1351](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/track.rs#L1351) | +| Bare Rust pool | `cache::Config::default()` and `Pool::default()` have no byte target or idle expiry. Standalone callers must drive `Pool::poll` if they enable expiry. | [rs/moq-net/src/model/cache.rs:90](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/cache.rs#L90); [rs/moq-net/src/model/cache.rs:140](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/cache.rs#L140) | +| Rust origins and standalone broadcasts | Construct a pool with **30 s idle expiry**, no byte target, and no retention ceiling. Origin drivers collect it; standalone use must arrange collection. | [rs/moq-net/src/model/origin.rs:164](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/origin.rs#L164); [rs/moq-net/src/model/broadcast.rs:74](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/broadcast.rs#L74); [rs/moq-net/src/model/cache.rs:23](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/cache.rs#L23) | +| Relay pool and ceiling | Unset `capacity` and `headroom`: no byte target. Unset `duration`: **30 s idle LRU expiry**, **no local age ceiling**. Explicit `duration` sets both expiry and ceiling. The configured pool is shared across origins. | [rs/moq-relay/src/cache.rs:26](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-relay/src/cache.rs#L26); [rs/moq-relay/src/cache.rs:105](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-relay/src/cache.rs#L105); [rs/moq-relay/src/cluster.rs:1247](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-relay/src/cluster.rs#L1247) | +| Rust idle track copies | `IDLE_LINGER = 30 s` retains an unread track and its cached state for returning readers/fetches, without an upstream subscription. This is not publisher freshness. | [rs/moq-net/src/model/track.rs:86](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/track.rs#L86) | +| JS raw tracks | `Info.maxAge` omitted/`undefined` means no publisher limit; `infoDefaults` preserves absence. `null` is not a supported unlimited publisher value. | [js/net/src/track.ts:147](https://github.com/moq-dev/moq/blob/c95a9b1201/js/net/src/track.ts#L147); [js/net/src/track.ts:193](https://github.com/moq-dev/moq/blob/c95a9b1201/js/net/src/track.ts#L193) | +| JS payload cache | `CACHE_WINDOW = 30 s`, independent of publisher maxAge. Writes refresh expiry; reads do not. The live edge is exempt, but a closed track's former edge ages too. Pruning is sliced and timer-driven, so reclamation is not an exact 30 s deadline. No shared byte-capacity pool here. | [js/net/src/track.ts:61](https://github.com/moq-dev/moq/blob/c95a9b1201/js/net/src/track.ts#L61); [js/net/src/track.ts:1262](https://github.com/moq-dev/moq/blob/c95a9b1201/js/net/src/track.ts#L1262) | +| Rust media producers | `hang::container::MAX_AGE = 30 s`; `track_info` explicitly sets it. `moq_mux::catalog::Config.max_age = None` means **inherit hang's 30 s**, not unlimited; `with_max_age(None)` also inherits. | [rs/hang/src/container/mod.rs:1](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/hang/src/container/mod.rs#L1); [rs/hang/src/container/frame.rs:40](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/hang/src/container/frame.rs#L40); [rs/moq-mux/src/catalog/producer.rs:430](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-mux/src/catalog/producer.rs#L430); [rs/moq-mux/src/catalog/producer.rs:473](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-mux/src/catalog/producer.rs#L473) | +| Rust catalog, MSF catalog, HLS record tracks | Inherit raw `Info` retention, currently unlimited. These represent snapshots/windows at the live edge; media overrides do not apply. | [rs/hang/src/catalog/root.rs:250](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/hang/src/catalog/root.rs#L250); [rs/moq-mux/src/catalog/producer.rs:528](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-mux/src/catalog/producer.rs#L528); [rs/moq-mux/src/hls.rs:383](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-mux/src/hls.rs#L383) | +| JS media producers | `Container.trackInfo({maxAge: undefined})` substitutes **30,000 ms**, used by `publish.Broadcast`. A finite override is fixed per network broadcast; changing it creates a new broadcast. There is currently no explicit unlimited option through this helper. | [js/hang/src/container/track.ts:16](https://github.com/moq-dev/moq/blob/c95a9b1201/js/hang/src/container/track.ts#L16); [js/publish/src/broadcast.ts:53](https://github.com/moq-dev/moq/blob/c95a9b1201/js/publish/src/broadcast.ts#L53); [js/publish/src/broadcast.ts:447](https://github.com/moq-dev/moq/blob/c95a9b1201/js/publish/src/broadcast.ts#L447) | +| JS catalog and room metadata | Browser catalog explicitly uses `Number.MAX_SAFE_INTEGER` ms (a very large finite value); room snapshots use **86,400,000 ms / one day**. Neither overrides the separate payload cache. | [js/publish/src/broadcast.ts:398](https://github.com/moq-dev/moq/blob/c95a9b1201/js/publish/src/broadcast.ts#L398); [js/room/src/metadata.ts:156](https://github.com/moq-dev/moq/blob/c95a9b1201/js/room/src/metadata.ts#L156) | +| On-demand audio history | Rust uses the track's max-age, falling back to hang's **30 s** if unlimited; JS uses broadcast/helper maxAge, with **30 s** fallback. Raw capture history is separate from encoded group retention. | [rs/moq-audio/src/encode/producer.rs:195](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-audio/src/encode/producer.rs#L195); [js/publish/src/audio/encoder.ts:413](https://github.com/moq-dev/moq/blob/c95a9b1201/js/publish/src/audio/encoder.ts#L413) | +| CLI and ingest gateways | `--max-age` unset and RTMP/SRT/RTC retention overrides `None` inherit the mux/hang media default, **30 s**. They do not make ingested media unlimited. | [rs/moq-cli/src/args.rs:798](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-cli/src/args.rs#L798); [rs/moq-rtmp/src/dial.rs:177](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-rtmp/src/dial.rs#L177); [rs/moq-rtmp/src/dial.rs:341](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-rtmp/src/dial.rs#L341); [rs/moq-srt/src/dial.rs:84](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-srt/src/dial.rs#L84); [rs/moq-srt/src/dial.rs:174](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-srt/src/dial.rs#L174); [rs/moq-rtc/src/server/mod.rs:146](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-rtc/src/server/mod.rs#L146); [rs/moq-rtc/src/server/whip.rs:164](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-rtc/src/server/whip.rs#L164) | +| GStreamer opaque data | Explicit **5 s** publisher age for `application/octet-stream`; codec media goes through its importer/catalog. | [rs/moq-gst/src/sink/pad.rs:465](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-gst/src/sink/pad.rs#L465) | +| UniFFI raw tracks | `MoqTrackInfo.max_age` defaults to `None`; absent info also uses raw defaults. Conversion currently sets age **only for Some**. Omitted and explicit null both mean unlimited today. Media uses the mux catalog policy. | [rs/moq-ffi/src/producer.rs:31](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-ffi/src/producer.rs#L31); [rs/moq-ffi/src/producer.rs:58](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-ffi/src/producer.rs#L58); [rs/moq-ffi/src/producer.rs:80](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-ffi/src/producer.rs#L80) | +| UniFFI origin cache | No capacity unless configured; Rust origin's **30 s idle expiry** is preserved when adding a capacity. | [rs/moq-ffi/src/origin.rs:331](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-ffi/src/origin.rs#L331) | +| C raw tracks / Python wrapper | C zero-initialized `max_age_present = false` means unlimited; when true, microseconds are used, including zero. Python `TrackInfo.max_age = None` also means unlimited. | [rs/moq-c/src/api.rs:637](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-c/src/api.rs#L637); [rs/moq-c/src/api.rs:664](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-c/src/api.rs#L664); [py/moq-rs/moq/_records.py:171](https://github.com/moq-dev/moq/blob/c95a9b1201/py/moq-rs/moq/_records.py#L171) | +| Archive recording and DVR | Recorder defaults: no DVR pruning (`dvr = None`, intentional durable archive), **10 s** missing-group max-delay, **2 s** object duration, **4 s** flush. Effective missing-group wait is also capped by source max-age. A DVR explicitly supplies `window` and `grace`; neither has a default. | [rs/moq-archive/src/producer/mod.rs:53](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-archive/src/producer/mod.rs#L53); [rs/moq-archive/src/producer/mod.rs:108](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-archive/src/producer/mod.rs#L108); [rs/moq-archive/src/producer/track.rs:450](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-archive/src/producer/track.rs#L450) | +| Archive playback cache and age | Republished info explicitly clears publisher max-age; stored history is governed by the store/DVR retention. Decoded archive objects have a separate **64 MiB** cache; the growing archive listing refresh interval defaults to **1 s**. | [rs/moq-archive/src/consumer/track.rs:299](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-archive/src/consumer/track.rs#L299); [rs/moq-archive/src/consumer/mod.rs:48](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-archive/src/consumer/mod.rs#L48) | +| Ended publisher availability | `moq_mux::Linger::DEFAULT = 30 s` keeps a cleanly ended broadcast and its producers up. Separate from group freshness and pool expiry. | [rs/moq-mux/src/linger.rs:31](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-mux/src/linger.rs#L31) | + +Cache capacity is a converging target, not a hard memory limit. Live edges remain protected; active reads can keep Rust idle-LRU entries fresh (JS uses write activity); an unbounded byte target plus a time window does not bound bytes, group count, publisher count, or an arbitrarily large newest group. Finite max-age alone is not a hostile-peer resource bound. See [rs/moq-net/src/model/cache.rs:125](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/cache.rs#L125). + +### Subscriber and wire absence are separate + +- Rust subscriptions default to `max_delay = Some(0)`; JS omitted subscriber `maxDelay` means 0. Explicit Rust `None` / JS `null` uses publisher retention alone. Changing publisher retention is not another latency-default change. See [rs/moq-net/src/model/subscription.rs:23](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/subscription.rs#L23) and [js/net/src/track.ts:233](https://github.com/moq-dev/moq/blob/c95a9b1201/js/net/src/track.ts#L233). +- A direct fetch has its own options, defaulting to no *subscriber* cap. It still obeys publisher/local retention: Rust `Fetching::poll` clamps the budget and returns `Error::Old` before serving stale data. A more tolerant subscriber cannot recover history expired by publisher policy. See [rs/moq-net/src/model/group.rs:4112](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/group.rs#L4112) and [rs/moq-net/src/model/track.rs:5531](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/model/track.rs#L5531). +- For an IETF SUBSCRIBE with absent MAX_AGE, Rust's `serving_subscription` sets `max_delay = None`; JS uses its largest safe integer budget. This means no additional subscriber restriction, **not guaranteed unlimited storage**. Explicit MAX_AGE overrides this. See [rs/moq-net/src/ietf/publisher.rs:30](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/ietf/publisher.rs#L30), [rs/moq-net/src/ietf/publisher.rs:1932](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/ietf/publisher.rs#L1932), and [js/net/src/ietf/publisher.ts:504](https://github.com/moq-dev/moq/blob/c95a9b1201/js/net/src/ietf/publisher.ts#L504). +- The checkout's Timestamp extension says SUBSCRIBE absence preserves publisher delivery policy and update absence preserves the current budget; zero expires superseded groups, never the newest. This is extension semantics, not a base-IETF default of zero: [drafts/draft-lcurley-moq-timestamp.md:142](https://github.com/moq-dev/moq/blob/c95a9b1201/drafts/draft-lcurley-moq-timestamp.md#L142). The published [timestamp-01](https://www.ietf.org/archive/id/draft-lcurley-moq-timestamp-01.html) predates this MAX_AGE section; do not cite that revision as specifying it. +- Base [moq-transport-22 section 10.3](https://www.ietf.org/archive/id/draft-ietf-moq-transport-22.html#section-10.3) defines a different property, MAX_CACHE_DURATION: absence allows caching until implementation constraints evict objects. Its present-value rule measures age from receipt of each object, unlike this model's superseded-group freshness. The implementation maps publisher age to that property ([rs/moq-net/src/ietf/properties.rs:89](https://github.com/moq-dev/moq/blob/c95a9b1201/rs/moq-net/src/ietf/properties.rs#L89)). That pre-existing distinction is not resolved by the authored default. No wire omission is reinterpreted by this quest. + +### Concrete consequences + +For a live feed with successive groups A then B, **zero publisher age makes A stale as soon as B supersedes it**, even if A's payload remains allocated until cache reclamation. A late join can still read the newest retained B from its start, including its keyframe or snapshot. Zero does not inherently break every late join. It removes overlap for catch-up, switching/warmup that needs A, and direct fetching of A once superseded. Even with zero, the newest group can be large or old in absolute time. + +With **30 s publisher age**, a reader can request superseded groups within the age window, provided they remain cached or can be filled. That does not delay a default live reader, whose own budget is still zero. The window is measured at group reach, not an exact 30 s of first-frame history, and idle expiry, byte pressure, upstream availability, track lifetime, and decoder needs can shorten useful history. This is eligibility, not a guaranteed DVR service level. + +HLS/DASH needs past media for listed segments and players starting several target durations behind live. Zero media retention makes those segment fetches fail once their media groups are superseded. A finite value must cover the advertised playlist and fetch/reorder needs; 30 s is the existing media policy, not a proof that every possible playlist configuration fits. The HLS record track is different: its newest group restates its window. + +DVR and archive need two policies: enough live overlap to record reordered groups without gaps, and durable storage retention to seek later. Zero source age can lose superseded content before recording or eliminate reorder tolerance; it does not erase bytes already stored. Thirty seconds is not a long DVR. Archive replay deliberately advertises no publisher limit, so historical reads remain possible while the store retains them; a relay's local ceiling may still restrict them. Keep this intentional exception explicit. + +### Implementation + +One shared 30 s constant serves raw and media; `hang::container::MAX_AGE` reuses it rather than keeping a second value. The default applies where an author builds a track, never where a peer's metadata is decoded. + +- **Rust raw:** an authored `track::Info` gets `max_age: Some(30 s)` unless the author sets it; explicit `None` stays unlimited. Every decode and republish path (lite, IETF, archive playback) states the received value explicitly, so wire absence still yields `None`; audit `..Default::default()` in those paths before changing any default. +- **Rust media:** follows decision 3, so mux `Config.max_age` and `with_max_age` stop meaning "inherit": omitted is 30 s, `None` is unlimited, and a value is that value. This changes the meaning of today's `with_max_age(None)`, so the PR is breaking and says so. CLI `--max-age` unset and the RTMP/SRT/RTC overrides stay at 30 s. +- **Catalog, MSF catalog, and HLS record tracks** inherit the raw default and become 30 s. Their newest group bootstraps a join, so this is harmless; switching them to zero is not part of this quest. +- **Explicit policies stay:** GStreamer's 5 s opaque data, the JS catalog's `Number.MAX_SAFE_INTEGER`, room snapshots' one day, and archive playback's explicit no-limit republish. +- **JS:** split authoring options from decoded `Info`. At producer construction an omitted or `undefined` `maxAge` is 30,000 ms, `null` is unlimited (matching [moq-dev/moq#7403](https://github.com/moq-dev/moq/pull/7403)'s `null` max-delay), and a number is that policy. `infoDefaults` and decoded `Info` keep absence as no publisher limit. `@moq/hang`'s `trackInfo` and `@moq/publish` gain the same `null`. +- **UniFFI and bindings:** fix the conversions that would otherwise inherit the new Rust default for explicit unlimited: UniFFI's `if let Some` in `rs/moq-ffi/src/producer.rs` and C's `if max_age_present` in `rs/moq-c/src/api.rs`. Where omission and unlimited can't be told apart (a UniFFI record default, a Go nil, a zeroed C struct), omitted means 30 s and an explicit unlimited flag carries unlimited, following the pattern [Subscriptions take a Delay](/quest/m1/subscription-delay.md) sets for max-delay. Python, Swift, Kotlin, Go, Dart, and C++ wrappers expose the same three cases. +- **Relay:** no change. Unset `duration` keeps no local age ceiling and its 30 s idle expiry; byte budgets and cache expiry are untouched. +- **Migration:** raw publishers relying on unlimited history must request unlimited, or a longer window, at construction, before publishing a new track or broadcast identity. Readers asking for history still set a sufficient max-delay and range. The PR title carries `!`, its Impact bullet says how to migrate, migration notes go under `## Unreleased`, and the Rust, JS, binding, and user docs change together. +- **Tests:** cover authored omission versus explicit unlimited in each binding, decoded absence staying unlimited, timed and untimed supersession at 30 s, a late join to an unchanged newest snapshot, a direct fetch of a superseded group inside and past the window, media segment fetch, and archive replay. Use mocked time and the existing harnesses, and run all-language interop for the API and FFI scope. Re-check the overlapping PRs listed above rather than duplicating their fixes. + +Public API: breaking in Rust, JS, and every binding (the authored default, and mux `with_max_age(None)`). Wire: none; only authored advertisements change. + +## Related + +- [Subscriptions take a Delay](/quest/m1/subscription-delay.md) - the max-delay binding defaults and explicit-unlimited flags this quest's bindings mirror From 1cc8571d1234697e11e94dc1a093efde97abe876 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:01:31 +0000 Subject: [PATCH 51/86] ci(queue): dispatches for different heads don't cancel each other (#7620) Co-authored-by: Claude Opus 5.5 --- .github/workflows/queue.yml | 5 +++-- sh/gh/queue.test.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml index dc55e61547e..ff77c49ed29 100644 --- a/.github/workflows/queue.yml +++ b/.github/workflows/queue.yml @@ -32,9 +32,10 @@ permissions: contents: read # Each group has its own ref, so a group never cancels another; a pull request's -# skipped runs replace their own. +# skipped runs replace their own. Dispatches all run on main, so each PR head's +# report has its own group too. concurrency: - group: queue-${{ github.ref }} + group: queue-${{ github.ref }}-${{ inputs.sha }} cancel-in-progress: true jobs: diff --git a/sh/gh/queue.test.ts b/sh/gh/queue.test.ts index 35607d74995..5556a008f14 100644 --- a/sh/gh/queue.test.ts +++ b/sh/gh/queue.test.ts @@ -50,6 +50,8 @@ assert.deepEqual(queue.on.merge_group, { types: ["checks_requested"] }); assert.ok(queue.on.workflow_dispatch.inputs.sha.required); assert.equal(queue.jobs.report.if, "github.event_name == 'workflow_dispatch'"); assert.deepEqual(queue.jobs.report.permissions, { checks: "write" }); +// biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub expression is literal YAML. +assert.equal(queue.concurrency.group, "queue-${{ github.ref }}-${{ inputs.sha }}", "dispatches for different heads must not cancel each other"); assert.equal(queue.jobs.report.steps.length, 1, "the report job checks nothing out and runs no code from a PR"); assert.deepEqual(queue.on.pull_request.branches, ["main"]); assert.ok(queue.on.pull_request.types.includes("edited"), "retargeting to main must report the context"); From 23ad65546f51c2fc7b0ed79ee2e67ba953a263cf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:02:37 +0000 Subject: [PATCH 52/86] fix(publish): park unread video capture and resume file sources at the present (#7552) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- js/publish/src/source/file.test.ts | 59 +++++ js/publish/src/source/file.ts | 135 ++---------- js/publish/src/source/pace.test.ts | 105 +++++++++ js/publish/src/source/pace.ts | 129 +++++++++++ js/publish/src/source/timeline.test.ts | 10 + js/publish/src/source/timeline.ts | 7 + js/publish/src/video/capture.test.ts | 100 ++++++++- js/publish/src/video/capture.ts | 139 +++++++----- js/publish/src/video/processor.test.ts | 7 + js/publish/src/video/processor.ts | 207 ++++++++++-------- quest/m1/push-to-talk/README.md | 1 - quest/m1/push-to-talk/capture-idle.md | 19 -- .../clients/js/capture-idle.browser.ts | 76 +++++++ test/interop/clients/js/capture-idle.html | 2 + test/interop/clients/js/src/capture-idle.ts | 87 ++++++++ test/justfile | 1 + 16 files changed, 802 insertions(+), 282 deletions(-) create mode 100644 js/publish/src/source/pace.test.ts create mode 100644 js/publish/src/source/pace.ts delete mode 100644 quest/m1/push-to-talk/capture-idle.md create mode 100644 test/interop/clients/js/capture-idle.browser.ts create mode 100644 test/interop/clients/js/capture-idle.html create mode 100644 test/interop/clients/js/src/capture-idle.ts diff --git a/js/publish/src/source/file.test.ts b/js/publish/src/source/file.test.ts index 26bf9e33dd8..2816548aa76 100644 --- a/js/publish/src/source/file.test.ts +++ b/js/publish/src/source/file.test.ts @@ -1,4 +1,5 @@ import { expect, mock, spyOn, test } from "bun:test"; +import { Manual, Micro, Nano, Second } from "@moq/clock"; import { File as FileSource } from "./file"; const flush = () => new Promise((resolve) => queueMicrotask(resolve)); @@ -85,3 +86,61 @@ test("clearing a decoded file clears its published media", async () => { else Reflect.deleteProperty(globalThis, "VideoFrame"); } }); + +test("an image read again after a gap skips to the slot due now", async () => { + const createImageBitmap = Object.getOwnPropertyDescriptor(globalThis, "createImageBitmap"); + const videoFrame = Object.getOwnPropertyDescriptor(globalThis, "VideoFrame"); + + Object.defineProperty(globalThis, "createImageBitmap", { + configurable: true, + value: async () => ({ close() {} }), + }); + Object.defineProperty(globalThis, "VideoFrame", { + configurable: true, + value: class { + timestamp: number; + constructor(_image: unknown, init: { timestamp: number }) { + this.timestamp = init.timestamp; + } + close() {} + }, + }); + + const clock = new Manual(); + const installed = clock.install(); + // Start away from zero so an unanchored timestamp can't pass by accident. + clock.jump(Nano.fromSecond(Second(1))); + const zero = 1_000_000; + + const source = new FileSource({ + file: new File([new Uint8Array([0])], "still.png", { type: "image/png" }), + }); + + try { + await settle(); + const video = source.out.source.peek()?.video; + if (!video || !("frames" in video)) throw new Error("missing image frames"); + const reader = video.frames.getReader(); + const read = async () => (await clock.drive(reader.read())).value?.timestamp; + + expect(await read()).toBe(zero); + + // Unread for a minute: the next frame is the one due now, not the 1,799 missed in between. + clock.jump(Nano.fromSecond(Second(60))); + expect(await read()).toBe(zero + 60_000_000); + + // Then the 30fps cadence resumes from the present. + const next = zero + Micro.fromSecond(Second(1801 / 30)); + expect(await read()).toBe(next); + expect(Nano.toMicro(Nano(clock.now()))).toBe(Micro(next)); + + await reader.cancel(); + } finally { + source.close(); + installed.close(); + if (createImageBitmap) Object.defineProperty(globalThis, "createImageBitmap", createImageBitmap); + else Reflect.deleteProperty(globalThis, "createImageBitmap"); + if (videoFrame) Object.defineProperty(globalThis, "VideoFrame", videoFrame); + else Reflect.deleteProperty(globalThis, "VideoFrame"); + } +}); diff --git a/js/publish/src/source/file.ts b/js/publish/src/source/file.ts index 361e8b4218d..45f96f73c85 100644 --- a/js/publish/src/source/file.ts +++ b/js/publish/src/source/file.ts @@ -1,7 +1,8 @@ -import { now as clockNow, sleepUntil as clockSleepUntil, Instant, Micro, Nano, Second } from "@moq/clock"; +import { Micro, Second } from "@moq/clock"; import * as Util from "@moq/hang/util"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { InputAudioTrack, InputVideoTrack, VideoSample } from "mediabunny"; +import { now, pace, sleepUntil } from "./pace"; import { Timeline } from "./timeline"; import type { Media } from "./types"; @@ -107,23 +108,32 @@ export class File { const zero = now(); let index = 0; - const frames = new ReadableStream({ - async pull(controller) { - const offset = Micro.fromSecond((index / IMAGE_FRAME_RATE) as Second); - const timestamp = (zero + offset) as Micro; - index++; + const frames = new ReadableStream( + { + async pull(controller) { + // Every repeat is the same picture, so a reader returning after a gap skips to the + // slot due now instead of replaying each one it missed. + const due = Math.floor(Second.fromMicro((now() - zero) as Micro) * IMAGE_FRAME_RATE); + index = Math.max(index, due); - await sleepUntil(timestamp, signal); + const offset = Micro.fromSecond((index / IMAGE_FRAME_RATE) as Second); + const timestamp = (zero + offset) as Micro; + index++; - // The bitmap is closed on teardown, so stop before building a frame from a dead one. - if (signal.aborted) { - controller.close(); - return; - } + await sleepUntil(timestamp, signal); - controller.enqueue(new VideoFrame(bitmap, { timestamp })); + // The bitmap is closed on teardown, so stop before building a frame from a dead one. + if (signal.aborted) { + controller.close(); + return; + } + + controller.enqueue(new VideoFrame(bitmap, { timestamp })); + }, }, - }); + // Prefetching would park a stale frame in the queue while nothing reads, and hand it out on resume. + { highWaterMark: 0 }, + ); effect.set(this.#out.source, { video: { frames, frameRate: IMAGE_FRAME_RATE } }, undefined); } @@ -209,90 +219,6 @@ export class File { } } -// The bits of a mediabunny sample the pacing loop touches. Video and audio samples only differ in -// what they convert to, which the caller supplies. -interface Sample { - readonly microsecondTimestamp: number; - setTimestamp(seconds: number): void; - close(): void; -} - -interface Sink { - samples(startTimestamp?: number): AsyncGenerator; -} - -/** - * Reads decoded samples out of a sink in real time, restarting the file when it ends. - * - * Decoding runs far faster than realtime, so holding each sample until its presentation time is - * what makes this publish at 1x instead of blasting the whole file out at once. It also applies - * backpressure: the sink only decodes ahead as far as the reader pulls. - */ -function pace( - sink: Sink, - timeline: Timeline, - convert: (sample: S) => T, - signal: AbortSignal, -): ReadableStream { - let samples = sink.samples(); - let loop = 0; - - return new ReadableStream({ - async pull(controller) { - for (;;) { - if (signal.aborted) { - controller.close(); - return; - } - - let next: IteratorResult; - try { - next = await samples.next(); - } catch (err) { - // Teardown disposes the Input, which makes any decode still in flight throw. - if (!signal.aborted) throw err; - controller.close(); - return; - } - - if (next.done) { - if (!timeline.loops) { - controller.close(); - return; - } - - // A fresh generator seeks back to the start; the offset keeps timestamps rising. - loop++; - samples = sink.samples(); - continue; - } - - const sample = next.value; - const timestamp = timeline.at(sample.microsecondTimestamp as Micro, loop); - - // A sample that's already late goes out immediately, letting a stalled reader catch up. - await sleepUntil(timestamp, signal); - if (signal.aborted) { - sample.close(); - controller.close(); - return; - } - - // Restamp onto our wall clock, the same epoch live capture uses, so a publish that - // mixes sources keeps one timeline. - sample.setTimestamp(Second.fromMicro(timestamp)); - controller.enqueue(convert(sample)); - sample.close(); - return; - } - }, - - async cancel() { - await samples.return(); - }, - }); -} - // Phone footage is usually stored sideways with rotation metadata, which toVideoFrame() ignores // because a VideoFrame can't carry it. Drawing through a canvas is the only way to bake it in. async function converter(track: InputVideoTrack): Promise<(sample: VideoSample) => VideoFrame> { @@ -320,16 +246,3 @@ async function decodable(track: InputVideoTrack | InputAudioTrack | null, file: ); return false; } - -// The installed clock in microseconds, the epoch capture stamps frames against (see video/processor.ts). -function now(): Micro { - return Nano.toMicro(Nano(clockNow())); -} - -// Wait until the given clock time, or until the effect tears down. -// -// A plain sleep rather than effect.timer because this runs once per sample: effect.timer registers a -// cleanup that only releases on teardown, which would pile up for as long as the file publishes. -function sleepUntil(timestamp: Micro, signal: AbortSignal): Promise { - return clockSleepUntil(Instant(Micro.toNano(timestamp)), { signal }).catch(() => {}); -} diff --git a/js/publish/src/source/pace.test.ts b/js/publish/src/source/pace.test.ts new file mode 100644 index 00000000000..29e2fac79a1 --- /dev/null +++ b/js/publish/src/source/pace.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "bun:test"; +import { Manual, Micro, Milli, Nano, Second } from "@moq/clock"; +import { now, pace, type Sample, SEEK_BEHIND, type Sink } from "./pace"; +import { Timeline } from "./timeline"; + +// A ten-second file with a sample every 100ms. +const DURATION = 10; +const SPACING = 100_000; + +class FakeSample implements Sample { + microsecondTimestamp: number; + restamped?: number; + closed = false; + + constructor(timestamp: number) { + this.microsecondTimestamp = timestamp; + } + + setTimestamp(seconds: number) { + this.restamped = Math.round(seconds * 1_000_000); + } + + close() { + this.closed = true; + } +} + +// Records where each pass starts and every sample it decodes. Like mediabunny, a start position +// begins at the sample covering it. +function fakeSink() { + const starts: (number | undefined)[] = []; + const decoded: FakeSample[] = []; + const sink: Sink = { + async *samples(start?: number) { + starts.push(start); + const first = Math.floor(Math.round((start ?? 0) * 1_000_000) / SPACING); + for (let i = first; i * SPACING < DURATION * 1_000_000; i++) { + const sample = new FakeSample(i * SPACING); + decoded.push(sample); + yield sample; + } + }, + }; + return { sink, starts, decoded }; +} + +function setup() { + const clock = new Manual(); + const installed = clock.install(); + // Start away from zero so an unanchored timestamp can't pass by accident. + clock.jump(Nano.fromSecond(Second(1))); + + const { sink, starts, decoded } = fakeSink(); + const zero = now(); + const abort = new AbortController(); + const reader = pace(sink, new Timeline(zero, DURATION), (sample) => sample.restamped, abort.signal).getReader(); + + const read = async () => (await clock.drive(reader.read())).value; + const close = async () => { + abort.abort(); + await reader.cancel(); + installed.close(); + }; + return { clock, zero, read, starts, decoded, close }; +} + +test("a reader returning after a long gap seeks to the present on the shared timeline", async () => { + const { clock, zero, read, starts, decoded, close } = setup(); + try { + expect(await read()).toBe(zero); + + // Unread for a minute and a bit: six full passes, then 1.55s into the seventh. + clock.jump(Nano.fromMilli(Milli(61_550))); + + // The sample covering the present, on the pass the clock is in, rather than the old cursor's. + expect(await read()).toBe(zero + 61_500_000); + expect(starts).toEqual([undefined, 1.55]); + // Only the first stale sample was decoded before seeking, not the minute that was missed. + expect(decoded.map((sample) => sample.microsecondTimestamp)).toEqual([0, SPACING, 1_500_000]); + + // Pacing continues from there at 1x. + expect(await read()).toBe(zero + 61_600_000); + expect(Nano.toMicro(Nano(clock.now()))).toBe(Micro(zero + 61_600_000)); + } finally { + await close(); + } + expect(decoded.every((sample) => sample.closed)).toBe(true); +}); + +test("a briefly slow reader drains every sample without seeking", async () => { + const { clock, zero, read, starts, close } = setup(); + try { + expect(await read()).toBe(zero); + + // Behind, but by less than the seek threshold. + clock.jump(Micro.toNano(Micro(SEEK_BEHIND - SPACING))); + + const late = []; + for (let i = 1; i <= 5; i++) late.push(await read()); + expect(late).toEqual([1, 2, 3, 4, 5].map((i) => zero + i * SPACING)); + expect(starts).toEqual([undefined]); + } finally { + await close(); + } +}); diff --git a/js/publish/src/source/pace.ts b/js/publish/src/source/pace.ts new file mode 100644 index 00000000000..d034c74a56c --- /dev/null +++ b/js/publish/src/source/pace.ts @@ -0,0 +1,129 @@ +import { now as clockNow, sleepUntil as clockSleepUntil, Instant, Micro, Nano, Second } from "@moq/clock"; +import type { Timeline } from "./timeline"; + +// Not exported from `source/index.ts`: this is an implementation detail of the file source. + +// A sample further behind the clock than this means nothing read the stream for a while, such as a +// capture parked while unwatched, rather than a briefly slow reader. Seeking to the present decodes +// at most a keyframe interval, while draining would decode and publish the whole gap. +export const SEEK_BEHIND = Micro.fromSecond(Second(1)); + +// The bits of a mediabunny sample the pacing loop touches. Video and audio samples only differ in +// what they convert to, which the caller supplies. +export interface Sample { + readonly microsecondTimestamp: number; + setTimestamp(seconds: number): void; + close(): void; +} + +export interface Sink { + samples(startTimestamp?: number): AsyncGenerator; +} + +/** + * Reads decoded samples out of a sink in real time, restarting the file when it ends. + * + * Decoding runs far faster than realtime, so holding each sample until its presentation time is + * what makes this publish at 1x instead of blasting the whole file out at once. It also applies + * backpressure: the sink only decodes as far as the reader pulls, and nothing ahead of that. + * + * A reader that returns after a long gap resumes at the present on the shared timeline, not at + * the old cursor, so the file's other track stays in sync and the gap is never replayed. + */ +export function pace( + sink: Sink, + timeline: Timeline, + convert: (sample: S) => T, + signal: AbortSignal, +): ReadableStream { + let samples = sink.samples(); + let loop = 0; + + return new ReadableStream( + { + async pull(controller) { + // Seek at most once per pull, so a decoder that can't keep up drains instead of reseeking forever. + let sought = false; + + for (;;) { + if (signal.aborted) { + controller.close(); + return; + } + + let next: IteratorResult; + try { + next = await samples.next(); + } catch (err) { + // Teardown disposes the Input, which makes any decode still in flight throw. + if (!signal.aborted) throw err; + controller.close(); + return; + } + + if (next.done) { + if (!timeline.loops) { + controller.close(); + return; + } + + // A fresh generator seeks back to the start; the offset keeps timestamps rising. + loop++; + samples = sink.samples(); + continue; + } + + const sample = next.value; + const timestamp = timeline.at(sample.microsecondTimestamp as Micro, loop); + + const present = now(); + if (!sought && present - timestamp > SEEK_BEHIND) { + sample.close(); + sought = true; + + // The sink starts at the sample covering this position, decoding from its keyframe. + const position = timeline.position(present); + await samples.return(); + loop = position.loop; + samples = sink.samples(Second.fromMicro(position.timestamp)); + continue; + } + + // A sample that's slightly late goes out immediately, letting a slow reader catch up. + await sleepUntil(timestamp, signal); + if (signal.aborted) { + sample.close(); + controller.close(); + return; + } + + // Restamp onto our wall clock, the same epoch live capture uses, so a publish that + // mixes sources keeps one timeline. + sample.setTimestamp(Second.fromMicro(timestamp)); + controller.enqueue(convert(sample)); + sample.close(); + return; + } + }, + + async cancel() { + await samples.return(); + }, + }, + // Prefetching would park a stale sample in the queue while nothing reads, and hand it out on resume. + { highWaterMark: 0 }, + ); +} + +// The installed clock in microseconds, the epoch capture stamps frames against (see video/processor.ts). +export function now(): Micro { + return Nano.toMicro(Nano(clockNow())); +} + +// Wait until the given clock time, or until the effect tears down. +// +// A plain sleep rather than effect.timer because this runs once per sample: effect.timer registers a +// cleanup that only releases on teardown, which would pile up for as long as the file publishes. +export function sleepUntil(timestamp: Micro, signal: AbortSignal): Promise { + return clockSleepUntil(Instant(Micro.toNano(timestamp)), { signal }).catch(() => {}); +} diff --git a/js/publish/src/source/timeline.test.ts b/js/publish/src/source/timeline.test.ts index 872054c1b38..8acd5660e37 100644 --- a/js/publish/src/source/timeline.test.ts +++ b/js/publish/src/source/timeline.test.ts @@ -50,4 +50,14 @@ describe("Timeline", () => { test("loops on a usable duration", () => { expect(new Timeline(ZERO, 10).loops).toBe(true); }); + + test("maps a wall clock time back to its pass and file timestamp", () => { + const timeline = new Timeline(ZERO, 10); + + expect(timeline.position(micro(500_000))).toEqual({ timestamp: micro(0), loop: 0 }); + expect(timeline.position(micro(3_500_000))).toEqual({ timestamp: micro(2_500_000), loop: 0 }); + expect(timeline.position(micro(26_000_000))).toEqual({ timestamp: micro(5_000_000), loop: 2 }); + expect(timeline.position(timeline.at(micro(4_000_000), 3))).toEqual({ timestamp: micro(4_000_000), loop: 3 }); + expect(new Timeline(ZERO, 0).position(micro(26_000_000))).toEqual({ timestamp: micro(25_000_000), loop: 0 }); + }); }); diff --git a/js/publish/src/source/timeline.ts b/js/publish/src/source/timeline.ts index 43eb1b1679a..c512b1c3343 100644 --- a/js/publish/src/source/timeline.ts +++ b/js/publish/src/source/timeline.ts @@ -32,4 +32,11 @@ export class Timeline { at(timestamp: Micro, loop: number): Micro { return (this.#zero + timestamp + loop * this.#duration) as Micro; } + + /** The file timestamp and pass due at the given wall clock time, the inverse of {@link at}. */ + position(at: Micro): { timestamp: Micro; loop: number } { + const elapsed = Math.max(at - this.#zero, 0); + const loop = this.loops ? Math.floor(elapsed / this.#duration) : 0; + return { timestamp: (elapsed - loop * this.#duration) as Micro, loop }; + } } diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index 6e94af14905..81b7150acfa 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -73,7 +73,10 @@ test("capture resamples live scale when frame dimensions stay unchanged", async }, }, }); + const consumer = new Effect(); try { + await settle(); + capture.out.frames.peek()?.subscribe(consumer); let changed = capture.out.display.changed(); controller.enqueue(new Frame() as unknown as VideoFrame); expect(await changed).toMatchObject({ width: 1920, height: 1080, scale: 2 }); @@ -82,6 +85,7 @@ test("capture resamples live scale when frame dimensions stay unchanged", async controller.enqueue(new Frame() as unknown as VideoFrame); expect(await changed).toMatchObject({ width: 1920, height: 1080, scale: 1 }); } finally { + consumer.close(); capture.close(); if (processor) Object.defineProperty(globalThis, "MediaStreamTrackProcessor", processor); else Reflect.deleteProperty(globalThis, "MediaStreamTrackProcessor"); @@ -120,7 +124,10 @@ test("rotation follows the live frames, not the track's settings", async () => { const track = { getSettings: () => ({ width: 1280, height: 720 }) } as unknown as StreamTrack; const orientation = new Signal(90); const capture = new Capture({ source: track, orientation }); + const consumer = new Effect(); try { + await settle(); + capture.out.frames.peek()?.subscribe(consumer); const display = capture.out.display.changed(); controller.enqueue(sized(720, 1280)); expect(await display).toMatchObject({ width: 720, height: 1280 }); @@ -163,6 +170,7 @@ test("rotation follows the live frames, not the track's settings", async () => { controller.enqueue(sized(1280, 720, 0)); expect(await rotation).toBe(0); } finally { + consumer.close(); capture.close(); if (processor) Object.defineProperty(globalThis, "MediaStreamTrackProcessor", processor); else Reflect.deleteProperty(globalThis, "MediaStreamTrackProcessor"); @@ -355,11 +363,17 @@ test("the held frame is closed when replaced, when an unread copy is dropped, an using input = frameSource(); const capture = new Capture({ source: input.source }); + const watch = new Effect(); + await settle(); + const reader = capture.out.frames.peek()?.subscribe(watch).getReader(); input.push(1_000); + (await reader?.read())?.value?.close(); input.push(2_000); + (await reader?.read())?.value?.close(); + watch.close(); await flush(); - // Only the newest is held: the source frames reached no reader and the first hold was replaced. + // Only the newest is held after the reader leaves. expect(open()).toHaveLength(1); // A reader that leaves without reading its copy releases it. @@ -573,3 +587,87 @@ test("an encoder torn down while a frame is in flight closes that frame", async } expect(open()).toEqual([]); }); + +test("capture parks after its bootstrap and last reader, and cancels while parked", async () => { + using _input = frameSource(); + using _clock = manualAt(0); + let pulls = 0; + let cancelled = false; + let deliver!: (timestamp: number) => void; + const frames = new ReadableStream( + { + pull(controller) { + pulls++; + return new Promise((resolve) => { + deliver = (timestamp) => { + controller.enqueue(new Frame(undefined, { timestamp }) as unknown as VideoFrame); + resolve(); + }; + }); + }, + cancel() { + cancelled = true; + }, + }, + { highWaterMark: 0 }, + ); + const capture = new Capture({ source: { frames } }); + const first = new Effect(); + const second = new Effect(); + try { + await flush(); + expect(pulls).toBe(1); + deliver(1_000); + await flush(); + expect(pulls).toBe(1); + expect(open()).toHaveLength(1); + + const fanout = capture.out.frames.peek(); + if (!fanout) throw new Error("missing fanout"); + const a = fanout.subscribe(first).getReader(); + const b = fanout.subscribe(second).getReader(); + (await a.read()).value?.close(); + (await b.read()).value?.close(); + await flush(); + expect(pulls).toBe(2); + + // One reader leaving does not stop another's capture. + first.close(); + deliver(2_000); + (await b.read()).value?.close(); + await flush(); + expect(pulls).toBe(3); + + // Stream cancellation and effect teardown must remove demand only once. + await b.cancel(); + second.close(); + deliver(3_000); + await flush(); + expect(pulls).toBe(3); + expect(open()).toHaveLength(1); + + const resumed = new Effect(); + try { + const reader = fanout.subscribe(resumed).getReader(); + (await reader.read()).value?.close(); + await flush(); + expect(pulls).toBe(4); + deliver(4_000); + (await reader.read()).value?.close(); + resumed.close(); + // Settle the read that was already in flight when demand went away. + await flush(); + if (pulls === 5) deliver(5_000); + await flush(); + } finally { + resumed.close(); + } + } finally { + first.close(); + second.close(); + capture.close(); + } + await flush(); + expect(cancelled).toBe(true); + expect(open()).toEqual([]); +}); diff --git a/js/publish/src/video/capture.ts b/js/publish/src/video/capture.ts index eb74343b227..a982988d67d 100644 --- a/js/publish/src/video/capture.ts +++ b/js/publish/src/video/capture.ts @@ -65,12 +65,7 @@ export class Capture { const source = effect.get(this.in.source); if (!source) return; - // A capture track goes through MediaStreamTrackProcessor, which rewrites timestamps onto our - // wall clock so they stay consistent when the source changes or the encoder reloads. A - // FrameSource already stamps against that clock, so take its frames as they are. - const stream = "frames" in source ? source.frames : TrackProcessor(normalizeSource(source).track); - - const fanout = new Frames(stream.pipeThrough(this.#measure(source))); + const fanout = new Frames(source, { output: this.#out, native: this.#native }); effect.cleanup(() => fanout.close()); effect.set(this.#out.frames, fanout, undefined); @@ -93,40 +88,6 @@ export class Capture { effect.set(this.#out.rotation, rotation, 0); } - // Sample live source metadata even when the coded dimensions stay unchanged. - #measure(source: Source): TransformStream { - return new TransformStream({ - transform: (input, controller) => { - let frame = input; - try { - const scale = "frames" in source ? undefined : normalizeSource(source).scale; - this.#out.display.set({ width: frame.codedWidth, height: frame.codedHeight, scale }); - - // Readers would otherwise turn a tagged frame twice: once natively when drawing, again - // for `out.rotation`. The encoder would also copy it into its decoder config. A - // constructor rotation adds to the frame's own, so cancel it here. - const tagged = frame as VideoFrame & { rotation?: number; flip?: boolean }; - let native = tagged.rotation; - if (native) { - // A mirrored frame subtracts a constructor rotation, and its mirror, drawn natively first, - // makes the picture need the opposite turn. - frame = new VideoFrame(input, { - rotation: tagged.flip ? native : 360 - native, - } as VideoFrameInit); - input.close(); - if (tagged.flip) native = 360 - native; - } - this.#native.set(native); - - controller.enqueue(frame); - } catch (error) { - frame.close(); - throw error; - } - }, - }); - } - /** Stop and release everything this holds. */ close() { this.#signals.close(); @@ -138,10 +99,13 @@ export class Capture { // late viewer, an encoder resuming after a demand gap) waits for a change that may never come. // Video only: replaying audio would repeat sound. class Frames extends Fanout { - // The newest frame through, owned here and dropped once closed. Boxed because the tap that fills - // it is built before super(). + // The newest frame through, owned here and dropped once closed. Boxed because the source pull + // that fills it is built before super(). readonly #held: Held; + // Zero parks the source; -1 wakes a parked pull during cancellation. + readonly #active: Signal; + // Copies handed to new readers and not yet read, released on close like the fanout's own queues. readonly #unread = new Set(); @@ -149,18 +113,68 @@ class Frames extends Fanout { // stamped here, and a later frame can be held below it. #replayed = 0; - constructor(source: ReadableStream) { - const held: Held = { closed: false, at: Instant(0) }; - const hold = new TransformStream({ - transform: (frame, controller) => { - held.frame?.close(); - held.frame = held.closed ? undefined : frame.clone(); - held.at = now(); - controller.enqueue(frame); + constructor(source: Source, state: { output: CaptureOutput; native: Signal }) { + // Track capture rewrites timestamps onto our epoch; FrameSource already uses that clock. + const stream = "frames" in source ? source.frames : TrackProcessor(normalizeSource(source).track); + // Metadata is sampled only for a frame we pull, before it is held or distributed. + const measure = (input: VideoFrame): VideoFrame => { + let frame = input; + try { + const scale = "frames" in source ? undefined : normalizeSource(source).scale; + state.output.display.set({ width: frame.codedWidth, height: frame.codedHeight, scale }); + + // Readers would otherwise turn a tagged frame twice: once natively when drawing, again + // for `out.rotation`. The encoder would also copy it into its decoder config. A + // constructor rotation adds to the frame's own, so cancel it here. + const tagged = frame as VideoFrame & { rotation?: number; flip?: boolean }; + let native = tagged.rotation; + if (native) { + // A mirrored frame subtracts a constructor rotation, and its mirror, drawn natively first, + // makes the picture need the opposite turn. + frame = new VideoFrame(input, { + rotation: tagged.flip ? native : 360 - native, + } as VideoFrameInit); + input.close(); + if (tagged.flip) native = 360 - native; + } + state.native.set(native); + + return frame; + } catch (error) { + frame.close(); + throw error; + } + }; + const held: Held = { at: Instant(0) }; + const active = new Signal(0); + const reader = stream.getReader(); + // The first picture supplies dimensions for encoder setup and a replay for still sources. + let initial = true; + const demand = new ReadableStream( + { + async pull(controller) { + while (!initial && active.peek() === 0) await active.changed(); + if (active.peek() < 0) return; + initial = false; + const { value } = await reader.read(); + if (active.peek() < 0) value?.close(); + else if (value) { + const frame = measure(value); + held.frame?.close(); + held.frame = frame.clone(); + held.at = now(); + controller.enqueue(frame); + } else controller.close(); + }, + async cancel(reason) { + active.set(-1); + await reader.cancel(reason); + }, }, - }); + { highWaterMark: 0 }, + ); - super(source.pipeThrough(hold), { + super(demand, { // A frame is a resource with an explicit lifetime, so every reader needs its own handle // and closes it. Sharing one would let the first reader close it under the others. clone: (frame) => frame.clone(), @@ -168,12 +182,12 @@ class Frames extends Fanout { }); this.#held = held; + this.#active = active; } override subscribe(effect: Effect, queue?: number): ReadableStream { const live = super.subscribe(effect, queue); const held = this.#held.frame; - if (!held) return live; // Re-stamped to now. The held frame may be minutes old, and publishing it at its capture time // would deliver it that late, a delay the jitter estimate keeps for the life of the stream. @@ -182,11 +196,17 @@ class Frames extends Fanout { // gap. Never stamp below the held stamp plus how long it was held, nor below an earlier replay. const clock = now(); const elapsed = Nano.toMicro(Instant.durationSince(clock, this.#held.at)); - const at = Math.round(Math.max(held.timestamp + elapsed, Nano.toMicro(Nano(clock)), this.#replayed)); + const at = Math.round(Math.max(held ? held.timestamp + elapsed : 0, Nano.toMicro(Nano(clock)), this.#replayed)); this.#replayed = at; - let first: VideoFrame | undefined = new VideoFrame(held, { timestamp: at }); - this.#unread.add(first); + let first = held ? new VideoFrame(held, { timestamp: at }) : undefined; + if (first) this.#unread.add(first); + let attached = this.#active.peek() >= 0; + if (attached) this.#active.set(this.#active.peek() + 1); const release = () => { + if (attached) { + attached = false; + if (this.#active.peek() >= 0) this.#active.set(this.#active.peek() - 1); + } if (first && this.#unread.delete(first)) first.close(); first = undefined; }; @@ -219,7 +239,7 @@ class Frames extends Fanout { } // A frame captured just before the copy's stamp would run time backwards. - if (value.timestamp > at) { + if (!held || value.timestamp > at) { controller.enqueue(value); return; } @@ -240,7 +260,6 @@ class Frames extends Fanout { override close(): void { super.close(); - this.#held.closed = true; this.#held.frame?.close(); this.#held.frame = undefined; @@ -249,5 +268,5 @@ class Frames extends Fanout { } } -// The newest frame, when it came through, and whether the fanout closed. -type Held = { frame?: VideoFrame; at: Instant; closed: boolean }; +// The newest frame and when it came through. +type Held = { frame?: VideoFrame; at: Instant }; diff --git a/js/publish/src/video/processor.test.ts b/js/publish/src/video/processor.test.ts index af8b3dffaf9..dfc8e6e3514 100644 --- a/js/publish/src/video/processor.test.ts +++ b/js/publish/src/video/processor.test.ts @@ -21,6 +21,7 @@ class FakeWorker { onmessageerror: unknown = null; terminated = false; + pulls = 0; started?: FakeTrack; #timestamp = 5_000_000; // the camera's own epoch, which the rewrite has to erase @@ -39,6 +40,7 @@ class FakeWorker { return; } + this.pulls++; this.#emit({ type: "frame", frame: new FakeVideoFrame(this.#timestamp) as unknown as VideoFrame, @@ -107,10 +109,15 @@ test("captures through the worker, transferring a clone", async () => { const track = new FakeTrack(); const stream = TrackProcessor(track as unknown as Parameters[0]); const reader = stream.getReader(); + // Opening the worker retains the device without asking it for a frame. + for (let i = 0; i < 20; i++) await Promise.resolve(); + expect(spawned[0].pulls).toBe(0); const first = await reader.read(); const second = await reader.read(); const after = Nano.toMicro(Nano(now())); + for (let i = 0; i < 20; i++) await Promise.resolve(); + expect(spawned[0].pulls).toBe(2); expect(spawned).toHaveLength(1); const worker = spawned[0]; diff --git a/js/publish/src/video/processor.ts b/js/publish/src/video/processor.ts index e3ae1be0353..4f59dbf89c2 100644 --- a/js/publish/src/video/processor.ts +++ b/js/publish/src/video/processor.ts @@ -15,7 +15,7 @@ export function TrackProcessor(track: StreamTrack): ReadableStream { // Chrome exposes MediaStreamTrackProcessor on the window, so use it directly. if ("MediaStreamTrackProcessor" in self) { const input: ReadableStream = new MediaStreamTrackProcessor({ track }).readable; - return input.pipeThrough(rewrite()); + return rewrite(input); } // Safari exposes it only inside a dedicated worker, which means spawning one to find out; Firefox @@ -79,14 +79,25 @@ class Epoch { } // Stamps frames as they arrive, for pipelines that deliver straight to us. -function rewrite(): TransformStream { +function rewrite(source: ReadableStream): ReadableStream { const epoch = new Epoch(); - - return new TransformStream({ - transform(frame, controller) { - controller.enqueue(epoch.stamp(frame, Nano.toMilli(Nano(now())))); + const reader = source.getReader(); + let cancelled = false; + return new ReadableStream( + { + async pull(controller) { + const { value } = await reader.read(); + if (cancelled) value?.close(); + else if (value) controller.enqueue(epoch.stamp(value, Nano.toMilli(Nano(now())))); + else controller.close(); + }, + async cancel(reason) { + cancelled = true; + await reader.cancel(reason); + }, }, - }); + { highWaterMark: 0 }, + ); } // Runs the native MediaStreamTrackProcessor in a worker, returning undefined if the engine doesn't @@ -111,34 +122,43 @@ async function workerProcessor(source: StreamTrack): Promise({ - async pull(controller) { - worker.post({ type: "pull" }); - - const msg = await worker.next(); - switch (msg.type) { - case "frame": - // Anchor on when the worker read the frame, not when we got around to handling - // the message, so a busy main thread can't skew video against audio. The worker's - // realm has its own origin, so the shared Unix-anchored time origin translates it - // onto the host clock. - // biome-ignore lint/style/noRestrictedGlobals: a cross-realm origin, not a clock read. - controller.enqueue(epoch.stamp(msg.frame, msg.at - performance.timeOrigin)); - return; - case "done": - worker.close(); - controller.close(); + return new ReadableStream( + { + async pull(controller) { + worker.post({ type: "pull" }); + + const msg = await worker.next(); + if (cancelled) { + if (msg.type === "frame") msg.frame.close(); return; - default: - worker.close(); - throw new Error(msg.type === "error" ? msg.message : `unexpected message: ${msg.type}`); - } + } + switch (msg.type) { + case "frame": + // Anchor on when the worker read the frame, not when we got around to handling + // the message, so a busy main thread can't skew video against audio. The worker's + // realm has its own origin, so the shared Unix-anchored time origin translates it + // onto the host clock. + // biome-ignore lint/style/noRestrictedGlobals: a cross-realm origin, not a clock read. + controller.enqueue(epoch.stamp(msg.frame, msg.at - performance.timeOrigin)); + return; + case "done": + worker.close(); + controller.close(); + return; + default: + worker.close(); + throw new Error(msg.type === "error" ? msg.message : `unexpected message: ${msg.type}`); + } + }, + cancel() { + cancelled = true; + worker.close(); + }, }, - cancel() { - worker.close(); - }, - }); + { highWaterMark: 0 }, + ); } // A live capture worker, one request in flight at a time. @@ -227,41 +247,44 @@ function videoProcessor(track: StreamTrack): ReadableStream { let video: HTMLVideoElement; let handle: number | undefined; - return new ReadableStream({ - async start() { - video = document.createElement("video") as HTMLVideoElement; - // Autoplay policies refuse play() on an unmuted element without a user gesture, even one - // with no audio track, so mute it: it carries the video track alone. Inline keeps iOS from - // taking it fullscreen. - video.muted = true; - video.playsInline = true; - video.srcObject = new MediaStream([track]); - await Promise.all([ - video.play(), - new Promise((r) => { - video.onloadedmetadata = r; - }), - ]); - }, - async pull(controller) { - // requestVideoFrameCallback fires once per frame the camera actually delivers, so we - // sample its true cadence rather than racing a wall clock: Safari and Firefox clamp - // performance.now() to whole milliseconds, which can't express a 33.333ms period. - await new Promise((resolve) => { - handle = video.requestVideoFrameCallback((now, metadata) => { - // captureTime is the frame's capture instant; both it and now are DOM timestamps, - // which the host clock shares, so audio and video stay on one epoch. - const timestamp = (metadata.captureTime ?? now) as Milli; - controller.enqueue(new VideoFrame(video, { timestamp: Micro.fromMilli(timestamp) })); - resolve(); + return new ReadableStream( + { + async start() { + video = document.createElement("video") as HTMLVideoElement; + // Autoplay policies refuse play() on an unmuted element without a user gesture, even one + // with no audio track, so mute it: it carries the video track alone. Inline keeps iOS from + // taking it fullscreen. + video.muted = true; + video.playsInline = true; + video.srcObject = new MediaStream([track]); + await Promise.all([ + video.play(), + new Promise((r) => { + video.onloadedmetadata = r; + }), + ]); + }, + async pull(controller) { + // requestVideoFrameCallback fires once per frame the camera actually delivers, so we + // sample its true cadence rather than racing a wall clock: Safari and Firefox clamp + // performance.now() to whole milliseconds, which can't express a 33.333ms period. + await new Promise((resolve) => { + handle = video.requestVideoFrameCallback((now, metadata) => { + // captureTime is the frame's capture instant; both it and now are DOM timestamps, + // which the host clock shares, so audio and video stay on one epoch. + const timestamp = (metadata.captureTime ?? now) as Milli; + controller.enqueue(new VideoFrame(video, { timestamp: Micro.fromMilli(timestamp) })); + resolve(); + }); }); - }); - }, - cancel() { - if (handle !== undefined) video.cancelVideoFrameCallback(handle); - if (video) video.srcObject = null; + }, + cancel() { + if (handle !== undefined) video.cancelVideoFrameCallback(handle); + if (video) video.srcObject = null; + }, }, - }); + { highWaterMark: 0 }, + ); } // Wraps a stream that can only be built asynchronously, without making the caller await it. @@ -269,32 +292,36 @@ function deferred(open: () => Promise>): ReadableStre let reader: ReadableStreamDefaultReader | undefined; let cancelled = false; - return new ReadableStream({ - // Nothing is pulled until this settles, so the source is always ready by the first pull. - async start() { - const stream = await open(); + return new ReadableStream( + { + // Nothing is pulled until this settles, so the source is always ready by the first pull. + async start() { + const stream = await open(); - // Cancelled while we were opening, so throw it away instead of reading from it. - if (cancelled) { - await stream.cancel(); - return; - } + // Cancelled while we were opening, so throw it away instead of reading from it. + if (cancelled) { + await stream.cancel(); + return; + } - reader = stream.getReader(); - }, - async pull(controller) { - if (!reader) { - controller.close(); - return; - } - - const { value } = await reader.read(); - if (!value) controller.close(); - else controller.enqueue(value); - }, - async cancel(reason) { - cancelled = true; - await reader?.cancel(reason); + reader = stream.getReader(); + }, + async pull(controller) { + if (!reader) { + controller.close(); + return; + } + + const { value } = await reader.read(); + if (cancelled) value?.close(); + else if (!value) controller.close(); + else controller.enqueue(value); + }, + async cancel(reason) { + cancelled = true; + await reader?.cancel(reason); + }, }, - }); + { highWaterMark: 0 }, + ); } diff --git a/quest/m1/push-to-talk/README.md b/quest/m1/push-to-talk/README.md index 18b6e6afd34..e754b81ebc0 100644 --- a/quest/m1/push-to-talk/README.md +++ b/quest/m1/push-to-talk/README.md @@ -56,7 +56,6 @@ encoder, and record why. ## Required - [Watch caches audio codec support](/quest/m1/push-to-talk/audio-support-cache.md) - an unmute resumes audio without waiting on a support check -- [Hidden capture idles](/quest/m1/push-to-talk/capture-idle.md) - an unread capture stops producing frames but stays open for an instant unhide ## Closes diff --git a/quest/m1/push-to-talk/capture-idle.md b/quest/m1/push-to-talk/capture-idle.md deleted file mode 100644 index 97003e3ffc0..00000000000 --- a/quest/m1/push-to-talk/capture-idle.md +++ /dev/null @@ -1,19 +0,0 @@ -# [S] Hidden capture idles - -## Goal - -While nothing reads it (the camera is invisible or every encoder is disabled), -`Video.Capture` produces no frames, yet unhiding resumes as quickly as it does -today. - -## Plan - -[moq-dev/moq#7157](https://github.com/moq-dev/moq/pull/7157) made `muted` and -`invisible` keep capture open so unmute and unhide skip getUserMedia. The -capture now keeps producing frames nobody reads. Keep the device open but stop -pulling and processing frames while unread, and measure the idle CPU before and -after. - -Decided 2026-10-30 (maintainer, /quest-plan of the 2026-10-30 /quest-complete follow-ups): ✅ new m1 push-to-talk child / drop. - -Public API: none. Wire: none. diff --git a/test/interop/clients/js/capture-idle.browser.ts b/test/interop/clients/js/capture-idle.browser.ts new file mode 100644 index 00000000000..57641c64bbb --- /dev/null +++ b/test/interop/clients/js/capture-idle.browser.ts @@ -0,0 +1,76 @@ +/** Demand gaps keep the device open without pumping frames, on both window capture paths. */ +import { expect, test } from "bun:test"; +import { createServer } from "vite"; +import { launch } from "./harness"; + +for (const path of ["native", "element"]) { + test(`unread ${path} video capture idles and resumes`, async () => { + const server = await createServer({ server: { host: "127.0.0.1", port: 0 } }); + await server.listen(); + const browser = await launch(["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"]); + try { + const page = await browser.newPage(); + await page.goto(`${server.resolvedUrls?.local[0]}capture-idle.html${path === "element" ? "?element" : ""}`); + await page.waitForFunction(() => window.captureIdle?.state().display); + const cdp = await page.context().newCDPSession(page); + const system = await browser.newBrowserCDPSession(); + await cdp.send("Performance.enable", { timeDomain: "threadTicks" }); + const cpu = async () => { + const { metrics } = await cdp.send("Performance.getMetrics"); + const { processInfo } = await system.send("SystemInfo.getProcessInfo"); + return { + main: metrics.find(({ name }) => name === "TaskDuration")?.value ?? 0, + renderer: processInfo + .filter(({ type }) => type === "renderer") + .reduce((sum, { cpuTime }) => sum + cpuTime, 0), + }; + }; + const seconds = Number(process.env.CAPTURE_IDLE_SECONDS ?? 0.25); + const sample = async (phase: string) => { + // Let the read already in flight settle before observing the idle window. + await page.waitForTimeout(200); + const before = await page.evaluate(() => window.captureIdle.state()); + const start = await cpu(); + await page.waitForTimeout(seconds * 1000); + const end = await cpu(); + const after = await page.evaluate(() => window.captureIdle.state()); + const result = { + path, + phase, + frames: after.processed - before.processed, + main_ms_per_s: ((end.main - start.main) * 1000) / seconds, + renderer_ms_per_s: ((end.renderer - start.renderer) * 1000) / seconds, + }; + console.log(JSON.stringify(result)); + expect(after.failure).toBeUndefined(); + expect(after.device).toBe("live"); + if (phase !== "reading") expect(result.frames).toBe(0); + return after; + }; + await sample("initial-idle"); + await page.evaluate(() => window.captureIdle.attach(1)); + await page.waitForFunction(() => window.captureIdle.state().delivered >= 3); + await page.evaluate(() => window.captureIdle.attach(2)); + await page.evaluate(() => window.captureIdle.detach(1)); + const reading = await sample("reading"); + expect(reading.delivered).toBeGreaterThan(3); + await page.evaluate(() => window.captureIdle.detach(2)); + const idle = await sample("detached-idle"); + const start = performance.now(); + await page.evaluate(() => window.captureIdle.attach(3)); + await page.waitForFunction((previous) => window.captureIdle.state().delivered > previous, idle.delivered); + console.log(JSON.stringify({ path, resume_ms: performance.now() - start })); + await page.waitForFunction( + (previous) => window.captureIdle.state().processed > previous + 2, + idle.processed, + ); + console.log(JSON.stringify({ path, live_resume_ms: performance.now() - start })); + expect((await page.evaluate(() => window.captureIdle.state())).failure).toBeUndefined(); + await page.evaluate(() => window.captureIdle.close()); + expect((await page.evaluate(() => window.captureIdle.state())).device).toBe("ended"); + } finally { + await browser.close(); + await server.close(); + } + }, 30_000); +} diff --git a/test/interop/clients/js/capture-idle.html b/test/interop/clients/js/capture-idle.html new file mode 100644 index 00000000000..4f0c4a506ae --- /dev/null +++ b/test/interop/clients/js/capture-idle.html @@ -0,0 +1,2 @@ + + diff --git a/test/interop/clients/js/src/capture-idle.ts b/test/interop/clients/js/src/capture-idle.ts new file mode 100644 index 00000000000..4bfd766827e --- /dev/null +++ b/test/interop/clients/js/src/capture-idle.ts @@ -0,0 +1,87 @@ +/** Real camera capture with independently attachable readers, including the element fallback. */ +import * as Publish from "@moq/publish"; +import { Effect } from "@moq/signals"; + +declare global { + interface Window { + captureIdle: Awaited>; + } +} + +async function open() { + const fallback = new URLSearchParams(location.search).has("element"); + if (fallback) { + Reflect.deleteProperty(window, "MediaStreamTrackProcessor"); + // Explicitly exercise the element path, even on engines with worker capture. + Object.defineProperty(window, "Worker", { + value: class { + constructor() { + throw new Error("element control"); + } + }, + }); + } + let processed = 0; + const Frame = window.VideoFrame; + window.VideoFrame = new Proxy(Frame, { + construct(target, args) { + processed++; + return Reflect.construct(target, args); + }, + }); + const media = await navigator.mediaDevices.getUserMedia({ + video: { width: 1280, height: 720, frameRate: 30 }, + }); + const track = media.getVideoTracks()[0]; + const capture = new Publish.Video.Capture({ source: track as Publish.Video.StreamTrack }); + const readers = new Map(); + let delivered = 0; + let last = -Infinity; + let failure: string | undefined; + return { + state: () => ({ + processed, + delivered, + last, + failure, + device: track.readyState, + display: capture.out.display.peek(), + }), + attach(id: number) { + if (readers.has(id)) throw new Error("reader already attached"); + const fanout = capture.out.frames.peek(); + if (!fanout) throw new Error("capture not ready"); + const effect = new Effect(); + readers.set(id, effect); + const reader = fanout.subscribe(effect).getReader(); + effect.spawn(async () => { + let previous = -Infinity; + try { + for (;;) { + const { value } = await reader.read(); + if (!value) return; + const timestamp = value.timestamp; + value.close(); + if (timestamp < previous) throw new Error("capture timestamp went backwards"); + previous = timestamp; + delivered++; + last = timestamp; + } + } catch (error) { + failure = String(error); + } + }); + }, + detach(id: number) { + readers.get(id)?.close(); + readers.delete(id); + }, + close() { + for (const effect of readers.values()) effect.close(); + capture.close(); + track.stop(); + }, + }; +} + +window.captureIdle = await open(); diff --git a/test/justfile b/test/justfile index 21a0d3f6fc5..a03400dbef0 100644 --- a/test/justfile +++ b/test/justfile @@ -17,6 +17,7 @@ set working-directory := '.' qmux_examples := absolute_path(env("CARGO_TARGET_DIR", source_directory() / ".." / "target")) / "debug" / "examples" harness_browser_tests := ''' + ./capture-idle.browser.ts ./capture-channel-count.browser.ts ./encoded-preview.browser.ts ./harness.browser.ts From 305f90a39423ea70a03fc6d0bf6a65be2ed3222a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:03:34 +0000 Subject: [PATCH 53/86] test(net): check refusal state cheaply and benchmark its scaling (#7549) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- quest/m0/ci/README.md | 1 - quest/m0/ci/test-cost-followups.md | 25 -------- rs/moq-net/benches/track.rs | 99 +++++++++++++++++++++++++++++- rs/moq-net/src/model/track.rs | 15 +++-- rs/moq-net/src/model/track/fill.rs | 6 ++ 5 files changed, 115 insertions(+), 31 deletions(-) delete mode 100644 quest/m0/ci/test-cost-followups.md diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index 4542d9ac160..d3c3e2cb19c 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -165,7 +165,6 @@ The historical samples locate candidates; they are not controlled benchmarks. - [Halve the slowest tests' CPU](/quest/m0/ci/slow-tests.md) - the slowest 1% of tests take under half their 289 CPU-seconds, with coverage kept - [Release credentials off main](/quest/m0/ci/publish-release-only.md) - `publish` admits only `release` and release tags once its `workflow_run` publishers run on their tag - [The queue host keeps the queue's disk](/quest/m0/ci/queue-host-disk.md) - agent sessions can't starve the queue runner of disk, and a crashed runner restarts -- [The next test-CPU cuts](/quest/m0/ci/test-cost-followups.md) - the slow-test leftovers from #6987, cut at their source - [Biome checks every file it can parse](/quest/m0/ci/biome-whole-repo.md) - Biome runs over the whole repository and main is clean under it - [Dispatcher tool changes run the dispatcher tests](/quest/m0/ci/dispatch-selection-gaps.md) - a tool-list change runs `sh/dispatch-query.test.py` in CI, and `sh/rs/uring.test.py` stops printing jq errors - [dispatch.sh's tests pass without KVM](/quest/m0/ci/dispatch-test-kvm.md) - `sh/dispatch-query.test.py` fakes the KVM probe instead of failing 20 tests on hosts without `/dev/kvm` diff --git a/quest/m0/ci/test-cost-followups.md b/quest/m0/ci/test-cost-followups.md deleted file mode 100644 index 74844081cc3..00000000000 --- a/quest/m0/ci/test-cost-followups.md +++ /dev/null @@ -1,25 +0,0 @@ -# [M] The next test-CPU cuts - -## Goal - -The test costs [moq-dev/moq#6987](https://github.com/moq-dev/moq/pull/6987) -found but left are cut at their source, with coverage kept. - -## Plan - -Left by #6987 (halve the slowest tests' CPU): - -- Encode the recovery clips once per run instead of per test. -- Use `tracing-test`'s `no-env-filter` where its filter costs time. -- Make the moq-stats oversize tests cheap. -- Make `a_fill_refused_group_by_group_stays_linear` cheap without losing what - it proves. - -Measure each before and after, as #6987 did. The export idle poll and mesh -session cost already have quests in `quest/m2/perf/`, and -[moq-dev/moq#7075](https://github.com/moq-dev/moq/pull/7075) already decodes a -moq-quic padding run as one frame. - -Decided 2026-10-30 (maintainer, /quest-plan of the 2026-10-30 /quest-complete follow-ups): ✅ new m0 CI quest / drop. - -Public API: none. Wire: none. diff --git a/rs/moq-net/benches/track.rs b/rs/moq-net/benches/track.rs index 1bfd838bf6f..e6c4da155e8 100644 --- a/rs/moq-net/benches/track.rs +++ b/rs/moq-net/benches/track.rs @@ -114,6 +114,10 @@ //! group filled must cost each reader the same, so a walk over the ranges, the groups of each //! fill, or the audience shows up as a slope. //! +//! `track_fill_refusal` refuses a wide fill one group at a time, swept over the number of +//! refusals and readers. Each reader consumes each refusal before the next, so the cost per +//! refusal must not grow with the history of answers. +//! //! Run with `cargo bench -p moq-net --bench track`. #![expect( @@ -2074,6 +2078,98 @@ fn bench_range_filling(c: &mut Criterion) { group.finish(); } +/// A shared fill refused newest first, with each reader taking each answer as it arrives. +struct RefusedFill { + _broadcast: broadcast::Producer, + _track: track::Producer, + _dynamic: track::Requests, + fill: track::Fill, + readers: Vec, +} + +impl RefusedFill { + const START: u64 = 1 << 40; + + fn new(readers: usize) -> Self { + let broadcast = broadcast::Info::default().produce(); + let mut track = broadcast.create_track("bench", timed()).unwrap(); + let dynamic = track.requests(); + track.start_at(Self::START).unwrap(); + let mut group = track.create_group(group::Info { sequence: Self::START }).unwrap(); + group.write_frame(Timestamp::ZERO, Bytes::from_static(b"x")).unwrap(); + group.finish().unwrap(); + let waiter = kio::Waiter::noop(); + let readers = (0..readers) + .map(|_| { + let mut reader = track.subscribe( + track::Subscription::default() + .with_range(0..) + .with_max_delay(AGED_BUDGET), + ); + let Poll::Ready(Ok(Some(group))) = reader.poll_recv_group(&waiter) else { + panic!("the live group is cached"); + }; + assert_eq!(group.sequence, Self::START); + assert!(reader.poll_recv_group(&waiter).is_pending()); + reader + }) + .collect(); + let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the miss below the start is asked"); + }; + assert!( + dynamic.poll_requested_fill(&waiter).is_pending(), + "readers share the fill" + ); + Self { + _broadcast: broadcast, + _track: track, + _dynamic: dynamic, + fill, + readers, + } + } + + fn run(&mut self, refusals: u64) { + let waiter = kio::Waiter::noop(); + for refused in 1..=refusals { + let sequence = self.fill.wanted().unwrap(); + self.fill.drop_groups(sequence..sequence + 1); + for reader in &mut self.readers { + assert!(reader.poll_recv_group(&waiter).is_pending()); + } + assert_eq!(self.fill.wanted(), Some(Self::START - refused - 1)); + } + } +} + +fn bench_fill_refusal(c: &mut Criterion) { + let mut group = c.benchmark_group("track_fill_refusal"); + group.sample_size(10); + for readers in [1usize, 8, 64] { + for refusals in [16u64, 256, 4096] { + group.throughput(Throughput::Elements(refusals * readers as u64)); + group.bench_function(BenchmarkId::new(format!("{readers}r"), refusals), |b| { + b.iter_batched_ref( + || RefusedFill::new(readers), + |fill| fill.run(refusals), + BatchSize::PerIteration, + ); + }); + } + } + // Keep the long single-reader history that exposes even a cheap per-refusal copy. + group.throughput(Throughput::Elements(100_000)); + group.bench_function("1r/100000", |b| { + b.iter_batched_ref( + || RefusedFill::new(1), + |fill| fill.run(100_000), + BatchSize::PerIteration, + ); + }); + group.finish(); +} + criterion_group!( benches, bench_range_widening, @@ -2106,6 +2202,7 @@ criterion_group!( bench_empty_run, bench_time_end, bench_fill, - bench_range_filling + bench_range_filling, + bench_fill_refusal ); criterion_main!(benches); diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 88ff29b4c7f..d437c590422 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -19961,12 +19961,13 @@ mod test { } /// A handler refusing a wide fill one group at a time, as a FETCH-capable session does, - /// pays for each refusal rather than for every refusal before it. + /// advances after each refusal and coalesces the resolved run, forgetting consumed answers. + /// `track_fill_refusal` benchmarks the cost over refusals and readers. #[test] #[cfg(feature = "async")] fn a_fill_refused_group_by_group_stays_linear() { const WIDE: u64 = 1 << 40; - const REFUSED: u64 = 100_000; + const REFUSED: u64 = 32; let mut producer = track_producer("test", Info::timed()); let dynamic = producer.requests(); let mut subscriber = producer.subscribe(replay().with_range(0..)); @@ -19977,12 +19978,18 @@ mod test { let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&kio::Waiter::noop()) else { panic!("the miss below the start is asked"); }; - for _ in 0..REFUSED { + for refused in 1..=REFUSED { let sequence = fill.wanted().unwrap(); fill.drop_groups(sequence..sequence + 1); assert!(subscriber.poll_recv_group(&kio::Waiter::noop()).is_pending()); + assert_eq!(fill.wanted(), Some(WIDE - refused - 1)); + assert_eq!(fill.answers(), 0, "the reader consumed the refusal"); + assert_eq!( + fill.resolved_runs(), + vec![WIDE - refused..WIDE], + "adjacent refusals remain one resolved run" + ); } - assert_eq!(fill.wanted(), Some(WIDE - REFUSED - 1)); } /// A descending reader joining a queued fill makes it newest first, as any descending diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index f8d0ed7af55..3c4a651ac08 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -786,6 +786,12 @@ impl Fill { outcome.refused.iter().count() + outcome.accepted.iter().count() } + /// The runs answered so far, even once their readers consumed the answers. + #[cfg(all(test, feature = "async"))] + pub(super) fn resolved_runs(&self) -> Vec> { + self.inner.result.read().resolved.iter().cloned().collect() + } + /// The next group still owed in the fill's order: a reader still reads it, and it was /// neither answered nor delivered live since it was asked. `None` once none is owed or /// no reader waits on the fill, so stop serving it. From af1a0cef997dc5cfeb8c92c48c75dbd74ac8bbb9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:04:12 +0000 Subject: [PATCH 54/86] feat(congestion): GCC candidate, GoogCC refreshed to WebRTC 556f7eec27 (#7546) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + quest/m2/quic-realtime/README.md | 1 - quest/m2/quic-realtime/gcc.md | 31 - quest/m2/webrtc/README.md | 3 +- rs/moq-congestion/Cargo.toml | 3 +- rs/moq-congestion/README.md | 6 +- rs/moq-congestion/src/gcc/AUTHORS | 212 + rs/moq-congestion/src/gcc/LICENSE | 29 + rs/moq-congestion/src/gcc/PATENTS | 24 + rs/moq-congestion/src/gcc/alr_detector.rs | 369 ++ rs/moq-congestion/src/gcc/api/mod.rs | 5 + .../src/gcc/api/transport/bandwidth_usage.rs | 6 + .../src/gcc/api/transport/mod.rs | 5 + .../src/gcc/api/transport/network_types.rs | 292 ++ .../src/gcc/api/units/data_rate.rs | 392 ++ .../src/gcc/api/units/data_size.rs | 167 + .../src/gcc/api/units/frequency.rs | 295 ++ rs/moq-congestion/src/gcc/api/units/mod.rs | 14 + .../src/gcc/api/units/time_delta.rs | 393 ++ .../src/gcc/api/units/timestamp.rs | 338 ++ .../src/gcc/api/units/unit_base.rs | 800 ++++ rs/moq-congestion/src/gcc/bench.rs | 94 + .../congestion_window_pushback_controller.rs | 187 + rs/moq-congestion/src/gcc/delay_based_bwe.rs | 1238 ++++++ .../gcc/delay_increase_detector_interface.rs | 27 + .../src/gcc/experiments/field_trials.rs | 69 + rs/moq-congestion/src/gcc/experiments/mod.rs | 5 + .../src/gcc/experiments/rate_control.rs | 77 + .../src/gcc/goog_cc_network_control.rs | 970 +++++ .../src/gcc/inter_arrival_delta.rs | 191 + .../src/gcc/link_capacity_estimator.rs | 100 + .../src/gcc/loss_based_bwe_v2.rs | 3683 +++++++++++++++++ rs/moq-congestion/src/gcc/mod.rs | 540 +++ .../src/gcc/pacing/bitrate_prober.rs | 399 ++ .../src/gcc/pacing/interval_budget.rs | 69 + rs/moq-congestion/src/gcc/pacing/mod.rs | 5 + .../src/gcc/probe_bitrate_estimator.rs | 522 +++ rs/moq-congestion/src/gcc/probe_controller.rs | 2077 ++++++++++ .../aimd_rate_control.rs | 641 +++ .../remote_bitrate_estimator/bwe_defines.rs | 41 + .../src/gcc/remote_bitrate_estimator/mod.rs | 5 + .../src/gcc/robust_throughput_estimator.rs | 800 ++++ .../src/gcc/send_side_bandwidth_estimation.rs | 445 ++ rs/moq-congestion/src/gcc/tests.rs | 486 +++ .../src/gcc/trendline_estimator.rs | 531 +++ rs/moq-congestion/src/lib.rs | 25 + rs/moq-congestion/src/pacer.rs | 2 +- rs/moq-congestion/src/rate.rs | 7 + rs/moq-quic/README.md | 2 +- rs/moq-quic/src/connection/bandwidth_probe.rs | 21 +- rs/moq-quic/src/connection/mod.rs | 15 +- rs/moq-quic/src/tests/probe.rs | 173 +- 52 files changed, 16776 insertions(+), 57 deletions(-) delete mode 100644 quest/m2/quic-realtime/gcc.md create mode 100644 rs/moq-congestion/src/gcc/AUTHORS create mode 100644 rs/moq-congestion/src/gcc/LICENSE create mode 100644 rs/moq-congestion/src/gcc/PATENTS create mode 100644 rs/moq-congestion/src/gcc/alr_detector.rs create mode 100644 rs/moq-congestion/src/gcc/api/mod.rs create mode 100644 rs/moq-congestion/src/gcc/api/transport/bandwidth_usage.rs create mode 100644 rs/moq-congestion/src/gcc/api/transport/mod.rs create mode 100644 rs/moq-congestion/src/gcc/api/transport/network_types.rs create mode 100644 rs/moq-congestion/src/gcc/api/units/data_rate.rs create mode 100644 rs/moq-congestion/src/gcc/api/units/data_size.rs create mode 100644 rs/moq-congestion/src/gcc/api/units/frequency.rs create mode 100644 rs/moq-congestion/src/gcc/api/units/mod.rs create mode 100644 rs/moq-congestion/src/gcc/api/units/time_delta.rs create mode 100644 rs/moq-congestion/src/gcc/api/units/timestamp.rs create mode 100644 rs/moq-congestion/src/gcc/api/units/unit_base.rs create mode 100644 rs/moq-congestion/src/gcc/bench.rs create mode 100644 rs/moq-congestion/src/gcc/congestion_window_pushback_controller.rs create mode 100644 rs/moq-congestion/src/gcc/delay_based_bwe.rs create mode 100644 rs/moq-congestion/src/gcc/delay_increase_detector_interface.rs create mode 100644 rs/moq-congestion/src/gcc/experiments/field_trials.rs create mode 100644 rs/moq-congestion/src/gcc/experiments/mod.rs create mode 100644 rs/moq-congestion/src/gcc/experiments/rate_control.rs create mode 100644 rs/moq-congestion/src/gcc/goog_cc_network_control.rs create mode 100644 rs/moq-congestion/src/gcc/inter_arrival_delta.rs create mode 100644 rs/moq-congestion/src/gcc/link_capacity_estimator.rs create mode 100644 rs/moq-congestion/src/gcc/loss_based_bwe_v2.rs create mode 100644 rs/moq-congestion/src/gcc/mod.rs create mode 100644 rs/moq-congestion/src/gcc/pacing/bitrate_prober.rs create mode 100644 rs/moq-congestion/src/gcc/pacing/interval_budget.rs create mode 100644 rs/moq-congestion/src/gcc/pacing/mod.rs create mode 100644 rs/moq-congestion/src/gcc/probe_bitrate_estimator.rs create mode 100644 rs/moq-congestion/src/gcc/probe_controller.rs create mode 100644 rs/moq-congestion/src/gcc/remote_bitrate_estimator/aimd_rate_control.rs create mode 100644 rs/moq-congestion/src/gcc/remote_bitrate_estimator/bwe_defines.rs create mode 100644 rs/moq-congestion/src/gcc/remote_bitrate_estimator/mod.rs create mode 100644 rs/moq-congestion/src/gcc/robust_throughput_estimator.rs create mode 100644 rs/moq-congestion/src/gcc/send_side_bandwidth_estimation.rs create mode 100644 rs/moq-congestion/src/gcc/tests.rs create mode 100644 rs/moq-congestion/src/gcc/trendline_estimator.rs diff --git a/Cargo.lock b/Cargo.lock index bb38ef4b3b2..79daca912f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5581,6 +5581,7 @@ dependencies = [ "rand 0.10.3", "rand_pcg", "stats_alloc", + "tracing", ] [[package]] diff --git a/quest/m2/quic-realtime/README.md b/quest/m2/quic-realtime/README.md index f15e8f5fa39..9e5ef198a75 100644 --- a/quest/m2/quic-realtime/README.md +++ b/quest/m2/quic-realtime/README.md @@ -105,7 +105,6 @@ owns that feedback. ## Required -- [GCC candidate](/quest/m2/quic-realtime/gcc.md) - libwebrtc's GoogCC vendored into `moq-congestion` and refreshed to current WebRTC - [BBR3 delay bound](/quest/m2/quic-realtime/bbr-delay-bound.md) - BBR3 with inflight capped when the forward delay gradient shows queue growth - [SCReAMv2 candidate](/quest/m2/quic-realtime/scream.md) - the RFC 8298bis delay path ported to `moq-congestion` - [io_uring runs an application's controller](/quest/m2/quic-realtime/uring-controller.md) - moq-uring takes a controller factory, so the winner installs the same way on both runtimes diff --git a/quest/m2/quic-realtime/gcc.md b/quest/m2/quic-realtime/gcc.md deleted file mode 100644 index 33b399288a8..00000000000 --- a/quest/m2/quic-realtime/gcc.md +++ /dev/null @@ -1,31 +0,0 @@ -# [L] GCC candidate - -## Goal - -libwebrtc's Google Congestion Control runs as a controller in -`moq-congestion`, fed by receive timestamps, and reports its queuing delay, -throughput, and reaction time on the shared moq-shaper uplink presets. - -## Plan - -Decided 2026-10-17: vendor [goog_cc](https://github.com/kixelated/goog_cc) -into `rs/moq-congestion/src/gcc/` and refresh it to current WebRTC rather -than depend on the crates.io 0.1.4 release. The port predates AI-assisted -review and tracks a 2024-12-09 WebRTC snapshot, so owning it in-tree lets us -trim it to what QUIC needs and review it the way we review the rest of the -fork. - -- Refresh against current `modules/congestion_controller/goog_cc` and record - the WebRTC revision in the module docs. -- Trim what QUIC makes redundant: RTP packet feedback types become the - Controller's per-packet send and receive instants; drop the parts that only - serve RTCP or probe clusters unless the comparison needs them. -- GCC outputs a target rate and pacing rate; map them to the Controller's - window and `Metrics::{pacing_rate, bandwidth_estimate}`, keeping - its congestion-window pushback as the inflight bound. -- Keep the BSD license and PATENTS file with the vendored code. -- Implement `Controller::probes` and report `probe_inflight` so moq-quic's - native probing still raises the estimate (decided 2026-10-25, maintainer; - see the [epic](/quest/m2/quic-realtime/README.md)). Measure that a stopped - rung recovers under it. -- Unit tests drive the controller with mocked time, not wall clock. diff --git a/quest/m2/webrtc/README.md b/quest/m2/webrtc/README.md index 86bbb454ce6..7371cd8f3e6 100644 --- a/quest/m2/webrtc/README.md +++ b/quest/m2/webrtc/README.md @@ -12,7 +12,7 @@ Decided 2026-10-30 with the maintainer (planning interview), after the str0m wor - **Vendor, then replace.** Copy the upstream crates in, trim them, and fix them in-tree. Replacing a layer with our own (SDP, RTP/RTCP, SRTP, SCTP) is a later child once a concrete need appears; none is planned yet. - **One crate.** `moq-ice` merges into it, since its only users are `moq-rtc` and moq-tokio's data-channel transport. moq-tokio's `webrtc` feature moves into `moq-webrtc` as well, which breaks the cycle (`moq-webrtc` depends on moq-tokio; moq-tokio would otherwise need ICE and SCTP). The vendored crates become private modules, not separate published crates, keeping their MIT/Apache-2.0 notices (str0m's `packet/` carries webrtc-rs's Apache notice). -- **What goes** (from the 2026-10-30 inventory: we use frame mode, full ICE, and DTLS 1.2, and never enable str0m's BWE, pacer, or stats): GCC bandwidth estimation (8k lines; GCC is being ported into [moq-congestion](/quest/m2/quic-realtime/gcc.md)), the leaky pacer (replaced by moq-congestion's pacer, made public by [rtc-pacing](/quest/m2/rtc-pacing.md)), H.266, RED, DTMF, G.7xx and comfort noise, VLA and simulcast layers, RTCP XR and app feedback, stats, internal test exports, and dimpl's DTLS 1.3 and version auto-selection. Roughly 17k of str0m's 72k lines and 11k of dimpl's 33k. +- **What goes** (from the 2026-10-30 inventory: we use frame mode, full ICE, and DTLS 1.2, and never enable str0m's BWE, pacer, or stats): GCC bandwidth estimation (8k lines; GCC is ported into `moq-congestion` as `moq_congestion::gcc`), the leaky pacer (replaced by moq-congestion's pacer, made public by [rtc-pacing](/quest/m2/rtc-pacing.md)), H.266, RED, DTMF, G.7xx and comfort noise, VLA and simulcast layers, RTCP XR and app feedback, stats, internal test exports, and dimpl's DTLS 1.3 and version auto-selection. Roughly 17k of str0m's 72k lines and 11k of dimpl's 33k. - **DTLS 1.2 only.** Every browser negotiates 1.2 today. [moq-rtc on rind](/quest/m2/rind/rtc.md) narrows to 1.2 to match. - **SCTP stays**, for browser data channels in [P2P](/quest/m2/p2p/README.md), but compiles only with the data-channel feature, so WHIP/WHEP builds carry none of it. Vendored, it can later be simplified or put on moq-congestion. - **Upstream fixes:** after vendoring, nobody merges upstream automatically. Record the vendored upstream versions in the crate docs; the release-cut audit checks the upstream changelogs for security fixes (malformed SRTP/RTCP input, for example) and ports them. @@ -30,4 +30,3 @@ Decided 2026-10-30 with the maintainer (planning interview), after the str0m wor - [moq-rtc paces WHEP viewers](/quest/m2/rtc-pacing.md) - adds the NACK hook and the shared pacer on the vendored stack - [moq-rtc on rind](/quest/m2/rind/rtc.md) - the vendored DTLS and SRTP take rind's crypto -- [GCC candidate](/quest/m2/quic-realtime/gcc.md) - where bandwidth estimation lives instead of str0m diff --git a/rs/moq-congestion/Cargo.toml b/rs/moq-congestion/Cargo.toml index 7e71c22d833..594044b6531 100644 --- a/rs/moq-congestion/Cargo.toml +++ b/rs/moq-congestion/Cargo.toml @@ -5,7 +5,7 @@ version = "0.1.0" edition = "2024" rust-version.workspace = true repository = "https://github.com/moq-dev/moq" -license = "MIT OR Apache-2.0" +license = "(MIT OR Apache-2.0) AND BSD-3-Clause" [features] # Test-only `bbr3::Config::probe_rng_seed`, for moq-quic's deterministic transport tests. @@ -15,6 +15,7 @@ testing = [] kio = { workspace = true } rand = { workspace = true } rand_pcg = { workspace = true } +tracing = { workspace = true } [dev-dependencies] cpu-time = "1.0.0" diff --git a/rs/moq-congestion/README.md b/rs/moq-congestion/README.md index 051cb2ed53a..5eed580d649 100644 --- a/rs/moq-congestion/README.md +++ b/rs/moq-congestion/README.md @@ -1,15 +1,17 @@ # moq-congestion -BBR3, Cubic, and Unlimited congestion controllers, consumed by moq-quic and the native transport drivers. +BBR3, Cubic, GCC, and Unlimited congestion controllers, consumed by moq-quic and the native transport drivers. The controllers take explicit `kio::clock::Instant` values and never drive a runtime. The `Controller` trait names no transport types: `on_sent` returns an opaque per-packet state that the transport stores and hands back with each acknowledgement or loss, and the controller paces its own sends. -Construct controllers with `Factory::build` on an `Arc`, `Arc`, or `Arc`. `Bbr3`, `Cubic`, `Unlimited`, and `Metrics` live at the crate root. +Construct controllers with `Factory::build` on an `Arc`, `Arc`, `Arc`, or `Arc`. `Bbr3`, `Cubic`, `Gcc`, `Unlimited`, and `Metrics` live at the crate root. For callers of the unpublished API, replace `Bbr3Config` with `bbr3::Config`, `CubicConfig` with `cubic::Config`, `ControllerFactory` with `Factory`, and `ControllerMetrics` with `Metrics`. Remove `Controller::into_any` from custom implementations; inspect the controller's behavior or metrics instead of downcasting. `clone_box` still copies controller state for path migration. The test-only `Bbr3::probing_rtt` and `min_rtt` accessors are removed; model internals are tested in this crate. The code was extracted from [moq-quic](../moq-quic), under the same MIT or Apache-2.0 license. Cubic originated in quinn; BBR3 was ported from moq-dev/noq and carries the fixes listed in [moq-quic's fork history](../moq-quic/README.md). +GCC is libwebrtc's Google Congestion Control, a delay-based controller that reads the peer's receive timestamps (draft-ietf-quic-receive-ts). `src/gcc` vendors a Rust port of WebRTC's `goog_cc` under WebRTC's BSD license, kept in [`src/gcc/LICENSE`](src/gcc/LICENSE) with its `PATENTS` grant; its module docs record the WebRTC revision it tracks and what it trims. + Controller tests run with `just rs test -p moq-congestion`. The per-packet benchmark runs nightly, or locally with: ```sh diff --git a/rs/moq-congestion/src/gcc/AUTHORS b/rs/moq-congestion/src/gcc/AUTHORS new file mode 100644 index 00000000000..46587ac6f38 --- /dev/null +++ b/rs/moq-congestion/src/gcc/AUTHORS @@ -0,0 +1,212 @@ +# Names should be added to this file with this pattern: +# +# For individuals: +# Name +# +# For organizations: +# Organization +# +# See python fnmatch module documentation for more information. +# +# Please keep the list sorted. + +# BEGIN individuals section. +Aaron Clauson +Adam Fedor +Akshay Shah +Alexander Brauckmann +Alexandre Gouaillard +Alex Henrie +Andrew MacDonald +Andrey Efremov +Andrew Johnson +Andy Tamilo +Anil Kumar +Anton Barkov +Ben Strong +Berthold Herrmann +Bob Withers +Brett Hebert +Brett Hebert +Bridger Maxwell +Bruno Pitrus +Cheng Qian +Christophe Dumez +Chris Tserng +Cody Barnes +Colin Plumb +Corby Hoback +Cyril Lashkevich +CZ Theng +Danail Kirov +Dave Cowart +David Porter +David Sanders +Dax Booysen +Denis Genestier +Dennis Angelo +Dharmesh Chauhan +Di Wu +Dirk-Jan C. Binnema +Dmitry Lizin +Eike Rathke +Eric Rescorla, RTFM Inc. +Filip Hlasek +Frederik Riedel, Frogg GmbH +Gao Chun +Giji Gangadharan +Graham Yoakum +Guillaume Petit +Gustavo Garcia +Hans Knoechel +Helmut Januschka +Hugues Ekra +Hyungjoo Na +Jake Hilton +James H. Brown +Jan Grulich +Jan Kalab +Jens Nielsen +Jesús Leganés-Combarro +Jiawei Ou +Jie Mao +Jiwon Kim +Johnny Wong +Jose Antonio Olivera Ortega +Karim Hammache +Keiichi Enomoto +Kiran Thind +Korniltsev Anatoly +Kyutae Lee +lauren n. liberda +Lennart Grahl +Luke Weber +Maksim Khobat +Mallikarjuna Rao V +Manish Jethani +Martin Storsjo +Matthias Liebig +Maksim Sisov +Maxim Pavlov +Maxim Potapov +Michael Iedema +Michael Klingbeil +Michał Zarach +Michel Promonet +Miguel Paris +Mike Gilbert +Mike Wei +Min Wang +Mike Woodworth +Mo Zanaty +Mohamed Risaldar UT +Nico Schlumprecht +Niek van der Maas +Olivier Crête +Pali Rohar +Paul Kapustin +Peng Yu +Pete Makeev +Philipp Hancke +Piasy Xu +Rafael Lopez Diez +Ralph Giles +Raman Budny +Ramprakash Jelari +Riku Voipio +Robert Bares +Robert Mader +Robert Mader +Robert Nagy +Ryan Yoakum +Samuel Attard +Sarah Thompson +Satender Saroha +Saul Kravitz +Sergio Garcia Murillo +Shaofan Qi +Shigemasa Watanabe +Shuhai Peng +Shunbo Li +Shunbo Li +Seija +Silviu Caragea +Stefan Gula +Stephan Hartmann +Steve Reid +Tao chen +Takaaki Suzuki +Tarun Chawla +Todd Wong +Tomas Popela +Trevor Hayes +Uladzislau Susha +Vicken Simonian +Victor Costan +Vinzenz Feenstra +Vladimir Beloborodov +Xiaohong Xu +Xiaolei Yu +Xinchao Tian +Yaowen Guo +Youfa +Yura Yaroshevich +Yuriy Pavlyshak +Yusuke Suzuki +Pengfei Han +Yingying Ma +Hailin Zhao +# END individuals section. + +# BEGIN organizations section. +8x8 Inc. <*@8x8.com> +8x8 Inc. <*@jitsi.org> +8x8 Inc. <*@sip-communicator.org> +Agora IO <*@agora.io> +ARM Holdings <*@arm.com> +BroadSoft Inc. <*@broadsoft.com> +Canonical Ltd <*@canonical.com> +CoSMo Software Consulting, Pte Ltd <*@cosmosoftware.io> +Discord Inc. <*@discordapp.com> +Facebook Inc. <*@fb.com> +Google Inc. <*@google.com> +Highfive, Inc. <*@highfive.com> +Hopin Ltd. <*@hopin.to> +HyperConnect Inc. <*@hpcnt.com> +Intel Corporation <*@intel.com> +LG Electronics, Inc. <*@lge.com> +Life On Air Inc. <*@lifeonair.com> +LiveKit <*@livekit.io> +Meta Platforms, Inc. <*@meta.com> +Microsoft Corporation <*@microsoft.com> +MIPS Technologies <*@mips.com> +Mozilla Foundation <*@mozilla.com> +Netgem S.A. <*@netgem.com> +Nutanix Inc. <*@nutanix.com> +NVIDIA Corporation <*@nvidia.com> +Opera Software ASA <*@opera.com> +Optical Tone Ltd <*@opticaltone.com> +Pengutronix e.K. <*@pengutronix.de> +Quebic Inc. <*@quebic.com> +Raptor Computing Systems, LLC <*@raptorcs.com> +RingCentral, Inc. <*@ringcentral.com> +Signal Messenger, LLC <*@signal.org> +Sinch AB <*@sinch.com> +struktur AG <*@struktur.de> +Telenor Digital AS <*@telenor.com> +Temasys Communications <*@temasys.io> +The Chromium Authors <*@chromium.org> +The WebRTC Authors <*@webrtc.org> +Threema GmbH <*@threema.ch> +Tuple, LLC <*@tuple.app> +Twilio, Inc. <*@twilio.com> +Twitch Interactive, Inc. <*@justin.tv> +Vewd Software AS <*@vewd.com> +Videona Socialmedia <*@videona.com> +Videxio AS <*@videxio.com> +Vidyo, Inc. <*@vidyo.com> +Vonage Holdings Corp. <*@vonage.com> +Wang Qing +Wire Swiss GmbH <*@wire.com> +&yet LLC <*@andyet.com> +# END organizations section. diff --git a/rs/moq-congestion/src/gcc/LICENSE b/rs/moq-congestion/src/gcc/LICENSE new file mode 100644 index 00000000000..4c41b7b251e --- /dev/null +++ b/rs/moq-congestion/src/gcc/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2011, The WebRTC project authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/rs/moq-congestion/src/gcc/PATENTS b/rs/moq-congestion/src/gcc/PATENTS new file mode 100644 index 00000000000..190607ac261 --- /dev/null +++ b/rs/moq-congestion/src/gcc/PATENTS @@ -0,0 +1,24 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the WebRTC code package. + +Google hereby grants to you a perpetual, worldwide, non-exclusive, +no-charge, irrevocable (except as stated in this section) patent +license to make, have made, use, offer to sell, sell, import, +transfer, and otherwise run, modify and propagate the contents of this +implementation of the WebRTC code package, where such license applies +only to those patent claims, both currently owned by Google and +acquired in the future, licensable by Google that are necessarily +infringed by this implementation of the WebRTC code package. This +grant does not include claims that would be infringed only as a +consequence of further modification of this implementation. If you or +your agent or exclusive licensee institute or order or agree to the +institution of patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that this +implementation of the WebRTC code package or any code incorporated +within this implementation of the WebRTC code package constitutes +direct or contributory patent infringement, or inducement of patent +infringement, then any patent rights granted to you under this License +for this implementation of the WebRTC code package shall terminate as +of the date such litigation is filed. diff --git a/rs/moq-congestion/src/gcc/alr_detector.rs b/rs/moq-congestion/src/gcc/alr_detector.rs new file mode 100644 index 00000000000..eba328f6dec --- /dev/null +++ b/rs/moq-congestion/src/gcc/alr_detector.rs @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::{ + api::units::{DataRate, DataSize, Timestamp}, + pacing::IntervalBudget, +}; + +#[derive(Clone, Debug)] +pub struct AlrDetectorConfig { + // Sent traffic ratio as a function of network capacity used to determine + // application-limited region. ALR region start when bandwidth usage drops + // below AlrStartUsageRatio and ends when it raises above + // AlrEndUsageRatio. NOTE: This is intentionally conservative at the moment + // until BW adjustments of application limited region is fine tuned. + pub bandwidth_usage_ratio: f64, + pub start_budget_level_ratio: f64, + pub stop_budget_level_ratio: f64, +} + +impl Default for AlrDetectorConfig { + fn default() -> Self { + // WebRTC-ProbingScreenshareBwe is default-on with the fixed settings + // "1.0,2875,80,40,-60,3", which override the detector's own defaults. + Self { + bandwidth_usage_ratio: 0.80, + start_budget_level_ratio: 0.40, + stop_budget_level_ratio: -0.60, + } + } +} + +// Application limited region detector is a class that utilizes signals of +// elapsed time and bytes sent to estimate whether network traffic is +// currently limited by the application's ability to generate traffic. +// +// AlrDetector provides a signal that can be utilized to adjust +// estimate bandwidth. +// Note: This class is not thread-safe. +#[derive(Clone)] +pub struct AlrDetector { + conf: AlrDetectorConfig, + + last_send_time: Option, + + alr_budget: IntervalBudget, + alr_started_time: Option, +} + +impl Default for AlrDetector { + fn default() -> Self { + Self::new(AlrDetectorConfig::default()) + } +} + +impl AlrDetector { + pub fn new(conf: AlrDetectorConfig) -> Self { + Self { + conf, + last_send_time: None, + alr_budget: IntervalBudget::new(0, true), + alr_started_time: None, + } + } + + // Upstream stamps the region's start with the clock when the send is processed; the send + // time is that instant here, and keeps the detector on the controller's mocked time. + pub fn on_bytes_sent(&mut self, bytes_sent: DataSize, send_time: Timestamp) { + let Some(last_send_time) = self.last_send_time else { + // Since the duration for sending the bytes is unknwon, return without + // updating alr state. + self.last_send_time = Some(send_time); + return; + }; + // A deliberate departure from upstream, which rounds each interval to whole milliseconds + // (`(send_time - last_send_time).ms()`), so sends under half a millisecond apart never + // refill its budget and sends 0.5-1ms apart refill it for a whole millisecond. Rounding + // each time instead, as upstream did before its send times became `Timestamp`s, keeps the + // intervals adding up to the time between the sends. + let delta_time_ms = send_time.ms() - last_send_time.ms(); + self.last_send_time = Some(send_time); + + self.alr_budget.use_budget(bytes_sent.bytes() as usize); + self.alr_budget.increase_budget(delta_time_ms); + if self.alr_budget.budget_ratio() > self.conf.start_budget_level_ratio + && self.alr_started_time.is_none() + { + self.alr_started_time = Some(send_time); + } else if self.alr_budget.budget_ratio() < self.conf.stop_budget_level_ratio + && self.alr_started_time.is_some() + { + self.alr_started_time = None; + } + } + + // Set current estimated bandwidth. + pub fn set_estimated_bitrate(&mut self, bitrate: DataRate) { + debug_assert!(bitrate > DataRate::zero()); + self.alr_budget + .set_target_rate_kbps((bitrate * self.conf.bandwidth_usage_ratio).kbps()); + } + + // Returns time when the current application-limited region started or empty + // result if the sender is currently not application-limited. + pub fn get_application_limited_region_start_time(&self) -> Option { + self.alr_started_time + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::gcc::api::units::TimeDelta; + + const ESTIMATED_BITRATE_BPS: i64 = 300000; + + pub struct SimulateOutgoingTrafficIn<'a> { + alr_detector: &'a mut AlrDetector, + timestamp_ms: &'a mut i64, + interval_ms: Option, + usage_percentage: Option, + } + + impl<'a> SimulateOutgoingTrafficIn<'a> { + pub fn new(alr_detector: &'a mut AlrDetector, timestamp_ms: &'a mut i64) -> Self { + Self { + alr_detector, + timestamp_ms, + interval_ms: None, + usage_percentage: None, + } + } + + pub fn for_time_ms(mut self, time_ms: i64) -> Self { + self.interval_ms.replace(time_ms); + self + } + + pub fn at_percent_of_estimated_bitrate(mut self, usage_percentage: i64) { + self.usage_percentage.replace(usage_percentage); + self.produce_traffic(); + } + + fn produce_traffic(&mut self) { + let interval_ms = self.interval_ms.unwrap(); + let usage_percentage = self.usage_percentage.unwrap(); + const TIME_STEP_MS: i64 = 10; + let mut t: i64 = 0; + + while t < interval_ms { + *self.timestamp_ms += TIME_STEP_MS; + self.alr_detector.on_bytes_sent( + DataSize::from_bytes( + ESTIMATED_BITRATE_BPS * usage_percentage * TIME_STEP_MS / (8 * 100 * 1000), + ), + Timestamp::from_millis(*self.timestamp_ms), + ); + t += TIME_STEP_MS; + } + let remainder_ms: i64 = interval_ms % TIME_STEP_MS; + if remainder_ms > 0 { + *self.timestamp_ms += TIME_STEP_MS; + self.alr_detector.on_bytes_sent( + DataSize::from_bytes( + ESTIMATED_BITRATE_BPS * usage_percentage * remainder_ms / (8 * 100 * 1000), + ), + Timestamp::from_millis(*self.timestamp_ms), + ); + } + } + } + + #[test] + fn alr_detection() { + let mut timestamp_ms: i64 = 1000; + let mut alr_detector = AlrDetector::default(); + alr_detector.set_estimated_bitrate(DataRate::from_bits_per_sec(ESTIMATED_BITRATE_BPS)); + + // Start in non-ALR state. + assert!( + alr_detector + .get_application_limited_region_start_time() + .is_none() + ); + + // Stay in non-ALR state when usage is close to 100%. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(1000) + .at_percent_of_estimated_bitrate(90); + assert!( + alr_detector + .get_application_limited_region_start_time() + .is_none() + ); + + // Verify that we ALR starts when bitrate drops below 20%. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(1500) + .at_percent_of_estimated_bitrate(20); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_some()) + ); + + // Verify that ALR ends when usage is above 65%. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(4000) + .at_percent_of_estimated_bitrate(100); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_none()) + ); + } + + #[test] + fn short_spike() { + let mut timestamp_ms: i64 = 1000; + let mut alr_detector = AlrDetector::default(); + alr_detector.set_estimated_bitrate(DataRate::from_bits_per_sec(ESTIMATED_BITRATE_BPS)); + // Start in non-ALR state. + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_none()) + ); + + // Verify that we ALR starts when bitrate drops below 20%. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(1000) + .at_percent_of_estimated_bitrate(20); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_some()) + ); + + // Verify that we stay in ALR region even after a short bitrate spike. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(100) + .at_percent_of_estimated_bitrate(150); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_some()) + ); + + // ALR ends when usage is above 65%. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(3000) + .at_percent_of_estimated_bitrate(100); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_none()) + ); + } + + #[test] + fn bandwidth_estimate_changes() { + let mut timestamp_ms: i64 = 1000; + let mut alr_detector = AlrDetector::default(); + alr_detector.set_estimated_bitrate(DataRate::from_bits_per_sec(ESTIMATED_BITRATE_BPS)); + + // Start in non-ALR state. + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_none()) + ); + + // ALR starts when bitrate drops below 20%. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(1000) + .at_percent_of_estimated_bitrate(20); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_some()) + ); + + // When bandwidth estimate drops the detector should stay in ALR mode and quit + // it shortly afterwards as the sender continues sending the same amount of + // traffic. This is necessary to ensure that ProbeController can still react + // to the BWE drop by initiating a new probe. + alr_detector.set_estimated_bitrate(DataRate::from_bits_per_sec(ESTIMATED_BITRATE_BPS / 5)); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_some()) + ); + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(1000) + .at_percent_of_estimated_bitrate(50); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_none()) + ); + } + + #[test] + fn parse_alr_specific_field_trial() { + let config = AlrDetectorConfig { + bandwidth_usage_ratio: 0.90, + start_budget_level_ratio: 0.0, + stop_budget_level_ratio: -0.10, + }; + let mut alr_detector = AlrDetector::new(config); + let mut timestamp_ms: i64 = 1000; + alr_detector.set_estimated_bitrate(DataRate::from_bits_per_sec(ESTIMATED_BITRATE_BPS)); + + // Start in non-ALR state. + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_none()) + ); + + // ALR does not start at 100% utilization. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(1000) + .at_percent_of_estimated_bitrate(100); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_none()) + ); + + // ALR does start at 85% utilization. + // Overused 10% above so it should take about 2s to reach a budget level of + // 0%. + SimulateOutgoingTrafficIn::new(&mut alr_detector, &mut timestamp_ms) + .for_time_ms(2100) + .at_percent_of_estimated_bitrate(85); + assert!( + (alr_detector + .get_application_limited_region_start_time() + .is_some()) + ); + } + + /// Packets under a millisecond apart refill the budget for the time between them: 1200 bytes + /// every 400us is 24 Mbit/s, under 80% of a 40 Mbit/s estimate, so the sender is + /// application-limited + #[test] + fn sub_millisecond_sends_refill_the_budget() { + let mut alr_detector = AlrDetector::default(); + alr_detector.set_estimated_bitrate(DataRate::from_kilobits_per_sec(40_000)); + let mut send_time = Timestamp::from_millis(1000); + for _ in 0..10_000 { + alr_detector.on_bytes_sent(DataSize::from_bytes(1200), send_time); + send_time += TimeDelta::from_micros(400); + } + assert!( + alr_detector + .get_application_limited_region_start_time() + .is_some() + ); + } +} diff --git a/rs/moq-congestion/src/gcc/api/mod.rs b/rs/moq-congestion/src/gcc/api/mod.rs new file mode 100644 index 00000000000..1fc28f0ed8e --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/mod.rs @@ -0,0 +1,5 @@ +/// The messages GoogCC exchanges with its transport. +pub mod transport; + +/// WebRTC's unit types, such as [DataSize](units::DataSize) and [DataRate](units::DataRate). +pub mod units; diff --git a/rs/moq-congestion/src/gcc/api/transport/bandwidth_usage.rs b/rs/moq-congestion/src/gcc/api/transport/bandwidth_usage.rs new file mode 100644 index 00000000000..10c917c19cd --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/transport/bandwidth_usage.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BandwidthUsage { + Normal, + Underusing, + Overusing, +} diff --git a/rs/moq-congestion/src/gcc/api/transport/mod.rs b/rs/moq-congestion/src/gcc/api/transport/mod.rs new file mode 100644 index 00000000000..a057b687a16 --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/transport/mod.rs @@ -0,0 +1,5 @@ +mod bandwidth_usage; +mod network_types; + +pub use bandwidth_usage::*; +pub use network_types::*; diff --git a/rs/moq-congestion/src/gcc/api/transport/network_types.rs b/rs/moq-congestion/src/gcc/api/transport/network_types.rs new file mode 100644 index 00000000000..9e99091042c --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/transport/network_types.rs @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! The messages GoogCC exchanges with its transport, trimmed to what a QUIC sender has: each +//! packet's send time, size and probe cluster, and its receive time or loss. RTP's sequence +//! numbers, ECN feedback, audio flag and untracked data, REMB, RTCP loss reports, route changes and +//! the network state estimate have no source here, and the pacer has no padding to send. + +use crate::gcc::api::units::{DataRate, DataSize, TimeDelta, Timestamp}; + +/// Use StreamsConfig for information about streams that is required for specific +/// adjustments to the algorithms in network controllers. Especially useful +/// for experiments. +#[derive(Debug, Clone, Copy, Default)] +pub struct StreamsConfig { + pub at_time: Timestamp, + pub requests_alr_probing: Option, + /// If `enable_repeated_initial_probing` is set to true, Probes are sent + /// periodically every 1s during the first 5s after the network becomes + /// available. The probes ignores max_total_allocated_bitrate. + pub enable_repeated_initial_probing: bool, + pub pacing_factor: Option, + + // TODO(srte): Use BitrateAllocationLimits here. + pub min_total_allocated_bitrate: Option, + pub max_total_allocated_bitrate: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct TargetRateConstraints { + pub at_time: Timestamp, + pub min_data_rate: Option, + pub max_data_rate: Option, + /// The initial bandwidth estimate to base target rate on. This should be used + /// as the basis for initial OnTargetTransferRate and OnPacerConfig callbacks. + pub starting_rate: Option, +} + +impl Default for TargetRateConstraints { + fn default() -> Self { + Self { + at_time: Timestamp::plus_infinity(), + min_data_rate: None, + max_data_rate: None, + starting_rate: None, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct NetworkAvailability { + pub at_time: Timestamp, + pub network_available: bool, +} + +impl Default for NetworkAvailability { + fn default() -> Self { + Self { + at_time: Timestamp::plus_infinity(), + network_available: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PacedPacketInfo { + pub probe_cluster_id: i32, + pub probe_cluster_min_probes: i64, + pub probe_cluster_min_bytes: i64, +} + +impl PacedPacketInfo { + pub const NOT_APROBE: i32 = -1; + + pub const fn new( + probe_cluster_id: i32, + probe_cluster_min_probes: i64, + probe_cluster_min_bytes: i64, + ) -> Self { + Self { + probe_cluster_id, + probe_cluster_min_probes, + probe_cluster_min_bytes, + } + } +} + +impl Default for PacedPacketInfo { + fn default() -> Self { + Self::new(Self::NOT_APROBE, -1, -1) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct SentPacket { + pub send_time: Timestamp, + /// Size of packet with overhead up to IP layer. + pub size: DataSize, + /// Probe cluster id and parameters including bitrate, number of packets and + /// number of bytes. + pub pacing_info: PacedPacketInfo, + /// Tracked data in flight when the packet was sent, excluding unacked data. + pub data_in_flight: DataSize, +} + +impl Default for SentPacket { + fn default() -> Self { + Self { + send_time: Timestamp::plus_infinity(), + size: DataSize::zero(), + pacing_info: PacedPacketInfo::default(), + data_in_flight: DataSize::zero(), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct RoundTripTimeUpdate { + pub round_trip_time: TimeDelta, +} + +/// Packet level feedback +#[derive(Debug, Clone, Copy)] +pub struct PacketResult { + pub sent_packet: SentPacket, + pub receive_time: Timestamp, + /// Whether `receive_time` is only a stand-in, so the packet counts as received but not in + /// delay or RTT calculations, as upstream marks a retransmission it cannot match + pub ambiguous_receive_time: bool, +} + +impl PacketResult { + pub const fn is_received(&self) -> bool { + !self.receive_time.is_plus_infinity() + } +} + +impl Default for PacketResult { + fn default() -> Self { + Self { + sent_packet: SentPacket::default(), + receive_time: Timestamp::plus_infinity(), + ambiguous_receive_time: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct TransportPacketsFeedback { + pub feedback_time: Timestamp, + pub data_in_flight: DataSize, + pub packet_feedbacks: Vec, +} + +impl Default for TransportPacketsFeedback { + fn default() -> Self { + Self { + feedback_time: Timestamp::plus_infinity(), + data_in_flight: DataSize::zero(), + packet_feedbacks: Vec::new(), + } + } +} + +impl TransportPacketsFeedback { + /// Packets received at a known time; one received at an ambiguous time is left out, since + /// every caller reads the time + pub fn received_with_send_info(&self) -> impl Iterator { + self.packet_feedbacks + .iter() + .filter(|fb| fb.is_received() && !fb.ambiguous_receive_time) + } + + pub fn sorted_by_receive_time(&self) -> Vec { + let mut res: Vec = self.received_with_send_info().cloned().collect(); + // Upstream breaks a tie on send time, then sequence number; a stable sort keeps send + // order, which QUIC acknowledges in. + res.sort_by(|a, b| { + a.receive_time + .cmp(&b.receive_time) + .then(a.sent_packet.send_time.cmp(&b.sent_packet.send_time)) + }); + res + } +} + +#[derive(Debug, Clone, Copy)] +pub struct PacerConfig { + /// Pacer should send at most data_window data over time_window duration. + /// If `time_window` is zero, the pacer should pace every packet as accurately + /// as possible and data_rate() is calculated over one second. + pub data_window: DataSize, + pub time_window: TimeDelta, +} + +impl Default for PacerConfig { + fn default() -> Self { + Self { + data_window: DataSize::infinity(), + time_window: TimeDelta::plus_infinity(), + } + } +} + +impl PacerConfig { + pub const DEFAULT_TIME_INTERVAL: TimeDelta = TimeDelta::from_millis(40); + + pub fn create(send_rate: DataRate, time_window: TimeDelta) -> Self { + let mut pacer_config = Self { + time_window, + ..Default::default() + }; + // Note that precision is lost here, since DataSize store number of bytes. + pacer_config.data_window = send_rate * pacer_config.rate_window(); + pacer_config + } + + pub fn rate_window(&self) -> TimeDelta { + if self.time_window.is_zero() { + TimeDelta::from_seconds(1) + } else { + self.time_window + } + } + + pub fn data_rate(&self) -> DataRate { + self.data_window / self.rate_window() + } +} + +#[derive(Debug, Clone)] +pub struct ProbeClusterConfig { + pub at_time: Timestamp, + pub target_data_rate: DataRate, + /// Duration of a probe. + pub target_duration: TimeDelta, + /// Delta time between sent bursts of packets during probe. + pub min_probe_delta: TimeDelta, + pub target_probe_count: i32, + pub id: i32, +} + +impl Default for ProbeClusterConfig { + fn default() -> Self { + Self { + at_time: Timestamp::plus_infinity(), + target_data_rate: DataRate::zero(), + target_duration: TimeDelta::zero(), + min_probe_delta: TimeDelta::from_millis(2), + target_probe_count: 0, + id: 0, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct TargetTransferRate { + pub target_rate: DataRate, +} + +/// Contains updates of network controller comand state. Using optionals to +/// indicate whether a member has been updated. The array of probe clusters +/// should be used to send out probes if not empty. +#[derive(Default, Debug, Clone)] +pub struct NetworkControlUpdate { + pub congestion_window: Option, + pub pacer_config: Option, + pub probe_cluster_configs: Vec, + pub target_rate: Option, +} + +// Process control +pub struct ProcessInterval { + pub at_time: Timestamp, + pub pacer_queue: Option, +} + +impl Default for ProcessInterval { + fn default() -> Self { + Self { + at_time: Timestamp::plus_infinity(), + pacer_queue: None, + } + } +} diff --git a/rs/moq-congestion/src/gcc/api/units/data_rate.rs b/rs/moq-congestion/src/gcc/api/units/data_rate.rs new file mode 100644 index 00000000000..b534adb2ffc --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/units/data_rate.rs @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! DataRate is a class that represents a given data rate. This can be used to +//! represent bandwidth, encoding bitrate, etc. The internal storage is bits per +//! second (bps). + +use std::fmt; +use std::ops::*; + +use super::{DataSize, Frequency, TimeDelta}; + +super::relative_unit!(DataRate); + +impl DataRate { + const ONE_SIDED: bool = true; + + pub const fn from_bits_per_sec(value: i64) -> Self { + Self::from_value(value) + } + + pub fn from_bits_per_sec_float(value: f64) -> Self { + Self::from_value_float(value) + } + + pub const fn from_bytes_per_sec(value: i64) -> Self { + Self::from_fraction(8, value) + } + + pub fn from_bytes_per_sec_float(value: f64) -> Self { + Self::from_fraction_float(8.0, value) + } + + pub const fn from_kilobits_per_sec(value: i64) -> Self { + Self::from_fraction(1000, value) + } + + pub fn from_kilobits_per_sec_float(value: f64) -> Self { + Self::from_fraction_float(1000.0, value) + } + + pub const fn infinity() -> Self { + Self::plus_infinity() + } + + pub const fn bps(&self) -> i64 { + self.to_value() + } + + pub const fn bps_float(&self) -> f64 { + self.to_value_float() + } + + pub const fn bytes_per_sec(&self) -> i64 { + self.to_fraction(8) + } + + pub fn bytes_per_sec_float(&self) -> f64 { + self.to_fraction_float(8.0) + } + + pub const fn kbps(&self) -> i64 { + self.to_fraction(1000) + } + + pub fn kbps_float(&self) -> f64 { + self.to_fraction_float(1000.0) + } + + pub const fn bps_or(&self, fallback_value: i64) -> i64 { + self.to_fraction_or(1, fallback_value) + } + + pub const fn kbps_or(&self, fallback_value: i64) -> i64 { + self.to_fraction_or(1000, fallback_value) + } + + pub const fn millibyte_per_sec(&self) -> i64 { + const MAX_BEFORE_CONVERSION: i64 = i64::MAX / (1000 / 8); + assert!( + self.bps() < MAX_BEFORE_CONVERSION, + "rate is too large to be expressed in microbytes per second" + ); + self.bps() * (1000 / 8) + } +} + +impl Div for DataSize { + type Output = DataRate; + + fn div(self, duration: TimeDelta) -> Self::Output { + DataRate::from_bits_per_sec(self.microbits() / duration.us()) + } +} + +impl Div for DataSize { + type Output = TimeDelta; + + fn div(self, rate: DataRate) -> Self::Output { + TimeDelta::from_micros(self.microbits() / rate.bps()) + } +} + +impl Mul for DataRate { + type Output = DataSize; + + fn mul(self, duration: TimeDelta) -> Self::Output { + let microbits: i64 = self.bps() * duration.us(); + DataSize::from_bytes((microbits + 4000000) / 8000000) + } +} + +impl Mul for TimeDelta { + type Output = DataSize; + + fn mul(self, rate: DataRate) -> Self::Output { + rate * self + } +} + +impl Div for DataRate { + type Output = DataSize; + + fn div(self, frequency: Frequency) -> Self::Output { + let millihertz: i64 = frequency.millihertz(); + // Note that the value is truncated here reather than rounded, potentially + // introducing an error of .5 bytes if rounding were expected. + DataSize::from_bytes(self.millibyte_per_sec() / millihertz) + } +} + +impl Div for DataRate { + type Output = Frequency; + + fn div(self, size: DataSize) -> Self::Output { + Frequency::from_milli_hertz(self.millibyte_per_sec() / size.bytes()) + } +} + +impl Mul for DataSize { + type Output = DataRate; + + fn mul(self, frequency: Frequency) -> Self::Output { + assert!(frequency.is_zero() || self.bytes() <= i64::MAX / 8 / frequency.millihertz()); + let millibits_per_second: i64 = self.bytes() * 8 * frequency.millihertz(); + DataRate::from_bits_per_sec((millibits_per_second + 500) / 1000) + } +} + +impl Mul for Frequency { + type Output = DataRate; + + fn mul(self, size: DataSize) -> Self::Output { + size * self + } +} + +impl fmt::Debug for DataRate { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.is_plus_infinity() { + write!(f, "+inf bps") + } else if self.is_minus_infinity() { + write!(f, "-inf bps") + } else if self.bps() == 0 || self.bps() % 1000 != 0 { + write!(f, "{} bps", self.bps()) + } else { + write!(f, "{} kbps", self.kbps()) + } + } +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn compiles_with_checks_and_logs() { + let a: DataRate = DataRate::from_kilobits_per_sec(300); + let b: DataRate = DataRate::from_kilobits_per_sec(210); + assert!(a > b); + } + + #[test] + fn const_expr() { + const VALUE: i64 = 12345; + const DATA_RATE_ZERO: DataRate = DataRate::zero(); + const DATA_RATE_INF: DataRate = DataRate::infinity(); + assert_eq!(DataRate::default(), DATA_RATE_ZERO); + assert!(DATA_RATE_ZERO.is_zero()); + assert!(DATA_RATE_INF.is_infinite()); + assert!(DATA_RATE_INF.bps_or(-1) == -1); + assert!(DATA_RATE_INF > DATA_RATE_ZERO); + + const DATA_RATE_BPS: DataRate = DataRate::from_bits_per_sec(VALUE); + const DATA_RATE_KBPS: DataRate = DataRate::from_kilobits_per_sec(VALUE); + assert!(DATA_RATE_BPS.bps() == VALUE); + assert!(DATA_RATE_BPS.bps_or(0) == VALUE); + assert!(DATA_RATE_KBPS.kbps_or(0) == VALUE); + } + + #[test] + fn get_back_same_values() { + const VALUE: i64 = 123 * 8; + assert_eq!(DataRate::from_bits_per_sec(VALUE).bps(), VALUE); + assert_eq!(DataRate::from_kilobits_per_sec(VALUE).kbps(), VALUE); + } + + #[test] + fn get_different_prefix() { + const VALUE: i64 = 123 * 8000; + assert_eq!(DataRate::from_bits_per_sec(VALUE).kbps(), VALUE / 1000); + } + + #[test] + fn identity_checks() { + const VALUE: i64 = 3000; + assert!(DataRate::zero().is_zero()); + assert!(!DataRate::from_bits_per_sec(VALUE).is_zero()); + + assert!(DataRate::infinity().is_infinite()); + assert!(!DataRate::zero().is_infinite()); + assert!(!DataRate::from_bits_per_sec(VALUE).is_infinite()); + + assert!(!DataRate::infinity().is_finite()); + assert!(DataRate::from_bits_per_sec(VALUE).is_finite()); + assert!(DataRate::zero().is_finite()); + } + + #[test] + fn comparison_operators() { + const SMALL: i64 = 450; + const LARGE: i64 = 451; + let small: DataRate = DataRate::from_bits_per_sec(SMALL); + let large: DataRate = DataRate::from_bits_per_sec(LARGE); + + assert_eq!(DataRate::zero(), DataRate::from_bits_per_sec(0)); + assert_eq!(DataRate::infinity(), DataRate::infinity()); + assert_eq!(small, small); + assert!(small <= small); + assert!(small >= small); + assert!(small != large); + assert!(small <= large); + assert!(small < large); + assert!(large >= small); + assert!(large > small); + assert!(DataRate::zero() < small); + assert!(DataRate::infinity() > large); + } + + #[test] + fn converts_to_and_from_double() { + const VALUE: i64 = 128; + const DOUBLE_VALUE: f64 = VALUE as f64; + const DOUBLE_KBPS: f64 = VALUE as f64 * 1e-3; + const FLOAT_KBPS: f32 = DOUBLE_KBPS as f32; + + assert_eq!(DataRate::from_bits_per_sec(VALUE).bps_float(), DOUBLE_VALUE); + assert_eq!(DataRate::from_bits_per_sec(VALUE).kbps_float(), DOUBLE_KBPS); + assert_eq!( + DataRate::from_bits_per_sec(VALUE).kbps_float() as f32, + FLOAT_KBPS + ); + assert_eq!(DataRate::from_bits_per_sec_float(DOUBLE_VALUE).bps(), VALUE); + assert_eq!( + DataRate::from_kilobits_per_sec_float(DOUBLE_KBPS).bps(), + VALUE + ); + + const INFINITY: f64 = f64::INFINITY; + assert_eq!(DataRate::infinity().bps_float(), INFINITY); + assert!(DataRate::from_bits_per_sec_float(INFINITY).is_infinite()); + assert!(DataRate::from_kilobits_per_sec_float(INFINITY).is_infinite()); + } + #[test] + fn clamping() { + const UPPER: DataRate = DataRate::from_kilobits_per_sec(800); + const LOWER: DataRate = DataRate::from_kilobits_per_sec(100); + const UNDER: DataRate = DataRate::from_kilobits_per_sec(100); + const INSIDE: DataRate = DataRate::from_kilobits_per_sec(500); + const OVER: DataRate = DataRate::from_kilobits_per_sec(1000); + assert_eq!(UNDER.clamp(LOWER, UPPER), LOWER); + assert_eq!(INSIDE.clamp(LOWER, UPPER), INSIDE); + assert_eq!(OVER.clamp(LOWER, UPPER), UPPER); + + let mut mutable_rate: DataRate = LOWER; + mutable_rate.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_rate, LOWER); + mutable_rate = INSIDE; + mutable_rate.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_rate, INSIDE); + mutable_rate = OVER; + mutable_rate.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_rate, UPPER); + } + + #[test] + fn math_operations() { + const VALUE_A: i64 = 450; + const VALUE_B: i64 = 267; + const RATE_A: DataRate = DataRate::from_bits_per_sec(VALUE_A); + const RATE_B: DataRate = DataRate::from_bits_per_sec(VALUE_B); + const INT32_VALUE: i32 = 123; + const FLOAT_VALUE: f64 = 123.0; + + assert_eq!((RATE_A + RATE_B).bps(), VALUE_A + VALUE_B); + assert_eq!((RATE_A - RATE_B).bps(), VALUE_A - VALUE_B); + + assert_eq!((RATE_A * VALUE_B).bps(), VALUE_A * VALUE_B); + assert_eq!((RATE_A * INT32_VALUE).bps(), VALUE_A * INT32_VALUE as i64); + assert_eq!((RATE_A * FLOAT_VALUE).bps(), VALUE_A * FLOAT_VALUE as i64); + + assert_eq!(RATE_A / RATE_B, VALUE_A as f64 / VALUE_B as f64); + + assert_eq!((RATE_A / 10).bps(), VALUE_A / 10); + assert_relative_eq!( + (RATE_A / 0.5).bps_float(), + VALUE_A as f64 * 2.0, + epsilon = 1.0 + ); + + let mut mutable_rate: DataRate = DataRate::from_bits_per_sec(VALUE_A); + mutable_rate += RATE_B; + assert_eq!(mutable_rate.bps(), VALUE_A + VALUE_B); + mutable_rate -= RATE_A; + assert_eq!(mutable_rate.bps(), VALUE_B); + } + + #[test] + fn data_rate_and_data_size_and_time_delta() { + const SECONDS: i64 = 5; + const BITS_PER_SECOND: i64 = 440; + const BYTES: i64 = 44000; + const DELTA_A: TimeDelta = TimeDelta::from_seconds(SECONDS); + const RATE_B: DataRate = DataRate::from_bits_per_sec(BITS_PER_SECOND); + const SIZE_C: DataSize = DataSize::from_bytes(BYTES); + assert_eq!((DELTA_A * RATE_B).bytes(), SECONDS * BITS_PER_SECOND / 8); + assert_eq!((RATE_B * DELTA_A).bytes(), SECONDS * BITS_PER_SECOND / 8); + assert_eq!((SIZE_C / DELTA_A).bps(), BYTES * 8 / SECONDS); + assert_eq!((SIZE_C / RATE_B).seconds(), BYTES * 8 / BITS_PER_SECOND); + } + + #[test] + fn data_rate_and_data_size_and_frequency() { + const HERTZ: i64 = 30; + const BITS_PER_SECOND: i64 = 96000; + const BYTES: i64 = 1200; + const FREQ_A: Frequency = Frequency::from_hertz(HERTZ); + const RATE_B: DataRate = DataRate::from_bits_per_sec(BITS_PER_SECOND); + const SIZE_C: DataSize = DataSize::from_bytes(BYTES); + assert_eq!((FREQ_A * SIZE_C).bps(), HERTZ * BYTES * 8); + assert_eq!((SIZE_C * FREQ_A).bps(), HERTZ * BYTES * 8); + assert_eq!((RATE_B / SIZE_C).hertz(), BITS_PER_SECOND / BYTES / 8); + assert_eq!((RATE_B / FREQ_A).bytes(), BITS_PER_SECOND / HERTZ / 8); + } + + const JUST_SMALL_ENOUGH_FOR_DIVISION: i64 = i64::MAX / 8000000; + const TOOLARGE_FOR_DIVISION: DataSize = + DataSize::from_bytes(JUST_SMALL_ENOUGH_FOR_DIVISION + 1); + + #[test] + fn division_fails_on_large_size() { + // Note that the failure is expected since the current implementation is + // implementated in a way that does not support division of large sizes. If + // the implementation is changed, this test can safely be removed. + const LARGE_SIZE: DataSize = DataSize::from_bytes(JUST_SMALL_ENOUGH_FOR_DIVISION); + const DATA_RATE: DataRate = DataRate::from_kilobits_per_sec(100); + const TIME_DELTA: TimeDelta = TimeDelta::from_millis(100); + assert!((LARGE_SIZE / DATA_RATE).is_finite()); + assert!((LARGE_SIZE / TIME_DELTA).is_finite()); + } + + #[test] + #[should_panic] + fn division_fails_on_large_size1() { + const DATA_RATE: DataRate = DataRate::from_kilobits_per_sec(100); + let _ = TOOLARGE_FOR_DIVISION / DATA_RATE; + } + #[test] + #[should_panic] + fn division_fails_on_large_size2() { + const TIME_DELTA: TimeDelta = TimeDelta::from_millis(100); + let _ = TOOLARGE_FOR_DIVISION / TIME_DELTA; + } +} diff --git a/rs/moq-congestion/src/gcc/api/units/data_size.rs b/rs/moq-congestion/src/gcc/api/units/data_size.rs new file mode 100644 index 00000000000..e2514aea440 --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/units/data_size.rs @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2019 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use std::fmt; + +super::relative_unit!(DataSize); + +impl DataSize { + const ONE_SIDED: bool = true; + + pub const fn from_bytes(value: i64) -> Self { + Self::from_value(value) + } + + pub fn from_bytes_float(value: f64) -> Self { + Self::from_value_float(value) + } + + pub const fn infinity() -> Self { + Self::plus_infinity() + } + + pub const fn bytes(&self) -> i64 { + self.to_value() + } + + pub const fn bytes_float(&self) -> f64 { + self.to_value_float() + } + + pub const fn bytes_or(&self, fallback_value: i64) -> i64 { + self.to_value_or(fallback_value) + } + + pub const fn microbits(&self) -> i64 { + const MAX_BEFORE_CONVERSION: i64 = i64::MAX / 8000000; + assert!( + self.bytes() <= MAX_BEFORE_CONVERSION, + "size is too large to be expressed in microbits" + ); + self.bytes() * 8000000 + } +} + +impl fmt::Debug for DataSize { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.is_plus_infinity() { + write!(f, "+inf bytes") + } else if self.is_minus_infinity() { + write!(f, "-inf bytes") + } else { + write!(f, "{} bytes", self.bytes()) + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn const_expr() { + const VALUE: i64 = 12345; + const DATA_SIZE_ZERO: DataSize = DataSize::zero(); + const DATA_SIZE_INF: DataSize = DataSize::infinity(); + assert!(DataSize::default() == DATA_SIZE_ZERO); + assert!(DATA_SIZE_ZERO.is_zero()); + assert!(DATA_SIZE_INF.is_infinite()); + assert!(DATA_SIZE_INF.bytes_or(-1) == -1); + assert!(DATA_SIZE_INF > DATA_SIZE_ZERO); + + const DATA_SIZE: DataSize = DataSize::from_bytes(VALUE); + assert!(DATA_SIZE.bytes_or(-1) == VALUE); + + assert_eq!(DATA_SIZE.bytes(), VALUE); + } + + #[test] + fn get_back_same_values() { + const VALUE: i64 = 123 * 8; + assert_eq!(DataSize::from_bytes(VALUE).bytes(), VALUE); + } + + #[test] + fn identity_checks() { + const VALUE: i64 = 3000; + assert!(DataSize::zero().is_zero()); + assert!(!DataSize::from_bytes(VALUE).is_zero()); + + assert!(DataSize::infinity().is_infinite()); + assert!(!DataSize::zero().is_infinite()); + assert!(!DataSize::from_bytes(VALUE).is_infinite()); + + assert!(!DataSize::infinity().is_finite()); + assert!(DataSize::from_bytes(VALUE).is_finite()); + assert!(DataSize::zero().is_finite()); + } + + #[test] + fn comparison_operators() { + const SMALL: i64 = 450; + const LARGE: i64 = 451; + let small: DataSize = DataSize::from_bytes(SMALL); + let large: DataSize = DataSize::from_bytes(LARGE); + + assert_eq!(DataSize::zero(), DataSize::from_bytes(0)); + assert_eq!(DataSize::infinity(), DataSize::infinity()); + assert_eq!(small, small); + assert!(small <= small); + assert!(small >= small); + assert!(small != large); + assert!(small <= large); + assert!(small < large); + assert!(large >= small); + assert!(large > small); + assert!(DataSize::zero() < small); + assert!(DataSize::infinity() > large); + } + + #[test] + fn converts_to_and_from_double() { + const VALUE: i64 = 128; + const DOUBLE_VALUE: f64 = VALUE as f64; + + assert_eq!(DataSize::from_bytes(VALUE).bytes_float(), DOUBLE_VALUE); + assert_eq!(DataSize::from_bytes_float(DOUBLE_VALUE).bytes(), VALUE); + + const INFINITY: f64 = f64::INFINITY; + assert_eq!(DataSize::infinity().bytes_float(), INFINITY); + assert!(DataSize::from_bytes_float(INFINITY).is_infinite()); + } + + #[test] + fn math_operations() { + const VALUE_A: i64 = 450; + const VALUE_B: i64 = 267; + const SIZE_A: DataSize = DataSize::from_bytes(VALUE_A); + const SIZE_B: DataSize = DataSize::from_bytes(VALUE_B); + assert_eq!((SIZE_A + SIZE_B).bytes(), VALUE_A + VALUE_B); + assert_eq!((SIZE_A - SIZE_B).bytes(), VALUE_A - VALUE_B); + + const INT32_VALUE: i32 = 123; + const FLOAT_VALUE: f64 = 123.0; + assert_eq!((SIZE_A * VALUE_B).bytes(), VALUE_A * VALUE_B); + assert_eq!((SIZE_A * INT32_VALUE).bytes(), VALUE_A * INT32_VALUE as i64); + assert_eq!( + (SIZE_A * FLOAT_VALUE).bytes_float(), + VALUE_A as f64 * FLOAT_VALUE + ); + + assert_eq!((SIZE_A / 10).bytes(), VALUE_A / 10); + assert_eq!(SIZE_A / SIZE_B, VALUE_A as f64 / VALUE_B as f64); + + let mut mutable_size: DataSize = DataSize::from_bytes(VALUE_A); + mutable_size += SIZE_B; + assert_eq!(mutable_size.bytes(), VALUE_A + VALUE_B); + mutable_size -= SIZE_A; + assert_eq!(mutable_size.bytes(), VALUE_B); + } +} diff --git a/rs/moq-congestion/src/gcc/api/units/frequency.rs b/rs/moq-congestion/src/gcc/api/units/frequency.rs new file mode 100644 index 00000000000..84e251982e0 --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/units/frequency.rs @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2019 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use std::{fmt, ops::*}; + +use super::TimeDelta; + +super::relative_unit!(Frequency); + +impl Frequency { + const ONE_SIDED: bool = true; + + pub const fn from_milli_hertz(value: i64) -> Self { + Self::from_value(value) + } + + pub fn from_milli_hertz_float(value: f64) -> Self { + Self::from_value_float(value) + } + + pub const fn from_hertz(value: i64) -> Self { + Self::from_fraction(1_000, value) + } + + pub fn from_hertz_float(value: f64) -> Self { + Self::from_fraction_float(1_000.0, value) + } + + pub const fn from_kilo_hertz(value: i64) -> Self { + Self::from_fraction(1_000_000, value) + } + + pub fn from_kilo_hertz_float(value: f64) -> Self { + Self::from_fraction_float(1_000_000.0, value) + } + + pub const fn hertz(&self) -> i64 { + self.to_fraction(1000) + } + + pub fn hertz_float(&self) -> f64 { + self.to_fraction_float(1000.0) + } + + pub const fn millihertz(&self) -> i64 { + self.to_value() + } + + pub const fn millihertz_float(&self) -> f64 { + self.to_value_float() + } +} + +impl Div for i64 { + type Output = Frequency; + + fn div(self, interval: TimeDelta) -> Frequency { + const KILO_PER_MICRO: i64 = 1000 * 1000000; + assert!(self <= i64::MAX / KILO_PER_MICRO); + assert!(interval.is_finite()); + assert!(!interval.is_zero()); + Frequency::from_milli_hertz(self * KILO_PER_MICRO / interval.us()) + } +} + +impl Div for i64 { + type Output = TimeDelta; + + fn div(self, frequency: Frequency) -> TimeDelta { + const MEGA_PER_MILLI: i64 = 1000000 * 1000; + assert!(self <= i64::MAX / MEGA_PER_MILLI); + assert!(frequency.is_finite()); + assert!(!frequency.is_zero()); + TimeDelta::from_micros(self * MEGA_PER_MILLI / frequency.millihertz()) + } +} + +impl Mul for Frequency { + type Output = f64; + + fn mul(self, time_delta: TimeDelta) -> f64 { + self.hertz_float() * time_delta.seconds_float() + } +} + +impl Mul for TimeDelta { + type Output = f64; + + fn mul(self, frequency: Frequency) -> f64 { + frequency * self + } +} + +impl fmt::Debug for Frequency { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.is_plus_infinity() { + write!(f, "+inf Hz") + } else if self.is_minus_infinity() { + write!(f, "-inf Hz") + } else if self.millihertz() % 1000 != 0 { + write!(f, "{:.3} Hz", self.hertz_float()) + } else { + write!(f, "{} Hz", self.hertz()) + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn const_expr() { + const FREQUENCY_ZERO: Frequency = Frequency::zero(); + const FREQUENCY_PLUS_INF: Frequency = Frequency::plus_infinity(); + const FREQUENCY_MINUS_INF: Frequency = Frequency::minus_infinity(); + assert!(Frequency::default() == FREQUENCY_ZERO); + assert!(FREQUENCY_ZERO.is_zero()); + assert!(FREQUENCY_PLUS_INF.is_plus_infinity()); + assert!(FREQUENCY_MINUS_INF.is_minus_infinity()); + + assert!(FREQUENCY_PLUS_INF > FREQUENCY_ZERO); + } + + #[test] + fn get_back_same_values() { + const VALUE: i64 = 31; + assert_eq!(Frequency::from_hertz(VALUE).hertz(), VALUE); + assert_eq!(Frequency::zero().hertz(), 0); + } + + #[test] + fn get_different_prefix() { + const VALUE: i64 = 30000; + assert_eq!(Frequency::from_milli_hertz(VALUE).hertz(), VALUE / 1000); + assert_eq!(Frequency::from_hertz(VALUE).millihertz(), VALUE * 1000); + assert_eq!(Frequency::from_kilo_hertz(VALUE).hertz(), VALUE * 1000); + } + + #[test] + fn identity_checks() { + const VALUE: i64 = 31; + assert!(Frequency::zero().is_zero()); + assert!(!Frequency::from_hertz(VALUE).is_zero()); + + assert!(Frequency::plus_infinity().is_infinite()); + assert!(Frequency::minus_infinity().is_infinite()); + assert!(!Frequency::zero().is_infinite()); + assert!(!Frequency::from_hertz(VALUE).is_infinite()); + + assert!(!Frequency::plus_infinity().is_finite()); + assert!(!Frequency::minus_infinity().is_finite()); + assert!(Frequency::from_hertz(VALUE).is_finite()); + assert!(Frequency::zero().is_finite()); + + assert!(Frequency::plus_infinity().is_plus_infinity()); + assert!(!Frequency::minus_infinity().is_plus_infinity()); + + assert!(Frequency::minus_infinity().is_minus_infinity()); + assert!(!Frequency::plus_infinity().is_minus_infinity()); + } + + #[test] + fn comparison_operators() { + const SMALL: i64 = 42; + const LARGE: i64 = 45; + let small: Frequency = Frequency::from_hertz(SMALL); + let large: Frequency = Frequency::from_hertz(LARGE); + + assert_eq!(Frequency::zero(), Frequency::from_hertz(0)); + assert_eq!(Frequency::plus_infinity(), Frequency::plus_infinity()); + assert_eq!(small, Frequency::from_hertz(SMALL)); + assert!(small <= Frequency::from_hertz(SMALL)); + assert!(small >= Frequency::from_hertz(SMALL)); + assert!(small != Frequency::from_hertz(LARGE)); + assert!(small <= Frequency::from_hertz(LARGE)); + assert!(small < Frequency::from_hertz(LARGE)); + assert!(large >= Frequency::from_hertz(SMALL)); + assert!(large > Frequency::from_hertz(SMALL)); + assert!(Frequency::zero() < small); + + assert!(Frequency::plus_infinity() > large); + assert!(Frequency::minus_infinity() < Frequency::zero()); + } + + #[test] + fn clamping() { + const UPPER: Frequency = Frequency::from_hertz(800); + const LOWER: Frequency = Frequency::from_hertz(100); + const UNDER: Frequency = Frequency::from_hertz(100); + const INSIDE: Frequency = Frequency::from_hertz(500); + const OVER: Frequency = Frequency::from_hertz(1000); + assert_eq!(UNDER.clamp(LOWER, UPPER), LOWER); + assert_eq!(INSIDE.clamp(LOWER, UPPER), INSIDE); + assert_eq!(OVER.clamp(LOWER, UPPER), UPPER); + + let mut mutable_frequency: Frequency = LOWER; + mutable_frequency.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_frequency, LOWER); + mutable_frequency = INSIDE; + mutable_frequency.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_frequency, INSIDE); + mutable_frequency = OVER; + mutable_frequency.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_frequency, UPPER); + } + + #[test] + fn math_operations() { + const VALUE_A: i64 = 457; + const VALUE_B: i64 = 260; + const FREQUENCY_A: Frequency = Frequency::from_hertz(VALUE_A); + const FREQUENCY_B: Frequency = Frequency::from_hertz(VALUE_B); + assert_eq!((FREQUENCY_A + FREQUENCY_B).hertz(), VALUE_A + VALUE_B); + assert_eq!((FREQUENCY_A - FREQUENCY_B).hertz(), VALUE_A - VALUE_B); + + assert_eq!( + (Frequency::from_hertz(VALUE_A) * VALUE_B).hertz(), + VALUE_A * VALUE_B + ); + + assert_eq!((FREQUENCY_B / 10).hertz(), VALUE_B / 10); + assert_eq!(FREQUENCY_B / FREQUENCY_A, VALUE_B as f64 / VALUE_A as f64); + + let mut mutable_frequency: Frequency = Frequency::from_hertz(VALUE_A); + mutable_frequency += Frequency::from_hertz(VALUE_B); + assert_eq!(mutable_frequency, Frequency::from_hertz(VALUE_A + VALUE_B)); + mutable_frequency -= Frequency::from_hertz(VALUE_B); + assert_eq!(mutable_frequency, Frequency::from_hertz(VALUE_A)); + } + #[test] + fn rounding() { + let freq_high: Frequency = Frequency::from_hertz_float(23.976); + assert_eq!(freq_high.hertz(), 24); + assert_eq!( + freq_high.round_down_to(Frequency::from_hertz(1)), + Frequency::from_hertz(23) + ); + assert_eq!( + freq_high.round_to(Frequency::from_hertz(1)), + Frequency::from_hertz(24) + ); + assert_eq!( + freq_high.round_up_to(Frequency::from_hertz(1)), + Frequency::from_hertz(24) + ); + + let freq_low: Frequency = Frequency::from_hertz_float(23.4); + assert_eq!(freq_low.hertz(), 23); + assert_eq!( + freq_low.round_down_to(Frequency::from_hertz(1)), + Frequency::from_hertz(23) + ); + assert_eq!( + freq_low.round_to(Frequency::from_hertz(1)), + Frequency::from_hertz(23) + ); + assert_eq!( + freq_low.round_up_to(Frequency::from_hertz(1)), + Frequency::from_hertz(24) + ); + } + + #[test] + fn infinity_operations() { + const VALUE: f64 = 267.0; + let finite: Frequency = Frequency::from_hertz_float(VALUE); + assert!((Frequency::plus_infinity() + finite).is_plus_infinity()); + assert!((Frequency::plus_infinity() - finite).is_plus_infinity()); + assert!((finite + Frequency::plus_infinity()).is_plus_infinity()); + assert!((finite - Frequency::minus_infinity()).is_plus_infinity()); + + assert!((Frequency::minus_infinity() + finite).is_minus_infinity()); + assert!((Frequency::minus_infinity() - finite).is_minus_infinity()); + assert!((finite + Frequency::minus_infinity()).is_minus_infinity()); + assert!((finite - Frequency::plus_infinity()).is_minus_infinity()); + } + + #[test] + fn time_delta_and_frequency() { + assert_eq!(1 / Frequency::from_hertz(50), TimeDelta::from_millis(20)); + assert_eq!(1 / TimeDelta::from_millis(20), Frequency::from_hertz(50)); + assert_eq!( + Frequency::from_kilo_hertz(200) * TimeDelta::from_millis(2), + 400.0 + ); + } +} diff --git a/rs/moq-congestion/src/gcc/api/units/mod.rs b/rs/moq-congestion/src/gcc/api/units/mod.rs new file mode 100644 index 00000000000..4a04ce5e0b6 --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/units/mod.rs @@ -0,0 +1,14 @@ +mod data_rate; +mod data_size; +mod frequency; +mod time_delta; +mod timestamp; +mod unit_base; + +pub use data_rate::*; +pub use data_size::*; +pub use frequency::*; +pub use time_delta::*; +pub use timestamp::*; + +pub(crate) use unit_base::*; diff --git a/rs/moq-congestion/src/gcc/api/units/time_delta.rs b/rs/moq-congestion/src/gcc/api/units/time_delta.rs new file mode 100644 index 00000000000..3f9451abaad --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/units/time_delta.rs @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! TimeDelta represents the difference between two timestamps. Commonly this can +//! be a duration. However since two Timestamps are not guaranteed to have the +//! same epoch (they might come from different computers, making exact +//! synchronisation infeasible), the duration covered by a TimeDelta can be +//! undefined. To simplify usage, it can be constructed and converted to +//! different units, specifically seconds (s), milliseconds (ms) and +//! microseconds (us). +super::relative_unit!(TimeDelta); + +use std::{fmt, time::Duration}; + +impl TimeDelta { + const ONE_SIDED: bool = false; + + pub const fn from_minutes(value: i64) -> Self { + Self::from_fraction(60_000_000, value) + } + + pub fn from_minutes_float(value: f64) -> Self { + Self::from_fraction_float(60_000_000.0, value) + } + + pub const fn from_seconds(value: i64) -> Self { + Self::from_fraction(1_000_000, value) + } + + pub fn from_seconds_float(value: f64) -> Self { + Self::from_fraction_float(1_000_000.0, value) + } + + pub const fn from_millis(value: i64) -> Self { + Self::from_fraction(1_000, value) + } + + pub fn from_millis_float(value: f64) -> Self { + Self::from_fraction_float(1_000.0, value) + } + + pub const fn from_micros(value: i64) -> Self { + Self::from_value(value) + } + + pub fn from_micros_float(value: f64) -> Self { + Self::from_value_float(value) + } + + pub const fn seconds(&self) -> i64 { + self.to_fraction(1_000_000) + } + + pub fn seconds_float(&self) -> f64 { + self.to_fraction_float(1_000_000.0) + } + + pub const fn ms(&self) -> i64 { + self.to_fraction(1_000) + } + + pub fn ms_float(&self) -> f64 { + self.to_fraction_float(1_000.0) + } + + pub const fn us(&self) -> i64 { + self.to_value() + } + + pub const fn us_float(&self) -> f64 { + self.to_value_float() + } + + pub const fn ns(&self) -> i64 { + self.to_multiple(1000) + } + + pub fn ns_float(&self) -> f64 { + self.to_multiple_float(1000.0) + } + + pub const fn seconds_or(&self, fallback_value: i64) -> i64 { + self.to_fraction_or(1_000_000, fallback_value) + } + + pub const fn ms_or(&self, fallback_value: i64) -> i64 { + self.to_fraction_or(1_000, fallback_value) + } + + pub const fn us_or(&self, fallback_value: i64) -> i64 { + self.to_value_or(fallback_value) + } + + pub const fn abs(&self) -> Self { + if self.us() < 0 { + Self::from_micros(-self.us()) + } else { + *self + } + } +} + +impl fmt::Debug for TimeDelta { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.is_plus_infinity() { + write!(f, "+inf ms") + } else if self.is_minus_infinity() { + write!(f, "-inf ms") + } else if self.us() == 0 || (self.us() % 1000) != 0 { + write!(f, "{} us", self.us()) + } else if self.ms() % 1000 != 0 { + write!(f, "{} ms", self.ms()) + } else { + write!(f, "{} s", self.seconds()) + } + } +} + +// Custom +impl From for TimeDelta { + fn from(duration: Duration) -> Self { + Self::from_micros(duration.as_micros() as i64) + } +} + +impl TryFrom for Duration { + type Error = std::num::TryFromIntError; + + fn try_from(delta: TimeDelta) -> Result { + Ok(Duration::from_micros(delta.us().try_into()?)) + } +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn const_expr() { + const VALUE: i64 = -12345; + const TIME_DELTA_ZERO: TimeDelta = TimeDelta::zero(); + const TIME_DELTA_PLUS_INF: TimeDelta = TimeDelta::plus_infinity(); + const TIME_DELTA_MINUS_INF: TimeDelta = TimeDelta::minus_infinity(); + assert!(TimeDelta::default() == TIME_DELTA_ZERO); + assert!(TIME_DELTA_ZERO.is_zero()); + assert!(TIME_DELTA_PLUS_INF.is_plus_infinity()); + assert!(TIME_DELTA_MINUS_INF.is_minus_infinity()); + assert!(TIME_DELTA_PLUS_INF.ms_or(-1) == -1); + + assert!(TIME_DELTA_PLUS_INF > TIME_DELTA_ZERO); + + const TIME_DELTA_MINUTES: TimeDelta = TimeDelta::from_minutes(VALUE); + const TIME_DELTA_SECONDS: TimeDelta = TimeDelta::from_seconds(VALUE); + const TIME_DELTA_MS: TimeDelta = TimeDelta::from_millis(VALUE); + const TIME_DELTA_US: TimeDelta = TimeDelta::from_micros(VALUE); + + assert!(TIME_DELTA_MINUTES.seconds_or(0) == VALUE * 60); + assert!(TIME_DELTA_SECONDS.seconds_or(0) == VALUE); + assert!(TIME_DELTA_MS.ms_or(0) == VALUE); + assert!(TIME_DELTA_US.us_or(0) == VALUE); + } + + #[test] + fn get_back_same_values() { + const VALUE: i64 = 499; + for sign in [-1, 0, 1] { + let value: i64 = VALUE * sign; + assert_eq!(TimeDelta::from_millis(value).ms(), value); + assert_eq!(TimeDelta::from_micros(value).us(), value); + assert_eq!(TimeDelta::from_seconds(value).seconds(), value); + assert_eq!(TimeDelta::from_seconds(value).seconds(), value); + } + assert_eq!(TimeDelta::zero().us(), 0); + } + + #[test] + fn get_different_prefix() { + const VALUE: i64 = 3000000; + assert_eq!(TimeDelta::from_micros(VALUE).seconds(), VALUE / 1000000); + assert_eq!(TimeDelta::from_millis(VALUE).seconds(), VALUE / 1000); + assert_eq!(TimeDelta::from_micros(VALUE).ms(), VALUE / 1000); + assert_eq!(TimeDelta::from_minutes(VALUE / 60).seconds(), VALUE); + + assert_eq!(TimeDelta::from_millis(VALUE).us(), VALUE * 1000); + assert_eq!(TimeDelta::from_seconds(VALUE).ms(), VALUE * 1000); + assert_eq!(TimeDelta::from_seconds(VALUE).us(), VALUE * 1000000); + assert_eq!(TimeDelta::from_minutes(VALUE / 60).seconds(), VALUE); + } + + #[test] + fn identity_checks() { + const VALUE: i64 = 3000; + assert!(TimeDelta::zero().is_zero()); + assert!(!TimeDelta::from_millis(VALUE).is_zero()); + + assert!(TimeDelta::plus_infinity().is_infinite()); + assert!(TimeDelta::minus_infinity().is_infinite()); + assert!(!TimeDelta::zero().is_infinite()); + assert!(!TimeDelta::from_millis(-VALUE).is_infinite()); + assert!(!TimeDelta::from_millis(VALUE).is_infinite()); + + assert!(!TimeDelta::plus_infinity().is_finite()); + assert!(!TimeDelta::minus_infinity().is_finite()); + assert!(TimeDelta::from_millis(-VALUE).is_finite()); + assert!(TimeDelta::from_millis(VALUE).is_finite()); + assert!(TimeDelta::zero().is_finite()); + + assert!(TimeDelta::plus_infinity().is_plus_infinity()); + assert!(!TimeDelta::minus_infinity().is_plus_infinity()); + + assert!(TimeDelta::minus_infinity().is_minus_infinity()); + assert!(!TimeDelta::plus_infinity().is_minus_infinity()); + } + + #[test] + fn comparison_operators() { + const SMALL: i64 = 450; + const LARGE: i64 = 451; + let small: TimeDelta = TimeDelta::from_millis(SMALL); + let large: TimeDelta = TimeDelta::from_millis(LARGE); + + assert_eq!(TimeDelta::zero(), TimeDelta::from_millis(0)); + assert_eq!(TimeDelta::plus_infinity(), TimeDelta::plus_infinity()); + assert_eq!(small, TimeDelta::from_millis(SMALL)); + assert!(small <= TimeDelta::from_millis(SMALL)); + assert!(small >= TimeDelta::from_millis(SMALL)); + assert!(small != TimeDelta::from_millis(LARGE)); + assert!(small <= TimeDelta::from_millis(LARGE)); + assert!(small < TimeDelta::from_millis(LARGE)); + assert!(large >= TimeDelta::from_millis(SMALL)); + assert!(large > TimeDelta::from_millis(SMALL)); + assert!(TimeDelta::zero() < small); + assert!(TimeDelta::zero() > TimeDelta::from_millis(-SMALL)); + assert!(TimeDelta::zero() > TimeDelta::from_millis(-SMALL)); + + assert!(TimeDelta::plus_infinity() > large); + assert!(TimeDelta::minus_infinity() < TimeDelta::zero()); + } + + #[test] + fn clamping() { + const UPPER: TimeDelta = TimeDelta::from_millis(800); + const LOWER: TimeDelta = TimeDelta::from_millis(100); + const UNDER: TimeDelta = TimeDelta::from_millis(100); + const INSIDE: TimeDelta = TimeDelta::from_millis(500); + const OVER: TimeDelta = TimeDelta::from_millis(1000); + assert_eq!(UNDER.clamp(LOWER, UPPER), LOWER); + assert_eq!(INSIDE.clamp(LOWER, UPPER), INSIDE); + assert_eq!(OVER.clamp(LOWER, UPPER), UPPER); + + let mut mutable_delta: TimeDelta = LOWER; + mutable_delta.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_delta, LOWER); + mutable_delta = INSIDE; + mutable_delta.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_delta, INSIDE); + mutable_delta = OVER; + mutable_delta.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_delta, UPPER); + } + + #[test] + fn can_be_inititialized_from_large_int() { + const MAX_INT: i32 = i32::MAX; + assert_eq!( + TimeDelta::from_seconds(MAX_INT as i64).us(), + MAX_INT as i64 * 1000000 + ); + assert_eq!( + TimeDelta::from_millis(MAX_INT as i64).us(), + MAX_INT as i64 * 1000 + ); + } + + #[test] + fn converts_to_and_from_double() { + const MICROS: i64 = 17017; + const NANOS_DOUBLE: f64 = MICROS as f64 * 1e3; + const MICROS_DOUBLE: f64 = MICROS as f64; + const MILLIS_DOUBLE: f64 = MICROS as f64 * 1e-3; + const SECONDS_DOUBLE: f64 = MILLIS_DOUBLE * 1e-3; + + assert_eq!( + TimeDelta::from_micros(MICROS).seconds_float(), + SECONDS_DOUBLE + ); + assert_eq!(TimeDelta::from_seconds_float(SECONDS_DOUBLE).us(), MICROS); + + assert_eq!(TimeDelta::from_micros(MICROS).ms_float(), MILLIS_DOUBLE); + assert_eq!(TimeDelta::from_millis_float(MILLIS_DOUBLE).us(), MICROS); + + assert_eq!(TimeDelta::from_micros(MICROS).us_float(), MICROS_DOUBLE); + assert_eq!(TimeDelta::from_micros_float(MICROS_DOUBLE).us(), MICROS); + + assert_relative_eq!( + TimeDelta::from_micros(MICROS).ns_float(), + NANOS_DOUBLE, + epsilon = 1.0 + ); + + const PLUS_INFINITY: f64 = f64::INFINITY; + const MINUS_INFINITY: f64 = -PLUS_INFINITY; + + assert_eq!(TimeDelta::plus_infinity().seconds_float(), PLUS_INFINITY); + assert_eq!(TimeDelta::minus_infinity().seconds_float(), MINUS_INFINITY); + assert_eq!(TimeDelta::plus_infinity().ms_float(), PLUS_INFINITY); + assert_eq!(TimeDelta::minus_infinity().ms_float(), MINUS_INFINITY); + assert_eq!(TimeDelta::plus_infinity().us_float(), PLUS_INFINITY); + assert_eq!(TimeDelta::minus_infinity().us_float(), MINUS_INFINITY); + assert_eq!(TimeDelta::plus_infinity().ns_float(), PLUS_INFINITY); + assert_eq!(TimeDelta::minus_infinity().ns_float(), MINUS_INFINITY); + + assert!(TimeDelta::from_seconds_float(PLUS_INFINITY).is_plus_infinity()); + assert!(TimeDelta::from_seconds_float(MINUS_INFINITY).is_minus_infinity()); + assert!(TimeDelta::from_millis_float(PLUS_INFINITY).is_plus_infinity()); + assert!(TimeDelta::from_millis_float(MINUS_INFINITY).is_minus_infinity()); + assert!(TimeDelta::from_micros_float(PLUS_INFINITY).is_plus_infinity()); + assert!(TimeDelta::from_micros_float(MINUS_INFINITY).is_minus_infinity()); + } + + #[test] + fn math_operations() { + const VALUE_A: i64 = 267; + const VALUE_B: i64 = 450; + const DELTA_A: TimeDelta = TimeDelta::from_millis(VALUE_A); + const DELTA_B: TimeDelta = TimeDelta::from_millis(VALUE_B); + assert_eq!((DELTA_A + DELTA_B).ms(), VALUE_A + VALUE_B); + assert_eq!((DELTA_A - DELTA_B).ms(), VALUE_A - VALUE_B); + + assert_eq!((DELTA_B / 10).ms(), VALUE_B / 10); + assert_eq!(DELTA_B / DELTA_A, VALUE_B as f64 / VALUE_A as f64); + + assert_eq!(TimeDelta::from_micros(-VALUE_A).abs().us(), VALUE_A); + assert_eq!(TimeDelta::from_micros(VALUE_A).abs().us(), VALUE_A); + + let mut mutable_delta: TimeDelta = TimeDelta::from_millis(VALUE_A); + mutable_delta += TimeDelta::from_millis(VALUE_B); + assert_eq!(mutable_delta, TimeDelta::from_millis(VALUE_A + VALUE_B)); + mutable_delta -= TimeDelta::from_millis(VALUE_B); + assert_eq!(mutable_delta, TimeDelta::from_millis(VALUE_A)); + } + + #[test] + fn multiply_by_scalar() { + const VALUE: TimeDelta = TimeDelta::from_micros(267); + const INT64: i64 = 450; + const INT32: i32 = 123; + const UNSIGNED_INT: usize = 125; + const FLOAT: f64 = 123.0; + + assert_eq!((VALUE * INT64).us(), VALUE.us() * INT64); + assert_eq!(VALUE * INT64, INT64 * VALUE); + + assert_eq!((VALUE * INT32).us(), VALUE.us() * INT32 as i64); + assert_eq!(VALUE * INT32, INT32 * VALUE); + + assert_eq!( + (VALUE * UNSIGNED_INT).us(), + VALUE.us() * UNSIGNED_INT as i64 + ); + assert_eq!(VALUE * UNSIGNED_INT, UNSIGNED_INT * VALUE); + + assert_relative_eq!( + (VALUE * FLOAT).us_float(), + VALUE.us_float() * FLOAT, + epsilon = 0.1 + ); + assert_eq!(VALUE * FLOAT, FLOAT * VALUE); + } + + #[test] + fn infinity_operations() { + const VALUE: i64 = 267; + const FINITE: TimeDelta = TimeDelta::from_millis(VALUE); + assert!((TimeDelta::plus_infinity() + FINITE).is_plus_infinity()); + assert!((TimeDelta::plus_infinity() - FINITE).is_plus_infinity()); + assert!((FINITE + TimeDelta::plus_infinity()).is_plus_infinity()); + assert!((FINITE - TimeDelta::minus_infinity()).is_plus_infinity()); + + assert!((TimeDelta::minus_infinity() + FINITE).is_minus_infinity()); + assert!((TimeDelta::minus_infinity() - FINITE).is_minus_infinity()); + assert!((FINITE + TimeDelta::minus_infinity()).is_minus_infinity()); + assert!((FINITE - TimeDelta::plus_infinity()).is_minus_infinity()); + } +} diff --git a/rs/moq-congestion/src/gcc/api/units/timestamp.rs b/rs/moq-congestion/src/gcc/api/units/timestamp.rs new file mode 100644 index 00000000000..5f7dd5f43dd --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/units/timestamp.rs @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! Timestamp represents the time that has passed since some unspecified epoch. +//! The epoch is assumed to be before any represented timestamps, this means that +//! negative values are not valid. The most notable feature is that the +//! difference of two Timestamps results in a TimeDelta. +super::unit_base!(Timestamp); + +use std::fmt; +use std::ops::*; + +use super::TimeDelta; + +#[allow( + dead_code, + reason = "WebRTC's unit API, kept whole so a refresh diffs cleanly" +)] +impl Timestamp { + const ONE_SIDED: bool = false; + + pub const fn from_seconds(value: i64) -> Self { + Self::from_fraction(1_000_000, value) + } + + pub fn from_seconds_float(value: f64) -> Self { + Self::from_fraction_float(1_000_000.0, value) + } + + pub const fn from_millis(value: i64) -> Self { + Self::from_fraction(1_000, value) + } + + pub fn from_millis_float(value: f64) -> Self { + Self::from_fraction_float(1_000.0, value) + } + + pub const fn from_micros(value: i64) -> Self { + Self::from_value(value) + } + + pub fn from_micros_float(value: f64) -> Self { + Self::from_value_float(value) + } + + pub const fn seconds(&self) -> i64 { + self.to_fraction(1_000_000) + } + + pub fn seconds_float(&self) -> f64 { + self.to_fraction_float(1_000_000.0) + } + + pub const fn ms(&self) -> i64 { + self.to_fraction(1_000) + } + + pub fn ms_float(&self) -> f64 { + self.to_fraction_float(1_000.0) + } + + pub const fn us(&self) -> i64 { + self.to_value() + } + + pub const fn us_float(&self) -> f64 { + self.to_value_float() + } + + pub const fn seconds_or(&self, fallback_value: i64) -> i64 { + self.to_fraction_or(1_000_000, fallback_value) + } + + pub const fn ms_or(&self, fallback_value: i64) -> i64 { + self.to_fraction_or(1_000, fallback_value) + } + + pub const fn us_or(&self, fallback_value: i64) -> i64 { + self.to_value_or(fallback_value) + } +} + +impl Add for Timestamp { + type Output = Self; + + fn add(self, delta: TimeDelta) -> Self { + if self.is_plus_infinity() || delta.is_plus_infinity() { + assert!(!self.is_minus_infinity()); + assert!(!delta.is_minus_infinity()); + return Self::plus_infinity(); + } else if self.is_minus_infinity() || delta.is_minus_infinity() { + assert!(!self.is_plus_infinity()); + assert!(!delta.is_plus_infinity()); + return Self::minus_infinity(); + } + Timestamp::from_micros(self.us() + delta.us()) + } +} + +impl Sub for Timestamp { + type Output = Self; + + fn sub(self, delta: TimeDelta) -> Self { + if self.is_plus_infinity() || delta.is_minus_infinity() { + assert!(!self.is_minus_infinity()); + assert!(!delta.is_plus_infinity()); + return Self::plus_infinity(); + } else if self.is_minus_infinity() || delta.is_plus_infinity() { + assert!(!self.is_plus_infinity()); + assert!(!delta.is_minus_infinity()); + return Self::minus_infinity(); + } + Timestamp::from_micros(self.us() - delta.us()) + } +} + +impl Sub for Timestamp { + type Output = TimeDelta; + + fn sub(self, other: Self) -> TimeDelta { + if self.is_plus_infinity() || other.is_minus_infinity() { + assert!(!self.is_minus_infinity()); + assert!(!other.is_plus_infinity()); + return TimeDelta::plus_infinity(); + } else if self.is_minus_infinity() || other.is_plus_infinity() { + assert!(!self.is_plus_infinity()); + assert!(!other.is_minus_infinity()); + return TimeDelta::minus_infinity(); + } + TimeDelta::from_micros(self.us() - other.us()) + } +} + +impl AddAssign for Timestamp { + fn add_assign(&mut self, delta: TimeDelta) { + *self = *self + delta; + } +} + +impl SubAssign for Timestamp { + fn sub_assign(&mut self, delta: TimeDelta) { + *self = *self - delta; + } +} + +impl fmt::Debug for Timestamp { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.is_plus_infinity() { + write!(f, "+inf ms") + } else if self.is_minus_infinity() { + write!(f, "-inf ms") + } else if self.us() == 0 || (self.us() % 1000) != 0 { + write!(f, "{} us", self.us()) + } else if self.ms() % 1000 != 0 { + write!(f, "{} ms", self.ms()) + } else { + write!(f, "{} s", self.seconds()) + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn const_expr() { + const VALUE: i64 = 12345; + const TIMESTAMP_INF: Timestamp = Timestamp::plus_infinity(); + assert!(TIMESTAMP_INF.is_infinite()); + assert!(TIMESTAMP_INF.ms_or(-1) == -1); + + const TIMESTAMP_SECONDS: Timestamp = Timestamp::from_seconds(VALUE); + const TIMESTAMP_MS: Timestamp = Timestamp::from_millis(VALUE); + const TIMESTAMP_US: Timestamp = Timestamp::from_micros(VALUE); + + assert!(TIMESTAMP_SECONDS.seconds_or(0) == VALUE); + assert!(TIMESTAMP_MS.ms_or(0) == VALUE); + assert!(TIMESTAMP_US.us_or(0) == VALUE); + + assert!(TIMESTAMP_MS > TIMESTAMP_US); + + assert_eq!(TIMESTAMP_SECONDS.seconds(), VALUE); + assert_eq!(TIMESTAMP_MS.ms(), VALUE); + assert_eq!(TIMESTAMP_US.us(), VALUE); + } + + #[test] + fn get_back_same_values() { + const VALUE: i64 = 499; + assert_eq!(Timestamp::from_millis(VALUE).ms(), VALUE); + assert_eq!(Timestamp::from_micros(VALUE).us(), VALUE); + assert_eq!(Timestamp::from_seconds(VALUE).seconds(), VALUE); + } + + #[test] + fn get_different_prefix() { + const VALUE: i64 = 3000000; + assert_eq!(Timestamp::from_micros(VALUE).seconds(), VALUE / 1000000); + assert_eq!(Timestamp::from_millis(VALUE).seconds(), VALUE / 1000); + assert_eq!(Timestamp::from_micros(VALUE).ms(), VALUE / 1000); + + assert_eq!(Timestamp::from_millis(VALUE).us(), VALUE * 1000); + assert_eq!(Timestamp::from_seconds(VALUE).ms(), VALUE * 1000); + assert_eq!(Timestamp::from_seconds(VALUE).us(), VALUE * 1000000); + } + + #[test] + fn identity_checks() { + const VALUE: i64 = 3000; + + assert!(Timestamp::plus_infinity().is_infinite()); + assert!(Timestamp::minus_infinity().is_infinite()); + assert!(!Timestamp::from_millis(VALUE).is_infinite()); + + assert!(!Timestamp::plus_infinity().is_finite()); + assert!(!Timestamp::minus_infinity().is_finite()); + assert!(Timestamp::from_millis(VALUE).is_finite()); + + assert!(Timestamp::plus_infinity().is_plus_infinity()); + assert!(!Timestamp::minus_infinity().is_plus_infinity()); + + assert!(Timestamp::minus_infinity().is_minus_infinity()); + assert!(!Timestamp::plus_infinity().is_minus_infinity()); + } + + #[test] + fn comparison_operators() { + const SMALL: i64 = 450; + const LARGE: i64 = 451; + + assert_eq!(Timestamp::plus_infinity(), Timestamp::plus_infinity()); + assert!(Timestamp::plus_infinity() >= Timestamp::plus_infinity()); + assert!(Timestamp::plus_infinity() > Timestamp::from_millis(LARGE)); + assert_eq!(Timestamp::from_millis(SMALL), Timestamp::from_millis(SMALL)); + assert!(Timestamp::from_millis(SMALL) <= Timestamp::from_millis(SMALL)); + assert!(Timestamp::from_millis(SMALL) >= Timestamp::from_millis(SMALL)); + assert!(Timestamp::from_millis(SMALL) != Timestamp::from_millis(LARGE)); + assert!(Timestamp::from_millis(SMALL) <= Timestamp::from_millis(LARGE)); + assert!(Timestamp::from_millis(SMALL) < Timestamp::from_millis(LARGE)); + assert!(Timestamp::from_millis(LARGE) >= Timestamp::from_millis(SMALL)); + assert!(Timestamp::from_millis(LARGE) > Timestamp::from_millis(SMALL)); + } + + #[test] + fn can_be_inititialized_from_large_int() { + const MAX_INT: i32 = i32::MAX; + assert_eq!( + Timestamp::from_seconds(MAX_INT as _).us(), + MAX_INT as i64 * 1000000 + ); + assert_eq!( + Timestamp::from_millis(MAX_INT as _).us(), + MAX_INT as i64 * 1000 + ); + } + + #[test] + fn converts_to_and_from_double() { + const MICROS: i64 = 17017; + const MICROS_DOUBLE: f64 = MICROS as f64; + const MILLIS_DOUBLE: f64 = MICROS as f64 * 1e-3; + const SECONDS_DOUBLE: f64 = MILLIS_DOUBLE * 1e-3; + + assert_eq!( + Timestamp::from_micros(MICROS).seconds_float(), + SECONDS_DOUBLE + ); + assert_eq!(Timestamp::from_seconds_float(SECONDS_DOUBLE).us(), MICROS); + + assert_eq!(Timestamp::from_micros(MICROS).ms_float(), MILLIS_DOUBLE); + assert_eq!(Timestamp::from_millis_float(MILLIS_DOUBLE).us(), MICROS); + + assert_eq!(Timestamp::from_micros(MICROS).us_float(), MICROS_DOUBLE); + assert_eq!(Timestamp::from_micros_float(MICROS_DOUBLE).us(), MICROS); + + const PLUS_INFINITY: f64 = f64::INFINITY; + const MINUS_INFINITY: f64 = -PLUS_INFINITY; + + assert_eq!(Timestamp::plus_infinity().seconds_float(), PLUS_INFINITY); + assert_eq!(Timestamp::minus_infinity().seconds_float(), MINUS_INFINITY); + assert_eq!(Timestamp::plus_infinity().ms_float(), PLUS_INFINITY); + assert_eq!(Timestamp::minus_infinity().ms_float(), MINUS_INFINITY); + assert_eq!(Timestamp::plus_infinity().us_float(), PLUS_INFINITY); + assert_eq!(Timestamp::minus_infinity().us_float(), MINUS_INFINITY); + + assert!(Timestamp::from_seconds_float(PLUS_INFINITY).is_plus_infinity()); + assert!(Timestamp::from_seconds_float(MINUS_INFINITY).is_minus_infinity()); + assert!(Timestamp::from_millis_float(PLUS_INFINITY).is_plus_infinity()); + assert!(Timestamp::from_millis_float(MINUS_INFINITY).is_minus_infinity()); + assert!(Timestamp::from_micros_float(PLUS_INFINITY).is_plus_infinity()); + assert!(Timestamp::from_micros_float(MINUS_INFINITY).is_minus_infinity()); + } + + #[test] + fn timestamp_and_time_delta_math() { + const VALUE_A: i64 = 267; + const VALUE_B: i64 = 450; + const TIME_A: Timestamp = Timestamp::from_millis(VALUE_A); + const TIME_B: Timestamp = Timestamp::from_millis(VALUE_B); + const DELTA_A: TimeDelta = TimeDelta::from_millis(VALUE_A); + const DELTA_B: TimeDelta = TimeDelta::from_millis(VALUE_B); + + assert_eq!((TIME_A - TIME_B), TimeDelta::from_millis(VALUE_A - VALUE_B)); + assert_eq!( + (TIME_B - DELTA_A), + Timestamp::from_millis(VALUE_B - VALUE_A) + ); + assert_eq!( + (TIME_B + DELTA_A), + Timestamp::from_millis(VALUE_B + VALUE_A) + ); + + let mut mutable_time: Timestamp = TIME_A; + mutable_time += DELTA_B; + assert_eq!(mutable_time, TIME_A + DELTA_B); + mutable_time -= DELTA_B; + assert_eq!(mutable_time, TIME_A); + } + + #[test] + fn infinity_operations() { + const VALUE: i64 = 267; + const FINITE_TIME: Timestamp = Timestamp::from_millis(VALUE); + const FINITE_DELTA: TimeDelta = TimeDelta::from_millis(VALUE); + assert!((Timestamp::plus_infinity() + FINITE_DELTA).is_infinite()); + assert!((Timestamp::plus_infinity() - FINITE_DELTA).is_infinite()); + assert!((FINITE_TIME + TimeDelta::plus_infinity()).is_infinite()); + assert!((FINITE_TIME - TimeDelta::minus_infinity()).is_infinite()); + } +} // namespace test diff --git a/rs/moq-congestion/src/gcc/api/units/unit_base.rs b/rs/moq-congestion/src/gcc/api/units/unit_base.rs new file mode 100644 index 00000000000..76cb88c44c7 --- /dev/null +++ b/rs/moq-congestion/src/gcc/api/units/unit_base.rs @@ -0,0 +1,800 @@ +// UnitBase is a superclass in C++. +// The closest we can do in Rust is a macro, as traits don't support const. +macro_rules! unit_base { + ($ty:ident) => { + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] + pub struct $ty(i64); + + #[allow( + dead_code, + reason = "WebRTC's unit API, kept whole so a refresh diffs cleanly" + )] + impl $ty { + pub const fn zero() -> Self { + Self(0) + } + + pub const fn plus_infinity() -> Self { + Self(i64::MAX) + } + + pub const fn minus_infinity() -> Self { + Self(i64::MIN) + } + + pub const fn is_zero(&self) -> bool { + self.0 == 0 + } + + pub const fn is_finite(&self) -> bool { + !self.is_infinite() + } + + pub const fn is_infinite(&self) -> bool { + self.0 == i64::MAX || self.0 == i64::MIN + } + + pub const fn is_plus_infinity(&self) -> bool { + self.0 == i64::MAX + } + + pub const fn is_minus_infinity(&self) -> bool { + self.0 == i64::MIN + } + + pub const fn round_to(&self, resolution: Self) -> Self { + assert!(self.is_finite()); + assert!(resolution.is_finite()); + assert!(resolution.0 > 0); + Self::from_value(((self.0 + resolution.0 / 2) / resolution.0) * resolution.0) + } + + pub const fn round_up_to(&self, resolution: Self) -> Self { + assert!(self.is_finite()); + assert!(resolution.is_finite()); + assert!(resolution.0 > 0); + Self::from_value(((self.0 + resolution.0 - 1) / resolution.0) * resolution.0) + } + + pub const fn round_down_to(&self, resolution: Self) -> Self { + assert!(self.is_finite()); + assert!(resolution.is_finite()); + assert!(resolution.0 > 0); + Self::from_value((self.0 / resolution.0) * resolution.0) + } + + #[allow(dead_code, reason = "each unit takes the conversions it needs")] + const fn from_fraction(denominator: i64, value: i64) -> Self { + assert!(denominator >= 0); + Self::from_value(value * denominator) + } + + #[allow(dead_code, reason = "each unit takes the conversions it needs")] + fn from_fraction_float(denominator: f64, value: f64) -> Self { + Self::from_value_float(value * denominator) + } + + #[allow(dead_code, reason = "each unit takes the conversions it needs")] + const fn to_fraction(self, denominator: i64) -> i64 { + self.divide_round_to_nearest(denominator) + } + + const fn divide_round_to_nearest(&self, d: i64) -> i64 { + assert!(d >= 0); + + let v = self.to_value(); + let mut result = v / d; + let remainder = v % d; + + if remainder.abs() * 2 >= d.abs() { + if (v < 0) != (d < 0) { + result -= 1 + } else { + result += 1 + } + } + result + } + + #[allow(dead_code, reason = "each unit takes the conversions it needs")] + fn to_fraction_float(self, denominator: f64) -> f64 { + assert!(denominator >= 0.0); + self.to_value_float() / denominator + } + + #[allow(dead_code, reason = "each unit takes the conversions it needs")] + const fn to_fraction_or(self, denominator: i64, fallback_value: i64) -> i64 { + assert!(denominator >= 0); + if self.is_finite() { + self.divide_round_to_nearest(denominator) + } else { + fallback_value + } + } + + pub const fn to_multiple(self, factor: i64) -> i64 { + assert!(factor >= 0); + self.to_value() * factor + } + + pub fn to_multiple_float(self, factor: f64) -> f64 { + assert!(factor >= 0.0); + self.to_value_float() * factor + } + + const fn from_value(value: i64) -> Self { + assert!(value != i64::MAX && value != i64::MIN); + if Self::ONE_SIDED { + assert!(value >= 0); + } + + Self(value) + } + + fn from_value_float(value: f64) -> Self { + assert!(!value.is_nan()); + + if value == f64::INFINITY { + return Self::plus_infinity(); + } + + if Self::ONE_SIDED { + assert!(value >= 0.0); + } + + if value == f64::NEG_INFINITY { + Self::minus_infinity() + } else { + Self(value as i64) + } + } + + const fn to_value(self) -> i64 { + assert!(self.is_finite()); + self.0 + } + + #[allow(dead_code, reason = "each unit takes the conversions it needs")] + const fn to_value_or(self, fallback_value: i64) -> i64 { + if self.is_finite() { + self.0 + } else { + fallback_value + } + } + + const fn to_value_float(self) -> f64 { + if self.is_plus_infinity() { + f64::INFINITY + } else if self.is_minus_infinity() { + f64::NEG_INFINITY + } else { + self.0 as f64 + } + } + } + }; +} + +macro_rules! relative_unit { + ($ty:ident) => { + crate::gcc::api::units::unit_base!($ty); + + impl $ty { + pub fn clamp_mut(&mut self, min_value: Self, max_value: Self) { + *self = Self(self.0.max(min_value.0).min(max_value.0)); + } + } + + impl ::std::ops::Add for $ty { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + if self.is_plus_infinity() || rhs.is_plus_infinity() { + assert!(!self.is_minus_infinity()); + assert!(!rhs.is_minus_infinity()); + return Self::plus_infinity(); + } else if self.is_minus_infinity() || rhs.is_minus_infinity() { + assert!(!self.is_plus_infinity()); + assert!(!rhs.is_plus_infinity()); + return Self::minus_infinity(); + } + Self::from_value(self.to_value() + rhs.to_value()) + } + } + + impl ::std::ops::Sub for $ty { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + if self.is_plus_infinity() || rhs.is_minus_infinity() { + assert!(!self.is_minus_infinity()); + assert!(!rhs.is_plus_infinity()); + return Self::plus_infinity(); + } else if self.is_minus_infinity() || rhs.is_plus_infinity() { + assert!(!self.is_plus_infinity()); + assert!(!rhs.is_minus_infinity()); + return Self::minus_infinity(); + } + Self::from_value(self.to_value() - rhs.to_value()) + } + } + + impl ::std::ops::AddAssign for $ty { + fn add_assign(&mut self, rhs: Self) { + *self = Self::from_value(self.to_value() + rhs.to_value()); + } + } + + impl ::std::ops::SubAssign for $ty { + fn sub_assign(&mut self, rhs: Self) { + *self = Self::from_value(self.to_value() - rhs.to_value()); + } + } + + impl ::std::ops::Div for $ty { + type Output = f64; + + fn div(self, rhs: Self) -> Self::Output { + self.to_value_float() / rhs.to_value_float() + } + } + + impl ::std::ops::Div for $ty { + type Output = Self; + + fn div(self, rhs: f64) -> Self::Output { + Self::from_value_float((self.to_value_float() / rhs).round()) + } + } + + impl ::std::ops::Div for $ty { + type Output = Self; + + fn div(self, rhs: i64) -> Self::Output { + Self::from_value(self.to_value() / rhs) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: isize) -> Self::Output { + Self::from_value(self.to_value() * rhs as i64) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: i64) -> Self::Output { + Self::from_value(self.to_value() * rhs) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: i32) -> Self::Output { + Self::from_value(self.to_value() * rhs as i64) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: i16) -> Self::Output { + Self::from_value(self.to_value() * rhs as i64) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: usize) -> Self::Output { + Self::from_value(self.to_value() * i64::try_from(rhs).unwrap()) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: u64) -> Self::Output { + Self::from_value(self.to_value() * i64::try_from(rhs).unwrap()) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: u32) -> Self::Output { + Self::from_value(self.to_value() * i64::try_from(rhs).unwrap()) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: u16) -> Self::Output { + Self::from_value(self.to_value() * i64::try_from(rhs).unwrap()) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: f64) -> Self::Output { + Self::from_value_float((self.to_value_float() * rhs).round()) + } + } + + impl ::std::ops::Mul for $ty { + type Output = Self; + + fn mul(self, rhs: f32) -> Self::Output { + Self::from_value_float((self.to_value_float() * rhs as f64).round()) + } + } + + impl ::std::ops::Neg for $ty { + type Output = Self; + + fn neg(self) -> Self::Output { + if self.is_plus_infinity() { + Self::minus_infinity() + } else if self.is_minus_infinity() { + Self::plus_infinity() + } else { + Self::from_value(-self.to_value()) + } + } + } + + impl ::std::ops::Mul<$ty> for isize { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for i64 { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for i32 { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for i16 { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for usize { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for u64 { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for u32 { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for u16 { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for f64 { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::Mul<$ty> for f32 { + type Output = $ty; + + fn mul(self, rhs: $ty) -> Self::Output { + rhs * self + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: isize) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: i64) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: i32) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: i16) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: usize) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: u64) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: u32) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: u16) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: f64) { + *self = *self * rhs; + } + } + + impl ::std::ops::MulAssign for $ty { + fn mul_assign(&mut self, rhs: f32) { + *self = *self * rhs; + } + } + }; +} + +pub(crate) use relative_unit; +pub(crate) use unit_base; + +#[cfg(test)] +mod test { + use std::fmt; + + relative_unit!(TestUnit); + + impl TestUnit { + const ONE_SIDED: bool = false; + + pub const fn from_kilo(kilo: i64) -> Self { + Self::from_fraction(1000, kilo) + } + + pub fn from_kilo_float(kilo: f64) -> Self { + Self::from_fraction_float(1000.0, kilo) + } + + pub const fn to_kilo(self) -> i64 { + self.to_fraction(1000) + } + + pub fn to_kilo_float(self) -> f64 { + self.to_fraction_float(1000.0) + } + + pub const fn to_kilo_or(self, fallback: i64) -> i64 { + self.to_fraction_or(1000, fallback) + } + + pub const fn to_milli(self) -> i64 { + self.to_multiple(1000) + } + + pub fn to_milli_float(self) -> f64 { + self.to_multiple_float(1000.0) + } + } + + impl fmt::Debug for TestUnit { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.is_plus_infinity() { + write!(f, "+inf") + } else if self.is_minus_infinity() { + write!(f, "-inf") + } else { + write!(f, "{}", self.0) + } + } + } + + fn test_unit_add_kilo(mut value: TestUnit, add_kilo: i64) -> TestUnit { + value += TestUnit::from_kilo(add_kilo); + value + } + + #[test] + fn const_expr() { + const VALUE: i64 = -12345; + const TEST_UNIT_ZERO: TestUnit = TestUnit::zero(); + const TEST_UNIT_PLUS_INF: TestUnit = TestUnit::plus_infinity(); + const TEST_UNIT_MINUS_INF: TestUnit = TestUnit::minus_infinity(); + + assert!(TEST_UNIT_ZERO.is_zero()); + assert!(TEST_UNIT_PLUS_INF.is_plus_infinity()); + assert!(TEST_UNIT_MINUS_INF.is_minus_infinity()); + assert!(TEST_UNIT_PLUS_INF.to_kilo_or(-1) == -1); + + // Check from is constexpr for floats. + assert!(TestUnit::from_value_float(0.0).is_zero()); + assert!(TestUnit::from_value_float(f64::INFINITY).is_plus_infinity()); + assert!(TestUnit::from_value_float(-f64::INFINITY).is_minus_infinity()); + assert!(TestUnit::from_value_float(250.0) == TestUnit::from_value(250)); + assert!(TestUnit::from_value_float(-250.0) == TestUnit::from_value(-250)); + + assert!(TEST_UNIT_PLUS_INF > TEST_UNIT_ZERO); + + const TEST_UNIT_KILO: TestUnit = TestUnit::from_kilo(VALUE); + const TEST_UNIT_VALUE: TestUnit = TestUnit::from_value(VALUE); + + assert!(TEST_UNIT_KILO.to_kilo_or(0) == VALUE); + assert!(TEST_UNIT_VALUE.to_value_or(0) == VALUE); + assert!(test_unit_add_kilo(TEST_UNIT_VALUE, 2).to_value() == VALUE + 2000); + assert!(TestUnit::from_value(500) / 2 == TestUnit::from_value(250)); + assert!(TestUnit::from_value_float(500.0) / 2 == TestUnit::from_value_float(250.0)); + } + + #[test] + fn get_back_same_values() { + const VALUE: i64 = 499; + for sign in [-1, 0, 1] { + let value: i64 = VALUE * sign; + assert_eq!(TestUnit::from_kilo(value).to_kilo(), value); + assert_eq!(TestUnit::from_value(value).to_value(), value); + } + assert_eq!(TestUnit::zero().to_value(), 0); + } + + #[test] + fn get_different_prefix() { + const VALUE: i64 = 3000000; + assert_eq!(TestUnit::from_value(VALUE).to_kilo(), VALUE / 1000); + assert_eq!(TestUnit::from_kilo(VALUE).to_value(), VALUE * 1000); + } + + #[test] + fn identity_checks() { + const VALUE: i64 = 3000; + assert!(TestUnit::zero().is_zero()); + assert!(!TestUnit::from_kilo(VALUE).is_zero()); + + assert!(TestUnit::plus_infinity().is_infinite()); + assert!(TestUnit::minus_infinity().is_infinite()); + assert!(!TestUnit::zero().is_infinite()); + assert!(!TestUnit::from_kilo(-VALUE).is_infinite()); + assert!(!TestUnit::from_kilo(VALUE).is_infinite()); + + assert!(!TestUnit::plus_infinity().is_finite()); + assert!(!TestUnit::minus_infinity().is_finite()); + assert!(TestUnit::from_kilo(-VALUE).is_finite()); + assert!(TestUnit::from_kilo(VALUE).is_finite()); + assert!(TestUnit::zero().is_finite()); + + assert!(TestUnit::plus_infinity().is_plus_infinity()); + assert!(!TestUnit::minus_infinity().is_plus_infinity()); + + assert!(TestUnit::minus_infinity().is_minus_infinity()); + assert!(!TestUnit::plus_infinity().is_minus_infinity()); + } + + #[test] + fn comparison_operators() { + const SMALL: i64 = 450; + const LARGE: i64 = 451; + let small: TestUnit = TestUnit::from_kilo(SMALL); + let large: TestUnit = TestUnit::from_kilo(LARGE); + + assert_eq!(TestUnit::zero(), TestUnit::from_kilo(0)); + assert_eq!(TestUnit::plus_infinity(), TestUnit::plus_infinity()); + assert_eq!(small, TestUnit::from_kilo(SMALL)); + assert!(small <= TestUnit::from_kilo(SMALL)); + assert!(small >= TestUnit::from_kilo(SMALL)); + assert!(small != TestUnit::from_kilo(LARGE)); + assert!(small <= TestUnit::from_kilo(LARGE)); + assert!(small < TestUnit::from_kilo(LARGE)); + assert!(large >= TestUnit::from_kilo(SMALL)); + assert!(large > TestUnit::from_kilo(SMALL)); + assert!(TestUnit::zero() < small); + assert!(TestUnit::zero() > TestUnit::from_kilo(-SMALL)); + assert!(TestUnit::zero() > TestUnit::from_kilo(-SMALL)); + + assert!(TestUnit::plus_infinity() > large); + assert!(TestUnit::minus_infinity() < TestUnit::zero()); + } + + #[test] + fn can_be_inititialized_from_large_int() { + const MAX_INT: i32 = i32::MAX; + assert_eq!( + TestUnit::from_kilo(MAX_INT as i64).to_value(), + (MAX_INT as i64) * 1000 + ); + } + + #[test] + fn converts_to_and_from_double() { + const VALUE: i64 = 17017; + const MILLI_DOUBLE: f64 = VALUE as f64 * 1e3; + const VALUE_DOUBLE: f64 = VALUE as f64; + const KILO_DOUBLE: f64 = VALUE as f64 * 1e-3; + + assert_eq!(TestUnit::from_value(VALUE).to_kilo_float(), KILO_DOUBLE); + assert_eq!(TestUnit::from_kilo_float(KILO_DOUBLE).to_value(), VALUE); + + assert_eq!(TestUnit::from_value(VALUE).to_value_float(), VALUE_DOUBLE); + assert_eq!(TestUnit::from_value_float(VALUE_DOUBLE).to_value(), VALUE); + + assert_relative_eq!( + TestUnit::from_value(VALUE).to_milli_float(), + MILLI_DOUBLE, + epsilon = 1.0 + ); + + const PLUS_INFINITY: f64 = f64::INFINITY; + const MINUS_INFINITY: f64 = -PLUS_INFINITY; + + assert_eq!(TestUnit::plus_infinity().to_kilo_float(), PLUS_INFINITY); + assert_eq!(TestUnit::minus_infinity().to_kilo_float(), MINUS_INFINITY); + assert_eq!(TestUnit::plus_infinity().to_kilo_float(), PLUS_INFINITY); + assert_eq!(TestUnit::minus_infinity().to_kilo_float(), MINUS_INFINITY); + assert_eq!(TestUnit::plus_infinity().to_milli_float(), PLUS_INFINITY); + assert_eq!(TestUnit::minus_infinity().to_milli_float(), MINUS_INFINITY); + + assert!(TestUnit::from_kilo_float(PLUS_INFINITY).is_plus_infinity()); + assert!(TestUnit::from_kilo_float(MINUS_INFINITY).is_minus_infinity()); + assert!(TestUnit::from_value_float(PLUS_INFINITY).is_plus_infinity()); + assert!(TestUnit::from_value_float(MINUS_INFINITY).is_minus_infinity()); + } + + #[test] + #[should_panic] + fn crashes_when_created_from_nan1() { + TestUnit::from_value_float(f64::NAN); + } + + #[test] + #[should_panic] + fn crashes_when_created_from_nan2() { + #[allow(clippy::zero_divided_by_zero, reason = "builds a NaN")] + TestUnit::from_value_float(0.0 / 0.0); + } + + #[test] + #[should_panic] + fn crashes_when_created_from_nan3() { + TestUnit::from_value_float(f64::INFINITY - f64::INFINITY); + } + + #[test] + fn clamping() { + const UPPER: TestUnit = TestUnit::from_value(800); + const LOWER: TestUnit = TestUnit::from_value(100); + const UNDER: TestUnit = TestUnit::from_value(100); + const INSIDE: TestUnit = TestUnit::from_value(500); + const OVER: TestUnit = TestUnit::from_value(1000); + assert_eq!(UNDER.clamp(LOWER, UPPER), LOWER); + assert_eq!(INSIDE.clamp(LOWER, UPPER), INSIDE); + assert_eq!(OVER.clamp(LOWER, UPPER), UPPER); + + let mut mutable_delta: TestUnit = LOWER; + mutable_delta.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_delta, LOWER); + mutable_delta = INSIDE; + mutable_delta.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_delta, INSIDE); + mutable_delta = OVER; + mutable_delta.clamp_mut(LOWER, UPPER); + assert_eq!(mutable_delta, UPPER); + } + + #[test] + fn math_operations() { + const VALUE_A: i64 = 267; + const VALUE_B: i64 = 450; + const DELTA_A: TestUnit = TestUnit::from_kilo(VALUE_A); + const DELTA_B: TestUnit = TestUnit::from_kilo(VALUE_B); + assert_eq!((DELTA_A + DELTA_B).to_kilo(), VALUE_A + VALUE_B); + assert_eq!((DELTA_A - DELTA_B).to_kilo(), VALUE_A - VALUE_B); + + const INT32_VALUE: i32 = 123; + const FLOAT_VALUE: f64 = 123.0; + assert_eq!( + (TestUnit::from_value(VALUE_A) * VALUE_B).to_value(), + VALUE_A * VALUE_B + ); + assert_eq!( + (TestUnit::from_value(VALUE_A) * INT32_VALUE).to_value(), + VALUE_A * INT32_VALUE as i64 + ); + assert_eq!( + (TestUnit::from_value(VALUE_A) * FLOAT_VALUE).to_value(), + VALUE_A * FLOAT_VALUE as i64 + ); + + assert_eq!((DELTA_B / 10).to_kilo(), VALUE_B / 10); + assert_eq!(DELTA_B / DELTA_A, VALUE_B as f64 / VALUE_A as f64); + + let mut mutable_delta: TestUnit = TestUnit::from_kilo(VALUE_A); + mutable_delta += TestUnit::from_kilo(VALUE_B); + assert_eq!(mutable_delta, TestUnit::from_kilo(VALUE_A + VALUE_B)); + mutable_delta -= TestUnit::from_kilo(VALUE_B); + assert_eq!(mutable_delta, TestUnit::from_kilo(VALUE_A)); + + // Division by an int rounds towards zero to follow regular int division. + assert_eq!(TestUnit::from_value(789) / 10, TestUnit::from_value(78)); + assert_eq!(TestUnit::from_value(-789) / 10, TestUnit::from_value(-78)); + } + + #[test] + fn infinity_operations() { + const VALUE: i64 = 267; + const FINITE: TestUnit = TestUnit::from_value(VALUE); + assert!((TestUnit::plus_infinity() + FINITE).is_plus_infinity()); + assert!((TestUnit::plus_infinity() - FINITE).is_plus_infinity()); + assert!((FINITE + TestUnit::plus_infinity()).is_plus_infinity()); + assert!((FINITE - TestUnit::minus_infinity()).is_plus_infinity()); + + assert!((TestUnit::minus_infinity() + FINITE).is_minus_infinity()); + assert!((TestUnit::minus_infinity() - FINITE).is_minus_infinity()); + assert!((FINITE + TestUnit::minus_infinity()).is_minus_infinity()); + assert!((FINITE - TestUnit::plus_infinity()).is_minus_infinity()); + } + + #[test] + fn unary_minus() { + const VALUE: i64 = 1337; + const UNIT: TestUnit = TestUnit::from_value(VALUE); + assert_eq!(-UNIT.to_value(), -VALUE); + + // Check infinity. + assert_eq!(-TestUnit::plus_infinity(), TestUnit::minus_infinity()); + assert_eq!(-TestUnit::minus_infinity(), TestUnit::plus_infinity()); + } +} diff --git a/rs/moq-congestion/src/gcc/bench.rs b/rs/moq-congestion/src/gcc/bench.rs new file mode 100644 index 00000000000..1fa6ca9e01d --- /dev/null +++ b/rs/moq-congestion/src/gcc/bench.rs @@ -0,0 +1,94 @@ +//! Controller-only per-packet measurements, as BBR3's: each ACK becomes GoogCC transport feedback, +//! so its cost grows with the packets each ACK carries. + +use super::*; +use crate::Acked; +use cpu_time::ThreadTime; + +const PACKET: u16 = 1200; + +/// Times the controller callbacks for `flight` packets acknowledged `batch` at a time, refilled as +/// they are acknowledged if `steady`, each with a receive time +fn measure(flight: usize, batch: usize, steady: bool) -> Duration { + let base = Instant::ZERO + Duration::from_secs(1); + let mut gcc = Gcc::new(Init { + now: base, + mtu: PACKET, + rtt: Duration::from_millis(10), + max_pacing_rate: None, + }); + let bytes = u64::from(PACKET); + let mut sent = Vec::with_capacity(2 * flight); + for pn in 0..flight { + let now = base + Duration::from_micros(pn as u64); + sent.push(( + now, + gcc.on_sent(Sent { + now, + bytes: PACKET, + in_flight: (pn as u64 + 1) * bytes, + }), + )); + } + let mut acked = Vec::with_capacity(batch); + let mut elapsed = Duration::ZERO; + for first in (0..flight).step_by(batch) { + let end = (first + batch).min(flight); + let now = base + Duration::from_millis(10) + Duration::from_micros(first as u64); + let in_flight = if steady { + flight - (end - first) + } else { + flight - end + } as u64 + * bytes; + let start = ThreadTime::now(); + acked.clear(); + acked.extend(sent[first..end].iter().map(|&(at, packet)| Acked { + sent: at, + bytes: PACKET, + packet, + received: Some(at - base + Duration::from_millis(5)), + })); + gcc.on_ack(Ack { + now, + packets: &acked, + in_flight, + app_limited: false, + rtt: Duration::from_millis(10), + }); + if steady { + for pn in first..end { + let packet = gcc.on_sent(Sent { + now, + bytes: PACKET, + in_flight: in_flight + (pn - first + 1) as u64 * bytes, + }); + sent.push((now, packet)); + } + } + elapsed += start.elapsed(); + } + std::hint::black_box(&gcc); + elapsed +} + +#[test] +#[ignore = "controller benchmark: run with --release and --success-output immediate"] +fn benchmark_per_packet() { + println!("gcc_packet controller_stack_bytes={}", size_of::()); + for steady in [false, true] { + for flight in [16, 64, 1000, 4000, 16000] { + for batch in [1, 8, 32] { + let mut samples = [Duration::ZERO; 3]; + for sample in &mut samples { + *sample = measure(flight, batch, steady); + } + samples.sort(); + println!( + "gcc_packet steady={steady} flight={flight} batch={batch} cpu_ns={}", + samples[1].as_nanos() + ); + } + } + } +} diff --git a/rs/moq-congestion/src/gcc/congestion_window_pushback_controller.rs b/rs/moq-congestion/src/gcc/congestion_window_pushback_controller.rs new file mode 100644 index 00000000000..a7975e67f06 --- /dev/null +++ b/rs/moq-congestion/src/gcc/congestion_window_pushback_controller.rs @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::{ + api::units::DataSize, + experiments::{FieldTrials, RateControlSettings}, +}; + +// This class enables pushback from congestion window directly to video encoder. +// When the congestion window is filling up, the video encoder target bitrate +// will be reduced accordingly to accommodate the network changes. To avoid +// pausing video too frequently, a minimum encoder target bitrate threshold is +// used to prevent video pause due to a full congestion window. +#[derive(Clone)] +pub struct CongestionWindowPushbackController { + add_pacing: bool, + min_pushback_target_bitrate_bps: u32, + current_data_window: Option, + outstanding_bytes: i64, + pacing_bytes: i64, + encoding_rate_ratio: f64, +} + +// const char CongestionWindowDefaultFieldTrialString[] = "QueueSize:350,MinBitrate:30000,DropFrame:true" +impl Default for CongestionWindowPushbackController { + fn default() -> Self { + Self { + add_pacing: false, + min_pushback_target_bitrate_bps: 30000, + current_data_window: None, + outstanding_bytes: 0, + pacing_bytes: 0, + encoding_rate_ratio: 1.0, + } + } +} + +impl CongestionWindowPushbackController { + pub fn new(field_trials: &FieldTrials) -> Self { + let rate_control_settings = RateControlSettings::new(field_trials); + Self { + add_pacing: field_trials.add_pacing_to_congestion_window_pushback, + min_pushback_target_bitrate_bps: rate_control_settings + .congestion_window_min_pushback_target_bitrate_bps(), + current_data_window: rate_control_settings.congestion_window_initial_data_window(), + outstanding_bytes: 0, + pacing_bytes: 0, + encoding_rate_ratio: 1.0, + } + } + + pub fn update_outstanding_data(&mut self, outstanding_bytes: i64) { + self.outstanding_bytes = outstanding_bytes; + } + pub fn update_pacing_queue(&mut self, pacing_bytes: i64) { + self.pacing_bytes = pacing_bytes; + } + pub fn update_target_bitrate(&mut self, mut bitrate_bps: u32) -> u32 { + let data_window = match self.current_data_window { + Some(data_window) if !data_window.is_zero() => data_window, + _ => return bitrate_bps, + }; + let mut total_bytes: i64 = self.outstanding_bytes; + if self.add_pacing { + total_bytes += self.pacing_bytes; + } + let fill_ratio: f64 = total_bytes as f64 / (data_window.bytes() as f64); + if fill_ratio > 1.5 { + self.encoding_rate_ratio *= 0.9; + } else if fill_ratio > 1.0 { + self.encoding_rate_ratio *= 0.95; + } else if fill_ratio < 0.1 { + self.encoding_rate_ratio = 1.0; + } else { + self.encoding_rate_ratio *= 1.05; + self.encoding_rate_ratio = self.encoding_rate_ratio.min(1.0); + } + let adjusted_target_bitrate_bps: u32 = + (bitrate_bps as f64 * self.encoding_rate_ratio) as u32; + + // Do not adjust below the minimum pushback bitrate but do obey if the + // original estimate is below it. + bitrate_bps = if adjusted_target_bitrate_bps < self.min_pushback_target_bitrate_bps { + std::cmp::min(bitrate_bps, self.min_pushback_target_bitrate_bps) + } else { + adjusted_target_bitrate_bps + }; + bitrate_bps + } + + pub fn set_data_window(&mut self, data_window: DataSize) { + self.current_data_window = Some(data_window); + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn full_congestion_window() { + let mut cwnd_controller = CongestionWindowPushbackController::default(); + + cwnd_controller.update_outstanding_data(100000); + cwnd_controller.set_data_window(DataSize::from_bytes(50000)); + + let mut bitrate_bps: u32 = 80000; + bitrate_bps = cwnd_controller.update_target_bitrate(bitrate_bps); + assert_eq!(72000, bitrate_bps); + + cwnd_controller.set_data_window(DataSize::from_bytes(50000)); + bitrate_bps = cwnd_controller.update_target_bitrate(bitrate_bps); + assert_eq!((72000.0 * 0.9 * 0.9) as u32, bitrate_bps); + } + + #[test] + fn normal_congestion_window() { + let mut cwnd_controller = CongestionWindowPushbackController::default(); + + cwnd_controller.update_outstanding_data(199999); + cwnd_controller.set_data_window(DataSize::from_bytes(200000)); + + let mut bitrate_bps: u32 = 80000; + bitrate_bps = cwnd_controller.update_target_bitrate(bitrate_bps); + assert_eq!(80000, bitrate_bps); + } + + #[test] + fn low_bitrate() { + let mut cwnd_controller = CongestionWindowPushbackController::default(); + + cwnd_controller.update_outstanding_data(100000); + cwnd_controller.set_data_window(DataSize::from_bytes(50000)); + + let mut bitrate_bps: u32 = 35000; + bitrate_bps = cwnd_controller.update_target_bitrate(bitrate_bps); + assert_eq!((35000.0 * 0.9) as u32, bitrate_bps); + + cwnd_controller.set_data_window(DataSize::from_bytes(20000)); + bitrate_bps = cwnd_controller.update_target_bitrate(bitrate_bps); + assert_eq!(30000, bitrate_bps); + } + + #[test] + fn no_pushback_on_data_window_unset() { + let mut cwnd_controller = CongestionWindowPushbackController::default(); + + cwnd_controller.update_outstanding_data(100_000_000); // Large number + + let mut bitrate_bps: u32 = 80000; + bitrate_bps = cwnd_controller.update_target_bitrate(bitrate_bps); + assert_eq!(80000, bitrate_bps); + } + + #[test] + fn pushback_on_inititial_data_window() { + let mut cwnd_controller = CongestionWindowPushbackController::default(); + cwnd_controller.set_data_window(DataSize::from_bytes(100000)); + cwnd_controller.update_outstanding_data(100_000_000); // Large number + + let mut bitrate_bps: u32 = 80000; + bitrate_bps = cwnd_controller.update_target_bitrate(bitrate_bps); + assert!(80000 > bitrate_bps); + } + + #[test] + fn pushback_drop_frame() { + let mut cwnd_controller = CongestionWindowPushbackController::default(); + // Not sure what this is meant to test. The DropFrame field is not used. + //CongestionWindowPushbackController cwnd_controller( + // ExplicitKeyValueConfig("WebRTC-CongestionWindow/DropFrame:true/")); + + cwnd_controller.update_outstanding_data(100_000_000); // Large number + cwnd_controller.set_data_window(DataSize::from_bytes(50000)); + + let mut bitrate_bps: u32 = 80000; + bitrate_bps = cwnd_controller.update_target_bitrate(bitrate_bps); + assert!(80000 > bitrate_bps); + } +} diff --git a/rs/moq-congestion/src/gcc/delay_based_bwe.rs b/rs/moq-congestion/src/gcc/delay_based_bwe.rs new file mode 100644 index 00000000000..f461af14759 --- /dev/null +++ b/rs/moq-congestion/src/gcc/delay_based_bwe.rs @@ -0,0 +1,1238 @@ +/* + * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! Upstream can run a second detector over audio packets (`WebRTC-Bwe-SeparateAudioPackets`, off +//! by default). QUIC packets carry no media type, so there is one detector. + +use crate::gcc::{ + DelayIncreaseDetectorInterface, InterArrivalDelta, TrendlineEstimator, + TrendlineEstimatorSettings, + api::{ + transport::{BandwidthUsage, PacketResult, TransportPacketsFeedback}, + units::{DataRate, DataSize, TimeDelta, Timestamp}, + }, + experiments::FieldTrials, + remote_bitrate_estimator::{AimdRateControl, RateControlInput}, +}; + +pub struct DelayBasedBweResult { + pub updated: bool, + pub probe: bool, + pub target_bitrate: DataRate, + pub recovered_from_overuse: bool, + pub delay_detector_state: BandwidthUsage, +} + +impl Default for DelayBasedBweResult { + fn default() -> Self { + Self { + updated: false, + probe: false, + target_bitrate: DataRate::zero(), + recovered_from_overuse: false, + delay_detector_state: BandwidthUsage::Normal, + } + } +} + +#[derive(Clone)] +pub struct DelayBasedBwe { + trendline_settings: TrendlineEstimatorSettings, + video_inter_arrival_delta: InterArrivalDelta, + video_delay_detector: TrendlineEstimator, + + last_seen_packet: Timestamp, + rate_control: AimdRateControl, + prev_bitrate: DataRate, + prev_state: BandwidthUsage, +} + +impl DelayBasedBwe { + const STREAM_TIME_OUT: TimeDelta = TimeDelta::from_seconds(2); + const SEND_TIME_GROUP_LENGTH: TimeDelta = TimeDelta::from_millis(5); + + // This ssrc is used to fulfill the current API but will be removed + // after the API has been changed. + #[cfg(test)] + const FIXED_SSRC: u32 = 0; + + pub fn new(field_trials: &FieldTrials) -> Self { + Self { + trendline_settings: field_trials.trendline_estimator_settings.clone(), + video_delay_detector: TrendlineEstimator::new( + field_trials.trendline_estimator_settings.clone(), + ), + last_seen_packet: Timestamp::minus_infinity(), + rate_control: AimdRateControl::new(field_trials, true), // send_side + prev_bitrate: DataRate::zero(), + prev_state: BandwidthUsage::Normal, + + // Note: Not initialized in C++ but this avoids needing an Option + video_inter_arrival_delta: InterArrivalDelta::new(Self::SEND_TIME_GROUP_LENGTH), + } + } + + fn active_delay_detector_state(&self) -> BandwidthUsage { + self.video_delay_detector.state() + } + + /// `packet_feedback_vector` is `msg.sorted_by_receive_time()`, which the caller already has. + pub fn incoming_packet_feedback_vector( + &mut self, + msg: &TransportPacketsFeedback, + packet_feedback_vector: &[PacketResult], + acked_bitrate: Option, + probe_bitrate: Option, + in_alr: bool, + ) -> DelayBasedBweResult { + // TODO(holmer): An empty feedback vector here likely means that + // all acks were too late and that the send time history had + // timed out. We should reduce the rate when this occurs. + if packet_feedback_vector.is_empty() { + tracing::debug!("Very late feedback received."); + return DelayBasedBweResult::default(); + } + + let mut delayed_feedback: bool = true; + let mut recovered_from_overuse: bool = false; + let mut prev_detector_state: BandwidthUsage = self.active_delay_detector_state(); + for packet_feedback in packet_feedback_vector { + delayed_feedback = false; + self.incoming_packet_feedback(packet_feedback, msg.feedback_time); + if prev_detector_state == BandwidthUsage::Underusing + && self.active_delay_detector_state() == BandwidthUsage::Normal + { + recovered_from_overuse = true; + } + prev_detector_state = self.active_delay_detector_state(); + } + + if delayed_feedback { + // TODO(bugs.webrtc.org/10125): Design a better mechanism to safe-guard + // against building very large network queues. + return DelayBasedBweResult::default(); + } + self.rate_control.set_in_application_limited_region(in_alr); + self.maybe_update_estimate( + acked_bitrate, + probe_bitrate, + recovered_from_overuse, + in_alr, + msg.feedback_time, + ) + } + + pub fn on_rtt_update(&mut self, avg_rtt: TimeDelta) { + self.rate_control.set_rtt(avg_rtt); + } + + #[cfg(test)] + pub fn latest_estimate(&self, ssrcs: &mut Vec, bitrate: &mut DataRate) -> bool { + // Currently accessed from both the process thread (see + // ModuleRtpRtcpImpl::Process()) and the configuration thread (see + // Call::GetStats()). Should in the future only be accessed from a single + // thread. + if !self.rate_control.valid_estimate() { + return false; + } + + *ssrcs = vec![Self::FIXED_SSRC]; + *bitrate = self.rate_control.latest_estimate(); + true + } + + pub fn set_start_bitrate(&mut self, start_bitrate: DataRate) { + tracing::debug!("BWE Setting start bitrate to: {:?}", start_bitrate); + self.rate_control.set_start_bitrate(start_bitrate); + } + pub fn set_min_bitrate(&mut self, min_bitrate: DataRate) { + // Called from both the configuration thread and the network thread. Shouldn't + // be called from the network thread in the future. + self.rate_control.set_min_bitrate(min_bitrate); + } + + /* unused? + pub fn trigger_overuse( + &mut self, + at_time: Timestamp, + link_capacity: Option, + ) -> DataRate { + let input = RateControlInput::new(BandwidthUsage::Overusing, link_capacity); + self.rate_control.update(input, at_time) + } + */ + + pub fn last_estimate(&self) -> DataRate { + self.prev_bitrate + } + + pub fn last_state(&self) -> BandwidthUsage { + self.prev_state + } + + fn incoming_packet_feedback(&mut self, packet_feedback: &PacketResult, at_time: Timestamp) { + // Reset if the stream has timed out. + if self.last_seen_packet.is_infinite() + || at_time - self.last_seen_packet > Self::STREAM_TIME_OUT + { + self.video_inter_arrival_delta = InterArrivalDelta::new(Self::SEND_TIME_GROUP_LENGTH); + self.video_delay_detector = TrendlineEstimator::new(self.trendline_settings.clone()); + } + self.last_seen_packet = at_time; + + let packet_size: DataSize = packet_feedback.sent_packet.size; + + let mut send_delta: TimeDelta = TimeDelta::zero(); + let mut recv_delta: TimeDelta = TimeDelta::zero(); + let mut size_delta: i64 = 0; + + let calculated_deltas: bool = self.video_inter_arrival_delta.compute_deltas( + packet_feedback.sent_packet.send_time, + packet_feedback.receive_time, + at_time, + packet_size.bytes() as usize, + &mut send_delta, + &mut recv_delta, + &mut size_delta, + ); + + self.video_delay_detector.update( + recv_delta.ms_float(), + send_delta.ms_float(), + packet_feedback.sent_packet.send_time.ms(), + packet_feedback.receive_time.ms(), + packet_size.bytes() as usize, + calculated_deltas, + ); + } + + fn maybe_update_estimate( + &mut self, + acked_bitrate: Option, + probe_bitrate: Option, + recovered_from_overuse: bool, + _in_alr: bool, + at_time: Timestamp, + ) -> DelayBasedBweResult { + let mut result = DelayBasedBweResult::default(); + + // Currently overusing the bandwidth. + if self.active_delay_detector_state() == BandwidthUsage::Overusing { + if let Some(acked_bitrate) = acked_bitrate { + if self + .rate_control + .time_to_reduce_further(at_time, acked_bitrate) + { + result.updated = self.update_estimate( + at_time, + Some(acked_bitrate), + &mut result.target_bitrate, + ); + } + } else if self.rate_control.valid_estimate() + && self.rate_control.initial_time_to_reduce_further(at_time) + { + // Overusing before we have a measured acknowledged bitrate. Reduce send + // rate by 50% every 200 ms. + // TODO(tschumim): Improve this and/or the acknowledged bitrate estimator + // so that we (almost) always have a bitrate estimate. + self.rate_control + .set_estimate(self.rate_control.latest_estimate() / 2, at_time); + result.updated = true; + result.probe = false; + result.target_bitrate = self.rate_control.latest_estimate(); + } + } else if let Some(probe_bitrate) = probe_bitrate { + result.probe = true; + result.updated = true; + self.rate_control.set_estimate(probe_bitrate, at_time); + result.target_bitrate = self.rate_control.latest_estimate(); + } else { + result.updated = + self.update_estimate(at_time, acked_bitrate, &mut result.target_bitrate); + result.recovered_from_overuse = recovered_from_overuse; + } + let detector_state: BandwidthUsage = self.active_delay_detector_state(); + if (result.updated && self.prev_bitrate != result.target_bitrate) + || detector_state != self.prev_state + { + let bitrate: DataRate = if result.updated { + result.target_bitrate + } else { + self.prev_bitrate + }; + + self.prev_bitrate = bitrate; + self.prev_state = detector_state; + } + + result.delay_detector_state = detector_state; + result + } + + // Updates the current remote rate estimate and returns true if a valid + // estimate exists. + fn update_estimate( + &mut self, + at_time: Timestamp, + acked_bitrate: Option, + target_rate: &mut DataRate, + ) -> bool { + let input = RateControlInput::new(self.active_delay_detector_state(), acked_bitrate); + *target_rate = self.rate_control.update(input, at_time); + self.rate_control.valid_estimate() + } +} + +#[cfg(test)] +mod test { + use crate::gcc::api::transport::{PacedPacketInfo, SentPacket}; + use crate::gcc::{ProbeBitrateEstimator, RobustThroughputEstimator}; + + pub use super::*; + + const DEFAULT_SSRC: u32 = 0; + const MTU: usize = 1200; + const ACCEPTED_BITRATE_ERROR_BPS: u32 = 50000; + + // Number of packets needed before we have a valid estimate. + const NUM_INITIAL_PACKETS: i64 = 2; + + const INITIAL_PROBING_PACKETS: i64 = 5; + + pub struct TestBitrateObserver { + updated: bool, + latest_bitrate: u32, + } + + impl TestBitrateObserver { + pub fn new() -> Self { + Self { + updated: false, + latest_bitrate: 0, + } + } + + pub fn on_receive_bitrate_changed(&mut self, bitrate: u32) { + self.latest_bitrate = bitrate; + self.updated = true; + } + + pub fn reset(&mut self) { + self.updated = false; + } + + pub fn updated(&self) -> bool { + self.updated + } + + pub fn latest_bitrate(&self) -> u32 { + self.latest_bitrate + } + } + + pub struct RtpStream { + fps: i64, + bitrate_bps: i64, + next_rtp_time: i64, + } + + impl RtpStream { + const SEND_SIDE_OFFSET_US: i64 = 1000000; + + pub fn new(fps: i64, bitrate_bps: i64) -> Self { + assert!(fps > 0); + Self { + fps, + bitrate_bps, + next_rtp_time: 0, + } + } + + // Generates a new frame for this stream. If called too soon after the + // previous frame, no frame will be generated. The frame is split into + // packets. + pub fn generate_frame(&mut self, time_now_us: i64, packets: &mut Vec) -> i64 { + if time_now_us < self.next_rtp_time { + return self.next_rtp_time; + } + let bits_per_frame: usize = ((self.bitrate_bps + self.fps / 2) / self.fps) as usize; + let n_packets: usize = std::cmp::max((bits_per_frame + 4 * MTU) / (8 * MTU), 1); + let payload_size: usize = (bits_per_frame + 4 * n_packets) / (8 * n_packets); + for _ in 0..n_packets { + let mut packet = PacketResult::default(); + packet.sent_packet.send_time = + Timestamp::from_micros(time_now_us + Self::SEND_SIDE_OFFSET_US); + packet.sent_packet.size = DataSize::from_bytes(payload_size as _); + packets.push(packet); + } + self.next_rtp_time = time_now_us + (1000000 + self.fps / 2) / self.fps; + self.next_rtp_time + } + + // The send-side time when the next frame can be generated. + pub fn next_rtp_time(&self) -> i64 { + self.next_rtp_time + } + + pub fn set_bitrate_bps(&mut self, bitrate_bps: i64) { + assert!(bitrate_bps >= 0); + self.bitrate_bps = bitrate_bps; + } + + pub fn bitrate_bps(&self) -> i64 { + self.bitrate_bps + } + } + + pub struct StreamGenerator { + // Capacity of the simulated channel in bits per second. + capacity: i64, + // The time when the last packet arrived. + prev_arrival_time_us: i64, + // All streams being transmitted on this simulated channel. + streams: Vec, + } + + impl StreamGenerator { + pub fn new(capacity: i64, time_now: i64) -> Self { + Self { + capacity, + prev_arrival_time_us: time_now, + streams: Vec::new(), + } + } + + // Add a new stream. + pub fn add_stream(&mut self, stream: RtpStream) { + self.streams.push(stream); + } + + // Set the link capacity. + pub fn set_capacity_bps(&mut self, capacity_bps: i64) { + assert!(capacity_bps > 0); + self.capacity = capacity_bps; + } + + // Divides `bitrate_bps` among all streams. The allocated bitrate per stream + // is decided by the initial allocation ratios. + pub fn set_bitrate_bps(&mut self, bitrate_bps: i64) { + let mut total_bitrate_before: i64 = 0; + for stream in &self.streams { + total_bitrate_before += stream.bitrate_bps(); + } + let mut bitrate_before: i64 = 0; + let mut total_bitrate_after: i64 = 0; + for stream in &mut self.streams { + bitrate_before += stream.bitrate_bps(); + let bitrate_after: i64 = (bitrate_before * bitrate_bps + total_bitrate_before / 2) + / total_bitrate_before; + stream.set_bitrate_bps(bitrate_after - total_bitrate_after); + total_bitrate_after += stream.bitrate_bps(); + } + assert_eq!(bitrate_before, total_bitrate_before); + assert_eq!(total_bitrate_after, bitrate_bps); + } + + // TODO(holmer): Break out the channel simulation part from this class to make + // it possible to simulate different types of channels. + pub fn generate_frame(&mut self, time_now_us: i64, packets: &mut Vec) -> i64 { + assert!(packets.is_empty()); + assert!(self.capacity > 0); + let it = self + .streams + .iter_mut() + .min_by_key(|x| x.next_rtp_time()) + .unwrap(); + it.generate_frame(time_now_us, packets); + for packet in packets { + let capacity_bpus: i64 = self.capacity / 1000; + let required_network_time_us: i64 = (8 * 1000 * packet.sent_packet.size.bytes() + + capacity_bpus / 2) + / capacity_bpus; + self.prev_arrival_time_us = std::cmp::max( + time_now_us + required_network_time_us, + self.prev_arrival_time_us + required_network_time_us, + ); + packet.receive_time = Timestamp::from_micros(self.prev_arrival_time_us); + } + let it = self + .streams + .iter() + .min_by_key(|x| x.next_rtp_time()) + .unwrap(); + it.next_rtp_time().max(time_now_us) + } + } + + pub struct DelayBasedBweTest { + clock: Timestamp, // Time at the receiver. + bitrate_observer: TestBitrateObserver, + acknowledged_bitrate_estimator: RobustThroughputEstimator, + probe_bitrate_estimator: ProbeBitrateEstimator, + bitrate_estimator: DelayBasedBwe, + stream_generator: StreamGenerator, + arrival_time_offset_ms: i64, + first_update: bool, + } + + impl DelayBasedBweTest { + pub fn new() -> Self { + let field_trials = FieldTrials::default(); + let clock = Timestamp::from_micros(100000000); + + Self { + acknowledged_bitrate_estimator: RobustThroughputEstimator::default(), + probe_bitrate_estimator: ProbeBitrateEstimator::default(), + bitrate_estimator: DelayBasedBwe::new(&field_trials), + stream_generator: StreamGenerator::new( + 1000000, // Capacity. + clock.us(), + ), + arrival_time_offset_ms: 0, + first_update: true, + clock, + bitrate_observer: TestBitrateObserver::new(), + } + } + + pub fn add_default_stream(&mut self) { + self.stream_generator.add_stream(RtpStream::new(30, 300000)); + } + + // Helpers to insert a single packet into the delay-based BWE. + fn incoming_feedback( + &mut self, + arrival_time: Timestamp, + send_time: Timestamp, + payload_size: usize, + pacing_info: PacedPacketInfo, + ) { + let receive_time = arrival_time + TimeDelta::from_millis(self.arrival_time_offset_ms); + + let packet = PacketResult { + receive_time, + ambiguous_receive_time: false, + sent_packet: SentPacket { + send_time, + size: DataSize::from_bytes(payload_size as _), + pacing_info, + ..Default::default() + }, + }; + + if packet.sent_packet.pacing_info.probe_cluster_id != PacedPacketInfo::NOT_APROBE { + self.probe_bitrate_estimator + .handle_probe_and_estimate_bitrate(&packet); + } + + let msg = TransportPacketsFeedback { + feedback_time: Timestamp::from_millis(self.clock.ms()), + packet_feedbacks: vec![packet], + ..Default::default() + }; + let sorted = msg.sorted_by_receive_time(); + self.acknowledged_bitrate_estimator + .incoming_packet_feedback(&sorted); + let result = self.bitrate_estimator.incoming_packet_feedback_vector( + &msg, + &sorted, + self.acknowledged_bitrate_estimator.bitrate(), + self.probe_bitrate_estimator + .fetch_and_reset_last_estimated_bitrate(), + /*in_alr*/ false, + ); + if result.updated { + self.bitrate_observer + .on_receive_bitrate_changed(result.target_bitrate.bps() as _); + } + } + + // Generates a frame of packets belonging to a stream at a given bitrate and + // with a given ssrc. The stream is pushed through a very simple simulated + // network, and is then given to the receive-side bandwidth estimator. + // Returns true if an over-use was seen, false otherwise. + // The StreamGenerator::updated() should be used to check for any changes in + // target bitrate after the call to this function. + fn generate_and_process_frame(&mut self, _ssrc: u32, bitrate_bps: u32) -> bool { + self.stream_generator.set_bitrate_bps(bitrate_bps as _); + let mut packets: Vec = Vec::new(); + + let next_time_us: i64 = self + .stream_generator + .generate_frame(self.clock.us(), &mut packets); + if packets.is_empty() { + return false; + } + + let mut overuse: bool = false; + self.bitrate_observer.reset(); + self.clock = packets.last().unwrap().receive_time; + for packet in &mut packets { + assert!(packet.receive_time.ms() + self.arrival_time_offset_ms >= 0); + packet.receive_time += TimeDelta::from_millis(self.arrival_time_offset_ms); + + if packet.sent_packet.pacing_info.probe_cluster_id != PacedPacketInfo::NOT_APROBE { + self.probe_bitrate_estimator + .handle_probe_and_estimate_bitrate(packet); + } + } + + self.acknowledged_bitrate_estimator + .incoming_packet_feedback(&packets); + let msg = TransportPacketsFeedback { + packet_feedbacks: packets, + feedback_time: self.clock, + ..Default::default() + }; + + let sorted = msg.sorted_by_receive_time(); + let result: DelayBasedBweResult = + self.bitrate_estimator.incoming_packet_feedback_vector( + &msg, + &sorted, + self.acknowledged_bitrate_estimator.bitrate(), + self.probe_bitrate_estimator + .fetch_and_reset_last_estimated_bitrate(), + /*in_alr*/ false, + ); + if result.updated { + self.bitrate_observer + .on_receive_bitrate_changed(result.target_bitrate.bps() as _); + if !self.first_update && result.target_bitrate.bps() < bitrate_bps as _ { + overuse = true; + } + self.first_update = false; + } + + self.clock = Timestamp::from_micros(next_time_us); + overuse + } + + // Run the bandwidth estimator with a stream of `number_of_frames` frames, or + // until it reaches `target_bitrate`. + // Can for instance be used to run the estimator for some time to get it + // into a steady state. + fn steady_state_run( + &mut self, + ssrc: u32, + number_of_frames: i64, + start_bitrate: u32, + min_bitrate: u32, + max_bitrate: u32, + target_bitrate: u32, + ) -> u32 { + let mut bitrate_bps: u32 = start_bitrate; + let mut bitrate_update_seen: bool = false; + // Produce `number_of_frames` frames and give them to the estimator. + for _ in 0..number_of_frames { + let overuse: bool = self.generate_and_process_frame(ssrc, bitrate_bps); + if overuse { + assert!(self.bitrate_observer.latest_bitrate() < max_bitrate); + assert!(self.bitrate_observer.latest_bitrate() > min_bitrate); + bitrate_bps = self.bitrate_observer.latest_bitrate(); + bitrate_update_seen = true; + } else if self.bitrate_observer.updated() { + bitrate_bps = self.bitrate_observer.latest_bitrate(); + self.bitrate_observer.reset(); + } + if bitrate_update_seen && bitrate_bps > target_bitrate { + break; + } + } + assert!(bitrate_update_seen); + bitrate_bps + } + + fn test_timestamp_grouping_test_helper(&mut self) { + const FRAMERATE: i64 = 50; // 50 fps to avoid rounding errors. + const FRAME_INTERVAL_MS: i64 = 1000 / FRAMERATE; + let mut send_time_ms: i64 = 0; + // Initial set of frames to increase the bitrate. 6 seconds to have enough + // time for the first estimate to be generated and for Process() to be called. + for _ in 0..=(6 * FRAMERATE) { + self.incoming_feedback( + self.clock, + Timestamp::from_millis(send_time_ms), + 1000, + Default::default(), + ); + + self.clock += TimeDelta::from_millis(FRAME_INTERVAL_MS as _); + send_time_ms += FRAME_INTERVAL_MS; + } + assert!(self.bitrate_observer.updated()); + assert!(self.bitrate_observer.latest_bitrate() >= 400000); + + // Insert batches of frames which were sent very close in time. Also simulate + // capacity over-use to see that we back off correctly. + const TIMESTAMP_GROUP_LENGTH: i64 = 15; + for _ in 0..100 { + for _ in 0..TIMESTAMP_GROUP_LENGTH { + // Insert `kTimestampGroupLength` frames with just 1 timestamp ticks in + // between. Should be treated as part of the same group by the estimator. + self.incoming_feedback( + self.clock, + Timestamp::from_millis(send_time_ms), + 100, + Default::default(), + ); + self.clock += + TimeDelta::from_millis(FRAME_INTERVAL_MS / TIMESTAMP_GROUP_LENGTH); + send_time_ms += 1; + } + // Increase time until next batch to simulate over-use. + self.clock += TimeDelta::from_millis(10); + send_time_ms += FRAME_INTERVAL_MS - TIMESTAMP_GROUP_LENGTH; + } + assert!(self.bitrate_observer.updated()); + // Should have reduced the estimate. + assert!(self.bitrate_observer.latest_bitrate() < 400000); + } + + fn test_wrapping_helper(&mut self, silence_time_s: i64) { + const FRAMERATE: i64 = 100; + const FRAME_INTERVAL_MS: i64 = 1000 / FRAMERATE; + let mut send_time_ms: i64 = 0; + + for _ in 0..3000 { + self.incoming_feedback( + self.clock, + Timestamp::from_millis(send_time_ms), + 1000, + Default::default(), + ); + self.clock += TimeDelta::from_millis(FRAME_INTERVAL_MS); + send_time_ms += FRAME_INTERVAL_MS; + } + let mut bitrate_before: DataRate = DataRate::zero(); + let mut ssrcs = Vec::new(); + self.bitrate_estimator + .latest_estimate(&mut ssrcs, &mut bitrate_before); + + self.clock += TimeDelta::from_millis(silence_time_s * 1000); + send_time_ms += silence_time_s * 1000; + + for _ in 0..24 { + self.incoming_feedback( + self.clock, + Timestamp::from_millis(send_time_ms), + 1000, + Default::default(), + ); + self.clock += TimeDelta::from_millis(2 * FRAME_INTERVAL_MS); + send_time_ms += FRAME_INTERVAL_MS; + } + let mut bitrate_after: DataRate = DataRate::zero(); + self.bitrate_estimator + .latest_estimate(&mut ssrcs, &mut bitrate_after); + assert!(bitrate_after < bitrate_before); + } + + fn initial_behavior_test_helper(&mut self, expected_converge_bitrate: u32) { + const FRAMERATE: i64 = 50; // 50 fps to avoid rounding errors. + const FRAME_INTERVAL_MS: i64 = 1000 / FRAMERATE; + const PACING_INFO: PacedPacketInfo = PacedPacketInfo::new(0, 5, 5000); + let mut bitrate: DataRate = DataRate::zero(); + let mut send_time_ms: i64 = 0; + let mut ssrcs: Vec = Vec::new(); + assert!( + !self + .bitrate_estimator + .latest_estimate(&mut ssrcs, &mut bitrate) + ); + assert_eq!(0, ssrcs.len()); + self.clock += TimeDelta::from_millis(1000); + assert!( + !self + .bitrate_estimator + .latest_estimate(&mut ssrcs, &mut bitrate) + ); + assert!(!self.bitrate_observer.updated()); + self.bitrate_observer.reset(); + self.clock += TimeDelta::from_millis(1000); + // Inserting packets for 5 seconds to get a valid estimate. + for i in 0..(5 * FRAMERATE + 1 + NUM_INITIAL_PACKETS) { + // NOTE!!! If the following line is moved under the if case then this test + // wont work on windows realease bots. + let pacing_info: PacedPacketInfo = if i < INITIAL_PROBING_PACKETS { + PACING_INFO + } else { + PacedPacketInfo::default() + }; + + if i == NUM_INITIAL_PACKETS { + assert!( + !self + .bitrate_estimator + .latest_estimate(&mut ssrcs, &mut bitrate) + ); + assert_eq!(0, ssrcs.len()); + assert!(!self.bitrate_observer.updated()); + self.bitrate_observer.reset(); + } + self.incoming_feedback( + self.clock, + Timestamp::from_millis(send_time_ms), + MTU, + pacing_info, + ); + self.clock += TimeDelta::from_millis(1000 / FRAMERATE); + send_time_ms += FRAME_INTERVAL_MS; + } + assert!( + self.bitrate_estimator + .latest_estimate(&mut ssrcs, &mut bitrate) + ); + assert_eq!(1, ssrcs.len()); + assert_eq!(&DEFAULT_SSRC, ssrcs.first().unwrap()); + assert_relative_eq!( + expected_converge_bitrate as f64, + bitrate.bps_float(), + epsilon = ACCEPTED_BITRATE_ERROR_BPS as f64 + ); + assert!(self.bitrate_observer.updated()); + self.bitrate_observer.reset(); + assert_eq!(self.bitrate_observer.latest_bitrate() as i64, bitrate.bps()); + } + + fn rate_increase_reordering_test_helper(&mut self, expected_bitrate_bps: u32) { + const FRAMERATE: i64 = 50; // 50 fps to avoid rounding errors. + const FRAME_INTERVAL_MS: i64 = 1000 / FRAMERATE; + const PACING_INFO: PacedPacketInfo = PacedPacketInfo::new(0, 5, 5000); + let mut send_time_ms: i64 = 0; + // Inserting packets for five seconds to get a valid estimate. + for i in 0..(5 * FRAMERATE + 1 + NUM_INITIAL_PACKETS) { + // NOTE!!! If the following line is moved under the if case then this test + // wont work on windows realease bots. + let pacing_info: PacedPacketInfo = if i < INITIAL_PROBING_PACKETS { + PACING_INFO + } else { + PacedPacketInfo::default() + }; + + // TODO(sprang): Remove this hack once the single stream estimator is gone, + // as it doesn't do anything in Process(). + if i == NUM_INITIAL_PACKETS { + // Process after we have enough frames to get a valid input rate estimate. + + assert!(!self.bitrate_observer.updated()); // No valid estimate. + } + self.incoming_feedback( + self.clock, + Timestamp::from_millis(send_time_ms), + MTU, + pacing_info, + ); + self.clock += TimeDelta::from_millis(FRAME_INTERVAL_MS); + send_time_ms += FRAME_INTERVAL_MS; + } + assert!(self.bitrate_observer.updated()); + assert_relative_eq!( + expected_bitrate_bps as f64, + self.bitrate_observer.latest_bitrate() as f64, + epsilon = ACCEPTED_BITRATE_ERROR_BPS as f64 + ); + for _ in 0..10 { + self.clock += TimeDelta::from_millis(2 * FRAME_INTERVAL_MS); + send_time_ms += 2 * FRAME_INTERVAL_MS; + self.incoming_feedback( + self.clock, + Timestamp::from_millis(send_time_ms), + 1000, + Default::default(), + ); + self.incoming_feedback( + self.clock, + Timestamp::from_millis(send_time_ms - FRAME_INTERVAL_MS), + 1000, + Default::default(), + ); + } + assert!(self.bitrate_observer.updated()); + assert_relative_eq!( + expected_bitrate_bps as f64, + self.bitrate_observer.latest_bitrate() as f64, + epsilon = ACCEPTED_BITRATE_ERROR_BPS as f64 + ); + } + fn rate_increase_rtp_timestamps_test_helper(&mut self, expected_iterations: i64) { + // This threshold corresponds approximately to increasing linearly with + // bitrate(i) = 1.04 * bitrate(i-1) + 1000 + // until bitrate(i) > 500000, with bitrate(1) ~= 30000. + let mut bitrate_bps: u32 = 30000; + let mut iterations: i64 = 0; + self.add_default_stream(); + // Feed the estimator with a stream of packets and verify that it reaches + // 500 kbps at the expected time. + while bitrate_bps < 500000 { + let overuse: bool = self.generate_and_process_frame(DEFAULT_SSRC, bitrate_bps); + if overuse { + assert!(self.bitrate_observer.latest_bitrate() > bitrate_bps); + bitrate_bps = self.bitrate_observer.latest_bitrate(); + self.bitrate_observer.reset(); + } else if self.bitrate_observer.updated() { + bitrate_bps = self.bitrate_observer.latest_bitrate(); + self.bitrate_observer.reset(); + } + iterations += 1; + } + assert_eq!(expected_iterations, iterations); + } + fn capacity_drop_test_helper( + &mut self, + number_of_streams: i64, + _wrap_time_stamp: bool, + expected_bitrate_drop_delta: u32, + receiver_clock_offset_change_ms: i64, + ) { + const FRAMERATE: i64 = 30; + const START_BITRATE: i64 = 900000; + const MIN_EXPECTED_BITRATE: i64 = 800000; + const MAX_EXPECTED_BITRATE: i64 = 1100000; + const INITIAL_CAPACITY_BPS: u32 = 1000000; + const REDUCED_CAPACITY_BPS: u32 = 500000; + + let steady_state_time; + if number_of_streams <= 1 { + steady_state_time = 10; + self.add_default_stream(); + } else { + steady_state_time = 10 * number_of_streams; + let mut bitrate_sum: i64 = 0; + let bitrate_denom: i64 = number_of_streams * (number_of_streams - 1); + for i in 0..number_of_streams { + // First stream gets half available bitrate, while the rest share the + // remaining half i.e.: 1/2 = Sum[n/(N*(N-1))] for n=1..N-1 (rounded up) + let mut bitrate: i64 = START_BITRATE / 2; + if i > 0 { + bitrate = (START_BITRATE * i + bitrate_denom / 2) / bitrate_denom; + } + self.stream_generator + .add_stream(RtpStream::new(FRAMERATE, bitrate)); + bitrate_sum += bitrate; + } + assert_eq!(bitrate_sum, START_BITRATE); + } + + // Run in steady state to make the estimator converge. + self.stream_generator + .set_capacity_bps(INITIAL_CAPACITY_BPS as _); + let mut bitrate_bps: u32 = self.steady_state_run( + DEFAULT_SSRC, + steady_state_time * FRAMERATE, + START_BITRATE as _, + MIN_EXPECTED_BITRATE as _, + MAX_EXPECTED_BITRATE as _, + INITIAL_CAPACITY_BPS, + ); + assert_relative_eq!( + INITIAL_CAPACITY_BPS as f64, + bitrate_bps as f64, + epsilon = 180000.0 + ); + self.bitrate_observer.reset(); + + // Add an offset to make sure the BWE can handle it. + self.arrival_time_offset_ms += receiver_clock_offset_change_ms; + + // Reduce the capacity and verify the decrease time. + self.stream_generator + .set_capacity_bps(REDUCED_CAPACITY_BPS as _); + let overuse_start_time: i64 = self.clock.ms(); + let mut bitrate_drop_time: i64 = -1; + for _ in 0..(100 * number_of_streams) { + self.generate_and_process_frame(DEFAULT_SSRC, bitrate_bps); + if bitrate_drop_time == -1 + && self.bitrate_observer.latest_bitrate() <= REDUCED_CAPACITY_BPS + { + bitrate_drop_time = self.clock.ms(); + } + if self.bitrate_observer.updated() { + bitrate_bps = self.bitrate_observer.latest_bitrate(); + } + } + + assert_relative_eq!( + expected_bitrate_drop_delta as f64, + (bitrate_drop_time - overuse_start_time) as f64, + epsilon = 33.0 + ); + } + } + + const NUM_PROBES_CLUSTER0: i64 = 5; + const NUM_PROBES_CLUSTER1: i64 = 8; + const PACING_INFO0: PacedPacketInfo = PacedPacketInfo::new(0, NUM_PROBES_CLUSTER0, 2000); + const PACING_INFO1: PacedPacketInfo = PacedPacketInfo::new(1, NUM_PROBES_CLUSTER1, 4000); + const TARGET_UTILIZATION_FRACTION: f64 = 0.95; + + #[test] + fn probe_detection() { + let mut bwe = DelayBasedBweTest::new(); + + // First burst sent at 8 * 1000 / 10 = 800 kbps. + for _ in 0..NUM_PROBES_CLUSTER0 { + bwe.clock += TimeDelta::from_millis(10); + bwe.incoming_feedback(bwe.clock, bwe.clock, 1000, PACING_INFO0); + } + assert!(bwe.bitrate_observer.updated()); + + // Second burst sent at 8 * 1000 / 5 = 1600 kbps. + for _ in 0..NUM_PROBES_CLUSTER1 { + bwe.clock += TimeDelta::from_millis(5); + bwe.incoming_feedback(bwe.clock, bwe.clock, 1000, PACING_INFO1); + } + + assert!(bwe.bitrate_observer.updated()); + assert!(bwe.bitrate_observer.latest_bitrate() > 1500000); + } + + #[test] + fn probe_detection_non_paced_packets() { + let mut bwe = DelayBasedBweTest::new(); + + // First burst sent at 8 * 1000 / 10 = 800 kbps, but with every other packet + // not being paced which could mess things up. + for _ in 0..NUM_PROBES_CLUSTER0 { + bwe.clock += TimeDelta::from_millis(5); + + bwe.incoming_feedback(bwe.clock, bwe.clock, 1000, PACING_INFO0); + // Non-paced packet, arriving 5 ms after. + bwe.clock += TimeDelta::from_millis(5); + bwe.incoming_feedback(bwe.clock, bwe.clock, 100, PacedPacketInfo::default()); + } + + assert!(bwe.bitrate_observer.updated()); + assert!(bwe.bitrate_observer.latest_bitrate() > 800000); + } + + #[test] + fn probe_detection_faster_arrival() { + let mut bwe = DelayBasedBweTest::new(); + + // First burst sent at 8 * 1000 / 10 = 800 kbps. + // Arriving at 8 * 1000 / 5 = 1600 kbps. + let mut send_time_ms: Timestamp = Timestamp::zero(); + for _ in 0..NUM_PROBES_CLUSTER0 { + bwe.clock += TimeDelta::from_millis(1); + send_time_ms += TimeDelta::from_millis(10); + + bwe.incoming_feedback(bwe.clock, send_time_ms, 1000, PACING_INFO0); + } + + assert!(!bwe.bitrate_observer.updated()); + } + + #[test] + fn probe_detection_slower_arrival() { + let mut bwe = DelayBasedBweTest::new(); + + // First burst sent at 8 * 1000 / 5 = 1600 kbps. + // Arriving at 8 * 1000 / 7 = 1142 kbps. + // Since the receive rate is significantly below the send rate, we expect to + // use 95% of the estimated capacity. + let mut send_time_ms: Timestamp = Timestamp::zero(); + for _ in 0..NUM_PROBES_CLUSTER1 { + bwe.clock += TimeDelta::from_millis(7); + send_time_ms += TimeDelta::from_millis(5); + + bwe.incoming_feedback(bwe.clock, send_time_ms, 1000, PACING_INFO1); + } + + assert!(bwe.bitrate_observer.updated()); + assert_relative_eq!( + bwe.bitrate_observer.latest_bitrate() as f64, + { TARGET_UTILIZATION_FRACTION * 1140000.0 }, + epsilon = 10000.0 + ); + } + + #[test] + fn probe_detection_slower_arrival_high_bitrate() { + let mut bwe = DelayBasedBweTest::new(); + + // Burst sent at 8 * 1000 / 1 = 8000 kbps. + // Arriving at 8 * 1000 / 2 = 4000 kbps. + // Since the receive rate is significantly below the send rate, we expect to + // use 95% of the estimated capacity. + let mut send_time_ms: Timestamp = Timestamp::zero(); + for _ in 0..NUM_PROBES_CLUSTER1 { + bwe.clock += TimeDelta::from_millis(2); + send_time_ms += TimeDelta::from_millis(1); + + bwe.incoming_feedback(bwe.clock, send_time_ms, 1000, PACING_INFO1); + } + + assert!(bwe.bitrate_observer.updated()); + assert_relative_eq!( + bwe.bitrate_observer.latest_bitrate() as f64, + TARGET_UTILIZATION_FRACTION * 4000000.0, + epsilon = 10000.0 + ); + } + + #[test] + fn initial_behavior() { + let mut bwe = DelayBasedBweTest::new(); + bwe.initial_behavior_test_helper(730000); + } + + #[test] + fn initialize_result() { + let result = DelayBasedBweResult::default(); + assert_eq!(result.delay_detector_state, BandwidthUsage::Normal); + } + + #[test] + fn rate_increase_reordering() { + let mut bwe = DelayBasedBweTest::new(); + bwe.rate_increase_reordering_test_helper(730000); + } + #[test] + fn rate_increase_rtp_timestamps() { + let mut bwe = DelayBasedBweTest::new(); + bwe.rate_increase_rtp_timestamps_test_helper(617); + } + + #[test] + fn capacity_drop_one_stream() { + let mut bwe = DelayBasedBweTest::new(); + bwe.capacity_drop_test_helper(1, false, 500, 0); + } + + #[test] + fn capacity_drop_pos_offset_change() { + let mut bwe = DelayBasedBweTest::new(); + bwe.capacity_drop_test_helper(1, false, 867, 30000); + } + + #[test] + fn capacity_drop_neg_offset_change() { + let mut bwe = DelayBasedBweTest::new(); + bwe.capacity_drop_test_helper(1, false, 933, -30000); + } + + #[test] + fn capacity_drop_one_stream_wrap() { + let mut bwe = DelayBasedBweTest::new(); + bwe.capacity_drop_test_helper(1, true, 533, 0); + } + + #[test] + fn test_timestamp_grouping() { + let mut bwe = DelayBasedBweTest::new(); + bwe.test_timestamp_grouping_test_helper(); + } + + #[test] + fn test_short_timeout_and_wrap() { + let mut bwe = DelayBasedBweTest::new(); + // Simulate a client leaving and rejoining the call after 35 seconds. This + // will make abs send time wrap, so if streams aren't timed out properly + // the next 30 seconds of packets will be out of order. + bwe.test_wrapping_helper(35); + } + + #[test] + fn test_long_timeout_and_wrap() { + let mut bwe = DelayBasedBweTest::new(); + // Simulate a client leaving and rejoining the call after some multiple of + // 64 seconds later. This will cause a zero difference in abs send times due + // to the wrap, but a big difference in arrival time, if streams aren't + // properly timed out. + bwe.test_wrapping_helper(10 * 64); + } + + #[test] + fn test_initial_overuse() { + let mut bwe = DelayBasedBweTest::new(); + const START_BITRATE: DataRate = DataRate::from_kilobits_per_sec(300); + const INITIAL_CAPACITY: DataRate = DataRate::from_kilobits_per_sec(200); + const DUMMY_SSRC: u32 = 0; + // High FPS to ensure that we send a lot of packets in a short time. + const FPS: i64 = 90; + + bwe.stream_generator + .add_stream(RtpStream::new(FPS, START_BITRATE.bps())); + bwe.stream_generator + .set_capacity_bps(INITIAL_CAPACITY.bps()); + + // Needed to initialize the AimdRateControl. + bwe.bitrate_estimator.set_start_bitrate(START_BITRATE); + + // Produce 40 frames (in 1/3 second) and give them to the estimator. + let mut bitrate_bps: i64 = START_BITRATE.bps(); + let mut seen_overuse: bool = false; + for _ in 0..40 { + let overuse: bool = bwe.generate_and_process_frame(DUMMY_SSRC, bitrate_bps as _); + if overuse { + assert!(bwe.bitrate_observer.updated()); + assert!(bwe.bitrate_observer.latest_bitrate() as i64 <= INITIAL_CAPACITY.bps()); + assert!( + bwe.bitrate_observer.latest_bitrate() as f64 + > 0.8 * INITIAL_CAPACITY.bps_float() + ); + //bitrate_bps = bwe.bitrate_observer.latest_bitrate() as _; + seen_overuse = true; + break; + } else if bwe.bitrate_observer.updated() { + bitrate_bps = bwe.bitrate_observer.latest_bitrate() as _; + bwe.bitrate_observer.reset(); + } + } + assert!(seen_overuse); + assert!(bwe.bitrate_observer.latest_bitrate() as i64 <= INITIAL_CAPACITY.bps()); + assert!(bwe.bitrate_observer.latest_bitrate() as f64 > 0.8 * INITIAL_CAPACITY.bps_float()); + } + + #[test] + fn test_timestamp_precision_handling() { + let mut bwe = DelayBasedBweTest::new(); + // This test does some basic checks to make sure that timestamps with higher + // than millisecond precision are handled properly and do not cause any + // problems in the estimator. Specifically, previously reported in + // webrtc:14023 and described in more details there, the rounding to the + // nearest milliseconds caused discrepancy in the accumulated delay. This lead + // to false-positive overuse detection. + // Technical details of the test: + // Send times(ms): 0.000, 9.725, 20.000, 29.725, 40.000, 49.725, ... + // Recv times(ms): 0.500, 10.000, 20.500, 30.000, 40.500, 50.000, ... + // Send deltas(ms): 9.750, 10.250, 9.750, 10.250, 9.750, ... + // Recv deltas(ms): 9.500, 10.500, 9.500, 10.500, 9.500, ... + // There is no delay building up between the send times and the receive times, + // therefore this case should never lead to an overuse detection. However, if + // the time deltas were accidentally rounded to the nearest milliseconds, then + // all the send deltas would be equal to 10ms while some recv deltas would + // round up to 11ms which would lead in a false illusion of delay build up. + let mut last_bitrate: u32 = bwe.bitrate_observer.latest_bitrate(); + for _ in 0..1000 { + bwe.clock += TimeDelta::from_micros(500); + bwe.incoming_feedback( + bwe.clock, + bwe.clock - TimeDelta::from_micros(500), + 1000, + PacedPacketInfo::default(), + ); + bwe.clock += TimeDelta::from_micros(9500); + bwe.incoming_feedback( + bwe.clock, + bwe.clock - TimeDelta::from_micros(250), + 1000, + PacedPacketInfo::default(), + ); + bwe.clock += TimeDelta::from_micros(10000); + + // The bitrate should never decrease in this test. + assert!(last_bitrate <= bwe.bitrate_observer.latest_bitrate()); + last_bitrate = bwe.bitrate_observer.latest_bitrate(); + } + } +} // namespace webrtc diff --git a/rs/moq-congestion/src/gcc/delay_increase_detector_interface.rs b/rs/moq-congestion/src/gcc/delay_increase_detector_interface.rs new file mode 100644 index 00000000000..cfb4e72e67e --- /dev/null +++ b/rs/moq-congestion/src/gcc/delay_increase_detector_interface.rs @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::api::transport::BandwidthUsage; + +pub trait DelayIncreaseDetectorInterface { + // Update the detector with a new sample. The deltas should represent deltas + // between timestamp groups as defined by the InterArrival class. + fn update( + &mut self, + recv_delta_ms: f64, + send_delta_ms: f64, + send_time_ms: i64, + arrival_time_ms: i64, + packet_size: usize, + calculated_deltas: bool, + ); + + fn state(&self) -> BandwidthUsage; +} diff --git a/rs/moq-congestion/src/gcc/experiments/field_trials.rs b/rs/moq-congestion/src/gcc/experiments/field_trials.rs new file mode 100644 index 00000000000..c1ab33a1bd0 --- /dev/null +++ b/rs/moq-congestion/src/gcc/experiments/field_trials.rs @@ -0,0 +1,69 @@ +use super::CongestionWindowConfig; + +use crate::gcc::{ + AlrDetectorConfig, LossBasedBweV2Config, ProbeControllerConfig, + RobustThroughputEstimatorSettings, RttBasedBackoffConfig, TrendlineEstimatorSettings, + remote_bitrate_estimator::BweBackOffFactor, +}; + +/// The WebRTC field trials that still reach a code path here, as typed settings. +/// +/// Upstream parses each from a string keyed by the trial's name, given in each field's doc. The +/// defaults are upstream's when no trial string is set. +#[derive(Clone, Debug)] +pub struct FieldTrials { + /// WebRTC-Bwe-RobustThroughputEstimatorSettings + pub robust_throughput_estimator_settings: RobustThroughputEstimatorSettings, + + /// WebRTC-Bwe-ProbingConfiguration + pub probing_configuration: ProbeControllerConfig, + + /// WebRTC-AlrDetectorParameters + pub alr_detector_parameters: AlrDetectorConfig, + + /// WebRTC-AddPacingToCongestionWindowPushback + pub add_pacing_to_congestion_window_pushback: bool, + + /// WebRTC-CongestionWindow + pub congestion_window: CongestionWindowConfig, + + /// WebRTC-Bwe-LossBasedBweV2 + pub loss_based_bwe_v2: LossBasedBweV2Config, + + /// WebRTC-Bwe-MaxRttLimit + pub max_rtt_limit: RttBasedBackoffConfig, + + /// WebRTC-Bwe-TrendlineEstimatorSettings + pub trendline_estimator_settings: TrendlineEstimatorSettings, + + /// WebRTC-BweBackOffFactor + pub bwe_back_off_factor: BweBackOffFactor, + + /// WebRTC-DontIncreaseDelayBasedBweInAlr + pub no_bitrate_increase_in_alr: bool, + + /// WebRTC-Bwe-MinAllocAsLowerBound + pub min_alloc_as_lower_bound: bool, + + /// WebRTC-Bwe-LimitProbesLowerThanThroughputEstimate + pub limit_probes_lower_than_throughput_estimate: bool, +} + +impl Default for FieldTrials { + fn default() -> Self { + Self { + robust_throughput_estimator_settings: RobustThroughputEstimatorSettings::default(), + probing_configuration: ProbeControllerConfig::default(), + alr_detector_parameters: AlrDetectorConfig::default(), + add_pacing_to_congestion_window_pushback: false, + congestion_window: CongestionWindowConfig::default(), + loss_based_bwe_v2: LossBasedBweV2Config::default(), + max_rtt_limit: RttBasedBackoffConfig::default(), + trendline_estimator_settings: TrendlineEstimatorSettings::default(), + bwe_back_off_factor: BweBackOffFactor::default(), + no_bitrate_increase_in_alr: false, + min_alloc_as_lower_bound: true, + limit_probes_lower_than_throughput_estimate: true, + } + } +} diff --git a/rs/moq-congestion/src/gcc/experiments/mod.rs b/rs/moq-congestion/src/gcc/experiments/mod.rs new file mode 100644 index 00000000000..04e51c1d649 --- /dev/null +++ b/rs/moq-congestion/src/gcc/experiments/mod.rs @@ -0,0 +1,5 @@ +mod field_trials; +mod rate_control; + +pub use field_trials::*; +pub use rate_control::*; diff --git a/rs/moq-congestion/src/gcc/experiments/rate_control.rs b/rs/moq-congestion/src/gcc/experiments/rate_control.rs new file mode 100644 index 00000000000..30ba4f80f8d --- /dev/null +++ b/rs/moq-congestion/src/gcc/experiments/rate_control.rs @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2019 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! Upstream's `RateControlSettings` also carries the video encoder settings +//! (`WebRTC-VideoRateControl`), which no congestion controller reads; only the congestion window +//! remains. + +use crate::gcc::{api::units::DataSize, experiments::FieldTrials}; + +/// WebRTC-CongestionWindow +#[derive(Clone, Debug)] +pub struct CongestionWindowConfig { + pub queue_size_ms: i64, // QueueSize + pub min_bitrate_bps: i64, // MinBitrate + pub initial_data_window: Option, // InitWin + pub drop_frame_only: bool, // DropFrame +} + +impl Default for CongestionWindowConfig { + // QueueSize:350,MinBitrate:30000,DropFrame:true + fn default() -> Self { + Self { + queue_size_ms: 350, + min_bitrate_bps: 30000, + initial_data_window: None, + drop_frame_only: true, + } + } +} + +#[derive(Clone, Default)] +pub struct RateControlSettings { + congestion_window_config: CongestionWindowConfig, +} + +impl RateControlSettings { + pub fn new(field_trials: &FieldTrials) -> Self { + Self { + congestion_window_config: field_trials.congestion_window.clone(), + } + } + + /// When CongestionWindowPushback is enabled, the pacer is oblivious to + /// the congestion window. The relation between outstanding data and + /// the congestion window affects encoder allocations directly. + pub fn use_congestion_window(&self) -> bool { + self.congestion_window_config.queue_size_ms > 0 + } + + pub fn get_congestion_window_additional_time_ms(&self) -> i64 { + self.congestion_window_config.queue_size_ms + } + + pub fn use_congestion_window_pushback(&self) -> bool { + self.congestion_window_config.queue_size_ms > 0 + && self.congestion_window_config.min_bitrate_bps > 0 + } + + pub fn use_congestion_window_drop_frame_only(&self) -> bool { + self.congestion_window_config.drop_frame_only + } + + pub fn congestion_window_min_pushback_target_bitrate_bps(&self) -> u32 { + self.congestion_window_config.min_bitrate_bps as _ + } + + pub fn congestion_window_initial_data_window(&self) -> Option { + self.congestion_window_config.initial_data_window + } +} diff --git a/rs/moq-congestion/src/gcc/goog_cc_network_control.rs b/rs/moq-congestion/src/gcc/goog_cc_network_control.rs new file mode 100644 index 00000000000..78a1e499781 --- /dev/null +++ b/rs/moq-congestion/src/gcc/goog_cc_network_control.rs @@ -0,0 +1,970 @@ +/* + * Copyright (c) 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! Upstream's `NetworkControllerInterface` also takes route changes, REMB, RTCP loss reports, +//! received packets, runtime constraint changes, and a network state estimate. A QUIC sender has +//! none of them: a path change rebuilds the controller, and the constraints come once, at +//! construction. What remains is the network becoming available, the process timer, the RTT, +//! each sent packet, each ACK's feedback, and stream settings, which carry the session's probe +//! target as the allocation GoogCC probes toward. + +use std::collections::VecDeque; + +use crate::gcc::{ + AlrDetector, BandwidthLimitedCause, CongestionWindowPushbackController, DelayBasedBwe, + DelayBasedBweResult, LossBasedState, ProbeBitrateEstimator, ProbeController, + RobustThroughputEstimator, SendSideBandwidthEstimation, + api::{ + transport::{ + BandwidthUsage, NetworkAvailability, NetworkControlUpdate, PacedPacketInfo, + PacerConfig, ProbeClusterConfig, ProcessInterval, RoundTripTimeUpdate, SentPacket, + StreamsConfig, TargetRateConstraints, TargetTransferRate, TransportPacketsFeedback, + }, + units::{DataRate, DataSize, TimeDelta, Timestamp}, + }, + experiments::{FieldTrials, RateControlSettings}, + remote_bitrate_estimator::CONGESTION_CONTROLLER_MIN_BITRATE, +}; + +/// Configuration sent to factory create function. The parameters here are +/// optional to use for a network controller implementation. +#[derive(Default, Debug, Clone)] +pub struct NetworkControllerConfig { + pub field_trials: FieldTrials, + + /// The initial constraints to start with. Note that the starting rate + /// has to be set initially to provide a starting state for the network + /// controller, even though the field is marked as optional. + pub constraints: TargetRateConstraints, + /// Initial stream specific configuration. + pub stream_based_config: StreamsConfig, + pub default_pacing_time_window: TimeDelta, +} + +/// Google Congestion Control, as WebRTC's `GoogCcNetworkController`. +#[derive(Clone)] +pub struct GoogCcNetworkController { + use_min_allocatable_as_lower_bound: bool, + limit_probes_lower_than_throughput_estimate: bool, + rate_control_settings: RateControlSettings, + + probe_controller: ProbeController, + congestion_window_pushback_controller: Option, + + bandwidth_estimation: SendSideBandwidthEstimation, + alr_detector: AlrDetector, + probe_bitrate_estimator: ProbeBitrateEstimator, + delay_based_bwe: DelayBasedBwe, + acknowledged_bitrate_estimator: RobustThroughputEstimator, + + initial_config: Option, + + min_target_rate: DataRate, + min_data_rate: DataRate, + max_data_rate: DataRate, + starting_rate: Option, + + first_packet_sent: bool, + first_transport_feedback_received: bool, + + feedback_max_rtts: VecDeque, + + last_loss_based_target_rate: DataRate, + last_pushback_target_rate: DataRate, + last_loss_base_state: LossBasedState, + + last_estimated_round_trip_time: TimeDelta, + + pacing_factor: Option, + pacing_time_window: TimeDelta, + min_total_allocated_bitrate: DataRate, + + previously_in_alr: bool, + last_is_bandwidth_limited: bool, + + current_data_window: Option, +} + +impl GoogCcNetworkController { + // Pacing-rate relative to our target send rate. + // Multiplicative factor that is applied to the target bitrate to calculate + // the number of bytes that can be transmitted per interval. + // Increasing this factor will result in lower delays in cases of bitrate + // overshoots from the encoder. + const DEFAULT_PACE_MULTIPLIER: f64 = 2.5; + // If send side bandwidth estimation is used, this pacing multiplier is used per + // default. + const DEFAULT_PACE_MULTIPLIER_WITH_SEND_SIDE_BWE: f64 = 1.1; + + // If the probe result is far below the current throughput estimate + // it's unlikely that the probe is accurate, so we don't want to drop too far. + // However, if we actually are overusing, we want to drop to something slightly + // below the current throughput estimate to drain the network queues. + const PROBE_DROP_THROUGHPUT_FRACTION: f64 = 0.85; + + pub fn new(config: NetworkControllerConfig) -> Self { + let field_trials = &config.field_trials; + let rate_control_settings = RateControlSettings::new(field_trials); + let starting_rate = config.constraints.starting_rate.expect("a starting rate"); + + let mut delay_based_bwe = DelayBasedBwe::new(field_trials); + delay_based_bwe.set_min_bitrate(CONGESTION_CONTROLLER_MIN_BITRATE); + + Self { + use_min_allocatable_as_lower_bound: field_trials.min_alloc_as_lower_bound, + limit_probes_lower_than_throughput_estimate: field_trials + .limit_probes_lower_than_throughput_estimate, + + probe_controller: ProbeController::new(field_trials.probing_configuration.clone()), + congestion_window_pushback_controller: rate_control_settings + .use_congestion_window_pushback() + .then(|| CongestionWindowPushbackController::new(field_trials)), + rate_control_settings, + bandwidth_estimation: SendSideBandwidthEstimation::new(field_trials), + alr_detector: AlrDetector::new(field_trials.alr_detector_parameters.clone()), + probe_bitrate_estimator: ProbeBitrateEstimator::default(), + delay_based_bwe, + acknowledged_bitrate_estimator: RobustThroughputEstimator::new( + field_trials.robust_throughput_estimator_settings.clone(), + ), + + min_target_rate: DataRate::zero(), + min_data_rate: DataRate::zero(), + max_data_rate: DataRate::plus_infinity(), + starting_rate: config.constraints.starting_rate, + first_packet_sent: false, + first_transport_feedback_received: false, + feedback_max_rtts: VecDeque::new(), + last_loss_based_target_rate: starting_rate, + last_pushback_target_rate: starting_rate, + last_loss_base_state: LossBasedState::DelayBasedEstimate, + last_estimated_round_trip_time: TimeDelta::plus_infinity(), + pacing_factor: config.stream_based_config.pacing_factor, + pacing_time_window: config.default_pacing_time_window, + min_total_allocated_bitrate: config + .stream_based_config + .min_total_allocated_bitrate + .unwrap_or(DataRate::zero()), + previously_in_alr: false, + last_is_bandwidth_limited: true, + current_data_window: None, + + initial_config: Some(config), + } + } + + pub fn on_network_availability(&mut self, msg: NetworkAvailability) -> NetworkControlUpdate { + NetworkControlUpdate { + probe_cluster_configs: self.probe_controller.on_network_availability(msg), + ..Default::default() + } + } + + pub fn on_streams_config(&mut self, msg: StreamsConfig) -> NetworkControlUpdate { + let mut update = NetworkControlUpdate::default(); + if let Some(requests_alr_probing) = msg.requests_alr_probing { + self.probe_controller + .enable_periodic_alr_probing(requests_alr_probing); + } + if let Some(max_total_allocated_bitrate) = msg.max_total_allocated_bitrate { + update.probe_cluster_configs = self + .probe_controller + .on_max_total_allocated_bitrate(max_total_allocated_bitrate, msg.at_time); + } + + let mut pacing_changed = false; + if let Some(pacing_factor) = msg.pacing_factor + && Some(pacing_factor) != self.pacing_factor + { + self.pacing_factor = Some(pacing_factor); + pacing_changed = true; + } + if let Some(min_total_allocated_bitrate) = msg.min_total_allocated_bitrate + && min_total_allocated_bitrate != self.min_total_allocated_bitrate + { + self.min_total_allocated_bitrate = min_total_allocated_bitrate; + pacing_changed = true; + + if self.use_min_allocatable_as_lower_bound { + self.clamp_constraints(); + self.delay_based_bwe.set_min_bitrate(self.min_data_rate); + self.bandwidth_estimation + .set_min_max_bitrate(self.min_data_rate, self.max_data_rate); + } + } + if pacing_changed { + update.pacer_config = Some(self.get_pacing_rates()); + } + update + } + + pub fn on_process_interval(&mut self, msg: ProcessInterval) -> NetworkControlUpdate { + let mut update = NetworkControlUpdate::default(); + if let Some(initial_config) = self.initial_config.take() { + update.probe_cluster_configs = self.reset_constraints(initial_config.constraints); + update.pacer_config = Some(self.get_pacing_rates()); + + if let Some(requests_alr_probing) = + initial_config.stream_based_config.requests_alr_probing + { + self.probe_controller + .enable_periodic_alr_probing(requests_alr_probing); + } + self.probe_controller.enable_repeated_initial_probing( + initial_config + .stream_based_config + .enable_repeated_initial_probing, + ); + if let Some(total_bitrate) = initial_config + .stream_based_config + .max_total_allocated_bitrate + { + let mut probes = self + .probe_controller + .on_max_total_allocated_bitrate(total_bitrate, msg.at_time); + update.probe_cluster_configs.append(&mut probes); + } + } + + if let (Some(congestion_window_pushback_controller), Some(pacer_queue)) = ( + &mut self.congestion_window_pushback_controller, + msg.pacer_queue, + ) { + congestion_window_pushback_controller.update_pacing_queue(pacer_queue.bytes()) + }; + self.bandwidth_estimation.update_estimate(msg.at_time); + // Avoid ALR probing before the first transport feedback is received in + // order to avoid ALR probing if receive side BWE is used. + self.probe_controller + .set_alr_start_time(if self.first_transport_feedback_received { + self.alr_detector + .get_application_limited_region_start_time() + } else { + None + }); + + let mut probes = self.probe_controller.process(msg.at_time); + update.probe_cluster_configs.append(&mut probes); + + if self.rate_control_settings.use_congestion_window() && !self.feedback_max_rtts.is_empty() + { + self.update_congestion_window_size(); + } + match ( + &mut self.congestion_window_pushback_controller, + self.current_data_window, + ) { + (Some(congestion_window_pushback_controller), Some(current_data_window)) => { + congestion_window_pushback_controller.set_data_window(current_data_window) + } + _ => update.congestion_window = self.current_data_window, + }; + + self.maybe_trigger_on_network_changed(&mut update, msg.at_time); + update + } + + pub fn on_round_trip_time_update(&mut self, msg: RoundTripTimeUpdate) { + debug_assert!(!msg.round_trip_time.is_zero()); + self.delay_based_bwe.on_rtt_update(msg.round_trip_time); + self.bandwidth_estimation.update_rtt(msg.round_trip_time); + } + + pub fn on_sent_packet(&mut self, sent_packet: SentPacket) -> NetworkControlUpdate { + self.alr_detector + .on_bytes_sent(sent_packet.size, sent_packet.send_time); + + if !self.first_packet_sent { + self.first_packet_sent = true; + // Initialize feedback time to send time to allow estimation of RTT until + // first feedback is received. + self.bandwidth_estimation + .update_propagation_rtt(sent_packet.send_time, TimeDelta::zero()); + } + self.bandwidth_estimation.on_sent_packet(&sent_packet); + + if let Some(congestion_window_pushback_controller) = + &mut self.congestion_window_pushback_controller + { + congestion_window_pushback_controller + .update_outstanding_data(sent_packet.data_in_flight.bytes()); + let mut update = NetworkControlUpdate::default(); + self.maybe_trigger_on_network_changed(&mut update, sent_packet.send_time); + update + } else { + NetworkControlUpdate::default() + } + } + + pub fn on_transport_packets_feedback( + &mut self, + report: &TransportPacketsFeedback, + ) -> NetworkControlUpdate { + if report.packet_feedbacks.is_empty() { + // TODO(bugs.webrtc.org/10125): Design a better mechanism to safe-guard + // against building very large network queues. + return NetworkControlUpdate::default(); + } + // Send side BWE is used. + self.first_transport_feedback_received = true; + + if let Some(congestion_window_pushback_controller) = + &mut self.congestion_window_pushback_controller + { + congestion_window_pushback_controller + .update_outstanding_data(report.data_in_flight.bytes()); + } + let mut max_feedback_rtt: TimeDelta = TimeDelta::minus_infinity(); + let mut min_propagation_rtt: TimeDelta = TimeDelta::plus_infinity(); + let max_recv_time = report + .received_with_send_info() + .map(|x| x.receive_time) + .max() + .unwrap_or(Timestamp::minus_infinity()); + + for feedback in report.received_with_send_info() { + let feedback_rtt: TimeDelta = report.feedback_time - feedback.sent_packet.send_time; + let min_pending_time: TimeDelta = max_recv_time - feedback.receive_time; + let propagation_rtt: TimeDelta = feedback_rtt - min_pending_time; + max_feedback_rtt = std::cmp::max(max_feedback_rtt, feedback_rtt); + min_propagation_rtt = std::cmp::min(min_propagation_rtt, propagation_rtt); + } + + if max_feedback_rtt.is_finite() { + self.feedback_max_rtts.push_back(max_feedback_rtt.ms()); + const MAX_FEEDBACK_RTT_WINDOW: usize = 32; + if self.feedback_max_rtts.len() > MAX_FEEDBACK_RTT_WINDOW { + self.feedback_max_rtts.pop_front(); + } + // TODO(srte): Use time since last unacknowledged packet. + self.bandwidth_estimation + .update_propagation_rtt(report.feedback_time, min_propagation_rtt); + } + + let alr_start_time: Option = self + .alr_detector + .get_application_limited_region_start_time(); + if self.previously_in_alr && alr_start_time.is_none() { + self.probe_controller + .set_alr_ended_time(report.feedback_time); + } + self.previously_in_alr = alr_start_time.is_some(); + let sorted = report.sorted_by_receive_time(); + self.acknowledged_bitrate_estimator + .incoming_packet_feedback(&sorted); + let acknowledged_bitrate = self.acknowledged_bitrate_estimator.bitrate(); + self.bandwidth_estimation + .set_acknowledged_rate(acknowledged_bitrate); + for feedback in &sorted { + if feedback.sent_packet.pacing_info.probe_cluster_id != PacedPacketInfo::NOT_APROBE { + self.probe_bitrate_estimator + .handle_probe_and_estimate_bitrate(feedback); + } + } + + let mut probe_bitrate: Option = self + .probe_bitrate_estimator + .fetch_and_reset_last_estimated_bitrate(); + + if let (Some(probe), Some(acknowledged)) = (probe_bitrate, acknowledged_bitrate) + && self.limit_probes_lower_than_throughput_estimate + { + // Limit the backoff to something slightly below the acknowledged + // bitrate. ("Slightly below" because we want to drain the queues + // if we are actually overusing.) + // The acknowledged bitrate shouldn't normally be higher than the delay + // based estimate, but it could happen e.g. due to packet bursts or + // encoder overshoot. We use std::cmp::min to ensure that a probe result + // below the current BWE never causes an increase. + let limit: DataRate = std::cmp::min( + self.delay_based_bwe.last_estimate(), + acknowledged * Self::PROBE_DROP_THROUGHPUT_FRACTION, + ); + probe_bitrate = Some(std::cmp::max(probe, limit)); + } + + let mut update = NetworkControlUpdate::default(); + + let result: DelayBasedBweResult = self.delay_based_bwe.incoming_packet_feedback_vector( + report, + &sorted, + acknowledged_bitrate, + probe_bitrate, + alr_start_time.is_some(), + ); + + if result.updated { + if result.probe { + self.bandwidth_estimation + .set_send_bitrate(result.target_bitrate, report.feedback_time); + } + // Since SetSendBitrate now resets the delay-based estimate, we have to + // call UpdateDelayBasedEstimate after SetSendBitrate. + self.bandwidth_estimation + .update_delay_based_estimate(report.feedback_time, result.target_bitrate); + } + self.bandwidth_estimation.update_loss_based_estimator( + report, + result.delay_detector_state, + probe_bitrate, + alr_start_time.is_some(), + ); + if result.updated { + // Update the estimate in the ProbeController, in case we want to probe. + self.maybe_trigger_on_network_changed(&mut update, report.feedback_time); + } + + if result.recovered_from_overuse { + self.probe_controller.set_alr_start_time(alr_start_time); + let mut probes = self.probe_controller.request_probe(report.feedback_time); + update.probe_cluster_configs.append(&mut probes); + } + + // No valid RTT could be because send-side BWE isn't used, in which case + // we don't try to limit the outstanding packets. + if self.rate_control_settings.use_congestion_window() && max_feedback_rtt.is_finite() { + self.update_congestion_window_size(); + } + match ( + &mut self.congestion_window_pushback_controller, + self.current_data_window, + ) { + (Some(congestion_window_pushback_controller), Some(current_data_window)) => { + congestion_window_pushback_controller.set_data_window(current_data_window) + } + _ => update.congestion_window = self.current_data_window, + }; + + update + } + + /// The data window the congestion window settings derive from the target rate and the RTT, + /// whether or not the pushback controller is the one enforcing it. + pub fn congestion_window(&self) -> Option { + self.current_data_window + } + + fn reset_constraints( + &mut self, + new_constraints: TargetRateConstraints, + ) -> Vec { + self.min_target_rate = new_constraints.min_data_rate.unwrap_or(DataRate::zero()); + self.max_data_rate = new_constraints + .max_data_rate + .unwrap_or(DataRate::plus_infinity()); + self.starting_rate = new_constraints.starting_rate; + self.clamp_constraints(); + + self.bandwidth_estimation.set_bitrates( + self.starting_rate, + self.min_data_rate, + self.max_data_rate, + new_constraints.at_time, + ); + + if let Some(starting_rate) = self.starting_rate { + self.delay_based_bwe.set_start_bitrate(starting_rate); + } + self.delay_based_bwe.set_min_bitrate(self.min_data_rate); + + self.probe_controller.set_bitrates( + self.min_data_rate, + self.starting_rate.unwrap_or(DataRate::zero()), + self.max_data_rate, + new_constraints.at_time, + ) + } + + fn clamp_constraints(&mut self) { + // TODO(holmer): We should make sure the default bitrates are set to 10 kbps, + // and that we don't try to set the min bitrate to 0 from any applications. + // The congestion controller should allow a min bitrate of 0. + self.min_data_rate = std::cmp::max(self.min_target_rate, CONGESTION_CONTROLLER_MIN_BITRATE); + if self.use_min_allocatable_as_lower_bound { + self.min_data_rate = + std::cmp::max(self.min_data_rate, self.min_total_allocated_bitrate); + } + if self.max_data_rate < self.min_data_rate { + tracing::warn!( + max = ?self.max_data_rate, + min = ?self.min_data_rate, + "max bitrate smaller than min bitrate" + ); + self.max_data_rate = self.min_data_rate; + } + if let Some(starting_rate) = self.starting_rate + && starting_rate < self.min_data_rate + { + tracing::warn!( + start = ?starting_rate, + min = ?self.min_data_rate, + "start bitrate smaller than min bitrate" + ); + self.starting_rate = Some(self.min_data_rate); + } + } + + fn maybe_trigger_on_network_changed( + &mut self, + update: &mut NetworkControlUpdate, + at_time: Timestamp, + ) { + let round_trip_time: TimeDelta = self.bandwidth_estimation.round_trip_time(); + let loss_based_target_rate: DataRate = self.bandwidth_estimation.target_rate(); + let loss_based_state: LossBasedState = self.bandwidth_estimation.loss_based_state(); + let mut pushback_target_rate: DataRate = loss_based_target_rate; + + if let Some(congestion_window_pushback_controller) = + &mut self.congestion_window_pushback_controller + { + let mut pushback_rate: i64 = congestion_window_pushback_controller + .update_target_bitrate(loss_based_target_rate.bps() as _) + as _; + pushback_rate = self + .bandwidth_estimation + .get_min_bitrate() + .max(pushback_rate); + pushback_target_rate = DataRate::from_bits_per_sec(pushback_rate); + } + + let is_bandwidth_limited = self + .alr_detector + .get_application_limited_region_start_time() + .is_none(); + + if (loss_based_target_rate != self.last_loss_based_target_rate) + || (loss_based_state != self.last_loss_base_state) + || (round_trip_time != self.last_estimated_round_trip_time) + || (pushback_target_rate != self.last_pushback_target_rate) + || (is_bandwidth_limited != self.last_is_bandwidth_limited) + { + self.last_loss_based_target_rate = loss_based_target_rate; + self.last_pushback_target_rate = pushback_target_rate; + self.last_estimated_round_trip_time = round_trip_time; + self.last_loss_base_state = loss_based_state; + self.last_is_bandwidth_limited = is_bandwidth_limited; + + self.alr_detector + .set_estimated_bitrate(loss_based_target_rate); + + // With DropFrame, the target stays at the loss-based rate and the pushback reaches + // the encoder as `cwnd_reduce_ratio`, a frame drop ratio, which no consumer reads. + let target_rate = if self + .rate_control_settings + .use_congestion_window_drop_frame_only() + { + loss_based_target_rate + } else { + pushback_target_rate + }; + update.target_rate = Some(TargetTransferRate { target_rate }); + + let mut probes = self.probe_controller.set_estimated_bitrate( + loss_based_target_rate, + get_bandwidth_limited_cause( + self.bandwidth_estimation.loss_based_state(), + self.bandwidth_estimation.is_rtt_above_limit(), + self.delay_based_bwe.last_state(), + ), + at_time, + ); + update.probe_cluster_configs.append(&mut probes); + update.pacer_config = Some(self.get_pacing_rates()); + tracing::trace!( + at = at_time.ms(), + pushback_target_bps = self.last_pushback_target_rate.bps(), + estimate_bps = loss_based_target_rate.bps(), + "bwe" + ); + } + } + + fn get_pacing_rates(&self) -> PacerConfig { + // Pacing rate is based on target rate before congestion window pushback, + // because we don't want to build queues in the pacer when pushback occurs. + let pacing_rate = self + .min_total_allocated_bitrate + .max(self.last_loss_based_target_rate) + * self + .pacing_factor + .unwrap_or(if self.first_transport_feedback_received { + Self::DEFAULT_PACE_MULTIPLIER_WITH_SEND_SIDE_BWE + } else { + Self::DEFAULT_PACE_MULTIPLIER + }); + PacerConfig::create(pacing_rate, self.pacing_time_window) + } + + fn update_congestion_window_size(&mut self) { + let min_feedback_max_rtt: TimeDelta = + TimeDelta::from_millis(*self.feedback_max_rtts.iter().min().expect("a feedback RTT")); + + const MIN_CWND: DataSize = DataSize::from_bytes(2 * 1500); + let time_window: TimeDelta = min_feedback_max_rtt + + TimeDelta::from_millis( + self.rate_control_settings + .get_congestion_window_additional_time_ms(), + ); + + let mut data_window: DataSize = self.last_loss_based_target_rate * time_window; + if let Some(current_data_window) = self.current_data_window { + data_window = std::cmp::max(MIN_CWND, (data_window + current_data_window) / 2); + } else { + data_window = std::cmp::max(MIN_CWND, data_window); + } + self.current_data_window = Some(data_window); + } +} + +fn get_bandwidth_limited_cause( + loss_based_state: LossBasedState, + is_rtt_above_limit: bool, + bandwidth_usage: BandwidthUsage, +) -> BandwidthLimitedCause { + if bandwidth_usage == BandwidthUsage::Overusing || bandwidth_usage == BandwidthUsage::Underusing + { + return BandwidthLimitedCause::DelayBasedLimitedDelayIncreased; + } else if is_rtt_above_limit { + return BandwidthLimitedCause::RttBasedBackOffHighRtt; + } + match loss_based_state { + // Probes may not be sent in this state. + LossBasedState::Decreasing => BandwidthLimitedCause::LossLimitedBwe, + // Probes may not be sent in this state. + LossBasedState::IncreaseUsingPadding => BandwidthLimitedCause::LossLimitedBwe, + // Probes may be sent in this state. + LossBasedState::Increasing => BandwidthLimitedCause::LossLimitedBweIncreasing, + LossBasedState::DelayBasedEstimate => BandwidthLimitedCause::DelayBasedLimited, + } +} + +#[cfg(test)] +mod test { + use crate::gcc::api::transport::PacketResult; + + use super::*; + + const INITIAL_BITRATE_KBPS: i64 = 60; + const INITIAL_BITRATE: DataRate = DataRate::from_kilobits_per_sec(INITIAL_BITRATE_KBPS); + const DEFAULT_PACING_RATE: f64 = 2.5; + const DEFAULT_PACING_RATE_SEND_SIDE_BWE: f64 = 1.1; + + fn create_packet_result( + arrival_time: Timestamp, + send_time: Timestamp, + payload_size: i64, + pacing_info: PacedPacketInfo, + ) -> PacketResult { + PacketResult { + sent_packet: SentPacket { + send_time, + size: DataSize::from_bytes(payload_size), + pacing_info, + ..Default::default() + }, + receive_time: arrival_time, + ambiguous_receive_time: false, + } + } + + // Simulate sending packets and receiving transport feedback during + // `runtime_ms`, then return the final target birate. + fn packet_transmission_and_feedback_block( + controller: &mut GoogCcNetworkController, + runtime_ms: i64, + delay: i64, + current_time: &mut Timestamp, + ) -> Option { + let mut target_bitrate: Option = None; + let mut delay_buildup: i64 = 0; + let start_time_ms: i64 = current_time.ms(); + while current_time.ms() - start_time_ms < runtime_ms { + const PAYLOAD_SIZE: i64 = 1000; + let packet: PacketResult = create_packet_result( + *current_time + TimeDelta::from_millis(delay_buildup), + *current_time, + PAYLOAD_SIZE, + PacedPacketInfo::default(), + ); + delay_buildup += delay; + let update = controller.on_sent_packet(packet.sent_packet); + if let Some(target_rate) = update.target_rate { + target_bitrate = Some(target_rate.target_rate); + } + let feedback = TransportPacketsFeedback { + feedback_time: packet.receive_time, + packet_feedbacks: vec![packet], + ..Default::default() + }; + let update = controller.on_transport_packets_feedback(&feedback); + if let Some(target_rate) = update.target_rate { + target_bitrate = Some(target_rate.target_rate); + } + *current_time += TimeDelta::from_millis(50); + let update = controller.on_process_interval(ProcessInterval { + at_time: *current_time, + ..Default::default() + }); + if let Some(target_rate) = update.target_rate { + target_bitrate = Some(target_rate.target_rate); + } + } + target_bitrate + } + + // Create transport packets feedback with a built-up delay. + fn create_transport_packets_feedback( + per_packet_network_delay: TimeDelta, + one_way_delay: TimeDelta, + send_time: Timestamp, + ) -> TransportPacketsFeedback { + let mut delay_buildup: TimeDelta = one_way_delay; + const FEEDBACK_SIZE: i64 = 3; + const PAYLOAD_SIZE: i64 = 1000; + let mut feedback = TransportPacketsFeedback::default(); + for _ in 0..FEEDBACK_SIZE { + let packet: PacketResult = create_packet_result( + /*arrival_time=*/ send_time + delay_buildup, + send_time, + PAYLOAD_SIZE, + PacedPacketInfo::default(), + ); + delay_buildup += per_packet_network_delay; + feedback.feedback_time = packet.receive_time + one_way_delay; + feedback.packet_feedbacks.push(packet); + } + feedback + } + + fn create_controller(stream_based_config: StreamsConfig) -> GoogCcNetworkController { + GoogCcNetworkController::new(NetworkControllerConfig { + constraints: TargetRateConstraints { + at_time: Timestamp::zero(), + min_data_rate: Some(DataRate::from_kilobits_per_sec(0)), + max_data_rate: Some(DataRate::from_kilobits_per_sec(5 * INITIAL_BITRATE_KBPS)), + starting_rate: Some(DataRate::from_kilobits_per_sec(INITIAL_BITRATE_KBPS)), + }, + stream_based_config, + default_pacing_time_window: PacerConfig::DEFAULT_TIME_INTERVAL, + ..Default::default() + }) + } + + fn available(controller: &mut GoogCcNetworkController, at_time: Timestamp) { + let _ = controller.on_network_availability(NetworkAvailability { + at_time, + network_available: true, + }); + } + + #[test] + fn initialize_target_rate_on_first_process_interval_after_network_available() { + let mut controller = create_controller(StreamsConfig::default()); + available(&mut controller, Timestamp::from_millis(123456)); + let update = controller.on_process_interval(ProcessInterval { + at_time: Timestamp::from_millis(123456), + ..Default::default() + }); + + assert_eq!(update.target_rate.unwrap().target_rate, INITIAL_BITRATE); + assert_eq!( + update.pacer_config.unwrap().data_rate(), + INITIAL_BITRATE * DEFAULT_PACING_RATE + ); + assert_eq!( + update.probe_cluster_configs[0].target_data_rate, + INITIAL_BITRATE * 3 + ); + assert_eq!( + update.probe_cluster_configs[1].target_data_rate, + INITIAL_BITRATE * 5 + ); + } + + #[test] + fn change_default_pacing_factor_after_first_feedback() { + let mut controller = create_controller(StreamsConfig::default()); + available(&mut controller, Timestamp::from_millis(123456)); + let update = controller.on_process_interval(ProcessInterval { + at_time: Timestamp::from_millis(123456), + ..Default::default() + }); + assert_eq!( + update.pacer_config.unwrap().data_rate(), + INITIAL_BITRATE * DEFAULT_PACING_RATE + ); + + let feedback_time = Timestamp::from_millis(123457); + let feedback = TransportPacketsFeedback { + feedback_time, + packet_feedbacks: vec![create_packet_result( + feedback_time, + feedback_time, + 100, + PacedPacketInfo::default(), + )], + ..Default::default() + }; + let update = controller.on_transport_packets_feedback(&feedback); + let pacer_config = update.pacer_config.expect("a pacer config"); + assert!(pacer_config.data_rate() >= INITIAL_BITRATE * DEFAULT_PACING_RATE_SEND_SIDE_BWE); + assert!( + pacer_config.data_rate() + < (INITIAL_BITRATE + DataRate::from_kilobits_per_sec(5)) + * DEFAULT_PACING_RATE_SEND_SIDE_BWE + ); + } + + #[test] + fn respects_configured_pacing_factor() { + let mut controller = create_controller(StreamsConfig { + pacing_factor: Some(10.0), + ..Default::default() + }); + available(&mut controller, Timestamp::from_millis(123456)); + let update = controller.on_process_interval(ProcessInterval { + at_time: Timestamp::from_millis(123456), + ..Default::default() + }); + assert_eq!( + update.pacer_config.unwrap().data_rate(), + INITIAL_BITRATE * 10.0 + ); + + let feedback_time = Timestamp::from_millis(123457); + let feedback = TransportPacketsFeedback { + feedback_time, + packet_feedbacks: vec![create_packet_result( + feedback_time, + feedback_time, + 100, + PacedPacketInfo::default(), + )], + ..Default::default() + }; + let update = controller.on_transport_packets_feedback(&feedback); + let pacer_config = update.pacer_config.expect("a pacer config"); + assert!(pacer_config.data_rate() >= INITIAL_BITRATE * 10.0); + assert!( + pacer_config.data_rate() + < (INITIAL_BITRATE + DataRate::from_kilobits_per_sec(5)) * 10.0 + ); + } + + #[test] + fn updates_delay_based_estimate() { + let mut controller = create_controller(StreamsConfig::default()); + const RUN_TIME_MS: i64 = 6000; + let mut current_time: Timestamp = Timestamp::from_millis(123); + available(&mut controller, current_time); + + // The test must run and insert packets/feedback long enough that the + // BWE computes a valid estimate. This is first done in an environment which + // simulates no bandwidth limitation, and therefore not built-up delay. + let target_bitrate_before_delay = packet_transmission_and_feedback_block( + &mut controller, + RUN_TIME_MS, + 0, + &mut current_time, + ) + .expect("an estimate"); + + // Repeat, but this time with a building delay, and make sure that the + // estimation is adjusted downwards. + let target_bitrate_after_delay = packet_transmission_and_feedback_block( + &mut controller, + RUN_TIME_MS, + 50, + &mut current_time, + ) + .expect("an estimate"); + assert!(target_bitrate_after_delay < target_bitrate_before_delay); + } + + #[test] + fn calculates_rtt_from_transport_feedback() { + let mut controller = create_controller(StreamsConfig::default()); + let current_time: Timestamp = Timestamp::from_millis(123); + let one_way_delay: TimeDelta = TimeDelta::from_millis(10); + + let feedback: TransportPacketsFeedback = create_transport_packets_feedback( + /*per_packet_network_delay=*/ TimeDelta::from_millis(50), + one_way_delay, + /*send_time=*/ current_time, + ); + controller.on_transport_packets_feedback(&feedback); + // The propagation RTT discounts each packet's wait behind the newest one received. + assert!(!controller.bandwidth_estimation.is_rtt_above_limit()); + assert_eq!( + controller.feedback_max_rtts.back(), + Some(&(2 * one_way_delay.ms() + 100)) + ); + } + + #[test] + fn triggers_target_rate_update_when_bandwidth_limited_changes() { + let mut controller = create_controller(StreamsConfig::default()); + let mut current_time = Timestamp::from_millis(123456); + available(&mut controller, current_time); + let update = controller.on_process_interval(ProcessInterval { + at_time: current_time, + ..Default::default() + }); + assert!(update.target_rate.is_some()); + assert!(controller.last_is_bandwidth_limited); + + // Send packet to initialize ALR detector + let mut sent_packet = SentPacket { + send_time: current_time, + size: DataSize::from_bytes(1000), + ..Default::default() + }; + let _ = controller.on_sent_packet(sent_packet); + + // Wait 10 seconds without sending any packets (this will make budget ratio + // accumulate and enter ALR) + current_time += TimeDelta::from_seconds(10); + // Send a tiny packet to trigger AlrDetector state change evaluation + sent_packet.send_time = current_time; + sent_packet.size = DataSize::from_bytes(1); + let update = controller.on_sent_packet(sent_packet); + let alr_detected = update.target_rate.is_some() + || controller + .on_process_interval(ProcessInterval { + at_time: current_time, + ..Default::default() + }) + .target_rate + .is_some(); + assert!(alr_detected); + assert!(!controller.last_is_bandwidth_limited); + + // Now send a large burst of packets to leave ALR. + let mut bandwidth_limited_detected = false; + for _ in 0..50 { + current_time += TimeDelta::from_millis(1); + sent_packet.send_time = current_time; + sent_packet.size = DataSize::from_bytes(1000); + let update = controller.on_sent_packet(sent_packet); + bandwidth_limited_detected |= update.target_rate.is_some(); + } + if !bandwidth_limited_detected { + bandwidth_limited_detected = controller + .on_process_interval(ProcessInterval { + at_time: current_time, + ..Default::default() + }) + .target_rate + .is_some(); + } + assert!(bandwidth_limited_detected); + assert!(controller.last_is_bandwidth_limited); + } +} diff --git a/rs/moq-congestion/src/gcc/inter_arrival_delta.rs b/rs/moq-congestion/src/gcc/inter_arrival_delta.rs new file mode 100644 index 00000000000..11026b60b1e --- /dev/null +++ b/rs/moq-congestion/src/gcc/inter_arrival_delta.rs @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2020 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::api::units::{TimeDelta, Timestamp}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SendTimeGroup { + size: usize, + first_send_time: Timestamp, + send_time: Timestamp, + first_arrival: Timestamp, + complete_time: Timestamp, + last_system_time: Timestamp, +} + +impl Default for SendTimeGroup { + fn default() -> Self { + Self { + size: 0, + first_send_time: Timestamp::minus_infinity(), + send_time: Timestamp::minus_infinity(), + first_arrival: Timestamp::minus_infinity(), + complete_time: Timestamp::minus_infinity(), + last_system_time: Timestamp::minus_infinity(), + } + } +} +impl SendTimeGroup { + pub fn is_first_packet(&self) -> bool { + self.complete_time.is_infinite() + } +} + +// Helper class to compute the inter-arrival time delta and the size delta +// between two send bursts. This code is branched from +// modules/remote_bitrate_estimator/inter_arrival. +#[derive(Clone)] +pub struct InterArrivalDelta { + send_time_group_length: TimeDelta, + current_timestamp_group: SendTimeGroup, + prev_timestamp_group: SendTimeGroup, + num_consecutive_reordered_packets: i64, +} + +impl InterArrivalDelta { + const REORDERED_RESET_THRESHOLD: i64 = 3; + const ARRIVAL_TIME_OFFSET_THRESHOLD: TimeDelta = TimeDelta::from_seconds(3); + const BURST_DELTA_THRESHOLD: TimeDelta = TimeDelta::from_millis(5); + const MAX_BURST_DURATION: TimeDelta = TimeDelta::from_millis(100); + + pub fn new(send_time_group_length: TimeDelta) -> Self { + Self { + send_time_group_length, + current_timestamp_group: SendTimeGroup::default(), + prev_timestamp_group: SendTimeGroup::default(), + num_consecutive_reordered_packets: 0, + } + } + + // This function returns true if a delta was computed, or false if the current + // group is still incomplete or if only one group has been completed. + // `send_time` is the send time. + // `arrival_time` is the time at which the packet arrived. + // `packet_size` is the size of the packet. + // `timestamp_delta` (output) is the computed send time delta. + // `arrival_time_delta` (output) is the computed arrival-time delta. + // `packet_size_delta` (output) is the computed size delta. + #[allow( + clippy::too_many_arguments, + reason = "upstream's ComputeDeltas, with its outputs as arguments" + )] + pub fn compute_deltas( + &mut self, + send_time: Timestamp, + arrival_time: Timestamp, + system_time: Timestamp, + packet_size: usize, + send_time_delta: &mut TimeDelta, + arrival_time_delta: &mut TimeDelta, + packet_size_delta: &mut i64, + ) -> bool { + let mut calculated_deltas: bool = false; + if self.current_timestamp_group.is_first_packet() { + // We don't have enough data to update the filter, so we store it until we + // have two frames of data to process. + self.current_timestamp_group.send_time = send_time; + self.current_timestamp_group.first_send_time = send_time; + self.current_timestamp_group.first_arrival = arrival_time; + } else if self.current_timestamp_group.first_send_time > send_time { + // Reordered packet. + return false; + } else if self.new_timestamp_group(arrival_time, send_time) { + // First packet of a later send burst, the previous packets sample is ready. + if self.prev_timestamp_group.complete_time.is_finite() { + *send_time_delta = + self.current_timestamp_group.send_time - self.prev_timestamp_group.send_time; + *arrival_time_delta = self.current_timestamp_group.complete_time + - self.prev_timestamp_group.complete_time; + + let system_time_delta: TimeDelta = self.current_timestamp_group.last_system_time + - self.prev_timestamp_group.last_system_time; + + if *arrival_time_delta - system_time_delta >= Self::ARRIVAL_TIME_OFFSET_THRESHOLD { + tracing::warn!( + "The arrival time clock offset has changed (diff = {} ms), resetting.", + arrival_time_delta.ms() - system_time_delta.ms() + ); + self.reset(); + return false; + } + if *arrival_time_delta < TimeDelta::zero() { + // The group of packets has been reordered since receiving its local + // arrival timestamp. + self.num_consecutive_reordered_packets += 1; + if self.num_consecutive_reordered_packets >= Self::REORDERED_RESET_THRESHOLD { + tracing::warn!( + "Packets between send burst arrived out of order, resetting: arrival_time_delta_ms={}, send_time_delta_ms={}", + arrival_time_delta.ms(), + send_time_delta.ms() + ); + self.reset(); + } + return false; + } else { + self.num_consecutive_reordered_packets = 0; + } + *packet_size_delta = (self.current_timestamp_group.size) as i64 + - (self.prev_timestamp_group.size) as i64; + calculated_deltas = true; + } + self.prev_timestamp_group = self.current_timestamp_group; + // The new timestamp is now the current frame. + self.current_timestamp_group.first_send_time = send_time; + self.current_timestamp_group.send_time = send_time; + self.current_timestamp_group.first_arrival = arrival_time; + self.current_timestamp_group.size = 0; + } else { + self.current_timestamp_group.send_time = + std::cmp::max(self.current_timestamp_group.send_time, send_time); + } + // Accumulate the frame size. + self.current_timestamp_group.size += packet_size; + self.current_timestamp_group.complete_time = arrival_time; + self.current_timestamp_group.last_system_time = system_time; + + calculated_deltas + } + + // Returns true if the last packet was the end of the current batch and the + // packet with `send_time` is the first of a new batch. + fn new_timestamp_group(&self, arrival_time: Timestamp, send_time: Timestamp) -> bool { + if self.current_timestamp_group.is_first_packet() + || self.belongs_to_burst(arrival_time, send_time) + { + false + } else { + send_time - self.current_timestamp_group.first_send_time > self.send_time_group_length + } + } + + fn belongs_to_burst(&self, arrival_time: Timestamp, send_time: Timestamp) -> bool { + assert!(self.current_timestamp_group.complete_time.is_finite()); + let arrival_time_delta: TimeDelta = + arrival_time - self.current_timestamp_group.complete_time; + let send_time_delta: TimeDelta = send_time - self.current_timestamp_group.send_time; + if send_time_delta.is_zero() { + return true; + } + let propagation_delta: TimeDelta = arrival_time_delta - send_time_delta; + if propagation_delta < TimeDelta::zero() + && arrival_time_delta <= Self::BURST_DELTA_THRESHOLD + && arrival_time - self.current_timestamp_group.first_arrival < Self::MAX_BURST_DURATION + { + return true; + } + false + } + + fn reset(&mut self) { + self.num_consecutive_reordered_packets = 0; + self.current_timestamp_group = SendTimeGroup::default(); + self.prev_timestamp_group = SendTimeGroup::default(); + } +} diff --git a/rs/moq-congestion/src/gcc/link_capacity_estimator.rs b/rs/moq-congestion/src/gcc/link_capacity_estimator.rs new file mode 100644 index 00000000000..73547343c04 --- /dev/null +++ b/rs/moq-congestion/src/gcc/link_capacity_estimator.rs @@ -0,0 +1,100 @@ +/* + * Copyright 2018 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::api::units::DataRate; + +#[derive(Clone)] +pub struct LinkCapacityEstimator { + estimate_kbps: Option, + deviation_kbps: f64, +} + +impl Default for LinkCapacityEstimator { + fn default() -> Self { + Self::new() + } +} + +impl LinkCapacityEstimator { + pub fn new() -> Self { + Self { + estimate_kbps: None, + deviation_kbps: 0.4, + } + } + pub fn upper_bound(&self) -> DataRate { + if let Some(estimate_kbps) = self.estimate_kbps { + DataRate::from_kilobits_per_sec_float( + estimate_kbps + 3.0 * self.deviation_estimate_kbps(estimate_kbps), + ) + } else { + DataRate::infinity() + } + } + + pub fn lower_bound(&self) -> DataRate { + if let Some(estimate_kbps) = self.estimate_kbps { + DataRate::from_kilobits_per_sec_float( + (estimate_kbps - 3.0 * self.deviation_estimate_kbps(estimate_kbps)).max(0.0), + ) + } else { + DataRate::zero() + } + } + + pub fn reset(&mut self) { + self.estimate_kbps.take(); + } + + pub fn on_overuse_detected(&mut self, acknowledged_rate: DataRate) { + self.update(acknowledged_rate, 0.05); + } + + /* unused + pub fn on_probe_rate(&mut self, probe_rate: DataRate) { + self.update(probe_rate, 0.5); + } + */ + + pub fn has_estimate(&self) -> bool { + self.estimate_kbps.is_some() + } + + pub fn estimate(&self) -> DataRate { + DataRate::from_kilobits_per_sec_float(self.estimate_kbps.unwrap_or_default()) + } + + fn update(&mut self, capacity_sample: DataRate, alpha: f64) { + let sample_kbps: f64 = capacity_sample.kbps_float(); + let estimate_kbps = if let Some(estimate_kbps) = self.estimate_kbps { + (1.0 - alpha) * estimate_kbps + alpha * sample_kbps + } else { + sample_kbps + }; + + // Estimate the variance of the link capacity estimate and normalize the + // variance with the link capacity estimate. + let norm: f64 = estimate_kbps.max(1.0); + let error_kbps: f64 = estimate_kbps - sample_kbps; + self.deviation_kbps = + (1.0 - alpha) * self.deviation_kbps + alpha * error_kbps * error_kbps / norm; + // 0.4 ~= 14 kbit/s at 500 kbit/s + // 2.5f ~= 35 kbit/s at 500 kbit/s + self.deviation_kbps = self.deviation_kbps.clamp(0.4, 2.5); + self.estimate_kbps = Some(estimate_kbps); + } + + fn deviation_estimate_kbps(&self, estimate_kbps: f64) -> f64 { + // Calculate the max bit rate std dev given the normalized + // variance and the current throughput bitrate. The standard deviation will + // only be used if self.estimate_kbps has a value. + (self.deviation_kbps * estimate_kbps).sqrt() + } +} diff --git a/rs/moq-congestion/src/gcc/loss_based_bwe_v2.rs b/rs/moq-congestion/src/gcc/loss_based_bwe_v2.rs new file mode 100644 index 00000000000..92f971fa3a0 --- /dev/null +++ b/rs/moq-congestion/src/gcc/loss_based_bwe_v2.rs @@ -0,0 +1,3683 @@ +/* + * Copyright 2021 The WebRTC project authors. All rights reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::{ + api::{ + transport::PacketResult, + units::{DataRate, DataSize, TimeDelta, Timestamp}, + }, + remote_bitrate_estimator::CONGESTION_CONTROLLER_MIN_BITRATE, +}; + +// State of the loss based estimate, which can be either increasing/decreasing +// when network is loss limited, or equal to the delay based estimate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LossBasedState { + Increasing = 0, + // TODO(bugs.webrtc.org/12707): Remove one of the increasing states once we + // have decided if padding is usefull for ramping up when BWE is loss + // limited. + IncreaseUsingPadding = 1, + Decreasing = 2, + DelayBasedEstimate = 3, +} +#[derive(Clone, Debug, Copy)] +pub struct LossBasedBweV2Result { + pub bandwidth_estimate: DataRate, + // State is used by goog_cc, which later sends probe requests to probe + // controller if state is Increasing. + pub state: LossBasedState, +} + +impl Default for LossBasedBweV2Result { + fn default() -> Self { + Self { + bandwidth_estimate: DataRate::zero(), + state: LossBasedState::DelayBasedEstimate, + } + } +} + +#[derive(Clone, Debug)] +struct ChannelParameters { + pub inherent_loss: f64, + pub loss_limited_bandwidth: DataRate, +} + +impl Default for ChannelParameters { + fn default() -> Self { + Self { + inherent_loss: 0.0, + loss_limited_bandwidth: DataRate::minus_infinity(), + } + } +} + +#[derive(Clone, Debug)] +pub struct LossBasedBweV2Config { + pub enabled: bool, + pub bw_rampup_upper_bound_factor: f64, + pub bw_rampup_upper_bound_in_hold_factor: f64, + pub bw_rampup_upper_bound_hold_threshold: f64, + pub bw_rampup_accel_max_factor: f64, + pub bw_rampup_accel_maxout_time: TimeDelta, + pub candidate_factors: Vec, + pub higher_bw_bias_factor: f64, + pub higher_log_bw_bias_factor: f64, + pub inherent_loss_lower_bound: f64, + pub loss_threshold_of_high_bandwidth_preference: f64, + pub bandwidth_preference_smoothing_factor: f64, + pub inherent_loss_upper_bound_bw_balance: DataRate, + pub inherent_loss_upper_bound_offset: f64, + pub initial_inherent_loss_estimate: f64, + pub newton_iterations: usize, + pub newton_step_size: f64, + pub acked_rate_candidate: bool, + pub delay_based_candidate: bool, + pub upper_bound_candidate_in_alr: bool, + pub observation_duration_lower_bound: TimeDelta, + pub observation_window_size: usize, + pub sending_rate_smoothing_factor: f64, + pub instant_upper_bound_temporal_weight_factor: f64, + pub instant_upper_bound_bw_balance: DataRate, + pub instant_upper_bound_loss_offset: f64, + pub temporal_weight_factor: f64, + pub bw_backoff_lower_bound_factor: f64, + pub max_increase_factor: f64, + pub delayed_increase_window: TimeDelta, + pub not_increase_if_inherent_loss_less_than_average_loss: bool, + pub not_use_acked_rate_in_alr: bool, + pub use_in_start_phase: bool, + pub min_num_observations: usize, + pub lower_bound_by_acked_rate_factor: f64, + pub hold_duration_factor: f64, + pub use_byte_loss_rate: bool, + pub padding_duration: TimeDelta, + pub bound_best_candidate: bool, + pub median_sending_rate_factor: f64, +} + +impl LossBasedBweV2Config { + pub fn is_valid(&self) -> bool { + let mut valid = true; + + if self.bw_rampup_upper_bound_factor <= 1.0 { + tracing::warn!( + "The bandwidth rampup upper bound factor must be greater than 1: {}", + self.bw_rampup_upper_bound_factor + ); + valid = false; + } + if self.bw_rampup_upper_bound_in_hold_factor <= 1.0 { + tracing::warn!( + "The bandwidth rampup upper bound factor in hold must be greater than 1: {}", + self.bw_rampup_upper_bound_in_hold_factor + ); + valid = false; + } + if self.bw_rampup_upper_bound_hold_threshold < 0.0 { + tracing::warn!( + "The bandwidth rampup hold threshold must be non-negative: {}", + self.bw_rampup_upper_bound_hold_threshold + ); + valid = false; + } + if self.bw_rampup_accel_max_factor < 0.0 { + tracing::warn!( + "The rampup acceleration max factor must be non-negative: {}", + self.bw_rampup_accel_max_factor + ); + valid = false; + } + if self.bw_rampup_accel_maxout_time <= TimeDelta::zero() { + tracing::warn!( + "The rampup acceleration maxout time must be above zero: {:?}", + self.bw_rampup_accel_maxout_time + ); + valid = false; + } + for candidate_factor in self.candidate_factors.iter() { + if *candidate_factor <= 0.0 { + tracing::warn!( + "All candidate factors must be greater than zero: {}", + candidate_factor + ); + valid = false; + } + } + + // Ensure that the configuration allows generation of at least one candidate + // other than the current estimate. + if !self.acked_rate_candidate + && !self.delay_based_candidate + && !self.candidate_factors.iter().any(|cf| *cf != 1.0) + { + tracing::warn!( + "The configuration does not allow generating candidates. Specify a candidate factor other than 1.0, allow the acknowledged rate to be a candidate, and/or allow the delay based estimate to be a candidate." + ); + valid = false; + } + + if self.higher_bw_bias_factor < 0.0 { + tracing::warn!( + "The higher bandwidth bias factor must be non-negative: {}", + self.higher_bw_bias_factor + ); + valid = false; + } + if self.inherent_loss_lower_bound < 0.0 || self.inherent_loss_lower_bound >= 1.0 { + tracing::warn!( + "The inherent loss lower bound must be in [0, 1): {}", + self.inherent_loss_lower_bound + ); + valid = false; + } + if self.loss_threshold_of_high_bandwidth_preference < 0.0 + || self.loss_threshold_of_high_bandwidth_preference >= 1.0 + { + tracing::warn!( + "The loss threshold of high bandwidth preference must be in [0, 1): {}", + self.loss_threshold_of_high_bandwidth_preference + ); + valid = false; + } + if self.bandwidth_preference_smoothing_factor <= 0.0 + || self.bandwidth_preference_smoothing_factor > 1.0 + { + tracing::warn!( + "The bandwidth preference smoothing factor must be in (0, 1]: {}", + self.bandwidth_preference_smoothing_factor + ); + valid = false; + } + if self.inherent_loss_upper_bound_bw_balance <= DataRate::zero() { + tracing::warn!( + "The inherent loss upper bound bandwidth balance must be positive: {:?}", + self.inherent_loss_upper_bound_bw_balance + ); + valid = false; + } + if self.inherent_loss_upper_bound_offset < self.inherent_loss_lower_bound + || self.inherent_loss_upper_bound_offset >= 1.0 + { + tracing::warn!( + "The inherent loss upper bound must be greater than or equal to the inherent loss lower bound, which is {}, and less than 1: {}", + self.inherent_loss_lower_bound, + self.inherent_loss_upper_bound_offset + ); + valid = false; + } + if self.initial_inherent_loss_estimate < 0.0 || self.initial_inherent_loss_estimate >= 1.0 { + tracing::warn!( + "The initial inherent loss estimate must be in [0, 1): {}", + self.initial_inherent_loss_estimate + ); + valid = false; + } + if self.newton_iterations == 0 { + tracing::warn!( + "The number of Newton iterations must be positive: {}", + self.newton_iterations + ); + valid = false; + } + if self.newton_step_size <= 0.0 { + tracing::warn!( + "The Newton step size must be positive: {}", + self.newton_step_size + ); + valid = false; + } + if self.observation_duration_lower_bound <= TimeDelta::zero() { + tracing::warn!( + "The observation duration lower bound must be positive: {}", + self.observation_duration_lower_bound.ms() + ); + valid = false; + } + if self.observation_window_size < 2 { + tracing::warn!( + "The observation window size must be at least 2: {}", + self.observation_window_size + ); + valid = false; + } + if self.sending_rate_smoothing_factor < 0.0 || self.sending_rate_smoothing_factor >= 1.0 { + tracing::warn!( + "The sending rate smoothing factor must be in [0, 1): {}", + self.sending_rate_smoothing_factor + ); + valid = false; + } + if self.instant_upper_bound_temporal_weight_factor <= 0.0 + || self.instant_upper_bound_temporal_weight_factor > 1.0 + { + tracing::warn!( + "The instant upper bound temporal weight factor must be in (0, 1]: {}", + self.instant_upper_bound_temporal_weight_factor + ); + valid = false; + } + if self.instant_upper_bound_bw_balance <= DataRate::zero() { + tracing::warn!( + "The instant upper bound bandwidth balance must be positive: {:?}", + self.instant_upper_bound_bw_balance + ); + valid = false; + } + if self.instant_upper_bound_loss_offset < 0.0 || self.instant_upper_bound_loss_offset >= 1.0 + { + tracing::warn!( + "The instant upper bound loss offset must be in [0, 1): {}", + self.instant_upper_bound_loss_offset + ); + valid = false; + } + if self.temporal_weight_factor <= 0.0 || self.temporal_weight_factor > 1.0 { + tracing::warn!( + "The temporal weight factor must be in (0, 1]: {}", + self.temporal_weight_factor + ); + valid = false; + } + if self.bw_backoff_lower_bound_factor > 1.0 { + tracing::warn!( + "The bandwidth backoff lower bound factor must not be greater than 1: {}", + self.bw_backoff_lower_bound_factor + ); + valid = false; + } + if self.max_increase_factor <= 0.0 { + tracing::warn!( + "The maximum increase factor must be positive: {}", + self.max_increase_factor + ); + valid = false; + } + if self.delayed_increase_window <= TimeDelta::zero() { + tracing::warn!( + "The delayed increase window must be positive: {:?}", + self.delayed_increase_window + ); + valid = false; + } + if self.min_num_observations == 0 { + tracing::warn!( + "The min number of observations must be positive: {}", + self.min_num_observations + ); + valid = false; + } + if self.lower_bound_by_acked_rate_factor < 0.0 { + tracing::warn!( + "The estimate lower bound by acknowledged rate factor must be non-negative: {}", + self.lower_bound_by_acked_rate_factor + ); + valid = false; + } + valid + } +} + +impl Default for LossBasedBweV2Config { + fn default() -> Self { + Self { + enabled: true, // Enabled + bw_rampup_upper_bound_factor: 1.5, // BwRampupUpperBoundFactor + bw_rampup_upper_bound_in_hold_factor: 1.2, // BwRampupUpperBoundInHoldFactor + bw_rampup_upper_bound_hold_threshold: 1.3, // BwRampupUpperBoundHoldThreshold + bw_rampup_accel_max_factor: 0.0, // BwRampupAccelMaxFactor + bw_rampup_accel_maxout_time: TimeDelta::from_seconds(60), // BwRampupAccelMaxoutTime + candidate_factors: vec![1.02, 1.0, 0.95], // CandidateFactors + higher_bw_bias_factor: 0.0002, // HigherBwBiasFactor + higher_log_bw_bias_factor: 0.02, // HigherLogBwBiasFactor + inherent_loss_lower_bound: 1.0e-3, // InherentLossLowerBound + loss_threshold_of_high_bandwidth_preference: 0.2, // LossThresholdOfHighBandwidthPreference + bandwidth_preference_smoothing_factor: 0.002, // BandwidthPreferenceSmoothingFactor + inherent_loss_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(100), // InherentLossUpperBoundBwBalance + inherent_loss_upper_bound_offset: 0.05, // InherentLossUpperBoundOffset + initial_inherent_loss_estimate: 0.01, // InitialInherentLossEstimate + newton_iterations: 1, // NewtonIterations + newton_step_size: 0.75, // NewtonStepSize + acked_rate_candidate: true, // AckedRateCandidate + delay_based_candidate: true, // DelayBasedCandidate + upper_bound_candidate_in_alr: false, // UpperBoundCandidateInAlr + observation_duration_lower_bound: TimeDelta::from_millis(250), // ObservationDurationLowerBound + observation_window_size: 15, // ObservationWindowSize + sending_rate_smoothing_factor: 0.0, // SendingRateSmoothingFactor + instant_upper_bound_temporal_weight_factor: 0.9, // InstantUpperBoundTemporalWeightFactor + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(100), // InstantUpperBoundBwBalance + instant_upper_bound_loss_offset: 0.05, // InstantUpperBoundLossOffset + temporal_weight_factor: 0.9, // TemporalWeightFactor + bw_backoff_lower_bound_factor: 1.0, // BwBackoffLowerBoundFactor + max_increase_factor: 1.3, // MaxIncreaseFactor + delayed_increase_window: TimeDelta::from_millis(300), // DelayedIncreaseWindow + not_increase_if_inherent_loss_less_than_average_loss: true, // NotIncreaseIfInherentLossLessThanAverageLoss + not_use_acked_rate_in_alr: true, // NotUseAckedRateInAlr + use_in_start_phase: true, // UseInStartPhase + min_num_observations: 3, // MinNumObservations + lower_bound_by_acked_rate_factor: 1.0, // LowerBoundByAckedRateFactor + hold_duration_factor: 2.0, // HoldDurationFactor + use_byte_loss_rate: true, // UseByteLossRate + padding_duration: TimeDelta::from_seconds(2), // PaddingDuration + bound_best_candidate: true, // BoundBest + median_sending_rate_factor: 2.0, // MedianSendingRateFactor + } + } +} + +#[derive(Default)] +struct Derivatives { + pub first: f64, + pub second: f64, +} + +#[derive(Debug, Clone)] +struct Observation { + pub num_packets: usize, + pub num_lost_packets: usize, + pub num_received_packets: usize, + pub sending_rate: DataRate, + pub size: DataSize, + pub lost_size: DataSize, + pub id: i64, +} + +impl Default for Observation { + fn default() -> Self { + Self { + num_packets: 0, + num_lost_packets: 0, + num_received_packets: 0, + sending_rate: DataRate::minus_infinity(), + size: DataSize::zero(), + lost_size: DataSize::zero(), + id: -1, + } + } +} + +impl Observation { + pub fn is_initialized(&self) -> bool { + self.id != -1 + } +} + +// Upstream keys lost packets by sequence number, so a packet one feedback reports lost and a +// later one received counts once. QUIC reports each packet once, acknowledged or lost. +#[derive(Clone)] +struct PartialObservation { + pub num_packets: usize, + pub num_lost_packets: usize, + pub lost_size: DataSize, + pub size: DataSize, +} + +impl Default for PartialObservation { + fn default() -> Self { + Self { + num_packets: 0, + num_lost_packets: 0, + lost_size: DataSize::zero(), + size: DataSize::zero(), + } + } +} + +#[derive(Clone)] +struct PaddingInfo { + pub padding_rate: DataRate, + pub padding_timestamp: Timestamp, +} + +impl Default for PaddingInfo { + fn default() -> Self { + Self { + padding_rate: DataRate::minus_infinity(), + padding_timestamp: Timestamp::minus_infinity(), + } + } +} + +#[derive(Clone)] +struct HoldInfo { + timestamp: Timestamp, + duration: TimeDelta, + rate: DataRate, +} + +impl Default for HoldInfo { + fn default() -> Self { + Self { + timestamp: Timestamp::minus_infinity(), + duration: TimeDelta::zero(), + rate: DataRate::plus_infinity(), + } + } +} + +#[derive(Clone)] +pub struct LossBasedBweV2 { + acknowledged_bitrate: Option, + config: LossBasedBweV2Config, + current_best_estimate: ChannelParameters, + num_observations: usize, + observations: Vec, + partial_observation: PartialObservation, + last_send_time_most_recent_observation: Timestamp, + last_time_estimate_reduced: Timestamp, + cached_instant_upper_bound: Option, + cached_instant_lower_bound: Option, + instant_upper_bound_temporal_weights: Vec, + temporal_weights: Vec, + recovering_after_loss_timestamp: Timestamp, + bandwidth_limit_in_current_window: DataRate, + min_bitrate: DataRate, + max_bitrate: DataRate, + delay_based_estimate: DataRate, + loss_based_result: LossBasedBweV2Result, + last_hold_info: HoldInfo, + last_padding_info: PaddingInfo, + average_reported_loss_ratio: f64, +} + +impl LossBasedBweV2 { + const INIT_HOLD_DURATION: TimeDelta = TimeDelta::from_millis(300); + const MAX_HOLD_DURATION: TimeDelta = TimeDelta::from_seconds(60); + + pub fn new(config: LossBasedBweV2Config) -> Self { + let mut this = Self { + acknowledged_bitrate: None, + current_best_estimate: ChannelParameters::default(), + num_observations: 0, + observations: Vec::new(), + partial_observation: PartialObservation::default(), + last_send_time_most_recent_observation: Timestamp::plus_infinity(), + last_time_estimate_reduced: Timestamp::minus_infinity(), + cached_instant_upper_bound: None, + cached_instant_lower_bound: None, + temporal_weights: Vec::new(), + instant_upper_bound_temporal_weights: Vec::new(), + recovering_after_loss_timestamp: Timestamp::minus_infinity(), + bandwidth_limit_in_current_window: DataRate::plus_infinity(), + min_bitrate: DataRate::from_kilobits_per_sec(1), + max_bitrate: DataRate::plus_infinity(), + delay_based_estimate: DataRate::plus_infinity(), + loss_based_result: LossBasedBweV2Result::default(), + last_hold_info: HoldInfo::default(), + last_padding_info: PaddingInfo::default(), + average_reported_loss_ratio: 0.0, + config, + }; + + if !this.config.enabled { + tracing::debug!( + "The configuration does not specify that the estimator should be enabled, disabling it." + ); + return this; + } + + if !this.config.is_valid() { + tracing::warn!("The configuration is invalid, disabling the estimator."); + this.config.enabled = false; + return this; + } + + this.current_best_estimate.inherent_loss = this.config.initial_inherent_loss_estimate; + this.observations + .resize_with(this.config.observation_window_size, Observation::default); + + for i in 0..this.config.observation_window_size { + this.temporal_weights + .push(this.config.temporal_weight_factor.powf(i as _)); + + this.instant_upper_bound_temporal_weights.push( + this.config + .instant_upper_bound_temporal_weight_factor + .powf(i as _), + ); + } + + this.last_hold_info.duration = Self::INIT_HOLD_DURATION; + + this + } + + pub fn is_enabled(&self) -> bool { + self.config.enabled + } + // Returns true iff a BWE can be calculated, i.e., the estimator has been + // initialized with a BWE and then has received enough `PacketResult`s. + pub fn is_ready(&self) -> bool { + self.is_enabled() + && self + .current_best_estimate + .loss_limited_bandwidth + .is_finite() + && self.num_observations >= self.config.min_num_observations + } + + // Returns true if loss based BWE is ready to be used in the start phase. + pub fn ready_to_use_in_start_phase(&self) -> bool { + self.is_ready() && self.config.use_in_start_phase + } + + // Returns `DataRate::plus_infinity` if no BWE can be calculated. + pub fn get_loss_based_result(&self) -> LossBasedBweV2Result { + if !self.is_ready() { + if !self.is_enabled() { + tracing::warn!("The estimator must be enabled before it can be used."); + } else { + if !self + .current_best_estimate + .loss_limited_bandwidth + .is_finite() + { + tracing::warn!("The estimator must be initialized before it can be used."); + } + if self.num_observations <= self.config.min_num_observations { + tracing::warn!( + "The estimator must receive enough loss statistics before it can be used." + ); + } + } + return LossBasedBweV2Result { + bandwidth_estimate: if self.delay_based_estimate.is_finite() { + self.delay_based_estimate + } else { + DataRate::plus_infinity() + }, + state: LossBasedState::DelayBasedEstimate, + }; + } + self.loss_based_result + } + + pub fn set_acknowledged_bitrate(&mut self, acknowledged_bitrate: DataRate) { + if acknowledged_bitrate.is_finite() { + self.acknowledged_bitrate = Some(acknowledged_bitrate); + self.calculate_instant_lower_bound(); + } else { + tracing::warn!( + "The acknowledged bitrate must be finite: {:?}", + acknowledged_bitrate + ); + } + } + pub fn set_min_max_bitrate(&mut self, min_bitrate: DataRate, max_bitrate: DataRate) { + if min_bitrate.is_finite() { + self.min_bitrate = min_bitrate; + self.calculate_instant_lower_bound(); + } else { + tracing::warn!("The min bitrate must be finite: {:?}", min_bitrate); + } + + if max_bitrate.is_finite() { + self.max_bitrate = max_bitrate; + } else { + tracing::warn!("The max bitrate must be finite: {:?}", max_bitrate); + } + } + pub fn update_bandwidth_estimate( + &mut self, + packet_results: &[PacketResult], + delay_based_estimate: DataRate, + in_alr: bool, + ) { + self.delay_based_estimate = delay_based_estimate; + if !self.is_enabled() { + tracing::warn!("The estimator must be enabled before it can be used."); + return; + } + + if packet_results.is_empty() { + tracing::debug!("The estimate cannot be updated without any loss statistics."); + return; + } + + if !self.push_back_observation(packet_results) { + return; + } + + if !self + .current_best_estimate + .loss_limited_bandwidth + .is_finite() + { + if !delay_based_estimate.is_finite() { + tracing::warn!( + "The delay based estimate must be finite: {:?}", + delay_based_estimate + ); + return; + } + self.current_best_estimate.loss_limited_bandwidth = delay_based_estimate; + self.loss_based_result = LossBasedBweV2Result { + bandwidth_estimate: delay_based_estimate, + state: LossBasedState::DelayBasedEstimate, + }; + } + + let mut best_candidate: ChannelParameters = self.current_best_estimate.clone(); + let mut objective_max: f64 = f64::MIN; + for mut candidate in self.get_candidates(in_alr) { + self.newtons_method_update(&mut candidate); + + let candidate_objective: f64 = self.get_objective(&candidate); + // Select the candidate with the highest objective or the one with the + // same objective but higher bandwidth if they are tied. + if candidate_objective > objective_max + || (candidate_objective == objective_max + && candidate.loss_limited_bandwidth > best_candidate.loss_limited_bandwidth) + { + objective_max = candidate_objective; + best_candidate = candidate; + } + } + if best_candidate.loss_limited_bandwidth < self.current_best_estimate.loss_limited_bandwidth + { + self.last_time_estimate_reduced = self.last_send_time_most_recent_observation; + } + + // Do not increase the estimate if the average loss is greater than current + // inherent loss. + if self.average_reported_loss_ratio > best_candidate.inherent_loss + && self + .config + .not_increase_if_inherent_loss_less_than_average_loss + && self.current_best_estimate.loss_limited_bandwidth + < best_candidate.loss_limited_bandwidth + { + best_candidate.loss_limited_bandwidth = + self.current_best_estimate.loss_limited_bandwidth; + } + + if self.is_in_loss_limited_state() { + // Bound the estimate increase if: + // 1. The estimate has been increased for less than + // `delayed_increase_window` ago, and + // 2. The best candidate is greater than bandwidth_limit_in_current_window. + if self.recovering_after_loss_timestamp.is_finite() + && self.recovering_after_loss_timestamp + self.config.delayed_increase_window + > self.last_send_time_most_recent_observation + && best_candidate.loss_limited_bandwidth > self.bandwidth_limit_in_current_window + { + best_candidate.loss_limited_bandwidth = self.bandwidth_limit_in_current_window; + } + + let increasing_when_loss_limited: bool = self.is_estimate_increasing_when_loss_limited( + /*old_estimate=*/ self.current_best_estimate.loss_limited_bandwidth, + /*new_estimate=*/ best_candidate.loss_limited_bandwidth, + ); + // Bound the best candidate by the acked bitrate. + if let Some(acknowledged_bitrate) = self.acknowledged_bitrate + && increasing_when_loss_limited + && acknowledged_bitrate.is_finite() + { + let mut rampup_factor: f64 = self.config.bw_rampup_upper_bound_factor; + if self.last_hold_info.rate.is_finite() + && acknowledged_bitrate + < self.config.bw_rampup_upper_bound_hold_threshold + * self.last_hold_info.rate + { + rampup_factor = self.config.bw_rampup_upper_bound_in_hold_factor; + } + + best_candidate.loss_limited_bandwidth = std::cmp::max( + self.current_best_estimate.loss_limited_bandwidth, + std::cmp::min( + best_candidate.loss_limited_bandwidth, + rampup_factor * (acknowledged_bitrate), + ), + ); + // Increase current estimate by at least 1kbps to make sure that the state + // will be switched to Increasing, thus padding is triggered. + if self.loss_based_result.state == LossBasedState::Decreasing + && best_candidate.loss_limited_bandwidth + == self.current_best_estimate.loss_limited_bandwidth + { + best_candidate.loss_limited_bandwidth = + self.current_best_estimate.loss_limited_bandwidth + + DataRate::from_bits_per_sec(1); + } + } + } + + let bounded_bandwidth_estimate: DataRate = if self.delay_based_estimate.is_finite() { + self.get_instant_lower_bound().max( + [ + best_candidate.loss_limited_bandwidth, + self.get_instant_upper_bound(), + self.delay_based_estimate, + ] + .iter() + .min() + .cloned() + .unwrap(), + ) + } else { + std::cmp::max( + self.get_instant_lower_bound(), + std::cmp::min( + best_candidate.loss_limited_bandwidth, + self.get_instant_upper_bound(), + ), + ) + }; + if self.config.bound_best_candidate + && bounded_bandwidth_estimate < best_candidate.loss_limited_bandwidth + { + // If network is lossy, cap the best estimate by the instant upper bound, + // e.g. 450kbps if loss rate is 50%. + // Otherwise, cap the estimate by the delay-based estimate or max_bitrate. + self.current_best_estimate.loss_limited_bandwidth = bounded_bandwidth_estimate; + self.current_best_estimate.inherent_loss = 0.0; + } else { + self.current_best_estimate = best_candidate; + if self.config.lower_bound_by_acked_rate_factor > 0.0 { + self.current_best_estimate.loss_limited_bandwidth = std::cmp::max( + self.current_best_estimate.loss_limited_bandwidth, + self.get_instant_lower_bound(), + ); + } + } + + if self.loss_based_result.state == LossBasedState::Decreasing + && self.last_hold_info.timestamp > self.last_send_time_most_recent_observation + && bounded_bandwidth_estimate < self.delay_based_estimate + { + // Ensure that acked rate is the lower bound of HOLD rate. + if self.config.lower_bound_by_acked_rate_factor > 0.0 { + self.last_hold_info.rate = + std::cmp::max(self.get_instant_lower_bound(), self.last_hold_info.rate); + } + // BWE is not allowed to increase above the HOLD rate. The purpose of + // HOLD is to not immediately ramp up BWE to a rate that may cause loss. + self.loss_based_result.bandwidth_estimate = + std::cmp::min(self.last_hold_info.rate, bounded_bandwidth_estimate); + return; + } + + if self.is_estimate_increasing_when_loss_limited( + /*old_estimate=*/ self.loss_based_result.bandwidth_estimate, + /*new_estimate=*/ bounded_bandwidth_estimate, + ) && self.can_keep_increasing_state(bounded_bandwidth_estimate) + && bounded_bandwidth_estimate < self.delay_based_estimate + && bounded_bandwidth_estimate < self.max_bitrate + { + if self.config.padding_duration > TimeDelta::zero() + && bounded_bandwidth_estimate > self.last_padding_info.padding_rate + { + // Start a new padding duration. + self.last_padding_info.padding_rate = bounded_bandwidth_estimate; + self.last_padding_info.padding_timestamp = + self.last_send_time_most_recent_observation; + } + self.loss_based_result.state = if self.config.padding_duration > TimeDelta::zero() { + LossBasedState::IncreaseUsingPadding + } else { + LossBasedState::Increasing + }; + } else if bounded_bandwidth_estimate < self.delay_based_estimate + && bounded_bandwidth_estimate < self.max_bitrate + { + if self.loss_based_result.state != LossBasedState::Decreasing + && self.config.hold_duration_factor > 0.0 + { + tracing::debug!( + "Switch to HOLD. Bounded BWE: {:?}, duration: {:?}", + bounded_bandwidth_estimate, + self.last_hold_info.duration + ); + self.last_hold_info = HoldInfo { + timestamp: self.last_send_time_most_recent_observation + + self.last_hold_info.duration, + duration: std::cmp::min( + Self::MAX_HOLD_DURATION, + self.last_hold_info.duration * self.config.hold_duration_factor, + ), + rate: bounded_bandwidth_estimate, + }; + } + + self.last_padding_info = PaddingInfo::default(); + self.loss_based_result.state = LossBasedState::Decreasing; + } else { + // Reset the HOLD info if delay based estimate works to avoid getting + // stuck in low bitrate. + self.last_hold_info = HoldInfo { + timestamp: Timestamp::minus_infinity(), + duration: Self::INIT_HOLD_DURATION, + rate: DataRate::plus_infinity(), + }; + self.last_padding_info = PaddingInfo::default(); + self.loss_based_result.state = LossBasedState::DelayBasedEstimate; + } + self.loss_based_result.bandwidth_estimate = bounded_bandwidth_estimate; + + if self.is_in_loss_limited_state() + && (self.recovering_after_loss_timestamp.is_infinite() + || self.recovering_after_loss_timestamp + self.config.delayed_increase_window + < self.last_send_time_most_recent_observation) + { + self.bandwidth_limit_in_current_window = std::cmp::max( + CONGESTION_CONTROLLER_MIN_BITRATE, + self.current_best_estimate.loss_limited_bandwidth * self.config.max_increase_factor, + ); + self.recovering_after_loss_timestamp = self.last_send_time_most_recent_observation; + } + } + // For unit testing only. + #[cfg(test)] + pub fn set_bandwidth_estimate(&mut self, bandwidth_estimate: DataRate) { + if bandwidth_estimate.is_finite() { + self.current_best_estimate.loss_limited_bandwidth = bandwidth_estimate; + self.loss_based_result = LossBasedBweV2Result { + bandwidth_estimate, + state: LossBasedState::DelayBasedEstimate, + }; + } else { + tracing::warn!( + "The bandwidth estimate must be finite: {:?}", + bandwidth_estimate + ); + } + } + + // Returns `0.0` if not enough loss statistics have been received. + fn update_average_reported_loss_ratio(&mut self) { + self.average_reported_loss_ratio = if self.config.use_byte_loss_rate { + self.calculate_average_reported_byte_loss_ratio() + } else { + self.calculate_average_reported_packet_loss_ratio() + }; + } + + fn calculate_average_reported_packet_loss_ratio(&self) -> f64 { + if self.num_observations == 0 { + return 0.0; + } + + let mut num_packets: f64 = 0.0; + let mut num_lost_packets: f64 = 0.0; + for observation in &self.observations { + if !observation.is_initialized() { + continue; + } + + let id: usize = observation.id.try_into().unwrap(); + let instant_temporal_weight: f64 = + self.instant_upper_bound_temporal_weights[(self.num_observations - 1) - id]; + num_packets += instant_temporal_weight * observation.num_packets as f64; + num_lost_packets += instant_temporal_weight * observation.num_lost_packets as f64; + } + + num_lost_packets / num_packets + } + // Calculates the average loss ratio over the last `observation_window_size` + // observations but skips the observation with min and max loss ratio in order + // to filter out loss spikes. + fn calculate_average_reported_byte_loss_ratio(&self) -> f64 { + if self.num_observations == 0 { + return 0.0; + } + + let mut total_bytes: DataSize = DataSize::zero(); + let mut lost_bytes: DataSize = DataSize::zero(); + let mut min_loss_rate: f64 = 1.0; + let mut max_loss_rate: f64 = 0.0; + let mut min_lost_bytes: DataSize = DataSize::zero(); + let mut max_lost_bytes: DataSize = DataSize::zero(); + let mut min_bytes_received: DataSize = DataSize::zero(); + let mut max_bytes_received: DataSize = DataSize::zero(); + let mut send_rate_of_max_loss_observation: DataRate = DataRate::zero(); + for observation in &self.observations { + if !observation.is_initialized() { + continue; + } + + let id: usize = observation.id.try_into().unwrap(); + let instant_temporal_weight: f64 = + self.instant_upper_bound_temporal_weights[(self.num_observations - 1) - id]; + total_bytes += instant_temporal_weight * observation.size; + lost_bytes += instant_temporal_weight * observation.lost_size; + + let loss_rate: f64 = if !observation.size.is_zero() { + observation.lost_size / observation.size + } else { + 0.0 + }; + if self.num_observations > 3 { + if loss_rate > max_loss_rate { + max_loss_rate = loss_rate; + max_lost_bytes = instant_temporal_weight * observation.lost_size; + max_bytes_received = instant_temporal_weight * observation.size; + send_rate_of_max_loss_observation = observation.sending_rate; + } + if loss_rate < min_loss_rate { + min_loss_rate = loss_rate; + min_lost_bytes = instant_temporal_weight * observation.lost_size; + min_bytes_received = instant_temporal_weight * observation.size; + } + } + } + if self.get_median_sending_rate() * self.config.median_sending_rate_factor + <= send_rate_of_max_loss_observation + { + // If the median sending rate is less than half of the sending rate of the + // observation with max loss rate, i.e. we suddenly send a lot of data, then + // the loss rate might not be due to a spike. + return lost_bytes / total_bytes; + } + + if total_bytes == max_bytes_received + min_bytes_received { + // It could happen if the observation window was 2. + return lost_bytes / total_bytes; + } + + (lost_bytes - min_lost_bytes - max_lost_bytes) + / (total_bytes - max_bytes_received - min_bytes_received) + } + + fn get_candidates(&self, in_alr: bool) -> Vec { + let best_estimate: ChannelParameters = self.current_best_estimate.clone(); + let mut bandwidths: Vec = Vec::new(); + for candidate_factor in &self.config.candidate_factors { + bandwidths.push(*candidate_factor * best_estimate.loss_limited_bandwidth); + } + + if let Some(acknowledged_bitrate) = self.acknowledged_bitrate + && self.config.acked_rate_candidate + && (!(self.config.not_use_acked_rate_in_alr && in_alr) + || (self.config.padding_duration > TimeDelta::zero() + && self.last_padding_info.padding_timestamp + self.config.padding_duration + >= self.last_send_time_most_recent_observation)) + { + bandwidths.push(acknowledged_bitrate * self.config.bw_backoff_lower_bound_factor); + } + + if self.delay_based_estimate.is_finite() + && self.config.delay_based_candidate + && self.delay_based_estimate > best_estimate.loss_limited_bandwidth + { + bandwidths.push(self.delay_based_estimate); + } + + if in_alr + && self.config.upper_bound_candidate_in_alr + && best_estimate.loss_limited_bandwidth > self.get_instant_upper_bound() + { + bandwidths.push(self.get_instant_upper_bound()); + } + + let candidate_bandwidth_upper_bound: DataRate = self.get_candidate_bandwidth_upper_bound(); + + let mut candidates: Vec = Vec::new(); + for bandwidth in &bandwidths { + let mut candidate: ChannelParameters = best_estimate.clone(); + candidate.loss_limited_bandwidth = std::cmp::min( + *bandwidth, + std::cmp::max( + best_estimate.loss_limited_bandwidth, + candidate_bandwidth_upper_bound, + ), + ); + candidate.inherent_loss = self.get_feasible_inherent_loss(&candidate); + candidates.push(candidate); + } + candidates + } + + fn get_candidate_bandwidth_upper_bound(&self) -> DataRate { + let mut candidate_bandwidth_upper_bound: DataRate = self.max_bitrate; + if self.is_in_loss_limited_state() && self.bandwidth_limit_in_current_window.is_finite() { + candidate_bandwidth_upper_bound = self.bandwidth_limit_in_current_window; + } + + let acknowledged_bitrate = match self.acknowledged_bitrate { + Some(rate) => rate, + None => return candidate_bandwidth_upper_bound, + }; + + if self.config.bw_rampup_accel_max_factor > 0.0 { + let time_since_bandwidth_reduced: TimeDelta = std::cmp::min( + self.config.bw_rampup_accel_maxout_time, + std::cmp::max( + TimeDelta::zero(), + self.last_send_time_most_recent_observation - self.last_time_estimate_reduced, + ), + ); + let rampup_acceleration: f64 = self.config.bw_rampup_accel_max_factor + * time_since_bandwidth_reduced + / self.config.bw_rampup_accel_maxout_time; + + candidate_bandwidth_upper_bound += rampup_acceleration * (acknowledged_bitrate); + } + candidate_bandwidth_upper_bound + } + + fn get_derivatives(&self, channel_parameters: &ChannelParameters) -> Derivatives { + let mut derivatives = Derivatives::default(); + + for observation in &self.observations { + if !observation.is_initialized() { + continue; + } + + let loss_probability: f64 = get_loss_probability( + channel_parameters.inherent_loss, + channel_parameters.loss_limited_bandwidth, + observation.sending_rate, + ); + + let id: usize = observation.id.try_into().unwrap(); + let temporal_weight: f64 = self.temporal_weights[(self.num_observations - 1) - id]; + if self.config.use_byte_loss_rate { + derivatives.first += temporal_weight + * ((to_kilo_bytes(observation.lost_size) / loss_probability) + - (to_kilo_bytes(observation.size - observation.lost_size) + / (1.0 - loss_probability))); + derivatives.second -= temporal_weight + * ((to_kilo_bytes(observation.lost_size) / loss_probability.powf(2.0)) + + (to_kilo_bytes(observation.size - observation.lost_size) + / (1.0 - loss_probability).powf(2.0))); + } else { + derivatives.first += temporal_weight + * ((observation.num_lost_packets as f64 / loss_probability) + - (observation.num_received_packets as f64 / (1.0 - loss_probability))); + derivatives.second -= temporal_weight + * ((observation.num_lost_packets as f64 / loss_probability.powf(2.0)) + + (observation.num_received_packets as f64 + / (1.0 - loss_probability).powf(2.0))); + } + } + + if derivatives.second >= 0.0 { + tracing::error!( + "The second derivative is mathematically guaranteed to be negative but is {}.", + derivatives.second + ); + derivatives.second = -1.0e-6; + } + + derivatives + } + + fn get_feasible_inherent_loss(&self, channel_parameters: &ChannelParameters) -> f64 { + channel_parameters + .inherent_loss + .max(self.config.inherent_loss_lower_bound) + .min(self.get_inherent_loss_upper_bound(channel_parameters.loss_limited_bandwidth)) + } + + fn get_inherent_loss_upper_bound(&self, bandwidth: DataRate) -> f64 { + if bandwidth.is_zero() { + return 1.0; + } + + let inherent_loss_upper_bound: f64 = self.config.inherent_loss_upper_bound_offset + + self.config.inherent_loss_upper_bound_bw_balance / bandwidth; + inherent_loss_upper_bound.min(1.0) + } + + fn adjust_bias_factor(&self, loss_rate: f64, bias_factor: f64) -> f64 { + bias_factor * (self.config.loss_threshold_of_high_bandwidth_preference - loss_rate) + / (self.config.bandwidth_preference_smoothing_factor + + (self.config.loss_threshold_of_high_bandwidth_preference - loss_rate).abs()) + } + + fn get_high_bandwidth_bias(&self, bandwidth: DataRate) -> f64 { + if bandwidth.is_finite() { + return self.adjust_bias_factor( + self.average_reported_loss_ratio, + self.config.higher_bw_bias_factor, + ) * bandwidth.kbps_float() + + self.adjust_bias_factor( + self.average_reported_loss_ratio, + self.config.higher_log_bw_bias_factor, + ) * (1.0 + bandwidth.kbps_float()).ln(); + } + 0.0 + } + + fn get_objective(&self, channel_parameters: &ChannelParameters) -> f64 { + let mut objective: f64 = 0.0; + + let high_bandwidth_bias: f64 = + self.get_high_bandwidth_bias(channel_parameters.loss_limited_bandwidth); + + for observation in &self.observations { + if !observation.is_initialized() { + continue; + } + + let loss_probability: f64 = get_loss_probability( + channel_parameters.inherent_loss, + channel_parameters.loss_limited_bandwidth, + observation.sending_rate, + ); + + let id: usize = observation.id.try_into().unwrap(); + let temporal_weight: f64 = self.temporal_weights[(self.num_observations - 1) - id]; + if self.config.use_byte_loss_rate { + objective += temporal_weight + * ((to_kilo_bytes(observation.lost_size) * loss_probability.ln()) + + (to_kilo_bytes(observation.size - observation.lost_size) + * (1.0 - loss_probability).ln())); + objective += + temporal_weight * high_bandwidth_bias * to_kilo_bytes(observation.size); + } else { + objective += temporal_weight + * ((observation.num_lost_packets as f64 * (loss_probability).ln()) + + (observation.num_received_packets as f64 + * (1.0 - loss_probability).ln())); + objective += temporal_weight * high_bandwidth_bias * observation.num_packets as f64; + } + } + + objective + } + + fn get_sending_rate(&self, instantaneous_sending_rate: DataRate) -> DataRate { + if self.num_observations == 0 { + return instantaneous_sending_rate; + } + + let most_recent_observation_idx: usize = + (self.num_observations - 1) % self.config.observation_window_size; + let most_recent_observation = &self.observations[most_recent_observation_idx]; + let sending_rate_previous_observation: DataRate = most_recent_observation.sending_rate; + + self.config.sending_rate_smoothing_factor * sending_rate_previous_observation + + (1.0 - self.config.sending_rate_smoothing_factor) * instantaneous_sending_rate + } + + fn get_instant_upper_bound(&self) -> DataRate { + self.cached_instant_upper_bound.unwrap_or(self.max_bitrate) + } + + fn calculate_instant_upper_bound(&mut self) { + let mut instant_limit: DataRate = self.max_bitrate; + if self.average_reported_loss_ratio > self.config.instant_upper_bound_loss_offset { + instant_limit = self.config.instant_upper_bound_bw_balance + / (self.average_reported_loss_ratio - self.config.instant_upper_bound_loss_offset); + } + + self.cached_instant_upper_bound = Some(instant_limit); + } + + fn get_instant_lower_bound(&self) -> DataRate { + self.cached_instant_lower_bound.unwrap_or(DataRate::zero()) + } + + fn calculate_instant_lower_bound(&mut self) { + let mut instance_lower_bound: DataRate = DataRate::zero(); + + if let Some(acknowledged_bitrate) = self.acknowledged_bitrate + && self.config.lower_bound_by_acked_rate_factor > 0.0 + { + instance_lower_bound = + self.config.lower_bound_by_acked_rate_factor * acknowledged_bitrate; + } + + if self.min_bitrate.is_finite() { + instance_lower_bound = std::cmp::max(instance_lower_bound, self.min_bitrate); + } + self.cached_instant_lower_bound = Some(instance_lower_bound); + } + + fn newtons_method_update(&self, channel_parameters: &mut ChannelParameters) { + if self.num_observations == 0 { + return; + } + + for _ in 0..self.config.newton_iterations { + let derivatives: Derivatives = self.get_derivatives(channel_parameters); + channel_parameters.inherent_loss -= + self.config.newton_step_size * derivatives.first / derivatives.second; + channel_parameters.inherent_loss = self.get_feasible_inherent_loss(channel_parameters); + } + } + + // Returns false if no observation was created. + fn push_back_observation(&mut self, packet_results: &[PacketResult]) -> bool { + if packet_results.is_empty() { + return false; + } + + self.partial_observation.num_packets += packet_results.len(); + let mut last_send_time: Timestamp = Timestamp::minus_infinity(); + let mut first_send_time: Timestamp = Timestamp::plus_infinity(); + for packet in packet_results { + if !packet.is_received() { + self.partial_observation.num_lost_packets += 1; + self.partial_observation.lost_size += packet.sent_packet.size; + } + self.partial_observation.size += packet.sent_packet.size; + last_send_time = std::cmp::max(last_send_time, packet.sent_packet.send_time); + first_send_time = std::cmp::min(first_send_time, packet.sent_packet.send_time); + } + + // This is the first packet report we have received. + if !self.last_send_time_most_recent_observation.is_finite() { + self.last_send_time_most_recent_observation = first_send_time; + } + + let observation_duration: TimeDelta = + last_send_time - self.last_send_time_most_recent_observation; + // Too small to be meaningful. + // To consider: what if it is too long?, i.e. we did not receive any packets + // for a long time, then all the packets we received are too old. + if observation_duration <= TimeDelta::zero() + || observation_duration < self.config.observation_duration_lower_bound + { + return false; + } + + self.last_send_time_most_recent_observation = last_send_time; + + let mut observation = Observation::default(); + observation.num_packets = self.partial_observation.num_packets; + observation.num_lost_packets = self.partial_observation.num_lost_packets; + observation.num_received_packets = observation.num_packets - observation.num_lost_packets; + observation.sending_rate = + self.get_sending_rate(self.partial_observation.size / observation_duration); + observation.lost_size = self.partial_observation.lost_size; + observation.size = self.partial_observation.size; + observation.id = self.num_observations as _; + self.observations[self.num_observations % self.config.observation_window_size] = + observation; + self.num_observations += 1; + + self.partial_observation = PartialObservation::default(); + self.update_average_reported_loss_ratio(); + self.calculate_instant_upper_bound(); + true + } + + fn is_estimate_increasing_when_loss_limited( + &mut self, + old_estimate: DataRate, + new_estimate: DataRate, + ) -> bool { + (old_estimate < new_estimate + || (old_estimate == new_estimate + && (self.loss_based_result.state == LossBasedState::Increasing + || self.loss_based_result.state == LossBasedState::IncreaseUsingPadding))) + && self.is_in_loss_limited_state() + } + + fn is_in_loss_limited_state(&self) -> bool { + self.loss_based_result.state != LossBasedState::DelayBasedEstimate + } + + fn can_keep_increasing_state(&self, estimate: DataRate) -> bool { + if self.config.padding_duration == TimeDelta::zero() + || self.loss_based_result.state != LossBasedState::IncreaseUsingPadding + { + return true; + } + + // Keep using the IncreaseUsingPadding if either the state has been + // IncreaseUsingPadding for less than PaddingDuration or the estimate + // increases. + self.last_padding_info.padding_timestamp + self.config.padding_duration + >= self.last_send_time_most_recent_observation + || self.last_padding_info.padding_rate < estimate + } + + fn get_median_sending_rate(&self) -> DataRate { + let mut sending_rates: Vec = Vec::new(); + for observation in &self.observations { + if !observation.is_initialized() + || !observation.sending_rate.is_finite() + || observation.sending_rate.is_zero() + { + continue; + } + sending_rates.push(observation.sending_rate); + } + if sending_rates.is_empty() { + return DataRate::zero(); + } + sending_rates.sort(); + if sending_rates.len().is_multiple_of(2) { + return (sending_rates[sending_rates.len() / 2 - 1] + + sending_rates[sending_rates.len() / 2]) + / 2; + } + sending_rates[sending_rates.len() / 2] + } +} + +fn to_kilo_bytes(datasize: DataSize) -> f64 { + datasize.bytes_float() / 1000.0 +} + +fn get_loss_probability( + mut inherent_loss: f64, + loss_limited_bandwidth: DataRate, + sending_rate: DataRate, +) -> f64 { + if !(0.0..=1.0).contains(&inherent_loss) { + tracing::warn!("The inherent loss must be in [0,1]: {}", inherent_loss); + inherent_loss = inherent_loss.clamp(0.0, 1.0) + } + if !sending_rate.is_finite() { + tracing::warn!("The sending rate must be finite: {:?}", sending_rate); + } + if !loss_limited_bandwidth.is_finite() { + tracing::warn!( + "The loss limited bandwidth must be finite: {:?}", + loss_limited_bandwidth + ); + } + + let mut loss_probability: f64 = inherent_loss; + if sending_rate.is_finite() + && loss_limited_bandwidth.is_finite() + && (sending_rate > loss_limited_bandwidth) + { + loss_probability += + (1.0 - inherent_loss) * (sending_rate - loss_limited_bandwidth) / sending_rate; + } + loss_probability.clamp(1.0e-6, 1.0 - 1.0e-6) +} + +#[cfg(test)] +mod test { + + use crate::gcc::api::transport::SentPacket; + + use super::*; + + const OBSERVATION_DURATION_LOWER_BOUND: TimeDelta = TimeDelta::from_millis(250); + const DELAYED_INCREASE_WINDOW: TimeDelta = TimeDelta::from_millis(300); + const MAX_INCREASE_FACTOR: f64 = 1.5; + const PACKET_SIZE: i64 = 15_000; + + fn config(enabled: bool, valid: bool) -> LossBasedBweV2Config { + LossBasedBweV2Config { + enabled, + bw_rampup_upper_bound_factor: if valid { 1.2 } else { 0.0 }, + candidate_factors: vec![1.1, 1.0, 0.95], + higher_bw_bias_factor: 0.01, + inherent_loss_lower_bound: 0.001, + inherent_loss_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(14), + inherent_loss_upper_bound_offset: 0.9, + initial_inherent_loss_estimate: 0.01, + newton_iterations: 2, + newton_step_size: 0.4, + observation_window_size: 15, + sending_rate_smoothing_factor: 0.01, + instant_upper_bound_temporal_weight_factor: 0.97, + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(90), + instant_upper_bound_loss_offset: 0.1, + temporal_weight_factor: 0.98, + min_num_observations: 1, + observation_duration_lower_bound: OBSERVATION_DURATION_LOWER_BOUND, + max_increase_factor: MAX_INCREASE_FACTOR, + delayed_increase_window: DELAYED_INCREASE_WINDOW, + ..Default::default() + } + } + + fn short_observation_config() -> LossBasedBweV2Config { + LossBasedBweV2Config { + min_num_observations: 1, + observation_window_size: 2, + ..Default::default() + } + } + + #[derive(Default)] + struct LossBasedBweV2Test {} + + impl LossBasedBweV2Test { + fn create_packet_results_with_received_packets( + &mut self, + first_packet_timestamp: Timestamp, + ) -> Vec { + let mut enough_feedback = vec![PacketResult::default(); 2]; + enough_feedback[0].sent_packet.size = DataSize::from_bytes(PACKET_SIZE); + enough_feedback[1].sent_packet.size = DataSize::from_bytes(PACKET_SIZE); + enough_feedback[0].sent_packet.send_time = first_packet_timestamp; + enough_feedback[1].sent_packet.send_time = + first_packet_timestamp + OBSERVATION_DURATION_LOWER_BOUND; + enough_feedback[0].receive_time = + first_packet_timestamp + OBSERVATION_DURATION_LOWER_BOUND; + enough_feedback[1].receive_time = + first_packet_timestamp + 2 * OBSERVATION_DURATION_LOWER_BOUND; + enough_feedback + } + + fn create_packet_results_with_10p_packet_loss_rate( + &mut self, + first_packet_timestamp: Timestamp, + lost_packet_size: DataSize, + ) -> Vec { + let mut enough_feedback = vec![PacketResult::default(); 10]; + for (i, packet) in enough_feedback.iter_mut().enumerate() { + packet.sent_packet.size = DataSize::from_bytes(PACKET_SIZE); + packet.sent_packet.send_time = + first_packet_timestamp + (i) as i64 * OBSERVATION_DURATION_LOWER_BOUND; + packet.receive_time = + first_packet_timestamp + (i + 1) as i64 * OBSERVATION_DURATION_LOWER_BOUND; + } + enough_feedback[9].receive_time = Timestamp::plus_infinity(); + enough_feedback[9].sent_packet.size = lost_packet_size; + enough_feedback + } + + fn create_packet_results_with_50p_packet_loss_rate( + &mut self, + first_packet_timestamp: Timestamp, + ) -> Vec { + let mut enough_feedback = vec![PacketResult::default(); 2]; + enough_feedback[0].sent_packet.size = DataSize::from_bytes(PACKET_SIZE); + enough_feedback[1].sent_packet.size = DataSize::from_bytes(PACKET_SIZE); + enough_feedback[0].sent_packet.send_time = first_packet_timestamp; + enough_feedback[1].sent_packet.send_time = + first_packet_timestamp + OBSERVATION_DURATION_LOWER_BOUND; + enough_feedback[0].receive_time = + first_packet_timestamp + OBSERVATION_DURATION_LOWER_BOUND; + enough_feedback[1].receive_time = Timestamp::plus_infinity(); + enough_feedback + } + + fn create_packet_results_with_100p_loss_rate( + &mut self, + first_packet_timestamp: Timestamp, + num_packets: usize, /* =2 */ + ) -> Vec { + let mut enough_feedback = Vec::with_capacity(num_packets); + + for i in 0..(num_packets - 1) { + enough_feedback.push(PacketResult { + sent_packet: SentPacket { + size: DataSize::from_bytes(PACKET_SIZE), + send_time: first_packet_timestamp + TimeDelta::from_millis(i as i64 * 10), + ..Default::default() + }, + receive_time: Timestamp::plus_infinity(), + ambiguous_receive_time: false, + }); + } + + enough_feedback.push(PacketResult { + sent_packet: SentPacket { + size: DataSize::from_bytes(PACKET_SIZE), + send_time: first_packet_timestamp + OBSERVATION_DURATION_LOWER_BOUND, + ..Default::default() + }, + receive_time: Timestamp::plus_infinity(), + ambiguous_receive_time: false, + }); + + enough_feedback + } + } + + #[test] + fn enabled_when_given_valid_configuration_values() { + let config = config(true, true); + let loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + assert!(loss_based_bandwidth_estimator.is_enabled()); + } + + #[test] + fn disabled_when_given_disabled_configuration() { + let config = config(false, true); + let loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + assert!(!loss_based_bandwidth_estimator.is_enabled()); + } + + #[test] + fn disabled_when_given_non_valid_configuration_values() { + let config = config(true, false); + let loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + assert!(!loss_based_bandwidth_estimator.is_enabled()); + } + + #[test] + fn disabled_when_given_non_positive_candidate_factor() { + let config = LossBasedBweV2Config { + candidate_factors: vec![-1.3, 1.1], + ..Default::default() + }; + let loss_based_bandwidth_estimator_1 = LossBasedBweV2::new(config); + assert!(!loss_based_bandwidth_estimator_1.is_enabled()); + + let config = LossBasedBweV2Config { + candidate_factors: vec![0.0, 1.1], + ..Default::default() + }; + let loss_based_bandwidth_estimator_2 = LossBasedBweV2::new(config); + assert!(!loss_based_bandwidth_estimator_2.is_enabled()); + } + + #[test] + fn disabled_when_given_configuration_that_does_not_allow_generating_candidates() { + let config = LossBasedBweV2Config { + candidate_factors: vec![1.0], + acked_rate_candidate: false, + delay_based_candidate: false, + ..Default::default() + }; + let loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + assert!(!loss_based_bandwidth_estimator.is_enabled()); + } + + #[test] + fn returns_delay_based_estimate_when_disabled() { + let config = config(false, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + /*packet_results=*/ &[], + /*delay_based_estimate=*/ DataRate::from_kilobits_per_sec(100), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(100) + ); + } + + #[test] + fn returns_delay_based_estimate_when_when_given_non_valid_configuration_values() { + let config = config(true, false); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + /*packet_results=*/ &[], + /*delay_based_estimate=*/ DataRate::from_kilobits_per_sec(100), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(100) + ); + } + + #[test] + fn bandwidth_estimate_given_initialization_and_then_feedback() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback: Vec = test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert!(loss_based_bandwidth_estimator.is_ready()); + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + .is_finite() + ); + } + + #[test] + fn no_bandwidth_estimate_given_no_initialization() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback: Vec = test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert!(!loss_based_bandwidth_estimator.is_ready()); + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + .is_plus_infinity() + ); + } + + #[test] + fn no_bandwidth_estimate_given_not_enough_feedback() { + // Create packet results where the observation duration is less than the lower + // bound. + let mut not_enough_feedback = [PacketResult::default(); 2]; + not_enough_feedback[0].sent_packet.size = DataSize::from_bytes(15_000); + not_enough_feedback[1].sent_packet.size = DataSize::from_bytes(15_000); + not_enough_feedback[0].sent_packet.send_time = Timestamp::zero(); + not_enough_feedback[1].sent_packet.send_time = + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND / 2; + not_enough_feedback[0].receive_time = + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND / 2; + not_enough_feedback[1].receive_time = Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND; + + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + assert!(!loss_based_bandwidth_estimator.is_ready()); + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + .is_plus_infinity() + ); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + ¬_enough_feedback, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert!(!loss_based_bandwidth_estimator.is_ready()); + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + .is_plus_infinity() + ); + } + + #[test] + fn set_value_is_the_estimate_until_additional_feedback_has_been_received() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + 2 * OBSERVATION_DURATION_LOWER_BOUND, + ); + + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert_ne!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(600) + ); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(600) + ); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert_ne!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(600) + ); + } + + #[test] + fn set_acknowledged_bitrate_only_affects_the_bwe_when_additional_feedback_is_given() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + 2 * OBSERVATION_DURATION_LOWER_BOUND, + ); + + let config = config(true, true); + let mut loss_based_bandwidth_estimator_1 = LossBasedBweV2::new(config.clone()); + let mut loss_based_bandwidth_estimator_2 = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator_1 + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator_2 + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator_1.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + loss_based_bandwidth_estimator_2.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert_eq!( + loss_based_bandwidth_estimator_1 + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(660) + ); + + loss_based_bandwidth_estimator_1 + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(900)); + + assert_eq!( + loss_based_bandwidth_estimator_1 + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(660) + ); + + loss_based_bandwidth_estimator_1.update_bandwidth_estimate( + &enough_feedback_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + loss_based_bandwidth_estimator_2.update_bandwidth_estimate( + &enough_feedback_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert_ne!( + loss_based_bandwidth_estimator_1 + .get_loss_based_result() + .bandwidth_estimate, + loss_based_bandwidth_estimator_2 + .get_loss_based_result() + .bandwidth_estimate + ); + } + + #[test] + fn bandwidth_estimate_is_capped_to_be_tcp_fair_given_too_high_loss_rate() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_no_received_packets: Vec = test + .create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_no_received_packets, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(100) + ); + } + + // When network is normal, estimate can increase but never be higher than + // the delay based estimate. + #[test] + fn bandwidth_estimate_capped_by_delay_based_estimate_when_network_normal() { + let mut test = LossBasedBweV2Test::default(); + // Create two packet results, network is in normal state, 100% packets are + // received, and no delay increase. + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + 2 * OBSERVATION_DURATION_LOWER_BOUND, + ); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + // If the delay based estimate is infinity, then loss based estimate increases + // and not bounded by delay based estimate. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + > DataRate::from_kilobits_per_sec(600) + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + /*delay_based_estimate=*/ DataRate::from_kilobits_per_sec(500), + /*in_alr=*/ false, + ); + // If the delay based estimate is not infinity, then loss based estimate is + // bounded by delay based estimate. + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(500) + ); + } + + // When loss based bwe receives a strong signal of overusing and an increase in + // loss rate, it should acked bitrate for emegency backoff. + #[test] + fn use_acked_bitrate_for_emegency_back_off() { + let mut test = LossBasedBweV2Test::default(); + // Create two packet results, first packet has 50% loss rate, second packet + // has 100% loss rate. + let enough_feedback_1: Vec = test + .create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + 2 * OBSERVATION_DURATION_LOWER_BOUND, + 2, + ); + + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + let acked_bitrate: DataRate = DataRate::from_kilobits_per_sec(300); + loss_based_bandwidth_estimator.set_acknowledged_bitrate(acked_bitrate); + // Update estimate when network is overusing, and 50% loss rate. + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + // Update estimate again when network is continuously overusing, and 100% + // loss rate. + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + // The estimate bitrate now is backed off based on acked bitrate. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + <= acked_bitrate + ); + } + + // When receiving the same packet feedback, loss based bwe ignores the feedback + // and returns the current estimate. + #[test] + fn no_bwe_change_if_observation_duration_unchanged() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(300)); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + let estimate_1: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + + // Use the same feedback and check if the estimate is unchanged. + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + let estimate_2: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + assert_eq!(estimate_2, estimate_1); + } + + // When receiving feedback of packets that were sent within an observation + // duration, and network is in the normal state, loss based bwe returns the + // current estimate. + #[test] + fn no_bwe_change_if_observation_duration_is_small_and_network_normal() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND - TimeDelta::from_millis(1), + ); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + let estimate_1: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + let estimate_2: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + assert_eq!(estimate_2, estimate_1); + } + + // When receiving feedback of packets that were sent within an observation + // duration, and network is in the underusing state, loss based bwe returns the + // current estimate. + #[test] + fn no_bwe_increase_if_observation_duration_is_small_and_network_underusing() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND - TimeDelta::from_millis(1), + ); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + let estimate_1: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + let estimate_2: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + assert!(estimate_2 <= estimate_1); + } + + #[test] + fn increase_to_delay_based_estimate_if_no_loss_or_delay_increase() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + 2 * OBSERVATION_DURATION_LOWER_BOUND, + ); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + delay_based_estimate + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + delay_based_estimate, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + delay_based_estimate + ); + } + + #[test] + fn increase_by_hold_factor_after_loss_based_bwe_backs_off() { + let mut test = LossBasedBweV2Test::default(); + let config = short_observation_config(); + + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + let acked_rate: DataRate = DataRate::from_kilobits_per_sec(100); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.set_acknowledged_bitrate(acked_rate); + + // Create some loss to create the loss limited scenario. + let enough_feedback_1: Vec = test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + let result_at_loss: LossBasedBweV2Result = + loss_based_bandwidth_estimator.get_loss_based_result(); + + // Network recovers after loss. + let mut feedback_count: i64 = 2; + while loss_based_bandwidth_estimator.get_loss_based_result().state + != LossBasedState::IncreaseUsingPadding + { + assert!(feedback_count < 100, "never reached IncreaseUsingPadding"); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * feedback_count, + ), + delay_based_estimate, + /*in_alr=*/ false, + ); + feedback_count += 1; + } + + let result_after_recovery: LossBasedBweV2Result = + loss_based_bandwidth_estimator.get_loss_based_result(); + // BwRampupUpperBoundInHoldFactor is 1.2. + assert_eq!( + result_after_recovery.bandwidth_estimate, + result_at_loss.bandwidth_estimate * 1.2 + ); + } + + #[test] + fn loss_based_state_is_delay_based_estimate_after_network_recovering() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + candidate_factors: vec![100.0, 1.0, 0.5], + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(10000), + max_increase_factor: 100.0, + not_increase_if_inherent_loss_less_than_average_loss: false, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(600); + let acked_rate: DataRate = DataRate::from_kilobits_per_sec(300); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.set_acknowledged_bitrate(acked_rate); + + // Create some loss to create the loss limited scenario. + let enough_feedback_1: Vec = test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + + // Network recovers after loss. + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + ); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + delay_based_estimate, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::DelayBasedEstimate + ); + + // Network recovers continuing. + let enough_feedback_3: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * 2, + ); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_3, + delay_based_estimate, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::DelayBasedEstimate + ); + } + + #[test] + fn loss_based_state_is_not_delay_based_estimate_if_delay_based_estimate_infinite() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + candidate_factors: vec![100.0, 1.0, 0.5], + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(10000), + max_increase_factor: 100.0, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + // Create some loss to create the loss limited scenario. + let enough_feedback_1: Vec = test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + + // Network recovers after loss. + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + ); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_ne!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::DelayBasedEstimate + ); + } + + // After loss based bwe backs off, the next estimate is capped by + // a factor of acked bitrate. + #[test] + fn increase_by_factor_of_acked_bitrate_after_loss_based_bwe_backs_off() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + loss_threshold_of_high_bandwidth_preference: 0.99, + bw_rampup_upper_bound_factor: 1.2, + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(10000), + ..short_observation_config() + }; + let enough_feedback_1: Vec = test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(300)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + let mut result: LossBasedBweV2Result = + loss_based_bandwidth_estimator.get_loss_based_result(); + let estimate_1: DataRate = result.bandwidth_estimate; + assert!(estimate_1.kbps() < 600); + + loss_based_bandwidth_estimator.set_acknowledged_bitrate(estimate_1 * 0.9); + + let mut feedback_count: i64 = 1; + while result.state != LossBasedState::IncreaseUsingPadding { + assert!(feedback_count < 100, "never reached IncreaseUsingPadding"); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + feedback_count * OBSERVATION_DURATION_LOWER_BOUND, + ), + delay_based_estimate, + /*in_alr=*/ false, + ); + feedback_count += 1; + result = loss_based_bandwidth_estimator.get_loss_based_result(); + } + assert_eq!(result.state, LossBasedState::IncreaseUsingPadding); + + // The estimate is capped by acked_bitrate * BwRampupUpperBoundFactor. + assert_eq!(result.bandwidth_estimate, estimate_1 * 0.9 * 1.2); + + // But if acked bitrate decreases, BWE does not decrease when there is no + // loss. + loss_based_bandwidth_estimator.set_acknowledged_bitrate(estimate_1 * 0.9); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + feedback_count * OBSERVATION_DURATION_LOWER_BOUND, + ), + delay_based_estimate, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + result.bandwidth_estimate + ); + } + + // Ensure that the state can switch to Increase even when the bandwidth is + // bounded by acked bitrate. + #[test] + fn ensure_increase_even_if_acked_bitrate_bound() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + loss_threshold_of_high_bandwidth_preference: 0.99, + bw_rampup_upper_bound_factor: 1.2, + // Set InstantUpperBoundBwBalance high to disable InstantUpperBound cap. + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(10000), + ..short_observation_config() + }; + let enough_feedback_1: Vec = test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(300)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + let mut result: LossBasedBweV2Result = + loss_based_bandwidth_estimator.get_loss_based_result(); + let estimate_1: DataRate = result.bandwidth_estimate; + assert!(estimate_1.kbps() < 600); + + // Set a low acked bitrate. + loss_based_bandwidth_estimator.set_acknowledged_bitrate(estimate_1 / 2); + + let mut feedback_count: i64 = 1; + while result.state != LossBasedState::IncreaseUsingPadding { + assert!(feedback_count < 100, "never reached IncreaseUsingPadding"); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + feedback_count * OBSERVATION_DURATION_LOWER_BOUND, + ), + delay_based_estimate, + /*in_alr=*/ false, + ); + feedback_count += 1; + result = loss_based_bandwidth_estimator.get_loss_based_result(); + } + + assert_eq!(result.state, LossBasedState::IncreaseUsingPadding); + // The estimate increases by 1kbps. + assert_eq!( + result.bandwidth_estimate, + estimate_1 + DataRate::from_bits_per_sec(1) + ); + } + + // After loss based bwe backs off, the estimate is bounded during the delayed + // window. + #[test] + fn estimate_bitrate_is_bounded_during_delayed_window_after_loss_based_bwe_backs_off() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test + .create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + DELAYED_INCREASE_WINDOW - TimeDelta::from_millis(2), + ); + let enough_feedback_3: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + DELAYED_INCREASE_WINDOW - TimeDelta::from_millis(1), + ); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(300)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + // Increase the acknowledged bitrate to make sure that the estimate is not + // capped too low. + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(5000)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + delay_based_estimate, + /*in_alr=*/ false, + ); + + // The estimate is capped by current_estimate * MaxIncreaseFactor because + // it recently backed off. + let estimate_2: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_3, + delay_based_estimate, + /*in_alr=*/ false, + ); + // The latest estimate is the same as the previous estimate since the sent + // packets were sent within the DelayedIncreaseWindow. + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + estimate_2 + ); + } + + // The estimate is not bounded after the delayed increase window. + #[test] + fn keep_increasing_estimate_after_delayed_increase_window() { + let mut test = LossBasedBweV2Test::default(); + let enough_feedback_1: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_2: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + DELAYED_INCREASE_WINDOW - TimeDelta::from_millis(1), + ); + let enough_feedback_3: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + DELAYED_INCREASE_WINDOW + TimeDelta::from_millis(1), + ); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(300)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + // Increase the acknowledged bitrate to make sure that the estimate is not + // capped too low. + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(5000)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_2, + delay_based_estimate, + /*in_alr=*/ false, + ); + + // The estimate is capped by current_estimate * MaxIncreaseFactor because it + // recently backed off. + let estimate_2: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_3, + delay_based_estimate, + /*in_alr=*/ false, + ); + // The estimate can continue increasing after the DelayedIncreaseWindow. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + >= estimate_2 + ); + } + + #[test] + fn not_increase_if_inherent_loss_less_than_average_loss() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + candidate_factors: vec![1.25], + not_increase_if_inherent_loss_less_than_average_loss: true, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + let enough_feedback_10p_loss_1: Vec = test + .create_packet_results_with_10p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + DataSize::from_bytes(PACKET_SIZE), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_10p_loss_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + let enough_feedback_10p_loss_2: Vec = test + .create_packet_results_with_10p_packet_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + DataSize::from_bytes(PACKET_SIZE), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_10p_loss_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + // Do not increase the bitrate because inherent loss is less than average loss + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(600) + ); + } + + #[test] + fn select_high_bandwidth_candidate_if_loss_rate_is_less_than_threshold() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + loss_threshold_of_high_bandwidth_preference: 0.20, + not_increase_if_inherent_loss_less_than_average_loss: false, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + let enough_feedback_10p_loss_1: Vec = test + .create_packet_results_with_10p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + DataSize::from_bytes(PACKET_SIZE), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_10p_loss_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + + let enough_feedback_10p_loss_2: Vec = test + .create_packet_results_with_10p_packet_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + DataSize::from_bytes(PACKET_SIZE), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_10p_loss_2, + delay_based_estimate, + /*in_alr=*/ false, + ); + + // Because LossThresholdOfHighBandwidthPreference is 20%, the average loss is + // 10%, bandwidth estimate should increase. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + > DataRate::from_kilobits_per_sec(600) + ); + } + + #[test] + fn select_low_bandwidth_candidate_if_loss_rate_is_is_higher_than_threshold() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + loss_threshold_of_high_bandwidth_preference: 0.05, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + let enough_feedback_10p_loss_1: Vec = test + .create_packet_results_with_10p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + DataSize::from_bytes(PACKET_SIZE), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_10p_loss_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + + let enough_feedback_10p_loss_2: Vec = test + .create_packet_results_with_10p_packet_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + DataSize::from_bytes(PACKET_SIZE), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_10p_loss_2, + delay_based_estimate, + /*in_alr=*/ false, + ); + + // Because LossThresholdOfHighBandwidthPreference is 5%, the average loss is + // 10%, bandwidth estimate should decrease. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + < DataRate::from_kilobits_per_sec(600) + ); + } + + #[test] + fn estimate_is_not_higher_than_max_bitrate() { + let mut test = LossBasedBweV2Test::default(); + let config = config(true, true); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_min_max_bitrate( + /*min_bitrate=*/ DataRate::from_kilobits_per_sec(10), + /*max_bitrate=*/ DataRate::from_kilobits_per_sec(1000), + ); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(1000)); + let enough_feedback: Vec = test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + <= DataRate::from_kilobits_per_sec(1000) + ); + } + + #[test] + fn not_back_off_to_acked_rate_in_alr() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(100), + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_min_max_bitrate( + /*min_bitrate=*/ DataRate::from_kilobits_per_sec(10), + /*max_bitrate=*/ DataRate::from_kilobits_per_sec(1000000), + ); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + let acked_rate: DataRate = DataRate::from_kilobits_per_sec(100); + loss_based_bandwidth_estimator.set_acknowledged_bitrate(acked_rate); + let enough_feedback_100p_loss_1: Vec = test + .create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_100p_loss_1, + delay_based_estimate, + /*in_alr=*/ true, + ); + + // Make sure that the estimate decreases but higher than acked rate. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + > acked_rate + ); + + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + < DataRate::from_kilobits_per_sec(600) + ); + } + + #[test] + fn back_off_to_acked_rate_if_not_in_alr() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(100), + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_min_max_bitrate( + /*min_bitrate=*/ DataRate::from_kilobits_per_sec(10), + /*max_bitrate=*/ DataRate::from_kilobits_per_sec(1000000), + ); + let delay_based_estimate: DataRate = DataRate::from_kilobits_per_sec(5000); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + + let acked_rate: DataRate = DataRate::from_kilobits_per_sec(100); + loss_based_bandwidth_estimator.set_acknowledged_bitrate(acked_rate); + let enough_feedback_100p_loss_1: Vec = test + .create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_100p_loss_1, + delay_based_estimate, + /*in_alr=*/ false, + ); + + // Make sure that the estimate decreases but higher than acked rate. + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + acked_rate + ); + } + + #[test] + fn not_ready_to_use_in_start_phase() { + let config = LossBasedBweV2Config { + use_in_start_phase: true, + ..short_observation_config() + }; + let loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + // Make sure that the estimator is not ready to use in start phase because of + // lacking TWCC feedback. + assert!(!loss_based_bandwidth_estimator.ready_to_use_in_start_phase()); + } + + #[test] + fn ready_to_use_in_start_phase() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + use_in_start_phase: true, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + let enough_feedback: Vec = test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback, + /*delay_based_estimate=*/ DataRate::from_kilobits_per_sec(600), + /*in_alr=*/ false, + ); + assert!(loss_based_bandwidth_estimator.ready_to_use_in_start_phase()); + } + + #[test] + fn bound_estimate_by_acked_rate() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + lower_bound_by_acked_rate_factor: 1.0, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_min_max_bitrate( + /*min_bitrate=*/ DataRate::from_kilobits_per_sec(10), + /*max_bitrate=*/ DataRate::from_kilobits_per_sec(1000000), + ); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(500)); + + let enough_feedback_100p_loss_1: Vec = test + .create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_100p_loss_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(500) + ); + } + + #[test] + fn not_bound_estimate_by_acked_rate() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + lower_bound_by_acked_rate_factor: 0.0, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_min_max_bitrate( + /*min_bitrate=*/ DataRate::from_kilobits_per_sec(10), + /*max_bitrate=*/ DataRate::from_kilobits_per_sec(1000000), + ); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(600)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(500)); + + let enough_feedback_100p_loss_1: Vec = test + .create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_100p_loss_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + < DataRate::from_kilobits_per_sec(500) + ); + } + + #[test] + fn has_decrease_state_because_of_upper_bound() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + candidate_factors: vec![1.0], + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(10), + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_min_max_bitrate( + /*min_bitrate=*/ DataRate::from_kilobits_per_sec(10), + /*max_bitrate=*/ DataRate::from_kilobits_per_sec(1000000), + ); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(500)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(100)); + + let enough_feedback_10p_loss_1: Vec = test + .create_packet_results_with_10p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + DataSize::from_bytes(PACKET_SIZE), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_10p_loss_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + // Verify that the instant upper bound decreases the estimate, and state is + // updated to Decreasing. + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(200) + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + } + + #[test] + fn has_increase_state_because_of_lower_bound() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + candidate_factors: vec![1.0], + lower_bound_by_acked_rate_factor: 10.0, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_min_max_bitrate( + /*min_bitrate=*/ DataRate::from_kilobits_per_sec(10), + /*max_bitrate=*/ DataRate::from_kilobits_per_sec(1000000), + ); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(500)); + loss_based_bandwidth_estimator.set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(1)); + + // Network has a high loss to create a loss scenario. + let enough_feedback_50p_loss_1: Vec = test + .create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_50p_loss_1, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + + // Network still has a high loss, but better acked rate. + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(200)); + let enough_feedback_50p_loss_2: Vec = test + .create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * 2, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_50p_loss_2, + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + // Verify that the instant lower bound increases the estimate, and state is + // updated to IncreaseUsingPadding. + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(200) * 10 + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::IncreaseUsingPadding + ); + } + + #[test] + fn estimate_increase_slowly_from_instant_upper_bound_in_alr_if_field_trial() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + upper_bound_candidate_in_alr: true, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(1000)); + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(150)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ true, + ); + let result_after_loss: LossBasedBweV2Result = + loss_based_bandwidth_estimator.get_loss_based_result(); + assert_eq!(result_after_loss.state, LossBasedState::Decreasing); + + for feedback_count in 1..=3 { + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + feedback_count * OBSERVATION_DURATION_LOWER_BOUND, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ true, + ); + } + // Expect less than 100% increase. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + < 2 * result_after_loss.bandwidth_estimate + ); + } + + #[test] + fn has_delay_based_state_if_loss_based_bwe_is_max() { + let mut test = LossBasedBweV2Test::default(); + let config = short_observation_config(); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_min_max_bitrate( + /*min_bitrate=*/ DataRate::from_kilobits_per_sec(10), + /*max_bitrate=*/ DataRate::from_kilobits_per_sec(1000), + ); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + /*feedback = */ + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ), + /*delay_based_estimate=*/ DataRate::from_kilobits_per_sec(2000), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::DelayBasedEstimate + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(1000) + ); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + /*feedback=*/ + &test.create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + ), + /*delay_based_estimate=*/ DataRate::from_kilobits_per_sec(2000), + /*in_alr=*/ false, + ); + let mut result: LossBasedBweV2Result = + loss_based_bandwidth_estimator.get_loss_based_result(); + assert_eq!(result.state, LossBasedState::Decreasing); + assert!(result.bandwidth_estimate < DataRate::from_kilobits_per_sec(1000)); + + // Eventually the estimator recovers to delay based state. + let mut feedback_count: i64 = 2; + while result.state != LossBasedState::DelayBasedEstimate { + assert!(feedback_count < 100, "never reached DelayBasedEstimate"); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + /*feedback = */ + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + feedback_count * OBSERVATION_DURATION_LOWER_BOUND, + ), + /*delay_based_estimate=*/ DataRate::from_kilobits_per_sec(2000), + /*in_alr=*/ false, + ); + feedback_count += 1; + result = loss_based_bandwidth_estimator.get_loss_based_result(); + } + assert_eq!(result.state, LossBasedState::DelayBasedEstimate); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(1000) + ); + } + + #[test] + fn increase_using_padding_state_if_field_trial() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + padding_duration: TimeDelta::from_millis(1000), + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(2500)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + + let mut feedback_count: i64 = 1; + while loss_based_bandwidth_estimator.get_loss_based_result().state + != LossBasedState::IncreaseUsingPadding + { + assert!(feedback_count < 100, "never reached IncreaseUsingPadding"); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * feedback_count, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + feedback_count += 1; + } + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::IncreaseUsingPadding + ); + } + + #[test] + fn best_candidate_resets_to_upper_bound_in_field_trial() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + padding_duration: TimeDelta::from_millis(1000), + bound_best_candidate: true, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(2500)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ true, + ); + let result_after_loss: LossBasedBweV2Result = + loss_based_bandwidth_estimator.get_loss_based_result(); + assert_eq!(result_after_loss.state, LossBasedState::Decreasing); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ true, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + 2 * OBSERVATION_DURATION_LOWER_BOUND, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ true, + ); + // After a BWE decrease due to large loss, BWE is expected to ramp up slowly + // and follow the acked bitrate. + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::IncreaseUsingPadding + ); + assert_relative_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + .kbps_float(), + result_after_loss.bandwidth_estimate.kbps_float(), + epsilon = 100.0 + ); + } + + #[test] + fn decrease_to_acked_candidate_if_padding_in_alr() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + padding_duration: TimeDelta::from_millis(1000), + // Set InstantUpperBoundBwBalance high to disable InstantUpperBound cap. + instant_upper_bound_bw_balance: DataRate::from_kilobits_per_sec(10000), + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(1000)); + let mut feedback_id: i64 = 0; + while loss_based_bandwidth_estimator.get_loss_based_result().state + != LossBasedState::Decreasing + { + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * feedback_id, + 2, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ true, + ); + feedback_id += 1; + } + + while loss_based_bandwidth_estimator.get_loss_based_result().state + != LossBasedState::IncreaseUsingPadding + { + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * feedback_id, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ true, + ); + feedback_id += 1; + } + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + > DataRate::from_kilobits_per_sec(900) + ); + + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(100)); + // Padding is sent now, create some lost packets. + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * feedback_id, + 2, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ true, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(100) + ); + } + + #[test] + fn decrease_after_padding() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + padding_duration: TimeDelta::from_millis(1000), + bw_rampup_upper_bound_factor: 2.0, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(2500)); + let mut acknowledged_bitrate: DataRate = DataRate::from_kilobits_per_sec(51); + loss_based_bandwidth_estimator.set_acknowledged_bitrate(acknowledged_bitrate); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + acknowledged_bitrate + ); + + acknowledged_bitrate = DataRate::from_kilobits_per_sec(26); + loss_based_bandwidth_estimator.set_acknowledged_bitrate(acknowledged_bitrate); + let mut feedback_id: i64 = 1; + while loss_based_bandwidth_estimator.get_loss_based_result().state + != LossBasedState::IncreaseUsingPadding + { + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * feedback_id, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + feedback_id += 1; + } + + let estimate_increased: Timestamp = + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * feedback_id; + // The state is IncreaseUsingPadding for a while without changing the + // estimate, which is limited by 2 * acked rate. + while loss_based_bandwidth_estimator.get_loss_based_result().state + == LossBasedState::IncreaseUsingPadding + { + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * feedback_id, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + feedback_id += 1; + } + + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + let start_decreasing: Timestamp = + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * (feedback_id - 1); + assert_eq!( + start_decreasing - estimate_increased, + TimeDelta::from_seconds(1) + ); + } + + #[test] + fn hold_rate_not_lower_than_acked_rate() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + hold_duration_factor: 10.0, + lower_bound_by_acked_rate_factor: 1.0, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(2500)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + + // During the hold duration, hold rate is not lower than the acked rate. + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(1000)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(1000) + ); + } + + #[test] + fn estimate_not_lower_than_acked_rate() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + lower_bound_by_acked_rate_factor: 1.0, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(2500)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + 2, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + < DataRate::from_kilobits_per_sec(1000) + ); + + loss_based_bandwidth_estimator + .set_acknowledged_bitrate(DataRate::from_kilobits_per_sec(1000)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + 2, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DataRate::from_kilobits_per_sec(1000) + ); + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * 2, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * 3, + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + + // Verify that the estimate recovers from the acked rate. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + > DataRate::from_kilobits_per_sec(1000) + ); + } + + #[test] + fn end_hold_duration_if_delay_based_estimate_works() { + let mut test = LossBasedBweV2Test::default(); + let config = short_observation_config(); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator + .set_bandwidth_estimate(DataRate::from_kilobits_per_sec(2500)); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_50p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + let estimate: DataRate = loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate; + + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + ), + /*delay_based_estimate=*/ estimate + DataRate::from_kilobits_per_sec(10), + /*in_alr=*/ false, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND * 2, + ), + /*delay_based_estimate=*/ estimate + DataRate::from_kilobits_per_sec(10), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::DelayBasedEstimate + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + estimate + DataRate::from_kilobits_per_sec(10) + ); + } + + #[test] + fn use_byte_loss_rate() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + use_byte_loss_rate: true, + ..short_observation_config() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DataRate::from_kilobits_per_sec(500)); + // Create packet feedback having 10% packet loss but more than 50% byte loss. + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_10p_packet_loss_rate( + /*first_packet_timestamp=*/ Timestamp::zero(), + /*lost_packet_size=*/ DataSize::from_bytes(PACKET_SIZE * 20), + ), + /*delay_based_estimate=*/ DataRate::plus_infinity(), + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + // The estimate is bounded by the instant upper bound due to high loss. + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + < DataRate::from_kilobits_per_sec(160) + ); + } + + #[test] + fn use_byte_loss_rate_ignore_loss_spike() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + use_byte_loss_rate: true, + observation_window_size: 5, + ..Default::default() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + const DELAY_BASED_ESTIMATE: DataRate = DataRate::from_kilobits_per_sec(500); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DELAY_BASED_ESTIMATE); + + // Fill the observation window. + for i in 0..5 { + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + i * OBSERVATION_DURATION_LOWER_BOUND, + ), + DELAY_BASED_ESTIMATE, + /*in_alr=*/ false, + ); + } + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + 5 * OBSERVATION_DURATION_LOWER_BOUND, + 2, + ), + DELAY_BASED_ESTIMATE, + /*in_alr=*/ false, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + 6 * OBSERVATION_DURATION_LOWER_BOUND, + ), + DELAY_BASED_ESTIMATE, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::DelayBasedEstimate + ); + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + DELAY_BASED_ESTIMATE + ); + + // But if more loss happen in a new observation, BWE back down. + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + 7 * OBSERVATION_DURATION_LOWER_BOUND, + 2, + ), + DELAY_BASED_ESTIMATE, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + < DELAY_BASED_ESTIMATE + ); + } + + #[test] + fn use_byte_loss_rate_does_not_ignore_loss_spike_on_send_burst() { + let mut test = LossBasedBweV2Test::default(); + let config = LossBasedBweV2Config { + use_byte_loss_rate: true, + observation_window_size: 5, + ..Default::default() + }; + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + const DELAY_BASED_ESTIMATE: DataRate = DataRate::from_kilobits_per_sec(500); + loss_based_bandwidth_estimator.set_bandwidth_estimate(DELAY_BASED_ESTIMATE); + + // Fill the observation window. + for i in 0..5 { + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + i * OBSERVATION_DURATION_LOWER_BOUND, + ), + DELAY_BASED_ESTIMATE, + /*in_alr=*/ false, + ); + } + + // If the loss happens when increasing sending rate, then + // the BWE should back down. + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &test.create_packet_results_with_100p_loss_rate( + /*first_packet_timestamp=*/ + Timestamp::zero() + 5 * OBSERVATION_DURATION_LOWER_BOUND, + /*num_packets=*/ 5, + ), + DELAY_BASED_ESTIMATE, + /*in_alr=*/ false, + ); + assert_eq!( + loss_based_bandwidth_estimator.get_loss_based_result().state, + LossBasedState::Decreasing + ); + assert!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate + <= DELAY_BASED_ESTIMATE + ); + } + + #[test] + fn select_higher_candidate_if_having_same_objective() { + let mut test = LossBasedBweV2Test::default(); + let config = short_observation_config(); + let mut loss_based_bandwidth_estimator = LossBasedBweV2::new(config); + const START_BITRATE: DataRate = DataRate::from_kilobits_per_sec(1000); + loss_based_bandwidth_estimator.set_bandwidth_estimate(START_BITRATE); + let enough_feedback_01: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ Timestamp::zero(), + ); + let enough_feedback_02: Vec = test + .create_packet_results_with_received_packets( + /*first_packet_timestamp=*/ + Timestamp::zero() + OBSERVATION_DURATION_LOWER_BOUND, + ); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_01, + START_BITRATE, + /*in_alr=*/ false, + ); + let delay_based_estimate = + DataRate::from_bytes_per_sec_float(START_BITRATE.bytes_per_sec() as f64 * 1.02 + 1.0); + loss_based_bandwidth_estimator.update_bandwidth_estimate( + &enough_feedback_02, + delay_based_estimate, + /*in_alr=*/ false, + ); + + assert_eq!( + loss_based_bandwidth_estimator + .get_loss_based_result() + .bandwidth_estimate, + delay_based_estimate + ); + } +} // namespace diff --git a/rs/moq-congestion/src/gcc/mod.rs b/rs/moq-congestion/src/gcc/mod.rs new file mode 100644 index 00000000000..fd777386cac --- /dev/null +++ b/rs/moq-congestion/src/gcc/mod.rs @@ -0,0 +1,540 @@ +//! libwebrtc's Google Congestion Control (GoogCC), driven by QUIC receive timestamps. +//! +//! The submodules are a Rust port of WebRTC's `modules/congestion_controller/goog_cc`, with the +//! parts of `api/transport`, `api/units`, `modules/pacing` and `modules/remote_bitrate_estimator` +//! it uses. They began as [goog_cc](https://github.com/kixelated/goog_cc) 0.1.4, a port of WebRTC +//! [da2a3d6c4d](https://webrtc.googlesource.com/src/+/da2a3d6c4d) (2024-12-09), and are refreshed +//! to WebRTC [556f7eec27](https://webrtc.googlesource.com/src/+/556f7eec27) (2026-11-01). They keep +//! WebRTC's BSD license, in `LICENSE` beside them, with its `PATENTS` grant and `AUTHORS`. +//! +//! Each file follows its upstream counterpart, so a refresh diffs WebRTC between the recorded +//! revision and a newer one and applies the changes file by file. What a QUIC sender has no source +//! for is gone, and each file's own docs say what: RTCP receiver reports and REMB, route changes, +//! the network state estimate, separate audio detection, RTP sequence numbers, ECN feedback, +//! padding, and the `AcknowledgedBitrateEstimator` that a disabled-by-default field trial selects. +//! Field trials are typed settings with upstream's defaults (`FieldTrials`). +//! +//! [`Gcc`] adapts it to [`Controller`]: each ACK becomes transport feedback carrying the peer's +//! receive times and each loss a feedback of lost packets, GoogCC's process timer runs from the +//! controller's calls, and a port of WebRTC's `BitrateProber` paces its probe clusters. + +/// `approx::assert_relative_eq!`: within `epsilon`, or within a relative `f64::EPSILON`. +#[cfg(test)] +macro_rules! assert_relative_eq { + ($a:expr, $b:expr $(,)?) => { + assert_relative_eq!($a, $b, epsilon = f64::EPSILON) + }; + ($a:expr, $b:expr, epsilon = $e:expr $(,)?) => {{ + let (a, b, e): (f64, f64, f64) = ($a.into(), $b.into(), $e.into()); + assert!( + (a - b).abs() <= e || (a - b).abs() <= a.abs().max(b.abs()) * f64::EPSILON, + "{a} is not within {e} of {b}" + ); + }}; +} + +mod api; +mod experiments; +mod pacing; +mod remote_bitrate_estimator; + +mod alr_detector; +mod congestion_window_pushback_controller; +mod delay_based_bwe; +mod delay_increase_detector_interface; +mod goog_cc_network_control; +mod inter_arrival_delta; +mod link_capacity_estimator; +mod loss_based_bwe_v2; +mod probe_bitrate_estimator; +mod probe_controller; +mod robust_throughput_estimator; +mod send_side_bandwidth_estimation; +mod trendline_estimator; + +use alr_detector::*; +use congestion_window_pushback_controller::*; +use delay_based_bwe::*; +use delay_increase_detector_interface::*; +use inter_arrival_delta::*; +use link_capacity_estimator::*; +use loss_based_bwe_v2::*; +use probe_bitrate_estimator::*; +use probe_controller::*; +use robust_throughput_estimator::*; +use send_side_bandwidth_estimation::*; +use trendline_estimator::*; + +use goog_cc_network_control::{GoogCcNetworkController, NetworkControllerConfig}; + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod bench; + +use std::{collections::VecDeque, num::NonZeroU16, sync::Arc, time::Duration}; + +use kio::clock::Instant; + +use crate::{ + Ack, Ce, Controller, Factory, Init, Loss, Metrics, Packet, Sent, + pacer::{self, Pacer, Rate}, +}; +use api::{ + transport::{ + NetworkAvailability, NetworkControlUpdate, PacedPacketInfo, PacerConfig, PacketResult, + ProcessInterval, RoundTripTimeUpdate, SentPacket, StreamsConfig, TargetRateConstraints, + TransportPacketsFeedback, + }, + units::{DataRate, DataSize, TimeDelta, Timestamp}, +}; +use pacing::BitrateProber; + +/// How often GoogCC expects its process timer, as `GetProcessInterval` +const PROCESS_INTERVAL: Duration = Duration::from_millis(25); +/// How far a mapped peer receive time may sit from the local clock: a peer's clock drifts far +/// less over a connection, and the bound keeps a hostile time from overflowing GoogCC's units. +const RECEIVE_SKEW: TimeDelta = TimeDelta::from_seconds(3600); +/// The smallest window GoogCC's congestion window settles on, `kMinCwnd` +const MIN_WINDOW: u64 = 2 * 1500; +/// How long a probe cluster's settings are kept for its packets' feedback, past the +/// `ProbeBitrateEstimator`'s own one second of history +const CLUSTER_HISTORY: TimeDelta = TimeDelta::from_seconds(2); + +/// The rate GoogCC starts at, whose initial probes go out at three and six times it: 1 Mbit/s, +/// rather than WebRTC's 300 kbit/s. +/// +/// WebRTC's encoder starts at GoogCC's rate, but a transport's sender writes at its own. From +/// 300 kbit/s the initial probing took several round trips to reach a few Mbit/s while 2 Mbit/s +/// of media queued behind the pacer. Draining that backlog once a probe found the path queued it +/// at the bottleneck, and the delay-based estimate cut the rate to 85% of the throughput +/// acknowledged so far, from a sender still starting up. Held above the estimate, the media kept +/// GoogCC out of the application-limited region, so no probe followed, and the estimate climbed +/// back at 8% a second. Only the 5 Mbit/s ceiling `ProbeController` keeps without a maximum rate +/// hid this: the probe it capped happened to restore the estimate after the cut. +const INITIAL_RATE: DataRate = DataRate::from_kilobits_per_sec(1000); +/// The most GoogCC estimates or probes when the transport sets no pacing limit, the 1 Gbit/s +/// `SendSideBandwidthEstimation` already defaults to. Without one, `ProbeController` caps every +/// probe at 5 Mbit/s, so a probe target above that could never be reached. +const MAX_RATE: DataRate = DataRate::from_kilobits_per_sec(1_000_000); + +/// Configuration for the [`Gcc`] congestion controller, which has no settings yet. +#[derive(Debug, Clone, Default)] +pub struct Config { + _private: (), +} + +impl Factory for Config { + fn build(self: Arc, init: Init) -> Box { + Box::new(Gcc::new(init)) + } +} + +/// A probe cluster's settings, kept for its packets' feedback +#[derive(Debug, Clone, Copy)] +struct Cluster { + info: PacedPacketInfo, + /// When its last packet was sent + sent: Timestamp, +} + +/// libwebrtc's Google Congestion Control, fed by receive timestamps. +/// +/// The window is GoogCC's congestion window (its target rate times the round trip plus 350ms), +/// the pacing rate its pacer's, and the bandwidth estimate its target rate. Each probe cluster +/// GoogCC requests paces at its rate while it runs and reports what it still needs as +/// [`Metrics::probe_inflight`], so an application-limited sender can fill it. +#[derive(Clone)] +pub struct Gcc { + cc: GoogCcNetworkController, + prober: BitrateProber, + pacer: Pacer, + mtu: u16, + /// The pacing rate GoogCC last configured, in bytes per second + pacing_rate: u64, + /// The target rate GoogCC last reported, in bytes per second + target_rate: u64, + /// The window until GoogCC computes one from feedback + initial_window: u64, + in_flight: u64, + /// Whether an ACK has arrived, which makes the network available to GoogCC and starts its + /// initial probes: before then a probe would only pace the handshake, which waits a round + /// trip between flights + available: bool, + next_process: Instant, + /// The local time, in microseconds, a peer receive time of zero maps to, fixed by the first + /// receive time the peer reports + receive_basis: Option, + /// Recently sent probe clusters, oldest first + clusters: VecDeque, + /// A probe target not yet handed to GoogCC, in bytes per second, which waits for a call that + /// knows the time + probe_target: Option, + /// Reused for each ACK's and loss's feedback + feedback: TransportPacketsFeedback, +} + +impl Gcc { + fn new(init: Init) -> Self { + let now = timestamp(init.now); + let starting_rate = INITIAL_RATE; + let cc = GoogCcNetworkController::new(NetworkControllerConfig { + constraints: TargetRateConstraints { + at_time: now, + min_data_rate: None, + max_data_rate: Some(init.max_pacing_rate.map_or(MAX_RATE, |rate| { + DataRate::from_bytes_per_sec(rate.min(i64::MAX as u64) as i64) + })), + starting_rate: Some(starting_rate), + }, + stream_based_config: StreamsConfig { + // Periodic probing while application-limited, so a sender held below its target + // finds the capacity to resume a stopped rendition + requests_alr_probing: Some(true), + ..Default::default() + }, + default_pacing_time_window: PacerConfig::DEFAULT_TIME_INTERVAL, + ..Default::default() + }); + let window_time = TimeDelta::from_micros(init.rtt.as_micros().min(i64::MAX as u128) as i64) + + TimeDelta::from_millis(experiments::CongestionWindowConfig::default().queue_size_ms); + let initial_window = ((starting_rate * window_time).bytes().max(0) as u64).max(MIN_WINDOW); + let mut gcc = Self { + cc, + prober: BitrateProber::default(), + pacer: Pacer::new( + init.rtt, + initial_window, + init.mtu, + init.max_pacing_rate, + init.now, + ), + mtu: init.mtu, + pacing_rate: starting_rate.bytes_per_sec() as u64, + target_rate: starting_rate.bytes_per_sec() as u64, + initial_window, + in_flight: 0, + available: false, + next_process: init.now, + receive_basis: None, + clusters: VecDeque::new(), + probe_target: None, + feedback: TransportPacketsFeedback::default(), + }; + gcc.process(init.now); + gcc + } + + /// Hands GoogCC a new probe target, then runs its process timer if it is due. + fn process(&mut self, now: Instant) { + if let Some(target) = self.probe_target.take() { + // The session's target is the rate its streams would send at, which GoogCC probes + // toward as soon as it exceeds the estimate, capping its probes at twice the target + let update = self.cc.on_streams_config(StreamsConfig { + at_time: timestamp(now), + max_total_allocated_bitrate: Some(DataRate::from_bytes_per_sec( + target.min(i64::MAX as u64) as i64, + )), + ..Default::default() + }); + self.apply(update); + } + if now < self.next_process { + return; + } + self.next_process = now + PROCESS_INTERVAL; + let update = self.cc.on_process_interval(ProcessInterval { + at_time: timestamp(now), + pacer_queue: None, + }); + self.apply(update); + } + + fn apply(&mut self, update: NetworkControlUpdate) { + if let Some(target) = update.target_rate { + self.target_rate = target.target_rate.bytes_per_sec().max(0) as u64; + } + if let Some(pacer) = update.pacer_config { + self.pacing_rate = (pacer.data_rate().bytes_per_sec().max(1)) as u64; + } + for cluster in &update.probe_cluster_configs { + self.prober.create_probe_cluster(cluster); + } + } + + /// Maps a peer receive time onto the local clock. + /// + /// The first one the peer reports lands at the local time its ACK arrived, so the mapped + /// times carry the forward delay of that packet as a constant offset, which GoogCC's + /// gradients ignore. + fn receive_time(&mut self, received: Duration, now: Instant) -> Timestamp { + let now = timestamp(now).us(); + // In i128, where a peer's time, at most 2^64 seconds, cannot overflow + let received = received.as_micros() as i128; + let basis = *self + .receive_basis + .get_or_insert_with(|| i128::from(now) - received); + let skew = i128::from(RECEIVE_SKEW.us()); + let mapped = (received + basis).clamp(i128::from(now) - skew, i128::from(now) + skew); + Timestamp::from_micros(mapped as i64) + } + + /// The feedback for `packet`, acknowledged at `now`. + /// + /// Until the peer reports a receive time, the ACK's arrival stands in for each packet's, so + /// GoogCC runs on the round-trip delay. Once it reports one, a packet without one, as when + /// the ACK carrying its time was lost, still counts as received for the loss-based estimate, + /// but not toward delay, rather than mix the two clocks. + fn acked(&mut self, packet: &crate::Acked, now: Instant) -> PacketResult { + let (receive_time, ambiguous_receive_time) = match packet.received { + Some(received) => (self.receive_time(received, now), false), + None => (timestamp(now), self.receive_basis.is_some()), + }; + PacketResult { + sent_packet: SentPacket { + send_time: timestamp(packet.sent), + size: DataSize::from_bytes(packet.bytes.into()), + pacing_info: self.cluster(packet.packet.probe_cluster), + data_in_flight: DataSize::zero(), + }, + receive_time, + ambiguous_receive_time, + } + } + + /// The pacing info of the probe cluster `id` was sent in. + fn cluster(&self, id: Option) -> PacedPacketInfo { + id.and_then(|id| { + self.clusters + .iter() + .rev() + .find(|cluster| short_id(cluster.info.probe_cluster_id) == Some(id)) + }) + .map_or_else(PacedPacketInfo::default, |cluster| cluster.info) + } + + fn feed(&mut self, now: Instant, in_flight: u64) { + if self.feedback.packet_feedbacks.is_empty() { + return; + } + self.feedback.feedback_time = timestamp(now); + self.feedback.data_in_flight = size(in_flight); + let update = self.cc.on_transport_packets_feedback(&self.feedback); + self.feedback.packet_feedbacks.clear(); + self.apply(update); + } + + /// The window GoogCC allows in flight. + fn congestion_window(&self) -> u64 { + let window = self + .cc + .congestion_window() + .map_or(self.initial_window, |window| window.bytes().max(0) as u64); + window.max(MIN_WINDOW) + } + + /// The bytes in flight up to which the current probe cluster may be filled. + /// + /// In whole packets: a cluster short of a packet's bytes still needs one more, and a sender + /// held to the bytes alone would leave it unfinished. + fn probe_inflight(&self) -> Option { + let (bytes, packets, _) = self.prober.remaining()?; + let mtu = u64::from(self.mtu); + let packets = u64::try_from(packets).unwrap_or(0) * mtu; + let bytes = u64::try_from(bytes.bytes()) + .unwrap_or(0) + .next_multiple_of(mtu); + Some(self.in_flight + bytes.max(packets)) + } +} + +impl Controller for Gcc { + fn on_sent(&mut self, sent: Sent) -> Packet { + self.process(sent.now); + let now = timestamp(sent.now); + self.in_flight = sent.in_flight; + self.pacer.on_transmit(sent.bytes); + let bytes = DataSize::from_bytes(sent.bytes.into()); + + // A large enough packet starts a pending probe cluster, as WebRTC's pacer starts one + // when media is queued, so the handshake's small last flight does not start a cluster + // only for the idle round trip after it to abandon + self.prober.on_incoming_packet(bytes); + let info = self.prober.current_cluster(now); + if info.is_some() { + self.prober.probe_sent(now, bytes); + } + let info = info.unwrap_or_default(); + let probe_cluster = short_id(info.probe_cluster_id); + if probe_cluster.is_some() { + match self.clusters.back_mut() { + Some(last) if last.info.probe_cluster_id == info.probe_cluster_id => { + last.sent = now; + } + _ => self.clusters.push_back(Cluster { info, sent: now }), + } + } + while self + .clusters + .front() + .is_some_and(|cluster| now - cluster.sent > CLUSTER_HISTORY) + { + self.clusters.pop_front(); + } + + let update = self.cc.on_sent_packet(SentPacket { + send_time: now, + size: bytes, + pacing_info: info, + data_in_flight: size(sent.in_flight), + }); + self.apply(update); + Packet { + probe_cluster, + ..Default::default() + } + } + + fn on_ack(&mut self, ack: Ack<'_>) { + self.process(ack.now); + self.in_flight = ack.in_flight; + if !self.available { + self.available = true; + let update = self.cc.on_network_availability(NetworkAvailability { + at_time: timestamp(ack.now), + network_available: true, + }); + self.apply(update); + } + if !ack.rtt.is_zero() { + self.cc.on_round_trip_time_update(RoundTripTimeUpdate { + round_trip_time: TimeDelta::from_micros( + ack.rtt.as_micros().min(i64::MAX as u128) as i64 + ), + }); + } + for packet in ack.packets { + let result = self.acked(packet, ack.now); + self.feedback.packet_feedbacks.push(result); + } + self.feed(ack.now, ack.in_flight); + } + + fn on_loss(&mut self, loss: Loss<'_>) { + self.process(loss.now); + self.in_flight = loss.in_flight; + for packet in loss.packets { + let result = PacketResult { + sent_packet: SentPacket { + send_time: timestamp(packet.sent), + size: DataSize::from_bytes(packet.bytes.into()), + pacing_info: self.cluster(packet.packet.probe_cluster), + data_in_flight: DataSize::zero(), + }, + receive_time: Timestamp::plus_infinity(), + ambiguous_receive_time: false, + }; + self.feedback.packet_feedbacks.push(result); + } + self.feed(loss.now, loss.in_flight); + } + + // GoogCC reacts to delay and loss; ECN is SCReAM's signal. + fn on_ce(&mut self, _ce: Ce) {} + + fn on_mtu_update(&mut self, new_mtu: u16) { + self.mtu = new_mtu; + } + + fn on_app_limited(&mut self, in_flight: u64) { + self.in_flight = in_flight; + self.prober.on_app_limited(); + } + + fn window(&self) -> u64 { + self.congestion_window() + } + + fn pace(&mut self, now: Instant, bytes: u64) -> Option { + self.process(now); + let at = timestamp(now); + if self.prober.current_cluster(at).is_some() { + let next = self.prober.next_probe_time(); + if next <= at { + return None; + } + return Some(now + Duration::from_micros((next - at).us().max(0) as u64)); + } + let rate = Rate { + window: self.congestion_window(), + pacing_rate: Some(self.pacing_rate), + }; + // A pacing rate makes the RTT irrelevant; the MTU only sizes the bursts. + self.pacer.delay(Duration::ZERO, bytes, self.mtu, now, rate) + } + + /// The pacer's own burst budget, or a single packet while a probe cluster runs. + /// + /// A transport checks each datagram's pace while the one before it is still being built, so + /// a batch longer than the budget releases a packet the budget never pays for. Under + /// 2 Mbit/s the budget is one packet, and batching two would pace at twice the rate. + fn burst(&self) -> Option { + let mtu = u64::from(self.mtu); + if self.prober.remaining().is_some() { + return Some(mtu); + } + Some(pacer::rate_capacity(self.pacing_rate, self.mtu).max(mtu)) + } + + fn metrics(&self) -> Metrics { + Metrics { + congestion_window: self.congestion_window(), + ssthresh: None, + pacing_rate: Some(self.pacing_rate), + bandwidth_estimate: Some(self.target_rate), + probe_inflight: self.probe_inflight(), + } + } + + fn probes(&self) -> bool { + true + } + + /// GoogCC reads a probe at the slower of its send and receive rates, so a cluster held below + /// its rate reads no higher, and its size bounds what the duplicates filling it cost. + fn paces_probe(&self) -> bool { + true + } + + fn on_probe_target(&mut self, bytes_per_second: u64) { + self.probe_target = Some(bytes_per_second); + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +fn timestamp(instant: Instant) -> Timestamp { + let micros = instant.duration_since(Instant::ZERO).as_micros(); + Timestamp::from_micros(micros.min(i64::MAX as u128 / 2) as i64) +} + +fn size(bytes: u64) -> DataSize { + DataSize::from_bytes(bytes.min(i64::MAX as u64 / 2) as i64) +} + +/// The probe cluster id a [`Packet`] stores, in sixteen bits without zero. GoogCC's ids, which +/// count up from one, wrap within the lower half, and the prober's restart ids within the upper +/// half, so a restarted cluster never shares a short id with one of GoogCC's. +fn short_id(id: i32) -> Option { + const HALF: u32 = 1 << 15; + let id = u32::try_from(id).ok().filter(|&id| id > 0)?; + let restart_base = BitrateProber::RESTART_ID_BASE as u32; + let short = match id.checked_sub(restart_base) { + Some(restart) => HALF | (restart % HALF), + None => (id - 1) % (HALF - 1) + 1, + }; + NonZeroU16::new(short as u16) +} diff --git a/rs/moq-congestion/src/gcc/pacing/bitrate_prober.rs b/rs/moq-congestion/src/gcc/pacing/bitrate_prober.rs new file mode 100644 index 00000000000..89263d1a3be --- /dev/null +++ b/rs/moq-congestion/src/gcc/pacing/bitrate_prober.rs @@ -0,0 +1,399 @@ +/* + * Copyright (c) 2014 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! WebRTC's pacer sends each probe cluster GoogCC asks for, padding with old media when it has no +//! new. Here the cluster paces the controller instead: its packets leave at the cluster's rate, +//! and while it runs the transport may fill it with duplicates of data in flight (QUIC's native +//! probing). The `WebRTC-Bwe-ProbingBehavior` settings keep their defaults. + +use std::collections::VecDeque; + +use crate::gcc::api::{ + transport::{PacedPacketInfo, ProbeClusterConfig}, + units::{DataRate, DataSize, TimeDelta, Timestamp}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProbingState { + // Probing will not be triggered in this state at all times. + Disabled, + // Probing is enabled and ready to trigger on the first packet arrival if + // there is a probe cluster. + Inactive, + // Probe cluster is filled with the set of data rates to be probed and + // probes are being sent. + Active, +} + +#[derive(Debug, Clone)] +struct ProbeCluster { + pace_info: PacedPacketInfo, + send_bitrate: DataRate, + + sent_probes: i64, + sent_bytes: i64, + min_probe_delta: TimeDelta, + requested_at: Timestamp, + started_at: Timestamp, +} + +/// Paces the probe clusters GoogCC requests, as WebRTC's `BitrateProber`. +#[derive(Debug, Clone)] +pub struct BitrateProber { + probing_state: ProbingState, + + // Probe bitrate per packet. These are used to compute the delta relative to + // the previous probe packet based on the size and time when that packet was + // sent. + clusters: VecDeque, + + // Time the next probe should be sent when in kActive state. + next_probe_time: Timestamp, + + max_probe_delay: TimeDelta, + min_packet_size: DataSize, + + /// Clusters restarted so far, numbering their new ids + restarts: i32, +} + +impl Default for BitrateProber { + fn default() -> Self { + Self { + probing_state: ProbingState::Inactive, + clusters: VecDeque::new(), + next_probe_time: Timestamp::plus_infinity(), + max_probe_delay: TimeDelta::from_millis(10), + min_packet_size: DataSize::from_bytes(200), + restarts: 0, + } + } +} + +impl BitrateProber { + const PROBE_CLUSTER_TIMEOUT: TimeDelta = TimeDelta::from_seconds(5); + const MAX_PENDING_PROBE_CLUSTERS: usize = 5; + /// Restarted clusters take ids from here up, above any GoogCC's ProbeController counts to + pub const RESTART_ID_BASE: i32 = 1 << 30; + + // Returns true if the prober is in a probing session, i.e., it currently + // wants packets to be sent out according to the time returned by + // TimeUntilNextProbe(). + #[cfg(test)] + pub fn is_probing(&self) -> bool { + self.probing_state == ProbingState::Active + } + + // Initializes a new probing session if the prober is allowed to probe. Does + // not initialize the prober unless the packet size is large enough to probe + // with. + pub fn on_incoming_packet(&mut self, packet_size: DataSize) { + self.maybe_set_active_state(packet_size); + } + + pub fn create_probe_cluster(&mut self, cluster_config: &ProbeClusterConfig) { + debug_assert!(self.probing_state != ProbingState::Disabled); + debug_assert!(cluster_config.min_probe_delta > TimeDelta::zero()); + + while let Some(front) = self.clusters.front() { + if cluster_config.at_time - front.requested_at > Self::PROBE_CLUSTER_TIMEOUT + || self.clusters.len() > Self::MAX_PENDING_PROBE_CLUSTERS + { + self.clusters.pop_front(); + } else { + break; + } + } + + let cluster = ProbeCluster { + pace_info: PacedPacketInfo::new( + cluster_config.id, + cluster_config.target_probe_count as i64, + (cluster_config.target_data_rate * cluster_config.target_duration).bytes(), + ), + send_bitrate: cluster_config.target_data_rate, + sent_probes: 0, + sent_bytes: 0, + min_probe_delta: cluster_config.min_probe_delta, + requested_at: cluster_config.at_time, + started_at: Timestamp::plus_infinity(), + }; + tracing::debug!( + bitrate = ?cluster.send_bitrate, + min_bytes = cluster.pace_info.probe_cluster_min_bytes, + min_probes = cluster.pace_info.probe_cluster_min_probes, + "probe cluster" + ); + self.clusters.push_back(cluster); + + self.maybe_set_active_state(DataSize::zero()); + } + + // Returns the time at which the next probe should be sent to get accurate + // probing. If probing is not desired at this time, Timestamp::PlusInfinity() + // will be returned. + pub fn next_probe_time(&self) -> Timestamp { + // Probing is not active or probing is already complete. + if self.probing_state != ProbingState::Active || self.clusters.is_empty() { + return Timestamp::plus_infinity(); + } + self.next_probe_time + } + + // Information about the current probing cluster. + pub fn current_cluster(&mut self, now: Timestamp) -> Option { + if self.clusters.is_empty() || self.probing_state != ProbingState::Active { + return None; + } + + if self.next_probe_time.is_finite() && now - self.next_probe_time > self.max_probe_delay { + tracing::debug!( + next = ?self.next_probe_time, + ?now, + "probe delay too high, discarding probe cluster" + ); + self.clusters.pop_front(); + if self.clusters.is_empty() { + self.probing_state = ProbingState::Inactive; + return None; + } + } + + self.clusters.front().map(|cluster| cluster.pace_info) + } + + /// The bytes and packets the next cluster still needs, at its rate, whether or not a packet + /// has started it. + pub fn remaining(&self) -> Option<(DataSize, i64, DataRate)> { + if self.probing_state == ProbingState::Disabled { + return None; + } + let cluster = self.clusters.front()?; + Some(( + DataSize::from_bytes( + (cluster.pace_info.probe_cluster_min_bytes - cluster.sent_bytes).max(0), + ), + (cluster.pace_info.probe_cluster_min_probes - cluster.sent_probes).max(0), + cluster.send_bitrate, + )) + } + + // Returns the minimum number of bytes that the prober recommends for + // the next probe, or zero if not probing. A probe can consist of multiple + // packets that are sent back to back. + pub fn recommended_min_probe_size(&self) -> DataSize { + match self.clusters.front() { + Some(cluster) => cluster.send_bitrate * cluster.min_probe_delta, + None => DataSize::zero(), + } + } + + // Called to report to the prober that a probe has been sent. In case of + // multiple packets per probe, this call would be made at the end of sending + // the last packet in probe. `size` is the total size of all packets in probe. + pub fn probe_sent(&mut self, now: Timestamp, size: DataSize) { + debug_assert!(self.probing_state == ProbingState::Active); + debug_assert!(!size.is_zero()); + + let Some(cluster) = self.clusters.front_mut() else { + return; + }; + if cluster.sent_probes == 0 { + debug_assert!(cluster.started_at.is_infinite()); + cluster.started_at = now; + } + cluster.sent_bytes += size.bytes(); + cluster.sent_probes += 1; + // Compute the time delta from the cluster start to ensure probe bitrate stays + // close to the target bitrate. + self.next_probe_time = + cluster.started_at + DataSize::from_bytes(cluster.sent_bytes) / cluster.send_bitrate; + if cluster.sent_bytes >= cluster.pace_info.probe_cluster_min_bytes + && cluster.sent_probes >= cluster.pace_info.probe_cluster_min_probes + { + self.clusters.pop_front(); + } + if self.clusters.is_empty() { + self.probing_state = ProbingState::Inactive; + } + } + + /// The sender ran out of data to send. + /// + /// WebRTC's pacer pads a probe cluster when media runs out, so a gap means the pacer fell + /// behind and the cluster is dropped. Here only the transport's duplicates can pad it, so a + /// sender with nothing to send restarts the cluster with its next packet instead, under a new + /// id: the packets it already sent span the gap, and estimating from them would read the gap + /// as the path's rate. + pub fn on_app_limited(&mut self) { + if self.probing_state != ProbingState::Active { + return; + } + let Some(cluster) = self.clusters.front_mut() else { + return; + }; + if cluster.sent_probes == 0 { + return; + } + cluster.sent_probes = 0; + cluster.sent_bytes = 0; + cluster.started_at = Timestamp::plus_infinity(); + self.restarts = (self.restarts + 1) % Self::RESTART_ID_BASE; + cluster.pace_info.probe_cluster_id = Self::RESTART_ID_BASE + self.restarts; + self.probing_state = ProbingState::Inactive; + } + + fn maybe_set_active_state(&mut self, packet_size: DataSize) { + if self.ready_to_set_active_state(packet_size) { + self.next_probe_time = Timestamp::minus_infinity(); + self.probing_state = ProbingState::Active; + } + } + + fn ready_to_set_active_state(&self, packet_size: DataSize) -> bool { + if self.clusters.is_empty() { + return false; + } + match self.probing_state { + ProbingState::Disabled | ProbingState::Active => false, + // A "large enough" packet must be sent first, before a probe can be + // generated and sent. + ProbingState::Inactive => { + packet_size + >= std::cmp::min(self.recommended_min_probe_size(), self.min_packet_size) + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn cluster(at_time: Timestamp, rate: DataRate, id: i32) -> ProbeClusterConfig { + ProbeClusterConfig { + at_time, + target_data_rate: rate, + target_duration: TimeDelta::from_millis(15), + min_probe_delta: TimeDelta::from_millis(2), + target_probe_count: 5, + id, + } + } + + // A cluster starts on a large enough packet, paces at its rate, and ends once it sent both + // its packets and its bytes. + #[test] + fn verify_states_and_time_between_probes() { + let mut prober = BitrateProber::default(); + assert!(!prober.is_probing()); + + let mut now = Timestamp::zero(); + let start_time = now; + assert_eq!(prober.next_probe_time(), Timestamp::plus_infinity()); + + const TEST_BITRATE_1: DataRate = DataRate::from_kilobits_per_sec(900); + const TEST_BITRATE_2: DataRate = DataRate::from_kilobits_per_sec(1800); + const CLUSTER_SIZE: i64 = 5; + const PROBE_SIZE: DataSize = DataSize::from_bytes(1000); + const MIN_PROBE_DURATION: TimeDelta = TimeDelta::from_millis(15); + + prober.create_probe_cluster(&cluster(now, TEST_BITRATE_1, 0)); + prober.create_probe_cluster(&cluster(now, TEST_BITRATE_2, 1)); + assert!(!prober.is_probing()); + + prober.on_incoming_packet(PROBE_SIZE); + assert!(prober.is_probing()); + assert_eq!(0, prober.current_cluster(now).unwrap().probe_cluster_id); + + // First packet should probe as soon as possible. + assert_eq!(Timestamp::minus_infinity(), prober.next_probe_time()); + + for _ in 0..CLUSTER_SIZE { + now = std::cmp::max(now, prober.next_probe_time()); + assert_eq!(now, std::cmp::max(now, prober.next_probe_time())); + assert_eq!(0, prober.current_cluster(now).unwrap().probe_cluster_id); + prober.probe_sent(now, PROBE_SIZE); + } + + assert!(now - start_time >= MIN_PROBE_DURATION); + // Verify that the actual bitrate is withing 10% of the target. + let bitrate = PROBE_SIZE * (CLUSTER_SIZE - 1) / (now - start_time); + assert!(bitrate > TEST_BITRATE_1 * 0.9); + assert!(bitrate < TEST_BITRATE_1 * 1.1); + + now = std::cmp::max(now, prober.next_probe_time()); + let probe2_started = now; + + for _ in 0..CLUSTER_SIZE { + now = std::cmp::max(now, prober.next_probe_time()); + assert_eq!(1, prober.current_cluster(now).unwrap().probe_cluster_id); + prober.probe_sent(now, PROBE_SIZE); + } + + // Verify that the actual bitrate is withing 10% of the target. + let duration = now - probe2_started; + assert!(duration >= MIN_PROBE_DURATION); + let bitrate = (PROBE_SIZE * (CLUSTER_SIZE - 1)) / duration; + assert!(bitrate > TEST_BITRATE_2 * 0.9); + assert!(bitrate < TEST_BITRATE_2 * 1.1); + + assert_eq!(prober.next_probe_time(), Timestamp::plus_infinity()); + assert!(!prober.is_probing()); + } + + // A sender that falls more than the maximum probe delay behind abandons the cluster rather + // than pacing at its rate forever. + #[test] + fn discards_delayed_probes() { + let mut prober = BitrateProber::default(); + let mut now = Timestamp::zero(); + prober.create_probe_cluster(&cluster(now, DataRate::from_kilobits_per_sec(900), 0)); + prober.on_incoming_packet(DataSize::from_bytes(1000)); + assert!(prober.is_probing()); + prober.probe_sent(now, DataSize::from_bytes(1000)); + + now = prober.next_probe_time() + TimeDelta::from_millis(11); + assert!(prober.current_cluster(now).is_none()); + assert!(!prober.is_probing()); + } + + // Clusters requested more than five seconds before a new one are dropped unsent. + #[test] + fn discards_stale_clusters() { + let mut prober = BitrateProber::default(); + let rate = DataRate::from_kilobits_per_sec(900); + prober.create_probe_cluster(&cluster(Timestamp::zero(), rate, 0)); + prober.create_probe_cluster(&cluster(Timestamp::from_seconds(6), rate, 1)); + prober.on_incoming_packet(DataSize::from_bytes(1000)); + assert_eq!( + prober + .current_cluster(Timestamp::from_seconds(6)) + .unwrap() + .probe_cluster_id, + 1 + ); + } + + // Probing waits for a packet large enough to probe with. + #[test] + fn does_not_initialize_probing_for_small_packets() { + let mut prober = BitrateProber::default(); + prober.create_probe_cluster(&cluster( + Timestamp::zero(), + DataRate::from_kilobits_per_sec(900), + 0, + )); + prober.on_incoming_packet(DataSize::from_bytes(100)); + assert!(!prober.is_probing()); + prober.on_incoming_packet(DataSize::from_bytes(1000)); + assert!(prober.is_probing()); + } +} diff --git a/rs/moq-congestion/src/gcc/pacing/interval_budget.rs b/rs/moq-congestion/src/gcc/pacing/interval_budget.rs new file mode 100644 index 00000000000..a00423d8a6e --- /dev/null +++ b/rs/moq-congestion/src/gcc/pacing/interval_budget.rs @@ -0,0 +1,69 @@ +#[derive(Clone)] +pub struct IntervalBudget { + target_rate_kbps: i64, + max_bytes_in_budget: i64, + bytes_remaining: i64, + can_build_up_underuse: bool, +} + +impl IntervalBudget { + const WINDOW_MS: i64 = 500; + + pub fn new(initial_target_rate_kbps: i64, can_build_up_underuse: bool) -> Self { + let mut this = Self { + bytes_remaining: 0, + can_build_up_underuse, + target_rate_kbps: 0, + max_bytes_in_budget: 0, + }; + this.set_target_rate_kbps(initial_target_rate_kbps); + this + } + + pub fn set_target_rate_kbps(&mut self, target_rate_kbps: i64) { + self.target_rate_kbps = target_rate_kbps; + self.max_bytes_in_budget = (Self::WINDOW_MS * self.target_rate_kbps) / 8; + self.bytes_remaining = std::cmp::min( + std::cmp::max(-self.max_bytes_in_budget, self.bytes_remaining), + self.max_bytes_in_budget, + ); + } + + pub fn increase_budget(&mut self, delta_time_ms: i64) { + let bytes: i64 = self.target_rate_kbps * delta_time_ms / 8; + if self.bytes_remaining < 0 || self.can_build_up_underuse { + // We overused last interval, compensate this interval. + self.bytes_remaining = + std::cmp::min(self.bytes_remaining + bytes, self.max_bytes_in_budget); + } else { + // If we underused last interval we can't use it this interval. + self.bytes_remaining = std::cmp::min(bytes, self.max_bytes_in_budget); + } + } + + pub fn use_budget(&mut self, bytes: usize) { + self.bytes_remaining = std::cmp::max( + self.bytes_remaining - bytes as i64, + -self.max_bytes_in_budget, + ); + } + + /* unused + pub fn bytes_remaining(&self) -> usize { + self.bytes_remaining.try_into().unwrap_or(0) + } + */ + + pub fn budget_ratio(&self) -> f64 { + if self.max_bytes_in_budget == 0 { + return 0.0; + } + self.bytes_remaining as f64 / self.max_bytes_in_budget as f64 + } + + /* unused + pub fn target_rate_kbps(&self) -> i64 { + self.target_rate_kbps + } + */ +} diff --git a/rs/moq-congestion/src/gcc/pacing/mod.rs b/rs/moq-congestion/src/gcc/pacing/mod.rs new file mode 100644 index 00000000000..5606d7034da --- /dev/null +++ b/rs/moq-congestion/src/gcc/pacing/mod.rs @@ -0,0 +1,5 @@ +mod bitrate_prober; +mod interval_budget; + +pub use bitrate_prober::*; +pub use interval_budget::*; diff --git a/rs/moq-congestion/src/gcc/probe_bitrate_estimator.rs b/rs/moq-congestion/src/gcc/probe_bitrate_estimator.rs new file mode 100644 index 00000000000..a1c04584358 --- /dev/null +++ b/rs/moq-congestion/src/gcc/probe_bitrate_estimator.rs @@ -0,0 +1,522 @@ +/* + * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use std::collections::HashMap; + +use crate::gcc::api::{ + transport::{PacedPacketInfo, PacketResult}, + units::{DataRate, DataSize, TimeDelta, Timestamp}, +}; + +#[derive(Clone)] +struct AggregatedCluster { + pub num_probes: i64, + pub first_send: Timestamp, + pub last_send: Timestamp, + pub first_receive: Timestamp, + pub last_receive: Timestamp, + pub size_last_send: DataSize, + pub size_first_receive: DataSize, + pub size_total: DataSize, +} + +impl Default for AggregatedCluster { + fn default() -> Self { + Self { + num_probes: 0, + first_send: Timestamp::plus_infinity(), + last_send: Timestamp::minus_infinity(), + first_receive: Timestamp::plus_infinity(), + last_receive: Timestamp::minus_infinity(), + size_last_send: DataSize::zero(), + size_first_receive: DataSize::zero(), + size_total: DataSize::zero(), + } + } +} + +#[derive(Default, Clone)] +pub struct ProbeBitrateEstimator { + clusters: HashMap, + estimated_data_rate: Option, +} + +impl ProbeBitrateEstimator { + // The minumum number of probes we need to receive feedback about in percent + // in order to have a valid estimate. + const MIN_RECEIVED_PROBES_RATIO: f64 = 0.80; + + // The minumum number of bytes we need to receive feedback about in percent + // in order to have a valid estimate. + const MIN_RECEIVED_BYTES_RATIO: f64 = 0.80; + + // The maximum |receive rate| / |send rate| ratio for a valid estimate. + const MAX_VALID_RATIO: f64 = 2.0; + + // The minimum |receive rate| / |send rate| ratio assuming that the link is + // not saturated, i.e. we assume that we will receive at least + // MinRatioForUnsaturatedLink * |send rate| if |send rate| is less than the + // link capacity. + const MIN_RATIO_FOR_UNSATURATED_LINK: f32 = 0.9; + + // The target utilization of the link. If we know true link capacity + // we'd like to send at 95% of that rate. + const TARGET_UTILIZATION_FRACTION: f32 = 0.95; + + // The maximum time period over which the cluster history is retained. + // This is also the maximum time period beyond which a probing burst is not + // expected to last. + const MAX_CLUSTER_HISTORY: TimeDelta = TimeDelta::from_seconds(1); + + // The maximum time interval between first and the last probe on a cluster + // on the sender side as well as the receive side. + const MAX_PROBE_INTERVAL: TimeDelta = TimeDelta::from_seconds(1); + + // Should be called for every probe packet we receive feedback about. + // Returns the estimated bitrate if the probe completes a valid cluster. + pub fn handle_probe_and_estimate_bitrate( + &mut self, + packet_feedback: &PacketResult, + ) -> Option { + let cluster_id: i32 = packet_feedback.sent_packet.pacing_info.probe_cluster_id; + assert_ne!(cluster_id, PacedPacketInfo::NOT_APROBE); + + self.erase_old_clusters(packet_feedback.receive_time); + + let cluster: &mut AggregatedCluster = self.clusters.entry(cluster_id).or_default(); + + if packet_feedback.sent_packet.send_time < cluster.first_send { + cluster.first_send = packet_feedback.sent_packet.send_time; + } + if packet_feedback.sent_packet.send_time > cluster.last_send { + cluster.last_send = packet_feedback.sent_packet.send_time; + cluster.size_last_send = packet_feedback.sent_packet.size; + } + if packet_feedback.receive_time < cluster.first_receive { + cluster.first_receive = packet_feedback.receive_time; + cluster.size_first_receive = packet_feedback.sent_packet.size; + } + if packet_feedback.receive_time > cluster.last_receive { + cluster.last_receive = packet_feedback.receive_time; + } + cluster.size_total += packet_feedback.sent_packet.size; + cluster.num_probes += 1; + + assert!( + packet_feedback + .sent_packet + .pacing_info + .probe_cluster_min_probes + > 0 + ); + assert!( + packet_feedback + .sent_packet + .pacing_info + .probe_cluster_min_bytes + > 0 + ); + + let min_probes: i64 = (packet_feedback + .sent_packet + .pacing_info + .probe_cluster_min_probes as f64 + * Self::MIN_RECEIVED_PROBES_RATIO) as i64; + let min_size: DataSize = DataSize::from_bytes( + packet_feedback + .sent_packet + .pacing_info + .probe_cluster_min_bytes as _, + ) * Self::MIN_RECEIVED_BYTES_RATIO; + if cluster.num_probes < min_probes || cluster.size_total < min_size { + return None; + } + + let send_interval: TimeDelta = cluster.last_send - cluster.first_send; + let receive_interval: TimeDelta = cluster.last_receive - cluster.first_receive; + + if send_interval <= TimeDelta::zero() + || send_interval > Self::MAX_PROBE_INTERVAL + || receive_interval <= TimeDelta::zero() + || receive_interval > Self::MAX_PROBE_INTERVAL + { + tracing::debug!( + "Probing unsuccessful, invalid send/receive interval [cluster id: {}] [send interval: {:?}] [receive interval: {:?}]", + cluster_id, + send_interval, + receive_interval + ); + return None; + } + // Since the `send_interval` does not include the time it takes to actually + // send the last packet the size of the last sent packet should not be + // included when calculating the send bitrate. + assert!(cluster.size_total > cluster.size_last_send); + let send_size: DataSize = cluster.size_total - cluster.size_last_send; + let send_rate: DataRate = send_size / send_interval; + + // Since the `receive_interval` does not include the time it takes to + // actually receive the first packet the size of the first received packet + // should not be included when calculating the receive bitrate. + assert!(cluster.size_total > cluster.size_first_receive); + let receive_size: DataSize = cluster.size_total - cluster.size_first_receive; + let receive_rate: DataRate = receive_size / receive_interval; + + let ratio: f64 = receive_rate / send_rate; + if ratio > Self::MAX_VALID_RATIO { + tracing::debug!( + "Probing unsuccessful, receive/send ratio too high [cluster id: {}] [send: {:?}/{:?} = {:?}] [receive: {:?}/{:?} = {:?}] [ratio: {:?} > {:?}]", + cluster_id, + send_size, + send_interval, + send_rate, + receive_size, + receive_interval, + receive_rate, + ratio, + Self::MAX_VALID_RATIO + ); + return None; + } + + tracing::debug!( + "Probing successful [cluster id: {}] [send: {:?}/{:?} = {:?}] [receive: {:?}/{:?} = {:?}]", + cluster_id, + send_size, + send_interval, + send_rate, + receive_size, + receive_interval, + receive_rate + ); + + let mut res: DataRate = std::cmp::min(send_rate, receive_rate); + // If we're receiving at significantly lower bitrate than we were sending at, + // it suggests that we've found the true capacity of the link. In this case, + // set the target bitrate slightly lower to not immediately overuse. + if receive_rate < Self::MIN_RATIO_FOR_UNSATURATED_LINK * send_rate { + assert!(send_rate > receive_rate); + res = Self::TARGET_UTILIZATION_FRACTION * receive_rate; + } + self.estimated_data_rate = Some(res); + self.estimated_data_rate + } + + pub fn fetch_and_reset_last_estimated_bitrate(&mut self) -> Option { + let estimated_data_rate: Option = self.estimated_data_rate; + self.estimated_data_rate.take(); + estimated_data_rate + } + + // Erases old cluster data that was seen before `timestamp`. + fn erase_old_clusters(&mut self, timestamp: Timestamp) { + self.clusters + .retain(|_, cluster| cluster.last_receive + Self::MAX_CLUSTER_HISTORY >= timestamp); + } +} + +#[cfg(test)] +mod test { + use super::*; + + const DEFAULT_MIN_PROBES: i64 = 5; + const DEFAULT_MIN_BYTES: i64 = 5000; + const TARGET_UTILIZATION_FRACTION: f64 = 0.95; + + #[derive(Default)] + struct TestProbeBitrateEstimator { + measured_data_rate: Option, + probe_bitrate_estimator: ProbeBitrateEstimator, + } + + impl TestProbeBitrateEstimator { + // TODO(philipel): Use PacedPacketInfo when ProbeBitrateEstimator is rewritten + // to use that information. + fn add_packet_feedback( + &mut self, + probe_cluster_id: i32, + size_bytes: usize, + send_time_ms: i64, + arrival_time_ms: i64, + min_probes: i64, + min_bytes: i64, + ) { + const REFERENCE_TIME: Timestamp = Timestamp::from_seconds(1000); + let mut feedback: PacketResult = PacketResult::default(); + feedback.sent_packet.send_time = REFERENCE_TIME + TimeDelta::from_millis(send_time_ms); + feedback.sent_packet.size = DataSize::from_bytes(size_bytes as _); + feedback.sent_packet.pacing_info = + PacedPacketInfo::new(probe_cluster_id, min_probes, min_bytes); + feedback.receive_time = REFERENCE_TIME + TimeDelta::from_millis(arrival_time_ms); + self.measured_data_rate = self + .probe_bitrate_estimator + .handle_probe_and_estimate_bitrate(&feedback); + } + } + + #[test] + fn one_cluster() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 10, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 20, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 30, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 30, 40, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + 800000.0, + epsilon = 10.0 + ); + } + + #[test] + fn one_cluster_too_few_probes() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 2000, 0, 10, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 2000, 10, 20, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 2000, 20, 30, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + + assert!(test.measured_data_rate.is_none()); + } + + #[test] + fn one_cluster_too_few_bytes() { + let mut test = TestProbeBitrateEstimator::default(); + const MIN_BYTES: i64 = 6000; + test.add_packet_feedback(0, 800, 0, 10, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 800, 10, 20, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 800, 20, 30, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 800, 30, 40, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 800, 40, 50, DEFAULT_MIN_PROBES, MIN_BYTES); + + assert!(test.measured_data_rate.is_none()); + } + + #[test] + fn small_cluster() { + let mut test = TestProbeBitrateEstimator::default(); + const MIN_BYTES: i64 = 1000; + test.add_packet_feedback(0, 150, 0, 10, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 150, 10, 20, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 150, 20, 30, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 150, 30, 40, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 150, 40, 50, DEFAULT_MIN_PROBES, MIN_BYTES); + test.add_packet_feedback(0, 150, 50, 60, DEFAULT_MIN_PROBES, MIN_BYTES); + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + 120000.0, + epsilon = 10.0 + ); + } + + #[test] + fn large_cluster() { + let mut test = TestProbeBitrateEstimator::default(); + const MIN_PROBES: i64 = 30; + const MIN_BYTES: i64 = 312500; + + for send_time in 0..25 { + let receive_time = send_time + 5; + test.add_packet_feedback(0, 12500, send_time, receive_time, MIN_PROBES, MIN_BYTES); + } + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + 100000000.0, + epsilon = 10.0 + ); + } + + #[test] + fn fast_receive() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 15, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 30, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 35, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 30, 40, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + 800000.0, + epsilon = 10.0 + ); + } + + #[test] + fn too_fast_receive() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 19, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 22, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 25, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 40, 27, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + + assert!(test.measured_data_rate.is_none()); + } + + #[test] + fn slow_receive() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 10, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 40, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 70, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 30, 85, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + // Expected send rate = 800 kbps, expected receive rate = 320 kbps. + + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + TARGET_UTILIZATION_FRACTION * 320000.0, + epsilon = 10.0 + ); + } + + #[test] + fn burst_receive() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 50, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 50, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 50, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 40, 50, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + + assert!(test.measured_data_rate.is_none()); + } + + #[test] + fn multiple_clusters() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 10, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 20, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 30, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 40, 60, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + // Expected send rate = 600 kbps, expected receive rate = 480 kbps. + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + TARGET_UTILIZATION_FRACTION * 480000.0, + epsilon = 10.0 + ); + + test.add_packet_feedback(0, 1000, 50, 60, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + // Expected send rate = 640 kbps, expected receive rate = 640 kbps. + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + 640000.0, + epsilon = 10.0 + ); + + test.add_packet_feedback(1, 1000, 60, 70, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(1, 1000, 65, 77, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(1, 1000, 70, 84, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(1, 1000, 75, 90, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + // Expected send rate = 1600 kbps, expected receive rate = 1200 kbps. + + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + TARGET_UTILIZATION_FRACTION * 1200000.0, + epsilon = 10.0 + ); + } + + #[test] + fn ignore_old_clusters() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 10, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 20, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 30, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + + test.add_packet_feedback(1, 1000, 60, 70, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(1, 1000, 65, 77, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(1, 1000, 70, 84, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(1, 1000, 75, 90, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + // Expected send rate = 1600 kbps, expected receive rate = 1200 kbps. + + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + TARGET_UTILIZATION_FRACTION * 1200000.0, + epsilon = 10.0 + ); + + // Coming in 6s later + test.add_packet_feedback( + 0, + 1000, + 40 + 6000, + 60 + 6000, + DEFAULT_MIN_PROBES, + DEFAULT_MIN_BYTES, + ); + + assert!(test.measured_data_rate.is_none()); + } + + #[test] + fn ignore_size_last_send_packet() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 10, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 20, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 30, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 30, 40, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1500, 40, 50, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + // Expected send rate = 800 kbps, expected receive rate = 900 kbps. + + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + 800000.0, + epsilon = 10.0 + ); + } + + #[test] + fn ignore_size_first_receive_packet() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1500, 0, 10, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 20, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 30, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 30, 40, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + // Expected send rate = 933 kbps, expected receive rate = 800 kbps. + + assert_relative_eq!( + test.measured_data_rate.unwrap().bps_float(), + TARGET_UTILIZATION_FRACTION * 800000.0, + epsilon = 10.0 + ); + } + + #[test] + fn no_last_estimated_bitrate_bps() { + let mut test = TestProbeBitrateEstimator::default(); + assert!( + test.probe_bitrate_estimator + .fetch_and_reset_last_estimated_bitrate() + .is_none() + ); + } + + #[test] + fn fetch_last_estimated_bitrate_bps() { + let mut test = TestProbeBitrateEstimator::default(); + test.add_packet_feedback(0, 1000, 0, 10, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 10, 20, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 20, 30, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + test.add_packet_feedback(0, 1000, 30, 40, DEFAULT_MIN_PROBES, DEFAULT_MIN_BYTES); + + let estimated_bitrate = test + .probe_bitrate_estimator + .fetch_and_reset_last_estimated_bitrate(); + assert!(estimated_bitrate.is_some()); + assert_relative_eq!( + estimated_bitrate.unwrap().bps_float(), + 800000.0, + epsilon = 10.0 + ); + assert!( + test.probe_bitrate_estimator + .fetch_and_reset_last_estimated_bitrate() + .is_none() + ); + } +} // namespace webrtc diff --git a/rs/moq-congestion/src/gcc/probe_controller.rs b/rs/moq-congestion/src/gcc/probe_controller.rs new file mode 100644 index 00000000000..63f15692e7e --- /dev/null +++ b/rs/moq-congestion/src/gcc/probe_controller.rs @@ -0,0 +1,2077 @@ +/* + * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::api::{ + transport::{NetworkAvailability, ProbeClusterConfig}, + units::{DataRate, TimeDelta, Timestamp}, +}; + +// NOTE: These have been renamed to match the field trials. +// WebRTC-Bwe-ProbingConfiguration +#[derive(Debug, Clone)] +pub struct ProbeControllerConfig { + // These parameters configure the initial probes. First we send one or two + // probes of sizes p1 * self.start_bitrate and p2 * self.start_bitrate. + // Then whenever we get a bitrate estimate of at least further_probe_threshold + // times the size of the last sent probe we'll send another one of size + // step_size times the new estimate. + pub p1: f64, + pub p2: f64, + pub step_size: f64, + pub further_probe_threshold: f64, + pub abort_further: bool, + // Duration of time from the first initial probe where repeated initial probes + // are sent if repeated initial probing is enabled. + pub initial_probing: TimeDelta, + // The minimum probing duration of an individual probe during + // the repeated_initial_probing_time_period. + pub initial_probe_duration: TimeDelta, + // Delta time between sent bursts of packets in a probe during + // the repeated_initial_probing_time_period. + pub initial_min_probe_delta: TimeDelta, + // Configures how often we send ALR probes and how big they are. + pub alr_interval: TimeDelta, + pub alr_scale: f64, + + // Configures the probes emitted by changed to the allocated bitrate. + pub probe_max_allocation: bool, + pub probe_max_allocation_without_alr: bool, + pub alloc_p1: f64, + pub alloc_p2: f64, + pub alloc_current_bwe_limit: f64, + + // The minimum number probing packets used. + pub min_probe_packets_sent: i32, + // The minimum probing duration. + pub min_probe_duration: TimeDelta, + // Delta time between sent bursts of packets in a probe. + pub min_probe_delta: TimeDelta, + pub loss_limited_scale: f64, + // Don't send a probe if min(estimate, network state estimate) is larger than + // this fraction of the set max or max allocated bitrate. + pub skip_if_est_larger_than_fraction_of_max: f64, + // Scale factor of the max allocated bitrate. Used when deciding if a probe + // can be skiped due to that the estimate is already high enough. + pub skip_max_allocated_scale: f64, +} + +impl Default for ProbeControllerConfig { + fn default() -> Self { + Self { + p1: 3.0, + p2: 6.0, + step_size: 2.0, + further_probe_threshold: 0.7, + abort_further: false, + initial_probing: TimeDelta::from_seconds(5), + initial_probe_duration: TimeDelta::from_millis(100), + initial_min_probe_delta: TimeDelta::from_millis(20), + alr_interval: TimeDelta::from_seconds(5), + alr_scale: 2.0, + probe_max_allocation: true, + probe_max_allocation_without_alr: true, + alloc_p1: 1.0, + alloc_p2: 2.0, + alloc_current_bwe_limit: 2.0, + min_probe_packets_sent: 5, + min_probe_duration: TimeDelta::from_millis(15), + min_probe_delta: TimeDelta::from_millis(2), + loss_limited_scale: 1.5, + skip_if_est_larger_than_fraction_of_max: 0.0, + skip_max_allocated_scale: 1.0, + } + } +} + +// Reason that bandwidth estimate is limited. Bandwidth estimate can be limited +// by either delay based bwe, or loss based bwe when it increases/decreases the +// estimate. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum BandwidthLimitedCause { + LossLimitedBweIncreasing = 0, + LossLimitedBwe = 1, + DelayBasedLimited = 2, + DelayBasedLimitedDelayIncreased = 3, + RttBasedBackOffHighRtt = 4, +} + +#[derive(Clone)] +enum State { + // Initial state where no probing has been triggered yet. + Init, + // Waiting for probing results to continue further probing. + WaitingForProbingResult, + // Probing is complete. + ProbingComplete, +} + +// This class controls initiation of probing to estimate initial channel +// capacity. There is also support for probing during a session when max +// bitrate is adjusted by an application. +#[derive(Clone)] +pub struct ProbeController { + network_available: bool, + repeated_initial_probing_enabled: bool, + last_allowed_repeated_initial_probe: Timestamp, + bandwidth_limited_cause: BandwidthLimitedCause, + state: State, + min_bitrate_to_probe_further: DataRate, + time_last_probing_initiated: Timestamp, + estimated_bitrate: DataRate, + start_bitrate: DataRate, + max_bitrate: DataRate, + last_bwe_drop_probing_time: Timestamp, + alr_start_time: Option, + alr_end_time: Option, + enable_periodic_alr_probing: bool, + time_of_last_large_drop: Timestamp, + bitrate_before_last_large_drop: DataRate, + max_total_allocated_bitrate: DataRate, + + in_rapid_recovery_experiment: bool, + next_probe_cluster_id: i32, + + config: ProbeControllerConfig, +} + +impl Default for ProbeController { + fn default() -> Self { + let mut this = Self { + network_available: false, + state: State::Init, + min_bitrate_to_probe_further: DataRate::plus_infinity(), + time_last_probing_initiated: Timestamp::minus_infinity(), + estimated_bitrate: DataRate::zero(), + start_bitrate: DataRate::zero(), + max_bitrate: DataRate::plus_infinity(), + last_bwe_drop_probing_time: Timestamp::zero(), + alr_start_time: None, + alr_end_time: None, + enable_periodic_alr_probing: false, + time_of_last_large_drop: Timestamp::minus_infinity(), + bitrate_before_last_large_drop: DataRate::zero(), + max_total_allocated_bitrate: DataRate::zero(), + in_rapid_recovery_experiment: false, + next_probe_cluster_id: 1, + config: ProbeControllerConfig::default(), + repeated_initial_probing_enabled: false, + last_allowed_repeated_initial_probe: Timestamp::minus_infinity(), + bandwidth_limited_cause: BandwidthLimitedCause::DelayBasedLimited, + }; + this.reset(Timestamp::zero()); + this + } +} + +impl ProbeController { + // the measured results back. + const MAX_WAITING_TIME_FOR_PROBING_RESULT: TimeDelta = TimeDelta::from_seconds(1); + + // Default probing bitrate limit. Applied only when the application didn't + // specify max bitrate. + const DEFAULT_MAX_PROBING_BITRATE: DataRate = DataRate::from_kilobits_per_sec(5000); + + // If the bitrate drops to a factor `kBitrateDropThreshold` or lower + // and we recover within `kBitrateDropTimeoutMs`, then we'll send + // a probe at a fraction `kProbeFractionAfterDrop` of the original bitrate. + const BITRATE_DROP_THRESHOLD: f64 = 0.66; + const BITRATE_DROP_TIMEOUT: TimeDelta = TimeDelta::from_seconds(5); + const PROBE_FRACTION_AFTER_DROP: f64 = 0.85; + + // Timeout for probing after leaving ALR. If the bitrate drops significantly, + // (as determined by the delay based estimator) and we leave ALR, then we will + // send a probe if we recover within `kLeftAlrTimeoutMs` ms. + const ALR_ENDED_TIMEOUT: TimeDelta = TimeDelta::from_seconds(3); + + // The expected uncertainty of probe result (as a fraction of the target probe + // This is a limit on how often probing can be done when there is a BW + // drop detected in ALR. + const MIN_TIME_BETWEEN_ALR_PROBES: TimeDelta = TimeDelta::from_seconds(5); + + // bitrate). Used to avoid probing if the probe bitrate is close to our current + // estimate. + const PROBE_UNCERTAINTY: f64 = 0.05; + + pub fn new(config: ProbeControllerConfig) -> Self { + Self { + config, + ..Default::default() + } + } + + pub fn set_bitrates( + &mut self, + min_bitrate: DataRate, + start_bitrate: DataRate, + max_bitrate: DataRate, + at_time: Timestamp, + ) -> Vec { + if start_bitrate > DataRate::zero() { + self.start_bitrate = start_bitrate; + self.estimated_bitrate = start_bitrate; + } else if self.start_bitrate.is_zero() { + self.start_bitrate = min_bitrate; + } + + // The reason we use the variable `old_max_bitrate_pbs` is because we + // need to set `max_bitrate_` before we call InitiateProbing. + let old_max_bitrate: DataRate = self.max_bitrate; + self.max_bitrate = if max_bitrate.is_finite() { + max_bitrate + } else { + Self::DEFAULT_MAX_PROBING_BITRATE + }; + + match self.state { + State::Init => { + if self.network_available { + return self.initiate_exponential_probing(at_time); + } + } + State::WaitingForProbingResult => (), + State::ProbingComplete => { + // If the new max bitrate is higher than both the old max bitrate and the + // estimate then initiate probing. + if !self.estimated_bitrate.is_zero() + && old_max_bitrate < self.max_bitrate + && self.estimated_bitrate < self.max_bitrate + { + return self.initiate_probing(at_time, &[self.max_bitrate], false); + } + } + } + vec![] + } + + // The total bitrate, as opposed to the max bitrate, is the sum of the + // configured bitrates for all active streams. + + pub fn on_max_total_allocated_bitrate( + &mut self, + max_total_allocated_bitrate: DataRate, + at_time: Timestamp, + ) -> Vec { + let in_alr: bool = self.alr_start_time.is_some(); + let allow_allocation_probe: bool = in_alr || self.config.probe_max_allocation_without_alr; + if self.config.probe_max_allocation + && matches!(self.state, State::ProbingComplete) + && max_total_allocated_bitrate > self.max_total_allocated_bitrate + && self.estimated_bitrate < self.max_bitrate + && self.estimated_bitrate < max_total_allocated_bitrate + && allow_allocation_probe + { + self.max_total_allocated_bitrate = max_total_allocated_bitrate; + + if self.config.alloc_p1 == 0.0 { + return vec![]; + } + + let mut first_probe_rate: DataRate = max_total_allocated_bitrate * self.config.alloc_p1; + let current_bwe_limit: DataRate = + self.config.alloc_current_bwe_limit * self.estimated_bitrate; + let mut limited_by_current_bwe: bool = current_bwe_limit < first_probe_rate; + if limited_by_current_bwe { + first_probe_rate = current_bwe_limit; + } + + let mut probes: Vec = vec![first_probe_rate]; + if self.config.alloc_p2 > 0.0 && !limited_by_current_bwe { + let mut second_probe_rate: DataRate = + max_total_allocated_bitrate * self.config.alloc_p2; + limited_by_current_bwe = current_bwe_limit < second_probe_rate; + if limited_by_current_bwe { + second_probe_rate = current_bwe_limit; + } + if second_probe_rate > first_probe_rate { + probes.push(second_probe_rate); + } + } + + let allow_further_probing: bool = limited_by_current_bwe; + + return self.initiate_probing(at_time, &probes, allow_further_probing); + } + if !max_total_allocated_bitrate.is_zero() { + self.last_allowed_repeated_initial_probe = at_time; + } + + self.max_total_allocated_bitrate = max_total_allocated_bitrate; + vec![] + } + + pub fn on_network_availability(&mut self, msg: NetworkAvailability) -> Vec { + self.network_available = msg.network_available; + + if !self.network_available && matches!(self.state, State::WaitingForProbingResult) { + self.state = State::ProbingComplete; + self.min_bitrate_to_probe_further = DataRate::plus_infinity(); + } + + if self.network_available + && matches!(self.state, State::Init) + && !self.start_bitrate.is_zero() + { + return self.initiate_exponential_probing(msg.at_time); + } + vec![] + } + + pub fn set_estimated_bitrate( + &mut self, + bitrate: DataRate, + bandwidth_limited_cause: BandwidthLimitedCause, + at_time: Timestamp, + ) -> Vec { + self.bandwidth_limited_cause = bandwidth_limited_cause; + if bitrate < Self::BITRATE_DROP_THRESHOLD * self.estimated_bitrate { + self.time_of_last_large_drop = at_time; + self.bitrate_before_last_large_drop = self.estimated_bitrate; + } + self.estimated_bitrate = bitrate; + + if matches!(self.state, State::WaitingForProbingResult) { + // Continue probing if probing results indicate channel has greater + // capacity unless we already reached the needed bitrate. + if self.config.abort_further + && (bitrate > self.max_bitrate + || (!self.max_total_allocated_bitrate.is_zero() + && bitrate > 2 * self.max_total_allocated_bitrate)) + { + // No need to continue probing. + self.min_bitrate_to_probe_further = DataRate::plus_infinity(); + } + tracing::debug!( + "Measured bitrate: {:?} Minimum to probe further: {:?}", + bitrate, + self.min_bitrate_to_probe_further, + ); + + if bitrate > self.min_bitrate_to_probe_further { + return self.initiate_probing(at_time, &[self.config.step_size * bitrate], true); + } + } + vec![] + } + + pub fn enable_periodic_alr_probing(&mut self, enable: bool) { + self.enable_periodic_alr_probing = enable; + } + + // Probes are sent periodically every 1s during the first 5s after the network + // becomes available or until OnMaxTotalAllocatedBitrate is invoked with a + // none zero max_total_allocated_bitrate (there are active streams being + // sent.) Probe rate is up to max configured bitrate configured via + // SetBitrates. + pub fn enable_repeated_initial_probing(&mut self, enable: bool) { + self.repeated_initial_probing_enabled = enable; + } + + pub fn set_alr_start_time(&mut self, alr_start_time: Option) { + self.alr_start_time = alr_start_time; + } + pub fn set_alr_ended_time(&mut self, alr_end_time: Timestamp) { + self.alr_end_time = Some(alr_end_time); + } + + pub fn request_probe(&mut self, at_time: Timestamp) -> Vec { + // Called once we have returned to normal state after a large drop in + // estimated bandwidth. The current response is to initiate a single probe + // session (if not already probing) at the previous bitrate. + // + // If the probe session fails, the assumption is that this drop was a + // real one from a competing flow or a network change. + let in_alr: bool = self.alr_start_time.is_some(); + let alr_ended_recently: bool = match self.alr_end_time { + Some(alr_end_time) => at_time - alr_end_time < Self::ALR_ENDED_TIMEOUT, + _ => false, + }; + + if (in_alr || alr_ended_recently || self.in_rapid_recovery_experiment) + && matches!(self.state, State::ProbingComplete) + { + let suggested_probe: DataRate = + Self::PROBE_FRACTION_AFTER_DROP * self.bitrate_before_last_large_drop; + let min_expected_probe_result: DataRate = + (1.0 - Self::PROBE_UNCERTAINTY) * suggested_probe; + let time_since_drop: TimeDelta = at_time - self.time_of_last_large_drop; + let time_since_probe: TimeDelta = at_time - self.last_bwe_drop_probing_time; + if min_expected_probe_result > self.estimated_bitrate + && time_since_drop < Self::BITRATE_DROP_TIMEOUT + && time_since_probe > Self::MIN_TIME_BETWEEN_ALR_PROBES + { + tracing::debug!("Detected big bandwidth drop, start probing."); + // Track how often we probe in response to bandwidth drop in ALR. + self.last_bwe_drop_probing_time = at_time; + return self.initiate_probing(at_time, &[suggested_probe], false); + } + } + vec![] + } + + // Resets the ProbeController to a state equivalent to as if it was just + // created EXCEPT for configuration settings like + // `enable_periodic_alr_probing_` `network_available_` and + // `max_total_allocated_bitrate_`. + pub fn reset(&mut self, at_time: Timestamp) { + self.bandwidth_limited_cause = BandwidthLimitedCause::DelayBasedLimited; + self.state = State::Init; + self.min_bitrate_to_probe_further = DataRate::plus_infinity(); + self.time_last_probing_initiated = Timestamp::zero(); + self.estimated_bitrate = DataRate::zero(); + self.start_bitrate = DataRate::zero(); + self.max_bitrate = Self::DEFAULT_MAX_PROBING_BITRATE; + let now: Timestamp = at_time; + self.last_bwe_drop_probing_time = now; + self.alr_end_time.take(); + self.time_of_last_large_drop = now; + self.bitrate_before_last_large_drop = DataRate::zero(); + } + + pub fn process(&mut self, at_time: Timestamp) -> Vec { + if at_time - self.time_last_probing_initiated > Self::MAX_WAITING_TIME_FOR_PROBING_RESULT + && matches!(self.state, State::WaitingForProbingResult) + { + tracing::debug!("WaitingForProbingResult: timeout"); + self.update_state(State::ProbingComplete); + } + if self.estimated_bitrate.is_zero() || !matches!(self.state, State::ProbingComplete) { + return vec![]; + } + if self.time_for_next_repeated_initial_probe(at_time) { + return self.initiate_probing( + at_time, + &[self.estimated_bitrate * self.config.p1], + true, + ); + } + if self.time_for_alr_probe(at_time) { + return self.initiate_probing( + at_time, + &[self.estimated_bitrate * self.config.alr_scale], + true, + ); + } + vec![] + } + + fn update_state(&mut self, new_state: State) { + match new_state { + State::Init => { + self.state = State::Init; + } + State::WaitingForProbingResult => { + self.state = State::WaitingForProbingResult; + } + State::ProbingComplete => { + self.state = State::ProbingComplete; + self.min_bitrate_to_probe_further = DataRate::plus_infinity(); + } + } + } + fn initiate_exponential_probing(&mut self, at_time: Timestamp) -> Vec { + assert!(self.network_available); + assert!(matches!(self.state, State::Init)); + assert!(self.start_bitrate > DataRate::zero()); + + // When probing at 1.8 Mbps ( 6x 300), this represents a threshold of + // 1.2 Mbps to continue probing. + let mut probes: Vec = vec![self.config.p1 * self.start_bitrate]; + if self.config.p2 > 0.0 { + probes.push(self.config.p2 * self.start_bitrate); + } + + if self.repeated_initial_probing_enabled && self.max_total_allocated_bitrate.is_zero() { + self.last_allowed_repeated_initial_probe = at_time + self.config.initial_probing; + tracing::debug!( + "Repeated initial probing enabled, last allowed probe: {:?} now: {:?}", + self.last_allowed_repeated_initial_probe, + at_time + ); + } + + self.initiate_probing(at_time, &probes, true) + } + fn initiate_probing( + &mut self, + now: Timestamp, + bitrates_to_probe: &[DataRate], + mut probe_further: bool, + ) -> Vec { + if self.config.skip_if_est_larger_than_fraction_of_max > 0.0 { + let max_probe_rate: DataRate = if self.max_total_allocated_bitrate.is_zero() { + self.max_bitrate + } else { + std::cmp::min( + self.config.skip_max_allocated_scale * self.max_total_allocated_bitrate, + self.max_bitrate, + ) + }; + if self.estimated_bitrate + > self.config.skip_if_est_larger_than_fraction_of_max * max_probe_rate + { + self.update_state(State::ProbingComplete); + return vec![]; + } + } + + let mut max_probe_bitrate: DataRate = self.max_bitrate; + if self.max_total_allocated_bitrate > DataRate::zero() { + // If a max allocated bitrate has been configured, allow probing up to 2x + // that rate. This allows some overhead to account for bursty streams, + // which otherwise would have to ramp up when the overshoot is already in + // progress. + // It also avoids minor quality reduction caused by probes often being + // received at slightly less than the target probe bitrate. + max_probe_bitrate = + std::cmp::min(max_probe_bitrate, self.max_total_allocated_bitrate * 2); + } + + match self.bandwidth_limited_cause { + BandwidthLimitedCause::RttBasedBackOffHighRtt + | BandwidthLimitedCause::DelayBasedLimitedDelayIncreased + | BandwidthLimitedCause::LossLimitedBwe => { + tracing::debug!( + "Not sending probe in bandwidth limited state. {:?}", + self.bandwidth_limited_cause + ); + return vec![]; + } + BandwidthLimitedCause::LossLimitedBweIncreasing => { + max_probe_bitrate = std::cmp::min( + max_probe_bitrate, + self.estimated_bitrate * self.config.loss_limited_scale, + ); + } + _ => (), + }; + + let mut pending_probes: Vec = vec![]; + for mut bitrate in bitrates_to_probe.iter().cloned() { + assert!(!bitrate.is_zero()); + if bitrate >= max_probe_bitrate { + bitrate = max_probe_bitrate; + probe_further = false; + } + pending_probes.push(self.create_probe_cluster_config(now, bitrate)); + } + self.time_last_probing_initiated = now; + if probe_further { + self.update_state(State::WaitingForProbingResult); + // Dont expect probe results to be larger than a fraction of the actual + // probe rate. + self.min_bitrate_to_probe_further = pending_probes.last().unwrap().target_data_rate + * self.config.further_probe_threshold; + } else { + self.update_state(State::ProbingComplete); + } + pending_probes + } + fn time_for_alr_probe(&self, at_time: Timestamp) -> bool { + if let Some(alr_start_time) = self.alr_start_time + && self.enable_periodic_alr_probing + { + let next_probe_time: Timestamp = + std::cmp::max(alr_start_time, self.time_last_probing_initiated) + + self.config.alr_interval; + return at_time >= next_probe_time; + } + false + } + fn time_for_next_repeated_initial_probe(&self, at_time: Timestamp) -> bool { + if !matches!(self.state, State::WaitingForProbingResult) + && self.last_allowed_repeated_initial_probe > at_time + { + let next_probe_time: Timestamp = + self.time_last_probing_initiated + Self::MAX_WAITING_TIME_FOR_PROBING_RESULT; + if at_time >= next_probe_time { + return true; + } + } + false + } + fn create_probe_cluster_config( + &mut self, + at_time: Timestamp, + bitrate: DataRate, + ) -> ProbeClusterConfig { + let mut config = ProbeClusterConfig { + id: self.next_probe_cluster_id, + at_time, + target_data_rate: bitrate, + target_probe_count: self.config.min_probe_packets_sent, + ..Default::default() + }; + + self.next_probe_cluster_id += 1; + + if at_time < self.last_allowed_repeated_initial_probe { + config.target_duration = self.config.initial_probe_duration; + config.min_probe_delta = self.config.initial_min_probe_delta; + } else { + config.target_duration = self.config.min_probe_duration; + config.min_probe_delta = self.config.min_probe_delta; + } + + config + } +} + +#[cfg(test)] +mod test { + use super::*; + const MIN_BITRATE: DataRate = DataRate::from_bits_per_sec(100); + const START_BITRATE: DataRate = DataRate::from_bits_per_sec(300); + const MAX_BITRATE: DataRate = DataRate::from_bits_per_sec(10000); + const MBPS_MULTIPLIER: DataRate = DataRate::from_kilobits_per_sec(1000); + + const EXPONENTIAL_PROBING_TIMEOUT: TimeDelta = TimeDelta::from_seconds(5); + + const ALR_PROBE_INTERVAL: TimeDelta = TimeDelta::from_seconds(5); + const ALR_ENDED_TIMEOUT: TimeDelta = TimeDelta::from_seconds(3); + const BITRATE_DROP_TIMEOUT: TimeDelta = TimeDelta::from_seconds(5); + + #[test] + fn initiates_probing_after_set_bitrates() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(probes.len() >= 2); + } + + #[test] + fn initiates_probing_when_network_available() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + + let probes: Vec = + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(probes.is_empty()); + let probes = probe_controller.on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }); + assert!(probes.len() >= 2); + } + + #[test] + fn sets_default_target_duration_and_target_probe_count() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes: Vec = + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(probes.len() >= 2); + + assert_eq!(probes[0].target_duration, TimeDelta::from_millis(15)); + assert_eq!(probes[0].target_probe_count, 5); + } + + #[test] + fn field_trials_override_default_target_duration_and_target_probe_count() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + min_probe_packets_sent: 2, + min_probe_duration: TimeDelta::from_millis(123), + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes: Vec = + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(probes.len() >= 2); + + assert_eq!(probes[0].target_duration, TimeDelta::from_millis(123)); + assert_eq!(probes[0].target_probe_count, 2); + } + + #[test] + fn probe_only_when_network_is_up() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + probe_controller.on_network_availability(NetworkAvailability { + at_time: clock, + network_available: false, + }); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(probes.is_empty()); + let probes = probe_controller.on_network_availability(NetworkAvailability { + at_time: clock, + network_available: true, + }); + assert!(probes.len() >= 2); + } + + #[test] + fn can_configure_initial_probe_rate_factor() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + p1: 2.0, + p2: 3.0, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert_eq!(probes.len(), 2); + assert_eq!(probes[0].target_data_rate, START_BITRATE * 2); + assert_eq!(probes[1].target_data_rate, START_BITRATE * 3); + } + + #[test] + fn disable_second_initial_probe_if_rate_factorzero() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + p1: 2.0, + p2: 0.0, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate, START_BITRATE * 2); + } + + #[test] + fn initiates_probing_on_max_bitrate_increase() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + // Long enough to time out exponential probing. + clock += EXPONENTIAL_PROBING_TIMEOUT; + probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + probe_controller.process(clock); + let probes = probe_controller.set_bitrates( + MIN_BITRATE, + START_BITRATE, + MAX_BITRATE + DataRate::from_bits_per_sec(100), + clock, + ); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate.bps(), MAX_BITRATE.bps() + 100); + } + + #[test] + fn probes_in_alr_when_allocation_probing_outside_alr_is_disabled() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + probe_max_allocation_without_alr: false, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + probe_controller.set_estimated_bitrate( + MAX_BITRATE - DataRate::from_bits_per_sec(1), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + // Wait long enough to time out exponential probing. + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + // Probe when in alr. + probe_controller.set_alr_start_time(Some(clock)); + let probes = probe_controller + .on_max_total_allocated_bitrate(MAX_BITRATE + DataRate::from_bits_per_sec(1), clock); + assert_eq!(probes.len(), 2); + assert_eq!(probes[0].target_data_rate, MAX_BITRATE); + } + + #[test] + fn probes_on_max_allocated_bitrate_increase_outside_alr_by_default() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + const TRANSPORT_MAX_BITRATE: DataRate = DataRate::from_kilobits_per_sec(10000); + const ESTIMATED_AFTER_INITIAL_PROBING: DataRate = DataRate::from_kilobits_per_sec(300); + const SCREEN_SHARE_MAX_BITRATE: DataRate = DataRate::from_kilobits_per_sec(3500); + + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, TRANSPORT_MAX_BITRATE, clock); + probe_controller.set_estimated_bitrate( + ESTIMATED_AFTER_INITIAL_PROBING, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + probe_controller.set_alr_start_time(None); + let probes = + probe_controller.on_max_total_allocated_bitrate(SCREEN_SHARE_MAX_BITRATE, clock); + assert_eq!(probes.len(), 1); + assert_eq!( + probes[0].target_data_rate, + 2 * ESTIMATED_AFTER_INITIAL_PROBING + ); + } + + #[test] + fn allocation_probing_outside_alr_can_be_disabled() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + probe_max_allocation_without_alr: false, + ..Default::default() + }); + const TRANSPORT_MAX_BITRATE: DataRate = DataRate::from_kilobits_per_sec(10000); + const ESTIMATED_AFTER_INITIAL_PROBING: DataRate = DataRate::from_kilobits_per_sec(300); + const SCREEN_SHARE_MAX_BITRATE: DataRate = DataRate::from_kilobits_per_sec(3500); + + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, TRANSPORT_MAX_BITRATE, clock); + probe_controller.set_estimated_bitrate( + ESTIMATED_AFTER_INITIAL_PROBING, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + probe_controller.set_alr_start_time(None); + assert!( + probe_controller + .on_max_total_allocated_bitrate(SCREEN_SHARE_MAX_BITRATE, clock) + .is_empty() + ); + } + + #[test] + fn does_not_probe_when_max_allocated_bitrate_decreases() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + // Set the initial allocation while initial probing is still in progress. + assert!( + probe_controller + .on_max_total_allocated_bitrate(MAX_BITRATE, clock) + .is_empty() + ); + probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + clock += EXPONENTIAL_PROBING_TIMEOUT; + assert!(probe_controller.process(clock).is_empty()); + + // A lower allocation should update the limit without triggering a probe. + assert!( + probe_controller + .on_max_total_allocated_bitrate(MAX_BITRATE / 2, clock) + .is_empty() + ); + + // A subsequent increase from the lower allocation should trigger probing. + let probes = probe_controller.on_max_total_allocated_bitrate(MAX_BITRATE * 3 / 4i64, clock); + assert!(!probes.is_empty()); + } + + #[test] + fn probes_on_max_allocated_bitrate_limited_by_current_bwe() { + let mut clock: Timestamp = Timestamp::zero(); + + assert!(MAX_BITRATE > 1.5 * START_BITRATE); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + // Wait long enough to time out exponential probing. + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + // Probe when in alr. + probe_controller.set_alr_start_time(Some(clock)); + let probes = probe_controller.on_max_total_allocated_bitrate(MAX_BITRATE, clock); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate, 2.0 * START_BITRATE); + + // Continue probing if probe succeeds. + let probes = probe_controller.set_estimated_bitrate( + 1.5 * START_BITRATE, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert_eq!(probes.len(), 1); + assert!(probes[0].target_data_rate > 1.5 * START_BITRATE); + } + + #[test] + fn can_disable_probing_on_max_total_allocated_bitrate_increase() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + probe_max_allocation: false, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + probe_controller.set_estimated_bitrate( + MAX_BITRATE - DataRate::from_bits_per_sec(1), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + probe_controller.set_alr_start_time(Some(clock)); + + // Do no probe, since probe_max_allocation:false. + probe_controller.set_alr_start_time(Some(clock)); + let probes = probe_controller + .on_max_total_allocated_bitrate(MAX_BITRATE + DataRate::from_bits_per_sec(1), clock); + assert!(probes.is_empty()); + } + + #[test] + fn initiates_probing_on_max_bitrate_increase_at_max_bitrate() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + // Long enough to time out exponential probing. + clock += EXPONENTIAL_PROBING_TIMEOUT; + probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + probe_controller.process(clock); + probe_controller.set_estimated_bitrate( + MAX_BITRATE, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + let probes = probe_controller.set_bitrates( + MIN_BITRATE, + START_BITRATE, + MAX_BITRATE + DataRate::from_bits_per_sec(100), + clock, + ); + assert_eq!(probes.len(), 1); + assert_eq!( + probes[0].target_data_rate, + MAX_BITRATE + DataRate::from_bits_per_sec(100) + ); + } + + #[test] + fn test_exponential_probing() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + + // Repeated probe should only be sent when estimated bitrate climbs above + // 0.7 * 6 * StartBitrate = 1260. + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(1000), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert!(probes.is_empty()); + + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(1800), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate.bps(), 2 * 1800); + } + + #[test] + fn exponential_probing_stop_if_max_bitrate_low() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + abort_further: true, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + // Repeated probe normally is sent when estimated bitrate climbs above + // 0.7 * 6 * StartBitrate = 1260. But since max bitrate is low, expect + // exponential probing to stop. + let probes = probe_controller.set_bitrates( + MIN_BITRATE, + START_BITRATE, + /*max_bitrate=*/ START_BITRATE, + clock, + ); + assert!(probes.is_empty()); + + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(1800), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert!(probes.is_empty()); + } + + #[test] + fn exponential_probing_stop_if_max_allocated_bitrate_low() { + let clock = Timestamp::zero(); + let mut probe_controller: ProbeController = ProbeController::new(ProbeControllerConfig { + abort_further: true, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + // Repeated probe normally is sent when estimated bitrate climbs above + // 0.7 * 6 * StartBitrate = 1260. But since allocated bitrate i slow, expect + // exponential probing to stop. + let probes = probe_controller.on_max_total_allocated_bitrate(START_BITRATE, clock); + assert!(probes.is_empty()); + + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(1800), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert!(probes.is_empty()); + } + + #[test] + fn initial_probing_to_low_max_allocatedbitrate() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + // Repeated probe is sent when estimated bitrate climbs above + // 0.7 * 6 * StartBitrate = 1260. + let probes = probe_controller.on_max_total_allocated_bitrate(START_BITRATE, clock); + assert!(probes.is_empty()); + + // If the inital probe result is received, a new probe is sent at 2x the + // needed max bitrate. + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(1800), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate.bps(), 2 * START_BITRATE.bps()); + } + + #[test] + fn initial_probing_timeout() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + // Advance far enough to cause a time out in waiting for probing result. + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(1800), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert!(probes.is_empty()); + } + + #[test] + fn repeated_initial_probing_sends_new_probe_after_timeout() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + probe_controller.enable_repeated_initial_probing(true); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + let start_time: Timestamp = clock; + let mut last_probe_time: Timestamp = clock; + while clock < start_time + TimeDelta::from_seconds(5) { + clock += TimeDelta::from_millis(100); + let probes = probe_controller.process(clock); + if !probes.is_empty() { + // Expect a probe every second. + assert_eq!(clock - last_probe_time, TimeDelta::from_seconds_float(1.1)); + assert_eq!(probes[0].min_probe_delta, TimeDelta::from_millis(20)); + assert_eq!(probes[0].target_duration, TimeDelta::from_millis(100)); + last_probe_time = clock; + } else { + assert!(clock - last_probe_time < TimeDelta::from_seconds_float(1.1)); + } + } + clock += TimeDelta::from_seconds(1); + // After 5s, repeated initial probing stops. + assert!(probe_controller.process(clock).is_empty()); + } + + #[test] + fn repeated_initial_probing_stop_if_max_allocated_bitrate_set() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + probe_controller.enable_repeated_initial_probing(true); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + clock += TimeDelta::from_millis(1100); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + probe_controller.on_max_total_allocated_bitrate(MIN_BITRATE, clock); + clock += TimeDelta::from_millis(1100); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + } + + #[test] + fn request_probe_in_alr() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(probes.len() >= 2); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + probe_controller.set_alr_start_time(Some(clock)); + clock += ALR_PROBE_INTERVAL + TimeDelta::from_millis(1); + probe_controller.process(clock); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(250), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + let probes = probe_controller.request_probe(clock); + + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate.bps_float(), 0.85 * 500.0); + } + + #[test] + fn request_probe_when_alr_ended_recently() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert_eq!(probes.len(), 2); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + probe_controller.set_alr_start_time(None); + clock += ALR_PROBE_INTERVAL + TimeDelta::from_millis(1); + probe_controller.process(clock); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(250), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + probe_controller.set_alr_ended_time(clock); + clock += ALR_ENDED_TIMEOUT - TimeDelta::from_millis(1); + let probes = probe_controller.request_probe(clock); + + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate.bps_float(), 0.85 * 500.0); + } + + #[test] + fn request_probe_when_alr_not_ended_recently() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert_eq!(probes.len(), 2); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + probe_controller.set_alr_start_time(None); + clock += ALR_PROBE_INTERVAL + TimeDelta::from_millis(1); + probe_controller.process(clock); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(250), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + probe_controller.set_alr_ended_time(clock); + clock += ALR_ENDED_TIMEOUT + TimeDelta::from_millis(1); + let probes = probe_controller.request_probe(clock); + assert!(probes.is_empty()); + } + + #[test] + fn request_probe_when_bwe_drop_not_recent() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert_eq!(probes.len(), 2); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + probe_controller.set_alr_start_time(Some(clock)); + clock += ALR_PROBE_INTERVAL + TimeDelta::from_millis(1); + probe_controller.process(clock); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(250), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + clock += BITRATE_DROP_TIMEOUT + TimeDelta::from_millis(1); + let probes = probe_controller.request_probe(clock); + assert!(probes.is_empty()); + } + + #[test] + fn periodic_probing() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.enable_periodic_alr_probing(true); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert_eq!(probes.len(), 2); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + let start_time: Timestamp = clock; + + // Expect the controller to send a new probe after 5s has passed. + probe_controller.set_alr_start_time(Some(start_time)); + clock += TimeDelta::from_seconds(5); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate.bps(), 1000); + + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + // The following probe should be sent at 10s into ALR. + probe_controller.set_alr_start_time(Some(start_time)); + clock += TimeDelta::from_seconds(4); + probe_controller.process(clock); + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert!(probes.is_empty()); + + probe_controller.set_alr_start_time(Some(start_time)); + clock += TimeDelta::from_seconds(1); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert!(probes.is_empty()); + } + + #[test] + fn periodic_probing_after_reset() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let alr_start_time: Timestamp = clock; + + probe_controller.set_alr_start_time(Some(alr_start_time)); + probe_controller.enable_periodic_alr_probing(true); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + probe_controller.reset(clock); + + clock += TimeDelta::from_seconds(10); + let probes = probe_controller.process(clock); + // Since bitrates are not yet set, no probe is sent event though we are in ALR + // mode. + assert!(probes.is_empty()); + + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert_eq!(probes.len(), 2); + + // Make sure we use `kStartBitrateBps` as the estimated bitrate + // until SetEstimatedBitrate is called with an updated estimate. + clock += TimeDelta::from_seconds(10); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate, START_BITRATE * 2); + } + + #[test] + fn no_probes_when_transport_is_not_writable() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + probe_controller.enable_periodic_alr_probing(true); + + let probes: Vec = + probe_controller.on_network_availability(NetworkAvailability { + network_available: false, + ..Default::default() + }); + assert!(probes.is_empty()); + assert!( + probe_controller + .set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock) + .is_empty() + ); + clock += TimeDelta::from_seconds(10); + assert!(probe_controller.process(clock).is_empty()); + + // Controller is reset after a network route change. + // But, a probe should not be sent since the transport is not writable. + // Transport is not writable until after DTLS negotiation completes. + // However, the bitrate constraints may change. + probe_controller.reset(clock); + assert!( + probe_controller + .set_bitrates(2 * MIN_BITRATE, 2 * START_BITRATE, 2 * MAX_BITRATE, clock) + .is_empty() + ); + clock += TimeDelta::from_seconds(10); + assert!(probe_controller.process(clock).is_empty()); + } + + #[test] + fn test_exponential_probing_overflow() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates( + MIN_BITRATE, + 10 * MBPS_MULTIPLIER, + 100 * MBPS_MULTIPLIER, + clock, + ); + // Verify that probe bitrate is capped at the specified max bitrate. + let probes = probe_controller.set_estimated_bitrate( + 60 * MBPS_MULTIPLIER, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate, 100 * MBPS_MULTIPLIER); + // Verify that repeated probes aren't sent. + let probes = probe_controller.set_estimated_bitrate( + 100 * MBPS_MULTIPLIER, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert!(probes.is_empty()); + } + + #[test] + fn test_allocated_bitrate_cap() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates( + MIN_BITRATE, + 10 * MBPS_MULTIPLIER, + 100 * MBPS_MULTIPLIER, + clock, + ); + + // Configure ALR for periodic probing. + probe_controller.enable_periodic_alr_probing(true); + let alr_start_time: Timestamp = clock; + probe_controller.set_alr_start_time(Some(alr_start_time)); + + let estimated_bitrate: DataRate = 10 * MBPS_MULTIPLIER; + probe_controller.set_estimated_bitrate( + estimated_bitrate, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + // Set a max allocated bitrate below the current estimate. + let max_allocated: DataRate = estimated_bitrate - 1 * MBPS_MULTIPLIER; + let probes = probe_controller.on_max_total_allocated_bitrate(max_allocated, clock); + assert!(probes.is_empty()); // No probe since lower than current max. + + // Probes such as ALR capped at 2x the max allocation limit. + clock += TimeDelta::from_seconds(5); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate, 2 * max_allocated); + + // Remove allocation limit. + assert!( + probe_controller + .on_max_total_allocated_bitrate(DataRate::zero(), clock) + .is_empty() + ); + clock += TimeDelta::from_seconds(5); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate, estimated_bitrate * 2); + } + + #[test] + fn configurable_probing_field_trial() { + let mut clock = Timestamp::zero(); + + // "WebRTC-Bwe-ProbingConfiguration/p1:2,p2:5,step_size:3,further_probe_threshold:0.8,alloc_p1:2,alloc_current_bwe_limit:1000.0,alloc_p2,min_probe_packets_sent:2/" + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + p1: 2.0, + p2: 5.0, + step_size: 3.0, + further_probe_threshold: 0.8, + alloc_p1: 2.0, + alloc_current_bwe_limit: 1000.0, + alloc_p2: 0.0, + min_probe_packets_sent: 2, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + + let probes = probe_controller.set_bitrates( + MIN_BITRATE, + START_BITRATE, + DataRate::from_kilobits_per_sec(5000), + clock, + ); + assert_eq!(probes.len(), 2); + assert_eq!(probes[0].target_data_rate.bps(), 600); + assert_eq!(probes[0].target_probe_count, 2); + assert_eq!(probes[1].target_data_rate.bps(), 1500); + assert_eq!(probes[1].target_probe_count, 2); + + // Repeated probe should only be sent when estimated bitrate climbs above + // 0.8 * 5 * StartBitrateBps = 1200. + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(1100), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert_eq!(probes.len(), 0); + + let probes = probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(1250), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate.bps(), 3 * 1250); + + clock += TimeDelta::from_seconds(5); + probe_controller.process(clock); + + probe_controller.set_alr_start_time(Some(clock)); + let probes = probe_controller + .on_max_total_allocated_bitrate(DataRate::from_kilobits_per_sec(200), clock); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate.bps(), 400_000); + } + + #[test] + fn limit_alr_probe_when_loss_based_bwe_limited() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.enable_periodic_alr_probing(true); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + // Expect the controller to send a new probe after 5s has passed. + probe_controller.set_alr_start_time(Some(clock)); + clock += TimeDelta::from_seconds(5); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + + probe_controller.set_estimated_bitrate( + DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::LossLimitedBweIncreasing, + clock, + ); + clock += TimeDelta::from_seconds(6); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + assert_eq!( + probes[0].target_data_rate, + 1.5 * DataRate::from_bits_per_sec(500) + ); + + probe_controller.set_estimated_bitrate( + 1.5 * DataRate::from_bits_per_sec(500), + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + clock += TimeDelta::from_seconds(6); + let probes = probe_controller.process(clock); + assert!(!probes.is_empty()); + assert!(probes[0].target_data_rate > 1.5 * 1.5 * DataRate::from_bits_per_sec(500)); + } + + #[test] + fn probe_in_alr_if_loss_based_increasing() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + probe_controller.enable_periodic_alr_probing(true); + probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::LossLimitedBweIncreasing, + clock, + ); + + // Wait long enough to time out exponential probing. + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + // Probe when in alr. + probe_controller.set_alr_start_time(Some(clock)); + clock += ALR_PROBE_INTERVAL + TimeDelta::from_millis(1); + let probes = probe_controller.process(clock); + assert_eq!(probes.len(), 1); + assert_eq!(probes[0].target_data_rate, 1.5 * START_BITRATE); + } + + #[test] + fn not_probe_when_in_alr_if_loss_based_decreases() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + probe_controller.enable_periodic_alr_probing(true); + probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::LossLimitedBwe, + clock, + ); + + // Wait long enough to time out exponential probing. + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + // Not probe in alr when loss based estimate decreases. + probe_controller.set_alr_start_time(Some(clock)); + clock += ALR_PROBE_INTERVAL + TimeDelta::from_millis(1); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + } + + #[test] + fn not_probe_if_loss_based_increasing_outside_alr() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + probe_controller.enable_periodic_alr_probing(true); + probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::LossLimitedBweIncreasing, + clock, + ); + + // Wait long enough to time out exponential probing. + clock += EXPONENTIAL_PROBING_TIMEOUT; + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + probe_controller.set_alr_start_time(None); + clock += ALR_PROBE_INTERVAL + TimeDelta::from_millis(1); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + } + + #[test] + fn max_allocated_bitrate_not_reset() { + let clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + probe_controller.on_max_total_allocated_bitrate(START_BITRATE / 4, clock); + probe_controller.reset(clock); + + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + assert_eq!(probes[0].target_data_rate, START_BITRATE / 4 * 2); + } + + #[test] + fn skip_alr_probe_if_estimate_larger_than_max_probe() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + skip_if_est_larger_than_fraction_of_max: 0.9, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.enable_periodic_alr_probing(true); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + let probes = probe_controller.set_estimated_bitrate( + MAX_BITRATE, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + assert!(probes.is_empty()); + + probe_controller.set_alr_start_time(Some(clock)); + clock += TimeDelta::from_seconds(10); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + // But if the max rate increase, A new probe is sent. + let probes = + probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, 2 * MAX_BITRATE, clock); + assert!(!probes.is_empty()); + } + + #[test] + fn skip_alr_probe_if_estimate_larger_than_fraction_of_max_allocated() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + skip_if_est_larger_than_fraction_of_max: 1.0, + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + probe_controller.enable_periodic_alr_probing(true); + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + probe_controller.set_estimated_bitrate( + MAX_BITRATE / 2, + BandwidthLimitedCause::DelayBasedLimited, + clock, + ); + + clock += TimeDelta::from_seconds(10); + probe_controller.set_alr_start_time(Some(clock)); + let probes = probe_controller.on_max_total_allocated_bitrate(MAX_BITRATE / 2, clock); + // No probes since total allocated is not higher than the current estimate. + assert!(probes.is_empty()); + clock += TimeDelta::from_seconds(2); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + + // But if the max allocated increase, A new probe is sent. + let probes = probe_controller.on_max_total_allocated_bitrate( + MAX_BITRATE / 2 + DataRate::from_bits_per_sec(1), + clock, + ); + assert!(!probes.is_empty()); + } + + #[test] + fn dont_probe_if_delay_increased() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + // Need to wait at least one second before process can trigger a new probe. + clock += TimeDelta::from_millis(1100); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + let probes = probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::DelayBasedLimitedDelayIncreased, + clock, + ); + assert!(probes.is_empty()); + + clock += TimeDelta::from_seconds(5); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + } + + #[test] + fn dont_probe_if_high_rtt() { + let mut clock = Timestamp::zero(); + let mut probe_controller = ProbeController::new(ProbeControllerConfig { + ..Default::default() + }); + assert!( + probe_controller + .on_network_availability(NetworkAvailability { + network_available: true, + ..Default::default() + }) + .is_empty() + ); + + let probes = probe_controller.set_bitrates(MIN_BITRATE, START_BITRATE, MAX_BITRATE, clock); + assert!(!probes.is_empty()); + + // Need to wait at least one second before process can trigger a new probe. + clock += TimeDelta::from_millis(1100); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + let probes = probe_controller.set_estimated_bitrate( + START_BITRATE, + BandwidthLimitedCause::RttBasedBackOffHighRtt, + clock, + ); + assert!(probes.is_empty()); + + clock += TimeDelta::from_seconds(5); + let probes = probe_controller.process(clock); + assert!(probes.is_empty()); + } +} diff --git a/rs/moq-congestion/src/gcc/remote_bitrate_estimator/aimd_rate_control.rs b/rs/moq-congestion/src/gcc/remote_bitrate_estimator/aimd_rate_control.rs new file mode 100644 index 00000000000..5256dfe195b --- /dev/null +++ b/rs/moq-congestion/src/gcc/remote_bitrate_estimator/aimd_rate_control.rs @@ -0,0 +1,641 @@ +/* + * Copyright (c) 2014 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::{ + LinkCapacityEstimator, + api::{ + transport::BandwidthUsage, + units::{DataRate, DataSize, TimeDelta, Timestamp}, + }, + experiments::FieldTrials, + remote_bitrate_estimator::BITRATE_WINDOW, +}; + +use super::{CONGESTION_CONTROLLER_MIN_BITRATE, RateControlInput}; + +// WebRTC-BweBackOffFactor +#[derive(Debug, Clone)] +pub struct BweBackOffFactor { + pub backoff_factor: f64, // Enabled-* +} + +impl Default for BweBackOffFactor { + fn default() -> Self { + Self { + backoff_factor: Self::DEFAULT_BACKOFF_FACTOR, + } + } +} + +impl BweBackOffFactor { + const DEFAULT_BACKOFF_FACTOR: f64 = 0.85; + + pub fn validate(&mut self) { + if self.backoff_factor >= 1.0 { + tracing::warn!("Back-off factor must be less than 1."); + } else if self.backoff_factor <= 0.0 { + tracing::warn!("Back-off factor must be greater than 0."); + } else { + return; + } + + tracing::warn!( + "Failed to parse parameters for AimdRateControl experiment from field trial string. Using default." + ); + self.backoff_factor = Self::DEFAULT_BACKOFF_FACTOR + } +} + +#[derive(Debug, PartialEq, Clone, Copy)] +enum RateControlState { + Hold, + Increase, + Decrease, +} + +// A rate control implementation based on additive increases of +// bitrate when no over-use is detected and multiplicative decreases when +// over-uses are detected. When we think the available bandwidth has changes or +// is unknown, we will switch to a "slow-start mode" where we increase +// multiplicatively. +#[derive(Clone)] +pub struct AimdRateControl { + min_configured_bitrate: DataRate, + current_bitrate: DataRate, + latest_estimated_throughput: DataRate, + link_capacity: LinkCapacityEstimator, + rate_control_state: RateControlState, + time_last_bitrate_change: Timestamp, + time_last_bitrate_decrease: Timestamp, + time_first_throughput_estimate: Timestamp, + bitrate_is_initialized: bool, + beta: f64, + in_alr: bool, + rtt: TimeDelta, + send_side: bool, + // Allow the delay based estimate to only increase as long as application + // limited region (alr) is not detected. + no_bitrate_increase_in_alr: bool, + last_decrease: Option, +} + +impl Default for AimdRateControl { + fn default() -> Self { + let max_configured_bitrate = DataRate::from_kilobits_per_sec(30000); + Self { + min_configured_bitrate: CONGESTION_CONTROLLER_MIN_BITRATE, + current_bitrate: max_configured_bitrate, + latest_estimated_throughput: max_configured_bitrate, + link_capacity: LinkCapacityEstimator::new(), + rate_control_state: RateControlState::Hold, + time_last_bitrate_change: Timestamp::minus_infinity(), + time_last_bitrate_decrease: Timestamp::minus_infinity(), + time_first_throughput_estimate: Timestamp::minus_infinity(), + bitrate_is_initialized: false, + beta: Self::DEFAULT_BACKOFF_FACTOR, + in_alr: false, + rtt: Self::DEFAULT_RTT, + send_side: false, + no_bitrate_increase_in_alr: false, + last_decrease: None, + } + } +} + +impl AimdRateControl { + const DEFAULT_RTT: TimeDelta = TimeDelta::from_millis(200); + const DEFAULT_BACKOFF_FACTOR: f64 = 0.85; + + pub fn new(field_trials: &FieldTrials, send_side: bool) -> Self { + let mut backoff = field_trials.bwe_back_off_factor.clone(); + backoff.validate(); + tracing::debug!( + "Using aimd rate control with back off factor {}", + backoff.backoff_factor + ); + + Self { + send_side, + beta: backoff.backoff_factor, + no_bitrate_increase_in_alr: field_trials.no_bitrate_increase_in_alr, + ..Default::default() + } + } + + // Returns true if the target bitrate has been initialized. This happens + // either if it has been explicitly set via SetStartBitrate/SetEstimate, or if + // we have measured a throughput. + pub fn valid_estimate(&self) -> bool { + self.bitrate_is_initialized + } + + pub fn set_start_bitrate(&mut self, start_bitrate: DataRate) { + self.current_bitrate = start_bitrate; + self.latest_estimated_throughput = self.current_bitrate; + self.bitrate_is_initialized = true; + } + + pub fn set_min_bitrate(&mut self, min_bitrate: DataRate) { + self.min_configured_bitrate = min_bitrate; + self.current_bitrate = self.current_bitrate.max(min_bitrate); + } + + /* unused + pub fn get_feedback_interval(&self) -> TimeDelta { + // Estimate how often we can send RTCP if we allocate up to 5% of bandwidth + // to feedback. + const RTCP_SIZE: DataSize = DataSize::from_bytes(80); + let rtcp_bitrate: DataRate = self.current_bitrate * 0.05; + let interval: TimeDelta = RTCP_SIZE / rtcp_bitrate; + const MIN_FEEDBACK_INTERVAL: TimeDelta = TimeDelta::from_millis(200); + const MAX_FEEDBACK_INTERVAL: TimeDelta = TimeDelta::from_millis(1000); + interval.clamp(MIN_FEEDBACK_INTERVAL, MAX_FEEDBACK_INTERVAL) + } + */ + + // Returns true if the bitrate estimate hasn't been changed for more than + // an RTT, or if the estimated_throughput is less than half of the current + // estimate. Should be used to decide if we should reduce the rate further + // when over-using. + pub fn time_to_reduce_further( + &mut self, + at_time: Timestamp, + estimated_throughput: DataRate, + ) -> bool { + let bitrate_reduction_interval: TimeDelta = self + .rtt + .clamp(TimeDelta::from_millis(10), TimeDelta::from_millis(200)); + if at_time - self.time_last_bitrate_change >= bitrate_reduction_interval { + return true; + } + if self.valid_estimate() { + // TODO(terelius/holmer): Investigate consequences of increasing + // the threshold to 0.95 * LatestEstimate(). + let threshold: DataRate = 0.5 * self.latest_estimate(); + return estimated_throughput < threshold; + } + false + } + // As above. To be used if overusing before we have measured a throughput. + pub fn initial_time_to_reduce_further(&mut self, at_time: Timestamp) -> bool { + self.valid_estimate() + && self.time_to_reduce_further( + at_time, + self.latest_estimate() / 2 - DataRate::from_bits_per_sec(1), + ) + } + + pub fn latest_estimate(&self) -> DataRate { + self.current_bitrate + } + pub fn set_rtt(&mut self, rtt: TimeDelta) { + self.rtt = rtt; + } + pub fn update(&mut self, input: RateControlInput, at_time: Timestamp) -> DataRate { + // Set the initial bit rate value to what we're receiving the first half + // second. + // TODO(bugs.webrtc.org/9379): The comment above doesn't match to the code. + if !self.bitrate_is_initialized { + const INITIALIZATION_TIME: TimeDelta = TimeDelta::from_seconds(5); + assert!(BITRATE_WINDOW <= INITIALIZATION_TIME); + + if let Some(estimated_throughput) = input.estimated_throughput { + if self.time_first_throughput_estimate.is_infinite() { + self.time_first_throughput_estimate = at_time; + } else if at_time - self.time_first_throughput_estimate > INITIALIZATION_TIME { + self.current_bitrate = estimated_throughput; + self.bitrate_is_initialized = true; + } + } + } + + self.change_bitrate(input, at_time); + self.current_bitrate + } + + pub fn set_in_application_limited_region(&mut self, in_alr: bool) { + self.in_alr = in_alr; + } + + pub fn set_estimate(&mut self, bitrate: DataRate, at_time: Timestamp) { + self.bitrate_is_initialized = true; + let prev_bitrate: DataRate = self.current_bitrate; + self.current_bitrate = self.clamp_bitrate(bitrate); + self.time_last_bitrate_change = at_time; + if self.current_bitrate < prev_bitrate { + self.time_last_bitrate_decrease = at_time; + } + } + + // Returns the increase rate when used bandwidth is near the link capacity. + pub fn get_near_max_increase_rate_bps_per_second(&self) -> f64 { + assert!(!self.current_bitrate.is_zero()); + let frame_interval: TimeDelta = TimeDelta::from_seconds_float(1.0 / 30.0); + let frame_size: DataSize = self.current_bitrate * frame_interval; + const PACKET_SIZE: DataSize = DataSize::from_bytes(1200); + let packets_per_frame: f64 = (frame_size / PACKET_SIZE).ceil(); + let avg_packet_size: DataSize = frame_size / packets_per_frame; + + // Approximate the over-use estimator delay to 100 ms. + let mut response_time: TimeDelta = self.rtt + TimeDelta::from_millis(100); + + response_time *= 2; + let increase_rate_bps_per_second: f64 = (avg_packet_size / response_time).bps_float(); + const MIN_INCREASE_RATE_BPS_PER_SECOND: f64 = 4000.0; + increase_rate_bps_per_second.max(MIN_INCREASE_RATE_BPS_PER_SECOND) + } + // Update the target bitrate based on, among other things, the current rate + // control state, the current target bitrate and the estimated throughput. + // When in the "increase" state the bitrate will be increased either + // additively or multiplicatively depending on the rate control region. When + // in the "decrease" state the bitrate will be decreased to slightly below the + // current throughput. When in the "hold" state the bitrate will be kept + // constant to allow built up queues to drain. + fn change_bitrate(&mut self, input: RateControlInput, at_time: Timestamp) { + let mut new_bitrate: Option = None; + let estimated_throughput: DataRate = input + .estimated_throughput + .unwrap_or(self.latest_estimated_throughput); + if let Some(estimated_throughput) = input.estimated_throughput { + self.latest_estimated_throughput = estimated_throughput; + } + + // An over-use should always trigger us to reduce the bitrate, even though + // we have not yet established our first estimate. By acting on the over-use, + // we will end up with a valid estimate. + if !self.bitrate_is_initialized && input.bw_state != BandwidthUsage::Overusing { + return; + } + + self.change_state(input, at_time); + + match self.rate_control_state { + RateControlState::Hold => (), + RateControlState::Increase => { + if estimated_throughput > self.link_capacity.upper_bound() { + self.link_capacity.reset(); + } + + // We limit the new bitrate based on the troughput to avoid unlimited + // bitrate increases. We allow a bit more lag at very low rates to not too + // easily get stuck if the encoder produces uneven outputs. + let mut increase_limit: DataRate = + 1.5 * estimated_throughput + DataRate::from_kilobits_per_sec(10); + if self.send_side && self.in_alr && self.no_bitrate_increase_in_alr { + // Do not increase the delay based estimate in alr since the estimator + // will not be able to get transport feedback necessary to detect if + // the new estimate is correct. + // If we have previously increased above the limit (for instance due to + // probing), we don't allow further changes. + increase_limit = self.current_bitrate; + } + + if self.current_bitrate < increase_limit { + let increased_bitrate: DataRate = if self.link_capacity.has_estimate() { + // The link_capacity estimate is reset if the measured throughput + // is too far from the estimate. We can therefore assume that our + // target rate is reasonably close to link capacity and use additive + // increase. + let additive_increase: DataRate = + self.additive_rate_increase(at_time, self.time_last_bitrate_change); + self.current_bitrate + additive_increase + } else { + // If we don't have an estimate of the link capacity, use faster ramp + // up to discover the capacity. + let multiplicative_increase: DataRate = self.multiplicative_rate_increase( + at_time, + self.time_last_bitrate_change, + self.current_bitrate, + ); + self.current_bitrate + multiplicative_increase + }; + new_bitrate = Some(increased_bitrate.min(increase_limit)); + } + self.time_last_bitrate_change = at_time; + } + RateControlState::Decrease => { + // Set bit rate to something slightly lower than the measured throughput + // to get rid of any self-induced delay. + let mut decreased_bitrate: DataRate = estimated_throughput * self.beta; + if decreased_bitrate > DataRate::from_kilobits_per_sec(5) { + decreased_bitrate -= DataRate::from_kilobits_per_sec(5); + } + + if decreased_bitrate > self.current_bitrate { + // TODO(terelius): The link_capacity estimate may be based on old + // throughput measurements. Relying on them may lead to unnecessary + // BWE drops. + if self.link_capacity.has_estimate() { + decreased_bitrate = self.beta * self.link_capacity.estimate(); + } + } + // Avoid increasing the rate when over-using. + if decreased_bitrate < self.current_bitrate { + new_bitrate = Some(decreased_bitrate); + } + + if self.bitrate_is_initialized && estimated_throughput < self.current_bitrate { + if let Some(new_bitrate) = new_bitrate { + self.last_decrease = Some(self.current_bitrate - new_bitrate); + } else { + self.last_decrease = Some(DataRate::zero()); + } + } + if estimated_throughput < self.link_capacity.lower_bound() { + // The current throughput is far from the estimated link capacity. Clear + // the estimate to allow an immediate update in OnOveruseDetected. + self.link_capacity.reset(); + } + + self.bitrate_is_initialized = true; + self.link_capacity.on_overuse_detected(estimated_throughput); + // Stay on hold until the pipes are cleared. + self.rate_control_state = RateControlState::Hold; + self.time_last_bitrate_change = at_time; + self.time_last_bitrate_decrease = at_time; + } + }; + + self.current_bitrate = self.clamp_bitrate(new_bitrate.unwrap_or(self.current_bitrate)); + } + + fn clamp_bitrate(&self, mut new_bitrate: DataRate) -> DataRate { + new_bitrate = new_bitrate.max(self.min_configured_bitrate); + new_bitrate + } + + fn multiplicative_rate_increase( + &self, + at_time: Timestamp, + last_time: Timestamp, + current_bitrate: DataRate, + ) -> DataRate { + let mut alpha: f64 = 1.08; + if last_time.is_finite() { + let time_since_last_update = at_time - last_time; + alpha = alpha.powf(time_since_last_update.seconds_float().min(1.0)); + } + let multiplicative_increase: DataRate = + (current_bitrate * (alpha - 1.0)).max(DataRate::from_bits_per_sec(1000)); + multiplicative_increase + } + fn additive_rate_increase(&self, at_time: Timestamp, last_time: Timestamp) -> DataRate { + let time_period_seconds: f64 = (at_time - last_time).seconds_float(); + let data_rate_increase_bps: f64 = + self.get_near_max_increase_rate_bps_per_second() * time_period_seconds; + DataRate::from_bits_per_sec_float(data_rate_increase_bps) + } + fn change_state(&mut self, input: RateControlInput, at_time: Timestamp) { + match input.bw_state { + BandwidthUsage::Normal => { + if self.rate_control_state == RateControlState::Hold { + self.time_last_bitrate_change = at_time; + self.rate_control_state = RateControlState::Increase; + } + } + BandwidthUsage::Overusing => { + if self.rate_control_state != RateControlState::Decrease { + self.rate_control_state = RateControlState::Decrease; + } + } + BandwidthUsage::Underusing => { + self.rate_control_state = RateControlState::Hold; + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + const INITIAL_TIME: Timestamp = Timestamp::from_millis(123_456); + + // After an overuse, we back off to 85% to the received bitrate. + const FRACTION_AFTER_OVERUSE: f64 = 0.85; + + #[test] + fn min_near_max_increase_rate_on_low_bandwith() { + let mut aimd_rate_control = AimdRateControl::default(); + aimd_rate_control.set_estimate(DataRate::from_bits_per_sec(30_000), INITIAL_TIME); + assert_eq!( + aimd_rate_control.get_near_max_increase_rate_bps_per_second(), + 4_000.0 + ); + } + + #[test] + fn near_max_increase_rate_is_5kbps_on_90kbps_and_200ms_rtt() { + let mut aimd_rate_control = AimdRateControl::default(); + aimd_rate_control.set_estimate(DataRate::from_bits_per_sec(90_000), INITIAL_TIME); + assert_eq!( + aimd_rate_control.get_near_max_increase_rate_bps_per_second(), + 5_000.0 + ); + } + + #[test] + fn near_max_increase_rate_is_5kbps_on_60kbps_and_100ms_rtt() { + let mut aimd_rate_control = AimdRateControl::default(); + aimd_rate_control.set_estimate(DataRate::from_bits_per_sec(60_000), INITIAL_TIME); + aimd_rate_control.set_rtt(TimeDelta::from_millis(100)); + assert_eq!( + aimd_rate_control.get_near_max_increase_rate_bps_per_second(), + 5_000.0 + ); + } + + #[test] + fn get_increase_rate() { + let mut aimd_rate_control = AimdRateControl::default(); + const BITRATE: DataRate = DataRate::from_bits_per_sec(300_000); + aimd_rate_control.set_estimate(BITRATE, INITIAL_TIME); + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Overusing, Some(BITRATE)), + INITIAL_TIME, + ); + assert!( + (aimd_rate_control.get_near_max_increase_rate_bps_per_second() - 14_000.0).abs() + <= 1_000.0, + ); + } + + #[test] + fn bwe_limited_by_acked_bitrate() { + let mut aimd_rate_control = AimdRateControl::default(); + const ACKED_BITRATE: DataRate = DataRate::from_bits_per_sec(10_000); + let mut now: Timestamp = INITIAL_TIME; + aimd_rate_control.set_estimate(ACKED_BITRATE, now); + while now - INITIAL_TIME < TimeDelta::from_seconds(20) { + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Normal, Some(ACKED_BITRATE)), + now, + ); + now += TimeDelta::from_millis(100); + } + assert!(aimd_rate_control.valid_estimate()); + assert_eq!( + aimd_rate_control.latest_estimate(), + 1.5 * ACKED_BITRATE + DataRate::from_bits_per_sec(10_000) + ); + } + + #[test] + fn bwe_not_limited_by_decreasing_acked_bitrate() { + let mut aimd_rate_control = AimdRateControl::default(); + const ACKED_BITRATE: DataRate = DataRate::from_bits_per_sec(10_000); + let mut now: Timestamp = INITIAL_TIME; + aimd_rate_control.set_estimate(ACKED_BITRATE, now); + while now - INITIAL_TIME < TimeDelta::from_seconds(20) { + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Normal, Some(ACKED_BITRATE)), + now, + ); + now += TimeDelta::from_millis(100); + } + assert!(aimd_rate_control.valid_estimate()); + // If the acked bitrate decreases the BWE shouldn't be reduced to 1.5x + // what's being acked, but also shouldn't get to increase more. + let prev_estimate: DataRate = aimd_rate_control.latest_estimate(); + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Normal, Some(ACKED_BITRATE / 2)), + now, + ); + let new_estimate: DataRate = aimd_rate_control.latest_estimate(); + assert_eq!(new_estimate, prev_estimate); + assert!( + (new_estimate.bps() + - (1.5f64 * ACKED_BITRATE + DataRate::from_bits_per_sec(10_000)).bps()) + .abs() + < 2_000 + ); + } + + #[test] + fn typical_drop() { + let mut aimd_rate_control = AimdRateControl::default(); + // The rate increase at 216 kbps should be 12 kbps. + const INITIAL_BITRATE: DataRate = DataRate::from_bits_per_sec(264_000); + const UPDATED_BITRATE: DataRate = DataRate::from_bits_per_sec(216_000); + let acked_bitrate: DataRate = + (UPDATED_BITRATE + DataRate::from_bits_per_sec(5_000)) / FRACTION_AFTER_OVERUSE; + let mut now: Timestamp = INITIAL_TIME; + aimd_rate_control.set_estimate(INITIAL_BITRATE, now); + now += TimeDelta::from_millis(100); + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Overusing, Some(acked_bitrate)), + now, + ); + assert_eq!(aimd_rate_control.latest_estimate(), UPDATED_BITRATE); + assert_eq!( + aimd_rate_control.get_near_max_increase_rate_bps_per_second(), + 12_000.0 + ); + } + + #[test] + fn sending_rate_bounded_when_throughput_not_estimated() { + let mut aimd_rate_control = AimdRateControl::default(); + const INITIAL_BITRATE: DataRate = DataRate::from_bits_per_sec(123_000); + let mut now: Timestamp = INITIAL_TIME; + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Normal, Some(INITIAL_BITRATE)), + now, + ); + // AimdRateControl sets the initial bit rate to what it receives after + // five seconds has passed. + // TODO(bugs.webrtc.org/9379): The comment in the AimdRateControl does not + // match the constant. + const INITIALIZATION_TIME: TimeDelta = TimeDelta::from_seconds(5); + now += INITIALIZATION_TIME + TimeDelta::from_millis(1); + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Normal, Some(INITIAL_BITRATE)), + now, + ); + for _ in 0..100 { + aimd_rate_control.update(RateControlInput::new(BandwidthUsage::Normal, None), now); + now += TimeDelta::from_millis(100); + } + assert!( + aimd_rate_control.latest_estimate() + <= INITIAL_BITRATE * 1.5 + DataRate::from_bits_per_sec(10_000) + ); + } + + #[test] + fn estimate_does_not_increase_in_alr() { + // When alr is detected, the delay based estimator is not allowed to increase + // bwe since there will be no feedback from the network if the new estimate + // is correct. + // WebRTC-DontIncreaseDelayBasedBweInAlr/Enabled/ + let field_trials = FieldTrials { + no_bitrate_increase_in_alr: true, + ..Default::default() + }; + let mut aimd_rate_control = AimdRateControl::new(&field_trials, true); + let mut now: Timestamp = INITIAL_TIME; + const INITIAL_BITRATE: DataRate = DataRate::from_bits_per_sec(123_000); + aimd_rate_control.set_estimate(INITIAL_BITRATE, now); + aimd_rate_control.set_in_application_limited_region(true); + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Normal, Some(INITIAL_BITRATE)), + now, + ); + assert_eq!(aimd_rate_control.latest_estimate(), INITIAL_BITRATE); + + for _ in 0..100 { + aimd_rate_control.update(RateControlInput::new(BandwidthUsage::Normal, None), now); + now += TimeDelta::from_millis(100); + } + assert_eq!(aimd_rate_control.latest_estimate(), INITIAL_BITRATE); + } + + #[test] + fn set_estimate_increase_bwe_in_alr() { + let field_trials = FieldTrials { + no_bitrate_increase_in_alr: true, + ..Default::default() + }; + let mut aimd_rate_control = AimdRateControl::new(&field_trials, true); + + const INITIAL_BITRATE: DataRate = DataRate::from_bits_per_sec(123_000); + aimd_rate_control.set_estimate(INITIAL_BITRATE, INITIAL_TIME); + aimd_rate_control.set_in_application_limited_region(true); + assert_eq!(aimd_rate_control.latest_estimate(), INITIAL_BITRATE); + aimd_rate_control.set_estimate(2 * INITIAL_BITRATE, INITIAL_TIME); + assert_eq!(aimd_rate_control.latest_estimate(), 2 * INITIAL_BITRATE); + } + + #[test] + fn estimate_increase_while_not_in_alr() { + // Allow the estimate to increase as long as alr is not detected to ensure + // tha BWE can not get stuck at a certain bitrate. + // WebRTC-DontIncreaseDelayBasedBweInAlr/Enabled/ + let field_trials = FieldTrials { + no_bitrate_increase_in_alr: true, + ..Default::default() + }; + let mut aimd_rate_control = AimdRateControl::new(&field_trials, true); + aimd_rate_control.no_bitrate_increase_in_alr = true; + let mut now: Timestamp = INITIAL_TIME; + const INITIAL_BITRATE: DataRate = DataRate::from_bits_per_sec(123_000); + aimd_rate_control.set_estimate(INITIAL_BITRATE, now); + aimd_rate_control.set_in_application_limited_region(false); + aimd_rate_control.update( + RateControlInput::new(BandwidthUsage::Normal, Some(INITIAL_BITRATE)), + now, + ); + for _ in 0..100 { + aimd_rate_control.update(RateControlInput::new(BandwidthUsage::Normal, None), now); + now += TimeDelta::from_millis(100); + } + assert!(aimd_rate_control.latest_estimate() > INITIAL_BITRATE); + } +} diff --git a/rs/moq-congestion/src/gcc/remote_bitrate_estimator/bwe_defines.rs b/rs/moq-congestion/src/gcc/remote_bitrate_estimator/bwe_defines.rs new file mode 100644 index 00000000000..5c2a2a977ed --- /dev/null +++ b/rs/moq-congestion/src/gcc/remote_bitrate_estimator/bwe_defines.rs @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use crate::gcc::api::{ + transport::BandwidthUsage, + units::{DataRate, TimeDelta}, +}; + +pub const CONGESTION_CONTROLLER_MIN_BITRATE: DataRate = DataRate::from_bits_per_sec(5_000); +pub const BITRATE_WINDOW: TimeDelta = TimeDelta::from_seconds(1); + +/* unused +pub enum BweNames { + ReceiverNoExtension = 0, + ReceiverTOffset = 1, + ReceiverAbsSendTime = 2, + SendSideTransportSeqNum = 3, + BweNamesMax = 4, +} +*/ + +pub struct RateControlInput { + pub bw_state: BandwidthUsage, + pub estimated_throughput: Option, +} + +impl RateControlInput { + pub fn new(bw_state: BandwidthUsage, estimated_throughput: Option) -> Self { + RateControlInput { + bw_state, + estimated_throughput, + } + } +} diff --git a/rs/moq-congestion/src/gcc/remote_bitrate_estimator/mod.rs b/rs/moq-congestion/src/gcc/remote_bitrate_estimator/mod.rs new file mode 100644 index 00000000000..b49f9aa6183 --- /dev/null +++ b/rs/moq-congestion/src/gcc/remote_bitrate_estimator/mod.rs @@ -0,0 +1,5 @@ +mod aimd_rate_control; +mod bwe_defines; + +pub use aimd_rate_control::*; +pub use bwe_defines::*; diff --git a/rs/moq-congestion/src/gcc/robust_throughput_estimator.rs b/rs/moq-congestion/src/gcc/robust_throughput_estimator.rs new file mode 100644 index 00000000000..411cffa0b11 --- /dev/null +++ b/rs/moq-congestion/src/gcc/robust_throughput_estimator.rs @@ -0,0 +1,800 @@ +/* + * Copyright (c) 2019 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +//! Upstream also keeps the older `AcknowledgedBitrateEstimator`, selected when this estimator's +//! field trial is disabled; it is enabled by default, so the older one is gone. QUIC tracks every +//! packet it sends, so no data goes unacknowledged outside the feedback, and `unacked_weight` with +//! it. + +use std::collections::VecDeque; + +use crate::gcc::api::{ + transport::PacketResult, + units::{DataRate, DataSize, TimeDelta, Timestamp}, +}; + +// WebRTC-Bwe-RobustThroughputEstimatorSettings +#[derive(Debug, Clone)] +pub struct RobustThroughputEstimatorSettings { + // The estimator keeps the smallest window containing at least + // `window_packets` and at least the packets received during the last + // `min_window_duration` milliseconds. + // (This means that it may store more than `window_packets` at high bitrates, + // and a longer duration than `min_window_duration` at low bitrates.) + // However, if will never store more than MaxPackets (for performance + // reasons), and never longer than max_window_duration (to avoid very old + // packets influencing the estimate for example when sending is paused). + pub window_packets: usize, + pub max_window_packets: usize, + pub window_duration: TimeDelta, + pub max_window_duration: TimeDelta, + + // The estimator window requires at least `required_packets` packets + // to produce an estimate. + pub required_packets: usize, +} + +impl Default for RobustThroughputEstimatorSettings { + fn default() -> Self { + Self { + window_packets: 20, + max_window_packets: 500, + window_duration: TimeDelta::from_seconds(1), + max_window_duration: TimeDelta::from_seconds(5), + required_packets: 10, + } + } +} + +impl RobustThroughputEstimatorSettings { + pub fn validate(&mut self) { + if self.window_packets < 10 || 1000 < self.window_packets { + tracing::warn!("Window size must be between 10 and 1000 packets"); + self.window_packets = 20; + } + if self.max_window_packets < 10 || 1000 < self.max_window_packets { + tracing::warn!("Max window size must be between 10 and 1000 packets"); + self.max_window_packets = 500; + } + self.max_window_packets = self.max_window_packets.max(self.window_packets); + + if self.required_packets < 10 || 1000 < self.required_packets { + tracing::warn!( + "Required number of initial packets must be between 10 and 1000 packets" + ); + self.required_packets = 10; + } + self.required_packets = self.required_packets.min(self.window_packets); + + if self.window_duration < TimeDelta::from_millis(100) + || TimeDelta::from_millis(3000) < self.window_duration + { + tracing::warn!("Window duration must be between 100 and 3000 ms"); + self.window_duration = TimeDelta::from_millis(750); + } + if self.max_window_duration < TimeDelta::from_seconds(1) + || TimeDelta::from_seconds(15) < self.max_window_duration + { + tracing::warn!("Max window duration must be between 1 and 15 s"); + self.max_window_duration = TimeDelta::from_seconds(5); + } + self.window_duration = self.window_duration.min(self.max_window_duration); + } +} + +#[derive(Clone)] +pub struct RobustThroughputEstimator { + settings: RobustThroughputEstimatorSettings, + window: VecDeque, + latest_discarded_send_time: Timestamp, +} + +impl Default for RobustThroughputEstimator { + fn default() -> Self { + Self::new(RobustThroughputEstimatorSettings::default()) + } +} + +impl RobustThroughputEstimator { + pub fn new(mut settings: RobustThroughputEstimatorSettings) -> Self { + settings.validate(); + + Self { + settings, + window: VecDeque::new(), + latest_discarded_send_time: Timestamp::minus_infinity(), + } + } + + fn first_packet_outside_window(&self) -> bool { + if self.window.is_empty() { + return false; + } + if self.window.len() > self.settings.max_window_packets { + return true; + } + let current_window_duration: TimeDelta = + self.window.back().unwrap().receive_time - self.window.front().unwrap().receive_time; + if current_window_duration > self.settings.max_window_duration { + return true; + } + if self.window.len() > self.settings.window_packets + && current_window_duration > self.settings.window_duration + { + return true; + } + false + } + + pub fn incoming_packet_feedback(&mut self, packet_feedback: &[PacketResult]) { + //assert!(packet_feedback.is_sorted_by_key(|x| x.receive_time)); + for packet in packet_feedback { + // Ignore packets without valid send or receive times. + // (This should not happen in production since lost packets are filtered + // out before passing the feedback vector to the throughput estimator. + // However, explicitly handling this case makes the estimator more robust + // and avoids a hard-to-detect bad state.) + if packet.receive_time.is_infinite() || packet.sent_packet.send_time.is_infinite() { + continue; + } + + // Insert the new packet. + self.window.push_back(*packet); + // In most cases, receive timestamps should already be in order, but in the + // rare case where feedback packets have been reordered, we do some swaps to + // ensure that the window is sorted. + let mut i = self.window.len() - 1; + while i > 0 && self.window[i].receive_time < self.window[i - 1].receive_time { + self.window.swap(i, i - 1); + i -= 1; + } + + const MAX_REORDERING_TIME: TimeDelta = TimeDelta::from_seconds(1); + let receive_delta: TimeDelta = + self.window.back().unwrap().receive_time - packet.receive_time; + if receive_delta > MAX_REORDERING_TIME { + tracing::warn!( + "Severe packet re-ordering or timestamps offset changed: {:?}", + receive_delta + ); + self.window.clear(); + self.latest_discarded_send_time = Timestamp::minus_infinity(); + } + } + + // Remove old packets. + while self.first_packet_outside_window() { + self.latest_discarded_send_time = std::cmp::max( + self.latest_discarded_send_time, + self.window.front().unwrap().sent_packet.send_time, + ); + self.window.pop_front(); + } + } + + pub fn bitrate(&self) -> Option { + if self.window.is_empty() || self.window.len() < self.settings.required_packets { + return None; + } + + let mut largest_recv_gap = TimeDelta::zero(); + let mut second_largest_recv_gap = TimeDelta::zero(); + for i in 1..self.window.len() { + // Find receive time gaps. + let gap: TimeDelta = self.window[i].receive_time - self.window[i - 1].receive_time; + if gap > largest_recv_gap { + second_largest_recv_gap = largest_recv_gap; + largest_recv_gap = gap; + } else if gap > second_largest_recv_gap { + second_largest_recv_gap = gap; + } + } + + let mut first_send_time: Timestamp = Timestamp::plus_infinity(); + let mut last_send_time: Timestamp = Timestamp::minus_infinity(); + let mut first_recv_time: Timestamp = Timestamp::plus_infinity(); + let mut last_recv_time: Timestamp = Timestamp::minus_infinity(); + let mut recv_size: DataSize = DataSize::from_bytes(0); + let mut send_size: DataSize = DataSize::from_bytes(0); + let mut first_recv_size: DataSize = DataSize::from_bytes(0); + let mut last_send_size: DataSize = DataSize::from_bytes(0); + let mut num_sent_packets_in_window: usize = 0; + for packet in &self.window { + if packet.receive_time < first_recv_time { + first_recv_time = packet.receive_time; + first_recv_size = packet.sent_packet.size; + } + last_recv_time = std::cmp::max(last_recv_time, packet.receive_time); + recv_size += packet.sent_packet.size; + + if packet.sent_packet.send_time < self.latest_discarded_send_time { + // If we have dropped packets from the window that were sent after + // this packet, then this packet was reordered. Ignore it from + // the send rate computation (since the send time may be very far + // in the past, leading to underestimation of the send rate.) + // However, ignoring packets creates a risk that we end up without + // any packets left to compute a send rate. + continue; + } + if packet.sent_packet.send_time > last_send_time { + last_send_time = packet.sent_packet.send_time; + last_send_size = packet.sent_packet.size; + } + first_send_time = std::cmp::min(first_send_time, packet.sent_packet.send_time); + + send_size += packet.sent_packet.size; + num_sent_packets_in_window += 1; + } + + // Suppose a packet of size S is sent every T milliseconds. + // A window of N packets would contain N*S bytes, but the time difference + // between the first and the last packet would only be (N-1)*T. Thus, we + // need to remove the size of one packet to get the correct rate of S/T. + // Which packet to remove (if the packets have varying sizes), + // depends on the network model. + // Suppose that 2 packets with sizes s1 and s2, are received at times t1 + // and t2, respectively. If the packets were transmitted back to back over + // a bottleneck with rate capacity r, then we'd expect t2 = t1 + r * s2. + // Thus, r = (t2-t1) / s2, so the size of the first packet doesn't affect + // the difference between t1 and t2. + // Analoguously, if the first packet is sent at time t1 and the sender + // paces the packets at rate r, then the second packet can be sent at time + // t2 = t1 + r * s1. Thus, the send rate estimate r = (t2-t1) / s1 doesn't + // depend on the size of the last packet. + recv_size -= first_recv_size; + send_size -= last_send_size; + + // Remove the largest gap by replacing it by the second largest gap. + // This is to ensure that spurious "delay spikes" (i.e. when the + // network stops transmitting packets for a short period, followed + // by a burst of delayed packets), don't cause the estimate to drop. + // This could cause an overestimation, which we guard against by + // never returning an estimate above the send rate. + assert!(first_recv_time.is_finite()); + assert!(last_recv_time.is_finite()); + let mut recv_duration: TimeDelta = + (last_recv_time - first_recv_time) - largest_recv_gap + second_largest_recv_gap; + recv_duration = std::cmp::max(recv_duration, TimeDelta::from_millis(1)); + + if num_sent_packets_in_window < self.settings.required_packets { + // Too few send times to calculate a reliable send rate. + return Some(recv_size / recv_duration); + } + + assert!(first_send_time.is_finite()); + assert!(last_send_time.is_finite()); + let mut send_duration: TimeDelta = last_send_time - first_send_time; + send_duration = std::cmp::max(send_duration, TimeDelta::from_millis(1)); + + Some(std::cmp::min( + send_size / send_duration, + recv_size / recv_duration, + )) + } +} + +#[cfg(test)] +mod test { + use crate::gcc::api::transport::SentPacket; + + use super::*; + + struct FeedbackGenerator { + send_clock: Timestamp, + recv_clock: Timestamp, + } + + impl Default for FeedbackGenerator { + fn default() -> Self { + Self { + send_clock: Timestamp::from_millis(100000), + recv_clock: Timestamp::from_millis(10000), + } + } + } + + impl FeedbackGenerator { + fn create_feedback( + &mut self, + number_of_packets: usize, + packet_size: DataSize, + send_rate: DataRate, + recv_rate: DataRate, + ) -> Vec { + let mut packet_feedback = Vec::with_capacity(number_of_packets); + + for _ in 0..number_of_packets { + self.recv_clock += packet_size / recv_rate; + packet_feedback.push(PacketResult { + sent_packet: SentPacket { + send_time: self.send_clock, + size: packet_size, + ..Default::default() + }, + receive_time: self.recv_clock, + ambiguous_receive_time: false, + }); + self.send_clock += packet_size / send_rate; + } + + packet_feedback + } + + fn current_receive_clock(&self) -> Timestamp { + self.recv_clock + } + + fn advance_receive_clock(&mut self, delta: TimeDelta) { + self.recv_clock += delta; + } + + fn advance_send_clock(&mut self, delta: TimeDelta) { + self.send_clock += delta; + } + } + + #[test] + fn initial_estimate() { + let mut feedback_generator = FeedbackGenerator::default(); + + let mut throughput_estimator = RobustThroughputEstimator::default(); + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + + // No estimate until the estimator has enough data. + let packet_feedback: Vec = + feedback_generator.create_feedback(9, DataSize::from_bytes(1000), send_rate, recv_rate); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + assert!(throughput_estimator.bitrate().is_none()); + + // Estimate once `required_packets` packets have been received. + let packet_feedback = + feedback_generator.create_feedback(1, DataSize::from_bytes(1000), send_rate, recv_rate); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + + // Estimate remains stable when send and receive rates are stable. + let packet_feedback = feedback_generator.create_feedback( + 15, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + } + + #[test] + fn estimate_adapts() { + let mut feedback_generator = FeedbackGenerator::default(); + let mut throughput_estimator = RobustThroughputEstimator::default(); + + // 1 second, 800kbps, estimate is stable. + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + for _ in 0..10 { + let packet_feedback: Vec = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + } + + // 1 second, 1600kbps, estimate increases + let send_rate = DataRate::from_bytes_per_sec(200000); + let recv_rate = DataRate::from_bytes_per_sec(200000); + for _ in 0..20 { + let packet_feedback: Vec = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert!(throughput.unwrap() >= DataRate::from_bytes_per_sec(100000)); + assert!(throughput.unwrap() <= send_rate); + } + + // 1 second, 1600kbps, estimate is stable + for _ in 0..20 { + let packet_feedback: Vec = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + } + + // 1 second, 400kbps, estimate decreases + let send_rate = DataRate::from_bytes_per_sec(50000); + let recv_rate = DataRate::from_bytes_per_sec(50000); + for _ in 0..5 { + let packet_feedback: Vec = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert!(throughput.unwrap() <= DataRate::from_bytes_per_sec(200000)); + assert!(throughput.unwrap() >= send_rate); + } + + // 1 second, 400kbps, estimate is stable + let send_rate = DataRate::from_bytes_per_sec(50000); + let recv_rate = DataRate::from_bytes_per_sec(50000); + for _ in 0..5 { + let packet_feedback: Vec = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + } + } + + #[test] + fn capped_by_receive_rate() { + let mut feedback_generator = FeedbackGenerator::default(); + let mut throughput_estimator = RobustThroughputEstimator::default(); + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(25000); + + let packet_feedback: Vec = feedback_generator.create_feedback( + 20, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert_relative_eq!( + throughput.unwrap().bytes_per_sec_float(), + recv_rate.bytes_per_sec_float(), + epsilon = 0.05 * recv_rate.bytes_per_sec_float() + ); // Allow 5% error + } + + #[test] + fn capped_by_send_rate() { + let mut feedback_generator = FeedbackGenerator::default(); + let mut throughput_estimator = RobustThroughputEstimator::default(); + let send_rate: DataRate = DataRate::from_bytes_per_sec(50000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + + let packet_feedback: Vec = feedback_generator.create_feedback( + 20, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert_relative_eq!( + throughput.unwrap().bytes_per_sec_float(), + send_rate.bytes_per_sec_float(), + epsilon = 0.05 * send_rate.bytes_per_sec_float() + ); // Allow 5% error + } + + #[test] + fn delay_spike() { + let mut feedback_generator = FeedbackGenerator::default(); + // This test uses a 500ms window to amplify the effect + // of a delay spike. + let settings = RobustThroughputEstimatorSettings { + window_duration: TimeDelta::from_millis(500), + ..Default::default() + }; + let mut throughput_estimator = RobustThroughputEstimator::new(settings); + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + + let packet_feedback: Vec = feedback_generator.create_feedback( + 20, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + + // Delay spike. 25 packets sent, but none received. + feedback_generator.advance_receive_clock(TimeDelta::from_millis(250)); + + // Deliver all of the packets during the next 50 ms. (During this time, + // we'll have sent an additional 5 packets, so we need to receive 30 + // packets at 1000 bytes each in 50 ms, i.e. 600000 bytes per second). + let recv_rate = DataRate::from_bytes_per_sec(600000); + // Estimate should not drop. + for _ in 0..30 { + let packet_feedback = feedback_generator.create_feedback( + 1, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert_relative_eq!( + throughput.unwrap().bytes_per_sec_float(), + send_rate.bytes_per_sec_float(), + epsilon = 0.05 * send_rate.bytes_per_sec_float() + ); // Allow 5% error + } + + // Delivery at normal rate. When the packets received before the gap + // has left the estimator's window, the receive rate will be high, but the + // estimate should be capped by the send rate. + let recv_rate = DataRate::from_bytes_per_sec(100000); + for _ in 0..20 { + let packet_feedback = feedback_generator.create_feedback( + 5, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert_relative_eq!( + throughput.unwrap().bytes_per_sec_float(), + send_rate.bytes_per_sec_float(), + epsilon = 0.05 * send_rate.bytes_per_sec_float() + ); // Allow 5% error + } + } + + #[test] + fn high_loss() { + let mut feedback_generator = FeedbackGenerator::default(); + let mut throughput_estimator = RobustThroughputEstimator::default(); + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + + let mut packet_feedback: Vec = feedback_generator.create_feedback( + 20, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + + // 50% loss + for packet in packet_feedback.iter_mut().skip(1).step_by(2) { + packet.receive_time = Timestamp::plus_infinity(); + } + + packet_feedback.sort_by_key(|x| x.receive_time); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert_relative_eq!( + throughput.unwrap().bytes_per_sec_float(), + send_rate.bytes_per_sec_float() / 2.0, + epsilon = 0.05 * send_rate.bytes_per_sec_float() / 2.0 + ); // Allow 5% error + } + + #[test] + fn reordered_feedback() { + let mut feedback_generator = FeedbackGenerator::default(); + let mut throughput_estimator = RobustThroughputEstimator::default(); + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + + let packet_feedback: Vec = feedback_generator.create_feedback( + 20, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + + let delayed_feedback: Vec = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + let packet_feedback = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + + // Since we're missing some feedback, it's expected that the + // estimate will drop. + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert!(throughput.unwrap() < send_rate); + + // But it should completely recover as soon as we get the feedback. + throughput_estimator.incoming_packet_feedback(&delayed_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + + // It should then remain stable (as if the feedbacks weren't reordered.) + for _ in 0..10 { + let packet_feedback = feedback_generator.create_feedback( + 15, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + } + } + + #[test] + fn deep_reordering() { + let mut feedback_generator = FeedbackGenerator::default(); + // This test uses a 500ms window to amplify the + // effect of reordering. + let settings = RobustThroughputEstimatorSettings { + window_duration: TimeDelta::from_millis(500), + ..Default::default() + }; + let mut throughput_estimator = RobustThroughputEstimator::new(settings); + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + + let mut delayed_packets: Vec = + feedback_generator.create_feedback(1, DataSize::from_bytes(1000), send_rate, recv_rate); + + for _ in 0..10 { + let packet_feedback: Vec = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + } + + // Delayed packet arrives ~1 second after it should have. + // Since the window is 500 ms, the delayed packet was sent ~500 + // ms before the second oldest packet. However, the send rate + // should not drop. + delayed_packets.first_mut().unwrap().receive_time = + feedback_generator.current_receive_clock(); + throughput_estimator.incoming_packet_feedback(&delayed_packets); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert_relative_eq!( + throughput.unwrap().bytes_per_sec_float(), + send_rate.bytes_per_sec_float(), + epsilon = 0.05 * send_rate.bytes_per_sec_float() + ); // Allow 5% error + + // Thoughput should stay stable. + for _ in 0..10 { + let packet_feedback: Vec = feedback_generator.create_feedback( + 10, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + assert_relative_eq!( + throughput.unwrap().bytes_per_sec_float(), + send_rate.bytes_per_sec_float(), + epsilon = 0.05 * send_rate.bytes_per_sec_float() + ); // Allow 5% error + } + } + + #[test] + fn resets_if_receive_clock_change_backwards() { + let mut feedback_generator = FeedbackGenerator::default(); + let mut throughput_estimator = RobustThroughputEstimator::default(); + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + + let packet_feedback: Vec = feedback_generator.create_feedback( + 20, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + assert_eq!(throughput_estimator.bitrate().unwrap(), send_rate); + + feedback_generator.advance_receive_clock(TimeDelta::from_seconds(-2)); + let send_rate = DataRate::from_bytes_per_sec(200000); + let recv_rate = DataRate::from_bytes_per_sec(200000); + let packet_feedback = feedback_generator.create_feedback( + 20, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + assert_eq!(throughput_estimator.bitrate().unwrap(), send_rate); + } + + #[test] + fn stream_paused_and_resumed() { + let mut feedback_generator = FeedbackGenerator::default(); + let mut throughput_estimator = RobustThroughputEstimator::default(); + let send_rate: DataRate = DataRate::from_bytes_per_sec(100000); + let recv_rate: DataRate = DataRate::from_bytes_per_sec(100000); + + let packet_feedback: Vec = feedback_generator.create_feedback( + 20, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_some()); + let expected_bytes_per_sec: f64 = 100.0 * 1000.0; + assert_relative_eq!( + throughput.unwrap().bytes_per_sec_float(), + expected_bytes_per_sec, + epsilon = 0.05 * expected_bytes_per_sec + ); // Allow 5% error + + // No packets sent or feedback received for 60s. + feedback_generator.advance_send_clock(TimeDelta::from_seconds(60)); + feedback_generator.advance_receive_clock(TimeDelta::from_seconds(60)); + + // Resume sending packets at the same rate as before. The estimate + // will initially be invalid, due to lack of recent data. + let packet_feedback = + feedback_generator.create_feedback(5, DataSize::from_bytes(1000), send_rate, recv_rate); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert!(throughput.is_none()); + + // But be back to the normal level once we have enough data. + for _ in 0..4 { + let packet_feedback = feedback_generator.create_feedback( + 5, + DataSize::from_bytes(1000), + send_rate, + recv_rate, + ); + throughput_estimator.incoming_packet_feedback(&packet_feedback); + let throughput = throughput_estimator.bitrate(); + assert_eq!(throughput.unwrap(), send_rate); + } + } +} // namespace webrtc diff --git a/rs/moq-congestion/src/gcc/send_side_bandwidth_estimation.rs b/rs/moq-congestion/src/gcc/send_side_bandwidth_estimation.rs new file mode 100644 index 00000000000..52593f949e2 --- /dev/null +++ b/rs/moq-congestion/src/gcc/send_side_bandwidth_estimation.rs @@ -0,0 +1,445 @@ +/* + * Copyright (c) 2012 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + * + * FEC and NACK added bitrate is handled outside class + */ + +//! QUIC carries no RTCP, so the receiver-report loss path (`UpdatePacketsLost`, the classic +//! 2%/10% loss controller and its min-bitrate history) and the REMB receiver limit are gone. The +//! start phase upstream ends two seconds after the first receiver report, so here it never does: +//! the delay-based estimate is trusted until the loss-based estimator is ready. + +use crate::gcc::remote_bitrate_estimator::CONGESTION_CONTROLLER_MIN_BITRATE; +use crate::gcc::{ + LossBasedBweV2, LossBasedState, + api::{ + transport::{BandwidthUsage, SentPacket, TransportPacketsFeedback}, + units::{DataRate, TimeDelta, Timestamp}, + }, + experiments::FieldTrials, +}; + +// WebRTC-Bwe-MaxRttLimit +#[derive(Clone, Debug)] +pub struct RttBasedBackoffConfig { + disabled: bool, // Disabled + configured_limit: TimeDelta, // limit + drop_fraction: f64, // fraction + drop_interval: TimeDelta, // interval, + bandwidth_floor: DataRate, // floor +} + +impl Default for RttBasedBackoffConfig { + fn default() -> Self { + Self { + disabled: false, + configured_limit: TimeDelta::from_seconds(3), + drop_fraction: 0.8, + drop_interval: TimeDelta::from_seconds(1), + bandwidth_floor: DataRate::from_kilobits_per_sec(5), + } + } +} + +#[derive(Clone)] +pub struct RttBasedBackoff { + pub drop_fraction: f64, + pub drop_interval: TimeDelta, + pub bandwidth_floor: DataRate, + pub rtt_limit: TimeDelta, + pub last_propagation_rtt_update: Timestamp, + pub last_propagation_rtt: TimeDelta, + pub last_packet_sent: Timestamp, +} + +impl RttBasedBackoff { + pub fn new(config: &RttBasedBackoffConfig) -> Self { + Self { + drop_fraction: config.drop_fraction, + drop_interval: config.drop_interval, + bandwidth_floor: config.bandwidth_floor, + rtt_limit: if !config.disabled { + config.configured_limit + } else { + TimeDelta::plus_infinity() + }, + last_propagation_rtt_update: Timestamp::plus_infinity(), + last_propagation_rtt: TimeDelta::zero(), + last_packet_sent: Timestamp::minus_infinity(), + } + } + + pub fn update_propagation_rtt(&mut self, at_time: Timestamp, propagation_rtt: TimeDelta) { + self.last_propagation_rtt_update = at_time; + self.last_propagation_rtt = propagation_rtt; + } + pub fn is_rtt_above_limit(&self) -> bool { + self.corrected_rtt() > self.rtt_limit + } + + fn corrected_rtt(&self) -> TimeDelta { + // Avoid timeout when no packets are being sent. + let timeout_correction: TimeDelta = std::cmp::max( + self.last_packet_sent - self.last_propagation_rtt_update, + TimeDelta::zero(), + ); + timeout_correction + self.last_propagation_rtt + } +} + +#[derive(Clone)] +pub struct SendSideBandwidthEstimation { + rtt_backoff: RttBasedBackoff, + + current_target: DataRate, + min_bitrate_configured: DataRate, + max_bitrate_configured: DataRate, + last_low_bitrate_log: Timestamp, + + last_round_trip_time: TimeDelta, + + delay_based_limit: DataRate, + time_last_decrease: Timestamp, + loss_based_bandwidth_estimator_v2: LossBasedBweV2, + loss_based_state: LossBasedState, +} + +impl Default for SendSideBandwidthEstimation { + fn default() -> Self { + Self::new(&FieldTrials::default()) + } +} + +impl SendSideBandwidthEstimation { + const DEFAULT_MAX_BITRATE: DataRate = DataRate::from_bits_per_sec(1000000000); + const LOW_BITRATE_LOG_PERIOD: TimeDelta = TimeDelta::from_millis(10000); + + pub fn new(field_trials: &FieldTrials) -> Self { + let mut loss_based_bandwidth_estimator_v2 = + LossBasedBweV2::new(field_trials.loss_based_bwe_v2.clone()); + if loss_based_bandwidth_estimator_v2.is_enabled() { + loss_based_bandwidth_estimator_v2 + .set_min_max_bitrate(CONGESTION_CONTROLLER_MIN_BITRATE, Self::DEFAULT_MAX_BITRATE); + } + + Self { + rtt_backoff: RttBasedBackoff::new(&field_trials.max_rtt_limit), + current_target: DataRate::zero(), + min_bitrate_configured: CONGESTION_CONTROLLER_MIN_BITRATE, + max_bitrate_configured: Self::DEFAULT_MAX_BITRATE, + last_low_bitrate_log: Timestamp::minus_infinity(), + last_round_trip_time: TimeDelta::zero(), + delay_based_limit: DataRate::plus_infinity(), + time_last_decrease: Timestamp::minus_infinity(), + loss_based_bandwidth_estimator_v2, + loss_based_state: LossBasedState::DelayBasedEstimate, + } + } + + pub fn target_rate(&self) -> DataRate { + std::cmp::max(self.min_bitrate_configured, self.current_target) + } + pub fn loss_based_state(&self) -> LossBasedState { + self.loss_based_state + } + // Return whether the current rtt is higher than the rtt limited configured in + // RttBasedBackoff. + pub fn is_rtt_above_limit(&self) -> bool { + self.rtt_backoff.is_rtt_above_limit() + } + + pub fn round_trip_time(&self) -> TimeDelta { + self.last_round_trip_time + } + + // Call periodically to update estimate. + pub fn update_estimate(&mut self, at_time: Timestamp) { + if self.rtt_backoff.is_rtt_above_limit() { + if at_time - self.time_last_decrease >= self.rtt_backoff.drop_interval + && self.current_target > self.rtt_backoff.bandwidth_floor + { + self.time_last_decrease = at_time; + let new_bitrate: DataRate = std::cmp::max( + self.current_target * self.rtt_backoff.drop_fraction, + self.rtt_backoff.bandwidth_floor, + ); + self.update_target_bitrate(new_bitrate, at_time); + return; + } + // TODO(srte): This is likely redundant in most cases. + self.apply_target_limits(at_time); + return; + } + + // We trust the delay-based estimate until the loss-based estimator is ready, to allow + // startup bitrate probing. + if !self + .loss_based_bandwidth_estimator_v2 + .ready_to_use_in_start_phase() + && self.delay_based_limit.is_finite() + && self.delay_based_limit > self.current_target + { + self.update_target_bitrate(self.delay_based_limit, at_time); + return; + } + + if self.loss_based_bandwidth_estimator_v2_ready_for_use() { + let result = self + .loss_based_bandwidth_estimator_v2 + .get_loss_based_result(); + self.loss_based_state = result.state; + self.update_target_bitrate(result.bandwidth_estimate, at_time); + return; + } + + // TODO(srte): This is likely redundant in most cases. + self.apply_target_limits(at_time); + } + pub fn on_sent_packet(&mut self, sent_packet: &SentPacket) { + // Only feedback-triggering packets will be reported here. + self.rtt_backoff.last_packet_sent = sent_packet.send_time; + } + pub fn update_propagation_rtt(&mut self, at_time: Timestamp, propagation_rtt: TimeDelta) { + self.rtt_backoff + .update_propagation_rtt(at_time, propagation_rtt); + } + // Call when a new delay-based estimate is available. + pub fn update_delay_based_estimate(&mut self, at_time: Timestamp, bitrate: DataRate) { + // TODO(srte): Ensure caller passes plus_infinity, not zero, to represent no + // limitation. + self.delay_based_limit = if bitrate.is_zero() { + DataRate::plus_infinity() + } else { + bitrate + }; + self.apply_target_limits(at_time); + } + pub fn update_rtt(&mut self, rtt: TimeDelta) { + if rtt > TimeDelta::zero() { + self.last_round_trip_time = rtt; + } + } + pub fn set_bitrates( + &mut self, + send_bitrate: Option, + min_bitrate: DataRate, + max_bitrate: DataRate, + at_time: Timestamp, + ) { + self.set_min_max_bitrate(min_bitrate, max_bitrate); + if let Some(send_bitrate) = send_bitrate { + self.set_send_bitrate(send_bitrate, at_time); + } + } + pub fn set_send_bitrate(&mut self, bitrate: DataRate, at_time: Timestamp) { + debug_assert!(bitrate > DataRate::zero()); + // Reset to avoid being capped by the estimate. + self.delay_based_limit = DataRate::plus_infinity(); + self.update_target_bitrate(bitrate, at_time); + } + pub fn set_min_max_bitrate(&mut self, min_bitrate: DataRate, max_bitrate: DataRate) { + self.min_bitrate_configured = std::cmp::max(min_bitrate, CONGESTION_CONTROLLER_MIN_BITRATE); + if max_bitrate > DataRate::zero() && max_bitrate.is_finite() { + self.max_bitrate_configured = std::cmp::max(self.min_bitrate_configured, max_bitrate); + } else { + self.max_bitrate_configured = Self::DEFAULT_MAX_BITRATE; + } + self.loss_based_bandwidth_estimator_v2 + .set_min_max_bitrate(self.min_bitrate_configured, self.max_bitrate_configured); + } + + pub fn get_min_bitrate(&self) -> i64 { + self.min_bitrate_configured.bps() + } + + pub fn set_acknowledged_rate(&mut self, acknowledged_rate: Option) { + let Some(acknowledged_rate) = acknowledged_rate else { + return; + }; + if self.loss_based_bandwidth_estimator_v2.is_enabled() { + self.loss_based_bandwidth_estimator_v2 + .set_acknowledged_bitrate(acknowledged_rate); + } + } + pub fn update_loss_based_estimator( + &mut self, + report: &TransportPacketsFeedback, + _delay_detector_state: BandwidthUsage, + _probe_bitrate: Option, + in_alr: bool, + ) { + if self.loss_based_bandwidth_estimator_v2.is_enabled() { + self.loss_based_bandwidth_estimator_v2 + .update_bandwidth_estimate( + &report.packet_feedbacks, + self.delay_based_limit, + in_alr, + ); + self.update_estimate(report.feedback_time); + } + } + + // Gets the upper limit for the target bitrate. This is the minimum of the + // delay based limit and the configured max. + fn get_upper_limit(&self) -> DataRate { + std::cmp::min(self.delay_based_limit, self.max_bitrate_configured) + } + // Prints a warning if `bitrate` if sufficiently long time has past since last + // warning. + fn maybe_log_low_bitrate_warning(&mut self, bitrate: DataRate, at_time: Timestamp) { + if at_time - self.last_low_bitrate_log > Self::LOW_BITRATE_LOG_PERIOD { + tracing::debug!( + "Estimated available bandwidth {:?} is below configured min bitrate {:?}.", + bitrate, + self.min_bitrate_configured + ); + self.last_low_bitrate_log = at_time; + } + } + + // Cap `bitrate` to [self.min_bitrate_configured, max_bitrate_configured_] and + // set `current_bitrate_` to the capped value. + fn update_target_bitrate(&mut self, mut new_bitrate: DataRate, at_time: Timestamp) { + new_bitrate = std::cmp::min(new_bitrate, self.get_upper_limit()); + if new_bitrate < self.min_bitrate_configured { + self.maybe_log_low_bitrate_warning(new_bitrate, at_time); + new_bitrate = self.min_bitrate_configured; + } + self.current_target = new_bitrate; + } + // Applies lower and upper bounds to the current target rate. + // TODO(srte): This seems to be called even when limits haven't changed, that + // should be cleaned up. + fn apply_target_limits(&mut self, at_time: Timestamp) { + self.update_target_bitrate(self.current_target, at_time); + } + + fn loss_based_bandwidth_estimator_v2_ready_for_use(&self) -> bool { + self.loss_based_bandwidth_estimator_v2.is_ready() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn initial_delay_based_bwe_with_probing() { + let mut bwe = SendSideBandwidthEstimation::default(); + let now = Timestamp::from_millis(0); + bwe.set_min_max_bitrate( + DataRate::from_bits_per_sec(100000), + DataRate::from_bits_per_sec(1500000), + ); + bwe.set_send_bitrate(DataRate::from_bits_per_sec(200000), now); + bwe.update_rtt(TimeDelta::from_millis(50)); + + // The delay-based estimate applies at once while the loss-based estimator has no + // observations. + bwe.update_delay_based_estimate(now, DataRate::from_bits_per_sec(1000000)); + bwe.update_estimate(now); + assert_eq!(1000000, bwe.target_rate().bps()); + } + + #[test] + fn setting_send_bitrate_overrides_delay_based_estimate() { + let mut bwe = SendSideBandwidthEstimation::default(); + const MIN_BITRATE_BPS: i64 = 10000; + const MAX_BITRATE_BPS: i64 = 10000000; + const INITIAL_BITRATE_BPS: i64 = 300000; + const DELAY_BASED_BITRATE_BPS: i64 = 350000; + const FORCED_HIGH_BITRATE: i64 = 2500000; + + let now_ms: i64 = 0; + + bwe.set_min_max_bitrate( + DataRate::from_bits_per_sec(MIN_BITRATE_BPS), + DataRate::from_bits_per_sec(MAX_BITRATE_BPS), + ); + bwe.set_send_bitrate( + DataRate::from_bits_per_sec(INITIAL_BITRATE_BPS), + Timestamp::from_millis(now_ms), + ); + + bwe.update_delay_based_estimate( + Timestamp::from_millis(now_ms), + DataRate::from_bits_per_sec(DELAY_BASED_BITRATE_BPS), + ); + bwe.update_estimate(Timestamp::from_millis(now_ms)); + assert!(bwe.target_rate().bps() >= INITIAL_BITRATE_BPS); + assert!(bwe.target_rate().bps() <= DELAY_BASED_BITRATE_BPS); + + bwe.set_send_bitrate( + DataRate::from_bits_per_sec(FORCED_HIGH_BITRATE), + Timestamp::from_millis(now_ms), + ); + assert_eq!(bwe.target_rate().bps(), FORCED_HIGH_BITRATE); + } + + #[test] + fn default_enabled() { + let rtt_backoff = RttBasedBackoff::new(&RttBasedBackoffConfig::default()); + assert!(rtt_backoff.rtt_limit.is_finite()); + } + + #[test] + fn can_be_disabled() { + let config = RttBasedBackoffConfig { + disabled: true, + ..Default::default() + }; + + let rtt_backoff = RttBasedBackoff::new(&config); + assert!(rtt_backoff.rtt_limit.is_plus_infinity()); + } + + #[test] + fn rtt_is_above_limit_if_rtt_greater_than_limit() { + let mut bwe = SendSideBandwidthEstimation::default(); + const MIN_BITRATE_BPS: i64 = 10000; + const MAX_BITRATE_BPS: i64 = 10000000; + const INITIAL_BITRATE_BPS: i64 = 300000; + let now_ms: i64 = 0; + bwe.set_min_max_bitrate( + DataRate::from_bits_per_sec(MIN_BITRATE_BPS), + DataRate::from_bits_per_sec(MAX_BITRATE_BPS), + ); + bwe.set_send_bitrate( + DataRate::from_bits_per_sec(INITIAL_BITRATE_BPS), + Timestamp::from_millis(now_ms), + ); + bwe.update_propagation_rtt( + /*at_time=*/ Timestamp::from_millis(now_ms), + /*propagation_rtt=*/ TimeDelta::from_millis(5000), + ); + assert!(bwe.is_rtt_above_limit()); + } + + #[test] + fn rtt_is_below_limit_if_rtt_less_than_limit() { + let mut bwe = SendSideBandwidthEstimation::default(); + const MIN_BITRATE_BPS: i64 = 10000; + const MAX_BITRATE_BPS: i64 = 10000000; + const INITIAL_BITRATE_BPS: i64 = 300000; + let now_ms: i64 = 0; + bwe.set_min_max_bitrate( + DataRate::from_bits_per_sec(MIN_BITRATE_BPS), + DataRate::from_bits_per_sec(MAX_BITRATE_BPS), + ); + bwe.set_send_bitrate( + DataRate::from_bits_per_sec(INITIAL_BITRATE_BPS), + Timestamp::from_millis(now_ms), + ); + bwe.update_propagation_rtt( + /*at_time=*/ Timestamp::from_millis(now_ms), + /*propagation_rtt=*/ TimeDelta::from_millis(1000), + ); + assert!(!bwe.is_rtt_above_limit()); + } +} diff --git a/rs/moq-congestion/src/gcc/tests.rs b/rs/moq-congestion/src/gcc/tests.rs new file mode 100644 index 00000000000..e3b87ff6e14 --- /dev/null +++ b/rs/moq-congestion/src/gcc/tests.rs @@ -0,0 +1,486 @@ +//! [`Gcc`] over a simulated uplink on mocked time: a FIFO bottleneck with a rate and a queue +//! bounded in time, a fixed propagation delay each way, and a receiver that acknowledges what +//! arrived each millisecond with its receive times. + +use std::collections::VecDeque; + +use super::*; +use crate::{Acked, Lost}; + +const MTU: u16 = 1200; +/// One-way propagation delay, each way +const DELAY: Duration = Duration::from_millis(20); +/// How long after sending a dropped packet the sender declares it lost without three later +/// packets acknowledged, standing in for QUIC's time threshold and probe timeout +const LOSS_TIMEOUT: Duration = Duration::from_secs(1); + +/// A packet between the sender and its ACK +struct Flight { + sent: Instant, + bytes: u16, + packet: Packet, + /// When it reaches the receiver, or `None` if the bottleneck dropped it + arrival: Option, +} + +/// What the sender has to send +#[derive(Clone, Copy)] +enum Source { + /// Always more than the controller allows + Bulk, + /// Media at this many bytes per second, in 30 frames each second + Media(u64), +} + +struct Sim { + gcc: Gcc, + epoch: Instant, + now: Instant, + /// The bottleneck's rate in bytes per second + rate: u64, + /// The longest a packet waits in the bottleneck before it is dropped + queue: Duration, + /// When the bottleneck finishes serving what it holds + busy_until: Instant, + flight: VecDeque, + in_flight: u64, + source: Source, + /// Media bytes written and not yet sent + backlog: u64, + next_frame: Instant, + /// Whether the sender fills the controller's probe with duplicates, as moq-quic's native + /// probing does + fill_probes: bool, + /// Whether the receiver reports receive times + timestamps: bool, + /// When each delivered packet reached the bottleneck, and its queueing delay there + queued: Vec<(Duration, Duration)>, +} + +impl Sim { + fn new(rate: u64, source: Source) -> Self { + let epoch = Instant::ZERO + Duration::from_secs(1000); + let gcc = Gcc::new(Init { + now: epoch, + mtu: MTU, + rtt: Duration::from_millis(333), + max_pacing_rate: None, + }); + Self { + gcc, + epoch, + now: epoch, + rate, + queue: Duration::from_millis(500), + busy_until: epoch, + flight: VecDeque::new(), + in_flight: 0, + source, + backlog: 0, + next_frame: epoch, + fill_probes: false, + timestamps: true, + queued: Vec::new(), + } + } + + fn elapsed(&self) -> Duration { + self.now - self.epoch + } + + /// Runs until `until` since the start, in steps of a millisecond + fn run(&mut self, until: Duration) { + while self.elapsed() < until { + self.ack(); + self.write(); + self.send(); + self.now += Duration::from_millis(1); + } + } + + fn write(&mut self) { + if let Source::Media(rate) = self.source { + while self.next_frame <= self.now { + self.backlog += rate / 30; + self.next_frame += Duration::from_secs(1) / 30; + } + } + } + + fn send(&mut self) { + loop { + let duplicate = match self.source { + Source::Bulk => false, + Source::Media(_) if self.backlog > 0 => false, + Source::Media(_) => { + let probe = self.gcc.metrics().probe_inflight; + if !self.fill_probes + || probe.is_none_or(|probe| self.in_flight + u64::from(MTU) > probe) + { + self.gcc.on_app_limited(self.in_flight); + return; + } + true + } + }; + if self.in_flight + u64::from(MTU) > self.gcc.window() { + return; + } + if self.gcc.pace(self.now, MTU.into()).is_some() { + return; + } + if duplicate { + self.gcc.on_probe_filled(self.in_flight); + } else { + self.backlog = self.backlog.saturating_sub(MTU.into()); + } + self.in_flight += u64::from(MTU); + let packet = self.gcc.on_sent(Sent { + now: self.now, + bytes: MTU, + in_flight: self.in_flight, + }); + let start = self.busy_until.max(self.now); + let wait = start - self.now; + let arrival = (wait <= self.queue).then(|| { + self.busy_until = + start + Duration::from_secs_f64(f64::from(MTU) / self.rate as f64); + self.queued.push((self.elapsed(), wait)); + self.busy_until + DELAY + }); + self.flight.push_back(Flight { + sent: self.now, + bytes: MTU, + packet, + arrival, + }); + } + } + + /// Acknowledges what arrived a propagation delay ago, and declares lost a packet the + /// bottleneck dropped once three later packets were acknowledged or [`LOSS_TIMEOUT`] passed + fn ack(&mut self) { + let acked_by = |arrival: Instant| arrival + DELAY; + let mut acked = Vec::new(); + let mut lost = Vec::new(); + while let Some(front) = self.flight.front() { + match front.arrival { + Some(arrival) if acked_by(arrival) <= self.now => {} + Some(_) => break, + None => { + let acked_later = self + .flight + .iter() + .skip(1) + .take(3) + .filter(|f| f.arrival.is_some_and(|a| acked_by(a) <= self.now)) + .count(); + if acked_later < 3 && self.now < front.sent + LOSS_TIMEOUT { + break; + } + } + } + let flight = self.flight.pop_front().unwrap(); + self.in_flight -= u64::from(flight.bytes); + match flight.arrival { + Some(arrival) => acked.push(Acked { + sent: flight.sent, + bytes: flight.bytes, + packet: flight.packet, + received: self.timestamps.then(|| arrival - self.epoch), + }), + None => lost.push(Lost { + sent: flight.sent, + bytes: flight.bytes, + packet: flight.packet, + new_burst: true, + }), + } + } + if !lost.is_empty() { + self.gcc.on_loss(Loss { + now: self.now, + packets: &lost, + in_flight: self.in_flight, + persistent: false, + }); + } + if !acked.is_empty() { + self.gcc.on_ack(Ack { + now: self.now, + packets: &acked, + in_flight: self.in_flight, + app_limited: false, + rtt: 2 * DELAY, + }); + } + } + + /// The bandwidth estimate, in bytes per second + fn estimate(&self) -> u64 { + self.gcc.metrics().bandwidth_estimate.unwrap() + } + + /// The `p` quantile of the queueing delay of packets reaching the bottleneck since `from` + fn queue(&self, from: Duration, p: f64) -> Duration { + let mut delays: Vec = self + .queued + .iter() + .filter(|(at, _)| *at >= from) + .map(|&(_, delay)| delay) + .collect(); + delays.sort_unstable(); + delays[((delays.len() - 1) as f64 * p) as usize] + } +} + +/// 2 Mbit/s in bytes per second +const LINK: u64 = 250_000; + +/// A bulk upload's initial probes find the link within a few seconds, and the delay-based +/// estimate then holds the queue near empty, below the link's rate +#[test] +fn finds_the_link_and_keeps_the_queue_short() { + let mut sim = Sim::new(LINK, Source::Bulk); + sim.run(Duration::from_secs(10)); + let estimate = sim.estimate(); + assert!( + (LINK / 2..=LINK * 11 / 10).contains(&estimate), + "estimate {estimate} B/s on a {LINK} B/s link" + ); + let queue = sim.queue(Duration::from_secs(5), 0.95); + assert!(queue < Duration::from_millis(50), "p95 queue {queue:?}"); +} + +/// When the link drops to a quarter, the queue's growth cuts the estimate to the new rate +/// within a couple of seconds, and the queue drains +#[test] +fn backs_off_a_capacity_drop() { + let mut sim = Sim::new(LINK, Source::Bulk); + sim.run(Duration::from_secs(10)); + sim.rate = LINK / 4; + sim.run(Duration::from_secs(12)); + let estimate = sim.estimate(); + assert!( + estimate <= LINK / 4 * 11 / 10, + "estimate {estimate} B/s 2s after the link fell to {} B/s", + LINK / 4 + ); + sim.run(Duration::from_secs(20)); + let queue = sim.queue(Duration::from_secs(17), 0.95); + assert!(queue < Duration::from_millis(50), "p95 queue {queue:?}"); +} + +/// Without receive timestamps the ACK's arrival stands in for each packet's, so GoogCC still +/// finds the link on the round-trip delay +#[test] +fn falls_back_to_ack_arrival() { + let mut sim = Sim::new(LINK, Source::Bulk); + sim.timestamps = false; + sim.run(Duration::from_secs(10)); + let estimate = sim.estimate(); + assert!( + (LINK / 2..=LINK * 11 / 10).contains(&estimate), + "estimate {estimate} B/s on a {LINK} B/s link" + ); +} + +/// A packet acknowledged without a receive time, once the peer reports some, reaches the +/// feedback as received, for the loss-based estimate's loss rate, but not among the packets whose +/// times the delay-based estimate and the RTT read +#[test] +fn counts_packets_acknowledged_without_times() { + let mut sim = Sim::new(LINK, Source::Bulk); + let untimed = |gcc: &mut Gcc, now| { + let packet = gcc.on_sent(Sent { + now, + bytes: MTU, + in_flight: MTU.into(), + }); + let acked = Acked { + sent: now, + bytes: MTU, + packet, + received: None, + }; + TransportPacketsFeedback { + packet_feedbacks: vec![gcc.acked(&acked, now)], + ..Default::default() + } + }; + + // Before any time is reported, the ACK's arrival is the packet's + let feedback = untimed(&mut sim.gcc, sim.now); + assert_eq!(feedback.received_with_send_info().count(), 1); + + sim.run(Duration::from_millis(500)); + let feedback = untimed(&mut sim.gcc, sim.now); + assert!(feedback.packet_feedbacks[0].is_received()); + assert_eq!(feedback.received_with_send_info().count(), 0); + assert!(feedback.sorted_by_receive_time().is_empty()); +} + +/// An application-limited sender gets probe clusters to fill: duplicates filling them carry the +/// estimate far past what the media alone reads from the same clusters +#[test] +fn filled_probes_raise_the_estimate() { + const MEDIA: u64 = 125_000; + let mut alone = Sim::new(10 * LINK, Source::Media(MEDIA)); + alone.run(Duration::from_secs(10)); + let mut filled = Sim::new(10 * LINK, Source::Media(MEDIA)); + filled.fill_probes = true; + filled.run(Duration::from_secs(10)); + assert!( + filled.estimate() >= 4 * alone.estimate(), + "estimate {} B/s filling the probes, {} B/s without", + filled.estimate(), + alone.estimate() + ); +} + +/// A probe target is the allocation GoogCC aims its probes at: no more than twice it, so filled +/// clusters raise the estimate to the target rather than to the link's rate, and the duplicates +/// cost no more than the target needs +#[test] +fn probes_aim_at_the_target() { + const MEDIA: u64 = 125_000; + /// 4 Mbit/s, a fifth of the link + const TARGET: u64 = 500_000; + let run = |target: Option| { + let mut sim = Sim::new(10 * LINK, Source::Media(MEDIA)); + sim.fill_probes = true; + if let Some(target) = target { + sim.gcc.on_probe_target(target); + } + sim.run(Duration::from_secs(10)); + sim.estimate() + }; + let aimed = run(Some(TARGET)); + let unaimed = run(None); + assert!( + (TARGET..=2 * TARGET).contains(&aimed), + "estimate {aimed} B/s probing toward {TARGET} B/s" + ); + assert!( + unaimed > 2 * TARGET, + "estimate {unaimed} B/s without a target" + ); +} + +/// A cluster short of a packet's bytes leaves room for a whole packet, which ends it, rather than +/// holding the sender to the bytes and leaving it unfinished +#[test] +fn probe_inflight_rounds_up_to_a_packet() { + let now = Instant::ZERO + Duration::from_secs(1000); + let mut gcc = Gcc::new(Init { + now, + mtu: MTU, + rtt: Duration::from_millis(100), + max_pacing_rate: None, + }); + // 3000 bytes, two and a half packets + gcc.prober + .create_probe_cluster(&api::transport::ProbeClusterConfig { + at_time: timestamp(now), + target_data_rate: DataRate::from_bytes_per_sec(200_000), + target_duration: TimeDelta::from_millis(15), + min_probe_delta: TimeDelta::from_millis(2), + target_probe_count: 1, + id: 1, + }); + let mut in_flight = 0; + for _ in 0..2 { + in_flight += u64::from(MTU); + gcc.on_sent(Sent { + now, + bytes: MTU, + in_flight, + }); + } + assert_eq!( + gcc.metrics().probe_inflight, + Some(in_flight + u64::from(MTU)) + ); + + in_flight += u64::from(MTU); + gcc.on_sent(Sent { + now, + bytes: MTU, + in_flight, + }); + assert_eq!(gcc.metrics().probe_inflight, None, "the cluster went on"); +} + +/// A restarted cluster's short id never matches one of GoogCC's, whatever either counter reached, +/// so a packet resolves to the cluster it was sent in +#[test] +fn restart_ids_never_alias_googcc_ids() { + let base = BitrateProber::RESTART_ID_BASE; + for googcc in [ + 1, + 2, + 16_384, + 16_385, + 32_767, + 32_768, + 65_535, + 65_536, + 1 << 20, + ] { + let short = short_id(googcc).unwrap().get(); + assert!(short < 1 << 15, "GoogCC id {googcc} took short id {short}"); + } + for restart in [0, 1, 16_383, 16_384, 32_767, 32_768, 65_535, (1 << 30) - 1] { + let short = short_id(base + restart).unwrap().get(); + assert!(short >= 1 << 15, "restart {restart} took short id {short}"); + } + assert_eq!(short_id(0), None); + assert_eq!(short_id(-1), None); +} + +/// Receive times a peer controls, however far off, regressing, or unreported, never panic or +/// leave the estimate outside GoogCC's bounds +#[test] +fn survives_hostile_receive_times() { + let mut now = Instant::ZERO + Duration::from_secs(1000); + let mut gcc = Gcc::new(Init { + now, + mtu: MTU, + rtt: Duration::from_millis(100), + max_pacing_rate: None, + }); + let times = [ + Some(Duration::ZERO), + Some(Duration::MAX), + Some(Duration::from_secs(u64::MAX / 2)), + None, + Some(Duration::from_secs(1)), + Some(Duration::ZERO), + Some(Duration::from_nanos(1)), + ]; + for round in 0..2000 { + now += Duration::from_millis(1); + let packet = gcc.on_sent(Sent { + now, + bytes: MTU, + in_flight: MTU.into(), + }); + let acked = [Acked { + sent: now, + bytes: MTU, + packet, + received: times[round % times.len()], + }]; + gcc.on_ack(Ack { + now, + packets: &acked, + in_flight: 0, + app_limited: false, + rtt: Duration::from_millis(100), + }); + let _ = gcc.pace(now, MTU.into()); + let metrics = gcc.metrics(); + assert!(metrics.bandwidth_estimate.unwrap() >= 5_000 / 8); + assert!(metrics.congestion_window >= MIN_WINDOW); + } +} diff --git a/rs/moq-congestion/src/gcc/trendline_estimator.rs b/rs/moq-congestion/src/gcc/trendline_estimator.rs new file mode 100644 index 00000000000..06de9322db1 --- /dev/null +++ b/rs/moq-congestion/src/gcc/trendline_estimator.rs @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2016 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +use std::collections::VecDeque; + +use crate::gcc::{DelayIncreaseDetectorInterface, api::transport::BandwidthUsage}; + +// WebRTC-Bwe-TrendlineEstimatorSettings +#[derive(Debug, Clone)] +pub struct TrendlineEstimatorSettings { + // Sort the packets in the window. Should be redundant, + // but then almost no cost. + pub enable_sort: bool, + + // Cap the trendline slope based on the minimum delay seen + // in the beginning_packets and end_packets respectively. + pub enable_cap: bool, + pub beginning_packets: usize, + pub end_packets: usize, + pub cap_uncertainty: f64, + + // Size (in packets) of the window. + pub window_size: usize, +} + +impl TrendlineEstimatorSettings { + const DEFAULT_TRENDLINE_WINDOW_SIZE: usize = 20; + + pub fn validate(&mut self) { + if self.window_size < 10 || 200 < self.window_size { + tracing::warn!("Window size must be between 10 and 200 packets"); + self.window_size = Self::DEFAULT_TRENDLINE_WINDOW_SIZE; + } + if self.enable_cap { + if self.beginning_packets < 1 + || self.end_packets < 1 + || self.beginning_packets > self.window_size + || self.end_packets > self.window_size + { + tracing::warn!( + "Size of beginning and end must be between 1 and {}", + self.window_size + ); + self.enable_cap = false; + self.beginning_packets = 0; + self.end_packets = 0; + self.cap_uncertainty = 0.0; + } + if self.beginning_packets + self.end_packets > self.window_size { + tracing::warn!("Size of beginning plus end can't exceed the window size"); + self.enable_cap = false; + self.beginning_packets = 0; + self.end_packets = 0; + self.cap_uncertainty = 0.0; + } + if self.cap_uncertainty < 0.0 || 0.025 < self.cap_uncertainty { + tracing::warn!("Cap uncertainty must be between 0 and 0.025"); + self.cap_uncertainty = 0.0; + } + } + } +} + +impl Default for TrendlineEstimatorSettings { + fn default() -> Self { + Self { + enable_sort: false, + enable_cap: false, + beginning_packets: 7, + end_packets: 7, + cap_uncertainty: 0.0, + window_size: 20, + } + } +} + +#[derive(Clone)] +pub struct TrendlineEstimator { + // Parameters. + settings: TrendlineEstimatorSettings, + smoothing_coef: f64, + threshold_gain: f64, + // Used by the existing threshold. + num_of_deltas: i64, + // Keep the arrival times small by using the change from the first packet. + first_arrival_time_ms: i64, + // Exponential backoff filtering. + accumulated_delay: f64, + smoothed_delay: f64, + // Linear least squares regression. + delay_hist: VecDeque, + + k_up: f64, + k_down: f64, + overusing_time_threshold: f64, + threshold: f64, + prev_modified_trend: f64, + last_update_ms: i64, + prev_trend: f64, + time_over_using: f64, + overuse_counter: i64, + hypothesis: BandwidthUsage, +} + +#[derive(Clone)] +struct PacketTiming { + pub arrival_time_ms: f64, + pub smoothed_delay_ms: f64, + pub raw_delay_ms: f64, +} + +impl Default for TrendlineEstimator { + fn default() -> Self { + Self::new(TrendlineEstimatorSettings::default()) + } +} + +impl DelayIncreaseDetectorInterface for TrendlineEstimator { + fn update( + &mut self, + recv_delta_ms: f64, + send_delta_ms: f64, + send_time_ms: i64, + arrival_time_ms: i64, + packet_size: usize, + calculated_deltas: bool, + ) { + if calculated_deltas { + self.update_trendline( + recv_delta_ms, + send_delta_ms, + send_time_ms, + arrival_time_ms, + packet_size, + ); + } + } + fn state(&self) -> BandwidthUsage { + self.hypothesis + } +} + +fn linear_fit_slope(packets: &VecDeque) -> Option { + assert!(packets.len() >= 2); + // Compute the "center of mass". + let mut sum_x: f64 = 0.0; + let mut sum_y: f64 = 0.0; + for packet in packets { + sum_x += packet.arrival_time_ms; + sum_y += packet.smoothed_delay_ms; + } + let x_avg: f64 = sum_x / packets.len() as f64; + let y_avg: f64 = sum_y / packets.len() as f64; + // Compute the slope k = \sum (x_i-x_avg)(y_i-y_avg) / \sum (x_i-x_avg)^2 + let mut numerator: f64 = 0.0; + let mut denominator: f64 = 0.0; + for packet in packets.iter() { + let x: f64 = packet.arrival_time_ms; + let y: f64 = packet.smoothed_delay_ms; + numerator += (x - x_avg) * (y - y_avg); + denominator += (x - x_avg) * (x - x_avg); + } + if denominator == 0.0 { + return None; + } + Some(numerator / denominator) +} + +fn compute_slope_cap( + packets: &VecDeque, + settings: &TrendlineEstimatorSettings, +) -> Option { + assert!(1 <= settings.beginning_packets && settings.beginning_packets < packets.len()); + assert!(1 <= settings.end_packets && settings.end_packets < packets.len()); + assert!(settings.beginning_packets + settings.end_packets <= packets.len()); + let mut early = &packets[0]; + for packet in packets.iter().take(settings.beginning_packets).skip(1) { + if packet.raw_delay_ms < early.raw_delay_ms { + early = packet; + } + } + let late_start: usize = packets.len() - settings.end_packets; + let mut late = &packets[late_start]; + for packet in packets.iter().skip(late_start + 1) { + if packet.raw_delay_ms < late.raw_delay_ms { + late = packet; + } + } + if late.arrival_time_ms - early.arrival_time_ms < 1.0 { + return None; + } + Some( + (late.raw_delay_ms - early.raw_delay_ms) / (late.arrival_time_ms - early.arrival_time_ms) + + settings.cap_uncertainty, + ) +} + +impl TrendlineEstimator { + const DEFAULT_TRENDLINE_SMOOTHING_COEFF: f64 = 0.9; + const DEFAULT_TRENDLINE_THRESHOLD_GAIN: f64 = 4.0; + const MAX_ADAPT_OFFSET_MS: f64 = 15.0; + const OVER_USING_TIME_THRESHOLD: f64 = 10.0; + const MIN_NUM_DELTAS: i64 = 60; + const DELTA_COUNTER_MAX: i64 = 1000; + + pub fn new(mut settings: TrendlineEstimatorSettings) -> Self { + settings.validate(); + + tracing::debug!( + "Using Trendline filter for delay change estimation with settings {:?} and no network state predictor", + settings + ); + Self { + settings, + smoothing_coef: Self::DEFAULT_TRENDLINE_SMOOTHING_COEFF, + threshold_gain: Self::DEFAULT_TRENDLINE_THRESHOLD_GAIN, + num_of_deltas: 0, + first_arrival_time_ms: -1, + accumulated_delay: 0.0, + smoothed_delay: 0.0, + delay_hist: VecDeque::new(), + k_up: 0.0087, + k_down: 0.039, + overusing_time_threshold: Self::OVER_USING_TIME_THRESHOLD, + threshold: 12.5, + prev_modified_trend: f64::NAN, + last_update_ms: -1, + prev_trend: 0.0, + time_over_using: -1.0, + overuse_counter: 0, + hypothesis: BandwidthUsage::Normal, + } + } + + fn update_trendline( + &mut self, + recv_delta_ms: f64, + send_delta_ms: f64, + _send_time_ms: i64, + arrival_time_ms: i64, + _packet_size: usize, + ) { + let delta_ms: f64 = recv_delta_ms - send_delta_ms; + self.num_of_deltas += 1; + self.num_of_deltas = self.num_of_deltas.min(Self::DELTA_COUNTER_MAX); + if self.first_arrival_time_ms == -1 { + self.first_arrival_time_ms = arrival_time_ms; + } + + // Exponential backoff filter. + self.accumulated_delay += delta_ms; + self.smoothed_delay = self.smoothing_coef * self.smoothed_delay + + (1.0 - self.smoothing_coef) * self.accumulated_delay; + + // Maintain packet window + self.delay_hist.push_back(PacketTiming { + arrival_time_ms: (arrival_time_ms - self.first_arrival_time_ms) as f64, + smoothed_delay_ms: self.smoothed_delay, + raw_delay_ms: self.accumulated_delay, + }); + if self.settings.enable_sort { + let mut i = self.delay_hist.len() - 1; + while i > 0 + && self.delay_hist[i].arrival_time_ms < self.delay_hist[i - 1].arrival_time_ms + { + self.delay_hist.swap(i, i - 1); + i -= 1; + } + } + if self.delay_hist.len() > self.settings.window_size { + self.delay_hist.pop_front(); + } + + // Simple linear regression. + let mut trend: f64 = self.prev_trend; + if self.delay_hist.len() == self.settings.window_size { + // Update self.trend if it is possible to fit a line to the data. The delay + // trend can be seen as an estimate of (send_rate - capacity)/capacity. + // 0 < trend < 1 . the delay increases, queues are filling up + // trend == 0 . the delay does not change + // trend < 0 . the delay decreases, queues are being emptied + trend = linear_fit_slope(&self.delay_hist).unwrap_or(trend); + if self.settings.enable_cap { + let cap = compute_slope_cap(&self.delay_hist, &self.settings); + // We only use the cap to filter out overuse detections, not + // to detect additional underuses. + if let Some(cap) = cap + && trend >= 0.0 + && trend > cap + { + trend = cap; + } + } + } + self.detect(trend, send_delta_ms, arrival_time_ms); + } + + fn detect(&mut self /* TrendlineEstimator */, trend: f64, ts_delta: f64, now_ms: i64) { + if self.num_of_deltas < 2 { + self.hypothesis = BandwidthUsage::Normal; + return; + } + let modified_trend: f64 = + self.num_of_deltas.min(Self::MIN_NUM_DELTAS) as f64 * trend * self.threshold_gain; + self.prev_modified_trend = modified_trend; + if modified_trend > self.threshold { + if self.time_over_using == -1.0 { + // Initialize the timer. Assume that we've been + // over-using half of the time since the previous + // sample. + self.time_over_using = ts_delta / 2.0; + } else { + // Increment timer + self.time_over_using += ts_delta; + } + self.overuse_counter += 1; + if self.time_over_using > self.overusing_time_threshold + && self.overuse_counter > 1 + && trend >= self.prev_trend + { + self.time_over_using = 0.0; + self.overuse_counter = 0; + self.hypothesis = BandwidthUsage::Overusing; + } + } else if modified_trend < -self.threshold { + self.time_over_using = -1.0; + self.overuse_counter = 0; + self.hypothesis = BandwidthUsage::Underusing; + } else { + self.time_over_using = -1.0; + self.overuse_counter = 0; + self.hypothesis = BandwidthUsage::Normal; + } + self.prev_trend = trend; + self.update_threshold(modified_trend, now_ms); + } + + fn update_threshold(&mut self /* TrendlineEstimator */, modified_trend: f64, now_ms: i64) { + if self.last_update_ms == -1 { + self.last_update_ms = now_ms; + } + + if modified_trend.abs() > self.threshold + Self::MAX_ADAPT_OFFSET_MS { + // Avoid adapting the threshold to big latency spikes, caused e.g., + // by a sudden capacity drop. + self.last_update_ms = now_ms; + return; + } + + let k: f64 = match modified_trend.abs() < self.threshold { + true => self.k_down, + false => self.k_up, + }; + const MAX_TIME_DELTA_MS: i64 = 100; + let time_delta_ms: i64 = std::cmp::min(now_ms - self.last_update_ms, MAX_TIME_DELTA_MS); + self.threshold += k * (modified_trend.abs() - self.threshold) * time_delta_ms as f64; + self.threshold = self.threshold.clamp(6.0, 600.0); + self.last_update_ms = now_ms; + } +} + +#[cfg(test)] +mod test { + use super::*; + + struct PacketTimeGenerator { + initial_clock: i64, + time_between_packets: f64, + packets: usize, + } + + impl PacketTimeGenerator { + pub fn new(initial_clock: i64, time_between_packets: f64) -> Self { + Self { + initial_clock, + time_between_packets, + packets: 0, + } + } + } + + impl Iterator for PacketTimeGenerator { + type Item = i64; + + fn next(&mut self) -> Option { + let value = + self.initial_clock + (self.time_between_packets * self.packets as f64) as i64; + self.packets += 1; + Some(value) + } + } + + struct TrendlineEstimatorTest { + send_times: Vec, + recv_times: Vec, + packet_sizes: Vec, + estimator: TrendlineEstimator, + count: usize, + } + + impl TrendlineEstimatorTest { + const PACKET_COUNT: usize = 25; + const PACKET_SIZE_BYTES: usize = 1200; + + pub fn new(send_times: PacketTimeGenerator, recv_times: PacketTimeGenerator) -> Self { + Self { + send_times: send_times.take(Self::PACKET_COUNT).collect(), + recv_times: recv_times.take(Self::PACKET_COUNT).collect(), + packet_sizes: vec![Self::PACKET_SIZE_BYTES; Self::PACKET_COUNT], + estimator: TrendlineEstimator::default(), + count: 1, + } + } + + fn run_test_until_state_change(&mut self) { + assert_eq!(self.send_times.len(), Self::PACKET_COUNT); + assert_eq!(self.recv_times.len(), Self::PACKET_COUNT); + assert_eq!(self.packet_sizes.len(), Self::PACKET_COUNT); + assert!(self.count >= 1); + assert!(self.count < Self::PACKET_COUNT); + + let initial_state = self.estimator.state(); + while self.count < Self::PACKET_COUNT { + let recv_delta: f64 = + (self.recv_times[self.count] - self.recv_times[self.count - 1]) as f64; + let send_delta: f64 = + (self.send_times[self.count] - self.send_times[self.count - 1]) as f64; + self.estimator.update( + recv_delta, + send_delta, + self.send_times[self.count], + self.recv_times[self.count], + self.packet_sizes[self.count], + true, + ); + if self.estimator.state() != initial_state { + return; + } + + self.count += 1; + } + } + } + + #[test] + fn normal() { + let send_time_generator = PacketTimeGenerator::new( + 123456789, /*initial clock*/ + 20.0, /*20 ms between sent packets*/ + ); + let recv_time_generator = PacketTimeGenerator::new( + 987654321, /*initial clock*/ + 20.0, /*delivered at the same pace*/ + ); + + let mut test = TrendlineEstimatorTest::new(send_time_generator, recv_time_generator); + + assert_eq!(test.estimator.state(), BandwidthUsage::Normal); + test.run_test_until_state_change(); + assert_eq!(test.estimator.state(), BandwidthUsage::Normal); + assert_eq!(test.count, TrendlineEstimatorTest::PACKET_COUNT); // All packets processed + } + + #[test] + fn overusing() { + let send_time_generator = PacketTimeGenerator::new( + 123456789, /*initial clock*/ + 20.0, /*20 ms between sent packets*/ + ); + let recv_time_generator = PacketTimeGenerator::new( + 987654321, /*initial clock*/ + 1.1 * 20.0, /*10% slower delivery*/ + ); + let mut test = TrendlineEstimatorTest::new(send_time_generator, recv_time_generator); + + assert_eq!(test.estimator.state(), BandwidthUsage::Normal); + test.run_test_until_state_change(); + assert_eq!(test.estimator.state(), BandwidthUsage::Overusing); + test.run_test_until_state_change(); + assert_eq!(test.estimator.state(), BandwidthUsage::Overusing); + assert_eq!(test.count, TrendlineEstimatorTest::PACKET_COUNT); // All packets processed + } + + #[test] + fn underusing() { + let send_time_generator = PacketTimeGenerator::new( + 123456789, /*initial clock*/ + 20.0, /*20 ms between sent packets*/ + ); + let recv_time_generator = PacketTimeGenerator::new( + 987654321, /*initial clock*/ + 0.85 * 20.0, /*15% faster delivery*/ + ); + let mut test = TrendlineEstimatorTest::new(send_time_generator, recv_time_generator); + + assert_eq!(test.estimator.state(), BandwidthUsage::Normal); + test.run_test_until_state_change(); + assert_eq!(test.estimator.state(), BandwidthUsage::Underusing); + test.run_test_until_state_change(); + assert_eq!(test.estimator.state(), BandwidthUsage::Underusing); + assert_eq!(test.count, TrendlineEstimatorTest::PACKET_COUNT); // All packets processed + } + + #[test] + fn includes_small_packets_by_default() { + let send_time_generator = PacketTimeGenerator::new( + 123456789, /*initial clock*/ + 20.0, /*20 ms between sent packets*/ + ); + let recv_time_generator = PacketTimeGenerator::new( + 987654321, /*initial clock*/ + 1.1 * 20.0, /*10% slower delivery*/ + ); + let mut test = TrendlineEstimatorTest::new(send_time_generator, recv_time_generator); + test.packet_sizes = vec![100; TrendlineEstimatorTest::PACKET_COUNT]; + + assert_eq!(test.estimator.state(), BandwidthUsage::Normal); + test.run_test_until_state_change(); + assert_eq!(test.estimator.state(), BandwidthUsage::Overusing); + test.run_test_until_state_change(); + assert_eq!(test.estimator.state(), BandwidthUsage::Overusing); + assert_eq!(test.count, TrendlineEstimatorTest::PACKET_COUNT); // All packets processed + } +} // namespace webrtc diff --git a/rs/moq-congestion/src/lib.rs b/rs/moq-congestion/src/lib.rs index a5dbc9422cd..381cbcffc4e 100644 --- a/rs/moq-congestion/src/lib.rs +++ b/rs/moq-congestion/src/lib.rs @@ -14,12 +14,14 @@ use std::sync::Arc; pub mod bbr3; /// CUBIC congestion control. pub mod cubic; +pub mod gcc; mod pacer; mod rate; mod unlimited; pub use bbr3::Bbr3; pub use cubic::Cubic; +pub use gcc::Gcc; pub use rate::Packet; pub use unlimited::Unlimited; @@ -128,6 +130,29 @@ pub trait Controller: Send + Sync { false } + /// Whether each probe is a cluster the controller sizes and paces itself, so a transport + /// filling [`Metrics::probe_inflight`] sends at the controller's [`Controller::pace`] + /// rather than holding its duplicates to the session's probe target + /// + /// A controller that probes by a gain on its estimate for as long as its cycle allows, as + /// BBR3 does, leaves the target to bound what its probes cost. One that reads a probe at the + /// rate it was sent, as GoogCC does, learns only that rate from a probe held below it, and + /// bounds the cost by the cluster's size. + fn paces_probe(&self) -> bool { + false + } + + /// The session's probe target, the rate in bytes per second the application wants to send, + /// or zero for none + /// + /// The transport reports it to each controller it builds and whenever it changes. A + /// controller that sizes its own probes, as GoogCC does, may aim them at it. + #[allow( + unused_variables, + reason = "a no-op default that keeps the parameter names for implementors" + )] + fn on_probe_target(&mut self, bytes_per_second: u64) {} + /// Duplicate the controller's state fn clone_box(&self) -> Box; } diff --git a/rs/moq-congestion/src/pacer.rs b/rs/moq-congestion/src/pacer.rs index b295b739b01..42675659f53 100644 --- a/rs/moq-congestion/src/pacer.rs +++ b/rs/moq-congestion/src/pacer.rs @@ -212,7 +212,7 @@ pub(crate) struct Rate { /// Burst intervals trade distributing datagrams over time against waking the connection up more /// often than user-space timer accuracy can service; overshooting one by more than 25% loses the /// tokens for the extra elapsed time. -fn rate_capacity(pacing_rate: u64, mtu: u16) -> u64 { +pub(crate) fn rate_capacity(pacing_rate: u64, mtu: u16) -> u64 { let mtu = u64::from(mtu); let bytes_in = |interval: Duration| ((pacing_rate as u128 * interval.as_nanos()) / 1_000_000_000) as u64; diff --git a/rs/moq-congestion/src/rate.rs b/rs/moq-congestion/src/rate.rs index abb57f61bba..8dd15e86773 100644 --- a/rs/moq-congestion/src/rate.rs +++ b/rs/moq-congestion/src/rate.rs @@ -6,6 +6,7 @@ //! use kio::clock::Instant; +use std::num::NonZeroU16; use std::time::Duration; use crate::{Ack, Sent}; @@ -30,6 +31,10 @@ pub struct Packet { pub(crate) tx_in_flight: u32, /// equivalent to P.is_app_limited: whether C.app_limited was set when the packet was sent. pub(crate) is_app_limited: bool, + /// The GCC probe cluster it was sent in, if any. Sixteen bits fit the padding after the + /// fields above. GoogCC's clusters and the prober's restarts each wrap within half of them, + /// far more than either starts in the two seconds a cluster is kept. + pub(crate) probe_cluster: Option, } /// An empty state, for a controller that keeps none. @@ -42,6 +47,7 @@ impl Default for Packet { lost: 0, tx_in_flight: 0, is_app_limited: false, + probe_cluster: None, } } } @@ -115,6 +121,7 @@ impl Sampler { lost: self.lost, tx_in_flight: sent.in_flight.try_into().unwrap_or(u32::MAX), is_app_limited: self.app_limited != 0, + probe_cluster: None, } } diff --git a/rs/moq-quic/README.md b/rs/moq-quic/README.md index 1f2df38cfda..c66f5453c90 100644 --- a/rs/moq-quic/README.md +++ b/rs/moq-quic/README.md @@ -2,7 +2,7 @@ MoQ's sans-IO QUIC state machine, a hard fork of [quinn-proto](https://github.com/quinn-rs/quinn). -BBR3, Cubic, and Unlimited live in [moq-congestion](../moq-congestion); this crate stores each packet's controller state and hands it back on acknowledgement or loss, and the controller paces the sends. +BBR3, Cubic, GCC, and Unlimited live in [moq-congestion](../moq-congestion); this crate stores each packet's controller state and hands it back on acknowledgement or loss, and the controller paces the sends. All credit for the code goes to the quinn developers; it stays under quinn's MIT or Apache-2.0 license. diff --git a/rs/moq-quic/src/connection/bandwidth_probe.rs b/rs/moq-quic/src/connection/bandwidth_probe.rs index 7e5fe79dda7..094de2bc47c 100644 --- a/rs/moq-quic/src/connection/bandwidth_probe.rs +++ b/rs/moq-quic/src/connection/bandwidth_probe.rs @@ -5,9 +5,10 @@ //! session that wants more than it sends, to resume a stopped rendition, would never find out //! the path has room. While the controller probes, this fills the pacing headroom it opens with //! duplicates of STREAM data already in flight, up to the session's target, so the probe's -//! samples measure the path. A duplicate needs no wire change, and repairs a loss of its -//! original. It is never retransmitted, and a reset stream's data is never sent again, so a -//! probe never sends past a group's expiry. +//! samples measure the path. A controller that paces its own probe cluster, as GoogCC does, gets +//! it filled at its pace instead, since it reads the cluster at the rate it was sent. A duplicate +//! needs no wire change, and repairs a loss of its original. It is never retransmitted, and a +//! reset stream's data is never sent again, so a probe never sends past a group's expiry. //! //! An attempt that ends short of the target found the path's ceiling, and another would only //! spend the same duplicates finding it again. So each miss in a row doubles how long the probe @@ -45,7 +46,8 @@ pub(super) struct BandwidthProbe { /// Until when the probe sits out after a miss resume: Option, /// Holds everything sent while probing to `target`, which the controller's probing gain - /// alone may reach past + /// alone may reach past, unless the controller paces its own probe + /// ([`Controller::paces_probe`](crate::congestion::Controller::paces_probe)) pacing: Bucket, /// The packet number to resume taking in-flight data from, so the duplicates spread over the /// whole flight, oldest first @@ -97,6 +99,11 @@ impl BandwidthProbe { } } + /// The rate the caller asked to probe up to, in bytes per second; zero when probing is off + pub(super) fn target(&self) -> u64 { + self.target + } + /// Probe up to `bytes_per_second`, retrying a changed target at once pub(super) fn set_target(&mut self, bytes_per_second: u64) { if bytes_per_second != self.target { @@ -194,8 +201,12 @@ impl Connection { } return Ready::Blocked(None); } + // A controller that paces its own probe cluster reads it at the rate it was sent, so the + // target would cap the reading; the cluster's size bounds the duplicates instead let mtu = self.path.current_mtu(); - if let Some(at) = self.bandwidth_probe.pacing.delay(target, bytes, mtu, now) { + if !self.path.congestion.paces_probe() + && let Some(at) = self.bandwidth_probe.pacing.delay(target, bytes, mtu, now) + { return Ready::Blocked(Some(at)); } let estimate = metrics.bandwidth_estimate.unwrap_or(0); diff --git a/rs/moq-quic/src/connection/mod.rs b/rs/moq-quic/src/connection/mod.rs index c7ce9b5a447..8327cb7028a 100644 --- a/rs/moq-quic/src/connection/mod.rs +++ b/rs/moq-quic/src/connection/mod.rs @@ -1597,13 +1597,22 @@ impl Connection { /// fills the inflight it allows /// ([`probe_inflight`](crate::congestion::Metrics::probe_inflight)) with duplicates /// of STREAM data still in flight, sending no more than the target in all, so the probe - /// measures the path. Duplicates count against congestion control, as - /// [`PathStats::sent_bandwidth_probe_bytes`]. A reset stream's data is never sent again. An + /// measures the path. A controller that paces its own probe cluster + /// ([`Controller::paces_probe`]) gets it filled at its pace instead, and may aim its probes at + /// the target ([`Controller::on_probe_target`]). Duplicates count against congestion control, + /// as [`PathStats::sent_bandwidth_probe_bytes`]. A reset stream's data is never sent again. An /// attempt that misses the target sits out a few seconds before retrying it, and a changed /// target is retried at once. A controller that never probes ([`Controller::probes`]) /// ignores the target, and zero, the default, never probes. pub fn set_probe_target(&mut self, bytes_per_second: u64) { + if bytes_per_second == self.bandwidth_probe.target() { + return; + } self.bandwidth_probe.set_target(bytes_per_second); + self.path.congestion.on_probe_target(bytes_per_second); + if let Some((_, prev)) = &mut self.prev_path { + prev.congestion.on_probe_target(bytes_per_second); + } } /// Resets path-specific settings. @@ -1619,6 +1628,7 @@ impl Connection { #[cfg(test)] pub(crate) fn path_changed(&mut self, now: Instant) { self.path.reset(now, &self.config); + self.path.congestion.on_probe_target(self.bandwidth_probe.target()); // The rebuilt controller never sees state the old one gave packets still in flight. for space in SpaceId::iter() { for info in self.spaces[space].sent_packets.values_mut() { @@ -3370,6 +3380,7 @@ impl Connection { &self.config, ) }; + new_path.congestion.on_probe_target(self.bandwidth_probe.target()); new_path.challenge = Some(self.rng.random()); new_path.challenge_pending = true; let prev_pto = self.pto(SpaceId::Data); diff --git a/rs/moq-quic/src/tests/probe.rs b/rs/moq-quic/src/tests/probe.rs index 63472854211..7d24fb37124 100644 --- a/rs/moq-quic/src/tests/probe.rs +++ b/rs/moq-quic/src/tests/probe.rs @@ -18,7 +18,7 @@ use rand::{RngExt, SeedableRng}; use rand_pcg::Pcg32; use super::*; -use crate::congestion::cubic; +use crate::congestion::{cubic, gcc}; /// The media's bitrate, 2 Mbit/s, which the probe has to see past const MEDIA: u64 = 250_000; @@ -93,6 +93,8 @@ struct Scenario { ack_threshold: Option, /// Bytes of bulk upload before the media starts, to learn the path's rate first warmup: usize, + /// Whether the media runs GCC, reading the receive timestamps it asks for, rather than BBR3 + gcc: bool, } impl Default for Scenario { @@ -110,6 +112,7 @@ impl Default for Scenario { receive_window: None, ack_threshold: None, warmup: 0, + gcc: false, } } } @@ -172,9 +175,9 @@ impl Outcome { .map(|sample| sample.at) } - /// How soon after `from` the estimate reaches the target, if it ever does - fn rise(&self, from: Duration) -> Option { - self.first(from..Duration::MAX, |estimate| estimate >= TARGET) + /// How soon after `from` the estimate reaches `target`, if it ever does + fn rise(&self, from: Duration, target: u64) -> Option { + self.first(from..Duration::MAX, |estimate| estimate >= target) .map(|reached| reached - from) } @@ -267,6 +270,10 @@ fn run(scenario: &Scenario) -> Outcome { }; transport.ack_frequency = Some(ack); } + if scenario.gcc { + transport.congestion = Arc::new(gcc::Config::default()); + transport.receive_timestamps = NonZeroU32::new(32); + } let mut config = client_config(); config.transport = transport; let (client_ch, server_ch) = pair.connect_with(config); @@ -610,20 +617,28 @@ fn compare(label: &str, scenario: Scenario, seeds: ops::RangeInclusive) -> /// Asserts the probing runs' estimate reaches the target within [`RISE`] of `from` on the mean fn rises(trials: &[Trial], from: Duration) { + let rise = reaches(trials, from, TARGET); + assert!( + rise <= RISE.as_secs_f64(), + "estimate reached {TARGET} B/s {rise:.1}s after {from:?} on the mean" + ); +} + +/// Asserts every probing run's estimate reaches `target` after `from`, returning how soon, in +/// seconds on the mean +fn reaches(trials: &[Trial], from: Duration, target: u64) -> f64 { let rises = trials.iter().map(|Trial { seed, probing, .. }| { - let rise = probing.rise(from).unwrap_or_else(|| { + let rise = probing.rise(from, target).unwrap_or_else(|| { panic!( - "seed {seed}: estimate {} B/s never reached {TARGET} B/s after {from:?}", + "seed {seed}: estimate {} B/s never reached {target} B/s after {from:?}", probing.samples.last().expect("sampled").estimate ) }); rise.as_secs_f64() }); let rise = rises.sum::() / trials.len() as f64; - assert!( - rise <= RISE.as_secs_f64(), - "estimate reached {TARGET} B/s {rise:.1}s after {from:?} on the mean" - ); + info!(target, rise, "estimate reached the target after {from:?} on the mean"); + rise } /// Asserts the estimate, probing or not, falls to within the ProbeBW_UP gain of `rate` within @@ -684,6 +699,48 @@ fn rises_to_target() { rises(&compare("rises", scenario, GATE), Duration::from_secs(5)); } +/// The same under GCC, at the target and at twice it. GCC aims its probes at the target, at +/// twice its estimate but no more than twice the target, and reads each at the slower of its send +/// and receive rates, so the duplicates fill each cluster at its pace. Once the target is above +/// its estimate, GCC probes from the application-limited region five seconds after it last +/// probed or entered that region, so this holds the rise to reaching the target, not to [`RISE`], +/// and reports how soon for the bake-off. +#[test] +#[ignore = "nightly: just rs quic-sim"] +fn rises_to_target_under_gcc() { + let _guard = subscribe(); + let opens = Duration::from_secs(5); + for target in [TARGET, 2 * TARGET] { + let scenario = Scenario { + latency: Duration::from_millis(50), + buffer: 170_000, + keyframe: 0, + rates: vec![(Duration::ZERO, 375_000), (opens, 1_500_000)], + duration: Duration::from_secs(20), + target, + gcc: true, + ..Scenario::default() + }; + let trials = compare(&format!("rises gcc {target}"), scenario, SEEDS); + for Trial { + seed, + probing, + baseline, + } in &trials + { + info!( + seed, + target, + rise = ?probing.rise(opens, target), + probing = probing.samples.last().expect("sampled").estimate, + baseline = baseline.samples.last().expect("sampled").estimate, + "estimate after the path opened up, in B/s" + ); + } + reaches(&trials, opens, target); + } +} + /// A path short of the target, behind a queue a second deep: each attempt that misses sits out /// past the longest BBR3 waits between its own probes, and the path opening up is still found #[test] @@ -711,7 +768,7 @@ fn backs_off_unreachable_target() { // The samples are 100ms apart assert!(gap > Duration::from_millis(2900), "attempts began at {starts:?}"); } - let rise = outcome.rise(opens).expect("reached the target"); + let rise = outcome.rise(opens, TARGET).expect("reached the target"); assert!(rise <= RISE, "reached the target {rise:?} after the path opened"); } @@ -845,6 +902,100 @@ fn probe_inflight_limits_only_an_attempt() { ); } +/// A controller that always probes, allowing [`Cluster::INFLIGHT`] in flight, and never limits +/// the window or the pace +#[derive(Clone)] +struct Cluster { + paces_probe: bool, +} + +impl Cluster { + const INFLIGHT: u64 = 20_000; +} + +impl Controller for Cluster { + fn on_sent(&mut self, _sent: congestion::Sent) -> congestion::Packet { + Default::default() + } + + fn on_ack(&mut self, _ack: congestion::Ack<'_>) {} + + fn on_loss(&mut self, _loss: congestion::Loss<'_>) {} + + fn on_ce(&mut self, _ce: congestion::Ce) {} + + fn on_mtu_update(&mut self, _new_mtu: u16) {} + + fn window(&self) -> u64 { + u64::MAX / 2 + } + + fn pace(&mut self, _now: Instant, _bytes: u64) -> Option { + None + } + + fn metrics(&self) -> congestion::Metrics { + congestion::Metrics { + congestion_window: self.window(), + probe_inflight: Some(Self::INFLIGHT), + ..Default::default() + } + } + + fn probes(&self) -> bool { + true + } + + fn paces_probe(&self) -> bool { + self.paces_probe + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +impl Factory for Cluster { + fn build(self: Arc, _init: congestion::Init) -> Box { + Box::new((*self).clone()) + } +} + +/// A controller that paces its own probe gets the inflight it allows filled at its pace, past +/// what the target's pace would let leave at once; any other is held to the target +#[test] +fn paced_probe_fills_past_the_target() { + let _guard = subscribe(); + let sent = |paces_probe| { + let mut pair = Pair::default(); + pair.latency = Duration::from_millis(20); + let mut config = client_config(); + config.transport.congestion = Arc::new(Cluster { paces_probe }); + let (client_ch, _) = pair.connect_with(config); + pair.client_conn_mut(client_ch).set_probe_target(TARGET); + let id = pair + .client_streams(client_ch) + .open(Dir::Uni, Default::default()) + .unwrap(); + pair.client_send(client_ch, id).write(&mut &[0; 1400][..]).unwrap(); + pair.drive_client(); + let conn = pair.client_conn_mut(client_ch); + (conn.stats().path.sent_bandwidth_probe_bytes, conn.bytes_in_flight()) + }; + + let (paced, in_flight) = sent(true); + assert!( + in_flight + DEFAULT_MTU as u64 > Cluster::INFLIGHT, + "{in_flight} bytes in flight, short of the probe's {}", + Cluster::INFLIGHT + ); + let (held, _) = sent(false); + assert!( + held < paced, + "{held} probe bytes held to the target, {paced} paced by the controller" + ); +} + #[test] #[ignore = "nightly: just rs quic-sim"] fn steps_keyframes() { From 543449b14ad268ce5503c57afe612858a6d689d1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:06:09 +0000 Subject: [PATCH 55/86] quest: borrow-free QUIC state, mechanism prototypes and measurements (#7531) Co-authored-by: Claude Opus 5.5 --- quest/m2/perf/README.md | 1 - quest/m2/perf/quic-borrow-free.md | 55 --------------------------- quest/m2/perf/quic-lock-contention.md | 10 +++-- 3 files changed, 7 insertions(+), 59 deletions(-) delete mode 100644 quest/m2/perf/quic-borrow-free.md diff --git a/quest/m2/perf/README.md b/quest/m2/perf/README.md index 42a9608cce8..1659c6c2bba 100644 --- a/quest/m2/perf/README.md +++ b/quest/m2/perf/README.md @@ -87,7 +87,6 @@ revisions on the same host and worker count, and profiles with - [Stream transports](/quest/m2/perf/stream/README.md) - qmux drives its own WebSocket and TCP sockets in one task, measured end to end, then serves them from the io_uring workers - [Relay allocations](/quest/m2/perf/relay-allocs.md) - fewer allocations per delivered frame, starting with per-stream session clones - [QUIC lock contention](/quest/m2/perf/quic-lock-contention.md) - a measured verdict on the tokio driver's connection-wide mutex and per-packet endpoint-to-connection hop, with levers built only on evidence -- [Borrow-free QUIC state](/quest/m2/perf/quic-borrow-free.md) - a prototyped report on sharing connection state with stream handles without `RefCell` or `Mutex`: context passing vs driver-owned state with per-stream channels - [Run to quiescence](/quest/m2/perf/uring-quiescence.md) - a received packet's reply is staged in the same turn, under a pass and train budget that keeps the fairness rule - [Ingest batch](/quest/m2/perf/ingest-batch.md) - relay ingest pays one lock, wake, and clock read per burst of whole frames instead of per frame - [Streaming frame read owners](/quest/m2/perf/frame-read-owners.md) - readers of a frame still arriving share one byte owner per publication instead of allocating one each per read diff --git a/quest/m2/perf/quic-borrow-free.md b/quest/m2/perf/quic-borrow-free.md deleted file mode 100644 index 865c946d9c0..00000000000 --- a/quest/m2/perf/quic-borrow-free.md +++ /dev/null @@ -1,55 +0,0 @@ -# [M] QUIC connection state without runtime borrow checks - -## Goal - -A design report, with prototypes, on how a QUIC connection and its stream -handles can share state without a runtime borrow check (`RefCell`, `Mutex`) -on the data path, and a recommendation to adopt one or keep the lock. Each -prototype covers one stream's write and read path in moq-quic and moq-tokio's -driver, enough to run the fanout bench. - -## Plan - -Decided with the maintainer on 2026-10-25: `Rc>` and -`Arc>` are API warts, a sign the borrow checker could not do the job -so it happens at runtime. Today moq-quic's `Connection` is mutated by the -driver (packets, timers, transmit) and by stream handles in other tasks, so -moq-tokio locks it and moq-uring borrows it from a `RefCell` per core. - -Compare: - -- **Context passing.** Stream handles are owned, non-`Clone` tokens, and every - operation takes `&mut Connection` (`conn.write(&mut stream, buf)`). One owner - task holds the connection and passes it into its children's polls - (`kio::Tasks`), like Zig's explicit `std.Io`. Cost to measure: moq-net's - per-subscription tasks become children of the session task, and - moq-net's `transport::poll` `&self` shape flips to `&mut` - plus context. -- **Driver-owned state, handles as channel ends.** The driver alone owns - moq-quic. A send stream is the writer of a per-stream lock-free log (the - handle appends; the driver packetizes and retransmits by offset; acks - evict), credit is an atomic the driver publishes, received data arrives - through a per-stream queue, and the waits from - [waits](/quest/m1/quic-api/waits.md) live in that per-stream cell. Keeps - the `&self` API and fits both runtimes; msquic's one owner per connection is - prior art. Costs: an `Arc` per stream and credit mirrored on both sides. - `kio::log` evicts only from its writer, so acked eviction needs a path - back from the driver. -- **Today's lock** as the baseline. - -Rejected: branded cells (`qcell`, GhostCell). Access still needs a `&mut` -owner token, so they reduce to context passing with shared pointers, and a -lifetime brand fights `'static` tasks. - -Measure each on [QUIC lock contention](/quest/m2/perf/quic-lock-contention.md)'s -sweep (connections × streams per connection × workers), on tokio and -moq-uring, and report the API each implies for moq-quic, the drivers, and -moq-net's `transport::poll` traits. Only context passing would revisit their -`&self` shape, and the report says so if it wins. - -Public API: none; prototypes stay on a branch. The adoption is its own quest. - -## Related - -- [QUIC lock contention](/quest/m2/perf/quic-lock-contention.md) - measures the lock this would remove; its staging queue is a partial form of the channel approach -- [moq-quic owns its waits](/quest/m1/quic-api/waits.md) - the wait shape every prototype keeps diff --git a/quest/m2/perf/quic-lock-contention.md b/quest/m2/perf/quic-lock-contention.md index 0b1a3aeaa3c..789d2e7a005 100644 --- a/quest/m2/perf/quic-lock-contention.md +++ b/quest/m2/perf/quic-lock-contention.md @@ -48,8 +48,13 @@ Levers, only where the profile shows a cost: and only on evidence. The maintainer wants the connection state free of runtime borrow checks -altogether (2026-10-25); [borrow-free state](/quest/m2/perf/quic-borrow-free.md) -designs that, and this profile is its baseline. +altogether (2026-10-25). The borrow-free mechanism report +([moq-dev/moq#7531](https://github.com/moq-dev/moq/pull/7531)) measured the +designs on one core: the `Mutex` costs 1.1 to 3.5% over context passing, and a +driver-owned channel (this quest's staging queue in full) costs about 4.5k +instructions per stream plus about 320 per extra write. Adopting a design, or +keeping the lock, is decided separately; this profile is the contended baseline +that decision weighs. A measured no-win is a valid outcome. Report public API and wire impact; the staging queue and batch dispatch should need none. @@ -61,6 +66,5 @@ staging queue and batch dispatch should need none. ## Related -- [Borrow-free state](/quest/m2/perf/quic-borrow-free.md) - the lock-free designs this profile is the baseline for - [Relay egress profile](/quest/m2/quic-egress-profile.md) - the transmit side of the same path - [Relay allocations](/quest/m2/perf/relay-allocs.md) - the allocation profile of the same workload From 64cba1337136c67ce77aaf6ae337f355c97a895e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:06:17 +0000 Subject: [PATCH 56/86] feat(net)!: end bounded reads and owe cached backfill (fetch as a range, part 1) (#7545) Co-authored-by: Claude Opus 5.5 --- quest/m1/subscribe-ranges/fetch-range.md | 175 +++++-- rs/moq-net/MIGRATE.md | 9 + rs/moq-net/src/ietf/publisher.rs | 3 +- rs/moq-net/src/ietf/subscriber.rs | 29 +- rs/moq-net/src/lite/publisher.rs | 3 +- rs/moq-net/src/model/delivered.rs | 45 ++ rs/moq-net/src/model/resume.rs | 11 +- rs/moq-net/src/model/skipped.rs | 8 + rs/moq-net/src/model/track.rs | 559 ++++++++++++++++++++- rs/moq-net/src/model/track/miss.rs | 76 ++- rs/moq-net/tests/integration/fetch_only.rs | 116 +++++ 11 files changed, 940 insertions(+), 94 deletions(-) diff --git a/quest/m1/subscribe-ranges/fetch-range.md b/quest/m1/subscribe-ranges/fetch-range.md index 54ad84e5a59..36fb438a660 100644 --- a/quest/m1/subscribe-ranges/fetch-range.md +++ b/quest/m1/subscribe-ranges/fetch-range.md @@ -15,20 +15,28 @@ Split out of dynamic range requests, which replaced `requested_group` with breaks one API once. Resized from [M] to [L] on 2026-10-24, when the first attempt ([moq-dev/moq#6722](https://github.com/moq-dev/moq/pull/6722)) found the questions decided below and landed only the `track_range_filling` benchmark. +The second attempt ([moq-dev/moq#7545](https://github.com/moq-dev/moq/pull/7545)) +landed the bounded-read end on a plain cursor, the parking wire publishers, +the cached-backfill rule, the bounded miss grace, and the unfed moq-transport +copy; what remains is below. - **Ending a bounded read** (decided 2026-10-20). `recv_group` returns `None` once every sequence in a bounded window is delivered, dropped, stale, or - unfillable. Today it parks past the window's end on purpose, so an update - can still widen it; a reader that wants more keeps an open range instead. - `Ok(None)` before any group arrives is a miss, which the moq-ffi fetch - wrapper reports as `NotFound`; after one, it is the range's end. A one-range - read surfaces no `NotFetchable` or `Old` result, and a peer's reset with - either names the group dropped. Every fetch boundary already treats - `NotFetchable` as not found (relay HTTP 404, hls `is_cache_miss`, moq-ffi - `map_fetch_error`, moq-c `NOT_FOUND`); a fetched `Old` becomes a miss too, - where relay HTTP answers 500 and moq-c reports `OLD` today. Both stay as wire - stream errors, and `Old` still aborts a cut group mid-read. - The Impact bullet says how to migrate each affected package. + unfillable. Landed for a track's own cache: a `with_range` cap never ends a + read, since it may rise again. A front's logical track (a routed track, + `model/resume.rs`) still parks, because each copy's reader parks and the + routes judge the end. Its `Delivered` counts what is no longer owed as + handed out (`track::Subscriber::skip_ordered`), so it cannot account for the + window alone, and `resume_model` asserts a reader never ends before a route + declares an end, which ending there has to change. `Ok(None)` before any group arrives is a miss, which the + moq-ffi fetch wrapper reports as `NotFound`; after one, it is the range's + end. A one-range read surfaces no `NotFetchable` or `Old` result, and a + peer's reset with either names the group dropped. Every fetch boundary + already treats `NotFetchable` as not found (relay HTTP 404, hls + `is_cache_miss`, moq-ffi `map_fetch_error`, moq-c `NOT_FOUND`); a fetched + `Old` becomes a miss too, where relay HTTP answers 500 and moq-c reports + `OLD` today. Both stay as wire stream errors, and `Old` still aborts a cut + group mid-read. The Impact bullet says how to migrate each affected package. - A one-range subscription for a cached live group missing the frames its range starts at needs a head fill. Frame bounds ([moq-dev/moq#6129](https://github.com/moq-dev/moq/pull/6129)) hand such a @@ -38,37 +46,117 @@ the questions decided below and landed only the `track_range_filling` benchmark. read has to request it instead of letting `Lagged` reach the caller. `fill::Ask` already carries a start frame, so the miss judge can count a headless slot as a hole for a bounded window. -- **Wire publishers keep parking.** The lite subscriber reads an upstream - FIN as the track's end on a published version. Once `recv_group` ends a - bounded read, the IETF publisher and lite publishers before lite-07 keep - parking on a bounded downstream SUBSCRIBE rather than FIN it, or a peer - reads the range's end as the track's. The lite-07 half already landed in - [moq-dev/moq#6807](https://github.com/moq-dev/moq/pull/6807): a lite-07 - publisher FINs a covered bounded subscription without SUBSCRIBE_END, which - ends only the subscription. +- **Wire publishers keep parking** (landed). Every wire publisher reads its + subscription through `track::Subscriber::parked`: the IETF publisher and + lite publishers before lite-07 keep parking on a bounded downstream + SUBSCRIBE rather than FIN it, and a lite-07 publisher FINs a covered bounded + subscription without SUBSCRIBE_END + ([moq-dev/moq#6807](https://github.com/moq-dev/moq/pull/6807)) until the + explicit end below removes that FIN. - **Route recovery keeps a frame start.** `resume::Recover` fetches the rest of a group from frame N on the new route (`group::Fetch::frame_start`), which a whole-group range cannot say, so a crate-private fetch stays for it. -- **A miss with no feed frontier** (maintainer, 2026-10-25). A reader asks - fills only below the newest group the live feed produced (decision 2 on - [moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123)), so on a track - nothing feeds live, such as the moq-archive reader's or a relay copy no - subscription feeds, `Range::groups(5..6)` asks no fill and parks forever. - While the track has no live feed, holes in a bounded range are misses up to - its end. This asks nothing of a live feed; the rejected options were - treating every bounded range's holes as misses, which asks fills for groups - a live feed has yet to produce, and keeping a by-sequence read beside - subscriptions. -- **Cached backfill is owed** (maintainer, 2026-10-25). A range reader never - reads a group another reader's fill cached today: the arrival walk skips - backfill and the miss judge counts it held, so a second `0..1` read after a - first reader's fill cached group 0 parks with no fill asked. A cached - backfill group inside a reader's window is owed to it like its own fill's - answer, so a relay behaves the same warm or cold. This retires - `fetched_backfill_not_subscribed` and - `refilled_sequence_stays_out_of_subscriptions`, which assert that a - subscriber never sees backfill. Re-filling a cached group was rejected: it - costs an upstream FETCH for a group already in the cache. +- **Ends are signaled, not inferred** (maintainer, 2026-11-01, on + [moq-dev/moq#7545](https://github.com/moq-dev/moq/pull/7545)): "we should + have a SUBSCRIBE_END message or something to signal the last group of a + track? we were using QUIC FIN to signal that but that prevents updates in the + future, so we should have a signal instead". The follow-up answers, the same + day: + - "do we need a SUBSCRIBE_COVERED? We have SUBSCRIBE_DROP so the peer can + determine that the bounded range has been received? SUBSCRIBE_END (rename + to FINISH?) would signal the final group of the track maybe, but would be + independent of the bounded range?" No new message: a subscriber judges a + bounded range complete from the groups it received, SUBSCRIBE_DROP, the + Range Starts in SUBSCRIBE_OK, and SUBSCRIBE_END. Drops and groups name + absolute sequences, so the subscriber judges against its latest ranges and + an update in flight changes nothing it has counted; the FIN's race with an + unread update goes away. One gap: before SUBSCRIBE_OK a lite-07 publisher + holds back what it gave up (`TrackRun::skipped`) and answers only once a + group stream resolves the start, so a bounded subscription none of whose + groups it can serve gets neither, and only the covered FIN ends it today. + Fix: once such a subscription is covered, the publisher sends SUBSCRIBE_OK + and then the drops it held. Datagrams are left out: they become their own + track kind, which takes no ranges (maintainer, 2026-11-01). + - "Keep stream open": SUBSCRIBE_END (or FINISH) names the track's final group + and no longer FINs the Subscribe Stream, so an update can still reach an + ended track's history. The subscriber FINs once it wants nothing more, and + the publisher FINs in reply. The covered FIN goes too. + - ✅ Rename SUBSCRIBE_END to SUBSCRIBE_FINISH in lite-07 (maintainer, + 2026-11-01). It matches TRACKS_FINISH and `track::Producer::finish`, and + "END" would read as closing the stream, which it no longer does. + + The settled lite-07 wire design, together: + - SUBSCRIBE_FINISH (formerly SUBSCRIBE_END) names the track's final group and + no longer FINs the Subscribe Stream. + - A covered bounded subscription is not FINed either: the subscriber judges + it complete from the groups it received, SUBSCRIBE_DROP, the Range Starts in + SUBSCRIBE_OK, and SUBSCRIBE_FINISH. + - A publisher that held back SUBSCRIBE_OK (`TrackRun::skipped`) sends it, then + the drops it held, once such a subscription is covered. + - The subscriber FINs the stream once it wants nothing more, and the + publisher FINs in reply. + - Datagrams take no ranges, since they become their own track kind. The + datagram-tracks plan + ([moq-dev/moq#7575](https://github.com/moq-dev/moq/pull/7575)) touches + `lite.md` too, and should take this design and the rename as given. + + This wire half belongs to [lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) + (Rust and the draft) and [JS ranges](/quest/m1/subscribe-ranges/js.md), not + to this quest. It is recorded here while + [moq-dev/moq#7487](https://github.com/moq-dev/moq/pull/7487) rewrites that + quest, and moves there once it lands. +- **The live edge is told, never inferred** (maintainer, 2026-11-01, on + [moq-dev/moq#7545](https://github.com/moq-dev/moq/pull/7545)): "If the relay + doesn't know the live edge, it shouldn't say anything. It should be a + separate message if it's async or the OK should block. NEVER use the cache + as the live edge." The OK blocks, since the wire already works that way: + lite-07 SUBSCRIBE_OK carries Largest Group and waits until each Range's + start resolves, a relay's `TrackRun` waits on its source's start + (`track::Subscriber::poll_start`) before answering, and an idle copy reads + nothing until its route answers. The draft's exception for a start past the + publisher's largest position should say that a relay knows that position + only from its upstream's answer (lite-07 ranges' draft change). A reader + asks fills only below the newest group the live feed produced (decision 2 + on [moq-dev/moq#6123](https://github.com/moq-dev/moq/pull/6123)), so with + nothing produced it waits for the answer that its own demand's upstream + subscription brings. Earlier (maintainer, 2026-10-25): while a track has no + live feed, holes in a bounded range are misses up to its end, and treating + "no group produced" as "no feed" asks too early + (`subscribe_ranges::sparse_ranges_reach_the_publisher_on_lite07` reads + `[0]` on lite-06). moq-archive needs nothing new: its consumer declares the + newest stored sequence with `start_at`. +- **The session marks a copy unfed** (maintainer, 2026-11-01, on + [moq-dev/moq#7545](https://github.com/moq-dev/moq/pull/7545); landed). ✅ + The session marks a copy it holds no upstream subscription for unfed + (`track::Producer::set_unfed`), from its own state, never from the cache. + A bounded read's holes there are misses up to its end at once, per the + 2026-10-25 rule, a refusal is final, and the read takes what the cache holds + in its window even while the copy is idle; an open read still waits for a + feed. The moq-transport subscriber marks a lingering copy unfed, such as one + learned from TRACK_STATUS for a fetch, and keeps a subscription whose ranges + all end below the publisher's reported Largest Object out of the upstream + SUBSCRIBE (`fetch_only.rs`). The rejected alternative, a one-range read always + subscribing so its OK names the edge, broke the fetch-only rule below. +- **Every hole ends** (maintainer, 2026-11-01, on + [moq-dev/moq#7545](https://github.com/moq-dev/moq/pull/7545)): "uhh the + publisher is supposed to emit the start/end bounds if the subscriber didn't + ask for one? I don't understand, but parking forever is terrible?" A hole is + asked as a miss, refused, or covered by the publisher's bounds, and a + bounded read never parks for good. A hole under the start the publisher + resolved is asked at once, and a refusal there is held while the feed may + still deliver the group late (maintainer, 2026-10-21, on + [moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171)). Nothing is + inferred from the first arrival, since a local producer may still write a + lower group (`late_lower_group`). Landed: that hold, and the wait before a + hole with no start under it is asked, last the arrival grace, or + `ledger::GRACE` when neither a budget nor a max age bounds it + (`TrackState::miss_grace`), and a held refusal wakes its reader at its + deadline. +- **Cached backfill is owed** (maintainer, 2026-10-25; landed). A cached + backfill group inside a reader's window is taken as filled, whether or not + the live feed may still deliver it, so a relay behaves the same warm or + cold. Re-filling a cached group was rejected: it costs an upstream FETCH for + a group already in the cache. - **Relay demand before lite-07** (maintainer, 2026-10-25). A one-range read's ranges join the track's aggregate, so a relay would put a bounded SUBSCRIBE upstream where `fetch_group` puts a FETCH. `fetch_only.rs` asserts that a @@ -80,7 +168,14 @@ the questions decided below and landed only the `track_range_filling` benchmark. is one range FETCH on moq-transport, and per-group FETCH is lite-only, until [lite-07 ranges](/quest/m1/subscribe-ranges/lite.md) land. A bounded upstream SUBSCRIBE accounted by SUBSCRIBE_START and SUBSCRIBE_DROP was - rejected: it works on lite-05 and later only. + rejected: it works on lite-05 and later only. Landed for a lingering + moq-transport copy whose subscription lies wholly below the reported Largest + Object (the unfed copy above). Still to do: a first request with a bounded + subscription, which SUBSCRIBEs before any TRACK_STATUS names an edge; a + subscription only partly below the edge; and lite before lite-07. A + subscription to a copy no SUBSCRIBE accepts waits for the track's info + today, so this also needs a one-range read to resolve from the info its fill + installs. - **The fill path goes linear first** (maintainer, 2026-10-25). One-range reads put every past read on the fill path, which `track_range_filling` measured as superlinear in range count and span. It is flat since diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index b1d9c4efbc3..7bc82a99c30 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased +### A bounded subscription ends once its ranges are read + +When every one of a subscription's `ranges` has an end, `track::Subscriber::recv_group` and `track::Ordered::next_group` return `None` once each group in them was handed out, dropped, skipped as stale, or given up as unfillable, instead of waiting for an update to widen the ranges. +This holds for a track read from its own producer's cache. A track read through an origin's routes, such as a remote broadcast's, still waits past its ranges for now, so a reader there stops once it has the groups it asked for rather than waiting for `None`. +A reader that wants to widen its ranges later keeps one open, such as `Range::groups(n..)`, and narrows its reads with `with_range`, whose cap never ends a read. +A group another reader's fetch or fill cached is now handed to every subscriber whose ranges hold it, not only fetches. +A missing group the live feed may still deliver is waited for at most one second before it is asked of a fill or named dropped, when neither the readers' `max_delay` nor the track's max age bounds the wait. Before, a reader with `max_delay: None` on a track with no max age waited for it until the track ended. +Over moq-transport, a subscription to a copy the session holds no upstream subscription for, such as one only fetched so far, whose ranges all end below the publisher's reported Largest Object, is served by FETCH without subscribing upstream. Each missing group is asked at once and a refusal is final, since nothing else would deliver it. + ### `broadcast::Demand` reads like `track::Demand` Replace `is_used()` with `wanted()`, and `used()`, `unused()`, `poll_used`, and `poll_unused` with a loop over `wanted_changed()` (or `poll_wanted_changed`), which returns the new value each time it changes and errors once the broadcast closes. diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 42a3a59d8fb..6b91d7614ea 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2052,7 +2052,8 @@ impl Publisher { .until_withdrawn(stream, |waiter| subscribing.poll_ok(waiter)) .await? { - Ok(subscribed) => subscribed, + // A peer reads the end of the subscription as the track's. + Ok(subscribed) => subscribed.parked(), Err(err) => return Some(Err(err)), }; drop(subscribing); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4f9e02231c5..5d928d470bb 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -751,6 +751,18 @@ fn largest_group(track: &track::Producer, answer: Option) -> Opt answer.map(|largest| largest.group).max(track.latest_sequence()) } +/// Whether `subscription` needs a live feed for a copy whose publisher reported its Largest +/// Object in group `edge`: one of its ranges is open, counts back from the live edge, or +/// reaches the group the publisher may still be writing. Ranges wholly below it read only +/// history, which fills serve. +fn wants_feed(subscription: &track::Subscription, edge: Option) -> bool { + if subscription.ranges.is_empty() { + return false; + } + let history = subscription.start().is_some() && subscription.end().zip(edge).is_some_and(|(end, edge)| end <= edge); + !history +} + /// A copy with no subscription upstream, kept for fetches and a returning subscriber. struct Idle { track: track::Producer, @@ -3963,6 +3975,12 @@ impl Subscriber { /// Keep a copy nobody subscribes to, cache and all, for fetches and a subscriber /// returning soon: `Some` once one does, to subscribe again, `None` once nobody held /// it through the linger, or the session ended. A finished copy never subscribes again. + /// + /// The session holds no upstream subscription for the copy meanwhile, so it marks the + /// copy unfed: a bounded read's holes are misses at once, which the fills answer. A + /// subscription whose ranges all lie below the Largest Object the publisher reported + /// reads only history, so it is served that way too, as a FETCH would be, rather than + /// subscribing. async fn linger( &mut self, broadcast_path: &Path, @@ -3980,10 +3998,11 @@ impl Subscriber { id }; let mut linger = crate::time::Deadline::new(&self.runtime); + idle.track.set_unfed(true); let mut subscribed = idle .track .subscription() - .is_some_and(|subscription| !subscription.ranges.is_empty()); + .is_some_and(|subscription| wants_feed(&subscription, idle.track.reported_group())); enum Step { Fill(track::Fill), Subscribe, @@ -4006,7 +4025,7 @@ impl Subscriber { while let Poll::Ready(Ok(subscription)) = idle.track.poll_subscription_changed(waiter) { subscribed = subscription .as_ref() - .is_some_and(|subscription| !subscription.ranges.is_empty()); + .is_some_and(|subscription| wants_feed(subscription, idle.track.reported_group())); } if subscribed && !idle.finished { return Poll::Ready(Step::Subscribe); @@ -4038,7 +4057,11 @@ impl Subscriber { ); group_fetches.push(fetch); } - Step::Subscribe => break true, + Step::Subscribe => { + // Marked before the SUBSCRIBE goes out: a hole may arrive live from here. + idle.track.set_unfed(false); + break true; + } Step::Expired => match idle.track.abort_unwanted(Error::Cancel) { Ok(()) => { tracing::debug!(broadcast = %crate::path::Rooted(self.origin.root(), broadcast_path), track = %track_name, "track released (idle)"); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 28c335ade3a..39833aafaf7 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -4003,7 +4003,8 @@ impl TrackRun { // Apply the initial cap from the original Subscribe. Subsequent updates // flow through the SUBSCRIBE_UPDATE arm below. let cap = bounds.groups().end().map_or(Bound::Unbounded, Bound::Excluded); - let track = track.with_range((Bound::Included(floor), cap)); + // The FIN of a covered subscription is the wire's to send, below. + let track = track.with_range((Bound::Included(floor), cap)).parked(); let emit_range = ctx.version.has_track_stream(); let datagrams = ctx.version.has_datagrams() && ctx.session.max_datagram_size() > 0; diff --git a/rs/moq-net/src/model/delivered.rs b/rs/moq-net/src/model/delivered.rs index 42bc13ee3da..ec631e54c53 100644 --- a/rs/moq-net/src/model/delivered.rs +++ b/rs/moq-net/src/model/delivered.rs @@ -55,6 +55,33 @@ impl Delivered { true } + /// Record every sequence in `groups` as handed out, joining the runs it touches. + pub fn insert_run(&mut self, groups: std::ops::Range) { + let Some(mut last) = groups.end.checked_sub(1).filter(|_| !groups.is_empty()) else { + return; + }; + let mut first = groups.start; + // A run ending at or just below the first sequence joins it. + if let Some((&start, &end)) = self.runs.range(..=first).next_back() + && end.checked_add(1).is_none_or(|after| after >= first) + { + first = start; + last = last.max(end); + } + // So does every run starting inside it or just past it. + let joined: Vec = self + .runs + .range(first..=last.saturating_add(1)) + .map(|(&start, _)| start) + .collect(); + for start in joined { + if let Some(end) = self.runs.remove(&start) { + last = last.max(end); + } + } + self.runs.insert(first, last); + } + /// The parts of `groups` not handed out, in order. pub fn gaps(&self, groups: std::ops::Range) -> Vec> { let mut gaps = Vec::new(); @@ -160,6 +187,24 @@ mod test { assert!(!delivered.covers(3..5) && !delivered.covers(8..10)); } + #[test] + fn insert_run_joins_every_run_it_touches() { + let mut delivered = Delivered::default(); + for sequence in [2, 5, 6, 9, 14] { + delivered.insert(sequence); + } + delivered.insert_run(3..5); + assert_eq!(runs(&delivered), [(2, 6), (9, 9), (14, 14)], "adjacent on both sides"); + delivered.insert_run(8..13); + assert_eq!(runs(&delivered), [(2, 6), (8, 12), (14, 14)], "a run inside it"); + delivered.insert_run(4..15); + assert_eq!(runs(&delivered), [(2, 14)], "overlapping several"); + delivered.insert_run(7..7); + assert_eq!(runs(&delivered), [(2, 14)], "an empty run"); + delivered.insert_run(u64::MAX - 1..u64::MAX); + assert!(delivered.contains(u64::MAX - 1) && !delivered.contains(u64::MAX)); + } + #[test] fn the_extremes_are_sequences_too() { let mut delivered = Delivered::default(); diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index c5a05bdedc9..67a39afc15d 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -552,10 +552,13 @@ impl Copy { if let Sub::Pending(pending) = &self.sub { let sub = ready!(pending.poll_ok(waiter))?; // An anchor the wire kept may resolve below where the reader got to. - let sub = sub.with_range(( - Bound::Included(self.start(groups.0)), - groups.1.map_or(Bound::Unbounded, Bound::Excluded), - )); + // The routes judge where the reader ends, not each copy. + let sub = sub + .with_range(( + Bound::Included(self.start(groups.0)), + groups.1.map_or(Bound::Unbounded, Bound::Excluded), + )) + .parked(); self.sub = Sub::Ready(Box::new(sub)); } let Sub::Ready(sub) = &mut self.sub else { unreachable!() }; diff --git a/rs/moq-net/src/model/skipped.rs b/rs/moq-net/src/model/skipped.rs index 30b7634a898..b39073a1bd2 100644 --- a/rs/moq-net/src/model/skipped.rs +++ b/rs/moq-net/src/model/skipped.rs @@ -72,6 +72,14 @@ impl Skipped { self.0.as_ref().is_none_or(|skipped| skipped.runs.is_empty()) } + /// The parts of `groups` not skipped, which no wider budget offers again. + pub fn gaps(&self, groups: RangeInclusive) -> Vec> { + match &self.0 { + Some(skipped) => skipped.runs.gaps(&groups).collect(), + None => vec![groups], + } + } + /// The lowest sequence skipped. pub fn lowest(&self) -> Option { let skipped = self.0.as_ref()?; diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index d437c590422..3df1698582e 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -516,6 +516,11 @@ pub(crate) struct TrackState { // creation, and only a session's copy goes idle: when its upstream subscription ends // with the copy still held, until the route's answer shows in the cache. feed: Feed, + // No subscription feeds this copy: its session holds none upstream for it, so nothing + // reaches it but what fills answer. Set and cleared only by the session, from its own + // state, never judged from the cache. A bounded read's holes are then misses up to its + // end at once, with nothing to wait for (see [`Self::nothing_arrives`]). + unfed: bool, // Readers start at this group: the route's live feed starts past a gap after // everything cached, so nothing bounds how old the cache below it is. live_floor: Option, @@ -1156,14 +1161,20 @@ impl TrackState { }; } - /// Find the next live group at or after `index` in arrival order. + /// Find the next live group at or after `index` in arrival order, for a reader reading + /// `window`. /// /// Returns the group and its absolute index so the consumer can advance past it. /// The cached producer rather than a consumer: `consume` reads the clock and can /// take the group's own lock, which the caller does once the track guard is gone. - fn poll_recv_group(&self, index: usize, min_sequence: u64) -> Poll>> { + fn poll_recv_group( + &self, + index: usize, + min_sequence: u64, + window: &Window, + ) -> Poll>> { let start = index.saturating_sub(self.offset); - let readable = self.readable(); + let readable = self.serves_bounded(window); let min_sequence = min_sequence.max(self.live_floor.unwrap_or(0)); for (i, (sequence, stamp)) in self.arrival.iter().enumerate().skip(start).filter(|_| readable) { // A cut group is handed out too, so its reader sees why it ended. @@ -1293,7 +1304,7 @@ impl TrackState { let best = window .runs(next_sequence.max(self.live_floor.unwrap_or(0))) - .filter(|_| self.readable()) + .filter(|_| self.serves_bounded(window)) .find_map(|run| { // A cut group is handed out too, so its reader sees why it ended. self.lookup @@ -2594,6 +2605,19 @@ impl TrackState { reached && (self.abort.is_none() || self.settled.load(Ordering::Relaxed)) } + /// Whether nothing more reaches the cache but what fills answer: the track was aborted, + /// reached its end, or its session holds no subscription that feeds it. + pub(super) fn nothing_arrives(&self) -> bool { + self.abort.is_some() || self.is_complete() || self.unfed + } + + /// Whether a bounded window reads from the cache: whenever readers may, and on a copy no + /// subscription feeds, whose groups in the window are the groups themselves however old + /// its live edge is. An open window still waits for the feed. + fn serves_bounded(&self, window: &Window) -> bool { + self.readable() || self.unfed && window.end().is_some() + } + /// Whether the live edge reached `fin`, or a received copy's tail decides it instead: /// what it still misses up to `fin` is waited for only as long as the grace. fn reaches(&self, fin: u64) -> bool { @@ -3601,6 +3625,28 @@ impl Producer { } } + /// Whether a subscription feeds this copy: its session holds one upstream for it, or + /// none, so nothing reaches it but what fills answer. Only the session knows, from its + /// own state; the cache never tells. While unfed, a bounded read's holes are misses up + /// to its end at once, and it reads the cache whether or not the copy is idle. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn set_unfed(&self, unfed: bool) { + // Unchanged wakes nobody: every write wakes the readers. + if self.state.read().unfed == unfed { + return; + } + if let Ok(mut state) = self.modify() { + state.unfed = unfed; + } + } + + /// The group of the newest Largest Object the upstream publisher's answers reported, + /// kept however far the cache has moved past it: every group below it is history there. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn reported_group(&self) -> Option { + self.state.read().object_edge.map(|((group, _), _)| group) + } + /// The live feed's route answered that it reaches `group`: a relative start counts back /// from it, rather than from whichever group lands first. A no-op unless live. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] @@ -3900,6 +3946,7 @@ impl Producer { stats: stats::Scope::default(), _stats_sub: stats::Subscription::default(), largest: Reported::default(), + parks: false, } } @@ -5395,6 +5442,7 @@ impl Subscribing { stats: self.stats.clone(), _stats_sub: self.stats.subscribe(), largest: Reported::default(), + parks: false, })) } @@ -5712,6 +5760,9 @@ pub struct Subscriber { // while held, closing them on drop. Empty (no-op) for an untagged track. _stats_sub: stats::Subscription, largest: Reported, + /// Whether a read past a bounded window's end parks rather than ending: a wire + /// publisher's, whose peer would read the end of its stream as the track's. + parks: bool, } /// What a [`Subscriber`]'s `largest` stream last reported. @@ -6352,6 +6403,10 @@ struct Filter { /// budget, scanned in the cache in the subscription's order and handed out ahead of the /// walk: each cached group in them is visited once. owed: RangeInclusiveSet, + /// Groups skipped as stale, which account for a bounded window as handed out would, + /// until a wider budget owes them again. Kept under an open window too, which a reader + /// may narrow to a bounded one. + written_off: RangeSet, } impl Cursor { @@ -6394,6 +6449,7 @@ impl Cursor { admitted: RangeInclusiveSet::new(), admitted_at: None, owed: RangeInclusiveSet::new(), + written_off: RangeSet::new(), }; (filter, state.datagram_offset + start, state.cache.clone()) }; @@ -6559,6 +6615,48 @@ impl Cursor { self.min_sequence.max(self.filter.window.floor().unwrap_or(0)) } + /// Whether a bounded window has nothing left to deliver: every group in its ranges was + /// handed out (`taken`), dropped, skipped as stale, or given up as unfillable. An open + /// window, or one with a fill still out, always has more, and so does one its cap + /// keeps a group out of, since a reader may raise a cap again. + fn is_exhausted(&self, taken: &Delivered) -> bool { + let Some(end) = self.filter.window.end() else { + return false; + }; + if self.filter.window.is_pending() || self.awaits_fill() { + return false; + } + let given_up = self.misses.as_ref().map(|misses| misses.given_up()); + let state = self.state.read(); + let accounted = |gap: ops::Range| { + let mut left = RangeSet::new(); + left.insert(gap.clone()); + for run in self.filter.written_off.overlapping(&gap) { + left.remove(run.clone()); + } + if let Some(given_up) = given_up { + for run in given_up.overlapping(&gap) { + left.remove(run.clone()); + } + } + for (run, _) in state.drops.overlapping(&gap) { + left.remove(run.clone()); + } + for sequence in self.seek_pending.range(gap).map(|(sequence, _)| *sequence) { + left.remove(sequence..sequence + 1); + } + left.is_empty() + }; + self.filter + .window + .runs(self.filter.window.floor().unwrap_or(0)) + .take_while(|run| *run.start() < end) + .all(|run| { + let run = *run.start()..run.end().checked_add(1).map_or(end, |next| next.min(end)); + taken.gaps(run).into_iter().all(&accounted) + }) + } + /// Whether group `sequence`, at or above [`Self::floor`], is one this cursor reads now: /// in its window and below its cap. One that isn't may be later. fn admits(&self, sequence: u64) -> bool { @@ -6603,6 +6701,29 @@ impl Cursor { self.filter.admitted_at = Some(at); } + /// Move each group written off as stale that nothing can offer again, since its group + /// left the cache or was judged at the bound, into `taken`, which compacts what it + /// accounts for below its floor. Only a group a wider budget may still offer stays + /// written off, so the write-offs never outnumber the stale skips the cache holds. + fn retire_written_off(&mut self, taken: &mut Delivered) { + // Most cursors never write a group off, and this runs on every hand-out. + if self.filter.written_off.is_empty() { + return; + } + // By run, so a long stale run costs one lookup rather than one per group. + let settled: Vec> = self + .filter + .written_off + .iter() + .flat_map(|run| self.skipped.gaps(run.start..=run.end - 1)) + .map(|gap| *gap.start()..gap.end() + 1) + .collect(); + for run in settled { + self.filter.written_off.remove(run.clone()); + taken.insert_run(run); + } + } + /// The next cached group still owed in the subscription's order, newest first unless /// ascending, consuming the spans scanned on the way: only one the walk could have read, /// never fetched backfill or a withheld group. The group itself stays owed until @@ -6614,7 +6735,7 @@ impl Cursor { let ascending = self.subscription.read().order == Order::Ascending; let state = self.state.read(); // Nothing is read from the cache now, so leave what is owed until it is. - if !state.readable() { + if !state.serves_bounded(&self.filter.window) { return None; } let low = state.live_floor.unwrap_or(0); @@ -6673,6 +6794,9 @@ impl Cursor { /// the cache, and nothing when the budget did not widen. fn reoffer(&mut self, budget: Duration) { for run in self.skipped.widened(budget) { + self.filter + .written_off + .remove(*run.start()..run.end().saturating_add(1)); // Judged again, so a drop not reported yet is withdrawn; a reported one stands. if let Some(drops) = &mut self.drops { let span = *run.start()..run.end().saturating_add(1); @@ -6890,6 +7014,7 @@ impl Cursor { &state, (from, revision, cap), window.named(), + window.end(), |span| { let end = cap.map_or(*span.end(), |cap| (*span.end()).min(cap.saturating_sub(1))); if cap == Some(0) || *span.start() > end { @@ -7003,7 +7128,9 @@ impl Cursor { Some((producer, taken)) => (producer.consume(), taken), None => { let floor = self.floor(); - let polled = self.poll(waiter, |state| state.poll_recv_group(self.index, floor)); + let polled = self.poll(waiter, |state| { + state.poll_recv_group(self.index, floor, &self.filter.window) + }); // A moved window moved the servable edge `drift` was judged against. if polled.is_pending() && self.watch_time(waiter) { self.admit(); @@ -7060,6 +7187,11 @@ impl Cursor { true => self.skipped.remove(consumer.sequence), false => self.skip(consumer.sequence, drift.budget), } + // Whatever the window is now: narrowed to a bounded one later, it accounts for + // this group by it, even once the group left the cache. + if let Some(end) = consumer.sequence.checked_add(1) { + self.filter.written_off.insert(consumer.sequence..end); + } continue; } return Poll::Ready(Ok(Some(Peeked { @@ -7463,12 +7595,19 @@ impl Subscriber { /// budget offers the cached groups it skipped as stale and now covers the same way. /// Once the track finishes, a reader with nothing left in range ends. /// + /// A reader whose [`Subscription::ranges`] all end ends too, once every group in them + /// was handed out, dropped, skipped as stale, or given up as unfillable, rather than + /// waiting for an update to widen them: a reader that wants more keeps a range open. A + /// [`Self::with_range`] cap never ends a read, since it may rise again, and a group it + /// keeps out stays owed. + /// A track an origin serves from whichever of its routes is up still parks there for now. + /// /// A group one of [`Subscription::ranges`] starts or ends inside reads only the frames /// they cover there. One missing the frames asked for reads [`Error::Lagged`] rather /// than starting short. /// /// Returns `Poll::Ready(Ok(Some(group)))` when a group is available, - /// `Poll::Ready(Ok(None))` when the track is finished, + /// `Poll::Ready(Ok(None))` when the track is finished or the ranges are read, /// `Poll::Ready(Err(e))` when the track has been aborted, or /// `Poll::Pending` when no group is available yet. pub fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { @@ -7505,7 +7644,18 @@ impl Subscriber { pub(super) fn poll_peek_group(&mut self, ordered: bool, waiter: &kio::Waiter) -> Poll>> { match &mut self.inner { Inner::Plain(cursor) => loop { - let Some(peeked) = ready!(cursor.poll_peek(ordered, &self.delivered, waiter))? else { + let peeked = match cursor.poll_peek(ordered, &self.delivered, waiter) { + Poll::Pending => { + // A read that only skipped since its last hand-out settles its write-offs too. + cursor.retire_written_off(&mut self.delivered); + if !self.parks && cursor.is_exhausted(&self.delivered) { + return Poll::Ready(Ok(None)); + } + return Poll::Pending; + } + polled => ready!(polled)?, + }; + let Some(peeked) = peeked else { return Poll::Ready(Ok(None)); }; if !self.delivered.contains(peeked.group.sequence) { @@ -7514,6 +7664,7 @@ impl Subscriber { // A replay advances only this copy's cursor, as an ordinary read would. cursor.take(peeked); }, + // A front's bounded read parks past its end for now, as each copy's reader does. Inner::Resume(resume, _) => resume .poll_peek_group(ordered, &self.delivered, waiter) .map(|res| res.map(|candidate| candidate.map(|candidate| Reserved::Resume(Box::new(candidate))))), @@ -7600,6 +7751,9 @@ impl Subscriber { let mut delivered = std::mem::take(&mut self.delivered); self.forget_past_budget(&mut delivered); self.delivered = delivered; + if let Inner::Plain(cursor) = &mut self.inner { + cursor.retire_written_off(&mut self.delivered); + } } /// Forget the gaps in `delivered` below each group past this subscriber's budget now: @@ -7617,6 +7771,15 @@ impl Subscriber { self.delivered.runs() } + /// How many runs of groups written off as stale this subscriber remembers. + #[cfg(test)] + fn written_off_runs(&self) -> usize { + match &self.inner { + Inner::Plain(cursor) => cursor.filter.written_off.iter().count(), + Inner::Resume(..) => 0, + } + } + /// Whether an unread cached group needs this subscription, without moving its cursor. pub(super) fn has_unread_group(&self, unread: &Unread<'_>) -> bool { let cursor = match &self.inner { @@ -7979,6 +8142,15 @@ impl Subscriber { } /// [`Self::with_range`] on a reader held in place. + /// Park past the end of a bounded window rather than end the read there, as a wire + /// publisher does: its peer reads the end of a subscription's stream as the track's, + /// unless the wire ends a covered subscription itself. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn parked(mut self) -> Self { + self.parks = true; + self + } + pub(crate) fn set_range(&mut self, groups: impl RangeBounds) { let (start, end) = super::subscription::sequence_bounds(groups); match &mut self.inner { @@ -8192,11 +8364,12 @@ impl Ordered { /// /// Honors the group range set by [`Self::with_range`]: /// a group past the cap stays in the producer's cache and becomes eligible again if - /// the cap rises or is removed. + /// the cap rises or is removed. Ranges that all end end the read once they are + /// accounted for, as for [`Subscriber::poll_recv_group`]. /// /// Returns `Poll::Ready(Ok(Some(group)))` when a group is available, /// `Poll::Ready(Ok(None))` when the track is finished (including an abort after its - /// declared end settled, see [`Producer::abort`]), + /// declared end settled, see [`Producer::abort`]) or the ranges are read, /// `Poll::Ready(Err(e))` when the track has been aborted short of its end, or /// `Poll::Pending` when no group is available yet. pub fn poll_next_group(&mut self, waiter: &kio::Waiter) -> Poll>> { @@ -18153,11 +18326,11 @@ mod test { assert!(pool.used() <= 3_000, "growth is reclaimed: {}", pool.used()); } - /// A stale arrival entry whose sequence was later re-served by fetched backfill - /// must not leak the replacement into arrival-order subscriptions. + /// A stale arrival entry whose sequence was later re-served by fetched backfill must + /// not put the replacement in the arrival walk, which a subscriber reads as backfill. #[kio::test] #[cfg(feature = "async")] - async fn refilled_sequence_stays_out_of_subscriptions() { + async fn a_refilled_sequence_stays_out_of_the_arrival_walk() { let (producer, _pool) = pooled_producer(1 << 40); let dynamic = producer.requests(); let consumer = producer.consume(); @@ -18186,7 +18359,11 @@ mod test { assert!(consumer.peek_group(1).is_some()); let mut walked = Vec::new(); let mut index = 0; - while let Poll::Ready(Ok(Some((group, next)))) = producer.state.read().poll_recv_group(index, 0) { + let window = { + let state = producer.state.read(); + Window::new(&replay().ranges, None, &*state).unwrap() + }; + while let Poll::Ready(Ok(Some((group, next)))) = producer.state.read().poll_recv_group(index, 0, &window) { walked.push(group.sequence); index = next + 1; } @@ -18370,11 +18547,12 @@ mod test { .unwrap(); } - /// Lowering the floor re-offers only what the arrival walk could have read: a fetched - /// group stays out of arrival order, while a live one is offered before its first frame. + /// Lowering the floor re-offers what the arrival walk could have read, a live group + /// before its first frame, and owes a fetched one as cached backfill. Only the walked + /// groups count as unread for a copy handing over. #[kio::test] #[cfg(feature = "async")] - async fn a_lowered_floor_reoffers_live_groups_but_not_fetched_ones() { + async fn a_lowered_floor_reoffers_live_groups_and_owes_fetched_ones() { let (producer, _pool) = pooled_producer(1 << 40); let dynamic = producer.requests(); let consumer = producer.consume(); @@ -18398,6 +18576,7 @@ mod test { subscriber.update(replay().with_range(0..)).unwrap(); assert_eq!(subscriber.assert_group().sequence, 3, "offered before its first frame"); + assert_eq!(subscriber.assert_group().sequence, 2, "cached backfill is owed"); subscriber.assert_no_group(); // A cursor floor lowered before the next read counts only walked groups as unread. @@ -18414,11 +18593,12 @@ mod test { assert!(!lowered.has_unread_group(&unread), "the fetched group 2 is not unread"); } - /// A fetched (backfill) group is served by sequence but never replayed to - /// arrival-order subscribers. + /// A group another reader's fill cached is owed to a subscriber whose window holds it, + /// as its own fill's answer would be, though the arrival walk never reaches it: a warm + /// cache serves what a cold one fills. #[kio::test] #[cfg(feature = "async")] - async fn fetched_backfill_not_subscribed() { + async fn cached_backfill_is_owed_to_a_subscriber() { let (producer, _pool) = pooled_producer(1 << 40); let dynamic = producer.requests(); let consumer = producer.consume(); @@ -18443,11 +18623,21 @@ mod test { assert_eq!(&fetched.read_frame().await.unwrap().unwrap().payload[..], b"backfill"); assert!(consumer.peek_group(2).is_some(), "backfill is cached for later fetches"); - // ...but an arrival-order subscriber only sees the live groups. + // ...and a subscriber reads it beside the live groups, asking no fill of its own. let mut subscriber = producer.subscribe(replay()); - assert_eq!(subscriber.assert_group().sequence, 5); - assert_eq!(subscriber.assert_group().sequence, 6); + let mut read: Vec<_> = (0..3).map(|_| subscriber.assert_group().sequence).collect(); + read.sort_unstable(); + assert_eq!(read, [2, 5, 6]); subscriber.assert_no_group(); + + // A bounded read of it alone ends once it took it. + let mut bounded = producer.subscribe(replay().with_range(2..3)); + assert_eq!(read_now(&mut bounded, false), Some(Some(2))); + assert_eq!(read_now(&mut bounded, false), Some(None)); + assert!( + dynamic.requested_fill_group().now_or_never().is_none(), + "the cache answered both reads" + ); } /// The feed's copy of a group a fetch cached first takes the slot, so a subscriber reads @@ -22324,4 +22514,327 @@ mod test { let _joined = producer.subscribe(None); assert_eq!(producer.demand().wanted(), Some(1), "a join asks for the same group"); } + + /// What a read returns right now: `Some(Some(sequence))` for a group, `Some(None)` for + /// its end, `None` while it parks. + fn read_now(subscriber: &mut Subscriber, ordered: bool) -> Option> { + match subscriber.poll_group(ordered, &kio::Waiter::noop()) { + Poll::Ready(res) => Some(res.expect("would have errored").map(|group| group.sequence)), + Poll::Pending => None, + } + } + + /// A read whose ranges all end returns `None` once it handed out every group in them, + /// in either order, rather than parking for an update to widen it. + #[test] + fn a_bounded_read_ends_once_its_window_is_read() { + for ordered in [false, true] { + let producer = track_producer("test", Info::timed()); + for sequence in 0..4u64 { + producer.create_group(sequence.into()).unwrap().finish().unwrap(); + } + let mut reader = producer.subscribe( + replay() + .with_order(Order::Ascending) + .with_ranges([Range::groups(1..2), Range::groups(3..4)]), + ); + assert_eq!(read_now(&mut reader, ordered), Some(Some(1)), "ordered: {ordered}"); + assert_eq!(read_now(&mut reader, ordered), Some(Some(3)), "ordered: {ordered}"); + assert_eq!(read_now(&mut reader, ordered), Some(None), "ordered: {ordered}"); + } + } + + /// An open range, or a bounded one still owed a group, parks; a local cap alone bounds + /// nothing, since its reader may raise it again. + #[test] + fn a_read_with_more_to_come_parks() { + let producer = track_producer("test", Info::timed()); + producer.create_group(0u64.into()).unwrap().finish().unwrap(); + + let mut open = producer.subscribe(replay().with_range(0..)); + assert_eq!(read_now(&mut open, false), Some(Some(0))); + assert_eq!(read_now(&mut open, false), None); + + let mut capped = producer.subscribe(replay().with_range(0..)).with_range(..1); + assert_eq!(read_now(&mut capped, false), Some(Some(0))); + assert_eq!(read_now(&mut capped, false), None); + + let mut owed = producer.subscribe(replay().with_range(0..2)); + assert_eq!(read_now(&mut owed, false), Some(Some(0))); + assert_eq!(read_now(&mut owed, false), None, "group 1 may still arrive"); + producer.create_group(1u64.into()).unwrap().finish().unwrap(); + assert_eq!(read_now(&mut owed, false), Some(Some(1))); + assert_eq!(read_now(&mut owed, false), Some(None)); + } + + /// A dropped group, or one skipped as stale, accounts for its place in a bounded window + /// as a group handed out does. + #[test] + fn a_bounded_read_ends_past_dropped_and_stale_groups() { + let producer = track_producer("test", Info::timed()); + let mut dropped = producer.subscribe(replay().with_range(0..2)); + producer.create_group(0u64.into()).unwrap().finish().unwrap(); + assert_eq!(read_now(&mut dropped, false), Some(Some(0))); + assert_eq!(read_now(&mut dropped, false), None); + producer.drop_groups(1..2, StreamError::NotFound); + assert_eq!(read_now(&mut dropped, false), Some(None)); + + let producer = track_producer("test", Info::timed()); + // Group 1 runs on past where group 0 could still present. + for (sequence, frames) in [(0u64, &[0u64][..]), (1, &[1_000, 1_100])] { + let mut group = producer.create_group(sequence.into()).unwrap(); + for at in frames { + group + .write_frame(Timestamp::from_millis(*at).unwrap(), bytes::Bytes::from_static(b"f")) + .unwrap(); + } + group.finish().unwrap(); + } + let mut stale = producer.subscribe( + Subscription::default() + .with_max_delay(Duration::from_millis(10)) + .with_order(Order::Ascending) + .with_range(0..2), + ); + assert_eq!(read_now(&mut stale, false), Some(Some(1)), "group 0 is stale"); + assert_eq!(read_now(&mut stale, false), Some(None)); + } + + /// A wire publisher's read parks past its window's end, since its peer would read the + /// end of the subscription as the track's. + #[test] + fn a_parked_bounded_read_waits_past_its_end() { + let producer = track_producer("test", Info::timed()); + producer.create_group(0u64.into()).unwrap().finish().unwrap(); + let mut reader = producer.subscribe(replay().with_range(0..1)).parked(); + assert_eq!(read_now(&mut reader, false), Some(Some(0))); + assert_eq!(read_now(&mut reader, false), None); + } + + /// A bounded read waits on the fill its miss asked, and ends once it took the answer. + #[kio::test] + #[cfg(feature = "async")] + async fn a_bounded_read_ends_after_its_fill() { + let (producer, _pool) = pooled_producer(1 << 40); + let dynamic = producer.requests(); + producer.create_group(0u64.into()).unwrap().finish().unwrap(); + producer.create_group(2u64.into()).unwrap().finish().unwrap(); + producer.finish().unwrap(); + let mut reader = producer.subscribe(replay().with_order(Order::Ascending).with_range(1..2)); + assert_eq!(read_now(&mut reader, false), None, "the fill is out"); + let fill = dynamic + .requested_fill_group() + .now_or_never() + .expect("the miss asks a fill") + .unwrap(); + assert_eq!(fill.sequence(), 1); + let mut group = fill.accept(Info::timed()).unwrap(); + group + .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"filled")) + .unwrap(); + group.finish().unwrap(); + assert_eq!(read_now(&mut reader, false), Some(Some(1))); + assert_eq!(read_now(&mut reader, false), Some(None)); + } + + /// A hole the live feed may still deliver is waited for over the arrival grace before + /// it is asked as a miss. With no budget and no publisher max age nothing bounds that + /// grace, so the wait is [`ledger::GRACE`]: a bounded read of a hole below where a live + /// feed started, with no start declared, ends rather than parking for good. + #[kio::test] + #[cfg(feature = "async")] + async fn a_hole_below_an_undeclared_start_is_asked_after_a_bounded_grace() { + let (producer, _pool) = pooled_producer(1 << 40); + let dynamic = producer.requests(); + producer.create_group(10u64.into()).unwrap().finish().unwrap(); + let mut reader = producer.subscribe(Subscription::default().with_max_delay(None).with_range(5..6)); + assert_eq!(read_now(&mut reader, false), None, "group 5 may still arrive"); + assert!(dynamic.requested_fill_group().now_or_never().is_none()); + elapse(&producer, ledger::GRACE); + assert_eq!(read_now(&mut reader, false), None, "the fill is out"); + let fill = dynamic + .requested_fill_group() + .now_or_never() + .expect("the hole is asked once the grace passed") + .unwrap(); + assert_eq!(fill.sequence(), 5); + fill.reject(Error::NotFound); + assert_eq!(read_now(&mut reader, false), Some(None), "the refusal is final"); + + // With no fill handler, the hole is given up once the grace passed. + let (producer, _pool) = pooled_producer(1 << 40); + producer.create_group(10u64.into()).unwrap().finish().unwrap(); + let mut reader = producer.subscribe(Subscription::default().with_max_delay(None).with_range(5..6)); + assert_eq!(read_now(&mut reader, false), None); + elapse(&producer, ledger::GRACE); + assert_eq!(read_now(&mut reader, false), Some(None)); + } + + /// A hole below a declared start is asked at once, and a refusal there is held while the + /// live feed may still deliver the group late. With nothing bounding the arrival grace the + /// hold lasts [`ledger::GRACE`], on a received copy as on a local track, so the read ends. + #[kio::test] + #[cfg(feature = "async")] + async fn a_refusal_below_the_start_is_held_for_a_bounded_grace() { + for received in [false, true] { + let (mut producer, _pool) = pooled_producer(1 << 40); + if received { + producer.received(); + } + let dynamic = producer.requests(); + producer.start_at(10).unwrap(); + producer.create_group(10u64.into()).unwrap().finish().unwrap(); + let mut reader = producer.subscribe(Subscription::default().with_max_delay(None).with_range(5..6)); + assert_eq!(read_now(&mut reader, false), None, "received: {received}"); + let fill = dynamic + .requested_fill_group() + .now_or_never() + .expect("a hole below the start is asked at once") + .unwrap(); + assert_eq!(fill.sequence(), 5, "received: {received}"); + fill.reject(Error::NotFound); + assert_eq!( + read_now(&mut reader, false), + None, + "received: {received}: may still arrive" + ); + elapse(&producer, ledger::GRACE); + assert_eq!(read_now(&mut reader, false), Some(None), "received: {received}"); + } + } + + /// A copy its session holds no subscription for asks a bounded read's holes as misses + /// at once, though it is idle and its feed produced nothing, and the read ends once the + /// fill answered or refused each, since nothing else will deliver them. + #[kio::test] + #[cfg(feature = "async")] + async fn an_unfed_copy_asks_a_bounded_read_s_holes_at_once() { + let (mut producer, _pool) = pooled_producer(1 << 40); + let dynamic = producer.requests(); + producer.set_idle(); + let mut reader = producer.subscribe(replay().with_order(Order::Ascending).with_range(1..3)); + assert_eq!(read_now(&mut reader, false), None); + assert!( + dynamic.requested_fill().now_or_never().is_none(), + "an idle copy a feed may still answer asks nothing" + ); + + producer.set_unfed(true); + assert_eq!(read_now(&mut reader, false), None, "the fill is out"); + let fill = dynamic + .requested_fill() + .now_or_never() + .expect("the holes are asked at once") + .unwrap(); + assert_eq!((fill.start(), fill.end()), (Position::group(1), 3)); + let mut group = fill.accept(1, Info::timed()).unwrap(); + group + .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"filled")) + .unwrap(); + group.finish().unwrap(); + fill.refuse_groups(2..3, Error::Unsupported); + assert_eq!(read_now(&mut reader, false), Some(Some(1))); + assert_eq!(read_now(&mut reader, false), Some(None), "the refusal is final"); + } + + /// On a copy no subscription feeds, a bounded read takes what the cache holds in its + /// window, idle or not, while an open read still waits for a feed to answer. + #[test] + fn an_unfed_copy_serves_a_bounded_read_from_its_cache() { + let producer = idle_copy(); + producer.set_unfed(true); + let mut bounded = producer.subscribe(replay().with_order(Order::Ascending).with_range(1..3)); + assert_eq!(read_now(&mut bounded, false), Some(Some(1))); + assert_eq!(read_now(&mut bounded, false), Some(Some(2))); + assert_eq!(read_now(&mut bounded, false), Some(None)); + + let mut open = producer.subscribe(replay().with_range(1..)); + assert_eq!(read_now(&mut open, false), None, "the cache is not the live edge"); + } + + /// A long bounded read that writes a group off as stale between each it hands out + /// remembers a write-off only while its group is cached, since only then can a wider + /// budget offer it again, and still ends. + #[test] + fn a_bounded_read_retires_its_write_offs_as_it_advances() { + // Small enough that each round evicts the groups before it. + let (producer, _pool) = pooled_producer(1); + let mut reader = producer.subscribe( + Subscription::default() + .with_max_delay(Duration::from_millis(10)) + .with_order(Order::Ascending) + .with_range(0..16), + ); + for round in 0..8u64 { + // Group 2k+1 runs on past where group 2k could still present, so 2k is stale. + let at = 2_000 * round; + for (sequence, frames) in [(2 * round, vec![at]), (2 * round + 1, vec![at + 1_000, at + 1_100])] { + let mut group = producer.create_group(sequence.into()).unwrap(); + for at in frames { + group + .write_frame(Timestamp::from_millis(at).unwrap(), bytes::Bytes::from_static(b"f")) + .unwrap(); + } + group.finish().unwrap(); + } + assert_eq!(read_now(&mut reader, false), Some(Some(2 * round + 1)), "round {round}"); + assert!( + reader.written_off_runs() <= 1, + "round {round}: {}", + reader.written_off_runs() + ); + } + assert_eq!(read_now(&mut reader, false), Some(None)); + } + + /// A read that skipped a group as stale while its window was open, then narrows to a + /// bounded window holding that group, still ends, whether the group is cached or evicted. + #[test] + fn a_read_narrowed_past_a_stale_skip_ends() { + for evict in [false, true] { + let (producer, _pool) = pooled_producer(if evict { 1 } else { 1 << 40 }); + let mut reader = producer.subscribe( + Subscription::default() + .with_max_delay(Duration::from_millis(10)) + .with_order(Order::Ascending) + .with_range(0..), + ); + for (sequence, frames) in [(0u64, &[0u64][..]), (1, &[1_000, 1_100])] { + let mut group = producer.create_group(sequence.into()).unwrap(); + for at in frames { + group + .write_frame(Timestamp::from_millis(*at).unwrap(), bytes::Bytes::from_static(b"f")) + .unwrap(); + } + group.finish().unwrap(); + } + assert_eq!(read_now(&mut reader, false), Some(Some(1)), "evict: {evict}"); + reader + .update( + Subscription::default() + .with_max_delay(Duration::from_millis(10)) + .with_order(Order::Ascending) + .with_range(0..2), + ) + .unwrap(); + assert_eq!(read_now(&mut reader, false), Some(None), "evict: {evict}"); + } + } + + /// A hole below the start that no fill handler can take is held as refused while the feed + /// may still deliver it, and the hold ending wakes the parked reader. + #[kio::test] + #[cfg(feature = "async")] + async fn an_unfillable_hole_below_the_start_wakes_its_reader() { + let (mut producer, _pool) = pooled_producer(1 << 40); + producer.start_at(10).unwrap(); + producer.create_group(10u64.into()).unwrap().finish().unwrap(); + let mut reader = producer.subscribe(Subscription::default().with_max_delay(None).with_range(5..6)); + let (flag, waiter) = Flag::waiter(); + assert!(reader.poll_group(false, &waiter).is_pending()); + flag.take(); + elapse(&producer, ledger::GRACE); + assert!(flag.take(), "the hold's end woke the reader"); + assert_eq!(read_now(&mut reader, false), Some(None)); + } } diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index feb76ffd73f..a9d0390b62f 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -5,7 +5,8 @@ //! fill is bounded by construction. Below it, a hole is a miss once the feed is past it: //! under where the feed starts, or accounted for by the arrival ledger without being cached //! (evicted, or a gap the feed skipped). A hole the ledger still waits for is deferred and -//! judged again as the feed moves. +//! judged again as the feed moves, or once its wait ends: the arrival grace, bounded by +//! [`ledger::GRACE`] when nothing else bounds it, so every hole is asked in finite time. //! //! Each sequence is judged once, except a group evicted after the reader judged it cached //! and before it took it: judged again as a miss, once. A refill evicted again before it is @@ -30,7 +31,7 @@ use super::super::delivered::Delivered; pub(super) use super::fill::Asking; use super::fill::{Ask, FillState, Join}; use super::{Drift, Order, Position, TrackState}; -use crate::Error; +use crate::{Error, ledger}; /// What a probe judges against: the span read, the feed's newest group, where the feed /// and readers start, and the drops revision. @@ -433,13 +434,21 @@ impl Misses { self.release(&taken); // Judged again: the same join may have more to answer, or park the waiter now. } + // A refusal held while the feed may still deliver its groups is judged again once + // the feed gives up on them, which nothing else wakes this reader for. Registered + // whether or not the deadline moved here: `ask` settles what no handler can take + // before this runs, and the judge registered only the deadline it saw. + if let Some(at) = self.deferred.deadline() { + state.read().cache.watch_wall(at, waiter); + } retry } /// Judge the holes in `target` the last probe has not, returning the runs to ask a /// fill for. Gives up a run already stale under this reader's budget. `named` is the - /// lowest group the reader's ranges name, which may reach below the feed's start, and - /// `taken` what the reader handed out, which an eviction never makes a miss again. + /// lowest group the reader's ranges name, which may reach below the feed's start, + /// `bound` the exclusive end of a window whose ranges all end, and `taken` what the + /// reader handed out, which an eviction never makes a miss again. #[expect( clippy::too_many_arguments, reason = "one probe's inputs, borrowed apart from its cursor" @@ -449,6 +458,7 @@ impl Misses { state: &TrackState, key: (u64, u64, Option), named: Option, + bound: Option, mut target: impl FnMut(RangeInclusive) -> RangeInclusiveSet, taken: &Delivered, drift: &Drift, @@ -456,12 +466,15 @@ impl Misses { waiter: &kio::Waiter, ) -> RangeSet { let mut asks = RangeSet::new(); + // On a copy no subscription feeds, a bounded window's holes are misses up to its + // end, since no feed will deliver them nor produce anything to judge them below. + let bound = bound.filter(|_| state.unfed).and_then(|end| end.checked_sub(1)); // The feed's start is unknown until the serving session resolves it, and readers // take nothing from a cache that does not reflect the feed. - if state.start_pending || !state.readable() { + if state.start_pending || !state.readable() && bound.is_none() { return asks; } - let Some(newest) = state.newest_produced else { + let Some(newest) = state.newest_produced.max(bound) else { return asks; }; let at = ( @@ -473,7 +486,7 @@ impl Misses { state.start_sequence, state.drops_revision, ); - let ended = state.abort.is_some() || state.is_complete(); + let ended = state.nothing_arrives(); let ledger = state.arrivals.changes(); let drops = &state.drops_changes; // Every reader probes on every poll, mostly with nothing changed since the last, so @@ -489,7 +502,7 @@ impl Misses { if unchanged && self.deferred.runs.is_empty() { return asks; } - let grace = state.arrival_grace(); + let grace = state.miss_grace(); let unchanged = unchanged && self.grace == Some(grace); let deadline = self.deferred.deadline(); if unchanged && deadline.is_none() { @@ -674,16 +687,11 @@ impl Misses { } for run in candidates.iter() { // Backfill another reader's fill cached is no hole, yet the arrival walk never - // reaches it: this reader takes it as filled where it would ask for a hole. + // reaches it: this reader takes it as filled, as it would its own fill's answer, + // even while the live feed may still deliver the group, so a warm cache serves + // what a cold one fills. A live copy landing later is the same group handed out. for held in backfill(state, run.clone()).iter() { - let (unfed, awaited) = state.unfed(held.clone(), now); - for run in unfed.iter() { - self.filled.extend(run.clone()); - } - for (run, folds) in awaited { - self.filled.extend(run.start..run.end.min(start)); - self.deferred.insert(run.start.max(start)..run.end, folds); - } + self.filled.extend(held.clone()); } for hole in holes(state, run.clone()) { for hole in self.unclaimed(state, hole) { @@ -853,7 +861,7 @@ impl Misses { for run in left.iter() { self.refused.insert(run.clone()); self.deferred - .insert(run.clone(), state.arrivals.folds_at(run.start, state.arrival_grace())); + .insert(run.clone(), state.arrivals.folds_at(run.start, state.miss_grace())); } } } @@ -958,6 +966,17 @@ impl Inboxes { } impl TrackState { + /// How long a hole the live feed may still deliver is waited for before it is a miss: + /// the arrival grace, or [`ledger::GRACE`] when nothing bounds that, so every hole is + /// asked or given up in finite time. A reader with no budget on a track with no max age + /// would otherwise wait out an unbounded grace, and a read of the hole would never end. + pub(super) fn miss_grace(&self) -> Duration { + match self.arrival_grace() { + Duration::MAX => ledger::GRACE, + grace => grace, + } + } + /// Split `hole`, which the cache holds nothing for, into what the live feed will never /// deliver and the gaps it still waits for, with when each stops being waited for. /// @@ -975,14 +994,15 @@ impl TrackState { ) -> (RangeSet, Vec<(ops::Range, Option)>) { let mut unfed = RangeSet::new(); let mut awaited = Vec::new(); - // Nothing more arrives after an abort, a seal, or once the live edge reached a - // declared end with no tail owed: until then, a group below the end may still arrive. - if self.abort.is_some() || self.is_complete() { + // Nothing more arrives after an abort, a seal, once the live edge reached a declared + // end with no tail owed, or while no subscription feeds the copy: until then, a group + // below the end may still arrive. + if self.nothing_arrives() { unfed.insert(hole); return (unfed, awaited); } let mut from = hole.start; - let grace = self.arrival_grace(); + let grace = self.miss_grace(); let mut gap = |groups: ops::Range, unfed: &mut RangeSet| match self.arrivals.folds_at(groups.start, grace) { Some(at) if at <= now => unfed.insert(groups), @@ -1088,6 +1108,7 @@ mod tests { &state, (0, 0, None), None, + None, |span| [span].into_iter().collect(), &Delivered::default(), &drift, @@ -1128,6 +1149,7 @@ mod tests { &state, (0, 0, None), None, + None, |_| panic!("the edge has not moved"), &Delivered::default(), &drift, @@ -1153,6 +1175,7 @@ mod tests { &state, (0, 0, None), None, + None, |_| panic!("recreation extends no span"), &Delivered::default(), &drift, @@ -1224,6 +1247,7 @@ mod tests { &state, (0, 0, None), None, + None, |span| [span].into_iter().collect(), &Delivered::default(), &drift, @@ -1258,6 +1282,7 @@ mod tests { &state, (0, 0, None), None, + None, |_| panic!("the edge has not moved"), &Delivered::default(), &drift, @@ -1295,6 +1320,7 @@ mod tests { &state, (0, 0, None), None, + None, |span| [span].into_iter().collect(), &Delivered::default(), &drift, @@ -1311,6 +1337,7 @@ mod tests { &state, (0, 0, None), None, + None, |_| panic!("an unchanged probe rebuilds no span"), &Delivered::default(), &drift, @@ -1332,6 +1359,7 @@ mod tests { &state, (0, 0, None), None, + None, |_| panic!("a late arrival extends no span"), &Delivered::default(), &drift, @@ -1349,6 +1377,7 @@ mod tests { &state, (0, 0, None), None, + None, |_| panic!("a due gap extends no span"), &Delivered::default(), &drift, @@ -1391,6 +1420,7 @@ mod tests { &state, (0, 0, None), None, + None, full, &Delivered::default(), &drift, @@ -1412,6 +1442,7 @@ mod tests { &state, (0, 0, None), None, + None, |_| panic!("nothing deferred: the new grace waits for a change"), &Delivered::default(), &drift, @@ -1435,6 +1466,7 @@ mod tests { &state, (0, 0, None), None, + None, full, &Delivered::default(), &drift, diff --git a/rs/moq-net/tests/integration/fetch_only.rs b/rs/moq-net/tests/integration/fetch_only.rs index 7f5b0f6715b..89a735d04e3 100644 --- a/rs/moq-net/tests/integration/fetch_only.rs +++ b/rs/moq-net/tests/integration/fetch_only.rs @@ -204,3 +204,119 @@ async fn a_relay_fetches_without_subscribing_upstream() { } assert!(failures.is_empty(), "failed: {failures:?}"); } + +/// Subscribe to group 1 alone: history below the Largest Object a publisher of +/// [`publish_finished`] reports. Our moq-transport publishers answer only a one-group FETCH. +async fn subscribe_history(track: &moq_net::track::Consumer) -> moq_net::track::Subscriber { + let subscription = moq_net::track::Subscription::default() + .with_order(moq_net::track::Order::Ascending) + .with_range(1..2); + kio::clock::timeout(TIMEOUT, track.subscribe(subscription)) + .await + .expect("subscribe timed out") + .expect("subscribe") +} + +/// The next group `sub` hands out, read whole. +async fn next_frames(sub: &mut moq_net::track::Subscriber) -> Option>> { + let mut group = kio::clock::timeout(TIMEOUT, sub.recv_group()) + .await + .expect("recv timed out") + .expect("recv group")?; + let mut frames = Vec::new(); + while let Some(frame) = kio::clock::timeout(TIMEOUT, group.read_frame()) + .await + .expect("read timed out") + .expect("read frame") + { + frames.push(frame.payload.to_vec()); + } + Some(frames) +} + +async fn bounded_direct(version: &str) { + let version: Version = version.parse().unwrap(); + let publisher = produce_origin(1); + let (broadcast, subscribed) = publish_finished(&publisher); + + let client = produce_origin(2); + let mut options = MockConnectOptions::new(version); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(client.clone()); + let pair = connect_mock(options).await; + + let remote = resolve(&client).await; + let copy = remote.track("video").unwrap(); + copy.info().await.expect("TRACK_STATUS resolves the track"); + // A front's bounded read still parks past its end, as each copy's reader does, so this + // reads the group and stops there. + let mut sub = subscribe_history(©).await; + assert_eq!(next_frames(&mut sub).await, Some(frames(1)), "{version}"); + assert!( + !subscribed.load(Ordering::SeqCst), + "{version}: the bounded read subscribed" + ); + + drop((sub, copy, remote, pair, broadcast, client, publisher)); +} + +/// A copy the session holds no subscription for is unfed: a bounded read of the history +/// below the publisher's Largest Object fetches its holes at once, never subscribing. +#[kio::test] +async fn a_bounded_read_of_history_fetches_without_subscribing() { + let mut failures = Vec::new(); + for version in VERSIONS { + if kio::clock::sim::spawn(bounded_direct(version)).await.is_err() { + failures.push(*version); + } + } + assert!(failures.is_empty(), "failed: {failures:?}"); +} + +async fn bounded_relayed(version: &str) { + let version: Version = version.parse().unwrap(); + let publisher = produce_origin(1); + let (broadcast, subscribed) = publish_finished(&publisher); + + let relay = produce_origin(2); + let mut options = MockConnectOptions::new(version); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(relay.clone()); + let upstream = connect_mock(options).await; + + let client = produce_origin(3); + let mut options = MockConnectOptions::new(version); + options.server_publish = Some(relay.consume()); + options.client_subscribe = Some(client.clone()); + let downstream = connect_mock(options).await; + + // A fetch leaves the relay holding a copy no subscription feeds. + let remote = resolve(&client).await; + assert_eq!(fetch(&remote, 2).await.expect("fetch"), frames(2), "{version}"); + + let copy = remote.track("video").unwrap(); + let mut sub = subscribe_history(©).await; + assert_eq!(next_frames(&mut sub).await, Some(frames(1)), "{version}"); + assert_eq!(next_frames(&mut sub).await, None, "{version}: the read ends"); + assert!( + !subscribed.load(Ordering::SeqCst), + "{version}: the relay subscribed upstream" + ); + + drop(( + sub, copy, remote, downstream, upstream, broadcast, client, relay, publisher, + )); +} + +/// A relay serves a bounded subscription to its fetch-only copy from the fills it asks +/// upstream, never subscribing upstream. +#[kio::test] +async fn a_relay_serves_a_bounded_read_of_history_without_subscribing_upstream() { + let mut failures = Vec::new(); + for version in VERSIONS { + if kio::clock::sim::spawn(bounded_relayed(version)).await.is_err() { + failures.push(*version); + } + } + assert!(failures.is_empty(), "failed: {failures:?}"); +} From e8cb170fbe1353f0e7aafe23f5f62f84ba14b878 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:06:28 +0000 Subject: [PATCH 57/86] feat(watch): run a min-delay and a max-delay decoder for warmup renditions (#7555) Co-authored-by: Claude Opus 5.5 --- doc/lib/js/watch.md | 16 + js/watch/src/video/decoder.test.ts | 633 +++++++++++++++++++++- js/watch/src/video/decoder.ts | 370 +++++++++++-- quest/m2/intra-refresh/README.md | 2 +- quest/m2/intra-refresh/consumer-warmup.md | 5 +- quest/m2/intra-refresh/dual-decoder.md | 108 +--- 6 files changed, 1003 insertions(+), 131 deletions(-) diff --git a/doc/lib/js/watch.md b/doc/lib/js/watch.md index 38ed468af74..465f73dda1e 100644 --- a/doc/lib/js/watch.md +++ b/doc/lib/js/watch.md @@ -53,6 +53,22 @@ The failing track closes its subscription and stops decoding. Select another source or rendition to clear the failure; the player does not resubscribe to broken media automatically. +A video rendition encoded with intra refresh has no keyframes, and its catalog +`warmup` says how long a decoder takes to clean up after joining. The player +decodes such a rendition twice: one decoder holds your delay and may show +refresh stripes after a join or a skip, while a second starts one group earlier +and tolerates `warmup` more lateness, so its picture is clean. The clean +picture shows whenever it has caught up. Each decoder costs its own CPU and +decoder memory, and both share one subscription. A rendition without `warmup` +uses one decoder. + +An H.264 group of such a rendition opens on a recovery point that is not an +IDR. Chromium's WebCodecs refuses to open a decoder on that picture: it wants +an IDR or a recovery point that counts zero frames. So when a decoder opens on +one, the player inserts a second recovery point with a zero count before its +first slice. The slices are untouched; the stripes clear as the sweep +completes. + `el.broadcast.out.status` is `offline`, `loading`, `live`, `error`, or `unsupported`. On `unsupported`, update the player to read the broadcast's newer catalog; the overlay shows this notice. On `error` the origin refused the broadcast, `el.broadcast.out.error` says why, diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index e97a3b77f49..fa32a7cd087 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -9,7 +9,7 @@ import { ringSamples } from "../audio/latency"; import { AudioRingBuffer } from "../audio/ring-buffer"; import type { Broadcast } from "../broadcast"; import { type Delay, Sync } from "../sync"; -import { Decoder } from "./decoder"; +import { Decoder, insertRecoveryPoint } from "./decoder"; import { Source } from "./source"; const flush = () => yieldNow(); @@ -165,15 +165,18 @@ class Picture { displayWidth = 16; displayHeight = 16; readonly timestamp: number; + // The index of the codec that decoded it, in creation order, or -1 for one a test made. + readonly codec: number; readonly #pictures: Picture[]; - constructor(timestamp: number, pictures: Picture[]) { + constructor(timestamp: number, pictures: Picture[], codec = -1) { this.#pictures = pictures; pictures.push(this); this.timestamp = timestamp; + this.codec = codec; } clone() { if (this.closed) throw new Error("clone of a closed frame"); - return new Picture(this.timestamp, this.#pictures); + return new Picture(this.timestamp, this.#pictures, this.codec); } close() { this.closed = true; @@ -188,34 +191,72 @@ function manualAt(ms: number) { clock.jump(Nano.fromMilli(Milli(ms))); return { advance: (by: number) => clock.advance(Nano.fromMilli(Milli(by))), installed: clock.install() }; } -function sample(group: number, timestamp: number, keyframe = true): Read { +function sample(group: number, timestamp: number, keyframe = true, payload: Uint8Array = new Uint8Array([1])): Read { return { kind: "frame", group, frame: { arrival: Nano.toMilli(Nano(now())), timestamp: Micro(timestamp), - payload: new Uint8Array([1]), + payload, keyframe, }, }; } const gap: Read = { kind: "gap", timestamp: Micro(0), duration: Micro(0) }; +// One read queue per container consumer, in the order each first reads. An empty queue waits for +// `push` rather than ending the track. +class Feeds { + readonly #queues: Read[][] = []; + readonly #owners = new Map(); + #wake: (() => void)[] = []; + + push(index: number, ...reads: Read[]) { + while (this.#queues.length <= index) this.#queues.push([]); + this.#queues[index].push(...reads); + for (const wake of this.#wake.splice(0)) wake(); + } + + async next(consumer: Container.Consumer): Promise { + let index = this.#owners.get(consumer); + if (index === undefined) { + index = this.#owners.size; + this.#owners.set(consumer, index); + } + for (;;) { + const read = this.#queues[index]?.shift(); + if (read) return read; + await new Promise((resolve) => this.#wake.push(resolve)); + } + } + + // Release every waiting read, ending its track. + close() { + this.#queues.length = 0; + for (const wake of this.#wake.splice(0)) wake(); + } +} + // `reads` stands in for the container consumer, unless `live`, which reads what tests publish into -// `producer` through real consumers instead. +// `producer` through real consumers instead, or `feeds`, which gives each consumer its own queue. +// `fields` add to the rendition's catalog entry, and `initial` is the delay the decoder subscribes with. async function guardedPlayback( kind: "legacy" | "cmaf", reads: Read[], hold: readonly number[] = [], live = false, buffer = 0, + { fields = {}, initial, feeds }: { fields?: Partial; initial?: Delay; feeds?: Feeds } = {}, ) { // Load the optional container before the fixture drains only reactive microtasks. if (kind === "cmaf") await import("@moq/hang/cmaf"); const originalDecoder = Object.getOwnPropertyDescriptor(globalThis, "VideoDecoder"); const originalChunk = Object.getOwnPropertyDescriptor(globalThis, "EncodedVideoChunk"); const submitted: number[] = []; + // What each codec, in creation order, was given, and the bytes of each chunk. + const decoded: number[][] = []; + const payloads: Uint8Array[][] = []; const outputs: number[] = []; const pictures: Picture[] = []; const owed = new Set(hold); @@ -225,7 +266,10 @@ async function guardedPlayback( const frames: VideoDecoderInit["output"][] = []; const configures: VideoDecoderConfig[] = []; // A read that is an Error stands in for a malformed container read. - const next = spyOn(Container.Consumer.prototype, "next").mockImplementation(async () => { + const next = spyOn(Container.Consumer.prototype, "next").mockImplementation(async function ( + this: Container.Consumer, + ) { + if (feeds) return feeds.next(this); const read = reads.shift() as Read | Error | undefined; if (read instanceof Error) throw read; return read; @@ -236,8 +280,11 @@ async function guardedPlayback( // Like WebCodecs, a configure requires a key chunk before any delta. #keyRequired = true; readonly callbacks: VideoDecoderInit; + readonly index = errors.length; constructor(callbacks: VideoDecoderInit) { this.callbacks = callbacks; + decoded.push([]); + payloads.push([]); errors.push(callbacks.error); frames.push(callbacks.output); } @@ -254,9 +301,11 @@ async function guardedPlayback( if (this.#keyRequired && chunk.type !== "key") throw new Error("a key chunk is required"); this.#keyRequired = false; submitted.push(chunk.timestamp); + decoded[this.index].push(chunk.timestamp); + payloads[this.index].push((chunk as unknown as { data: Uint8Array }).data); const emit = () => { outputs.push(chunk.timestamp); - const picture = new Picture(chunk.timestamp, pictures); + const picture = new Picture(chunk.timestamp, pictures, this.index); void this.callbacks.output(picture as unknown as VideoFrame); }; if (owed.delete(chunk.timestamp)) { @@ -282,13 +331,20 @@ async function guardedPlayback( value: class { timestamp: number; type: EncodedVideoChunkType; + data: Uint8Array; constructor(init: EncodedVideoChunkInit) { this.timestamp = init.timestamp; this.type = init.type; + this.data = new Uint8Array(init.data as Uint8Array); } }, }); - const video = config({ codedWidth: 16, codedHeight: 16, description: "01640028ffe100046764002801000268ee" }); + const video = config({ + codedWidth: 16, + codedHeight: 16, + description: "01640028ffe100046764002801000268ee", + ...fields, + }); if (kind === "cmaf") video.container = { kind, @@ -313,7 +369,7 @@ async function guardedPlayback( mediaProducer.close(); }; const source = new Source({ broadcast, supported: async () => true }); - const delay = new Signal(buffer ? Milli(100) : "instant"); + const delay = new Signal(initial ?? (buffer ? Milli(100) : "instant")); const sync = new Sync({ delay, buffer: Milli(buffer) }); const enabled = new Signal(true); // Published before the decoder subscribes, since a broadcast with no request handler refuses @@ -326,7 +382,9 @@ async function guardedPlayback( delay, fail: (error: DOMException, index = -1) => errors.at(index)?.(error), emit: (timestamp: number, index = -1) => - frames.at(index)?.(new Picture(timestamp, pictures) as unknown as VideoFrame), + frames.at(index)?.( + new Picture(timestamp, pictures, (index + frames.length) % frames.length) as unknown as VideoFrame, + ), get codecs() { return errors.length; }, @@ -342,6 +400,8 @@ async function guardedPlayback( }, track, submitted, + decoded, + payloads, outputs, pictures, sync, @@ -383,6 +443,7 @@ async function guardedPlayback( source.close(); sync.close(); closeAll(); + feeds?.close(); next.mockRestore(); for (const [name, original] of [ ["VideoDecoder", originalDecoder], @@ -1347,3 +1408,553 @@ for (const kind of ["legacy", "cmaf"] as const) { } }); } + +describe("Decoder with a warmup rendition", () => { + // One refresh sweep, which is one group. + const WARMUP = Catalog.u53(300); + // The codecs in creation order: the min-delay decoder opens first. + const MIN = 0; + const MAX = 1; + + // A frame of `group` at `ms`, a recovery point when `key`. + const at = (group: number, ms: number, key = false) => sample(group, ms * 1000, key); + + // A warmup rendition whose two container consumers each read their own feed, presenting at once. + // `hold` keeps those timestamps in the codec until `release`. + async function refresh(fields: Partial = { warmup: WARMUP }, hold: number[] = []) { + const feeds = new Feeds(); + const playback = await guardedPlayback("legacy", [], hold, false, 0, { fields, feeds }); + // Every picture shown, as [ms, codec]. + const shown: [number, number][] = []; + const watch = new Effect(); + watch.subscribe(playback.decoder.out.frame, (frame) => { + const picture = frame as unknown as Picture | undefined; + if (picture) shown.push([picture.timestamp / 1000, picture.codec]); + }); + const current = () => { + const picture = playback.decoder.out.frame.peek() as unknown as Picture | undefined; + return picture && [picture.timestamp / 1000, picture.codec]; + }; + const push = async (lane: number, ...reads: Read[]) => { + feeds.push(lane, ...reads); + await microtasks(); + }; + return { + playback, + shown, + current, + push, + async close() { + watch.close(); + playback.close(); + await microtasks(); + expect(playback.pictures.filter((frame) => !frame.closed)).toEqual([]); + }, + }; + } + + function ascending(shown: [number, number][]) { + for (let i = 1; i < shown.length; i++) { + expect(shown[i][0]).toBeGreaterThanOrEqual(shown[i - 1][0]); + } + } + + it("opens one subscription and one decoder without a warmup", async () => { + for (const [fields, decoders] of [ + [{}, 1], + [{ warmup: Catalog.u53(0) }, 1], + [{ warmup: WARMUP }, 2], + ] as const) { + const subscribe = spyOn(Moq.Track.Consumer.prototype, "subscribe"); + const run = await refresh(fields); + try { + expect(run.playback.codecs).toBe(decoders); + const budgets = subscribe.mock.calls.map(([options]) => options?.maxDelay); + // The max-delay decoder tolerates one sweep more; js/net merges the two upstream. + expect(budgets).toEqual(decoders === 1 ? [Milli(0)] : [Milli(0), Milli(WARMUP)]); + } finally { + subscribe.mockRestore(); + await run.close(); + } + } + }); + + it("shows the min-delay picture on a cold join, then the max-delay one once it catches up", async () => { + const run = await refresh(); + try { + expect(run.playback.codecs).toBe(2); + + // The max-delay decoder joined a group earlier. Before the min-delay decoder anchors the + // clock, its pictures have no place on it. + await run.push(MAX, at(9, 700, true), at(9, 733)); + expect(run.current()).toBeUndefined(); + + // The min-delay decoder's picture shows at once, stripes and all. + await run.push(MIN, at(10, 1000, true)); + expect(run.current()).toEqual([1000, MIN]); + + // Pictures behind it never win, even though they are clean. + await run.push(MAX, at(9, 966)); + expect(run.current()).toEqual([1000, MIN]); + + // Caught up: the max-delay picture wins the tie, and keeps the lead. + await run.push(MAX, at(10, 1000, true)); + expect(run.current()).toEqual([1000, MAX]); + await run.push(MIN, at(10, 1033)); + await run.push(MAX, at(10, 1033)); + expect(run.current()).toEqual([1033, MAX]); + ascending(run.shown); + } finally { + await run.close(); + } + }); + + it("keeps the max-delay picture through a stall shorter than one sweep", async () => { + const run = await refresh(); + try { + for (const lane of [MIN, MAX]) await run.push(lane, at(10, 1000, true), at(10, 1033)); + expect(run.current()).toEqual([1033, MAX]); + + // Group 10 stalls. The min-delay decoder skips to group 11 and shows its stripes. + await run.push(MIN, gap, at(11, 1300, true)); + expect(run.current()).toEqual([1300, MIN]); + + // The max-delay decoder tolerates the stall: it gets the rest of group 10, decodes it + // behind the playhead, and takes over again once it reaches the min-delay picture. + await run.push(MAX, at(10, 1066), at(10, 1100), at(10, 1266)); + expect(run.current()).toEqual([1300, MIN]); + await run.push(MAX, at(11, 1300, true)); + expect(run.current()).toEqual([1300, MAX]); + await run.push(MIN, at(11, 1333)); + await run.push(MAX, at(11, 1333)); + expect(run.current()).toEqual([1333, MAX]); + + // Only the min-delay codec restarted at the skip. + expect(run.playback.resets).toBe(1); + expect(run.playback.decoded[MAX]).toEqual( + [1000, 1033, 1066, 1100, 1266, 1300, 1333].map((ms) => ms * 1000), + ); + ascending(run.shown); + } finally { + await run.close(); + } + }); + + it("skips both decoders on a stall longer than one sweep", async () => { + const run = await refresh(); + try { + for (const lane of [MIN, MAX]) await run.push(lane, at(10, 1000, true), at(10, 1033)); + + // Past the max-delay decoder's budget too: both skip and restart their codecs. + await run.push(MIN, gap, at(12, 1600, true)); + expect(run.current()).toEqual([1600, MIN]); + await run.push(MAX, gap, at(12, 1600, true)); + expect(run.current()).toEqual([1600, MAX]); + expect(run.playback.resets).toBe(2); + ascending(run.shown); + } finally { + await run.close(); + } + }); + + it("moves the shared clock only from the min-delay decoder", async () => { + const run = await refresh(); + const received = spyOn(run.playback.sync, "received"); + const reset = spyOn(run.playback.sync, "reset"); + try { + await run.push(MIN, at(10, 1000, true), at(10, 1033)); + await run.push(MAX, at(9, 700, true), at(9, 733), at(10, 1000, true), at(10, 1033)); + expect(received.mock.calls.map(([timestamp]) => timestamp)).toEqual([Milli(1000), Milli(1033)]); + expect(run.current()).toEqual([1033, MAX]); + + // The publisher restarts its timeline. The min-delay decoder resets the clock and shows the + // new timeline; the max-delay picture still on the old one never wins. + await run.push(MIN, marker(10, 1_066_000), at(11, 0, true)); + expect(reset).toHaveBeenCalledTimes(1); + expect(run.current()).toEqual([0, MIN]); + + // Once the max-delay decoder reads the restart too, it takes over again, without its own reset. + await run.push(MAX, marker(10, 1_066_000), at(11, 0, true)); + expect(reset).toHaveBeenCalledTimes(1); + expect(run.current()).toEqual([0, MAX]); + expect(received.mock.calls.map(([timestamp]) => timestamp)).toEqual([Milli(1000), Milli(1033), Milli(0)]); + } finally { + received.mockRestore(); + reset.mockRestore(); + await run.close(); + } + }); + + it("keeps a picture held from before a restart out of the new timeline", async () => { + const run = await refresh(undefined, [0]); + try { + for (const lane of [MIN, MAX]) await run.push(lane, at(10, 1000, true)); + expect(run.current()).toEqual([1000, MAX]); + + // The min-delay decoder reads a restart, but its first new picture is still in the codec. + // The picture it holds from the old timeline must not show, nor block the new one. + await run.push(MIN, marker(10, 1_066_000), at(11, 0, true)); + expect(run.current()).toEqual([1000, MAX]); + run.playback.release(); + await microtasks(); + expect(run.current()).toEqual([0, MIN]); + } finally { + await run.close(); + } + }); + + it("promotes a pending rendition once the outgoing one restarts its timeline", async () => { + const run = await refresh({}, [0]); + try { + await run.push(MIN, at(10, 1000, true)); + expect(run.current()).toEqual([1000, MIN]); + + // The outgoing rendition reads a restart whose first picture is still in the codec. It has + // presented nothing on the new timeline, so the replacement need not catch up to 1000. + await run.push(MIN, marker(10, 1_066_000), at(11, 0, true)); + expect(run.current()).toEqual([1000, MIN]); + run.playback.switchRendition(); + await microtasks(); + expect(run.playback.codecs).toBe(2); + // Emitted straight from the replacement codec, since decoding there would flush the held picture. + run.playback.emit(0, 1); + await microtasks(); + expect(run.current()).toEqual([0, 1]); + } finally { + await run.close(); + } + }); + + it("takes the max-delay picture when that decoder opens after a restart", async () => { + const run = await refresh({}); + try { + expect(run.playback.codecs).toBe(1); + await run.push(MIN, at(10, 1000, true), marker(10, 1_066_000), at(11, 0, true), at(11, 33)); + expect(run.current()).toEqual([33, MIN]); + + // The catalog gains a warmup. The new decoder never saw the restart, yet reads the same timeline. + run.playback.switchRendition("video", { warmup: WARMUP }); + await microtasks(); + expect(run.playback.codecs).toBe(2); + await run.push(MAX, at(11, 0, true), at(11, 33), at(11, 66)); + await run.push(MIN, at(11, 66)); + expect(run.current()).toEqual([66, MAX]); + } finally { + await run.close(); + } + }); + + it("never shows a lower timestamp across a 100ms trailing gap and a recovery boundary", async () => { + const run = await refresh(); + try { + await run.push(MIN, at(10, 1000, true)); + let min = 1000; + let max = 900; + for (let cycle = 0; cycle < 6; cycle++) { + // Trailing by 100ms, the max-delay decoder catches up at twice the frame rate. + while (max < min) { + min += 33; + run.playback.emit(min * 1000, MIN); + run.playback.emit(max * 1000, MAX); + max = Math.min(max + 66, min); + run.playback.emit(max * 1000, MAX); + await microtasks(); + } + // Level for a few frames, the max-delay picture winning each tie. + for (let i = 0; i < 3; i++) { + min += 33; + max = min; + run.playback.emit(min * 1000, MIN); + await microtasks(); + run.playback.emit(max * 1000, MAX); + await microtasks(); + expect(run.current()).toEqual([min, MAX]); + } + // A skip leaves it 100ms behind again. + min += 100; + } + expect(run.shown.some(([, codec]) => codec === MIN)).toBe(true); + ascending(run.shown); + } finally { + await run.close(); + } + }); + + it("hands the picture back when the max-delay decoder stops producing", async () => { + const run = await refresh(); + try { + for (const lane of [MIN, MAX]) await run.push(lane, at(10, 1000, true)); + expect(run.current()).toEqual([1000, MAX]); + + await run.push(MIN, at(10, 1033)); + expect(run.current()).toEqual([1033, MIN]); + await run.push(MIN, at(10, 1066)); + expect(run.current()).toEqual([1066, MIN]); + } finally { + await run.close(); + } + }); + + // Through real container consumers: the max-delay one reaches back one more sweep for its start. + it("joins the max-delay decoder one group earlier", async () => { + const clock = manualAt(5000); + const playback = await guardedPlayback("legacy", [], [], true, 0, { + fields: { warmup: WARMUP }, + initial: Milli(100), + }); + const track = playback.track as Moq.Track.Producer; + const write = (group: Moq.Group.Producer, from: number, to: number) => { + for (let ms = from; ms <= to; ms += 30) group.writeFrame(legacyFrame(Micro.fromMilli(Milli(ms)))); + }; + const flush = async () => { + await clock.advance(0); + await microtasks(); + }; + try { + // A relay serves a cold join newest first: the live group, then the sweep before it. + write(track.createGroup(5), 1500, 1620); + await flush(); + const live = track.createGroup(4); + write(live, 1200, 1470); + live.finish(); + await flush(); + + // The min-delay decoder starts at the live group, which covers its 100ms reach. The + // max-delay one reaches 400ms back, to the group before. + expect(playback.decoded[MIN][0]).toBe(1_500_000); + expect(playback.decoded[MAX][0]).toBe(1_200_000); + expect(playback.decoded[MAX]).toContain(1_620_000); + + // The poster is the min-delay picture. The burst moved the playhead to 1520, so by the + // time the rest comes due, the max-delay decoder has decoded it too, and its clean + // pictures win. + const shown = playback.decoder.out.frame.peek() as unknown as Picture; + expect([shown.timestamp, shown.codec]).toEqual([1_500_000, MIN]); + await clock.advance(10); + await microtasks(); + const due = playback.decoder.out.frame.peek() as unknown as Picture; + expect([due.timestamp, due.codec]).toEqual([1_530_000, MAX]); + } finally { + playback.close(); + await microtasks(); + clock.installed.close(); + } + }); + + // Through real container consumers, on a live track of 300ms groups at 30ms per frame, 100ms + // behind. Group 2 stalls after 660ms while group 3 keeps arriving, until `release`. + async function stall(release: number) { + const clock = manualAt(0); + const playback = await guardedPlayback("legacy", [], [], true, 0, { + fields: { warmup: WARMUP }, + initial: Milli(100), + }); + const track = playback.track as Moq.Track.Producer; + const shown: [number, number][] = []; + const watch = new Effect(); + watch.subscribe(playback.decoder.out.frame, (frame) => { + const picture = frame as unknown as Picture | undefined; + if (picture) shown.push([picture.timestamp / 1000, picture.codec]); + }); + const groups: Moq.Group.Producer[] = []; + const write = (ms: number) => { + const sequence = Math.floor(ms / 300); + groups[sequence] ??= track.createGroup(sequence); + groups[sequence].writeFrame(legacyFrame(Micro.fromMilli(Milli(ms)))); + if (ms % 300 === 270) groups[sequence].finish(); + }; + const stalled = (ms: number) => ms > 660 && ms < 900; + try { + for (let ms = 0; ms <= 1500; ms += 10) { + if (ms % 30 === 0 && !stalled(ms)) write(ms); + if (ms === release) for (let late = 690; late < 900; late += 30) write(late); + await clock.advance(10); + await microtasks(); + } + return { decoded: playback.decoded, resets: playback.resets, shown }; + } finally { + watch.close(); + playback.close(); + await microtasks(); + clock.installed.close(); + } + } + + it("keeps the max-delay decoder continuous through a live stall shorter than one sweep", async () => { + const { decoded, resets, shown } = await stall(1000); + const stalled = [690, 720, 750, 780, 810, 840, 870].map((ms) => ms * 1000); + // The min-delay decoder skipped the rest of group 2 and restarted at group 3. + for (const ms of stalled) expect(decoded[MIN]).not.toContain(ms); + expect(resets).toBe(1); + // The max-delay decoder waited for it. + for (const ms of stalled) expect(decoded[MAX]).toContain(ms); + // Stripes showed until the max-delay picture caught up, then it took over again. + expect(shown.some(([ms, codec]) => ms >= 900 && codec === MIN)).toBe(true); + expect(shown.at(-1)?.[1]).toBe(MAX); + ascending(shown); + }); + + it("skips both decoders on a live stall longer than one sweep", async () => { + const { decoded, resets, shown } = await stall(1400); + for (const lane of [MIN, MAX]) expect(decoded[lane]).not.toContain(690_000); + expect(resets).toBe(2); + expect(shown.at(-1)?.[1]).toBe(MAX); + ascending(shown); + }); + + it("holds a leading max-delay picture when that decoder closes", async () => { + const run = await refresh(); + try { + for (const lane of [MIN, MAX]) await run.push(lane, at(10, 1000, true)); + // The min-delay decoder is slower to deliver, so the max-delay one leads. + await run.push(MAX, at(10, 1033), at(10, 1066)); + expect(run.current()).toEqual([1066, MAX]); + + // The catalog drops its warmup. The min-delay picture behind does not replace the lead. + run.playback.switchRendition("video", { warmup: Catalog.u53(0) }); + await microtasks(); + expect(run.current()).toEqual([1066, MAX]); + await run.push(MIN, at(10, 1033)); + expect(run.current()).toEqual([1066, MAX]); + await run.push(MIN, at(10, 1066), at(10, 1100)); + expect(run.current()).toEqual([1100, MIN]); + ascending(run.shown); + } finally { + await run.close(); + } + }); +}); + +// H.264 NAL units, without their start code or length prefix. +const NAL = { + aud: [0x09, 0xf0], + sps: [0x67, 0x64, 0x00, 0x28], + pps: [0x68, 0xee], + // An SEI recovery point counting one 30-frame sweep, as an intra-refresh encoder writes it. + sweep: [0x06, 0x06, 0x02, 0x0f, 0x84, 0x80], + // The one the decoder inserts: recovery_frame_cnt = 0. + recovered: [0x06, 0x06, 0x01, 0x84, 0x80], + slice: [0x41, 0x9a, 0x02], + idr: [0x65, 0x88, 0x84], +}; + +// Length-prefixed with `size` bytes, as an avcC `description` declares. +function avcc(nals: number[][], size = 4): Uint8Array { + const out: number[] = []; + for (const nal of nals) { + for (let i = size - 1; i >= 0; i--) out.push((nal.length >> (8 * i)) & 0xff); + out.push(...nal); + } + return new Uint8Array(out); +} + +function annexB(nals: number[][], code = [0, 0, 0, 1]): Uint8Array { + return new Uint8Array(nals.flatMap((nal) => [...code, ...nal])); +} + +// avcC records whose lengthSizeMinusOne is 3 and 1. +const AVCC4 = new Uint8Array([0x01, 0x64, 0x00, 0x28, 0xff]); +const AVCC2 = new Uint8Array([0x01, 0x64, 0x00, 0x28, 0xfd]); + +describe("insertRecoveryPoint", () => { + it("inserts a zero-count recovery point before the first slice of a length-prefixed recovery point", () => { + const payload = avcc([NAL.sweep, NAL.slice, NAL.slice]); + expect(insertRecoveryPoint(payload, AVCC4)).toEqual(avcc([NAL.sweep, NAL.recovered, NAL.slice, NAL.slice])); + expect(insertRecoveryPoint(avcc([NAL.slice], 2), AVCC2)).toEqual(avcc([NAL.recovered, NAL.slice], 2)); + }); + + it("inserts it before the first slice of an Annex B recovery point", () => { + // The slice's 4-byte start code keeps its leading zero byte, which now leads the inserted unit. + expect(insertRecoveryPoint(annexB([NAL.aud, NAL.sweep, NAL.slice]))).toEqual( + new Uint8Array([...annexB([NAL.aud, NAL.sweep]), 0, ...annexB([NAL.recovered, NAL.slice], [0, 0, 1])]), + ); + const short = [0, 0, 1]; + expect(insertRecoveryPoint(annexB([NAL.sps, NAL.pps, NAL.slice], short))).toEqual( + annexB([NAL.sps, NAL.pps, NAL.recovered, NAL.slice], short), + ); + }); + + it("leaves an IDR, a payload without a slice, and a malformed payload unchanged", () => { + for (const [payload, description] of [ + [avcc([NAL.sps, NAL.pps, NAL.idr]), AVCC4], + [annexB([NAL.sps, NAL.pps, NAL.idr]), undefined], + [avcc([NAL.sps, NAL.pps]), AVCC4], + [annexB([NAL.sweep]), undefined], + // A length running past the payload, and a truncated description. + [avcc([NAL.slice]).subarray(0, 6), AVCC4], + [avcc([NAL.slice]), new Uint8Array([0x01])], + ] as const) { + expect(insertRecoveryPoint(payload, description)).toBe(payload); + } + }); +}); + +for (const kind of ["legacy", "cmaf"] as const) { + describe(`Decoder opening a ${kind} warmup rendition`, () => { + const MIN = 0; + const MAX = 1; + const recovery = avcc([NAL.sweep, NAL.slice]); + const patched = avcc([NAL.sweep, NAL.recovered, NAL.slice]); + const delta = avcc([NAL.slice]); + const idr = avcc([NAL.sps, NAL.pps, NAL.idr]); + const at = (group: number, ms: number, key: boolean, payload: Uint8Array) => + sample(group, ms * 1000, key, payload); + + async function open(fields: Partial) { + const feeds = new Feeds(); + const playback = await guardedPlayback(kind, [], [], false, 0, { fields, feeds }); + return { + playback, + push: async (lane: number, ...reads: Read[]) => { + feeds.push(lane, ...reads); + await microtasks(); + }, + }; + } + + it("rewrites only the recovery point that opens each codec", async () => { + const { playback, push } = await open({ warmup: Catalog.u53(300) }); + try { + expect(playback.codecs).toBe(2); + for (const lane of [MIN, MAX]) { + await push(lane, at(10, 1000, true, recovery), at(10, 1033, false, delta)); + // The next group continues the open codec, so Chromium does not check it. + await push(lane, at(11, 1300, true, recovery)); + } + for (const lane of [MIN, MAX]) expect(playback.payloads[lane]).toEqual([patched, delta, recovery]); + + // A skip resets the min-delay codec, so the group it reopens on is rewritten again. + await push(MIN, gap, at(13, 1900, true, recovery)); + expect(playback.resets).toBe(1); + expect(playback.payloads[MIN].at(-1)).toEqual(patched); + } finally { + playback.close(); + await microtasks(); + } + }); + + it("leaves an opening IDR unchanged", async () => { + const { playback, push } = await open({ warmup: Catalog.u53(300) }); + try { + for (const lane of [MIN, MAX]) await push(lane, at(10, 1000, true, idr)); + for (const lane of [MIN, MAX]) expect(playback.payloads[lane]).toEqual([idr]); + } finally { + playback.close(); + await microtasks(); + } + }); + + it("leaves a rendition without a warmup unchanged", async () => { + const { playback, push } = await open({}); + try { + expect(playback.codecs).toBe(1); + await push(MIN, at(10, 1000, true, recovery), gap, at(13, 1900, true, recovery)); + expect(playback.resets).toBe(1); + expect(playback.payloads[MIN]).toEqual([recovery, recovery]); + } finally { + playback.close(); + await microtasks(); + } + }); + }); +} diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index 0ca3ddda970..96bddd79f7a 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -113,7 +113,7 @@ export class Decoder { readonly out = readonlys(this.#out); // The current track running, held so we can cancel it when the new track is ready. - #active = new Signal(undefined); + #active = new Signal(undefined); // The rendition preparing to replace the active one, with the catalog of the broadcast it // subscribed to. One value so a later source that reuses the track name still notifies: // the name alone would compare equal, and the jitter effect would keep the previous catalog. @@ -126,7 +126,7 @@ export class Decoder { readonly #identity: Computed; #signals = new Effect(); - #selected?: DecoderTrack; + #selected?: Rendition; #failed?: Failure; #clearCurrentFrame(): void { @@ -259,10 +259,10 @@ export class Decoder { // Only a rendition read from the same path continues the outgoing arrival estimate: another // path's arrivals have nothing to do with what this one learned. const outgoing = this.#active.peek(); - const predecessor = outgoing?.broadcast.path === active.path ? outgoing.consumer : undefined; + const predecessor = outgoing?.broadcast.path === active.path ? outgoing : undefined; // Start a new pending effect. - let pending: DecoderTrack | undefined = new DecoderTrack({ + let pending: Rendition | undefined = new Rendition({ sync: this.sync, broadcast: active, track, @@ -357,7 +357,7 @@ export class Decoder { effect.proxy(this.#spread, active.spread); } - #recordError(track: DecoderTrack, error: Error): void { + #recordError(track: Rendition, error: Error): void { // An outgoing failure must not replace the selected rendition's failure latch. if (track !== this.#selected && this.#selected?.error.peek()) return; this.#failed = { @@ -418,6 +418,12 @@ export class Decoder { interface DecoderTrackProps { sync: Sync; + // Set for the max-delay decoder of a rendition with a catalog `warmup`: its start and its + // lateness budget reach this much further, and it leaves the shared clock and stats alone. + warmup?: Getter; + // Whether the rendition has a catalog `warmup`, so its groups may open on a recovery point that + // is not an IDR. Set for both decoders. + refresh?: Getter; broadcast: Moq.Broadcast.Consumer; // The rendition ID; `name` is its track's name in `broadcast`. track: string; @@ -431,7 +437,8 @@ interface DecoderTrackProps { // arrival estimate it continues. predecessor?: Container.Consumer; - stats: Signal; + // Absent for the max-delay decoder: the bytes arrive once and the min-delay decoder counts them. + stats?: Signal; } class DecoderTrack { @@ -443,10 +450,33 @@ class DecoderTrack { config: DecoderConfig; identity: string; catalog: Getter; - stats: Signal; + stats?: Signal; timestamp = new Signal(undefined); frame = new Signal(undefined); + // The group the current `frame` was read from. Group sequences are shared by every subscription to + // the track, so they place a picture on a timeline for either decoder. + group?: number; + // The first group of the timeline this decoder reads, undefined until a publisher restart. + readonly start = new Signal(undefined); + + // Counts the timeline restarts this decoder read. A picture decoded before one is from the old + // timeline and never presents after it, even when its clock wait already resolved. + #generation = 0; + // The group each frame in the codec was read from, by timestamp. Each output found here retires its + // entry and every one before it. A publisher that never outputs a picture grows this by one entry + // per chunk; the maintainer accepted that as an exception to the bounded-state rule + // (moq-dev/moq#7555, 2026-11-01), since WebCodecs bounds no codec's pending output, so any cap is + // a guess that can evict a picture the codec still owes. + #groups = new Map(); + + // How far past the shared clock's budgets this decoder reaches. + #warmup: Getter; + // Whether this decoder drives the shared clock: only the min-delay one reports arrivals and + // restarts the timeline. + #drives: boolean; + #refresh: Getter; + // Network jitter + decode buffer. buffered = new Signal([]); // First decoded picture on this timeline, independent of the previously held poster. @@ -460,6 +490,8 @@ class DecoderTrack { // Codec description from the last configure, reused after a playhead reset. #description?: Uint8Array; + // Whether the next chunk opens the codec, after a configure or a reset. + #opening = false; // A hole or timeline restart makes the next decoded frame decide whether the codec resets. #resetPending = false; @@ -482,6 +514,9 @@ class DecoderTrack { this.identity = props.identity; this.catalog = props.catalog; this.stats = props.stats; + this.#warmup = props.warmup ?? getter(Milli.zero); + this.#drives = props.warmup === undefined; + this.#refresh = props.refresh ?? getter(false); this.#predecessor = props.predecessor; this.#signals.run(this.#run.bind(this)); @@ -490,10 +525,16 @@ class DecoderTrack { // Reorders groups and frames up to the shared max delay. A rendition switch is not a cold start: // the path's arrivals are what they were, so the estimate continues from whatever read before. #consume(effect: Effect, sub: Moq.Track.Subscriber, format: Container.Format): Container.Consumer { + // The playout delay is where a cold join starts, and the shared max delay how late it may fall. + // The max-delay decoder adds the warmup to both, so it starts at the group before and keeps + // it, as Rust's does, rather than passing the consumer a `warmup` that would reach further. + const delay = new Derived([this.sync.out.delay, this.sync.out.maxDelay, this.#warmup], (min, max, warmup) => ({ + min: Milli(min + warmup), + max: Milli(max + warmup), + })); const consumer = new Container.Consumer(sub, { format, - // The playout delay is where a cold join starts, and the shared max delay how late it may fall. - delay: new Derived([this.sync.out.delay, this.sync.out.maxDelay], (min, max) => ({ min, max })), + delay, predecessor: this.consumer ?? this.#predecessor, }); effect.cleanup(() => consumer.close()); @@ -507,7 +548,8 @@ class DecoderTrack { broadcast: this.broadcast, track: this.name, priority: Catalog.PRIORITY.video, - maxDelay: this.sync.out.maxDelay, + // js/net merges both decoders' subscriptions into one request at the larger max delay. + maxDelay: new Derived([this.sync.out.maxDelay, this.#warmup], (max, warmup) => Milli(max + warmup)), }); if (!sub) return; @@ -519,6 +561,17 @@ class DecoderTrack { // A cancelled codec must not recreate the poster its track already released. Firefox can // still deliver output after close(). if (signal.aborted) return; + const generation = this.#generation; + const group = this.#groups.get(frame.timestamp); + // Pictures come out in presentation order, so a chunk at or before this one that + // produced none, such as a hidden VP9 frame, never will. Only a picture with an entry + // proves that: one without was decoded before the last reset cleared the map, so its + // timestamp says nothing about the chunks fed since. + if (group !== undefined) { + for (const timestamp of this.#groups.keys()) { + if (timestamp <= frame.timestamp) this.#groups.delete(timestamp); + } + } const timestamp = Milli.fromMicro(frame.timestamp as Micro); if (timestamp < (this.timestamp.peek() ?? 0)) { // Late frame, don't render it. @@ -529,13 +582,18 @@ class DecoderTrack { // Render something while we wait for the sync to catch up, unless the frame is // already off the timeline: WebCodecs can hold a picture past a reset or a skip. - if (this.frame.peek() === undefined && this.sync.presentable(timestamp)) { + // Only the min-delay decoder posts one: the max-delay picture shows on the clock. + if (this.#drives && this.frame.peek() === undefined && this.sync.presentable(timestamp)) { + this.group = group; this.frame.set(frame.clone()); } // Resolves at once when the delay is "instant". Closing the track releases it. // Check the captured signal again in case cancellation followed the resolved wait. if (!(await this.sync.wait(timestamp, signal)) || signal.aborted) return; + // The max-delay decoder restarts its timeline after the clock did, so the clock's own + // epoch cannot tell its leftover pictures apart. + if (this.#generation !== generation) return; if (timestamp < (this.timestamp.peek() ?? 0)) { // Late frame, don't render it. @@ -548,6 +606,7 @@ class DecoderTrack { // Trim the decode buffer as frames are rendered this.#trimBuffered(timestamp); + this.group = group; this.frame.update((prev) => { prev?.close(); return frame.clone(); // avoid closing the frame here @@ -629,20 +688,15 @@ class DecoderTrack { // resets this guard. Rejected payloads still count as received bytes. const stale = latest !== undefined && next.group < latest; const accepted = !stale && recovery.accept(frame); - this.stats.update((current) => ({ + this.stats?.update((current) => ({ frameCount: (current?.frameCount ?? 0) + (accepted ? 1 : 0), bytesReceived: (current?.bytesReceived ?? 0) + frame.payload.byteLength, })); if (!accepted) continue; - // Classify ingress before decoder backpressure changes the delivery clock. - this.sync.received(Milli.fromMicro(frame.timestamp as Micro), frame.arrival); - - const chunk = new EncodedVideoChunk({ - type: frame.keyframe ? "key" : "delta", - data: frame.payload, - timestamp: frame.timestamp, - }); + // Classify ingress before decoder backpressure changes the delivery clock. Only the + // min-delay decoder moves the clock: the other reads frames already behind it. + if (this.#drives) this.sync.received(Milli.fromMicro(frame.timestamp as Micro), frame.arrival); // Track decode buffer: frames sent to decoder but not yet rendered. Only bridge from // the previous frame when no gap or end came between them. Group ids can't answer that: @@ -657,7 +711,8 @@ class DecoderTrack { latest = next.group; this.#resetAtKeyframe(decoder, frame.keyframe); - decoder.decode(chunk); + this.#groups.set(frame.timestamp, next.group); + decoder.decode(this.#chunk(frame)); if (!this.sync.playing.peek()) await this.#waitStartup(effect, Milli.fromMicro(frame.timestamp as Micro)); } @@ -715,14 +770,14 @@ class DecoderTrack { // resets this guard. Rejected payloads still count as received bytes. const stale = latest !== undefined && next.group < latest; const accepted = !stale && recovery.accept(frame); - this.stats.update((current) => ({ + this.stats?.update((current) => ({ frameCount: (current?.frameCount ?? 0) + (accepted ? 1 : 0), bytesReceived: (current?.bytesReceived ?? 0) + frame.payload.byteLength, })); if (!accepted) continue; - // Classify ingress before decoder backpressure changes the delivery clock. - this.sync.received(Milli.fromMicro(frame.timestamp), frame.arrival); + // Classify ingress before decoder backpressure changes the delivery clock (see #runLegacy). + if (this.#drives) this.sync.received(Milli.fromMicro(frame.timestamp), frame.arrival); // Track decode buffer (see #runLegacy: bridge only when no gap or end came between, // never on group adjacency, which proves nothing about the timeline). @@ -735,13 +790,8 @@ class DecoderTrack { if (decoder.state === "closed") break; latest = next.group; this.#resetAtKeyframe(decoder, frame.keyframe); - decoder.decode( - new EncodedVideoChunk({ - type: frame.keyframe ? "key" : "delta", - data: frame.payload, - timestamp: frame.timestamp, - }), - ); + this.#groups.set(frame.timestamp, next.group); + decoder.decode(this.#chunk(frame)); if (!this.sync.playing.peek()) await this.#waitStartup(effect, Milli.fromMicro(frame.timestamp)); } }); @@ -788,17 +838,20 @@ class DecoderTrack { if (latest === undefined || read.group >= latest) this.#endGroup = read.group; } else if (this.#endGroup !== undefined && read.group > this.#endGroup) { this.#endGroup = undefined; - this.#onBreak(); + this.#onBreak(read.group); } } // A fresh timeline invalidates parked pictures and its previous decoded span. The held picture // stays until the new keyframe renders; the codec resets before that keyframe is decoded. - #onBreak(): void { + #onBreak(group: number): void { this.first.set(undefined); this.timestamp.set(undefined); this.#buffered.set([]); - this.sync.reset(); + this.#generation++; + this.start.set(group); + // The min-delay decoder restarts the shared clock. The max-delay one reads the same end later. + if (this.#drives) this.sync.reset(); this.#resetPending = true; } @@ -812,10 +865,24 @@ class DecoderTrack { // A closed decoder throws on reset. if (!keyframe || decoder.state === "closed") return; decoder.reset(); + this.#groups.clear(); this.#configure(decoder); } + // The chunk for `frame`, run through the opening rewrite when it opens the codec. + #chunk(frame: Container.ReceivedFrame): EncodedVideoChunk { + let data = frame.payload; + if (this.#opening) { + this.#opening = false; + if (frame.keyframe && this.#refresh.peek() && isH264(this.config.codec)) { + data = insertRecoveryPoint(data, this.#description); + } + } + return new EncodedVideoChunk({ type: frame.keyframe ? "key" : "delta", data, timestamp: frame.timestamp }); + } + #configure(decoder: VideoDecoder): void { + this.#opening = true; decoder.configure({ codec: this.config.codec, description: this.#description, @@ -870,6 +937,241 @@ class DecoderTrack { } } +/** Constructor properties for {@link Rendition}. */ +interface RenditionProps extends Omit { + stats: Signal; + // The rendition on screen when this one was selected from the same path, whose decoders' arrival + // estimates these continue. + predecessor?: Rendition; +} + +/** + * One rendition's playback: a min-delay decoder, plus a max-delay decoder while the catalog gives the + * rendition a nonzero `warmup` (intra refresh, so no keyframes). + * + * The min-delay decoder holds the app's latency and drives the shared clock, so it may show refresh + * stripes after a cold join or a skip. The max-delay decoder starts one group earlier and tolerates + * the warmup more lateness, so its pictures are clean, and it decodes what is already due as fast as + * it can without moving the clock. Its picture is shown whenever its timestamp is at or past the + * min-delay one's, and nothing shown ever steps back in time. Rust players do the same in + * `moq_video::decode::Consumer`. + */ +class Rendition { + readonly error = new Signal(undefined); + readonly broadcast: Moq.Broadcast.Consumer; + readonly track: string; + readonly config: DecoderConfig; + readonly identity: string; + readonly catalog: Getter; + + // The picture chosen between the two decoders, and its timestamp once it presented on the clock: + // undefined while it is only a poster. + readonly frame = new Signal(undefined); + readonly timestamp = new Signal(undefined); + + readonly min: DecoderTrack; + readonly max = new Signal(undefined); + + // The lane picture last shown, its timestamp, and the first group of the min-delay timeline it was + // shown on. Nothing below that timestamp shows again until the timeline restarts. + #shown?: VideoFrame; + #last?: Milli; + #start?: number; + + #signals = new Effect(); + + constructor(props: RenditionProps) { + this.broadcast = props.broadcast; + this.track = props.track; + this.config = props.config; + this.identity = props.identity; + this.catalog = props.catalog; + + // A missing or zero `warmup` keeps one decoder. The budgets follow a changed value without + // reopening the max-delay decoder. + const warmup = new Signal(Milli.zero); + const dual = new Signal(false); + + const predecessor = props.predecessor; + this.min = new DecoderTrack({ ...props, refresh: dual, predecessor: predecessor?.consumer }); + + this.#signals.run((effect) => { + const config = effect.get(props.catalog)?.video?.renditions?.[props.track]; + warmup.set(Milli(config?.warmup ?? 0)); + dual.set(warmup.peek() > 0); + }); + this.#signals.run((effect) => { + if (!effect.get(dual)) return; + const max = new DecoderTrack({ + ...props, + stats: undefined, + warmup, + refresh: dual, + predecessor: predecessor?.max.peek()?.consumer ?? predecessor?.consumer, + }); + effect.cleanup(() => max.close()); + effect.set(this.max, max); + }); + + // Either decoder failing fails the rendition, as an HEVC refresh frame sent as `key` does. + this.#signals.run((effect) => { + const max = effect.get(this.max); + const failed = effect.get(this.min.error) ?? (max && effect.get(max.error)); + if (failed) this.error.set(failed); + }); + + this.#signals.run((effect) => { + const max = effect.get(this.max); + this.#select( + { frame: effect.get(this.min.frame), lane: this.min }, + max && { frame: effect.get(max.frame), lane: max }, + effect.get(this.min.start), + ); + }); + } + + // The min-delay decoder's arrival estimate, which a replacement rendition continues. + get consumer(): Container.Consumer | undefined { + return this.min.consumer; + } + + // Startup reads the clock-driving decoder's first picture and its buffered media. + get first(): Getter { + return this.min.first; + } + + get buffered(): Getter { + return this.min.buffered; + } + + get spread(): Getter { + return this.min.spread; + } + + // Show the max-delay picture when it is at or past the min-delay one, so it wins ties, else the + // min-delay picture. Only pictures read from `start` on, the min-delay decoder's timeline, are + // candidates: one held from before a restart neither shows nor sets the floor. Not `caughtUp`: its + // slack, applied per picture, would alternate backwards and forwards. + #select(min: Lane, max: Lane | undefined, start: number | undefined): void { + // A restarted timeline begins below the previous one's pictures. Like a decoder's own timestamp + // on a break, the selection's clears until the new timeline presents, so a pending rendition is + // not held to the old timeline's playhead. + if (start !== this.#start) { + this.#start = start; + this.#last = undefined; + this.timestamp.set(undefined); + } + const current = (lane: Lane | undefined): lane is Lane & { frame: VideoFrame } => + lane?.frame !== undefined && (start === undefined || (lane.lane.group ?? -1) >= start); + // Even a clean max-delay picture is a sweep behind until the min-delay decoder has one. + if (!current(min)) return; + + let pick = min; + if (current(max) && max.frame.timestamp >= min.frame.timestamp) pick = max; + if (pick.frame === this.#shown) return; + + const timestamp = Milli.fromMicro(pick.frame.timestamp as Micro); + if (this.#last !== undefined && timestamp < this.#last) return; + this.#shown = pick.frame; + this.#last = timestamp; + + this.timestamp.set(pick.lane.timestamp.peek()); + this.frame.update((prev) => { + prev?.close(); + return pick.frame.clone(); + }); + } + + close(): void { + this.#signals.close(); + this.min.close(); + this.frame.update((prev) => { + prev?.close(); + return undefined; + }); + } +} + +// One decoder's current picture, for the selector. +interface Lane { + frame: VideoFrame | undefined; + lane: DecoderTrack; +} + +function isH264(codec: string): boolean { + return codec.startsWith("avc1.") || codec.startsWith("avc3."); +} + +// An SEI NAL unit (type 6) holding one recovery point message (payload type 6, one byte): +// recovery_frame_cnt = 0, exact_match_flag = 0, broken_link_flag = 0, changing_slice_group_idc = 0, +// then payload alignment and the RBSP trailing bits. It needs no emulation prevention. +const RECOVERY_SEI = new Uint8Array([0x06, 0x06, 0x01, 0x84, 0x80]); + +/** + * Insert an SEI recovery point with `recovery_frame_cnt = 0` before the first slice of an H.264 + * access unit that has no IDR, so a codec can open on it. `description` is the avcC record when the + * payload is length-prefixed, and absent for Annex B. An IDR, a payload without a slice, or one that + * does not parse comes back unchanged. + * + * Chromium's WebCodecs checks the chunk that opens a codec, after `configure()` or `reset()`: an + * H.264 chunk sent as `key` must hold an IDR, or an SEI recovery point with `recovery_frame_cnt == + * 0` (`AnalyzeAnnexB` in `media/formats/mp4/avc.cc`). Otherwise `decode()` throws "A key frame is + * required". An intra-refresh group opens on a recovery point that counts a whole sweep, so every + * join and skip of a `warmup` rendition would fail. The extra message declares that one picture + * recovered, which is what a `warmup` rendition already accepts: the min-delay decoder shows the + * refresh stripes until the sweep completes, and the max-delay decoder covers them. The slices + * are unchanged; the message goes right before the first one, after any SEI the stream carries. + * @internal + */ +export function insertRecoveryPoint(payload: Uint8Array, description?: Uint8Array): Uint8Array { + if (description) { + // lengthSizeMinusOne, in the low two bits of the avcC record's fifth byte. + if (description.byteLength < 5) return payload; + const size = (description[4] & 0x03) + 1; + for (let offset = 0; offset + size <= payload.byteLength; ) { + let length = 0; + for (let i = 0; i < size; i++) length = length * 256 + payload[offset + i]; + const start = offset + size; + if (length === 0 || start + length > payload.byteLength) return payload; + const type = payload[start] & 0x1f; + if (type === 5) return payload; + if (type >= 1 && type <= 4) { + const prefix = new Uint8Array(size); + for (let i = 0, n = RECOVERY_SEI.byteLength; i < size; i++, n = Math.floor(n / 256)) { + prefix[size - 1 - i] = n % 256; + } + return splice(payload, offset, prefix, RECOVERY_SEI); + } + offset = start + length; + } + return payload; + } + + // Annex B: each NAL unit follows a 00 00 01 start code, with an optional leading zero byte. + for (let i = 0; i + 3 < payload.byteLength; i++) { + if (payload[i] !== 0 || payload[i + 1] !== 0 || payload[i + 2] !== 1) continue; + const type = payload[i + 3] & 0x1f; + if (type === 5) return payload; + if (type >= 1 && type <= 4) return splice(payload, i, new Uint8Array([0, 0, 1]), RECOVERY_SEI); + i += 2; + } + return payload; +} + +// `payload` with `parts` inserted at `at`. +function splice(payload: Uint8Array, at: number, ...parts: Uint8Array[]): Uint8Array { + const length = parts.reduce((sum, part) => sum + part.byteLength, payload.byteLength); + const out = new Uint8Array(length); + out.set(payload.subarray(0, at), 0); + let offset = at; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + out.set(payload.subarray(at), offset); + return out; +} + // Chrome configures a hardware H.264 decoder that then fails the first interlaced picture: field // coding only shows in the SPS, and Chrome falls back to software only when configure() fails. function hardwareAcceleration(config: { interlaced?: boolean }): HardwareAcceleration | undefined { diff --git a/quest/m2/intra-refresh/README.md b/quest/m2/intra-refresh/README.md index 4030eb12dda..c921a1a8932 100644 --- a/quest/m2/intra-refresh/README.md +++ b/quest/m2/intra-refresh/README.md @@ -49,7 +49,7 @@ make these presentation guarantees true even when sweep lengths change. ## Required -- [Dual decoder](/quest/m2/intra-refresh/dual-decoder.md) - js/watch runs a min-delay and a max-delay decoder and shows the max-delay picture whenever it has caught up +- [Dual decoder](/quest/m2/intra-refresh/dual-decoder.md) - js/watch's two decoders open on an H.264 recovery point in Firefox and Safari, as they now do in Chromium - [Plan: recovery boundaries](/quest/m2/intra-refresh/plan-boundary.md) - settle publication ordering for a later, longer sweep - [Consumer warmup](/quest/m2/intra-refresh/consumer-warmup.md) - the transcoder decodes the groups covering `warmup` as history before each group, except at a true IDR - [H.264 import](/quest/m2/intra-refresh/h264-import.md) - the splitter keeps `recovery_frame_cnt` and import publishes `warmup` from it diff --git a/quest/m2/intra-refresh/consumer-warmup.md b/quest/m2/intra-refresh/consumer-warmup.md index 1ffafd36f0d..ea90f7bc11b 100644 --- a/quest/m2/intra-refresh/consumer-warmup.md +++ b/quest/m2/intra-refresh/consumer-warmup.md @@ -6,9 +6,8 @@ The transcoder, given a source rendition with `warmup` set, never encodes a picture its decoder has not fully recovered. Every group it transcodes, whether fetched on demand for a rung or read live after a skip, is preceded by enough earlier groups decoded as history that its first picture is clean. -Players stay live with stripes instead, via the browser -[dual decoder](/quest/m2/intra-refresh/dual-decoder.md) and the native -`moq_video::decode::Consumer` pair. +Players stay live with stripes instead, via the min-delay and max-delay +decoder pairs in `@moq/watch` and the native `moq_video::decode::Consumer`. Renditions without `warmup` behave exactly as today. ## Plan diff --git a/quest/m2/intra-refresh/dual-decoder.md b/quest/m2/intra-refresh/dual-decoder.md index 9ce0326e948..cb551299000 100644 --- a/quest/m2/intra-refresh/dual-decoder.md +++ b/quest/m2/intra-refresh/dual-decoder.md @@ -1,92 +1,36 @@ -# [M] Browser viewers stay live through refresh recovery with two decoders +# [S] Browser decoders join intra-refresh H.264 in every engine ## Goal -A js/watch viewer of a video rendition with `warmup` set never freezes or -waits for a refresh sweep. A min-delay decoder holds the app's latency and -may show refresh stripes right after a cold join or a skip. A max-delay -decoder tolerates one more sweep of delay, so it keeps continuity and its -picture is clean. Whenever both have a frame for the current playback time, -the max-delay picture is shown. Stripes appear only while the max-delay -decoder is catching up and both decoders are unrecovered. A rendition without `warmup` uses -one decoder exactly as today. +A js/watch viewer of an H.264 intra-refresh rendition (catalog `warmup` set) +joins and recovers from skips in Chromium, Firefox, and Safari. Each of its two +decoders (the min-delay and max-delay pair in `js/watch/src/video/decoder.ts`) +can open on a group's first frame, a recovery point that is not an IDR, and +decodes on from there, showing stripes until the sweep completes. ## Plan -Decided in the 2026-10-14 planning interview: +The two decoders, their selector, and the Chromium join landed with this +quest's first PR ([moq-dev/moq#7555](https://github.com/moq-dev/moq/pull/7555)). +What remains is checking the other engines. -- Replaces the freeze rule for players. Live with stripes is better than a - frozen picture. Rust players do the same in `moq_video::decode::Consumer` - (`rs/moq-video/src/decode/select.rs` holds its switch rule); only - the transcoder avoids stripes, by decoding history per - [consumer warmup](/quest/m2/intra-refresh/consumer-warmup.md). -- Budgets: the min-delay decoder skips at the app's latency target (today's - `sync.out.maxDelay`). The max-delay decoder skips at that target plus the - catalog `warmup`, which is one refresh sweep. Two decoders run, both for the - whole session, whenever the budgets differ, so `warmup` gates the feature. - A missing or zero `warmup` keeps one decoder. -- One playback clock. Only the min-delay decoder drives `Sync` (`received`, - `reset` on a break). The max-delay decoder decodes past-due frames as fast - as it can without moving the clock, so it doesn't raise latency. -- No withhold rule and no catalog precision dependency. The max-delay decoder - joins one group earlier, so the frames before its sweep completes are all - behind the playhead. They never win the timestamp comparison. A - rounded-down or stale `warmup` only shrinks its budget and costs extra - stripes, never a dirty frame the min-delay decoder wasn't also showing. - This quest therefore does not wait on - [plan-boundary](/quest/m2/intra-refresh/plan-boundary.md); the warmup draft - amendment it needed landed in [#6494](https://github.com/moq-dev/moq/pull/6494). -- Selection is a small frame selector over the two current candidates: show - the max-delay frame when its timestamp is at or beyond the min-delay frame's, - so it wins ties, else the min-delay frame. Separately, never display a - timestamp below the last one shown. A max-delay decoder that stops producing - therefore loses as soon as the min-delay one advances. Do not reuse `caughtUp` from - `js/watch/src/video/playhead.ts`: its 100 ms slack tolerates one backwards - step per rendition switch, and applied per frame it would alternate - backwards and forwards. Keep the pair per rendition, inside or beside - `DecoderTrack` in `js/watch/src/video/decoder.ts`, so rendition switching - still promotes a whole pair through the existing pending/active path. -- Each decoder opens its own subscription through `subscribeMedia` - (`js/watch/src/media.ts`); a `Container.Consumer` consumes its subscriber. - `js/net` dedups the two into one upstream request with the larger - `maxDelay` (`combineSubscriptions`, `js/net/src/track.ts`), so live groups - arrive once. The second decoder's only extra transfer is the earlier group it - joins on, up to one refresh group per cold join or gap. -- The max-delay decoder reads groups oldest first. Subscriptions default to - descending order and aggregate that way, so a cold join can activate the - newest group in `Container.Consumer` first, and `DecoderTrack` then drops - the earlier group as stale. That would leave the max-delay decoder starting - mid-sweep. Its consumer must wait for the lower group within its budget - before emitting a newer one. ✅ Decided 2026-10-30 (maintainer): a - `Container.Consumer` takes `delay: { min, max }` (ms) and starts at the - group covering the live edge less `min` (the playout delay) plus its - `warmup` prop (the catalog's name and units), waiting for it under that - reach. `max` (`null` for no limit) only bounds lateness and never moves the - start; a `min` past `max` starts at `max`. All default to zero, which - starts at the newest group at once. This decoder passes the playout delay - plus the catalog `warmup` as `min`, and the shared max delay plus the - `warmup` as `max`, so its start reaches the earlier group without the - `warmup` prop, as Rust's max-delay decoder does - ([moq-dev/moq#7311](https://github.com/moq-dev/moq/pull/7311)). -- H.264 recovery-point frames sent as `key` chunks pass Chromium's WebCodecs - keyframe check (`ParseH264KeyFrame` accepts `is_sei_recovery_point`; WPT - `videoDecoder-h264-sei`). Chromium rejects a non-IRAP H.265 frame sent as - `key`, so an HEVC refresh rendition fails through the decoder's existing - `error` signal; no special case. Firefox and Safari behavior is unknown: - check H.264 on both during verification. -- Tests in `js/watch/src/video/decoder.test.ts` with a synthetic `warmup` - catalog and track: a cold join shows the min-delay picture and then switches - to max-delay once it is caught up; a stall shorter than one sweep makes the - min-delay decoder skip while max-delay stays continuous and takes over again; - a stall longer than one sweep skips both; a rendition without `warmup` opens - one subscription and one decoder; only the min-delay decoder moves `Sync`; - a selector regression that repeatedly crosses a 100 ms trailing gap and a - recovery boundary never displays a lower timestamp; a max-delay decoder that - stops producing while the min-delay one continues hands the picture back on - the next min-delay frame. -- Measure the cost of always running two decoders: CPU, decoder memory, and - dropped frames for one versus two decoders on a 1080p rendition, in a - browser, recorded in the PR. +- Chromium (done, verified on 156): WebCodecs accepts an H.264 chunk that opens + a codec only when it is an IDR or carries an SEI recovery point with + `recovery_frame_cnt == 0` (`media/formats/mp4/avc.cc`, `AnalyzeAnnexB`). An + intra-refresh recovery point counts a whole sweep. + ✅ Decided 2026-11-01 (option A): on the chunk that opens a codec (after + `configure` or `reset`), when the rendition has `warmup` and the chunk is + H.264 with no IDR, `insertRecoveryPoint` in `decoder.ts` inserts an SEI + recovery point with a zero count before the first slice. Rejected: treating + H.264 `warmup` renditions as unsupported in Chromium (B), and asking Chromium + to accept a nonzero count (C), an outside party. +- Firefox and Safari: unverified. Playwright's Firefox build reports H.264 + unsupported and its WebKit build would not launch on the Linux host. Check + both with a real browser, joining at a non-IDR recovery point sent as `key`, + with and without the inserted recovery point. If one rejects the join, find + what its key check accepts. +- HEVC: Chromium rejects a non-IRAP H.265 frame sent as `key`, so an HEVC + refresh rendition fails through the decoder's existing `error` signal. ## Related From 46796195577860022adff0965e439bdaa286a4de Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:06:30 +0000 Subject: [PATCH 58/86] perf(net): copy small whole frames out of shared receive buffers (#7522) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + quest/m2/performance-survey/README.md | 1 - quest/m2/performance-survey/cache-backing.md | 34 -- rs/moq-net/Cargo.toml | 8 + rs/moq-net/benches/backing.rs | 436 +++++++++++++++++++ rs/moq-net/src/coding/reader.rs | 55 ++- rs/moq-net/src/fuzz.rs | 81 ++++ rs/moq-net/tests/integration/zero_copy.rs | 3 +- 8 files changed, 580 insertions(+), 39 deletions(-) delete mode 100644 quest/m2/performance-survey/cache-backing.md create mode 100644 rs/moq-net/benches/backing.rs diff --git a/Cargo.lock b/Cargo.lock index 79daca912f9..9eba9b2373e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5780,6 +5780,7 @@ dependencies = [ "arrayvec", "bench-harness", "bytes", + "crossbeam-epoch", "flate2", "futures", "hashbrown 0.17.1", diff --git a/quest/m2/performance-survey/README.md b/quest/m2/performance-survey/README.md index 70c09dbe0c1..665f07719c1 100644 --- a/quest/m2/performance-survey/README.md +++ b/quest/m2/performance-survey/README.md @@ -184,7 +184,6 @@ No duplicate quest or workflow edit belongs here. - [Task capacity after bursts](/quest/m2/performance-survey/task-capacity.md) - polling and memory after peak task occupancy subsides - [QUIC receive-state reuse](/quest/m2/performance-survey/quic-receive-reuse.md) - reassembly metadata retained by recycled completed streams - [Cache metadata](/quest/m2/performance-survey/cache-metadata.md) - bookkeeping capacity after cached groups expire -- [Cache backing amplification](/quest/m2/performance-survey/cache-backing.md) - small retained samples pinning large shared fragments - [Bandwidth grants](/quest/m2/performance-survey/bandwidth-grants.md) - repeated full allocation in Rust and JS - [Cached JS group joins](/quest/m2/performance-survey/js-cached-groups.md) - repeated sorting while replaying retained groups - [PCM batch processing](/quest/m2/performance-survey/audio-batches.md) - repeated tail movement during encoding and resampling diff --git a/quest/m2/performance-survey/cache-backing.md b/quest/m2/performance-survey/cache-backing.md deleted file mode 100644 index 82eb01f58bc..00000000000 --- a/quest/m2/performance-survey/cache-backing.md +++ /dev/null @@ -1,34 +0,0 @@ -# [M] Measure shared cache backing amplification - -## Goal - -Small retained media samples do not pin disproportionately large backing -allocations when a measured alternative earns its copying cost. - -## Plan - -[Group storage](/rs/moq-net/src/model/group.rs) bills `Bytes::len()` and stores -the supplied view. [fMP4 import](/rs/moq-mux/src/container/fmp4/import.rs) -slices samples from a fragment. Selective expiry across groups or tracks can -leave a small sample holding a large fragment after most logical charges -disappear. Cache capacity is not a promised hard RSS limit; this is a backing -amplification hypothesis, not proof of a broken capacity contract. - -Benchmark the actual importer and cache, sweeping fragment size × retained -sample fraction × tracks. Include mixed audio/video, several keyframe groups -in a fragment, selective expiry and independently allocated samples as a -control. Measure uniquely owned backing bytes versus `Pool::used()`, copy -volume, allocations, CPU and delivery latency. Do not double count shared -backing or assume each slice owns a fresh allocation. Compare continued -sharing with copying/compaction only where the workload warrants it. - -This covers completed reliable groups. Coordinate with -receive leases ([moq-dev/moq#5878](https://github.com/moq-dev/moq/pull/5878)) -and [browser media copies](/quest/m2/performance-survey/browser-media-copies.md). -Decoded datagrams are already billed to the cache pool -([moq-dev/moq#6413](https://github.com/moq-dev/moq/pull/6413)) and the media -reorder queues are bounded ([moq-dev/moq#6453](https://github.com/moq-dev/moq/pull/6453)). - -Follow the [survey benchmark contract](/quest/m2/performance-survey/README.md): -baseline first, paired evidence, equivalent work and nightly coverage. A -measured no-win is valid. No API or wire change is expected; report actual impact. diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index fb55be30215..7ee1bb39b5a 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -46,6 +46,8 @@ uuid = { workspace = true } [dev-dependencies] criterion = { package = "bench-harness", path = "../bench-harness" } +# Runs the group log's deferred frees before the backing bench reads the live heap. +crossbeam-epoch = "0.9.21" futures = { workspace = true } # The tests' executor, with simulated time. kio = { path = "../kio", features = ["runtime", "sim"] } @@ -121,6 +123,12 @@ name = "frame" harness = false required-features = ["fuzz"] +# Receives frames through the private session reader via the hidden `fuzz` module. +[[bench]] +name = "backing" +harness = false +required-features = ["fuzz"] + [[bench]] name = "group" harness = false diff --git a/rs/moq-net/benches/backing.rs b/rs/moq-net/benches/backing.rs new file mode 100644 index 00000000000..fbde04be899 --- /dev/null +++ b/rs/moq-net/benches/backing.rs @@ -0,0 +1,436 @@ +//! What cached frames received from the network keep alive beyond the bytes the cache bills. +//! +//! A receiving session hands each frame's payload to the cache as the transport delivered +//! it. A frame that arrives whole in one chunk would be cached as that chunk, a slice of +//! whatever receive allocation the packet was copied into, while a frame spanning packets is +//! copied into storage of its own. The pool bills each frame's length, so a small frame could +//! keep a much larger receive allocation alive, including the other frames' bytes that were +//! copied out of it. The session's frame reader therefore copies a whole frame of at most +//! 2 KiB out of its chunk, and this bench guards that every backing stays near the control. +//! +//! Each case receives [`SECONDS`] of media on `tracks` video and audio track pairs sharing one +//! pool, through the session's frame reader ([`moq_net::fuzz::GroupRecv`]). Frames are +//! interleaved by timestamp and packed back to back into packets of [`PACKET`] stream bytes, +//! which arrive in one of three backings: +//! +//! - `exact`: every chunk in an allocation of its own, the control. +//! - `datagram`: one allocation per datagram, as `moq-uring` copies a completion without GRO. +//! - `recv64k`: consecutive datagrams share a 64 KiB allocation, as `moq-tokio`'s endpoint +//! driver copies each receive into one reused `BytesMut` and splits datagrams off it. +//! A `moq-uring` GRO completion is one allocation of up to 64 KiB as well. +//! +//! Then the oldest video groups are evicted, keeping `keep` of them, while every audio group +//! stays cached: the selective expiry that leaves a small audio frame holding a receive +//! allocation whose video bytes the cache already dropped. +//! +//! `BACKING_ALLOCS=1` prints, for every case, the live heap the cache retains against +//! [`cache::Pool::used`], the payload bytes retained, how many frames arrived whole, and the +//! allocations and bytes allocated per frame received, instead of timing. The live heap +//! counts each allocation once, however many frames slice it. An argument filters the cases +//! by name. Timing covers only the cases that keep every group, since eviction adds a few +//! aborts. The first time a timed case runs, it prints the same line for itself and for the +//! two cases that evict its video groups, so a timed run such as the nightly one logs every +//! case's memory. The ratio is reported, not asserted: the reader's unit test pins the copy. +//! +//! Run with `cargo bench -p moq-net --features fuzz --bench backing`. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicUsize, Ordering}; + +use bytes::{Bytes, BytesMut}; +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_net::{Timescale, Timestamp, broadcast, cache, fuzz::GroupRecv, group, track}; + +/// Stream bytes per packet: a 1200-byte datagram less its QUIC overhead. +const PACKET: usize = 1150; + +/// What `moq-tokio`'s receive buffer reallocates once a datagram split off it is still held: +/// `bytes` keeps at most 64 KiB of a buffer's original capacity. +const RECV_CAPACITY: usize = 64 * 1024; + +/// Media received per case. +const SECONDS: u64 = 10; + +/// One group per track every two seconds: a video GOP, and the audio beside it. +const GROUP_MS: u64 = 2000; + +const VIDEO_FPS: u64 = 30; +/// A keyframe is this many delta frames. +const KEYFRAME: u64 = 10; + +/// 20 ms Opus frames at 64 kbps. +const AUDIO_FPS: u64 = 50; +const AUDIO_FRAME: usize = 160; + +/// Video bitrates, in kbps. Only the lowest has delta frames small enough to arrive whole. +const VIDEO_KBPS: [u64; 3] = [150, 1000, 4000]; +const TRACKS: [usize; 2] = [1, 4]; + +#[derive(Clone, Copy, Debug)] +enum Backing { + Exact, + Datagram, + Recv, +} + +impl Backing { + const ALL: [Self; 3] = [Self::Exact, Self::Datagram, Self::Recv]; + + fn name(self) -> &'static str { + match self { + Self::Exact => "exact", + Self::Datagram => "datagram", + Self::Recv => "recv64k", + } + } +} + +/// How many of each video track's groups stay cached: all, the newest quarter, or none. +const KEEP: [(&str, u64, u64); 3] = [("all", 1, 1), ("quarter", 1, 4), ("none", 0, 1)]; + +struct Counter; + +static COUNTING: AtomicBool = AtomicBool::new(false); +static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0); +static ALLOCATED: AtomicUsize = AtomicUsize::new(0); +static LIVE: AtomicIsize = AtomicIsize::new(0); + +#[global_allocator] +static ALLOCATOR: Counter = Counter; + +impl Counter { + fn count(size: usize) { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + ALLOCATED.fetch_add(size, Ordering::Relaxed); + } + } +} + +// Counting only. Every call forwards to the system allocator unchanged. +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + Self::count(layout.size()); + LIVE.fetch_add(layout.size() as isize, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + Self::count(layout.size()); + LIVE.fetch_add(layout.size() as isize, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + Self::count(new_size); + LIVE.fetch_add(new_size as isize - layout.size() as isize, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + LIVE.fetch_sub(layout.size() as isize, Ordering::Relaxed); + unsafe { System.dealloc(ptr, layout) } + } +} + +/// One frame of the workload, and the packets its payload landed in. +struct Frame { + /// Index into [`Media::tracks`]: video tracks first, then audio. + track: usize, + timestamp: u64, + /// Opens a group. + start: bool, + /// `(packet, offset, len)` per chunk, in order. + chunks: Vec<(usize, usize, usize)>, +} + +/// The workload of one case, packetized once. +struct Media { + tracks: usize, + frames: Vec, + /// Stream bytes per packet. + packets: Vec, + payload: usize, +} + +impl Media { + fn new(tracks: usize, video_kbps: u64) -> Self { + // A GOP of one keyframe and the deltas after it carries the bitrate. + let gop = VIDEO_FPS * GROUP_MS / 1000; + let delta = (video_kbps * 1000 / 8 * GROUP_MS / 1000 / (KEYFRAME + gop - 1)) as usize; + + let mut frames = Vec::new(); + for track in 0..tracks { + for index in 0..VIDEO_FPS * SECONDS { + let timestamp = index * 1000 / VIDEO_FPS; + let start = index % gop == 0; + let size = if start { delta * KEYFRAME as usize } else { delta }; + frames.push((timestamp, track, start, size)); + } + for index in 0..AUDIO_FPS * SECONDS { + let timestamp = index * 1000 / AUDIO_FPS; + let start = timestamp % GROUP_MS < 1000 / AUDIO_FPS; + frames.push((timestamp, tracks + track, start, AUDIO_FRAME)); + } + } + frames.sort_by_key(|(timestamp, track, ..)| (*timestamp, *track)); + + // Back to back, each packet filled before the next. + let mut packets = vec![0]; + let mut payload = 0; + let frames = frames + .into_iter() + .map(|(timestamp, track, start, mut size)| { + payload += size; + let mut chunks = Vec::new(); + while size > 0 { + let packet = packets.len() - 1; + let offset = packets[packet]; + let len = size.min(PACKET - offset); + chunks.push((packet, offset, len)); + size -= len; + packets[packet] += len; + if packets[packet] == PACKET { + packets.push(0); + } + } + Frame { + track, + timestamp, + start, + chunks, + } + }) + .collect(); + + Self { + tracks: 2 * tracks, + frames, + packets, + payload, + } + } + + fn is_video(&self, track: usize) -> bool { + track < self.tracks / 2 + } +} + +/// The transport's receive path: copies each datagram into its backing and hands out slices. +struct Driver { + backing: Backing, + recv: BytesMut, + /// The packet the last chunk came from. + current: Option<(usize, Bytes)>, +} + +/// What every datagram's bytes are copied from: the content does not matter. +static WIRE: [u8; PACKET] = [0; PACKET]; + +impl Driver { + fn new(backing: Backing) -> Self { + Self { + backing, + recv: match backing { + Backing::Recv => BytesMut::with_capacity(RECV_CAPACITY), + _ => BytesMut::new(), + }, + current: None, + } + } + + fn chunk(&mut self, media: &Media, (packet, offset, len): (usize, usize, usize)) -> Bytes { + let bytes = media.packets[packet]; + match self.backing { + Backing::Exact => Bytes::copy_from_slice(&WIRE[offset..offset + len]), + Backing::Datagram | Backing::Recv => { + if self.current.as_ref().is_none_or(|(current, _)| *current != packet) { + let datagram = match self.backing { + // As `moq-uring` copies a completion out of its receive pool. + Backing::Datagram => BytesMut::from(&WIRE[..bytes]).freeze(), + // As `moq-tokio` receives: reserve, copy in, split off. + _ => { + self.recv.reserve(bytes); + self.recv.extend_from_slice(&WIRE[..bytes]); + self.recv.split_to(bytes).freeze() + } + }; + self.current = Some((packet, datagram)); + } + let (_, datagram) = self.current.as_ref().expect("set above"); + datagram.slice(offset..offset + len) + } + } + } +} + +/// A receiving broadcast, its tracks sharing one pool. +struct Cache { + pool: cache::Pool, + _broadcast: broadcast::Producer, + tracks: Vec, + /// Each track's finished groups, oldest first, with their payload bytes. + groups: Vec>, +} + +impl Cache { + fn new(media: &Media) -> Self { + let mut info = broadcast::Info::new(); + info.pool = cache::Pool::unbounded(); + let pool = info.pool.clone(); + let broadcast = info.produce(); + let tracks = (0..media.tracks) + .map(|index| { + let info = track::Info::default().with_timescale(Timescale::MILLI); + broadcast.create_track(format!("{index}").as_str(), info).unwrap() + }) + .collect(); + Self { + pool, + _broadcast: broadcast, + tracks, + groups: (0..media.tracks).map(|_| Vec::new()).collect(), + } + } + + /// Receive every frame, then evict all but `keep` of each video track's groups. + fn run(&mut self, media: &Media, backing: Backing, (_, num, den): (&str, u64, u64)) { + let mut driver = Driver::new(backing); + let mut open: Vec> = (0..media.tracks).map(|_| None).collect(); + for frame in &media.frames { + let slot = &mut open[frame.track]; + if frame.start { + if let Some((recv, bytes)) = slot.take() { + self.groups[frame.track].push((recv.finish().unwrap(), bytes)); + } + *slot = Some((GroupRecv::new(self.tracks[frame.track].append_group().unwrap()), 0)); + } + let (recv, bytes) = slot.as_mut().expect("every track opens with a group"); + let chunks: Vec<_> = frame.chunks.iter().map(|chunk| driver.chunk(media, *chunk)).collect(); + *bytes += chunks.iter().map(Bytes::len).sum::(); + let timestamp = Timestamp::new(frame.timestamp, Timescale::MILLI).unwrap(); + recv.frame(Some(timestamp), chunks).unwrap(); + } + for (track, recv) in open.into_iter().enumerate() { + if let Some((recv, bytes)) = recv { + self.groups[track].push((recv.finish().unwrap(), bytes)); + } + } + drop(driver); + + for track in (0..media.tracks).filter(|track| media.is_video(*track)) { + let groups = &mut self.groups[track]; + let keep = groups.len() as u64 * num / den; + let evict = groups.len() - keep as usize; + for (group, _) in groups.drain(..evict) { + group.abort(moq_net::Error::Evicted).unwrap(); + } + } + } + + /// Payload bytes still cached. + fn payload(&self) -> usize { + self.groups.iter().flatten().map(|(_, bytes)| bytes).sum() + } +} + +/// Every `(name, tracks, video kbps, backing, keep)` case. +fn cases() -> impl Iterator { + TRACKS.into_iter().flat_map(|tracks| { + VIDEO_KBPS.into_iter().flat_map(move |kbps| { + KEEP.into_iter().flat_map(move |keep| { + Backing::ALL + .into_iter() + .map(move |backing| (name(tracks, kbps, keep, backing), tracks, kbps, backing, keep)) + }) + }) + }) +} + +/// A case's name within the `backing` group. +fn name(tracks: usize, kbps: u64, keep: (&str, u64, u64), backing: Backing) -> String { + format!("tracks={tracks}/video={kbps}k/keep={}/{}", keep.0, backing.name()) +} + +/// The live heap, once the cache's deferred frees have run. +fn live() -> isize { + // The group log frees evicted entries through epoch-based reclamation. + for _ in 0..64 { + crossbeam_epoch::pin().flush(); + } + LIVE.load(Ordering::Relaxed) +} + +/// Receive one case once, describing the live heap it retains against the pool's bill, the +/// payload retained, and the allocations and bytes allocated per frame received. +fn measure(media: &Media, backing: Backing, keep: (&str, u64, u64)) -> String { + let before = live(); + let mut cache = Cache::new(media); + let empty = live(); + + ALLOCATIONS.store(0, Ordering::Relaxed); + ALLOCATED.store(0, Ordering::Relaxed); + COUNTING.store(true, Ordering::Relaxed); + cache.run(media, backing, keep); + COUNTING.store(false, Ordering::Relaxed); + + let retained = live() - empty; + let used = cache.pool.used(); + let payload = cache.payload(); + drop(cache); + let left = live() - before; + + // The frames the cache can keep as the chunk they arrived in. + let whole = media.frames.iter().filter(|frame| frame.chunks.len() == 1).count(); + let frames = media.frames.len(); + let allocations = ALLOCATIONS.load(Ordering::Relaxed) as f64 / frames as f64; + let allocated = ALLOCATED.load(Ordering::Relaxed) as f64 / frames as f64; + format!( + "retained {retained} B, pool {used} B ({:.2}x), payload {payload} B, {whole} of {frames} frames whole, \ + {allocations:.2} allocations and {allocated:.0} B allocated per frame, {left} B left after drop", + retained as f64 / used as f64, + ) +} + +fn bench(c: &mut Criterion) { + // Every case's memory, without timing. + if std::env::var_os("BACKING_ALLOCS").is_some() { + let filter = std::env::args().skip(1).find(|arg| !arg.starts_with('-')); + for (name, tracks, kbps, backing, keep) in cases() { + if filter.as_deref().is_none_or(|filter| name.contains(filter)) { + println!("{name}: {}", measure(&Media::new(tracks, kbps), backing, keep)); + } + } + return; + } + + // Eviction is a handful of aborts, so only the cases keeping every group are timed. The + // first run of one, which Criterion's filter allows only for a selected case, prints the + // memory of every case sharing its media and backing, untimed. + let mut group = c.benchmark_group("backing"); + for (id, tracks, kbps, backing, keep) in cases().filter(|(.., keep)| keep.0 == "all") { + let media = Media::new(tracks, kbps); + let mut measured = false; + group.throughput(Throughput::Bytes(media.payload as u64)); + group.bench_function(BenchmarkId::from_parameter(id), |b| { + if !std::mem::replace(&mut measured, true) { + for other in KEEP { + let case = name(tracks, kbps, other, backing); + eprintln!("backing/{case}: {}", measure(&media, backing, other)); + } + } + b.iter_batched( + || Cache::new(&media), + |mut cache| { + cache.run(&media, backing, keep); + cache + }, + BatchSize::PerIteration, + ) + }); + } + group.finish(); +} + +criterion_group!(benches, bench); +criterion_main!(benches); diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index ac38145f937..eb47277624f 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -40,6 +40,17 @@ pub struct Reader { /// enough that an ordinary burst is still one wake. const WAKE_BUDGET: usize = 64 * 1024; +/// The largest frame [`Reader::poll_read_frame`] copies out of a chunk holding all of it, +/// rather than caching the chunk as it arrived. +/// +/// A transport hands over slices of its receive allocations: a QUIC datagram, or a whole +/// receive batch of them. A frame that arrives in one such slice and is cached as-is keeps +/// that allocation alive, the other frames' bytes included, while the cache bills only the +/// frame. This covers a QUIC datagram's stream payload (about 1.45 KB at the default +/// `max_udp_payload_size`), not a jumbo path's, so a larger frame from the in-memory +/// transport still crosses as the publisher's allocation. +const COPY_WHOLE: usize = 2 * 1024; + /// The largest [`Reader::scratch`] kept between decodes; one past this is freed. const SCRATCH_KEEP: usize = 64 * 1024; @@ -399,14 +410,22 @@ impl Reader { // One chunk per lease: a lease may hold the transport's lock, so the frame is // written, and its consumers woken, only once the lease is gone. A chunk holding - // the rest of the frame is taken whole, so a frame delivered in one piece keeps - // its allocation; any other stops where the segment being written seals. + // the rest of the frame is taken whole, so a large frame delivered in one piece + // keeps its allocation, and one of at most [`COPY_WHOLE`] is copied out of it; + // any other stops where the segment being written seals. let remaining = frame.remaining(); let take = match lease.chunk().len() >= remaining { true => remaining, false => lease.chunk().len().min(frame.unsealed()), }; - let chunk = lease.copy_to_bytes(take); + let chunk = match take as u64 == frame.size && take <= COPY_WHOLE { + true => { + let chunk = Bytes::copy_from_slice(&lease.chunk()[..take]); + lease.advance(take); + chunk + } + false => lease.copy_to_bytes(take), + }; let complete = chunk.len() == remaining; let ended = lease.is_fin() && !lease.has_remaining(); if complete || chunk.len() == frame.unsealed() { @@ -887,6 +906,36 @@ mod tests { )); } + /// A frame that arrives whole in a larger chunk, as in a datagram it shares with other + /// frames, is copied out of it when it is at most [`COPY_WHOLE`], so caching it does not + /// keep the rest of the receive allocation alive. A larger one is cached as the chunk. + #[test] + fn a_small_whole_frame_is_copied_out_of_its_chunk() { + let datagram: &'static [u8] = vec![7u8; 2 * COPY_WHOLE + 2].leak(); + for size in [1, COPY_WHOLE, COPY_WHOLE + 1] { + let (mut frame, mut payload, waiter, _wakes) = parked_frame(size); + let mut reader = queue(&[datagram], crate::lite::Version::Lite05); + let noop = &kio::Waiter::noop(); + assert!(matches!(reader.poll_read_frame(noop, &mut frame), Poll::Ready(Ok(())))); + let _group = frame.finish().unwrap(); + let Poll::Ready(Ok(Some(chunk))) = payload.poll_read_chunk(&waiter) else { + panic!("the committed frame has no payload"); + }; + assert_eq!(chunk, datagram[..size], "size={size}"); + assert_eq!( + chunk.as_ptr() == datagram.as_ptr(), + size > COPY_WHOLE, + "size={size}: copied {}", + size <= COPY_WHOLE + ); + assert_eq!( + leased(&mut reader), + datagram.len() - size, + "size={size}: the rest stays leased" + ); + } + } + /// A consumer parked on the group cannot run until this task yields, so a burst of /// chunks owes exactly one wake: at the boundary, from whoever wrote them. Every /// other exit hands the wake to the caller's `finish` or `abort`. diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 0e2f00310aa..b98bbc8e3d0 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -1673,6 +1673,87 @@ impl crate::transport::Error for NoError { } } +/// One group's stream on a receiving session, as the backing bench feeds it: each frame's +/// payload arrives as the chunks given, exactly as a transport hands them over, and passes +/// through the session's frame reader into the cache. +/// +/// A chunk keeps whatever allocation it was sliced from, so the bench can see which +/// receive buffers the cached frames keep alive. +pub struct GroupRecv { + reader: coding::Reader, lite::Version>, + queue: std::rc::Rc>>, + group: Option, +} + +impl GroupRecv { + /// Receive frames into `group`. + pub fn new(group: group::Producer) -> Self { + let queue = std::rc::Rc::default(); + let stream = Queue(std::rc::Rc::clone(&queue)); + Self { + reader: coding::Reader::new(crate::transport::poll::Buffered::new(stream), lite::Version::Lite05), + queue, + group: Some(group), + } + } + + /// Receive one frame whose payload is `chunks`, in order, and commit it. + pub fn frame( + &mut self, + timestamp: Option, + chunks: impl IntoIterator, + ) -> crate::Result<()> { + let mut size = 0; + self.queue + .borrow_mut() + .extend(chunks.into_iter().inspect(|chunk| size += chunk.len())); + let group = self.group.take().ok_or(crate::Error::Closed)?; + let mut frame = group.create_frame(frame::Info { + size: size as u64, + timestamp, + })?; + match self.reader.poll_read_frame(&kio::Waiter::noop(), &mut frame) { + std::task::Poll::Ready(Ok(())) => {} + std::task::Poll::Ready(Err(err)) => return Err(err), + std::task::Poll::Pending => unreachable!("every chunk of the frame is queued"), + } + self.group = Some(frame.finish()?); + Ok(()) + } + + /// Finish the group, returning its producer, which keeps it cached until dropped or aborted. + pub fn finish(mut self) -> crate::Result { + let mut group = self.group.take().ok_or(crate::Error::Closed)?; + group.finish()?; + Ok(group) + } +} + +/// The chunks queued by [`GroupRecv::frame`], handed over one at a time without copying. +struct Queue(std::rc::Rc>>); + +impl crate::transport::poll::Chunks for Queue { + type Error = NoError; + + fn poll_read_chunk( + &mut self, + _waiter: &kio::Waiter, + max: usize, + ) -> std::task::Poll, NoError>> { + let mut queue = self.0.borrow_mut(); + let Some(front) = queue.front_mut() else { + return std::task::Poll::Pending; + }; + let chunk = match front.len() > max { + true => front.split_to(max), + false => queue.pop_front().expect("front exists"), + }; + std::task::Poll::Ready(Ok(Some(chunk))) + } + + fn stop(&mut self, _code: u32) {} +} + /// A source-selection watch for the private request-lifetime benchmark. pub struct RequestValidity { watch: crate::model::broadcast::RequestValidity, diff --git a/rs/moq-net/tests/integration/zero_copy.rs b/rs/moq-net/tests/integration/zero_copy.rs index b1780917af4..b8888660444 100644 --- a/rs/moq-net/tests/integration/zero_copy.rs +++ b/rs/moq-net/tests/integration/zero_copy.rs @@ -2,7 +2,8 @@ //! A frame crosses an in-memory session as the same allocation it was written as: the //! publisher hands the payload to the transport, and the subscriber reads it back, -//! without a copy on either side. +//! without a copy on either side. Only a frame of at most 2 KiB is copied, so a small +//! frame never keeps a larger receive allocation alive. use crate::support; From 2eec576fde7a827668e0ded3bdf79667e839356c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:06:59 +0000 Subject: [PATCH 59/86] quest(no-spawn): split into an epic with four children (#7519) Co-authored-by: Claude Opus 5.5 --- quest/m1/poll-first/README.md | 2 +- quest/m1/poll-first/no-spawn.md | 39 -------------- quest/m1/poll-first/no-spawn/README.md | 61 ++++++++++++++++++++++ quest/m1/poll-first/no-spawn/admissions.md | 41 +++++++++++++++ quest/m1/poll-first/no-spawn/cluster.md | 52 ++++++++++++++++++ quest/m1/poll-first/no-spawn/ffi.md | 35 +++++++++++++ quest/m1/poll-first/no-spawn/sim-tests.md | 23 ++++++++ quest/m2/ffi-poll-migrate.md | 2 +- quest/m2/net-wrap/README.md | 2 +- quest/m2/spawn-audit.md | 4 +- quest/m3/studio/web/video-web.md | 2 +- 11 files changed, 218 insertions(+), 45 deletions(-) delete mode 100644 quest/m1/poll-first/no-spawn.md create mode 100644 quest/m1/poll-first/no-spawn/README.md create mode 100644 quest/m1/poll-first/no-spawn/admissions.md create mode 100644 quest/m1/poll-first/no-spawn/cluster.md create mode 100644 quest/m1/poll-first/no-spawn/ffi.md create mode 100644 quest/m1/poll-first/no-spawn/sim-tests.md diff --git a/quest/m1/poll-first/README.md b/quest/m1/poll-first/README.md index 653eb03aab6..882587d683b 100644 --- a/quest/m1/poll-first/README.md +++ b/quest/m1/poll-first/README.md @@ -75,4 +75,4 @@ and their alternatives: ## Required - [Generated async wrappers behind a feature](/quest/m1/poll-first/async-feature.md) - each library's `async fn` wrappers are generated from its `poll_*` signatures and compile only with its `async` feature -- [Libraries spawn nothing](/quest/m1/poll-first/no-spawn.md) - moq-ffi, moq-stats, and moq-relay's cluster and auth run as polled `kio::Tasks` children instead of spawned tasks +- [Libraries spawn nothing](/quest/m1/poll-first/no-spawn/README.md) - moq-ffi, moq-stats, and moq-relay's cluster and auth run as polled `kio::Tasks` children instead of spawned tasks diff --git a/quest/m1/poll-first/no-spawn.md b/quest/m1/poll-first/no-spawn.md deleted file mode 100644 index 35c35d270a8..00000000000 --- a/quest/m1/poll-first/no-spawn.md +++ /dev/null @@ -1,39 +0,0 @@ -# [L] Libraries spawn nothing - -## Goal - -moq-ffi, moq-stats, and moq-relay's cluster and auth decider spawn no tasks: -their background work runs as `kio::Tasks` children that the owner polls, -returned as a handle whose poll drives it. Only the edges spawn a root task: -binaries and the bindings' runtime. moq-relay's in-memory integration tests -(`rs/moq-relay/tests/integration/connection.rs`) run on kio's simulator -instead of paused tokio. - -## Plan - -Decided with the maintainer on 2026-10-25, replacing the m2 runtime trait -(spawn and time for tokio, browser, io_uring, and sim): - -- **Poll everywhere, no spawn.** A spawn trait keeps `'static` detached tasks - in the library, which poll-first rejects; `kio::Tasks` already gives a - library structured children with no runtime. Time is - `kio::clock`, so nothing is left for a runtime trait. - `rs/AGENTS.md` already treats `tokio::spawn` in a library as a smell. -- **No I/O seam.** I/O stays at moq-net's `transport::poll` traits and - moq-sock: tokio's readiness model and io_uring's completion model (the - kernel owns the buffer) can't share a zero-cost API. -- **Blocking work is out of scope.** OS threads for blocking capture, encode, - or file I/O are not async tasks and stay. - -Per crate: moq-ffi spawns only its root at the binding boundary; moq-stats and moq-relay's `Cluster::new` and auth decider -return work their owner polls. List any spawn that has to stay, with why, in -the PR. - -Public API: constructors that spawned return a handle to poll or await -instead. The PR's Impact section describes migration for the published crates whose API changes. Wire: -none. - -## Related - -- [The remaining spawns](/quest/m2/spawn-audit.md) - the same rule for every other library crate -- [Generated async wrappers](/quest/m1/poll-first/async-feature.md) - the poll-first sibling that gates the async layer diff --git a/quest/m1/poll-first/no-spawn/README.md b/quest/m1/poll-first/no-spawn/README.md new file mode 100644 index 00000000000..ca02cfbac92 --- /dev/null +++ b/quest/m1/poll-first/no-spawn/README.md @@ -0,0 +1,61 @@ +# Libraries spawn nothing + +## Goal + +moq-ffi, moq-stats, and moq-relay's cluster and auth decider spawn no tasks: +their background work runs as `kio::Tasks` children that the owner polls, +returned as a handle whose poll drives it. Only the edges spawn a root task: +binaries and the bindings' runtime. moq-relay's in-memory integration tests +(`rs/moq-relay/tests/integration/connection.rs`) run on kio's simulator +instead of paused tokio. + +## Plan + +Decided with the maintainer on 2026-10-25, replacing the m2 runtime trait +(spawn and time for tokio, browser, io_uring, and sim): + +- **Poll everywhere, no spawn.** A spawn trait keeps `'static` detached tasks + in the library, which poll-first rejects; `kio::Tasks` already gives a + library structured children with no runtime. Time is + `kio::clock`, so nothing is left for a runtime trait. + `rs/AGENTS.md` already treats `tokio::spawn` in a library as a smell. +- **No I/O seam.** I/O stays at moq-net's `transport::poll` traits and + moq-sock: tokio's readiness model and io_uring's completion model (the + kernel owns the buffer) can't share a zero-cost API. +- **Blocking work is out of scope.** OS threads for blocking capture, encode, + or file I/O are not async tasks and stay. + +✅ **Split into four children** (maintainer, 2026-11-01, D1 on +[moq-dev/moq#7519](https://github.com/moq-dev/moq/pull/7519)), landed in the +order of Required below. One PR could not hold them all, and two open PRs +already changed part of the scope: +[moq-dev/moq#7360](https://github.com/moq-dev/moq/pull/7360) (merged +2026-11-01) removed the auth decider loop and returns moq-stats' publisher from +`Producer::new`, and +[moq-dev/moq#7497](https://github.com/moq-dev/moq/pull/7497) rewrites moq-ffi's +`Task` and `Background` plumbing. Each child records its own decisions. + +Every child lists any spawn that has to stay, with why, in its PR. Public API: +constructors that spawned return a handle to poll or await instead. Each PR's +Impact section describes migration for the published crates whose API changes. +Wire: none. + +These spawns stay and belong to [the spawn audit](/quest/m2/spawn-audit.md): +`Critical`'s ACME renewal (`rs/moq-relay/src/relay.rs`) and web certificate +reload (`rs/moq-relay/src/web.rs`), the io_uring handoff in +`rs/moq-relay/src/connection.rs`, and moq-relay's web, shutdown, and uring +spawns. Each UniFFI call's task on the bindings' runtime stays too: that is the +binding boundary. + +## Required + +- [Cluster polls its own work](/quest/m1/poll-first/no-spawn/cluster.md) - `Cluster::new` returns a `Driver` the relay polls, and the dial loop runs as `kio::Tasks` children +- [Admissions and stats are polled](/quest/m1/poll-first/no-spawn/admissions.md) - HTTP admissions run inside their waiter, and the stats publisher runs in the cluster's driver +- [moq-ffi spawns only its root](/quest/m1/poll-first/no-spawn/ffi.md) - one runtime-owned driver task holds every `Background` as `kio::Tasks` children +- [The relay's in-memory tests run on kio's simulator](/quest/m1/poll-first/no-spawn/sim-tests.md) - `rs/moq-relay/tests/integration/connection.rs` runs on the simulator instead of paused tokio + +## Related + +- [The remaining spawns](/quest/m2/spawn-audit.md) - the same rule for every other library crate +- [Generated async wrappers](/quest/m1/poll-first/async-feature.md) - the poll-first sibling that gates the async layer +- [Poll-native async in moq-ffi](/quest/m2/ffi-poll-migrate.md) - its PR reworks the moq-ffi task plumbing this epic touches diff --git a/quest/m1/poll-first/no-spawn/admissions.md b/quest/m1/poll-first/no-spawn/admissions.md new file mode 100644 index 00000000000..86c63132c59 --- /dev/null +++ b/quest/m1/poll-first/no-spawn/admissions.md @@ -0,0 +1,41 @@ +# [S] Admissions and stats are polled + +## Goal + +moq-relay spawns no task per HTTP admission, and `Cluster::with_stats` spawns +no stats publisher. The `Server` decider's request runs inside the caller that +waits on its answer, and the publisher runs as a child of the cluster's +`Driver`. + +## Plan + +Spawns in scope on `main` (2026-11-01): + +- `rs/moq-relay/src/auth.rs`, `Decider::Server`: one + `tokio::spawn(moq_tokio::panic::isolate(..))` per admission HTTP request. + [moq-dev/moq#7360](https://github.com/moq-dev/moq/pull/7360) (merged + 2026-11-01) already removed the decider loop around it. +- `rs/moq-relay/src/cluster.rs`, `Cluster::with_stats`: the stats publisher, + through `Critical`. Since #7360, `moq_stats::Producer::new` returns + `(Producer, Publisher)`, so moq-stats itself spawns nothing. + +✅ **The waiter polls the admission** (maintainer, 2026-11-01, D3 on +[moq-dev/moq#7519](https://github.com/moq-dev/moq/pull/7519)): the caller +waiting on `Answer` polls the `moq_auth::Client` request inline, and dropping +the wait cancels the request, which replaces the `reply.closed()` select. A +panic then ends the session being admitted, the same scope the per-admission +`isolate` gives today. Rejected: one long-lived `kio::Tasks` of admissions +polled by `Relay::run`, which brings back the long-lived decider task that +#7360 removed. + +The publisher joins `Driver`, the type +[Cluster polls its own work](/quest/m1/poll-first/no-spawn/cluster.md) +introduces, so a publisher panic still ends the relay. `Publisher::run` is an +`async fn`; giving `Publisher` a `poll` is optional and only worth it if +`Driver` needs it. + +Public API: `Cluster::with_stats` no longer needs a tokio runtime. Wire: none. + +## Required + +- [Cluster polls its own work](/quest/m1/poll-first/no-spawn/cluster.md) - provides the `Driver` the publisher joins diff --git a/quest/m1/poll-first/no-spawn/cluster.md b/quest/m1/poll-first/no-spawn/cluster.md new file mode 100644 index 00000000000..b7cde935748 --- /dev/null +++ b/quest/m1/poll-first/no-spawn/cluster.md @@ -0,0 +1,52 @@ +# [L] Cluster polls its own work + +## Goal + +moq-relay's `Cluster` spawns no tasks. `Cluster::new` returns the origin +driver and cache governor as work its owner polls, and `Started::run` drives +every dial, the `api.url` reconciler, and mDNS discovery as `kio::Tasks` +children instead of a tokio `JoinSet` and detached tasks. The relay still exits +with the panic message when a task it cannot serve without panics. + +## Plan + +Spawns in scope on `main` (2026-11-01), all in `rs/moq-relay/src/cluster.rs`: + +- `Cluster::new`: the cache governor and the origin driver, through `Critical`. +- `run_work`: the dial `JoinSet`, the `api.url` reconciler, and mDNS. +- `run_mdns` and `apply_peer_list`: one detached task per peer, tracked by an + `AbortHandle` in `DialMap`. These become a queue into the `run` loop's + `Tasks`, so `DialMap` holds `TaskKey`s. +- `Held::drop`: the close of a LAN dial's client. `Dialers` polls its existing + close set instead. + +✅ **`Cluster::new` returns a driver** (maintainer, 2026-11-01, D2 on +[moq-dev/moq#7519](https://github.com/moq-dev/moq/pull/7519)): +`Cluster::new(options) -> anyhow::Result<(Cluster, Driver)>`, the same shape as +`origin::Producer::new`. `Relay::run` polls `Driver` next to `Started::run`. +`Driver` catches each child's panic and turns it into an error, so the relay +still exits with "origin driver panicked: ..." the way `Critical` does now. +Tests spawn the driver, because they are edges. Rejected: keeping the signature +and moving the driver into `Started::run`, which breaks `Cluster::new`'s +documented promise that the origin serves sessions whether or not `start` is +ever called. There are about 68 `Cluster::new(` call sites across moq-relay, +moq-cli, the benches, and moq-net's `mesh_withdraw` test. + +✅ **Peer dials stop running in parallel** (maintainer, 2026-11-01, D5 on +[moq-dev/moq#7519](https://github.com/moq-dev/moq/pull/7519)): as `kio::Tasks` +children, every peer dial supervisor runs on the task that polls +`Started::run` instead of its own tokio task. The heavy session I/O is still +spawned inside moq-tokio, which the spawn audit owns, so the cost is probably +small. Measure it once in this PR and report the result. + +`Critical` stays for ACME renewal and the web certificate reload, which belong +to the spawn audit; `Cluster::with_stats` keeps spawning the publisher until +[the admissions child](/quest/m1/poll-first/no-spawn/admissions.md) moves it +into `Driver`. + +Public API: `Cluster::new` returns `(Cluster, Driver)`; the PR's Impact section +describes the migration. Wire: none. + +## Related + +- [The remaining spawns](/quest/m2/spawn-audit.md) - owns moq-tokio's session spawns and `Critical`'s remaining tasks diff --git a/quest/m1/poll-first/no-spawn/ffi.md b/quest/m1/poll-first/no-spawn/ffi.md new file mode 100644 index 00000000000..c11a7fad8a9 --- /dev/null +++ b/quest/m1/poll-first/no-spawn/ffi.md @@ -0,0 +1,35 @@ +# [M] moq-ffi spawns only its root + +## Goal + +moq-ffi spawns one task outside its UniFFI calls: a runtime-owned driver that +polls every `Background` as a `kio::Tasks` child. Origin drivers, the server +accept loop, cleanups, and demand waits no longer spawn tasks of their own. + +## Plan + +Spawns in scope on `main` (2026-11-01), all in `rs/moq-ffi/src/`: + +- `ffi.rs`, `ffi::spawn` and `Background::driver`: origin drivers + (`origin.rs`), the server accept loop (`server.rs`), and cleanups. +- `ffi.rs`, `detached`: demand waits (`demand.rs`, `producer.rs`). +- `ffi.rs`, each UniFFI call's task on the bindings' runtime. This stays: each + call's own future is the binding boundary. + +✅ **One root driver** (maintainer, 2026-11-01, D4 on +[moq-dev/moq#7519](https://github.com/moq-dev/moq/pull/7519)): moq-ffi's root +is one runtime-owned driver task that holds every `Background` as `kio::Tasks` +children. Rejected: having each handle's calls poll its `Background`, which +fails for an origin with no reader, since nobody would poll its driver. + +[moq-dev/moq#7497](https://github.com/moq-dev/moq/pull/7497) replaces +`ffi::Task` with `ffi::Polled` and changes `Background` and shutdown in +`ffi.rs`, keeping demand waits `detached` for now. Build on it: converting +before it lands would conflict on almost every line. Recount the sites above +once it merges. + +Public API: no binding signature changes. Wire: none. + +## Required + +- [Poll-native async in moq-ffi](/quest/m2/ffi-poll-migrate.md) - its PR rewrites the `Task` and `Background` plumbing this changes diff --git a/quest/m1/poll-first/no-spawn/sim-tests.md b/quest/m1/poll-first/no-spawn/sim-tests.md new file mode 100644 index 00000000000..f5f4c7e581c --- /dev/null +++ b/quest/m1/poll-first/no-spawn/sim-tests.md @@ -0,0 +1,23 @@ +# [M] The relay's in-memory tests run on kio's simulator + +## Goal + +moq-relay's in-memory integration tests +(`rs/moq-relay/tests/integration/connection.rs`) run on kio's simulator +instead of paused tokio. + +## Plan + +This needs the cluster's and the admissions' work to be polled, which the +children it requires deliver. It also needs `moq_tokio::origin::spawn` and the +memory connect helper the tests use to run without tokio. moq-tokio belongs to +[the spawn audit](/quest/m2/spawn-audit.md) (m2), so this quest may move to +m2, or require the part of the spawn audit that converts those two helpers. +Decide that when it starts. + +Public API: none. Wire: none. + +## Required + +- [Cluster polls its own work](/quest/m1/poll-first/no-spawn/cluster.md) - the cluster's driver and dials stop spawning on tokio +- [Admissions and stats are polled](/quest/m1/poll-first/no-spawn/admissions.md) - admissions stop spawning on tokio diff --git a/quest/m2/ffi-poll-migrate.md b/quest/m2/ffi-poll-migrate.md index c211625940c..91ef76c574b 100644 --- a/quest/m2/ffi-poll-migrate.md +++ b/quest/m2/ffi-poll-migrate.md @@ -77,4 +77,4 @@ get items in call order; sequence and arrival order are unchanged. Wire: none. ## Related - [Foreign poll for hot reads](https://github.com/moq-dev/rust2/blob/main/quest/m1/ffi-poll-protocol.md) - the generated protocol that follows this, once the fork exists -- [Libraries spawn nothing](/quest/m1/poll-first/no-spawn.md) - moq-ffi spawns only its root at the binding boundary +- [moq-ffi spawns only its root](/quest/m1/poll-first/no-spawn/ffi.md) - one runtime-owned driver task holds every `Background` as `kio::Tasks` children diff --git a/quest/m2/net-wrap/README.md b/quest/m2/net-wrap/README.md index 6c36e493cd2..def14e4acfb 100644 --- a/quest/m2/net-wrap/README.md +++ b/quest/m2/net-wrap/README.md @@ -15,7 +15,7 @@ Compression is not a wrapper: it moves into moq-net as a track flag moq-flate, so moq-flate is out of scope. Non-goals: -- A runtime seam: libraries poll and spawn nothing ([Libraries spawn nothing](/quest/m1/poll-first/no-spawn.md)). +- A runtime seam: libraries poll and spawn nothing ([Libraries spawn nothing](/quest/m1/poll-first/no-spawn/README.md)). - The transport seam: custom transports implement moq-net's `transport::poll` traits ([native WebTransport](/quest/m1/quic-api/native-web.md) moves the last backend onto them). ## Plan diff --git a/quest/m2/spawn-audit.md b/quest/m2/spawn-audit.md index ef8f07a03d5..196c0c4fe22 100644 --- a/quest/m2/spawn-audit.md +++ b/quest/m2/spawn-audit.md @@ -3,7 +3,7 @@ ## Goal Every async task spawn left in a library crate after -[Libraries spawn nothing](/quest/m1/poll-first/no-spawn.md) is either converted +[Libraries spawn nothing](/quest/m1/poll-first/no-spawn/README.md) is either converted to a `kio::Tasks` child its owner polls or listed with the reason it must stay. Blocking OS threads are out of scope. @@ -26,4 +26,4 @@ migration note per published crate. Wire: none. ## Required -- [Libraries spawn nothing](/quest/m1/poll-first/no-spawn.md) - the rule and the pattern this applies +- [Libraries spawn nothing](/quest/m1/poll-first/no-spawn/README.md) - the rule and the pattern this applies diff --git a/quest/m3/studio/web/video-web.md b/quest/m3/studio/web/video-web.md index 99bb372055a..7df8c0047d5 100644 --- a/quest/m3/studio/web/video-web.md +++ b/quest/m3/studio/web/video-web.md @@ -35,4 +35,4 @@ target; confirm the web studio still wants it before starting. ## Required -- [Libraries spawn nothing](/quest/m1/poll-first/no-spawn.md) - library work polled by its owner, with no runtime to spawn on +- [Libraries spawn nothing](/quest/m1/poll-first/no-spawn/README.md) - library work polled by its owner, with no runtime to spawn on From d6beb5987562ba3531f63c7536ef86062fe92391 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:07:14 +0000 Subject: [PATCH 60/86] test(relay): finish auth leases before reusing the test listener (#7550) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- quest/m0/test-flakes-2/README.md | 5 +- quest/m0/test-flakes-2/relay-auth-trust.md | 17 ------- rs/moq-relay/src/http_client.rs | 55 ++++++++++++++-------- 3 files changed, 40 insertions(+), 37 deletions(-) delete mode 100644 quest/m0/test-flakes-2/relay-auth-trust.md diff --git a/quest/m0/test-flakes-2/README.md b/quest/m0/test-flakes-2/README.md index effc428413c..0a53c93816c 100644 --- a/quest/m0/test-flakes-2/README.md +++ b/quest/m0/test-flakes-2/README.md @@ -52,11 +52,14 @@ moq-rtc's session-end test was never load-bound: on the paused clock, dimpl's randomly jittered DTLS handshake give-up raced ICE's establishment deadline, fixed by [moq-dev/moq#7379](https://github.com/moq-dev/moq/pull/7379). +moq-relay's auth-trust test was never load-bound either: its fixture left each +lease's background `end` POST unserved, so a reused port let one steal the next +trial's accept. Fixed by [moq-dev/moq#7550](https://github.com/moq-dev/moq/pull/7550). + Public API: none. Wire: none. ## Required - [IETF22 fallback](/quest/m0/test-flakes-2/ietf22-fallback.md) - an IETF22 browser viewer follows a fallback within the media lane's bound, keeping its session and its audio -- [moq-relay's auth-trust test survives load](/quest/m0/test-flakes-2/relay-auth-trust.md) - it stops timing out on a busy host - [Draft-22 interop with a native publisher](/quest/m0/test-flakes-2/draft22-interop.md) - no "invalid fetch range" or audio gap with a Rust or C++ publisher - [A Kotlin cancel test and a JS late-publisher test hold up](/quest/m0/test-flakes-2/kotlin-js-flakes.md) - the Kotlin flow-cancel test stops failing `unroutable` and the IETF blind-handle test passes under load diff --git a/quest/m0/test-flakes-2/relay-auth-trust.md b/quest/m0/test-flakes-2/relay-auth-trust.md deleted file mode 100644 index 94dadcb7ff8..00000000000 --- a/quest/m0/test-flakes-2/relay-auth-trust.md +++ /dev/null @@ -1,17 +0,0 @@ -# [XS] moq-relay's auth-trust test survives load - -## Goal - -`http_client::tests::auth_trust_is_independent_from_mesh_verification` in -moq-relay stops timing out when the host is busy, fixed at its cause. - -## Plan - -Seen during [moq-dev/moq#6994](https://github.com/moq-dev/moq/pull/6994)'s -local `just ci`: it timed out under load and passes alone in 0.08 s. Find what -it waits on by wall clock and make it deterministic, rather than raising a -timeout. - -Decided 2026-10-30 (maintainer, /quest-plan of the 2026-10-30 /quest-complete follow-ups): ✅ new flake child / drop. - -Public API: none. Wire: none. diff --git a/rs/moq-relay/src/http_client.rs b/rs/moq-relay/src/http_client.rs index c205446436d..cdf2c5b67a0 100644 --- a/rs/moq-relay/src/http_client.rs +++ b/rs/moq-relay/src/http_client.rs @@ -101,6 +101,7 @@ mod tests { PrivateKeyDer::Pkcs8(key.serialize_der().into()), ) .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); for mode in ["insecure", "root", "fingerprint"] { let mut mesh = moq_tokio::tls::Connect::default(); match mode { @@ -114,36 +115,52 @@ mod tests { _ => unreachable!(), } for trusted in [false, true] { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let auth = crate::auth::Config { root: if trusted { vec![root.clone()] } else { Vec::new() }, url: Some(format!("https://{}/", listener.local_addr().unwrap()).parse().unwrap()), ..Default::default() }; let auth = auth.init("relay", &mesh, false).unwrap(); - let serve = async { - let (stream, _) = listener.accept().await.unwrap(); - if let Ok(mut stream) = tokio_rustls::TlsAcceptor::from(Arc::new(server.clone())) - .accept(stream) - .await - { - read_request(&mut stream).await; - let body = r#"{"publish":["**"],"subscribe":["**"]}"#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - stream.write_all(response.as_bytes()).await.unwrap(); - } - }; let admit = auth.admit(auth.request(moq_auth::Transport::Quic, "/")); - let (admitted, ()) = tokio::join!(admit, serve); + let (admitted, request) = tokio::join!(admit, serve_auth(&listener, &server)); assert_eq!(admitted.is_ok(), trusted, "mesh {mode}, auth root {trusted}"); + if let Ok(lease) = admitted { + let request = request.unwrap(); + assert_eq!(request.event, moq_auth::Event::Connect); + // Drain the lease's End before another admission uses this listener. + let reported = lease.reported(); + drop(lease); + let ((), ended) = tokio::join!(reported, serve_auth(&listener, &server)); + let ended = ended.unwrap(); + assert_eq!(ended.id, request.id); + assert!(matches!(ended.event, moq_auth::Event::End { .. })); + } else { + assert!(request.is_none(), "untrusted TLS must fail before HTTP"); + } } } } - async fn read_request(stream: &mut (impl tokio::io::AsyncRead + Unpin)) { + async fn serve_auth(listener: &tokio::net::TcpListener, tls: &rustls::ServerConfig) -> Option { + let (stream, _) = listener.accept().await.unwrap(); + let Ok(mut stream) = tokio_rustls::TlsAcceptor::from(Arc::new(tls.clone())) + .accept(stream) + .await + else { + return None; + }; + let request = read_request(&mut stream).await; + let body = r#"{"publish":["**"],"subscribe":["**"]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.shutdown().await.unwrap(); + Some(request) + } + + async fn read_request(stream: &mut (impl tokio::io::AsyncRead + Unpin)) -> moq_auth::Request { let mut request = Vec::new(); loop { let mut data = [0; 4096]; @@ -164,7 +181,7 @@ mod tests { .parse() .unwrap(); if request.len() >= end + 4 + length { - return; + return serde_json::from_slice(&request[end + 4..end + 4 + length]).unwrap(); } } } From bb5868781989db8dd7b9720d746e3371c8f9165f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:07:18 +0000 Subject: [PATCH 61/86] fix(nix): treat R2 service failures as cache misses (#7537) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- infra/README.md | 8 +++ infra/nix/src/worker.test.ts | 105 +++++++++++++++++++++++++++++++++++ infra/nix/src/worker.ts | 19 ++++++- quest/m0/ci/nix-cache-5xx.md | 26 ++++++--- 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 infra/nix/src/worker.test.ts diff --git a/infra/README.md b/infra/README.md index b101e9b7dc3..1c61d7000fe 100644 --- a/infra/README.md +++ b/infra/README.md @@ -269,6 +269,14 @@ after upload; the next warm re-uploads what it still needs. `flake.nix` sets `fallback = true` because that expiry can remove a path before one that depends on it. +The Worker logs R2 internal errors (10001) and service-unavailable errors +(10043) and treats the failed read as a miss: the other bucket can still +serve the path, and otherwise the 404 is non-cacheable so Nix builds from +source. +Other errors, including invalid object names, permissions, and bucket +configuration, still fail the request. A failure after the archive body +starts streaming remains a failed download, covered by `fallback`. + Nix only trusts paths signed by the key in `flake.nix`, so the signing key is the trust boundary, not the bucket keys. It lives in the `cache-write` Environment beside one R2 token for both buckets. Only `main` and the five diff --git a/infra/nix/src/worker.test.ts b/infra/nix/src/worker.test.ts new file mode 100644 index 00000000000..3c98fa5e1e6 --- /dev/null +++ b/infra/nix/src/worker.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; + +import worker from "./worker"; + +const key = "00000000000000000000000000000000.narinfo"; +const request = (path = key, method = "GET") => new Request(`https://nix.moq.dev/${path}`, { method }); +const bucket = (get = mock(async (_key: string): Promise => null)) => + ({ get }) as unknown as R2Bucket; + +describe("bucket reads", () => { + for (const binding of ["RELEASE", "CI"] as const) { + for (const code of [10001, 10043]) { + test(`${binding} service error ${code} makes GET and HEAD a non-cacheable miss`, async () => { + const error = new Error(`get: Service failed (${code})`); + const failed = mock(async () => { + throw error; + }); + const env = { RELEASE: bucket(), CI: bucket(), [binding]: bucket(failed) }; + const log = spyOn(console, "error").mockImplementation(() => {}); + try { + for (const method of ["GET", "HEAD"]) { + for (const path of [key, "nar/archive.nar.zst"]) { + const response = await worker.fetch(request(path, method), env); + expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + } + } + expect(log).toHaveBeenCalledTimes(4); + expect(failed).toHaveBeenCalledTimes(4); + expect(env.RELEASE.get).toHaveBeenCalledTimes(4); + expect(env.CI.get).toHaveBeenCalledTimes(4); + } finally { + log.mockRestore(); + } + }); + } + + for (const error of [ + new Error("get: Unauthorized (10002)"), + new Error("get: Access denied (10003)"), + new Error("get: No such bucket (10006)"), + new Error("get: Invalid object name (10020)"), + new Error("get: Unknown failure (0)"), + new Error("unrelated 10001"), + new TypeError("bad binding"), + ]) { + test(`${binding} preserves ${error}`, async () => { + const env = { + RELEASE: bucket(), + CI: bucket(), + [binding]: bucket(mock(async () => Promise.reject(error))), + }; + await expect(worker.fetch(request(), env)).rejects.toBe(error); + }); + } + } + + test("a missing binding is a configuration error", async () => { + await expect(worker.fetch(request(), { RELEASE: undefined, CI: bucket() } as never)).rejects.toThrow(TypeError); + }); + + test("a release miss checks CI and preserves the object's headers and body", async () => { + const body = "signed metadata"; + const release = mock(async () => null); + const ci = mock(async () => ({ size: body.length, body: new Response(body).body }) as R2ObjectBody); + const response = await worker.fetch(request(), { RELEASE: bucket(release), CI: bucket(ci) }); + expect(await response.text()).toBe(body); + expect(response.headers.get("Content-Type")).toBe("text/x-nix-narinfo"); + expect(response.headers.get("Content-Length")).toBe(String(body.length)); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=300"); + expect(release).toHaveBeenCalledWith(key); + expect(ci).toHaveBeenCalledWith(key); + }); + + test("a release service error still serves the path from CI", async () => { + const body = "signed metadata"; + const release = mock(async () => { + throw new Error("get: Internal error (10001)"); + }); + const ci = mock(async () => ({ size: body.length, body: new Response(body).body }) as R2ObjectBody); + const log = spyOn(console, "error").mockImplementation(() => {}); + try { + const response = await worker.fetch(request(), { RELEASE: bucket(release), CI: bucket(ci) }); + expect(response.status).toBe(200); + expect(await response.text()).toBe(body); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=300"); + expect(log).toHaveBeenCalledTimes(1); + } finally { + log.mockRestore(); + } + }); + + test("a plain miss stays cacheable", async () => { + const response = await worker.fetch(request(), { RELEASE: bucket(), CI: bucket() }); + expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toBeNull(); + }); + + test("cache metadata needs no bucket read", async () => { + const env = { RELEASE: bucket(), CI: bucket() }; + expect((await worker.fetch(request("nix-cache-info"), env)).status).toBe(200); + expect(env.RELEASE.get).not.toHaveBeenCalled(); + expect(env.CI.get).not.toHaveBeenCalled(); + }); +}); diff --git a/infra/nix/src/worker.ts b/infra/nix/src/worker.ts index 088955e80ca..6004c242d31 100644 --- a/infra/nix/src/worker.ts +++ b/infra/nix/src/worker.ts @@ -56,8 +56,21 @@ export default { // Releases first, since they never expire. A NAR is named by its hash, so // whichever bucket holds it serves the same bytes. + let unavailable = false; for (const bucket of [env.RELEASE, env.CI]) { - const object = await bucket.get(key); + let object: R2ObjectBody | null; + try { + object = await bucket.get(key); + } catch (error) { + // R2 exposes its error code in the message, not as a typed field: + // https://developers.cloudflare.com/r2/api/error-codes/ + if (!(error instanceof Error && /^get: .* \((10001|10043)\)$/.test(error.message))) throw error; + // Treat the failed read as a miss, so the other bucket can still + // serve the path and Nix otherwise builds it from source. + console.error(`Nix cache read unavailable: ${key}`, error); + unavailable = true; + continue; + } if (!object) continue; const headers = { @@ -68,6 +81,8 @@ export default { return new Response(request.method === "HEAD" ? null : object.body, { headers }); } - return new Response("Not Found", { status: 404 }); + // Keep intermediaries from caching a transient failure as missing. + const headers = unavailable ? { "Cache-Control": "no-store" } : undefined; + return new Response("Not Found", { status: 404, headers }); }, }; diff --git a/quest/m0/ci/nix-cache-5xx.md b/quest/m0/ci/nix-cache-5xx.md index 52c5841c9f1..421a557b62f 100644 --- a/quest/m0/ci/nix-cache-5xx.md +++ b/quest/m0/ci/nix-cache-5xx.md @@ -11,13 +11,25 @@ substitute. Decided 2026-10-31 (user, /quest-plan of the follow-ups from moq-dev/quest's host-gate cutover for moq.pro and rust2 and its fixer repairs). - On 2026-10-31 one HTTP 500 for a `.narinfo` failed `check flake` and - ejected a seven-PR batch in the host land gate. Nix stops on a failed - path-info lookup even with `fallback = true`, which covers only failed - downloads. -- Prefer fixing it at the source: find what made the cache Worker return 500 - for a lookup and have it answer 404 (a miss) when its bucket read fails. - If Nix still sees 5xx from the edge, retry the lookup in the Worker or - through Nix's `download-attempts`. + ejected a seven-PR batch in the host land gate. +- [The Worker fix](https://github.com/moq-dev/moq/pull/7537) turns R2 internal + errors (10001) and service-unavailable errors (10043) into logged misses: + the other bucket is still tried, and a resulting 404 is non-cacheable. + Malformed input, bad bindings, authorization + errors, and unknown failures still fail. No retries or client settings + change. Both buckets and metadata/archive reads have local test coverage. +- A local HTTP fault-injection probe verified that the real dev shell + evaluates and builds a fresh dependency from source with these 404s. + Nix 2.35.2 also completed that probe against the original 500 response, + while logging the error and disabling the cache. This does not reproduce + the historical `check flake` failure. The available Cloudflare OAuth token + received `Forbidden` when querying historical Worker logs, so the original + incident's R2 code or edge origin remains unconfirmed. +- Decided 2026-11-01 (maintainer): land the narrow service-code handling + without waiting for Workers Observability access to the original incident. +- Remaining: after the fix merges, the maintainer deploys the Worker with + `just infra nix deploy`, then verifies cache misses and dev-shell + evaluation/build through `nix.moq.dev`. - The [Nix cache verification](/quest/m0/ci/nix-cache-verify.md) owns the release side of the cache. From 1b858666ac7ad2a17dac4b5c64f24de4aebeaeb6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:07:51 +0000 Subject: [PATCH 62/86] perf(kio): index task wakes so a poll skips capacity a burst left behind (#7513) Co-authored-by: Claude Opus 5.5 --- quest/m2/perf/uring-quiescence.md | 7 +- quest/m2/performance-survey/README.md | 2 +- quest/m2/performance-survey/task-capacity.md | 97 ++++----- rs/kio/Cargo.toml | 4 + rs/kio/benches/task_capacity.rs | 119 ++++++++++ rs/kio/benches/tasks.rs | 96 +++++++- rs/kio/src/task.rs | 217 +++++++++++++++++-- 7 files changed, 464 insertions(+), 78 deletions(-) create mode 100644 rs/kio/benches/task_capacity.rs diff --git a/quest/m2/perf/uring-quiescence.md b/quest/m2/perf/uring-quiescence.md index cb29f6bb02e..878fca494c9 100644 --- a/quest/m2/perf/uring-quiescence.md +++ b/quest/m2/perf/uring-quiescence.md @@ -42,9 +42,10 @@ must not starve the socket. Decided in the 2026-09-30 audit: the egress requeue quest merged here, since both budgets need the same sweep. - Add `passes` per turn to the metrics beside `turns`. -- [Task capacity](/quest/m2/performance-survey/task-capacity.md) also - reshapes the `Tasks::poll` wake-word snapshot. Whichever of the two lands - second rebases its benchmark on the other. +- `Tasks::poll` now snapshots through a wake index + ([moq-dev/moq#7513](https://github.com/moq-dev/moq/pull/7513)), so each + re-snapshot costs the woken words, not the set's capacity. Baseline the pass + budget on that, using the `tasks_capacity_*` groups in kio's `tasks` bench. - Ingress fairness: a pass budget does not bound work inside one poll. The endpoint's `Inner::poll_run` calls `poll_recv` until `Pending`, so one poll drains every queued receive completion, and `Endpoint::demux` drains diff --git a/quest/m2/performance-survey/README.md b/quest/m2/performance-survey/README.md index 665f07719c1..561223b97f0 100644 --- a/quest/m2/performance-survey/README.md +++ b/quest/m2/performance-survey/README.md @@ -181,7 +181,7 @@ No duplicate quest or workflow edit belongs here. ## Required -- [Task capacity after bursts](/quest/m2/performance-survey/task-capacity.md) - polling and memory after peak task occupancy subsides +- [Task capacity after bursts](/quest/m2/performance-survey/task-capacity.md) - memory a task set keeps after peak occupancy subsides - [QUIC receive-state reuse](/quest/m2/performance-survey/quic-receive-reuse.md) - reassembly metadata retained by recycled completed streams - [Cache metadata](/quest/m2/performance-survey/cache-metadata.md) - bookkeeping capacity after cached groups expire - [Bandwidth grants](/quest/m2/performance-survey/bandwidth-grants.md) - repeated full allocation in Rust and JS diff --git a/quest/m2/performance-survey/task-capacity.md b/quest/m2/performance-survey/task-capacity.md index 708e03195dd..d0124bb56ad 100644 --- a/quest/m2/performance-survey/task-capacity.md +++ b/quest/m2/performance-survey/task-capacity.md @@ -1,59 +1,54 @@ -# [M] Task polling recovers after a burst +# [M] Task sets release memory after a burst ## Goal -Long-lived task sets do not pay an unjustified steady-state CPU or memory -cost for a burst that has already drained. +A long-lived task set does not keep an unjustified amount of memory for a +burst that has already drained, or the measurement shows that the retained +capacity is worth what it saves. ## Plan -[kio::Tasks](/rs/kio/src/task.rs) grows slots and per-slot wakers, retains -them after completion, and snapshots every allocated wake-bit word in each -`Tasks::poll`. Dispatch scans those words again. Work can depend on -historical peak/64 even when only one task remains. The io_uring worker and -session serving loops are current consumers. - -Extend [task benchmarks](/rs/kio/benches/tasks.rs) with peak slots × current -live/ready slots: burst then one survivor, sparse live slots, full occupancy, -and empty-but-polled. Report instructions and CPU per owner poll/per ready -task, allocations and retained bytes after draining. Compare a long-lived -worker or session before and after connection/subscription churn. - -The present sparse-wake case fixes 16 ready tasks among 1,000 residents; it -does not isolate historical capacity. Any reclamation or ready-word index -must preserve stale-waker safety, concurrent wakeups, coalescing and snapshot -fairness. Do not trade cheap burst reuse for churn without measuring both. - -A 2026-10-22 staging CDN profile (`f57c0aad`, DWARF stacks, 27-36% incomplete) -put `Tasks::poll` at 2.40% self-time on a 501-connection chat -shape and 1.90% on SD, the largest moq-net site on both, and kio -tasks/waiters at 7.5-11.5% of samples. Self-time only; it does not show how -much is historical capacity. - -Measured in the 2026-10-27 audit on main -[`651c30fee9`](https://github.com/moq-dev/moq/commit/651c30fee9d88fd56f4079433dd3e1f98d77f21c), -one live, non-ready task left after a burst (the task carries only a `bool`, -so real session tasks retain more per slot). Dropping the set returned the -heap to baseline, so this is retained capacity, not a leak. Timings are -unpinned wall clock on a shared host, so they show the slope only: - -| Historical peak | Retained heap | Median ns per owner poll | -|---|---|---| -| 1 | 912 B | 79.9 | -| 1,024 | 188,768 B | 86.6 | -| 16,384 | 3,019,184 B | 1,216.1 | -| 65,536 | 12,076,592 B | 4,830.8 | - -Candidates to evaluate: reclaimable slot chunks, and an index of nonempty -ready words so a poll does not scan historical capacity. Include owner polls -triggered by unrelated active work in the sweep. - -Related: [worker quiescence](/quest/m2/perf/uring-quiescence.md) and -[relay allocations](/quest/m2/perf/relay-allocs.md) own other costs. -Worker quiescence also reshapes the `Tasks::poll` snapshot with a pass -budget; whichever of the two lands second rebases its benchmark on the other. +[kio::Tasks](/rs/kio/src/task.rs) keeps every slot it ever grew: the slab +entry (`Option>`, whose `Park` holds an inline `Waiter` of about +100 B), the per-slot waker allocation, and the wake bitset. The CPU half of +this quest landed in [moq-dev/moq#7513](https://github.com/moq-dev/moq/pull/7513). +A three-level wake index means an owner poll visits only woken words, so poll +cost no longer depends on the historical peak. The memory half remains. + +Decided 2026-11-01 by the maintainer on +[moq-dev/moq#7513](https://github.com/moq-dev/moq/pull/7513): + +- ✅ (a) Land the CPU half as is, and keep this quest for the memory half: + reclaimable slot chunks, measured against repeat-burst cost and the real + consumers' task sizes. +- (b) Treat retained capacity as justified and delete this quest. +- (c) Implement chunk reclaim in the same PR. + +The [`task_capacity` bench](/rs/kio/benches/task_capacity.rs) reports +retained bytes after a burst. With an 8-byte task, the set keeps about +184 B per peak slot whatever is still live: 188,912 B after a 1,024-task +burst and 12,085,304 B after 65,536. A real task adds `size_of::()` +per slot, so measure the actual consumers (`GroupServe`, the io_uring worker's +`Task`, the session sets) before judging the cost. Dropping the set returns +the heap to baseline, so this is retained capacity, not a leak. + +The candidate is reclaimable slot chunks. Store slots, wakers and wake bits per +1,024-slot chunk, and free a chunk once it empties. That needs allocation to +prefer low free slots, so chunks can drain while one survivor stays behind, +plus some hysteresis so a set oscillating across a chunk boundary does not +reallocate 1,024 wakers each time. Today's LIFO free list and full reuse make +a repeated burst cheap. In the harness from +[moq-dev/moq#7513](https://github.com/moq-dev/moq/pull/7513), a repeated +1,024-task burst through a drained set costs about 840 instructions per task, +the task's own allocations included, and the set allocates nothing for it. Do +not trade that for reclaimed memory without measuring both, with an A/A control. + +Any reclamation must preserve stale-waker safety. A waker that outlives its +chunk must keep its chunk alive or point at nothing. It must also keep +concurrent wakeups, coalescing, snapshot fairness and the rotation order +(`concurrent_wakes_are_never_lost`, `wakes_past_64_chunks_run_in_slot_order`). Follow the [survey's benchmark contract](/quest/m2/performance-survey/README.md): -measure the baseline before optimizing, retain paired evidence, verify equivalent -work, schedule coverage nightly, and accept a measured no-win. Expected public -API and wire impact: none; report the actual impact in the implementation PR. +measure the baseline before optimizing, retain paired evidence, verify +equivalent work, schedule coverage nightly, and accept a measured no-win. +Expected public API and wire impact: none. diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index 1b457a46767..4e6291cebf9 100644 --- a/rs/kio/Cargo.toml +++ b/rs/kio/Cargo.toml @@ -68,6 +68,10 @@ tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "test name = "tasks" harness = false +[[bench]] +name = "task_capacity" +harness = false + [[bench]] name = "waiter" harness = false diff --git a/rs/kio/benches/task_capacity.rs b/rs/kio/benches/task_capacity.rs new file mode 100644 index 00000000000..81f7fcf35be --- /dev/null +++ b/rs/kio/benches/task_capacity.rs @@ -0,0 +1,119 @@ +//! Heap a [`kio::Tasks`] set keeps after a burst drains, swept over the burst's peak. +//! +//! Run `cargo bench -p kio --bench task_capacity` and compare the table across +//! revisions. Each row grows a set to `peak` parked tasks, removes all but +//! `live` of them, and reports the bytes the set still holds (its slots, wakers, +//! wake bitset and live tasks; the tasks' gates are outside the count), the +//! allocations per owner poll over 1,000 more polls, and the bytes left once +//! the set and its gates drop (nonzero would be a leak). Per-poll time for the +//! same shapes is in the `tasks` bench's `tasks_capacity_*` groups. + +use std::{ + alloc::{GlobalAlloc, Layout, System}, + hint::black_box, + sync::{ + Arc, Mutex, + atomic::{AtomicIsize, AtomicUsize, Ordering}, + }, + task::{Poll, Waker}, +}; + +struct Counter; + +static LIVE: AtomicIsize = AtomicIsize::new(0); +static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0); + +#[global_allocator] +static ALLOCATOR: Counter = Counter; + +// Counting only. Every call forwards to the system allocator unchanged. +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + LIVE.fetch_add(layout.size() as isize, Ordering::Relaxed); + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + LIVE.fetch_add(layout.size() as isize, Ordering::Relaxed); + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + LIVE.fetch_add(new_size as isize - layout.size() as isize, Ordering::Relaxed); + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + LIVE.fetch_sub(layout.size() as isize, Ordering::Relaxed); + unsafe { System.dealloc(ptr, layout) } + } +} + +/// A parked waker slot a task re-arms on every poll, so each task holds one +/// registration the way a channel read does. +type Gate = Arc>>; + +fn task(gate: Gate) -> impl FnMut(&kio::Waiter) -> Poll<()> { + move |waiter| { + *gate.lock().unwrap() = Some(waiter.waker().clone()); + Poll::Pending + } +} + +fn main() { + println!("| peak | live | retained bytes | bytes per peak slot | allocations per poll | bytes after drop |"); + println!("|---:|---:|---:|---:|---:|---:|"); + for peak in [1, 1024, 16_384, 65_536] { + let mut lives = vec![0, 1, peak / 64]; + lives.sort(); + lives.dedup(); + for live in lives { + let start = LIVE.load(Ordering::Relaxed); + let gates: Vec = (0..peak).map(|_| Gate::default()).collect(); + // Gates, keys and the owner's waiter precede the baseline, so only the set counts. + let mut keys = Vec::with_capacity(peak); + let waiter = kio::Waiter::noop(); + let base = LIVE.load(Ordering::Relaxed); + + let mut tasks = kio::Tasks::new(); + for gate in &gates { + keys.push(tasks.insert(task(gate.clone()))); + } + let _ = tasks.poll(&waiter); + // Keep every `peak / live`-th task, so live slots spread over the peak. + let stride = peak.checked_div(live).unwrap_or(0); + for (slot, key) in keys.drain(..).enumerate() { + if live == 0 || !slot.is_multiple_of(stride) { + drop(tasks.remove(key)); + // The removed task's registration goes with it, as a channel's would. + gates[slot].lock().unwrap().take(); + } + } + assert_eq!(tasks.len(), live); + let retained = LIVE.load(Ordering::Relaxed) - base; + + let before = ALLOCATIONS.load(Ordering::Relaxed); + for round in 0..1_000 { + if live > 0 { + let gate = &gates[(round % live) * stride]; + if let Some(waker) = gate.lock().unwrap().take() { + waker.wake(); + } + } + let _ = black_box(tasks.poll(&waiter)); + } + let allocations = (ALLOCATIONS.load(Ordering::Relaxed) - before) as f64 / 1_000.0; + + // Parked wakers hold their slot's waker state until the gates drop. + drop((tasks, gates, keys, waiter)); + let after = LIVE.load(Ordering::Relaxed) - start; + println!( + "| {peak} | {live} | {retained} | {:.1} | {allocations} | {after} |", + retained as f64 / peak as f64 + ); + } + } +} diff --git a/rs/kio/benches/tasks.rs b/rs/kio/benches/tasks.rs index accda24a341..054342a3e9e 100644 --- a/rs/kio/benches/tasks.rs +++ b/rs/kio/benches/tasks.rs @@ -23,6 +23,20 @@ //! which is the driver shape: `Tasks` reuses retired slots and their wakers, //! so steady-state churn stops allocating. //! +//! The `tasks_capacity_*` groups sweep a set's historical peak (1 to 65,536 +//! slots) against what is live and woken now, `kio::Tasks` alone. A burst grows +//! the set, and all but the named tasks are removed before measuring, so a cost +//! that rises with the peak is one the drained burst left behind: +//! +//! - `idle`: one live task, nothing woken. The owner polled for unrelated work. +//! - `survivor`: one live task, woken every poll. +//! - `sparse`: every 64th slot live, 16 of them woken per poll. +//! - `full`: every slot live, 16 woken per poll (no drained burst at all). +//! - `churn`: nothing live; 16 tasks pushed and drained per iteration, reusing +//! the slots the burst left. +//! +//! Retained bytes after the burst are in the `task_capacity` bench. +//! //! Run with `cargo bench -p kio`. use std::{ @@ -36,7 +50,7 @@ use std::{ task::{Context, Poll, Waker}, }; -use criterion::{Criterion, criterion_group, criterion_main}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use futures::stream::StreamExt; /// The shared wakeable primitive: a resettable flag plus a parked waker. @@ -236,5 +250,83 @@ fn spawn_drain_hot(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, wake_sparse, spawn_drain, spawn_drain_hot); +/// Historical peaks: one task, one chunk, and 16 and 64 chunks (the exact reach +/// of the top wake-index word). +const PEAKS: [usize; 4] = [1, 1024, 16_384, 65_536]; + +/// A set that grew to `peak` tasks, every one parked, then removed all but the +/// slots `live` keeps. Returns the set and the live tasks' gates. +#[expect(clippy::type_complexity, reason = "an opaque task type cannot be aliased on stable")] +fn after_burst( + peak: usize, + live: impl Fn(usize) -> bool, +) -> (kio::Tasks Poll<()>>, Vec>) { + let gates: Vec<_> = (0..peak).map(|_| Gate::new()).collect(); + let mut tasks = kio::Tasks::new(); + let keys: Vec<_> = gates.iter().map(|gate| tasks.insert(forever(gate.clone()))).collect(); + let _ = tasks.poll(&kio::Waiter::noop()); + let mut kept = Vec::new(); + for (slot, (key, gate)) in keys.into_iter().zip(gates).enumerate() { + match live(slot) { + true => kept.push(gate), + false => drop(tasks.remove(key)), + } + } + (tasks, kept) +} + +fn capacity(c: &mut Criterion) { + // Live slot predicate and tasks woken per owner poll. + type Shape = (&'static str, fn(usize) -> bool, usize); + let shapes: [Shape; 4] = [ + ("idle", |slot| slot == 0, 0), + ("survivor", |slot| slot == 0, 1), + ("sparse", |slot| slot % 64 == 0, 16), + ("full", |_| true, 16), + ]; + for (name, live, ready) in shapes { + let mut group = c.benchmark_group(format!("tasks_capacity_{name}")); + for peak in PEAKS { + group.bench_with_input(BenchmarkId::from_parameter(peak), &peak, |b, &peak| { + let (mut tasks, gates) = after_burst(peak, live); + let ready = ready.min(gates.len()); + let waiter = kio::Waiter::noop(); + let mut next = 0; + b.iter(|| { + for _ in 0..ready { + gates[next].fire(); + next = (next + 1) % gates.len(); + } + let _ = black_box(tasks.poll(&waiter)); + }); + }); + } + group.finish(); + } + + let mut group = c.benchmark_group("tasks_capacity_churn"); + for peak in PEAKS { + group.bench_with_input(BenchmarkId::from_parameter(peak), &peak, |b, &peak| { + fn done(_: &kio::Waiter) -> Poll<()> { + Poll::Ready(()) + } + let waiter = kio::Waiter::noop(); + let mut tasks = kio::Tasks::new(); + for _ in 0..peak { + tasks.push(done); + } + while tasks.poll(&waiter).is_pending() {} + b.iter(|| { + for _ in 0..16 { + tasks.push(done); + } + while tasks.poll(&waiter).is_pending() {} + black_box(&tasks); + }); + }); + } + group.finish(); +} + +criterion_group!(benches, wake_sparse, spawn_drain, spawn_drain_hot, capacity); criterion_main!(benches); diff --git a/rs/kio/src/task.rs b/rs/kio/src/task.rs index 6c3e583baf7..df4fb3dfe89 100644 --- a/rs/kio/src/task.rs +++ b/rs/kio/src/task.rs @@ -29,15 +29,22 @@ //! [unicycle](https://github.com/udoprog/unicycle) proved out against //! `FuturesUnordered`'s intrusive list): a wake is one `fetch_or`, lock-free, //! and inherently deduplicated since setting a set bit is a no-op. Each poll -//! snapshots a word at a time with `swap(0)`, so a task woken mid-pass lands in -//! the next pass instead of spinning this one. Because tasks are closures, not -//! pinned futures, a slot's identity is stable and its [`Waker`] is minted once -//! and reused across occupants: the per-task allocation `FuturesUnordered` +//! snapshots the woken words with `swap(0)` before running anything, so a task +//! woken mid-pass lands in the next pass instead of spinning this one. Because +//! tasks are closures, not pinned futures, a slot's identity is stable and its +//! [`Waker`] is minted once and reused across occupants: the per-task allocation `FuturesUnordered` //! pays per future (and futures-buffered removes with an unsafe arena) is a //! per-slot cost here, amortized to zero in a long-lived set. An owner that //! wakes its own tasks mid-poll (a driver advancing its clock) runs that poll in //! [`Waiter::batch`], so the set's wake of the owner is not a second poll. Safe Rust //! throughout. +//! +//! The bitset is indexed so a poll visits only woken words, not every word the +//! set ever grew: a word's first bit also marks it in its chunk's summary, and a +//! chunk's first marked word also marks bit `chunk % 64` of one word shared by +//! the whole set. A poll with nothing woken is then one `swap`, however many +//! slots a past burst left behind. Up to 64 chunks (65,536 slots) the top word +//! is exact; past that a bit stands for every 64th chunk, one `swap` each. use std::{ cell::{Cell, RefCell}, @@ -57,11 +64,19 @@ const CHUNK_SLOTS: usize = WORD_BITS * CHUNK_WORDS; /// One fixed block of the wake bitset. Chunks never move or grow, so a waker /// can hold its chunk directly and set its bit without any lock. -struct Chunk([AtomicU64; CHUNK_WORDS]); +struct Chunk { + words: [AtomicU64; CHUNK_WORDS], + /// Bit `w` is set once word `w` went nonzero and stays set until a poll + /// takes the word, so a poll swaps only the words that hold wakes. + summary: AtomicU64, +} impl Chunk { fn new() -> Arc { - Arc::new(Self(std::array::from_fn(|_| AtomicU64::new(0)))) + Arc::new(Self { + words: std::array::from_fn(|_| AtomicU64::new(0)), + summary: AtomicU64::new(0), + }) } } @@ -83,10 +98,11 @@ pub struct Tasks { free: Vec, len: usize, shared: Arc, - /// The readiness snapshot each poll dispatches from, taken before any task - /// runs so a mid-pass wake lands in the next pass wherever its slot sits. + /// The readiness snapshot each poll dispatches from: the woken words as + /// `(word index, bits)` in ascending word order, taken before any task runs + /// so a mid-pass wake lands in the next pass wherever its slot sits. /// Retained so steady-state polls don't allocate. - scratch: Vec, + scratch: Vec<(usize, u64)>, /// The slot that leads the next readiness snapshot, rotated after every /// nonempty pass so wake-all resources cannot favor low-numbered tasks. next: usize, @@ -118,6 +134,9 @@ struct Shared { /// Set by the first wake since the last poll began, so a burst of wakes /// costs one parent wake, not one each. pending: AtomicBool, + /// The chunks with a marked summary: bit `c % 64` for chunk `c`. The top + /// level of the wake index, so a poll skips the chunks nothing woke. + ready: AtomicU64, } impl Shared { @@ -220,10 +239,12 @@ impl Polling { } /// The per-slot waker: sets the slot's wake bit and nudges the owner. +/// +/// One lives per slot the set ever grew, so it keeps only the slot index and +/// derives its bits from that. struct SlotWaker { chunk: Arc, - word: usize, - bit: u64, + slot: usize, shared: Arc, } @@ -235,9 +256,21 @@ impl Wake for SlotWaker { fn wake_by_ref(self: &Arc) { // An already-set bit means the slot is queued for the next pass and the // owner was already nudged: a burst of wakes is one bit and one nudge. - if self.chunk.0[self.word].fetch_or(self.bit, Ordering::AcqRel) & self.bit == 0 { - self.shared.wake_parent(); + let index = (self.slot % CHUNK_SLOTS) / WORD_BITS; + let bit = 1 << (self.slot % WORD_BITS); + let word = self.chunk.words[index].fetch_or(bit, Ordering::AcqRel); + if word & bit != 0 { + return; + } + // Only the wake that makes a level nonzero marks the level above. A poll + // clears each level before taking the one below it, so a later wake into a + // still-nonzero level is either taken by that poll or covered by the mark + // the level's first wake left (or is about to leave) for the next one. + if word == 0 && self.chunk.summary.fetch_or(1 << index, Ordering::AcqRel) == 0 { + let chunk = self.slot / CHUNK_SLOTS; + self.shared.ready.fetch_or(1 << (chunk % WORD_BITS), Ordering::AcqRel); } + self.shared.wake_parent(); } } @@ -253,6 +286,7 @@ impl Tasks { shared: Arc::new(Shared { parent: Mutex::new(None), pending: AtomicBool::new(false), + ready: AtomicU64::new(0), }), scratch: Vec::new(), next: 0, @@ -291,8 +325,7 @@ impl Tasks { self.children.push(None); self.wakers.push(Waker::from(Arc::new(SlotWaker { chunk: self.chunks[index / CHUNK_SLOTS].clone(), - word: (index % CHUNK_SLOTS) / WORD_BITS, - bit: 1 << (index % WORD_BITS), + slot: index, shared: self.shared.clone(), }))); index @@ -402,14 +435,31 @@ impl Tasks { // during the pass (by another task or a self-wake) lands in the next // pass wherever its slot sits, never this one. Dispatching straight off // the words instead would run a forward wake chain in a single pass, - // starving the caller's other arms. + // starving the caller's other arms. Each level is cleared before the + // level below it is read, the order the wakers' marks rely on. let mut scratch = std::mem::take(&mut self.scratch); scratch.clear(); - for chunk in &self.chunks { - for word in &chunk.0 { - scratch.push(word.swap(0, Ordering::AcqRel)); + let mut ready = self.shared.ready.swap(0, Ordering::AcqRel); + while ready != 0 { + let first = ready.trailing_zeros() as usize; + ready &= ready - 1; + for (chunk_index, chunk) in self.chunks.iter().enumerate().skip(first).step_by(WORD_BITS) { + let mut words = chunk.summary.swap(0, Ordering::AcqRel); + while words != 0 { + let word = words.trailing_zeros() as usize; + words &= words - 1; + let bits = chunk.words[word].swap(0, Ordering::AcqRel); + if bits != 0 { + scratch.push((chunk_index * CHUNK_WORDS + word, bits)); + } + } } } + // Up to 64 chunks the bits visit them in order; past that, a bit's chunks + // interleave with the next bit's. + if self.chunks.len() > WORD_BITS { + scratch.sort_unstable_by_key(|&(word, _)| word); + } let slots = self.children.len(); let mut advanced = false; @@ -421,8 +471,12 @@ impl Tasks { } let first_word = from / WORD_BITS; let last_word = (to - 1) / WORD_BITS; - for (word_index, word) in scratch.iter().enumerate().take(last_word + 1).skip(first_word) { - let mut bits = *word; + let start = scratch.partition_point(|&(word, _)| word < first_word); + for &(word_index, word) in &scratch[start..] { + if word_index > last_word { + break; + } + let mut bits = word; if word_index == first_word { bits &= u64::MAX << (from % WORD_BITS); } @@ -937,6 +991,127 @@ mod tests { assert_eq!(*order.lock().unwrap(), [0, 1, 2, 1, 2, 0, 2, 0, 1]); } + /// Past 64 chunks a bit of the top index word stands for every 64th chunk: + /// each woken task still runs exactly once, in rotated slot order, and a + /// chunk sharing a woken chunk's bit is not polled for it. + #[test] + #[cfg_attr(miri, ignore = "65 chunks of tasks take too long to interpret")] + fn wakes_past_64_chunks_run_in_slot_order() { + fn task(id: usize, order: Arc>>) -> impl FnMut(&Waiter) -> Poll<()> { + move |_| { + order.lock().unwrap().push(id); + Poll::Pending + } + } + + let order = Arc::new(Mutex::new(Vec::new())); + let mut tasks = Tasks::new(); + let slots = 66 * CHUNK_SLOTS; + for id in 0..slots { + tasks.push(task(id, order.clone())); + } + let waiter = Waiter::noop(); + assert!(tasks.poll(&waiter).is_pending()); + assert_eq!(order.lock().unwrap().len(), slots, "every new task ran once"); + order.lock().unwrap().clear(); + + // Chunks 1 and 65 share a top bit, as do 0 and 64; chunk 2 has its own. + let woken = [ + 65 * CHUNK_SLOTS + 3, + CHUNK_SLOTS + 7, + 2 * CHUNK_SLOTS, + 64 * CHUNK_SLOTS, + 5, + ]; + for &slot in &woken { + tasks.wakers[slot].wake_by_ref(); + } + assert!(tasks.poll(&waiter).is_pending()); + let mut sorted = woken; + sorted.sort(); + assert_eq!( + *order.lock().unwrap(), + sorted, + "ascending slot order from the rotation point" + ); + order.lock().unwrap().clear(); + + // The rotation point moved past the first slot run, which now runs last. + for &slot in &woken { + tasks.wakers[slot].wake_by_ref(); + } + assert!(tasks.poll(&waiter).is_pending()); + let mut rotated = sorted[1..].to_vec(); + rotated.push(sorted[0]); + assert_eq!(*order.lock().unwrap(), rotated); + + order.lock().unwrap().clear(); + assert!(tasks.poll(&waiter).is_pending()); + assert!(order.lock().unwrap().is_empty(), "nothing was woken"); + } + + /// Wakes from other threads racing the owner's polls are never lost: every + /// task woken after its last poll runs again. + #[test] + fn concurrent_wakes_are_never_lost() { + const TASKS: usize = 3 * CHUNK_SLOTS; + const THREADS: usize = 4; + const ROUNDS: usize = if cfg!(miri) { 16 } else { 2_000 }; + + // Each task records the highest wake count it has observed. + let counts: Arc> = Arc::new((0..TASKS).map(|_| AtomicUsize::new(0)).collect()); + let seen: Arc> = Arc::new((0..TASKS).map(|_| AtomicUsize::new(0)).collect()); + let mut tasks = Tasks::new(); + for id in 0..TASKS { + let (counts, seen) = (counts.clone(), seen.clone()); + tasks.push(move |_: &Waiter| { + seen[id].store(counts[id].load(Ordering::SeqCst), Ordering::SeqCst); + Poll::Pending + }); + } + + let wakes = Arc::new(Wakes(AtomicUsize::new(0))); + let waiter = Waiter::new(Waker::from(wakes.clone())); + assert!(tasks.poll(&waiter).is_pending()); + let wakers: Arc> = Arc::new(tasks.wakers.clone()); + + let threads: Vec<_> = (0..THREADS) + .map(|thread| { + let (counts, wakers) = (counts.clone(), wakers.clone()); + std::thread::spawn(move || { + // Spread each thread's wakes over every word and chunk. + let mut slot = thread * 997; + for _ in 0..ROUNDS { + for _ in 0..8 { + slot = (slot + 4099) % TASKS; + counts[slot].fetch_add(1, Ordering::SeqCst); + wakers[slot].wake_by_ref(); + } + } + }) + }) + .collect(); + // Poll while the wakers run: a wake that marks a word or chunk the + // running poll already passed must stay indexed for a later one. + while !threads.iter().all(|thread| thread.is_finished()) { + assert!(tasks.poll(&waiter).is_pending()); + } + for thread in threads { + thread.join().unwrap(); + } + assert!(wakes.0.load(Ordering::SeqCst) > 0, "no wake reached the owner"); + + // Every wake is in; one more poll must run each task woken since its last. + assert!(tasks.poll(&waiter).is_pending()); + for id in 0..TASKS { + assert_eq!( + seen[id].load(Ordering::SeqCst), + counts[id].load(Ordering::SeqCst), + "task {id} lost a wake" + ); + } + } + /// Drive a set to completion as a future. Generic on purpose: the two tests /// below hand this same code to `tokio::spawn` (which demands `Send`) and to /// `spawn_local` with `!Send` tasks. Send-ness is inferred from the task From b8f13fa21abda602c6e309bfcfbee302553fd39f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:08:19 +0000 Subject: [PATCH 63/86] docs(quest): retire the superseded unknown-range marker quest (#7503) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/ietf-publisher-unknown-range.md | 37 ------------------------ 2 files changed, 38 deletions(-) delete mode 100644 quest/m1/ietf-publisher-unknown-range.md diff --git a/quest/m1/README.md b/quest/m1/README.md index e24b335d82e..860a70a2ff6 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -162,7 +162,6 @@ per-frame CPU growth since #5926. - [moq-quic's API improves on quinn's](/quest/m1/quic-api/README.md) - moq-quic and both QUIC drivers ship one mirrored API: `send::`/`recv::` namespaces, one error per direction, consuming terminal operations, plain configs, no quinn baggage - [On-demand JavaScript FETCH](/quest/m1/js-fetch-on-demand.md) - browser publishers answer IETF FETCH for evicted or uncached groups through the JS ranges request surface - [Rust cancels an unclaimed fill head when a subscription ends](/quest/m1/ietf-fill-end-cancel.md) - SUBSCRIPTION_ENDED cancels, not finishes, a fill no group was sent for, as in JS -- [Rust marks a lost listed group unknown](/quest/m1/ietf-publisher-unknown-range.md) - the Rust IETF publisher sends End of Unknown Range for a listed group it can't serve, after the publisher's open quests land - [Native rung disable](/quest/m1/simulcast-rung-disable.md) - OBS stops sending the top renditions its grant cannot fund, as the Rust/FFI publishers do, and a shaped uplink through moq-ffi proves the resume - [Audio jitter target](/quest/m1/audio-jitter-target/README.md) - the playout target is default in both languages; a manual browser proof and a native trace replay remain - [A/V clock](/quest/m1/av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles diff --git a/quest/m1/ietf-publisher-unknown-range.md b/quest/m1/ietf-publisher-unknown-range.md deleted file mode 100644 index dfb757042fe..00000000000 --- a/quest/m1/ietf-publisher-unknown-range.md +++ /dev/null @@ -1,37 +0,0 @@ -# [S] The Rust IETF publisher marks a lost listed group unknown - -## Goal - -When the Rust moq-transport publisher has listed a group in a FETCH answer or -a draft-22 fill but can no longer serve it, it sends End of Unknown Range -(`0x10C`) for that group on drafts 18 and 22, as the JS publisher does, instead -of skipping it silently. - -## Plan - -Found 2026-10-28 by [moq-dev/moq#7150](https://github.com/moq-dev/moq/pull/7150), -which fixed the JS publisher. In `rs/moq-net/src/ietf/publisher.rs` Rust skips -such a group in two places: the FETCH answer loop in `serve_fetch_stream`, on -`NotFound | NotFetchable | Unsupported | Old`, and `run_fill`, when -`fetch_group` fails with the same errors. - -Rust can refetch from upstream, so a skip there means a failed refetch rather -than only an eviction. `NotFound` and `Old` map to `0x10C`. Decide in the PR -whether `Unsupported` and `NotFetchable` mean "unknown" too, or need their own -handling, and record the reason. - -Decided 2026-10-28 (maintainer): it starts after the open quests that edit -the same publisher land. Those have landed: Rust FETCH parity in -[moq-dev/moq#6999](https://github.com/moq-dev/moq/pull/6999), and the -SUBSCRIBE properties opt-out in -[moq-dev/moq#7085](https://github.com/moq-dev/moq/pull/7085). A third was -IETF fill timeout, which -[moq-dev/moq#7194](https://github.com/moq-dev/moq/pull/7194) dropped for -[one group](https://github.com/moq-dev/moq/pull/7204), which edited the same -paths and has landed. A FETCH or fill now lists at most one group, so this -marks that one group. - -Tests: a listed group evicted before it is written, for FETCH on drafts 18 -and 22 and for a draft-22 fill, each failing before the fix. Run `just test interop --all` if the framing order changes. - -Public API: none. Wire: none; the marker exists in both drafts. From a66c8a7beb4823e800ea41d990b209ec14e2e8ae Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:09:14 +0000 Subject: [PATCH 64/86] perf(moq-net): fanout subscribers stop contending the shared track lock (#7493) Co-authored-by: Claude Opus 5.5 --- quest/m1/release-perf/README.md | 21 ++- quest/m1/release-perf/fanout-lock.md | 61 ------- quest/m1/release-perf/relay-cpu-bisect.md | 2 +- rs/moq-net/Cargo.toml | 9 +- rs/moq-net/benches/session.rs | 1 + rs/moq-net/benches/session/mod.rs | 107 +++++++++--- rs/moq-net/src/lite/publisher.rs | 34 ++++ rs/moq-net/src/model/cache.rs | 17 +- rs/moq-net/src/model/resume.rs | 13 +- rs/moq-net/src/model/track.rs | 38 ++++- rs/moq-net/src/model/track/idle.rs | 137 +--------------- rs/moq-net/src/model/track/miss.rs | 5 + rs/moq-net/src/util.rs | 2 + rs/moq-net/src/util/idle.rs | 191 ++++++++++++++++++++++ 14 files changed, 407 insertions(+), 231 deletions(-) delete mode 100644 quest/m1/release-perf/fanout-lock.md create mode 100644 rs/moq-net/src/util/idle.rs diff --git a/quest/m1/release-perf/README.md b/quest/m1/release-perf/README.md index c265a7c08c1..1f974bc788d 100644 --- a/quest/m1/release-perf/README.md +++ b/quest/m1/release-perf/README.md @@ -1,4 +1,4 @@ -# Main is no slower than release +# [S] Main is no slower than release ## Goal @@ -266,11 +266,26 @@ Decided 2026-10-30 (maintainer), in - The sparse rows are accepted as that per-group model cost. - The fanout mode is accidental and is fixed before the cut, in - [Fanout lock contention](/quest/m1/release-perf/fanout-lock.md). + [moq-dev/moq#7493](https://github.com/moq-dev/moq/pull/7493). + +That fix cut the shared track lock from about 155 acquisitions per delivered +group to 34 (release takes 31), counted on one worker. Most were the +logical-track reader re-reading every copy's end, the retention edge, and a +probe's fills on each of several reads per wake, plus a whole track walk on +every group machine's wake. Its own pairing, 14 alternating rounds at load 7 +to 28, found no high-mode round on either side: relay CPU 0.066 to 0.090 cores +against release's 0.062 to 0.104, with mixed-sign paired deltas inside +release's own spread, and 5 to 36% fewer user-space instructions than release +in every round. The re-pair above, on a quieter host, is what remains. + +Decided 2026-11-01 (maintainer), in +[moq-dev/moq#7493](https://github.com/moq-dev/moq/pull/7493): ✅ the new +`session_fanout_threads` Criterion case joins the release pairing through a +bench-only backport to `release`, which carries it once that PR lands. Bench-only +backports are allowed from now on. ## Required -- [Fanout lock contention](/quest/m1/release-perf/fanout-lock.md) - relay fanout subscribers stop contending one track lock, so main's relay CPU matches release's in every round - [Relay CPU per frame since #5926](/quest/m1/release-perf/relay-cpu-bisect.md) - the 18-56% rise in relay instructions per delivered frame is bisected, attributed, and its avoidable cost fixed ## Related diff --git a/quest/m1/release-perf/fanout-lock.md b/quest/m1/release-perf/fanout-lock.md deleted file mode 100644 index 4c22fcc1b05..00000000000 --- a/quest/m1/release-perf/fanout-lock.md +++ /dev/null @@ -1,61 +0,0 @@ -# [M] Relay fanout subscribers stop contending one track lock - -## Goal - -The relay's `fanout` workload (`bench/relay.sh`, 64 subscribers of one track) -uses no more relay CPU on `main` than on `release` in every alternating -round, with the same delivery. Subscribers of one track no longer serialize -on the track's shared `kio::Lock` for every frame, so spreading their tasks -across Tokio workers no longer multiplies the relay's CPU. - -## Plan - -Found by the 2026-10-30 re-run in the -[release-perf epic](/quest/m1/release-perf/README.md) -([moq-dev/moq#7299](https://github.com/moq-dev/moq/pull/7299)), `release` -`de0a2b0534` against `main` `7c8e255304`. Decided 2026-10-30 (maintainer): -this is accidental cost, fixed before the cut. - -What the profile found: - -- `main`'s relay is bimodal under `fanout`. Release used 0.062 to 0.082 - cores in all seven rounds; main used 0.062 to 0.072 in three and 0.157 to - 0.241 in four, at the same 640 frames/s and p99. -- In the high mode 21 to 24 Tokio workers are busy and the relay makes 9,400 - to 12,600 voluntary context switches/s, against 2 to 6 workers and 400 to - 1,300 switches/s in the low mode. Release never exceeded 4 busy workers or - 2,800 switches/s. Eight CPU-profiled runs of `main` (frame pointers, the - default shared runtime) reproduced the high mode in five. -- In a high run 54% of samples end in a syscall (3 of 227 in a low run), - mostly futex waits on the track's shared `kio::Lock` and the wakes after - them. Every subscriber's read takes it, in the lite publisher's - `poll_recv_next` → `poll_group` / `poll_peek_group` and `poll_forward_drop` - → `poll_drop` (`rs/moq-net/src/lite/publisher.rs`), which reach the track - state through `rs/moq-net/src/model/resume.rs` and - `rs/moq-net/src/model/track.rs`. -- Packed onto a few workers, the 64 tasks never contend. Spread over many, - each frame wakes them all onto one mutex. - -Approach: - -- First find what `release`'s read path did differently, since release ran in - the same rounds and never spread. Compare how many times one subscriber's - wake takes the track lock on each branch. -- Then cut the per-wake lock work: take the lock once per wake rather than - once per poll step, or keep per-reader state outside the producer's mutex - so a reader only locks when a new group or drop is published. -- Add a Criterion case that reproduces the contention without the relay - harness (many readers of one track on a multi-threaded runtime), and keep - it in the release pairing. -- Verify with seven alternating `fanout` rounds against `origin/release`, - recording `/proc/loadavg`, busy workers, and voluntary context switches - per run; a quiet host is preferred. `release` has no `listen.tcp.tls`, so a - `just bench origin/release` pairing passes `"quic ws wss tcp"` (or just - `quic`). `just bench-profile` cannot yet run the default shared runtime, - where the mode shows up. - -Public API: none expected. Wire: none. - -## Related - -- [Log lock profile](/quest/m1/unbounded-groups/log-lock-profile.md) - lock contention profiling of `GroupState`, not the track lock diff --git a/quest/m1/release-perf/relay-cpu-bisect.md b/quest/m1/release-perf/relay-cpu-bisect.md index b206f31d9c9..96ce6aa7863 100644 --- a/quest/m1/release-perf/relay-cpu-bisect.md +++ b/quest/m1/release-perf/relay-cpu-bisect.md @@ -23,7 +23,7 @@ Decided 2026-11-01 (maintainer, follow-up planning after the 2026-11-01 /quest-a - Bisect `cdff3c8f95..main` on chat over io_uring first (the largest rise and the tightest ranges), with a step size that keeps each build's cost bounded, then confirm the culprits on video and on the tokio workers. A rise that shows on io_uring but not on the tokio workers is attributed on its own, not folded into a shared culprit. - Attribute each step with `perf record` at a fixed period, as #7505 did, before deciding fix or accept. Record accepted costs in the epic's Plan with the feature they pay for. - Commit the driver this needs as a deterministic mode of `just bench-profile` or `just bench-runtime` (pinned cores, instructions per delivered frame, fixed-period samples) instead of another throwaway script; #7505's driver on `bench/relay.sh` was not committed. -- [Fanout lock contention](/quest/m1/release-perf/fanout-lock.md) covers the bimodal futex cost on the fanout workload; don't fix it twice. A bisect step that lands on the same lock goes there. +- [moq-dev/moq#7493](https://github.com/moq-dev/moq/pull/7493) fixed the bimodal futex cost on the fanout workload; don't fix it twice. A bisect step that lands on the same lock starts from that PR's lock counts. Public API: none expected. Wire: none. diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 7ee1bb39b5a..a85d38b1860 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -53,12 +53,13 @@ futures = { workspace = true } kio = { path = "../kio", features = ["runtime", "sim"] } proptest = { workspace = true } serde_json = { workspace = true } -# The benches run on tokio. test-util (tokio::time::pause/advance) is test-only, so -# it must not leak into the normal dependency feature set. -tokio = { workspace = true, features = ["rt", "test-util", "time"] } +# The benches run on tokio, the session fanout bench across worker threads. test-util +# (tokio::time::pause/advance) is test-only, so it must not leak into the normal +# dependency feature set. +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "test-util", "time"] } tracing-test = { workspace = true } -# Optional per-thread CPU timing in the session benchmark. +# Thread and process CPU timing in the session benchmark. [target.'cfg(unix)'.dev-dependencies] libc = { workspace = true } diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index 304cc1a23d3..fdb194ff8d2 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -13,6 +13,7 @@ criterion_group!( session::fetch_range::, session::range_updates::, session::withdrawal::, + session::fanout_threads::, session::idle ); criterion_main!(benches); diff --git a/rs/moq-net/benches/session/mod.rs b/rs/moq-net/benches/session/mod.rs index 2032db72277..43faa4665e1 100644 --- a/rs/moq-net/benches/session/mod.rs +++ b/rs/moq-net/benches/session/mod.rs @@ -48,7 +48,13 @@ //! holds others open, swept over sessions and requests held per session, so a slope //! here is per-request bookkeeping growing with the sessions or the requests. //! -//! Everything runs on one current-thread runtime, so the only work measured is +//! `session_fanout_threads` serves one broadcast's one-frame groups through one relay to 64 +//! viewers on multi-threaded runtimes, timed in process CPU. Each group wakes every +//! subscription's serve task at once, so a read path that makes them take one shared lock +//! many times pays for it in lock waits and wakes across workers: the `workers=16` rows +//! climb above the `workers=1` ones. +//! +//! Everything else runs on one current-thread runtime, so the only work measured is //! protocol and relay work, never scheduling across threads. //! //! Run with `cargo bench -p moq-net --features fuzz --bench session`. @@ -283,40 +289,64 @@ fn runtime() -> tokio::runtime::Runtime { .unwrap() } -/// One round's clock; the CPU variant excludes scheduling delays from other processes. +/// A multi-threaded runtime with `workers` threads, for the scenarios timed in process CPU. +fn threaded(workers: usize) -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(workers) + .enable_time() + .build() + .unwrap() +} + +/// What a round is timed in. +#[derive(Clone, Copy)] +enum Clock { + Wall, + /// This thread's CPU time, excluding scheduling delays from other processes. + Thread, + /// Every thread's CPU time, for a multi-threaded runtime: lock waits and wakes across + /// its workers count too. + Process, +} + +/// One round's clock. enum MeasurementClock { Wall(Instant), #[cfg(unix)] - Cpu(Duration), + Cpu(libc::clockid_t, Duration), } impl MeasurementClock { - fn start(cpu: bool) -> Self { - if cpu { - #[cfg(unix)] - return Self::Cpu(Self::cpu()); - #[cfg(not(unix))] - panic!("SESSION_CPU requires Unix thread CPU clocks"); + fn start(clock: Clock) -> Self { + #[cfg(unix)] + let id = match clock { + Clock::Wall => return Self::Wall(Instant::now()), + Clock::Thread => libc::CLOCK_THREAD_CPUTIME_ID, + Clock::Process => libc::CLOCK_PROCESS_CPUTIME_ID, + }; + #[cfg(unix)] + return Self::Cpu(id, Self::cpu(id)); + #[cfg(not(unix))] + match clock { + Clock::Wall => Self::Wall(Instant::now()), + _ => panic!("CPU-timed rounds require Unix CPU clocks"), } - Self::Wall(Instant::now()) } fn elapsed(self) -> Duration { match self { Self::Wall(start) => start.elapsed(), #[cfg(unix)] - Self::Cpu(start) => Self::cpu() - .checked_sub(start) - .expect("thread CPU clock moved backwards"), + Self::Cpu(id, start) => Self::cpu(id).checked_sub(start).expect("CPU clock moved backwards"), } } #[cfg(unix)] - fn cpu() -> Duration { + fn cpu(id: libc::clockid_t) -> Duration { let mut time = libc::timespec { tv_sec: 0, tv_nsec: 0 }; // SAFETY: `time` is a valid, initialized output pointer for this supported clock. - let result = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut time) }; - assert_eq!(result, 0, "thread CPU clock: {}", std::io::Error::last_os_error()); + let result = unsafe { libc::clock_gettime(id, &mut time) }; + assert_eq!(result, 0, "CPU clock: {}", std::io::Error::last_os_error()); Duration::new( u64::try_from(time.tv_sec).unwrap(), u32::try_from(time.tv_nsec).unwrap(), @@ -600,7 +630,7 @@ struct Room { /// The group each viewer's subscriber is reading, when paced, flattened in viewer order. reading: Vec>, cache_now: moq_net::time::Instant, - cpu: bool, + clock: Clock, } impl Room { @@ -667,7 +697,10 @@ impl Room { writing: Vec::new(), reading: Vec::new(), cache_now: moq_net::time::Instant::now(), - cpu: std::env::var_os("SESSION_CPU").is_some(), + clock: match std::env::var_os("SESSION_CPU") { + Some(_) => Clock::Thread, + None => Clock::Wall, + }, }; room.writing.resize_with(room.watched.len(), || None); room.reading.resize_with(shape.viewers * shape.watch, || None); @@ -848,7 +881,7 @@ impl Room { let mut next_report = Duration::from_secs(10); for _ in 0..iters { self.tick(); - let start = MeasurementClock::start(self.cpu); + let start = MeasurementClock::start(self.clock); self.round().await; elapsed += start.elapsed(); if report && began.elapsed() >= next_report { @@ -1415,6 +1448,42 @@ impl IdleRoom { } } +/// One relay serving one broadcast's one-frame groups to 64 viewers, as the relay load +/// harness's `fanout` workload does, on multi-threaded runtimes timed in process CPU; see +/// the module docs. +pub(crate) fn fanout_threads(c: &mut Criterion) { + // Process CPU is read through a Unix clock. + if !cfg!(unix) || std::env::var_os("SESSION_ALLOCS").is_some() || std::env::var_os("SESSION_EXPIRY_ONLY").is_some() + { + return; + } + let shape = Shape { + publishers: 1, + viewers: 64, + frame: 400, + group_frames: 1, + ..Shape::BASE + }; + let mut group = c.benchmark_group(format!("{}session_fanout_threads", B::PREFIX)); + // Groups delivered per round: one to each viewer. + group.throughput(Throughput::Elements(shape.viewers as u64)); + for workers in [1, 16] { + let rt = threaded(workers); + for version in ["moq-lite-06", "moq-lite-07-wip"] { + let mut room = None; + group.bench_function(format!("{version}/workers={workers}"), |b| { + let room = room.get_or_insert_with(|| { + let mut room = rt.block_on(Room::::new(version, shape)); + room.clock = Clock::Process; + room + }); + b.iter_custom(|iters| rt.block_on(room.measure(iters))) + }); + } + } + group.finish(); +} + pub(crate) fn idle(c: &mut Criterion) { let mut group = c.benchmark_group("session_idle"); for version in ["moq-lite-06", "moq-transport-22"] { diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 39833aafaf7..dbd55906922 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -23,6 +23,7 @@ use crate::{ priority::{Priority, PriorityHandle, PriorityQueue}, track::Order, transport::poll::{GroupOf, SendGroup as _, SendOptions}, + util::Idle, }; use super::Version; @@ -3990,6 +3991,8 @@ struct TrackRun { opening: Option, /// The groups with a stream, serving or awaiting the acknowledgement of their FIN. children: kio::Tasks, + /// The track side's last poll, if it parked; see [`Self::poll_step`]. + idle: Option>, } impl TrackRun { @@ -4030,6 +4033,7 @@ impl TrackRun { datagrams, opening: None, children: kio::Tasks::new(), + idle: None, } } @@ -4047,6 +4051,9 @@ impl TrackRun { /// what lets relays pause an upstream subscription across consumer churn without /// tearing it down. Lite-07 ends one whose every range is bounded once it is covered. fn update(&mut self, upd: lite::SubscribeUpdate) { + // The update moves the cursor and the bounds the track side judges against, which + // notifies nothing it parked on. + Idle::disarm(&mut self.idle); // Every update restates the wanted group. Past our largest it is a join, which // repeats the pending ask; at or below it the group already exists. if upd @@ -4171,6 +4178,8 @@ impl TrackRun { if let Some(opening) = &mut self.opening && let Poll::Ready(res) = opening.poll_open(&mut self.ctx.session, self.ctx.group.as_ref(), waiter) { + // The track side takes groups again. + Idle::disarm(&mut self.idle); let opening = self.opening.take().expect("polled above"); match res { Ok(()) => self.children.push(opening), @@ -4178,7 +4187,13 @@ impl TrackRun { } } // Drive the in-flight group machines; completions just retire. + let running = self.children.len(); let _ = self.children.poll(waiter); + // A retired machine releases its group, and the track side waits on the machines + // to finish or cover the subscription, neither of which the track notifies. + if self.children.len() < running { + Idle::disarm(&mut self.idle); + } // A group whose header does not fit this peer's varint wrote nothing. The // reset is on a stream the subscriber never tied to this subscription, so // forward it here or the reader waits forever. @@ -4194,6 +4209,25 @@ impl TrackRun { return Poll::Ready(Ok(ControlFlow::Continue(()))); } + // Most wakes are a group machine's frame or the writer's credit. Nothing the track + // side parked on fired then, so it would park again: skip it rather than lock the + // shared track state every subscriber of the track reads through. + if Idle::parked(&self.idle, (), waiter) { + return Poll::Pending; + } + let mut idle = Idle::start(&mut self.idle, waiter); + let polled = self.poll_track(writer, idle.waiter()); + idle.finish(&mut self.idle, (), polled.is_pending()); + polled + } + + /// The track side of [`Self::poll_step`]: forward drops, resolve the start, and take the + /// next group, datagram, or boundary. Every wait here parks on the track subscriber. + fn poll_track( + &mut self, + writer: &mut Writer, + waiter: &kio::Waiter, + ) -> Poll, Error>> { if self.poll_forward_drop(writer, waiter)? { return Poll::Ready(Ok(ControlFlow::Continue(()))); } diff --git a/rs/moq-net/src/model/cache.rs b/rs/moq-net/src/model/cache.rs index 4ba981694f1..a5524a702c8 100644 --- a/rs/moq-net/src/model/cache.rs +++ b/rs/moq-net/src/model/cache.rs @@ -43,7 +43,7 @@ use crate::util::SendCell; use std::collections::BTreeMap; use std::sync::Mutex; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock, PoisonError, Weak}; use std::task::{Poll, ready}; use std::time::Duration; @@ -714,6 +714,10 @@ pub(super) struct Track { // lock, and wakes only the readers following it. largest: kio::Shared, + // Whether the track ever declared an end, set under the track lock and never cleared, + // so a reader looking for one skips that lock until there is one to read. + declared: AtomicBool, + // What the frames this track's groups are still receiving may allocate up front, // shared with every track minted from the same origin handle. frames: super::frame::Budget, @@ -822,6 +826,7 @@ impl Track { wakes: Default::default(), aborts: OnceLock::new(), largest: Default::default(), + declared: AtomicBool::new(false), }); if let Some(key) = track.pool.register(&track) { let _ = track.sweep.set(key); @@ -849,6 +854,16 @@ impl Track { &self.wakes } + /// The track declared an end. Called under the track lock. + pub(super) fn declare_end(&self) { + self.declared.store(true, Ordering::Release); + } + + /// Whether the track ever declared an end, so the end may be worth the track lock. + pub(super) fn end_declared(&self) -> bool { + self.declared.load(Ordering::Acquire) + } + /// Watch every group the publisher cuts, rather than chosen sequences. pub(super) fn watch_cuts(&self) -> aborts::Watch { self.aborts.get_or_init(Arc::default).watch_cuts() diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 67a39afc15d..8adf4905ec3 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1214,10 +1214,7 @@ impl Reader { _ => None, }, None => match &mut copy.sub { - Sub::Ready(sub) => match sub.poll_finished(&kio::Waiter::noop()) { - Poll::Ready(Ok(end)) => Some(end), - _ => None, - }, + Sub::Ready(sub) => sub.declared_end(), Sub::Pending(_) => copy.track.declared_end(), }, }; @@ -1282,11 +1279,17 @@ impl Reader { self.learn_end(None); self.window(); self.out.retain(|(_, out)| out.strong_count() > 0); + // Only a replaced copy is judged against the edge, which costs the serving track's + // lock: a reader following one route never pays it. + let edge = match self.copies.iter().any(|copy| copy.replaced) { + true => self.copies.last().and_then(|copy| copy.track.latest_sequence()), + false => None, + }; let reader = Retain { groups: self.groups, window: &self.window, groups_read: self.groups_read, - edge: self.copies.last().and_then(|copy| copy.track.latest_sequence()), + edge, mirrored: &self.mirrored, delivered, out: &self.out, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 3df1698582e..086bb1b603b 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2553,6 +2553,7 @@ impl TrackState { } self.end = Some(end); self.end_error = error; + self.cache.declare_end(); self.cache.wakes().arrived(fin..); Ok(()) } @@ -6559,6 +6560,11 @@ impl Cursor { let revision = self.subscription.ranges_revision(); (revision != self.filter.resolved).then(|| (revision, preferences.ranges.clone())) }; + // A settled window has nothing to read from the track, whose lock every subscriber + // of it takes. + if replaced.is_none() && self.filter.window.is_settled() { + return false; + } let state = self.state.read(); let moved = match replaced { Some((revision, ranges)) => { @@ -7004,13 +7010,14 @@ impl Cursor { order: subscription.order, } }; - let fills = self.state.read().fills.clone(); let misses = self.misses.get_or_insert_with(Box::default); let mut given_up = Vec::new(); misses.retain((from, revision, cap), || below(window.missable(from), cap)); - let asks = { + // One lock for the judgement and the fills it asks, since every subscriber of the + // track probes through it. + let (asks, fills) = { let state = self.state.read(); - misses.judge( + let asks = misses.judge( &state, (from, revision, cap), window.named(), @@ -7030,7 +7037,8 @@ impl Cursor { drift, &mut given_up, waiter, - ) + ); + (asks, state.fills.clone()) }; misses.ask(&self.state, &fills, &asks, &asking, &mut given_up); loop { @@ -7040,7 +7048,10 @@ impl Cursor { } misses.ask(&self.state, &fills, &retry, &asking, &mut given_up); } - misses.expire(&self.state.read(), drift, &mut given_up, waiter); + // Only what a fill still owes can expire. + if misses.is_waiting() { + misses.expire(&self.state.read(), drift, &mut given_up, waiter); + } if let Some(drops) = &mut self.drops { for run in given_up { drops.give_up(run); @@ -7051,7 +7062,7 @@ impl Cursor { /// The next group a fill answered for this cursor in its order, cached and not handed /// out yet. fn next_filled(&mut self) -> Option { - let misses = self.misses.as_mut()?; + let misses = self.misses.as_mut().filter(|misses| misses.has_filled())?; let order = self.subscription.read().order; let mut given_up = Vec::new(); let group = misses.next_filled(&self.state.read(), order, &mut given_up); @@ -8099,6 +8110,21 @@ impl Subscriber { } } + /// The track's declared exclusive end, as [`Self::poll_finished`] reports it, without + /// registering a wake. A track that never declared one answers without its lock. + pub(super) fn declared_end(&mut self) -> Option { + match &mut self.inner { + Inner::Plain(cursor) => match cursor.cache.end_declared() { + true => cursor.state.read().end, + false => None, + }, + Inner::Resume(..) => match self.poll_finished(&kio::Waiter::noop()) { + Poll::Ready(Ok(end)) => Some(end), + _ => None, + }, + } + } + /// Poll for the track's declared exclusive end, without blocking. pub fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll> { match &mut self.inner { diff --git a/rs/moq-net/src/model/track/idle.rs b/rs/moq-net/src/model/track/idle.rs index 21c0e02fb42..05b05b5e020 100644 --- a/rs/moq-net/src/model/track/idle.rs +++ b/rs/moq-net/src/model/track/idle.rs @@ -3,20 +3,14 @@ //! A read that parks registers on everything that can change its answer: the track, the //! subscription, the fills it waits on, and the wall deadlines it judged against. Each of //! those notifies on every change, so until one fires, the answer stays the same. [`Idle`] -//! parks reads through a waiter of its own that records any wake before passing it on to -//! the task, and answers a read repeated before that wake with the same `Pending`, rather -//! than walking the whole read path again to reach it. +//! answers a read repeated before that wake with the same `Pending`, rather than walking +//! the whole read path again to reach it. //! -//! Only what notifies can be trusted this way. A change the reader makes to its own cursor, -//! such as moving its range, notifies nothing, so it disarms the gate itself. +//! A change the reader makes to its own cursor, such as moving its range, notifies nothing, +//! so it disarms the gate itself. -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - task::{Wake, Waker}, -}; +/// A cursor's parked group read, and the waiter it parked on. +pub(super) type Idle = crate::util::Idle; /// Which read parked: a [`Cursor`](super::Cursor) parks the arrival and sequence walks /// separately, since one finding nothing says nothing about the other. @@ -35,122 +29,3 @@ impl Walk { } } } - -/// A cursor's parked read, and the waiter it parked on. -pub(super) struct Idle { - /// The read that parked, or `None` while nothing is parked. - parked: Option, - woken: Arc, - /// Wakes [`Self::woken`]. - wake: Waker, - /// Holds the waiter reads park on with [`Self::wake`]. Kept across reads, so parking - /// again on a list that still holds it is free, as it would be for the task's own - /// waiter, and retired as a task's own park retires one that parked on more lists - /// than it can record. - park: kio::Park, -} - -/// Records a wake, then wakes the task. -struct Woken { - woken: AtomicBool, - task: Waker, -} - -impl Wake for Woken { - fn wake(self: Arc) { - self.wake_by_ref(); - } - - fn wake_by_ref(self: &Arc) { - // Before the task's wake, so the read it triggers sees the change. - self.woken.store(true, Ordering::Release); - self.task.wake_by_ref(); - } -} - -impl Idle { - fn new(task: &Waker) -> Self { - let woken = Arc::new(Woken { - woken: AtomicBool::new(false), - task: task.clone(), - }); - Self { - parked: None, - wake: Waker::from(woken.clone()), - woken, - park: kio::Park::default(), - } - } - - /// Whether `walk`, read again for `task`, would park exactly as it last did: it parked, - /// nothing it parked on has fired since, and it still wakes the same task. - pub(super) fn parked(this: &Option>, walk: Walk, task: &kio::Waiter) -> bool { - this.as_ref().is_some_and(|idle| { - idle.parked == Some(walk) - && !idle.woken.woken.load(Ordering::Acquire) - && idle.woken.task.will_wake(task.waker()) - }) - } - - /// Start a full read for `task`, returning the gate holding the waiter it must park - /// on. A wake from here on counts against the read about to happen. - pub(super) fn start(this: &mut Option>, task: &kio::Waiter) -> Box { - match this.take() { - Some(mut idle) if idle.woken.task.will_wake(task.waker()) => { - idle.parked = None; - // Cleared before the read looks at anything, so a change it misses wakes - // the waiter after this store and leaves the next read a full one. - idle.woken.woken.store(false, Ordering::Release); - idle - } - // Another task: parking anew drops the old waiter, whose registrations go dead. - _ => Box::new(Self::new(task.waker())), - } - } - - /// The waiter a full read parks on. - pub(super) fn waiter(&mut self) -> &kio::Waiter { - self.park.hold(&std::task::Context::from_waker(&self.wake)) - } - - /// Put the gate back after a full read of `walk`, armed if it parked. - pub(super) fn finish(mut self: Box, this: &mut Option>, walk: Walk, pending: bool) { - self.parked = pending.then_some(walk); - *this = Some(self); - } - - /// Forget the parked read: the cursor moved in a way nothing notifies. - pub(super) fn disarm(this: &mut Option>) { - if let Some(idle) = this { - idle.parked = None; - } - } -} - -#[cfg(test)] -mod test { - use super::*; - - /// The waiter reads park on is kept across reads, but retired once it parked on more - /// lists than it records, so re-parking cannot stack entries those lists never collect. - #[test] - fn a_waiter_parked_past_its_records_is_retired() { - let task = kio::Waiter::noop(); - let mut slot = None; - for (lists, retired) in [(1, false), (9, true)] { - let mut idle = Idle::start(&mut slot, &task); - let mut parked: Vec<_> = (0..lists).map(|_| kio::WaiterList::new()).collect(); - let waiter = idle.waiter(); - for list in &mut parked { - waiter.register(list); - } - idle.finish(&mut slot, Walk::Arrival, true); - assert!(Idle::parked(&slot, Walk::Arrival, &task)); - - let mut idle = Idle::start(&mut slot, &task); - idle.waiter(); - assert_eq!(parked.iter().all(kio::WaiterList::is_empty), retired); - idle.finish(&mut slot, Walk::Arrival, false); - } - } -} diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index a9d0390b62f..eff1d137c70 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -197,6 +197,11 @@ impl Misses { !self.wanted.is_empty() } + /// Whether a fill answered a group not handed out yet. + pub fn has_filled(&self) -> bool { + !self.filled.is_empty() + } + /// The lowest sequence asked and not answered yet. pub fn lowest_wanted(&self) -> Option { self.wanted.iter().next().map(|run| run.start) diff --git a/rs/moq-net/src/util.rs b/rs/moq-net/src/util.rs index 529cea34d19..438ce870ef5 100644 --- a/rs/moq-net/src/util.rs +++ b/rs/moq-net/src/util.rs @@ -3,10 +3,12 @@ use std::{collections::VecDeque, future::Future, pin::Pin, task::Poll}; mod cell; +mod idle; #[cfg(feature = "async")] mod later; mod wake; pub(crate) use cell::SendCell; +pub(crate) use idle::Idle; #[cfg(feature = "async")] pub(crate) use later::{Later, Learning}; pub(crate) use wake::QueueWake; diff --git a/rs/moq-net/src/util/idle.rs b/rs/moq-net/src/util/idle.rs new file mode 100644 index 00000000000..75f55c57558 --- /dev/null +++ b/rs/moq-net/src/util/idle.rs @@ -0,0 +1,191 @@ +//! An early exit for a read that parked and was not woken since. +//! +//! A read that parks registers on everything that can change its answer, and each of those +//! notifies on every change. [`Idle`] parks the read through a waiter of its own, which +//! records any wake before passing it on to the task, so until one fires a repeated read +//! can answer `Pending` again without walking, and locking, everything it read before. +//! +//! Only what notifies can be trusted this way. A change the owner makes itself, which +//! notifies nothing, has to [`Idle::disarm`] the gate. +//! +//! An owner with more than one read keys each by `K`, since one parking says nothing +//! about another. + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Wake, Waker}, +}; + +/// A read's parked state, and the waiter it parked on. +pub(crate) struct Idle { + /// The read that last parked, while nothing has disarmed it since. + parked: Option, + woken: Arc, + /// Wakes [`Self::woken`]. + wake: Waker, + /// Holds the waiter reads park on with [`Self::wake`]. Kept across reads, so parking + /// again on a list that still holds it is free, as it would be for the task's own + /// waiter, and retired as a task's own park retires one that parked on more lists + /// than it can record. + park: kio::Park, +} + +/// Records a wake, then wakes the task. +struct Woken { + woken: AtomicBool, + task: Waker, +} + +impl Wake for Woken { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + // Before the task's wake, so the read it triggers sees the change. + self.woken.store(true, Ordering::Release); + self.task.wake_by_ref(); + } +} + +impl Idle { + fn new(task: &Waker) -> Box { + let woken = Arc::new(Woken { + woken: AtomicBool::new(false), + task: task.clone(), + }); + Box::new(Self { + parked: None, + wake: Waker::from(woken.clone()), + woken, + park: kio::Park::default(), + }) + } + + /// Whether `read`, again for `task`, would park exactly as it last did: it parked, + /// nothing it parked on has fired since, nothing disarmed it, and it still wakes the + /// same task. + pub(crate) fn parked(this: &Option>, read: K, task: &kio::Waiter) -> bool { + this.as_ref().is_some_and(|idle| { + idle.parked == Some(read) + && !idle.woken.woken.load(Ordering::Acquire) + && idle.woken.task.will_wake(task.waker()) + }) + } + + /// Start a full read for `task`, returning the gate holding the waiter it must park on. + /// A wake from here on counts against the read about to happen. + pub(crate) fn start(this: &mut Option>, task: &kio::Waiter) -> Box { + match this.take() { + Some(mut idle) if idle.woken.task.will_wake(task.waker()) => { + idle.parked = None; + // Cleared before the read looks at anything, so a change it misses wakes the + // waiter after this store and leaves the next read a full one. + idle.woken.woken.store(false, Ordering::Release); + idle + } + // Another task: parking anew drops the old waiter, whose registrations go dead. + _ => Self::new(task.waker()), + } + } + + /// The waiter a full read parks on. + pub(crate) fn waiter(&mut self) -> &kio::Waiter { + self.park.hold(&Context::from_waker(&self.wake)) + } + + /// Put the gate back after a full `read`, armed if it parked. + pub(crate) fn finish(mut self: Box, this: &mut Option>, read: K, pending: bool) { + self.parked = pending.then_some(read); + *this = Some(self); + } + + /// Forget the parked read: something it depends on changed without notifying. + pub(crate) fn disarm(this: &mut Option>) { + if let Some(idle) = this { + idle.parked = None; + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + /// A parked read stays parked until a list it parked on wakes it, a read for another + /// task parks anew, and a disarmed one walks again. + #[test] + fn a_read_stays_parked_until_woken_or_disarmed() { + let task = kio::Waiter::noop(); + let mut slot: Option> = None; + assert!(!Idle::parked(&slot, (), &task), "nothing parked yet"); + + let mut list = kio::WaiterList::new(); + let mut idle = Idle::start(&mut slot, &task); + idle.waiter().register(&mut list); + idle.finish(&mut slot, (), true); + assert!(Idle::parked(&slot, (), &task)); + + list.wake(); + assert!(!Idle::parked(&slot, (), &task), "a wake reopens the read"); + + let mut idle = Idle::start(&mut slot, &task); + idle.waiter().register(&mut list); + idle.finish(&mut slot, (), true); + assert!(Idle::parked(&slot, (), &task)); + Idle::disarm(&mut slot); + assert!(!Idle::parked(&slot, (), &task), "a disarm reopens the read"); + + let idle = Idle::start(&mut slot, &task); + idle.finish(&mut slot, (), false); + assert!( + !Idle::parked(&slot, (), &task), + "a read that got something is not parked" + ); + + let other = kio::Waiter::new(Waker::from(Arc::new(Woken { + woken: AtomicBool::new(false), + task: Waker::noop().clone(), + }))); + let idle = Idle::start(&mut slot, &task); + idle.finish(&mut slot, (), true); + assert!(!Idle::parked(&slot, (), &other), "another task reads anew"); + } + + /// A gate keyed by read answers only for the read that parked. + #[test] + fn a_parked_read_says_nothing_about_another() { + let task = kio::Waiter::noop(); + let mut slot = None; + let idle = Idle::start(&mut slot, &task); + idle.finish(&mut slot, 1u8, true); + assert!(Idle::parked(&slot, 1, &task)); + assert!(!Idle::parked(&slot, 2, &task), "another read walks"); + } + + /// The waiter reads park on is kept across reads, but retired once it parked on more + /// lists than it records, so re-parking cannot stack entries those lists never collect. + #[test] + fn a_waiter_parked_past_its_records_is_retired() { + let task = kio::Waiter::noop(); + let mut slot: Option> = None; + for (lists, retired) in [(1, false), (9, true)] { + let mut idle = Idle::start(&mut slot, &task); + let mut parked: Vec<_> = (0..lists).map(|_| kio::WaiterList::new()).collect(); + let waiter = idle.waiter(); + for list in &mut parked { + waiter.register(list); + } + idle.finish(&mut slot, (), true); + assert!(Idle::parked(&slot, (), &task)); + + let mut idle = Idle::start(&mut slot, &task); + idle.waiter(); + assert_eq!(parked.iter().all(kio::WaiterList::is_empty), retired); + idle.finish(&mut slot, (), false); + } + } +} From d721eed5a4b0ea4ccd864c2465626ae4a393b91b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:09:17 +0000 Subject: [PATCH 65/86] quest(m1): lite-07 ranges wait for fetch-range (#7487) Co-authored-by: Claude Opus 5.5 --- quest/m1/subscribe-ranges/lite.md | 122 ++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 25 deletions(-) diff --git a/quest/m1/subscribe-ranges/lite.md b/quest/m1/subscribe-ranges/lite.md index 8ed65d2d990..b528dc5d1d9 100644 --- a/quest/m1/subscribe-ranges/lite.md +++ b/quest/m1/subscribe-ranges/lite.md @@ -15,6 +15,13 @@ moves out of this quest (to its own quest since 2026-10-27), since JS still requ cross-language capability. This quest keeps FETCH on lite-07 and makes the range fill work without it. +Decided 2026-11-01 (maintainer, on +[moq-dev/moq#7487](https://github.com/moq-dev/moq/pull/7487)): this quest +waits for [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md)'s miss +judging (a miss with no feed frontier, and holes below where a live feed +started) and its cached backfill rule. Without them a range fill is a +functional regression from FETCH, which answers a miss at once. + ### Range fill through the subscription Lite-07 SUBSCRIBE and SUBSCRIBE_UPDATE carry the range list (`lite::Range`, @@ -23,35 +30,100 @@ the codec, and a merged request goes upstream as one span, from group 0 when its starts mix (`wire_span`). A lite-07 publisher FINs a covered bounded subscription without SUBSCRIBE_END; the Rust copy settles its tail, keeps the cached copy, and subscribes again only for demand the covered ranges leave -out. - -An attempt that routed lite-07 fills through `FeedFetches`, adding each held -fill's groups to the upstream ranges, broke fetches of an open or previously -cut group (`catalog_handover` fetch tests, `restart::retired_fetch_lite07`, -`fetch_cancel`, `untimed`, `rejoin`): - -- A held fill is judged by the track's arrivals ledger, which still holds the - cut group from the subscription that left. It settles `NotFound` before the - new subscription delivers the group. Judge it against the subscription that - asked for it. -- Dropping a fill's range once it is answered shrinks or closes the upstream - subscription while its group stream is still arriving, which resets it. - Keep the range until that stream finishes. -- A relay serving the fill from its own cut copy has the same problem one hop - up. +out. The maintainer has since directed an explicit end signal (SUBSCRIBE_END +or similar) in place of that FIN, since a FIN prevents later updates (on +[moq-dev/moq#7545](https://github.com/moq-dev/moq/pull/7545)). +[Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) owns that +design; build the fill on whatever it settles, not on the FIN. + +The first attempt +([moq-dev/moq#7487](https://github.com/moq-dev/moq/pull/7487), WIP commit +[b7f573d](https://github.com/moq-dev/moq/commit/b7f573d755ae53e6f2bdb94cc480ae47fd6ba254)) built this in +`rs/moq-net/src/lite/subscriber.rs` and was moved back out until the +requirement lands. Reuse its design: + +- A `RangeFills` set holds each fill and asks for its next owed run + (`Fill::next_run`), added as an absolute bounded range after the demand's + ranges on the upstream SUBSCRIBE or SUBSCRIBE_UPDATE. With no demand open, + a bounded SUBSCRIBE asks for the fills alone. A fill answers itself from the + cache as its groups arrive. +- A fill is judged only against the subscription that asked for it, not the + track's arrivals ledger, which still holds a group an earlier subscription + cut. A SUBSCRIBE_DROP over its groups, a SUBSCRIBE_START past its range's + start, or a covered end refuses it, so a cut group is filled whole. +- A run stays on the wire while a fill owes part of it, then until each of its + streams still being read finishes, so narrowing never resets a group in + flight. +- Only the demand's ranges set the floor and the live state. A subscription + carrying only fills sets neither, and canceling it does not idle the copy. + If the demand leaves while fills are out, the subscription keeps the + demand's ranges until they are answered. +- A NotFound drop or a covered end names the fill's groups dropped + (`Fill::drop_groups`), as a FETCH miss does; Old and NOT_FETCHABLE reject + them, and any other error refuses only that request. + +Its integration tests (`range_fill.rs`: a miss subscribes and sends no FETCH, +a miss rides the live subscription then retires its range, a group the +publisher never had becomes a miss after the grace, a cut group is filled +whole) passed. These failed, and must pass once the requirement lands: + +- On-demand `requests()` tracks with no live feed, such as moq-archive's, never + ask a fill and park (`untimed::a_fetched_untimed_frame_arrives_untimed`, + `fetch_cancel::a_refetch_within_the_linger_stays_on_the_relay_lite07`, + `subscription_end_integrity::a_session_close_cancels_a_pending_fetch_from_either_side`). + Fetch as a range's miss with no feed frontier covers them. +- A missing or expired group fetched with no budget parks where FETCH answered + NotFound at once (`rejoin::{rejoined,taken_over}_quiet_catalog_survives_cache_expiry`). + Fetch as a range's holes below where a live feed started cover them. +- A relay re-asks upstream for groups it cached, since its covered memory + leaves with the downstream demand + (`fetch_fanout::sequential_viewers_share_the_relays_fetched_groups`, two + upstream SUBSCRIBEs where FETCH needed one). Fetch as a range's cached + backfill rule covers it: a relay leaves cached groups out of a bounded + upstream range. On lite-07 the test counts SUBSCRIBE streams, not FETCH. + +Open questions the attempt raised, each with the attempt's recommendation: + +- **Datagram sequences.** A range fill of a sequence served only as a datagram + never completes: datagrams resolve no SUBSCRIBE_START, so a FIN or end + overtaking them leaves the receiver no owed range to wait on + (`datagram::a_fetched_datagram_is_refused_per_version`). Recommended: a + lite-07 publisher sends SUBSCRIBE_START for a bounded past range, then + SUBSCRIBE_DROP with NOT_FETCHABLE for each sequence it holds only as a + datagram; the subscriber already maps that drop onto the fill. This is a + wire change. The alternative keeps FETCH for a fill the first answer shows is + a datagram, at a round trip and a second path. +- **Fills from partway through a group.** Route recovery (`resume::Recover`) + fills from frame N, which a whole-group range cannot say. Recommended: keep + FETCH on lite-07 for a fill whose first group starts past frame 0, matching + Fetch as a range's crate-private fetch for that case. The alternative asks + for the whole group and drops the frames the reader already has. +- **One budget caps every range.** Merging the fills into the upstream + Subscriber Max Age takes the most tolerant budget, which loosens the live + feed's cap while a fill is out + (`route_change::resume_delivers_the_outage_tail_within_the_budget` delivers + a stale group 5). Recommended: settle the partial-group question first, + since the failing fill is a recovery fill, and rerun the test. The + alternatives fill under the demand's budget, refusing older groups as Old, + or add a per-range budget to the wire. +- `auth::lite_07_narrowing_resets_a_fetch_in_flight` read `Stream(Cancel)` + instead of `Unauthorized`/`Unroutable` when the grant narrowed mid-group; + the refusal must carry the grant's error. The Rust copy sends a relative or time end as open, since nothing names a resolved end and it resolves the end itself (`wire_ranges`), so only absolute bounded ranges complete upstream. A hole on a live track counts as dropped -only once the feed gives up on it, past the arrivals grace; -[Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) owns judging -such a miss sooner. A range served only by datagrams never completes: they -resolve no SUBSCRIBE_START, so a FIN overtaking them leaves the receiver no -owed range to wait on. Datagram fills need the receiver told what is owed -first. - -Update `drafts/draft-lcurley-moq-lite.md` with each wire change here. Run -`just drafts check` and `just test interop --all`. +only once the feed gives up on it, past the arrivals grace, until Fetch as a +range judges such a miss sooner. + +Update `drafts/draft-lcurley-moq-lite.md` with each wire change here, +including that a subscriber fills missing groups through its ranges. Run +`just drafts check` and `just test interop --all`, and the `moq-net-tests` +`restart::retired_fetch_lite07` and `moq-relay` suites. + +## Required + +- [Fetch as a range](/quest/m1/subscribe-ranges/fetch-range.md) - its miss judging and cached backfill rule, which a range fill needs to answer a miss as FETCH does ## Related From e7a5c10913b02e21ef851a0250b594fe837d1425 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:10:59 +0000 Subject: [PATCH 66/86] chore(quest): checkpoint VA-API audit and release dependency (#7463) Co-authored-by: GPT-6.1 Sol Co-authored-by: Claude Opus 5.5 --- quest/m1/vendor-sync/vaapi.md | 47 +++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/quest/m1/vendor-sync/vaapi.md b/quest/m1/vendor-sync/vaapi.md index dfd697bb5b9..bcc994d158f 100644 --- a/quest/m1/vendor-sync/vaapi.md +++ b/quest/m1/vendor-sync/vaapi.md @@ -6,27 +6,52 @@ [chromeos/cros-libva](https://github.com/chromeos/cros-libva) and [chromeos/cros-codecs](https://github.com/chromeos/cros-codecs) have landed since the last check, for the code it vendors, each ported with its test or -recorded as not applying, and released to crates.io if anything changed. +recorded as not applying, and released to crates.io if anything changed, with +moq's `moq-vaapi` dependency bumped to that release. ## Plan Recurring child of the [vendor sync](/quest/m1/vendor-sync/README.md), which says how each run works and recurs. moq-vaapi lives in its own repository and -ships from crates.io for build reasons, but is vendored code all the same. Port -fixes there and bump moq's `moq-vaapi` version in the same cycle. +ships from crates.io for build reasons. Carry these checked heads and +dispositions into the next cycle when the parent re-creates this child. moq-vaapi vendors Discord's forks: cros-libva tag `discord-0.0.13` ([`896a248e`](https://github.com/discord/cros-libva/commit/896a248e003caae7a7120812f7eaa423c93a920d), 2026-03-13) and cros-codecs tag `discord-0.0.5` ([`9cdecb19`](https://github.com/discord/cros-codecs/commit/9cdecb192ee3682950d02bd80a7f106163531b76), -2026-06-15). Discord tagged nothing newer by 2026-10-30, so the chromeos -upstreams are the ones to track. - -Last checked: nothing beyond those tags. On 2026-10-30 chromeos/cros-libva was -at `59384456`, five commits ahead, of which `59384456` (a VP9 struct field for -libva 2.23+) is the only fix and moq-vaapi is H.264-only, and -chromeos/cros-codecs was at `a050c6cd`, one commit ahead. Record the commits -checked in moq-dev/vaapi's README, and copy them here for the next run. +2026-06-15). Track the ChromeOS default branches against these diverged fork +baselines, without undoing Discord's additions. + +Checked 2026-10-31: + +- cros-libva + [`59384456ac2ae78c0c3e5515f41ef1efd9b802cf`](https://github.com/chromeos/cros-libva/commit/59384456ac2ae78c0c3e5515f41ef1efd9b802cf): + all five commits absent from the vendor baseline inspected. Explicit image + lifetimes and packed headers are already present. The retained VP9 wrapper + already zero-fills the libva 2.23 fields with `..Default::default()`; there + is no VP9 codec driver needing segmentation control. Protected-content + headers have no consumer. Borrowed input buffers are an optional optimization, + not a correctness fix; retain the existing owned-buffer API. +- cros-codecs + [`a050c6cde530e563197ee9e3e72ab01f48a0c41f`](https://github.com/chromeos/cros-codecs/commit/a050c6cde530e563197ee9e3e72ab01f48a0c41f): + the only commit absent from the vendor baseline removes + `log/release_max_level_debug`, which conflicts with a downstream application's + release log level. Ported in + [`6d38f3d0`](https://github.com/moq-dev/vaapi/commit/6d38f3d02e2709962789ad73698a27b548fd582e), + with a feature-unification compilation check in `just ci`. That check failed + before the fix and passes after it. VA-API's README records every disposition + and both full checked heads. +- GitHub's published advisory endpoints for both ChromeOS repositories and + both Discord forks returned empty lists, as did the global Rust advisory + queries for `cros-libva` and `cros-codecs`. This does not cover undisclosed + vulnerabilities. + +Remaining this cycle: merge the ported fix, +[moq-dev/vaapi#5](https://github.com/moq-dev/vaapi/pull/5), publish a new +`moq-vaapi` release to crates.io, then bump moq's workspace `moq-vaapi = "0.1.0"` +and lockfile to it and run the affected checks. Public API and wire formats are +unchanged; applications regain control of their release log level. CI, added 2026-11-01 (maintainer, follow-up planning after the 2026-11-01 /quest-afk run): moq-dev/vaapi's only workflow, `.github/workflows/check.yml`, From 26732b9ce6978492355dd2c0dff0ea09160aa8fa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:12:33 +0000 Subject: [PATCH 67/86] refactor(publish): keep each network broadcast's renditions and encoder state in one publication (#7371) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- doc/lib/js/publish.md | 4 +- js/publish/src/audio/capture.test.ts | 1 + js/publish/src/audio/encoder.test.ts | 1 + js/publish/src/audio/encoder.ts | 68 ++++--- js/publish/src/broadcast.test.ts | 130 ++++++++++++ js/publish/src/broadcast.ts | 220 +++++++-------------- js/publish/src/internal.ts | 3 - js/publish/src/publication.ts | 180 +++++++++++++++++ js/publish/src/rendition-internal.ts | 21 +- js/publish/src/video/capture.test.ts | 4 +- js/publish/src/video/encoder-stall.test.ts | 1 + js/publish/src/video/encoder.test.ts | 82 ++++++-- js/publish/src/video/encoder.ts | 114 +++++------ quest/m1/cut-audit/README.md | 3 +- quest/m2/README.md | 1 - quest/m2/publish-publication.md | 24 --- 16 files changed, 570 insertions(+), 287 deletions(-) create mode 100644 js/publish/src/publication.ts delete mode 100644 quest/m2/publish-publication.md diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 0982fe5da9f..4d5119d6c6b 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -173,7 +173,9 @@ running. It does not retry on demand or bandwidth changes, and enable cycles that keep the same capture do not retry. A new capture source (such as the track a camera or microphone reopens after a device swap), changed encoder settings, or a replacement encoder recovers it. Recovery keeps the catalog ID and uses a -fresh `track` name. +fresh `track` name. A new network broadcast (a rename or a `maxAge` change) +also starts the encoder over, under the catalog ID, with its advertised +maxima reset. ## Strict CSP diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index b6433737e56..b5a3cba0a2f 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -559,6 +559,7 @@ test("a failed encoder stays failed until the capture source changes", async () const capture = new Capture({ enabled, source: source as never }); const track = new Moq.Track.Producer("audio").accept({ timescale: Moq.Time.Timescale.MILLI }); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => {}, diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index 7559920ee08..70db56f10c7 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -291,6 +291,7 @@ async function setup(order = new Order(), codec?: Codec, groupDuration?: Milli) }; const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 1f324c572c9..ce6a1e25b95 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -8,14 +8,14 @@ import { type Getter, getter, type Inputs, - Once, type Readonlys, readonlys, Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { errors, watched } from "../internal"; +import { watched } from "../internal"; import { Estimator, type Order } from "../order"; +import { Slot } from "../publication"; import type { AudioFrame, Capture, Format } from "./capture"; import { Gain } from "./gain"; import { Resampler } from "./resampler"; @@ -233,18 +233,18 @@ export class Encoder { */ readonly settled: Computed; - // Each registration keeps its terminal failure even after the public error clears. - #failure = new Signal(new Once()); // The capture inputs #runCaptureRecovery last saw, carried across a gap in the source or format. #seen?: { capture?: Capture; source?: unknown; format?: Format }; #signals = new Effect(); #estimator = new Estimator(); // The estimator's delay, republished whenever it rises. #estimate = new Signal({}); - // What this rendition advertised on each broadcast, keyed by the broadcast or, with none, the - // encoder. A broadcast keeps a rendition's track when it re-registers, so returning to one - // continues its bitrate maximum and last config, while another broadcast starts its own. - #advertised = new WeakMap(); + // What this rendition keeps for the current network broadcast's track: its bitrate maximum, last + // config, and failure. A network broadcast keeps a rendition's track when it re-registers, so + // returning to one continues them, while a new one starts its own. + #slot = new Signal | undefined>(undefined); + // The slot while no network broadcast is serving the rendition. + #local = new Slot(); // Whether the rendition was disabled since a config last resolved, so it keeps advertising its last // config as disabled until the re-enabled capture resolves a new one. A capture that never reopens // leaves it disabled, which is accurate: no frames are coming. @@ -298,6 +298,7 @@ export class Encoder { } }); } + this.#signals.run(this.#runError.bind(this)); this.#signals.run(this.#runSettingsRecovery.bind(this)); this.#signals.run(this.#runCaptureRecovery.bind(this)); this.#signals.run(this.#runRegister.bind(this)); @@ -376,24 +377,34 @@ export class Encoder { this.#rearm(); } - // Clear a latched failure so a fresh registration retries. + // The current slot, read through `effect`. + #current(effect: Effect): Slot { + return effect.get(this.#slot) ?? this.#local; + } + + // The public error is the current slot's, so a new network broadcast starts without one. + #runError(effect: Effect): void { + effect.set(this.#out.error, effect.get(this.#current(effect).error)); + } + + // Clear a latched failure, so the broadcast serves the rendition again under a fresh track name. #rearm(): void { - if (!this.#out.error.peek()) return; - this.#out.error.set(undefined); + const failure = (this.#slot.peek() ?? this.#local).error; + if (!failure.peek()) return; + failure.set(undefined); this.#floor = undefined; - this.#failure.set(new Once()); } // Register the rendition on the broadcast, publish its config, and encode while it hands over a // track. Re-registers cleanly when the broadcast swaps. #runRegister(effect: Effect): void { - const failure = effect.get(this.#failure); const broadcast = effect.get(this.in.broadcast); if (!broadcast) return; const rendition = broadcast.audio(this.name); - errors.set(rendition, failure); effect.cleanup(() => rendition.close()); + effect.proxy(this.#slot, rendition.slot); + effect.cleanup(() => this.#slot.set(undefined)); // Publish the resolved config; undefined (no capture) drops it from the catalog. effect.proxy(rendition.config, this.out.catalog); @@ -436,12 +447,10 @@ export class Encoder { const capture = effect.get(this.in.capture); const format = capture ? effect.get(capture.out.format) : undefined; const track = effect.get(rendition.track); + // A dead encoder can't serve anyone: the broadcast aborts its track with the real error, so + // the current subscriber and every later one get it rather than a track that stays silent. const fatal = effect.get(this.#out.error); - // A dead encoder can't serve anyone, so the current subscriber and every later one get - // the real error rather than a track that stays silent. - if (fatal) track?.abort(fatal); - const onDemand = effect.get(broadcast.in.onDemand) ?? true; const demanded = track && effect.get(track.demand().wanted) !== undefined; this.#encoding = enabled && !!format && !!track && !fatal && (!onDemand || !!demanded); @@ -581,16 +590,11 @@ export class Encoder { #runCatalog(effect: Effect): void { const estimate = effect.get(this.#estimate); const enabled = effect.get(this.in.enabled) && !effect.get(this.#out.error); - const key = effect.get(this.in.broadcast) ?? this; - let track = this.#advertised.get(key); - if (!track) { - track = { bitrate: 0 }; - this.#advertised.set(key, track); - } + const slot = this.#current(effect); if (!enabled) this.#paused = true; const config = enabled ? effect.get(this.#config)?.catalog : undefined; if (!config) { - const last = this.#paused ? track.last : undefined; + const last = this.#paused ? slot.last : undefined; effect.set(this.#out.catalog, last && { ...last, ...estimate, enabled: false }); return; } @@ -598,10 +602,10 @@ export class Encoder { // The catalog bitrate is the most this track has been configured for, so lowering the knob // retunes the encoder without lowering what the catalog advertises. - track.bitrate = Math.max(track.bitrate, config.bitrate ?? 0); - const bitrate = track.bitrate > 0 ? Catalog.u53(track.bitrate) : undefined; - track.last = { ...config, bitrate, ...estimate }; - effect.set(this.#out.catalog, track.last); + slot.bitrate = Math.max(slot.bitrate, config.bitrate ?? 0); + const bitrate = slot.bitrate > 0 ? Catalog.u53(slot.bitrate) : undefined; + slot.last = { ...config, bitrate, ...estimate }; + effect.set(this.#out.catalog, slot.last); } // Collect the encode-only Opus knobs that are set, reading the codec through the effect so the @@ -810,10 +814,10 @@ export class Encoder { } #abort(error: unknown): void { - if (this.#out.error.peek()) return; + const failure = (this.#slot.peek() ?? this.#local).error; + if (failure.peek()) return; const cause = error instanceof Error ? error : new Error(String(error)); - this.#failure.peek().set(cause); - this.#out.error.set(cause); + failure.set(cause); this.#live?.track.abort(cause); } diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index bcb192b3736..307ac81a94a 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -761,3 +761,133 @@ test("identity changes before the first announce take a fresh track name", async broadcast.close(); }); + +// Identity follows hang's `same_identity`: interlacing reinitializes a video decoder. +test("an interlacing change takes a fresh track name", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("interlaced.hang") }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + video.config.set({ ...videoConfig, interlaced: true }); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/1"); + expect(video.track.peek()?.name).toBe("video/1"); + + broadcast.close(); +}); + +// Names belong to one network broadcast, so a new one serves each rendition under its ID again. +test("a new network broadcast starts every track name over", async () => { + const name = new Signal(Path.from("one.hang")); + const broadcast = new Broadcast({ origin: new Origin.Producer(), name }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + video.config.set({ ...videoConfig, codec: "vp8" }); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/1"); + expect(() => broadcast.video("video/1")).toThrow("replacement track"); + + name.set(Path.from("two.hang")); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBeUndefined(); + expect(video.track.peek()?.name).toBe("video"); + expect(video.track.peek()?.appendGroup().sequence).toBe(0); + expect(() => broadcast.video("video/1").close()).not.toThrow(); + + broadcast.close(); +}); + +// A fold that throws publishes nothing, so it must not move a track the catalog still lists. +test("a refused catalog fold renames no track", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("refused.hang") }); + const video = broadcast.video("video"); + const audio = broadcast.audio("audio"); + video.config.set(videoConfig); + await settle(); + const track = video.track.peek(); + + video.config.set({ ...videoConfig, codec: "vp8" }); + audio.config.set({ ...audioConfig, track: "elsewhere" }); + await settle(); + expect(video.track.peek()).toBe(track); + expect(track?.closed.peek()).toBeUndefined(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.codec).toBe(videoConfig.codec); + + // Once the fold succeeds, the new identity moves to a fresh name. + audio.config.set(audioConfig); + await settle(); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/1"); + expect(video.track.peek()?.name).toBe("video/1"); + + broadcast.close(); +}); + +// A failure keeps the rendition down, even across a sibling's registration, until it clears. The +// rendition then recovers under a fresh track name, so its group sequence can start over. +test("a failed rendition stays down until its failure clears", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("failed.hang") }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + const track = video.track.peek(); + const slot = video.slot.peek(); + if (!track || !slot) throw new Error("expected a served rendition"); + + const failure = new Error("encoder failed"); + slot.error.set(failure); + await settle(); + expect(track.closed.peek()).toBe(failure); + expect(video.track.peek()).toBeUndefined(); + broadcast.audio("audio").config.set(audioConfig); + await settle(); + expect(video.track.peek()).toBeUndefined(); + + slot.error.set(undefined); + await settle(); + expect(video.track.peek()?.name).toBe("video/1"); + expect((await readCatalog(broadcast))?.video?.renditions.video?.track).toBe("video/1"); + + broadcast.close(); +}); + +// A producer may abort its track itself, which fails the rendition's slot the same way. +test("aborting a rendition's track fails its slot", async () => { + const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("aborted.hang") }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + const failure = new Error("encoder failed"); + video.track.peek()?.abort(failure); + await settle(); + expect(video.slot.peek()?.error.peek()).toBe(failure); + + broadcast.close(); +}); + +// What a producer keeps for a rendition belongs to one network broadcast, so a new one starts it over. +test("a new network broadcast starts each rendition's slot over", async () => { + const name = new Signal(Path.from("one.hang")); + const broadcast = new Broadcast({ origin: new Origin.Producer(), name }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + const first = video.slot.peek(); + if (!first) throw new Error("expected a slot"); + first.bitrate = 1_000_000; + first.error.set(new Error("encoder failed")); + await settle(); + expect(video.track.peek()).toBeUndefined(); + + name.set(Path.from("two.hang")); + await settle(); + const second = video.slot.peek(); + expect(second).toBeDefined(); + expect(second).not.toBe(first); + expect(second?.bitrate).toBe(0); + expect(second?.error.peek()).toBeUndefined(); + expect(video.track.peek()?.name).toBe("video"); + expect(video.track.peek()?.closed.peek()).toBeUndefined(); + + broadcast.close(); +}); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 46dc3ea80be..3057e931f64 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -1,13 +1,12 @@ import { Milli } from "@moq/clock"; import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; -import * as Json from "@moq/json"; import * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, Signal } from "@moq/signals"; import { rotateVideoDimensions } from "@moq/video"; import { CatalogProducer } from "./catalog"; -import { errors } from "./internal"; import { Order } from "./order"; +import { type Identity, identity, type Listing, Publication, type Slot } from "./publication"; import type { Kind, Rendition } from "./rendition"; import { makeRendition } from "./rendition-internal"; @@ -112,31 +111,20 @@ export class Broadcast { // key add/remove; the Rendition values compare by identity, which is stable. readonly #renditions = new Signal>>({}); - // The writable track producer signals backing each Rendition's read-only `track`, keyed by name. + // The writable signals backing each Rendition's read-only `track` and `slot`, keyed by name. // A static network track is exposed here while it is open. On-demand gates non-audio tracks // on a subscriber; audio keeps its handle so it can buffer PCM across demand changes. - readonly #tracks = new Map>(); + // The slot is the current publication's, while there is one. + readonly #handles = new Map< + string, + { track: Signal; slot: Signal } + >(); - // The kind of each rendition track in the current network broadcast. A new one starts over. - #kinds = new Map(); + // The renditions of the current network broadcast, undefined while there is none. + readonly #publication = new Signal(undefined); - // Each catalog ID's track name in the current network broadcast, when it is not the ID itself. - // A replacement for a closed track, or a rendition whose media identity changed, keeps its - // catalog ID under a fresh track name, so a name always means the same content. - readonly #names = new Signal>({}); - - // Every replacement track name minted in the current network broadcast, even one never served. - #replacements = new Set(); - - // The decoder identity of each track name in the current network broadcast, or undefined before - // there is one. Fixed from the broadcast's start rather than its first announce, since an encoder - // may cache groups on a track before anyone can find it, and a late subscriber or a fetch reads - // them under the catalog's config. Kept across a rendition's removal, so a name returning with - // different media takes a fresh name too. - #served: Map | undefined; - - // The identity of each track name in the latest catalog fold, recorded when a network broadcast starts. - #listed = new Map(); + // Each catalog ID in the latest catalog fold, which seeds a new network broadcast's publication. + #listed = new Map(); #signals = new Effect(); @@ -189,31 +177,29 @@ export class Broadcast { if (this.#renditions.peek()[name]) { throw new Error(`rendition already registered: ${name}`); } - // A name keeps its track, so it keeps its kind: different media takes a different name. - const previous = this.#kinds.get(name); - if (previous !== undefined && previous !== kind) { - throw new Error(`rendition ${name} was registered as ${previous}, not ${kind}`); - } - // A replacement track holds or held the name, so another rendition's media owns it. - if (this.#replacements.has(name)) { - throw new Error(`rendition ${name} names another rendition's replacement track`); - } + this.#publication.peek()?.check(name, kind); - const track = new Signal(undefined); - this.#tracks.set(name, track); + const handle = { + track: new Signal(undefined), + slot: new Signal(undefined), + }; + this.#handles.set(name, handle); - const rendition = makeRendition(name, kind, track, () => this.#unregister(name)); + // The slot is the one its producer fills, so it holds this rendition's config. + const slot = handle.slot as Getter | undefined>; + const rendition = makeRendition(name, kind, handle.track, slot, () => this.#unregister(name)); this.#renditions.update((renditions) => ({ ...renditions, [name]: rendition as Rendition })); return rendition; } #unregister(name: string): void { - const track = this.#tracks.get(name); - if (track) { + const handle = this.#handles.get(name); + if (handle) { // The network track stays with the broadcast, so registering the name again continues it. - track.set(undefined); - this.#tracks.delete(name); + handle.track.set(undefined); + handle.slot.set(undefined); + this.#handles.delete(name); } this.#renditions.update((renditions) => { @@ -229,13 +215,14 @@ export class Broadcast { #runCatalog(effect: Effect) { const enabled = effect.get(this.in.enabled); const renditions = effect.get(this.#renditions); - const names = effect.get(this.#names); + const publication = effect.get(this.#publication); + if (publication) effect.get(publication.renames); const video: Record = {}; const audio: Record = {}; const text: Record = {}; - const listed = new Map(); - const renamed: Record = {}; + const listed = new Map(); + const served: [Rendition, string, Identity][] = []; for (const rendition of Object.values(renditions)) { const source = enabled ? effect.get(rendition.config) : undefined; @@ -245,16 +232,10 @@ export class Broadcast { if (target.broadcast !== undefined || target.track !== undefined) { throw new Error(`rendition ${rendition.name} config sets broadcast or track, which the broadcast owns`); } - let track = names[rendition.name] ?? rendition.name; - // Everything else (bitrate, frame rate, coded size, ...) updates the served track in place. - const media = identity(source); - const served = this.#served?.get(track); - if (served && !Json.deepEqual(served, media)) { - track = this.#mint(rendition.name); - renamed[rendition.name] = track; - } - this.#served?.set(track, media); - listed.set(track, media); + const media = identity(rendition.kind, source); + listed.set(rendition.name, { kind: rendition.kind, identity: media }); + const track = publication?.name(rendition, media) ?? rendition.name; + served.push([rendition, track, media]); const config = track !== rendition.name ? { ...(source as object), track } : source; if (rendition.kind === "video") { @@ -299,22 +280,14 @@ export class Broadcast { }); this.#listed = listed; - // The network loop ends each renamed rendition's old track and serves the new name. - if (Object.keys(renamed).length > 0) this.#names.update((names) => ({ ...names, ...renamed })); + // Only once the catalog holds them, so the track loop never ends a track the catalog still lists. + if (publication) for (const [rendition, track, media] of served) publication.commit(rendition, track, media); } - // The next unused `/` track name in the current network broadcast. This publisher owns the - // namespace and reserves `/` for the rendition (see `net`), so skipping every name it has - // served, registered, or listed in the catalog (including an application's own sections) is - // enough to never reuse one. - #mint(id: string): string { - const taken = new Set([ - ...this.#replacements, - ...(this.#served?.keys() ?? []), - ...Object.keys(this.#renditions.peek()), - // Every ID that held a track here, even one removed before it had a config. - ...this.#kinds.keys(), - ]); + // The names outside a publication's own that a replacement must avoid: every registered ID, and + // every ID and track name in the catalog, including an application's own sections. + #reserved(): Set { + const taken = new Set(Object.keys(this.#renditions.peek())); // A no-op edit is the catalog's only read; it publishes nothing. this.catalog.mutate((catalog) => { for (const entries of [ @@ -330,12 +303,7 @@ export class Broadcast { } } }); - for (let n = 1; ; n++) { - const name = `${id}/${n}`; - if (taken.has(name)) continue; - this.#replacements.add(name); - return name; - } + return taken; } #run(effect: Effect) { @@ -368,19 +336,14 @@ export class Broadcast { // Each track lives as long as the broadcast: a track name always means the same content, // so a re-announce or a rendition change keeps writing to it instead of restarting its - // group sequence under the same epoch. Rendition tracks by name, so registering a name - // again finds its track; the catalog's stay out of reach of a colliding rendition. - const tracks = new Map }>(); - this.#names.set({}); - this.#replacements = new Set(); - // From here a name keeps the identity it is first listed with. - this.#served = new Map(this.#listed); - const kinds = new Map(); - this.#kinds = kinds; + // group sequence under the same epoch. The publication keeps rendition tracks by catalog ID, + // so registering an ID again finds its track; the catalog's stay out of reach of a colliding + // rendition. + const publication = new Publication(this.#listed, () => this.#reserved()); + this.#publication.set(publication); effect.cleanup(() => { - for (const { track } of tracks.values()) track.finish(); - for (const { track } of tracks.values()) track.close(); - if (this.#kinds === kinds) this.#kinds = new Map(); + publication.close(); + if (this.#publication.peek() === publication) this.#publication.set(undefined); }); let serving = false; @@ -420,58 +383,54 @@ export class Broadcast { // track is open. Audio keeps it while buffering PCM; on-demand video gates it on a subscriber. effect.run((inner) => { const renditions = inner.get(this.#renditions); - const names = inner.get(this.#names); + inner.get(publication.renames); for (const rendition of Object.values(renditions)) { - const signal = this.#tracks.get(rendition.name); - if (!signal) continue; - - let entry = tracks.get(rendition.name); - const cause = entry && errors.get(entry.owner)?.peek(); - if (entry && cause) entry.track.abort(cause); - const name = names[rendition.name] ?? rendition.name; - if (entry && entry.track.name !== name) { + const handle = this.#handles.get(rendition.name); + if (!handle) continue; + + const entry = publication.entry(rendition); + inner.set(handle.slot, entry.slot); + // A replacement registration is a new producer, so the last one's failure no longer holds. + if (entry.owner && entry.owner !== rendition) entry.slot.error.set(undefined); + const cause = inner.get(entry.slot.error); + // Including an idle track, which no producer holds to abort. + if (entry.track && cause) entry.track.abort(cause); + if (entry.track && entry.track.name !== entry.name) { // The catalog moved this rendition to a new name for new media. End the old track // cleanly on its last identity, so its subscribers follow the catalog. entry.track.finish(); entry.track.close(); - entry = undefined; - } else if (entry && entry.owner !== rendition && entry.track.closed.peek() !== undefined) { - // An error leaves this rendition down. Only a replacement registration may recover, - // with a fresh track name so its group sequence can start over. - const fresh = this.#mint(rendition.name); - this.#names.update((names) => ({ ...names, [rendition.name]: fresh })); + entry.track = undefined; + } else if (entry.track && entry.track.closed.peek() !== undefined) { + // A failure leaves this rendition down until it clears. It then recovers under a fresh + // track name, so its group sequence can start over. + if (!cause) publication.rename(entry); continue; } - if (!entry) { + if (!entry.track) { const info = Container.trackInfo({ maxAge, priority: Catalog.PRIORITY[rendition.kind] }); - const track = broadcast.createTrack( - name, + entry.track = broadcast.createTrack( + entry.name, rendition.kind === "video" ? { ...info, refresh: VIDEO_REFRESH } : info, ); - entry = { track, owner: rendition }; - tracks.set(rendition.name, entry); - kinds.set(rendition.name, rendition.kind); - } else { - entry.owner = rendition; } + entry.owner = rendition; const served = entry.track; - const error = errors.get(rendition); - if (error) { - inner.run((failure) => { - const cause = failure.get(error); - if (cause) served.abort(cause); - }); - } inner.run((gate) => { - if (gate.get(served.closed) !== undefined) return; + const closed = gate.get(served.closed); + if (closed !== undefined) { + // A producer aborting its track directly fails the rendition the same way. + if (closed && !entry.slot.error.peek()) entry.slot.error.set(closed); + return; + } if ( rendition.kind !== "audio" && gate.get(this.in.onDemand) && gate.get(served.demand().wanted) === undefined ) return; - gate.set(signal, served); + gate.set(handle.track, served); }); } }); @@ -482,32 +441,3 @@ export class Broadcast { this.#signals.close(); } } - -// The fields a consumer can't change without reinitializing its decoder (or demuxer, for -// `container`), so changing any of them takes a new track name. -type Identity = Pick< - Partial, - | "codec" - | "description" - | "container" - | "sampleRate" - | "numberOfChannels" - | "displayAspectWidth" - | "displayAspectHeight" ->; - -// Cloned, so a producer mutating a config it shares (such as its container) can't rewrite what a -// name was served with. -function identity(config: unknown): Identity { - const { codec, description, container, sampleRate, numberOfChannels, displayAspectWidth, displayAspectHeight } = - config as Identity; - return structuredClone({ - codec, - description, - container, - sampleRate, - numberOfChannels, - displayAspectWidth, - displayAspectHeight, - }); -} diff --git a/js/publish/src/internal.ts b/js/publish/src/internal.ts index 30ef43dc943..a3222a88221 100644 --- a/js/publish/src/internal.ts +++ b/js/publish/src/internal.ts @@ -1,9 +1,6 @@ import type * as Moq from "@moq/net"; import type { Effect, Getter } from "@moq/signals"; -/** @internal Terminal failures of broadcast-owned rendition tracks, including idle tracks. */ -export const errors = new WeakMap>(); - /** * @internal Whether a subscriber is attached to `track`, scoped to `effect`. Changes only when that * does, not with every group the subscribers want, so a bandwidth reservation keyed on it holds. diff --git a/js/publish/src/publication.ts b/js/publish/src/publication.ts new file mode 100644 index 00000000000..0cb40493265 --- /dev/null +++ b/js/publish/src/publication.ts @@ -0,0 +1,180 @@ +import * as Json from "@moq/json"; +import type * as Moq from "@moq/net"; +import { type Getter, Signal } from "@moq/signals"; +import type { Kind, Rendition } from "./rendition"; + +/** + * @internal What a rendition's producer keeps for one catalog ID in one network broadcast. A new + * network broadcast starts a new slot, so what the producer advertised, and how it failed, start over + * with it. A class, so a signal holding one changes with each new slot. + */ +export class Slot { + /** The coded-size ceiling of the largest accepted probe (video). */ + ceiling?: { width: number; height: number }; + + /** The largest bitrate and frame rate advertised, since the catalog may only raise them. */ + bitrate = 0; + framerate = 0; + + /** The last config published while enabled, which a disabled rendition keeps advertising. */ + last?: Config; + + /** + * The producer's terminal failure. It ends the track and keeps the rendition down until it clears, + * when the rendition recovers under a fresh track name. + */ + readonly error = new Signal(undefined); +} + +/** @internal One catalog ID's rendition in a network broadcast. */ +export type Entry = { + readonly id: string; + readonly kind: Kind; + // The track name the catalog lists: the ID itself until new media or a replacement renames it. + name: string; + // The network track serving it. Kept after its rendition leaves, so registering the ID again + // continues it. + track?: Moq.Track.Producer; + // The registration that last served `track`. + owner?: Rendition; + // What the registration's producer keeps for this ID. + readonly slot: Slot; +}; + +/** @internal What the catalog lists for a rendition: its kind and its config's media identity. */ +export type Listing = { kind: Kind; identity: Identity }; + +/** + * @internal The renditions of one network broadcast, keyed by catalog ID. A new network broadcast + * starts a new publication, so every name, kind, track, and slot starts over with it. + */ +export class Publication { + // Each catalog ID that holds or held a rendition here. + readonly #entries = new Map(); + + // Every track name handed out here, with the catalog ID owning it and the identity it was first + // listed with (none for a replacement never listed). Kept after the ID leaves, so a name always + // means the same content: an encoder may cache groups on a track before anyone can find it, and + // a late subscriber or a fetch reads them under the catalog's config. + readonly #names = new Map(); + + readonly #renames = new Signal(0); + + /** Bumped on every rename, so the catalog fold and the track loop pick up the new name. */ + readonly renames: Getter = this.#renames; + + // Names outside this publication that a minted name must also avoid. + readonly #reserved: () => Iterable; + + /** + * Seeded with the latest catalog fold, so from here a name keeps the identity it is first listed + * with. `reserved` lists the other names a replacement must not take. + */ + constructor(listed: Map, reserved: () => Iterable) { + this.#reserved = reserved; + for (const [id, { kind, identity }] of listed) { + this.#entries.set(id, { id, kind, name: id, slot: new Slot() }); + this.#names.set(id, { id, identity }); + } + } + + /** Throw unless `id` may register as `kind`. */ + check(id: string, kind: Kind): void { + // An ID keeps its track, so it keeps its kind: different media takes a different ID. + const previous = this.#entries.get(id)?.kind; + if (previous !== undefined && previous !== kind) { + throw new Error(`rendition ${id} was registered as ${previous}, not ${kind}`); + } + // A replacement track holds or held the name, so another rendition's media owns it. + const owner = this.#names.get(id)?.id; + if (owner !== undefined && owner !== id) { + throw new Error(`rendition ${id} names another rendition's replacement track`); + } + } + + /** The entry for `rendition`'s catalog ID, created on first sight. */ + entry(rendition: Rendition): Entry { + let entry = this.#entries.get(rendition.name); + if (!entry) { + entry = { id: rendition.name, kind: rendition.kind, name: rendition.name, slot: new Slot() }; + this.#entries.set(rendition.name, entry); + this.#names.set(rendition.name, { id: rendition.name }); + } + return entry; + } + + /** + * The track name `rendition` would serve its config's `identity` under: its current one, or a + * fresh one for a new identity. Everything else updates the served track in place. Changes + * nothing until {@link commit}, so a catalog edit that fails leaves the tracks alone. + */ + name(rendition: Rendition, identity: Identity): string { + const current = this.#entries.get(rendition.name)?.name ?? rendition.name; + const listed = this.#names.get(current)?.identity; + if (listed && !Json.deepEqual(listed, identity)) return this.#mint(rendition.name); + return current; + } + + /** Record that the catalog lists `rendition` under `name` with `identity`. */ + commit(rendition: Rendition, name: string, identity: Identity): void { + const entry = this.entry(rendition); + if (entry.name !== name) this.#move(entry, name); + const listed = this.#names.get(name); + if (listed && !listed.identity) listed.identity = identity; + } + + /** + * Move `entry` to a fresh track name, such as for a replacement registration after its track + * closed. The track loop ends its old track, so that track's subscribers follow the catalog. + */ + rename(entry: Entry): void { + this.#move(entry, this.#mint(entry.id)); + } + + #move(entry: Entry, name: string): void { + this.#names.set(name, { id: entry.id }); + entry.name = name; + this.#renames.update((n) => n + 1); + } + + // The next unused `/` track name. This publisher owns the namespace and reserves `/` for + // the rendition (see `Broadcast.net`), so skipping every name handed out here and every reserved + // one is enough to never reuse one. + #mint(id: string): string { + const taken = new Set([...this.#names.keys(), ...this.#entries.keys(), ...this.#reserved()]); + for (let n = 1; ; n++) { + const name = `${id}/${n}`; + if (!taken.has(name)) return name; + } + } + + /** End every track. */ + close(): void { + for (const { track } of this.#entries.values()) track?.finish(); + for (const { track } of this.#entries.values()) track?.close(); + } +} + +/** + * The fields a consumer can't change without reinitializing its decoder (or demuxer, for + * `container`), so changing any of them takes a new track name. Mirrors hang's + * `VideoConfig::same_identity` and `AudioConfig::same_identity`. + */ +export type Identity = Record; + +const IDENTITY: Record = { + video: ["codec", "description", "container", "displayAspectWidth", "displayAspectHeight", "interlaced"], + audio: ["codec", "description", "container", "sampleRate", "numberOfChannels"], + text: ["container"], +}; + +/** + * @internal The identity of a `kind` config. Cloned, so a producer mutating a config it shares (such + * as its container) can't rewrite what a name was served with. + */ +export function identity(kind: Kind, config: unknown): Identity { + const source = config as Record; + const fields: Identity = {}; + for (const field of IDENTITY[kind]) fields[field] = source[field]; + return structuredClone(fields); +} diff --git a/js/publish/src/rendition-internal.ts b/js/publish/src/rendition-internal.ts index 44eea26e877..9ad9efaf973 100644 --- a/js/publish/src/rendition-internal.ts +++ b/js/publish/src/rendition-internal.ts @@ -1,14 +1,16 @@ import type * as Moq from "@moq/net"; import { type Getter, Signal } from "@moq/signals"; +import type { Slot } from "./publication"; /** The media kind of a rendition, selecting its catalog section (`video`, `audio`, or `text`). */ export type Kind = "video" | "audio" | "text"; -/** Register a handle whose track and teardown belong to its broadcast. */ +/** @internal Register a handle whose track and teardown belong to its broadcast. */ export let makeRendition: ( name: string, kind: Kind, track: Getter, + slot: Getter | undefined>, close: () => void, ) => Rendition; @@ -44,18 +46,31 @@ export class Rendition { */ readonly track: Getter; + /** + * @internal What the producer keeps for this rendition in the current network broadcast, or + * `undefined` while there is none. Setting its `error` ends the track. + */ + readonly slot: Getter | undefined>; + readonly #close: () => void; #closed = false; - private constructor(name: string, kind: Kind, track: Getter, close: () => void) { + private constructor( + name: string, + kind: Kind, + track: Getter, + slot: Getter | undefined>, + close: () => void, + ) { this.name = name; this.kind = kind; this.track = track; + this.slot = slot; this.#close = close; } static { - makeRendition = (name, kind, track, close) => new Rendition(name, kind, track, close); + makeRendition = (name, kind, track, slot, close) => new Rendition(name, kind, track, slot, close); } /** diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index 81b7150acfa..e1f64567de9 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -457,7 +457,7 @@ test("an encoder resuming on a still source encodes a keyframe of the current pi const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI }); // No subscriber yet, so the encoder idles while the source sends its only frame. const live = new Signal(undefined); - const rendition = { config: new Signal(undefined), track: live, close: () => {} }; + const rendition = { config: new Signal(undefined), track: live, slot: new Signal(undefined), close: () => {} }; const encoder = new Encoder("video", { enabled: true, broadcast: { video: () => rendition, order: new Order() } as never, @@ -564,7 +564,7 @@ test("an encoder torn down while a frame is in flight closes that frame", async const capture = new Capture({ source: input.source }); const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI }); const live = new Signal(undefined); - const rendition = { config: new Signal(undefined), track: live, close: () => {} }; + const rendition = { config: new Signal(undefined), track: live, slot: new Signal(undefined), close: () => {} }; const encoder = new Encoder("video", { enabled: true, broadcast: { video: () => rendition, order: new Order() } as never, diff --git a/js/publish/src/video/encoder-stall.test.ts b/js/publish/src/video/encoder-stall.test.ts index 8441cfb25f5..f03a2f95540 100644 --- a/js/publish/src/video/encoder-stall.test.ts +++ b/js/publish/src/video/encoder-stall.test.ts @@ -110,6 +110,7 @@ test("a slower-than-realtime encoder drops input instead of ratcheting catalog l // An on-time audio rendition shares the broadcast, writing each capture as it happens. const audio = new Estimator(); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index 0ea3499c4c9..a69d7f1b2ff 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -7,6 +7,7 @@ import * as Moq from "@moq/net"; import { Signal } from "@moq/signals"; import { Broadcast } from "../broadcast"; import { Estimator, Order } from "../order"; +import { Slot } from "../publication"; import { Encoder } from "./encoder"; // How long a fake hardware probe takes on the installed clock. @@ -123,6 +124,7 @@ test("encoding tracks encoder config in its child effect", async () => { const track = new Moq.Track.Producer("video/hd").accept({ timescale: Moq.Time.Timescale.MILLI }); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { @@ -164,6 +166,7 @@ test("an on-demand gap marks a discontinuity and leaves the broadcast-owned trac const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI, priority: 60 }); const live = new Signal(track); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: live, close: () => { @@ -218,6 +221,7 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI, priority: 60 }); const sub = track.subscribe(); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { @@ -303,6 +307,7 @@ test("every published config fits a probe for its own codec", async () => { const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI, priority: 60 }); const sub = track.subscribe(); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { @@ -655,6 +660,7 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI }); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { @@ -822,6 +828,7 @@ test("the probe encodes with the frame rate and bitrate ceiling", async () => { const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI, priority: 60 }); const sub = track.subscribe(); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { @@ -1029,6 +1036,7 @@ async function recordKeyframes( const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MICRO, ...info }); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { @@ -1333,6 +1341,50 @@ test("a failed video encoder disables only its rendition and recovers on a fresh } }); +// A failure belongs to the network broadcast's track, so a new network broadcast starts without it, +// and with its own maxima. +test("a new network broadcast clears a video encoder failure and its maxima", async () => { + using _videoEncoder = installFakeVideoEncoder(); + using _probe = spyOn(FakeVideoEncoder, "isConfigSupported").mockResolvedValue({ supported: true }); + const flush = async () => { + for (let i = 0; i < 10; i++) await new Promise((resolve) => queueMicrotask(resolve)); + }; + const name = new Signal(Moq.Path.from("one.hang")); + const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name }); + const capture = { + in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) } as never) }, + out: { display: new Signal({ width: 640, height: 480 }), frames: new Signal(undefined) }, + }; + const encoder = new Encoder("video", { broadcast, capture: capture as never, config: { frameRate: 60 } }); + try { + await encoder.out.catalog.changed(); + await flush(); + encoder.config.set({ frameRate: 30 }); + await flush(); + expect(encoder.out.catalog.peek()?.framerate).toBe(60); + + const live = FakeVideoEncoder.instances.find((instance) => instance.state === "configured"); + if (!live) throw new Error("expected live video encoder"); + const failure = new Error("GPU encoder failed"); + live.fail(failure); + await flush(); + expect(encoder.out.error.peek()).toBe(failure); + expect(encoder.out.active.peek()).toBe(false); + + name.set(Moq.Path.from("two.hang")); + await flush(); + expect(encoder.out.error.peek()).toBeUndefined(); + expect(encoder.out.active.peek()).toBe(true); + const catalog = encoder.out.catalog.peek(); + expect(catalog?.enabled).not.toBe(false); + expect(catalog?.track).toBeUndefined(); + expect(catalog?.framerate).toBe(30); + } finally { + encoder.close(); + broadcast.close(); + } +}); + // A track's coded size is a ceiling fixed for its name: a smaller capture resizes in band, opening a // group whose keyframe carries the new size, and the catalog never hears about it. test("a shrinking capture resizes in band below the advertised ceiling", async () => { @@ -1404,6 +1456,7 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI }); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { @@ -1476,6 +1529,7 @@ test("the catalog bitrate and frame rate only rise", async () => { const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI, priority: 60 }); const sub = track.subscribe(); const rendition = { + slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), close: () => { @@ -1527,16 +1581,22 @@ test("the catalog bitrate and frame rate only rise", async () => { } }); -// Another broadcast is another track, so the ceiling and maxima start over there, while returning to a -// broadcast continues the track it kept. -test("each broadcast keeps its own ceiling and maxima", async () => { +// Another slot is another track, so the ceiling and maxima start over there, while returning to a +// slot continues the track it kept. A new network broadcast, or another broadcast, hands over a new slot. +test("each slot keeps its own ceiling and maxima", async () => { using _videoEncoder = installFakeVideoEncoder(); - const rendition = () => ({ - config: new Signal(undefined), - track: new Signal(undefined), - close: () => {}, - }); - const first = { video: rendition, order: new Order() }; + // A broadcast serving its one rendition from one slot, as a network broadcast does. + const fake = () => { + const slot = new Signal(new Slot()); + const rendition = () => ({ + config: new Signal(undefined), + track: new Signal(undefined), + slot, + close: () => {}, + }); + return { video: rendition, order: new Order() }; + }; + const first = fake(); const broadcast = new Signal(first); const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, @@ -1558,7 +1618,7 @@ test("each broadcast keeps its own ceiling and maxima", async () => { expect(encoder.out.catalog.peek()).toMatchObject({ codedWidth: 1920, codedHeight: 1072, framerate: 60 }); const bitrate = encoder.out.catalog.peek()?.bitrate ?? 0; - broadcast.set({ video: rendition, order: new Order() }); + broadcast.set(fake()); // The previous broadcast's probe never describes the new track, even while its own runs. for (let i = 0; i < 5; i++) await Promise.resolve(); expect(encoder.out.catalog.peek()).toBeUndefined(); @@ -1581,7 +1641,7 @@ test("each broadcast keeps its own ceiling and maxima", async () => { enabled.set(false); await settle(); expect(encoder.out.catalog.peek()?.enabled).toBe(false); - broadcast.set({ video: rendition, order: new Order() }); + broadcast.set(fake()); await settle(); expect(encoder.out.catalog.peek()).toBeUndefined(); } finally { diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 042f193871f..186d7395690 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -8,14 +8,14 @@ import { type Getter, getter, type Inputs, - Once, type Readonlys, readonlys, Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { errors, watched } from "../internal"; +import { watched } from "../internal"; import { Estimator, type Order } from "../order"; +import { Slot } from "../publication"; import { hardwareReliable } from "../support/video"; import type { Capture } from "./capture"; import { normalizeSource, type Source } from "./types"; @@ -162,10 +162,10 @@ export class Encoder { // The output dimensions of the video in pixels. #dimensions = new Signal<{ width: number; height: number } | undefined>(undefined); - // The coded-size ceiling to probe for the current broadcast: each side's largest of its accepted + // The coded-size ceiling to probe for the current slot: each side's largest of its accepted // ceiling and the current size. A smaller capture resizes in band below it without changing what // the catalog advertises. - #coded = new Signal<{ broadcast?: Broadcast; width: number; height: number } | undefined>(undefined); + #coded = new Signal<{ slot: Slot; width: number; height: number } | undefined>(undefined); // The config the browser accepted and encoded a probe frame with, and the codec string it reported. // Kept through a re-probe, so the rendition stays in the catalog until the new probe replaces it. @@ -198,8 +198,6 @@ export class Encoder { */ readonly settled: Computed; - // Each registration keeps its terminal failure even after the public error clears. - #failure = new Signal(new Once()); // The capture inputs #runCaptureRecovery last saw, carried across a gap in the source or display. #seen?: { capture?: Capture; @@ -210,10 +208,12 @@ export class Encoder { #estimator = new Estimator(); // The estimator's delay, republished whenever it rises. #estimate = new Signal({}); - // What this rendition advertised on each broadcast, keyed by the broadcast or, for a local preview, - // the encoder. A broadcast keeps a rendition's track when it re-registers, so returning to one - // continues its ceiling, maxima, and last config, while another broadcast starts its own. - #advertised = new WeakMap(); + // What this rendition keeps for the current network broadcast's track: its ceiling, maxima, last + // config, and failure. A network broadcast keeps a rendition's track when it re-registers, so + // returning to one continues them, while a new one starts its own. + #slot = new Signal | undefined>(undefined); + // The slot while no network broadcast is serving the rendition, such as for a local preview. + #local = new Slot(); // Whether the rendition was disabled since a config last resolved, so it keeps advertising its last // config as disabled until the re-enabled capture resolves a new one. A capture that never reopens // leaves it disabled, which is accurate: no frames are coming. @@ -270,20 +270,20 @@ export class Encoder { } }); } + this.#signals.run(this.#runError.bind(this)); this.#signals.run(this.#runSettingsRecovery.bind(this)); this.#signals.run(this.#runCaptureRecovery.bind(this)); this.#signals.run(this.#runRegister.bind(this)); } - // What this rendition advertised on `broadcast`'s track. - #track(broadcast: Broadcast | undefined): Advertised { - const key = broadcast ?? this; - let track = this.#advertised.get(key); - if (!track) { - track = { bitrate: 0, framerate: 0 }; - this.#advertised.set(key, track); - } - return track; + // The current slot, read through `effect`. + #current(effect: Effect): Slot { + return effect.get(this.#slot) ?? this.#local; + } + + // The public error is the current slot's, so a new network broadcast starts without one. + #runError(effect: Effect): void { + effect.set(this.#out.error, effect.get(this.#current(effect).error)); } // Count a failure until `effect` reruns with new inputs. @@ -320,23 +320,21 @@ export class Encoder { this.#rearm(); } - // Clear a latched failure so a fresh registration retries. + // Clear a latched failure, so the broadcast serves the rendition again under a fresh track name. #rearm(): void { - if (!this.#out.error.peek()) return; - this.#out.error.set(undefined); - this.#failure.set(new Once()); + (this.#slot.peek() ?? this.#local).error.set(undefined); } // Register the rendition on the broadcast and drive its catalog + encode loop. Re-registers cleanly // when the broadcast swaps. #runRegister(effect: Effect): void { - const failure = effect.get(this.#failure); const broadcast = effect.get(this.in.broadcast); if (!broadcast) return; const rendition = broadcast.video(this.name); - errors.set(rendition, failure); effect.cleanup(() => rendition.close()); + effect.proxy(this.#slot, rendition.slot); + effect.cleanup(() => this.#slot.set(undefined)); // Publish the resolved catalog config; undefined (until one resolves) leaves it out of the catalog. effect.proxy(rendition.config, this.out.catalog); @@ -578,10 +576,10 @@ export class Encoder { } #abort(error: unknown, track: Moq.Track.Producer): void { - if (this.#out.error.peek()) return; + const failure = (this.#slot.peek() ?? this.#local).error; + if (failure.peek()) return; const cause = error instanceof Error ? error : new Error(String(error)); - this.#failure.peek().set(cause); - this.#out.error.set(cause); + failure.set(cause); track.abort(cause); } @@ -593,11 +591,11 @@ export class Encoder { #runCatalog(effect: Effect): void { const estimate = effect.get(this.#estimate); const enabled = effect.get(this.in.enabled) && !effect.get(this.#out.error); - const track = this.#track(effect.get(this.in.broadcast)); + const slot = this.#current(effect); if (!enabled) this.#paused = true; const live = enabled ? effect.get(this.#live) : undefined; if (!live) { - const last = this.#paused ? track.last : undefined; + const last = this.#paused ? slot.last : undefined; effect.set(this.#out.catalog, last && { ...last, ...estimate, enabled: false }); return; } @@ -606,12 +604,12 @@ export class Encoder { // Advertise the codec string the probe's encoder reported rather than the one we configured, // so it names the profile and level the bitstream actually carries at the ceiling. const { config, reported } = live.detected; - track.bitrate = Math.max(track.bitrate, config.bitrate); - track.framerate = Math.max(track.framerate, config.framerate); + slot.bitrate = Math.max(slot.bitrate, config.bitrate); + slot.framerate = Math.max(slot.framerate, config.framerate); const catalog: Catalog.VideoConfig = { codec: reported, - bitrate: Catalog.u53(track.bitrate), - framerate: track.framerate, + bitrate: Catalog.u53(slot.bitrate), + framerate: slot.framerate, codedWidth: Catalog.u53(config.width), codedHeight: Catalog.u53(config.height), optimizeForLatency: true, @@ -621,7 +619,7 @@ export class Encoder { ...estimate, }; - track.last = catalog; + slot.last = catalog; effect.set(this.#out.catalog, catalog); } @@ -642,9 +640,9 @@ export class Encoder { // Captured now: the run's signal is replaced once a rerun starts. const superseded = effect.abort; effect.spawn(async () => { - const track = this.#track(dimensions.broadcast); + const { slot } = dimensions; try { - let detected: Detected; + let detected: Probe; try { detected = await this.#bestCodec(dimensions, target); } catch (err) { @@ -652,7 +650,7 @@ export class Encoder { // down to fit it, until a smaller capture reruns the probe. Re-probing the ceiling with the // current target, rather than reusing the old probe, still refuses a target the hardware // can't meet and survives a disable that dropped the old probe. - const accepted = track.ceiling; + const accepted = slot.ceiling; if ( superseded.aborted || !accepted || @@ -670,8 +668,8 @@ export class Encoder { // Only an accepted probe commits a ceiling, so a size the hardware refuses never sticks. const { width, height } = detected.config; - track.ceiling = { width, height }; - this.#codec.set({ ...detected, broadcast: dimensions.broadcast }); + slot.ceiling = { width, height }; + this.#codec.set({ ...detected, slot }); } catch (err) { // A newer probe reports its own outcome. if (superseded.aborted) return; @@ -690,12 +688,12 @@ export class Encoder { #runResolved(effect: Effect): void { if (!effect.get(this.in.enabled)) return; - // A probe for another broadcast describes another track, so wait for this one's. - const broadcast = effect.get(this.in.broadcast); + // A probe for another slot describes another track, so wait for this one's. + const slot = this.#current(effect); const detected = effect.get(this.#codec); const dimensions = effect.get(this.#dimensions); const target = effect.get(this.#target); - if (!detected || detected.broadcast !== broadcast || !dimensions || !target) return; + if (!detected || detected.slot !== slot || !dimensions || !target) return; // A capture that outgrew the probe encodes scaled down to fit until the re-probe at the new // ceiling lands, so the live config never exceeds what the catalog advertises. @@ -758,24 +756,24 @@ export class Encoder { effect.set(this.#dimensions, { width, height }); } - // The ceiling to probe: each side's largest of this broadcast's accepted ceiling and the current - // size. Reruns once a probe commits a ceiling, which leaves it unchanged. + // The ceiling to probe: each side's largest of this slot's accepted ceiling and the current size. + // Reruns once a probe commits a ceiling, which leaves it unchanged. #runCeiling(effect: Effect): void { const dimensions = effect.get(this.#dimensions); if (!dimensions) return; - const broadcast = effect.get(this.in.broadcast); + const slot = this.#current(effect); effect.get(this.#codec); - const ceiling = this.#track(broadcast).ceiling; + const ceiling = slot.ceiling; effect.set(this.#coded, { - broadcast, + slot, width: Math.max(ceiling?.width ?? 0, dimensions.width), height: Math.max(ceiling?.height ?? 0, dimensions.height), }); } // Try to determine the best config for the given settings. - async #bestCodec(dimensions: { width: number; height: number }, target: Target): Promise { + async #bestCodec(dimensions: { width: number; height: number }, target: Target): Promise { // A list of codecs to try, in order of preference. Only full RFC 6381 strings: Chrome, Firefox, // and Safari all refuse a bare `avc1` or `vp09`, and native players can't decode without the profile. const HARDWARE_CODECS = [ @@ -840,7 +838,7 @@ export class Encoder { // The full config, like moq-video's probe: an encoder may pick its level from the frame rate // or bitrate. The bitrate is the ceiling rather than the grant, so a bandwidth estimate never // re-probes. - const config: Detected["config"] = { + const config: Probe["config"] = { codec, width: dimensions.width, height: dimensions.height, @@ -900,26 +898,16 @@ type Target = { }; // A hardware probe result. -type Detected = { +type Probe = { // The config the browser accepted and encoded a probe frame with. Its bitrate is the ceiling, // before any bandwidth grant. config: VideoEncoderConfig & { framerate: number; bitrate: number }; // The codec string the encoder reported for it, which the catalog advertises. reported: string; - // The broadcast the probe ran for; another one starts its own ceiling. - broadcast?: Broadcast; }; -// What a rendition advertised on one broadcast's track. -type Advertised = { - // The coded-size ceiling of the largest accepted probe. - ceiling?: { width: number; height: number }; - // The largest bitrate and frame rate advertised, since the catalog may only raise them. - bitrate: number; - framerate: number; - // The last config published while enabled, which a disabled rendition keeps advertising. - last?: Catalog.VideoConfig; -}; +// A probe and the slot it ran for; another slot starts its own ceiling. +type Detected = Probe & { slot: Slot }; // The most this rendition ever sends with `codec`, before a bandwidth grant caps it. function ceiling(codec: string, dimensions: { width: number; height: number }, target: Target): number { diff --git a/quest/m1/cut-audit/README.md b/quest/m1/cut-audit/README.md index c944b27ed38..2da59567d3b 100644 --- a/quest/m1/cut-audit/README.md +++ b/quest/m1/cut-audit/README.md @@ -42,8 +42,7 @@ internal and can ship after the cut: [moq-transport request leases](/quest/m2/ietf-request-lease.md), [group heads filled in place](/quest/m2/ietf-fill-refill.md), [one relay serve path](/quest/m2/relay-serve-path.md), -moqsrc pad actors (since landed), and -[publish rendition identity](/quest/m2/publish-publication.md). Other +moqsrc pad actors and publish rendition identity (both since landed). Other redesign evidence went into the quests that already own the mechanism: [subscribe ranges](/quest/m1/subscribe-ranges/README.md), [drop set](/quest/m1/drop-set.md), diff --git a/quest/m2/README.md b/quest/m2/README.md index a5077df3886..f09abc92486 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -116,7 +116,6 @@ lite-07; bitrate enforcement is not part of this release. - [One relay serve path for every runtime](/quest/m2/relay-serve-path.md) - io_uring serves through the shared `Connection::serve` and config mapping - [A parked read of a resumed group reads its budget without re-syncing](/quest/m2/recover-budget.md) - measure, then drop the per-frame reader sync - [Transcode rungs scale off the runtime and keep bounded state](/quest/m2/transcode-rungs.md) - resize on encoder threads, memoize rungs by shape, one writer per rung track -- [@moq/publish owns rendition identity in one publication](/quest/m2/publish-publication.md) - one map per broadcast replaces five - [One port](/quest/m2/one-port/README.md) - one UDP port carries QUIC, SRT, and WebRTC media through an embedder hook, and one TCP port carries HTTP, RTMP, and RTMPS - [qmux receive leases](/quest/m2/qmux-receive-lease.md) - WebSocket, TCP, and WebRTC sessions bound incomplete messages by their receive window, at a per-session cost the maintainer picks - [QUIC caps](/quest/m2/rate-quic.md) - paced MAX_DATA credit in and a capped pacer out hold a QUIC session to its token's bitrate diff --git a/quest/m2/publish-publication.md b/quest/m2/publish-publication.md deleted file mode 100644 index 86ea4c9fd7c..00000000000 --- a/quest/m2/publish-publication.md +++ /dev/null @@ -1,24 +0,0 @@ -# [M] @moq/publish owns rendition identity in one publication - -## Goal - -`@moq/publish` keeps each network broadcast's renditions in one publication -object mapping catalog id to kind, name, track, and owner, so renames, -replacements, and resets are structural instead of kept in agreement across -five maps and per-encoder state. - -## Plan - -From the 2026-10-24 pre-cut audit at 151a529670. Internal only. - -Rendition identity fixes keep landing in `js/publish` (e24828a601, -fe311b3d34, 2938e78f77, 3791aecce3, 6e06a956b0, 644789413d, b32b7a7421, -d0337260a6). The state is spread over five name-keyed maps in `Broadcast` -(`#kinds`, `#names`, `#replacements`, `#served`, `#listed`), plus a -per-Broadcast `#advertised` WeakMap and three failure layers in each -encoder. Replace them with one publication object per network broadcast, -mirroring Rust's identity rules (`hang::catalog::VideoConfig::same_identity` -and `AudioConfig::same_identity`, enforced by `moq-mux` since -[moq-dev/moq#7110](https://github.com/moq-dev/moq/pull/7110)). - -Public API: none. Wire: none. From a9b696b752c0f146892a7f5add9ee1ef21892c44 Mon Sep 17 00:00:00 2001 From: Michael Stuurman Date: Mon, 2 Nov 2026 08:13:03 +0000 Subject: [PATCH 68/86] fix(net): answer a draft-18 TRACK_STATUS followed at once by a FIN (#7439) Co-authored-by: Michael Stuurman Co-authored-by: Claude Opus 5.5 Co-authored-by: Luke Curley Co-authored-by: quest fixer --- doc/concept/standard.md | 3 +- js/net/src/ietf/publisher.test.ts | 29 +++ js/net/src/ietf/publisher.ts | 4 +- js/net/src/ietf/request_stream.ts | 26 ++- quest/m1/interop-coverage/README.md | 1 - .../m1/interop-coverage/draft18-status-fin.md | 33 ---- rs/moq-net/src/ietf/publisher.rs | 165 +++++++++++++++--- rs/moq-net/src/ietf/request_stream.rs | 24 ++- rs/moq-net/src/ietf/subscriber.rs | 7 +- 9 files changed, 215 insertions(+), 77 deletions(-) delete mode 100644 quest/m1/interop-coverage/draft18-status-fin.md diff --git a/doc/concept/standard.md b/doc/concept/standard.md index ae384cd4d08..15dd7bf38e0 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -28,7 +28,8 @@ subset drops. What a peer actually observes against this implementation: - **Pull.** Subscribers ask. Single-track `PUBLISH` offers are declined, unless they answer our `SUBSCRIBE_TRACKS` (below); announce a namespace and serve the resulting subscriptions. Announcements go out unsolicited. A client asks only for the prefixes its readers watch, and a server or relay link for every prefix it may discover. We never hold two overlapping requests at a peer that did not declare solicit: a broader one replaces the narrower ones once they are closed. The solicit `SETUP` option makes us wait to be asked. Without it, a peer also gets each match as a `NAMESPACE` on its `SUBSCRIBE_NAMESPACE` stream, so it hears the namespace twice. We never send `PUBLISH`. - **We list no tracks by `PUBLISH`.** Rust and JavaScript refuse each `SUBSCRIBE_TRACKS` with `NOT_SUPPORTED`, and the session stays up. Our own peers list tracks over moq-lite instead. - **A Rust TRACKS listing sends `SUBSCRIBE_TRACKS` upstream, only on demand.** Listing a broadcast from a moq-transport peer sends `SUBSCRIBE_TRACKS` with `FORWARD=0` while someone lists it, never on connect, announce, or subscribe, so a peer that refuses, resets, or ignores it affects only that listing. Each listed track's paused `PUBLISH` is its upstream copy, which a reader resumes rather than subscribing again. Only a `PUBLISH_DONE` with `TRACK_ENDED` removes a listed track, and it lists as finished; any other end fails the listing, and readers list again. Our own moq-transport publishers refuse it, so a Rust listing of one fails. -- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Datagrams are never fetchable. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED` still subscribes. +- **History is served one group at a time.** Our moq-transport publishers answer a `FETCH` only within one group, a draft-18 joining `FETCH` for the subscription's current group head, and a draft 20+ fill for the largest group's head. Whole-group fetches wait for an open group to finish. Both implementations accept and ignore `FILL_TIMEOUT`, including zero. A JavaScript subscription joins at the current group: on draft 18 it fetches the group's head with a Relative Joining `FETCH`, and from draft 20 asks for it with a fill. Datagrams are never fetchable. Subscribers still request ranges upstream, one per run of missing groups; a third-party publisher can answer those ranges and mark their gaps. A Rust reader that only fetches learns the track from `TRACK_STATUS`, so a finished track stays fetchable; a refused `TRACK_STATUS` fails that reader's request, whatever the code, rather than falling back to a `SUBSCRIBE`. +- **A requester's FIN.** On draft 18 a requester finishing its stream cancels its request, except after `TRACK_STATUS`: nothing can follow it, so the FIN only ends the requester's input and the request is still answered. From draft 20 a FIN never cancels; a reset or `STOP_SENDING` does. - **Compression.** Rust peers exchange compressed tracks when both offer the [Flate extension](/draft/moq-flate). Other peers receive one shared inflated copy. A publisher that seals the compression declaration makes that fallback unavailable: an incompatible peer is refused, preserving the sealed metadata. JavaScript does not offer the IETF extension. - **Timing.** A track whose `SUBSCRIBE_OK` or `PUBLISH` declares no `TIMESCALE` is untimed. A `SUBSCRIBE` or `FETCH` with `INCLUDE_PROPERTIES=0` gets no `TIMESCALE` in its `SUBSCRIBE_OK` or `FETCH_OK`, so its objects go out unstamped. A Rust reader that never subscribed takes the units from `TRACK_STATUS`, and still asks for the properties whenever it subscribes to or fetches a timed track. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. A `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. An unknown message parameter, or one defined only for another message, closes the session. Other malformed control input closes the session with the draft's code too, such as a server `SETUP` carrying `PATH` or `AUTHORITY`, a namespace field ending in `/` (it would map to a path with an empty segment), a request cut short by FIN, or a second `GOAWAY` on one stream; a first `GOAWAY` on a request stream is legal. diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index fb80ddaa63a..1ad11294089 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -219,6 +219,8 @@ function requestStream(version: IetfVersion) { wire: () => new Reader(undefined, Uint8Array.from(written), version), send: (bytes: Uint8Array) => input.enqueue(bytes), cancel: () => input.error(new Error("request cancelled")), + /** FIN the request's side: the requester has nothing more to send. */ + finish: () => input.close(), /** * End the request as `version` cancels one: by FIN through draft 18, and by a reset * after. @@ -287,6 +289,33 @@ test("TRACK_STATUS is answered on every draft without subscribing", async () => } }); +// A requester may FIN right after TRACK_STATUS, since nothing can follow it (moxygen does). +// That only ends its input, so the request is still answered, on draft 18 too. +test("TRACK_STATUS finished at once is answered on every draft", async () => { + for (const version of IETF_VERSIONS) { + const pair = createMockTransportPair(ALPNS[version]); + const session = new NativeSession(pair.server, version, true); + const { pub, origin } = publisher(pair.server, { session }); + const requests = origin.requestsRoute(Path.from("room")).requested(); + + // The FIN arrives while the broadcast still resolves. + const { stream, wire, finish } = requestStream(version); + const task = pub.runTrackStatusRequest(trackStatus("room"), stream); + const pending = (await requests.next()).value; + if (!pending) throw new Error("no request for room"); + finish(); + const served = new BroadcastProducer(); + served.createTrack("video"); + hooks.acceptRequest(pending, served); + await task; + expect(await wire().u53()).toBe(TrackStatusOk.id); + + await requests.return?.(); + served.close(); + origin.close(); + } +}); + // A track that has published nothing answers without LARGEST_OBJECT. test("TRACK_STATUS for an empty track names no largest", async () => { const version = Version.DRAFT_22; diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index f03af9447fe..8227432e423 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -2078,7 +2078,9 @@ export class Publisher { * @internal */ runTrackStatusRequest(msg: TrackStatusRequest, stream: Stream): Promise { - const request = new RequestStream(stream, this.#session, this.#quic); + // Nothing can follow a TRACK_STATUS, so a FIN only ends the requester's input; a reset or + // STOP_SENDING withdraws it. + const request = new RequestStream(stream, this.#session, this.#quic, { finCancels: false }); return request.serve(this.#runTrackStatusRequest(msg, request)); } diff --git a/js/net/src/ietf/request_stream.ts b/js/net/src/ietf/request_stream.ts index c1472a46a97..f32d42461a4 100644 --- a/js/net/src/ietf/request_stream.ts +++ b/js/net/src/ietf/request_stream.ts @@ -11,7 +11,14 @@ import type { Session } from "./session.ts"; import { MalformedAuthToken } from "./token.ts"; import { type IetfVersion, Version } from "./version.ts"; -/** Whether the draft treats the requester finishing its messages as cancellation. */ +/** + * Whether the requester finishing its messages withdraws a request on `version`. + * + * Draft 18 asks for a reset or STOP_SENDING (section 3.3.2) and names a FIN as a cancel only for + * SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS (section 6.1), but our own draft-18 requesters cancel + * by FIN, so we read one that way. TRACK_STATUS passes `false` instead: nothing can follow it, so + * a requester may FIN right after it (moxygen does). + */ function finCancels(version: IetfVersion): boolean { return version === Version.DRAFT_18; } @@ -147,6 +154,8 @@ export class RequestStream { readonly #stream: Stream; readonly #session: Session; readonly #quic?: WebTransport; + // Whether the peer's FIN withdraws the request, rather than only ending its input. + readonly #finCancels: boolean; // The next message, read ahead of whoever takes it. Unset until someone first waits. #ahead?: Promise; #taking = false; @@ -155,12 +164,16 @@ export class RequestStream { readonly #ended: Promise; #end!: () => void; - /** Own `stream` for a request on `session`, closing `quic` over the peer's violations. */ - constructor(stream: Stream, session: Session, quic?: WebTransport) { + /** + * Own `stream` for a request on `session`, closing `quic` over the peer's violations. The + * peer's FIN withdraws the request where `finCancels`, by default as the draft has it. + */ + constructor(stream: Stream, session: Session, quic?: WebTransport, options: { finCancels?: boolean } = {}) { this.#stream = stream; this.writer = stream.writer; this.#session = session; this.#quic = quic; + this.#finCancels = options.finCancels ?? finCancels(session.version); this.#ended = new Promise((resolve) => { this.#end = resolve; }); @@ -208,7 +221,7 @@ export class RequestStream { */ cancel(): void { if (this.#closed) return; - if (finCancels(this.#session.version)) this.writer.close(); + if (this.#finCancels) this.writer.close(); else this.writer.reset(new StreamError(StreamCode.Cancel, { message: "cancel" })); } @@ -256,12 +269,11 @@ export class RequestStream { // Start reading on the first wait. #read(): Promise { if (this.#ahead === undefined) { - const version = this.#session.version; this.#ahead = this.#readAhead(); // The read ahead stops at an untaken message, so watch for the peer's end apart from it. this.#stream.reader.closed.then( () => { - if (finCancels(version)) this.#end(); + if (this.#finCancels) this.#end(); }, () => this.#end(), ); @@ -279,7 +291,7 @@ export class RequestStream { const read = this.#readFramed(); read.then( (framed) => { - if (framed === undefined && finCancels(this.#session.version)) this.#end(); + if (framed === undefined && this.#finCancels) this.#end(); }, (err: unknown) => { if (err instanceof ProtocolViolation) this.abort(err); diff --git a/quest/m1/interop-coverage/README.md b/quest/m1/interop-coverage/README.md index 3f1326fd5c3..3006b2d6c74 100644 --- a/quest/m1/interop-coverage/README.md +++ b/quest/m1/interop-coverage/README.md @@ -44,7 +44,6 @@ coverage on `main` at `3ab6cbf81d`. ## Required -- [Draft-18 TRACK_STATUS FIN](/quest/m1/interop-coverage/draft18-status-fin.md) - a draft-18 TRACK_STATUS followed at once by a FIN is answered, so moqx-forwarded status requests reach moq-dev publishers - [JS subscribers FETCH](/quest/m1/interop-coverage/js-ietf-fetch.md) - `@moq/net` fetches past groups over moq-transport, and joins the CLI FETCH round ## Related diff --git a/quest/m1/interop-coverage/draft18-status-fin.md b/quest/m1/interop-coverage/draft18-status-fin.md deleted file mode 100644 index fddb0d801dc..00000000000 --- a/quest/m1/interop-coverage/draft18-status-fin.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] A draft-18 TRACK_STATUS followed at once by a FIN is answered - -## Goal - -On draft 18, a moq-net publisher (Rust and `@moq/net`) answers a TRACK_STATUS whose requester FINs right after sending it, with TRACK_STATUS_OK or REQUEST_ERROR, instead of resetting the stream with CANCELLED. A reset or STOP_SENDING still withdraws it. Every other draft-18 request keeps treating a requester FIN as a cancel. A unit test sends TRACK_STATUS plus an immediate FIN on drafts 18, 20 and 22 and gets an answer on each. `doc/concept/standard.md` no longer says a reader whose publisher refuses `TRACK_STATUS` still subscribes, where that reader only queries or fetches. - -## Plan - -Decided 2026-10-31 (maintainer, `/quest-import` of [moq-dev/moq#7438](https://github.com/moq-dev/moq/issues/7438) and [moq-dev/moq#7423](https://github.com/moq-dev/moq/issues/7423)). - -moxygen FINs right after TRACK_STATUS on draft 18+, since nothing can follow one. The WG agrees this is normal (moq-wg/moq-transport#1634), and draft 19 §3.3.2 says a FIN is not a cancellation. On draft 18, `rs/moq-net/src/ietf/request_stream.rs:11` `fin_cancels` treats any requester FIN as a withdrawal. So a TRACK_STATUS forwarded by a moxygen-based relay such as moqx is reset, and a moq-lite viewer behind moq-relay never subscribes. - -- Scope of the FIN rule: - - ✅ TRACK_STATUS only: a FIN only ends a TRACK_STATUS requester's input, on every draft. Our own draft-18 requesters keep cancelling other requests by FIN. - - FIN never cancels on draft 18 (except SUBSCRIBE_NAMESPACE/SUBSCRIBE_TRACKS, §6.1) - - Won't fix -- The fix is in [moq-dev/moq#7439](https://github.com/moq-dev/moq/pull/7439), an external contributor's draft that allows maintainer edits, with exactly this scope. Rust passes `fin_cancels: bool` through `poll_cancel`/`poll_withdrawn`/`until_withdrawn`/`resolve`, and `serve_track_status_stream` passes `false`. JS `RequestStream` takes `{ finCancels }`. Review it, mark it ready, and land it. Push the doc fix below onto it, or land it as a follow-up commit. -- #7423 asked that a TRACK_INFO behind a publisher refusing TRACK_STATUS fall back to subscribing: - - ✅ Won't fix + doc fix: publishers must answer TRACK_STATUS. moqxr should implement it, which the maintainer called "really not hard". This matches the 2026-10-29 rejection of blind subscribing on [moq-dev/moq#7187](https://github.com/moq-dev/moq/pull/7187). - - Fallback subscribe - - Keep open, defer -- Doc: `doc/concept/standard.md:31` ends with "a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED` still subscribes". That is left over from the fallback that [moq-dev/moq#5991](https://github.com/moq-dev/moq/pull/5991) removed. Reword it so only a subscribing reader still subscribes, while a query-only or fetch-only reader fails, as `a_refused_track_status_never_subscribes` (`rs/moq-net/src/ietf/subscriber.rs`) pins. -- Not here: moqx's GROUP_ORDER parameter in TRACK_STATUS_OK, which draft 18 refuses. That is upstream moxygen's fix, facebookexperimental/moxygen#264, tracked by [Moxygen pin bump](/quest/m1/moxygen-pin-bump.md). - -Public API: none. Wire: a draft-18 TRACK_STATUS is no longer withdrawn by a FIN alone. `release` refuses TRACK_STATUS, so nothing needs backporting. - -## Closes - -- [#7438](https://github.com/moq-dev/moq/issues/7438) - draft-18 TRACK_STATUS followed by a FIN is reset instead of answered - -## Related - -- [Moxygen pin bump](/quest/m1/moxygen-pin-bump.md) - the other half of playing through moqx on draft 18 diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 6b91d7614ea..01eaddc9100 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1019,13 +1019,15 @@ impl Publisher { self.poll_peer(waiter).map(|peer| self.excluding(&peer)) } - /// Resolve a request's broadcast while the peer still wants the answer. + /// Resolve a request's broadcast while the peer still wants the answer, a FIN withdrawing it + /// where `fin_cancels`. async fn resolve( &self, stream: &mut Stream, + fin_cancels: bool, path: &crate::Path, ) -> Result, Error> { - let mut resolve = Resolve::new(self.session.clone(), path.clone()); + let mut resolve = Resolve::new(self.session.clone(), path.clone(), fin_cancels); kio::wait(|waiter| resolve.poll(waiter, self, stream)).await } @@ -1367,7 +1369,11 @@ impl Publisher { let verdict = gate.verify(token, Some(slot.clone())); // The subscriber cancelling or the session closing withdraws the token. let verified = self - .until_withdrawn(&mut stream, |waiter| verdict.poll_grant(waiter)) + .until_withdrawn( + &mut stream, + super::request_stream::fin_cancels(self.version), + |waiter| verdict.poll_grant(waiter), + ) .await; let Some(verified) = verified else { drop((verdict, slot)); @@ -1415,7 +1421,14 @@ impl Publisher { // We just received a subscribe for this exact namespace, so the peer must have // already seen the announcement. The immediate lookup resolves it at once, or // falls back to the route covering it (an `origin::Requests`), if any. - let broadcast = match self.resolve(&mut stream, &msg.track_namespace).await { + let broadcast = match self + .resolve( + &mut stream, + super::request_stream::fin_cancels(self.version), + &msg.track_namespace, + ) + .await + { Ok(Some(broadcast)) => broadcast, // The reason is the origin's, not ours: a dynamic router refusing on // authorization or a drain says so, and only an unroutable path means the @@ -1677,23 +1690,28 @@ impl Publisher { &self, session: &mut Transport, stream: &mut Stream, + fin_cancels: bool, waiter: &kio::Waiter, ) -> Poll<()> { if session.poll_closed(waiter).is_ready() { return Poll::Ready(()); } - super::request_stream::poll_withdrawn(stream, self.version, waiter) + super::request_stream::poll_withdrawn(stream, fin_cancels, waiter) } /// Poll `step` of a request's setup until ready, or `None` once [`Self::poll_withdrawn`] is. async fn until_withdrawn( &self, stream: &mut Stream, + fin_cancels: bool, mut step: impl FnMut(&kio::Waiter) -> Poll, ) -> Option { let mut session = self.session.clone(); kio::wait(|waiter| { - if self.poll_withdrawn(&mut session, stream, waiter).is_ready() { + if self + .poll_withdrawn(&mut session, stream, fin_cancels, waiter) + .is_ready() + { return Poll::Ready(None); } step(waiter).map(Some) @@ -1734,8 +1752,14 @@ impl Publisher { if let Poll::Ready(res) = marker.poll(waiter, track_serve) { return Poll::Ready(res); } - if super::request_stream::poll_cancel(stream, request_finished, goaways, self.version, waiter).is_ready() - || closed_session.poll_closed(waiter).is_ready() + if super::request_stream::poll_cancel( + stream, + request_finished, + goaways, + super::request_stream::fin_cancels(self.version), + waiter, + ) + .is_ready() || closed_session.poll_closed(waiter).is_ready() { return Poll::Ready(Err(Error::Cancel)); } @@ -2022,7 +2046,9 @@ impl Publisher { msg: &ietf::Subscribe<'_>, ) -> Option> { let declared = self - .until_withdrawn(stream, |waiter| self.peer_setup.poll_get(waiter)) + .until_withdrawn(stream, super::request_stream::fin_cancels(self.version), |waiter| { + self.peer_setup.poll_get(waiter) + }) .await?; // A live SUBSCRIBE's NEW_GROUP_REQUEST asks nothing more: subscribing is itself a join, // which the track's refresh ratio judges. @@ -2049,7 +2075,9 @@ impl Publisher { // anything is served. let subscribing = track.subscribe(subscription.clone()); let mut subscribed = match self - .until_withdrawn(stream, |waiter| subscribing.poll_ok(waiter)) + .until_withdrawn(stream, super::request_stream::fin_cancels(self.version), |waiter| { + subscribing.poll_ok(waiter) + }) .await? { // A peer reads the end of the subscription as the track's. @@ -2060,8 +2088,10 @@ impl Publisher { // A relay's copy that went idle cannot say where its live edge is until its route // answers again; answering from its cache would advertise a stale one. - self.until_withdrawn(stream, |waiter| subscribed.poll_live(waiter)) - .await?; + self.until_withdrawn(stream, super::request_stream::fin_cancels(self.version), |waiter| { + subscribed.poll_live(waiter) + }) + .await?; // The filter and any fill are relative to the live edge, so snapshot it once: the fill // ends exactly where a Next Object subscription begins, which is what lets the draft's @@ -2888,7 +2918,14 @@ impl Publisher { // The peer must have seen the announcement to name this namespace, so this // resolves like a SUBSCRIBE does. - let broadcast = match gate.guard(self.resolve(&mut stream, &namespace)).await { + let broadcast = match gate + .guard(self.resolve( + &mut stream, + super::request_stream::fin_cancels(self.version), + &namespace, + )) + .await + { Ok(Some(broadcast)) => broadcast, Ok(None) => return Ok(()), Err(err) => return self.reject_fetch(stream, &err, &err.to_string()).await, @@ -3001,8 +3038,14 @@ impl Publisher { compression, )); kio::wait(|waiter| { - if super::request_stream::poll_cancel(&mut stream, &mut finished, &mut messages, self.version, waiter) - .is_ready() + if super::request_stream::poll_cancel( + &mut stream, + &mut finished, + &mut messages, + super::request_stream::fin_cancels(self.version), + waiter, + ) + .is_ready() { return Poll::Ready(None); } @@ -3129,8 +3172,14 @@ impl Publisher { let res = { let mut respond = std::pin::pin!(respond); kio::wait(|waiter| { - if super::request_stream::poll_cancel(&mut stream, &mut finished, &mut messages, self.version, waiter) - .is_ready() + if super::request_stream::poll_cancel( + &mut stream, + &mut finished, + &mut messages, + super::request_stream::fin_cancels(self.version), + waiter, + ) + .is_ready() { return Poll::Ready(None); } @@ -3188,7 +3237,15 @@ impl Publisher { let mut deadline = crate::time::Deadline::after(&self.runtime, Duration::from_secs(10)); kio::wait(|waiter| { // The request reader is what the subscriber FINs or resets. - if super::request_stream::poll_cancel(stream, finished, messages, self.version, waiter).is_ready() { + if super::request_stream::poll_cancel( + stream, + finished, + messages, + super::request_stream::fin_cancels(self.version), + waiter, + ) + .is_ready() + { return Poll::Ready(Err(Error::Cancel)); } let joins = self.joins.poll(waiter, |joins| match joins.get(&subscribe_id) { @@ -3265,7 +3322,13 @@ impl Publisher { if !gate.allows() { return self.reject_track_status(stream, &Error::Unauthorized).await; } - let broadcast = match gate.guard(self.resolve(&mut stream, &msg.track_namespace)).await { + // Nothing can follow a TRACK_STATUS, so a FIN only ends the requester's input; a reset or + // STOP_SENDING withdraws it. + let fin_cancels = false; + let broadcast = match gate + .guard(self.resolve(&mut stream, fin_cancels, &msg.track_namespace)) + .await + { Ok(Some(broadcast)) => broadcast, Ok(None) => return Ok(()), Err(err) => return self.reject_track_status(stream, &err).await, @@ -3282,7 +3345,7 @@ impl Publisher { let mut finished = false; let mut messages = super::request_stream::Messages::new(self.session.clone(), self.control.client()); kio::wait(|waiter| { - if super::request_stream::poll_cancel(&mut stream, &mut finished, &mut messages, self.version, waiter) + if super::request_stream::poll_cancel(&mut stream, &mut finished, &mut messages, fin_cancels, waiter) .is_ready() { return Poll::Ready(None); @@ -4388,16 +4451,18 @@ struct LiveGroup { /// requester withdraws or the session closes. struct Resolve { session: Transport, + fin_cancels: bool, path: crate::Path, state: ResolveState, } #[kio::machine(self.state: ResolveState)] impl Resolve { - fn new(session: Transport, path: crate::Path) -> Self { + fn new(session: Transport, path: crate::Path, fin_cancels: bool) -> Self { Self { session, path, + fin_cancels, state: ResolveState::Start, } } @@ -4408,7 +4473,7 @@ impl Resolve { publisher: &Publisher, stream: &mut Stream, ) -> Poll, Error>> { - let origin = wait!(Origin => match publisher.poll_withdrawn(&mut self.session, stream, waiter) { + let origin = wait!(Origin => match publisher.poll_withdrawn(&mut self.session, stream, self.fin_cancels, waiter) { Poll::Ready(()) => Poll::Ready(None), Poll::Pending => publisher.poll_serving_origin(waiter).map(Some), }); @@ -4417,7 +4482,7 @@ impl Resolve { }; // The immediate lookup: answering a peer never starts discovery of its own. let requesting: kio::Pending = origin.lookup(self.path.clone(), None); - let found = wait!(Lookup => match publisher.poll_withdrawn(&mut self.session, stream, waiter) { + let found = wait!(Lookup => match publisher.poll_withdrawn(&mut self.session, stream, self.fin_cancels, waiter) { Poll::Ready(()) => Poll::Ready(None), Poll::Pending => requesting.poll_ok(waiter).map(Some), }); @@ -6986,7 +7051,7 @@ mod serve_tests { &mut stream, &mut finished, &mut goaways, - version, + super::super::request_stream::fin_cancels(version), waiter ), Poll::Ready(Err(Error::ProtocolViolation)) @@ -8628,6 +8693,52 @@ mod serve_tests { body } + /// A requester may FIN right after TRACK_STATUS, since nothing can follow it (moxygen + /// does). That ends its input and is still answered, on draft 18 too. + #[kio::test] + async fn track_status_finished_at_once_is_answered() { + for version in ALL_VERSIONS { + let h = serve_session( + version, + track::Info::timed(), + crate::origin::Route::default(), + ScriptedSession::per_stream_eof(vec![Vec::new()]), + ); + let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) + .await + .unwrap(); + let mark = h.log.trail().len(); + let body = track_status("room", true, version); + let task = h + .publisher + .clone() + .handle_stream(ietf::TrackStatus::ID, ietf::Body(bytes::Bytes::from(body)), stream) + .unwrap(); + kio::clock::timeout(std::time::Duration::from_secs(1), task) + .await + .unwrap_or_else(|_| panic!("{version}: never answered")); + + let trail = h.log.trail()[mark..].to_vec(); + let wire: Vec = trail + .iter() + .filter_map(|(_, sent)| match sent { + Sent::Write(bytes) => Some(bytes.clone()), + _ => None, + }) + .flatten() + .collect(); + assert!(!wire.is_empty(), "{version}: not answered"); + let mut buf = Decoder::new(&wire, version.into()); + assert_eq!(buf.varint().unwrap(), ietf::TrackStatusOk::ID, "{version}"); + ietf::TrackStatusOk::decode(&mut buf, version).unwrap(); + assert!( + matches!(trail.last(), Some((_, Sent::Finish))), + "{version}: the answer did not end with a FIN: {trail:?}" + ); + assert!(h.log.resets().is_empty(), "{version}: reset"); + } + } + /// TRACK_STATUS gets what a SUBSCRIBE_OK would carry, as far as each draft's answer has /// room, without subscribing to the track. INCLUDE_PROPERTIES=0 empties the properties. #[kio::test] @@ -12511,7 +12622,11 @@ mod serve_tests { ); assert_eq!(demanded(), !matches!(setup, Setup::Resolving), "{version} {setup:?}"); - withdraw(&request, version); + match kind { + // A FIN only ends a TRACK_STATUS requester's input; a reset withdraws it. + request::Kind::TrackStatus => request.close(crate::lite::test_transport::Close::Reset), + _ => withdraw(&request, version), + } let res = kio::clock::timeout(std::time::Duration::from_secs(1), serving) .await .unwrap_or_else(|_| panic!("{version} {setup:?}: outlived its withdrawal")); diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index 89ee8c0d369..ac39ce7094a 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -8,21 +8,28 @@ use crate::{ coding::{Decode, DecodeError, Decoder, Stream, Writer}, }; +/// Whether the requester finishing its messages withdraws a request on `version`. +/// +/// Draft 18 asks for a reset or STOP_SENDING (section 3.3.2) and names a FIN as a cancel only for +/// SUBSCRIBE_NAMESPACE and SUBSCRIBE_TRACKS (section 6.1), but our own draft-18 requesters cancel +/// by FIN, so we read one that way. TRACK_STATUS passes `false` instead: nothing can follow it, so +/// a requester may FIN right after it (moxygen does). pub(super) fn fin_cancels(version: Version) -> bool { matches!(version, Version::Draft18) } -/// Watch abrupt cancellation after the requester has finished sending messages. +/// Watch abrupt cancellation after the requester has finished sending messages, and its FIN +/// where `fin_cancels`. pub(super) fn poll_cancel( stream: &mut Stream, finished: &mut bool, messages: &mut Messages, - version: Version, + fin_cancels: bool, waiter: &kio::Waiter, ) -> Poll> { if !*finished { match messages.poll_end(&mut stream.reader, waiter) { - Poll::Ready(Ok(())) if !fin_cancels(version) => *finished = true, + Poll::Ready(Ok(())) if !fin_cancels => *finished = true, Poll::Ready(result) => return Poll::Ready(result), Poll::Pending => {} } @@ -33,17 +40,18 @@ pub(super) fn poll_cancel( /// Watch for the requester ending a request it has not been answered on yet, without consuming /// what it sent meanwhile: a waiting message (an update, say) ends the watch on the reader and /// is read once the request is answered. A withdrawal queued behind an update is therefore seen -/// only then; until the verifier answers, the requester holds nothing but its own slot. +/// only then; until the verifier answers, the requester holds nothing but its own slot. A FIN +/// withdraws it only where `fin_cancels`. pub(super) fn poll_withdrawn( stream: &mut Stream, - version: Version, + fin_cancels: bool, waiter: &kio::Waiter, ) -> Poll<()> { if stream.writer.poll_closed(waiter).is_ready() { return Poll::Ready(()); } match stream.reader.poll_has_more(waiter) { - Poll::Ready(Ok(false)) if fin_cancels(version) => Poll::Ready(()), + Poll::Ready(Ok(false)) if fin_cancels => Poll::Ready(()), Poll::Ready(Err(_)) => Poll::Ready(()), _ => Poll::Pending, } @@ -489,7 +497,7 @@ mod tests { &mut stream, &mut finished, &mut messages, - version, + fin_cancels(version), waiter, ))) .is_pending(), @@ -503,7 +511,7 @@ mod tests { &mut stream, &mut finished, &mut messages, - version, + fin_cancels(version), waiter, ))), Poll::Ready(Err(Error::ProtocolViolation)) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 5d928d470bb..79cca9bdd1f 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1885,7 +1885,12 @@ impl Subscriber { let version = self.version; let verified = kio::wait(|waiter| { if session.poll_closed(waiter).is_ready() - || super::request_stream::poll_withdrawn(&mut stream, version, waiter).is_ready() + || super::request_stream::poll_withdrawn( + &mut stream, + super::request_stream::fin_cancels(version), + waiter, + ) + .is_ready() { return Poll::Ready(None); } From 3dede33f3cc1dd0bbd2f6f33009cc9b8b761a7b5 Mon Sep 17 00:00:00 2001 From: OneTooMany Date: Mon, 2 Nov 2026 08:13:04 +0000 Subject: [PATCH 69/86] quest: pin the restart a follower gets when the serving epochless route leaves (#7370) Co-authored-by: Thomas Gravgaard Hansen Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: Luke Curley --- quest/m1/README.md | 1 + quest/m1/epochless-leave-restart.md | 58 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 quest/m1/epochless-leave-restart.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 860a70a2ff6..6df3e4c3d3a 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -181,6 +181,7 @@ per-frame CPU growth since #5926. - [A reconfigure keeps the published jitter and delay floor](/quest/m1/catalog-jitter-floor.md) - `Rendition::set` floors unsupplied jitter and delay at the catalog's values, so a same-codec reconfigure after a jitter rise no longer aborts TS import - [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - nightly ffprobe checks that advertised timing, sync flags, and bytes match the media served - [Unbounded groups](/quest/m1/unbounded-groups/README.md) - groups and frames lose their size caps; a group keeps a 32 MB tail, and readers behind it replay through paced flights or refresh +- [A follower is told when the serving epochless route leaves](/quest/m1/epochless-leave-restart.md) - wire-level tests pin the restart a follower gets when the serving epochless route is withdrawn or dies while another remains - [Legacy lite close drain](/quest/m1/lite-legacy-close-drain.md) - a lite-01..06 graceful close never cuts a final group the subscriber has not read - [JS join tests cover draft 20](/quest/m1/join-tests-draft20.md) - join.test.ts runs on every supported draft, not just 18 and 22 - [Poll-first async](/quest/m1/poll-first/README.md) - library crates compile no coroutines by default: async wrappers are generated from `poll_*` and gated behind an `async` feature diff --git a/quest/m1/epochless-leave-restart.md b/quest/m1/epochless-leave-restart.md new file mode 100644 index 00000000000..8a6011bd6b7 --- /dev/null +++ b/quest/m1/epochless-leave-restart.md @@ -0,0 +1,58 @@ +# [S] A follower is told when the serving epochless route leaves + +## Goal + +When the route serving an epochless broadcast is withdrawn, or its session +dies, while another route for the path remains, a follower downstream of the +relay is announced the restart its version carries, such as an end then a +start on lite-06 and older and `ANNOUNCE_RESTART` on lite-07-wip. A re-request +then resumes on the survivor. Integration tests in the merge gate pin this +over the wire, with an epochless route, on lite-04 to 06, lite-07-wip, and +moq-transport 18 and 22. + +## Plan + +[Identity and recovery](/doc/concept/identity.md) promises that without an +epoch, any other route winning the path is a restart, and players restart only +on the announcement, never on a request error. If a follower is not told, a +player's track subscriptions end while the path stays announced, and playback +freezes until something else changes the announcement. The release line +before [moq-dev/moq#5087](https://github.com/moq-dev/moq/pull/5087) behaves +this way: two epochless publishers on one path, and the serving one stops. +Main implements the restart in the origin's announce cursor (the "another +instance" case), but only +`an_equal_cost_peer_replacement_waits_for_the_old_route` asserts it, at the +model level with no hold and no wire. + +Decided in quest-plan (2026-10-30): + +- Regression coverage on main, not a fix or a release backport. Reading the code + shows main already behaves correctly. If a test shows otherwise, fix it in + this PR, at the relay's announcement, never by splicing the survivor into + the front or by having players retry on track errors. +- Rust assertion only. `route_dies_without_an_epoch` in + `rs/moq-net/tests/integration/route_change.rs` already covers the + `Disconnect` and `Unannounce` triggers over moq-transport 18 and 22 and + lite-04 to 06 under mock time (`pinned_tests!`). Its `Topology::new` mints + an epoch, which those versions drop (moq-transport since + [moq-dev/moq#7239](https://github.com/moq-dev/moq/pull/7239)), so it is + epochless there but not on lite-07-wip. It checks that the subscription + ends and a re-request reaches the survivor. + Extend it, or a sibling sharing its topology, so a follower one wire hop + past the relay observes the restart for both triggers. Watching + `announced()` on the relay's own origin only sees its local model event, + which `better_route_restarts` already covers; the follower must see what the + relay sends over the wire. + Moq-transport 18 and 22 get the follower assertion from the shared + topology. Lite-07-wip is required too, through a topology that announces + without an epoch, as `replacement.rs`'s `epochless_lite07` already does. + Player-level resume is already + covered on a graceful leave by `just test native-play` (lite-06 publishers) and + `just test media --media-cases replacement`. A crash differs only in when + the relay notices, which the `Disconnect` trigger already exercises, so no + new browser or native harness case. +- Assert the event kind the version can carry, not just "something arrived". + The `best_route_wins_and_fails_over` helper accepts Update or Start, which + would hide a regression to an in-place update. + +Public API: none. Wire: none. From 3942c9642cb8bef098b8029daf516d9fe42cdead Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:14:59 +0000 Subject: [PATCH 70/86] feat(watch): one support probe for audio and video (#7359) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- js/watch/api/index.api.md | 1 + js/watch/src/audio/source.test.ts | 121 +++++++++++++++++- js/watch/src/audio/source.ts | 53 ++------ js/watch/src/support-probe.ts | 100 +++++++++++++++ .../components/unsupported-indicator.test.ts | 63 +++++++++ .../ui/components/unsupported-indicator.ts | 16 ++- js/watch/src/video/config.ts | 3 - js/watch/src/video/decoder.ts | 9 +- js/watch/src/video/source.test.ts | 3 +- js/watch/src/video/source.ts | 76 +---------- quest/m2/README.md | 1 - quest/m2/watch-support-probe.md | 26 ---- 12 files changed, 317 insertions(+), 155 deletions(-) create mode 100644 js/watch/src/support-probe.ts create mode 100644 js/watch/src/ui/components/unsupported-indicator.test.ts delete mode 100644 quest/m2/watch-support-probe.md diff --git a/js/watch/api/index.api.md b/js/watch/api/index.api.md index 16172b792d3..416efce137c 100644 --- a/js/watch/api/index.api.md +++ b/js/watch/api/index.api.md @@ -80,6 +80,7 @@ export namespace Audio { available: Signals.Signal>; catalog: Signals.Signal; config: Signals.Signal; + error: Signals.Signal; jitter: Signals.Signal; track: Signals.Signal; }; diff --git a/js/watch/src/audio/source.test.ts b/js/watch/src/audio/source.test.ts index 8237f14b156..2a3dfc28ddf 100644 --- a/js/watch/src/audio/source.test.ts +++ b/js/watch/src/audio/source.test.ts @@ -20,10 +20,15 @@ function broadcast(renditions: Record) { return { catalog, broadcast: { out: { catalog } } as unknown as Broadcast }; } -// Wait on the probe's causal boundary, rather than a timer or a guessed number of ticks. +// Wait out the probe, then a tick for the effects its result wakes. A probe served wholly from +// the cache sets and clears `pending` in one run, which notifies nothing, so start with a tick +// that lets the rerun happen rather than waiting on a change that may never come. async function settled(source: Source): Promise { - if (!source.pending.peek()) await source.pending.changed(); - while (source.pending.peek()) await source.pending.changed(); + await yieldNow(); + while (source.pending.peek()) { + await source.pending.changed(); + await yieldNow(); + } } test("a rejected rendition probe preserves its supported siblings", async () => { @@ -195,3 +200,113 @@ test("holds on a broadcast whose renditions match the previous one", async () => source.close(); } }); + +async function quietly(fn: () => Promise): Promise { + const warn = console.warn; + console.warn = () => {}; + try { + await fn(); + } finally { + console.warn = warn; + } +} + +test("reports NotSupportedError when no rendition is supported", () => + quietly(async () => { + const state = broadcast({ mic: config("opus") }); + const source = new Source({ broadcast: state.broadcast, supported: async () => false }); + try { + await settled(source); + expect(source.out.error.peek()?.name).toBe("NotSupportedError"); + expect(source.out.available.peek()).toEqual({}); + + state.catalog.set({ audio: { renditions: {} } }); + await settled(source); + expect(source.out.error.peek()).toBeUndefined(); + } finally { + source.close(); + } + })); + +test("a rejecting probe alone is unsupported", () => + quietly(async () => { + const source = new Source({ + broadcast: broadcast({ mic: config("opus") }).broadcast, + supported: async () => { + throw new TypeError("probe failed"); + }, + }); + try { + await settled(source); + expect(source.out.error.peek()?.name).toBe("NotSupportedError"); + expect(source.out.track.peek()).toBeUndefined(); + } finally { + source.close(); + } + })); + +test("does not wedge the effect when a support probe never settles", async () => { + const supported = new Signal<(config: Catalog.AudioConfig) => Promise>(() => new Promise(() => {})); + const source = new Source({ broadcast: broadcast({ mic: config("opus") }).broadcast, supported }); + try { + if (!source.pending.peek()) await source.pending.changed(); + expect(source.out.available.peek()).toEqual({}); + expect(source.out.error.peek()).toBeUndefined(); + + // Swapping the probe reruns the effect, which has to tear the parked one down first. + supported.set(async () => true); + await settled(source); + expect(source.out.track.peek()).toBe("mic"); + } finally { + source.close(); + } +}); + +test("a catalog replaced while its probe is pending publishes only the replacement", async () => { + const probes = new Map void>(); + const state = broadcast({ old: config("opus") }); + const source = new Source({ + broadcast: state.broadcast, + supported: (c) => new Promise((resolve) => probes.set(c.codec, resolve)), + }); + try { + if (!source.pending.peek()) await source.pending.changed(); + const old = probes.get("opus"); + expect(old).toBeDefined(); + + state.catalog.set({ audio: { renditions: { next: config("mp4a.40.2") } } }); + while (!probes.has("mp4a.40.2")) await yieldNow(); + + // The torn-down probe settles late; its result must not land. + old?.(true); + probes.get("mp4a.40.2")?.(true); + await settled(source); + expect(Object.keys(source.out.available.peek())).toEqual(["next"]); + expect(source.out.track.peek()).toBe("next"); + } finally { + source.close(); + } +}); + +test("serves unchanged configs from the cache when the catalog changes", async () => { + const state = broadcast({ mic: config("opus") }); + const probed: string[] = []; + const source = new Source({ + broadcast: state.broadcast, + supported: async (c) => { + probed.push(c.codec); + return true; + }, + }); + try { + await settled(source); + expect(probed).toEqual(["opus"]); + + state.catalog.set({ audio: { renditions: { mic: config("opus"), backup: config("mp4a.40.2") } } }); + await settled(source); + expect(probed).toEqual(["opus", "mp4a.40.2"]); + expect(Object.keys(source.out.available.peek())).toEqual(["mic", "backup"]); + } finally { + source.close(); + } +}); diff --git a/js/watch/src/audio/source.ts b/js/watch/src/audio/source.ts index 901476fedd0..0641d075d45 100644 --- a/js/watch/src/audio/source.ts +++ b/js/watch/src/audio/source.ts @@ -2,6 +2,7 @@ import { Milli } from "@moq/clock"; import type * as Catalog from "@moq/hang/catalog"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { Broadcast } from "../broadcast"; +import { SupportProbe } from "../support-probe"; /** What the viewer wants from the audio rendition. */ export type Target = { @@ -32,6 +33,11 @@ export type SourceInput = { export type SourceOutput = { catalog: Signal; available: Signal>; + + // Why no rendition can be chosen, or undefined while healthy or still probing. A + // `NotSupportedError` means the browser can decode none of the catalog's renditions. + error: Signal; + track: Signal; config: Signal; @@ -47,18 +53,20 @@ export class Source { /** The wired inputs, read-only. */ readonly in: Readonlys; + #probe = new SupportProbe("audio"); + readonly #out: SourceOutput = { catalog: new Signal(undefined), available: new Signal>({}), + error: this.#probe.error, track: new Signal(undefined), config: new Signal(undefined), jitter: new Signal(undefined), }; /** The derived outputs, read-only. */ readonly out = readonlys(this.#out); - #pending = new Signal(false); /** Whether rendition support is still being resolved. @internal */ - readonly pending: Getter = this.#pending; + readonly pending: Getter = this.#probe.pending; // The last selection, which a disable holds while no enabled rendition remains. #held?: { broadcast: Broadcast; track: string }; @@ -92,46 +100,13 @@ export class Source { } #runSupported(effect: Effect): void { + const supported = effect.get(this.in.supported); // Read the catalog from the broadcast itself, so the probe result is tagged with the right one. const broadcast = effect.get(this.in.broadcast); const renditions = (broadcast && effect.get(broadcast.out.catalog)?.audio?.renditions) ?? {}; - const supported = effect.get(this.in.supported); - if (!supported) return; - - effect.set(this.#pending, true, false); - effect.spawn(async () => { - try { - const available: Record = {}; - - for (const [name, config] of Object.entries(renditions)) { - // `supported` comes from the consumer, so we cannot assume it ever settles. A rerun - // waits for the tasks it spawned, so an unraced probe would hold the next run shut. - let isSupported = false; - try { - isSupported = !!(await effect.race(supported(config))); - } catch (err) { - if (effect.abort.aborted) return; - console.warn( - `[Source] audio rendition ${name} (${config.codec}) support probe failed; treating as unsupported`, - err, - ); - } - - // Torn down: stop probing and publish nothing, since the rerun redoes this. - if (effect.abort.aborted) return; - - if (isSupported) available[name] = config; - } - - if (Object.keys(available).length === 0 && Object.keys(renditions).length > 0) { - console.warn("no supported audio renditions found:", renditions); - } - - this.#probed.set(broadcast); - this.#out.available.set(available); - } finally { - this.#pending.set(false); - } + this.#probe.run(effect, supported, renditions, (available) => { + this.#probed.set(broadcast); + this.#out.available.set(available); }); } diff --git a/js/watch/src/support-probe.ts b/js/watch/src/support-probe.ts new file mode 100644 index 00000000000..0af9ffe5b6d --- /dev/null +++ b/js/watch/src/support-probe.ts @@ -0,0 +1,100 @@ +import { type Effect, type Getter, Signal } from "@moq/signals"; + +/** Internal key used to describe which fields can change a support probe's result. */ +export const supportCacheKey = Symbol("supportCacheKey"); + +/** A function that checks if a rendition configuration can be played. */ +type Supported = ((config: Config) => Promise) & { + [supportCacheKey]?: (config: Config) => string; +}; + +/** + * Probes which renditions a `supported` function accepts, shared by the audio and video sources. + * + * A result is cached per rendition until the rendition's config changes, keyed by the probe's + * `supportCacheKey` or else the whole config. + */ +export class SupportProbe { + readonly #kind: string; + + readonly #pending = new Signal(false); + /** Whether rendition support is still being resolved. */ + readonly pending: Getter = this.#pending; + + /** A `NotSupportedError` once no rendition of a non-empty catalog is supported. */ + readonly error = new Signal(undefined); + + #cache = new WeakMap, Map>(); + + constructor(kind: "audio" | "video") { + this.#kind = kind; + } + + /** + * Probe `renditions` within `effect`, then `publish` the supported ones. + * Nothing is published when the effect is torn down first, since its rerun redoes this. + */ + run( + effect: Effect, + supported: Supported | undefined, + renditions: Record, + publish: (available: Record) => void, + ): void { + this.error.set(undefined); + if (!supported) return; + + let cache = this.#cache.get(supported); + if (!cache) { + cache = new Map(); + this.#cache.set(supported, cache); + } + const names = new Set(Object.keys(renditions)); + for (const name of cache.keys()) { + if (!names.has(name)) cache.delete(name); + } + + const cacheKey = supported[supportCacheKey] ?? ((config: Config) => JSON.stringify(config)); + + effect.set(this.#pending, true, false); + effect.spawn(async () => { + const available: Record = {}; + + for (const [name, config] of Object.entries(renditions)) { + const key = cacheKey(config); + const cached = cache.get(name); + let isSupported = cached?.key === key ? cached.supported : undefined; + if (isSupported === undefined) { + try { + // `supported` comes from the consumer, so we cannot assume it ever settles. A + // rerun waits for the tasks it spawned, so an unraced probe would hold it shut. + const result = await effect.race(supported(config)); + // Undefined when torn down; the abort check below handles it. + if (result !== undefined) { + isSupported = !!result; + // Recompute the key: a probe may rewrite the config it accepted. + cache.set(name, { key: cacheKey(config), supported: isSupported }); + } + } catch (err) { + if (effect.abort.aborted) return; + console.warn( + `[Source] ${this.#kind} rendition ${name} (${config.codec}) support probe failed; treating as unsupported`, + err, + ); + } + } + + // Torn down: stop probing and publish nothing, since the rerun redoes this. + if (effect.abort.aborted) return; + + if (isSupported) available[name] = config; + } + + if (Object.keys(available).length === 0 && Object.keys(renditions).length > 0) { + console.warn(`[Source] no supported ${this.#kind} renditions found:`, renditions); + this.error.set(new DOMException(`no supported ${this.#kind} renditions`, "NotSupportedError")); + } + publish(available); + this.#pending.set(false); + }); + } +} diff --git a/js/watch/src/ui/components/unsupported-indicator.test.ts b/js/watch/src/ui/components/unsupported-indicator.test.ts new file mode 100644 index 00000000000..997c544c7a8 --- /dev/null +++ b/js/watch/src/ui/components/unsupported-indicator.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { yieldNow } from "@moq/clock"; +import { Effect, Signal } from "@moq/signals"; +import type MoqWatch from "../../element"; +import { unsupportedIndicator } from "./unsupported-indicator"; + +const unsupported = () => new DOMException("no supported renditions", "NotSupportedError"); + +test("reports unsupported audio only when the broadcast has no video", async () => { + // The only DOM the component touches: a container holding a text span. + const container = { style: { display: "" }, setAttribute: () => {}, appendChild: () => {} }; + const text = { textContent: "" }; + const original = globalThis.document; + let created = 0; + globalThis.document = { + createElement: () => (created++ === 0 ? container : text), + } as unknown as Document; + + const video = { + catalog: new Signal<{ renditions: Record } | undefined>(undefined), + error: new Signal(undefined), + }; + const audio = { + catalog: new Signal({ renditions: { mic: { codec: "opus" } } }), + error: new Signal(undefined), + }; + const watch = { + broadcast: { out: { status: new Signal("live") } }, + video: { source: { out: video } }, + audio: { source: { out: audio } }, + } as unknown as MoqWatch; + + const effect = new Effect(); + try { + unsupportedIndicator(effect, watch); + await yieldNow(); + expect(container.style.display).toBe("none"); + + audio.error.set(unsupported()); + await yieldNow(); + expect(container.style.display).toBe(""); + expect(text.textContent).toBe("This audio codec is not supported by your browser: opus"); + + // A video section the reachability filter emptied is no video, so audio still reports. + video.catalog.set({ renditions: {} }); + await yieldNow(); + expect(container.style.display).toBe(""); + expect(text.textContent).toBe("This audio codec is not supported by your browser: opus"); + + // Playable video carries the broadcast, so unsupported audio alone is not reported. + video.catalog.set({ renditions: { hd: { codec: "avc1.640028" } } }); + await yieldNow(); + expect(container.style.display).toBe("none"); + + video.error.set(unsupported()); + await yieldNow(); + expect(container.style.display).toBe(""); + expect(text.textContent).toBe("This video codec is not supported by your browser: avc1.640028"); + } finally { + effect.close(); + globalThis.document = original; + } +}); diff --git a/js/watch/src/ui/components/unsupported-indicator.ts b/js/watch/src/ui/components/unsupported-indicator.ts index db02f72bc63..81e7a190ea9 100644 --- a/js/watch/src/ui/components/unsupported-indicator.ts +++ b/js/watch/src/ui/components/unsupported-indicator.ts @@ -1,7 +1,7 @@ import type { Effect } from "@moq/signals"; import type MoqWatch from "../../element"; -/** Shows when the player needs an update or cannot decode any video rendition. */ +/** Shows when the player needs an update, or cannot decode the video or, lacking video, the audio. */ export function unsupportedIndicator(parent: Effect, watch: MoqWatch): HTMLElement { const container = document.createElement("div"); container.className = "watch-ui__notice watch-ui__unsupported-indicator"; @@ -20,16 +20,22 @@ export function unsupportedIndicator(parent: Effect, watch: MoqWatch): HTMLEleme return; } - const unsupported = effect.get(watch.video.source.out.error)?.name === "NotSupportedError"; + // Audio speaks for the broadcast only when there is no video to report on. The reachability + // filter can leave a video section with no renditions, which counts as no video. + const video = effect.get(watch.video.source.out.catalog); + const hasVideo = Object.keys(video?.renditions ?? {}).length > 0; + const kind = hasVideo ? "video" : "audio"; + const source = hasVideo ? watch.video.source : watch.audio.source; + const unsupported = effect.get(source.out.error)?.name === "NotSupportedError"; const show = unsupported && (status === "loading" || status === "live"); container.style.display = show ? "" : "none"; if (!show) return; - const renditions = effect.get(watch.video.source.out.catalog)?.renditions ?? {}; + const renditions = (hasVideo ? video : effect.get(watch.audio.source.out.catalog))?.renditions ?? {}; const codecs = [...new Set(Object.values(renditions).map((rendition) => rendition.codec))].join(", "); text.textContent = codecs - ? `This video codec is not supported by your browser: ${codecs}` - : "This video codec is not supported by your browser"; + ? `This ${kind} codec is not supported by your browser: ${codecs}` + : `This ${kind} codec is not supported by your browser`; }); return container; diff --git a/js/watch/src/video/config.ts b/js/watch/src/video/config.ts index 0c284366e48..79d20ce83f0 100644 --- a/js/watch/src/video/config.ts +++ b/js/watch/src/video/config.ts @@ -1,8 +1,5 @@ import type * as Catalog from "@moq/hang/catalog"; -/** Internal key used to describe which fields can change a support probe's result. */ -export const supportCacheKey = Symbol("supportCacheKey"); - /** The catalog fields that determine the demuxer and WebCodecs decoder instance. */ export type DecoderConfig = Pick< Catalog.VideoConfig, diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index 96bddd79f7a..6272bfde526 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -18,14 +18,9 @@ import { import { rotateVideoDimensions } from "@moq/video"; import { base64ToBytes } from "../base64"; import { type Failure, failureMoved, nextMedia, subscribeMedia } from "../media"; +import { supportCacheKey } from "../support-probe"; import type { Sync } from "../sync"; -import { - type DecoderConfig, - decoderConfigKey, - type PlaybackIdentity, - playbackIdentity, - supportCacheKey, -} from "./config"; +import { type DecoderConfig, decoderConfigKey, type PlaybackIdentity, playbackIdentity } from "./config"; import { caughtUp, renditionJitter, switchJitter } from "./playhead"; import { Recovery } from "./recovery"; import type { Source } from "./source"; diff --git a/js/watch/src/video/source.test.ts b/js/watch/src/video/source.test.ts index d6e33de2955..c40569d1465 100644 --- a/js/watch/src/video/source.test.ts +++ b/js/watch/src/video/source.test.ts @@ -4,7 +4,8 @@ import * as Catalog from "@moq/hang/catalog"; import { Path } from "@moq/net"; import { Signal } from "@moq/signals"; import { Broadcast } from "../broadcast"; -import { decoderConfigKey, supportCacheKey } from "./config"; +import { supportCacheKey } from "../support-probe"; +import { decoderConfigKey } from "./config"; import { Source } from "./source"; const flush = () => yieldNow(); diff --git a/js/watch/src/video/source.ts b/js/watch/src/video/source.ts index 6ddbf1922c3..31162082e73 100644 --- a/js/watch/src/video/source.ts +++ b/js/watch/src/video/source.ts @@ -2,7 +2,7 @@ import * as Catalog from "@moq/hang/catalog"; import type * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { supportCacheKey } from "./config"; +import { SupportProbe } from "../support-probe"; /** * A function that checks if a video configuration can be played. @@ -11,10 +11,6 @@ import { supportCacheKey } from "./config"; */ export type Supported = (config: Catalog.VideoConfig) => Promise; -type CacheableSupported = Supported & { - [supportCacheKey]?: (config: Catalog.VideoConfig) => string; -}; - /** What the viewer wants from the video rendition; the source picks the best one within it. */ export type Target = { // Optional manual override for the selected rendition name. @@ -207,18 +203,19 @@ export class Source { /** The wired inputs, read-only. */ readonly in: Readonlys; + #probe = new SupportProbe("video"); + readonly #out: SourceOutput = { catalog: new Signal(undefined), available: new Signal>({}), - error: new Signal(undefined), + error: this.#probe.error, track: new Signal(undefined), config: new Signal(undefined), }; /** The derived outputs, read-only. */ readonly out = readonlys(this.#out); - #pending = new Signal(false); /** Whether rendition support is still being resolved. @internal */ - readonly pending: Getter = this.#pending; + readonly pending: Getter = this.#probe.pending; // The last selection, which a disable holds while no enabled rendition remains. #held?: { broadcast: Broadcast; track: string }; @@ -228,7 +225,6 @@ export class Source { #probed = new Signal(undefined); #signals = new Effect(); - #supportCache = new WeakMap>(); constructor(props?: Inputs) { this.in = { @@ -255,72 +251,12 @@ export class Source { #runSupported(effect: Effect): void { const supported = effect.get(this.in.supported); - if (!supported) { - this.#out.error.set(undefined); - return; - } - // Read the catalog from the broadcast itself, so the probe result is tagged with the right one. const broadcast = effect.get(this.in.broadcast); const renditions = (broadcast && effect.get(broadcast.out.catalog)?.video?.renditions) ?? {}; - this.#out.error.set(undefined); - let cache = this.#supportCache.get(supported); - if (!cache) { - cache = new Map(); - this.#supportCache.set(supported, cache); - } - const names = new Set(Object.keys(renditions)); - for (const name of cache.keys()) { - if (!names.has(name)) cache.delete(name); - } - - effect.set(this.#pending, true, false); - effect.spawn(async () => { - const available: Record = {}; - - for (const [name, config] of Object.entries(renditions)) { - const cacheKey = (supported as CacheableSupported)[supportCacheKey]; - const key = cacheKey ? cacheKey(config) : JSON.stringify(config); - const cached = cache.get(name); - let isSupported: boolean | undefined = false; - if (cached?.key === key) { - isSupported = cached.supported; - } else { - let failed = false; - try { - // `supported` comes from the consumer, so we cannot assume it ever settles. A - // rerun waits for the tasks it spawned, so an unraced probe would hold it shut. - isSupported = await effect.race(supported(config)); - } catch (err) { - failed = true; - console.warn( - `[Source] video rendition ${name} (${config.codec}) support probe failed; treating as unsupported`, - err, - ); - } - if (!failed && isSupported !== undefined) { - cache.set(name, { - key: cacheKey ? cacheKey(config) : JSON.stringify(config), - supported: isSupported, - }); - } - } - - // Torn down: stop probing and publish nothing, since the rerun redoes this. - if (effect.abort.aborted) return; - - if (isSupported) available[name] = config; - } - - if (Object.keys(available).length === 0 && Object.keys(renditions).length > 0) { - console.warn("[Source] No supported video renditions found:", renditions); - this.#out.error.set(new DOMException("no supported video renditions", "NotSupportedError")); - } else { - this.#out.error.set(undefined); - } + this.#probe.run(effect, supported, renditions, (available) => { this.#probed.set(broadcast); this.#out.available.set(available); - this.#pending.set(false); }); } diff --git a/quest/m2/README.md b/quest/m2/README.md index f09abc92486..ebf7916e218 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -126,7 +126,6 @@ lite-07; bitrate enforcement is not part of this release. - [One port on the io_uring workers](/quest/m2/uring-demux.md) - `moq-uring`'s workers host the UDP demux, so a ring relay keeps WebRTC media and SRT on its QUIC port - [Synced data playback](/quest/m2/watch-data-sync.md) - js/watch releases JSON and binary payloads on the media playhead, and a slow data track holds media back - [Watch decode gate](/quest/m2/watch-decode-gate.md) - video lookahead stays encoded until it is near presentation, like audio -- [One support probe for watch audio and video](/quest/m2/watch-support-probe.md) - one probe helper with video's cache, and audio reports unsupported renditions - [moqsrc A/V sync](/quest/m2/moqsrc-av-sync.md) - a nightly test measures A/V skew from `moqsrc` through clock-synced sinks - [fMP4 ambisonic layout](/quest/m2/mux-vr-ambisonic-fmp4.md) - fMP4 Opus import fills `ambisonic` from the `dOps` mapping table - [Linux decoded frames](/quest/m2/obs-decode-linux.md) - present supported native decoded surfaces with visible CPU fallback diff --git a/quest/m2/watch-support-probe.md b/quest/m2/watch-support-probe.md deleted file mode 100644 index d41718ea7b5..00000000000 --- a/quest/m2/watch-support-probe.md +++ /dev/null @@ -1,26 +0,0 @@ -# [S] One support probe for watch audio and video - -## Goal - -The watch audio and video `Source`s probe rendition support through one -private helper that owns the race against an unsettled probe, the config-keyed -cache, `pending`, and abort. Audio gains video's cache and an `out.error` that -is `NotSupportedError` when no audio rendition is supported, and the bundled -UI's unsupported indicator reports an audio-only broadcast it cannot play. -Rendition selection stays separate per kind. Additive `@moq/watch` API; no -wire impact. - -## Plan - -Decided 2026-10-27 (maintainer interview, from the 2026-10-27 refactor audit): - -- Audio exposes `out.error` like video, and the unsupported indicator is its - consumer: it fires when video is unsupported, or when the catalog has no - video and audio is unsupported. -- Video's `#runSupported` (`js/watch/src/video/source.ts`) caches per - rendition by `supportCacheKey` or the JSON config and sets the error; audio's - (`js/watch/src/audio/source.ts`) re-probes every run and only logs. The - helper takes video's behavior for both. -- Test a catalog replaced while a probe is pending, a probe that never - settles, a rejecting probe, and a catalog change that leaves configs - unchanged (served from cache). From 29a56f860e8e36a68f17a7623c46d48ba691e095 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:15:08 +0000 Subject: [PATCH 71/86] test(js/net): age the stalled-consumer eviction test past the cache window (#7219) Co-authored-by: Claude Opus 5.5 --- js/net/src/broadcast.test.ts | 14 ++++++++------ quest/m1/README.md | 1 - quest/m1/js-cache-age-test.md | 18 ------------------ 3 files changed, 8 insertions(+), 25 deletions(-) delete mode 100644 quest/m1/js-cache-age-test.md diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index dcd8a0bd4fb..182696e672a 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -503,16 +503,18 @@ test("a stalled consumer does not pin evicted groups", async () => { clock.jump(Nano.fromMilli(Milli(10_000))); const broadcast = new BroadcastProducer(); - const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI, maxAge: Milli(1000) }); + const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - // A subscriber that never reads. Its sink must not grow without bound. - const stalled = broadcast.track("video").subscribe(); + // A subscriber that never reads. Its sink must not grow without bound. No max delay, + // so only eviction (not a skip of a superseded group) can drop the old group. + const stalled = broadcast.track("video").subscribe({ maxDelay: null }); producer.writeFrame(textFrame("old")); - // Advance past the cache window and write again to trigger a prune. The old - // (closed, aged-out) group is dropped from the stalled sink, not retained. - clock.jump(Nano.fromMilli(Milli(2_000))); + // Advance past the cache window, plus the scan slice a prune may lag by, and write + // again to trigger a prune. The old (closed, aged-out) group is dropped from the + // stalled sink, not retained. + clock.jump(Nano.fromMilli(Milli(30_000 + 30_000 / 8))); producer.writeFrame(textFrame("fresh")); // The next group in arrival order is the fresh one (seq 1): the old (seq 0) group diff --git a/quest/m1/README.md b/quest/m1/README.md index 6df3e4c3d3a..3ab218d653d 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -173,7 +173,6 @@ per-frame CPU growth since #5926. - [CI housekeeping](/quest/m1/ci-housekeeping.md) - remove the actionlint queue ignore once a release after 1.7.12 reaches the dev shell - [crates.io trusts the workflow](/quest/m1/crates-trusted-publisher.md) - maintainer action: every published crate gets a trusted publisher for `release-rs.yml` - [Rust releases publish through OIDC](/quest/m1/release-rs-oidc.md) - release-plz publishes with trusted publishing and `CARGO_REGISTRY_TOKEN` is revoked -- [The JS stalled-consumer eviction test ages its groups](/quest/m1/js-cache-age-test.md) - the test drives the clock the cache reads, so it can fail - [Main is no slower than release](/quest/m1/release-perf/README.md) - every benchmark where main is slower than release is fixed or accepted with a reason, checked by a final paired run before the cut - [Interop coverage](/quest/m1/interop-coverage/README.md) - the riskiest moq-transport features moq-net exposes get cross-implementation tests over drafts 18 and 22 - [Calibrate the paired nightly benchmarks](/quest/m1/bench-ci.md) - A/A and deliberately slowed Nightly runs set the paired comparison's threshold, so its regression alerts can be trusted diff --git a/quest/m1/js-cache-age-test.md b/quest/m1/js-cache-age-test.md deleted file mode 100644 index e062b2a8538..00000000000 --- a/quest/m1/js-cache-age-test.md +++ /dev/null @@ -1,18 +0,0 @@ -# [XS] The JS stalled-consumer eviction test ages its groups - -## Goal - -`js/net/src/broadcast.test.ts` "a stalled consumer does not pin evicted -groups" ages its groups past `maxAge` on the clock the cache actually reads, -so it fails if a stalled consumer does pin evicted groups. - -## Plan - -Decided 2026-10-29 (maintainer), a follow-up of -[moq-dev/moq#7076](https://github.com/moq-dev/moq/pull/7076). The test calls -`setSystemTime`, but the cache ages groups on the monotonic clock, so that -step does nothing. Drive a `Manual` clock past `maxAge` instead, as the -other converted tests do, and restore the ambient clock when it finishes. -Prove the test fails when the consumer is made to pin evicted groups. - -Public API: none. Wire: none. From 1f02db73afd41b484ace6d71c692d16edbea2fd4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:16:06 +0000 Subject: [PATCH 72/86] test: halve the slowest tests' CPU (#6987) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- Cargo.toml | 6 + quest/m0/ci/README.md | 1 - quest/m0/ci/five-minutes.md | 9 +- quest/m0/ci/slow-tests.md | 77 ----- rs/moq-audio/src/decode/consumer.rs | 5 +- rs/moq-cli/src/publish.rs | 7 +- rs/moq-hls/src/import.rs | 88 +++--- rs/moq-json/src/snapshot/encoder.rs | 1 + rs/moq-json/src/stream/encoder.rs | 11 +- rs/moq-json/src/stream/mod.rs | 6 +- rs/moq-json/src/stream/producer.rs | 8 + rs/moq-json/src/window/encoder.rs | 22 +- rs/moq-json/src/window/properties.rs | 1 + .../src/container/ts/export_timing_test.rs | 277 ++++++++++------- rs/moq-mux/src/container/ts/fixture.rs | 22 +- .../src/container/ts/passthrough_test.rs | 9 +- rs/moq-mux/src/container/ts/recovery_test.rs | 37 +-- rs/moq-mux/src/jitter.rs | 35 ++- rs/moq-mux/src/rate.rs | 4 +- rs/moq-net/src/ietf/publisher.rs | 2 +- rs/moq-net/src/lite/publisher.rs | 2 +- rs/moq-net/src/model/front.rs | 34 ++- .../integration/routing_contracts/handoff.rs | 7 +- .../integration/routing_contracts/model.rs | 287 ++++++++++-------- .../tests/integration/pattern_exhaustive.rs | 8 +- rs/moq-quic/src/tests/probe.rs | 6 +- rs/moq-quic/src/tests/sim.rs | 29 +- rs/moq-quic/src/tests/sim/workloads.rs | 4 +- rs/moq-quic/src/tests/util.rs | 24 +- .../tests/integration/cluster_unknown.rs | 10 +- rs/moq-tokio/tests/integration/broadcast.rs | 4 +- rs/moq-video/src/encode/encoder.rs | 16 +- rs/qmux/src/session/tests.rs | 5 +- 33 files changed, 603 insertions(+), 461 deletions(-) delete mode 100644 quest/m0/ci/slow-tests.md diff --git a/Cargo.toml b/Cargo.toml index 5d5a0e36474..9e8d4da072c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -306,6 +306,12 @@ opt-level = 3 [profile.dev.package.sonora-simd] opt-level = 3 +# Same for the rubato resampler, whose SIMD intrinsics are all of +# `resample::tests::duration_and_samples_are_invariant_across_partitions_and_epochs`: +# 0.9 CPU-seconds unoptimized, 0.14 optimized. +[profile.dev.package.rubato] +opt-level = 3 + # The Go mirror stores native staticlibs in Git, whose per-file limit is 100 MB. # Keep all release entry points on the same compact native profile. [profile.release] diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index d3c3e2cb19c..5ae13f591b6 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -162,7 +162,6 @@ The historical samples locate candidates; they are not controlled benchmarks. - [Verify the R2 Rust cache](/quest/m0/ci/mbx-r2-verify.md) - confirm the hosted Nightly jobs reuse the previous Nightly, then measure restore time and expiry - [Verify the Nix cache](/quest/m0/ci/nix-cache-verify.md) - confirm releases use `nix.moq.dev`, check its bucket sizes, and retire Cachix - [Unify dependency features](/quest/m0/ci/hakari.md) - a cargo-hakari workspace-hack so every job compiles each dependency alike and mbx hits across jobs -- [Halve the slowest tests' CPU](/quest/m0/ci/slow-tests.md) - the slowest 1% of tests take under half their 289 CPU-seconds, with coverage kept - [Release credentials off main](/quest/m0/ci/publish-release-only.md) - `publish` admits only `release` and release tags once its `workflow_run` publishers run on their tag - [The queue host keeps the queue's disk](/quest/m0/ci/queue-host-disk.md) - agent sessions can't starve the queue runner of disk, and a crashed runner restarts - [Biome checks every file it can parse](/quest/m0/ci/biome-whole-repo.md) - Biome runs over the whole repository and main is clean under it diff --git a/quest/m0/ci/five-minutes.md b/quest/m0/ci/five-minutes.md index ee8e0d6aaad..5684d83b5df 100644 --- a/quest/m0/ci/five-minutes.md +++ b/quest/m0/ci/five-minutes.md @@ -67,8 +67,8 @@ What remains for a moq-net edit, largest first: lever was measured at about 30 ms of CPU per build and dropped), or running the rs lint beside the test build in a second target directory, which trades CPU for wall time against the epic's CPU measure. Decided 2026-10-30 (user): defer that - parallel lint; measure it after the slow-tests and hakari quests land. -- The test run is the [slow tests](/quest/m0/ci/slow-tests.md) quest's. + parallel lint; measure it after the hakari quest lands (slow-tests landed in + [moq-dev/moq#6987](https://github.com/moq-dev/moq/pull/6987)). - The lint's remaining 230 s: the dev loom compile and `cargo doc`. Decided 2026-10-31 (maintainer, see the [epic](/quest/m0/ci/README.md)): the gate drops the loom compile, the Send-depth check (now its own Nightly job), the @@ -80,14 +80,13 @@ What remains for a moq-net edit, largest first: - `just test` has no root recipe: bare `just test` lists the cross-language suites. Name which command the `just test` baseline above counts before measuring it. -- Prefer the epic's levers, such as [slow tests](/quest/m0/ci/slow-tests.md), - [hakari](/quest/m0/ci/hakari.md), and cache hits, and tighter diff selection, +- Prefer the epic's levers, such as [hakari](/quest/m0/ci/hakari.md) and cache + hits, and tighter diff selection, over moving coverage. Move a check to the queue or nightly only under the epic's rule: evidence and an explicit coverage tradeoff for each. - Split any large single lever into its own quest. ## Related -- [Halve the slowest tests' CPU](/quest/m0/ci/slow-tests.md) - the largest test-time lever - [Unify dependency features](/quest/m0/ci/hakari.md) - cache hits across feature shapes - [Land main through GitHub's merge queue](/quest/m0/ci/land.md) - the queue gate that keeps checks moved out of the local gate diff --git a/quest/m0/ci/slow-tests.md b/quest/m0/ci/slow-tests.md deleted file mode 100644 index f2a0e504661..00000000000 --- a/quest/m0/ci/slow-tests.md +++ /dev/null @@ -1,77 +0,0 @@ -# [M] Halve the slowest tests' CPU - -## Goal - -The 103 test IDs frozen in [moq-dev/moq#6914](https://github.com/moq-dev/moq/pull/6914) -(the slowest `ceil(10,275 / 100)` of one full normal `cargo nextest` run) take under -68.92 CPU-seconds together, half their 137.83 s baseline, with every case and its -coverage preserved. Per-test CPU is the test process's user plus system time, not -nextest's wall-clock duration. Measure the same IDs on the same machine as the baseline -it is compared against; missing or failed cases are not imputed. - -## Plan - -Decided with the maintainer on 2026-10-26, from moq-dev/rust2's -[test selection report](https://github.com/moq-dev/rust2/pull/69): the slowest 1% -of tests make up 57% of what either package selection or a function-level selector -would run, so cutting them beats building a selector. - -- Prefer making a test cheaper: smaller fixtures, shared setup, simulated time instead - of real waits, fewer redundant cases. -- Moving a test to nightly only follows this epic's rule: evidence and an explicit - coverage tradeoff for each. - -### Measurement - -[moq-dev/moq#6914](https://github.com/moq-dev/moq/pull/6914) holds the frozen list with -per-test baseline and after CPU, plus the runner wrapper and command. The wrapper is a -`CARGO_TARGET__RUNNER` that records each nextest test child's `wait4` user plus -system time, run under the normal `just rs test --workspace --exclude moq-net-fuzz ---exclude moq-quic-fuzz` with zero retries. On a different machine, re-measure the -baseline source (`6f6a3ae21432a3df74d4c432451f3909da339bee`) and the candidate with the -same wrapper rather than comparing against these numbers. Unchanged tests varied -between runs on a shared host (moq-stats' `produce::tests` by up to 40%), so credit only -the tests a change touches. - -### Done so far - -[moq-dev/moq#6914](https://github.com/moq-dev/moq/pull/6914) cut the cohort from -137.83 s to 116.83 s with fixture-only changes: the two MPEG-TS reset tests in -`container::ts::import` drain on the exporter's deadlines with a retention derived from -their stimulus instead of an hour of idle output (12.98 s to 0.18 s), and the jitter -`drift` and `feed` helpers advance a `kio::clock::Deadlines` queue instead of the tokio -paused clock (10.16 s to 6.80 s across 8 tests). About 48 s remains to cut. - -Tried and dropped, so don't redo them without new evidence: - -- Caching the jitter lead median: the frame-rate benchmark stayed within noise and one - cell regressed about 6%. -- A containment shortcut for miss-verdict intersections in moq-net: sparse gap and - range sweeps were unchanged (4096 timed gaps, 2.796 ms to 2.783 ms). The broader - algorithmic fix landed in - [moq-dev/moq#6921](https://github.com/moq-dev/moq/pull/6921). -- Retaining only one phase's span in the TS reset tests: it shed the boundary and - failed the unsignalled-reset assertion. - -### Remaining cost - -After CPU of the frozen cohort by module, largest first: - -| Module | Tests | CPU s | -| --- | ---: | ---: | -| moq-mux `container::ts::export_timing_test` | 14 | 22.2 | -| moq-net `model::track::test` | 5 | 17.3 | -| moq-mux `container::ts::recovery_test` | 18 | 11.6 | -| moq-net integration `mesh_withdraw` | 6 | 9.7 | -| moq-net integration `routing_contracts::handoff` | 3 | 8.3 | -| moq-mux `jitter::tests` | 8 | 6.8 | -| moq-quic `tests::sim` | 3 | 4.6 | -| moq-stats `produce::tests` | 4 | 4.0 | -| everything else | 42 | 32.4 | - -The moq-net `model::track::test` gap tests run through the same miss judging, which -[moq-dev/moq#6921](https://github.com/moq-dev/moq/pull/6921) made incremental after this -measurement, so re-measure them first; the export timing and recovery tests are the -largest fixture-only target. - -Public API: none. diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index 9323e8eb52b..ec43d3ee511 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -2138,11 +2138,12 @@ mod tests { /// /// PCM rather than Opus only for speed: the frame term comes from the decoded /// sample count either way, and an unoptimized Opus codec takes minutes over - /// the whole corpus. [`opus_states_its_own_frame_duration`] covers Opus. + /// the whole corpus. [`opus_states_its_own_frame_duration`] covers Opus. The + /// lowest common rate keeps each packet small, since only its duration counts. async fn replay(case: &crate::jitter::tests::Case) -> Vec { use crate::jitter::tests::duration; - let rate = 48_000; + let rate = 8_000; let samples = (case.frame * rate as f64 / 1000.0) as usize; let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, rate, 1); catalog.jitter = case.advertised.map(duration); diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index 677e1e4d0ae..836e4656480 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -1739,10 +1739,11 @@ pub(crate) mod tests { .into_iter() .map(|(sequence, frames)| (sequence, frames.expect("the recording reads every group whole"))) .collect(); - let output: Vec = recording + let output = recording .iter() - .flat_map(|(_, frames)| frames.iter().flat_map(|frame| frame.payload.iter().copied())) - .collect(); + .flat_map(|(_, frames)| frames.iter().map(|frame| &frame.payload[..])) + .collect::>() + .concat(); assert!(output.len() > KYRION.len(), "{version}: both copies crossed"); assert!( input.ends_with(&output), diff --git a/rs/moq-hls/src/import.rs b/rs/moq-hls/src/import.rs index cdfe9311eff..5f8c1723e52 100644 --- a/rs/moq-hls/src/import.rs +++ b/rs/moq-hls/src/import.rs @@ -186,7 +186,9 @@ fn same_origin(entry: &Url, url: &Url) -> bool { /// Fetches playlists and segments over HTTP or from the filesystem, applying HLS byte /// ranges (and validating that the server honored them). struct Fetcher { - client: Client, + /// `None` for a `file` playlist: every fetch stays on its scheme, so it never needs one, nor + /// the system trust roots building one loads. + client: Option, /// The caller's playlist URL. A playlist is untrusted, so every fetch stays on its origin. entry: Url, } @@ -198,6 +200,10 @@ impl Fetcher { return Err(Error::ResourceRefused); } + if entry.scheme() == "file" { + return Ok(Self { client: None, entry }); + } + // An intermediate hop is already a request to its origin, so check each one before // following it. A stopped hop reaches `fetch` as a redirect response, refused there. let origin = entry.clone(); @@ -222,7 +228,10 @@ impl Fetcher { .timeout(REQUEST_TIMEOUT) .redirect(redirect) .build()?; - Ok(Self { client, entry }) + Ok(Self { + client: Some(client), + entry, + }) } /// Fetch and parse a media playlist, with the URL its relative URIs resolve against. @@ -251,7 +260,8 @@ impl Fetcher { if !same_origin(&self.entry, url) { return Err(Error::ResourceRefused); } - if url.scheme() == "file" { + // Same origin, so a file URL exactly when the entry is one. + let Some(client) = &self.client else { let path = url.to_file_path().map_err(|_| Error::InvalidFileUrl)?; let Some(range) = resource.range else { let bytes = tokio::fs::read(&path).await.map(Bytes::from).map_err(Error::from)?; @@ -265,50 +275,49 @@ impl Fetcher { .read_to_end(&mut bytes) .await .map_err(Error::from)?; - Ok(( + return Ok(( Self::validate_range_length(url, range, Bytes::from(bytes))?, url.clone(), - )) - } else { - let response = self.request(resource).send().await?; - if response.status().is_redirection() { - return Err(Error::ResourceRefused); - } - let mut response = response.error_for_status()?; - let served = response.url().clone(); - let Some(range) = resource.range else { - return Ok((response.bytes().await?, served)); - }; + )); + }; + let response = Self::request(client, resource).send().await?; + if response.status().is_redirection() { + return Err(Error::ResourceRefused); + } + let mut response = response.error_for_status()?; + let served = response.url().clone(); + let Some(range) = resource.range else { + return Ok((response.bytes().await?, served)); + }; - // An origin that ignores `Range` sends the whole resource, which would cost every - // earlier byte of the file for each segment. - if response.status() != reqwest::StatusCode::PARTIAL_CONTENT { - return Err(Error::ByteRangeIgnored { + // An origin that ignores `Range` sends the whole resource, which would cost every + // earlier byte of the file for each segment. + if response.status() != reqwest::StatusCode::PARTIAL_CONTENT { + return Err(Error::ByteRangeIgnored { + url: redact(url), + status: response.status().as_u16(), + }); + } + Self::validate_content_range(url, range, response.headers())?; + + // Stream the body so an overrun fails without buffering the excess. + let mut body = BytesMut::new(); + while let Some(chunk) = response.chunk().await? { + let read = body.len() + chunk.len(); + if !u64::try_from(read).is_ok_and(|read| read <= range.length) { + return Err(Error::ByteRangeLengthMismatch { url: redact(url), - status: response.status().as_u16(), + expected: range.length, + actual: read, }); } - Self::validate_content_range(url, range, response.headers())?; - - // Stream the body so an overrun fails without buffering the excess. - let mut body = BytesMut::new(); - while let Some(chunk) = response.chunk().await? { - let read = body.len() + chunk.len(); - if !u64::try_from(read).is_ok_and(|read| read <= range.length) { - return Err(Error::ByteRangeLengthMismatch { - url: redact(url), - expected: range.length, - actual: read, - }); - } - body.extend_from_slice(&chunk); - } - Ok((Self::validate_range_length(url, range, body.freeze())?, served)) + body.extend_from_slice(&chunk); } + Ok((Self::validate_range_length(url, range, body.freeze())?, served)) } - fn request(&self, resource: &Resource) -> reqwest::RequestBuilder { - let mut request = self.client.get(resource.url.clone()); + fn request(client: &Client, resource: &Resource) -> reqwest::RequestBuilder { + let mut request = client.get(resource.url.clone()); if let Some(range) = resource.range { let end = range.start + range.length - 1; request = request.header(reqwest::header::RANGE, format!("bytes={}-{}", range.start, end)); @@ -701,7 +710,8 @@ pub struct Import { impl Import { /// Create a new HLS import that will write into the given broadcast. /// - /// Installs rind's rustls provider as the process default unless one already is. + /// For an HTTP playlist, installs rind's rustls provider as the process default unless one + /// already is. A file playlist builds no HTTP client, so it installs none. pub fn new(broadcast: moq_net::broadcast::Producer, catalog: CatalogProducer, cfg: Config) -> Result { let fetcher = Fetcher::new(&cfg)?; Ok(Self { diff --git a/rs/moq-json/src/snapshot/encoder.rs b/rs/moq-json/src/snapshot/encoder.rs index 46548d8fcf1..6c3a4112f02 100644 --- a/rs/moq-json/src/snapshot/encoder.rs +++ b/rs/moq-json/src/snapshot/encoder.rs @@ -584,6 +584,7 @@ mod test { let mut runner = TestRunner::new(PropertyConfig { cases, rng_seed: RngSeed::Fixed(u64::from(seed)), + rng_algorithm: proptest::test_runner::RngAlgorithm::XorShift, failure_persistence: None, ..Default::default() }); diff --git a/rs/moq-json/src/stream/encoder.rs b/rs/moq-json/src/stream/encoder.rs index 765d0613c4a..8c9e1118c97 100644 --- a/rs/moq-json/src/stream/encoder.rs +++ b/rs/moq-json/src/stream/encoder.rs @@ -48,13 +48,20 @@ impl Pending<'_, T> { /// /// Payloads must reach the wire in order. A group has no size cap, so the encoder keeps no budget. pub struct Encoder { + /// The most bytes one record may hold. A field rather than the constant so a test can shrink it + /// to a size it can reach. + pub(super) max_frame_bytes: u64, + _marker: PhantomData, } impl Encoder { /// Create an encoder. pub fn new(_config: Config) -> Self { - Self { _marker: PhantomData } + Self { + max_frame_bytes: moq_net::group::MAX_CACHE_BYTES, + _marker: PhantomData, + } } } @@ -68,7 +75,7 @@ impl Encoder { /// ([`moq_net::group::MAX_CACHE_BYTES`]). The refused record leaves the encoder untouched. pub fn encode(&mut self, value: &T) -> Result> { let payload = Bytes::from(serde_json::to_vec(value)?); - if payload.len() as u64 > moq_net::group::MAX_CACHE_BYTES { + if payload.len() as u64 > self.max_frame_bytes { return Err(moq_net::Error::FrameTooLarge.into()); } diff --git a/rs/moq-json/src/stream/mod.rs b/rs/moq-json/src/stream/mod.rs index 992bcb7ef0c..24f36ca9576 100644 --- a/rs/moq-json/src/stream/mod.rs +++ b/rs/moq-json/src/stream/mod.rs @@ -380,15 +380,19 @@ mod test { matches!(result, Err(crate::Error::Net(moq_net::Error::FrameTooLarge))) } + /// A frame limit small enough to pass cheaply, standing in for moq-net's 32 MiB cache. + const BUDGET: u64 = 32 * 1024; + /// A record larger than a frame is refused before anything is written, so the log carries on: /// the next record lands and decodes. #[test] fn an_oversized_record_is_refused_and_the_log_continues() { for compression in [false, true] { let (mut producer, track) = producer_on(Config::default(), info(compression)); + producer.set_max_frame_bytes(BUDGET); producer.append(&json!({ "n": 0 })).unwrap(); - let oversized = Value::String("x".repeat(moq_net::group::MAX_CACHE_BYTES as usize)); + let oversized = Value::String("x".repeat(BUDGET as usize)); assert!(refused(producer.append(&oversized)), "compression={compression}"); producer.append(&json!({ "n": 1 })).unwrap(); diff --git a/rs/moq-json/src/stream/producer.rs b/rs/moq-json/src/stream/producer.rs index b723ceb959f..e855cc520e2 100644 --- a/rs/moq-json/src/stream/producer.rs +++ b/rs/moq-json/src/stream/producer.rs @@ -101,6 +101,14 @@ impl Producer { } } +#[cfg(test)] +impl Producer { + /// Shrink the frame limit to a size a test can reach. + pub(super) fn set_max_frame_bytes(&self, bytes: u64) { + self.inner.lock().unwrap().encoder.max_frame_bytes = bytes; + } +} + /// Shared publishing state behind [`Producer`]'s `Arc`. /// /// The track and the encoder are separate fields so a [`Pending`](super::Pending) record (which diff --git a/rs/moq-json/src/window/encoder.rs b/rs/moq-json/src/window/encoder.rs index e5d64a940c7..e43c113c7ca 100644 --- a/rs/moq-json/src/window/encoder.rs +++ b/rs/moq-json/src/window/encoder.rs @@ -158,6 +158,10 @@ pub struct Encoder { /// Frames emitted into the current group, header included. group_frames: usize, + /// The most bytes a group may hold, the cap both a header and the ops after it meet. A field + /// rather than the constant so a test can shrink it to a size it can reach. + max_group_bytes: u64, + /// Whether the next frame must be a header because a frame was lost. Kept separate from the /// window, which a resync must never discard. resync: bool, @@ -179,6 +183,7 @@ impl Encoder { start: 0, group_bytes: 0, group_frames: 0, + max_group_bytes: moq_net::group::MAX_CACHE_BYTES, resync: true, _marker: PhantomData, } @@ -257,7 +262,7 @@ impl Encoder { let payload = Bytes::from(bytes); self.group_frames += 1; self.group_bytes = self.group_bytes.saturating_add(payload.len() as u64); - if self.group_bytes > moq_net::group::MAX_CACHE_BYTES { + if self.group_bytes > self.max_group_bytes { self.reset(); return Ok(None); } @@ -293,7 +298,7 @@ impl Encoder { /// Encode the header restating the whole window and opening a new group. fn emit_header(&mut self, bytes: Vec) -> Result { let payload = Bytes::from(bytes); - if payload.len() as u64 > moq_net::group::MAX_CACHE_BYTES { + if payload.len() as u64 > self.max_group_bytes { return Err(Error::Json("window header exceeds the group cache limit".into())); } @@ -414,11 +419,15 @@ impl Encoder { mod test { use super::*; + /// A group budget small enough to fill cheaply, standing in for moq-net's 32 MiB cache. + const BUDGET: u64 = 32 * 1024; + #[test] fn an_op_that_would_evict_the_header_rolls_first() { let mut encoder = Encoder::::new(ProducerConfig::default().with_ops(true)); - let first = "a".repeat(16 * 1024 * 1024); - let next = "b".repeat(15 * 1024 * 1024); + encoder.max_group_bytes = BUDGET; + let first = "a".repeat(16 * 1024); + let next = "b".repeat(15 * 1024); let frame = encoder.push(&first).unwrap(); assert!(frame.keyframe); @@ -434,7 +443,7 @@ mod test { let frame = encoder.push(&next).unwrap(); assert!(frame.keyframe); - assert!(frame.payload.len() < moq_net::group::MAX_CACHE_BYTES as usize); + assert!(frame.payload.len() < BUDGET as usize); frame.commit(); } @@ -457,7 +466,8 @@ mod test { #[test] fn a_header_larger_than_the_group_cache_is_rejected() { let mut encoder = Encoder::::new(ProducerConfig::default()); - let record = "x".repeat(moq_net::group::MAX_CACHE_BYTES as usize); + encoder.max_group_bytes = BUDGET; + let record = "x".repeat(BUDGET as usize); let err = encoder.push(&record).err().expect("oversized header should fail"); assert!(err.to_string().contains("group cache limit")); diff --git a/rs/moq-json/src/window/properties.rs b/rs/moq-json/src/window/properties.rs index c55e46ae5ff..680e9040899 100644 --- a/rs/moq-json/src/window/properties.rs +++ b/rs/moq-json/src/window/properties.rs @@ -238,6 +238,7 @@ fn window_codec_property_matches_every_readers_logical_window() { let mut runner = TestRunner::new(Config { cases, rng_seed: RngSeed::Fixed(u64::from(seed)), + rng_algorithm: proptest::test_runner::RngAlgorithm::XorShift, failure_persistence: None, ..Default::default() }); diff --git a/rs/moq-mux/src/container/ts/export_timing_test.rs b/rs/moq-mux/src/container/ts/export_timing_test.rs index 4d39e22c855..1392ff3828e 100644 --- a/rs/moq-mux/src/container/ts/export_timing_test.rs +++ b/rs/moq-mux/src/container/ts/export_timing_test.rs @@ -3,11 +3,15 @@ //! resumes, when the source's clock runs off the receiver's, and when two receivers render one //! broadcast for a 1+1 (SMPTE ST 2022-7) pair. //! -//! Every case runs on the paused clock, with each frame written at the instant a live source -//! would send it. A receiver's view is measured from its own output: the lead of each PES (its -//! decode time less the PCR of the slot carrying it) and the instant each slot went out. +//! Every case runs on a deadline queue the test advances itself, with each frame written at the +//! instant a live source would send it. A receiver's view is measured from its own output: the +//! lead of each PES (its decode time less the PCR of the slot carrying it) and the instant each +//! slot went out. use std::io::Cursor; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Wake, Waker}; use std::time::Duration; use bytes::{Bytes, BytesMut}; @@ -64,6 +68,11 @@ fn length_prefixed(nals: &[&[u8]]) -> Bytes { /// A live H.264 + AAC broadcast whose frames are written on the source's clock. struct Live { + /// The receivers' clock: every timer the broadcast and its legs arm waits here, and only + /// [`Self::advance`] moves it. + clock: kio::clock::Deadlines, + /// Keeps [`Self::clock`] the thread's clock, unless another broadcast's is: see [`Self::beside`]. + _entered: Option, _broadcast: moq_net::broadcast::Producer, catalog: crate::catalog::Producer, /// Written group by group, so a group can be left open: see [`Self::run`]. @@ -95,6 +104,22 @@ struct Live { impl Live { async fn new(scale: f64, audio_lag: i64) -> Self { + let clock = kio::clock::Deadlines::new(kio::clock::now()); + let entered = clock.enter(); + Self::on(clock, Some(entered), scale, audio_lag).await + } + + /// Another broadcast on this one's clock. + async fn beside(&self, scale: f64, audio_lag: i64) -> Self { + Self::on(self.clock.clone(), None, scale, audio_lag).await + } + + async fn on( + clock: kio::clock::Deadlines, + entered: Option, + scale: f64, + audio_lag: i64, + ) -> Self { let mut broadcast = moq_net::broadcast::Info::new().produce(); let consumer = broadcast.consume(); let mut catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); @@ -141,6 +166,8 @@ impl Live { Self { source: crate::source::announced(&consumer).await, + clock, + _entered: entered, _broadcast: broadcast, catalog, video, @@ -205,9 +232,9 @@ impl Live { /// Send nothing for `gap`, as a publisher that died, then resume at the live edge on the /// same timeline from the next group, as its replacement or the other half of a 1+1 pair /// would. - async fn fall_silent(&mut self, gap: Duration, legs: &mut [&mut Leg]) { + fn fall_silent(&mut self, gap: Duration, legs: &mut [&mut Leg]) { let resume = kio::clock::now() + gap; - advance(resume, legs).await; + self.advance(resume, legs); let media = (resume.duration_since(self.start).as_secs_f64() * self.scale * 1e6) as u64; self.tick = media.div_ceil(VIDEO_US * GOP) * GOP; self.audio_index = media.div_ceil(AUDIO_US); @@ -220,10 +247,13 @@ impl Live { .unwrap() .with_delay(DELAY) .with_mux_rate(self.rate); + let woken = Arc::new(Woken(AtomicBool::new(true))); Leg { export, every, next_poll: kio::clock::now(), + waiter: kio::Waiter::new(Waker::from(woken.clone())), + woken, out: Vec::new(), } } @@ -235,12 +265,12 @@ impl Live { /// still on its way when the next group starts, as when the relay's backlog for a joiner /// arrives behind the live edge. The subscription then skips it as slow once the next /// group is the delay newer, and counts a discontinuity. - async fn run(&mut self, until: u64, legs: &mut [&mut Leg], stall: impl Fn(u64) -> bool) { + fn run(&mut self, until: u64, legs: &mut [&mut Leg], stall: impl Fn(u64) -> bool) { while self.tick < until { let video = self.tick * VIDEO_US; let audio = self.audio_index * AUDIO_US; let (video_at, audio_at) = self.send_times(video, audio); - advance(video_at.min(audio_at), legs).await; + self.advance(video_at.min(audio_at), legs); if audio_at < video_at { self.send_audio(); } else { @@ -304,10 +334,10 @@ impl Live { /// Send audio alone, each frame at its instant, until media time `until` (µs), as a /// broadcast whose video has stopped. - async fn run_audio(&mut self, until: u64, legs: &mut [&mut Leg]) { + fn run_audio(&mut self, until: u64, legs: &mut [&mut Leg]) { while self.audio_index * AUDIO_US < until { let (_, at) = self.send_times(0, self.audio_index * AUDIO_US); - advance(at, legs).await; + self.advance(at, legs); self.send_audio(); for leg in legs.iter_mut() { leg.poll_now(); @@ -316,7 +346,7 @@ impl Live { } /// End the broadcast and let every leg render what it holds. - async fn finish(&mut self, legs: &mut [&mut Leg]) { + fn finish(&mut self, legs: &mut [&mut Leg]) { if let Some(mut group) = self.group.take() { group.finish().unwrap(); } @@ -324,7 +354,35 @@ impl Live { self.video.finish().unwrap(); self.audio.finish().unwrap(); for leg in legs.iter_mut() { - leg.drain().await; + leg.drain(&self.clock); + } + } + + /// Run the clock to `at`, letting every leg read whenever it would have on its own. + fn advance(&self, at: Instant, legs: &mut [&mut Leg]) { + while let Some(wake) = legs + .iter() + .filter_map(|leg| leg.wake()) + .filter(|wake| *wake <= at) + .min() + { + self.clock.advance(wake.max(self.clock.now())); + let now = kio::clock::now(); + for leg in legs.iter_mut() { + if leg.wake().is_some_and(|wake| wake <= now) { + leg.poll(); + if !leg.every.is_zero() { + while leg.next_poll <= now { + leg.next_poll += leg.every; + } + } + } + } + } + self.clock.advance(at.max(self.clock.now())); + // Read up to the instant the next frame arrives, so the arrival is timed exactly. + for leg in legs.iter_mut() { + leg.poll_now(); } } } @@ -332,13 +390,29 @@ impl Live { /// One receiver: an exporter and what it rendered, with the instant each slot went out. struct Leg { export: Export, - /// How often it reads its subscription. Zero reads after every frame the source sends; a - /// coarser cadence stands for a receiver behind a burstier network or scheduler. + /// How often it reads its subscription. Zero reads whenever its export is woken, as by each + /// frame the source sends; a coarser cadence stands for a receiver behind a burstier network + /// or scheduler. every: Duration, next_poll: Instant, + /// What its export registers to be woken by, and whether it was since the last read. + waiter: kio::Waiter, + woken: Arc, out: Vec<(Instant, Frame)>, } +struct Woken(AtomicBool); + +impl Wake for Woken { + fn wake(self: Arc) { + self.0.store(true, Ordering::Relaxed); + } + + fn wake_by_ref(self: &Arc) { + self.0.store(true, Ordering::Relaxed); + } +} + impl Leg { /// When this leg next reads on its own. fn wake(&self) -> Option { @@ -348,38 +422,41 @@ impl Leg { } } + /// Read if anything woke the export since it last read, as the task polling it would: a read + /// nothing woke finds nothing new. fn poll_now(&mut self) { - if !self.every.is_zero() { - return; + if self.every.is_zero() && self.woken.0.load(Ordering::Relaxed) { + self.poll(); } - self.poll(); } - fn poll(&mut self) { - let waiter = kio::Waiter::noop(); + /// Read everything ready now, returning whether the broadcast ended. + fn poll(&mut self) -> bool { + self.woken.0.store(false, Ordering::Relaxed); let now = kio::clock::now(); - while let std::task::Poll::Ready(frame) = self.export.poll_next(&waiter) { + while let std::task::Poll::Ready(frame) = self.export.poll_next(&self.waiter) { match frame.expect("exporter error") { Some(frame) => self.out.push((now, frame)), - None => break, + None => return true, } } + false } - /// Read to the end of the broadcast. - async fn drain(&mut self) { - loop { - self.poll(); + /// Read to the end of the broadcast, or until nothing is due and 10 ms pass with no end. + /// + /// Only `clock` fires the export's timers, so the 10 ms is an advance of it: a timeout armed + /// on it would never fire. + fn drain(&mut self, clock: &kio::clock::Deadlines) { + while !self.poll() { if let Some(deadline) = self.export.next_due() { - kio::clock::sleep_until(deadline).await; + clock.advance(deadline.max(clock.now())); continue; } - match kio::clock::timeout(Duration::from_millis(10), self.export.next()).await { - Ok(Ok(Some(frame))) => self.out.push((kio::clock::now(), frame)), - Ok(Ok(None)) => break, - Err(_) if self.export.next_due().is_none() => break, - Err(_) => {} - Ok(Err(err)) => panic!("exporter error: {err}"), + clock.advance(clock.now() + Duration::from_millis(10)); + if self.export.next_due().is_none() { + self.poll(); + break; } } } @@ -390,34 +467,6 @@ impl Leg { } } -/// Run the clock to `at`, letting every leg read whenever it would have on its own. -async fn advance(at: Instant, legs: &mut [&mut Leg]) { - while let Some(wake) = legs - .iter() - .filter_map(|leg| leg.wake()) - .filter(|wake| *wake <= at) - .min() - { - kio::clock::sleep_until(wake).await; - let now = kio::clock::now(); - for leg in legs.iter_mut() { - if leg.wake().is_some_and(|wake| wake <= now) { - leg.poll(); - if !leg.every.is_zero() { - while leg.next_poll <= now { - leg.next_poll += leg.every; - } - } - } - } - } - kio::clock::sleep_until(at).await; - // Read up to the instant the next frame arrives, so the arrival is timed exactly. - for leg in legs.iter_mut() { - leg.poll_now(); - } -} - /// A PES start as a receiver sees it. #[derive(Debug, Clone, Copy)] struct Unit { @@ -545,9 +594,9 @@ const CLOCK_SLACK: f64 = 2.0 * SLOT_MS; /// return the leg's units. async fn join(lag: i64, stall: impl Fn(u64) -> bool) -> Vec { let mut live = Live::new(1.0, lag).await; - live.run(JOIN, &mut [], |_| false).await; + live.run(JOIN, &mut [], |_| false); let mut leg = live.join(Duration::ZERO).await; - live.run(24 * GOP, &mut [&mut leg], stall).await; + live.run(24 * GOP, &mut [&mut leg], stall); assert_eq!(leg.export.dropped(), 0, "every frame arrived inside its deadline"); leg.units() } @@ -610,9 +659,9 @@ async fn a_track_sent_later_than_the_delay_loses_nothing() { for lag in [LONG_LAG, -LONG_LAG] { for join in [3 * GOP, JOIN] { let mut live = Live::new(1.0, lag).await; - live.run(join, &mut [], |_| false).await; + live.run(join, &mut [], |_| false); let mut leg = live.join(Duration::ZERO).await; - live.run(join + 20 * GOP, &mut [&mut leg], |_| false).await; + live.run(join + 20 * GOP, &mut [&mut leg], |_| false); let dropped = leg.export.dropped(); if dropped > 0 { lost.push(format!( @@ -641,10 +690,10 @@ async fn an_unmarked_step_keeps_the_export_rendering() { for lag in [0, LAGS[0], LAGS[1]] { let mut live = Live::new(1.0, lag).await; let mut leg = live.join(Duration::ZERO).await; - live.run(8 * GOP + GOP / 2, &mut [&mut leg], |_| false).await; + live.run(8 * GOP + GOP / 2, &mut [&mut leg], |_| false); live.offset += step; let stepped = kio::clock::now(); - live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false).await; + live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false); let case = format!("{} s step, lag {lag}", step as f64 / 1e6); assert_eq!(leg.export.dropped(), 0, "{case}"); @@ -683,10 +732,10 @@ async fn frames_held_through_a_silence_drop_as_late() { } let mut leg = live.join(Duration::ZERO).await; let tail = if declared { 2 } else { 5 }; - live.run(8 * GOP + tail, &mut [&mut leg], |_| false).await; - live.fall_silent(Duration::from_secs(3), &mut [&mut leg]).await; + live.run(8 * GOP + tail, &mut [&mut leg], |_| false); + live.fall_silent(Duration::from_secs(3), &mut [&mut leg]); let resumed = kio::clock::now(); - live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false).await; + live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false); let dropped = leg.export.dropped(); assert!( @@ -729,12 +778,12 @@ async fn a_cut_judges_held_video_when_the_loss_is_observed() { let mut leg = live.join(Duration::ZERO).await; // The declared clock holds only the group's first depth frames. let tail = if declared { 2 } else { 8 }; - live.run(8 * GOP + tail, &mut [&mut leg], |_| false).await; - live.fall_silent(silence, &mut [&mut leg]).await; + live.run(8 * GOP + tail, &mut [&mut leg], |_| false); + live.fall_silent(silence, &mut [&mut leg]); let observed = kio::clock::now(); live.group.take().unwrap().abort(moq_net::Error::Unroutable).unwrap(); leg.poll_now(); - live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false).await; + live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false); assert_eq!( leg.export.dropped() > 0, !silence.is_zero(), @@ -768,8 +817,8 @@ async fn a_successor_settles_held_video_across_a_skip_or_restart() { } let mut leg = live.join(Duration::ZERO).await; let tail = if declared { 2 } else { 8 }; - live.run(8 * GOP + tail, &mut [&mut leg], |_| false).await; - live.fall_silent(Duration::from_secs(3), &mut [&mut leg]).await; + live.run(8 * GOP + tail, &mut [&mut leg], |_| false); + live.fall_silent(Duration::from_secs(3), &mut [&mut leg]); if restart { live.group.take().unwrap().finish().unwrap(); let mut marker = live.video.append_group().unwrap(); @@ -786,9 +835,9 @@ async fn a_successor_settles_held_video_across_a_skip_or_restart() { } // Deliver the successor with its boundary, so no clean-end Pending flush // runs before the new frame can settle the held tail. - live.run(live.tick + 1, &mut [], |_| false).await; + live.run(live.tick + 1, &mut [], |_| false); leg.poll_now(); - live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false).await; + live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false); assert!(leg.export.dropped() > 0, "declared {declared}, restart {restart}"); } } @@ -807,11 +856,11 @@ async fn a_clean_group_end_settles_held_video_before_a_silence() { } let mut leg = live.join(Duration::ZERO).await; let tail = if declared { 2 } else { 8 }; - live.run(8 * GOP + tail, &mut [&mut leg], |_| false).await; + live.run(8 * GOP + tail, &mut [&mut leg], |_| false); live.group.take().unwrap().finish().unwrap(); leg.poll_now(); - live.fall_silent(Duration::from_secs(3), &mut [&mut leg]).await; - live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false).await; + live.fall_silent(Duration::from_secs(3), &mut [&mut leg]); + live.run(live.tick + 8 * GOP, &mut [&mut leg], |_| false); assert_eq!(leg.export.dropped(), 0, "declared {declared}"); } } @@ -824,7 +873,7 @@ async fn video_held_for_most_of_the_delay_drops_nothing() { let mut live = Live::new(1.0, lag).await; live.declare_video_jitter(DELAY * 4 / 5); let mut leg = live.join(Duration::ZERO).await; - live.run(20 * GOP, &mut [&mut leg], |_| false).await; + live.run(20 * GOP, &mut [&mut leg], |_| false); assert_eq!(leg.export.dropped(), 0, "lag {lag}: frames dropped on a clean source"); } } @@ -844,9 +893,9 @@ async fn the_video_held_at_the_end_goes_out() { } let mut leg = live.join(Duration::ZERO).await; let last = 8 * GOP + if declared { 2 } else { 5 }; - live.run(last, &mut [&mut leg], |_| false).await; - live.run_audio((last + 3 * GOP) * VIDEO_US, &mut [&mut leg]).await; - live.finish(&mut [&mut leg]).await; + live.run(last, &mut [&mut leg], |_| false); + live.run_audio((last + 3 * GOP) * VIDEO_US, &mut [&mut leg]); + live.finish(&mut [&mut leg]); assert_eq!(leg.export.dropped(), 0, "lag {lag}: frames dropped"); let video = leg.units().iter().filter(|unit| unit.pid == VIDEO_PID).count() as u64; @@ -860,22 +909,23 @@ async fn the_video_held_at_the_end_goes_out() { // turn once the walk passes the delay, and one running fast makes the buffer grow without bound. // The output's clock may follow it only as fast as 13818-1 lets a system clock change, so // reaching a source 30 ppm off takes hours (the jitter buffer's own tests run a day); these -// cases run five minutes of a source at the limit, long enough to measure its rate. +// cases run a minute of a source at the limit, long enough to measure its rate: frames arrive +// on the source's exact clock, so the measurement settles within a few steering steps. /// How far the instant each slot goes out may wander against the source over a run: what /// reaching a source at the limit costs under the slew limit (30 ppm squared over twice /// 0.075 Hz/s of 27 MHz), and a slot. const DRIFT_SLACK: Duration = Duration::from_millis(162 + 25); -/// Five minutes of media. -const DRIFT_TICKS: u64 = 300_000_000 / VIDEO_US; +/// A minute of media. +const DRIFT_TICKS: u64 = 60_000_000 / VIDEO_US; /// The spread of (slot sent − source's clock at the slot) after the first 10 s over `ticks` /// of media, the drops, and the export's stats at the end. async fn on_a_scaled_clock(scale: f64, ticks: u64) -> (Duration, u64, Stats) { let mut live = Live::new(scale, 0).await; let mut leg = live.join(Duration::ZERO).await; - live.run(ticks, &mut [&mut leg], |_| false).await; + live.run(ticks, &mut [&mut leg], |_| false); let first = leg.out.first().expect("output").1.timestamp.as_micros() as u64; let held: Vec = leg @@ -913,7 +963,7 @@ async fn a_fast_source_clock_keeps_the_delay() { /// A source 400 ppm off, past what the output's clock may follow, is measured and counted. #[tokio::test(start_paused = true)] async fn a_source_past_the_limit_is_counted() { - let (_, _, stats) = on_a_scaled_clock(1.0004, 300_000_000 / VIDEO_US).await; + let (_, _, stats) = on_a_scaled_clock(1.0004, DRIFT_TICKS).await; let drift = stats.release.drift.expect("a measurement"); assert!((drift - 400.0).abs() < 1.0, "measured {drift} ppm"); assert!(stats.release.out_of_tolerance > 0, "not counted"); @@ -929,7 +979,7 @@ async fn a_source_past_the_limit_is_counted() { async fn a_source_far_off_real_time_fails_within_seconds() { let mut live = Live::new(1.0 / 1.22, 0).await; let mut leg = live.join(Duration::ZERO).await; - live.run(10_000_000 / VIDEO_US, &mut [&mut leg], |_| false).await; + live.run(10_000_000 / VIDEO_US, &mut [&mut leg], |_| false); } // A 1+1 pair is two receivers of one broadcast whose outputs a seamless switch compares packet @@ -958,13 +1008,20 @@ fn assert_same_packets(a: &Leg, b: &Leg, from: u64) { "slot at {:?} rendered to a different size", a.timestamp ); - for (offset, (x, y)) in a.payload.iter().zip(b.payload.iter()).enumerate() { - // Only the low nibble of byte 3 is the counter. - assert!( - x == y || (offset % 188 == 3 && (x ^ y) & 0xf0 == 0), - "slot at {:?} differs outside the continuity counter: offset {offset}, {x:#04x} vs {y:#04x}", - a.timestamp, - ); + for (packet, (x, y)) in a.payload.chunks(188).zip(b.payload.chunks(188)).enumerate() { + // Only the low nibble of byte 3 is the counter. Compared a packet at a time, then + // byte by byte only to name the offset that differs. + if x.len() > 3 && x[..3] == y[..3] && (x[3] ^ y[3]) & 0xf0 == 0 && x[4..] == y[4..] { + continue; + } + for (offset, (x, y)) in x.iter().zip(y).enumerate() { + assert!( + x == y || (offset == 3 && (x ^ y) & 0xf0 == 0), + "slot at {:?} differs outside the continuity counter: offset {}, {x:#04x} vs {y:#04x}", + a.timestamp, + packet * 188 + offset, + ); + } } } } @@ -980,10 +1037,10 @@ async fn pair_at(rate: u64, scale: f64, lag: i64, ticks: u64, stall: impl Fn(u64 let mut live = Live::new(scale, lag).await; live.rate = rate; let mut a = live.join(Duration::ZERO).await; - live.run(JOIN, &mut [&mut a], stall).await; + live.run(JOIN, &mut [&mut a], stall); let mut b = live.join(Duration::from_millis(100)).await; - live.run(ticks, &mut [&mut a, &mut b], stall).await; - live.finish(&mut [&mut a, &mut b]).await; + live.run(ticks, &mut [&mut a, &mut b], stall); + live.finish(&mut [&mut a, &mut b]); let joined = b.out.first().expect("the joiner rendered").1.timestamp.as_micros() as u64; (a, b, joined + 2_000_000) } @@ -1025,10 +1082,9 @@ async fn two_legs_render_a_drifting_source_the_same_way() { async fn a_joiner_keeps_the_system_clock_in_tolerance() { const WINDOW: u64 = 30_000_000; let mut live = Live::new(1.0, 0).await; - live.run(JOIN, &mut [], |_| false).await; + live.run(JOIN, &mut [], |_| false); let mut leg = live.join(Duration::ZERO).await; - live.run(JOIN + 300_000_000 / VIDEO_US, &mut [&mut leg], |_| false) - .await; + live.run(JOIN + 300_000_000 / VIDEO_US, &mut [&mut leg], |_| false); // The instant each slot first went out, by its PCR time. let mut first = std::collections::BTreeMap::new(); @@ -1073,9 +1129,9 @@ async fn a_joiner_keeps_the_system_clock_in_tolerance() { #[tokio::test(start_paused = true)] async fn a_joiner_runs_at_the_delay_from_its_first_output() { let mut live = Live::new(1.0, DELAY.as_micros() as i64).await; - live.run(JOIN, &mut [], |_| false).await; + live.run(JOIN, &mut [], |_| false); let mut leg = live.join(Duration::ZERO).await; - live.run(JOIN + 10_000_000 / VIDEO_US, &mut [&mut leg], |_| false).await; + live.run(JOIN + 10_000_000 / VIDEO_US, &mut [&mut leg], |_| false); assert_eq!(leg.export.dropped(), 0, "every frame arrived inside its deadline"); let held: Vec = leg @@ -1104,7 +1160,7 @@ async fn live_audio_shares_its_pes() { for lag in [DELAY.as_micros() as i64, LAGS[0], LAGS[1]] { let mut live = Live::new(1.0, lag).await; let mut leg = live.join(Duration::ZERO).await; - live.run(10_000_000 / VIDEO_US, &mut [&mut leg], |_| false).await; + live.run(10_000_000 / VIDEO_US, &mut [&mut leg], |_| false); assert_eq!( leg.export.dropped(), 0, @@ -1156,10 +1212,10 @@ async fn declared_legs_author_the_same_dts() { let mut live = Live::new(1.0, 0).await; live.reorder(); let mut a = live.join(Duration::ZERO).await; - live.run(join, &mut [&mut a], stall).await; + live.run(join, &mut [&mut a], stall); let mut b = live.join(Duration::from_millis(100)).await; - live.run(12 * GOP, &mut [&mut a, &mut b], stall).await; - live.finish(&mut [&mut a, &mut b]).await; + live.run(12 * GOP, &mut [&mut a, &mut b], stall); + live.finish(&mut [&mut a, &mut b]); let (a, b) = (decode_times(&a), decode_times(&b)); let shared: Vec<_> = b.iter().filter(|(pts, _)| a.contains_key(pts)).collect(); assert!( @@ -1178,12 +1234,11 @@ async fn declared_legs_author_the_same_dts() { async fn a_stitch_keeps_slots_continuous_across_different_track_leads() { for lag in [0, LONG_LAG, -LONG_LAG] { let mut old = Live::new(1.0, lag).await; - let mut new = Live::new(1.0, lag).await; + let mut new = old.beside(1.0, lag).await; let mut leg = old.join(Duration::ZERO).await; for tick in 0..20 * GOP { - old.run(tick + 1, &mut [&mut leg], |_| false).await; - new.run(tick + 1, &mut [], |tick| (JOIN + 1..5 * GOP).contains(&tick)) - .await; + old.run(tick + 1, &mut [&mut leg], |_| false); + new.run(tick + 1, &mut [], |tick| (JOIN + 1..5 * GOP).contains(&tick)); if tick == JOIN { let catalog = new.source.catalog::<()>(Default::default()).await.unwrap(); leg.export diff --git a/rs/moq-mux/src/container/ts/fixture.rs b/rs/moq-mux/src/container/ts/fixture.rs index 1d6831c0afd..39003dad1fa 100644 --- a/rs/moq-mux/src/container/ts/fixture.rs +++ b/rs/moq-mux/src/container/ts/fixture.rs @@ -55,18 +55,18 @@ impl Mux { /// One packet on `pid`, its payload filled with the PID's low byte. pub(super) fn packet(&mut self, pid: u16, flags: Flags) -> &mut Self { let cc = self.cc(pid); - let mut pkt = vec![0x47, (pid >> 8) as u8 & 0x1f, pid as u8]; + let mut pkt = [pid as u8; 188]; + pkt[..3].copy_from_slice(&[0x47, (pid >> 8) as u8 & 0x1f, pid as u8]); let marked = flags.pcr.is_some() || flags.random_access || flags.discontinuity; if marked { - pkt.push(0x30 | cc); - let mut field = vec![ - u8::from(flags.discontinuity) << 7 - | u8::from(flags.random_access) << 6 - | u8::from(flags.pcr.is_some()) << 4, - ]; + pkt[3] = 0x30 | cc; + pkt[5] = u8::from(flags.discontinuity) << 7 + | u8::from(flags.random_access) << 6 + | u8::from(flags.pcr.is_some()) << 4; + pkt[4] = 1; if let Some(pcr) = flags.pcr { let (base, ext) = (pcr / 300, pcr % 300); - field.extend_from_slice(&[ + pkt[6..12].copy_from_slice(&[ (base >> 25) as u8, (base >> 17) as u8, (base >> 9) as u8, @@ -74,13 +74,11 @@ impl Mux { ((base & 1) << 7) as u8 | 0x7e | (ext >> 8) as u8, ext as u8, ]); + pkt[4] = 7; } - pkt.push(field.len() as u8); - pkt.extend_from_slice(&field); } else { - pkt.push(0x10 | cc); + pkt[3] = 0x10 | cc; } - pkt.resize(188, pid as u8); self.out.extend_from_slice(&pkt); self } diff --git a/rs/moq-mux/src/container/ts/passthrough_test.rs b/rs/moq-mux/src/container/ts/passthrough_test.rs index e35c5d6227a..c2170a35ec6 100644 --- a/rs/moq-mux/src/container/ts/passthrough_test.rs +++ b/rs/moq-mux/src/container/ts/passthrough_test.rs @@ -36,10 +36,11 @@ struct Imported { impl Imported { /// Every object's packets, concatenated in order. fn bytes(&self) -> Vec { - self.objects - .iter() - .flat_map(|object| object.payload.iter().copied()) - .collect() + let mut bytes = Vec::new(); + for object in &self.objects { + bytes.extend_from_slice(&object.payload); + } + bytes } fn section(&self) -> &hang::catalog::M2ts { diff --git a/rs/moq-mux/src/container/ts/recovery_test.rs b/rs/moq-mux/src/container/ts/recovery_test.rs index 005c46443ba..8da9d02fc4b 100644 --- a/rs/moq-mux/src/container/ts/recovery_test.rs +++ b/rs/moq-mux/src/container/ts/recovery_test.rs @@ -137,7 +137,9 @@ impl Encode { match self.video { Video::H264(x264) => args.extend(["-c:v", "libx264", "-x264-params", x264, "-profile:v", "high"]), Video::H265(params) => { - x265 = format!("{params}:log-level=error"); + // x265 sizes its own thread pools by the host's cores, ignoring `-threads`, which makes + // the encode both costlier and different from one host to the next. + x265 = format!("{params}:pools=none:frame-threads=1:log-level=error"); args.extend(["-c:v", "libx265", "-x265-params", &x265]); } Video::Mpeg2 => args.extend([ @@ -700,19 +702,14 @@ impl Ledger { struct Run { ledgers: Vec, frames: Vec, + /// The frames' payloads, concatenated. + ts: Vec, stats: stats::Export, /// The stats after each output frame, and when, from the republisher's start. samples: Vec<(Duration, stats::Export)>, } impl Run { - fn ts(&self) -> Vec { - self.frames - .iter() - .flat_map(|frame| frame.payload.iter().copied()) - .collect() - } - fn ledger(&self, video: bool) -> &Ledger { self.ledgers.iter().find(|ledger| ledger.video == video).unwrap() } @@ -960,15 +957,20 @@ async fn run(recording: &Recording, arm: &Arm) -> Run { samples.push((start.elapsed(), export.stats())); } publisher.abort(); + let mut ts = Vec::new(); + for frame in &frames { + ts.extend_from_slice(&frame.payload); + } let mut run = Run { ledgers, frames, + ts, stats: export.stats(), samples, }; // Match each PES, and each ADTS frame of an audio PES, to the unit it carries. - let mut reader = PesPacketReader::new(TsPacketReader::new(std::io::Cursor::new(run.ts()))); + let mut reader = PesPacketReader::new(TsPacketReader::new(std::io::Cursor::new(&run.ts))); while let Some(pes) = reader.read_pes_packet().unwrap() { let video = pes.header.stream_id.is_video(); assert!(video || pes.header.stream_id.is_audio(), "only audio and video go out"); @@ -1196,7 +1198,8 @@ fn decoder(video_only: bool) -> Vec<&'static str> { if !video_only { args.extend(["-map", "0:a:0", "-c:a", "pcm_s16le"]); } - args.extend(["-f", "framemd5", "pipe:1"]); + // A fast hash: MD5 over every raw picture costs about as much as the decode. + args.extend(["-f", "framehash", "-hash", "murmur3", "pipe:1"]); args } @@ -1215,7 +1218,7 @@ fn parse(output: &[u8], video_only: bool) -> Result { } let fields: Vec<&str> = line.split(',').map(str::trim).collect(); let [index, _dts, pts, duration, _size, hash] = fields[..] else { - panic!("framemd5 line {line}"); + panic!("framehash line {line}"); }; match index { "0" => { @@ -1364,7 +1367,7 @@ impl Evidence { }; let dir = std::path::Path::new(&root).join("ts-recovery").join(name); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("export.ts"), run.ts()).unwrap(); + std::fs::write(dir.join("export.ts"), &run.ts).unwrap(); std::fs::write(dir.join("ledgers.txt"), format!("{:#?}", run.ledgers)).unwrap(); std::fs::write(dir.join("stats.txt"), format!("{:#?}", run.stats)).unwrap(); Self(Some(dir)) @@ -1397,7 +1400,7 @@ async fn recover(arm: Arm) -> Run { run.stats.release ); assert!(assert_on_time(&run.frames) > 200, "too little output to judge"); - assert_continuous(&run.ts()); + assert_continuous(&run.ts); assert_eq!( count_discontinuity(&run.frames), profile.breaks(), @@ -1406,12 +1409,12 @@ async fn recover(arm: Arm) -> Run { let reference = Reference::new(profile, run.ledger(true)); let pids = (run.pid(Class::Video), run.pid(Class::Audio)); - for (i, (ts, emitted)) in segments(&run.ts(), run.ledger(true), pids).unwrap().iter().enumerate() { + for (i, (ts, emitted)) in segments(&run.ts, run.ledger(true), pids).unwrap().iter().enumerate() { let output = decode(ts, true).unwrap_or_else(|err| panic!("segment {i}: {err}")); (reference.compare(&output, emitted)).unwrap_or_else(|err| panic!("segment {i}: {err}")); } if profile != Profile::Open { - let output = decode(&run.ts(), false).unwrap(); + let output = decode(&run.ts, false).unwrap(); let video = run.ledger(true); let emitted: Vec<&Unit> = video.units.iter().filter(|unit| unit.output).collect(); reference.compare(&output, &emitted).unwrap(); @@ -1927,7 +1930,7 @@ async fn the_graders_fail_what_they_should() { assert!(err.contains("gaps reported"), "{err}"); // The output less the tables ahead of the keyframe it resumes at. - let ts = run.ts(); + let ts = &run.ts; let pid = run.pid(Class::Video); let pids = (pid, run.pid(Class::Audio)); let packets = ts.as_chunks::<188>().0; @@ -1941,7 +1944,7 @@ async fn the_graders_fail_what_they_should() { .filter(|&(at, packet)| !((tables..resume).contains(&at) && payload(packet).0 == 0)) .flat_map(|(_, packet)| packet.iter().copied()) .collect(); - segments(&ts, &run.ledgers[video], pids).unwrap(); + segments(ts, &run.ledgers[video], pids).unwrap(); let err = segments(&stripped, &run.ledgers[video], pids).unwrap_err(); assert!(err.contains("no PAT"), "{err}"); diff --git a/rs/moq-mux/src/jitter.rs b/rs/moq-mux/src/jitter.rs index 6abf2507b9f..207655ec82d 100644 --- a/rs/moq-mux/src/jitter.rs +++ b/rs/moq-mux/src/jitter.rs @@ -690,20 +690,28 @@ impl Steer { where K: 'a, { - let fits: Vec<(f64, f64)> = heard + let fits: Vec<_> = heard .filter_map(|key| self.tracks.get(key)) .filter(|series| 2 * series.len() >= self.phase.len()) .filter_map(|series| { let (slope, at) = envelope(series, now)?; - Some((depth(series, slope, at, now), slope)) + Some((series, slope, at)) }) .collect(); - let best = fits.iter().map(|&(depth, _)| depth).fold(f64::INFINITY, f64::min); - let steadiest = fits - .iter() - .filter(|&&(depth, _)| depth - best <= FIT) - .map(|&(_, slope)| slope) - .min_by(|a, b| a.abs().total_cmp(&b.abs())); + // A depth only chooses between tracks, and sorting for it is most of a step's work. + let steadiest = match fits[..] { + [(_, slope, _)] => Some(slope), + _ => { + let depths: Vec<(f64, f64)> = (fits.iter()) + .map(|&(series, slope, at)| (depth(series, slope, at, now), slope)) + .collect(); + let best = depths.iter().map(|&(depth, _)| depth).fold(f64::INFINITY, f64::min); + (depths.iter()) + .filter(|&&(depth, _)| depth - best <= FIT) + .map(|&(_, slope)| slope) + .min_by(|a, b| a.abs().total_cmp(&b.abs())) + } + }; let rate = match steadiest { Some(rate) => rate, None => envelope(&self.phase, now)?.0, @@ -1945,6 +1953,11 @@ mod tests { released(buffer).into_iter().map(|(item, _)| item).collect() } + /// Let go of every frame due by now. + fn drain(buffer: &mut Buffer, waiter: &kio::Waiter) { + while buffer.poll_next(waiter).is_ready() {} + } + /// A fill reads each track's available backlog before acquisition selects a clock. #[tokio::test(start_paused = true)] async fn acquisition_observes_every_tracks_available_frames() { @@ -2507,6 +2520,7 @@ mod tests { }; let mut last: Option<(f64, Decode)> = None; let mut stepped = false; + let waiter = kio::Waiter::noop(); for k in 0..(hours * 3_600.0 * 2.0) as u64 { let decode = Duration::from_millis(k * 500); let hour = decode.as_secs_f64() / 3_600.0; @@ -2547,7 +2561,7 @@ mod tests { let slack = since(arrived, deadline) as f64 / 1e9; run.worst = run.worst.max((slack - delay.as_secs_f64()).abs()); } - due(&mut buffer); + drain(&mut buffer, &waiter); let Some(clock) = buffer.clock else { continue; @@ -2703,6 +2717,7 @@ mod tests { out_of_tolerance: 0, rates: Vec::new(), }; + let waiter = kio::Waiter::noop(); for (sent, track, decode) in frames { let arrived = start + Duration::from_secs_f64(sent); clock.advance(arrived.max(clock.now())); @@ -2726,7 +2741,7 @@ mod tests { if let Some(rate) = buffer.drift() { fed.rates.push((decode.as_secs_f64(), rate)); } - due(&mut buffer); + drain(&mut buffer, &waiter); } fed.dropped = buffer.dropped(); fed.drift = buffer.drift(); diff --git a/rs/moq-mux/src/rate.rs b/rs/moq-mux/src/rate.rs index ddccff5fcaf..ea5abe49a6f 100644 --- a/rs/moq-mux/src/rate.rs +++ b/rs/moq-mux/src/rate.rs @@ -1348,7 +1348,7 @@ mod tests { /// invariants after every wake. mod model { use proptest::prelude::*; - use proptest::test_runner::{Config, RngSeed, TestRunner}; + use proptest::test_runner::{Config, RngAlgorithm, RngSeed, TestRunner}; use super::*; @@ -1513,6 +1513,8 @@ mod tests { let mut runner = TestRunner::new(Config { cases: 512, rng_seed: RngSeed::Fixed(6781), + // Every op forks the runner's RNG, which ChaCha makes the bulk of a debug run. + rng_algorithm: RngAlgorithm::XorShift, failure_persistence: None, ..Default::default() }); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 01eaddc9100..5723cf9cd27 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -6276,7 +6276,7 @@ mod group_priority_test { async fn a_busy_track_yields_to_its_siblings() { /// `TrackServe`'s passes per poll; each starts at most one group. const BUDGET: usize = 32; - const GROUPS: u64 = 10_000; + const GROUPS: u64 = 2_000; const BATCH: u64 = 1_000; let session = SinkSession::new(crate::lite::test_transport::Log::default()).with_unacked_fin(); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index dbd55906922..fb17d2439dd 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -8761,7 +8761,7 @@ mod tests { async fn a_busy_subscription_yields_to_its_siblings() { /// `RequestServe`'s passes per poll; each serves at most one group. const BUDGET: u64 = 32; - const GROUPS: u64 = 10_000; + const GROUPS: u64 = 2_000; const BATCH: u64 = 1_000; type Task = Box Poll<()>>; diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index f87ebf6f844..797d9742adc 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -1695,6 +1695,8 @@ mod tests { ]; fn walk(front: &Front, alphabet: &[Event], depth: usize, sequences: &mut usize) { + // Rendered only when an event needs it: formatting dominates the walk. + let mut before = None; for event in alphabet { // Ids are minted once per resolution, so a source can never resolve // while it is already serving; the alphabet reuses ids for brevity. @@ -1704,7 +1706,6 @@ mod tests { continue; } let mut next = front.clone(); - let before = format!("{next:?}"); let actions = next.step(event.clone()); *sequences += 1; @@ -1713,11 +1714,32 @@ mod tests { // An event that changes nothing produces nothing, except letting // go of a source the front never took. - if format!("{next:?}") == before { - assert!( - actions.iter().all(|action| matches!(action, Action::Detach { .. })), - "no-op event {event:?} produced {actions:?}" - ); + if !actions.iter().all(|action| matches!(action, Action::Detach { .. })) { + // The cheap fields settle most events; the rest compare as rendered. + let flags = |f: &Front| { + ( + f.serving, + f.serving_closing, + f.upstream, + f.resolved, + f.armed, + f.held, + f.retiring, + f.ended, + ) + }; + let moved = flags(&next) != flags(front) + || next.parked != front.parked + || next.excluded != front.excluded + || next.tracks.len() != front.tracks.len(); + if !moved { + let before = before.get_or_insert_with(|| format!("{front:?}")); + assert_ne!( + &format!("{next:?}"), + before, + "no-op event {event:?} produced {actions:?}" + ); + } } // Nothing after the end. if front.ended() { diff --git a/rs/moq-net/tests/integration/routing_contracts/handoff.rs b/rs/moq-net/tests/integration/routing_contracts/handoff.rs index 1cd9f592da2..a84af6c6283 100644 --- a/rs/moq-net/tests/integration/routing_contracts/handoff.rs +++ b/rs/moq-net/tests/integration/routing_contracts/handoff.rs @@ -1420,7 +1420,7 @@ fn churn(rng: &mut XorShift, freshness: Freshness) -> Net { if freshness == FLOORS { net.check().unwrap(); } - loop_free(&net); + loop_free(&net, ids); } for &(a, b) in &links { @@ -1436,8 +1436,9 @@ fn churn(rng: &mut XorShift, freshness: Freshness) -> Net { /// A node drops a feasibility distance only once every neighbor echoed its /// ROUTE_END, so an older advertisement of its own never returns as a loop. -fn loop_free(net: &Net) { - for id in (100..110).chain(200..210) { +/// `ids` are the origins' current identities; none past them exists yet. +fn loop_free(net: &Net, ids: [Id; 2]) { + for id in (100..110.min(ids[0] + 1)).chain(200..210.min(ids[1] + 1)) { for node in 0..RELAYS + 3 { let mut at = node; for hop in 0.. { diff --git a/rs/moq-net/tests/integration/routing_contracts/model.rs b/rs/moq-net/tests/integration/routing_contracts/model.rs index c297f1c2c5d..12ce354d60a 100644 --- a/rs/moq-net/tests/integration/routing_contracts/model.rs +++ b/rs/moq-net/tests/integration/routing_contracts/model.rs @@ -680,7 +680,11 @@ struct Front { pub struct Net { cfg: Config, nodes: Vec, - links: BTreeMap<(Node, Node), Link>, + /// `links[a][b]`: `a`'s side of its link to `b`. Dense rather than a map, + /// since selection looks a side up per route and the sweeps select constantly. + links: Vec>>, + /// Every `(subject, scope)` a configured link grants. + grants: BTreeSet<(&'static str, &'static str)>, up: BTreeSet<(Node, Node)>, /// `(node, peer)`: the identity `node` proved on its session to `peer`. A /// session proves one key, once. @@ -710,7 +714,8 @@ impl Net { ..State::default() }) .collect(), - links: BTreeMap::new(), + links: vec![vec![None; nodes]; nodes], + grants: BTreeSet::new(), up: BTreeSet::new(), proved: BTreeMap::new(), draining: BTreeSet::new(), @@ -737,8 +742,16 @@ impl Net { rule.admits(ab.cost) && rule.admits(ba.cost), "zero-cost link {a}-{b} refused" ); - self.links.insert((a, b), ab); - self.links.insert((b, a), ba); + self.links[a][b] = Some(ab); + self.links[b][a] = Some(ba); + self.grants = self + .links + .iter() + .flatten() + .flatten() + .filter_map(|link| link.grant) + .map(|grant| (grant.subject, grant.scope)) + .collect(); self.connect(a, b); } @@ -747,13 +760,18 @@ impl Net { self.link(a, b, Link::cost(cost), Link::cost(cost)); } + /// `node`'s side of its configured link to `peer`. + fn side(&self, node: Node, peer: Node) -> &Link { + self.links[node][peer].as_ref().expect("a configured link") + } + fn pair(a: Node, b: Node) -> (Node, Node) { (a.min(b), a.max(b)) } /// Open a session; each side replays what it currently advertises. pub fn connect(&mut self, a: Node, b: Node) { - assert!(self.links.contains_key(&(a, b)), "unknown link {a}-{b}"); + assert!(self.links[a][b].is_some(), "unknown link {a}-{b}"); self.up.insert(Self::pair(a, b)); for (x, y) in [(a, b), (b, a)] { if let Some(id) = self.nodes[x].proven { @@ -817,18 +835,17 @@ impl Net { /// Re-open every configured link of `node`. pub fn reconnect(&mut self, node: Node) { - let peers: Vec = self.links.keys().filter(|(a, _)| *a == node).map(|(_, b)| *b).collect(); + let peers: Vec = (0..self.nodes.len()) + .filter(|&b| self.links[node][b].is_some()) + .collect(); for peer in peers { self.connect(node, peer); } } + /// Every neighbor `node` has a session up with, in ascending order. fn peers(&self, node: Node) -> impl Iterator + '_ { - self.up.iter().filter_map(move |&(a, b)| match node { - n if n == a => Some(b), - n if n == b => Some(a), - _ => None, - }) + (0..self.nodes.len()).filter(move |&peer| self.links[node][peer].is_some() && self.connected(node, peer)) } /// `node` starts originating `id` at seqno `seqno`, proving it on every session. @@ -1169,7 +1186,7 @@ impl Net { /// The key `at` files `id` under when `from` names it, or `None` when refused. fn key(&self, at: Node, from: Node, id: Id) -> Option { - match self.links[&(at, from)].grant { + match self.side(at, from).grant { Some(grant) if self.proved.get(&(from, at)) != Some(&id) => { if id & QUALIFIED != 0 && !self.cfg.requalify { return None; @@ -1225,7 +1242,7 @@ impl Net { let Some(id) = self.key(at, from, id) else { return; }; - let scope = match self.links[&(at, from)].grant { + let scope = match self.side(at, from).grant { Some(grant) if id & QUALIFIED != 0 => Some(grant.scope), _ => None, }; @@ -1372,46 +1389,61 @@ impl Net { down_only: false, }); } - state - .rx - .get(&id)? - .iter() - .filter(|(next, entry)| !entry.held && !except.contains(next) && self.feasible(node, id, entry.advert)) - .filter_map(|(&next, entry)| { - let advert = entry.advert; - let link = self.links[&(node, next)]; - let metric = self.cfg.rule.add(advert.metric, link.cost)?; - let selected = Selected { - next: Some(next), - seqno: advert.seqno, - metric, - advertised: advert.metric, - down_only: self.cfg.export == Export::DownOnly && (link.upstream || advert.down_only), - }; - let draining = self.draining.contains(&(node, next)); - Some(((draining, Reverse(link.priority), metric, next), selected)) - }) - .min_by_key(|(rank, _)| *rank) - .map(|(_, selected)| selected) + // A plain loop rather than an iterator chain: selection dominates the + // sweeps, and unoptimized test builds pay for every adapter. + type Rank = (bool, Reverse, Metric, Node); + let mut best: Option<(Rank, Selected)> = None; + for (&next, entry) in state.rx.get(&id)? { + if entry.held || except.contains(&next) || !self.feasible(node, id, entry.advert) { + continue; + } + let advert = entry.advert; + let link = self.side(node, next); + let Some(metric) = self.cfg.rule.add(advert.metric, link.cost) else { + continue; + }; + let rank = ( + self.draining.contains(&(node, next)), + Reverse(link.priority), + metric, + next, + ); + if best.as_ref().is_some_and(|(best, _)| *best <= rank) { + continue; + } + let selected = Selected { + next: Some(next), + seqno: advert.seqno, + metric, + advertised: advert.metric, + down_only: self.cfg.export == Export::DownOnly && (link.upstream || advert.down_only), + }; + best = Some((rank, selected)); + } + best.map(|(_, selected)| selected) } /// The usable but infeasible routes `node` holds for `id`, best first. fn infeasible(&self, node: Node, id: Id) -> Vec { - let Some(routes) = self.nodes[node].rx.get(&id) else { - return Vec::new(); - }; - let mut out: Vec<(Metric, Node)> = routes - .iter() - .filter(|(_, entry)| !entry.held && !self.feasible(node, id, entry.advert)) - .filter_map(|(&next, entry)| { - let metric = self.cfg.rule.add(entry.advert.metric, self.links[&(node, next)].cost)?; - Some((metric, next)) - }) - .collect(); + let mut out: Vec<(Metric, Node)> = self.infeasible_unordered(node, id).collect(); out.sort(); out.into_iter().map(|(_, next)| next).collect() } + /// [`Net::infeasible`] with each route's metric, in neighbor order. + fn infeasible_unordered(&self, node: Node, id: Id) -> impl Iterator + '_ { + self.nodes[node] + .rx + .get(&id) + .into_iter() + .flatten() + .filter(move |(_, entry)| !entry.held && !self.feasible(node, id, entry.advert)) + .filter_map(move |(&next, entry)| { + let metric = self.cfg.rule.add(entry.advert.metric, self.side(node, next).cost)?; + Some((metric, next)) + }) + } + /// Where `node` sends a request for `id` on behalf of `from`: a feasible /// route's next hop if one avoids them, else an infeasible route's (RFC 8966 /// s3.8.1.2). @@ -1437,31 +1469,38 @@ impl Net { /// Whether `node` may follow only exportable routes for a request from `from`. fn exports_only(&self, node: Node, from: Option) -> bool { - self.cfg.recheck && from.is_some_and(|from| self.links[&(node, from)].upstream) + self.cfg.recheck && from.is_some_and(|from| self.side(node, from).upstream) } /// Whether `node` holds a reachable route to `id` but none is feasible: it /// needs the origin to advance its seqno. pub fn starved(&self, node: Node, id: Id) -> bool { - self.route(node, id).is_none() && !self.infeasible(node, id).is_empty() + self.route(node, id).is_none() && self.infeasible_unordered(node, id).next().is_some() } /// Whether `node`, with no usable route to `id`, holds a held route it could - /// recover through. A held route counts only if its last finite advertisement - /// is feasible here, so holds follow a strictly decreasing metric toward a - /// starved node and cannot hold each other up. + /// recover through. pub fn grounded(&self, node: Node, id: Id) -> bool { - self.route(node, id).is_none() - && self.nodes[node].rx.get(&id).is_some_and(|routes| { - routes - .values() - .any(|entry| entry.held && self.feasible(node, id, entry.advert)) - }) + self.route(node, id).is_none() && self.recoverable(node, id) + } + + /// Whether `node` holds a held route to `id`. One counts only if its last + /// finite advertisement is feasible here, so holds follow a strictly + /// decreasing metric toward a starved node and cannot hold each other up. + fn recoverable(&self, node: Node, id: Id) -> bool { + self.nodes[node].rx.get(&id).is_some_and(|routes| { + routes + .values() + .any(|entry| entry.held && self.feasible(node, id, entry.advert)) + }) } - /// Whether `node` keeps showing `id`'s announcements. - pub fn live(&self, node: Node, id: Id) -> bool { - self.route(node, id).is_some() || self.starved(node, id) || self.grounded(node, id) + /// Whether `node`, having selected `route` for `id`, keeps showing `id`'s + /// announcements: it is routed, [starved](Self::starved), or + /// [grounded](Self::grounded). The caller passes the route it already + /// selected, since selecting dominates the sweeps. + fn live(&self, node: Node, id: Id, route: Option) -> bool { + route.is_some() || self.infeasible_unordered(node, id).next().is_some() || self.recoverable(node, id) } /// The neighbors whose views can certify `id` at `node`: those whose last @@ -1478,18 +1517,21 @@ impl Net { }) } - /// The regions of `id` that `node`'s certifiers certify, or `None` while it - /// holds no route or a certifier has not yet answered the current interest: - /// its announcements may still trail its route. - fn certified(&self, node: Node, id: Id) -> Option> { + /// The regions of `id` that `node`'s certifiers certify, those whose route + /// is held only when `held`, or `None` while it holds no route or a + /// certifier has not yet answered the current interest: its announcements + /// may still trail its route. + fn certified(&self, node: Node, id: Id, held: bool) -> Option> { let state = &self.nodes[node]; - state.rx.get(&id)?; + let routes = state.rx.get(&id)?; let mut regions = Vec::new(); for next in self.certifiers(node, id) { if state.heard_on.get(&(next, id)) != Some(&state.interest) { return None; } - regions.extend(state.heard[&(next, id)].floors.keys()); + if held || !routes[&next].held { + regions.extend(state.heard[&(next, id)].floors.keys()); + } } Some(regions) } @@ -1506,7 +1548,7 @@ impl Net { if self.nodes[node].origins.contains_key(&id) || self.route(node, id).is_none() { return; } - let Some(regions) = self.certified(node, id) else { + let Some(regions) = self.certified(node, id, true) else { return; }; let state = &self.nodes[node]; @@ -1544,19 +1586,18 @@ impl Net { forget(&mut state.forgot, id, &remembered); } - /// The view `node` shows and forwards for `id`. - fn adopted(&self, node: Node, id: Id) -> Option<&View> { + /// The view `node` shows and forwards for `id`, having selected `route` for it. + fn adopted(&self, node: Node, id: Id, route: Option) -> Option<&View> { let state = &self.nodes[node]; if state.origins.contains_key(&id) { return state.views.get(&id); } match self.cfg.freshness { - Freshness::NextHop => self - .route(node, id) + Freshness::NextHop => route .and_then(|route| route.next) .and_then(|next| state.heard.get(&(next, id))), Freshness::Barrier | Freshness::Floors { .. } => { - self.live(node, id).then(|| state.views.get(&id)).flatten() + self.live(node, id, route).then(|| state.views.get(&id)).flatten() } } } @@ -1607,18 +1648,29 @@ impl Net { if let Some(selected) = selected && peers .iter() - .any(|&peer| !(self.links[&(node, peer)].upstream && selected.down_only)) + .any(|&peer| !(self.side(node, peer).upstream && selected.down_only)) { self.record(node, id, selected); } - let live = serves && self.live(node, id); - let view = self.adopted(node, id).cloned().unwrap_or_default(); + // Recording may have tightened the feasibility distance: select again. + let route = self.route(node, id); + let live = serves && self.live(node, id, route); + let adopted = self.adopted(node, id, route); // A sender that certifies anything answers every stream, even one // whose interest none of its regions meets, so the receiver knows it // certifies nothing there. - let answers = !view.floors.is_empty(); + let answers = adopted.is_some_and(|view| !view.floors.is_empty()); + // Each floor names its region: the stream's interest, or a scope + // below it. Peers mostly share an interest, so restrict once per interest. + let mut restricted: Vec<(&'static str, View)> = Vec::new(); + for &peer in &peers { + let watch = self.nodes[peer].watch; + if !restricted.iter().any(|(interest, _)| *interest == watch) { + restricted.push((watch, adopted.map_or_else(View::default, |view| view.restrict(watch)))); + } + } for &peer in &peers { - let upstream = self.links[&(node, peer)].upstream; + let upstream = self.side(node, peer).upstream; let state = &self.nodes[node]; let reach = match selected { Some(route) if !(upstream && route.down_only) => Some(Reach::Finite(Advert { @@ -1650,12 +1702,11 @@ impl Net { state.sent_views.remove(&(peer, id)); continue; } - // Each floor names its region: the stream's interest, or a scope below it. let watch = self.nodes[peer].watch; - let view = view.restrict(watch); + let (_, view) = restricted.iter().find(|(interest, _)| *interest == watch).unwrap(); let state = &mut self.nodes[node]; let unchanged = match state.sent_views.get(&(peer, id)) { - Some(sent) => *sent == view, + Some(sent) => sent == view, None => !answers, }; if unchanged { @@ -1668,7 +1719,7 @@ impl Net { self.stats.claim_bytes += bytes; self.queues.entry((node, peer)).or_default().push_back(Msg::Claims { id, - view, + view: view.clone(), interest: self.nodes[peer].interest, }); } @@ -1677,15 +1728,12 @@ impl Net { // identity and every neighbor echoed its ROUTE_END: no node then holds an // advertisement from this one, so no older one can return as a loop. let state = &mut self.nodes[node]; - let busy: BTreeSet = state - .rx - .keys() - .chain(state.origins.keys()) - .chain(state.sent.keys().map(|(_, id)| id)) - .chain(state.unacked.keys().map(|(_, id)| id)) - .copied() - .collect(); - state.fd.retain(|id, _| busy.contains(id)); + state.fd.retain(|id, _| { + state.rx.contains_key(id) + || state.origins.contains_key(id) + || state.sent.keys().any(|(_, sent)| sent == id) + || state.unacked.keys().any(|(_, unacked)| unacked == id) + }); self.requests(node); self.show(node); } @@ -1695,16 +1743,20 @@ impl Net { if self.cfg.requests == Requests::Off { return; } + // Every pending or starved identity; one neither pending nor starved + // falls through untouched below. let state = &self.nodes[node]; let mut ids: BTreeSet = state.pending.keys().copied().collect(); - ids.extend(state.rx.keys().filter(|id| self.starved(node, **id))); + ids.extend(state.rx.keys()); for id in ids { let state = &self.nodes[node]; if state.origins.contains_key(&id) { self.nodes[node].pending.remove(&id); continue; } - let starved = self.starved(node, id); + // Nothing below changes a selection, so one serves the whole pass. + let route = self.route(node, id); + let starved = route.is_none() && self.infeasible_unordered(node, id).next().is_some(); if starved { let want = state.fd[&id].0 + 1; match self.cfg.requests { @@ -1739,7 +1791,11 @@ impl Net { let Some(pending) = self.nodes[node].pending.get(&id).cloned() else { continue; }; - let answered = self.route(node, id).is_some_and(|route| route.seqno >= pending.seqno); + let answered = route.is_some_and(|route| route.seqno >= pending.seqno); + if answered || (pending.from.is_empty() && !starved) { + self.nodes[node].pending.remove(&id); + continue; + } // Hop by hop, a request follows the next hop even back through a // requester: next hops form no loop, one pending request per hop stops // a bounce, and no requester ever cancels, so excluding one can strand @@ -1748,17 +1804,15 @@ impl Net { Requests::HopByHop => BTreeSet::new(), _ => pending.from.clone(), }; - let target = self.target(node, id, &except); - if answered || target.is_none() || (pending.from.is_empty() && !starved) { + let Some(target) = self.target(node, id, &except) else { self.nodes[node].pending.remove(&id); continue; - } + }; let resend = match self.cfg.requests { - Requests::HopByHop => target != pending.asked, + Requests::HopByHop => Some(target) != pending.asked, _ => pending.asked.is_none(), }; if resend { - let target = target.unwrap(); self.nodes[node].pending.get_mut(&id).unwrap().asked = Some(target); self.send_request(node, target, id, pending.seqno); } @@ -1772,20 +1826,18 @@ impl Net { ids.extend(state.heard.keys().map(|(_, id)| *id)); let mut shown = BTreeMap::new(); for id in ids { - let Some(view) = self.adopted(node, id) else { + let route = self.route(node, id); + let Some(view) = self.adopted(node, id, route) else { continue; }; - let usable = self.route(node, id).is_some(); + let usable = route.is_some(); // A claim is available only through a certifier that is not held: - // another scope's usable route cannot serve it. + // another scope's usable route cannot serve it. Only claims behind a + // usable route need the certifiers. let serving: Option> = match self.cfg.freshness { - Freshness::Floors { .. } if !state.origins.contains_key(&id) => self.certified(node, id).map(|_| { - self.certifiers(node, id) - .into_iter() - .filter(|next| !state.rx[&id][next].held) - .flat_map(|next| state.heard[&(next, id)].floors.keys().copied()) - .collect() - }), + Freshness::Floors { .. } if usable && !view.items.is_empty() && !state.origins.contains_key(&id) => { + self.certified(node, id, false) + } _ => None, }; let current = state.current.get(&id); @@ -1827,11 +1879,11 @@ impl Net { // A claim no routed neighbor's region covers was let go, not ended. let uncertified = matches!(self.cfg.freshness, Freshness::Floors { .. }) && self - .certified(node, id) + .certified(node, id, true) .is_some_and(|regions| !regions.iter().any(|region| overlaps(claim, region))); let kind = if !overlaps(claim, self.nodes[node].watch) { Kind::Unwatched - } else if self.live(node, id) && !uncertified { + } else if self.live(node, id, self.route(node, id)) && !uncertified { Kind::End } else { Kind::Offline @@ -1940,12 +1992,7 @@ impl Net { if key & QUALIFIED == 0 { return None; } - let subjects: BTreeSet<&str> = self - .links - .values() - .filter_map(|link| link.grant) - .map(|grant| grant.subject) - .collect(); + let subjects: BTreeSet<&str> = self.grants.iter().map(|(subject, _)| *subject).collect(); let known: BTreeSet = self .nodes .iter() @@ -1983,19 +2030,13 @@ impl Net { } fn candidates(&self, node: Node, path: &str, from: Option) -> Vec { - let grants: BTreeSet<(&str, &str)> = self - .links - .values() - .filter_map(|link| link.grant) - .map(|grant| (grant.subject, grant.scope)) - .collect(); let mut out = Vec::new(); for (index, claim) in self.claims.iter().enumerate() { if !claim.covers(path) || self.hidden.contains(&(node, index)) { continue; } // A claim counts under its bare identity, and under a qualified key only inside that grant's scope. - let inside = grants.iter().filter(|(_, scope)| within(claim.prefix, scope)); + let inside = self.grants.iter().filter(|(_, scope)| within(claim.prefix, scope)); for id in iter::once(claim.id).chain(inside.map(|(subject, _)| qualified(subject, claim.id))) { let Some(route) = self.route_from(node, id, from) else { continue; @@ -2010,7 +2051,7 @@ impl Net { total, advertised: route.advertised, draining: route.next.is_some_and(|next| self.draining.contains(&(node, next))), - priority: route.next.map_or(0, |next| self.links[&(node, next)].priority), + priority: route.next.map_or(0, |next| self.side(node, next).priority), prefix: claim.prefix.len(), epoch: claim.epoch, down_only: route.down_only, diff --git a/rs/moq-pattern/tests/integration/pattern_exhaustive.rs b/rs/moq-pattern/tests/integration/pattern_exhaustive.rs index 27a7e4c187b..cce96f37fed 100644 --- a/rs/moq-pattern/tests/integration/pattern_exhaustive.rs +++ b/rs/moq-pattern/tests/integration/pattern_exhaustive.rs @@ -148,8 +148,12 @@ fn intersect_matches_exactly_what_both_match() { for (i, a) in patterns.iter().enumerate() { for (j, b) in patterns.iter().enumerate() { let both = a.intersect(b).unwrap(); - for (k, path) in paths.iter().enumerate() { - assert_eq!(both.matches(path), table[i][k] && table[j][k], "{a} & {b} on {path:?}"); + // Below the diagonal, the commute check makes this the union `b & a` already + // matched against every path. + if i <= j { + for (k, path) in paths.iter().enumerate() { + assert_eq!(both.matches(path), table[i][k] && table[j][k], "{a} & {b} on {path:?}"); + } } assert_eq!(both, b.intersect(a).unwrap(), "{a} & {b} commutes"); assert_eq!(both.is_empty(), !a.overlaps(b), "{a} & {b} empty iff disjoint"); diff --git a/rs/moq-quic/src/tests/probe.rs b/rs/moq-quic/src/tests/probe.rs index 7d24fb37124..c785f1fc29c 100644 --- a/rs/moq-quic/src/tests/probe.rs +++ b/rs/moq-quic/src/tests/probe.rs @@ -246,9 +246,9 @@ struct Reading { } fn run(scenario: &Scenario) -> Outcome { - // Each run's packet trace is about 30 MB and takes most of its time; what it measured is - // reported once it returns - let _guard = subscribe_at(tracing::Level::INFO); + // Each run's packet trace is about 30 MB, so `RUST_LOG=trace` is slow here; what it measured + // is reported once it returns + let _guard = subscribe(); entropy::reseed(scenario.seed); let mut server_transport = transport::Config::default(); if let Some(window) = scenario.receive_window { diff --git a/rs/moq-quic/src/tests/sim.rs b/rs/moq-quic/src/tests/sim.rs index d2abb4650a3..558d6076bc3 100644 --- a/rs/moq-quic/src/tests/sim.rs +++ b/rs/moq-quic/src/tests/sim.rs @@ -266,9 +266,8 @@ impl Peer { }; let mut stream = conn.send_stream(id); while sending.written < sending.spec.len { - let end = sending.spec.len.min(sending.written + 65_536); - let data = payload(index, sending.written..end); - match stream.write(&mut &data[..]) { + let end = sending.spec.len.min(sending.written + PAYLOAD_MAX); + match stream.write(&mut payload(index, sending.written..end)) { Ok(written) => sending.written += written, Err(send::Error::Blocked) => break, Err(error) => panic!("writing stream {index}: {error}"), @@ -318,7 +317,7 @@ impl Peer { let end = receiving.read + chunk.bytes.len(); prop_assert!(end <= len, "stream {index} ran past its {len} bytes"); prop_assert!( - chunk.bytes == payload(index, receiving.read..end), + is_payload(index, receiving.read, &chunk.bytes), "stream {index} corrupted at {}", receiving.read ); @@ -373,9 +372,27 @@ impl Peer { } } +/// The longest range `payload` serves +const PAYLOAD_MAX: usize = 65_536; + /// The bytes at `range` of stream `index`, distinct per stream and offset -fn payload(index: usize, range: ops::Range) -> Vec { - range.map(|i| (i % 251) as u8 ^ (index as u8)).collect() +/// +/// Each stream reads one cycle of prime length from its own rotation, so streams fewer than 251 +/// apart differ at every offset. Slicing a shared buffer keeps the per-step writes and checks from +/// dominating the simulation. +fn payload(index: usize, range: ops::Range) -> &'static [u8] { + static CYCLE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| (0..251 + PAYLOAD_MAX).map(|i| (i % 251) as u8).collect()); + let start = (index + range.start) % 251; + &CYCLE[start..][..range.len()] +} + +/// Whether `bytes` are stream `index`'s payload from `offset`, however long the chunk +fn is_payload(index: usize, offset: usize, bytes: &[u8]) -> bool { + bytes.chunks(PAYLOAD_MAX).enumerate().all(|(i, piece)| { + let start = offset + i * PAYLOAD_MAX; + piece == payload(index, start..start + piece.len()) + }) } fn run(case: &Case) -> Result { diff --git a/rs/moq-quic/src/tests/sim/workloads.rs b/rs/moq-quic/src/tests/sim/workloads.rs index d2eb7267457..cd67f3a7bd9 100644 --- a/rs/moq-quic/src/tests/sim/workloads.rs +++ b/rs/moq-quic/src/tests/sim/workloads.rs @@ -78,7 +78,7 @@ impl Transfer { _ => self.len, }; while self.written < target { - match stream.write(&mut &payload(key, self.written..target)[..]) { + match stream.write(&mut payload(key, self.written..target)) { Ok(written) => self.written += written, Err(send::Error::Blocked) => return Ok(()), Err(send::Error::Stopped(code)) if matches!(self.end, End::Stop) => { @@ -309,7 +309,7 @@ fn conn(pair: &mut Pair, handles: [ConnectionHandle; 2], side: usize) -> &mut Co } fn datagram(side: usize, index: usize) -> Vec { - let mut data = payload(side, 0..256); + let mut data = payload(side, 0..256).to_vec(); data[..4].copy_from_slice(&(index as u32).to_le_bytes()); data } diff --git a/rs/moq-quic/src/tests/util.rs b/rs/moq-quic/src/tests/util.rs index 3ae406e3d0e..1e89a0cca4b 100644 --- a/rs/moq-quic/src/tests/util.rs +++ b/rs/moq-quic/src/tests/util.rs @@ -17,7 +17,7 @@ use rustls::{ client::WebPkiServerVerifier, pki_types::{CertificateDer, PrivateKeyDer}, }; -use tracing::{info_span, trace}; +use tracing::{debug_span, trace}; use super::crypto::rustls::{QuicClientConfig, QuicServerConfig, configured_provider}; use super::*; @@ -160,7 +160,7 @@ impl Pair { } pub(super) fn drive_client(&mut self) { - let span = info_span!("client"); + let span = debug_span!("client"); let _guard = span.enter(); self.client.drive(self.time, self.server.addr); for (packet, buffer) in self.client.outbound.drain(..) { @@ -192,7 +192,7 @@ impl Pair { } pub(super) fn drive_server(&mut self) { - let span = info_span!("server"); + let span = debug_span!("server"); let _guard = span.enter(); self.server.drive(self.time, self.client.addr); for (packet, buffer) in self.server.outbound.drain(..) { @@ -233,7 +233,7 @@ impl Pair { /// Just start connecting the client pub(super) fn begin_connect(&mut self, config: client::Config) -> ConnectionHandle { - let span = info_span!("client"); + let span = debug_span!("client"); let _guard = span.enter(); let (client_ch, client_conn) = self .client @@ -621,14 +621,18 @@ impl ::std::ops::DerefMut for TestEndpoint { } } +/// Logs the test's own events, and the stack's per-packet trace when `RUST_LOG` asks for it. +/// +/// Formatting every trace event costs more than the simulation it describes, and the runs are +/// deterministic, so a failure replays with `RUST_LOG=trace` to read it. The per-step side spans +/// are debug for the same reason: they only label the stack's own events. pub(super) fn subscribe() -> tracing::subscriber::DefaultGuard { - subscribe_at(tracing::Level::TRACE) -} - -/// [`subscribe`] at `level`, for a test whose trace would be too long to read -pub(super) fn subscribe_at(level: tracing::Level) -> tracing::subscriber::DefaultGuard { + let filter = tracing_subscriber::EnvFilter::builder() + .with_default_directive(tracing::Level::INFO.into()) + .from_env() + .expect("RUST_LOG"); let builder = tracing_subscriber::FmtSubscriber::builder() - .with_max_level(level) + .with_env_filter(filter) .with_writer(|| TestWriter); tracing::subscriber::set_default(builder.finish()) } diff --git a/rs/moq-relay/tests/integration/cluster_unknown.rs b/rs/moq-relay/tests/integration/cluster_unknown.rs index 97a27d7ee4f..b4bca9e9e12 100644 --- a/rs/moq-relay/tests/integration/cluster_unknown.rs +++ b/rs/moq-relay/tests/integration/cluster_unknown.rs @@ -54,11 +54,13 @@ async fn spawn_relay(id: u64, connect: Vec, cluster_version: Option = (0..100).map(|seed| payload(1000, seed)).collect(); for _ in 0..2000 { - for seed in 0..100 { + for datagram in &datagrams { sender - .send_datagram(payload(1000, seed), SendOptions::default()) + .send_datagram(datagram.clone(), SendOptions::default()) .expect("the sender's keep-alive kept it open"); } tokio::time::advance(Duration::from_millis(1)).await; From 7a36f1b007a08896ee4498f440aa13575c04503f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:17:22 +0000 Subject: [PATCH 73/86] refactor(ci): declare each check module once in the dispatcher (#7356) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- quest/m2/README.md | 1 - quest/m2/dispatch-modules.md | 22 --- sh/dispatch.sh | 287 ++++++++++++++++++----------------- sh/gh/test-inventory.py | 9 +- sh/gh/test-inventory.test.py | 11 ++ sh/js/workspace.py | 2 +- sh/js/workspace.test.py | 2 +- sh/rs/select.test.py | 11 +- 8 files changed, 172 insertions(+), 173 deletions(-) delete mode 100644 quest/m2/dispatch-modules.md diff --git a/quest/m2/README.md b/quest/m2/README.md index ebf7916e218..ec247317c97 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -205,7 +205,6 @@ lite-07; bitrate enforcement is not part of this release. - [Dependency and alternatives audit](/quest/m2/audit-deps.md) - every dependency earns its weight, preferring fewer, and the rest are current, deduplicated, and the best maintained option - [Dead code and API surface audit](/quest/m2/audit-dead-code.md) - repeat the removal audit before the next major release - [Typecheck with bun check](/quest/m2/bun-check.md) - workspace packages typecheck through bun once nixpkgs ships bun 1.4.4 -- [Declare each check module once in the dispatcher](/quest/m2/dispatch-modules.md) - one registration call per module replaces its parallel tables - [Format YAML with Biome](/quest/m2/biome-yaml.md) - `just check` fails on unformatted YAML - [Format Markdown with Biome](/quest/m2/biome-markdown.md) - Biome formats every Markdown file and remark is gone, landed in a quiet window - [Test coverage audit](/quest/m2/audit-test-coverage.md) - repeat the coverage audit against the m1 baselines after the next release ships diff --git a/quest/m2/dispatch-modules.md b/quest/m2/dispatch-modules.md deleted file mode 100644 index 23b96a54d43..00000000000 --- a/quest/m2/dispatch-modules.md +++ /dev/null @@ -1,22 +0,0 @@ -# [M] Declare each check module once in the dispatcher - -## Goal - -`sh/dispatch.sh` declares each module in one `module` registration call -(scope pattern, tools, whether it compiles Rust, lane, runs alone) that fills -the tables it reads today, instead of listing the module separately in -`scope`, `tools`, `compiles`, `lane`, `alone`, and the lane order. Selection -and scheduling are unchanged. No public API or wire impact. - -## Plan - -Decided 2026-10-27 (maintainer interview, from the 2026-10-27 refactor audit): -stay in bash with a registration function; porting the dispatcher to Python -was declined. - -- Keep each table's comment with the module it explains. -- Preserve the Bun lane (concurrent `bun install` breaks), the shared C++ - build lane, and Dart running alone. -- Prove equivalence with the existing `sh/dispatch-query.test.py` and - `sh/changed.test.py`, plus a before/after diff of `just ci-select` output for - a few representative diffs. diff --git a/sh/dispatch.sh b/sh/dispatch.sh index 51b6c7231e7..2a4a4e3f461 100755 --- a/sh/dispatch.sh +++ b/sh/dispatch.sh @@ -56,69 +56,151 @@ else fi fi -# The impact map: a module is in scope when a changed path matches its -# pattern. An empty pattern is a repository-wide lint that runs on every diff. -declare -A scope=( - # The JS tests also read Rust's shared JSON vectors; sh/js/workspace.py - # fails a check that reads a cross-tree input this pattern misses. - # demo/pub is a Worker outside the Bun workspace, listed so Biome lints it. - # Root Nix inputs own the browser version every npm Playwright pin matches. - [js]='^(js/|bench/browser/|doc/|drafts/|demo/(boy|pub|web|carla/web)/|test/|rs/.*\.json$|sh/js/|sh/rs/stats-docs\.py$|package\.json$|bun\.lock(b)?$|biome\.jsonc$|flake\.(nix|lock)$)' - # Workers with lockfiles outside the Bun workspace. - [workers]='^(infra/|demo/pub/|sh/js/workers\.sh$)' - [moq_sh]='^infra/moq-sh/' - # sh/rs/select.sh widens to the whole workspace for inputs every crate shares. - # moq-cli builds the Rust interop clients as examples from outside its - # crate; sh/rs/select.test.py fails when a target's directory is missing. - # moq-relay's tests read its NixOS module. - [rs]='^(\.cargo/|nix/modules/moq-relay\.nix$|sh/local\.(sh|ts)$|rs/|demo/carla/bridge/|test/interop/clients/rust/|sh/rs/|Cargo\.(toml|lock)$|clippy\.toml$|rust-toolchain\.toml$|\.config/nextest\.toml$)' - [bench]='^bench/' - # The CARLA demo's simulator worker and launchers. Its player is a JS - # package and its GPU bridge a Rust crate, so those modules cover them. - [carla]='^demo/carla/(sim/|justfile$)' - [drafts]='^(drafts/|sh/drafts/|doc/\.vitepress/drafts\.ts$)' - # Quest documents form one graph, so any change validates the whole tree, - # as does a bump of the flake-pinned validator. - [quest]='^(quest/|flake\.lock$)' - # The drill mutations patch Rust source, so a Rust change can move the code - # they target. Only nightly runs the drills; this catches a stale patch. - [drill]='^(rs/|test/drill/)' - # maturin bundles rs/moq-ffi into the moq-ffi wheel, and the other - # bindings generate from it, built by the `cargo build-ffi` alias in - # .cargo/config.toml. Each check also compiles its doc samples. - [py]='^(py/|sh/py/|pyproject\.toml$|uv\.lock$|rs/moq-ffi/|doc/lib/py/|doc/lib/samples\.sh$)' - [kt]='^(\.cargo/config\.toml$|kt/|sh/kt/|rs/moq-ffi/|doc/lib/kt/|doc/lib/samples\.sh$)' - [swift]='^(\.cargo/config\.toml$|swift/|sh/swift/|rs/moq-ffi/|doc/lib/swift/|doc/lib/samples\.sh$)' - [go]='^(\.cargo/config\.toml$|go/|sh/go/|rs/moq-ffi/|doc/lib/go/|doc/lib/samples\.sh$)' - [dart]='^(\.cargo/config\.toml$|dart/|sh/dart/|rs/moq-ffi/|doc/lib/dart/|doc/lib/samples\.sh$)' - # The C package links moq-c's native-libs lists, and the C++ package's FFI - # pin. Both packages format with sh/clang-format.sh and OBS's style file. - [c]='^(\.cargo/config\.toml$|c/|sh/c/|sh/clang-format\.sh$|cpp/obs/\.clang-format$|rs/moq-ffi/|rs/moq-c/native-libs/|cpp/moq/FFI_VERSION$|cpp/moq/cmake/moq-ffi\.cmake$)' - # The package links moq-c's native-libs lists, and compiles its doc samples. - [cpp]='^(\.cargo/config\.toml$|cpp/moq/|cpp/justfile$|sh/cpp/|sh/clang-format\.sh$|cpp/obs/\.clang-format$|rs/moq-ffi/|rs/moq-c/native-libs/|doc/lib/cpp/|doc/lib/samples\.sh$)' - # The plugin and its tests call through the bindings generated from cpp/moq - # and moq-ffi and link through moq-c's native-libs lists, and flake.nix owns - # the libobs headers it compiles against. - [obs_compile]='^(\.cargo/config\.toml$|cpp/obs/|cpp/moq/|sh/obs/|rs/moq-ffi/|rs/moq-c/native-libs/|flake\.nix$)' - # `obs check` compares the OBS pinned in buildspec.json, flake.nix, and - # nixpkgs, and the last moves on a flake.lock bump alone. - [obs]='^(cpp/obs/|sh/obs/|flake\.(nix|lock)$)' - # Rust source is compiled below. Nix reads Cargo metadata, packaging inputs, - # and its own modules and test helpers; those still gate the full flake. - [flake]='(^\.cargo/|^rs/.*\.(toml|txt|ptx|in)$|^Cargo\.(toml|lock)$|^rust-toolchain\.toml$|^flake\.lock$|^nix/|\.nix$)' - # ts.yml's crate filters against the closure test/ts/run.sh builds, which - # moves only with a manifest, the lockfile, or the harness's packages. - [ts_paths]='^(rs/.*Cargo\.toml$|Cargo\.(toml|lock)$|test/ts/run\.sh$|\.github/workflows/release-gate\.yml$|sh/gh/ts-paths\.py$)' - # Ruff lints and formats every Python file under the nearest pyproject.toml. - [python]='(\.pyi?|(^|/)pyproject\.toml)$' - [markdown]='' - [shell]='' - [large_files]='' - [toml]='' - [nix]='' - [justfile]='' - [gh]='' -) +# The impact map: each module registers once, in the order its lane starts, so +# the slowest come first. A module is in scope when a changed path matches its +# pattern; an empty pattern is a repository-wide lint that runs on every diff. +# After the pattern: +# tools='...' The commands it needs. A module missing one is skipped +# locally, so an incomplete toolchain checks less; under +# CI_STRICT (CI) it is an error, up front, because there a skip +# is indistinguishable from a pass. +# compiles='...' The actions (check, fix) whose run compiles Rust through +# Cargo, so CI restores the Rust build cache only when one is +# selected. +# lane=NAME Modules that share mutable state run in one lane, in module +# order; every other module is a lane of its own, and lanes +# run concurrently. +# alone Its lane runs with no other lane beside it, so it waits for +# the rest. +# fix `just fix` runs it too; check and ci run every module. +declare -A scope=() tools=() compiles=([check]="" [fix]="") lane=() alone=() +checks=() +fixes=() +module() { + local name=$1 option each solo= + scope[$name]=$2 + tools[$name]= + checks+=("$name") + shift 2 + for option; do + case "$option" in + tools=*) tools[$name]=${option#tools=} ;; + compiles=*) for each in ${option#compiles=}; do compiles[$each]+=" $name"; done ;; + lane=*) lane[$name]=${option#lane=} ;; + alone) solo=1 ;; + fix) fixes+=("$name") ;; + *) + echo "error: module $name has an unknown option '$option'" >&2 + exit 2 + ;; + esac + done + [[ -z "$solo" ]] || alone[${lane[$name]:-$name}]=1 +} + +# maturin bundles rs/moq-ffi into the moq-ffi wheel, and the other bindings +# generate from it, built by the `cargo build-ffi` alias in .cargo/config.toml. +# Each binding's check also compiles its doc samples. kt, swift, go, cpp, and +# obs_compile each build moq-ffi's cdylib with the same features and profile +# before reading target/debug/libmoq_ffi.so. Cargo runs one build at a time per +# target directory and leaves a fresh output untouched, so the first of those +# lanes builds it while the rest wait in Cargo, and none rewrites it while +# another reads it. py builds its test features into a target directory of its +# own, and carla runs its tools outside py's venv. +module py '^(py/|sh/py/|pyproject\.toml$|uv\.lock$|rs/moq-ffi/|doc/lib/py/|doc/lib/samples\.sh$)' \ + tools='uv' compiles=check +# sh/rs/select.sh widens to the whole workspace for inputs every crate shares. +# moq-cli builds the Rust interop clients as examples from outside its +# crate; sh/rs/select.test.py fails when a target's directory is missing. +# moq-relay's tests read its NixOS module. +module rs '^(\.cargo/|nix/modules/moq-relay\.nix$|sh/local\.(sh|ts)$|rs/|demo/carla/bridge/|test/interop/clients/rust/|sh/rs/|Cargo\.(toml|lock)$|clippy\.toml$|rust-toolchain\.toml$|\.config/nextest\.toml$)' \ + tools='cargo jq' compiles='check fix' fix +# The C package links moq-c's native-libs lists, and the C++ package's FFI +# pin. Both packages format with sh/clang-format.sh and OBS's style file. +module c '^(\.cargo/config\.toml$|c/|sh/c/|sh/clang-format\.sh$|cpp/obs/\.clang-format$|rs/moq-ffi/|rs/moq-c/native-libs/|cpp/moq/FFI_VERSION$|cpp/moq/cmake/moq-ffi\.cmake$)' \ + tools='cargo cmake cc c++ pkg-config uniffi-bindgen-cpp clang-format' compiles=check fix +# The package links moq-c's native-libs lists, and compiles its doc samples. +# It shares a lane with obs_compile because both build cpp/moq/build. +module cpp '^(\.cargo/config\.toml$|cpp/moq/|cpp/justfile$|sh/cpp/|sh/clang-format\.sh$|cpp/obs/\.clang-format$|rs/moq-ffi/|rs/moq-c/native-libs/|doc/lib/cpp/|doc/lib/samples\.sh$)' \ + tools='cargo cmake c++ uniffi-bindgen-cpp clang-format' compiles=check lane=cpp fix +# The plugin and its tests call through the bindings generated from cpp/moq +# and moq-ffi and link through moq-c's native-libs lists, and flake.nix owns +# the libobs headers it compiles against. +module obs_compile '^(\.cargo/config\.toml$|cpp/obs/|cpp/moq/|sh/obs/|rs/moq-ffi/|rs/moq-c/native-libs/|flake\.nix$)' \ + tools='cargo cmake pkg-config uniffi-bindgen-cpp' compiles=check lane=cpp +# The JS tests also read Rust's shared JSON vectors; sh/js/workspace.py +# fails a check that reads a cross-tree input this pattern misses. +# demo/pub is a Worker outside the Bun workspace, listed so Biome lints it. +# Root Nix inputs own the browser version every npm Playwright pin matches. +# The bun lane exists because `bun install` relinks the root workspace's +# packages even when nothing changed, so a concurrent install or import of +# them fails. +module js '^(js/|bench/browser/|doc/|drafts/|demo/(boy|pub|web|carla/web)/|test/|rs/.*\.json$|sh/js/|sh/rs/stats-docs\.py$|package\.json$|bun\.lock(b)?$|biome\.jsonc$|flake\.(nix|lock)$)' \ + tools='bun python3' lane=bun fix +module kt '^(\.cargo/config\.toml$|kt/|sh/kt/|rs/moq-ffi/|doc/lib/kt/|doc/lib/samples\.sh$)' \ + tools='cargo gradle java ktfmt' compiles=check fix +# Rust source is compiled below. Nix reads Cargo metadata, packaging inputs, +# and its own modules and test helpers; those still gate the full flake. +module flake '(^\.cargo/|^rs/.*\.(toml|txt|ptx|in)$|^Cargo\.(toml|lock)$|^rust-toolchain\.toml$|^flake\.lock$|^nix/|\.nix$)' \ + tools='nix' +module bench '^bench/' \ + tools='cargo' compiles=check +# The CARLA demo's simulator worker and launchers. Its player is a JS +# package and its GPU bridge a Rust crate, so those modules cover them. +module carla '^demo/carla/(sim/|justfile$)' \ + tools='uv' +# swift needs no tools: it skips off macOS by design, and platform.yml is its +# real gate. Its formatter is absent where nixpkgs cannot build it (see +# flake.nix). +module swift '^(\.cargo/config\.toml$|swift/|sh/swift/|rs/moq-ffi/|doc/lib/swift/|doc/lib/samples\.sh$)' \ + fix +module go '^(\.cargo/config\.toml$|go/|sh/go/|rs/moq-ffi/|doc/lib/go/|doc/lib/samples\.sh$)' \ + tools='cargo go uniffi-bindgen-go' compiles=check fix +module drafts '^(drafts/|sh/drafts/|doc/\.vitepress/drafts\.ts$)' \ + tools='bun kramdown-rfc xml2rfc' lane=bun +module markdown '' \ + tools='bun' lane=bun fix +module gh '' \ + tools='actionlint zizmor bun python3 cargo clippy-driver dpkg-deb nfpm rpm' lane=bun +# The drill mutations patch Rust source, so a Rust change can move the code +# they target. Only nightly runs the drills; this catches a stale patch. +module drill '^(rs/|test/drill/)' +# Workers with lockfiles outside the Bun workspace. +module workers '^(infra/|demo/pub/|sh/js/workers\.sh$)' \ + tools='bun' +module moq_sh '^infra/moq-sh/' \ + tools='dash' +# Quest documents form one graph, so any change validates the whole tree, +# as does a bump of the flake-pinned validator. +module quest '^(quest/|flake\.lock$)' \ + tools='quest' +# `obs check` compares the OBS pinned in buildspec.json, flake.nix, and +# nixpkgs, and the last moves on a flake.lock bump alone. +module obs '^(cpp/obs/|sh/obs/|flake\.(nix|lock)$)' \ + tools='clang-format cmake gersemi jq' fix +# ts.yml's crate filters against the closure test/ts/run.sh builds, which +# moves only with a manifest, the lockfile, or the harness's packages. +module ts_paths '^(rs/.*Cargo\.toml$|Cargo\.(toml|lock)$|test/ts/run\.sh$|\.github/workflows/release-gate\.yml$|sh/gh/ts-paths\.py$)' \ + tools='cargo python3' +# Ruff lints and formats every Python file under the nearest pyproject.toml. +module python '(\.pyi?|(^|/)pyproject\.toml)$' \ + tools='ruff' fix +module shell '' \ + tools='shellcheck shfmt' fix +module large_files '' +module toml '' \ + tools='taplo' fix +module nix '' \ + tools='nixfmt' fix +module justfile '' \ + fix +# dart builds moq-ffi with the filtered environment its build hook gets, then +# checks that the hook finds that build fresh. Any other Cargo build in between +# reruns the host build scripts in target/debug that track the environment, +# such as bindgen's BINDGEN_EXTRA_CLANG_ARGS, and so rebuilds what the hook +# then rebuilds again, so it runs alone, last. +module dart '^(\.cargo/config\.toml$|dart/|sh/dart/|rs/moq-ffi/|doc/lib/dart/|doc/lib/samples\.sh$)' \ + tools='cargo dart uniffi_bindgen_dart' compiles=check alone fix # Report each native suite from the same impact map, before any toolchain is # installed. Rust dependencies can break external consumers too, so include @@ -145,87 +227,12 @@ if [[ "$action" == ci-platform ]]; then exit fi -# The tools each module needs. A module missing one is skipped locally, so an -# incomplete toolchain checks less; under CI_STRICT (CI) it is an error, up -# front, because there a skip is indistinguishable from a pass. swift needs -# none: it skips off macOS by design, and platform.yml is its real gate. Its -# formatter is absent where nixpkgs cannot build it (see flake.nix). -declare -A tools=( - [js]='bun python3' - [workers]='bun' - [moq_sh]='dash' - [rs]='cargo jq' - [bench]='cargo' - [carla]='uv' - [drafts]='bun kramdown-rfc xml2rfc' - [quest]='quest' - [drill]='' - [py]='uv' - [kt]='cargo gradle java ktfmt' - [swift]='' - [go]='cargo go uniffi-bindgen-go' - [dart]='cargo dart uniffi_bindgen_dart' - [c]='cargo cmake cc c++ pkg-config uniffi-bindgen-cpp clang-format' - [cpp]='cargo cmake c++ uniffi-bindgen-cpp clang-format' - [obs_compile]='cargo cmake pkg-config uniffi-bindgen-cpp' - [obs]='clang-format cmake gersemi jq' - [flake]='nix' - [ts_paths]='cargo python3' - [markdown]='bun' - [python]='ruff' - [shell]='shellcheck shfmt' - [large_files]='' - [toml]='taplo' - [nix]='nixfmt' - [justfile]='' - [gh]='actionlint zizmor bun python3 cargo clippy-driver dpkg-deb nfpm rpm' -) - # CI and the local check share one selection and one lint/test dispatch. verb=$action [[ "$verb" != ci ]] || verb=check -# The modules whose run compiles Rust through Cargo, so CI restores the Rust -# build cache only when one is selected. The bindings build moq-ffi. -declare -A compiles=( - [check]='rs bench py kt go dart c cpp obs_compile' - [fix]='rs' -) - -# Lanes start in this order, so the slowest come first. dart runs alone (see -# below), so it waits for the rest. -case "$action" in - check | ci) modules=(py rs c cpp obs_compile js kt flake bench carla swift go drafts markdown gh drill workers moq_sh quest obs ts_paths python shell large_files toml nix justfile dart) ;; - fix) modules=(js rs dart go kt swift c cpp obs python markdown shell toml nix justfile) ;; -esac - -# Modules that share mutable state run in one lane, in module order; every -# other module is a lane of its own, and lanes run concurrently. -declare -A lane=( - # `bun install` relinks the root workspace's packages even when nothing - # changed, so a concurrent install or import of them fails. - [js]=bun - [drafts]=bun - [markdown]=bun - [gh]=bun - # Both build cpp/moq/build. - [cpp]=cpp - [obs_compile]=cpp -) - -# kt, swift, go, cpp, and obs_compile each build moq-ffi's cdylib with the same -# features and profile before reading target/debug/libmoq_ffi.so. Cargo runs -# one build at a time per target directory and leaves a fresh output untouched, -# so the first of those lanes builds it while the rest wait in Cargo, and none -# rewrites it while another reads it. py builds its test features into a target -# directory of its own, and carla runs its tools outside py's venv. - -# Lanes that run with no other lane beside them. dart builds moq-ffi with the -# filtered environment its build hook gets, then checks that the hook finds -# that build fresh. Any other Cargo build in between reruns the host build -# scripts in target/debug that track the environment, such as bindgen's -# BINDGEN_EXTRA_CLANG_ARGS, and so rebuilds what the hook then rebuilds again. -declare -A alone=([dart]=1) +modules=("${checks[@]}") +[[ "$action" != fix ]] || modules=("${fixes[@]}") # CHECK_MODULES names the modules to run, or with a leading `-` those to skip. declare -A only=() skip=() diff --git a/sh/gh/test-inventory.py b/sh/gh/test-inventory.py index b8ec8d59d4b..d25d1b2ea38 100644 --- a/sh/gh/test-inventory.py +++ b/sh/gh/test-inventory.py @@ -436,11 +436,10 @@ def execute(self, path: Path): self.executed.add(path) # The module dispatch passes its own names: `just check` and the like. if path == self.root / "sh/dispatch.sh": - for line in re.findall(r"modules=\(([^)]*)\)", path.read_text(errors="replace")): - for name in line.split(): - for verb in ("check", "fix"): - for recipe in self.resolve([name, verb], self.root): - self.visit_recipe(recipe) + for name in re.findall(r"^module (\w+) ", path.read_text(errors="replace"), re.M): + for verb in ("check", "fix"): + for recipe in self.resolve([name, verb], self.root): + self.visit_recipe(recipe) def bases(self, text: str, cwd: Path, file: Path | None) -> list[Path]: """The directories a script path in text may be relative to.""" diff --git a/sh/gh/test-inventory.test.py b/sh/gh/test-inventory.test.py index 2444b7072e1..3df4f711efd 100644 --- a/sh/gh/test-inventory.test.py +++ b/sh/gh/test-inventory.test.py @@ -212,6 +212,17 @@ def test_a_commented_out_caller_covers_nothing(self): self.write("justfile", JUSTFILE.replace(caller, f"echo 'a # b' a#b && {caller}")) self.assertNotIn("ignore media::gpu::tests::renders", self.orphans()) + def test_the_module_dispatcher_reaches_each_registered_module(self): + # sh/dispatch.sh runs `just check` for each `module` registration. + self.write("sh/dispatch.sh", "module() { :; }\nmodule rs '^rs/' \\\n tools='cargo' fix\n") + self.write("rs/justfile", RS + "\n check:\n just rs sweep\n") + self.assertIn("recipe rs::sweep", self.orphans()) + + self.write("justfile", JUSTFILE + " bash sh/dispatch.sh check\n") + orphans = self.orphans() + self.assertNotIn("recipe rs::sweep", orphans) + self.assertNotIn("ignore media::probe::long_sweep", orphans) + def test_a_dispatcher_runs_only_the_branch_its_caller_selects(self): self.write( "sh/group.sh", diff --git a/sh/js/workspace.py b/sh/js/workspace.py index 96f56270f4d..ebe17000669 100644 --- a/sh/js/workspace.py +++ b/sh/js/workspace.py @@ -109,7 +109,7 @@ def selection(root, paths): def unmapped(root, paths): """Existing inputs the dispatcher's static JS impact map misses, so changing one would skip its readers.""" - impact = re.compile(re.search(r"^\s*\[js\]='([^']*)'", (root / "sh/dispatch.sh").read_text(), re.M).group(1)) + impact = re.compile(re.search(r"^module js '([^']*)'", (root / "sh/dispatch.sh").read_text(), re.M).group(1)) # A directory input matches through the files under it, and an import through its file. return [ path diff --git a/sh/js/workspace.test.py b/sh/js/workspace.test.py index 172df9bea98..35851b8f7d0 100644 --- a/sh/js/workspace.test.py +++ b/sh/js/workspace.test.py @@ -38,7 +38,7 @@ def setUp(self): self.write() (self.root / "test/tsconfig.json").write_text('{"exclude":["**/dist"]}') (self.root / "sh").mkdir() - (self.root / "sh/dispatch.sh").write_text(" [js]='^(js/|doc/|demo/|test/)'\n") + (self.root / "sh/dispatch.sh").write_text("module js '^(js/|doc/|demo/|test/)'\n") def write(self): (self.root / "package.json").write_text(json.dumps({"workspaces": list(self.packages)})) diff --git a/sh/rs/select.test.py b/sh/rs/select.test.py index 9dc0f5cd6b5..ba22669c1e4 100644 --- a/sh/rs/select.test.py +++ b/sh/rs/select.test.py @@ -13,6 +13,11 @@ LINUX = os.uname().sysname == "Linux" +def dispatch_scope(): + """The rs module's impact pattern, as sh/dispatch.sh registers it.""" + return re.search(r"^module rs '([^']*)'", (ROOT / "sh/dispatch.sh").read_text(), re.M).group(1) + + class SelectTest(unittest.TestCase): # CI runs target lints before its test build. def selected(self, *paths, action="lint", github_actions=False): @@ -71,7 +76,7 @@ def test_workspace_clippy_configuration_selects_workspace(self): with self.subTest(paths=paths): calls = self.selected(*paths) self.assertTrue(any(call.startswith("rs lint --workspace ") for call in calls), calls) - scope = re.search(r"^\s*\[rs\]='([^']*)'", (ROOT / "sh/dispatch.sh").read_text(), re.M).group(1) + scope = dispatch_scope() self.assertRegex("clippy.toml", scope) def test_gate_runs_no_feature_matrix(self): @@ -157,11 +162,11 @@ def test_relay_nixos_module_selects_the_relay(self): # moq-relay's tests read the module. calls = self.selected("nix/modules/moq-relay.nix", action="test") self.assertTrue(any("/rs/moq-relay#" in call for call in calls), calls) - scope = re.search(r"^\s*\[rs\]='([^']*)'", (ROOT / "sh/dispatch.sh").read_text(), re.M).group(1) + scope = dispatch_scope() self.assertRegex("nix/modules/moq-relay.nix", scope) def test_dispatch_scope_covers_out_of_crate_targets(self): - scope = re.search(r"^\s*\[rs\]='([^']*)'", (ROOT / "sh/dispatch.sh").read_text(), re.M).group(1) + scope = dispatch_scope() metadata = json.loads( subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"], cwd=ROOT, text=True) ) From 274d322f0846b6e7a93ba69261ab1e5f5213bb3e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:17:56 +0000 Subject: [PATCH 74/86] test: browser tests expect current element behavior (#7060) Co-authored-by: Claude Opus 5.5 --- quest/m0/interop-green/README.md | 8 ++--- quest/m0/interop-green/stale-browser-tests.md | 25 --------------- test/interop/clients/js/publish-ui.browser.ts | 10 +++--- test/video/renderer.browser.ts | 32 ++++++++++++------- 4 files changed, 28 insertions(+), 47 deletions(-) delete mode 100644 quest/m0/interop-green/stale-browser-tests.md diff --git a/quest/m0/interop-green/README.md b/quest/m0/interop-green/README.md index 08d8aaf328e..fdedf1c7ae7 100644 --- a/quest/m0/interop-green/README.md +++ b/quest/m0/interop-green/README.md @@ -1,4 +1,4 @@ -# Interop and Video pass on main +# [XS] Interop and Video pass on main ## Goal @@ -24,11 +24,7 @@ Already owned: the intermittent media-matrix timestamp-gap cells [test flakes](/quest/m0/test-flakes-2/README.md), with [moq-dev/moq#6990](https://github.com/moq-dev/moq/pull/6990) open. -This README's own work, after the children: confirm a scheduled Interop +Every child has landed. What remains: confirm a scheduled Interop and Video run each pass. Public API: none. Wire: none. - -## Required - -- [Browser tests expect current behavior](/quest/m0/interop-green/stale-browser-tests.md) - the renderer and element version tests match the shipped validation and version list diff --git a/quest/m0/interop-green/stale-browser-tests.md b/quest/m0/interop-green/stale-browser-tests.md deleted file mode 100644 index 73ed8f81c18..00000000000 --- a/quest/m0/interop-green/stale-browser-tests.md +++ /dev/null @@ -1,25 +0,0 @@ -# [XS] Browser tests expect current behavior - -## Goal - -The Video workflow's renderer test and the Interop harness's element -version tests assert the behavior the elements ship. - -## Plan - -- `test/video/renderer.browser.ts` expects an invalid `url` and `volume` - attribute to reset to their defaults with no page errors. The elements now - keep the last valid value, report the invalid attribute as a page error, - and suspend media until it is fixed (`js/watch/MIGRATE.md`). Assert that - contract: the last valid value is kept, each invalid attribute reports - once, and media stays suspended until a valid value returns. It fails - every Video run on main. -- `test/interop/clients/js/publish-ui.browser.ts` (the "version lists keep - their defaults" and "version lists refuse unsupported" tests) uses - `moq-transport-14`, which - [moq-dev/moq#6753](https://github.com/moq-dev/moq/pull/6753) removed, so - the setter throws `unsupported version`. Use a supported draft where the - test means a valid version and keep an unsupported one only where it - tests the refusal. - -Public API: none. Wire: none. diff --git a/test/interop/clients/js/publish-ui.browser.ts b/test/interop/clients/js/publish-ui.browser.ts index b3eff9092a1..c87971b6a5a 100644 --- a/test/interop/clients/js/publish-ui.browser.ts +++ b/test/interop/clients/js/publish-ui.browser.ts @@ -582,7 +582,7 @@ test("version lists keep their defaults and explicit false booleans round-trip", await customElements.whenDefined("moq-watch"); const watch = document.createElement("moq-watch"); const versions = []; - for (const value of ["moq-transport-14", " moq-lite-06 , moq-transport-22 ", "", " , ", null]) { + for (const value of ["moq-transport-18", " moq-lite-06 , moq-transport-22 ", "", " , ", null]) { if (value === null) watch.removeAttribute("versions"); else watch.setAttribute("versions", value); versions.push(watch.versions); @@ -593,7 +593,7 @@ test("version lists keep their defaults and explicit false booleans round-trip", return { versions, follow }; }); expect(result).toEqual({ - versions: [["moq-transport-14"], ["moq-lite-06", "moq-transport-22"], undefined, undefined, undefined], + versions: [["moq-transport-18"], ["moq-lite-06", "moq-transport-22"], undefined, undefined, undefined], follow: false, }); } finally { @@ -643,7 +643,7 @@ test("version lists refuse unsupported, duplicate, and empty lists and snapshot return error instanceof TypeError && element.versions === undefined; } }); - const list = ["moq-lite-06", "moq-transport-14"]; + const list = ["moq-lite-06", "moq-transport-18"]; element.versions = list; list.push("moq-lite-05"); const snapshot = element.versions; @@ -661,8 +661,8 @@ test("version lists refuse unsupported, duplicate, and empty lists and snapshot tag: result.tag, markup: [true, true], thrown: [true, true, true], - snapshot: ["moq-lite-06", "moq-transport-14"], - reflected: "moq-lite-06,moq-transport-14", + snapshot: ["moq-lite-06", "moq-transport-18"], + reflected: "moq-lite-06,moq-transport-18", }); } finally { await browser.close(); diff --git a/test/video/renderer.browser.ts b/test/video/renderer.browser.ts index 0eb9927803f..2ee12a79171 100644 --- a/test/video/renderer.browser.ts +++ b/test/video/renderer.browser.ts @@ -120,14 +120,10 @@ test(" swaps its canvas when the GPU is lost and keeps previewing t } }, 60_000); -test("invalid player and publisher attributes fall back without browser errors", async () => { +test("invalid player and publisher attributes keep the last valid value and suspend media until corrected", async () => { const page = await open(); const errors: string[] = []; - const warnings: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); - page.on("console", (message) => { - if (message.type() === "warning") warnings.push(message.text()); - }); try { const report = await page.evaluate(async () => { const watch = document.createElement("moq-watch"); @@ -135,6 +131,8 @@ test("invalid player and publisher attributes fall back without browser errors", watch.setAttribute("visible", "always"); watch.setAttribute("paused", ""); document.body.append(watch, publish); + const enabled = () => [watch.connection.enabled.peek(), publish.connection.enabled.peek()]; + const before = enabled(); for (const element of [watch, publish]) { element.setAttribute("url", "https://example.invalid/"); element.setAttribute("url", "http://["); @@ -142,15 +140,27 @@ test("invalid player and publisher attributes fall back without browser errors", watch.setAttribute("volume", "0.75"); watch.setAttribute("volume", "foo"); await Promise.resolve(); - const result = { watch: watch.url, publish: publish.url, volume: watch.volume }; + const kept = { watch: watch.url?.href, publish: publish.url?.href, volume: watch.volume }; + const suspended = enabled(); + // The watch element resumes only once every invalid attribute is corrected. + for (const element of [watch, publish]) element.setAttribute("url", "https://example.invalid/"); + const partial = enabled(); + watch.setAttribute("volume", "0.25"); + const corrected = enabled(); watch.remove(); publish.remove(); - return result; + return { before, kept, suspended, partial, corrected }; + }); + expect(report).toEqual({ + before: [true, true], + kept: { watch: "https://example.invalid/", publish: "https://example.invalid/", volume: 0.75 }, + suspended: [false, false], + partial: [false, true], + corrected: [true, true], }); - expect(report).toEqual({ watch: undefined, publish: undefined, volume: 0.5 }); - expect(errors).toEqual([]); - expect(warnings.filter((message) => message.includes("invalid url="))).toHaveLength(2); - expect(warnings.filter((message) => message.includes("invalid volume="))).toHaveLength(1); + expect(errors.filter((message) => message.includes('invalid url="http://["'))).toHaveLength(2); + expect(errors.filter((message) => message.includes('invalid volume="foo"'))).toHaveLength(1); + expect(errors).toHaveLength(3); } finally { await page.close(); } From cadfbe7310b23f572121647423b302192b48c92d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:39:29 +0000 Subject: [PATCH 75/86] feat(cli): an interrupted mp4 recording keeps its buffering fragment (#7559) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- doc/bin/cli.md | 5 +- quest/m2/README.md | 1 - quest/m2/mp4-repair-lock.md | 2 +- quest/m2/mp4-sigint-flush.md | 13 ---- rs/moq-cli/src/subscribe.rs | 11 ++- rs/moq-cli/tests/integration/export.rs | 72 ++++++++++++++++-- rs/moq-mux/CHANGELOG.md | 1 + rs/moq-mux/src/container/fmp4/export.rs | 22 +++++- rs/moq-mux/src/container/mp4.rs | 98 ++++++++++++++++++++++++- rs/moq-relay/src/listener.rs | 4 +- 10 files changed, 199 insertions(+), 30 deletions(-) delete mode 100644 quest/m2/mp4-sigint-flush.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index f6be125bb8b..e98f611a13f 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -205,8 +205,9 @@ Other video waits for its first keyframe. `mp4 --output ` records to a new file the way OBS's hybrid MP4 does: it is fragmented MP4 while recording, so a crash leaves a playable file, and the broadcast's end or the first Ctrl-C (or SIGTERM) finishes it as a regular MP4 with -its index at the end, without copying the media. A second signal exits at once -and leaves it fragmented. Unlike `fmp4`, a rendition that joins mid-recording is +its index at the end, without copying the media. Finishing writes everything +received, the fragment still buffering included. A second signal exits at once +and leaves it fragmented, minus that fragment. Unlike `fmp4`, a rendition that joins mid-recording is kept, though only the finished file indexes it. ```bash diff --git a/quest/m2/README.md b/quest/m2/README.md index ec247317c97..6aee310dbb6 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -238,7 +238,6 @@ lite-07; bitrate enforcement is not part of this release. - [We own our WebRTC stack](/quest/m2/webrtc/README.md) - moq-rtc becomes moq-webrtc with moq-ice, the data-channel transport, and a vendored, trimmed str0m/dimpl/is/sctp-proto - [Certificate reload and qmux's tasks are supervised](/quest/m2/tls-reload-supervision.md) - a panic in either fails loudly instead of silently stopping - [PUBLISH_DONE says why](/quest/m2/publish-done-codes.md) - GOING_AWAY, TOO_FAR_BEHIND, and EXPIRED round-trip as stream codes -- [An interrupted mp4 recording keeps its last fragment](/quest/m2/mp4-sigint-flush.md) - SIGINT flushes the buffering fragment before finishing - [Repair a crashed mp4 recording](/quest/m2/mp4-repair.md) - a crashed recording's fragmented file becomes a regular MP4 - [moq repair refuses a live recording](/quest/m2/mp4-repair-lock.md) - the recorder's advisory lock keeps `moq repair` off a file still being written - [Windows receives notice truncated control data](/quest/m2/cmsg-windows-truncation.md) - Windows handles `MSG_CTRUNC` like the Unix paths diff --git a/quest/m2/mp4-repair-lock.md b/quest/m2/mp4-repair-lock.md index 97736e180e9..a0acb9fcde5 100644 --- a/quest/m2/mp4-repair-lock.md +++ b/quest/m2/mp4-repair-lock.md @@ -19,4 +19,4 @@ Public API: none (CLI behavior only). Wire: none. ## Related -- [An interrupted mp4 recording keeps its last fragment](/quest/m2/mp4-sigint-flush.md) - the other half of recording durability +- [An interrupted mp4 recording keeps its last fragment](/doc/bin/cli.md#export) - the other half of recording durability diff --git a/quest/m2/mp4-sigint-flush.md b/quest/m2/mp4-sigint-flush.md deleted file mode 100644 index e1a0167c63c..00000000000 --- a/quest/m2/mp4-sigint-flush.md +++ /dev/null @@ -1,13 +0,0 @@ -# [S] An interrupted mp4 recording keeps its last fragment - -## Goal - -On the first SIGINT or SIGTERM, `moq export mp4 --output` writes the fragment it is still buffering before finishing the file, so an interrupted recording loses nothing it had received. - -## Plan - -Follow-up of [moq-dev/moq#7357](https://github.com/moq-dev/moq/pull/7357), which drops up to one buffering fragment on SIGINT. - -Decided 2026-10-30 (maintainer, follow-up planning after the /quest-afk run). Flush through the fMP4 exporter's normal fragment path so the flushed fragment is indexed by the final `moov` like any other; extend #7357's nightly SIGINT test to check the last frames received before the signal are in the file. - -Public API: none. Wire: none. diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index cefbecb1c65..c55e5484e29 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -373,7 +373,8 @@ impl Subscribe { Ok(()) } - /// Record a regular MP4 until the broadcast ends or `finish` is cancelled, then finish it. + /// Record a regular MP4 until the broadcast ends or `finish` is cancelled, then finish it with + /// everything it received. async fn run_mp4(self) -> anyhow::Result<()> { let path = self.args.output.clone().context("an mp4 export records to --output")?; let finish = self.finish.clone(); @@ -389,10 +390,16 @@ impl Subscribe { .with_context(|| format!("failed to create {}", path.display()))?; // A failed export still finishes what it recorded; a failed write leaves the file as is. + let mut stopped = false; let ended = loop { let next = tokio::select! { next = mp4.next() => next, - () = finish.cancelled() => break Ok(()), + // Write out what was received before finishing. + () = finish.cancelled(), if !stopped => { + mp4.stop(); + stopped = true; + continue; + } }; match next { Ok(Some(data)) => file.write_all(&data).await?, diff --git a/rs/moq-cli/tests/integration/export.rs b/rs/moq-cli/tests/integration/export.rs index 9e7db241973..30d2301b920 100644 --- a/rs/moq-cli/tests/integration/export.rs +++ b/rs/moq-cli/tests/integration/export.rs @@ -10,6 +10,7 @@ use std::io::Cursor; use std::os::fd::AsRawFd; use std::process::Stdio; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -1121,28 +1122,83 @@ fn boxes(file: &[u8]) -> Vec { boxes } -/// SIGINT during `export mp4` finishes the recording as a regular MP4 with its moov at the end. +/// Count the frames the relay delivers on `demo`'s video track as they arrive. A GOP lasts two +/// seconds, so subscribing during the first one counts them all. +fn delivered(relay: &Relay) -> Arc { + let count = Arc::::default(); + let counting = count.clone(); + let origin = relay.origin.clone(); + tokio::spawn(async move { + let broadcast = origin.follow("demo", None).unwrap().available().await.unwrap(); + let mut catalogs = hang::catalog::Catalog::<()>::subscribe(&broadcast).await.unwrap(); + let name = loop { + let catalog = catalogs.next().await.unwrap().expect("the catalog ended"); + if let Some(name) = catalog.video.renditions.keys().next() { + break name.clone(); + } + }; + let mut track = broadcast.track(&name).unwrap().subscribe(None).await.unwrap(); + while let Some(mut group) = track.recv_group().await.unwrap() { + while group.read_frame().await.unwrap().is_some() { + counting.fetch_add(1, Ordering::SeqCst); + } + } + }); + count +} + +/// How many samples the regular MP4 `moov` indexes for `handler`'s track. +fn indexed(moov: &mp4_atom::Moov, handler: &[u8; 4]) -> usize { + let trak = moov + .trak + .iter() + .find(|trak| trak.mdia.hdlr.handler == handler.into()) + .expect("a trak"); + match &trak.mdia.minf.stbl.stsz.samples { + mp4_atom::StszSamples::Identical { count, .. } => *count as usize, + mp4_atom::StszSamples::Different { sizes } => sizes.len(), + } +} + +/// SIGINT during `export mp4` finishes the recording as a regular MP4 with its moov at the end, +/// keeping every frame received, the GOP it was still buffering included. #[tokio::test] #[ignore = "real-time process test: just rs cli-export"] async fn an_interrupted_mp4_recording_is_finished() { let relay = relay().await; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("recording.mp4"); + let video = delivered(&relay); let mut recording = moq(&relay, &[]) .args(["export", "mp4", "--output"]) .arg(&path) .stderr(Stdio::piped()) .spawn() .expect("spawn export"); - let (mut publisher, feeding) = import(&relay); + // Stop feeding partway into the second GOP, which then stays open. + let format = Format { + clip: &CLIP[..120_320], + ..TS + }; + let (mut publisher, feeding) = import_as(&relay, &[], format); + let stdin = feeding.await.unwrap(); + // The relay delivers the export the same frames as the counter, so once it stops growing + // the export has received what it will before the signal. + let delivered = || video.load(Ordering::SeqCst); kio::clock::timeout(TIMEOUT, async { - while std::fs::metadata(&path).map_or(0, |file| file.len()) < 10_000 { - kio::clock::sleep(Duration::from_millis(50)).await; + let mut last = delivered(); + loop { + kio::clock::sleep(Duration::from_secs(1)).await; + let now = delivered(); + if now == last && now > 0 { + break; + } + last = now; } }) .await - .expect("the recording never grew"); + .expect("the relay kept delivering"); let pid = recording.id().expect("running").to_string(); let status = std::process::Command::new("kill") .args(["-INT", &pid]) @@ -1169,8 +1225,12 @@ async fn an_interrupted_mp4_recording_is_finished() { }; assert!(moov.mvex.is_none()); assert_eq!(moov.trak.len(), 2, "video and audio"); + assert_eq!( + indexed(moov, b"vide"), + delivered(), + "every video frame delivered before the signal" + ); - let stdin = feeding.await.unwrap(); drop(stdin); assert!(wait(&mut publisher).await.success()); } diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index b2e93a15cb5..7238150996d 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MP4 and Matroska import fill a video rendition's `projection` and `stereo` from Spherical Video V2's `sv3d`/`st3d` boxes and Matroska's `Projection`/`StereoMode`, and Opus channel mapping family 2 (raw Opus, Matroska, and other OpusHead sources) imports with `layout: "ambisonic"`. What the catalog cannot describe is refused with `fmp4::Error::UnsupportedSpatial`, `mkv::Error::UnsupportedSpatial`, or `opus::Error::UnsupportedChannelCount`: a cubemap or mesh projection, a nonzero pose, uncentred or vertically cropped equirectangular bounds, a stereo packing other than mono, left-right, or top-bottom with the left eye first, and family 2 at zeroth order or with RFC 8486's non-diegetic stereo pair. - fMP4 carries surround Opus: a `dOps` channel mapping table (family 1, 2, or 255) imports into the OpusHead description, and an exported `dOps` writes the description's table back instead of refusing it with `opus::Error::UnsupportedMappingFamily`. Family 3, whose OpusHead carries a demixing matrix rather than a table, is still refused both ways. +- `container::mp4::Export::stop` ends a recording before the broadcast does: `next` still returns the fragment each track was buffering, then `None`, so `finish` indexes it. ### Changed diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index c1ec519686f..ed9cdad1f93 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -90,6 +90,9 @@ pub struct Export { /// that declares it elsewhere. Its fragments carry a track id the init never declared. late: bool, + /// Set by [`stop`](Self::stop), after which nothing more is read. + stopped: bool, + /// The `tkhd.track_id` the next new rendition gets. next_track_id: u32, @@ -498,6 +501,7 @@ impl Export { queued: VecDeque::new(), init_emitted: false, late: false, + stopped: false, next_track_id: 1, sequence_number: 1, } @@ -538,6 +542,19 @@ impl Export { self } + /// Stop reading the broadcast. The export still writes out the media it read, the + /// fragment each track was buffering included, then ends. + /// + /// Stopping before the init segment could be written ends the export without writing. + pub(crate) fn stop(&mut self) { + self.catalog = None; + for track in self.tracks.values_mut() { + track.source = None; + track.finished = true; + } + self.stopped = true; + } + /// The `trak` and `trex` declaring `track_id`, once the track could be described. pub(crate) fn declaration(&self, track_id: u32) -> Option<(&mp4_atom::Trak, &mp4_atom::Trex)> { self.declarations() @@ -668,7 +685,7 @@ impl Export { } if self.catalog.is_none() && self.tracks.values().all(|t| t.finished) { // Media was read but can't be written without every track's description. - if !self.queued.is_empty() { + if !self.queued.is_empty() && !self.stopped { return Poll::Ready(Err(Error::TrackUndescribed(self.undescribed()).into())); } return Poll::Ready(Ok(None)); @@ -823,7 +840,8 @@ impl Export { .iter() .filter(|(_, track)| { !track.buffer.is_empty() - && (track.finished + // A stopped track's pending frame joins its buffer first. + && ((track.finished && track.pending.is_none()) || track.group_finished || (self.fragment_duration == Some(Duration::ZERO) && track.buffer.last().is_some_and(|frame| frame.duration.is_some()))) diff --git a/rs/moq-mux/src/container/mp4.rs b/rs/moq-mux/src/container/mp4.rs index aa955fc8744..9d526e34fce 100644 --- a/rs/moq-mux/src/container/mp4.rs +++ b/rs/moq-mux/src/container/mp4.rs @@ -124,7 +124,8 @@ impl Export { self } - /// The next bytes to append to the file, or `None` once the broadcast ends. + /// The next bytes to append to the file, or `None` once the broadcast ends or the export + /// [stops](Self::stop). pub async fn next(&mut self) -> Result> { kio::wait(|waiter| self.poll_next(waiter)).await } @@ -142,9 +143,16 @@ impl Export { Poll::Ready(Ok(Some(data))) } + /// Stop recording before the broadcast ends. [`next`](Self::next) still returns the media + /// already read, the fragment each track was buffering included, then `None`. + pub fn stop(&mut self) { + self.fmp4.stop(); + } + /// The writes that turn the file into a regular MP4, or `None` when nothing was written. /// - /// Media still buffered for a fragment that has not closed is left out. + /// Media still buffered for a fragment that has not closed is left out, unless + /// [`stop`](Self::stop) wrote it first. pub fn finish(self) -> Result> { let Some(header) = &self.header else { return Ok(None); @@ -849,6 +857,92 @@ mod tests { assert_eq!(presented, expected); } + /// Stopping writes out the fragment each track was still buffering, so the finished file + /// holds every frame read, and ends the export without waiting for more. + #[tokio::test(start_paused = true)] + async fn stopping_writes_the_buffered_fragments() { + let mut live = Live::avc3(); + let mut audio = add_opus(&mut live, 0); + let video: Vec<_> = (0..5u64).map(|i| video_frame(i * 33_000, i % 3 == 0)).collect(); + for frame in &video { + live.track.write(frame.clone()).unwrap(); + } + for i in 0..8u64 { + audio.write(raw_frame(i * 20_000, OPUS, i % 5 == 0)).unwrap(); + } + + let mut export = export(&live).await; + let mut file = Vec::new(); + record(&mut export, &mut file).await; + let written = + |export: &super::Export<_>| export.tracks.values().map(|track| track.samples.len()).sum::(); + assert_eq!(written(&export), 3 + 5, "only the closed groups"); + + export.stop(); + while let Some(data) = export.next().await.expect("export failed") { + file.extend_from_slice(&data); + } + assert_eq!(written(&export), 5 + 8); + + finish(export, &mut file); + let moov = moov(atoms(&file)); + let expected: Vec> = video.iter().map(length_prefixed).collect(); + assert_eq!(samples(&file, trak(&moov, b"vide")), expected); + assert_eq!(samples(&file, trak(&moov, b"soun")), vec![OPUS; 8]); + } + + /// A frame read but not yet placed in a fragment, as each track's first is when the header + /// goes out, is written too. + #[tokio::test(start_paused = true)] + async fn stopping_writes_the_frames_read() { + let mut live = Live::avc3(); + let mut audio = add_opus(&mut live, 0); + let frame = video_frame(0, true); + live.track.write(frame.clone()).unwrap(); + audio.write(raw_frame(0, OPUS, true)).unwrap(); + + let mut export = export(&live).await; + let mut file = export.next().await.unwrap().expect("the header").to_vec(); + export.stop(); + while let Some(data) = export.next().await.expect("export failed") { + file.extend_from_slice(&data); + } + + finish(export, &mut file); + let moov = moov(atoms(&file)); + assert_eq!(samples(&file, trak(&moov, b"vide")), [length_prefixed(&frame)]); + assert_eq!(samples(&file, trak(&moov, b"soun")), [OPUS]); + } + + /// Stopping before every track can be described ends the export without a header, rather + /// than failing on the media it could not write. + #[tokio::test(start_paused = true)] + async fn stopping_before_the_header_writes_nothing() { + /// An IDR without the SPS a High profile track waits for. + const IDR_ONLY: &[u8] = &[0, 0, 0, 1, 0x65, 0x88, 0x84, 0x21]; + + let mut live = Live::avc3(); + let mut audio = add_opus(&mut live, 0); + if let Some(config) = live.catalog.modify().unwrap().video.renditions.values_mut().next() + && let hang::catalog::VideoCodec::H264(h264) = &mut config.codec + { + h264.profile = 110; + } + live.track.write(raw_frame(0, IDR_ONLY, true)).unwrap(); + for i in 0..10u64 { + audio.write(raw_frame(i * 20_000, OPUS, i % 5 == 0)).unwrap(); + } + + let mut export = export(&live).await; + let mut file = Vec::new(); + record(&mut export, &mut file).await; + assert!(file.is_empty(), "the video holds the header back"); + + export.stop(); + assert!(export.next().await.expect("stopping is not a failure").is_none()); + assert!(export.finish().unwrap().is_none()); + } + /// Nothing to finish before the header is written. #[tokio::test(start_paused = true)] async fn nothing_written_needs_no_finish() { diff --git a/rs/moq-relay/src/listener.rs b/rs/moq-relay/src/listener.rs index 09d5a12fc2b..f88bfff2862 100644 --- a/rs/moq-relay/src/listener.rs +++ b/rs/moq-relay/src/listener.rs @@ -17,7 +17,9 @@ mod demux; mod idle; pub(crate) use demux::{Classified, Stream, Tls, classify}; -pub(crate) use idle::{Admission, Io}; +pub(crate) use idle::Admission; +#[cfg(feature = "websocket")] +pub(crate) use idle::Io; use std::{ io, From b51eb8cdecc33c858f6df8f3b2ccbcba88b9535e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 08:59:05 +0000 Subject: [PATCH 76/86] feat(relay)!: enforce bitrate caps on QUIC sessions (#7535) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- doc/bin/relay/auth.md | 22 +- doc/lib/js/auth.md | 8 +- doc/lib/rs/moq-auth.md | 4 +- js/auth/README.md | 12 +- js/auth/api/index.api.md | 179 ++++++++---- js/auth/src/claims.test.ts | 42 ++- js/auth/src/claims.ts | 39 ++- js/auth/src/contract.test.ts | 10 +- js/auth/src/contract.ts | 72 +++-- js/auth/src/interop.test.ts | 4 +- js/auth/src/key.test.ts | 24 +- js/auth/src/key.ts | 15 +- js/auth/src/set.ts | 4 +- js/auth/src/wire.ts | 138 +++++++-- quest/m2/README.md | 1 - quest/m2/quic-sim.md | 1 - quest/m2/rate-grant.md | 17 +- quest/m2/rate-quic.md | 96 ------- quest/m2/rate-websocket.md | 18 +- quest/m2/rtc-pacing.md | 4 +- quest/m2/srt-rate-handle.md | 2 +- rs/moq-auth/CHANGELOG.md | 6 +- rs/moq-auth/rate.json | 80 ++++++ rs/moq-auth/src/claims.rs | 25 +- rs/moq-auth/src/grant.rs | 26 +- rs/moq-auth/src/key.rs | 8 + rs/moq-auth/src/lib.rs | 5 +- rs/moq-auth/src/rate.rs | 110 +++++++ rs/moq-auth/src/serve.rs | 16 +- rs/moq-auth/src/set.rs | 2 + rs/moq-auth/src/wire.rs | 190 ++++++++++-- rs/moq-auth/tests/released_token.rs | 53 ++-- rs/moq-net/CHANGELOG.md | 4 + rs/moq-net/src/transport.rs | 14 + rs/moq-relay/CHANGELOG.md | 6 +- rs/moq-relay/src/auth.rs | 138 ++++++++- rs/moq-relay/src/auth/caps.rs | 176 ++++++++++++ rs/moq-relay/src/auth/refresh.rs | 191 ++++++++++-- rs/moq-relay/src/cluster.rs | 19 +- rs/moq-relay/src/connection.rs | 24 +- rs/moq-relay/src/incoming.rs | 26 +- rs/moq-relay/src/relay.rs | 24 +- rs/moq-relay/src/uring.rs | 56 +++- .../tests/integration/bitrate_caps.rs | 272 ++++++++++++++++++ rs/moq-relay/tests/integration/main.rs | 1 + rs/moq-relay/tests/integration/peer_limits.rs | 5 +- rs/moq-tokio/CHANGELOG.md | 2 + rs/moq-tokio/src/quic/backend.rs | 8 +- rs/moq-tokio/src/server.rs | 56 +++- 49 files changed, 1845 insertions(+), 410 deletions(-) delete mode 100644 quest/m2/rate-quic.md create mode 100644 rs/moq-auth/rate.json create mode 100644 rs/moq-auth/src/rate.rs create mode 100644 rs/moq-relay/src/auth/caps.rs create mode 100644 rs/moq-relay/tests/integration/bitrate_caps.rs diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 0f960857631..e9f1601afb5 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -223,9 +223,25 @@ everything. Put application data somewhere other than the token. Tokens and key scopes from the older `moq-token` format still work: each `put` and `get` prefix `p` reads as the subtree `p/**`. -Roles are pattern arrays. Object roles, including bitrate caps such as -`{"path":["alice/**"],"rate":8000000}`, are refused in full by token and -auth-server grant parsing. No transport currently enforces bitrate caps. +### Bitrate caps + +A role may cap its bitrate, in bits per second, by naming its patterns as +`{"path":["alice/**"],"rate":8000000}` in a token or an auth-server grant. A +`publish` cap bounds what the session sends the relay, and a `subscribe` cap +what the relay sends it, QUIC overhead and retransmissions included. Each +averages over a second, with one second of the rate as a burst. Older relays +refuse a capped role rather than drop the cap. + +Only QUIC and WebTransport on the tokio or io\_uring listener enforce caps. +WebSocket, iroh, and HTTP `/fetch` and `/announced` refuse a capped grant, +whether it arrives at connect, in band, or at revalidation. When in-band tokens +add grants to one session, each direction takes the highest cap among them, and +a grant giving that direction paths without a cap lifts it. A cap changed at +revalidation applies to the live session. + +Every QUIC session starts with only the flow-control credit its SETUP needs, so +a client cannot bank the full `[quic] receive_window` before its cap applies; +the relay opens the window once it admits the session. ### Path matching diff --git a/doc/lib/js/auth.md b/doc/lib/js/auth.md index 21be4ba17c6..ffa503bec0e 100644 --- a/doc/lib/js/auth.md +++ b/doc/lib/js/auth.md @@ -16,9 +16,11 @@ checks a connection path against a token's claims exactly as `moq auth serve` does. A grant can label its session's stats independently of its authorization root. Tokens are interchangeable with the Rust side. Grants and claims are [`Pattern`](https://www.npmjs.com/package/@moq/pattern) unions: `foo` is one -broadcast, `foo/**` is a subtree, `**` is everything. Parsed claim and grant roles are -pattern string arrays; `authorize` returns reduced `Patterns` for each role. -Object roles, including bitrate caps, are refused during parsing. +broadcast, `foo/**` is a subtree, `**` is everything. A claim or grant role may +also carry a bitrate cap, parsed into `publish_rate` or `subscribe_rate` beside +the pattern arrays; key scopes bound paths only, and `authorize` checks paths +only. Answer a capped grant with `encodeGrant`, which writes the cap where the +relay reads it. ```bash bun add @moq/auth diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index d3b3e3488ad..c06f997afad 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -13,13 +13,13 @@ Everything a party needs to ask for or answer an authorization on answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. -- **Request and grant.** `Request` is the JSON a relay POSTs on each session event (`connect`, `revalidate`, `end`), carrying everything it knows about the session. `Grant` is the answer: publish and subscribe [`Pattern`](https://docs.rs/moq-pattern) unions, an expiry, a revalidation cadence, optional stats tier and label, and whether the session is a cluster peer or an [upstream link](/bin/relay/cluster#upstream-links). `Grant::validate` checks the grant contract; the accept loop must enforce its caps or refuse it. Expiry is exact, with no allowance for clock skew. +- **Request and grant.** `Request` is the JSON a relay POSTs on each session event (`connect`, `revalidate`, `end`), carrying everything it knows about the session. `Grant` is the answer: publish and subscribe [`Pattern`](https://docs.rs/moq-pattern) unions with optional [bitrate caps](/bin/relay/auth#bitrate-caps) (`publish_rate`, `subscribe_rate`), an expiry, a revalidation cadence, optional stats tier and label, and whether the session is a cluster peer or an [upstream link](/bin/relay/cluster#upstream-links). `Grant::validate` checks the grant contract; the accept loop must enforce its caps or refuse it. Expiry is exact, with no allowance for clock skew. - **Lease.** `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant: read it, wait for a change, revoke it, and learn why it ended. Whoever runs the accept loop builds the producer, so an embedder can decide in process with no HTTP. - **Refusals.** `Refusal` carries a `refusal::Reason` (`Refused`, `Forbidden`, or `Expired`) and optional `root` and `tier`. An auth server returns it as JSON with HTTP 403; `Client` returns `Error::Refused(refusal)`. Legacy plain-text 401/403 bodies and unknown reason strings read as `Refused`. Set `root` only from a verified credential, never the dialed path. Embedded relay deciders pass the same shape in `auth::Error::Refused`. - **Client.** `Client` drives a lease against an auth server over `https://`, `unix://`, or loopback `http://`. It revalidates on cadence and retries through an outage until the grant expires, revokes on a 401, 403, or invalid grant, and reports `end` with the session's byte totals. - **Server** (feature `serve`). `serve::Server` is the reference auth server behind `moq auth serve`: JWTs from the query or the moq-transport SETUP token, certificate grants, anonymous rules, tiers, and session caps. `Server::router` mounts it in your own axum service. - **Mesh enrollment.** `mesh` holds the [enrollment contract](/bin/relay/auth#mesh-enrollment): a device proves its Ed25519 endpoint key against a fresh challenge and receives relay URLs and a scoped token. `mesh::Client` (feature `client`) enrolls with an enrollment key and renews before expiry; `mesh::Coordinator` (feature `serve`) answers it. -- **Keys and claims.** Generate HMAC, RSA, ECDSA, or EdDSA keys as JWKs, sign `Claims` (`root`, `publish`, `subscribe`, `exp`), and verify them. `Claims::authorize(path)` scopes verified claims to the path a client dialed, exactly as `moq auth serve` does. Roles hold path pattern unions directly. +- **Keys and claims.** Generate HMAC, RSA, ECDSA, or EdDSA keys as JWKs, sign `Claims` (`root`, `publish`, `subscribe`, their optional `Rate` caps, `exp`), and verify them. `Claims::authorize(path)` scopes verified claims to the path a client dialed, exactly as `moq auth serve` does. Roles hold path pattern unions directly. ```bash cargo add moq-auth diff --git a/js/auth/README.md b/js/auth/README.md index 2937ea29eb6..6da88f74923 100644 --- a/js/auth/README.md +++ b/js/auth/README.md @@ -7,7 +7,9 @@ contract, the relay flags, and worked examples, see the [Authentication Documentation](../../doc/bin/relay/auth.md). Grants and claims name paths with patterns from `@moq/pattern`: `foo` is one -broadcast, `foo/**` is a subtree, `**` is everything. +broadcast, `foo/**` is a subtree, `**` is everything. A role may also carry a +bitrate cap, written `{"path": [...], "rate": bps}` and parsed into +`publish_rate` or `subscribe_rate` beside the patterns. ## Installation @@ -105,13 +107,15 @@ moq-auth verify --key root.jwk --in alice.jwt ### Answering a relay -A server implementing the contract validates what the relay sends and answers a grant: +A server implementing the contract validates what the relay sends and answers a grant. +`encodeGrant` writes a cap into its role, where the relay reads it: ```typescript -import { type Grant, GrantSchema, RequestSchema } from "@moq/auth"; +import { encodeGrant, type Grant, RequestSchema } from "@moq/auth"; const request = RequestSchema.parse(await body.json()); -const grant: Grant = GrantSchema.parse({ subscribe: ["**"], expires: 4102444800, revalidate: 60 }); +const grant: Grant = { subscribe: ["**"], subscribe_rate: 8_000_000, expires: 4102444800, revalidate: 60 }; +return Response.json(encodeGrant(grant)); ``` ### Working example diff --git a/js/auth/api/index.api.md b/js/auth/api/index.api.md index d8321776b27..bf2cf84bca3 100644 --- a/js/auth/api/index.api.md +++ b/js/auth/api/index.api.md @@ -61,23 +61,37 @@ export const ClaimsSchema: ZodMiniPipe; jti: ZodMiniOptional; nbf: ZodMiniOptional; - publish: ZodMiniOptional>>; + publish: ZodMiniOptional>, ZodMiniObject<{ + path: ZodMiniArray>; + rate: ZodMiniNumber; + }, $strict>]>>; put: ZodMiniOptional, ZodMiniArray>]>>; root: ZodMiniDefault>; sub: ZodMiniOptional; - subscribe: ZodMiniOptional>>; -}, $strict>, ZodMiniTransform>, ZodMiniObject<{ + path: ZodMiniArray>; + rate: ZodMiniNumber; + }, $strict>]>>; +}, $strict>, ZodMiniTransform & { + subscribe?: string[] | { + path: string[]; + rate: number; + } | undefined; +}, "get" | "put">, "publish" | "subscribe"> & { publish?: string[]; + publish_rate?: number; subscribe?: string[]; + subscribe_rate?: number; }, { exp?: number | undefined; get?: string | string[] | undefined; @@ -85,27 +99,78 @@ export const ClaimsSchema: ZodMiniPipe>; export type Grant = infer; -export const GrantSchema: ZodMiniObject<{ +export const GrantSchema: ZodMiniPipe; label: ZodMiniOptional>; mounts: ZodMiniOptional, ZodMiniString>>; peer: ZodMiniOptional>; - publish: ZodMiniOptional>>; + publish: ZodMiniOptional>, ZodMiniObject<{ + path: ZodMiniArray>; + rate: ZodMiniNumber; + }, $strict>]>>; revalidate: ZodMiniOptional; root: ZodMiniOptional>; - subscribe: ZodMiniOptional>>; + subscribe: ZodMiniOptional>, ZodMiniObject<{ + path: ZodMiniArray>; + rate: ZodMiniNumber; + }, $strict>]>>; tier: ZodMiniOptional>; upstream: ZodMiniOptional>; -}, $strip>; +}, $strip>, ZodMiniTransform | undefined; + peer?: boolean | undefined; + publish?: string[] | { + path: string[]; + rate: number; + } | undefined; + revalidate?: number | undefined; + root?: string | undefined; + subscribe?: string[] | { + path: string[]; + rate: number; + } | undefined; + tier?: string | undefined; + upstream?: boolean | undefined; +}, "publish" | "subscribe"> & { + publish?: string[]; + publish_rate?: number; + subscribe?: string[]; + subscribe_rate?: number; +}, { + expires?: number | undefined; + label?: string | undefined; + mounts?: Record | undefined; + peer?: boolean | undefined; + publish?: string[] | { + path: string[]; + rate: number; + } | undefined; + revalidate?: number | undefined; + root?: string | undefined; + subscribe?: string[] | { + path: string[]; + rate: number; + } | undefined; + tier?: string | undefined; + upstream?: boolean | undefined; +}>>; export { InvalidPattern } from "@moq/pattern"; @@ -120,11 +185,20 @@ export const Key: { iss?: unknown; jti?: unknown; nbf?: number; - publish?: string[]; + publish?: string[] | { + path: string[]; + rate: number; + }; put?: string | string[]; root?: string; sub?: unknown; - subscribe?: string[]; + subscribe?: string[] | { + path: string[]; + rate: number; + }; + } & { + publish_rate?: number; + subscribe_rate?: number; }) => Promise; verify: (key: PublicKey | SymmetricKey, token: string) => Promise; }; @@ -171,10 +245,7 @@ export const KeySchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{ put?: string[] | undefined; root: string; subscribe?: string[] | undefined; - }, "get" | "put"> & { - publish?: string[]; - subscribe?: string[]; - }, { + }, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -225,10 +296,7 @@ export const KeySchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{ put?: string[] | undefined; root: string; subscribe?: string[] | undefined; - }, "get" | "put"> & { - publish?: string[]; - subscribe?: string[]; - }, { + }, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -275,10 +343,7 @@ export const KeySchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{ put?: string[] | undefined; root: string; subscribe?: string[] | undefined; - }, "get" | "put"> & { - publish?: string[]; - subscribe?: string[]; - }, { + }, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -324,10 +389,7 @@ export const KeySchema: ZodMiniDiscriminatedUnion<[ZodMiniObject<{ put?: string[] | undefined; root: string; subscribe?: string[] | undefined; - }, "get" | "put"> & { - publish?: string[]; - subscribe?: string[]; - }, { + }, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -348,11 +410,20 @@ export const KeySet: { iss?: unknown; jti?: unknown; nbf?: number; - publish?: string[]; + publish?: string[] | { + path: string[]; + rate: number; + }; put?: string | string[]; root?: string; sub?: unknown; - subscribe?: string[]; + subscribe?: string[] | { + path: string[]; + rate: number; + }; + } & { + publish_rate?: number; + subscribe_rate?: number; }) => Promise; verify: (set: KeySet, token: string) => Promise; }; @@ -396,10 +467,7 @@ export const KeySetSchema: ZodMiniObject<{ put?: string[] | undefined; root: string; subscribe?: string[] | undefined; - }, "get" | "put"> & { - publish?: string[]; - subscribe?: string[]; - }, { + }, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -450,10 +518,7 @@ export const KeySetSchema: ZodMiniObject<{ put?: string[] | undefined; root: string; subscribe?: string[] | undefined; - }, "get" | "put"> & { - publish?: string[]; - subscribe?: string[]; - }, { + }, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -500,10 +565,7 @@ export const KeySetSchema: ZodMiniObject<{ put?: string[] | undefined; root: string; subscribe?: string[] | undefined; - }, "get" | "put"> & { - publish?: string[]; - subscribe?: string[]; - }, { + }, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -549,10 +611,7 @@ export const KeySetSchema: ZodMiniObject<{ put?: string[] | undefined; root: string; subscribe?: string[] | undefined; - }, "get" | "put"> & { - publish?: string[]; - subscribe?: string[]; - }, { + }, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -756,10 +815,7 @@ export const ScopeSchema: ZodMiniPipe & { - publish?: string[]; - subscribe?: string[]; -}, { +}, "get" | "put">, { get?: string[] | undefined; publish?: string[] | undefined; put?: string[] | undefined; @@ -802,6 +858,25 @@ export function authorize(claims: Claims, path: string): Permissions; export { compareSpecificity } from "@moq/pattern"; +export function encodeGrant(grant: Grant): { + expires?: number; + label?: string; + mounts?: Record; + peer?: boolean; + publish?: string[] | { + path: string[]; + rate: number; + }; + revalidate?: number; + root?: string; + subscribe?: string[] | { + path: string[]; + rate: number; + }; + tier?: string; + upstream?: boolean; +}; + export function patterns(texts: readonly string[] | undefined): Patterns; export function scopeAllows(scope: Scope, claims: Claims): boolean; diff --git a/js/auth/src/claims.test.ts b/js/auth/src/claims.test.ts index 494f31cb177..7ae3c55d439 100644 --- a/js/auth/src/claims.test.ts +++ b/js/auth/src/claims.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { authorize, type Claims, ClaimsSchema, type Permissions, ScopeSchema } from "./claims.ts"; -import { GrantSchema } from "./contract.ts"; +import { encodeGrant, GrantSchema } from "./contract.ts"; import { encodeGrants } from "./wire.ts"; // These cases mirror the Rust moq-auth crate's claims::tests one-for-one, so both @@ -120,13 +120,37 @@ test("authorize - wildcards rebase as a set", () => { expect(permissions.subscribe.toJSON()).toEqual(["", "**/chat"]); }); -test("roles are pattern lists and refuse object restrictions in full", () => { - for (const role of ["publish", "subscribe"]) { - for (const object of [{ path: "alice/**" }, { path: ["alice/**"], rate: 8000 }]) { - const input = { publish: ["**"], subscribe: ["**"], [role]: object }; - expect(ClaimsSchema.safeParse(input).success).toBe(false); - expect(ScopeSchema.safeParse(input).success).toBe(false); - expect(GrantSchema.safeParse(input).success).toBe(false); - } +test("shared rate vectors parse and re-encode like Rust", async () => { + const vectors = await Bun.file(new URL("../../../rs/moq-auth/rate.json", import.meta.url)).json(); + for (const row of vectors.valid) { + expect(encodeGrants(ClaimsSchema.parse(row.input))).toEqual(row.claims); + expect(encodeGrant(GrantSchema.parse(row.input))).toEqual(row.grant); } + for (const input of vectors.invalid) { + expect(ClaimsSchema.safeParse(input).success).toBe(false); + expect(GrantSchema.safeParse(input).success).toBe(false); + } +}); + +test("a capped role decodes beside its patterns and never encodes as a prefix", () => { + for (const role of ["publish", "subscribe"] as const) { + // Subtrees alone would be written as legacy prefixes, which a cap must not be. + const claims = ClaimsSchema.parse({ + publish: ["alice/**"], + subscribe: ["**"], + [role]: { path: ["alice/**"], rate: 8000 }, + }); + expect(claims[role]).toEqual(["alice/**"]); + expect(claims[`${role}_rate`]).toBe(8000); + const encoded = encodeGrants(claims); + expect(encoded).not.toHaveProperty("put"); + expect(encoded).not.toHaveProperty("get"); + expect(encoded[role]).toEqual({ path: ["alice/**"], rate: 8000 }); + } + expect(() => ClaimsSchema.parse({ publish: { path: ["**"], rate: 0 } })).toThrow(/rate must be a positive integer/); +}); + +test("a key scope bounds paths, never bitrate", () => { + expect(ScopeSchema.safeParse({ publish: { path: ["**"], rate: 8000 } }).success).toBe(false); + expect(ScopeSchema.safeParse({ publish: ["**"], subscribe: { path: ["**"], rate: 8000 } }).success).toBe(false); }); diff --git a/js/auth/src/claims.ts b/js/auth/src/claims.ts index c83fed0894b..ec989177ace 100644 --- a/js/auth/src/claims.ts +++ b/js/auth/src/claims.ts @@ -1,5 +1,6 @@ /** - * The payload of a token: a root, plus the publish/subscribe patterns granted beneath it. + * The payload of a token: a root, plus the publish/subscribe patterns granted beneath it + * and an optional bitrate cap on each. * * @module */ @@ -7,24 +8,9 @@ import { Pattern, Patterns } from "@moq/pattern"; import * as z from "@zod/mini"; import * as Path from "./path.ts"; -import { decodeGrants } from "./wire.ts"; - -/** A list of pattern texts, each validated by `Pattern.parse`. */ -export const PatternListSchema = z.array( - z.string().check( - z.refine( - (text) => { - try { - Pattern.parse(text); - return true; - } catch { - return false; - } - }, - { message: "invalid path pattern" }, - ), - ), -); +import { decodeGrants, decodeRoles, PatternListSchema, WireRoleSchema } from "./wire.ts"; + +export { PatternListSchema } from "./wire.ts"; /** Parse a list of pattern texts into a reduced union. */ export function patterns(texts: readonly string[] | undefined): Patterns { @@ -45,7 +31,8 @@ const ScopeFields = { * * `root` is optional on the wire to match the Rust `moq-auth` crate, which omits it * when the scope sits at the top level. A legacy `put`/`get` prefix scope reads as the - * subtree patterns it meant. Any other field is refused. + * subtree patterns it meant. A scope bounds paths, never bitrate, so a capped role is + * refused. Any other field is refused. */ export const ScopeSchema = z .pipe( @@ -67,6 +54,10 @@ export type Scope = z.output; const ClaimsFields = { ...ScopeFields, + /** Patterns the holder may publish, relative to `root`, optionally with a bitrate cap. */ + publish: z.optional(WireRoleSchema), + /** Patterns the holder may subscribe to, relative to `root`, optionally with a bitrate cap. */ + subscribe: z.optional(WireRoleSchema), /** Expiration time, as a whole unix timestamp in seconds. */ exp: z.optional(z.int()), /** Issued-at time, as a whole unix timestamp in seconds. */ @@ -94,8 +85,10 @@ const PrefixListSchema = z.union([z.string(), z.array(z.string())]); * exactly what it says: `alice` is one broadcast, `alice/**` is a subtree, and `**` is * everything under the root. Legacy `moq-token` claims read too, each `put`/`get` * prefix `p` as the subtree `p/**`, and signing writes that form whenever it says the - * same thing. The registered `iss`, `sub`, and `jti` claims are read and dropped; any - * other field fails verification. + * same thing. A role may instead be `{"path": [...], "rate": bps}`, which parses into + * `publish_rate` or `subscribe_rate` beside the patterns; a capped role is never written + * as a prefix, since an older verifier would drop the cap. The registered `iss`, `sub`, + * and `jti` claims are read and dropped; any other field fails verification. */ export const ClaimsSchema = z .pipe( @@ -105,7 +98,7 @@ export const ClaimsSchema = z put: z.optional(PrefixListSchema), get: z.optional(PrefixListSchema), }), - z.transform(({ iss: _iss, sub: _sub, jti: _jti, ...claims }, ctx) => decodeGrants(claims, ctx)), + z.transform(({ iss: _iss, sub: _sub, jti: _jti, ...claims }, ctx) => decodeRoles(decodeGrants(claims, ctx))), ) .check( // Emptiness, not just presence: `publish: []` grants nothing, and the Rust crate diff --git a/js/auth/src/contract.test.ts b/js/auth/src/contract.test.ts index 5f96d97d8c7..dbe867039eb 100644 --- a/js/auth/src/contract.test.ts +++ b/js/auth/src/contract.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { GrantSchema, RefusalSchema, RequestSchema, TokenSchema } from "./contract.ts"; +import { encodeGrant, GrantSchema, RefusalSchema, RequestSchema, TokenSchema } from "./contract.ts"; // The fixtures below are what the Rust `moq_auth::Request` and `Grant` serialize to, // so a server written against these schemas reads what a relay sends. @@ -125,6 +125,14 @@ test("a grant round trips and is validated", () => { expect(() => GrantSchema.parse({ publish: ["**"], label: "project/*" })).toThrow(/invalid path/); }); +test("a capped grant answers with its cap nested in the role", () => { + const grant = GrantSchema.parse({ publish: { path: ["alice/**"], rate: 8_000_000 }, subscribe: ["**"] }); + expect(grant).toMatchObject({ publish: ["alice/**"], publish_rate: 8_000_000, subscribe: ["**"] }); + expect(encodeGrant(grant)).toEqual({ publish: { path: ["alice/**"], rate: 8_000_000 }, subscribe: ["**"] }); + // A cap alone names nothing. + expect(() => encodeGrant({ publish_rate: 8000 })).toThrow(/name something/); +}); + test("a tier a metrics label would escape is refused", () => { for (const tier of ['a"b', "a\\b", "a\nb", "/edge", "a//b"]) { expect(() => GrantSchema.parse({ publish: ["**"], tier })).toThrow(/invalid tier/); diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index 93d4693566d..f472af26764 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -9,7 +9,7 @@ import { Pattern } from "@moq/pattern"; import * as z from "@zod/mini"; -import { PatternListSchema } from "./claims.ts"; +import { decodeRoles, encodeRoles, WireRoleSchema } from "./wire.ts"; const PathSchema = z.string().check( z.refine( @@ -166,34 +166,41 @@ export type Request = z.infer; * A 2xx carrying one of these admits; anything else refuses. A grant that names * nothing is a refusal too, and one that asks to be revalidated must say when it * expires, so an outage always has a bound the server chose. + * + * A role may be `{"path": [...], "rate": bps}`, which parses into `publish_rate` or + * `subscribe_rate` beside the patterns. Answer with {@link encodeGrant}, which writes a + * cap back into its role; a bare `publish_rate` field is ignored by the relay. */ export const GrantSchema = z - .object({ - /** Patterns the session may publish, relative to the root. */ - publish: z.optional(PatternListSchema), - /** Patterns the session may subscribe to, relative to the root. */ - subscribe: z.optional(PatternListSchema), - /** The path the patterns are relative to, replacing the dialed one. Absent means the dialed path. */ - root: z.optional(z.string()), - /** Subtrees read from elsewhere: each path, relative to the root, resolves at the absolute path it maps to. Read-only. */ - mounts: z.optional(z.record(z.string(), z.string())), - /** When the session closes, as whole unix seconds. */ - expires: z.optional(z.int()), - /** How long until the relay asks again, in whole seconds. Zero would be a tight loop. */ - revalidate: z.optional(z.int().check(z.positive())), - /** The stats tier this session's traffic is bucketed under: `/`-joined segments of ASCII letters, digits, `.`, `_`, and `-`. */ - tier: z.optional(TierSchema), - /** The literal path this session's stats record under, independent of its root. Absent records under the empty label. */ - label: z.optional(PathSchema), - /** - * The session is a cluster peer (another relay): what it announces entered the cluster elsewhere, - * and it discovers hidden routes without asking, for peers that predate the opt-in. - * A client certificate alone is not. - */ - peer: z.optional(z.boolean()), - /** The peer is upstream: the relay never offers it a route learned from another upstream. Requires `peer`. */ - upstream: z.optional(z.boolean()), - }) + .pipe( + z.object({ + /** Patterns the session may publish, relative to the root, optionally with a bitrate cap. */ + publish: z.optional(WireRoleSchema), + /** Patterns the session may subscribe to, relative to the root, optionally with a bitrate cap. */ + subscribe: z.optional(WireRoleSchema), + /** The path the patterns are relative to, replacing the dialed one. Absent means the dialed path. */ + root: z.optional(z.string()), + /** Subtrees read from elsewhere: each path, relative to the root, resolves at the absolute path it maps to. Read-only. */ + mounts: z.optional(z.record(z.string(), z.string())), + /** When the session closes, as whole unix seconds. */ + expires: z.optional(z.int()), + /** How long until the relay asks again, in whole seconds. Zero would be a tight loop. */ + revalidate: z.optional(z.int().check(z.positive())), + /** The stats tier this session's traffic is bucketed under: `/`-joined segments of ASCII letters, digits, `.`, `_`, and `-`. */ + tier: z.optional(TierSchema), + /** The literal path this session's stats record under, independent of its root. Absent records under the empty label. */ + label: z.optional(PathSchema), + /** + * The session is a cluster peer (another relay): what it announces entered the cluster elsewhere, + * and it discovers hidden routes without asking, for peers that predate the opt-in. + * A client certificate alone is not. + */ + peer: z.optional(z.boolean()), + /** The peer is upstream: the relay never offers it a route learned from another upstream. Requires `peer`. */ + upstream: z.optional(z.boolean()), + }), + z.transform(decodeRoles), + ) .check( z.refine((grant) => (grant.publish?.length ?? 0) > 0 || (grant.subscribe?.length ?? 0) > 0, { message: "a grant must name something", @@ -208,6 +215,17 @@ export const GrantSchema = z /** A value parsed by {@link GrantSchema}. */ export type Grant = z.infer; +/** + * The JSON an auth server answers with for `grant`, writing each capped role as + * `{"path": [...], "rate": bps}`. Throws on a grant {@link GrantSchema} would refuse. + */ +export function encodeGrant(grant: Grant): z.input { + const { publish, publish_rate, subscribe, subscribe_rate, ...rest } = grant; + const wire = { ...encodeRoles({ publish, publish_rate, subscribe, subscribe_rate }), ...rest }; + GrantSchema.parse(wire); + return wire; +} + /** The reason and verified attribution an auth server returns with HTTP 403. */ export const RefusalSchema = z.object({ /** Unknown reason strings read as `refused`. */ diff --git a/js/auth/src/interop.test.ts b/js/auth/src/interop.test.ts index b7927746d82..d9d71299694 100644 --- a/js/auth/src/interop.test.ts +++ b/js/auth/src/interop.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { authorize } from "./claims.ts"; -import { GrantSchema, RequestSchema } from "./contract.ts"; +import { encodeGrant, GrantSchema, RequestSchema } from "./contract.ts"; import { Key } from "./key.ts"; import { KeySet } from "./set.ts"; @@ -302,6 +302,6 @@ describe("Rust-serialized contract", () => { peer: true, upstream: true, }); - expect(JSON.stringify(grant)).toBe(RUST_GRANT); + expect(JSON.stringify(encodeGrant(grant))).toBe(RUST_GRANT); }); }); diff --git a/js/auth/src/key.test.ts b/js/auth/src/key.test.ts index 86149e7d55b..1ae15cd4389 100644 --- a/js/auth/src/key.test.ts +++ b/js/auth/src/key.test.ts @@ -70,7 +70,8 @@ test("verify refuses unsafe signed payloads without dropping their restrictions" { publish: ["**"], exp: 1000 }, { publish: ["**"], nbf: 1001 }, { publish: ["**"], subscribe: { path: "alice/**", rate: 8000 } }, - { publish: { path: ["alice/**"], rate: 8000 }, subscribe: ["**"] }, + { publish: { path: ["alice/**"], rate: 0 }, subscribe: ["**"] }, + { publish: { path: ["alice/**"] }, subscribe: ["**"] }, ]) { await expect(Key.verify(key, await signed(payload))).rejects.toThrow(); } @@ -831,6 +832,27 @@ test("key scope is enforced when signing and verifying", async () => { await expect(Key.verify(scoped, forged)).rejects.toThrow("exceed the key scope"); }); +test("caps survive signing, and a key scope bounds only their paths", async () => { + const key: Key = { ...Key.parse(encodeJwk(testKey)), scope: { root: "room", publish: ["**"], subscribe: ["**"] } }; + const token = await Key.sign(key, { + root: "room", + publish: { path: ["alice/**"], rate: 8000 }, + subscribe: { path: ["bob/**"], rate: 16000 }, + }); + const claims = await Key.verify(key, token); + expect(claims).toMatchObject({ + publish: ["alice/**"], + publish_rate: 8000, + subscribe: ["bob/**"], + subscribe_rate: 16000, + }); + + // The decoded claims sign again with their caps. + const resigned = await Key.verify(key, await Key.sign(key, claims)); + expect(resigned).toMatchObject({ publish_rate: 8000, subscribe_rate: 16000 }); + await expect(Key.sign(key, { ...claims, publish_rate: 0 })).rejects.toThrow(/rate must be a positive integer/); +}); + test("key scope treats absolute and rooted grants alike", async () => { const scoped: Key = { ...Key.parse(encodeJwk(testKey)), scope: { root: "project", publish: ["live/**"] } }; diff --git a/js/auth/src/key.ts b/js/auth/src/key.ts index 502b56aeb0a..7b3d3945416 100644 --- a/js/auth/src/key.ts +++ b/js/auth/src/key.ts @@ -4,7 +4,7 @@ import * as jose from "jose"; import { type Algorithm, AlgorithmSchema } from "./algorithm.ts"; import { type Claims, ClaimsSchema, ScopeSchema, scopeAllows } from "./claims.ts"; import * as Path from "./path.ts"; -import { encodeGrants } from "./wire.ts"; +import { encodeGrants, nestRates } from "./wire.ts"; /** * A validated key identifier (kid). At most 128 alphanumeric, hyphen, or underscore characters. @@ -194,18 +194,25 @@ function parse(jwk: string): Key { * `iat` is written only when the claims carry one, so an unset field stays off the * wire rather than being stamped with the current time. * + * Takes the wire shape or the decoded {@link Claims}, so a cap may be either a capped + * role or a `publish_rate`/`subscribe_rate` beside it. + * * Throws on a root spelled with an empty segment, a `\` escaping anything but `\` or * `/`, or more than 4096 bytes. Verifying stays lenient on the empty segments, so tokens * minted before stay valid. */ -async function sign(key: Key, claims: z.input): Promise { +async function sign( + key: Key, + claims: z.input & { publish_rate?: number; subscribe_rate?: number }, +): Promise { ensureOperationSupported(key, "sign"); // Scope-check and sign what the schema parsed, never the raw input: an untyped - // caller could pass legacy `put`/`get` fields the scope check would not see. + // caller could pass legacy `put`/`get` fields the scope check would not see. The + // caps of decoded claims, as `verify` returns them, nest back into their roles first. let parsed: Claims; try { - parsed = ClaimsSchema.parse(claims); + parsed = ClaimsSchema.parse(nestRates(claims)); } catch (error) { throw new Error(`Invalid claims: ${error instanceof Error ? error.message : "unknown error"}`); } diff --git a/js/auth/src/set.ts b/js/auth/src/set.ts index 7472f135dc5..5f95fa789d0 100644 --- a/js/auth/src/set.ts +++ b/js/auth/src/set.ts @@ -9,7 +9,7 @@ import * as z from "@zod/mini"; import * as jose from "jose"; -import type { Claims, ClaimsSchema } from "./claims.ts"; +import type { Claims } from "./claims.ts"; import { Key, KeySchema, type PublicKey } from "./key.ts"; /** @@ -58,7 +58,7 @@ function find(set: KeySet, kid: string): Key | undefined { } /** Sign the claims with the first key that permits signing and contains signing material. */ -async function sign(set: KeySet, claims: z.input): Promise { +async function sign(set: KeySet, claims: Parameters[1]): Promise { const key = set.keys.find((key) => key.key_ops.includes("sign") && (key.kty === "oct" || key.d !== undefined)); if (!key) throw new Error("Cannot find signing key"); return await Key.sign(key, claims); diff --git a/js/auth/src/wire.ts b/js/auth/src/wire.ts index 798f840565a..673f0aaff92 100644 --- a/js/auth/src/wire.ts +++ b/js/auth/src/wire.ts @@ -1,21 +1,64 @@ /** - * The JSON encoding of the grants in claims and key scopes. + * The JSON encoding of the grants in claims, key scopes, and auth server grants. * * Grants were once prefix lists named `put` and `get`, and every published `moq-token` - * reader still expects them. Must stay in lockstep with `wire.rs` in the Rust - * `moq-auth` crate. + * reader still expects them. A role with a bitrate cap is written as an object, + * `{"path": [...], "rate": bps}`, which every older reader refuses rather than drop the + * cap, so a capped role is never written as prefixes. Must stay in lockstep with + * `wire.rs` in the Rust `moq-auth` crate. * * @module */ import { Pattern, Patterns } from "@moq/pattern"; +import * as z from "@zod/mini"; -/** One grant in whichever encoding it arrived: legacy `put`/`get` prefix lists, or patterns. */ +/** A list of pattern texts, each validated by `Pattern.parse`. */ +export const PatternListSchema = z.array( + z.string().check( + z.refine( + (text) => { + try { + Pattern.parse(text); + return true; + } catch { + return false; + } + }, + { message: "invalid path pattern" }, + ), + ), +); + +// At most 2^53 - 1, so every JSON reader holds the cap exactly. +const RateSchema = z.number().check( + z.refine((bps) => Number.isSafeInteger(bps) && bps > 0, { + message: "rate must be a positive integer of at most 9007199254740991 bits per second", + }), +); + +/** + * One role in a claims or grant document: a list of patterns, or exactly `path` and + * `rate`, so a misspelled cap is refused rather than read as none. A key scope bounds + * paths, never bitrate, so it takes only the list. + */ +export const WireRoleSchema = z.union([ + PatternListSchema, + z.strictObject({ path: PatternListSchema, rate: RateSchema }), +]); + +/** One role as written. */ +type WireRole = z.output; + +/** The decoded roles: pattern lists, each with an optional cap in bits per second. */ +type Roles = { publish?: string[]; publish_rate?: number; subscribe?: string[]; subscribe_rate?: number }; + +/** One grant in whichever encoding it arrived: legacy `put`/`get` prefix lists, or roles. */ type WireGrants = { put?: string | string[]; get?: string | string[]; - publish?: string[]; - subscribe?: string[]; + publish?: WireRole; + subscribe?: WireRole; }; /** @@ -25,10 +68,7 @@ type WireGrants = { * encodings, and a legacy prefix containing `*` is refused: it had no wildcards, so * reading one now would silently widen the grant. */ -export function decodeGrants( - wire: T, - ctx: { issues: unknown[] }, -): Omit & { publish?: string[]; subscribe?: string[] } { +export function decodeGrants(wire: T, ctx: { issues: unknown[] }): Omit { const { put, get, ...rest } = wire; if (put === undefined && get === undefined) return rest; @@ -51,16 +91,68 @@ export function decodeGrants( } } +/** Split each capped role into its patterns and a `_rate` field beside them. */ +export function decodeRoles( + wire: T, +): Omit & { + // Spelled out rather than `Roles`, so the public `Claims` and `Grant` types document each field. + /** Patterns the holder may publish, relative to the root. */ + publish?: string[]; + /** The most the holder may publish, in bits per second, or no cap when absent. */ + publish_rate?: number; + /** Patterns the holder may subscribe to, relative to the root. */ + subscribe?: string[]; + /** The most the holder may receive from its subscriptions, in bits per second, or no cap when absent. */ + subscribe_rate?: number; +} { + const { publish, subscribe, ...rest } = wire; + const split = (role: WireRole | undefined): { path?: string[]; rate?: number } => + role === undefined || Array.isArray(role) ? { path: role } : role; + const pub = split(publish); + const sub = split(subscribe); + return { + ...(pub.path && { publish: pub.path }), + ...(pub.rate !== undefined && { publish_rate: pub.rate }), + ...(sub.path && { subscribe: sub.path }), + ...(sub.rate !== undefined && { subscribe_rate: sub.rate }), + ...rest, + }; +} + +/** + * Nest each `_rate` field back into its role, so the decoded claims `Key.verify` + * returns parse again. A role already in the capped form is left for the schema to refuse. + */ +export function nestRates< + T extends { publish?: unknown; subscribe?: unknown; publish_rate?: number; subscribe_rate?: number }, +>(claims: T) { + const { publish_rate, subscribe_rate, ...rest } = claims; + return { + ...rest, + ...(publish_rate !== undefined && { publish: { path: rest.publish ?? [], rate: publish_rate } }), + ...(subscribe_rate !== undefined && { subscribe: { path: rest.subscribe ?? [], rate: subscribe_rate } }), + }; +} + +/** Write each role as its patterns, or as the capped object when it has a rate; an empty uncapped role is omitted. */ +export function encodeRoles(roles: Roles): { publish?: WireRole; subscribe?: WireRole } { + const role = (path: string[] | undefined, rate: number | undefined): WireRole | undefined => + rate !== undefined ? { path: path ?? [], rate } : path?.length ? path : undefined; + const publish = role(roles.publish, roles.publish_rate); + const subscribe = role(roles.subscribe, roles.subscribe_rate); + return { ...(publish && { publish }), ...(subscribe && { subscribe }) }; +} + /** - * Write grants the legacy way when every pattern is a subtree, so every published - * `moq-token` reader agrees on what they grant. Anything a prefix can't say is written - * as `publish`/`subscribe`, which an older reader refuses rather than misreads. + * Write grants the legacy way when every pattern is a subtree and no role is capped, so + * every published `moq-token` reader agrees on what they grant. Anything a prefix can't + * say is written as `publish`/`subscribe`, which an older reader refuses rather than misreads. */ -export function encodeGrants( - grants: T, -): Omit & WireGrants { - const { publish, subscribe, ...rest } = grants; - const prefixes = (texts: string[] | undefined) => { +export function encodeGrants(grants: T): Omit & WireGrants { + const { publish, publish_rate, subscribe, subscribe_rate, ...rest } = grants; + const prefixes = (texts: string[] | undefined, rate: number | undefined) => { + // A cap has no prefix form, so one moves the whole document to patterns. + if (rate !== undefined) return undefined; const out: string[] = []; for (const pattern of new Patterns((texts ?? []).map((text) => Pattern.parse(text)))) { const prefix = pattern.asPrefix(); @@ -70,14 +162,10 @@ export function encodeGrants, + /// Patterns the holder may subscribe to, relative to the root. pub subscribe: Patterns, + /// The most the holder may receive from its subscriptions, or no cap when absent. + pub subscribe_rate: Option, + /// The expiration time of the token as a unix timestamp (`exp`). pub expires: Option, @@ -155,6 +163,19 @@ impl Claims { self } + /// Cap what the holder may publish, in bits per second; `None` lifts the cap. + pub fn with_publish_rate(mut self, rate: impl Into>) -> Self { + self.publish_rate = rate.into(); + self + } + + /// Cap what the holder may receive from its subscriptions, in bits per second; `None` + /// lifts the cap. + pub fn with_subscribe_rate(mut self, rate: impl Into>) -> Self { + self.subscribe_rate = rate.into(); + self + } + /// Expire the token at this time. Enforced by [`Key::verify`](crate::Key::verify). /// /// Accepts an `Option` so a caller can pass one through without unwrapping it. @@ -248,6 +269,8 @@ mod tests { fn create_test_claims() -> Claims { Claims { + publish_rate: None, + subscribe_rate: None, root: "test-path".to_string(), publish: patterns(&["test-pub/**"]), subscribe: patterns(&["test-sub/**"]), diff --git a/rs/moq-auth/src/grant.rs b/rs/moq-auth/src/grant.rs index 3ad16c15820..7c461bcd754 100644 --- a/rs/moq-auth/src/grant.rs +++ b/rs/moq-auth/src/grant.rs @@ -1,7 +1,7 @@ +use crate::Rate; use kio::clock::Wall; use moq_pattern::Patterns; use serde::{Deserialize, Serialize}; -use serde_with::{DurationSeconds, serde_as}; use std::collections::BTreeMap; use std::time::Duration; @@ -11,20 +11,25 @@ use std::time::Duration; /// nothing is a refusal too, and one that asks to be revalidated must say when it /// expires, so an outage always has a bound the server chose. [`validate`](Self::validate) /// checks these once at the boundary. -#[serde_as] -#[serde_with::skip_serializing_none] +/// +/// A role with a cap is written `{"path": [...], "rate": bps}`, which every older +/// reader refuses rather than drop the cap. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(default)] +#[serde(from = "crate::wire::Grant", into = "crate::wire::Grant")] #[non_exhaustive] pub struct Grant { /// Patterns the session may publish, relative to the root. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub publish: Patterns, + /// The most the session may publish, or no cap when absent. + pub publish_rate: Option, + /// Patterns the session may subscribe to, relative to the root. - #[serde(skip_serializing_if = "Patterns::is_empty")] pub subscribe: Patterns, + /// The most the session may receive from its subscriptions, or no cap when absent. + pub subscribe_rate: Option, + /// The path the patterns are relative to, replacing the dialed one. This is how a /// server aliases a slug to a canonical id. Absent means the dialed path. pub root: Option, @@ -32,15 +37,12 @@ pub struct Grant { /// Subtrees the session reads from elsewhere: each path, relative to the root, /// resolves at the absolute path it maps to. Read-only: nothing is published /// beneath one. The patterns still name the path relative to the root. - #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub mounts: BTreeMap, /// When the session closes, as unix seconds. - #[serde_as(as = "Option")] pub expires: Option, /// How long until the relay asks again, in seconds. - #[serde_as(as = "Option>")] pub revalidate: Option, /// The stats tier this session's traffic is bucketed under: `/`-joined segments of @@ -53,13 +55,11 @@ pub struct Grant { /// The session is a cluster peer (another relay): what it announces entered /// the cluster elsewhere, not here, and it discovers hidden routes without /// asking, for peers that predate the opt-in. A client certificate alone is not. - #[serde(skip_serializing_if = "std::ops::Not::not")] pub peer: bool, /// The cluster peer is upstream: the relay never offers it a route learned /// from another upstream peer, so it never carries traffic between two. /// Requires `peer`. - #[serde(skip_serializing_if = "std::ops::Not::not")] pub upstream: bool, } @@ -125,6 +125,8 @@ mod tests { #[test] fn round_trips_in_seconds() { let grant = Grant { + publish_rate: None, + subscribe_rate: None, publish: patterns(&["alice/**"]), subscribe: patterns(&["**"]), root: Some("pid/room".into()), @@ -150,6 +152,8 @@ mod tests { #[test] fn serializes_to_the_cross_language_vector() { let grant = Grant { + publish_rate: None, + subscribe_rate: None, publish: patterns(&["alice/**"]), subscribe: patterns(&["**"]), root: Some("pid/room".into()), diff --git a/rs/moq-auth/src/key.rs b/rs/moq-auth/src/key.rs index cb2d4eb318e..0fbdb8f583e 100644 --- a/rs/moq-auth/src/key.rs +++ b/rs/moq-auth/src/key.rs @@ -779,6 +779,8 @@ mod tests { fn create_test_claims() -> Claims { Claims { + publish_rate: None, + subscribe_rate: None, root: "test-path".to_string(), publish: patterns(&["test-pub/**"]), subscribe: patterns(&["test-sub/**"]), @@ -1058,6 +1060,8 @@ mod tests { fn test_key_sign_invalid_claims() { let key = create_test_key(); let invalid_claims = Claims { + publish_rate: None, + subscribe_rate: None, root: "test-path".to_string(), publish: patterns(&[]), subscribe: patterns(&[]), @@ -1236,6 +1240,8 @@ mod tests { fn test_key_verify_token_without_exp() { let key = create_test_key(); let claims = Claims { + publish_rate: None, + subscribe_rate: None, root: "test-path".to_string(), publish: patterns(&["**"]), subscribe: patterns(&["**"]), @@ -1256,6 +1262,8 @@ mod tests { fn test_key_round_trip() { let key = create_test_key(); let original_claims = Claims { + publish_rate: None, + subscribe_rate: None, root: "test-path".to_string(), publish: patterns(&["test-pub/**"]), subscribe: patterns(&["test-sub/**"]), diff --git a/rs/moq-auth/src/lib.rs b/rs/moq-auth/src/lib.rs index 38183768fff..bb48a08b43d 100644 --- a/rs/moq-auth/src/lib.rs +++ b/rs/moq-auth/src/lib.rs @@ -16,7 +16,8 @@ //! its presenter key, that another client verifies with [`KeySet::to_peer_set`]. //! //! Grants and claims name paths with [`Pattern`]s from `moq-pattern`, re-exported here: -//! `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. +//! `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. Each role may also +//! carry a bitrate cap, a [`Rate`]. #![warn(missing_docs)] @@ -30,6 +31,7 @@ mod key; mod key_id; mod path; mod peer; +mod rate; mod request; mod set; mod wire; @@ -55,5 +57,6 @@ pub use key::*; pub use key_id::*; pub use moq_pattern::{InvalidPattern, Pattern, Patterns, Segment, Specificity}; pub use peer::*; +pub use rate::*; pub use request::*; pub use set::*; diff --git a/rs/moq-auth/src/rate.rs b/rs/moq-auth/src/rate.rs new file mode 100644 index 00000000000..62cb5b027e8 --- /dev/null +++ b/rs/moq-auth/src/rate.rs @@ -0,0 +1,110 @@ +use std::num::NonZeroU64; + +/// A bitrate cap on one direction of a session, in bits per second. +/// +/// Positive, and at most 2^53 - 1 so every JSON reader holds it exactly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "u64", into = "u64")] +pub struct Rate(NonZeroU64); + +/// A bitrate that is zero or past JSON's exact integer range. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("rate must be a positive integer of at most 9007199254740991 bits per second")] +pub struct InvalidRate; + +impl Rate { + /// The largest rate JSON carries exactly: 2^53 - 1 bits per second. + pub const MAX: Self = Self(NonZeroU64::new((1 << 53) - 1).expect("non-zero")); + + /// A cap of `bps` bits per second. + pub fn from_bps(bps: u64) -> Result { + match NonZeroU64::new(bps) { + Some(bps) if bps <= Self::MAX.0 => Ok(Self(bps)), + _ => Err(InvalidRate), + } + } + + /// This cap in bits per second. + pub fn as_bps(self) -> u64 { + self.0.get() + } +} + +impl TryFrom for Rate { + type Error = InvalidRate; + + fn try_from(bps: u64) -> Result { + Self::from_bps(bps) + } +} + +impl From for u64 { + fn from(rate: Rate) -> Self { + rate.as_bps() + } +} + +impl std::fmt::Display for Rate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}bps", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuses_zero_and_inexact_rates() { + assert_eq!(Rate::from_bps(0), Err(InvalidRate)); + assert_eq!(Rate::from_bps(1 << 53), Err(InvalidRate)); + assert_eq!(Rate::from_bps((1 << 53) - 1), Ok(Rate::MAX)); + for json in ["0", "-1", "1.5", "9007199254740992", "null", "\"8000\""] { + assert!(serde_json::from_str::(json).is_err(), "{json}"); + } + assert_eq!(serde_json::from_str::("8000").unwrap().as_bps(), 8000); + } + + /// The vectors `js/auth` reads too, so both languages agree on the capped wire shape. + #[test] + fn shared_wire_vectors() { + let vectors: serde_json::Value = serde_json::from_str(include_str!("../rate.json")).unwrap(); + for row in vectors["valid"].as_array().unwrap() { + let claims: crate::Claims = serde_json::from_value(row["input"].clone()).unwrap(); + claims.validate().unwrap(); + assert_eq!(serde_json::to_value(&claims).unwrap(), row["claims"]); + let grant: crate::Grant = serde_json::from_value(row["input"].clone()).unwrap(); + grant.validate().unwrap(); + assert_eq!(serde_json::to_value(&grant).unwrap(), row["grant"]); + } + for input in vectors["invalid"].as_array().unwrap() { + assert!( + serde_json::from_value::(input.clone()).is_err(), + "{input}" + ); + assert!( + serde_json::from_value::(input.clone()).is_err(), + "{input}" + ); + } + } + + /// A cap survives signing and rebasing, and a key scope bounds paths, never bitrate. + #[test] + fn caps_survive_signing_but_scopes_refuse_them() { + let wire = serde_json::json!({ + "root": "room", + "publish": {"path": ["alice/**"], "rate": 8000}, + "subscribe": {"path": ["bob/**"], "rate": 16000}, + }); + assert!(serde_json::from_value::(wire.clone()).is_err()); + let claims: crate::Claims = serde_json::from_value(wire).unwrap(); + let key = crate::Key::generate(crate::Algorithm::HS256, None).unwrap(); + let claims = key.verify(&key.sign(&claims).unwrap()).unwrap(); + assert_eq!(claims.publish_rate, Some(Rate::from_bps(8000).unwrap())); + assert_eq!(claims.subscribe_rate, Some(Rate::from_bps(16000).unwrap())); + let scope: crate::Scope = + serde_json::from_str(r#"{"root":"room","publish":["**"],"subscribe":["**"]}"#).unwrap(); + assert!(scope.allows(&claims)); + } +} diff --git a/rs/moq-auth/src/serve.rs b/rs/moq-auth/src/serve.rs index b59a4832ebf..bde4808d75e 100644 --- a/rs/moq-auth/src/serve.rs +++ b/rs/moq-auth/src/serve.rs @@ -233,7 +233,7 @@ impl Policy { if jwt.is_some() && request.tls.is_some() { return Err(Denial::TokenAndCertificate); } - let (permissions, expires, certificate, root) = if let Some(jwt) = jwt { + let (permissions, rates, expires, certificate, root) = if let Some(jwt) = jwt { let claims = self.verify(&jwt).await?; let permissions = claims.authorize(&request.path).map_err(|err| match err { crate::Error::RootMismatch(path) => Denial::RootMismatch { @@ -246,20 +246,28 @@ impl Policy { }, other => Denial::InvalidToken(other.to_string()), })?; - (permissions, claims.expires, false, Some(claims.root)) + let rates = (claims.publish_rate, claims.subscribe_rate); + (permissions, rates, claims.expires, false, Some(claims.root)) } else if let Some(peer) = &request.tls { if self.mtls.is_empty() { return Err(Denial::NoMtlsGrant); } - (authorize(&self.mtls, &request.path)?, peer.expires, true, None) + ( + authorize(&self.mtls, &request.path)?, + (None, None), + peer.expires, + true, + None, + ) } else { if self.public.is_empty() { return Err(Denial::NoPublicGrant); } - (authorize(&self.public, &request.path)?, None, false, None) + (authorize(&self.public, &request.path)?, (None, None), None, false, None) }; let mut grant = Grant::new(permissions.publish, permissions.subscribe); + (grant.publish_rate, grant.subscribe_rate) = rates; grant.expires = match (expires, self.expires) { (Some(expires), _) => Some(expires), (None, Some(bound)) => Some(kio::clock::wall().checked_add(bound).ok_or(Denial::ExpiresOutOfRange)?), diff --git a/rs/moq-auth/src/set.rs b/rs/moq-auth/src/set.rs index 3c3cd6d99e5..86f83d75943 100644 --- a/rs/moq-auth/src/set.rs +++ b/rs/moq-auth/src/set.rs @@ -149,6 +149,8 @@ mod tests { fn create_test_claims() -> Claims { Claims { + publish_rate: None, + subscribe_rate: None, root: "test-path".to_string(), publish: patterns(&["test-pub/**"]), subscribe: patterns(&["test-sub/**"]), diff --git a/rs/moq-auth/src/wire.rs b/rs/moq-auth/src/wire.rs index 8309bdb5b8d..1a0e72de723 100644 --- a/rs/moq-auth/src/wire.rs +++ b/rs/moq-auth/src/wire.rs @@ -6,10 +6,17 @@ //! reader, old or new, agrees on what they grant. Anything a prefix can't say is written //! as `publish` and `subscribe`, which an old reader sees as granting nothing and //! refuses. Both encodings are read; one document never mixes them. +//! +//! A role with a bitrate cap is written as an object, `{"path": [...], "rate": bps}`, +//! which every older reader refuses rather than drop the cap, so a capped role is never +//! written as prefixes. +use crate::Rate; use moq_pattern::{Pattern, Patterns}; use serde::{Deserialize, Deserializer, Serialize}; -use serde_with::serde_as; +use serde_with::{DurationSeconds, serde_as}; +use std::collections::BTreeMap; +use std::time::Duration; /// The wire form of [`Claims`](crate::Claims). #[serde_with::skip_serializing_none] @@ -24,9 +31,9 @@ pub(crate) struct Claims { #[serde(deserialize_with = "present")] get: Option, #[serde(deserialize_with = "present")] - publish: Option, + publish: Option, #[serde(deserialize_with = "present")] - subscribe: Option, + subscribe: Option, #[serde_as(as = "Option")] exp: Option, #[serde_as(as = "Option")] @@ -46,7 +53,10 @@ pub(crate) struct Claims { impl From for Claims { fn from(claims: crate::Claims) -> Self { - let grants = Grants::encode(claims.publish, claims.subscribe); + let grants = Grants::encode( + Role::new(claims.publish, claims.publish_rate), + Role::new(claims.subscribe, claims.subscribe_rate), + ); Self { root: claims.root, put: grants.put.map(Prefixes::Many), @@ -76,8 +86,10 @@ impl TryFrom for crate::Claims { let (publish, subscribe) = grants.decode()?; Ok(Self { root: wire.root, - publish, - subscribe, + publish: publish.path, + publish_rate: publish.rate, + subscribe: subscribe.path, + subscribe_rate: subscribe.rate, expires: wire.exp, issued: wire.iat, not_before: wire.nbf, @@ -104,13 +116,14 @@ pub(crate) struct Scope { impl From for Scope { fn from(scope: crate::Scope) -> Self { - let grants = Grants::encode(scope.publish, scope.subscribe); + let grants = Grants::encode(Role::new(scope.publish, None), Role::new(scope.subscribe, None)); + let paths = |role: Option| role.map(|role| role.path); Self { root: scope.root, put: grants.put, get: grants.get, - publish: grants.publish, - subscribe: grants.subscribe, + publish: paths(grants.publish), + subscribe: paths(grants.subscribe), } } } @@ -119,17 +132,18 @@ impl TryFrom for crate::Scope { type Error = String; fn try_from(wire: Scope) -> Result { + // A key scope bounds paths, never bitrate, so its roles are only lists. let grants = Grants { put: wire.put, get: wire.get, - publish: wire.publish, - subscribe: wire.subscribe, + publish: wire.publish.map(|path| Role::new(path, None)), + subscribe: wire.subscribe.map(|path| Role::new(path, None)), }; let (publish, subscribe) = grants.decode()?; Ok(Self { root: wire.root, - publish, - subscribe, + publish: publish.path, + subscribe: subscribe.path, }) } } @@ -161,19 +175,23 @@ fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> R struct Grants { put: Option>, get: Option>, - publish: Option, - subscribe: Option, + publish: Option, + subscribe: Option, } impl Grants { - fn encode(publish: Patterns, subscribe: Patterns) -> Self { - let prefixes = |patterns: &Patterns| -> Option> { - patterns + fn encode(publish: Role, subscribe: Role) -> Self { + // A cap has no prefix form, so one moves the whole document to patterns. + let prefixes = |role: &Role| -> Option> { + if role.rate.is_some() { + return None; + } + role.path .iter() .map(|pattern| pattern.as_prefix().map(str::to_string)) .collect() }; - let some = |patterns: Patterns| (!patterns.is_empty()).then_some(patterns); + let some = |role: Role| (!role.is_empty()).then_some(role); match (prefixes(&publish), prefixes(&subscribe)) { (Some(put), Some(get)) => Self { @@ -191,7 +209,7 @@ impl Grants { } } - fn decode(self) -> Result<(Patterns, Patterns), String> { + fn decode(self) -> Result<(Role, Role), String> { let legacy = self.put.is_some() || self.get.is_some(); if !legacy { return Ok((self.publish.unwrap_or_default(), self.subscribe.unwrap_or_default())); @@ -200,13 +218,139 @@ impl Grants { return Err("mixes the legacy put/get fields with publish/subscribe".to_string()); } - let subtrees = |prefixes: Option>| -> Result { - prefixes + let subtrees = |prefixes: Option>| -> Result { + let path = prefixes .unwrap_or_default() .iter() .map(|prefix| Pattern::subtree(prefix).map_err(|err| format!("legacy prefix {prefix:?}: {err}"))) - .collect() + .collect::>()?; + Ok(Role::new(path, None)) }; Ok((subtrees(self.put)?, subtrees(self.get)?)) } } + +/// One role on the wire: a list of patterns, or an object adding a bitrate cap. +/// +/// The object takes exactly `path` and `rate`, so a misspelled cap is refused rather +/// than read as none. +#[derive(Default)] +struct Role { + path: Patterns, + rate: Option, +} + +impl Role { + fn new(path: Patterns, rate: Option) -> Self { + Self { path, rate } + } + + fn is_empty(&self) -> bool { + self.path.is_empty() && self.rate.is_none() + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Capped

{ + path: P, + rate: Rate, +} + +impl Serialize for Role { + fn serialize(&self, serializer: S) -> Result { + match self.rate { + None => self.path.serialize(serializer), + Some(rate) => Capped { path: &self.path, rate }.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for Role { + fn deserialize>(deserializer: D) -> Result { + use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer}; + + // Dispatch on the shape, so a bad pattern or cap reports its own error rather + // than an untagged enum's "matched no variant". + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Role; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a list of path patterns, or an object of a `path` list and a `rate`") + } + + fn visit_seq>(self, seq: A) -> Result { + Ok(Role::new(Patterns::deserialize(SeqAccessDeserializer::new(seq))?, None)) + } + + fn visit_map>(self, map: A) -> Result { + let Capped { path, rate } = Capped::::deserialize(MapAccessDeserializer::new(map))?; + Ok(Role::new(path, Some(rate))) + } + } + deserializer.deserialize_any(Visitor) + } +} + +/// The wire form of [`Grant`](crate::Grant), whose roles may carry caps. A field it does +/// not know is ignored, so an auth server may answer more than this reader takes. +#[serde_as] +#[serde_with::skip_serializing_none] +#[derive(Serialize, Deserialize, Default)] +#[serde(default)] +pub(crate) struct Grant { + #[serde(skip_serializing_if = "Role::is_empty")] + publish: Role, + #[serde(skip_serializing_if = "Role::is_empty")] + subscribe: Role, + root: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + mounts: BTreeMap, + #[serde_as(as = "Option")] + expires: Option, + #[serde_as(as = "Option>")] + revalidate: Option, + tier: Option, + label: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + peer: bool, + #[serde(skip_serializing_if = "std::ops::Not::not")] + upstream: bool, +} + +impl From for Grant { + fn from(grant: crate::Grant) -> Self { + Self { + publish: Role::new(grant.publish, grant.publish_rate), + subscribe: Role::new(grant.subscribe, grant.subscribe_rate), + root: grant.root, + mounts: grant.mounts, + expires: grant.expires, + revalidate: grant.revalidate, + tier: grant.tier, + label: grant.label, + peer: grant.peer, + upstream: grant.upstream, + } + } +} + +impl From for crate::Grant { + fn from(wire: Grant) -> Self { + Self { + publish: wire.publish.path, + publish_rate: wire.publish.rate, + subscribe: wire.subscribe.path, + subscribe_rate: wire.subscribe.rate, + root: wire.root, + mounts: wire.mounts, + expires: wire.expires, + revalidate: wire.revalidate, + tier: wire.tier, + label: wire.label, + peer: wire.peer, + upstream: wire.upstream, + } + } +} diff --git a/rs/moq-auth/tests/released_token.rs b/rs/moq-auth/tests/released_token.rs index 67edd931a17..ec90fe30c0c 100644 --- a/rs/moq-auth/tests/released_token.rs +++ b/rs/moq-auth/tests/released_token.rs @@ -5,7 +5,6 @@ //! `publish`/`subscribe`, which 0.14 reads as granting nothing and refuses, rather //! than misreading an exact grant as a prefix. -use base64::Engine; use moq_auth::{Algorithm, Claims, Key}; fn patterns(texts: &[&str]) -> moq_auth::Patterns { @@ -71,34 +70,36 @@ fn patterns_keep_the_published_auth_wire_shape() { } } +/// A capped role is an object every published verifier refuses in full, so an old +/// relay never admits the paths while dropping the cap. #[test] -fn capped_objects_are_refused_in_full_by_current_and_published_auth() { +fn capped_roles_are_refused_in_full_by_published_auth() { let key = Key::generate(Algorithm::HS256, None).unwrap(); let published = moq_auth_published::Key::from_str(&key.to_str().unwrap()).unwrap(); let legacy = moq_token::Key::from_str(&key.to_str().unwrap()).unwrap(); - let jwk: serde_json::Value = serde_json::to_value(&key).unwrap(); - let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(jwk["k"].as_str().unwrap()) - .unwrap(); - let signing = jsonwebtoken::EncodingKey::from_secret(&secret); - for role in ["publish", "subscribe"] { - for path in [serde_json::json!("alice/**"), serde_json::json!(["alice/**"])] { - let mut payload = serde_json::json!({"publish":["**"],"subscribe":["**"]}); - payload[role] = serde_json::json!({"path":path,"rate":8_000_000}); - let token = jsonwebtoken::encode( - &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), - &payload, - &signing, - ) - .unwrap(); - assert!( - key.verify(&token).is_err(), - "must not discard the restriction: {payload}" - ); - assert!(published.verify(&token).is_err()); - assert!(legacy.verify(&token).is_err()); - assert!(serde_json::from_value::(payload.clone()).is_err()); - assert!(serde_json::from_value::(payload).is_err()); - } + let rate = moq_auth::Rate::from_bps(8_000_000).unwrap(); + for publish in [true, false] { + // Subtrees alone would be written as legacy prefixes, which a cap must not be. + let mut claims = Claims::default() + .with_publish(patterns(&["alice/**"])) + .with_subscribe(patterns(&["**"])); + claims = match publish { + true => claims.with_publish_rate(rate), + false => claims.with_subscribe_rate(rate), + }; + let token = key.sign(&claims).unwrap(); + let verified = key.verify(&token).unwrap(); + assert_eq!( + (verified.publish_rate, verified.subscribe_rate), + (claims.publish_rate, claims.subscribe_rate) + ); + assert!(published.verify(&token).is_err(), "{claims:?}"); + assert!(legacy.verify(&token).is_err(), "{claims:?}"); + + let mut grant = moq_auth::Grant::new(claims.publish.clone(), claims.subscribe.clone()); + (grant.publish_rate, grant.subscribe_rate) = (claims.publish_rate, claims.subscribe_rate); + let wire = serde_json::to_value(&grant).unwrap(); + assert_eq!(serde_json::from_value::(wire.clone()).unwrap(), grant); + assert!(serde_json::from_value::(wire).is_err()); } } diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 05326cc85f5..456e1513be5 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `transport::SETUP_RECEIVE_WINDOW`, the connection credit a server offers before it admits a session: enough for the SETUP and one in-band AUTH. + ## [0.3.10](https://github.com/moq-dev/moq/compare/moq-net-v0.3.9...moq-net-v0.3.10) - 2026-10-05 ### Fixed diff --git a/rs/moq-net/src/transport.rs b/rs/moq-net/src/transport.rs index 2420752f0c9..eb5aed715e1 100644 --- a/rs/moq-net/src/transport.rs +++ b/rs/moq-net/src/transport.rs @@ -152,6 +152,20 @@ pub const MIN_RECEIVE_WINDOW: u64 = { if message > segment { message } else { segment } }; +/// The connection receive window a server offers before it admits the session. +/// +/// It fits the SETUP and one in-band AUTH, each with its framing, which is all a client +/// sends before the reply. Credit cannot be withdrawn, so a session that started with +/// its full window could bank it and spend it after a lower window or a bitrate cap +/// arrives; the server opens the full window only once it admits the session. +pub const SETUP_RECEIVE_WINDOW: u64 = { + // A type and a size prefix, each a varint of at most 8 bytes. + const PREFIXES: u64 = 16; + let setup = crate::setup::MAX_SETUP_SIZE as u64 + PREFIXES; + let auth = ::MAX_SIZE as u64 + PREFIXES; + setup + auth +}; + /// The typed transport condition when no application code was received. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[non_exhaustive] diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 104c8b3d4d3..65be2e610b6 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- QUIC and WebTransport sessions on the tokio and io_uring listeners enforce a grant's bitrate caps: `publish_rate` paces what the relay receives and `subscribe_rate` what it sends. Unions take the highest cap and an uncapped grant lifts it; a re-checked cap reaches the live session. WebSocket, iroh, and HTTP refuse a capped grant at connect, in band, and at revalidation. Every QUIC session starts with only the SETUP's flow-control credit, opened once admitted. + ### Changed -- [**breaking**] Auth token roles hold path patterns directly, matching release. Unsupported object roles, including bitrate caps, are refused by auth parsing before admission. +- [**breaking**] Auth token roles hold path patterns directly, matching release; a capped role is enforced or refused, never dropped. - Auth requests distinguish `webtransport` from native `quic`. Upgrade auth servers before relays: older servers reject the new value. Rust and JavaScript auth parsers now map future transport names to `unknown`. - `listen.timeout` bounds auth admission as part of the handshake, replacing the fixed ten-second admission timeout, so an auth server answering `connect` late is admitted within the deadline and `"0"` waits through admission too. HTTP reads bound admission by the web listener's timeout. `Auth::admit` and `Cluster::admit` no longer time out on their own; the caller owns the deadline. In-band token verification keeps its ten-second bound. diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index f43f5ce4643..032b37c1b32 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -3,8 +3,11 @@ //! process answering [`Admissions`]. Nothing else admits anyone; a verified client //! certificate is reported to whoever decides as a fact. +mod caps; mod refresh; +pub(crate) use caps::{Caps, Pacer}; + use std::sync::Arc; use axum::http; @@ -439,6 +442,10 @@ pub struct Token { pub subscribe: moq_auth::Patterns, /// Patterns the session may publish, relative to `root`. pub publish: moq_auth::Patterns, + /// The most the session may publish, or no cap when absent. + pub publish_rate: Option, + /// The most the session may receive from its subscriptions, or no cap when absent. + pub subscribe_rate: Option, /// The tier this session's stats record under. pub tier: Tier, /// The path this session's stats record under, empty when the grant names none. @@ -478,6 +485,8 @@ impl Token { .map_err(invalid)?, subscribe: grant.subscribe.clone(), publish: grant.publish.clone(), + publish_rate: grant.publish_rate, + subscribe_rate: grant.subscribe_rate, tier: crate::configured_tier(grant.tier.clone()).map_err(invalid)?, label: Path::new(grant.label.as_deref().unwrap_or_default()).map_err(invalid)?, peer: grant.peer, @@ -522,6 +531,11 @@ pub struct Lease { expires: Option, /// The session's stats context, moved to a re-checked tier. stats: moq_net::stats::Session, + /// What holds the connection to its grant's caps, or `None` on a transport that + /// cannot, which refuses any capped grant. + pacer: Option, + /// The caps the connection holds. + caps: Caps, } struct Source { @@ -533,15 +547,22 @@ impl Lease { /// Hold `consumer` for a session that dialed `path`, reducing its grant to /// what the origin scopes by. /// - /// Fails if the grant's paths are too long; see [`Token::new`]. + /// Fails if the grant's paths are too long; see [`Token::new`]. Fails too if the + /// grant caps a bitrate, which nothing here enforces. pub fn new(path: &str, consumer: lease::Consumer) -> Result { let grant = consumer.grant(); - Self::granted(path, &grant, consumer) + Self::granted(path, &grant, consumer, None) } /// Hold `consumer` with its token built from `grant`, a snapshot of it. A later /// update still reaches [`Self::ended`] as a change, which re-checks it. - fn granted(path: &str, grant: &Grant, consumer: lease::Consumer) -> Result { + /// + /// A capped grant needs `pacer` to hold the connection to it, and is refused without. + fn granted(path: &str, grant: &Grant, consumer: lease::Consumer, pacer: Option) -> Result { + let caps = Caps::of(grant); + if caps.is_capped() && pacer.is_none() { + return Err(Error::Forbidden(caps::UNENFORCED.into())); + } Ok(Self { refresh: None, source: None, @@ -550,6 +571,8 @@ impl Lease { consumer, session: None, stats: Default::default(), + pacer, + caps, }) } @@ -592,6 +615,15 @@ impl Lease { &self.token } + /// The caps the connection is held to: the union of its live credentials once + /// [prepared](Self::preparing), which the connection applies before answering SETUP. + pub(crate) fn caps(&self) -> Caps { + match &self.refresh { + Some(refresh) => refresh.caps(), + None => self.caps, + } + } + /// Ask the lease's producer to re-check now. A no-op on a fixed lease. pub fn revalidate(&self) { if let Some(refresh) = &self.refresh { @@ -650,6 +682,15 @@ impl Lease { if fresh.upstream != self.token.upstream { return "upstream changed".into(); } + let caps = Caps::of(&grant); + if caps != self.caps { + let Some(pacer) = &self.pacer else { + return caps::UNENFORCED.into(); + }; + tracing::info!(%caps, "bitrate caps changed"); + pacer.pace(caps); + self.caps = caps; + } if fresh.publish != self.token.publish || fresh.subscribe != self.token.subscribe { match &self.session { Some(session) => { @@ -914,14 +955,21 @@ impl Auth { /// A path naming a `.` or `..` segment is refused before the decider sees it. A /// URL transport resolves those away, but an in-band SETUP Path arrives verbatim, /// and `/tenant-a/../tenant-b` would reach the decider spelled under `tenant-a`. + /// + /// A grant that caps a bitrate is refused: nothing here holds the session to it. pub async fn admit(&self, request: Request) -> Result { + self.admit_paced(request, None).await + } + + /// [Admit](Self::admit) a session whose connection `pacer` holds to a capped grant. + pub(crate) async fn admit_paced(&self, request: Request, pacer: Option) -> Result { if navigates(&request.path) { return Err(Error::Request(format!("path {:?} has a dot segment", request.path))); } // Build the token from the grant the admission checked: re-reading it could // pick up an update that skipped the check. let (consumer, grant) = self.decide(request.clone()).await?; - let mut lease = Lease::granted(&request.path, &grant, consumer)?; + let mut lease = Lease::granted(&request.path, &grant, consumer, pacer)?; lease.source = Some(Source { auth: self.clone(), request, @@ -1208,6 +1256,88 @@ mod tests { } } + /// Nothing holds a session admitted without a pacer to a cap, whatever its transport + /// claims, so a capped grant is refused rather than dropped: at admission, and on a + /// lease built without one. + #[tokio::test(start_paused = true)] + async fn capped_grants_are_refused_without_a_pacer() { + use moq_auth::Transport::*; + let rate = moq_auth::Rate::from_bps(8000).unwrap(); + for transport in [Quic, WebTransport, Iroh, WebSocket, Http, Rtmp, Srt] { + for publish in [false, true] { + let mut grant = Grant::new(Pattern::all(), Pattern::all()); + match publish { + true => grant.publish_rate = Some(rate), + false => grant.subscribe_rate = Some(rate), + } + let (auth, mut admissions) = Auth::embedded("test"); + let request = auth.request(transport, "/room"); + let (result, ()) = tokio::join!(auth.admit(request), async { + admissions + .next() + .await + .unwrap() + .grant(lease::Consumer::fixed(grant.clone())); + }); + let Err(Error::Forbidden(message)) = result else { + panic!("{transport} admitted a cap it cannot enforce"); + }; + assert_eq!(message, caps::UNENFORCED, "{transport}"); + + let (auth, mut admissions) = Auth::embedded("test"); + let request = auth.request(transport, "/room"); + let pacer = Pacer::new(|_| {}); + let (result, ()) = tokio::join!(auth.admit_paced(request, Some(pacer)), async { + admissions + .next() + .await + .unwrap() + .grant(lease::Consumer::fixed(grant.clone())); + }); + assert_eq!(result.expect("paced").caps(), Caps::of(&grant), "{transport}"); + + assert!(matches!( + Lease::new("/room", lease::Consumer::fixed(grant)), + Err(Error::Forbidden(_)) + )); + } + } + } + + /// A lease without in-band auth applies a re-checked cap through its pacer, and ends + /// on one without a pacer. + #[tokio::test(start_paused = true)] + async fn a_rechecked_cap_reaches_the_connection() { + use futures::FutureExt; + let rate = moq_auth::Rate::from_bps(8000).unwrap(); + let uncapped = Grant::new(Pattern::all(), Pattern::all()); + let mut capped = uncapped.clone(); + capped.subscribe_rate = Some(rate); + + let applied = Arc::new(std::sync::Mutex::new(Vec::new())); + let record = applied.clone(); + let pacer = Pacer::new(move |caps| record.lock().unwrap().push(caps)); + let (producer, consumer) = lease::Producer::new(uncapped.clone()); + let (auth, mut admissions) = Auth::embedded("test"); + let request = auth.request(moq_auth::Transport::Quic, "/room"); + let (lease, ()) = tokio::join!(auth.admit_paced(request, Some(pacer)), async { + admissions.next().await.unwrap().grant(consumer); + }); + let mut lease = lease.unwrap(); + for grant in [&capped, &uncapped] { + producer.update(grant.clone()); + assert!(lease.ended().now_or_never().is_none(), "a cap change keeps the lease"); + } + producer.revoke(lease::Reason::Refused); + assert_eq!(lease.ended().await, lease::Reason::Refused); + assert_eq!(*applied.lock().unwrap(), [Caps::of(&capped), Caps::default()]); + + let (producer, consumer) = lease::Producer::new(uncapped); + let mut lease = Lease::new("/room", consumer).unwrap(); + producer.update(capped); + assert_eq!(lease.ended().await.to_string(), caps::UNENFORCED); + } + #[test] fn exactly_one_source() { assert!(config(None, &[]).validate(false).is_err()); diff --git a/rs/moq-relay/src/auth/caps.rs b/rs/moq-relay/src/auth/caps.rs new file mode 100644 index 00000000000..0c3fe335f65 --- /dev/null +++ b/rs/moq-relay/src/auth/caps.rs @@ -0,0 +1,176 @@ +//! The bitrate caps a grant puts on a session, and the QUIC connection that holds it to them. + +use std::{num::NonZeroU64, sync::Arc}; + +use moq_auth::{Grant, Rate}; + +/// Why a capped grant is refused: nothing under the session would hold it to the cap. +pub(crate) const UNENFORCED: &str = "this transport cannot enforce bitrate caps"; + +/// What a session may publish and what it may receive for its subscriptions, each +/// uncapped when `None`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Caps { + pub publish: Option, + pub subscribe: Option, +} + +impl Caps { + /// The caps one grant names. + pub fn of(grant: &Grant) -> Self { + Self { + publish: grant.publish_rate, + subscribe: grant.subscribe_rate, + } + } + + /// The caps of a union of grants, matching the union's permissive meaning: each + /// direction takes the highest cap among the grants that cap it, and a grant that + /// gives the direction paths without a cap lifts it. A grant naming neither has no say. + pub fn union<'a>(grants: impl IntoIterator + Clone) -> Self { + fn highest<'a>(roles: impl Iterator)>) -> Option { + let mut highest = None; + for (paths, rate) in roles { + match rate { + Some(rate) => highest = highest.max(Some(rate)), + None if !paths.is_empty() => return None, + None => {} + } + } + highest + } + Self { + publish: highest( + grants + .clone() + .into_iter() + .map(|grant| (&grant.publish, grant.publish_rate)), + ), + subscribe: highest(grants.into_iter().map(|grant| (&grant.subscribe, grant.subscribe_rate))), + } + } + + /// Whether either direction is capped. + pub fn is_capped(&self) -> bool { + self.publish.is_some() || self.subscribe.is_some() + } + + /// The QUIC limits that hold a connection to these caps: the relay receives what the + /// session publishes and sends what it subscribes to, in bytes rather than bits. + pub fn limits(&self) -> moq_quic::Limits { + let rate = |cap: Option| match cap { + None => moq_quic::Rate::Unlimited, + Some(rate) => { + moq_quic::Rate::BytesPerSecond(NonZeroU64::new(rate.as_bps().div_ceil(8)).expect("a positive rate")) + } + }; + let mut limits = moq_quic::Limits::default(); + limits.receive_rate = Some(rate(self.publish)); + limits.send_rate = Some(rate(self.subscribe)); + limits + } +} + +impl std::fmt::Display for Caps { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let cap = |rate: Option| rate.map_or_else(|| "uncapped".to_string(), |rate| rate.to_string()); + write!(f, "publish {}, subscribe {}", cap(self.publish), cap(self.subscribe)) + } +} + +/// Applies a session's caps to the QUIC connection carrying it, on whichever runtime +/// drives that connection. +/// +/// Only a transport that can hold a session to its caps has one, so a session without +/// one refuses a capped grant at admission, in band, and at revalidation. +#[derive(Clone)] +pub struct Pacer(Arc); + +impl Pacer { + /// Pace a live connection by calling `apply` with each new set of caps. + pub(crate) fn new(apply: impl Fn(Caps) + Send + Sync + 'static) -> Self { + Self(Arc::new(apply)) + } + + /// Hold the connection to `caps` from now on. + pub(crate) fn pace(&self, caps: Caps) { + (self.0)(caps) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rate(bps: u64) -> Option { + Some(Rate::from_bps(bps).unwrap()) + } + + fn grant(publish: &[&str], publish_rate: Option, subscribe: &[&str], subscribe_rate: Option) -> Grant { + let patterns = |texts: &[&str]| { + texts + .iter() + .map(|text| text.parse().unwrap()) + .collect::() + }; + let mut grant = Grant::new(patterns(publish), patterns(subscribe)); + grant.publish_rate = publish_rate; + grant.subscribe_rate = subscribe_rate; + grant + } + + #[test] + fn a_union_takes_the_highest_cap_and_an_uncapped_grant_lifts_it() { + let low = grant(&["a/**"], rate(1_000), &["**"], rate(8_000)); + let high = grant(&["b/**"], rate(2_000), &["**"], rate(4_000)); + let caps = Caps::union([&low, &high]); + assert_eq!( + caps, + Caps { + publish: rate(2_000), + subscribe: rate(8_000) + } + ); + + let uncapped = grant(&["c/**"], None, &[], None); + let caps = Caps::union([&low, &high, &uncapped]); + assert_eq!( + caps, + Caps { + publish: None, + subscribe: rate(8_000) + }, + "only the direction it grants" + ); + + let silent = grant(&[], None, &["d"], rate(16_000)); + assert_eq!( + Caps::union([&low, &silent]), + Caps { + publish: rate(1_000), + subscribe: rate(16_000) + } + ); + assert_eq!(Caps::union([]), Caps::default()); + } + + #[test] + fn limits_convert_bits_to_bytes_rounding_up() { + let caps = Caps { + publish: rate(8_000_001), + subscribe: None, + }; + let limits = caps.limits(); + assert_eq!( + limits.receive_rate, + Some(moq_quic::Rate::BytesPerSecond(NonZeroU64::new(1_000_001).unwrap())) + ); + assert_eq!(limits.send_rate, Some(moq_quic::Rate::Unlimited)); + let one = Caps { + publish: None, + subscribe: rate(1), + } + .limits(); + assert_eq!(one.send_rate, Some(moq_quic::Rate::BytesPerSecond(NonZeroU64::MIN))); + } +} diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index b05956c2f2a..9d07da5d7db 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, VecDeque}; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::FuturesUnordered}; use moq_net::{SessionError, auth, setup::Role}; -use super::{Grant, Source, Token, lease}; +use super::{Caps, Grant, Pacer, Source, Token, caps, lease}; /// A connection owns its lifecycle while each credential owns only its grant. pub(super) struct Refresh { @@ -31,6 +31,11 @@ pub(super) struct Refresh { /// queue doubles keeps a push amortized O(1). survivors: usize, next: u64, + /// What holds the connection to the union's caps, or `None` on a transport that + /// cannot, where every capped credential is refused. + pacer: Option, + /// The caps of the union of every live credential, which the connection holds. + caps: Caps, } type Verified = (auth::Request, Result); @@ -108,8 +113,13 @@ impl Refresh { Err(lease::Reason::Expired) => return Err(moq_net::Error::Expired), Err(_) => return Err(moq_net::Error::Unauthorized), } + if lease.pacer.is_none() && Caps::of(&initial.grant).is_capped() { + return Err(moq_net::Error::Unauthorized); + } initial.initial = true; - let this = Self { + // The connection applies the first caps itself, with the window it opens. + let caps = Caps::of(&initial.grant); + let mut this = Self { handle, requests, requests_open: true, @@ -124,16 +134,43 @@ impl Refresh { waiting: VecDeque::new(), survivors: 0, next: 1, + pacer: lease.pacer.clone(), + caps, }; this.authorize(); Ok(this) } + /// The caps the connection is held to. + pub(super) fn caps(&self) -> Caps { + self.caps + } + + /// Why `grant` cannot join this session, if it cannot: a cap the transport cannot + /// enforce, or a grant that does not keep the session's root, routing, and role. + fn refusal(&self, grant: &Grant) -> Option { + if self.unenforced(grant) { + return Some(caps::UNENFORCED.to_string()); + } + (!self.valid(grant)).then(|| { + format!( + "token must retain root {}, connection routing, and usable SETUP role permissions", + self.admitted.root + ) + }) + } + + /// Whether `grant` caps a bitrate this connection cannot be held to. + fn unenforced(&self, grant: &Grant) -> bool { + self.pacer.is_none() && Caps::of(grant).is_capped() + } + fn valid(&self, grant: &Grant) -> bool { let Ok(fresh) = self.admitted.recheck(grant) else { return false; }; - super::still_valid(grant).is_ok() + !self.unenforced(grant) + && super::still_valid(grant).is_ok() && fresh.root == self.admitted.root && fresh.mounts == self.admitted.mounts && fresh.peer == self.admitted.peer @@ -148,7 +185,10 @@ impl Refresh { /// The connection credential's grant. moq-net adds every grant issued to an AUTH token /// itself, and keeps a request token's grant to that request alone. - fn authorize(&self) { + /// + /// The caps cover every live credential, request tokens included: each is a grant the + /// decider issued to this connection. + fn authorize(&mut self) { let mut union = auth::Grant::default(); for credential in self.credentials.values().filter(|credential| credential.initial) { let grant = credential.wire(self.role); @@ -158,6 +198,19 @@ impl Refresh { // Credential timers remove one grant at a time; no single deadline may // expire a union that another credential still covers. self.handle.authorize(&union); + + // The last credential leaving ends the session, which needs no caps. + if self.credentials.is_empty() { + return; + } + let caps = Caps::union(self.credentials.values().map(|credential| &credential.grant)); + if caps != self.caps + && let Some(pacer) = &self.pacer + { + tracing::info!(%caps, "bitrate caps changed"); + pacer.pace(caps); + self.caps = caps; + } } fn request(&mut self, request: auth::Request) { @@ -185,7 +238,11 @@ impl Refresh { // exact snapshot that would be issued. let mut credential = Credential::new(self.owner.clone()); if !self.valid(&credential.grant) { - request.reject(SessionError::Unauthorized, "connection credential no longer valid"); + let reason = match self.unenforced(&credential.grant) { + true => caps::UNENFORCED, + false => "connection credential no longer valid", + }; + request.reject(SessionError::Unauthorized, reason); return; } credential.initial = true; @@ -356,19 +413,15 @@ impl Refresh { // Validate the exact snapshot that is issued, not a later read. let credential = result.map(Credential::new); match credential { - Ok(mut credential) if self.valid(&credential.grant) => { - credential.issued = Some(request.accept(credential.wire(self.role))); - self.credentials.insert(self.next, credential); - self.next += 1; - self.authorize(); - } - Ok(_) => request.reject( - SessionError::Unauthorized, - format!( - "token must retain root {}, connection routing, and usable SETUP role permissions", - self.admitted.root - ), - ), + Ok(mut credential) => match self.refusal(&credential.grant) { + None => { + credential.issued = Some(request.accept(credential.wire(self.role))); + self.credentials.insert(self.next, credential); + self.next += 1; + self.authorize(); + } + Some(refusal) => request.reject(SessionError::Unauthorized, refusal), + }, Err(err) => refuse(request, err), } } @@ -393,6 +446,7 @@ impl Refresh { Change::Withdrawn => lease::Reason::Dropped, Change::Grant(grant) => match super::still_valid(&grant) { Err(reason) => reason, + Ok(()) if self.unenforced(&grant) => caps::UNENFORCED.into(), Ok(()) => lease::Reason::Narrowed, }, }; @@ -736,6 +790,107 @@ mod tests { let _ = supervising.await; } + fn capped(prefix: &str, publish: Option, subscribe: Option) -> Grant { + let mut grant = grant(prefix, 3000); + grant.publish_rate = publish.map(|bps| moq_auth::Rate::from_bps(bps).unwrap()); + grant.subscribe_rate = subscribe.map(|bps| moq_auth::Rate::from_bps(bps).unwrap()); + grant + } + + fn rate(bps: u64) -> Option { + Some(moq_auth::Rate::from_bps(bps).unwrap()) + } + + /// A pacer that records every set of caps it applies. + fn recorder() -> (Pacer, std::sync::Arc>>) { + let applied = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let record = applied.clone(); + (Pacer::new(move |caps| record.lock().unwrap().push(caps)), applied) + } + + /// A session admitted on `initial`, held to its caps by `pacer`. + async fn paced(initial: lease::Consumer, pacer: Option) -> (super::super::Lease, super::super::Admissions) { + let (auth, mut admissions) = super::super::Auth::embedded("test"); + let request = auth.request(moq_auth::Transport::Quic, "/room"); + let (lease, ()) = tokio::join!(auth.admit_paced(request, pacer), async { + admissions.next().await.unwrap().grant(initial); + }); + (lease.unwrap(), admissions) + } + + /// In-band tokens join the caps: the highest holds, an uncapped grant lifts it, and a + /// re-check that caps that grant restores the highest. Each change reaches the + /// live connection. + #[tokio::test(start_paused = true)] + async fn the_union_of_credentials_paces_the_connection() { + let (pacer, applied) = recorder(); + let initial = lease::Consumer::fixed(capped("a/**", Some(1_000), None)); + let (lease, mut admissions) = paced(initial, Some(pacer)).await; + assert_eq!( + lease.caps(), + Caps { + publish: rate(1_000), + subscribe: None + } + ); + let (client, supervising) = sessions(lease).await; + let auth = client.auth(); + + let (higher, ()) = tokio::join!(auth.add("higher"), async { + let higher = capped("a/**", Some(4_000), None); + admissions.next().await.unwrap().grant(lease::Consumer::fixed(higher)); + }); + // Held, since dropping a token withdraws its credential. + let _higher = higher.unwrap(); + let (producer, uncapped) = lease::Producer::new(capped("a/**", None, None)); + let (lifted, ()) = tokio::join!(auth.add("uncapped"), async { + admissions.next().await.unwrap().grant(uncapped); + }); + let _lifted = lifted.unwrap(); + // The auth server caps the grant it had left uncapped. + producer.update(capped("a/**", Some(2_000), None)); + while applied.lock().unwrap().len() < 3 { + kio::clock::sleep(Duration::from_millis(1)).await; + } + let highest = Caps { + publish: rate(4_000), + subscribe: None, + }; + assert_eq!(*applied.lock().unwrap(), [highest, Caps::default(), highest]); + client.abort(moq_net::Error::Cancel); + supervising.abort(); + let _ = supervising.await; + } + + /// Without a pacer, a capped in-band token is refused, and the session keeps its grant. + #[tokio::test(start_paused = true)] + async fn an_unpaced_session_refuses_a_capped_token() { + let (lease, mut admissions) = admission(grant("a/**", 3000)).await; + let (client, supervising) = sessions(lease).await; + let auth = client.auth(); + let (result, ()) = tokio::join!(auth.add("capped"), async { + let capped = capped("a/**", None, Some(8_000)); + admissions.next().await.unwrap().grant(lease::Consumer::fixed(capped)); + }); + assert!(result.is_err(), "the cap was discarded"); + assert!(!supervising.is_finished(), "a refused token ended the session"); + client.abort(moq_net::Error::Cancel); + supervising.abort(); + let _ = supervising.await; + } + + /// Without a pacer, a re-check that adds a cap ends the session rather than drop it. + #[tokio::test(start_paused = true)] + async fn an_unpaced_session_ends_when_rechecked_into_a_cap() { + let (producer, consumer) = lease::Producer::new(grant("a/**", 3000)); + let (lease, _admissions) = paced(consumer, None).await; + let (client, supervising) = sessions(lease).await; + producer.update(capped("a/**", Some(8_000), None)); + let reason = supervising.await.unwrap(); + assert_eq!(reason.to_string(), caps::UNENFORCED); + client.abort(moq_net::Error::Cancel); + } + /// How a lease ends decides how its session's stats count the end: an expired grant as /// `expired`, a shutdown as clean, and any other reason as `application`. #[tokio::test(start_paused = true)] diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index 3a14f88651a..afa4d4a83e9 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1189,10 +1189,18 @@ impl Cluster { } } - /// The QUIC limits an admitted session widens to, if any: only a `peer` grant does. - #[cfg(any(feature = "quic", all(target_os = "linux", feature = "_uring")))] - pub(crate) fn quic_limits(&self, lease: &auth::Lease) -> Option { - lease.token().peer.then(|| self.peer_quic.limits()) + /// The QUIC limits an admitted session runs under once it is answered: a `peer` + /// grant's wider ones, and the lease's bitrate caps. A receive window left unset opens + /// to the configured one. + pub(crate) fn quic_limits(&self, lease: &auth::Lease) -> moq_quic::Limits { + let mut limits = match lease.token().peer { + true => self.peer_quic.limits(), + false => moq_quic::Limits::default(), + }; + let caps = lease.caps().limits(); + limits.receive_rate = caps.receive_rate; + limits.send_rate = caps.send_rate; + limits } /// The origin ID used by this relay on the wire. @@ -1301,9 +1309,10 @@ impl Cluster { &self, auth: &auth::Auth, request: moq_auth::Request, + pacer: Option, ) -> Result { let result = async { - let lease = auth.admit(request.clone()).await?; + let lease = auth.admit_paced(request.clone(), pacer).await?; self.scope_session(lease, &request) } .await; diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 1ba7e40e312..dfb47c73870 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -92,7 +92,9 @@ impl Connection { // cannot hold the connection open behind a decision that never comes. // Boxed: the admission future is large, and nesting it inline overflows debug stacks. let admitted = request - .within(Box::pin(lifecycle.admit(request.admission(&lifecycle.auth)))) + .within(Box::pin( + lifecycle.admit(request.admission(&lifecycle.auth), request.pacer()), + )) .await .inspect_err(|_| lifecycle.cluster.refusals.record(RefusalKind::Unavailable)) .map_err(|err| err.context("admission timed out"))?; @@ -112,15 +114,6 @@ impl Connection { stats, } = admitted; - // A peer's QUIC limits widen before SETUP OK, so its first requests see them. - // Other transports keep their own limits. - #[cfg(feature = "quic")] - if let Some(limits) = lifecycle.cluster.quic_limits(&lease) - && let Some(quic) = request.quic() - { - quic.set_limits(limits)?; - } - // Accept the connection. // NOTE: subscribe and publish seem backwards because of how relays work. // We publish the tracks the client is allowed to subscribe to. @@ -143,6 +136,9 @@ impl Connection { return Err(err.into()); } }; + // Before SETUP OK, so the first requests see them: a peer's wider limits, and the + // caps the window then opens under. Other transports keep their own limits. + let request = request.with_quic_limits(lifecycle.cluster.quic_limits(&lease))?; let serving = lifecycle.shutdown.serve(); // Bounded too: a peer that stalls the SETUP reply must not outlive the relay. // Abandoning the reply refuses the session with the timeout code, which its stats count. @@ -272,9 +268,12 @@ impl Lifecycle { .ok_or_else(|| anyhow::anyhow!("connection task panicked")) } + /// Admit `request`, holding its connection to a capped grant through `pacer`, or + /// refusing a capped grant without one. pub(crate) async fn admit( &self, request: moq_auth::Request, + pacer: Option, ) -> Result<(cluster::Admitted, Option), Rejection> { self.span.record("session", &request.id); // Peer-controlled, so recorded escaped rather than displayed raw. @@ -282,7 +281,7 @@ impl Lifecycle { self.span .record("implementation", tracing::field::debug(implementation)); } - let admitted = self.shutdown.admit(self.authorize(request)).await; + let admitted = self.shutdown.admit(self.authorize(request, pacer)).await; // Cluster admission records its own refusals; LAN and drain refusals are recorded here. if let Err(err) = &admitted && let Some(refusal) = err.refusal @@ -295,6 +294,7 @@ impl Lifecycle { async fn authorize( &self, request: moq_auth::Request, + pacer: Option, ) -> Result<(cluster::Admitted, Option), Rejection> { if cluster::Cluster::is_lan_path(&request.path) { let Some(presented) = cluster::Cluster::lan_credential(&request.path) else { @@ -331,7 +331,7 @@ impl Lifecycle { } let admitted = self .cluster - .admit_session(&self.auth, request.clone()) + .admit_session(&self.auth, request.clone(), pacer) .await .map_err(|err| { // Cluster admission owns this refusal; only local/drain refusals remain here. diff --git a/rs/moq-relay/src/incoming.rs b/rs/moq-relay/src/incoming.rs index ac1aeddafa4..169adda7e8d 100644 --- a/rs/moq-relay/src/incoming.rs +++ b/rs/moq-relay/src/incoming.rs @@ -57,12 +57,18 @@ pub trait Sealed: Sized { /// Refuse the session, closing it with `code`. async fn reject(self, code: moq_net::SessionError); - /// The QUIC connection under the request, to widen a peer's limits before SETUP OK. - #[cfg(feature = "quic")] - fn quic(&self) -> Option<&moq_tokio::quic::driver::connection::Connection> { + /// What holds the connection to a capped grant once the session runs, or `None` on + /// a transport that cannot, which refuses one. + fn pacer(&self) -> Option { None } + /// Apply QUIC limits before answering SETUP, which then opens the receive window the + /// handshake held back. A transport without moq-quic ignores them. + fn with_quic_limits(self, _limits: moq_quic::Limits) -> anyhow::Result { + Ok(self) + } + fn auth(&self) -> moq_net::auth::Handle; fn requests(&mut self) -> moq_net::auth::Requests; fn role(&self) -> Option; @@ -103,8 +109,18 @@ impl Sealed for moq_tokio::server::Request { } #[cfg(feature = "quic")] - fn quic(&self) -> Option<&moq_tokio::quic::driver::connection::Connection> { - moq_tokio::server::Request::quic(self) + fn pacer(&self) -> Option { + let quic = moq_tokio::server::Request::quic(self)?.clone(); + Some(auth::Pacer::new(move |caps| { + if let Err(err) = quic.set_limits(caps.limits()) { + tracing::warn!(%err, "failed to apply bitrate caps"); + } + })) + } + + #[cfg(feature = "quic")] + fn with_quic_limits(self, limits: moq_quic::Limits) -> anyhow::Result { + Ok(moq_tokio::server::Request::with_quic_limits(self, limits)?) } fn auth(&self) -> moq_net::auth::Handle { diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index 36a5451e2ae..3996b7aefb9 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -906,14 +906,28 @@ async fn serve_listening( ) -> anyhow::Result<()> { loop { let request = listener.accept().await?; - let conn = Connection::new(request, cluster.clone(), auth.clone()) - .with_id(cluster.next_connection_id()) - .with_shutdown(shutdown.clone()) - .with_sessions(sessions.clone()); - tokio::spawn(conn.run()); + spawn_connection(request, &cluster, &auth, &shutdown, &sessions); } } +/// Spawn the [`Connection`] task for one accepted `request`. +/// +/// Its own frame, so the session future built on the stack before `spawn` moves it +/// to the heap stays out of the accept loop's frame, which every accept stacks on. +fn spawn_connection( + request: moq_tokio::server::Request, + cluster: &cluster::Cluster, + auth: &auth::Auth, + shutdown: &shutdown::Observer, + sessions: &crate::session::Registry, +) { + let conn = Connection::new(request, cluster.clone(), auth.clone()) + .with_id(cluster.next_connection_id()) + .with_shutdown(shutdown.clone()) + .with_sessions(sessions.clone()); + tokio::spawn(conn.run()); +} + #[cfg(test)] mod tests { #[cfg(feature = "stun")] diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index 96cd539073f..a73cb3d067d 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -82,6 +82,9 @@ struct Serve { /// `listen.reset.secret`, shared so any worker resets any worker's /// connections, a restarted group's included. reset_secret: Option<[u8; 32]>, + /// `[quic] receive_window`, opened once a session is admitted: until then a + /// connection holds [`moq_net::transport::SETUP_RECEIVE_WINDOW`]. + window: Option, } /// A bound group of io_uring QUIC workers sharing one port. @@ -100,6 +103,8 @@ pub struct Workers { members: Vec, addr: SocketAddr, server: moq_uring::quic::server::Config, + /// `[quic] receive_window`, which admission opens; see [`Serve::window`]. + window: Option, /// The same live resolver used by every worker and the fingerprint endpoint. certificates: moq_tokio::tls::Certificates, /// One counter set per worker, in shard order. Created here rather than on @@ -238,6 +243,8 @@ impl Workers { } let mut server = moq_uring::quic::server::Config::new(tls); server.transport = transport(&quic)?; + let window = server.transport.limits.receive_window; + server.transport.limits.receive_window = NonZeroU64::new(moq_net::transport::SETUP_RECEIVE_WINDOW); // GSO is a property of the socket, not the connection, so it rides the // worker's UDP config rather than the connection's. @@ -259,6 +266,7 @@ impl Workers { members, addr, server, + window, certificates, udp, pin: config.pin, @@ -329,6 +337,7 @@ impl Workers { local: self.addr, timeout: self.timeout, reset_secret: self.reset_secret, + window: self.window, }; let cores = match self.pin { @@ -852,10 +861,24 @@ async fn serve_request( auth_request.alpn = alpn.clone(); auth_request.tls = identity.as_ref().and_then(crate::auth::peer); let admission = lifecycle.clone(); + // The connection is pinned to this worker, so a cap that changes on the shared + // runtime reaches it through `caps`, which a task here applies. + let caps = kio::Producer::new(auth::Caps::default()); + let pacer = { + let caps = caps.clone(); + auth::Pacer::new(move |new| { + if let Ok(mut caps) = caps.write() { + *caps = new; + } + }) + }; // The handshake deadline covers admission too, so a peer cannot hold the connection // open behind a decision that never comes. Dropping the shared task withdraws it. let decided = deadline - .bound(lifecycle.shared(&serve.tokio, async move { admission.admit(auth_request).await })) + .bound(lifecycle.shared( + &serve.tokio, + async move { admission.admit(auth_request, Some(pacer)).await }, + )) .await .inspect_err(|err| { if matches!(err.downcast_ref::(), Some(moq_net::Error::Timeout)) { @@ -877,10 +900,6 @@ async fn serve_request( stats, } = admitted; - // A peer's QUIC limits widen before SETUP OK, so its first requests see them. - if let Some(limits) = serve.cluster.quic_limits(&lease) { - conn.set_limits(limits)?; - } let mut request = request.with_stats(stats).with_limits(serve.cluster.limits(&lease)); if let Some(subscriber) = subscriber { request = request.with_publisher(subscriber); @@ -901,6 +920,33 @@ async fn serve_request( return Err(err.into()); } }; + // Before SETUP OK, so the first requests see them: a peer's wider limits, the caps, + // and then the window the handshake held back, which the caps pace. + let mut limits = serve.cluster.quic_limits(&lease); + limits.receive_window = limits.receive_window.or(serve.window); + conn.set_limits(limits)?; + // The task holds only a consumer: dropping the lease, and with it `pacer`, closes + // `caps`, which ends the task and releases its handle on the connection. + let mut applied = lease.caps(); + if let Ok(mut caps) = caps.write() { + *caps = applied; + } + let caps = caps.consume(); + let pacing = async move { + while let Ok(next) = caps + .wait(|caps| match **caps != applied { + true => std::task::Poll::Ready(**caps), + false => std::task::Poll::Pending, + }) + .await + { + if let Err(err) = conn.set_limits(next.limits()) { + tracing::warn!(%err, "failed to apply bitrate caps"); + } + applied = next; + } + }; + handle.spawn(pacing.in_current_span()); let serving = serve.shutdown.serve(); // Bounded by the drain deadline too, like the tokio path, so a peer that stalls // the SETUP reply cannot hold the drain. That is a tokio timer, so it runs on the diff --git a/rs/moq-relay/tests/integration/bitrate_caps.rs b/rs/moq-relay/tests/integration/bitrate_caps.rs new file mode 100644 index 00000000000..6dd07b48c7b --- /dev/null +++ b/rs/moq-relay/tests/integration/bitrate_caps.rs @@ -0,0 +1,272 @@ +//! A capped grant over QUIC is admitted and holds its session to its caps, and every +//! session holds only the SETUP window until it is admitted, on the tokio listener and +//! the io_uring workers alike. +//! +//! Over real sockets, so the caps are measured on the wall clock as lower bounds on +//! how long a transfer takes: uncapped, the same transfer takes milliseconds. +#![cfg(feature = "quic")] + +use std::time::Duration; + +use moq_relay::{Config, Relay}; +use moq_tokio::moq_net; +use moq_tokio::quic::driver; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// One megabyte a second, so a fresh cap allows a one-megabyte burst. +const RATE: u64 = 8_000_000; +/// Past the burst and the SETUP window, so the rest waits on the rate. +const FRAME: usize = 2 << 20; +/// The least time [`FRAME`] can take under [`RATE`]: what is left after a full burst and +/// the SETUP window, at the rate, with some slack for the burst a cap starts with. +const LEAST: Duration = Duration::from_millis(700); + +fn config(dir: &std::path::Path) -> Config { + let mut config = super::peer_limits::config(dir); + config.quic.max_streams = None; + config +} + +/// A grant of everything, with `publish` and `subscribe` caps. +fn grant(publish: Option, subscribe: Option) -> moq_auth::Grant { + let all: moq_auth::Patterns = moq_auth::Pattern::all().into(); + let mut grant = moq_auth::Grant::new(all.clone(), all); + grant.publish_rate = publish.map(|bps| moq_auth::Rate::from_bps(bps).unwrap()); + grant.subscribe_rate = subscribe.map(|bps| moq_auth::Rate::from_bps(bps).unwrap()); + grant +} + +fn client() -> moq_tokio::Client { + let mut config = moq_tokio::connect::Config::default(); + config.tls.insecure = Some(true); + config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) +} + +/// Connect `client`, admitted on `grant`. +async fn connect( + client: moq_tokio::Client, + port: u16, + admissions: &mut moq_relay::auth::Admissions, + grant: moq_auth::Grant, +) -> moq_tokio::Connection { + let url: url::Url = format!("https://localhost:{port}/room").parse().unwrap(); + let connecting = client.connect(url).established(); + let admitting = async { + let admission = admissions.next().await.expect("admission"); + admission.grant(moq_auth::lease::Consumer::fixed(grant)); + }; + let (connection, ()) = kio::clock::timeout(TIMEOUT, async { tokio::join!(connecting, admitting) }) + .await + .expect("connect timeout"); + connection.expect("connect failed") +} + +/// Publish one [`FRAME`] on `test/video`, and how long a subscriber takes to read it. +async fn transfer( + port: u16, + admissions: &mut moq_relay::auth::Admissions, + publisher: moq_auth::Grant, + subscriber: moq_auth::Grant, +) -> Duration { + let origin = moq_tokio::origin::spawn(); + let broadcast = origin.create_broadcast("test").expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast + .create_track( + "video", + moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI), + ) + .expect("create track"); + let _publishing = connect(client().with_publisher(&origin), port, admissions, publisher).await; + + let subscribing = moq_tokio::origin::spawn(); + let consumer = subscribing.consume(); + let _subscribed = connect(client().with_subscriber(subscribing), port, admissions, subscriber).await; + let broadcast_consumer = kio::clock::timeout(TIMEOUT, consumer.request("test", None)) + .await + .expect("request timeout") + .expect("announced broadcast resolves"); + let mut subscription = broadcast_consumer + .track("video") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"); + + let start = kio::clock::now(); + let mut group = track.append_group().expect("append group"); + group + .write_frame(moq_net::Timestamp::ZERO, vec![7u8; FRAME]) + .expect("write frame"); + group.finish().expect("finish group"); + let mut group = kio::clock::timeout(TIMEOUT, subscription.recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("track closed"); + let frame = kio::clock::timeout(TIMEOUT, group.read_frame()) + .await + .expect("read_frame timeout") + .expect("read_frame failed") + .expect("group closed"); + assert_eq!(frame.payload.len(), FRAME); + kio::clock::now() - start +} + +/// A capped publisher feeds the relay at its rate, and a capped subscriber is fed at its. +async fn held_to_its_caps(mut relay: Relay, port: u16) { + let mut admissions = relay.admissions().expect("embedded auth"); + let running = tokio::spawn(relay.run()); + + let elapsed = transfer(port, &mut admissions, grant(Some(RATE), None), grant(None, None)).await; + assert!(elapsed >= LEAST, "a capped publisher sent {FRAME} bytes in {elapsed:?}"); + + let elapsed = transfer(port, &mut admissions, grant(None, None), grant(None, Some(RATE))).await; + assert!( + elapsed >= LEAST, + "a capped subscriber received {FRAME} bytes in {elapsed:?}" + ); + + running.abort(); + let _ = running.await; +} + +/// Before admission the relay credits only the SETUP window, so a client cannot bank +/// the full one; once admitted, the window opens. +async fn window_opens_once_admitted(mut relay: Relay, port: u16) { + let mut admissions = relay.admissions().expect("embedded auth"); + let running = tokio::spawn(relay.run()); + + let mut connect = moq_tokio::connect::Config::default(); + connect.tls.insecure = Some(true); + let mut tls = connect.tls.build().expect("tls config"); + tls.alpn_protocols = vec![b"moq-lite-06".to_vec()]; + let crypto = driver::crypto::rustls::QuicClientConfig::try_from(tls).expect("quic crypto"); + let endpoint = + driver::endpoint::Endpoint::new(Default::default(), std::net::UdpSocket::bind("127.0.0.1:0").unwrap()) + .expect("endpoint"); + let conn = endpoint + .connect( + &driver::client::Config::new(std::sync::Arc::new(crypto)), + ([127, 0, 0, 1], port).into(), + "localhost", + ) + .expect("connect") + .await + .expect("handshake"); + + // SETUP type, then a one-byte message holding zero parameters. + let mut setup = conn.open_uni(Default::default()).await.expect("open"); + setup.write_all(&[0x01, 0x01, 0x00][..]).await.expect("write"); + setup.finish(); + let admission = kio::clock::timeout(TIMEOUT, admissions.next()) + .await + .expect("admission timeout") + .expect("admission"); + + // Unread until the session runs, so it holds its credit, and the write waits for more. + let banked = 1 << 20; + let mut stream = conn.open_uni(Default::default()).await.expect("open"); + let writing = tokio::spawn(async move { + stream.write_all(&vec![0u8; banked][..]).await.expect("write"); + stream + }); + let blocked = async { + while conn.stats().frame_tx.data_blocked == 0 { + kio::clock::sleep(Duration::from_millis(10)).await; + } + }; + kio::clock::timeout(TIMEOUT, blocked) + .await + .expect("the client was never blocked on the SETUP window"); + let stats = conn.stats(); + assert!( + stats.udp_tx.bytes < moq_net::transport::SETUP_RECEIVE_WINDOW + (64 << 10), + "sent {} bytes before admission", + stats.udp_tx.bytes + ); + + // Nothing reads the blocked stream before admission, so only opening the window + // credits more. The session then stops that stream, so watch the credit, not the data. + let credited = stats.frame_rx.max_data; + admission.grant(moq_auth::lease::Consumer::fixed(grant(None, None))); + let opened = async { + while conn.stats().frame_rx.max_data == credited { + kio::clock::sleep(Duration::from_millis(10)).await; + } + }; + kio::clock::timeout(TIMEOUT, opened) + .await + .expect("the window never opened once admitted"); + writing.abort(); + + conn.close(driver::VarInt::from_u32(0), "done"); + endpoint.close(driver::VarInt::from_u32(0), "done").await; + running.abort(); + let _ = running.await; +} + +async fn tokio_relay() -> (Relay, u16, tempfile::TempDir) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let dir = tempfile::tempdir().expect("tempdir"); + let relay = Relay::load(config(dir.path())) + .await + .expect("load relay") + .with_signals(false); + let port = relay.quic_addr().expect("quic listener bound").port(); + (relay, port, dir) +} + +#[tokio::test] +async fn tokio_session_is_held_to_its_caps() { + let (relay, port, _dir) = tokio_relay().await; + held_to_its_caps(relay, port).await; +} + +#[tokio::test] +async fn tokio_window_opens_once_admitted() { + let (relay, port, _dir) = tokio_relay().await; + window_opens_once_admitted(relay, port).await; +} + +#[cfg(all(target_os = "linux", feature = "_uring"))] +async fn uring_relay() -> Option<(Relay, u16, tempfile::TempDir)> { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + match moq_uring::Worker::new(Default::default()) { + Ok(_) => {} + Err(moq_uring::Error::Unsupported(reason)) if std::env::var_os("CI_STRICT").is_none() => { + eprintln!("skipping io_uring relay test: {reason}"); + return None; + } + Err(err) => panic!("io_uring worker setup failed: {err}"), + } + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = config(dir.path()); + config.runtime.workers = Some(1); + config.runtime.pin = false; + config.runtime.io_uring = true; + let relay = Relay::load(config).await.expect("load relay").with_signals(false); + let port = relay.addr().expect("workers bound an address").port(); + Some((relay, port, dir)) +} + +#[cfg(all(target_os = "linux", feature = "_uring"))] +#[tokio::test] +async fn uring_session_is_held_to_its_caps() { + if let Some((relay, port, _dir)) = uring_relay().await { + held_to_its_caps(relay, port).await; + } +} + +#[cfg(all(target_os = "linux", feature = "_uring"))] +#[tokio::test] +async fn uring_window_opens_once_admitted() { + if let Some((relay, port, _dir)) = uring_relay().await { + window_opens_once_admitted(relay, port).await; + } +} diff --git a/rs/moq-relay/tests/integration/main.rs b/rs/moq-relay/tests/integration/main.rs index 9c4d360edda..757a5f02814 100644 --- a/rs/moq-relay/tests/integration/main.rs +++ b/rs/moq-relay/tests/integration/main.rs @@ -8,6 +8,7 @@ //! `shutdown_signal` owns those signals, which reach every listener in this process. mod auth_lifetime; +mod bitrate_caps; mod cluster_close; mod cluster_drain; mod cluster_idle; diff --git a/rs/moq-relay/tests/integration/peer_limits.rs b/rs/moq-relay/tests/integration/peer_limits.rs index c71d7cdd147..6a13235c642 100644 --- a/rs/moq-relay/tests/integration/peer_limits.rs +++ b/rs/moq-relay/tests/integration/peer_limits.rs @@ -1,5 +1,5 @@ //! A session admitted as a cluster peer widens its QUIC limits before SETUP OK, -//! while a viewer keeps the `--quic-*` ones, on the tokio listener and the +//! while a viewer opens to the `--quic-*` ones, on the tokio listener and the //! io_uring workers alike. #![cfg(feature = "quic")] @@ -140,7 +140,8 @@ async fn peer_widens_and_viewer_does_not(mut relay: Relay, port: u16) { "a viewer keeps the stream limit, opened {}", viewer.streams ); - assert_eq!(viewer.max_data, 0, "a viewer's receive window is unchanged"); + // Both start with only the SETUP window, which admission opens for a viewer too. + assert!(viewer.max_data > 0, "a viewer's receive window opens once admitted"); let _leases = deciding.await.expect("decider"); running.abort(); diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index c37a54f514d..00a372c38f8 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `server::Request::with_quic_limits` sets the QUIC limits `ok` applies before answering SETUP. A server QUIC connection holds only `moq_net::transport::SETUP_RECEIVE_WINDOW` until `ok` opens `[quic] receive_window`, so a client cannot bank credit before it is admitted. + - `server::Request::within` finishes a future, such as admitting the session, within what remains of the handshake deadline, closing the session with the timeout code otherwise. ## [0.19.23](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.22...moq-tokio-v0.19.23) - 2026-10-06 diff --git a/rs/moq-tokio/src/quic/backend.rs b/rs/moq-tokio/src/quic/backend.rs index fc648f13639..90337dc74e6 100644 --- a/rs/moq-tokio/src/quic/backend.rs +++ b/rs/moq-tokio/src/quic/backend.rs @@ -574,6 +574,9 @@ fn proto_status(err: &web::proto::ConnectError) -> Option { pub(crate) struct Server { pub quic: driver::endpoint::Endpoint, + /// The configured receive window, opened once a session is admitted: until then a + /// connection holds [`moq_net::transport::SETUP_RECEIVE_WINDOW`]. + pub window: Option, tls: crate::tls::Serve, /// Reloads the QUIC-LB peers for as long as the server runs. _peers: Option, @@ -597,7 +600,9 @@ impl Server { demux, } = options; let quic = quic.resolve(); - let transport = transport(&quic, "server")?; + let mut transport = transport(&quic, "server")?; + let window = transport.limits.receive_window; + transport.limits.receive_window = std::num::NonZeroU64::new(moq_net::transport::SETUP_RECEIVE_WINDOW); let keylog = quic .keylog .as_deref() @@ -713,6 +718,7 @@ impl Server { Ok(Self { quic, + window, tls: served, _peers: peers, }) diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index d7b5396fe2e..cba979c6d71 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -685,6 +685,9 @@ impl Server { // The QUIC endpoint address, reported as a QUIC session's local side. #[cfg(feature = "quic")] let local = self.local_addr().ok(); + // The window a QUIC session opens to once admitted. + #[cfg(feature = "quic")] + let window = self.quic.as_ref().and_then(|quic| quic.window); // tokio::select! does not support cfg directives on arms, so we need to create the futures here. // `Some(None)` once a configured endpoint stops, which only losing its @@ -787,7 +790,7 @@ impl Server { let (request, deadline) = accept_request(&setup.server, session, deadline).await?; // Only WebTransport carries a request URL; raw QUIC puts the path in the SETUP. let transport = match url { Some(_) => Transport::WebTransport, None => Transport::Quic }; - Ok(Request { transport, url, identity, authority, link, handshake: Box::new(request), quic, deadline, active }) + Ok(Request { transport, url, identity, authority, link, handshake: Box::new(request), quic, window, quic_limits: Default::default(), deadline, active }) }.boxed()).boxed()); } } @@ -819,6 +822,10 @@ impl Server { handshake: Box::new(request), #[cfg(feature = "quic")] quic: None, + #[cfg(feature = "quic")] + window: None, + #[cfg(feature = "quic")] + quic_limits: Default::default(), deadline, active, }) @@ -995,6 +1002,10 @@ impl Listener { handshake: Box::new(request), #[cfg(feature = "quic")] quic: None, + #[cfg(feature = "quic")] + window: None, + #[cfg(feature = "quic")] + quic_limits: Default::default(), deadline, active, }) @@ -1140,6 +1151,10 @@ pub(crate) async fn accept_websocket( handshake: Box::new(request), #[cfg(feature = "quic")] quic: None, + #[cfg(feature = "quic")] + window: None, + #[cfg(feature = "quic")] + quic_limits: Default::default(), deadline, active: options.active, }) @@ -1669,6 +1684,10 @@ pub(crate) async fn accept_stream( handshake: Box::new(request), #[cfg(feature = "quic")] quic: None, + #[cfg(feature = "quic")] + window: None, + #[cfg(feature = "quic")] + quic_limits: Default::default(), deadline, active, }) @@ -1730,6 +1749,12 @@ pub struct Request { /// The connection under a QUIC or WebTransport request, for [`Request::quic`]. #[cfg(feature = "quic")] quic: Option, + /// The receive window [`Request::ok`] opens unless [`Request::with_quic_limits`] names one. + #[cfg(feature = "quic")] + window: Option, + /// What [`Request::ok`] applies to the QUIC connection before answering SETUP. + #[cfg(feature = "quic")] + quic_limits: moq_quic::Limits, /// What remains of the handshake deadline, which also bounds [`Request::ok`]. deadline: Option, } @@ -1774,13 +1799,26 @@ impl Request { self } - /// The QUIC connection under this request, to change its limits before answering - /// SETUP. `None` on a transport moq-quic does not drive (qmux streams, iroh). + /// The QUIC connection under this request, to change its limits once the session runs. + /// `None` on a transport moq-quic does not drive (qmux streams, iroh). #[cfg(feature = "quic")] pub fn quic(&self) -> Option<&crate::quic::driver::connection::Connection> { self.quic.as_ref() } + /// Set the QUIC limits [`Self::ok`] applies before answering SETUP, replacing any set + /// before. Ignored on a transport moq-quic does not drive. + /// + /// Until then the connection holds only [`moq_net::transport::SETUP_RECEIVE_WINDOW`]; + /// `ok` opens `receive_window`, or `[quic] receive_window` when it is unset, after the + /// other limits, so a `receive_rate` set here paces the opened window. + #[cfg(feature = "quic")] + pub fn with_quic_limits(mut self, limits: moq_quic::Limits) -> Result { + limits.validate()?; + self.quic_limits = limits; + Ok(self) + } + /// Finish `work` before what remains of the handshake deadline, or close the /// session with the timeout code and return [`moq_net::Error::Timeout`]. /// @@ -1800,6 +1838,14 @@ impl Request { /// on a peer that withholds flow-control credit, which keep-alives would /// otherwise hold open. pub async fn ok(self) -> crate::Result { + // Admitted: open the window the handshake held back, before SETUP OK. + #[cfg(feature = "quic")] + if let Some(quic) = &self.quic { + let mut limits = self.quic_limits; + limits.receive_window = limits.receive_window.or(self.window); + quic.set_limits(limits) + .expect("validated by with_quic_limits and the config"); + } let Request { handshake, deadline, @@ -2635,6 +2681,10 @@ mod tests { handshake: Box::new(request), #[cfg(feature = "quic")] quic: None, + #[cfg(feature = "quic")] + window: None, + #[cfg(feature = "quic")] + quic_limits: Default::default(), deadline, } .with_stats( From 7ad8d426e072a4b1f03483204ec2c9ae8d3d3ffb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 09:40:43 +0000 Subject: [PATCH 77/86] feat(net): pace relay ingest by the group's fastest reader (#7388) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- doc/bin/relay/config.md | 9 + doc/concept/moq-lite.md | 12 + quest/m1/unbounded-groups/fastest-reader.md | 65 ++- quest/m2/receive-pool.md | 9 + rs/moq-net/benches/group.rs | 58 ++- rs/moq-net/src/coding/reader.rs | 141 ++++++- rs/moq-net/src/ietf/subscriber.rs | 30 +- rs/moq-net/src/lite/subscriber.rs | 23 +- rs/moq-net/src/lite/test_transport.rs | 12 + rs/moq-net/src/model/frame.rs | 8 + rs/moq-net/src/model/group.rs | 151 +++++-- rs/moq-net/src/model/mod.rs | 2 + rs/moq-net/src/model/pace.rs | 387 ++++++++++++++++++ rs/moq-net/src/model/track.rs | 4 +- rs/moq-net/src/recv.rs | 11 + rs/moq-net/src/transport.rs | 64 +++ rs/moq-net/src/transport/boxed.rs | 248 +++++++++-- rs/moq-net/src/transport/memory.rs | 25 ++ .../tests/integration/fastest_reader.rs | 250 +++++++++++ rs/moq-net/tests/integration/main.rs | 1 + rs/moq-net/tests/integration/support/mock.rs | 23 ++ rs/moq-quic/src/connection/mod.rs | 11 + rs/moq-quic/src/connection/streams/state.rs | 27 ++ rs/moq-tokio/src/quic/driver/connection.rs | 7 + rs/moq-tokio/src/quic/driver/recv.rs | 55 +-- rs/moq-tokio/src/quic/web/recv.rs | 16 +- rs/moq-tokio/src/transport.rs | 4 + rs/moq-tokio/src/transport/grouped.rs | 6 + rs/moq-tokio/src/transport/owned.rs | 6 + rs/moq-uring/src/quic/driver/connection.rs | 6 + rs/moq-uring/src/quic/driver/recv.rs | 8 +- rs/moq-uring/src/quic/web.rs | 19 + rs/moq-uring/src/transport.rs | 7 + rs/moq-uring/src/transport/adapter.rs | 9 + rs/moq-uring/src/transport/local.rs | 8 + 35 files changed, 1601 insertions(+), 121 deletions(-) create mode 100644 rs/moq-net/src/model/pace.rs create mode 100644 rs/moq-net/tests/integration/fastest_reader.rs diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 441352ce03b..cb32e1f7e8a 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -391,6 +391,15 @@ newest 32 MB. A reader of a track's newest group that reaches a shed frame asks for a new group on tracks that opt into refresh. Any other reader of a shed frame sees `Evicted`. +While a group has readers, the relay receives at most 16 MB of it past the +fastest one, then stops reading its stream until that reader catches up, so +flow control slows the upstream hop and the publisher. A slower reader is never +waited on: one that trails the fastest by more than the rest of the 32 MB +reaches a shed frame. A group nobody reads keeps arriving and shedding. +A stopped stream keeps its receive credit, so the relay stops reading at most as +many streams per connection as fit in half of `quic.receive_window`; past that, +groups keep arriving and shedding as if unpaced. + ## \[stats] ```toml diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 4e5b157ef7d..27cbea25374 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -383,6 +383,18 @@ Put together, a conference might use: Under light congestion video drops the tail of a group; under heavy congestion video stops and audio lags by at most 500 ms. No protocol change, just knobs. +A long group is paced by its fastest reader instead. Each hop receives at most +16 MB of a group past its fastest reader, then stops reading the group's stream, +so stream flow control pushes back hop by hop to the publisher, and a reader +that keeps up loses nothing. A slower reader is never waited on: one that trails +the fastest by more than the group's 32 MB tail reaches a shed frame, and asks +for a new group on tracks that opt into [refresh](#refresh) or ends with +`Evicted`. A hop with no reader keeps receiving and shedding, so pacing never +stalls an unwatched track, and the publisher's own writes never wait unless it +asks to. A stopped stream keeps its receive credit, so a hop stops at most as +many streams per connection as fit in half its connection window, and the rest +keep receiving and shedding. + ### Refresh A publisher can opt a track into refresh by setting its byte ratio. A new diff --git a/quest/m1/unbounded-groups/fastest-reader.md b/quest/m1/unbounded-groups/fastest-reader.md index 4c87b863a70..71c725010e6 100644 --- a/quest/m1/unbounded-groups/fastest-reader.md +++ b/quest/m1/unbounded-groups/fastest-reader.md @@ -73,6 +73,18 @@ Decided 2026-10-28 (maintainer, after an audit of the plan): credit, in Rust and JS. Test it: enough parked groups to fill the connection window, with TRACK_INFO still arriving and the groups then read. +Decided 2026-10-30 (maintainer, in the +[moq-dev/moq#7388](https://github.com/moq-dev/moq/pull/7388) iteration): + +- moq-net caps the receive credit paused groups hold per session at about half + the connection window, read live from the transport + (`poll::Session::receive_window`). A group that would push the total past + the cap keeps reading and shedding instead of pausing. This stands until the + [receive pool](/quest/m2/receive-pool.md) folds paused credit into its + per-worker budget. +- The readiness future is `group::Producer::poll_ready` and `async fn ready()`, + pending until a reader exists and the 16 MB window has room. + Prior art splits the same way (2026-10-28 survey): Varnish streaming, ATS read-while-writer, and WHATWG `tee()` fan out at the writer's pace and buffer everything; Varnish `transit_buffer`, nginx unbuffered proxying, and ATS flow @@ -80,15 +92,48 @@ control pace a single client; Node `pipe()` to several destinations paces the slowest. This is `tee()` with a bounded buffer that drops the branch that falls off it ([whatwg/streams#1235](https://github.com/whatwg/streams/issues/1235)). -Open, for the implementer to measure and record: the readiness API's shape, -which is an API decision to bring to the maintainer before coding. qmux transports carry the same rule through their -own stream credit ([qmux receive lease](/quest/m2/qmux-receive-lease.md)). +Landed in [moq-dev/moq#7388](https://github.com/moq-dev/moq/pull/7388) (Rust, +session ingest only): readers report the byte end of each frame they take +(`Pace` in `rs/moq-net/src/model/pace.rs`), and the shared transport reader's +payload loop (`coding::Reader::poll_read_frame`) waits on +`group::Producer::poll_paced` before taking more, and admits a frame only once +its declared size fits the window (`coding::Reader::poll_admit`), so moq-lite +and moq-transport ingest both pause past the 16 MB window. A reader that took every +committed frame counts as waiting on the writer, so a frame larger than the +window never stalls it. A paused stream is not read, so it is watched for a +reset instead (`poll::RecvStream::poll_reset`, which moq-quic answers through +moq-tokio and moq-uring without taking bytes). Tests cover one hop on lite-06, +lite-07-wip, and moq-transport-22: an idle reader holds the relay copy near the +window and then receives every frame, a slow reader ends `Evicted` while the +fast one keeps up, a session past its credit cap reads on, and a publisher's +reset ends a paused relay copy. The same PR landed the credit +cap (each paused stream reserves its stream window from a per-session budget +in `rs/moq-net/src/transport/boxed.rs`; moq-quic reports its windows through +moq-tokio and moq-uring, and a transport that reports none never pauses) and +the public readiness future. + +What remains: -Tests: a relay chain where a fast reader paces a publisher that writes faster -than the link, with memory held near the window; a second, slower reader ending -`Evicted` without slowing the fast one; a track with no readers never blocking -its publisher; Rust and JS parity. Benchmark the hot path in `group.rs` and -record any regression. +- Receive-window reporting for the transports that report none yet (quinn, + iroh, qmux, WebTransport in the browser, and the FFI adapters), whose ingest + never pauses until they do. Each must also answer + `poll::RecvStream::poll_reset`, or a reset goes unseen while paused. +- A sized readiness check for local writers: `group::Producer::ready()` + waits for room but not for the next frame's size, so a local frame larger + than the room left still sheds what the egress has yet to send. +- JS parity. +- A two-relay chain test with memory measured near the window. +- A quiet-host comparison against the base of `group_read_frames`, + `group_contended`, and `group_relay_frames`, plus `group_paced` (a writer + parked at the window, over 1, 8, and 64 readers), which the first slice + added. The cursor's report is one relaxed load per frame or batch, and a + `fetch_max` only for the fastest reader. On the busy host that landed it, + runs swung by up to 50% either way between repeats. +- Fills that write a whole batch of groups at once evict their own earlier + groups before the reader takes them (the follow-up from + [moq-dev/moq#7339](https://github.com/moq-dev/moq/pull/7339)). Pacing within + a group does not cover this; decide whether it belongs here or with + [flights](/quest/m1/unbounded-groups/flights.md). -Docs: the moq-lite concept page's congestion section and the relay cache docs -change inline. +qmux transports carry the same rule through their own stream credit +([qmux receive lease](/quest/m2/qmux-receive-lease.md)). diff --git a/quest/m2/receive-pool.md b/quest/m2/receive-pool.md index ee1333748f4..25af3e931dc 100644 --- a/quest/m2/receive-pool.md +++ b/quest/m2/receive-pool.md @@ -46,6 +46,15 @@ Open, for the implementer to bring to the maintainer: to move to the cache without a window where the bytes are billed twice or not at all. +Idea for later (maintainer, 2026-10-30, from the +[fastest-reader pacing](/quest/m1/unbounded-groups/fastest-reader.md) iteration +in [moq-dev/moq#7388](https://github.com/moq-dev/moq/pull/7388)): instead of +moq-quic granting `MAX_STREAM_DATA` automatically as a stream is read, the +application could pause a stream, or grant it an explicit +`allow(fastest + 16 MB)`, so flow control never grants credit past the pacing +window. Paused streams would then hold no credit beyond that window, replacing +moq-net's per-session cap on paused credit. + Lessons from other stacks: QUICHE frees 8 KiB blocks as they are consumed, msquic never advertises credit it could not back, and quinn and lsquic both let a slice pin its packet buffer. #5878 already handles the last with compaction. diff --git a/rs/moq-net/benches/group.rs b/rs/moq-net/benches/group.rs index 6b838ff303c..7ca89ed39f0 100644 --- a/rs/moq-net/benches/group.rs +++ b/rs/moq-net/benches/group.rs @@ -22,6 +22,10 @@ //! holds (a small pool's debt, or the 32 MiB live tail) and the frames it sheds, so //! shedding the front stays a flat cost per frame. //! +//! `group_paced` keeps a writer one frame past the 16 MiB window ahead of its fastest +//! reader, as a paced ingest runs, swept over readers: each frame read reports progress, +//! and the fastest reader's read wakes the parked writer. +//! //! Run with `cargo bench -p moq-net`. #![expect( @@ -758,6 +762,57 @@ fn bench_shed(c: &mut Criterion) { g.finish(); } +/// Counts wakes, so a bench can check that reads let a parked writer on. +#[derive(Default)] +struct Wakes(AtomicU64); + +impl std::task::Wake for Wakes { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +/// A writer held at the pacing window: each round it polls [`group::Producer::poll_ready`], +/// parking whenever it is past the window, every reader takes one frame, and the writer +/// writes the next. A reader reports a prefetched batch at once, so the fastest one's batch +/// read wakes the parked writer. Throughput counts frames read, so the per-read progress +/// report and the park and wake show up together, and the readers trailing the fastest one +/// as a slope across readers. +fn bench_paced(c: &mut Criterion) { + // Payloads are shared clones, so a megabyte costs a refcount and the window is 16 frames. + let payload = Bytes::from(vec![0u8; 1 << 20]); + let mut g = c.benchmark_group("group_paced"); + for readers in [1, 8, 64] { + g.throughput(Throughput::Elements(readers as u64)); + g.bench_function(BenchmarkId::from_parameter(readers), |b| { + let mut ctx = fresh_group(); + let mut consumers: Vec<_> = (0..readers).map(|_| ctx.group.consume()).collect(); + // One frame past the window, so the writer is paced from the first round. + for _ in 0..17 { + ctx.group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + } + let wakes = Arc::new(Wakes::default()); + let writer = kio::Waiter::new(wakes.clone().into()); + let reader = kio::Waiter::noop(); + let mut parked = 0u64; + b.iter(|| { + parked += ctx.group.poll_ready(&writer).is_pending() as u64; + for consumer in &mut consumers { + let Poll::Ready(Ok(Some(frame))) = consumer.poll_read_frame(&reader) else { + panic!("a frame is ready"); + }; + black_box(frame); + } + ctx.group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + }); + // A writer still paced parks again on the next round, so wakes trail parks. + assert!(parked > 0, "the writer never parked"); + assert!(wakes.0.load(Ordering::Relaxed) > 0, "no read woke the writer"); + }); + } + g.finish(); +} + criterion_group!( benches, bench_write, @@ -768,6 +823,7 @@ criterion_group!( bench_relay_frames, bench_inflate, bench_contended, - bench_shed + bench_shed, + bench_paced ); criterion_main!(benches); diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index eb47277624f..0457e4e9f97 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -40,6 +40,31 @@ pub struct Reader { /// enough that an ordinary burst is still one wake. const WAKE_BUDGET: usize = 64 * 1024; +/// A stream ingest may stop reading to pace a group's writer, which holds its receive +/// credit meanwhile. +pub(crate) trait Pause { + /// Reserve the credit this stream may hold while paused, returning whether it may pause. + /// A stream outside any session always may. + fn pause(&mut self) -> bool { + true + } + + /// Return what [`Self::pause`] reserved, as the stream is read again. + fn resume(&mut self) {} +} + +impl Pause for crate::transport::boxed::RecvStream { + fn pause(&mut self) -> bool { + crate::transport::boxed::RecvStream::pause(self) + } + + fn resume(&mut self) { + crate::transport::boxed::RecvStream::resume(self) + } +} + +impl Pause for crate::transport::poll::Buffered {} + /// The largest frame [`Reader::poll_read_frame`] copies out of a chunk holding all of it, /// rather than caching the chunk as it arrived. /// @@ -370,7 +395,9 @@ impl Reader { } } - /// Fill a frame's payload from the stream, returning `Pending` once it would block. + /// Fill a frame's payload from the stream, returning `Pending` once it would block or + /// the group's writer is paced by its fastest reader (see + /// [`crate::frame::Producer::poll_ready`]) and the stream may pause (see [`Pause`]). /// /// The payload reaches the frame as it arrives, but keeps its receive credit until the /// segment it lands in seals, so an incomplete frame holds at most one segment of the @@ -392,12 +419,22 @@ impl Reader { &mut self, waiter: &kio::Waiter, frame: &mut crate::frame::Producer, - ) -> Poll> { + ) -> Poll> + where + S: Pause, + { self.want = 0; let mut owed = 0; let result = loop { - let mut lease = match self.poll_lease(waiter, frame.remaining().min(1)) { + // A writer too far ahead of the group's fastest reader leaves the rest in the + // stream, so flow control pushes back upstream. + let lease = match self.poll_pace(waiter, frame.poll_ready(waiter)) { + Poll::Ready(Ok(())) => self.poll_lease(waiter, frame.remaining().min(1)), + Poll::Ready(Err(err)) => break Err(err), + Poll::Pending => Poll::Pending, + }; + let mut lease = match lease { Poll::Pending => { if owed > 0 { frame.notify(); @@ -457,6 +494,46 @@ impl Reader { Poll::Ready(result) } + /// Poll until `group`'s writer may admit the frame `header` opens from this stream: until + /// that leaves no byte its fastest reader has yet to take for the live tail to shed. + /// + /// Pacing before the frame exists matters because admitting it trims the tail to fit + /// its declared size, so a large frame would otherwise shed what that reader still wants. + /// An error means the stream failed while paused. + pub(crate) fn poll_admit( + &mut self, + waiter: &kio::Waiter, + group: &crate::recv::Group, + header: &crate::frame::Header, + ) -> Poll> + where + S: Pause, + { + self.poll_pace(waiter, group.poll_admit(waiter, header)) + } + + /// Wait out `paced`, the group's pace gate, if the session has room for the credit this + /// stream holds while paused (see [`Pause`]); otherwise read on, and the group sheds. + /// + /// A paused stream is not read, so it is watched for a reset instead, which fails the + /// wait rather than leaving it to the group's reader to notice. + fn poll_pace(&mut self, waiter: &kio::Waiter, paced: Poll<()>) -> Poll> + where + S: Pause, + { + match paced { + Poll::Ready(()) => { + self.stream.resume(); + Poll::Ready(Ok(())) + } + Poll::Pending if self.stream.pause() => { + let err = ready!(self.stream.poll_reset(waiter)); + Poll::Ready(Err(self.version.transport_error(err))) + } + Poll::Pending => Poll::Ready(Ok(())), + } + } + /// Poll for exactly `size` bytes. pub fn poll_read_exact(&mut self, waiter: &kio::Waiter, size: usize) -> Poll> { self.want = 0; @@ -691,11 +768,12 @@ mod tests { /// Hands back one queued chunk per poll, then blocks: a stream whose socket has /// delivered a burst and has nothing more for this turn. Finishes once `fin` is set and - /// the queue is empty. + /// the queue is empty, and reports a reset once `reset` is set. #[derive(Default)] struct Queue { chunks: VecDeque<&'static [u8]>, fin: bool, + reset: bool, } impl Chunks for Queue { @@ -719,13 +797,20 @@ mod tests { } fn stop(&mut self, _code: u32) {} + + fn poll_reset(&mut self, _: &kio::Waiter) -> Poll { + match self.reset { + true => Poll::Ready(Default::default()), + false => Poll::Pending, + } + } } fn queue(chunks: &[&'static [u8]], version: V) -> Reader, V> { Reader::new( Buffered::new(Queue { chunks: chunks.iter().copied().collect(), - fin: false, + ..Default::default() }), version, ) @@ -906,6 +991,48 @@ mod tests { )); } + /// A group 17 MB past an idle reader, so its writer is paced. + fn paced_group() -> (crate::group::Producer, crate::group::Consumer) { + let mut group = crate::group::Info { sequence: 0 }.produce(); + let idle = group.consume(); + let payload = Bytes::from(vec![0u8; 1 << 20]); + for _ in 0..17 { + group.write_frame(crate::Timestamp::ZERO, payload.clone()).unwrap(); + } + (group, idle) + } + + /// A paused stream is not read, so it is watched for a reset instead: one ends the wait + /// at once, whether a frame waits to be admitted or to fill, rather than once the + /// group's reader moves. + #[test] + fn a_reset_ends_a_paused_stream() { + let noop = &kio::Waiter::noop(); + + let (group, _idle) = paced_group(); + let group = crate::recv::Group::new(group); + let header = crate::frame::Header::from(crate::frame::Info { + size: 1, + timestamp: Some(crate::Timestamp::ZERO), + }); + let mut reader = queue(&[], crate::lite::Version::Lite05); + assert!(reader.poll_admit(noop, &group, &header).is_pending(), "paused to admit"); + reader.stream.get_mut().reset = true; + assert!(matches!(reader.poll_admit(noop, &group, &header), Poll::Ready(Err(_)))); + + let (group, _idle) = paced_group(); + let mut frame = group + .create_frame(crate::frame::Info { + size: 1, + timestamp: Some(crate::Timestamp::ZERO), + }) + .unwrap(); + let mut reader = queue(&[b"x"], crate::lite::Version::Lite05); + assert!(reader.poll_read_frame(noop, &mut frame).is_pending(), "paused to fill"); + reader.stream.get_mut().reset = true; + assert!(matches!(reader.poll_read_frame(noop, &mut frame), Poll::Ready(Err(_)))); + } + /// A frame that arrives whole in a larger chunk, as in a datagram it shares with other /// frames, is copied out of it when it is at most [`COPY_WHOLE`], so caching it does not /// keep the rest of the receive allocation alive. A larger one is cached as the chunk. @@ -1008,7 +1135,7 @@ mod tests { Buffered::new(Burst { chunks: Queue { chunks: std::iter::repeat_n(CHUNK, chunks).collect(), - fin: false, + ..Default::default() }, payload, waiter, @@ -1033,6 +1160,8 @@ mod tests { struct CreditedLease<'a>(&'a mut Credited); + impl Pause for Credited {} + impl RecvStream for Credited { type Error = crate::lite::test_transport::SinkError; type Lease<'a> = CreditedLease<'a>; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 79cca9bdd1f..b6fd48fe08d 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -5110,6 +5110,9 @@ enum IngestPhase { Size { timestamp: Option }, /// Reading the status of an empty object. Status { timestamp: Option }, + /// Waiting to admit the object, or an empty one's properties, until the group's + /// fastest reader leaves room for it. + Admit { header: frame::Header }, /// Streaming the object payload. Payload { frame: frame::Producer }, /// An explicit end-of-group or end-of-track status arrived. @@ -6027,11 +6030,12 @@ impl FetchPayloadRecv { properties: decoded.properties, }) }); - let header = frame::Header { + let header: frame::Header = frame::Header { info: frame::Info { size, timestamp }, fields, inflated: super::object::inflated(decoded.inflated, producer.compressed())?, }; + wait!(Admit => stream.poll_admit(waiter, producer, header))?; let mut frame: crate::frame::Producer = producer.create_object(header)?; let read: Result<(), Error> = wait!(Read => stream.poll_read_frame(waiter, frame)); if let Err(err) = read { @@ -6187,7 +6191,7 @@ impl GroupIngest { /// `Ready(Ok(_))` once the stream FINs on an object boundary, or an explicit /// end-of-group or end-of-track status arrives. The caller finishes or aborts the /// group; an object cut short mid-payload was already aborted here with the reason. - pub(crate) fn poll( + pub(crate) fn poll( &mut self, reader: &mut Reader, group: &mut crate::recv::Group, @@ -6237,22 +6241,32 @@ impl GroupIngest { self.phase = IngestPhase::Status { timestamp: *timestamp }; continue; } + let timestamp = object_time(self.timescale, *timestamp)?; + let header = self.header(group, frame::Info { size, timestamp })?; + self.phase = IngestPhase::Admit { header }; + } + IngestPhase::Admit { header } => { + ready!(reader.poll_admit(waiter, group, header))?; + let IngestPhase::Admit { header } = std::mem::replace(&mut self.phase, IngestPhase::Delta) else { + unreachable!() + }; // `create_object` is the allocation chokepoint: it rejects an oversized // `size` and allocates up front only within the origin's budget, so no // pre-check is needed. - let timestamp = object_time(self.timescale, *timestamp)?; - let header = self.header(group, frame::Info { size, timestamp })?; + let empty = header.info.size == 0; let frame = group.create_object(header)?; - self.phase = IngestPhase::Payload { frame }; + match empty { + // An empty object has no payload to read, only properties to admit. + true => group.restore(frame.finish()?), + false => self.phase = IngestPhase::Payload { frame }, + } } IngestPhase::Status { timestamp } => { let status = ready!(reader.poll_varint(waiter))?; if status == 0 { let timestamp = object_time(self.timescale, *timestamp)?; let header = self.header(group, frame::Info { size: 0, timestamp })?; - let frame = group.create_object(header)?; - group.restore(frame.finish()?); - self.phase = IngestPhase::Delta; + self.phase = IngestPhase::Admit { header }; continue; } // A status object has no frame to hold its time, so its block keeps it. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 1ddef7bac5a..bb5a71c72e3 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1400,6 +1400,8 @@ enum IngestPhase { timestamp: Option, inflated: Option, }, + /// Waiting to admit the frame until the group's fastest reader leaves room for it. + Admit { header: frame::Header }, /// Streaming the frame payload. Payload { frame: frame::Producer }, } @@ -1445,7 +1447,7 @@ impl FrameIngest { /// `Ready(Ok(()))` once the stream FINs on a frame boundary. The caller /// finishes or aborts the group; a frame cut short mid-payload was already /// aborted here with the reason. - pub(crate) fn poll( + pub(crate) fn poll( &mut self, reader: &mut Reader, group: &mut crate::recv::Group, @@ -1585,16 +1587,25 @@ impl FrameIngest { } None => None, }; + self.phase = IngestPhase::Admit { + header: frame::Header { + info: frame::Info { size, timestamp }, + fields, + inflated, + }, + }; + } + IngestPhase::Admit { header } => { + ready!(reader.poll_admit(waiter, group, header))?; + let IngestPhase::Admit { header } = std::mem::replace(&mut self.phase, IngestPhase::Timing) else { + unreachable!() + }; // `create_object` is the allocation chokepoint: it rejects an oversized // `size` and allocates up front only within the origin's budget, so no // pre-check is needed. No wire timestamp (pre-lite-05) means an untimed // frame. A compressed frame stores the slice as it arrives, its inflated // size checked against the same limits before anything inflates. - let frame = group.create_object(frame::Header { - info: frame::Info { size, timestamp }, - fields, - inflated, - })?; + let frame = group.create_object(header)?; self.phase = IngestPhase::Payload { frame }; } IngestPhase::Payload { frame } => { diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 231559b8777..5071888d88e 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -616,6 +616,8 @@ pub struct SinkSession { stats: Arc>, /// Set by [`Self::with_probe`]. probe: Option, + /// Set by [`Self::with_receive_window`]. + receive_window: Option, } impl SinkSession { @@ -645,6 +647,12 @@ impl SinkSession { self } + /// Report `window` as the receive windows in force. + pub fn with_receive_window(mut self, window: crate::transport::ReceiveWindow) -> Self { + self.receive_window = Some(window); + self + } + /// Give the transport a sender for the session to hand its probe target. pub fn with_probe(mut self) -> Self { self.probe = Some(crate::transport::Probe::new()); @@ -830,6 +838,10 @@ impl poll::Session for SinkSession { fn probe(&self) -> Option { self.probe.clone() } + + fn receive_window(&self) -> Option { + self.receive_window + } } /// Connection statistics a test can dictate. Every metric defaults to unknown, diff --git a/rs/moq-net/src/model/frame.rs b/rs/moq-net/src/model/frame.rs index 497243e03e9..b3c8ca71a01 100644 --- a/rs/moq-net/src/model/frame.rs +++ b/rs/moq-net/src/model/frame.rs @@ -1199,6 +1199,14 @@ impl Producer { } } + /// Poll until the group's writer may take more of this frame from upstream; see + /// [`group::Producer::poll_paced`]. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn poll_ready(&self, waiter: &kio::Waiter) -> Poll<()> { + self.group() + .poll_paced(waiter, self.buf.written(Ordering::Acquire) as u64) + } + /// Publish what has been written so far, waking consumers parked on the group. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn notify(&self) { diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 9660405062d..719acf55121 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -19,6 +19,7 @@ //! //! The stream is closed with [Error] when all writers or readers are dropped. use super::flate; +use super::pace::Pace; mod content; use crate::cache; use crate::frame::{self, Cached, FrameBuf, Payload}; @@ -57,6 +58,9 @@ pub(super) const CACHE_OVERHEAD: u64 = (kio::Producer::::HEAP // straight after. + 2 * size_of::() + size_of::() + // The `Pace` its writer and readers share, behind its own `Arc`. + + 2 * size_of::() + + size_of::() + kio::log::Writer::::HEAP) as u64; /// A group contains a sequence number because they can arrive out of order. @@ -143,6 +147,8 @@ pub(super) struct Entry { fields: Option>, // A compressed group's inflated size of the stored slice; zero on a plain group. inflated: u64, + // Where the frame ends on its copy's [`Pace`] scale, set as it enters the log. + end: u64, } impl Clone for Entry { @@ -152,6 +158,7 @@ impl Clone for Entry { frame: self.frame.clone(), fields: self.fields.clone(), inflated: self.inflated, + end: self.end, } } } @@ -164,6 +171,7 @@ impl Entry { frame, fields: None, inflated, + end: 0, } } @@ -258,6 +266,9 @@ pub(super) struct GroupState { // Whether an adopted head (see [`Handle::adopt_head`]) begins at the group's first // object, for a wire header that arrives after it. adopted: Option, + + // How far readers got, which each frame entering the log is measured against. + pace: Arc, } /// The frames a copy of a group holds, from where it starts, which a successor copy of @@ -652,6 +663,7 @@ impl Inflate { }, fields: slice.fields.clone(), inflated: 0, + end: slice.end, }; let cost = Plain::cost(&entry); self.plain.bill.add(cost); @@ -871,13 +883,15 @@ impl GroupState { } let base = self.log.base() as usize; if base < end { - let (mut charged, mut payload, mut fields) = (0, 0, 0); + let (mut charged, mut payload, mut fields, mut shed) = (0, 0, 0, 0); for entry in (base..end).map(|index| self.log.get(index as u64).expect("committed below the end")) { + shed = entry.end; charged += self.entry_bytes(entry); payload += entry.frame.payload.len() as u64; fields += entry.fields.as_ref().map_or(0, |fields| fields.heap()); } self.forget(payload, fields); + self.pace.shed(shed); let heap = kio::log::Writer::::HEAP; let before = self.log.heap().max(heap); self.log.evict(end as u64); @@ -953,8 +967,15 @@ impl GroupState { } /// Append a completed frame, returning the slot growth it charged. - fn push(&mut self, entry: Entry) -> u64 { + /// + /// The frame's place on the pace scale costs its payload, metadata, and slot, as the + /// live tail does, so a paced writer of tiny frames is bounded too. + fn push(&mut self, mut entry: Entry) -> u64 { let slots = self.reserve_frame(); + let metadata = entry.fields.as_ref().map_or(0, |fields| fields.heap()); + entry.end = self + .pace + .commit(entry.frame.payload.len() as u64 + metadata + size_of::() as u64); self.log.push(entry); slots } @@ -1062,6 +1083,7 @@ impl GroupState { /// /// A compressed group hands a plain reader each frame whole once it completes, as the /// slices to inflate, and a `raw` one the stored slice, streaming one still arriving. + /// A frame handed out counts as taken, pacing the writer (see [`Pace`]). fn poll_frame_source(&self, index: usize, raw: bool) -> Poll>> { self.check_read(index, raw)?; if index < self.held() @@ -1075,6 +1097,7 @@ impl GroupState { // walk spare a group a consumer is actively draining. self.charge.refresh(); if let Some(flate) = self.flate.as_ref().filter(|_| !raw) { + self.pace.took(entry.end); return Poll::Ready(Ok(Some(Source::Inflate { job: self.inflate(flate, index, index)?, local: index, @@ -1083,6 +1106,7 @@ impl GroupState { }))); } let (header, source) = entry.read(self.flate.is_some()); + self.pace.took(entry.end); return Poll::Ready(Ok(Some(Source::Ready(header, source)))); } if index == self.committed() @@ -1091,6 +1115,8 @@ impl GroupState { && (raw || self.flate.is_none()) { self.charge.refresh(); + // A reader streaming the open frame is at the writer's edge. + self.pace.took_committed(); let info = frame::Info { size: p.buf.size() as u64, timestamp: p.timestamp, @@ -1119,7 +1145,14 @@ impl GroupState { (Some(total), Some(err)) if index < total => Poll::Ready(Err(err.clone())), (Some(_), _) => Poll::Ready(Ok(())), (None, Some(err)) => Poll::Ready(Err(err.clone())), - (None, None) => Poll::Pending, + (None, None) => { + // Waiting on frames the writer has yet to commit, wherever the reader skipped + // to, so it holds the writer back from none of them. + if index >= self.committed() { + self.pace.took_committed(); + } + Poll::Pending + } } } @@ -1446,6 +1479,8 @@ struct Alive { // A reader of the frame log, cloned into each cursor so it reads frames without the // group lock. log: kio::log::Reader, + // The `GroupState::pace` cursors report their reads to. + pace: Arc, // When the group was created here, on the ambient clock; see [`Consumer::arrived`]. arrived: crate::time::Instant, } @@ -1558,7 +1593,7 @@ impl Producer { let state = kio::Producer::::default(); let charge = cache.charge(); let access = charge.access(); - let log = { + let (log, pace) = { let mut state = state.write().ok().expect("a new group is open"); if track.compress { state.flate = Some(Box::new(Flate::new( @@ -1567,7 +1602,7 @@ impl Producer { ))); } state.charge = charge; - state.log.reader() + (state.log.reader(), state.pace.clone()) }; let alive = Arc::new(Alive { aborted: AtomicBool::new(false), @@ -1577,6 +1612,7 @@ impl Producer { started: AtomicBool::new(false), access, log, + pace, arrived: super::clock::now(), }); Self { @@ -2090,6 +2126,7 @@ impl Producer { frame, fields, inflated, + end: 0, }; if !owned { state.canonicalize(state.next_index - 1, &mut entry)?; @@ -2181,6 +2218,52 @@ impl Producer { pub async fn closed(&self) -> Result<()> { kio::wait(|waiter| self.handle.poll_closed(waiter)).await } + /// Poll until a reader holds the group and the writer is at most 16 MB past the end of + /// the last frame its fastest reader took, so a local writer runs no further ahead than + /// the live tail keeps behind that reader. + /// + /// Ready once the group is closed, so the next write surfaces why. A reader that took + /// every committed frame is waiting on the writer, so it never holds the writer back, + /// and one whose next frame was shed has fallen off the group and is not waited on. + /// Writing without waiting never fails: the group sheds its front instead, as it also + /// does for a frame larger than the room left, which trims the tail to fit. + pub fn poll_ready(&self, waiter: &kio::Waiter) -> Poll<()> { + ready!(self.poll_paced(waiter, 0)); + // A closed group reports `Closed`, which is ready too. + self.handle.state.poll_wanted_changed(waiter, false).map(|_| ()) + } + + /// Wait until a reader holds the group and the writer has room; see [`Self::poll_ready`]. + pub async fn ready(&self) { + kio::wait(|waiter| self.poll_ready(waiter)).await + } + + /// Poll until the writer may take more from upstream, `partial` bytes into a frame + /// it has not committed: until it is at most [`super::pace::AHEAD`] past the end of the last + /// frame its fastest reader took. + /// + /// Ready at once while no reader holds the group, so a group nobody watches keeps + /// writing and sheds its front, and once it is closed, so the next write surfaces why. + /// A reader that took every committed frame is waiting on the writer, so a frame + /// larger than the window never stalls it, and one whose next frame was shed has + /// fallen off the group and is not waited on. + /// + /// A session's ingest waits on this; a local writer waits on [`Self::poll_ready`]. + pub(crate) fn poll_paced(&self, waiter: &kio::Waiter, partial: u64) -> Poll<()> { + let pace = &self.handle.alive.pace; + if !pace.ahead(partial) { + return Poll::Ready(()); + } + // Either wakes the writer: the last reader leaving, or the group closing. + if self.handle.poll_unwanted(waiter).is_ready() || self.handle.state.poll_closed(waiter).is_ready() { + return Poll::Ready(()); + } + match pace.paced(pace.park(waiter), partial) { + true => Poll::Pending, + false => Poll::Ready(()), + } + } + /// The non-writing observer used by the cache and built-in sessions. // Sessions need an abortable observer while public callers use Demand. pub(crate) fn handle(&self) -> Handle { @@ -2395,6 +2478,8 @@ impl Handle { if state.canonicalize(index, &mut entry).is_err() { return; } + // Below the log, so off this copy's pace scale: taking it reports nothing. + entry.end = 0; adopted.push(entry); } // Below the log's first frame, so these never enter it: readers find them here. @@ -2790,7 +2875,7 @@ impl Handle { raw: false, compressed: self.track.compress, cache: self.cache.clone(), - access: self.alive.access.clone(), + alive: self.alive.clone(), refreshed: self.cache.pool().now(), }, // Untagged: a tagged track attaches the egress meter via `with_meter` @@ -3042,8 +3127,9 @@ impl Prefetch { } } - /// Buffer `entry` at the end of the batch, with its inflated size if `inflated`. - fn push(&mut self, entry: &Entry, inflated: bool) { + /// Buffer `entry` at the end of the batch, with its inflated size if `inflated`, + /// returning where it ends on the group's pace scale. + fn push(&mut self, entry: &Entry, inflated: bool) -> u64 { let at = self.len; let frames = self .frames @@ -3074,6 +3160,7 @@ impl Prefetch { self.inflated.get_or_insert_default()[at] = entry.inflated; } self.len += 1; + entry.end } /// Refill with up to `budget` of the completed frames from `index` on, under the @@ -3110,25 +3197,39 @@ impl Prefetch { Some(flate) if !raw && count > 0 => Some(state.inflate(flate, index, index + count - 1)?), _ => None, }; + let mut end = 0; for entry in state.entries(index, index + count) { - self.push(entry, compressed && raw); + end = self.push(entry, compressed && raw); } + // The batch is the reader's whatever the group sheds, so it counts as taken. + state.pace.took(end); Ok(job) } /// Refill with up to `budget` of the completed frames from `index` on, reading the /// log without the group's guard, and return how many. A plain read of a `compressed` /// group inflates under the guard instead, through [`Self::fill`]. - fn fill_log(&mut self, log: &mut kio::log::Reader, index: usize, budget: usize, compressed: bool) -> usize { + /// + /// The batch is the reader's whatever the group sheds, so it reports it taken to `pace`. + fn fill_log( + &mut self, + log: &mut kio::log::Reader, + pace: &Pace, + index: usize, + budget: usize, + compressed: bool, + ) -> usize { self.reset(compressed); let mut read = log.read(); + let mut end = 0; for index in (index as u64..).take(budget.min(Self::CAP)) { // Evicted or not yet committed: the guard resolves which. let Ok(Some(entry)) = read.get(index) else { break; }; - self.push(entry, compressed); + end = self.push(entry, compressed); } + pace.took(end); self.len } @@ -3274,9 +3375,10 @@ struct Cursor { // Whether the group is compressed, so a plain read inflates under the state's lock. compressed: bool, - // Record prefetched reads without entering the group's state on every frame. + // Record prefetched reads without entering the group's state on every frame, through + // the group's access stamp, and report the frames taken to its pace. cache: Arc, - access: Arc, + alive: Arc, refreshed: u64, } @@ -3293,7 +3395,7 @@ impl Clone for Cursor { raw: self.raw, compressed: self.compressed, cache: self.cache.clone(), - access: self.access.clone(), + alive: self.alive.clone(), refreshed: self.refreshed, } } @@ -3893,7 +3995,7 @@ impl Cursor { /// Record prefetched reads, updating the eviction rank once per sampled tick. fn refresh_if_stale(&mut self) { - self.access.touch(); + self.alive.access.touch(); let tick = self.cache.pool().now(); if tick != self.refreshed { self.state.read().charge.refresh(); @@ -3977,13 +4079,10 @@ impl Cursor { // A committed frame comes from the log without the lock, unless a plain read of a // compressed group has to inflate it under the lock. let committed = match !self.compressed || raw { - true => self - .log - .read() - .get(index as u64) - .ok() - .flatten() - .map(|entry| entry.read(self.compressed)), + true => self.log.read().get(index as u64).ok().flatten().map(|entry| { + self.alive.pace.took(entry.end); + entry.read(self.compressed) + }), false => None, }; let (info, source) = match committed { @@ -4054,7 +4153,12 @@ impl Cursor { // The batch is drained: refill it from the log without the lock, unless a plain // read of a compressed group has to inflate under it. - if (!self.compressed || self.raw) && self.prefetch.fill_log(&mut self.log, index, budget, self.compressed) > 0 { + if (!self.compressed || self.raw) + && self + .prefetch + .fill_log(&mut self.log, &self.alive.pace, index, budget, self.compressed) + > 0 + { self.refresh_if_stale(); let (frames, bytes) = self.prefetch.buffered(); stats.frames(frames); @@ -4854,6 +4958,7 @@ mod test { frame: Frame::new(None, Bytes::from_static(b"b")).into(), fields: fields(1), inflated: 0, + end: 0, }, ], compressed: false, diff --git a/rs/moq-net/src/model/mod.rs b/rs/moq-net/src/model/mod.rs index 00a715a1efb..9123fd25f95 100644 --- a/rs/moq-net/src/model/mod.rs +++ b/rs/moq-net/src/model/mod.rs @@ -23,6 +23,8 @@ pub(crate) mod datagram; mod delivered; mod expiry; mod skipped; +// How far a group's writer reads ahead of its fastest reader. +mod pace; // The DEFLATE codec behind compressed tracks. mod flate; pub(crate) mod object; diff --git a/rs/moq-net/src/model/pace.rs b/rs/moq-net/src/model/pace.rs new file mode 100644 index 00000000000..bb1ca8ff938 --- /dev/null +++ b/rs/moq-net/src/model/pace.rs @@ -0,0 +1,387 @@ +//! Fastest-reader pacing: how far a group's writer reads ahead of its furthest reader. +//! +//! Readers report the frames they take; a paced writer (a session's ingest) stops reading +//! its upstream stream while it is more than [`AHEAD`] past the end of the last frame the +//! fastest reader took, so stream flow control pushes back to the publisher. Only the +//! fastest reader counts: a slower one is never waited on, and falls off the group's +//! front as the live tail sheds. +//! +//! Progress is in bytes on one scale per group copy: each frame entering the copy's log +//! ends where the frames before it, payload and slot, add up to. A frame larger than the +//! window never stalls its writer, since a reader that took every committed frame is +//! waiting on the writer. + +use std::sync::{ + Mutex, PoisonError, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; +use std::task::Waker; + +use super::group::MAX_CACHE_BYTES; + +/// How far a paced writer runs ahead of its group's fastest reader: half the live tail, +/// so the other half stays behind the fastest reader for slower ones. +pub(super) const AHEAD: u64 = MAX_CACHE_BYTES / 2; + +/// One group copy's reader progress, shared by its writer and every cursor. +#[derive(Default)] +pub(super) struct Pace { + // Where the last frame the fastest reader took ends. + read: AtomicU64, + // Where the last committed frame ends. + written: AtomicU64, + // Where the last frame shed from the copy's front ends: a reader short of it fell off. + front: AtomicU64, + // Set while the writer waits on `read`, so a read wakes it without locking otherwise. + parked: AtomicBool, + // The writer's waker: a group has one writer, so one slot, far smaller than a list. + writer: Mutex>, +} + +impl Pace { + /// A reader took the frame ending at `end`, and every frame before it. + pub fn took(&self, end: u64) { + // The common case, a reader trailing the fastest one, writes nothing. + if self.read.load(Ordering::Relaxed) >= end { + return; + } + // SeqCst pairs with `park`: either this sees the writer parked, or it sees the read. + self.read.fetch_max(end, Ordering::SeqCst); + self.wake(); + } + + /// Wake the writer, if parked, since what it waits on changed. + fn wake(&self) { + // A load first, so a writer not parked costs a read rather than a locked swap; still + // SeqCst, to stay in the order `park` pairs with. + if self.parked.load(Ordering::SeqCst) && self.parked.swap(false, Ordering::SeqCst) { + let writer = self.writer.lock().unwrap_or_else(PoisonError::into_inner).take(); + if let Some(writer) = writer { + writer.wake(); + } + } + } + + /// A reader took every frame committed so far, such as by taking the open one. + pub fn took_committed(&self) { + self.took(self.written()); + } + + /// Count a committed frame costing `cost`, returning where it ends. + /// + /// Called under the group's lock, so commits never race. + pub fn commit(&self, cost: u64) -> u64 { + let end = self.written() + cost; + self.written.store(end, Ordering::Release); + end + } + + /// The copy shed every frame up to the one ending at `end`, which may leave the + /// fastest reader fallen off, so the writer no longer waits on it. + pub fn shed(&self, end: u64) { + // SeqCst pairs with `park`, as a read does. + self.front.fetch_max(end, Ordering::SeqCst); + self.wake(); + } + + fn written(&self) -> u64 { + self.written.load(Ordering::Acquire) + } + + /// Whether a writer `partial` bytes into an uncommitted frame is more than [`AHEAD`] + /// past the fastest reader, given it read up to `read`. + /// + /// Never while that reader took every committed frame, since it is waiting on the + /// writer, nor once the frames it has yet to take were shed, since it fell off. + pub fn paced(&self, read: u64, partial: u64) -> bool { + let written = self.written(); + read < written && read >= self.front.load(Ordering::SeqCst) && written.saturating_add(partial) - read > AHEAD + } + + /// Whether the writer is paced, as [`Self::paced`] judges it now. + pub fn ahead(&self, partial: u64) -> bool { + self.paced(self.read.load(Ordering::SeqCst), partial) + } + + /// Register `waiter` for the next read or shed, returning how far readers got, read + /// after registering so a read in between is never missed; [`Self::paced`] reads the + /// front after it too. + pub fn park(&self, waiter: &kio::Waiter) -> u64 { + let mut writer = self.writer.lock().unwrap_or_else(PoisonError::into_inner); + match &mut *writer { + Some(waker) => waker.clone_from(waiter.waker()), + None => *writer = Some(waiter.waker().clone()), + } + drop(writer); + self.parked.store(true, Ordering::SeqCst); + self.read.load(Ordering::SeqCst) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::task::Poll; + + use bytes::Bytes; + + use super::*; + use crate::{Error, frame, group}; + + const MB: usize = 1 << 20; + + struct Flag(AtomicBool); + + impl std::task::Wake for Flag { + fn wake(self: Arc) { + self.0.store(true, Ordering::SeqCst); + } + } + + impl Flag { + fn woken(&self) -> bool { + self.0.swap(false, Ordering::SeqCst) + } + } + + fn flag() -> (Arc, kio::Waiter) { + let flag = Arc::new(Flag(AtomicBool::new(false))); + let waiter = kio::Waiter::new(std::task::Waker::from(flag.clone())); + (flag, waiter) + } + + /// An untimed group with `frames` frames of a megabyte each. + fn written(frames: usize) -> group::Producer { + let mut group = group::Info { sequence: 0 }.produce_untimed(); + let payload = Bytes::from(vec![0u8; MB]); + for _ in 0..frames { + group.write_frame(None, payload.clone()).unwrap(); + } + group + } + + fn read(reader: &mut group::Consumer) { + let Poll::Ready(Ok(Some(_))) = reader.poll_read_frame(&kio::Waiter::noop()) else { + panic!("a committed frame is ready"); + }; + } + + /// A writer more than the window ahead of its fastest reader waits, and that reader + /// taking frames lets it on; a slower reader is never waited on. + #[test] + fn the_fastest_reader_paces_the_writer() { + let (flag, waiter) = flag(); + let mut group = written(15); + let mut fast = group.consume(); + let _slow = group.consume(); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Ready(()), "within the window"); + + group.write_frame(None, Bytes::from(vec![0u8; MB])).unwrap(); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Pending, "past the window"); + assert!(!flag.woken()); + + // A batch read takes several frames at once, which the writer counts as read. + read(&mut fast); + assert!(flag.woken(), "the fastest reader's read wakes the writer"); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Ready(())); + } + + /// A one-byte frame whose properties hold `metadata` bytes. + #[cfg(feature = "async")] + fn fielded(id: u64, metadata: usize) -> frame::Header { + use crate::model::object::{Block, Fields, Properties}; + let block = Block { + bytes: Bytes::from(vec![0u8; metadata]), + immutable: false, + }; + frame::Header { + info: frame::Info { + size: 1, + timestamp: None, + }, + fields: Some(Arc::new(Fields { + id, + properties: Properties { + block: Some(block), + ..Default::default() + }, + })), + inflated: None, + } + } + + /// Metadata counts toward the window as it does toward the live tail, both committed + /// and to admit a frame, so frames whose properties outweigh their payloads are paced. + #[test] + #[cfg(feature = "async")] + fn metadata_counts_toward_the_window() { + let (_flag, waiter) = flag(); + let mut group = group::Info { sequence: 0 }.produce_untimed(); + let _reader = group.consume(); + for id in 0..15 { + let mut frame = group.create_object(fielded(id, MB)).unwrap(); + frame.write(Bytes::from_static(b"f")).unwrap(); + group = frame.finish().unwrap(); + } + let group = crate::recv::Group::new(group); + assert_eq!(group.poll_admit(&waiter, &fielded(15, 0)), Poll::Ready(())); + assert_eq!( + group.poll_admit(&waiter, &fielded(15, 2 * MB)), + Poll::Pending, + "its metadata would pass the window" + ); + } + + /// Bytes measure the window, not frames, so an open frame counts as it fills. + #[test] + fn an_open_frame_counts_toward_the_window() { + let (_flag, waiter) = flag(); + let group = written(15); + let _reader = group.consume(); + let mut frame = group + .create_frame(frame::Info { + size: 2 * MB as u64, + timestamp: None, + }) + .unwrap(); + assert_eq!(frame.poll_ready(&waiter), Poll::Ready(())); + frame.write(Bytes::from(vec![0u8; MB])).unwrap(); + assert_eq!(frame.poll_ready(&waiter), Poll::Pending); + } + + /// A frame is admitted only once its declared size fits the window, since admitting + /// it trims the tail to fit, which would otherwise shed what the reader still wants. + #[test] + fn a_large_frame_waits_to_be_admitted() { + let (flag, waiter) = flag(); + let group = written(15); + let mut reader = group.consume(); + assert_eq!(group.poll_paced(&waiter, 20 * MB as u64), Poll::Pending); + while reader.poll_read_frame(&kio::Waiter::noop()).is_ready() {} + assert!(flag.woken()); + assert_eq!( + group.poll_paced(&waiter, 20 * MB as u64), + Poll::Ready(()), + "the reader took everything, so the frame sheds nothing it wants" + ); + } + + /// A group nobody reads keeps writing, and a parked writer moves on once its last + /// reader leaves. + #[test] + fn a_group_without_readers_never_waits() { + let (flag, waiter) = flag(); + let group = written(16); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Ready(())); + + let reader = group.consume(); + assert_eq!( + group.poll_paced(&waiter, 0), + Poll::Pending, + "a reader that took nothing" + ); + drop(reader); + assert!(flag.woken(), "the last reader leaving wakes the writer"); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Ready(())); + } + + /// A local writer waits for a reader before it writes, then for room in the window, and + /// a reader arriving or reading wakes it. + #[test] + fn a_local_writer_waits_for_a_reader_and_room() { + let (flag, waiter) = flag(); + let mut group = group::Info { sequence: 0 }.produce_untimed(); + assert_eq!(group.poll_ready(&waiter), Poll::Pending, "nobody reads yet"); + let mut reader = group.consume(); + assert!(flag.woken(), "a reader arriving wakes the writer"); + assert_eq!(group.poll_ready(&waiter), Poll::Ready(())); + + for _ in 0..17 { + group.write_frame(None, Bytes::from(vec![0u8; MB])).unwrap(); + } + assert_eq!(group.poll_ready(&waiter), Poll::Pending, "past the window"); + read(&mut reader); + assert!(flag.woken(), "the reader's read wakes the writer"); + assert_eq!(group.poll_ready(&waiter), Poll::Ready(())); + + drop(reader); + assert_eq!(group.poll_ready(&waiter), Poll::Pending, "the reader left"); + group.handle().abort(Error::Cancel).unwrap(); + assert_eq!(group.poll_ready(&waiter), Poll::Ready(()), "a closed group never waits"); + } + + /// A reader that took every committed frame is waiting on the writer, so a frame + /// larger than the window never stalls against it. + #[test] + fn a_reader_at_the_edge_never_stalls_a_large_frame() { + let (_flag, waiter) = flag(); + let group = written(1); + let mut reader = group.consume(); + read(&mut reader); + assert!(reader.poll_read_frame(&kio::Waiter::noop()).is_pending()); + + let mut frame = group + .create_frame(frame::Info { + size: 20 * MB as u64, + timestamp: None, + }) + .unwrap(); + frame.write(Bytes::from(vec![0u8; 18 * MB])).unwrap(); + assert_eq!(frame.poll_ready(&waiter), Poll::Ready(())); + } + + /// A reader whose next frame the tail shed fell off the group, so the writer stops + /// waiting on it. + #[test] + fn a_reader_that_fell_off_is_not_waited_on() { + let (_flag, waiter) = flag(); + let mut group = written(1); + let _reader = group.consume(); + // A local writer never waits, so it sheds the front past the 32 MB tail. + for _ in 0..40 { + group.write_frame(None, Bytes::from(vec![0u8; MB])).unwrap(); + } + assert_eq!(group.poll_paced(&waiter, 0), Poll::Ready(())); + } + + /// A reader that skipped past what the writer has written is waiting on it, so it + /// never holds the writer back from the frame it wants. + #[test] + fn a_reader_ahead_of_the_writer_never_stalls_it() { + let (_flag, waiter) = flag(); + let mut group = group::Info { sequence: 0 }.produce_untimed(); + let mut reader = group.consume().with_range(20..); + assert!(reader.poll_read_frame(&kio::Waiter::noop()).is_pending()); + for _ in 0..17 { + group.write_frame(None, Bytes::from(vec![0u8; MB])).unwrap(); + } + // Each commit wakes the parked reader, which polls again and reports the edge. + assert!(reader.poll_read_frame(&kio::Waiter::noop()).is_pending()); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Ready(())); + } + + /// Shedding past the fastest reader under cache pressure wakes a parked writer: that + /// reader fell off. + #[test] + fn shedding_past_the_reader_wakes_the_writer() { + let (flag, waiter) = flag(); + let group = written(17); + let _reader = group.consume(); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Pending); + group.handle().shed(u64::MAX, u64::MAX); + assert!(flag.woken()); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Ready(())); + } + + /// An abort wakes a parked writer, so its next write surfaces why. + #[test] + fn an_abort_wakes_a_parked_writer() { + let (flag, waiter) = flag(); + let group = written(17); + let _reader = group.consume(); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Pending); + group.handle().abort(Error::Cancel).unwrap(); + assert!(flag.woken()); + assert_eq!(group.poll_paced(&waiter, 0), Poll::Ready(())); + } +} diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 086bb1b603b..778d23c1ab7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -17655,7 +17655,7 @@ mod test { #[kio::test] #[cfg(feature = "async")] async fn fetch_refresh_survives_eviction() { - let (mut producer, _pool) = pooled_producer(10_000); + let (mut producer, _pool) = pooled_producer(11_000); let consumer = producer.consume(); finished_group(&mut producer, 3_000); // seq 0 @@ -18445,7 +18445,7 @@ mod test { #[cfg(feature = "async")] async fn same_tick_fetch_protects() { // No time advances at all: every timestamp lands in the same tick. - let (mut producer, _pool) = pooled_producer(10_000); + let (mut producer, _pool) = pooled_producer(11_000); let consumer = producer.consume(); finished_group(&mut producer, 3_000); // seq 0 diff --git a/rs/moq-net/src/recv.rs b/rs/moq-net/src/recv.rs index d005fd209c9..8e87b288d58 100644 --- a/rs/moq-net/src/recv.rs +++ b/rs/moq-net/src/recv.rs @@ -30,6 +30,17 @@ impl Group { .map(frame::Producer::cancel_on_drop) } + /// Poll until the writer may admit the frame `header` opens, its payload and metadata, + /// without shedding a byte its fastest reader has yet to take; see + /// [`group::Producer::poll_paced`]. + pub fn poll_admit(&self, waiter: &kio::Waiter, header: &frame::Header) -> std::task::Poll<()> { + let metadata = header.fields.as_ref().map_or(0, |fields| fields.heap()); + self.writer + .as_ref() + .expect("group writer is available between frames") + .poll_paced(waiter, header.info.size.saturating_add(metadata)) + } + /// Restore the writer returned by a completed frame. pub fn restore(&mut self, writer: group::Producer) { debug_assert!(self.writer.is_none()); diff --git a/rs/moq-net/src/transport.rs b/rs/moq-net/src/transport.rs index eb5aed715e1..c640e1e1e8b 100644 --- a/rs/moq-net/src/transport.rs +++ b/rs/moq-net/src/transport.rs @@ -138,6 +138,30 @@ pub trait Metrics { struct Untracked; impl Metrics for Untracked {} +/// The receive credit a transport grants its peer, which bounds what a stream the session +/// stops reading still holds. +/// +/// A session's ingest stops reading a group's stream while the group's fastest reader is far +/// behind, so flow control pushes back to the publisher. Such a paused stream keeps up to +/// `stream` bytes counted against the shared `connection` window, so the session pauses only +/// as many streams as fit in half of it; the rest keep reading. See +/// [`poll::Session::receive_window`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct ReceiveWindow { + /// The connection-wide receive window, in bytes, or [`u64::MAX`] when streams share none. + pub connection: u64, + /// The most receive credit one stream may hold, in bytes. + pub stream: u64, +} + +impl ReceiveWindow { + /// A connection-wide window of `connection` bytes, of which one stream holds at most `stream`. + pub fn new(connection: u64, stream: u64) -> Self { + Self { connection, stream } + } +} + /// The smallest connection receive window a transport that leases may offer. /// /// A session keeps each unit it is still assembling leased, and a unit larger than the @@ -341,6 +365,16 @@ pub mod poll { None } + /// Return the receive windows now in force, or `None` when the transport cannot say. + /// + /// Read whenever the session considers pausing a stream, so a window changed on a live + /// connection is followed. Without one, the session cannot bound the credit a paused + /// stream holds, so it never pauses one: ingest reads every stream as it arrives and + /// sheds what its readers fall behind on. + fn receive_window(&self) -> Option { + None + } + /// Accept the next unidirectional stream opened by the peer. fn accept_uni(&self) -> AcceptUni<'_, Self> { AcceptUni::new(self) @@ -549,6 +583,10 @@ pub mod poll { fn probe(&self) -> Option { (**self).probe() } + + fn receive_window(&self) -> Option { + (**self).receive_window() + } } /// A transport that the async client and server can box: @@ -828,6 +866,18 @@ pub mod poll { /// Call this method manually if you want to specify a code yourself. fn stop(&mut self, code: u32); + /// Poll until the stream fails without reading it: the peer resets it, or the + /// connection closes. Pending for good once the peer finished it. + /// + /// A session that stops reading a stream to pace it (see + /// [`Session::receive_window`]) watches this instead, so a reset still ends the group + /// it carries. It takes no bytes and grants no credit. The default never completes, + /// which suits a transport reporting no receive window, since none of its streams pause. + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll { + let _ = waiter; + Poll::Pending + } + /// Poll for the next chunk, up to `max` bytes, or `None` once the peer finished the /// stream. Its credit returns as it is read. /// @@ -905,6 +955,12 @@ pub mod poll { /// An implementation MUST also do this on Drop: [`Buffered`] forwards an explicit stop /// but leaves the drop to the wrapped stream. fn stop(&mut self, code: u32); + + /// Poll until the stream fails without reading it. See [`RecvStream::poll_reset`]. + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll { + let _ = waiter; + Poll::Pending + } } /// Leases over a transport that cannot keep bytes unconsumed, buffering what it reads. @@ -975,6 +1031,14 @@ pub mod poll { self.len = 0; self.inner.stop(code); } + + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll { + // Every byte arrived, so nothing is left to reset. + match self.fin { + true => Poll::Pending, + false => self.inner.poll_reset(waiter), + } + } } /// A [`Lease`] of a [`Buffered`] stream. diff --git a/rs/moq-net/src/transport/boxed.rs b/rs/moq-net/src/transport/boxed.rs index 459f7abe283..f541e489990 100644 --- a/rs/moq-net/src/transport/boxed.rs +++ b/rs/moq-net/src/transport/boxed.rs @@ -6,17 +6,19 @@ //! awaits them. Each transport only instantiates the thin forwarding impls below. use std::any::Any; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Weak}; use std::task::Poll; use bytes::{Buf, Bytes}; use super::poll::{self, SendOptions}; -use super::{Address, CloseKind, Probe, Wire}; +use super::{Address, CloseKind, Probe, ReceiveWindow, Wire}; /// A boxed [`poll::Boxable`] session, shared by every task that uses it. #[derive(Clone)] pub(crate) struct Session { - inner: std::sync::Arc, + inner: Arc>, /// Shared by every clone, so whichever task closes the transport first says why. closing: std::sync::Arc, /// Shared by every clone, so each task counts the subscriptions and fetches it carries. @@ -26,12 +28,20 @@ pub(crate) struct Session { impl Session { pub(crate) fn new(session: S) -> Self { Self { - inner: std::sync::Arc::new(session), + inner: Arc::new(Shared { + paused: AtomicU64::new(0), + session, + }), closing: Default::default(), work: Default::default(), } } + /// The handle each stream this session yields reserves its paused credit through. + fn budget(&self) -> Budget { + Arc::downgrade(&self.inner) + } + /// How this side closed the transport. pub(crate) fn closing(&self) -> &std::sync::Arc { &self.closing @@ -45,10 +55,38 @@ impl Session { /// Close with `code` over `cause`, this side's reason for ending the session. pub(crate) fn abort(&self, cause: &crate::Error, code: u32, reason: &str) { self.closing.sent(code, Some(cause)); - self.inner.close(code, reason); + self.inner.session.close(code, reason); } } +/// A boxed transport and the receive credit its paused streams hold. +struct Shared { + /// The stream credit reserved by streams this session stopped reading to pace a group's + /// writer, at most half the connection window; see [`ReceiveWindow`]. + paused: AtomicU64, + session: S, +} + +impl Shared { + /// Reserve the credit of one more paused stream, unless it would push the paused total + /// over half the connection window, returning how much it reserved. + fn reserve(&self) -> Option { + let window = self.session.receive_window()?; + let charge = window.stream.min(window.connection); + let cap = window.connection / 2; + self.paused + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |paused| { + paused.checked_add(charge).filter(|total| *total <= cap) + }) + .ok()?; + Some(charge) + } +} + +/// The session a stream reserves paused credit from, which a stream outliving it never keeps +/// open: once it is gone, nothing is paused. +type Budget = Weak>; + /// A boxed transport error, keeping both code registries and the message. #[derive(Debug)] pub(crate) struct Error(Box); @@ -115,9 +153,56 @@ impl SendStream { pub(crate) struct RecvStream(Box); impl RecvStream { + /// Box a stream outside any session, which therefore never pauses. #[cfg(test)] pub(crate) fn new(stream: impl poll::RecvStream + Send + Sync) -> Self { - Self(Box::new(stream)) + Self::budgeted(stream, Weak::>::new()) + } + + fn budgeted(stream: impl poll::RecvStream + Send + Sync, session: Budget) -> Self { + Self(Box::new(Budgeted { + stream, + session, + held: 0, + })) + } + + /// Reserve the receive credit this stream may hold while ingest stops reading it, returning + /// whether it may stop: not once the session's paused streams hold half its connection + /// window, nor on a transport that reports no window. + pub(crate) fn pause(&mut self) -> bool { + self.0.pause() + } + + /// Return the credit [`Self::pause`] reserved, as ingest reads the stream again. + pub(crate) fn resume(&mut self) { + self.0.resume() + } +} + +/// A transport's receive stream and the paused credit it reserved from its session. +struct Budgeted { + stream: T, + session: Budget, + /// The credit reserved while paused, zero while read. + held: u64, +} + +impl Budgeted { + /// Return the credit reserved while paused, if any. + fn release(&mut self) { + let held = std::mem::take(&mut self.held); + if held > 0 + && let Some(shared) = self.session.upgrade() + { + shared.paused.fetch_sub(held, Ordering::AcqRel); + } + } +} + +impl Drop for Budgeted { + fn drop(&mut self) { + self.release(); } } @@ -175,18 +260,20 @@ impl super::Metrics for Metrics { } trait ErasedSession: Send + Sync { - fn poll_accept_uni(&self, waiter: &kio::Waiter) -> Poll>; - fn poll_accept_bi(&self, waiter: &kio::Waiter) -> Poll>; + fn poll_accept_uni(&self, waiter: &kio::Waiter, budget: Budget) -> Poll>; + fn poll_accept_bi(&self, waiter: &kio::Waiter, budget: Budget) -> Poll>; fn poll_open_uni(&self, waiter: &kio::Waiter, options: SendOptions<'_, Group>) -> Poll>; fn poll_open_bi( &self, waiter: &kio::Waiter, options: SendOptions<'_, Group>, + budget: Budget, ) -> Poll>; fn poll_open_request( &self, waiter: &kio::Waiter, options: SendOptions<'_, Group>, + budget: Budget, ) -> Poll, Error>>; fn send_group(&self, priority: i64) -> Option; fn send_datagram(&self, payload: Bytes, options: SendOptions<'_, Group>) -> Result<(), Error>; @@ -199,6 +286,7 @@ trait ErasedSession: Send + Sync { fn metrics(&self) -> Metrics; fn wire(&self) -> Option; fn probe(&self) -> Option; + fn receive_window(&self) -> Option; } trait ErasedGroup: Send + Sync { @@ -222,6 +310,9 @@ trait ErasedRecv: Send + Sync { fn poll_read_chunk(&mut self, waiter: &kio::Waiter, max: usize) -> Poll, Error>>; fn set_priority(&mut self, priority: i64); fn stop(&mut self, code: u32); + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll; + fn pause(&mut self) -> bool; + fn resume(&mut self); } // Not `Send`: a lease may hold its transport's lock, and never outlives a poll. @@ -247,18 +338,18 @@ fn unbox<'a, G: 'static>(options: SendOptions<'a, Group>) -> SendOptions<'a, G> } } -fn bi((send, recv): poll::BiStreams) -> (SendStream, RecvStream) { - (SendStream(Box::new(send)), RecvStream(Box::new(recv))) +fn bi((send, recv): poll::BiStreams, budget: Budget) -> (SendStream, RecvStream) { + (SendStream(Box::new(send)), RecvStream::budgeted(recv, budget)) } impl ErasedSession for S { - fn poll_accept_uni(&self, waiter: &kio::Waiter) -> Poll> { + fn poll_accept_uni(&self, waiter: &kio::Waiter, budget: Budget) -> Poll> { poll::Session::poll_accept_uni(self, waiter) - .map(|res| res.map(|recv| RecvStream(Box::new(recv))).map_err(Error::new)) + .map(|res| res.map(|recv| RecvStream::budgeted(recv, budget)).map_err(Error::new)) } - fn poll_accept_bi(&self, waiter: &kio::Waiter) -> Poll> { - poll::Session::poll_accept_bi(self, waiter).map(|res| res.map(bi::).map_err(Error::new)) + fn poll_accept_bi(&self, waiter: &kio::Waiter, budget: Budget) -> Poll> { + poll::Session::poll_accept_bi(self, waiter).map(|res| res.map(|bis| bi::(bis, budget)).map_err(Error::new)) } fn poll_open_uni(&self, waiter: &kio::Waiter, options: SendOptions<'_, Group>) -> Poll> { @@ -270,17 +361,22 @@ impl ErasedSession for S { &self, waiter: &kio::Waiter, options: SendOptions<'_, Group>, + budget: Budget, ) -> Poll> { - poll::Session::poll_open_bi(self, waiter, unbox(options)).map(|res| res.map(bi::).map_err(Error::new)) + poll::Session::poll_open_bi(self, waiter, unbox(options)) + .map(|res| res.map(|bis| bi::(bis, budget)).map_err(Error::new)) } fn poll_open_request( &self, waiter: &kio::Waiter, options: SendOptions<'_, Group>, + budget: Budget, ) -> Poll, Error>> { - poll::Session::poll_open_request(self, waiter, unbox(options)) - .map(|res| res.map(|streams| streams.map(bi::)).map_err(Error::new)) + poll::Session::poll_open_request(self, waiter, unbox(options)).map(|res| { + res.map(|streams| streams.map(|bis| bi::(bis, budget))) + .map_err(Error::new) + }) } fn send_group(&self, priority: i64) -> Option { @@ -326,6 +422,10 @@ impl ErasedSession for S { fn probe(&self) -> Option { poll::Session::probe(self) } + + fn receive_window(&self) -> Option { + poll::Session::receive_window(self) + } } impl ErasedGroup for G { @@ -378,23 +478,42 @@ impl + Send + Sync> ErasedSend for T { } } -impl ErasedRecv for T { +impl ErasedRecv for Budgeted { fn poll_lease(&mut self, waiter: &kio::Waiter, want: usize) -> Poll, Error>> { - poll::RecvStream::poll_lease(self, waiter, want) + poll::RecvStream::poll_lease(&mut self.stream, waiter, want) .map(|res| res.map(|lease| Lease(Box::new(lease))).map_err(Error::new)) } // Forwarded rather than left to the provided method, so reading a chunk never boxes a lease. fn poll_read_chunk(&mut self, waiter: &kio::Waiter, max: usize) -> Poll, Error>> { - poll::RecvStream::poll_read_chunk(self, waiter, max).map_err(Error::new) + poll::RecvStream::poll_read_chunk(&mut self.stream, waiter, max).map_err(Error::new) } fn set_priority(&mut self, priority: i64) { - poll::RecvStream::set_priority(self, priority) + poll::RecvStream::set_priority(&mut self.stream, priority) } fn stop(&mut self, code: u32) { - poll::RecvStream::stop(self, code) + poll::RecvStream::stop(&mut self.stream, code) + } + + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll { + poll::RecvStream::poll_reset(&mut self.stream, waiter).map(Error::new) + } + + fn pause(&mut self) -> bool { + if self.held > 0 { + return true; + } + let Some(held) = self.session.upgrade().and_then(|shared| shared.reserve()) else { + return false; + }; + self.held = held; + true + } + + fn resume(&mut self) { + self.release(); } } @@ -418,15 +537,15 @@ impl poll::Session for Session { type Error = Error; fn poll_accept_uni(&self, waiter: &kio::Waiter) -> Poll> { - self.inner.poll_accept_uni(waiter) + self.inner.session.poll_accept_uni(waiter, self.budget()) } fn poll_accept_bi(&self, waiter: &kio::Waiter) -> Poll> { - self.inner.poll_accept_bi(waiter) + self.inner.session.poll_accept_bi(waiter, self.budget()) } fn poll_open_uni(&self, waiter: &kio::Waiter, options: SendOptions<'_, Group>) -> Poll> { - self.inner.poll_open_uni(waiter, options) + self.inner.session.poll_open_uni(waiter, options) } fn poll_open_bi( @@ -434,7 +553,7 @@ impl poll::Session for Session { waiter: &kio::Waiter, options: SendOptions<'_, Group>, ) -> Poll> { - self.inner.poll_open_bi(waiter, options) + self.inner.session.poll_open_bi(waiter, options, self.budget()) } fn poll_open_request( @@ -442,52 +561,56 @@ impl poll::Session for Session { waiter: &kio::Waiter, options: SendOptions<'_, Group>, ) -> Poll, Error>> { - self.inner.poll_open_request(waiter, options) + self.inner.session.poll_open_request(waiter, options, self.budget()) } fn send_group(&self, priority: i64) -> Option { - self.inner.send_group(priority) + self.inner.session.send_group(priority) } fn send_datagram(&self, payload: Bytes, options: SendOptions<'_, Group>) -> Result<(), Error> { - self.inner.send_datagram(payload, options) + self.inner.session.send_datagram(payload, options) } fn poll_recv_datagram(&self, waiter: &kio::Waiter) -> Poll> { - self.inner.poll_recv_datagram(waiter) + self.inner.session.poll_recv_datagram(waiter) } fn max_datagram_size(&self) -> usize { - self.inner.max_datagram_size() + self.inner.session.max_datagram_size() } fn protocol(&self) -> Option<&str> { - self.inner.protocol() + self.inner.session.protocol() } fn address(&self) -> Address { - self.inner.address() + self.inner.session.address() } fn close(&self, code: u32, reason: &str) { self.closing.sent(code, None); - self.inner.close(code, reason) + self.inner.session.close(code, reason) } fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { - self.inner.poll_closed(waiter) + self.inner.session.poll_closed(waiter) } fn metrics(&self) -> impl super::Metrics { - self.inner.metrics() + self.inner.session.metrics() } fn wire(&self) -> Option { - self.inner.wire() + self.inner.session.wire() } fn probe(&self) -> Option { - self.inner.probe() + self.inner.session.probe() + } + + fn receive_window(&self) -> Option { + self.inner.session.receive_window() } } @@ -543,6 +666,10 @@ impl poll::RecvStream for RecvStream { fn stop(&mut self, code: u32) { self.0.stop(code) } + + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll { + self.0.poll_reset(waiter) + } } impl Buf for Lease<'_> { @@ -577,3 +704,50 @@ impl poll::Lease for Lease<'_> { self.0.release() } } + +#[cfg(test)] +mod test { + use super::*; + use crate::lite::test_transport::SinkSession; + + fn accept(session: &Session) -> RecvStream { + let Poll::Ready(Ok((_, recv))) = poll::Session::poll_accept_bi(session, &kio::Waiter::noop()) else { + panic!("the sink accepts at once"); + }; + recv + } + + /// Each paused stream reserves its stream window, and the session pauses no more than + /// fit in half the connection window; a stream resuming or dropped returns its share. + #[test] + fn paused_streams_hold_at_most_half_the_connection_window() { + let gate = kio::Producer::new(true); + let session = + Session::new(SinkSession::accepted_bi(gate.consume()).with_receive_window(ReceiveWindow::new(100, 20))); + let mut streams: Vec<_> = (0..3).map(|_| accept(&session)).collect(); + assert!(streams[0].pause()); + assert!(streams[0].pause(), "a paused stream reserves once"); + assert!(streams[1].pause()); + assert!(!streams[2].pause(), "a third would hold 60 of 100"); + + streams[0].resume(); + assert!(streams[2].pause(), "a resumed stream returned its share"); + drop(streams.remove(1)); + assert!(accept(&session).pause(), "so did a dropped one"); + } + + /// A transport that reports no window never pauses a stream, since nothing bounds what + /// it would hold; nor does a stream that outlived its session. + #[test] + fn an_unbounded_stream_never_pauses() { + let gate = kio::Producer::new(true); + let session = Session::new(SinkSession::accepted_bi(gate.consume())); + assert!(!accept(&session).pause()); + + let session = + Session::new(SinkSession::accepted_bi(gate.consume()).with_receive_window(ReceiveWindow::new(100, 20))); + let mut stream = accept(&session); + drop(session); + assert!(!stream.pause()); + } +} diff --git a/rs/moq-net/src/transport/memory.rs b/rs/moq-net/src/transport/memory.rs index d41267eafd6..3a12c52602d 100644 --- a/rs/moq-net/src/transport/memory.rs +++ b/rs/moq-net/src/transport/memory.rs @@ -404,6 +404,14 @@ impl poll::Session for Session { fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { poll_close(&self.side.conn.close, waiter) } + + /// Streams share no connection window, and one holds at most its buffer. + fn receive_window(&self) -> Option { + Some(super::ReceiveWindow::new( + u64::MAX, + self.conn().config.stream_buffer as u64, + )) + } } /// One direction of a stream: the messages its writer queued for its reader. @@ -647,6 +655,23 @@ impl poll::Chunks for Chunks { drop(channel); self.release(); } + + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll { + if self.stopped { + return Poll::Ready(Error::Closed); + } + if let Poll::Ready(err) = poll_close(&self.close, waiter) { + return Poll::Ready(err); + } + let channel = ready!(self.channel.poll(waiter, |channel| match channel.end { + Some(End::Reset(_)) => Poll::Ready(()), + _ => Poll::Pending, + })); + let Some(End::Reset(code)) = channel.end else { + unreachable!("checked above") + }; + Poll::Ready(Error::Reset(code)) + } } impl Drop for Chunks { diff --git a/rs/moq-net/tests/integration/fastest_reader.rs b/rs/moq-net/tests/integration/fastest_reader.rs new file mode 100644 index 00000000000..d4112857e9b --- /dev/null +++ b/rs/moq-net/tests/integration/fastest_reader.rs @@ -0,0 +1,250 @@ +#![cfg(feature = "async")] + +//! A relay's ingest buffers at most a window past the group's fastest reader, then leaves +//! the rest in the stream, so flow control pushes back to the publisher. A slower reader +//! is never waited on: it falls off the group's front. + +use std::time::Duration; + +use bytes::Bytes; +use moq_net::{Hop, Timestamp, Version, origin, track}; + +use crate::support::harness::{MockPair, peer, spawn}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +const MB: usize = 1 << 20; + +const VERSIONS: &[&str] = &["moq-lite-06", "moq-lite-07-wip", "moq-transport-22"]; + +fn origin(hop: u64) -> origin::Producer { + let (origin, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + spawn(driver); + origin +} + +/// A publisher's track and a relay's subscription to it, over one peering. +struct Relay { + track: track::Producer, + subscriber: track::Subscriber, + _broadcast: origin::Publication, + pair: MockPair, + _origins: [origin::Producer; 2], +} + +async fn relay(version: &str) -> Relay { + let publisher = origin(1); + let relay = origin(2); + let pair = peer(version.parse::().unwrap(), &publisher, &relay).await; + + let broadcast = publisher.create_broadcast("live").unwrap(); + let track = broadcast + .create_track("data", track::Info::default().with_timescale(moq_net::Timescale::MILLI)) + .unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let remote = kio::clock::timeout(TIMEOUT, relay.consume().follow("live", None).unwrap().available()) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + let subscriber = kio::clock::timeout( + TIMEOUT, + remote + .track("data") + .unwrap() + .subscribe(track::Subscription::default().with_max_delay(Duration::from_secs(3600))), + ) + .await + .expect("subscribe timeout") + .expect("subscribe"); + Relay { + track, + subscriber, + _broadcast: broadcast, + pair, + _origins: [publisher, relay], + } +} + +#[kio::test] +async fn an_idle_reader_paces_the_relay_and_loses_nothing() { + let payload = Bytes::from(vec![7u8; MB]); + for version in VERSIONS { + let mut relay = relay(version).await; + + let mut group = relay.track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + let mut reader = kio::clock::timeout(TIMEOUT, relay.subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("group 0"); + // Within the publisher's own 32 MB tail, so only the relay's pacing holds it back. + for _ in 1..30 { + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + } + group.finish().unwrap(); + + // Simulated time moves only once every task is parked: the relay's ingest stopped. + kio::clock::sleep(Duration::from_secs(1)).await; + let held = reader.frame_count(); + assert!( + (15..=18).contains(&held), + "{version}: the relay read {held} MB ahead of a reader that took nothing" + ); + + for index in 0..30 { + let frame = kio::clock::timeout(TIMEOUT, reader.read_frame()) + .await + .unwrap_or_else(|_| panic!("{version}: frame {index} never arrived")) + .unwrap_or_else(|err| panic!("{version}: frame {index}: {err}")) + .expect("a frame"); + assert_eq!(frame.payload.len(), MB, "{version}: frame {index}"); + } + let end = kio::clock::timeout(TIMEOUT, reader.read_frame()) + .await + .expect("end timeout"); + assert!(matches!(end, Ok(None)), "{version}: {end:?}"); + } +} + +/// A paused stream is not read, so the relay watches it for a reset instead: a publisher +/// aborting the group ends the relay's copy while its reader still takes nothing. +#[kio::test] +async fn a_reset_reaches_a_paused_relay() { + let payload = Bytes::from(vec![7u8; MB]); + for version in VERSIONS { + let mut relay = relay(version).await; + + let mut group = relay.track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + let mut reader = kio::clock::timeout(TIMEOUT, relay.subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("group 0"); + for _ in 1..30 { + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + } + kio::clock::sleep(Duration::from_secs(1)).await; + assert!(reader.frame_count() < 30, "{version}: the relay never paused"); + + group.abort(moq_net::Error::Cancel).unwrap(); + let end = kio::clock::timeout(TIMEOUT, reader.finished()) + .await + .unwrap_or_else(|_| panic!("{version}: the reset never reached the paused relay")); + assert!(end.is_err(), "{version}: {end:?}"); + } +} + +/// A frame larger than the room left waits to be admitted, since admitting it trims the +/// tail to fit: an idle reader loses nothing to a large frame either. +#[kio::test] +async fn a_large_frame_sheds_nothing_an_idle_reader_wants() { + let payload = Bytes::from(vec![7u8; MB]); + for version in VERSIONS { + let mut relay = relay(version).await; + + let mut group = relay.track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + let mut reader = kio::clock::timeout(TIMEOUT, relay.subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("group 0"); + for _ in 1..15 { + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + } + // Let the relay take what fits, so the publisher's egress has sent every frame the + // large one sheds from the publisher's own tail. + kio::clock::sleep(Duration::from_secs(1)).await; + group + .write_frame(Timestamp::ZERO, Bytes::from(vec![7u8; 20 * MB])) + .unwrap(); + group.finish().unwrap(); + + kio::clock::sleep(Duration::from_secs(1)).await; + assert_eq!( + reader.frame_count(), + 15, + "{version}: the large frame waits for the reader" + ); + for index in 0..16 { + let frame = kio::clock::timeout(TIMEOUT, reader.read_frame()) + .await + .unwrap_or_else(|_| panic!("{version}: frame {index} never arrived")) + .unwrap_or_else(|err| panic!("{version}: frame {index}: {err}")) + .expect("a frame"); + let size = if index < 15 { MB } else { 20 * MB }; + assert_eq!(frame.payload.len(), size, "{version}: frame {index}"); + } + } +} + +/// A session whose paused streams would hold more than half its connection window reads +/// on instead, and its idle reader keeps what the live tail holds. +#[kio::test] +async fn a_session_past_its_credit_cap_reads_on() { + let payload = Bytes::from(vec![7u8; MB]); + for version in VERSIONS { + let mut relay = relay(version).await; + // One paused stream's credit is already more than half the connection window. + let window = moq_net::transport::ReceiveWindow::new(MB as u64, MB as u64); + relay.pair.server_transport.set_receive_window(Some(window)); + + let mut group = relay.track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + let reader = kio::clock::timeout(TIMEOUT, relay.subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("group 0"); + for _ in 1..30 { + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + } + group.finish().unwrap(); + + kio::clock::sleep(Duration::from_secs(1)).await; + let held = reader.frame_count(); + assert_eq!(held, 30, "{version}: the relay paused past its credit cap"); + } +} + +#[kio::test] +async fn a_slow_reader_falls_off_without_slowing_the_fast_one() { + let payload = Bytes::from(vec![7u8; MB]); + for version in VERSIONS { + let mut relay = relay(version).await; + + let mut group = relay.track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + let mut fast = kio::clock::timeout(TIMEOUT, relay.subscriber.recv_group()) + .await + .expect("group timeout") + .unwrap() + .expect("group 0"); + let mut slow = fast.clone(); + + // Well past the 32 MB tail: the fast reader keeps up the whole way. + for index in 0..48 { + if index > 0 { + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + } + let frame = kio::clock::timeout(TIMEOUT, fast.read_frame()) + .await + .unwrap_or_else(|_| panic!("{version}: the fast reader stalled at frame {index}")) + .unwrap_or_else(|err| panic!("{version}: frame {index}: {err}")) + .expect("a frame"); + assert_eq!(frame.payload.len(), MB, "{version}: frame {index}"); + } + group.finish().unwrap(); + + let fell = kio::clock::timeout(TIMEOUT, slow.read_frame()) + .await + .expect("read timeout"); + assert!( + matches!(fell, Err(moq_net::Error::Evicted)), + "{version}: the slow reader got {fell:?}" + ); + } +} diff --git a/rs/moq-net/tests/integration/main.rs b/rs/moq-net/tests/integration/main.rs index 6c4c2ad082b..c92b429c3ac 100644 --- a/rs/moq-net/tests/integration/main.rs +++ b/rs/moq-net/tests/integration/main.rs @@ -23,6 +23,7 @@ mod drained_claim; mod empty_group; mod errored_end; mod extensions; +mod fastest_reader; mod fetch_cancel; mod fetch_fanout; mod fetch_from_feed; diff --git a/rs/moq-net/tests/integration/support/mock.rs b/rs/moq-net/tests/integration/support/mock.rs index 8f870f3d42e..0b7b82880f7 100644 --- a/rs/moq-net/tests/integration/support/mock.rs +++ b/rs/moq-net/tests/integration/support/mock.rs @@ -413,6 +413,14 @@ impl poll::Chunks for MockChunks { fn stop(&mut self, code: u32) { self.stop_after_latency(code); } + + // Unlike data, a reset skips the link's latency: no test times one. + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll { + match &mut self.inner { + Some(inner) => poll::RecvStream::poll_reset(inner, waiter), + None => Poll::Ready(memory::Error::Closed), + } + } } impl Drop for MockChunks { @@ -454,6 +462,8 @@ struct Side { panic_on_stats: AtomicBool, /// The wire counters [`poll::Session::wire`] exposes, which a test updates as a transport would. wire: moq_net::transport::Wire, + /// What [`poll::Session::receive_window`] reports instead of the memory transport's, if set. + receive_window: Mutex>>, } /// The connection statistics a [`MockSession`] reports. Unavailable by default. @@ -643,6 +653,13 @@ impl poll::Session for MockSession { fn wire(&self) -> Option { Some(self.side.wire.clone()) } + + fn receive_window(&self) -> Option { + match *self.side.receive_window.lock().unwrap() { + Some(window) => window, + None => self.inner.receive_window(), + } + } } #[allow(dead_code, reason = "only some test binaries steer delivery")] @@ -676,6 +693,11 @@ impl MockSession { *self.side.stats.lock().unwrap() = stats; } + /// Report `window` as the receive windows in force, as a transport whose limits changed. + pub fn set_receive_window(&self, window: Option) { + *self.side.receive_window.lock().unwrap() = Some(window); + } + /// How many times the stats were read. pub fn stats_reads(&self) -> usize { self.side.stats_reads.load(Ordering::Relaxed) @@ -913,6 +935,7 @@ pub fn create_mock_session_pair(config: memory::Config) -> (MockSession, MockSes stats: Default::default(), stats_reads: Default::default(), panic_on_stats: Default::default(), + receive_window: Default::default(), wire: Default::default(), }), }; diff --git a/rs/moq-quic/src/connection/mod.rs b/rs/moq-quic/src/connection/mod.rs index 8327cb7028a..e0000595b5b 100644 --- a/rs/moq-quic/src/connection/mod.rs +++ b/rs/moq-quic/src/connection/mod.rs @@ -1672,6 +1672,17 @@ impl Connection { } } + /// The connection-wide receive window now in force; see [`Limits::receive_window`] + pub fn receive_window(&self) -> u64 { + self.streams.receive_window() + } + + /// The most receive credit one stream may hold: the largest per-stream window ever in force, + /// since credit already granted outlives a shrink; see [`Limits::stream_receive_window`] + pub fn stream_receive_window(&self) -> u64 { + self.streams.peak_stream_receive_window() + } + /// Change every limit `limits` sets, leaving the others as they are /// /// Errors, changing nothing, on limits [`Limits::validate`] refuses. diff --git a/rs/moq-quic/src/connection/streams/state.rs b/rs/moq-quic/src/connection/streams/state.rs index 400e7a10c6c..e1318f948a9 100644 --- a/rs/moq-quic/src/connection/streams/state.rs +++ b/rs/moq-quic/src/connection/streams/state.rs @@ -184,6 +184,9 @@ pub(crate) struct StreamsState { /// new stream starts with whatever [`stream_receive_window`](Self::stream_receive_window) /// became since pub(super) initial_stream_receive_window: u64, + /// The largest stream receive window ever in force, which bounds the credit a stream may + /// still hold after the window shrinks + peak_stream_receive_window: u64, // Pertinent state from the TransportParameters supplied by the peer initial_max_stream_data_uni: VarInt, @@ -255,6 +258,7 @@ impl StreamsState { send_window, stream_receive_window: stream_receive_window.into(), initial_stream_receive_window: stream_receive_window.into(), + peak_stream_receive_window: stream_receive_window.into(), initial_max_stream_data_uni: 0u32.into(), initial_max_stream_data_bidi_local: 0u32.into(), initial_max_stream_data_bidi_remote: 0u32.into(), @@ -1115,10 +1119,22 @@ impl StreamsState { self.unblock(); } + /// The connection receive window now in force. + pub(crate) fn receive_window(&self) -> u64 { + self.receive_window + } + + /// The most credit one receive stream may hold: the largest window ever in force, since + /// credit already granted outlives a shrink. + pub(crate) fn peak_stream_receive_window(&self) -> u64 { + self.peak_stream_receive_window + } + /// Set the receive window of each stream, which applies as each stream's credit is next /// extended: credit already granted cannot be taken back. pub(crate) fn set_stream_receive_window(&mut self, window: u64) { self.stream_receive_window = window; + self.peak_stream_receive_window = self.peak_stream_receive_window.max(window); } /// Set the receive_window and returns whether the receive_window has been @@ -2952,6 +2968,17 @@ mod tests { assert!(should_transmit.should_transmit()); } + /// A stream may still hold credit granted under a larger window, so the bound reported to + /// the application never falls when the window shrinks. + #[test] + fn stream_receive_window_reports_its_peak() { + let mut server = make(Side::Server); + let initial = server.peak_stream_receive_window(); + server.set_stream_receive_window(initial * 4); + server.set_stream_receive_window(initial / 2); + assert_eq!(server.peak_stream_receive_window(), initial * 4); + } + #[test] fn shrink_receive_window() { let mut server = make(Side::Server); diff --git a/rs/moq-tokio/src/quic/driver/connection.rs b/rs/moq-tokio/src/quic/driver/connection.rs index 6ed11b2bd4c..65174659d3c 100644 --- a/rs/moq-tokio/src/quic/driver/connection.rs +++ b/rs/moq-tokio/src/quic/driver/connection.rs @@ -528,6 +528,13 @@ impl Connection { self.0.state.lock().inner.stats() } + /// The receive windows now in force: the connection's, and the most one stream holds. + #[cfg(feature = "net")] + pub(crate) fn receive_window(&self) -> moq_net::transport::ReceiveWindow { + let state = self.0.state.lock(); + moq_net::transport::ReceiveWindow::new(state.inner.receive_window(), state.inner.stream_receive_window()) + } + /// How many send streams the connection still holds state for #[cfg(test)] pub(crate) fn send_streams(&self) -> usize { diff --git a/rs/moq-tokio/src/quic/driver/recv.rs b/rs/moq-tokio/src/quic/driver/recv.rs index cedb3567fa4..9139ef770cd 100644 --- a/rs/moq-tokio/src/quic/driver/recv.rs +++ b/rs/moq-tokio/src/quic/driver/recv.rs @@ -227,36 +227,39 @@ impl Stream { /// /// This operation is cancel-safe. pub async fn received_reset(&mut self) -> Result, Error> { - poll_fn(|cx| { - let mut conn = self.conn.state.lock(); + poll_fn(|cx| self.poll_received_reset(cx)).await + } - if let Some(code) = self.reset { - return Poll::Ready(Ok(Some(code))); - } + /// Poll form of [`Self::received_reset`]. It takes no bytes, so a lease in progress is + /// left as it was. + pub(crate) fn poll_received_reset(&mut self, cx: &mut Context<'_>) -> Poll, Error>> { + let mut conn = self.conn.state.lock(); - match conn.inner.recv_stream(self.id).received_reset() { - Err(_) => Poll::Ready(Ok(None)), - Ok(Some(error_code)) => { - // Stream state has just now been freed, so the connection may need to issue new - // stream ID flow control credit, and later reads fail with the cached code. - self.reset = Some(error_code); - conn.wake(); - Poll::Ready(Ok(Some(error_code))) - } - Ok(None) => { - if let Some(e) = &conn.error { - return Poll::Ready(Err(e.clone().into())); - } - // Resets always notify readers, since a reset is an immediate read error. We - // could introduce a dedicated channel to reduce the risk of spurious wakeups, - // but that increased complexity is probably not justified, as an application - // that is expecting a reset is not likely to receive large amounts of data. - conn.blocked_readers.insert(self.id, cx.waker().clone()); - Poll::Pending + if let Some(code) = self.reset { + return Poll::Ready(Ok(Some(code))); + } + + match conn.inner.recv_stream(self.id).received_reset() { + Err(_) => Poll::Ready(Ok(None)), + Ok(Some(error_code)) => { + // Stream state has just now been freed, so the connection may need to issue new + // stream ID flow control credit, and later reads fail with the cached code. + self.reset = Some(error_code); + conn.wake(); + Poll::Ready(Ok(Some(error_code))) + } + Ok(None) => { + if let Some(e) = &conn.error { + return Poll::Ready(Err(e.clone().into())); } + // Resets always notify readers, since a reset is an immediate read error. We + // could introduce a dedicated channel to reduce the risk of spurious wakeups, + // but that increased complexity is probably not justified, as an application + // that is expecting a reset is not likely to receive large amounts of data. + conn.blocked_readers.insert(self.id, cx.waker().clone()); + Poll::Pending } - }) - .await + } } /// Handle common logic related to reading out of a receive stream diff --git a/rs/moq-tokio/src/quic/web/recv.rs b/rs/moq-tokio/src/quic/web/recv.rs index 541774131fb..af1188809c9 100644 --- a/rs/moq-tokio/src/quic/web/recv.rs +++ b/rs/moq-tokio/src/quic/web/recv.rs @@ -2,7 +2,7 @@ use std::{ io, pin::Pin, sync::Arc, - task::{Context, Poll}, + task::{Context, Poll, ready}, }; use bytes::{BufMut, Bytes}; @@ -45,6 +45,20 @@ impl RecvStream { } } + /// Poll until the peer resets the stream or the session closes, without reading it; pending + /// for good once every byte arrived. See [`recv::Stream::received_reset`]. + pub fn poll_reset(&mut self, cx: &mut Context<'_>) -> Poll { + let close = &self.close; + let Some(inner) = &mut self.inner else { + return Poll::Ready(ReadError::ClosedStream); + }; + match ready!(inner.poll_received_reset(cx)) { + Ok(None) => Poll::Pending, + Ok(Some(code)) => Poll::Ready(map_error(close, recv::Error::Reset(code))), + Err(err) => Poll::Ready(map_error(close, err)), + } + } + /// Rank this stream for eviction. See [`recv::Stream::set_priority`]. pub fn set_priority(&mut self, priority: i64) { if let Some(inner) = &mut self.inner { diff --git a/rs/moq-tokio/src/transport.rs b/rs/moq-tokio/src/transport.rs index 1cd7f00d23c..fba3824bb32 100644 --- a/rs/moq-tokio/src/transport.rs +++ b/rs/moq-tokio/src/transport.rs @@ -908,6 +908,10 @@ impl Grouped for crate::quic::web::Session { Some(crate::quic::driver::connection::Connection::stats(self).credit.evicted) } + fn receive_window(&self) -> Option { + Some(crate::quic::driver::connection::Connection::receive_window(self)) + } + fn send_group(&self, priority: i64) -> Option { Some(Self::send_group(self, priority)) } diff --git a/rs/moq-tokio/src/transport/grouped.rs b/rs/moq-tokio/src/transport/grouped.rs index 4a10d7ecddd..89cc0c54089 100644 --- a/rs/moq-tokio/src/transport/grouped.rs +++ b/rs/moq-tokio/src/transport/grouped.rs @@ -42,6 +42,12 @@ pub trait Grouped: web_transport_trait::Session { None } + /// The receive windows now in force, or `None` when the backend cannot say; see + /// [`poll::Session::receive_window`](moq_net::transport::poll::Session::receive_window). + fn receive_window(&self) -> Option { + None + } + /// Open a send group at `priority`, or `None` when the backend has none. fn send_group(&self, priority: i64) -> Option<::Group> { let _ = priority; diff --git a/rs/moq-tokio/src/transport/owned.rs b/rs/moq-tokio/src/transport/owned.rs index 9ded0bbba8b..f5cd027d453 100644 --- a/rs/moq-tokio/src/transport/owned.rs +++ b/rs/moq-tokio/src/transport/owned.rs @@ -200,6 +200,9 @@ impl> poll::Session fn probe(&self) -> Option { super::Grouped::probe(&self.session) } + fn receive_window(&self) -> Option { + super::Grouped::receive_window(&self.session) + } } impl poll::SendStream for BridgedSend { type Error = Error; @@ -259,6 +262,9 @@ impl poll::RecvStream for crate::quic::web::RecvStream { // A stream already closed has nobody left to tell. crate::quic::web::RecvStream::stop(self, code); } + fn poll_reset(&mut self, waiter: &moq_net::kio::Waiter) -> Poll { + crate::quic::web::RecvStream::poll_reset(self, &mut waiter.context()).map(Error) + } } #[cfg(feature = "quic")] diff --git a/rs/moq-uring/src/quic/driver/connection.rs b/rs/moq-uring/src/quic/driver/connection.rs index bca44657742..5dc203793fa 100644 --- a/rs/moq-uring/src/quic/driver/connection.rs +++ b/rs/moq-uring/src/quic/driver/connection.rs @@ -393,6 +393,12 @@ impl Connection { pub(crate) fn streams_evicted(&self) -> u64 { self.shared.conn.borrow().stats().credit.evicted } + + /// The receive windows now in force: the connection's, and the most one stream holds. + pub(crate) fn receive_window(&self) -> moq_net::transport::ReceiveWindow { + let conn = self.shared.conn.borrow(); + moq_net::transport::ReceiveWindow::new(conn.receive_window(), conn.stream_receive_window()) + } } impl Clone for Connection { diff --git a/rs/moq-uring/src/quic/driver/recv.rs b/rs/moq-uring/src/quic/driver/recv.rs index 4a4fbe19f96..c205f6c7f36 100644 --- a/rs/moq-uring/src/quic/driver/recv.rs +++ b/rs/moq-uring/src/quic/driver/recv.rs @@ -215,7 +215,13 @@ impl Stream { /// /// Cancel-safe. pub async fn received_reset(&mut self) -> Result, Error> { - poll_fn(|cx| self.parked(cx, Self::wait_received_reset)).await + poll_fn(|cx| self.poll_received_reset(cx)).await + } + + /// Poll form of [`Self::received_reset`]. It takes no bytes, so a lease in progress is + /// left as it was. + pub(crate) fn poll_received_reset(&mut self, cx: &Context<'_>) -> Poll, Error>> { + self.parked(cx, Self::wait_received_reset) } fn wait_received_reset(&mut self, waiter: &kio::Waiter) -> Poll, Error>> { diff --git a/rs/moq-uring/src/quic/web.rs b/rs/moq-uring/src/quic/web.rs index 3cbc17bba1c..751ca45747f 100644 --- a/rs/moq-uring/src/quic/web.rs +++ b/rs/moq-uring/src/quic/web.rs @@ -459,6 +459,11 @@ impl Session { self.conn.streams_evicted() } + /// The receive windows now in force: the connection's, and the most one stream holds. + pub(crate) fn receive_window(&self) -> moq_net::transport::ReceiveWindow { + self.conn.receive_window() + } + /// Open a send group at `priority`, for streams to join with /// [`SendStream::set_group`]. The HTTP/3 streams stay outside every /// group, so they go first. @@ -1119,6 +1124,20 @@ impl RecvStream { inner.set_priority(priority); } } + + /// Poll until the peer resets the stream or the connection closes, without reading it; + /// pending for good once every byte arrived. See [`recv::Stream::received_reset`]. + pub fn poll_reset(&mut self, cx: &mut Context<'_>) -> Poll { + let web = self.web; + let Some(inner) = &mut self.inner else { + return Poll::Ready(Error::Closed); + }; + match ready!(inner.poll_received_reset(cx)) { + Ok(None) => Poll::Pending, + Ok(Some(code)) => Poll::Ready(map(web, Error::Reset(code.into_inner()))), + Err(err) => Poll::Ready(map(web, err.into())), + } + } } /// Unmap a stream error's code from the session's code space. diff --git a/rs/moq-uring/src/transport.rs b/rs/moq-uring/src/transport.rs index 6b043479fd7..7489caeef04 100644 --- a/rs/moq-uring/src/transport.rs +++ b/rs/moq-uring/src/transport.rs @@ -37,6 +37,10 @@ impl Grouped for quic::web::Session { Some(quic::web::Session::streams_evicted(self)) } + fn receive_window(&self) -> Option { + Some(quic::web::Session::receive_window(self)) + } + fn send_group(&mut self, priority: i64) -> Option { Some(quic::web::Session::send_group(self, priority)) } @@ -92,6 +96,9 @@ impl moq_net::transport::poll::RecvStream for quic::web::RecvStream { fn stop(&mut self, code: u32) { web_transport_trait::poll::RecvStream::stop(self, code) } + fn poll_reset(&mut self, waiter: &kio::Waiter) -> std::task::Poll { + quic::web::RecvStream::poll_reset(self, &mut waiter.context()).map(adapter::Error) + } } #[cfg(feature = "quic")] diff --git a/rs/moq-uring/src/transport/adapter.rs b/rs/moq-uring/src/transport/adapter.rs index d54a97220f1..db65d110dc6 100644 --- a/rs/moq-uring/src/transport/adapter.rs +++ b/rs/moq-uring/src/transport/adapter.rs @@ -89,6 +89,12 @@ pub trait Grouped: backend::Session { None } + /// The receive windows now in force, or `None` when the backend cannot say; see + /// [`poll::Session::receive_window`](moq_net::transport::poll::Session::receive_window). + fn receive_window(&self) -> Option { + None + } + /// Open a send group at `priority`, or `None` when the backend has none. fn send_group(&mut self, priority: i64) -> Option<::Group> { let _ = priority; @@ -254,6 +260,9 @@ impl + 'static> poll::Sessi fn probe(&self) -> Option { Grouped::probe(&self.session) } + fn receive_window(&self) -> Option { + Grouped::receive_window(&self.session) + } } /// An outgoing WebTransport stream adapted to moq-net. /// diff --git a/rs/moq-uring/src/transport/local.rs b/rs/moq-uring/src/transport/local.rs index 571cbc2b246..90890356063 100644 --- a/rs/moq-uring/src/transport/local.rs +++ b/rs/moq-uring/src/transport/local.rs @@ -119,6 +119,10 @@ impl poll::Session for Local { fn probe(&self) -> Option { self.0.probe() } + + fn receive_window(&self) -> Option { + self.0.receive_window() + } } impl poll::SendStream for Local { @@ -173,6 +177,10 @@ impl poll::RecvStream for Local { self.0.stop(code) } + fn poll_reset(&mut self, waiter: &kio::Waiter) -> Poll { + self.0.poll_reset(waiter) + } + fn poll_read_chunk(&mut self, waiter: &kio::Waiter, max: usize) -> Poll, Self::Error>> { self.0.poll_read_chunk(waiter, max) } From 2ec2f1d55e5588f32374f5b004a7d6bd7efc9453 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 09:53:50 +0000 Subject: [PATCH 78/86] feat(net)!: resolve archive time ranges through a fill lookup (#7310) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- quest/m2/README.md | 1 - quest/m2/archive-dvr.md | 1 - quest/m2/archive-time-ranges.md | 35 --- quest/m3/studio/replay.md | 1 - rs/moq-archive/src/consumer/index.rs | 86 +++++- rs/moq-archive/src/consumer/mod.rs | 13 +- rs/moq-archive/src/consumer/tests.rs | 221 +++++++++++++++ rs/moq-archive/src/consumer/track.rs | 114 +++++++- rs/moq-net/src/model/group.rs | 10 + rs/moq-net/src/model/subscription.rs | 7 +- rs/moq-net/src/model/subscription/window.rs | 171 +++++++++++- rs/moq-net/src/model/track.rs | 289 ++++++++++++++++++-- rs/moq-net/src/model/track/fill.rs | 74 ++++- rs/moq-net/src/model/track/miss.rs | 10 + 14 files changed, 935 insertions(+), 98 deletions(-) delete mode 100644 quest/m2/archive-time-ranges.md diff --git a/quest/m2/README.md b/quest/m2/README.md index 468b7da5df4..80a3f5bb4d1 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -183,7 +183,6 @@ lite-07; bitrate enforcement is not part of this release. - [The relay over QUIC on the sim](/quest/m2/quic-sim.md) - the relay and moq-tokio's `Client` and `Server` run over `moq-quic` on the seeded sim - [moq-shaper's own socket never passes for path loss](/quest/m2/shaper-socket.md) - relay-sized buffers and its own socket drops reported beside path drops -- [Archive time ranges](/quest/m2/archive-time-ranges.md) - a `Time(pts)` range on an archive consumer broadcast resolves from the archive's keys, however old - [DVR rewind](/quest/m2/archive-dvr.md) - follow-on capability after the release contract is stable - [Archive lookups cancel when abandoned](/quest/m2/archive-lookup-withdrawal.md) - a public withdrawal signal on `Fill` stops a time lookup's store read once its readers leave diff --git a/quest/m2/archive-dvr.md b/quest/m2/archive-dvr.md index b1f29bd5dd6..1846ef0d18e 100644 --- a/quest/m2/archive-dvr.md +++ b/quest/m2/archive-dvr.md @@ -31,4 +31,3 @@ over missing groups, and return to live. ## Required - [Subscribe ranges](/quest/m1/subscribe-ranges/README.md) - the `Time(pts)` range a seek sends -- [Archive time ranges](/quest/m2/archive-time-ranges.md) - the archive consumer resolves time ranges from its keys diff --git a/quest/m2/archive-time-ranges.md b/quest/m2/archive-time-ranges.md deleted file mode 100644 index 2814a22664d..00000000000 --- a/quest/m2/archive-time-ranges.md +++ /dev/null @@ -1,35 +0,0 @@ -# [M] Archive time ranges - -## Goal - -A `Time(pts)` range on an `archive::Consumer` broadcast resolves from the -archive's key listing, not only from the groups the consumer's cache holds: -a start reaches the stored group containing pts, and an end stops just after -it, however old. - -## Plan - -Decided 2026-10-23 (maintainer, on -[moq-dev/moq#6262](https://github.com/moq-dev/moq/pull/6262)): this stays in -m2, required by [DVR rewind](/quest/m2/archive-dvr.md), rather than blocking -the archive consumer, which ships without time anchors. - -The consumer creates each track live from its newest stored group and serves -older groups through `track::Fill`, so its cache holds almost nothing a time -anchor can resolve against: today a `Time(pts)` start older than the newest -group resolves to the oldest cached group instead. A `Fill` names group -positions only, so an anchor the cache can't resolve never reaches the -handler. [moq-dev/moq#6171](https://github.com/moq-dev/moq/pull/6171) deferred carrying the anchor to the consumer quest, which -landed without it. - -- `moq-net` passes a time anchor the cache can't resolve to the - `track::Requests` handler, unresolved, in its fill. -- The consumer resolves it from its index: every object key carries its first - frame's pts, so the object holding pts is one lookup, and the group within - it comes from the stored group timestamps. - -Test with the in-memory store: a start and an end older than the newest -group, an anchor before the oldest stored object, an anchor in a gap, and an -untimed track refusing it. - -Public API: `track::Fill` gains the anchor. Wire: none. diff --git a/quest/m3/studio/replay.md b/quest/m3/studio/replay.md index bd64a3fb18d..0dfbb0e2044 100644 --- a/quest/m3/studio/replay.md +++ b/quest/m3/studio/replay.md @@ -30,6 +30,5 @@ any other. ## Related -- [Archive time ranges](/quest/m2/archive-time-ranges.md) - ranges reaching past the cache - [Archive DVR](/quest/m2/archive-dvr.md) - viewer scrubbing beyond the relay cache - [Recording](/quest/m3/studio/recording.md) - clips use the same file writer diff --git a/rs/moq-archive/src/consumer/index.rs b/rs/moq-archive/src/consumer/index.rs index a93e50ae47a..d8595470177 100644 --- a/rs/moq-archive/src/consumer/index.rs +++ b/rs/moq-archive/src/consumer/index.rs @@ -1,9 +1,9 @@ //! One track's object keys, listed once and extended after its newest key. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use futures::TryStreamExt; -use moq_net::Timescale; +use moq_net::{Timescale, Timestamp}; use object_store::path::Path; use object_store::{ObjectStore, ObjectStoreExt}; @@ -13,11 +13,15 @@ use crate::{Error, Info, Result}; /// Where an object starts: its first sequence and that sequence's first frame index. pub(super) type Start = (u64, u64); +/// One listed object: where it starts, its first frame's timestamp on a timed track, and +/// its key. +pub(super) type Listing = (Start, Option, Path); + /// What one listing of a track's directory found. #[derive(Default)] pub(super) struct Listed { /// The objects new since the cursor, oldest first. - pub objects: Vec<(Start, Path)>, + pub objects: Vec

, /// The track's `end`, when asked for. pub end: Option, } @@ -26,7 +30,13 @@ pub(super) struct Listed { pub(super) struct Index { pub dir: Path, pub timescale: Option, - objects: BTreeMap, + /// Each object's first frame timestamp, on a timed track, and key. + objects: BTreeMap, Path)>, + /// Each timed object starting a group, by that group's start timestamp with where it + /// starts, so a time finds the newest such object at or before it in one lookup. Group + /// starts never rewind, so they sort as the objects do. An object continuing a group is + /// left out: its first frame says nothing about where the groups after it start. + times: BTreeSet<(u64, Start)>, /// The newest key listed, which the next listing starts after. cursor: Path, } @@ -38,6 +48,7 @@ impl Index { dir, timescale: super::track::timescale(info)?, objects: BTreeMap::new(), + times: BTreeSet::new(), cursor, }) } @@ -52,9 +63,14 @@ impl Index { } /// Take in the objects a listing after [`Self::cursor`] found. - pub fn extend(&mut self, objects: &[(Start, Path)]) { - for (start, path) in objects { - self.objects.insert(*start, path.clone()); + pub fn extend(&mut self, objects: &[Listing]) { + for (start, pts, path) in objects { + self.objects.insert(*start, (*pts, path.clone())); + if let Some(pts) = pts + && start.1 == 0 + { + self.times.insert((*pts, *start)); + } self.cursor = path.clone(); } } @@ -63,7 +79,7 @@ impl Index { pub fn newest(&self) -> Option<(Start, Path)> { self.objects .last_key_value() - .map(|(start, path)| (*start, path.clone())) + .map(|(start, (_, path))| (*start, path.clone())) } /// The objects holding group `sequence` from frame `frame` on, oldest first: the last one @@ -71,11 +87,11 @@ impl Index { /// object after them, which bounds a gap. pub fn holding(&self, sequence: u64, frame: u64) -> (Vec<(Start, Path)>, Option) { let mut holding = Vec::new(); - if let Some((start, path)) = self.objects.range(..=(sequence, frame)).next_back() { + if let Some((start, (_, path))) = self.objects.range(..=(sequence, frame)).next_back() { holding.push((*start, path.clone())); } let mut after = None; - for (start, path) in self.objects.range((sequence, frame.saturating_add(1))..) { + for (start, (_, path)) in self.objects.range((sequence, frame.saturating_add(1))..) { if start.0 != sequence { after = Some(*start); break; @@ -85,9 +101,53 @@ impl Index { (holding, after) } + /// The objects that may hold the newest group starting at or before `pts`, newest first: + /// from the newest object starting a group at or before it, or the oldest object when none + /// does, up to the next object starting a group after it, which holds only later groups. + /// One continuing a group the next object still continues starts none, so it is skipped. + /// Empty on an untimed track. + pub fn around(&self, pts: Timestamp) -> Vec<(Start, Path)> { + let Some(timescale) = self.timescale else { + return Vec::new(); + }; + // A stored timestamp is whole, so it is at or before `pts` exactly when it is at or + // before `pts` rounded down to the track's timescale. + let limit = u64::try_from(pts.as_scale(timescale)).unwrap_or(u64::MAX); + let from = self + .times + .range(..=(limit, (u64::MAX, u64::MAX))) + .next_back() + .map_or((0, 0), |(_, start)| *start); + + let mut around = Vec::new(); + let mut objects = self.objects.range(from..).peekable(); + while let Some((start, (first, path))) = objects.next() { + if start.1 == 0 && first.is_some_and(|first| first > limit) { + break; + } + let continued = start.1 > 0 && objects.peek().is_some_and(|(next, _)| next.0 == start.0); + if !continued { + around.push((*start, path.clone())); + } + } + around.reverse(); + around + } + + /// The object just after the one starting at `start`, or the oldest for `None`. + pub fn after(&self, start: Option) -> Option<(Start, Path)> { + let (start, (_, path)) = match start { + Some(start) => self.objects.range((start.0, start.1.saturating_add(1))..).next()?, + None => self.objects.first_key_value()?, + }; + Some((*start, path.clone())) + } + /// Forget an object the store no longer has, such as one a DVR pruned. pub fn forget(&mut self, start: Start) { - self.objects.remove(&start); + if let Some((Some(pts), _)) = self.objects.remove(&start) { + self.times.remove(&(pts, start)); + } } } @@ -117,7 +177,9 @@ pub(super) async fn list( match Name::parse(filename, timed)? { Name::Info => {} Name::End => found = Some(meta.location), - Name::Object(start) => listed.objects.push(((start.group, start.frame), meta.location)), + Name::Object(start) => listed + .objects + .push(((start.group, start.frame), start.pts, meta.location)), } } listed.objects.sort(); diff --git a/rs/moq-archive/src/consumer/mod.rs b/rs/moq-archive/src/consumer/mod.rs index 9e1125a01a1..7fc6fff1ea9 100644 --- a/rs/moq-archive/src/consumer/mod.rs +++ b/rs/moq-archive/src/consumer/mod.rs @@ -7,6 +7,10 @@ //! demand from the store through [`track::Requests`](moq_net::track::Requests) fills, each sequence in its recorded form: a //! group as a group, a datagram as a datagram. //! +//! A reader's time start older than the newest group is looked up from the object keys, each of +//! which carries its first frame's timestamp, so the range starts at the stored group containing +//! it however old, and a time end stops just past the group containing it. +//! //! A growing archive is followed: every [`Config::refresh`], one delimiter LIST of the prefix //! finds added tracks, and one LIST per track after its newest key finds new objects. Once the //! prefix holds `.end`, each track ends as recorded and refreshing stops. Without it the archive @@ -330,7 +334,14 @@ async fn refresh(shared: Arc>, mut tracks: Vec (listed.objects, 0), + true => ( + listed + .objects + .into_iter() + .map(|(start, _, path)| (start, path)) + .collect(), + 0, + ), // Only the newest sequence: fills serve the rest. false => { let Some((start, path)) = track.index.lock().unwrap().newest() else { diff --git a/rs/moq-archive/src/consumer/tests.rs b/rs/moq-archive/src/consumer/tests.rs index a0785df0d56..a44bb4a59ae 100644 --- a/rs/moq-archive/src/consumer/tests.rs +++ b/rs/moq-archive/src/consumer/tests.rs @@ -739,3 +739,224 @@ async fn an_unordered_listing_is_sorted() { } assert_eq!(super::tests::sequence(&mut reader).await, Some(3)); } + +/// Read `range` in order with no age limit: the sequences of the groups it delivers before +/// it stops delivering. A range ending before the live edge parks once read, as on any live +/// track. +async fn read_range(broadcast: &broadcast::Producer, track: &str, range: track::Range) -> Vec { + let track = broadcast.consume().track(track).unwrap(); + let subscription = Subscription::default() + .with_ranges([range]) + .with_order(Order::Ascending) + .with_max_delay(None); + let mut reader = track.subscribe(subscription).await.unwrap().ordered(); + let mut read = Vec::new(); + while let Ok(group) = timeout(5 * REFRESH, reader.next_group()).await { + match group.unwrap() { + Some(group) => read.push(group.sequence), + None => break, + } + } + read +} + +fn at(millis: u64) -> track::Anchor { + track::Anchor::Time(Timestamp::from_millis(millis).unwrap()) +} + +/// Groups `sequences` of a timed track, two to an object, each group starting at its +/// sequence in seconds. +async fn stored(archive: &Archive, sequences: impl IntoIterator) { + let sequences: Vec = sequences.into_iter().collect(); + for pair in sequences.chunks(2) { + let entries = pair.iter().map(|sequence| frames(true, *sequence, 0..3)).collect(); + archive.object("video", 0, entries).await; + } +} + +/// A time range older than the newest group resolves from the archive's keys, not from +/// the one group its cache holds: the start reaches the group containing it, and the end +/// stops just after the group containing it. +#[tokio::test(start_paused = true)] +async fn a_time_range_resolves_from_the_archive() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + stored(&archive, 0..10).await; + + let (broadcast, _run) = serve(&archive.store).await; + let range = |start, end| track::Range { start, end }; + assert_eq!( + read_range(&broadcast, "video", range(at(4_500), Some(at(6_500)))).await, + [4, 5, 6] + ); + // A start on the first frame of its group, in the second object of a pair. + assert_eq!( + read_range(&broadcast, "video", range(at(3_000), Some(at(3_001)))).await, + [3] + ); + // An end alone, from the oldest group. + assert_eq!( + read_range(&broadcast, "video", range(track::Anchor::Absolute(0), Some(at(1_200)))).await, + [0, 1] + ); + // The cache now holds groups 6 and 9, but not what lies between: still looked up. + assert_eq!( + read_range(&broadcast, "video", range(at(8_500), Some(at(8_600)))).await, + [8] + ); + + // A start alone reads on through the live edge. + let track = broadcast.consume().track("video").unwrap(); + let subscription = Subscription::default() + .with_ranges([range(at(7_002), None)]) + .with_order(Order::Ascending) + .with_max_delay(None); + let mut reader = track.subscribe(subscription).await.unwrap().ordered(); + for expected in 7..10 { + assert_eq!(sequence(&mut reader).await, Some(expected)); + } +} + +/// A completed archive's track ends only once a time range's lookup answers, so the range +/// still reads its history. +#[tokio::test(start_paused = true)] +async fn a_completed_archive_reads_a_time_range() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + stored(&archive, 0..6).await; + archive.end("video", End::Finish(At { group: 6, frame: 0 })).await; + archive.complete().await; + + let (broadcast, _run) = serve(&archive.store).await; + let range = track::Range { + start: at(2_500), + end: None, + }; + assert_eq!(read_range(&broadcast, "video", range).await, [2, 3, 4, 5]); +} + +/// A time older than every stored object reaches back to the oldest stored group. +#[tokio::test(start_paused = true)] +async fn a_time_before_the_archive_starts_at_its_oldest_group() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + // Groups 0 to 2 were pruned. + stored(&archive, 3..8).await; + + let (broadcast, _run) = serve(&archive.store).await; + let range = track::Range { + start: at(500), + end: Some(at(4_500)), + }; + assert_eq!(read_range(&broadcast, "video", range).await, [3, 4]); +} + +/// A time older than an archive whose oldest object continues a pruned group reaches back to +/// the oldest group stored whole. +#[tokio::test(start_paused = true)] +async fn a_time_before_a_pruned_head_starts_at_the_oldest_whole_group() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + archive + .object("video", 2, vec![frames(true, 5, 2..4), frames(true, 6, 0..2)]) + .await; + archive.object("video", 0, vec![frames(true, 7, 0..2)]).await; + archive.object("video", 0, vec![frames(true, 8, 0..1)]).await; + + let (broadcast, _run) = serve(&archive.store).await; + let range = track::Range { + start: at(500), + end: Some(at(7_500)), + }; + assert_eq!(read_range(&broadcast, "video", range).await, [6, 7]); +} + +/// A time in a gap the archive never stored starts at the group before the gap, the newest +/// starting before it, and the gap reads as missing. +#[tokio::test(start_paused = true)] +async fn a_time_in_a_gap_starts_before_it() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + stored(&archive, [0, 1, 2, 3, 6, 7, 8, 9]).await; + + let (broadcast, _run) = serve(&archive.store).await; + let range = track::Range { + start: at(4_500), + end: Some(at(6_500)), + }; + assert_eq!(read_range(&broadcast, "video", range).await, [3, 6]); +} + +/// A continuation's first frame bounds no group start: an object continuing group 0 at a +/// later timestamp than the group 1 it then starts still holds the group containing a time +/// between the two. +#[tokio::test(start_paused = true)] +async fn a_continuation_does_not_bound_a_group_start() { + let archive = Archive::new(); + archive.track("video", 0, true).await; + let frame = |sequence: u64, index: u64, pts: u64| Frame { + timestamp: Some(pts), + payload: Bytes::from(format!("{sequence}/{index}")), + }; + archive + .object( + "video", + 0, + vec![Entry::Group { + sequence: 0, + frames: vec![frame(0, 0, 0)], + }], + ) + .await; + archive + .object( + "video", + 1, + vec![ + Entry::Group { + sequence: 0, + frames: vec![frame(0, 1, 1_000)], + }, + Entry::Group { + sequence: 1, + frames: vec![frame(1, 0, 500)], + }, + ], + ) + .await; + stored(&archive, 2..4).await; + + let (broadcast, _run) = serve(&archive.store).await; + let range = track::Range { + start: at(600), + end: Some(at(700)), + }; + assert_eq!(read_range(&broadcast, "video", range).await, [1]); +} + +/// An untimed track refuses a time range rather than looking it up. +#[tokio::test(start_paused = true)] +async fn an_untimed_track_refuses_a_time_range() { + let archive = Archive::new(); + archive.track("log", 0, false).await; + for sequence in 0..4 { + archive.object("log", 0, vec![frames(false, sequence, 0..1)]).await; + } + + let (broadcast, _run) = serve(&archive.store).await; + let track = broadcast.consume().track("log").unwrap(); + let subscription = Subscription::default() + .with_ranges([track::Range { + start: at(1_000), + end: None, + }]) + .with_max_delay(None); + let result = match track.subscribe(subscription).await { + Ok(subscriber) => timeout(Duration::from_secs(10), subscriber.ordered().next_group()) + .await + .expect("the read is refused") + .map(|group| group.map(|group| group.sequence)), + Err(err) => Err(err), + }; + assert!(matches!(result, Err(moq_net::Error::TimestampMismatch)), "{result:?}"); +} diff --git a/rs/moq-archive/src/consumer/track.rs b/rs/moq-archive/src/consumer/track.rs index d0ba0372d70..5ca84109ef0 100644 --- a/rs/moq-archive/src/consumer/track.rs +++ b/rs/moq-archive/src/consumer/track.rs @@ -167,6 +167,9 @@ impl Live { /// Serve `fill` from the archive, a group at a time in the fill's order, until every group is /// answered or nobody waits on it. pub(super) async fn serve(shared: Arc>, index: Arc>, fill: Fill) { + if let Some(pts) = fill.time() { + return look_up(&shared, &index, &fill, pts).await; + } let mut last = None; while let Some(sequence) = fill.wanted() && last != Some(sequence) @@ -176,6 +179,96 @@ pub(super) async fn serve(shared: Arc>, index: Arc(shared: &Shared, index: &Mutex, fill: &Fill, pts: Timestamp) { + let (around, timed, timescale) = { + let index = index.lock().unwrap(); + (index.around(pts), index.timed(), index.timescale) + }; + let Some(timescale) = timescale else { + // A reader's time start on an untimed track is refused before it is looked up. + return; + }; + let limit = pts.as_scale(timescale); + + // Newest first: only a group whose first frame an object holds says where it starts. + for (start, path) in around { + let Some(object) = load(shared, index, start, &path, timed).await else { + return; + }; + let found = object + .entries + .iter() + .enumerate() + .filter_map(|(position, entry)| match entry { + Entry::Group { sequence, frames } if position > 0 || object.frame_start == 0 => { + let first = frames.first()?; + first + .timestamp + .is_some_and(|at| u128::from(at) <= limit) + .then_some(*sequence) + } + _ => None, + }) + .last(); + if let Some(sequence) = found { + return serve_group(shared, index, fill, sequence).await; + } + } + + // Every group starts after it: the oldest group is where its range reaches back to, skipping + // one whose head was pruned, which can't be served from its first frame. + let mut next = index.lock().unwrap().after(None); + while let Some((start, path)) = next { + let Some(object) = load(shared, index, start, &path, timed).await else { + return; + }; + let found = object + .entries + .iter() + .enumerate() + .find_map(|(position, entry)| match entry { + Entry::Group { sequence, frames } + if !frames.is_empty() && (position > 0 || object.frame_start == 0) => + { + Some(*sequence) + } + _ => None, + }); + if let Some(sequence) = found { + return serve_group(shared, index, fill, sequence).await; + } + next = index.lock().unwrap().after(Some(start)); + } +} + +/// The object a lookup searches, `None` once the store failed. +/// +/// An abandoned lookup still finishes the read and answers nobody: a lookup owes no group, so +/// nothing tells it when its readers stop waiting. +async fn load( + shared: &Shared, + index: &Mutex, + start: (u64, u64), + path: &object_store::path::Path, + timed: bool, +) -> Option> { + match super::object(shared, path, start, timed).await { + Ok(object) => Some(object), + Err(err) => { + if let Error::NotFound(_) = err { + // Pruned since it was listed. + index.lock().unwrap().forget(start); + } + tracing::warn!(%path, %err, "archived object is unavailable for a time lookup"); + None + } + } +} + /// Answer group `sequence` of `fill` from the objects holding it, drop the run of sequences /// around it that the archive does not hold, or refuse it for now when the store fails. async fn serve_group(shared: &Shared, index: &Mutex, fill: &Fill, sequence: u64) { @@ -186,15 +279,22 @@ async fn serve_group(shared: &Shared, index: &Mutex, f (holding, after, index.timed(), index.timescale) }; let next = after.map_or(u64::MAX, |start| start.0); + // A lookup owes no group, so nothing says when its readers stop waiting: it finishes the read. + let lookup = fill.time().is_some(); let mut objects = Vec::with_capacity(holding.len()); for (start, path) in &holding { - let unwanted = std::pin::pin!(fill.unwanted(sequence)); - let load = std::pin::pin!(super::object(shared, path, *start, timed)); - let loaded = match future::select(unwanted, load).await { - // Every reader narrowed past it: the fill moves on to the groups it still owes. - Either::Left(_) => return, - Either::Right((loaded, _)) => loaded, + let load = super::object(shared, path, *start, timed); + let loaded = match lookup { + true => load.await, + false => { + let unwanted = std::pin::pin!(fill.unwanted(sequence)); + match future::select(unwanted, std::pin::pin!(load)).await { + // Every reader narrowed past it: the fill moves on to the groups it still owes. + Either::Left(_) => return, + Either::Right((loaded, _)) => loaded, + } + } }; match loaded { Ok(object) => objects.push(object), @@ -274,7 +374,7 @@ async fn serve_group(shared: &Shared, index: &Mutex, f } } - if !fill.is_wanted(sequence) { + if !lookup && !fill.is_wanted(sequence) { return; } let Ok(mut producer) = fill.accept(sequence, None) else { diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 719acf55121..27248319db6 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -2766,6 +2766,16 @@ impl Handle { } } + /// Where the group starts in presentation time, accepted unverified or not: what a time + /// range counts on a track whose history a local handler serves, where no remote + /// publisher wrote it (see [`Self::timestamp`]). + pub(super) fn first_timestamp(&self) -> Option { + match self.state.read().timeline { + Timeline::Timed { start, .. } => Some(start), + _ => None, + } + } + /// The start this group lends its neighbors' rewind check: its first frame's timestamp, /// once frame 0 was checked to fit between theirs. pub(super) fn bound(&self) -> Option { diff --git a/rs/moq-net/src/model/subscription.rs b/rs/moq-net/src/model/subscription.rs index addaeb24cdd..08bf5ae5d66 100644 --- a/rs/moq-net/src/model/subscription.rs +++ b/rs/moq-net/src/model/subscription.rs @@ -138,8 +138,11 @@ pub enum Anchor { /// The group containing this presentation time: the newest one starting at or before it. /// /// Resolved from the starts of the groups the serving hop holds. When none starts at - /// or before it, a start reaches back to the oldest group, and while no group has - /// started at all it waits like `Relative(0)`. An end settles once a group starting + /// or before it, a hop whose [`Requests`](crate::track::Requests) handler serves its + /// history looks the start up there (see [`Fill::time`](crate::track::Fill::time)), + /// reading nothing until it answers; otherwise, or when the handler can't tell, a start + /// reaches back to the oldest group. While no group has started at all it waits like + /// `Relative(0)`. An end settles once a group starting /// after it is known and every sequence below that one is accounted for (started, /// dropped, or never to start). Until then reads stop at the first held group that /// may still start, so at the live edge at most that one group past the end is read diff --git a/rs/moq-net/src/model/subscription/window.rs b/rs/moq-net/src/model/subscription/window.rs index 48683800611..f35ef12e15c 100644 --- a/rs/moq-net/src/model/subscription/window.rs +++ b/rs/moq-net/src/model/subscription/window.rs @@ -52,6 +52,14 @@ pub(in crate::model) trait Holdings { false } + /// Whether a [`Requests`](crate::track::Requests) handler serving the track's history + /// may look up a time its groups can't resolve: a time start before every group with a + /// known start then waits on that lookup, and a sequence the live feed never delivered, + /// or delivered and the cache evicted, may still arrive from that history. + fn looks_up(&self) -> bool { + false + } + /// Where `pts` falls among the groups with a known start. fn containing(&self, pts: Timestamp) -> Containing; @@ -161,6 +169,9 @@ struct Span { /// The lowest group a miss asks for. A time start before every group held asks none /// below the oldest: which of them it reaches is unknown until they start. reach: u64, + /// A time start before every group held, looked up from the track's history: the span + /// reads nothing until the lookup answers (see [`Window::found`]). + looking: Option, } impl Span { @@ -377,7 +388,7 @@ impl Window { return !self.pending; } let mut moved = false; - for span in &mut self.spans { + for span in self.spans.iter_mut().filter(|span| span.looking.is_none()) { let End::Time(time) = &mut span.end else { continue }; let limit = time.limit; if let Some(end) = time.advance(span.start, holdings) { @@ -396,10 +407,13 @@ impl Window { /// The groups an unsettled time end reads on speculation, which may start past it: a /// read parked on the cap wakes when one of them starts. pub fn speculative(&self) -> impl Iterator + '_ { - self.spans.iter().filter_map(|span| match span.end { - End::Time(time) => time.speculative, - _ => None, - }) + self.spans + .iter() + .filter(|span| span.looking.is_none()) + .filter_map(|span| match span.end { + End::Time(time) => time.speculative, + _ => None, + }) } /// The lowest sequence whose start could still move an unsettled time end, `None` @@ -407,6 +421,7 @@ impl Window { pub fn watch_starts(&self) -> Option { self.spans .iter() + .filter(|span| span.looking.is_none()) .filter_map(|span| match span.end { End::Time(time) => time.unknown(span.start), _ => None, @@ -484,12 +499,13 @@ impl Window { } } - /// Each range resolved, in the order given: `None` for one empty as given or still - /// waiting on a first group. One that only resolves empty keeps its span, ending at or - /// below its start. An unsettled time end is reported open, never at its cap. + /// Each range resolved, in the order given: `None` for one empty as given, still + /// waiting on a first group, or still looking up its start. One that only resolves + /// empty keeps its span, ending at or below its start. An unsettled time end is reported + /// open, never at its cap. pub fn resolved(&self) -> Vec> { let mut resolved = vec![None; self.ranges.len()]; - for span in &self.spans { + for span in self.spans.iter().filter(|span| span.looking.is_none()) { resolved[span.index] = Some(Resolved { start: span.start, end: match span.end { @@ -599,6 +615,47 @@ impl Window { Some((*run.start()).max(range.start)..end) } + /// The time starts still looked up, each with the exclusive end of the run below every + /// group held, which holds the group containing it if the track's history does. + pub fn lookups(&self) -> impl Iterator + '_ { + self.spans + .iter() + .filter_map(|span| span.looking.map(|pts| (pts, span.reach))) + } + + /// The lookup of time start `pts` answered with the group containing it, or the oldest + /// group of the history when `pts` is older than all of it: its spans start there, once + /// that group has started. `None`, or an answer that never started, means the history + /// couldn't tell, so they start where the groups held say. Returns whether the groups in + /// range moved. + pub fn found(&mut self, pts: Timestamp, group: Option, holdings: &impl Holdings) -> bool { + let mut moved = false; + for span in self.spans.iter_mut().filter(|span| span.looking == Some(pts)) { + span.looking = None; + moved = true; + let started = group.filter(|group| holdings.next_start(*group).is_some_and(|(found, _)| found == *group)); + (span.start, span.floored, span.reach) = match (started, holdings.containing(pts)) { + (Some(group), _) | (None, Containing::Group(group)) => (group, true, group), + (None, _) => ( + 0, + false, + holdings.next_start(0).map_or(span.reach, |(oldest, _)| oldest), + ), + }; + // A time end walked from group 0 meanwhile walks again from where the span reaches: + // a start the history couldn't place asks nothing below the oldest group held, so + // none of those may hold the end open. + if let Some(Anchor::Time(end)) = self.ranges[span.index].end { + let mut time = TimeEnd::new(end, span.reach); + span.end = time.advance(span.start, holdings).map_or(End::Time(time), End::Before); + } + } + if moved { + self.rebuild(); + } + moved + } + fn place(&mut self, latest: Option, holdings: &impl Holdings) -> Result<()> { let time = |anchor: Option| matches!(anchor, Some(Anchor::Time(_))); if !holdings.timed() @@ -627,9 +684,13 @@ impl Window { } end => end, }; - let reach = match range.start { - Anchor::Time(_) if !floored => holdings.next_start(0).map_or(start, |(oldest, _)| oldest), - _ => start, + let (reach, looking) = match range.start { + Anchor::Time(pts) => match unresolved(start, floored, holdings) { + Some(searched) => (searched, Some(pts)), + None if floored => (start, None), + None => (holdings.next_start(0).map_or(start, |(oldest, _)| oldest), None), + }, + _ => (start, None), }; self.spans.push(Span { index, @@ -637,6 +698,7 @@ impl Window { end, floored, reach, + looking, }); } self.rebuild(); @@ -645,10 +707,15 @@ impl Window { fn rebuild(&mut self) { self.covered = None; - self.settled = !self.pending && !self.spans.iter().any(|span| matches!(span.end, End::Time(_))); + self.settled = !self.pending + && !self + .spans + .iter() + .any(|span| span.looking.is_some() || matches!(span.end, End::Time(_))); self.groups = RangeInclusiveSet::new(); self.missable = RangeInclusiveSet::new(); - for span in &self.spans { + // A span still looking up its start reads nothing until it knows which group that is. + for span in self.spans.iter().filter(|span| span.looking.is_none()) { let last = match span.limit() { Some(end) => match end.checked_sub(1) { Some(last) if last >= span.start => last, @@ -682,6 +749,22 @@ fn start(anchor: Anchor, latest: Option, holdings: &impl Holdings) -> Optio } } +/// The exclusive end of the run a lookup of a time start resolved to `start` searches, when +/// the groups held can't tell which group contains it and a handler may: it is before every +/// group known to start, or in a gap after `start`, the newest known to start at or before it. +/// Even a gap the live feed accounted for may sit in the history once evicted. +fn unresolved(start: u64, floored: bool, holdings: &impl Holdings) -> Option { + if !holdings.looks_up() { + return None; + } + if !floored { + return holdings.next_start(0).map(|(oldest, _)| oldest); + } + let next = start.checked_add(1)?; + let (after, _) = holdings.next_start(next)?; + (after > next).then_some(after) +} + /// Where a range stops, or `None` while it waits on a latest group. A time end's walk /// starts once its start is known. fn end(anchor: Option, latest: Option) -> Option { @@ -713,6 +796,7 @@ mod tests { held: std::collections::BTreeMap, gone: Vec>, complete: bool, + looks_up: bool, asked: Cell, } @@ -734,6 +818,10 @@ mod tests { true } + fn looks_up(&self) -> bool { + self.looks_up + } + fn containing(&self, pts: Timestamp) -> Containing { let mut stamped = false; for (&sequence, held) in self.held.iter().rev() { @@ -1208,4 +1296,59 @@ mod tests { Err(Error::TimestampMismatch) )); } + + /// With a handler serving the history, a time start the groups held can't place is looked + /// up and reads nothing meanwhile: before every start, or in a gap after the newest group + /// starting at or before it, even one the live feed accounted for, since an evicted group + /// still sits in the history. The answer places it; none leaves what the groups held say. + #[test] + fn a_time_start_the_groups_cant_place_is_looked_up() { + let mut groups = Groups::new([Groups::started(4, 4000), Groups::started(9, 9000)]); + groups.gone.push(5..9); + groups.looks_up = true; + let mut window = Window::new(&[range(time(8500), None)], Some(9), &groups).unwrap(); + assert_eq!(window.lookups().collect::>(), [(at(8500), 9)]); + assert!(!window.is_settled() && !window.contains(9) && window.resolved() == [None]); + groups.held.insert(8, Held::Started(at(8000))); + assert!(window.found(at(8500), Some(8), &groups)); + assert_eq!(window.resolved(), [Some(Resolved { start: 8, end: None })]); + assert!(window.is_settled()); + + let window = Window::new(&[range(time(8500), None)], Some(9), &groups).unwrap(); + assert_eq!(window.lookups().count(), 0, "group 8 is held, with 9 after it"); + + let mut window = Window::new(&[range(time(500), None)], Some(9), &groups).unwrap(); + assert_eq!(window.lookups().collect::>(), [(at(500), 4)]); + assert!(window.found(at(500), None, &groups)); + assert_eq!(window.resolved(), [Some(Resolved { start: 0, end: None })]); + assert!(window.contains(4) && !window.missable(0).any(|run| *run.start() < 4)); + } + + /// A time end on a start the history couldn't place walks from the oldest group held, + /// since nothing asks for those below it: none holds the end open. + #[test] + fn an_unanswered_lookup_settles_a_time_end() { + let mut groups = Groups::new([Groups::started(4, 4000), Groups::started(9, 9000)]); + groups.looks_up = true; + let mut window = Window::new(&[range(time(500), Some(time(3000)))], Some(9), &groups).unwrap(); + assert_eq!(window.lookups().collect::>(), [(at(500), 4)]); + assert!(window.found(at(500), None, &groups)); + assert!(window.is_settled()); + assert_eq!(window.resolved(), [Some(Resolved { start: 0, end: Some(4) })]); + } + + /// Without a handler serving the history, the groups held decide alone. + #[test] + fn a_time_start_is_not_looked_up_without_a_history() { + let groups = Groups::new([Groups::started(4, 4000), Groups::started(9, 9000)]); + let window = Window::new(&[range(time(8500), None), range(time(500), None)], Some(9), &groups).unwrap(); + assert_eq!(window.lookups().count(), 0); + assert_eq!( + window.resolved(), + [ + Some(Resolved { start: 4, end: None }), + Some(Resolved { start: 0, end: None }), + ] + ); + } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 778d23c1ab7..c0ead14ddbf 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -747,16 +747,23 @@ impl Slot { /// Where the group starts in presentation time, from a live copy holding frame 0. A /// copy joined mid-group stamps a later frame, which proves nothing about the start. - fn start(&self) -> Option { + /// + /// Only for time anchors. `unverified` counts a start accepted below the eviction + /// watermark too, which a track whose history a local handler serves trusts (see + /// [`Holdings::looks_up`]): no remote publisher wrote it. + fn start(&self, unverified: bool) -> Option { self.copies() .filter(|group| group.live_first_frame() == Some(0)) - .find_map(group::Handle::timestamp) + .find_map(|group| match unverified { + true => group.first_timestamp(), + false => group.timestamp(), + }) } - /// Whether the group has no start yet but may still get one: a copy holding frame 0 - /// has not presented it. - fn may_start(&self) -> bool { - self.start().is_none() && self.copies().any(group::Handle::may_start) + /// Whether the group has no [`Self::start`] yet but may still get one: a copy holding + /// frame 0 has not presented it. + fn may_start(&self, unverified: bool) -> bool { + self.start(unverified).is_none() && self.copies().any(group::Handle::may_start) } /// The checked start of whichever copy began at frame 0. @@ -795,6 +802,11 @@ impl Holdings for TrackState { self.received && !self.anchored } + fn looks_up(&self) -> bool { + // A received copy's handler is its session, which fetches by group alone. + !self.received && self.fills.read().has_handlers() + } + fn containing(&self, pts: Timestamp) -> Containing { let starts = self.starts.lock().unwrap_or_else(PoisonError::into_inner); let (Some((&first, &first_at)), Some((&last, &last_at))) = (starts.first_key_value(), starts.last_key_value()) @@ -828,24 +840,28 @@ impl Holdings for TrackState { fn next_held(&self, from: u64) -> Option<(u64, Held)> { let (&sequence, slot) = self.lookup.range(from..).next()?; - let held = match slot.start() { + let held = match slot.start(self.looks_up()) { Some(at) => Held::Started(at), - None if slot.may_start() => Held::Pending, + None if slot.may_start(self.looks_up()) => Held::Pending, None => Held::Never, }; Some((sequence, held)) } fn gone(&self, sequence: u64) -> Option { + let dropped = self.drops.get_key_value(&sequence).map(|(dropped, _)| dropped.end); + // The history a handler serves may still hold what the live feed never delivered, or + // delivered and the cache evicted, starting anywhere: only a drop, or a reader's own + // refused fills (see `Seen`), says it is gone. + if self.looks_up() { + return dropped; + } // Without a declared start, the first arrival stands in for it, as the tail's does. let floor = self.start_sequence.or(self.arrivals.first()).unwrap_or(0); if sequence < floor { return Some(floor); } - self.drops - .get_key_value(&sequence) - .map(|(dropped, _)| dropped.end) - .or_else(|| self.arrivals.run_end(sequence)) + dropped.or_else(|| self.arrivals.run_end(sequence)) } fn complete(&self) -> bool { @@ -853,6 +869,48 @@ impl Holdings for TrackState { } } +/// Where a cursor's groups start, for resolving its time anchors: its track's, with the +/// runs its own fills refused or it gave up counted gone, so a time end walking history a +/// handler serves passes what that history lacks. +struct Seen<'a> { + state: &'a TrackState, + misses: Option<&'a miss::Misses>, +} + +impl Holdings for Seen<'_> { + fn timed(&self) -> bool { + self.state.timed() + } + + fn drops_anchors(&self) -> bool { + self.state.drops_anchors() + } + + fn looks_up(&self) -> bool { + self.state.looks_up() + } + + fn containing(&self, pts: Timestamp) -> Containing { + self.state.containing(pts) + } + + fn next_start(&self, from: u64) -> Option<(u64, Timestamp)> { + self.state.next_start(from) + } + + fn next_held(&self, from: u64) -> Option<(u64, Held)> { + self.state.next_held(from) + } + + fn gone(&self, sequence: u64) -> Option { + self.state.gone(sequence).or_else(|| self.misses?.gone(sequence)) + } + + fn complete(&self) -> bool { + self.state.complete() + } +} + impl TrackState { /// The upstream publisher's Largest Object, unless a frame or group received since its /// report outdates it, even one the cache has since evicted. @@ -961,9 +1019,11 @@ impl TrackState { } /// Index where cached group `sequence` starts, read back through its slot so only the - /// cached copy holding frame 0 counts, and wake the reads a time end parks on it. + /// cached copy holding frame 0 counts, and wake the reads a time end parks on it. On a + /// track whose history a handler serves, a start accepted unverified counts: the fills + /// below the eviction watermark that a time range walks are all unverified. fn index_start(&self, sequence: u64) { - let Some(start) = self.lookup.get(&sequence).and_then(Slot::start) else { + let Some(start) = self.lookup.get(&sequence).and_then(|slot| slot.start(self.looks_up())) else { return; }; self.starts @@ -5525,6 +5585,7 @@ fn queue(fills: &kio::Shared, sequence: u64, request: &FetchOptions) // One group either way, so it never turns a wider fill it joins newest first. order: Order::Ascending, }, + time: None, }; // The handler gate is atomic with a handler dropping (no fill stranded on a queue // nobody drains); with no handler, `Fetching::poll` fails fast. @@ -6256,6 +6317,13 @@ struct Cursor { finished: Option>>, } +/// A time start a [`Cursor`]'s window looks up through a fill. +struct Lookup { + pts: Timestamp, + /// The fill looking it up, `None` once its answer was taken in. + join: Option, +} + /// A group a cursor found but has not moved past; see [`Cursor::take`]. pub(super) struct Peeked { group: group::Consumer, @@ -6404,6 +6472,8 @@ struct Filter { /// budget, scanned in the cache in the subscription's order and handed out ahead of the /// walk: each cached group in them is visited once. owed: RangeInclusiveSet, + /// The time starts [`Self::window`] looks up from the track's history, each asked once. + lookups: Vec, /// Groups skipped as stale, which account for a bounded window as handed out would, /// until a wider budget owes them again. Kept under an open window too, which a reader /// may narrow to a bounded one. @@ -6450,6 +6520,7 @@ impl Cursor { admitted: RangeInclusiveSet::new(), admitted_at: None, owed: RangeInclusiveSet::new(), + lookups: Vec::new(), written_off: RangeSet::new(), }; (filter, state.datagram_offset + start, state.cache.clone()) @@ -6573,6 +6644,7 @@ impl Cursor { Ok(window) => { self.filter.window = window; self.filter.refused = None; + self.filter.lookups.clear(); } Err(err) => self.filter.refused = Some(err), } @@ -6583,7 +6655,13 @@ impl Cursor { // the first to arrive, or the newest once it went live, however many have arrived // or left since. None if self.filter.window.is_pending() => self.filter.window.settle(state.first_live(), &*state), - None => self.filter.window.settle(state.latest_live(), &*state), + None => { + let seen = Seen { + state: &state, + misses: self.misses.as_deref(), + }; + self.filter.window.settle(state.latest_live(), &seen) + } }; drop(state); if moved { @@ -6997,11 +7075,71 @@ impl Cursor { })) } + /// Look up each time start the window can't resolve from the cache, once, and take in + /// the answers, parking `waiter` on the lookups still out. + fn look_up(&mut self, asking: &miss::Asking, waiter: &kio::Waiter) { + let lookups: Vec<(Timestamp, u64)> = self.filter.window.lookups().collect(); + if lookups.is_empty() && self.filter.lookups.iter().all(|lookup| lookup.join.is_none()) { + return; + } + let fills = self.state.read().fills.clone(); + for (pts, reach) in lookups { + if self.filter.lookups.iter().any(|lookup| lookup.pts == pts) { + continue; + } + let ask = fill::Ask { + start: Position::group(0), + end: reach, + frame_end: None, + asking: *asking, + time: Some(pts), + }; + let (joins, _) = fills.lock().join(&fills, &ask); + self.filter.lookups.push(Lookup { + pts, + join: joins.into_iter().next(), + }); + } + let mut moved = false; + for lookup in &mut self.filter.lookups { + // The window stops looking once replaced; with no join, nothing can answer. + if !self.filter.window.lookups().any(|(pts, _)| pts == lookup.pts) { + lookup.join = None; + continue; + } + let answer = match &lookup.join { + None => None, + Some(join) => match join.outcome().poll(waiter, |outcome| { + outcome.first_accepted().map_or(Poll::Pending, Poll::Ready) + }) { + Poll::Ready(Ok(group)) => Some(group), + // Ended unanswered: its handler couldn't tell. + Poll::Ready(Err(closed)) => closed.first_accepted(), + Poll::Pending => continue, + }, + }; + let state = self.state.read(); + // The answer's first frame says whether it starts at or before the time. + if let Some(group) = answer + && let Some(slot) = state.lookup.get(&group) + && slot.may_start(state.looks_up()) + { + let _ = slot.serving().poll_started(waiter); + continue; + } + lookup.join = None; + moved |= self.filter.window.found(lookup.pts, answer, &*state); + } + if moved { + self.filter.revision += 1; + self.update_servable(); + } + } + /// Ask fills for what this cursor misses from `from`, and take in what they answered, /// parking `waiter` on the fills still out. Whatever it gives up goes to the drop cursor. /// `taken` is what its subscriber handed out, which an eviction never makes a miss. fn probe(&mut self, from: u64, drift: &Drift, taken: &Delivered, waiter: &kio::Waiter) { - let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); let asking = { let subscription = self.subscription.read(); miss::Asking { @@ -7010,6 +7148,8 @@ impl Cursor { order: subscription.order, } }; + self.look_up(&asking, waiter); + let (window, cap, revision) = (&self.filter.window, self.end_sequence, self.filter.revision); let misses = self.misses.get_or_insert_with(Box::default); let mut given_up = Vec::new(); misses.retain((from, revision, cap), || below(window.missable(from), cap)); @@ -7057,6 +7197,11 @@ impl Cursor { drops.give_up(run); } } + // A refusal taken in may move a time end walking history past it, widening what + // is missed: the read goes again to ask for it. + if !self.filter.window.is_settled() && self.sync() { + waiter.waker().wake_by_ref(); + } } /// The next group a fill answered for this cursor in its order, cached and not handed @@ -7076,7 +7221,7 @@ impl Cursor { /// Whether a fill still owes this cursor a group, so the end of the feed is not its end. fn awaits_fill(&self) -> bool { - self.misses.as_ref().is_some_and(|misses| misses.is_waiting()) + self.misses.as_ref().is_some_and(|misses| misses.is_waiting()) || self.filter.window.lookups().next().is_some() } /// Find the next group in arrival order, or in sequence order for `ordered`, without @@ -19616,9 +19761,10 @@ mod test { ); } - /// A time start before every group held asks no fill below the oldest, since which of - /// those groups it reaches is unknown until they start; another range still asks for its - /// own misses. + /// A time start before every group held looks up its group through a fill that owes + /// none: a handler that can't resolve times drops it, and the start reaches back to the + /// oldest group held, asking no fill below it, since which of those groups it reaches is + /// unknown until they start. Another range still asks for its own misses. #[test] #[cfg(feature = "async")] fn a_time_start_before_the_cache_asks_nothing_below_it() { @@ -19637,13 +19783,27 @@ mod test { producer.start_at(10).unwrap(); write_group(&producer, 10); let waiter = kio::Waiter::noop(); + assert_eq!( + drain_groups(&mut subscriber), + Vec::::new(), + "the time start waits on its lookup" + ); + let Poll::Ready(Ok(lookup)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the time start is looked up"); + }; + assert_eq!( + (lookup.time(), lookup.start().group, lookup.end()), + (Some(at(5)), 0, 10) + ); + assert_eq!(lookup.wanted(), None, "a lookup owes no group"); + drop(lookup); let mut read = drain_groups(&mut subscriber); let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) else { panic!("the group range misses its groups"); }; assert_eq!( - (fill.start().group, fill.end()), - (0, 2), + (fill.time(), fill.start().group, fill.end()), + (None, 0, 2), "nothing below 10 for the time start" ); while let Some(sequence) = fill.wanted() { @@ -19655,6 +19815,91 @@ mod test { assert!(dynamic.poll_requested_fill(&waiter).is_pending()); } + /// A time start before every group held starts at the group its lookup answers, once that + /// group's first frame says where it starts, asking nothing below it; a time end walks the + /// history below the feed's start through fills, stopping just past the group containing it. + #[test] + #[cfg(feature = "async")] + fn a_looked_up_time_range_reads_the_history() { + let mut producer = track_producer("test", Info::timed()); + let dynamic = producer.requests(); + let at = |millis| Timestamp::from_millis(millis).unwrap(); + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Time(at(5)), + end: Some(Anchor::Time(at(7))), + }])); + producer.start_at(10).unwrap(); + write_group(&producer, 10); + let waiter = kio::Waiter::noop(); + assert_eq!( + drain_groups(&mut subscriber), + Vec::::new(), + "nothing reads until it is found" + ); + let Poll::Ready(Ok(lookup)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the time start is looked up"); + }; + assert_eq!(lookup.time(), Some(at(5))); + let found = lookup.accept(5, None); + drop(lookup); + assert_eq!( + drain_groups(&mut subscriber), + Vec::::new(), + "nothing reads until the group found starts" + ); + write_filled(found, 5); + let mut read = drain_groups(&mut subscriber); + // One group at a time past the start, until one starts after the end. + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) { + assert_eq!(fill.time(), None); + while let Some(sequence) = fill.wanted() { + assert!(sequence >= 5, "group {sequence} is below the looked-up start"); + write_filled(fill.accept(sequence, None), sequence); + } + drop(fill); + read.extend(drain_groups(&mut subscriber)); + } + assert_eq!(read, [5, 6, 7]); + } + + /// A time end walking the history a handler serves takes no group the live feed + /// delivered and the cache evicted as gone: the history still holds it, and it may start + /// inside the end, so the end settles on what the history answers. + #[test] + #[cfg(feature = "async")] + fn a_time_end_walks_evicted_arrivals_through_the_history() { + let producer = track_producer("test", Info::timed()); + let dynamic = producer.requests(); + let at = |millis| Timestamp::from_millis(millis).unwrap(); + for sequence in 0..10 { + write_group(&producer, sequence); + } + for sequence in [0, 1, 2, 3, 5, 6, 7, 8] { + evict(&producer, sequence); + } + let mut subscriber = producer.subscribe(replay().with_ranges([Range { + start: Anchor::Time(at(5)), + end: Some(Anchor::Time(at(6))), + }])); + let waiter = kio::Waiter::noop(); + assert_eq!(drain_groups(&mut subscriber), Vec::::new()); + let Poll::Ready(Ok(lookup)) = dynamic.poll_requested_fill(&waiter) else { + panic!("the time start in the evicted gap is looked up"); + }; + assert_eq!(lookup.time(), Some(at(5))); + write_filled(lookup.accept(5, None), 5); + drop(lookup); + let mut read = drain_groups(&mut subscriber); + while let Poll::Ready(Ok(fill)) = dynamic.poll_requested_fill(&waiter) { + while let Some(sequence) = fill.wanted() { + write_filled(fill.accept(sequence, None), sequence); + } + drop(fill); + read.extend(drain_groups(&mut subscriber)); + } + assert_eq!(read, [5, 6], "groups 7 and 8 start past the end"); + } + /// A wide fill refused group by group, each with its own error, holds only the answers its /// readers have yet to take, not one per refusal. #[test] diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 3c4a651ac08..f582f0f4a18 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -21,7 +21,7 @@ use std::{ use rangemap::{RangeMap, RangeSet}; use super::{Info, Order, Position, TrackState, clamp_max_delay}; -use crate::{Error, Result, StreamError, group, util::SendCell}; +use crate::{Error, Result, StreamError, Timestamp, group, util::SendCell}; /// The fills a track's readers asked for, queued or taken by a handler, under one lock. #[derive(Default)] @@ -44,6 +44,8 @@ struct Pending { end: u64, // Exclusive frame cap in the last group; None asks through the original end. frame_end: Option, + // The time a lookup asks for, which owes no group. + time: Option, // What its joins ask with now, shared with them and its handler. asks: kio::Shared, queued: bool, @@ -198,6 +200,11 @@ impl Outcome { self.accepted.contains(&sequence) } + /// The lowest group accepted: a lookup's answer. + pub(super) fn first_accepted(&self) -> Option { + self.accepted.iter().next().map(|run| run.start) + } + /// The partial copy accepted for `sequence`, if it still holds `frames`. pub(super) fn partial(&self, sequence: u64, frames: group::Bounds) -> Option { self.partial.get(&sequence).filter(|copy| copy.covers(frames)).cloned() @@ -267,6 +274,8 @@ pub(super) struct Ask { pub end: u64, pub frame_end: Option, pub asking: Asking, + /// A lookup of the group holding this time within the run, which owes none of it. + pub time: Option, } /// A reader's place in one fill: the part of it the reader wants. @@ -403,6 +412,15 @@ impl FillState { if span.is_empty() { return (joins, unfillable); } + if let Some(time) = ask.time { + // A lookup joins nothing and nothing joins it: it owes no group, so no other + // reader's run is answered through it. + match self.handlers { + 0 => unfillable.push(span), + _ => joins.push(self.queue_lookup(shared, ask, time)), + } + return (joins, unfillable); + } let covered: Vec<(ops::Range, u64)> = self .covers .overlapping(&span) @@ -498,6 +516,7 @@ impl FillState { start, end: groups.end, frame_end: if groups.end == ask.end { ask.frame_end } else { None }, + time: None, asks, queued: true, wants: RangeMap::from_iter([(groups.clone(), 1)]), @@ -509,6 +528,41 @@ impl FillState { (joins, unfillable) } + /// Queue a fill looking up `time` within `ask`'s run, with the join waiting on its answer. + fn queue_lookup(&mut self, shared: &kio::Shared, ask: &Ask, time: Timestamp) -> Join { + let id = self.next; + self.next += 1; + let result = kio::Producer::::default(); + let mut asks = Asks::default(); + asks.add(&ask.asking); + let asks = kio::Shared::new(asks); + let join = Join { + fills: shared.clone(), + id, + groups: ask.start.group..ask.end, + held: RangeSet::new(), + outcome: Some(result.consume()), + attempt: result.weak(), + asking: ask.asking, + asks: asks.clone(), + }; + self.pending.insert( + id, + Pending { + start: ask.start, + end: ask.end, + frame_end: None, + time: Some(time), + asks, + queued: true, + wants: RangeMap::new(), + result, + }, + ); + self.queue.push_back(id); + join + } + /// Count a join out of reading `groups` of fill `id`. What nobody reads any more is no /// longer routed to it: a reader wanting it later asks afresh rather than joining a fetch /// being cancelled, so a sequence a fill stops owing stays unowed. Its answers are @@ -615,7 +669,7 @@ pub(super) fn poll_requested( }) { let id = guard.pop().expect("predicate guaranteed a fill"); let pending = guard.pending.get(&id).expect("popped fill is pending"); - let (start, end, frame_end) = (pending.start, pending.end, pending.frame_end); + let (start, end, frame_end, time) = (pending.start, pending.end, pending.frame_end, pending.time); let (asks, result) = (pending.asks.clone(), pending.result.clone()); drop(guard); // Built with the fills unlocked, so no other lock nests under theirs. @@ -626,6 +680,7 @@ pub(super) fn poll_requested( start, end, frame_end, + time, asks, result, }; @@ -688,6 +743,7 @@ struct Inner { end: u64, // Exclusive frame cap in the last group; None asks through the original end. frame_end: Option, + time: Option, asks: kio::Shared, result: kio::Producer, } @@ -709,6 +765,20 @@ impl Fill { self.inner.end } + /// The presentation time a reader's range starts at that no group the cache holds + /// resolves, so this fill looks it up: [`accept`](Self::accept) the group containing it, + /// the newest one starting at or before it, or the oldest group when every group starts + /// after it. The reader's range then starts there, once that group's first frame is + /// written. + /// + /// A lookup owes no group, so [`wanted`](Self::wanted) is `None` and + /// [`is_wanted`](Self::is_wanted) false throughout; it waits only on its first accept. + /// Dropped unanswered, as a handler that can't resolve times does, the reader's range + /// reaches back to the oldest group the cache holds. `None` for a fill of groups. + pub fn time(&self) -> Option { + self.inner.time + } + /// The most urgent delivery priority among the readers waiting on it now. pub fn priority(&self) -> i8 { self.inner.asks.read().priority() diff --git a/rs/moq-net/src/model/track/miss.rs b/rs/moq-net/src/model/track/miss.rs index eff1d137c70..20b4e340f2f 100644 --- a/rs/moq-net/src/model/track/miss.rs +++ b/rs/moq-net/src/model/track/miss.rs @@ -212,6 +212,15 @@ impl Misses { &self.given_up } + /// The exclusive end of the run from `sequence` this reader's fills refused or it gave + /// up, `None` when it may still be read. + pub fn gone(&self, sequence: u64) -> Option { + [&self.refused, &self.given_up] + .into_iter() + .find_map(|runs| runs.get(&sequence)) + .map(|run| run.end) + } + /// What was refilled after an eviction and not handed out yet. #[cfg(all(test, feature = "async"))] pub fn refilled(&self) -> &RangeSet { @@ -821,6 +830,7 @@ impl Misses { end: run.end, frame_end: None, asking: *asking, + time: None, }; let (joins, unfillable) = fills.lock().join(fills, &ask); self.wanted.insert(run.clone()); From b1d70e717d188c87ee0867d8647cce0a9d3169fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 10:04:07 +0000 Subject: [PATCH 79/86] fix(ietf): cancel an unclaimed fill head when a subscription ends (#7382) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot --- quest/m1/README.md | 1 - quest/m1/ietf-fill-end-cancel.md | 21 ------ rs/moq-net/src/ietf/subscriber.rs | 107 +++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 30 deletions(-) delete mode 100644 quest/m1/ietf-fill-end-cancel.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 3ab218d653d..a9ed35f1c1f 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -161,7 +161,6 @@ per-frame CPU growth since #5926. - [A moq-transport request token is one auth per distinct token](/quest/m1/ietf-token-auth.md) - requests naming the same token share one verification and grant, on any request type, ending with the last such request - [moq-quic's API improves on quinn's](/quest/m1/quic-api/README.md) - moq-quic and both QUIC drivers ship one mirrored API: `send::`/`recv::` namespaces, one error per direction, consuming terminal operations, plain configs, no quinn baggage - [On-demand JavaScript FETCH](/quest/m1/js-fetch-on-demand.md) - browser publishers answer IETF FETCH for evicted or uncached groups through the JS ranges request surface -- [Rust cancels an unclaimed fill head when a subscription ends](/quest/m1/ietf-fill-end-cancel.md) - SUBSCRIPTION_ENDED cancels, not finishes, a fill no group was sent for, as in JS - [Native rung disable](/quest/m1/simulcast-rung-disable.md) - OBS stops sending the top renditions its grant cannot fund, as the Rust/FFI publishers do, and a shaped uplink through moq-ffi proves the resume - [Audio jitter target](/quest/m1/audio-jitter-target/README.md) - the playout target is default in both languages; a manual browser proof and a native trace replay remain - [A/V clock](/quest/m1/av-clock.md) - the audio playhead drives Sync.reference while audio plays, through per-track sync handles diff --git a/quest/m1/ietf-fill-end-cancel.md b/quest/m1/ietf-fill-end-cancel.md deleted file mode 100644 index 9f4be21be03..00000000000 --- a/quest/m1/ietf-fill-end-cancel.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] Rust cancels an unclaimed fill head when a subscription ends - -## Goal - -When SUBSCRIPTION_ENDED arrives while a fill head is still unclaimed, Rust's -moq-transport subscriber cancels that fill instead of finishing it, as the JS -subscriber does since [moq-dev/moq#7089](https://github.com/moq-dev/moq/pull/7089), -so a reader never sees a clean end for a group it was never sent. - -## Plan - -Planned 2026-10-30 (maintainer, `/quest-plan`). The finish is `Fill::end` in -`rs/moq-net/src/ietf/subscriber.rs`. Add a test that ends a subscription with -an unclaimed fill head and expects the cancel, in both languages' terms. - -[IETF fill refill](/quest/m2/ietf-fill-refill.md) would later remove this -fill handoff entirely; this fix is for the release cut, not a reason to wait. - -## Related - -- [IETF fill refill](/quest/m2/ietf-fill-refill.md) - replaces the fill claim and handoff this touches diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index b6fd48fe08d..826897ba137 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -276,7 +276,8 @@ enum Fill { /// The tail is what ends the group, and a publisher serving the subscription's range /// opens a stream for it even when the group ended at the join point, since that empty /// stream is how the group ends. One that opens none instead leaves this head unfinished - /// until the publisher ends the subscription, which is what publishes it. + /// until the publisher ends the subscription: TRACK_ENDED publishes it, and any other + /// end cancels it. /// /// Nothing shorter is safe to infer. A later group arriving looks like proof that no /// tail is coming, but streams are independent: the tail's own can still be behind it. @@ -298,7 +299,7 @@ enum Fill { /// the join to the next group boundary. Failed { sequence: u64 }, - /// The publisher ended the subscription cleanly, so no tail is coming. Unlike + /// The publisher ended the track, so no tail is coming. Unlike /// [`Fill::Done`], a head still being written is the whole group once it lands: a /// draft-18 joining FETCH is not in PUBLISH_DONE's Stream Count, so the settle can /// end before it does. @@ -373,7 +374,7 @@ impl Fill { /// Release a head nothing claimed, publishing the objects it did carry. /// /// The tail is what normally ends the group, so this is the fallback for when none is - /// coming: the publisher ended the subscription, or the tail that arrived could not be + /// coming: the publisher ended the track, or the tail that arrived could not be /// stitched. Finishing rather than aborting, because the head is a valid prefix of the /// group: it starts at the group's first object and has no holes. fn release(&mut self) { @@ -382,8 +383,11 @@ impl Fill { } } - /// The publisher ended the subscription cleanly: release the head, and any still being - /// written when it lands. + /// The publisher ended the track: release the head, and any still being written when it + /// lands. + /// + /// Only TRACK_ENDED, since any other end, such as SUBSCRIPTION_ENDED, leaves the group + /// open upstream and so [`Self::abort`]s the head instead. /// /// [`Fill::Failed`] stays: a joining FETCH is not in PUBLISH_DONE's Stream Count, so its /// head can land after the end, and the tail it owed may already have been dropped. @@ -3865,10 +3869,15 @@ impl Subscriber { poll_settled(&mut settle, waiter, &fill, count) }) .await; - // The publisher ended without a tail for the head, so the head - // is the whole group, even one that lands after this. + // No tail is coming for the head. Only TRACK_ENDED makes it the + // whole group, even one that lands after this. Any other end + // leaves the group open upstream, so the head is cut short + // rather than passed off as the whole group. if let Ok(mut fill) = fill.write() { - fill.end(); + match &end { + Ok(()) => fill.end(), + Err(err) => fill.abort(err.clone()), + } } } // Only TRACK_ENDED ends the track. Any other status ends just this @@ -15708,6 +15717,88 @@ mod joining_fetch_tests { } } + /// A head nothing claimed when the publisher ends the subscription is the whole group only + /// on TRACK_ENDED. SUBSCRIPTION_ENDED leaves the group open upstream, so finishing the head + /// would pass a prefix off as the whole group: it is cancelled instead. + #[kio::test] + async fn an_unclaimed_head_finishes_only_when_the_track_ends() { + let largest = Some(ietf::Location { group: 4, object: 0 }); + for version in JOINING_DRAFTS { + for (status_code, whole) in [(ietf::PublishDoneStatus::TrackEnded.code(), true), (0x3, false)] { + let mut ok = subscribe_ok(version, largest); + ok.extend(message_bytes( + ietf::PublishDone::ID, + &ietf::PublishDone { + status_code, + stream_count: 0, + reason_phrase: "done".into(), + }, + version, + )); + let mut run = JoinRun::start(version, None, ok, fetch_ok(version)).await; + let fetch_id = *run + .subscriber + .state + .lock() + .fetches + .keys() + .next() + .expect("joining FETCH"); + + let mut objects = Vec::new(); + let w = &mut Encoder::new(&mut objects, version.into()); + w.varint(ietf::FetchHeader::TYPE).unwrap(); + ietf::FetchHeader { request_id: fetch_id }.encode(w, version).unwrap(); + ietf::FetchObject::Object { + subgroup: ietf::FetchSubgroup::Zero, + group: Some(4), + object: Some(0), + priority: Some(0), + properties: None, + } + .encode(w, version) + .unwrap(); + w.bytes(b"x").unwrap(); + let data = ScriptedSession::new(objects); + data.close(crate::lite::test_transport::Close::Fin); + let (_, recv) = data.clone().open_bi(Default::default()).await.unwrap(); + let mut reader = Reader::new(crate::transport::boxed::RecvStream::new(recv), version); + + // The whole head lands before the grace runs out, and no tail ever claims it. + run.subscriber.clone().recv_fill(&mut reader).await.expect("fill"); + assert!( + matches!( + *run.subscriber + .state + .lock() + .subscribes + .values() + .next() + .unwrap() + .fill + .read(), + Fill::Ready { .. } + ), + "{version}/{status_code}: the head waits for its tail" + ); + + let mut subscriber = (&mut run._hold.2).await.expect("subscribed"); + let mut group = subscriber.recv_group().await.unwrap().unwrap(); + assert_eq!(group.sequence, 4, "{version}/{status_code}"); + assert_eq!(group.read_frame().await.unwrap().unwrap().payload.as_ref(), b"x"); + + while !run.subscriber.state.lock().subscribes.is_empty() { + settle().await; + } + let end = group.read_frame().await; + match whole { + true => assert!(matches!(end, Ok(None)), "{version}: the head was cut short: {end:?}"), + false => assert!(end.is_err(), "{version}: the head read as the whole group: {end:?}"), + } + } + } + } + /// SUBSCRIPTION_ENDED ends only our subscription, not the track, so the copy is released /// for a later reader to subscribe afresh rather than lingering as if the track ended. Its /// reader sees an error, not a clean end: the fresh copy's groups continue past this one's, From f8c608a24bbad5c51b1ffa2ad7a4996e9405ac0c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 11:06:07 +0000 Subject: [PATCH 80/86] feat(hang,publish): publish HLS segment records from the browser by default (#7367) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- js/hang/api/container.api.md | 3 +- js/hang/api/hls.api.md | 72 +++++ js/hang/api/index.api.md | 32 +-- js/hang/package.json | 1 + js/hang/src/container/legacy.ts | 24 +- js/hang/src/hls.test.ts | 183 ++++++++++++- js/hang/src/hls.ts | 300 ++++++++++++++++++++- js/json/api/index.api.md | 1 + js/json/src/window/producer.ts | 5 + js/publish/api/index.api.md | 3 + js/publish/src/audio/capture.test.ts | 2 + js/publish/src/audio/encoder.test.ts | 33 +++ js/publish/src/audio/encoder.ts | 20 +- js/publish/src/broadcast.test.ts | 166 +++++++++++- js/publish/src/broadcast.ts | 86 +++++- js/publish/src/internal.ts | 20 ++ js/publish/src/publication.ts | 13 +- js/publish/src/rendition-internal.ts | 28 +- js/publish/src/video/capture.test.ts | 18 +- js/publish/src/video/encoder-stall.test.ts | 2 + js/publish/src/video/encoder.test.ts | 60 +++++ js/publish/src/video/encoder.ts | 19 +- quest/m2/README.md | 2 - quest/m2/js-hls.md | 23 -- rs/moq-cli/Cargo.toml | 4 + rs/moq-relay/src/auth/caps.rs | 1 + test/interop/README.md | 8 + test/interop/clients/js-native/hls.ts | 110 ++++++++ test/interop/clients/rust/hls.rs | 170 ++++++++++++ test/interop/interop.sh | 60 ++++- 30 files changed, 1377 insertions(+), 92 deletions(-) create mode 100644 js/hang/api/hls.api.md delete mode 100644 quest/m2/js-hls.md create mode 100644 test/interop/clients/js-native/hls.ts create mode 100644 test/interop/clients/rust/hls.rs diff --git a/js/hang/api/container.api.md b/js/hang/api/container.api.md index 0db0d680e8d..f3f01b44471 100644 --- a/js/hang/api/container.api.md +++ b/js/hang/api/container.api.md @@ -6,6 +6,7 @@ import { Format as Format_1 } from "@moq/hang/container"; import { Micro } from "@moq/clock"; import { Milli } from "@moq/clock"; import { Net } from "@moq/hang"; +import { Recorder } from "@moq/hang/hls"; import { Signals } from "@moq/hang"; import { Video } from "@moq/hang/catalog"; @@ -64,7 +65,7 @@ export namespace Legacy { } export { Frame }; export class Producer { - constructor(track: Net.Track.Producer, format: Legacy.Format); + constructor(track: Net.Track.Producer, format: Legacy.Format, recorder?: Recorder); close(err?: Error): void; discontinuity(end?: Micro): void; encode(data: Uint8Array | Legacy.Source, timestamp: Micro, keyframe: boolean): void; diff --git a/js/hang/api/hls.api.md b/js/hang/api/hls.api.md new file mode 100644 index 00000000000..a65b60469dd --- /dev/null +++ b/js/hang/api/hls.api.md @@ -0,0 +1,72 @@ +# `@moq/hang/hls` + +```ts +import { Hls } from "@moq/hang/catalog"; +import { Micro } from "@moq/clock"; +import { Net } from "@moq/hang"; +import { Signals } from "@moq/hang"; +import { Window } from "@moq/json"; + +export interface Config { + duration?: Micro; + durationHint?: Micro; +} + +export class Consumer implements AsyncIterable { + [Symbol.asyncIterator](): AsyncIterator; + close(): void; + next(): Promise; + static subscribe(broadcast: Net.Broadcast.Consumer, hls: Hls): Consumer; +} + +export const DEFAULT_DURATION: Micro; + +export type Entry = Omit & { + duration?: Micro; + pts: Micro; +}; + +export type Event = { + push: { + entry: Entry; + index: number; + }; +} | { + pop: Window.Span; +} | { + skip: Window.Span; +}; + +export interface Record { + duration?: number; + group: number; + pts: number; + sequence: number; +} + +export class Recorder { + constructor(track: Net.Track.Producer, config?: Config); + static readonly INFO: Partial; + abort(err: Error): void; + end(pts: Micro): void; + finish(): void; + frame(group: number, pts: Micro): void; + readonly hls: Signals.Getter; + stop(group: number): void; + readonly track: Net.Track.Producer; +} + +export const SUFFIX: ".hls"; + +export class Segmenter { + constructor(config?: Config); + close(): void; + get declared(): Micro | undefined; + end(pts: Micro): void; + frame(group: number, pts: Micro): void; + stop(group: number): void; + take(): Record[]; +} + +export function defaultName(track: string): string; +``` diff --git a/js/hang/api/index.api.md b/js/hang/api/index.api.md index 4b26a93bb4b..5c748b48db5 100644 --- a/js/hang/api/index.api.md +++ b/js/hang/api/index.api.md @@ -5,8 +5,6 @@ import { $ZodBranded } from "zod/v4/core"; import { $brand } from "@zod/mini"; import { $loose } from "zod/v4/core"; import { $strip } from "zod/v4/core"; -import { Micro } from "@moq/clock"; -import { Window } from "@moq/json"; import { ZodMiniNullable } from "@zod/mini"; import { ZodMiniNumber } from "@zod/mini"; import { ZodMiniObject } from "@zod/mini"; @@ -158,35 +156,7 @@ export namespace Health { export function viewer(sample: Health.Sample, thresholds?: Health.Thresholds): Health.Health; } -export namespace Hls { - export class Consumer implements AsyncIterable { - [Symbol.asyncIterator](): AsyncIterator; - close(): void; - next(): Promise; - static subscribe(broadcast: Net.Broadcast.Consumer, hls: import("@moq/hang/catalog").Hls): Hls.Consumer; - } - export type Entry = Omit & { - duration?: Micro; - pts: Micro; - }; - export type Event = { - push: { - entry: Hls.Entry; - index: number; - }; - } | { - pop: Window.Span; - } | { - skip: Window.Span; - }; - export interface Record { - duration?: number; - group: number; - pts: number; - sequence: number; - } - export const SUFFIX: ".hls"; -} +export * as Hls from "@moq/hang/hls"; export * as Net from "@moq/net"; diff --git a/js/hang/package.json b/js/hang/package.json index 1a0f714d2c8..c1001c7733d 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -15,6 +15,7 @@ "./cmaf": "./src/cmaf/index.ts", "./catalog": "./src/catalog/index.ts", "./container": "./src/container/index.ts", + "./hls": "./src/hls.ts", "./util": "./src/util/index.ts" }, "scripts": { diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index dd2bc91c7cf..717259a7866 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -5,6 +5,7 @@ export type { BufferedRange, BufferedRanges, Frame } from "./types"; import type { Micro } from "@moq/clock"; import type { AudioConfig, VideoConfig } from "../catalog"; +import type { Recorder } from "../hls"; import type { Format as ContainerFormat } from "./format"; import type { Frame } from "./types"; @@ -78,11 +79,18 @@ export class Producer { #interval?: Micro; // A discontinuity's marker is the newest group, so another one would say nothing new. #marked = false; + #recorder?: Recorder; - /** Wrap a track to publish legacy-container frames into it. */ - constructor(track: Moq.Track.Producer, format: Format) { + /** + * Wrap a track to publish legacy-container frames into it. + * + * With a `recorder`, each written group is reported into the track's HLS records. The recorder + * outlives this producer, so another producer taking over the track continues its records. + */ + constructor(track: Moq.Track.Producer, format: Format, recorder?: Recorder) { this.#format = format; this.#track = track; + this.#recorder = recorder; } /** Encode and append a frame; a keyframe starts a new group. Throws if the first frame is not a keyframe, a group start goes backwards, or a frame is below the previous group start. */ @@ -107,6 +115,7 @@ export class Producer { payload: encodeFrame(data, timestamp), timestamp: Time.Timestamp.fromMicros(timestamp), }); + if (this.#group) this.#recorder?.frame(this.#group.sequence, timestamp); this.#reordered ||= this.#previous !== undefined && timestamp < this.#previous; if (this.#previous !== undefined && timestamp > this.#previous) { @@ -147,6 +156,8 @@ export class Producer { timestamp: Time.Timestamp.fromMicros(timestamp), }); group.finish(); + // The marker is contiguous, so without this the open HLS segment would absorb the pause. + this.#recorder?.stop(group.sequence); this.#liveEdge = this.#liveEdge === undefined ? timestamp : (Math.max(this.#liveEdge, timestamp) as Micro); this.#marked = true; } @@ -167,6 +178,11 @@ export class Producer { this.#end !== undefined && this.#interval !== undefined ? ((this.#end + this.#interval) as Micro) : undefined; + // Where this group's content stops: the duration marker when there is one, else the furthest + // point written. + const reached = + end === undefined ? this.#end : this.#end === undefined ? end : (Math.max(end, this.#end) as Micro); + if (reached !== undefined) this.#recorder?.end(reached); // A presentation endpoint cannot bound the decode-order tail after reordering. this.#format.finishGroup(this.#group, this.#reordered ? undefined : end); this.#group.finish(); @@ -179,9 +195,11 @@ export class Producer { this.#reordered = false; } - /** Close the track and current group, optionally with an error. */ + /** Close the track and current group, optionally with an error. Finishes or aborts the HLS records with it. */ close(err?: Error) { if (!err) this.#close(); + if (err) this.#recorder?.abort(err); + else this.#recorder?.finish(); if (err) this.#group?.abort(err); else this.#group?.finish(); if (err) this.#track.abort(err); diff --git a/js/hang/src/hls.test.ts b/js/hang/src/hls.test.ts index 62c31cf74a2..497ee5be9a5 100644 --- a/js/hang/src/hls.test.ts +++ b/js/hang/src/hls.test.ts @@ -3,7 +3,8 @@ import type { Micro } from "@moq/clock"; import * as Json from "@moq/json"; import { Broadcast, Time } from "@moq/net"; import { u53 } from "./catalog"; -import { Consumer, type Record } from "./hls.ts"; +import * as Legacy from "./container/legacy.ts"; +import { Consumer, DEFAULT_DURATION, defaultName, type Record, Recorder, Segmenter } from "./hls.ts"; const us = (ms: number): Micro => (ms * 1000) as Micro; @@ -49,3 +50,183 @@ test("a record without its group is refused", async () => { await expect(consumer.next()).rejects.toThrow(); close(); }); + +// The segmenter cases mirror `rs/moq-mux/src/hls.rs`, so both cut the same records. + +const hinted = { durationHint: us(4000) }; + +// Each record as `[group, pts, duration]` in wire units, checking the numbering. +function drain(segmenter: Segmenter, first = 0): [number, number, number | undefined][] { + return segmenter.take().map((record, index) => { + expect(record.sequence).toBe(first + index); + return [record.group, record.pts, record.duration]; + }); +} + +// Groups `start..end`, one frame each, `step` ms apart. +function groups(segmenter: Segmenter, start: number, end: number, step: number) { + for (let group = start; group < end; group++) segmenter.frame(group, us(group * step)); +} + +test("long groups get a segment each", () => { + const segmenter = new Segmenter(); + groups(segmenter, 0, 3, 5000); + expect(drain(segmenter)).toEqual([ + [0, 0, undefined], + [1, 5000, undefined], + [2, 10000, undefined], + ]); + expect(segmenter.declared).toBe(us(5000)); +}); + +test("short groups pack into a grid cell", () => { + const segmenter = new Segmenter(); + groups(segmenter, 0, 10, 1000); + expect(drain(segmenter)).toEqual([ + [0, 0, undefined], + [4, 4000, undefined], + [8, 8000, undefined], + ]); +}); + +test("a hint declares its smallest multiple at or past the duration", () => { + expect(new Segmenter({ durationHint: us(1500) }).declared).toBe(us(4500)); + expect(new Segmenter(hinted).declared).toBe(us(4000)); +}); + +test("a stopped first segment declares the grid", () => { + const segmenter = new Segmenter(); + groups(segmenter, 0, 2, 1000); + segmenter.close(); + expect(segmenter.declared).toBe(DEFAULT_DURATION); + expect(drain(segmenter)).toEqual([ + [0, 0, undefined], + [2, 1000, 0], + ]); +}); + +test("the end extends the stop", () => { + const segmenter = new Segmenter(hinted); + groups(segmenter, 0, 2, 1000); + segmenter.end(us(2000)); + segmenter.close(); + expect(drain(segmenter)).toEqual([ + [0, 0, undefined], + [2, 2000, 0], + ]); + // Nothing reports after the close. + segmenter.frame(5, us(9000)); + expect(drain(segmenter, 2)).toEqual([]); +}); + +test("a skipped sequence stops the segment", () => { + const segmenter = new Segmenter(hinted); + groups(segmenter, 0, 2, 1000); + segmenter.end(us(1500)); + segmenter.frame(5, us(60000)); + expect(drain(segmenter)).toEqual([ + [0, 0, undefined], + [2, 1500, 0], + [5, 60000, undefined], + ]); +}); + +test("a stop ends the segment and the next group starts one", () => { + const segmenter = new Segmenter(hinted); + groups(segmenter, 0, 2, 1000); + segmenter.end(us(2000)); + segmenter.stop(2); + segmenter.frame(3, us(10000)); + expect(drain(segmenter)).toEqual([ + [0, 0, undefined], + [2, 2000, 0], + [3, 10000, undefined], + ]); +}); + +test("a report that goes backwards is refused", () => { + const segmenter = new Segmenter(hinted); + segmenter.frame(3, us(0)); + segmenter.frame(3, us(10)); + expect(() => segmenter.frame(2, us(20))).toThrow("group 2 after group 3"); +}); + +test("an uneven GOP cuts on the grid", () => { + const segmenter = new Segmenter(); + // 3s GOPs on a 4s grid: a group starts a segment only in a later cell than the one before. + groups(segmenter, 0, 9, 3000); + expect(drain(segmenter)).toEqual([ + [0, 0, undefined], + [2, 6000, undefined], + [3, 9000, undefined], + [4, 12000, undefined], + [6, 18000, undefined], + [7, 21000, undefined], + [8, 24000, undefined], + ]); + expect(segmenter.declared).toBe(us(6000)); +}); + +test("a sub-millisecond pts truncates on the wire", () => { + // 120 frames of 30000/1001 fps video: a 4004.004 ms grid. + const segmenter = new Segmenter({ durationHint: 33366.7 as Micro }); + expect(segmenter.declared).toBeCloseTo(4_004_004); + groups(segmenter, 0, 1, 0); + segmenter.frame(1, 4_004_999 as Micro); + expect(drain(segmenter)).toEqual([ + [0, 0, undefined], + [1, 4004, undefined], + ]); +}); + +test("a recorder advertises its declaration rounded up to whole milliseconds", () => { + const broadcast = new Broadcast.Producer(); + const recorder = new Recorder(broadcast.createTrack(defaultName("video"), Recorder.INFO), { + durationHint: 33366.7 as Micro, + }); + expect(recorder.hls.peek()).toEqual({ track: "video.hls", duration: u53(4005) }); + recorder.finish(); + broadcast.close(); +}); + +// A legacy producer reports each group it writes, and a recorder publishes and advertises them. +test("a recorded legacy track publishes its records and declares its duration", async () => { + const broadcast = new Broadcast.Producer(); + const media = broadcast.createTrack("video", { timescale: Time.Timescale.MICRO }); + const recorder = new Recorder(broadcast.createTrack(defaultName("video"), Recorder.INFO)); + expect(recorder.hls.peek()).toBeUndefined(); + + const producer = new Legacy.Producer(media, new Legacy.Format("video"), recorder); + const payload = new Uint8Array([1]); + // 2s GOPs of 30 fps-ish frames, then a pause and two more GOPs. + for (let ms = 0; ms < 6000; ms += 100) producer.encode(payload, us(ms), ms % 2000 === 0); + producer.discontinuity(us(6000)); + for (let ms = 20000; ms < 24000; ms += 100) producer.encode(payload, us(ms), ms % 2000 === 0); + producer.close(); + expect(recorder.hls.peek()).toEqual({ track: "video.hls", duration: u53(4000) }); + + const consumer = Consumer.subscribe(broadcast.consume(), { track: "video.hls", duration: u53(4000) }); + const entries = []; + for await (const event of consumer) if ("push" in event) entries.push(event.push.entry); + expect(entries).toEqual([ + { sequence: 0, group: 0, pts: us(0) }, + { sequence: 1, group: 2, pts: us(4000) }, + { sequence: 2, group: 3, pts: us(6000), duration: us(0) }, + { sequence: 3, group: 4, pts: us(20000) }, + { sequence: 4, group: 6, pts: us(24000), duration: us(0) }, + ]); + broadcast.close(); +}); + +test("a report out of order aborts the records but not the media", async () => { + const broadcast = new Broadcast.Producer(); + const records = broadcast.createTrack(defaultName("video"), Recorder.INFO); + const recorder = new Recorder(records); + recorder.frame(3, us(0)); + recorder.frame(2, us(10)); + expect(records.closed.peek()).toBeInstanceOf(Error); + // Later reports are ignored. + recorder.frame(4, us(20)); + recorder.finish(); + broadcast.close(); +}); diff --git a/js/hang/src/hls.ts b/js/hang/src/hls.ts index 4a3cc106d47..840362102fc 100644 --- a/js/hang/src/hls.ts +++ b/js/hang/src/hls.ts @@ -8,21 +8,41 @@ * zero `duration` marks where content stopped: that segment holds no media, and what follows it * is a discontinuity. * - * Every `pts` and `duration` is in milliseconds. The track is an uncompressed `@moq/json` window. + * Every `pts` and `duration` is in milliseconds. The track is a compressed `@moq/json` window + * whose groups restate the recent records. + * + * A {@link Recorder} writes one rendition's records from reports of the frames its media track + * publishes, as `rs/moq-mux`'s does. Segments are cut on a grid of {@link Config.duration} (4s by + * default) in pts: a group starts a segment when its pts falls in a later multiple of the duration + * than the previous group's. Short groups (audio) pack into one segment per grid cell, and long ones + * (video GOPs) get one each. A hint replaces the duration with its smallest multiple at or beyond + * it, which is also the nominal duration declared; without one, the first segment declares it. + * Each rendition cuts on its own grid and numbers its own records, sharing no state with the + * others, so renditions whose keyframes share timestamps cut the same boundaries. + * + * Content stopping (a skipped group sequence, a discontinuity marker, or the end of the track) + * publishes a zero-duration record where it stopped, so the segment before it ends there rather + * than absorbing the pause. * * @module */ -import type { Micro } from "@moq/clock"; +import { Micro } from "@moq/clock"; import * as Json from "@moq/json"; -import type * as Moq from "@moq/net"; +import * as Moq from "@moq/net"; +import { type Getter, Signal } from "@moq/signals"; import * as z from "@zod/mini"; import type * as Catalog from "./catalog"; -import { u53Schema } from "./catalog"; +import { PRIORITY, u53, u53Schema } from "./catalog"; /** The conventional suffix appended to a media track's name to name its HLS record track. */ export const SUFFIX = ".hls"; +/** The conventional HLS record track name for `track`: the name plus {@link SUFFIX}. */ +export function defaultName(track: string): string { + return `${track}${SUFFIX}`; +} + /** One HLS segment: its first group and pts, in milliseconds. */ export interface Record { /** The segment's number, consecutive within the track and equal to the record's window index. */ @@ -99,3 +119,275 @@ function micros(millis: number): Micro { if (!Number.isSafeInteger(value)) throw new Error("HLS record timestamp overflow"); return value as Micro; } + +/** The default {@link Config.duration} (4 seconds). */ +export const DEFAULT_DURATION = Micro(4_000_000); + +// Recent records repeated when the window track rolls to a new group: one more than the 256 +// segments an HLS edge lists, since a segment is listed only once the record after it arrives. +const CHECKPOINT_RECORDS = 257; + +/** How a track's HLS segments are cut. */ +export interface Config { + /** + * The spacing of the pts grid segments are cut on. Defaults to {@link DEFAULT_DURATION}. + * + * A grid rather than a running length on purpose: renditions whose keyframes share timestamps + * cut the same boundaries without sharing any state. + */ + duration?: Micro; + + /** The application's nominal group duration, when known. */ + durationHint?: Micro; +} + +// A pts in wire milliseconds, truncated as a timescale conversion does. +function wire(pts: Micro): number { + return Math.floor(pts / 1000); +} + +/** + * Builds one track's records from reports of the frames it published. + * + * Reports arrive in group order: {@link frame} for each frame (or at least the first of each group), + * {@link end} wherever the content is known to extend, and {@link stop} where it stops. Records + * queue until {@link take} drains them. + */ +export class Segmenter { + readonly #duration: Micro; + // The grid's spacing: the hint's multiple, else the configured duration. + readonly #grid: Micro; + // Fixed nominal duration; absent until an unhinted track's first segment closes. Kept unrounded; + // only the wire declaration rounds. + #declared?: Micro; + // The number the next record gets. + #sequence = 0; + // The open segment's start: absent before the first segment and after a stop. + #open?: Micro; + // The grid cell of the open segment's newest group. + #cell = 0; + // The newest reported group. + #last?: number; + // The newest reported content time in the open segment. + #frontier?: Micro; + #ready: Record[] = []; + #closed = false; + + /** A segmenter numbering its records from zero. */ + constructor(config: Config = {}) { + this.#duration = config.duration ?? DEFAULT_DURATION; + const hint = config.durationHint; + if (hint !== undefined && hint > 0) { + this.#declared = Micro(hint * Math.max(1, Math.ceil(this.#duration / hint))); + } + this.#grid = this.#declared ?? this.#duration; + } + + /** The fixed nominal segment duration, once known. */ + get declared(): Micro | undefined { + return this.#declared; + } + + // The grid cell `pts` falls in. A zero grid gives every distinct pts its own. + #cellOf(pts: Micro): number { + return this.#grid > 0 ? Math.floor(pts / this.#grid) : pts; + } + + /** + * Report a frame of group `group`, presented at `pts`. + * + * Throws when `group` precedes the previous report's: segments hold whole groups in order, so that + * frame could never be listed. + */ + frame(group: number, pts: Micro): void { + if (this.#closed) return; + if (this.#last !== undefined) { + if (group < this.#last) throw new Error(`HLS report for group ${group} after group ${this.#last}`); + if (group !== this.#last) this.#group(group, this.#last, pts); + } else { + this.#begin(group, pts); + } + this.#last = group; + this.#advance(pts); + } + + // Group `group` starts at `pts`, after group `last`. + #group(group: number, last: number, pts: Micro): void { + const start = this.#open; + if (start === undefined) { + this.#begin(group, pts); + return; + } + if (group !== last + 1) { + // A skipped sequence stops the segment where its content reached, so the skipped time is + // not absorbed into it and the reader never has to guess what lies between. + this.#stopAt(last + 1); + this.#begin(group, pts); + return; + } + // Rust's segmenter also cuts on source-declared boundaries, held to a ceiling; nothing here + // declares them, so the grid alone decides. + const cell = this.#cellOf(pts); + if (cell > this.#cell) { + // A grid's first segment starts wherever the content does, so it may be short. + this.#declared ??= Micro(Math.max(pts - start, this.#grid)); + this.#begin(group, pts); + } + this.#cell = cell; + } + + // Start a segment with group `group` at `pts`. + #begin(group: number, pts: Micro): void { + this.#open = pts; + this.#frontier = pts; + this.#cell = this.#cellOf(pts); + this.#ready.push({ sequence: this.#sequence++, group, pts: wire(pts) }); + } + + /** + * Report that the content extends to `pts`, without a frame starting there. + * + * A frame report says where content starts; the stop after the last segment would otherwise land + * at its last frame's start. + */ + end(pts: Micro): void { + if (!this.#closed) this.#advance(pts); + } + + // Content in the open segment reached `pts`. + #advance(pts: Micro): void { + if (this.#open !== undefined && (this.#frontier === undefined || pts > this.#frontier)) this.#frontier = pts; + } + + /** + * Content stopped before group `group`: queue a zero-duration record where it reached. + * + * The next group starts a new segment. Nothing happens while no segment is open. + */ + stop(group: number): void { + if (!this.#closed) this.#stopAt(group); + } + + #stopAt(group: number): void { + const start = this.#open; + if (start === undefined) return; + this.#open = undefined; + // One stopped early says nothing about the cadence, so the target falls back to the grid. + this.#declared ??= this.#grid; + const end = this.#frontier ?? start; + this.#frontier = undefined; + this.#ready.push({ sequence: this.#sequence++, group, pts: wire(end), duration: 0 }); + } + + /** Stop after the newest group, then ignore every later report. */ + close(): void { + if (this.#closed) return; + if (this.#last !== undefined) this.#stopAt(this.#last + 1); + this.#closed = true; + } + + /** Take the records queued since the last call. */ + take(): Record[] { + const ready = this.#ready; + this.#ready = []; + return ready; + } +} + +/** + * Cuts one media track's segments and publishes their records onto its HLS record track. + * + * The records are an optional sidecar, so nothing here throws at the media: a report it cannot place + * or a failed publish aborts the record track instead, and the media carries on. + */ +export class Recorder { + /** The properties a record track is created with. */ + static readonly INFO: Partial = { + timescale: Moq.Time.Timescale.MILLI, + priority: PRIORITY.catalog, + // A joiner asks for a fresh group once the ops outweigh eight headers. + refresh: 8, + // Records are small, repetitive JSON. + compress: true, + }; + + /** The record track. */ + readonly track: Moq.Track.Producer; + + readonly #segmenter: Segmenter; + readonly #sink: Json.Window.Producer; + readonly #declared = new Signal(undefined); + #done = false; + + /** + * The `hls` field to advertise for the media track, once its nominal duration is known: the + * record track and that duration in whole milliseconds, rounded up. + */ + readonly hls: Getter = this.#declared; + + /** Publish onto `track`, created with {@link INFO}, starting at record zero. */ + constructor(track: Moq.Track.Producer, config?: Config) { + this.track = track; + this.#segmenter = new Segmenter(config); + this.#sink = new Json.Window.Producer({ track, checkpointRecords: CHECKPOINT_RECORDS }); + this.#publish(); + } + + /** Report a frame of group `group`, presented at `pts`; see {@link Segmenter.frame}. */ + frame(group: number, pts: Micro): void { + if (this.#done) return; + try { + this.#segmenter.frame(group, pts); + } catch (err) { + // Records that silently omit frames would misdirect the playlist. + this.abort(err instanceof Error ? err : new Error(String(err))); + return; + } + this.#publish(); + } + + /** Report that the content extends to `pts`; see {@link Segmenter.end}. */ + end(pts: Micro): void { + this.#segmenter.end(pts); + } + + /** Report that content stopped before group `group`; see {@link Segmenter.stop}. */ + stop(group: number): void { + this.#segmenter.stop(group); + this.#publish(); + } + + /** Stop after the newest group and finish the record track. Idempotent. */ + finish(): void { + if (this.#done) return; + this.#segmenter.close(); + this.#publish(); + this.#done = true; + this.#sink.finish(); + } + + /** Abort the record track with `err`, so readers see the cause rather than a complete record. Idempotent. */ + abort(err: Error): void { + if (this.#done) return; + this.#done = true; + this.#segmenter.close(); + this.#sink.abort(err); + } + + #publish(): void { + if (this.#done) return; + for (const value of this.#segmenter.take()) { + try { + this.#sink.push({ value, at: Moq.Time.Timestamp.now() }); + } catch (err) { + console.warn("HLS record publish failed; aborting the record track", err); + this.abort(err instanceof Error ? err : new Error(String(err))); + return; + } + } + const declared = this.#segmenter.declared; + if (declared !== undefined && this.#declared.peek() === undefined) { + this.#declared.set({ track: this.track.name, duration: u53(Math.ceil(declared / 1000)) }); + } + } +} diff --git a/js/json/api/index.api.md b/js/json/api/index.api.md index 6933f7664f9..d952e2bdcea 100644 --- a/js/json/api/index.api.md +++ b/js/json/api/index.api.md @@ -166,6 +166,7 @@ export namespace Window { } export class Producer { constructor(config: Window.ProducerConfig); + abort(err: Error): void; cut(): void; finish(): void; get offset(): number; diff --git a/js/json/src/window/producer.ts b/js/json/src/window/producer.ts index 2cd04e8fb07..698f5f86c75 100644 --- a/js/json/src/window/producer.ts +++ b/js/json/src/window/producer.ts @@ -161,6 +161,11 @@ export class Producer { this.#close(); } + /** Abort the track with `err`, so readers see the cause rather than a complete window. Idempotent. */ + abort(err: Error): void { + this.#close(err); + } + #close(abort?: Error): void { if (this.#finished) return; this.#finished = true; diff --git a/js/publish/api/index.api.md b/js/publish/api/index.api.md index 7f85518b9c5..496011d832f 100644 --- a/js/publish/api/index.api.md +++ b/js/publish/api/index.api.md @@ -134,6 +134,7 @@ export type BroadcastInput = { } | undefined>; enabled: Signals.Getter; flip: Signals.Getter; + hls: Signals.Getter; maxAge: Signals.Getter; name: Signals.Getter; onDemand: Signals.Getter; @@ -213,6 +214,8 @@ export namespace Preview { export class Rendition { close(): void; readonly config: Signals.Signal; + hls(track: Net.Track.Producer): Hang.Hls.Recorder | undefined; + readonly hlsHint: Signals.Signal; readonly kind: Kind; readonly name: string; readonly track: Signals.Getter; diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index b5a3cba0a2f..330f295fc0d 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -562,6 +562,8 @@ test("a failed encoder stays failed until the capture source changes", async () slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => {}, }; const encoder = new Encoder("audio", { diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index 70db56f10c7..07099de5e0c 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -294,6 +294,8 @@ async function setup(order = new Order(), codec?: Codec, groupDuration?: Milli) slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -1025,6 +1027,37 @@ test.each([undefined, false, true])( }, ); +// An HLS export subscribes only to the records the catalog advertises, so their reader must start the +// on-demand audio encode just as a media subscriber does. +test("a record subscriber starts on-demand audio", async () => { + using _webcodecs = installFakeWebCodecs(); + const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("records.hang") }); + const feed = new Feed(); + const configuring = configured(); + const encoder = new Encoder("audio", { broadcast, capture: fakeCapture(feed) as never }); + let records: Moq.Track.Subscriber | undefined; + try { + await configuring; + await settle(); + const net = broadcast.net.peek(); + if (!net) throw new Error("expected network broadcast"); + expect(encoder.out.active.peek()).toBe(false); + + records = net.track("audio.hls").subscribe(); + await settle(); + expect(encoder.out.active.peek()).toBe(true); + + records.close(); + records = undefined; + await settle(); + expect(encoder.out.active.peek()).toBe(false); + } finally { + records?.close(); + encoder.close(); + broadcast.close(); + } +}); + test("a backlog encode refusal aborts its rendition when demand arrives", async () => { using _webcodecs = installFakeWebCodecs(); const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("refused.hang") }); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index ce6a1e25b95..c824a0b0f12 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -13,7 +13,7 @@ import { Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { watched } from "../internal"; +import { hlsHint, wanted, watched } from "../internal"; import { Estimator, type Order } from "../order"; import { Slot } from "../publication"; import type { AudioFrame, Capture, Format } from "./capture"; @@ -128,7 +128,7 @@ export type EncoderOutput = { // PCM rather than in the graph, so this is pre-gain. Undefined for a source that isn't a track. root: Signal; // True while encoding: enabled with a captured format and a track, which also takes a - // subscriber unless the broadcast's `onDemand` is false. + // subscriber to it or its HLS records unless the broadcast's `onDemand` is false. active: Signal; // Cumulative output totals (frames, bytes) measured while serving. stats: Signal; @@ -146,7 +146,8 @@ type Pipeline = { * A single audio rendition encoder. * * Registers itself on the {@link Broadcast} under {@link name} (via `broadcast.audio(name)`), pumps PCM - * off the source via a {@link Capture}, and retains raw PCM while enabled. A subscriber triggers encoding of that history followed by + * off the source via a {@link Capture}, and retains raw PCM while enabled. A subscriber, to the track or its HLS records, + * triggers encoding of that history followed by * live audio; leaving resumes buffering without ending the epoch. The broadcast's `onDemand: false` * keeps encoding continuously instead. Rename by constructing a new encoder; the name is not a signal. */ @@ -403,6 +404,9 @@ export class Encoder { const rendition = broadcast.audio(this.name); effect.cleanup(() => rendition.close()); + // Audio groups are far shorter than a segment, so any hint declares the grid's own duration up front. + rendition.hlsHint.set(hlsHint(this.groupDuration.peek())); + effect.subscribe(this.groupDuration, (duration) => rendition.hlsHint.set(hlsHint(duration))); effect.proxy(this.#slot, rendition.slot); effect.cleanup(() => this.#slot.set(undefined)); @@ -433,7 +437,11 @@ export class Encoder { const live = { track, - producer: new Container.Legacy.Producer(track, new Container.Legacy.Format("audio")), + producer: new Container.Legacy.Producer( + track, + new Container.Legacy.Format("audio"), + rendition.hls(track), + ), }; this.#live = live; effect.cleanup(() => { @@ -452,8 +460,8 @@ export class Encoder { const fatal = effect.get(this.#out.error); const onDemand = effect.get(broadcast.in.onDemand) ?? true; - const demanded = track && effect.get(track.demand().wanted) !== undefined; - this.#encoding = enabled && !!format && !!track && !fatal && (!onDemand || !!demanded); + const demanded = !!track && wanted(effect, track, rendition.hls(track)); + this.#encoding = enabled && !!format && !!track && !fatal && (!onDemand || demanded); effect.set(this.#out.active, this.#encoding, false); try { if (this.#encoding) this.#pipeline?.drain(); diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 307ac81a94a..0fc7b910269 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -1,6 +1,7 @@ -import { expect, test } from "bun:test"; -import { Manual, Milli, Nano } from "@moq/clock"; +import { expect, spyOn, test } from "bun:test"; +import { Manual, Micro, Milli, Nano } from "@moq/clock"; import * as Catalog from "@moq/hang/catalog"; +import * as Container from "@moq/hang/container"; import * as Json from "@moq/json"; import { Origin, Path, Track } from "@moq/net"; import { Effect, Signal } from "@moq/signals"; @@ -762,6 +763,167 @@ test("identity changes before the first announce take a fresh track name", async broadcast.close(); }); +test("each video and audio track publishes HLS records and advertises them once declared", async () => { + const broadcast = new Broadcast({ enabled: true, origin: new Origin.Producer(), name: Path.from("hls.hang") }); + const video = broadcast.video("video/hd"); + video.config.set(videoConfig); + const audio = broadcast.audio("audio/data"); + audio.config.set(audioConfig); + const text = broadcast.text("captions/en"); + await settle(); + + const track = video.track.peek(); + if (!track) throw new Error("expected the video track"); + const recorder = video.hls(track); + expect(recorder?.track.name).toBe("video/hd.hls"); + const audioTrack = audio.track.peek(); + expect(audioTrack && audio.hls(audioTrack)?.track.name).toBe("audio/data.hls"); + const textTrack = text.track.peek(); + expect(textTrack && text.hls(textTrack)).toBeUndefined(); + + // Nothing declared a duration yet, so the catalog doesn't advertise one. + expect((await readCatalog(broadcast))?.video?.renditions["video/hd"]?.hls).toBeUndefined(); + + const producer = new Container.Legacy.Producer(track, new Container.Legacy.Format("video"), recorder); + for (let ms = 0; ms <= 4000; ms += 2000) producer.encode(new Uint8Array([1]), Micro(ms * 1000), true); + await settle(); + const catalog = await readCatalog(broadcast); + expect(catalog?.video?.renditions["video/hd"]?.hls).toEqual({ track: "video/hd.hls", duration: Catalog.u53(4000) }); + expect(catalog?.audio?.renditions["audio/data"]?.hls).toBeUndefined(); + + broadcast.close(); + // Closing the broadcast finishes the records with their media. + expect(recorder?.track.closed.peek()).toBeNull(); +}); + +test("records end with their media however it closes", async () => { + const broadcast = new Broadcast({ enabled: true, origin: new Origin.Producer(), name: Path.from("ended.hang") }); + const video = broadcast.video("video"); + video.config.set(videoConfig); + await settle(); + + const track = video.track.peek(); + if (!track) throw new Error("expected the video track"); + const recorder = video.hls(track); + if (!recorder) throw new Error("expected HLS records"); + + // An application aborting the media directly, not through an encoder, still ends the records. + const cause = new Error("media failed"); + track.abort(cause); + await settle(); + expect(recorder.track.closed.peek()).toBe(cause); + + broadcast.close(); +}); + +test("hls: false publishes no records", async () => { + const broadcast = new Broadcast({ + enabled: true, + origin: new Origin.Producer(), + name: Path.from("nohls.hang"), + hls: false, + }); + const video = broadcast.video("video"); + await settle(); + const track = video.track.peek(); + if (!track) throw new Error("expected the video track"); + expect(video.hls(track)).toBeUndefined(); + broadcast.close(); +}); + +test("on demand, a record subscriber wants the media too", async () => { + const broadcast = new Broadcast({ + enabled: true, + origin: new Origin.Producer(), + name: Path.from("lend.hang"), + onDemand: true, + }); + await settle(); + const net = broadcast.net.peek(); + if (!net) throw new Error("expected a network producer"); + + const rendition = broadcast.video("video"); + await settle(); + expect(rendition.track.peek()).toBeUndefined(); + + // An HLS export reads only the records, yet the media they describe has to be produced. + const records = net.track("video.hls").subscribe(); + await settle(); + expect(rendition.track.peek()).toBeDefined(); + + records.close(); + await settle(); + expect(rendition.track.peek()).toBeUndefined(); + broadcast.close(); +}); + +test("a hinted rendition advertises its records before encoding, so an on-demand export finds them", async () => { + const broadcast = new Broadcast({ + enabled: true, + origin: new Origin.Producer(), + name: Path.from("hinted.hang"), + onDemand: true, + }); + const video = broadcast.video("video"); + video.hlsHint.set(Micro(2_000_000)); + video.config.set(videoConfig); + await settle(); + + expect(video.track.peek()).toBeUndefined(); + const catalog = await readCatalog(broadcast); + expect(catalog?.video?.renditions.video?.hls).toEqual({ track: "video.hls", duration: Catalog.u53(4000) }); + broadcast.close(); +}); + +test("registering a name another rendition's HLS records hold throws", async () => { + const broadcast = new Broadcast({ enabled: true, origin: new Origin.Producer(), name: Path.from("taken.hang") }); + broadcast.video("video"); + expect(() => broadcast.audio("video.hls")).toThrow("HLS record track"); + await settle(); + expect(() => broadcast.audio("video.hls")).toThrow("HLS record track"); + // A text rendition publishes no records, so its sidecar name stays free. + broadcast.text("captions"); + expect(() => broadcast.audio("captions.hls")).not.toThrow(); + broadcast.close(); +}); + +test("with hls off, a name ending in the record suffix is free", async () => { + const broadcast = new Broadcast({ + enabled: true, + origin: new Origin.Producer(), + name: Path.from("free.hang"), + hls: false, + }); + broadcast.video("video"); + expect(() => broadcast.audio("video.hls")).not.toThrow(); + broadcast.close(); +}); + +test("turning hls on leaves a rendition holding a record name its media", async () => { + const hls = new Signal(false); + const broadcast = new Broadcast({ + enabled: true, + origin: new Origin.Producer(), + name: Path.from("flip.hang"), + hls, + }); + const video = broadcast.video("video"); + const audio = broadcast.audio("video.hls"); + const later = broadcast.audio("audio"); + await settle(); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + hls.set(true); + await settle(); + warn.mockRestore(); + + const videoTrack = video.track.peek(); + expect(videoTrack && video.hls(videoTrack)).toBeUndefined(); + expect(audio.track.peek()?.name).toBe("video.hls"); + const laterTrack = later.track.peek(); + expect(laterTrack && later.hls(laterTrack)?.track.name).toBe("audio.hls"); + broadcast.close(); +}); + // Identity follows hang's `same_identity`: interlacing reinitializes a video decoder. test("an interlacing change takes a fresh track name", async () => { const broadcast = new Broadcast({ origin: new Origin.Producer(), name: Path.from("interlaced.hang") }); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 3057e931f64..c084e45b8ea 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -1,10 +1,12 @@ import { Milli } from "@moq/clock"; import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; +import * as Hls from "@moq/hang/hls"; import * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, Signal } from "@moq/signals"; import { rotateVideoDimensions } from "@moq/video"; import { CatalogProducer } from "./catalog"; +import { wanted } from "./internal"; import { Order } from "./order"; import { type Identity, identity, type Listing, Publication, type Slot } from "./publication"; import type { Kind, Rendition } from "./rendition"; @@ -65,6 +67,14 @@ export type BroadcastInput = { * its past. Unless false, audio keeps raw history up to `maxAge`. Capture stays open. */ onDemand: Getter; + + /** + * Whether each video and audio track publishes HLS segment records, advertised in its catalog + * entry's `hls` field, so an edge can export the broadcast as HLS. Defaults to true. A record + * track's subscribers count as demand for its media under {@link onDemand}, since an export reads + * the records rather than the media. Changing this publishes a new network broadcast. + */ + hls: Getter; }; /** @@ -123,6 +133,10 @@ export class Broadcast { // The renditions of the current network broadcast, undefined while there is none. readonly #publication = new Signal(undefined); + // Each track name's HLS recorder in the current network broadcast, whose `hls` field the catalog + // advertises once its duration is known. + readonly #records = new Signal>({}); + // Each catalog ID in the latest catalog fold, which seeds a new network broadcast's publication. #listed = new Map(); @@ -139,6 +153,7 @@ export class Broadcast { rotation: getter(props?.rotation ?? 0), maxAge: getter(props?.maxAge), onDemand: getter(props?.onDemand), + hls: getter(props?.hls ?? true), }; this.#signals.run(this.#runCatalog.bind(this)); @@ -178,6 +193,14 @@ export class Broadcast { throw new Error(`rendition already registered: ${name}`); } this.#publication.peek()?.check(name, kind); + // A video or audio rendition's HLS records hold or will hold the name. + if (this.in.hls.peek() && name.endsWith(Hls.SUFFIX)) { + const media = name.slice(0, -Hls.SUFFIX.length); + const owner = this.#renditions.peek()[media]; + if (this.#records.peek()[media] || (owner && owner.kind !== "text")) { + throw new Error(`rendition ${name} names another rendition's HLS record track`); + } + } const handle = { track: new Signal(undefined), @@ -187,7 +210,14 @@ export class Broadcast { // The slot is the one its producer fills, so it holds this rendition's config. const slot = handle.slot as Getter | undefined>; - const rendition = makeRendition(name, kind, handle.track, slot, () => this.#unregister(name)); + const rendition = makeRendition( + name, + kind, + handle.track, + slot, + (served) => this.#records.peek()[served.name], + () => this.#unregister(name), + ); this.#renditions.update((renditions) => ({ ...renditions, [name]: rendition as Rendition })); return rendition; @@ -217,6 +247,7 @@ export class Broadcast { const renditions = effect.get(this.#renditions); const publication = effect.get(this.#publication); if (publication) effect.get(publication.renames); + const records = effect.get(this.#records); const video: Record = {}; const audio: Record = {}; @@ -236,7 +267,11 @@ export class Broadcast { listed.set(rendition.name, { kind: rendition.kind, identity: media }); const track = publication?.name(rendition, media) ?? rendition.name; served.push([rendition, track, media]); - const config = track !== rendition.name ? { ...(source as object), track } : source; + let config = track !== rendition.name ? { ...(source as object), track } : source; + // Filled in here rather than by the producer, so one rewriting its config can't drop it. + const recorder = records[track]; + const hls = recorder && effect.get(recorder.hls); + if (hls) config = { ...(config as object), hls }; if (rendition.kind === "video") { video[rendition.name] = config as Catalog.VideoConfig; @@ -313,6 +348,7 @@ export class Broadcast { const name = effect.get(this.in.name); const maxAge = effect.get(this.in.maxAge); + const hls = effect.get(this.in.hls); if (Catalog.detectFormat(name) === undefined) { console.warn( `You should append .hang to broadcast name ${JSON.stringify(name)} to make the catalog format explicit.`, @@ -339,6 +375,7 @@ export class Broadcast { // group sequence under the same epoch. The publication keeps rendition tracks by catalog ID, // so registering an ID again finds its track; the catalog's stay out of reach of a colliding // rendition. + this.#records.set({}); const publication = new Publication(this.#listed, () => this.#reserved()); this.#publication.set(publication); effect.cleanup(() => { @@ -395,12 +432,16 @@ export class Broadcast { if (entry.owner && entry.owner !== rendition) entry.slot.error.set(undefined); const cause = inner.get(entry.slot.error); // Including an idle track, which no producer holds to abort. - if (entry.track && cause) entry.track.abort(cause); + if (entry.track && cause) { + entry.track.abort(cause); + entry.recorder?.abort(cause); + } if (entry.track && entry.track.name !== entry.name) { // The catalog moved this rendition to a new name for new media. End the old track // cleanly on its last identity, so its subscribers follow the catalog. entry.track.finish(); entry.track.close(); + entry.recorder?.finish(); entry.track = undefined; } else if (entry.track && entry.track.closed.peek() !== undefined) { // A failure leaves this rendition down until it clears. It then recovers under a fresh @@ -414,9 +455,20 @@ export class Broadcast { entry.name, rendition.kind === "video" ? { ...info, refresh: VIDEO_REFRESH } : info, ); + entry.recorder = hls ? this.#record(broadcast, rendition, entry.name) : undefined; } entry.owner = rendition; const served = entry.track; + const recorder = entry.recorder; + if (recorder) { + // The records end with their media, however it closes, so a reader of only the + // records sees the same finish or failure. + inner.run((ended) => { + const closed = ended.get(served.closed); + if (closed instanceof Error) recorder.abort(closed); + else if (closed === null) recorder.finish(); + }); + } inner.run((gate) => { const closed = gate.get(served.closed); if (closed !== undefined) { @@ -424,11 +476,7 @@ export class Broadcast { if (closed && !entry.slot.error.peek()) entry.slot.error.set(closed); return; } - if ( - rendition.kind !== "audio" && - gate.get(this.in.onDemand) && - gate.get(served.demand().wanted) === undefined - ) + if (rendition.kind !== "audio" && gate.get(this.in.onDemand) && !wanted(gate, served, recorder)) return; gate.set(handle.track, served); }); @@ -436,6 +484,28 @@ export class Broadcast { }); } + // Start the HLS records of `rendition`'s video or audio track `name`. A record track name already + // taken, by a rendition registered while hls was off or an application's own track, leaves the + // rendition without records rather than down. + #record(broadcast: Moq.Broadcast.Producer, rendition: Rendition, name: string): Hls.Recorder | undefined { + if (rendition.kind === "text") return undefined; + const records = Hls.defaultName(name); + if (records in this.#renditions.peek() || this.#publication.peek()?.has(records)) { + console.warn(`rendition ${name} publishes no HLS records: ${records} is a rendition`); + return undefined; + } + let track: Moq.Track.Producer; + try { + track = broadcast.createTrack(records, Hls.Recorder.INFO); + } catch (err) { + console.warn(`rendition ${name} publishes no HLS records`, err); + return undefined; + } + const recorder = new Hls.Recorder(track, { durationHint: rendition.hlsHint.peek() }); + this.#records.update((records) => ({ ...records, [name]: recorder })); + return recorder; + } + /** Stop and release everything this holds. */ close() { this.#signals.close(); diff --git a/js/publish/src/internal.ts b/js/publish/src/internal.ts index a3222a88221..89918de4818 100644 --- a/js/publish/src/internal.ts +++ b/js/publish/src/internal.ts @@ -1,3 +1,5 @@ +import { Micro, Milli } from "@moq/clock"; +import type * as Hls from "@moq/hang/hls"; import type * as Moq from "@moq/net"; import type { Effect, Getter } from "@moq/signals"; @@ -8,3 +10,21 @@ import type { Effect, Getter } from "@moq/signals"; export function watched(track: Moq.Track.Producer, effect: Effect): Getter { return effect.computed((demand) => demand.get(track.demand().wanted) !== undefined); } + +/** + * @internal Whether anyone wants `track`'s media: a subscriber to it, or to its HLS records, since an + * export reads only the records. + */ +export function wanted(effect: Effect, track: Moq.Track.Producer, recorder: Hls.Recorder | undefined): boolean { + if (effect.get(track.demand().wanted) !== undefined) return true; + return recorder !== undefined && effect.get(recorder.track.demand().wanted) !== undefined; +} + +/** + * @internal A rendition's HLS duration hint for groups of `duration`. A zero or unbounded group still + * hints a millisecond, which declares the grid's own duration up front: without a hint, an on-demand + * rendition never advertises the records whose subscription would start it encoding. + */ +export function hlsHint(duration: Milli): Micro { + return Micro.fromMilli(Number.isFinite(duration) && duration > 0 ? duration : Milli(1)); +} diff --git a/js/publish/src/publication.ts b/js/publish/src/publication.ts index 0cb40493265..27afb6a5d17 100644 --- a/js/publish/src/publication.ts +++ b/js/publish/src/publication.ts @@ -1,3 +1,4 @@ +import type * as Hls from "@moq/hang/hls"; import * as Json from "@moq/json"; import type * as Moq from "@moq/net"; import { type Getter, Signal } from "@moq/signals"; @@ -35,6 +36,8 @@ export type Entry = { // The network track serving it. Kept after its rendition leaves, so registering the ID again // continues it. track?: Moq.Track.Producer; + // The HLS records accompanying this track. + recorder?: Hls.Recorder; // The registration that last served `track`. owner?: Rendition; // What the registration's producer keeps for this ID. @@ -78,6 +81,11 @@ export class Publication { } } + /** Whether a catalog ID holds or held a rendition here. */ + has(id: string): boolean { + return this.#entries.has(id); + } + /** Throw unless `id` may register as `kind`. */ check(id: string, kind: Kind): void { // An ID keeps its track, so it keeps its kind: different media takes a different ID. @@ -150,7 +158,10 @@ export class Publication { /** End every track. */ close(): void { - for (const { track } of this.#entries.values()) track?.finish(); + for (const { track, recorder } of this.#entries.values()) { + track?.finish(); + recorder?.finish(); + } for (const { track } of this.#entries.values()) track?.close(); } } diff --git a/js/publish/src/rendition-internal.ts b/js/publish/src/rendition-internal.ts index 9ad9efaf973..fad0053b62c 100644 --- a/js/publish/src/rendition-internal.ts +++ b/js/publish/src/rendition-internal.ts @@ -1,3 +1,5 @@ +import type { Micro } from "@moq/clock"; +import type * as Hls from "@moq/hang/hls"; import type * as Moq from "@moq/net"; import { type Getter, Signal } from "@moq/signals"; import type { Slot } from "./publication"; @@ -11,6 +13,7 @@ export let makeRendition: ( kind: Kind, track: Getter, slot: Getter | undefined>, + hls: (track: Moq.Track.Producer) => Hls.Recorder | undefined, close: () => void, ) => Rendition; @@ -37,9 +40,18 @@ export class Rendition { */ readonly config = new Signal(undefined); + /** + * The rendition's longest group duration, when known (a video encoder's keyframe interval). Read + * when each of its tracks is created, it fixes that track's HLS segment duration up front, so the + * catalog advertises the records before any media is encoded, as an on-demand broadcast needs. + * Without one, the first segment declares the duration. + */ + readonly hlsHint = new Signal(undefined); + /** * The open track producer, or `undefined` while the network broadcast is down, after the track - * closes, and, for non-audio tracks with the broadcast's `onDemand`, while no subscriber is attached. + * closes, and, for non-audio tracks with the broadcast's `onDemand`, while no subscriber is attached + * to it or its HLS records. * Producers should * encode only while this is set, and only media matching the current {@link config}: a new * identity swaps in a new track. @@ -52,6 +64,7 @@ export class Rendition { */ readonly slot: Getter | undefined>; + readonly #hls: (track: Moq.Track.Producer) => Hls.Recorder | undefined; readonly #close: () => void; #closed = false; @@ -60,17 +73,28 @@ export class Rendition { kind: Kind, track: Getter, slot: Getter | undefined>, + hls: (track: Moq.Track.Producer) => Hls.Recorder | undefined, close: () => void, ) { this.name = name; this.kind = kind; this.track = track; this.slot = slot; + this.#hls = hls; this.#close = close; } static { - makeRendition = (name, kind, track, slot, close) => new Rendition(name, kind, track, slot, close); + makeRendition = (name, kind, track, slot, hls, close) => new Rendition(name, kind, track, slot, hls, close); + } + + /** + * The HLS recorder of `track`, one of this rendition's {@link Rendition.track | tracks}, or `undefined` when it + * publishes no records. Pass it to the container producer writing the track, so each group it + * writes is reported into the records the catalog advertises in the rendition's `hls` field. + */ + hls(track: Moq.Track.Producer): Hls.Recorder | undefined { + return this.#hls(track); } /** diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index e1f64567de9..cf8bdb8e347 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -457,7 +457,14 @@ test("an encoder resuming on a still source encodes a keyframe of the current pi const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI }); // No subscriber yet, so the encoder idles while the source sends its only frame. const live = new Signal(undefined); - const rendition = { config: new Signal(undefined), track: live, slot: new Signal(undefined), close: () => {} }; + const rendition = { + slot: new Signal(undefined), + config: new Signal(undefined), + track: live, + hls: () => undefined, + hlsHint: new Signal(undefined), + close: () => {}, + }; const encoder = new Encoder("video", { enabled: true, broadcast: { video: () => rendition, order: new Order() } as never, @@ -564,7 +571,14 @@ test("an encoder torn down while a frame is in flight closes that frame", async const capture = new Capture({ source: input.source }); const track = new Moq.Track.Producer("video").accept({ timescale: Moq.Time.Timescale.MILLI }); const live = new Signal(undefined); - const rendition = { config: new Signal(undefined), track: live, slot: new Signal(undefined), close: () => {} }; + const rendition = { + slot: new Signal(undefined), + config: new Signal(undefined), + track: live, + hls: () => undefined, + hlsHint: new Signal(undefined), + close: () => {}, + }; const encoder = new Encoder("video", { enabled: true, broadcast: { video: () => rendition, order: new Order() } as never, diff --git a/js/publish/src/video/encoder-stall.test.ts b/js/publish/src/video/encoder-stall.test.ts index f03a2f95540..454a5593c47 100644 --- a/js/publish/src/video/encoder-stall.test.ts +++ b/js/publish/src/video/encoder-stall.test.ts @@ -113,6 +113,8 @@ test("a slower-than-realtime encoder drops input instead of ratcheting catalog l slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index a69d7f1b2ff..8f5e6165e55 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -127,6 +127,8 @@ test("encoding tracks encoder config in its child effect", async () => { slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -169,6 +171,8 @@ test("an on-demand gap marks a discontinuity and leaves the broadcast-owned trac slot: new Signal(undefined), config: new Signal(undefined), track: live, + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -224,6 +228,8 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -310,6 +316,8 @@ test("every published config fits a probe for its own codec", async () => { slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -663,6 +671,8 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -831,6 +841,8 @@ test("the probe encodes with the frame rate and bitrate ceiling", async () => { slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -1039,6 +1051,8 @@ async function recordKeyframes( slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -1196,6 +1210,46 @@ test.each([false, true])( }, ); +// An HLS export subscribes only to the records the catalog advertises, so an on-demand rendition has +// to advertise them before anything is encoded, even with every frame a keyframe. +test("an on-demand all-keyframe rendition advertises its records, whose subscriber starts it", async () => { + using recording = await installRecording(); + const broadcast = new Broadcast({ + origin: new Moq.Origin.Producer(), + name: Moq.Path.from("keyframes.hang"), + onDemand: true, + }); + const encoder = new Encoder("video", { + enabled: true, + broadcast, + capture: recording.capture as never, + config: { keyframeInterval: Milli(0) }, + }); + const subscribers: Moq.Track.Subscriber[] = []; + try { + await settle(); + const net = broadcast.net.peek(); + if (!net) throw new Error("expected network broadcast"); + expect(encoder.out.active.peek()).toBe(false); + + const catalogSubscriber = net.track(Broadcast.CATALOG_TRACK).subscribe(); + subscribers.push(catalogSubscriber); + const catalog = await new Json.Snapshot.Consumer({ track: catalogSubscriber }).latest(); + expect(catalog?.value.video?.renditions.video?.hls).toEqual({ + track: "video.hls", + duration: Catalog.u53(4000), + }); + + subscribers.push(net.track("video.hls").subscribe()); + await settle(); + expect(encoder.out.active.peek()).toBe(true); + } finally { + for (const subscriber of subscribers) subscriber.close(); + encoder.close(); + broadcast.close(); + } +}); + test("an unwatched rendition reserves nothing and encodes at its configured bitrate", async () => { using _videoEncoder = installFakeVideoEncoder(); const broadcast = new Broadcast({ origin: new Moq.Origin.Producer(), name: Moq.Path.from("unwatched.hang") }); @@ -1459,6 +1513,8 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -1532,6 +1588,8 @@ test("the catalog bitrate and frame rate only rise", async () => { slot: new Signal(undefined), config: new Signal(undefined), track: new Signal(track), + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => { track.finish(); track.close(); @@ -1592,6 +1650,8 @@ test("each slot keeps its own ceiling and maxima", async () => { config: new Signal(undefined), track: new Signal(undefined), slot, + hls: () => undefined, + hlsHint: new Signal(undefined), close: () => {}, }); return { video: rendition, order: new Order() }; diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 186d7395690..5eee7e40d44 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -1,6 +1,7 @@ import { Micro, Milli, type Second } from "@moq/clock"; import * as Catalog from "@moq/hang/catalog"; import * as Container from "@moq/hang/container"; +import type * as Hls from "@moq/hang/hls"; import type * as Moq from "@moq/net"; import { type Computed, @@ -13,13 +14,16 @@ import { Signal, } from "@moq/signals"; import type { Broadcast } from "../broadcast"; -import { watched } from "../internal"; +import { hlsHint, watched } from "../internal"; import { Estimator, type Order } from "../order"; import { Slot } from "../publication"; import { hardwareReliable } from "../support/video"; import type { Capture } from "./capture"; import { normalizeSource, type Source } from "./types"; +// The keyframe interval when the config sets none. +const DEFAULT_KEYFRAME_INTERVAL = Milli.fromSecond(2 as Second); + /** Cumulative encoder output totals, measured from the chunks the encoder produces. */ export interface Stats { /** Total frames encoded while serving. Monotonic; diff over an interval for a frame rate. */ @@ -336,6 +340,11 @@ export class Encoder { effect.proxy(this.#slot, rendition.slot); effect.cleanup(() => this.#slot.set(undefined)); + // The longest GOP fixes each new track's HLS segment duration, as moq-video's encoders hint theirs. + const hint = (config: Config | undefined) => hlsHint(config?.keyframeInterval ?? DEFAULT_KEYFRAME_INTERVAL); + rendition.hlsHint.set(hint(this.config.peek())); + effect.subscribe(this.config, (config) => rendition.hlsHint.set(hint(config))); + // Publish the resolved catalog config; undefined (until one resolves) leaves it out of the catalog. effect.proxy(rendition.config, this.out.catalog); @@ -347,7 +356,7 @@ export class Encoder { effect.set(this.#out.active, enabled && !!track && !error, false); if (!enabled || !track || error) return; - this.#encode(track, broadcast.order, effect); + this.#encode(track, rendition.hls(track), broadcast.order, effect); }); // Reserve against the connection while a subscriber is attached. Wait for a ceiling so we @@ -383,11 +392,11 @@ export class Encoder { } // Encode captured frames into the track producer, reconfiguring when the resolved config changes. - #encode(track: Moq.Track.Producer, order: Order, effect: Effect): void { + #encode(track: Moq.Track.Producer, hls: Hls.Recorder | undefined, order: Order, effect: Effect): void { const capture = effect.get(this.in.capture); if (!capture) return; - const producer = new Container.Legacy.Producer(track, new Container.Legacy.Format("video")); + const producer = new Container.Legacy.Producer(track, new Container.Legacy.Format("video"), hls); const demand = track.demand(); let lastKeyframe: Micro | undefined; @@ -505,7 +514,7 @@ export class Encoder { } lastEncoded = frame.timestamp as Micro; - const interval = config?.keyframeInterval ?? Milli.fromSecond(2 as Second); + const interval = config?.keyframeInterval ?? DEFAULT_KEYFRAME_INTERVAL; // Force a keyframe if this is the first frame (no group yet), the GOP elapsed, the // caller asked for one, or a join wants a group past the largest. No spacing: the diff --git a/quest/m2/README.md b/quest/m2/README.md index 80a3f5bb4d1..f95c170a423 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -186,8 +186,6 @@ lite-07; bitrate enforcement is not part of this release. - [DVR rewind](/quest/m2/archive-dvr.md) - follow-on capability after the release contract is stable - [Archive lookups cancel when abandoned](/quest/m2/archive-lookup-withdrawal.md) - a public withdrawal signal on `Fill` stops a time lookup's store read once its readers leave -- [JS HLS field](/quest/m2/js-hls.md) - follow-on capability after the release contract is stable - - [Demo dashboard shows viewer lag](/quest/m2/qos-lag-dashboard.md) - follow-on capability after the release contract is stable - [Publisher timeliness at relay ingest](/quest/m2/qos-publisher-timeliness.md) - follow-on capability after the release contract is stable diff --git a/quest/m2/js-hls.md b/quest/m2/js-hls.md deleted file mode 100644 index b9d7b25365b..00000000000 --- a/quest/m2/js-hls.md +++ /dev/null @@ -1,23 +0,0 @@ -# [M] JS HLS field - -## Goal - -`@moq/publish` publishes the per-rendition `hls` field and record tracks by -default, with the same records and declared duration Rust does, so a -browser-published broadcast exports as HLS like a native one. - -## Plan - -Decided 2026-10-27 (maintainer): records are on by default in Rust -(`moq_mux::catalog::Config` publishes them unless `with_hls(None)`), and JS -matches, until [time addressing](/quest/m1/time-addressing/consumers.md) lets -the export list segments from group starts and deletes the records. The -archive does not use the field, so the old idle `flush()` and catalog and data -track timelines stay out of this port. - -Each rendition cuts its own segments on a pts grid and numbers its own -records, sharing no state with the others, as `rs/moq-mux/src/hls.rs` does. - -The read side follows Catalog HLS field. Port the Rust producer -(`rs/moq-mux`) to `js/hang`, publish it from `@moq/publish` by default, and -check its records against Rust output in the interop suite. diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 9adf4962d33..daa31ee58fc 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -203,6 +203,10 @@ path = "../../test/interop/clients/rust/goaway-client.rs" name = "interop-finish" path = "../../test/interop/clients/rust/finish.rs" +[[example]] +name = "interop-hls" +path = "../../test/interop/clients/rust/hls.rs" + [[example]] name = "interop-audio" path = "../../test/interop/clients/rust/audio.rs" diff --git a/rs/moq-relay/src/auth/caps.rs b/rs/moq-relay/src/auth/caps.rs index 0c3fe335f65..0c8d11329e8 100644 --- a/rs/moq-relay/src/auth/caps.rs +++ b/rs/moq-relay/src/auth/caps.rs @@ -88,6 +88,7 @@ pub struct Pacer(Arc); impl Pacer { /// Pace a live connection by calling `apply` with each new set of caps. + #[cfg(any(test, feature = "quic", all(target_os = "linux", feature = "_uring")))] pub(crate) fn new(apply: impl Fn(Caps) + Send + Sync + 'static) -> Self { Self(Arc::new(apply)) } diff --git a/test/interop/README.md b/test/interop/README.md index 95e9144fc32..767e037b8df 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -545,6 +545,7 @@ clients/ rust/audio.rs the Rust subscriber's audio decode via rs/moq-audio (`interop-audio`) rust/tail.rs the finite raw-track client (`interop-tail`) rust/catalog.rs the compressed-catalog publisher (`interop-catalog`) + rust/hls.rs the HLS-records publisher and reader (`interop-hls`) python/interop.py publish/subscribe via py/moq-rs (import moq) go/main.go publish/subscribe via go/wrapper (import moq-go/moq) cpp/main.cpp publish/subscribe via cpp/moq (find_package(moq-cpp)) @@ -562,6 +563,7 @@ clients/ src/close.ts the refused session, read off `WebTransport.closed` js-native/subscribe.ts subscribe via @moq/net + @moq/hang + the WebTransport polyfill js-native/catalog.ts the compressed-catalog reader, pinned to one version + js-native/hls.ts the HLS-records publisher via @moq/hang c/subscribe.c subscribe via rs/moq-c ``` @@ -646,3 +648,9 @@ readers at once, each pinned to lite-07, lite-06, or moq-transport-22 and readin `@moq/watch` does. Only the lite-07 reader sees the flag; the other two share the relay's inflated copy, so `moq_relay_inflated_bytes_total` must grow by exactly the catalog's inflated size. The harness enables the relay's `[stats]` and `[internal]` listener for it. + +The HLS-records lane has the Rust publisher (moq-mux) and each native JS runtime +(`@moq/hang`'s recorder) publish one fixed frame schedule, with GOPs on and off the 4 s +grid, a discontinuity, and a finish, and the Rust reader prints each broadcast's +declared `hls` durations and every record. The two readouts must be identical, so a +browser-published broadcast exports as HLS like a native one. diff --git a/test/interop/clients/js-native/hls.ts b/test/interop/clients/js-native/hls.ts new file mode 100644 index 00000000000..a71613872b5 --- /dev/null +++ b/test/interop/clients/js-native/hls.ts @@ -0,0 +1,110 @@ +/** + * HLS record interop publisher: publish the schedule `interop-hls publish` does through + * `@moq/hang`'s recorder, finish every track, and stay up until the harness acknowledges. The + * harness reads both publishers' records with `interop-hls read` and requires them identical. + * + * node --import tsx hls.ts URL BROADCAST + * + * Keep the schedule in step with `test/interop/clients/rust/hls.rs`. + * + * @module + */ +import { strict as assert } from "node:assert"; +import { createInterface } from "node:readline"; +import { Micro } from "@moq/clock"; +import * as Catalog from "@moq/hang/catalog"; +import * as Container from "@moq/hang/container"; +import * as Hls from "@moq/hang/hls"; +import * as Json from "@moq/json"; +import * as Moq from "@moq/net"; +import { install } from "@moq/web-transport"; + +install(); +console.debug = console.error; +console.info = console.error; +const [url, path] = process.argv.slice(2); +if (!url || !path) throw new Error("usage: hls.ts URL BROADCAST"); + +/** One frame: its pts in milliseconds and whether it opens a group. */ +type Frame = { ms: number; key: boolean }; + +/** Frames every `step` ms over `[start, end)`, opening a group every `gop` ms. */ +function frames(start: number, end: number, step: number, gop: number): Frame[] { + const out: Frame[] = []; + for (let ms = start; ms < end; ms += step) out.push({ ms, key: (ms - start) % gop === 0 }); + return out; +} + +// Video: 2s GOPs, a pause, then 3s GOPs off the grid. Audio: 100ms groups of 20ms frames, a +// pause, then more. Each part ends in a discontinuity but the last, which the finish ends. +const VIDEO = [frames(0, 6000, 100, 2000), frames(20000, 30000, 100, 3000)]; +const AUDIO = [frames(0, 9000, 20, 100), frames(15000, 17000, 20, 100)]; + +const origin = new Moq.Origin.Producer(); +const connection = await Moq.Connection.connect({ + url: new URL(url), + websocket: { enabled: false }, + publish: origin.consume(), +}); +try { + const publication = origin.createBroadcast(Moq.Path.from(path)); + const broadcast = publication.broadcast; + const renditions: { video: Record; audio: Record } = { + video: {}, + audio: {}, + }; + const publish = (kind: "video" | "audio", parts: Frame[][]) => { + const media = broadcast.createTrack(kind, Container.trackInfo({ priority: Catalog.PRIORITY[kind] })); + const recorder = new Hls.Recorder(broadcast.createTrack(Hls.defaultName(kind), Hls.Recorder.INFO)); + const producer = new Container.Legacy.Producer(media, new Container.Legacy.Format(kind), recorder); + for (const [index, part] of parts.entries()) { + if (index > 0) producer.discontinuity(); + for (const { ms, key } of part) producer.encode(new Uint8Array([1]), Micro(ms * 1000), key); + } + producer.close(); + const hls = recorder.hls.peek(); + assert(hls, `${kind} declared no HLS duration`); + if (kind === "video") renditions.video[kind] = { codec: "avc1.64001f", container: { kind: "legacy" }, hls }; + else + renditions.audio[kind] = { + codec: "opus", + sampleRate: Catalog.u53(48000), + numberOfChannels: Catalog.u53(2), + container: { kind: "legacy" }, + hls, + }; + }; + publish("video", VIDEO); + publish("audio", AUDIO); + + const track = broadcast.createTrack(Catalog.TRACK, { + timescale: Moq.Time.Timescale.MILLI, + priority: Catalog.PRIORITY.catalog, + compress: true, + }); + const catalog = new Json.Snapshot.Producer({ track, deltas: false }); + catalog.update({ + value: Catalog.encode({ + video: { renditions: renditions.video }, + audio: { renditions: renditions.audio }, + }), + at: Moq.Time.Timestamp.now(), + }); + catalog.finish(); + publication.announce(); + console.log("hls ready"); + + const lines = createInterface({ input: process.stdin }); + try { + const ack = await lines[Symbol.asyncIterator]().next(); + assert.equal(ack.value, "clean end", "reader did not acknowledge"); + } finally { + lines.close(); + } + broadcast.close(); + console.log("hls acknowledged"); +} finally { + connection.close(); + origin.close(); +} +process.exit(0); diff --git a/test/interop/clients/rust/hls.rs b/test/interop/clients/rust/hls.rs new file mode 100644 index 00000000000..a8908bf3be4 --- /dev/null +++ b/test/interop/clients/rust/hls.rs @@ -0,0 +1,170 @@ +//! HLS record interop: `publish` writes a fixed frame schedule through moq-mux, whose catalog +//! publishes HLS records by default, and `read` prints a broadcast's declared durations and +//! records. The harness reads this publisher and the native JS one (`js-native/hls.ts`), which +//! publishes the same schedule through `@moq/hang`, and requires the two readouts identical. +//! +//! interop-hls publish URL BROADCAST +//! interop-hls read URL BROADCAST +//! +//! Keep the schedule in step with `test/interop/clients/js-native/hls.ts`. +use std::time::Duration; + +use anyhow::{Context, ensure}; +use moq_mux::catalog::Stream; +use moq_mux::catalog::hang::Container; +use moq_mux::container::{Frame, Kind}; +use moq_tokio::moq_net; +use tokio::io::AsyncBufReadExt; + +/// One frame: its pts in milliseconds and whether it opens a group. +type Schedule = Vec>; + +/// Frames every `step` ms over `start..end`, opening a group every `gop` ms. +fn frames(start: u64, end: u64, step: u64, gop: u64) -> Vec<(u64, bool)> { + (start..end) + .step_by(step as usize) + .map(|ms| (ms, (ms - start).is_multiple_of(gop))) + .collect() +} + +/// Video: 2s GOPs, a pause, then 3s GOPs off the grid. Audio: 100ms groups of 20ms frames, a +/// pause, then more. Each part ends in a discontinuity but the last, which the finish ends. +fn video() -> Schedule { + vec![frames(0, 6000, 100, 2000), frames(20000, 30000, 100, 3000)] +} + +fn audio() -> Schedule { + vec![frames(0, 9000, 20, 100), frames(15000, 17000, 20, 100)] +} + +fn write( + producer: &mut moq_mux::container::Producer, + schedule: Schedule, +) -> anyhow::Result<()> { + for (index, part) in schedule.into_iter().enumerate() { + if index > 0 { + producer.discontinuity()?; + } + for (ms, keyframe) in part { + producer.write(Frame { + timestamp: moq_net::Timestamp::from_millis(ms)?, + payload: bytes::Bytes::from_static(&[1]), + keyframe, + duration: None, + })?; + } + } + producer.finish()?; + Ok(()) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let args: Vec<_> = std::env::args().collect(); + ensure!( + args.len() == 4 && ["publish", "read"].contains(&args[1].as_str()), + "usage: interop-hls publish|read URL BROADCAST" + ); + let url: url::Url = args[2].parse()?; + let path = &args[3]; + let mut config = moq_tokio::connect::Config::default(); + config.websocket.enabled = Some(false); + let client = config.init(Default::default())?.with_reconnect(false); + let origin = moq_tokio::origin::spawn(); + + if args[1] == "publish" { + let publication = origin.create_broadcast(path)?; + let mut broadcast = publication.broadcast().clone(); + let mut catalog = moq_mux::catalog::Producer::<()>::new(&mut broadcast, Default::default())?; + let track = broadcast.create_track("video", catalog.track_info(hang::catalog::PRIORITY.video))?; + let config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + // Each producer owns its catalog entry, so both stay alive until the reader is done. + let mut video_track = catalog.video(track, Container::Legacy(Kind::Video), config)?; + write(&mut video_track, video())?; + let track = broadcast.create_track("audio", catalog.track_info(hang::catalog::PRIORITY.audio))?; + let config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); + let mut audio_track = catalog.audio(track, Container::Legacy(Kind::Audio), config)?; + write(&mut audio_track, audio())?; + catalog.finish()?; + + let session = client + .clone() + .with_publisher(&origin) + .connect(url) + .established() + .await?; + publication.announce(Default::default())?; + println!("hls ready"); + let mut commands = tokio::io::BufReader::new(tokio::io::stdin()).lines(); + ensure!( + commands.next_line().await?.as_deref() == Some("clean end"), + "reader did not acknowledge" + ); + println!("hls acknowledged"); + drop((video_track, audio_track, catalog)); + session.close().await?; + client.close().await; + return Ok(()); + } + + let session = client + .clone() + .with_subscriber(origin.clone()) + .connect(url) + .established() + .await?; + kio::clock::timeout(Duration::from_secs(30), async { + let broadcast = origin.consume().follow(path, None)?.available().await?; + let mut catalogs = + moq_mux::catalog::Consumer::<()>::new(&broadcast, moq_mux::catalog::CatalogFormat::Hang).await?; + // Both renditions, each advertising its records. + let renditions = loop { + let catalog = catalogs + .next() + .await? + .context("catalog ended before advertising records")?; + let video = catalog + .video + .renditions + .iter() + .map(|(name, config)| ("video", name, &config.hls)); + let audio = catalog + .audio + .renditions + .iter() + .map(|(name, config)| ("audio", name, &config.hls)); + let renditions: Vec<_> = video + .chain(audio) + .filter_map(|(kind, name, hls)| Some((kind, name.clone(), hls.clone()?))) + .collect(); + if renditions.len() == 2 { + break renditions; + } + }; + for (kind, name, hls) in renditions { + let mut records = moq_mux::hls::Consumer::subscribe(&broadcast, &hls).await?; + let mut out = Vec::new(); + while let Some(event) = records.next().await? { + if let moq_mux::hls::Event::Push { entry, .. } = event { + let mut record = format!("{}:{}:{}", entry.sequence, entry.group, entry.pts.as_millis()); + if let Some(duration) = entry.duration { + record += &format!(":{}", duration.as_millis()); + } + out.push(record); + } + } + ensure!(!out.is_empty(), "{kind} {name} published no records"); + println!( + "hls {kind} {name} track={} duration={} records={}", + hls.track, + hls.duration.as_millis(), + out.join(",") + ); + } + Ok::<_, anyhow::Error>(()) + }) + .await??; + session.close().await?; + client.close().await; + Ok(()) +} diff --git a/test/interop/interop.sh b/test/interop/interop.sh index f113695ef0f..42fbdf34eaa 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -525,6 +525,7 @@ plan_pass() { record "not run" case=compress publisher="$runtime" subscriber=rust version="$version" done record "not run" case="compressed catalog" publisher=rust subscriber="$runtime" + record "not run" case="hls records" publisher="$runtime" subscriber=rust done needs js-native-node || return 0 for version in "${DROP_VERSIONS[@]}"; do @@ -602,7 +603,7 @@ build_relay_cli() { exit 1 } if [[ "$MEDIA" -eq 0 ]]; then - (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p moq-cli --example interop-tail --example interop-catalog --example interop-audio --example interop-control --example interop-goaway --example interop-goaway-client --example interop-finish) + (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p moq-cli --example interop-tail --example interop-catalog --example interop-audio --example interop-hls --example interop-control --example interop-goaway --example interop-goaway-client --example interop-finish) fi [[ -n "$RELAY" ]] || RELAY="$TARGET_BASE/$PROFILE/moq-relay" # The `moq-cli` crate ships its binary as `moq` (a `[[bin]]` override). @@ -2353,6 +2354,62 @@ run_catalog_fanout() { rm "$fifo" } +# shellcheck disable=SC2329 # invoked indirectly via harness_spawn +run_hls_publisher() { + local fifo="$1" runtime="$2" + shift 2 + case "$runtime" in + rust) timeout -k 3 $((TIMEOUT * 2)) "$TARGET_BASE/$PROFILE/examples/interop-hls" publish "$@" <"$fifo" 9>&- ;; + js-native-node) (cd "$CLIENTS/js-native" && timeout -k 3 $((TIMEOUT * 2)) node --import tsx hls.ts "$@" <"$fifo" 9>&-) ;; + js-native-bun) (cd "$CLIENTS/js-native" && timeout -k 3 $((TIMEOUT * 2)) bun hls.ts "$@" <"$fifo" 9>&-) ;; + esac +} + +# A native JS publisher's HLS records and declared durations match Rust's for the same frames: +# moq-mux and @moq/hang each publish one schedule through the relay, and the Rust reader's +# readout of the two broadcasts must be identical. +run_hls_records() { + local runtime="$1" started=$SECONDS publisher pid why="" want got + local name="hls-$1" fifo="$LOGS/hls-ack" broadcast="hls-$1-$$-$RANDOM" + local cell=(case="hls records" publisher="$runtime" subscriber=rust log="$LOGS/$name-$runtime-read.log") + if is_broken "$runtime"; then + echo " FAIL hls records $runtime -> rust (client unavailable)" + record fail "${cell[@]}" detail="client unavailable" + overall=1 + return + fi + mkfifo "$fifo" + exec 9<>"$fifo" + for publisher in rust "$runtime"; do + harness_spawn "$name-$publisher-pub" "$LOGS/$name-$publisher-pub.log" run_hls_publisher "$fifo" "$publisher" "$(token_url --publish "$broadcast-$publisher/**")" "$broadcast-$publisher" + pid="$HARNESS_PID" + harness_spawn "$name-$publisher-read" "$LOGS/$name-$publisher-read.log" timeout -k 3 "$TIMEOUT" "$TARGET_BASE/$PROFILE/examples/interop-hls" read "$(token_url --subscribe "$broadcast-$publisher/**")" "$broadcast-$publisher" + harness_wait "$HARNESS_PID" || why="${why:-$publisher records unreadable}" + printf 'clean end\n' >&9 + harness_wait "$pid" || true + grep -qx 'hls acknowledged' "$LOGS/$name-$publisher-pub.log" || why="${why:-$publisher publisher did not acknowledge}" + done + want=$(grep '^hls ' "$LOGS/$name-rust-read.log" || true) + got=$(grep '^hls ' "$LOGS/$name-$runtime-read.log" || true) + if [[ -z "$why" && -z "$want" ]]; then + why="rust published no records" + elif [[ -z "$why" && "$want" != "$got" ]]; then + why="records differ from rust" + fi + if [[ -z "$why" ]]; then + echo " PASS hls records $runtime -> rust (match moq-mux)" + record pass "${cell[@]}" seconds=$((SECONDS - started)) + else + echo " FAIL hls records $runtime -> rust ($why)" + record fail "${cell[@]}" seconds=$((SECONDS - started)) detail="$why" + overall=1 + diff <(echo "$want") <(echo "$got") || true + for publisher in rust "$runtime"; do cat "$LOGS/$name-$publisher-pub.log" "$LOGS/$name-$publisher-read.log"; done + fi + exec 9>&- + rm "$fifo" +} + # This checkout's CLI FETCH and finite track tails in this pass, then the lite drop lanes. run_tails() { local runtime version @@ -2394,6 +2451,7 @@ run_tails() { run_tail_pair "$runtime" rust compress "$version" done run_catalog_fanout "$runtime" + run_hls_records "$runtime" done if needs js-native-node; then for version in "${DROP_VERSIONS[@]}"; do From 7168b8f9720df554bf53f39cd5729afefd4bcaa8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 14:52:41 +0000 Subject: [PATCH 81/86] feat(cli): moq repair finishes a cut-short mp4 recording (#7560) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: Codex --- doc/bin/cli.md | 9 +- quest/m2/README.md | 3 +- quest/m2/mp4-repair-lock.md | 8 +- quest/m2/mp4-repair.md | 13 - rs/moq-cli/src/args.rs | 13 +- rs/moq-cli/src/main.rs | 5 + rs/moq-cli/src/repair.rs | 66 +++ rs/moq-cli/src/subscribe.rs | 20 +- rs/moq-cli/tests/integration/export.rs | 64 +++ rs/moq-mux/src/container/fmp4/mod.rs | 4 + rs/moq-mux/src/container/mp4.rs | 637 +++++++++++++++++++------ 11 files changed, 678 insertions(+), 164 deletions(-) delete mode 100644 quest/m2/mp4-repair.md create mode 100644 rs/moq-cli/src/repair.rs diff --git a/doc/bin/cli.md b/doc/bin/cli.md index e98f611a13f..fab9bdecf50 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -36,6 +36,7 @@ Pi 4, CM4, or Zero 2 W on a 64-bit OS. On a CPU missing a feature rind needs, | `announced` | `[prefix]` | Follow the broadcasts announced on a relay. | | `fetch` | `` | Write one group of a track to stdout. | | `auth` | | Generate, sign, and verify relay JWTs. | +| `repair` | `mp4 ` | Finish an `export mp4` recording that was cut short. | | `devices` | | List capture sources and their ids. | ## Grammar @@ -207,11 +208,15 @@ fragmented MP4 while recording, so a crash leaves a playable file, and the broadcast's end or the first Ctrl-C (or SIGTERM) finishes it as a regular MP4 with its index at the end, without copying the media. Finishing writes everything received, the fragment still buffering included. A second signal exits at once -and leaves it fragmented, minus that fragment. Unlike `fmp4`, a rendition that joins mid-recording is -kept, though only the finished file indexes it. +and leaves it fragmented, minus that fragment. Unlike `fmp4`, a rendition that +joins mid-recording is kept, though only the finished file indexes it. +`moq repair mp4 ` finishes a recording that a crash or second signal left +fragmented, in place and the same way: it indexes every fragment written in full, +late renditions included, and cuts a partial one at the end. ```bash moq ... export mp4 --output recording.mp4 +moq repair mp4 recording.mp4 ``` MPEG-TS export pads to the source's constant mux rate when the catalog diff --git a/quest/m2/README.md b/quest/m2/README.md index f95c170a423..fd1acc4fc4b 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -234,8 +234,7 @@ lite-07; bitrate enforcement is not part of this release. - [We own our WebRTC stack](/quest/m2/webrtc/README.md) - moq-rtc becomes moq-webrtc with moq-ice, the data-channel transport, and a vendored, trimmed str0m/dimpl/is/sctp-proto - [Certificate reload and qmux's tasks are supervised](/quest/m2/tls-reload-supervision.md) - a panic in either fails loudly instead of silently stopping - [PUBLISH_DONE says why](/quest/m2/publish-done-codes.md) - GOING_AWAY, TOO_FAR_BEHIND, and EXPIRED round-trip as stream codes -- [Repair a crashed mp4 recording](/quest/m2/mp4-repair.md) - a crashed recording's fragmented file becomes a regular MP4 -- [moq repair refuses a live recording](/quest/m2/mp4-repair-lock.md) - the recorder's advisory lock keeps `moq repair` off a file still being written +- [moq repair refuses a live recording](/quest/m2/mp4-repair-lock.md) - the recorder's advisory lock keeps `moq repair mp4` off a file still being written - [Windows receives notice truncated control data](/quest/m2/cmsg-windows-truncation.md) - Windows handles `MSG_CTRUNC` like the Unix paths - [The error-queue reader decodes truncated control data](/quest/m2/errqueue-truncation.md) - `MSG_ERRQUEUE` keeps what fit on `MSG_CTRUNC`, like the receive paths - [moq-quic's internal dev-dependencies are path-only](/quest/m2/quic-dev-dep-path.md) - moq-congestion is a path dev-dependency diff --git a/quest/m2/mp4-repair-lock.md b/quest/m2/mp4-repair-lock.md index a0acb9fcde5..ad067eb8862 100644 --- a/quest/m2/mp4-repair-lock.md +++ b/quest/m2/mp4-repair-lock.md @@ -2,18 +2,18 @@ ## Goal -`moq export mp4 --output ` holds an advisory lock on its output while it records, and `moq repair ` refuses, with a message saying a recorder still has it open, when it can't take that lock. Neither side runs unlocked: where the filesystem can't lock, both refuse. Repairing a file mid-recording no longer corrupts it. +`moq export mp4 --output ` holds an advisory lock on its output while it records, and `moq repair mp4 ` refuses, with a message saying a recorder still has it open, when it can't take that lock. Neither side runs unlocked: where the filesystem can't lock, both refuse. Repairing a file mid-recording no longer corrupts it. ## Plan -Found by [moq-dev/moq#7560](https://github.com/moq-dev/moq/pull/7560), which adds `moq repair`: it can't tell a running `export mp4` still writes the file, and repairing one corrupts it. #7560 is still a draft, so this waits on it landing; `moq repair` and `moq_mux::container::mp4::repair` don't exist on `main` until then. +Found by [moq-dev/moq#7560](https://github.com/moq-dev/moq/pull/7560), which adds `moq repair mp4`: it can't tell a running `export mp4` still writes the file, and repairing one corrupts it. This waits on #7560 landing; `moq repair mp4` and `moq_mux::container::mp4::repair` don't exist on `main` until then. Decided 2026-11-01 (maintainer, follow-up planning after the 2026-11-01 /quest-afk run): m2. An advisory lock taken by the recorder and checked by repair, as #7560 suggested. - The CLI owns the lock, not moq-mux: `repair` in moq-mux takes `impl Read + Seek` and stays file-agnostic. Use an exclusive `flock`-style lock on the open file (`std::fs::File::try_lock` is stable in Rust 1.89), held for the recording's life and released by the OS if the recorder dies, so a crashed recording stays repairable. - Supported or refused (`AGENTS.md`), on both sides. The recorder fails at startup if it can't take the lock, a filesystem without lock support included, since its recording could never be repaired safely. `repair` takes the same lock non-blocking before scanning and refuses if it can't: held means a recorder still has the file open, and any other lock error (no lock support) refuses with that error rather than repairing unlocked. -- Test: a held lock makes `moq repair` refuse and leave the file untouched; #7560's nightly `export::a_killed_mp4_recording_is_repaired` still repairs after SIGKILL, since the lock dies with the process. -- Update `doc/bin/cli.md`'s `repair` entry. +- Test: a held lock makes `moq repair mp4` refuse and leave the file untouched; #7560's nightly `export::a_killed_mp4_recording_is_repaired` still repairs after SIGKILL, since the lock dies with the process. +- Update `doc/bin/cli.md`'s `repair mp4` entry. Public API: none (CLI behavior only). Wire: none. diff --git a/quest/m2/mp4-repair.md b/quest/m2/mp4-repair.md deleted file mode 100644 index ce5ff24bf2f..00000000000 --- a/quest/m2/mp4-repair.md +++ /dev/null @@ -1,13 +0,0 @@ -# [M] Repair a crashed mp4 recording - -## Goal - -`moq` can turn the fragmented file a crashed or twice-interrupted `moq export mp4` left behind into a regular MP4, with the same layout a clean finish produces, including renditions that joined mid-recording. - -## Plan - -Follow-up of [moq-dev/moq#7357](https://github.com/moq-dev/moq/pull/7357). Its finish step already rebuilds the sample tables by parsing the written moofs back; reuse that indexer rather than writing a second one. - -Decided 2026-10-30 (maintainer, follow-up planning after the /quest-afk run). Late renditions are written inside `free` boxes that only the final `moov` indexes, so they're unrecoverable today: the recorder must also write each late rendition's track description into the file at the time it joins. Test a file cut at several points (mid-fragment, after a late rendition joins) and check the repaired file decodes like a clean finish of the same data. - -Public API: a CLI subcommand or flag (pick the shape in the PR and list it). Wire: none. diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index 9f45c72542a..539143e1c68 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -445,7 +445,7 @@ impl Invocation { // Only `import` and `export` share an Origin. The rest own the process: `play` // drives a window on the main thread, `transcode` builds its own Origin, `fetch` - // and `announced` open their own session, and `auth` / `devices` never touch the network at all. + // and `announced` open their own session, and `auth` / `repair` / `devices` never touch the network at all. if let Some(command) = self.stages.iter().find(|command| !command.is_stageable()) { anyhow::bail!( "`{}` must be the only verb; it can't share a process with another `--` stage", @@ -685,6 +685,8 @@ pub enum Command { Announced(crate::announced::Args), /// Write one group of a track to stdout. Fetch(crate::fetch::Args), + /// Finish a recording that a crash or second signal left unfinished. + Repair(crate::repair::Args), /// Play a broadcast in a native window and speaker. #[cfg(feature = "play")] Play(crate::play::Args), @@ -744,6 +746,7 @@ impl Command { Self::Export(_) | Self::Subscribe(_) => "export", Self::Announced(_) => "announced", Self::Fetch(_) => "fetch", + Self::Repair(_) => "repair", #[cfg(feature = "play")] Self::Play(_) => "play", #[cfg(feature = "transcode")] @@ -1390,7 +1393,7 @@ pub struct Recorded { /// The file to record to, which must not exist yet. It plays as fragmented MP4 while /// recording; the broadcast's end or the first SIGINT/SIGTERM finishes it as a regular - /// MP4, and a second signal exits at once, leaving it fragmented. + /// MP4, and a second signal exits at once, leaving it fragmented for `moq repair mp4`. #[usage(long, value_name = "FILE", value_hint = usage_rs::ValueHint::FilePath)] pub output: PathBuf, } @@ -2775,7 +2778,11 @@ mod tests { Invocation::try_parse_from(argv).unwrap_or_else(|err| panic!("{flag:?}: {err}")) }; - for verb in [&["auth", "generate"][..], &["completion", "bash"]] { + for verb in [ + &["auth", "generate"][..], + &["completion", "bash"], + &["repair", "mp4", "recording.mp4"], + ] { for flag in accept.iter().chain(dial) { let err = parse(flag, verb).reject(verb[0]).unwrap_err().to_string(); assert!(err.contains(flag[0]), "{verb:?} {flag:?}: {err}"); diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 4ec8fcf129f..89dadceef91 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -16,6 +16,7 @@ mod hls; mod moq; mod play; mod publish; +mod repair; mod report; mod rtc; mod rtmp; @@ -365,6 +366,10 @@ async fn run() -> anyhow::Result<()> { cli.reject("completion")?; return completion.run(); } + Command::Repair(repair) => { + cli.reject("repair")?; + return repair.run().await; + } #[cfg(feature = "capture")] Command::Devices => { cli.reject("devices")?; diff --git a/rs/moq-cli/src/repair.rs b/rs/moq-cli/src/repair.rs new file mode 100644 index 00000000000..8f2499ce2c7 --- /dev/null +++ b/rs/moq-cli/src/repair.rs @@ -0,0 +1,66 @@ +//! `moq repair `: finish a recording that its recorder left unfinished. + +use std::path::PathBuf; + +use anyhow::Context; + +/// Finish a recording that a crash or second signal left unfinished. +#[derive(usage_rs::Args, Clone)] +#[usage(unknown_flags = "error", args_override_self = false)] +pub struct Args { + #[usage(subcommand)] + format: Format, +} + +impl Args { + /// Repair the recording in the named format. + pub async fn run(self) -> anyhow::Result<()> { + match self.format { + Format::Mp4(mp4) => mp4.run().await, + } + } +} + +/// The recorder that left the file, one per format that can be cut short. +#[derive(usage_rs::Subcommands, Clone)] +enum Format { + /// Finish an `export mp4` recording that a crash or second signal left fragmented. + Mp4(Mp4), +} + +/// `moq repair mp4 `. +#[derive(usage_rs::Args, Clone)] +#[usage(unknown_flags = "error", args_override_self = false)] +struct Mp4 { + /// The recording, finished in place as a regular MP4 that indexes every complete fragment. + #[usage(arg, value_name = "FILE", value_hint = usage_rs::ValueHint::FilePath)] + file: PathBuf, +} + +impl Mp4 { + /// Index the recording and write its `moov`, or report that it is already finished. + async fn run(self) -> anyhow::Result<()> { + let path = self.file; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + let (file, finish) = tokio::task::spawn_blocking(move || { + let mut file = file; + let finish = moq_mux::container::mp4::repair(&mut file); + (file, finish) + }) + .await?; + let finish = finish.with_context(|| format!("failed to repair {}", path.display()))?; + + match finish { + Some(finish) => { + crate::subscribe::write_finish(&mut tokio::fs::File::from_std(file), &finish).await?; + tracing::info!(path = %path.display(), "recording repaired"); + } + None => tracing::info!(path = %path.display(), "recording already finished"), + } + Ok(()) + } +} diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index c55e5484e29..e58c102ad74 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -410,12 +410,7 @@ impl Subscribe { match mp4.finish()? { Some(trailer) => { - file.write_all(&trailer.moov).await?; - // The header points readers past the media at the moov, so the moov must be on disk first. - file.sync_data().await?; - file.seek(std::io::SeekFrom::Start(trailer.mdat_offset)).await?; - file.write_all(&trailer.mdat).await?; - file.sync_all().await?; + write_finish(&mut file, &trailer).await?; tracing::info!(path = %path.display(), "recording finished"); } // Nothing reached the file, so there is no recording to keep. @@ -604,3 +599,16 @@ impl Subscribe { Ok(()) } } + +/// Make the writes that turn a recording into a regular MP4: the moov where the media ends, +/// then the mdat header over the reserved box. +pub async fn write_finish(file: &mut tokio::fs::File, finish: &moq_mux::container::mp4::Finish) -> std::io::Result<()> { + file.set_len(finish.moov_offset).await?; + file.seek(std::io::SeekFrom::Start(finish.moov_offset)).await?; + file.write_all(&finish.moov).await?; + // The header points readers past the media at the moov, so the moov must be on disk first. + file.sync_data().await?; + file.seek(std::io::SeekFrom::Start(finish.mdat_offset)).await?; + file.write_all(&finish.mdat).await?; + file.sync_all().await +} diff --git a/rs/moq-cli/tests/integration/export.rs b/rs/moq-cli/tests/integration/export.rs index 30d2301b920..763e782f922 100644 --- a/rs/moq-cli/tests/integration/export.rs +++ b/rs/moq-cli/tests/integration/export.rs @@ -1111,6 +1111,70 @@ async fn a_passthrough_export_ends_with_its_redundant_importer() { feeding.abort(); } +/// `repair mp4` finishes a recording whose `export mp4` was killed as a regular MP4, and leaves +/// it alone once finished. +#[tokio::test] +#[ignore = "real-time process test: just rs cli-export"] +async fn a_killed_mp4_recording_is_repaired() { + let relay = relay().await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("recording.mp4"); + let mut recording = moq(&relay, &[]) + .args(["export", "mp4", "--output"]) + .arg(&path) + .spawn() + .expect("spawn export"); + let (mut publisher, feeding) = import(&relay); + + kio::clock::timeout(TIMEOUT, async { + while std::fs::metadata(&path).map_or(0, |file| file.len()) < 10_000 { + kio::clock::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("the recording never grew"); + recording.kill().await.unwrap(); + let killed = std::fs::read(&path).unwrap(); + assert_eq!( + boxes(&killed)[1].kind().to_string(), + "free", + "the killed recording is unfinished" + ); + + let repair = || async { + let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_moq")) + .args(["repair", "mp4"]) + .arg(&path) + .output(); + let output = kio::clock::timeout(TIMEOUT, output) + .await + .expect("repair never exited") + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + std::fs::read(&path).unwrap() + }; + let repaired = repair().await; + let boxes = boxes(&repaired); + let kinds: Vec = boxes.iter().map(|atom| atom.kind().to_string()).collect(); + assert_eq!(kinds, ["ftyp", "mdat", "moov"]); + let Some(mp4_atom::Any::Moov(moov)) = boxes.last() else { + unreachable!(); + }; + assert_eq!(moov.trak.len(), 2, "video and audio"); + // Only the reserved box changed before the moov, which replaces any partial fragment. + let reserved = u32::from_be_bytes(killed[..4].try_into().unwrap()) as usize; + let media = reserved + u64::from_be_bytes(repaired[reserved + 8..reserved + 16].try_into().unwrap()) as usize; + assert!(media <= killed.len()); + assert_eq!(repaired[..reserved], killed[..reserved]); + assert_eq!(repaired[reserved + 16..media], killed[reserved + 16..media]); + + assert_eq!(repair().await, repaired, "a finished recording is left alone"); + + let stdin = feeding.await.unwrap(); + drop(stdin); + assert!(wait(&mut publisher).await.success()); +} + /// The top-level boxes of an MP4 file, by kind. fn boxes(file: &[u8]) -> Vec { use mp4_atom::DecodeMaybe; diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index b14ff7d172f..da472731c0b 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -370,6 +370,10 @@ pub enum Error { /// What the sample tables can't express. reason: &'static str, }, + + /// A file to repair is not a recording that an MP4 export left unfinished. + #[error("not an unfinished mp4 recording: {0}")] + Unrepairable(&'static str), } impl From for Error { diff --git a/rs/moq-mux/src/container/mp4.rs b/rs/moq-mux/src/container/mp4.rs index 9d526e34fce..0d86c8de171 100644 --- a/rs/moq-mux/src/container/mp4.rs +++ b/rs/moq-mux/src/container/mp4.rs @@ -2,7 +2,8 @@ //! //! [`Export`] writes a file that is a valid fragmented MP4 while it grows, so a crash still //! leaves a playable file, and [`Export::finish`] turns it into a regular MP4 with its `moov` -//! at the end without moving any media, the way OBS's hybrid MP4 does. +//! at the end without moving any media, the way OBS's hybrid MP4 does. [`repair`] finishes a +//! file the same way after a crash. //! //! The file starts with `ftyp`, a reserved 16-byte `free` box and the fragmented `moov`, then //! the moof+mdat fragments. Finishing appends a `moov` that indexes every sample where it @@ -11,9 +12,12 @@ //! header there would swallow the fragmented `moov` and leave a crashed file unreadable. //! //! A rendition that joins after the fragmented `moov` is written into `free` boxes, which a -//! reader of the unfinished file skips, and only the final `moov` indexes it. +//! reader of the unfinished file skips. Before a track's first fragment, and again whenever +//! its description changes, a `free` box holds a `moov` declaring it, so the final `moov` +//! can be rebuilt from the file alone. use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::{Read, Seek, SeekFrom}; use std::task::Poll; use std::time::Duration; @@ -33,10 +37,10 @@ const NON_SYNC: u32 = 0x0001_0000; /// Subscribe to a moq broadcast and record it as a crash-safe regular MP4. /// /// [`next`](Self::next) returns the bytes to append to the file, the same fMP4 an -/// [`fmp4::Export`] writes plus the reserved box, so the file plays if the recording never -/// finishes. [`finish`](Self::finish) then returns the trailing `moov` and the `mdat` header -/// that make it a regular MP4. Writing those needs a file that can be overwritten in place, -/// not a stream. +/// [`fmp4::Export`] writes plus the reserved box and track descriptions, so the file plays if +/// the recording never finishes. [`finish`](Self::finish) then returns the trailing `moov` and +/// the `mdat` header that make it a regular MP4. Writing those needs a file that can be +/// overwritten in place, not a stream. /// /// Unlike [`fmp4::Export`], a rendition that joins after the fragmented `moov` is recorded. pub struct Export { @@ -45,51 +49,22 @@ pub struct Export { /// Bytes returned so far, which is where the next chunk starts in the file. written: u64, - /// The fragmented `moov`, once written. - header: Option
, + /// What the file holds, once the fragmented `moov` is written. + index: Option, - /// What each track wrote, by track id. - tracks: BTreeMap, -} - -/// What the fragmented `moov` fixed. -struct Header { - /// Where the reserved box starts. - reserved: u64, - /// The movie timescale. - timescale: u32, - /// The tracks it declares. Any other track joined later. - declared: HashSet, -} - -/// What one track wrote, in decode order. -#[derive(Default)] -struct Track { - /// One chunk per `trun`, its samples contiguous in the file. - chunks: Vec, - samples: Vec, -} - -struct Run { - /// Where the first sample starts in the file. - offset: u64, - /// The first sample's decode time, in media ticks. - decode_time: u64, - /// How many samples the run holds. - count: u32, -} - -struct Sample { - size: u32, - duration: u32, - cts: i64, - sync: bool, + /// The declaration the file last described each track with, by track id. + described: HashMap, } /// The writes that finish a recording, in order. #[derive(Clone, Debug)] pub struct Finish { - /// The regular `moov`, appended after everything [`Export::next`] returned. + /// Where [`moov`](Self::moov) is written, from the start of the file, which is cut there + /// first: the end of everything [`Export::next`] returned, or of a repaired file's last + /// complete fragment. + pub moov_offset: u64, + + /// The regular `moov`, written first. pub moov: Bytes, /// Where [`mdat`](Self::mdat) overwrites the reserved box, from the start of the file. @@ -105,8 +80,8 @@ impl Export { Self { fmp4: fmp4::Export::new(source, catalog).with_late_tracks(), written: 0, - header: None, - tracks: BTreeMap::new(), + index: None, + described: HashMap::new(), } } @@ -154,72 +129,7 @@ impl Export { /// Media still buffered for a fragment that has not closed is left out, unless /// [`stop`](Self::stop) wrote it first. pub fn finish(self) -> Result> { - let Some(header) = &self.header else { - return Ok(None); - }; - - let declarations: HashMap = - self.fmp4.declarations().map(|(id, trak, _)| (id, trak)).collect(); - let mut timelines = Vec::new(); - for (&id, track) in self.tracks.iter().filter(|(_, track)| !track.samples.is_empty()) { - let Some(&trak) = declarations.get(&id) else { - return Err(unindexable(id, "the track has no declaration").into()); - }; - timelines.push((trak, track, Timeline::new(id, trak, track)?)); - } - - let movie = u128::from(header.timescale); - let to_movie = |ticks: u64, timescale: u32| { - u64::try_from(u128::from(ticks) * movie / u128::from(timescale)).map_err(|_| Error::PtsOverflow) - }; - // The earliest track starts the movie; the rest start that much later. - let mut origin = u64::MAX; - for (trak, _, timeline) in &timelines { - origin = origin.min(to_movie(timeline.start, trak.mdia.mdhd.timescale)?); - } - - let mut traks = Vec::new(); - for (trak, track, timeline) in timelines { - let timescale = trak.mdia.mdhd.timescale; - let delay = to_movie(timeline.start, timescale)? - origin; - let presented = to_movie(timeline.duration, timescale)?; - let mut trak = trak.clone(); - trak.mdia.mdhd.duration = timeline.deltas.iter().map(|&delta| u64::from(delta)).sum(); - trak.tkhd.duration = delay + presented; - trak.edts = edits(delay, timeline.media_time, presented); - trak.mdia.minf.stbl = sample_table(&trak.mdia.minf.stbl.stsd, track, &timeline); - traks.push(trak); - } - traks.sort_by_key(|trak| (trak.mdia.hdlr.handler != b"vide".into(), trak.tkhd.track_id)); - - let moov = mp4_atom::Moov { - mvhd: mp4_atom::Mvhd { - timescale: header.timescale, - duration: traks.iter().map(|trak| trak.tkhd.duration).max().unwrap_or(0), - // Normal playback rate and full volume; both default to 0. - rate: mp4_atom::FixedPoint::from(1), - volume: mp4_atom::FixedPoint::from(1), - next_track_id: traks.iter().map(|trak| trak.tkhd.track_id).max().unwrap_or(0) + 1, - ..Default::default() - }, - trak: traks, - ..Default::default() - }; - let mut buf = Vec::new(); - moov.encode(&mut buf)?; - - // Everything from the reserved box to the moov, the fragmented moov and every moof - // included, becomes one mdat that a reader skips through to the moov. - let mut mdat = BytesMut::with_capacity(RESERVED as usize); - mdat.put_u32(1); - mdat.put_slice(b"mdat"); - mdat.put_u64(self.written - header.reserved); - - Ok(Some(Finish { - moov: Bytes::from(buf), - mdat_offset: header.reserved, - mdat: mdat.freeze(), - })) + self.index.map(|index| index.finish(self.written)).transpose() } /// The file header: the init segment's `ftyp`, the reserved box, then its `moov`. @@ -236,24 +146,42 @@ impl Export { data.put_u64(0); data.put_slice(&init[split..]); - self.header = Some(Header { - reserved: self.written + split as u64, - timescale: moov.mvhd.timescale, - declared: moov.trak.iter().map(|trak| trak.tkhd.track_id).collect(), - }); + self.index = Some(Index::new(self.written + split as u64, &moov)?); + self.described = self + .fmp4 + .declarations() + .map(|(id, trak, trex)| (id, Description::new(trak, trex))) + .collect(); Ok(data.freeze()) } - /// Index a moof+mdat fragment where it will land, wrapping a late track's in a `free` box. + /// Index a moof+mdat fragment where it will land, after a description of each track the + /// file has yet to describe as it is now, and wrapping a late track's in a `free` box. fn fragment(&mut self, fragment: Bytes) -> Result { - let header = self.header.as_ref().expect("the init segment comes first"); + let index = self.index.as_mut().expect("the init segment comes first"); let moof = mp4_atom::Moof::decode(&mut std::io::Cursor::new(fragment.as_ref()))?; let mut data = BytesMut::new(); + for traf in &moof.traf { + let id = traf.tfhd.track_id; + let Some((trak, trex)) = self.fmp4.declaration(id) else { + return Err(unindexable(id, "the track has no declaration").into()); + }; + if self + .described + .get(&id) + .is_none_or(|known| known.trak != *trak || known.trex != *trex) + { + let description = Description::new(trak, trex); + // Indexed as a repair reads it back, which keeps the two finishes identical. + index.describe(&description.encode(index.timescale, &mut data)?)?; + self.described.insert(id, description); + } + } if moof .traf .iter() - .any(|traf| !header.declared.contains(&traf.tfhd.track_id)) + .any(|traf| !index.declared.contains(&traf.tfhd.track_id)) { mp4_atom::Header { kind: b"free".into(), @@ -261,18 +189,264 @@ impl Export { } .encode(&mut data)?; } - let start = self.written + data.len() as u64; - for traf in &moof.traf { - self.index(start, traf)?; - } + index.fragment(self.written + data.len() as u64, &moof)?; data.put_slice(&fragment); Ok(data.freeze()) } +} + +/// Rebuild the writes that finish a recording [`Export`] left unfinished, such as after a +/// crash or a cut-short finish, or `None` when the file is already a regular MP4. +/// +/// The final `moov` indexes every complete fragment, the same as finishing the recording +/// right after writing the last one. Whatever follows it, such as a partial fragment or +/// `moov`, is cut, so a repair cut short can itself be repaired. +pub fn repair(file: &mut F) -> Result> { + let eof = file.seek(SeekFrom::End(0))?; + let Some(ftyp) = Atom::read(file, 0, eof)?.filter(|atom| atom.kind == *b"ftyp") else { + return Err(Error::Unrepairable("it does not start with an ftyp box").into()); + }; + let Some(reserved) = Atom::read(file, ftyp.end, eof)? else { + return Err(Error::Unrepairable("it ends before its header").into()); + }; + if reserved.kind == *b"mdat" { + return Ok(None); + } + if reserved.kind != *b"free" || reserved.end - reserved.start != RESERVED { + return Err(Error::Unrepairable("it has no reserved box").into()); + } + let Some(header) = Atom::read(file, reserved.end, eof)?.filter(|atom| atom.kind == *b"moov") else { + return Err(Error::Unrepairable("it has no complete moov after its reserved box").into()); + }; + let moov: mp4_atom::Moov = header.decode(file)?; + if moov.mvex.is_none() { + return Err(Error::Unrepairable("its moov is not fragmented").into()); + } + let mut index = Index::new(reserved.start, &moov)?; + + // Where the last complete fragment ends, and the descriptions written since, which only + // count once the fragment written with them is complete. + let mut end = header.end; + let mut described = Vec::new(); + let mut position = header.end; + while let Some(atom) = Atom::read(file, position, eof)? { + let moof = match &atom.kind { + b"moof" => { + let Some(mdat) = Atom::read(file, atom.end, eof)? else { + break; + }; + if mdat.kind != *b"mdat" { + return Err(Error::Unrepairable("a moof is not followed by its mdat").into()); + } + position = mdat.end; + atom + } + b"free" => { + position = atom.end; + match Atom::read(file, atom.body, atom.end)? { + Some(inner) if inner.kind == *b"moof" => { + let mdat = Atom::read(file, inner.end, atom.end)?; + if mdat.is_none_or(|mdat| mdat.kind != *b"mdat" || mdat.end != atom.end) { + return Err(Error::Unrepairable("a free box holds a moof without its mdat").into()); + } + inner + } + Some(inner) if inner.kind == *b"moov" => { + described.push(inner.decode::<_, mp4_atom::Moov>(file)?); + continue; + } + _ => return Err(Error::Unrepairable("a free box holds neither a fragment nor a moov").into()), + } + } + // A finish cut short appended part or all of a moov, which ends the recording. + b"moov" => break, + _ => return Err(Error::Unrepairable("it holds a box a recording never writes").into()), + }; + for moov in described.drain(..) { + index.describe(&moov)?; + } + index.fragment(moof.start, &moof.decode(file)?)?; + end = position; + } + if end < eof { + tracing::warn!( + offset = end, + bytes = eof - end, + "cutting what follows the last complete fragment" + ); + } + Ok(Some(index.finish(end)?)) +} + +/// One box in a file. +struct Atom { + kind: [u8; 4], + /// Where the box starts. + start: u64, + /// Where its body starts, past the header. + body: u64, + /// Where the box ends. + end: u64, +} + +impl Atom { + /// The box at `start`, or `None` when it does not end by `limit`: cut short, or not a box. + fn read(file: &mut F, start: u64, limit: u64) -> Result> { + let mut header = [0u8; 16]; + let available = limit.saturating_sub(start).min(16) as usize; + if available < 8 { + return Ok(None); + } + file.seek(SeekFrom::Start(start))?; + file.read_exact(&mut header[..available])?; + let kind = header[4..8].try_into().unwrap(); + let (length, size) = match u32::from_be_bytes(header[..4].try_into().unwrap()) { + 1 if available == 16 => (16, u64::from_be_bytes(header[8..].try_into().unwrap())), + // A crash can leave zeroes, and a recording never writes a box that runs to the end. + 0 | 1 => return Ok(None), + size => (8, u64::from(size)), + }; + let end = start.checked_add(size).filter(|&end| size >= length && end <= limit); + Ok(end.map(|end| Self { + kind, + start, + body: start + length, + end, + })) + } + + /// Read and decode the whole box. + fn decode(&self, file: &mut F) -> Result { + let mut data = vec![0u8; (self.end - self.start) as usize]; + file.seek(SeekFrom::Start(self.start))?; + file.read_exact(&mut data)?; + Ok(T::decode(&mut std::io::Cursor::new(data.as_slice()))?) + } +} + +/// What a recording's file holds: its header, each track's latest description, and the +/// samples of every fragment. +#[derive(Clone)] +struct Index { + /// Where the reserved box starts. + reserved: u64, + /// The movie timescale. + timescale: u32, + /// The tracks the fragmented `moov` declares. Any other track joined later. + declared: HashSet, + /// The latest description of each track, by track id. + descriptions: HashMap, + /// What each track wrote, by track id. + tracks: BTreeMap, +} + +/// How a track is declared. +#[derive(Clone)] +struct Description { + trak: mp4_atom::Trak, + trex: mp4_atom::Trex, +} + +impl Description { + fn new(trak: &mp4_atom::Trak, trex: &mp4_atom::Trex) -> Self { + Self { + trak: trak.clone(), + trex: trex.clone(), + } + } + + /// Write a `free` box holding a `moov` that declares the track, returning that `moov` as + /// read back from the file. + fn encode(&self, timescale: u32, data: &mut BytesMut) -> Result { + let moov = mp4_atom::Moov { + mvhd: mp4_atom::Mvhd { + timescale, + ..Default::default() + }, + trak: vec![self.trak.clone()], + mvex: Some(mp4_atom::Mvex { + trex: vec![self.trex.clone()], + ..Default::default() + }), + ..Default::default() + }; + let mut buf = Vec::new(); + moov.encode(&mut buf)?; + mp4_atom::Header { + kind: b"free".into(), + size: Some(buf.len()), + } + .encode(data)?; + data.put_slice(&buf); + Ok(mp4_atom::Moov::decode(&mut std::io::Cursor::new(buf.as_slice()))?) + } +} + +/// What one track wrote, in decode order. +#[derive(Clone, Default)] +struct Track { + /// One chunk per `trun`, its samples contiguous in the file. + chunks: Vec, + samples: Vec, +} + +#[derive(Clone)] +struct Run { + /// Where the first sample starts in the file. + offset: u64, + /// The first sample's decode time, in media ticks. + decode_time: u64, + /// How many samples the run holds. + count: u32, +} + +#[derive(Clone)] +struct Sample { + size: u32, + duration: u32, + cts: i64, + sync: bool, +} + +impl Index { + /// Index a file whose reserved box starts at `reserved`, followed by the fragmented `moov`. + fn new(reserved: u64, moov: &mp4_atom::Moov) -> Result { + let mut index = Self { + reserved, + timescale: moov.mvhd.timescale, + declared: moov.trak.iter().map(|trak| trak.tkhd.track_id).collect(), + descriptions: HashMap::new(), + tracks: BTreeMap::new(), + }; + index.describe(moov)?; + Ok(index) + } + + /// Take each track `moov` declares as its description from here on. + fn describe(&mut self, moov: &mp4_atom::Moov) -> Result<()> { + let trexs = moov.mvex.as_ref().map_or(&[][..], |mvex| mvex.trex.as_slice()); + for trak in &moov.trak { + let id = trak.tkhd.track_id; + let Some(trex) = trexs.iter().find(|trex| trex.track_id == id) else { + return Err(unindexable(id, "the track has no trex").into()); + }; + self.descriptions.insert(id, Description::new(trak, trex)); + } + Ok(()) + } + + /// Record the samples of `moof`, which starts at `offset` in the file. + fn fragment(&mut self, offset: u64, moof: &mp4_atom::Moof) -> Result<()> { + for traf in &moof.traf { + self.traf(offset, traf)?; + } + Ok(()) + } /// Record the samples of `traf`, whose moof starts at `moof` in the file. - fn index(&mut self, moof: u64, traf: &mp4_atom::Traf) -> Result<()> { + fn traf(&mut self, moof: u64, traf: &mp4_atom::Traf) -> Result<()> { let id = traf.tfhd.track_id; - let Some((_, trex)) = self.fmp4.declaration(id) else { + let Some(Description { trex, .. }) = self.descriptions.get(&id) else { return Err(unindexable(id, "the track has no declaration").into()); }; let Some(tfdt) = &traf.tfdt else { @@ -315,6 +489,71 @@ impl Export { } Ok(()) } + + /// The writes that make the file a regular MP4 with its `moov` at `end`. + fn finish(&self, end: u64) -> Result { + let mut timelines = Vec::new(); + for (&id, track) in self.tracks.iter().filter(|(_, track)| !track.samples.is_empty()) { + let Some(Description { trak, .. }) = self.descriptions.get(&id) else { + return Err(unindexable(id, "the track has no declaration").into()); + }; + timelines.push((trak, track, Timeline::new(id, trak, track)?)); + } + + let movie = u128::from(self.timescale); + let to_movie = |ticks: u64, timescale: u32| { + u64::try_from(u128::from(ticks) * movie / u128::from(timescale)).map_err(|_| Error::PtsOverflow) + }; + // The earliest track starts the movie; the rest start that much later. + let mut origin = u64::MAX; + for (trak, _, timeline) in &timelines { + origin = origin.min(to_movie(timeline.start, trak.mdia.mdhd.timescale)?); + } + + let mut traks = Vec::new(); + for (trak, track, timeline) in timelines { + let timescale = trak.mdia.mdhd.timescale; + let delay = to_movie(timeline.start, timescale)? - origin; + let presented = to_movie(timeline.duration, timescale)?; + let mut trak = trak.clone(); + trak.mdia.mdhd.duration = timeline.deltas.iter().map(|&delta| u64::from(delta)).sum(); + trak.tkhd.duration = delay + presented; + trak.edts = edits(delay, timeline.media_time, presented); + trak.mdia.minf.stbl = sample_table(&trak.mdia.minf.stbl.stsd, track, &timeline); + traks.push(trak); + } + traks.sort_by_key(|trak| (trak.mdia.hdlr.handler != b"vide".into(), trak.tkhd.track_id)); + + let moov = mp4_atom::Moov { + mvhd: mp4_atom::Mvhd { + timescale: self.timescale, + duration: traks.iter().map(|trak| trak.tkhd.duration).max().unwrap_or(0), + // Normal playback rate and full volume; both default to 0. + rate: mp4_atom::FixedPoint::from(1), + volume: mp4_atom::FixedPoint::from(1), + next_track_id: traks.iter().map(|trak| trak.tkhd.track_id).max().unwrap_or(0) + 1, + ..Default::default() + }, + trak: traks, + ..Default::default() + }; + let mut buf = Vec::new(); + moov.encode(&mut buf)?; + + // Everything from the reserved box to the moov, the fragmented moov and every moof + // included, becomes one mdat that a reader skips through to the moov. + let mut mdat = BytesMut::with_capacity(RESERVED as usize); + mdat.put_u32(1); + mdat.put_slice(b"mdat"); + mdat.put_u64(end - self.reserved); + + Ok(Finish { + moov_offset: end, + moov: Bytes::from(buf), + mdat_offset: self.reserved, + mdat: mdat.freeze(), + }) + } } fn unindexable(track: u32, reason: &'static str) -> Error { @@ -547,14 +786,46 @@ mod tests { } } + /// Append everything the export writes like [`record`], keeping the file's length and what + /// the export indexed after each write. + async fn record_indexed( + export: &mut super::Export, + file: &mut Vec, + indexes: &mut Vec<(usize, super::Index)>, + ) { + while let Ok(next) = kio::clock::timeout(Duration::from_millis(1), export.next()).await { + match next.expect("export failed") { + Some(data) => file.extend_from_slice(&data), + None => break, + } + indexes.push((file.len(), export.index.clone().expect("the header comes first"))); + } + } + /// Finish the recording in `file` the way a writer does. fn finish(export: super::Export, file: &mut Vec) { - let finish = export.finish().expect("finish").expect("something was written"); + apply(export.finish().expect("finish").expect("something was written"), file); + } + + /// Make the writes of `finish` to `file`. + fn apply(finish: super::Finish, file: &mut Vec) { + file.truncate(finish.moov_offset as usize); file.extend_from_slice(&finish.moov); let offset = finish.mdat_offset as usize; file[offset..offset + finish.mdat.len()].copy_from_slice(&finish.mdat); } + fn repair(file: &[u8]) -> crate::Result> { + super::repair(&mut std::io::Cursor::new(file)) + } + + fn assert_same(finish: &super::Finish, expected: &super::Finish, cut: usize) { + assert_eq!(finish.moov_offset, expected.moov_offset, "cut at {cut}"); + assert_eq!(finish.moov, expected.moov, "cut at {cut}"); + assert_eq!(finish.mdat_offset, expected.mdat_offset, "cut at {cut}"); + assert_eq!(finish.mdat, expected.mdat, "cut at {cut}"); + } + fn atoms(file: &[u8]) -> Vec { let mut cursor = std::io::Cursor::new(file); let mut atoms = Vec::new(); @@ -635,7 +906,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_finished_recording_is_a_regular_mp4() { let mut live = Live::avc3(); - let mut audio = add_opus(&mut live, 0); + let mut audio = add_opus(&mut live, 312); let video: Vec<_> = (0..9u64).map(|i| video_frame(i * 33_000, i % 3 == 0)).collect(); for frame in &video { live.track.write(frame.clone()).unwrap(); @@ -650,13 +921,15 @@ mod tests { let mut file = Vec::new(); record(&mut export, &mut file).await; - // Unfinished, it is the fMP4 a crash would leave. + // Unfinished, it is the fMP4 a crash would leave. The fragmented moov already describes + // every track as written, Opus pre-skip edit included. let unfinished = atoms(&file); assert_eq!(kinds(&unfinished[..3]), ["ftyp", "free", "moov"]); assert!( kinds(&unfinished[3..]) .iter() - .all(|kind| kind == "moof" || kind == "mdat") + .all(|kind| kind == "moof" || kind == "mdat"), + "no description beyond the fragmented moov" ); assert!(moov(unfinished).mvex.is_some()); @@ -715,7 +988,10 @@ mod tests { .iter() .filter(|atom| matches!(atom, mp4_atom::Any::Free(_))) .count(); - assert_eq!(frees, 3, "the reserved box and both audio fragments"); + assert_eq!( + frees, 4, + "the reserved box, the audio description and both audio fragments" + ); finish(export, &mut file); let moov = moov(atoms(&file)); @@ -874,8 +1150,16 @@ mod tests { let mut export = export(&live).await; let mut file = Vec::new(); record(&mut export, &mut file).await; - let written = - |export: &super::Export<_>| export.tracks.values().map(|track| track.samples.len()).sum::(); + let written = |export: &super::Export<_>| { + export + .index + .as_ref() + .unwrap() + .tracks + .values() + .map(|track| track.samples.len()) + .sum::() + }; assert_eq!(written(&export), 3 + 5, "only the closed groups"); export.stop(); @@ -950,4 +1234,89 @@ mod tests { let export = export(&live).await; assert!(export.finish().unwrap().is_none()); } + + /// A recording cut anywhere, mid-fragment or right after a late rendition joins, repairs + /// to exactly what finishing it after its last complete write would have produced. + #[tokio::test(start_paused = true)] + async fn a_cut_recording_repairs_like_a_finish_there() { + let mut live = Live::avc3(); + for i in 0..4u64 { + live.track.write(video_frame(i * 33_000, i % 3 == 0)).unwrap(); + } + let mut export = export(&live).await; + let mut file = Vec::new(); + let mut indexes = Vec::new(); + record_indexed(&mut export, &mut file, &mut indexes).await; + + let mut audio = add_opus(&mut live, 312); + for i in 0..10u64 { + audio.write(raw_frame(100_000 + i * 20_000, OPUS, i % 5 == 0)).unwrap(); + } + audio.finish().unwrap(); + for i in 4..9u64 { + live.track.write(video_frame(i * 33_000, i % 3 == 0)).unwrap(); + } + live.track.finish().unwrap(); + record_indexed(&mut export, &mut file, &mut indexes).await; + assert!(indexes.len() > 4, "several writes to cut between"); + + for cut in 0..=file.len() { + let repaired = repair(&file[..cut]); + let Some((end, index)) = indexes.iter().rev().find(|(end, _)| *end <= cut) else { + assert!(repaired.is_err(), "a header cut at {cut} is no recording"); + continue; + }; + let repaired = repaired.unwrap().expect("unfinished"); + assert_same(&repaired, &index.finish(*end as u64).unwrap(), cut); + + // A repair cut short after its moov leaves a file that repairs the same. + let mut half = file[..cut].to_vec(); + half.truncate(repaired.moov_offset as usize); + half.extend_from_slice(&repaired.moov); + assert_same(&repair(&half).unwrap().expect("unfinished"), &repaired, cut); + } + + // Uncut, the repair is the finish byte for byte, and indexes the late rendition. + let mut repaired = file.clone(); + apply(repair(&file).unwrap().expect("unfinished"), &mut repaired); + finish(export, &mut file); + assert_eq!(repaired, file); + let moov = moov(atoms(&file)); + assert_eq!(samples(&file, trak(&moov, b"soun")), vec![OPUS; 10]); + assert_eq!(samples(&file, trak(&moov, b"vide")).len(), 9); + } + + /// A finish cut short before its mdat header leaves part or all of the moov at the end, + /// which the repair cuts and writes again. + #[tokio::test(start_paused = true)] + async fn an_interrupted_finish_repairs() { + let mut live = Live::avc3(); + let mut audio = add_opus(&mut live, 0); + for i in 0..6u64 { + live.track.write(video_frame(i * 33_000, i % 3 == 0)).unwrap(); + audio.write(raw_frame(i * 20_000, OPUS, i == 0)).unwrap(); + } + live.track.finish().unwrap(); + audio.finish().unwrap(); + let mut export = export(&live).await; + let mut file = Vec::new(); + record(&mut export, &mut file).await; + let finished = export.finish().unwrap().expect("something was written"); + + for appended in [1, 8, finished.moov.len() / 2, finished.moov.len()] { + let mut cut = file.clone(); + cut.extend_from_slice(&finished.moov[..appended]); + assert_same(&repair(&cut).unwrap().expect("unfinished"), &finished, appended); + } + + let mut done = file.clone(); + apply(finished, &mut done); + assert_eq!(kinds(&atoms(&done)), ["ftyp", "mdat", "moov"]); + assert!(repair(&done).unwrap().is_none(), "already a regular MP4"); + + // A plain fMP4 has no reserved box for the mdat header. + let ftyp = u32::from_be_bytes(file[..4].try_into().unwrap()) as usize; + let fmp4 = [&file[..ftyp], &file[ftyp + 16..]].concat(); + assert!(repair(&fmp4).is_err()); + } } From 4a82a6a5f75232c53dbc3b80f4677a7a8b50027f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 15:07:13 +0000 Subject: [PATCH 82/86] test(net): cover draft 20 joins with the supported version matrix (#7553) Co-authored-by: GPT-6.1 Sol --- js/net/src/ietf/join.test.ts | 116 ++++++++++++++++------------- js/net/src/lite/connection.test.ts | 25 ++++--- quest/m1/README.md | 1 - quest/m1/join-tests-draft20.md | 19 ----- 4 files changed, 80 insertions(+), 81 deletions(-) delete mode 100644 quest/m1/join-tests-draft20.md diff --git a/js/net/src/ietf/join.test.ts b/js/net/src/ietf/join.test.ts index def2ff01ff9..8a9f23aed27 100644 --- a/js/net/src/ietf/join.test.ts +++ b/js/net/src/ietf/join.test.ts @@ -20,10 +20,15 @@ import { RequestError } from "./request.ts"; import { NativeSession } from "./session.ts"; import { Subscribe, SubscribeOk } from "./subscribe.ts"; import { Subscriber } from "./subscriber.ts"; -import { ALPN, type IetfVersion, Version } from "./version.ts"; +import { ALPN, type IetfVersion, Version, versionName } from "./version.ts"; +const VERSIONS = [Version.DRAFT_18, Version.DRAFT_20, Version.DRAFT_22] as const; const VERSION = Version.DRAFT_18; -const DRAFT_22 = Version.DRAFT_22; +const ALPNS: Record = { + [Version.DRAFT_18]: ALPN.DRAFT_18, + [Version.DRAFT_20]: ALPN.DRAFT_20, + [Version.DRAFT_22]: ALPN.DRAFT_22, +}; const ALIAS = 9n; /** The group SUBSCRIBE_OK names as Largest in most cases below. */ const GROUP = 3; @@ -53,7 +58,7 @@ async function nextStream( /** A subscriber with one track, and the SUBSCRIBE it sent, still unanswered. */ async function subscribed(version: IetfVersion = VERSION) { - const pair = createMockTransportPair(version === VERSION ? ALPN.DRAFT_18 : ALPN.DRAFT_22); + const pair = createMockTransportPair(ALPNS[version]); const session = new NativeSession(pair.server, version, true); const subscriber = new Subscriber({ session }); const consumer = subscriber.consume(Path.from("room")).track("video").subscribe(); @@ -167,6 +172,7 @@ async function frames(group: { readFrame(): Promise<{ payload: Uint8Array } | un test("a draft-18 join subscribes at Largest Object and fetches Largest's group", async () => { const { subscribe, fetch } = await join(); expect(subscribe.filter).toEqual({ kind: "nextObject" }); + expect(subscribe.fill).toBeUndefined(); expect(fetch?.type).toEqual({ kind: "relativeJoining", subscription: subscribe.requestId, offset: 0n }); expect(fetch?.groupOrder).toBe("ascending"); expect(fetch?.requestId).not.toBe(subscribe.requestId); @@ -468,56 +474,65 @@ test("a joining FETCH spanning another group drops the head", async () => { expect(await group?.closed).toBeInstanceOf(Error); }); -test("a draft-22 join subscribes at Next Object with a StartGroup=1 fill, and sends no FETCH", async () => { - const { subscribe, answer, client } = await subscribed(DRAFT_22); - expect(subscribe.filter).toEqual({ kind: "nextObject" }); - expect(subscribe.fill).toEqual({ filter: { kind: "relative", groups: 1n }, rangeFilters: false }); - await answer({ groupId: BigInt(GROUP), objectId: 1n }); - expect(await nextStream(client, 200, DRAFT_22)).toBeUndefined(); -}); +// Drafts 20 and 22 fill the current group; only draft 18 sends a joining FETCH. +for (const version of VERSIONS) { + if (version === VERSION) continue; -test("a draft-22 fill and its tail stitch into one group", async () => { - const { subscriber, consumer, subscribe, answer } = await subscribed(DRAFT_22); - await answer({ groupId: BigInt(GROUP), objectId: 1n }); + test(`${versionName(version)}: a join subscribes at Next Object with a StartGroup=1 fill, and sends no FETCH`, async () => { + const { subscribe, answer, client } = await subscribed(version); + expect(subscribe.filter).toEqual({ kind: "nextObject" }); + expect(subscribe.fill).toEqual({ filter: { kind: "relative", groups: 1n }, rangeFilters: false }); + await answer({ groupId: BigInt(GROUP), objectId: 1n }); + expect(await nextStream(client, 200, version)).toBeUndefined(); + }); - const tail = subscriber.handleGroup(...liveStream(GROUP, 2, ["t2"], DRAFT_22)); - // The fill names the SUBSCRIBE's own request. - await subscriber.handleFetch(await head(subscribe.requestId, ["h0", "h1"], GROUP, DRAFT_22)); - await tail; + test(`${versionName(version)}: a fill and its tail stitch into one group`, async () => { + const { subscriber, consumer, subscribe, answer } = await subscribed(version); + await answer({ groupId: BigInt(GROUP), objectId: 1n }); - const group = await consumer.ordered().nextGroup(); - expect(group?.sequence).toBe(GROUP); - expect(await frames(group)).toEqual(["h0", "h1", "t2"]); -}); + const tail = subscriber.handleGroup(...liveStream(GROUP, 2, ["t2"], version)); + // The fill names the SUBSCRIBE's own request. + await subscriber.handleFetch(await head(subscribe.requestId, ["h0", "h1"], GROUP, version)); + await tail; -test("a draft-22 fill that overtakes SUBSCRIBE_OK waits for it", async () => { - const { subscriber, consumer, subscribe, answer } = await subscribed(DRAFT_22); - const fill = subscriber.handleFetch(await head(subscribe.requestId, ["h0", "h1"], GROUP, DRAFT_22)); - await answer({ groupId: BigInt(GROUP), objectId: 1n }); - await fill; - await subscriber.handleGroup(...liveStream(GROUP, 2, ["t2"], DRAFT_22)); + const group = await consumer.ordered().nextGroup(); + expect(group?.sequence).toBe(GROUP); + expect(await frames(group)).toEqual(["h0", "h1", "t2"]); + }); - const group = await consumer.ordered().nextGroup(); - expect(await frames(group)).toEqual(["h0", "h1", "t2"]); -}); + test(`${versionName(version)}: a fill that overtakes SUBSCRIBE_OK waits for it`, async () => { + const { subscriber, consumer, subscribe, answer } = await subscribed(version); + const fill = subscriber.handleFetch(await head(subscribe.requestId, ["h0", "h1"], GROUP, version)); + await answer({ groupId: BigInt(GROUP), objectId: 1n }); + await fill; + await subscriber.handleGroup(...liveStream(GROUP, 2, ["t2"], version)); -test("a draft-22 fill counts toward PUBLISH_DONE's Stream Count", async () => { - const { subscriber, consumer, subscribe, peer, answer } = await subscribed(DRAFT_22); - await answer({ groupId: 0n, objectId: 0n }); - const started = now(); - await subscriber.handleFetch(await head(subscribe.requestId, ["catalog"], 0, DRAFT_22)); - await peer.writer.u53(PublishDone.id); - const done = new PublishDone({ statusCode: PublishDoneStatus.TRACK_ENDED, streamCount: 1n, reasonPhrase: "done" }); - await done.encode(peer.writer, DRAFT_22); + const group = await consumer.ordered().nextGroup(); + expect(await frames(group)).toEqual(["h0", "h1", "t2"]); + }); - // The fill was the one stream counted, so the head is the whole group and the track ends. - const group = await consumer.recvGroup(); - expect(group?.sequence).toBe(0); - expect(await frames(group)).toEqual(["catalog"]); - expect(await consumer.recvGroup()).toBeUndefined(); - expect(await consumer.closed).toBeNull(); - expect(Nano.toMilli(Instant.durationSince(now(), started))).toBeLessThan(GRACE); -}); + test(`${versionName(version)}: a fill counts toward PUBLISH_DONE's Stream Count`, async () => { + const { subscriber, consumer, subscribe, peer, answer } = await subscribed(version); + await answer({ groupId: 0n, objectId: 0n }); + const started = now(); + await subscriber.handleFetch(await head(subscribe.requestId, ["catalog"], 0, version)); + await peer.writer.u53(PublishDone.id); + const done = new PublishDone({ + statusCode: PublishDoneStatus.TRACK_ENDED, + streamCount: 1n, + reasonPhrase: "done", + }); + await done.encode(peer.writer, version); + + // The fill was the one stream counted, so the head is the whole group and the track ends. + const group = await consumer.recvGroup(); + expect(group?.sequence).toBe(0); + expect(await frames(group)).toEqual(["catalog"]); + expect(await consumer.recvGroup()).toBeUndefined(); + expect(await consumer.closed).toBeNull(); + expect(Nano.toMilli(Instant.durationSince(now(), started))).toBeLessThan(GRACE); + }); +} test("a clean end finishes a head nothing claimed", () => { const t = new Tail(); @@ -559,7 +574,7 @@ test("fetch objects decode as FetchFrame wrote them", async () => { // serves a fill split from the subscription at Largest, as a strict relay does. async function publishing(version: IetfVersion) { const origin = new OriginProducer(); - const pair = createMockTransportPair(version === VERSION ? ALPN.DRAFT_18 : ALPN.DRAFT_22); + const pair = createMockTransportPair(ALPNS[version]); const url = new URL("https://localhost:4443/test"); const [client, server] = await Promise.all([ Connection.connect({ url, transport: pair.client }), @@ -586,9 +601,8 @@ async function publishing(version: IetfVersion) { return { remote: wireOf(client).consume(Path.from("room")), group, sent }; } -for (const version of [VERSION, DRAFT_22]) { - const draft = version === VERSION ? "draft 18" : "draft 22"; - test(`${draft}: a late JS subscriber gets a JS publisher's current groups from object 0`, async () => { +for (const version of VERSIONS) { + test(`${versionName(version)}: a late JS subscriber gets a JS publisher's current groups from object 0`, async () => { const { remote, group, sent } = await publishing(version); const catalogGroup = await remote.track("catalog.json").subscribe().recvGroup(); expect(catalogGroup?.sequence).toBe(0); @@ -599,7 +613,7 @@ for (const version of [VERSION, DRAFT_22]) { group.writeFrame(textFrame("v2")); group.finish(); expect(await frames(videoGroup)).toEqual(["v0", "v1", "v2"]); - // A joining FETCH per track on draft 18; draft 22 asks for the head with a fill. + // Draft 18 sends a joining FETCH per track; drafts 20 and 22 request a fill. expect(sent.mock.calls).toHaveLength(version === VERSION ? 2 : 0); }); } diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 90d848a32c7..d4d343eaf55 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -7,7 +7,7 @@ import { SessionCode, SessionError } from "../error.ts"; import { createMockTransportPair, type MockTransport, textFrame } from "../mock.ts"; import { Producer } from "../origin.ts"; import * as Path from "../path.ts"; -import { Stream, Writer } from "../stream.ts"; +import { acceptBudget, Stream, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; import { wireOf } from "../wire.ts"; import { Connection, probeLevel } from "./connection.ts"; @@ -214,14 +214,15 @@ function holdFins(transport: MockTransport, streams: "uni" | "incoming-bidi") { }; } -// Hand `transport` each incoming unidirectional stream only once its FIN arrived, buffered outside -// the transport so a session close cannot discard it. QMux keeps a finished stream's unread bytes -// across a close this way, so the reads still owed land after the close itself. +// Buffer incoming unidirectional streams outside the transport, independently of when the +// connection accepts them: its accept budget may yield until after the session closes. QMux +// keeps a finished stream's unread bytes across a close, so the reads still owed land afterwards. function deliverWhole(transport: MockTransport) { + const incoming = transport.incomingUnidirectionalStreams; Object.defineProperty(transport, "incomingUnidirectionalStreams", { - value: transport.incomingUnidirectionalStreams.pipeThrough( - new TransformStream, ReadableStream>({ - transform(stream, controller) { + value: new ReadableStream>({ + async start(controller) { + for await (const stream of incoming) { controller.enqueue( new ReadableStream({ async start(whole) { @@ -231,9 +232,10 @@ function deliverWhole(transport: MockTransport) { }, }), ); - }, - }), - ), + } + controller.close(); + }, + }), }); } @@ -357,6 +359,8 @@ for (const [alpn, version] of [ // SUBSCRIBE FIN it sent first; the closing session reads them. for (const overtaken of hasTrackStream(version) ? ["group"] : ["group", "fin"]) { test(`a peer close drains the overtaken ${overtaken} over ${versionName(version)}`, async () => { + // Exercise a close that overtakes acceptance, regardless of the host CPU budget. + const yielding = spyOn(acceptBudget, "poll").mockImplementation(() => yieldNow()); const pair = createMockTransportPair(alpn); deliverWhole(pair.client); if (overtaken === "fin") finAfterClose(pair.client); @@ -395,6 +399,7 @@ for (const [alpn, version] of [ expect(await group?.readString()).toBeUndefined(); expect(await track.nextGroup()).toBeUndefined(); } finally { + yielding.mockRestore(); track.close(); remote.close(); client.abort(); diff --git a/quest/m1/README.md b/quest/m1/README.md index a9ed35f1c1f..96017b5e0bd 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -181,7 +181,6 @@ per-frame CPU growth since #5926. - [Unbounded groups](/quest/m1/unbounded-groups/README.md) - groups and frames lose their size caps; a group keeps a 32 MB tail, and readers behind it replay through paced flights or refresh - [A follower is told when the serving epochless route leaves](/quest/m1/epochless-leave-restart.md) - wire-level tests pin the restart a follower gets when the serving epochless route is withdrawn or dies while another remains - [Legacy lite close drain](/quest/m1/lite-legacy-close-drain.md) - a lite-01..06 graceful close never cuts a final group the subscriber has not read -- [JS join tests cover draft 20](/quest/m1/join-tests-draft20.md) - join.test.ts runs on every supported draft, not just 18 and 22 - [Poll-first async](/quest/m1/poll-first/README.md) - library crates compile no coroutines by default: async wrappers are generated from `poll_*` and gated behind an `async` feature - [Authored tracks default to a 30 s max-age](/quest/m1/max-age-defaults.md) - raw and media tracks advertise 30 s unless their author chooses otherwise, with explicit unlimited in every binding - [JS ordered reads wait only on owed groups](/quest/m1/js-ordered-owed.md) - @moq/net matches Rust's owed-group wait diff --git a/quest/m1/join-tests-draft20.md b/quest/m1/join-tests-draft20.md deleted file mode 100644 index e5ae1a7f324..00000000000 --- a/quest/m1/join-tests-draft20.md +++ /dev/null @@ -1,19 +0,0 @@ -# [XS] JS join tests cover draft 20 - -## Goal - -`js/net/src/ietf/join.test.ts` runs its join cases on every moq-transport -draft @moq/net supports, including draft 20, instead of hard-coding drafts 18 -and 22. - -## Plan - -[moq-dev/moq#7190](https://github.com/moq-dev/moq/pull/7190) restored draft 20 -and added it to `fetch.test.ts`, but `join.test.ts` still names drafts 18 and -22. Iterate its cases over the supported versions, as `fetch.test.ts` does, -with each draft's own expectation: draft 18 joins with a relative joining -FETCH and draft 22 with a fill, so confirm which path draft 20 takes first. - -Decided 2026-10-30 (maintainer, /quest-plan of the 2026-10-30 /quest-complete follow-ups): ✅ new m1 XS quest / drop. - -Public API: none. Wire: none. From 8e53bf32185fec578e460ff6ccbe41a80a039a6a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 15:16:05 +0000 Subject: [PATCH 83/86] ci: unify dependency features with a cargo-hakari workspace-hack (#6985) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT-6.1 Sol --- .config/hakari.toml | 28 ++ .github/workflows/android.yml | 9 + .github/workflows/features.yml | 5 + .taplo.toml | 12 +- Cargo.lock | 241 +++++++++++++ demo/carla/bridge/Cargo.toml | 3 + flake.nix | 2 + quest/m0/ci/README.md | 2 +- quest/m0/ci/hakari.md | 48 +-- rs/bench-harness/Cargo.toml | 3 + rs/hang/Cargo.toml | 1 + rs/justfile | 16 +- rs/kio/Cargo.toml | 1 + rs/libmoq/Cargo.toml | 3 + rs/moq-archive/Cargo.toml | 1 + rs/moq-audio/Cargo.toml | 1 + rs/moq-auth/Cargo.toml | 1 + rs/moq-bench/Cargo.toml | 1 + rs/moq-boy/Cargo.toml | 1 + rs/moq-c/Cargo.toml | 1 + rs/moq-cli/Cargo.toml | 1 + rs/moq-congestion/Cargo.toml | 1 + rs/moq-e2ee/Cargo.toml | 1 + rs/moq-ffi/Cargo.toml | 1 + rs/moq-gst/Cargo.toml | 1 + rs/moq-hls/Cargo.toml | 1 + rs/moq-ice/Cargo.toml | 1 + rs/moq-json/Cargo.toml | 1 + rs/moq-loc/Cargo.toml | 3 + rs/moq-msf/Cargo.toml | 3 + rs/moq-mux/Cargo.toml | 1 + rs/moq-native/Cargo.toml | 3 + rs/moq-net-tests/Cargo.toml | 1 + rs/moq-net/Cargo.toml | 1 + rs/moq-nvenc/Cargo.toml | 3 + rs/moq-pattern/Cargo.toml | 1 + rs/moq-quic/Cargo.toml | 1 + rs/moq-relay/Cargo.toml | 1 + rs/moq-room/Cargo.toml | 1 + rs/moq-rtc/Cargo.toml | 1 + rs/moq-rtmp/Cargo.toml | 1 + rs/moq-rtsp/Cargo.toml | 1 + rs/moq-scene/Cargo.toml | 1 + rs/moq-shaper/Cargo.toml | 1 + rs/moq-sign/Cargo.toml | 1 + rs/moq-sock/Cargo.toml | 1 + rs/moq-srt/Cargo.toml | 1 + rs/moq-stats/Cargo.toml | 1 + rs/moq-studio/Cargo.toml | 1 + rs/moq-tokio/Cargo.toml | 1 + rs/moq-transcode/Cargo.toml | 1 + rs/moq-uring/Cargo.toml | 1 + rs/moq-v4l/Cargo.toml | 3 + rs/moq-video/Cargo.toml | 1 + rs/qmux/Cargo.toml | 1 + rs/test-env/Cargo.toml | 3 + rs/uniffi-bindgen/Cargo.toml | 3 + rs/workspace-hack/.gitattributes | 4 + rs/workspace-hack/Cargo.toml | 578 +++++++++++++++++++++++++++++++ rs/workspace-hack/build.rs | 2 + rs/workspace-hack/src/lib.rs | 1 + sh/rs/hakari.py | 57 +++ 62 files changed, 1037 insertions(+), 36 deletions(-) create mode 100644 .config/hakari.toml create mode 100644 rs/workspace-hack/.gitattributes create mode 100644 rs/workspace-hack/Cargo.toml create mode 100644 rs/workspace-hack/build.rs create mode 100644 rs/workspace-hack/src/lib.rs create mode 100755 sh/rs/hakari.py diff --git a/.config/hakari.toml b/.config/hakari.toml new file mode 100644 index 00000000000..1cf0c2ee789 --- /dev/null +++ b/.config/hakari.toml @@ -0,0 +1,28 @@ +# This file contains settings for `cargo hakari`. +# See https://docs.rs/cargo-hakari/latest/cargo_hakari/config for a full list of options. + +hakari-package = "workspace-hack" + +# Format version for hakari's output. Version 4 requires cargo-hakari 0.9.22 or above. +dep-format-version = "4" + +# Setting workspace.resolver = "2" or higher in the root Cargo.toml is HIGHLY recommended. +# Hakari works much better with the v2 resolver. (The v2 and v3 resolvers are identical from +# hakari's perspective, so you're welcome to set either.) +# +# For more about the new feature resolver, see: +# https://blog.rust-lang.org/2021/03/25/Rust-1.51.0.html#cargos-new-feature-resolver +resolver = "2" + +# Add triples corresponding to platforms commonly used by developers here. +# https://doc.rust-lang.org/rustc/platform-support.html +platforms = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu"] + +# Write out exact versions rather than a semver range. (Defaults to false.) +# exact-versions = true + +[traversal-excludes] +workspace-members = ["moq-net-fuzz", "moq-quic-fuzz"] + +[final-excludes] +workspace-members = ["kio-macros"] diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0851b000bfc..79877d84eaa 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -31,6 +31,7 @@ concurrency: env: CARGO_NDK_VERSION: 4.1.2 + CARGO_HAKARI_VERSION: 0.9.39 jobs: android: @@ -58,5 +59,13 @@ jobs: - name: Install cargo-ndk run: cargo install --locked "cargo-ndk@$CARGO_NDK_VERSION" + # The host dev-dependencies in the workspace-hack need the Nix shell's + # native libraries, which this cross-build deliberately runs without. + - name: Disable the workspace-hack + run: | + cargo install --locked "cargo-hakari@$CARGO_HAKARI_VERSION" + cargo hakari disable + cargo update --workspace + - name: Check run: just rs android diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml index e99bf3a918b..b444e901f20 100644 --- a/.github/workflows/features.yml +++ b/.github/workflows/features.yml @@ -32,6 +32,11 @@ jobs: with: access-key-id: ${{ secrets.MBX_R2_ACCESS_KEY_ID }} secret-access-key: ${{ secrets.MBX_R2_SECRET_ACCESS_KEY }} + # The workspace-hack unifies third-party features so the gate's builds + # share a cache, which would hide a crate using a feature it does not + # declare. Every build here checks declarations, so it runs without it. + - name: Disable the workspace-hack + run: nix develop --command bash -c 'cargo hakari disable && cargo update --workspace' - name: Check feature and profile variants run: nix develop --command just ${{ matrix.recipe }} env: diff --git a/.taplo.toml b/.taplo.toml index a1834cfa308..fa00ce09a5c 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -1,5 +1,15 @@ # Skip vendored, build, and scratch trees (c/moq/build links into target/) so `taplo format` doesn't recurse into them. -exclude = ["**/node_modules/**", "**/target/**", "c/moq/build/**", "**/dist/**", "**/.venv/**", "**/.direnv/**", "**/.tmp/**"] +# `cargo hakari` owns the workspace-hack's formatting; `just rs lint` checks it. +exclude = [ + "**/node_modules/**", + "**/target/**", + "c/moq/build/**", + "**/dist/**", + "**/.venv/**", + "**/.direnv/**", + "**/.tmp/**", + "rs/workspace-hack/Cargo.toml", +] # Match the existing repo style (4-space indent on Cargo.toml, lines up # to ~120 chars) so the initial format pass stays minimal. diff --git a/Cargo.lock b/Cargo.lock index 9eba9b2373e..cd340c67688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,6 +855,7 @@ version = "0.0.1" dependencies = [ "codspeed-criterion-compat", "criterion", + "workspace-hack", ] [[package]] @@ -3677,6 +3678,7 @@ dependencies = [ "tokio", "tracing", "url", + "workspace-hack", ] [[package]] @@ -4800,6 +4802,7 @@ dependencies = [ "smallvec", "test-env", "tokio", + "workspace-hack", ] [[package]] @@ -5039,6 +5042,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" version = "0.6.12" +dependencies = [ + "workspace-hack", +] [[package]] name = "libredox" @@ -5345,6 +5351,7 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tracing", + "workspace-hack", ] [[package]] @@ -5378,6 +5385,7 @@ dependencies = [ "tokio", "tracing", "unsafe-libopus", + "workspace-hack", ] [[package]] @@ -5413,6 +5421,7 @@ dependencies = [ "tracing-test", "url", "wiremock", + "workspace-hack", ] [[package]] @@ -5456,6 +5465,7 @@ dependencies = [ "toml 1.1.6+spec-1.1.0", "tracing", "usage-rs", + "workspace-hack", ] [[package]] @@ -5477,6 +5487,7 @@ dependencies = [ "tokio", "tracing", "usage-rs", + "workspace-hack", ] [[package]] @@ -5502,6 +5513,7 @@ dependencies = [ "tokio", "tracing", "url", + "workspace-hack", ] [[package]] @@ -5517,6 +5529,7 @@ dependencies = [ "parking_lot", "pollster", "tracing", + "workspace-hack", ] [[package]] @@ -5570,6 +5583,7 @@ dependencies = [ "usage-rs", "vt100", "winit", + "workspace-hack", ] [[package]] @@ -5582,6 +5596,7 @@ dependencies = [ "rand_pcg", "stats_alloc", "tracing", + "workspace-hack", ] [[package]] @@ -5598,6 +5613,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tokio", + "workspace-hack", "zeroize", ] @@ -5623,6 +5639,7 @@ dependencies = [ "tracing", "uniffi 0.32.2 (registry+https://github.com/rust-lang/crates.io-index)", "url", + "workspace-hack", ] [[package]] @@ -5647,6 +5664,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "workspace-hack", ] [[package]] @@ -5679,6 +5697,7 @@ dependencies = [ "tracing", "tracing-test", "url", + "workspace-hack", ] [[package]] @@ -5691,6 +5710,7 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tracing", + "workspace-hack", ] [[package]] @@ -5709,6 +5729,7 @@ dependencies = [ "serde_path_to_error", "thiserror 2.0.21", "tracing", + "workspace-hack", ] [[package]] @@ -5718,6 +5739,7 @@ dependencies = [ "bytes", "moq-net", "thiserror 2.0.21", + "workspace-hack", ] [[package]] @@ -5727,6 +5749,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "workspace-hack", ] [[package]] @@ -5767,11 +5790,15 @@ dependencies = [ "tracing-test", "url", "webm-iterable", + "workspace-hack", ] [[package]] name = "moq-native" version = "0.20.0" +dependencies = [ + "workspace-hack", +] [[package]] name = "moq-net" @@ -5801,6 +5828,7 @@ dependencies = [ "tracing-test", "url", "uuid", + "workspace-hack", ] [[package]] @@ -5821,6 +5849,7 @@ dependencies = [ "moq-net", "serde_json", "tokio", + "workspace-hack", ] [[package]] @@ -5830,6 +5859,7 @@ dependencies = [ "cudarc", "libloading 0.9.0", "thiserror 2.0.21", + "workspace-hack", ] [[package]] @@ -5840,6 +5870,7 @@ dependencies = [ "moq-pattern 0.1.2", "serde", "serde_json", + "workspace-hack", ] [[package]] @@ -5881,6 +5912,7 @@ dependencies = [ "tinyvec", "tracing", "tracing-subscriber", + "workspace-hack", ] [[package]] @@ -5950,6 +5982,7 @@ dependencies = [ "url", "usage-rs", "web-transport-trait", + "workspace-hack", "x509-parser", ] @@ -5964,6 +5997,7 @@ dependencies = [ "moq-tokio", "thiserror 2.0.21", "tokio", + "workspace-hack", ] [[package]] @@ -5987,6 +6021,7 @@ dependencies = [ "tracing-test", "url", "uuid", + "workspace-hack", ] [[package]] @@ -6013,6 +6048,7 @@ dependencies = [ "tokio-rustls", "tracing", "tracing-test", + "workspace-hack", ] [[package]] @@ -6033,6 +6069,7 @@ dependencies = [ "tracing", "tracing-test", "url", + "workspace-hack", ] [[package]] @@ -6055,6 +6092,7 @@ dependencies = [ "tokio", "toml 1.1.6+spec-1.1.0", "tracing", + "workspace-hack", ] [[package]] @@ -6071,6 +6109,7 @@ dependencies = [ "serde_json", "tokio", "toml 1.1.6+spec-1.1.0", + "workspace-hack", ] [[package]] @@ -6088,6 +6127,7 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "tokio", + "workspace-hack", "zeroize", ] @@ -6107,6 +6147,7 @@ dependencies = [ "tracing", "tracing-test", "windows-sys 0.61.2", + "workspace-hack", ] [[package]] @@ -6125,6 +6166,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "workspace-hack", ] [[package]] @@ -6142,6 +6184,7 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tracing", + "workspace-hack", ] [[package]] @@ -6167,6 +6210,7 @@ dependencies = [ "tracing", "url", "usage-rs", + "workspace-hack", ] [[package]] @@ -6251,6 +6295,7 @@ dependencies = [ "web-transport-proto", "web-transport-trait", "webpki-roots 1.0.10", + "workspace-hack", "x509-parser", ] @@ -6271,6 +6316,7 @@ dependencies = [ "tracing", "url", "usage-rs", + "workspace-hack", ] [[package]] @@ -6305,6 +6351,7 @@ dependencies = [ "url", "web-transport-proto", "web-transport-trait", + "workspace-hack", ] [[package]] @@ -6313,6 +6360,7 @@ version = "0.1.0" dependencies = [ "bitflags 2.13.2", "libc", + "workspace-hack", ] [[package]] @@ -6379,6 +6427,7 @@ dependencies = [ "tracing-test", "wgpu", "windows", + "workspace-hack", "x11rb 0.14.0", "yuv", "zune-jpeg", @@ -8566,6 +8615,7 @@ dependencies = [ "tokio-util", "tracing", "web-transport-trait", + "workspace-hack", ] [[package]] @@ -10818,6 +10868,9 @@ dependencies = [ [[package]] name = "test-env" version = "0.0.1" +dependencies = [ + "workspace-hack", +] [[package]] name = "textwrap" @@ -11554,6 +11607,7 @@ name = "uniffi-bindgen" version = "0.1.0" dependencies = [ "uniffi 0.32.2 (git+https://github.com/kixelated/uniffi-rs?rev=88d835d)", + "workspace-hack", ] [[package]] @@ -13029,6 +13083,193 @@ dependencies = [ "primefield", ] +[[package]] +name = "workspace-hack" +version = "0.1.0" +dependencies = [ + "anyhow", + "arrayvec", + "aws-lc-rs", + "aws-lc-sys", + "base16ct 1.0.0", + "base64 0.23.1", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "byteorder", + "bytes", + "camino", + "cc", + "chrono", + "clang-sys", + "clap", + "clap_builder", + "const-oid 0.9.6", + "cpal", + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "crypto-common 0.1.7", + "crypto-common 0.2.2", + "ctutils", + "cudarc", + "data-encoding", + "der 0.7.10", + "der 0.8.2", + "derive_more 2.1.1", + "derive_more-impl", + "digest 0.10.7", + "digest 0.11.3", + "dispatch2", + "displaydoc", + "either", + "errno", + "event-listener", + "event-listener-strategy", + "fastrand", + "flate2", + "flume", + "foldhash", + "form_urlencoded", + "fs-err", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-lite", + "futures-sink", + "futures-task", + "futures-util", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", + "half", + "hashbrown 0.17.1", + "hex", + "hmac 0.12.1", + "hybrid-array", + "hyper", + "hyper-rustls", + "hyper-util", + "image", + "indexmap 2.14.2", + "itertools 0.13.0", + "lazy_static", + "libc", + "libspa", + "linux-raw-sys 0.12.1", + "log", + "md-5 0.10.6", + "md-5 0.11.0", + "memchr", + "miniz_oxide 0.8.9", + "mio", + "nix", + "nom 7.1.3", + "nom 8.0.0", + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-traits", + "objc2 0.6.5", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "object_store", + "once_cell", + "percent-encoding", + "pipewire", + "pkcs1", + "pkcs8 0.10.2", + "pkcs8 0.11.0", + "portable-atomic", + "postcard", + "ppv-lite86", + "proc-macro2", + "proptest", + "quick-xml 0.41.0", + "quick-xml 0.42.0", + "quote", + "rand 0.8.8", + "rand 0.9.5", + "rand_chacha 0.3.1", + "rcgen 0.14.10", + "regex", + "regex-automata", + "regex-syntax", + "reqwest", + "rustc-hash 1.1.0", + "rustix 1.1.5", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "security-framework-sys", + "semver", + "serde", + "serde_core", + "serde_json", + "serde_spanned 1.1.1", + "serde_with", + "sha2 0.10.9", + "sha2 0.11.1", + "signature 2.2.0", + "signature 3.0.0", + "simd-adler32", + "simdutf8", + "siphasher", + "slab", + "smallvec", + "socket2 0.6.5", + "spin 0.10.1", + "spin 0.9.9", + "spki 0.7.3", + "spki 0.8.1", + "subtle", + "syn 2.0.119", + "syn 3.0.6", + "time", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tokio-util", + "toml 1.1.6+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "tower", + "tower-http 0.6.11", + "tracing", + "tracing-core", + "tracing-subscriber", + "tungstenite", + "typenum", + "url", + "usage-argv", + "usage-rs", + "uuid", + "wgpu", + "wgpu-core", + "wgpu-hal", + "wgpu-types", + "winapi", + "windows", + "windows-core", + "windows-result", + "windows-sys 0.52.0", + "windows-sys 0.61.2", + "winit", + "winnow 0.7.15", + "winnow 1.0.4", + "x509-parser", + "zerocopy", + "zeroize", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/demo/carla/bridge/Cargo.toml b/demo/carla/bridge/Cargo.toml index 8809275a994..bd2c67d2f16 100644 --- a/demo/carla/bridge/Cargo.toml +++ b/demo/carla/bridge/Cargo.toml @@ -31,6 +31,9 @@ parking_lot = "0.12" pollster = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +workspace-hack = { path = "../../../rs/workspace-hack" } + [target.'cfg(target_os = "linux")'.dev-dependencies] # The stand-in producer: a native Vulkan renderer that hands the bridge # exportable slots exactly the way the CARLA plugin does. diff --git a/flake.nix b/flake.nix index 6666e66fb0d..a80d0d6c8b5 100644 --- a/flake.nix +++ b/flake.nix @@ -158,6 +158,8 @@ cargo-semver-checks cargo-deny cargo-bloat + # The workspace-hack that gives every build one feature set per dependency. + cargo-hakari # Weekly mutation testing of the security-critical modules (`just rs mutants`). cargo-mutants cargo-nextest diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index 5ae13f591b6..9a5829c6f15 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -161,7 +161,7 @@ The historical samples locate candidates; they are not controlled benchmarks. - [Measure the parallel frontend](/quest/m0/ci/nightly-levers.md) - a pinned nightly's parallel frontend, adopted only if the numbers beat its risks; Cranelift is ruled out - [Verify the R2 Rust cache](/quest/m0/ci/mbx-r2-verify.md) - confirm the hosted Nightly jobs reuse the previous Nightly, then measure restore time and expiry - [Verify the Nix cache](/quest/m0/ci/nix-cache-verify.md) - confirm releases use `nix.moq.dev`, check its bucket sizes, and retire Cachix -- [Unify dependency features](/quest/m0/ci/hakari.md) - a cargo-hakari workspace-hack so every job compiles each dependency alike and mbx hits across jobs +- [Unify dependency features](/quest/m0/ci/hakari.md) - align the FFI builds' features and record the hosted platform jobs' hits once the workspace-hack lands - [Release credentials off main](/quest/m0/ci/publish-release-only.md) - `publish` admits only `release` and release tags once its `workflow_run` publishers run on their tag - [The queue host keeps the queue's disk](/quest/m0/ci/queue-host-disk.md) - agent sessions can't starve the queue runner of disk, and a crashed runner restarts - [Biome checks every file it can parse](/quest/m0/ci/biome-whole-repo.md) - Biome runs over the whole repository and main is clean under it diff --git a/quest/m0/ci/hakari.md b/quest/m0/ci/hakari.md index 461cb4e56ad..377f473477f 100644 --- a/quest/m0/ci/hakari.md +++ b/quest/m0/ci/hakari.md @@ -1,12 +1,10 @@ -# [M] Unify dependency features with cargo-hakari +# [S] Unify dependency features with cargo-hakari ## Goal -Every CI job, the queue gate, Nightly's platform jobs, and the FFI builds -compile each dependency with the same features, so mbx's cache hits across -them. A cargo-hakari workspace-hack crate pins the union of the dependency -features, and the gate fails when it is out of date. Record mbx's hit rate across -jobs, and their CPU time, before and after. +The FFI builds compile each dependency with the same features as each other, +so mbx's cache hits across them, and Nightly's hosted platform jobs record +their hit rate and CPU time before and after the workspace-hack. ## Plan @@ -15,31 +13,19 @@ from guppy's authors, rather than only trimming CI's feature permutations. mbx keys every compilation on its inputs, so a dependency built with a different feature set in another job is a miss, however warm the cache. -- Generate the workspace-hack crate with `cargo hakari generate`, and add a - `cargo hakari generate --diff` and `cargo hakari manage-deps --dry-run` check to - `just check`. -- moq publishes crates with release-plz, and a published crate cannot depend on - an unpublished workspace-hack. Use hakari's supported setup for published - workspaces, and prove a release-plz dry run still publishes. -- Keep the feature-contract checks, which `just rs features` runs nightly and - on release PRs, testing each crate's own declarations: the - `quic-features` recipe in `rs/justfile` (moq-quic has no native runtime), - `sh/rs/media-features.sh` (minimal builds exclude codecs and rendering), and - `sh/rs/net-features.sh` and `sh/rs/tokio-features.sh`. A workspace-hack would - inject the union into them and hide a missing declaration. Exclude the crates - they guard from the hack, or run those checks with it disabled - (`cargo hakari disable`), and show each still fails on a planted violation. -- Unifying features means some packages build features they don't use. Measure - whether that costs more CPU than the cache hits save; target, profile, and - `RUSTFLAGS` differences still split keys. -- Measure the baseline here, before generating the hack: across jobs, which - compilations one job misses although another job built the same crate at - the same version, grouped by what differs (features, profile, `RUSTFLAGS`, - target). Repeat it after, from the same commit. +[moq-dev/moq#6985](https://github.com/moq-dev/moq/pull/6985) added +`rs/workspace-hack` as a path-only dev-dependency of every member outside +`.config/hakari.toml`'s excludes. Test, lint, and bench builds now unify; +library-only builds do not, since dev-dependencies stay out of them. That +leaves the FFI builds (`cargo build --release -p moq-ffi` in the bindings and +release workflows), which also split on their own flags: the Dart build passes +the host triple as `--target` and adds the `testing` feature. Align those +invocations rather than add the hack to them, unless the PR's decision on the +layout says otherwise. -Decided 2026-10-27 (maintainer): this quest no longer waits on -[Verify the R2 Rust cache](/quest/m0/ci/mbx-r2-verify.md). It measures its own -before and after baseline, so the cross-job miss report moved here from that -quest, which stays a waiting quest for the R2 numbers alone. +Nightly's macOS and Windows jobs read and write R2, so their before and after +can only come from scheduled Nightlies on `main` +(`gh run list --workflow nightly.yml --branch main --event schedule`): compare +the mbx summaries of the last run before the merge with the first two after. Public API: none; the workspace-hack crate is not published. diff --git a/rs/bench-harness/Cargo.toml b/rs/bench-harness/Cargo.toml index ebdc164515e..4943205802a 100644 --- a/rs/bench-harness/Cargo.toml +++ b/rs/bench-harness/Cargo.toml @@ -17,5 +17,8 @@ codspeed-criterion-compat = "6" [target.'cfg(not(codspeed))'.dependencies] criterion = "0.8" +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ["cfg(codspeed)"] } diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 3370a7768dc..865232fe5a3 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -36,6 +36,7 @@ moq-tokio = { path = "../moq-tokio", default-features = false, features = ["quic tokio = { workspace = true, features = ["macros", "fs"] } tracing = { workspace = true } url = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/justfile b/rs/justfile index eb5b90ec00d..0c77bde020a 100644 --- a/rs/justfile +++ b/rs/justfile @@ -51,7 +51,10 @@ lint *args: cargo fmt --all --check RUSTDOCFLAGS="-D warnings" bash sh/local.sh cargo doc --locked --no-deps "$@" cargo shear - cargo sort --workspace --check --no-format + # The workspace-hack's table order is `cargo hakari`'s, which `cargo sort` disagrees with. + cargo sort --check --no-format . $(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name != "workspace-hack") | .manifest_path | rtrimstr("/Cargo.toml")') + cargo hakari generate --diff + python3 sh/rs/hakari.py # Auto-fix clippy/format/shear/sort. [positional-arguments] @@ -59,7 +62,8 @@ fix *args: bash sh/local.sh cargo clippy --locked --fix --allow-staged --allow-dirty --all-targets "$@" cargo fmt --all cargo shear --fix - cargo sort --workspace --no-format + cargo hakari generate + cargo sort --no-format . $(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name != "workspace-hack") | .manifest_path | rtrimstr("/Cargo.toml")') # Like `check`, but only the crates in a changed-file list plus their dependents. check-changed $LIST: @@ -186,7 +190,9 @@ x11-bench-ci: } 2>&1 | tee "$log" # moq-video's MediaCodec code is `#[cfg(target_os = "android")]`. Needs an NDK, -# which the dev shell lacks, so android.yml runs it outside Nix. `check` rather +# which the dev shell lacks, so android.yml runs it outside Nix with the +# workspace-hack disabled (`cargo hakari disable && cargo update --workspace`). +# Do the same locally, then restore Cargo.lock and rs/workspace-hack. `check` rather # than a denying clippy: the capture plumbing is dead on a target without a # capture backend. @@ -212,6 +218,10 @@ relay-minimal: c-tests: sh/rs/c-tests.sh +# The workspace-hack would supply third-party features a crate forgets to +# declare, so CI runs this after `cargo hakari disable && cargo update +# --workspace`; do the same locally, then `git checkout Cargo.lock rs/workspace-hack`. + # Compile the full feature matrix, including isolated combinations and their tests. features: just rs net-features --all diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index 4e6291cebf9..1f829d30868 100644 --- a/rs/kio/Cargo.toml +++ b/rs/kio/Cargo.toml @@ -63,6 +63,7 @@ kio = { path = ".", features = ["async", "manual", "sim", "serde"] } serde_json = { workspace = true } test-env = { path = "../test-env" } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "test-util", "time"] } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "tasks" diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 79dce540ac6..81eb3d6825d 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -12,5 +12,8 @@ rust-version.workspace = true keywords = ["quic", "media", "live"] categories = ["multimedia", "network-programming"] +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } + [lints] workspace = true diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index 06e2585c95c..7bf235b54a3 100644 --- a/rs/moq-archive/Cargo.toml +++ b/rs/moq-archive/Cargo.toml @@ -34,6 +34,7 @@ s3s = "0.17" s3s-fs = "0.17" tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "test-util", "time"] } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "producer" diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 89e4140570c..24aac34cf6d 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -139,6 +139,7 @@ criterion = { package = "bench-harness", path = "../bench-harness" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "test-util"] } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "bus" diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 082ea4de2dd..41bc8ba6fe9 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -51,6 +51,7 @@ tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } tracing-test = { workspace = true } wiremock = "0.6" +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "verify" diff --git a/rs/moq-bench/Cargo.toml b/rs/moq-bench/Cargo.toml index c282274686f..dc5dccf8af7 100644 --- a/rs/moq-bench/Cargo.toml +++ b/rs/moq-bench/Cargo.toml @@ -68,6 +68,7 @@ tempfile = { workspace = true } test-env = { path = "../test-env" } # test-util enables tokio::time::pause/advance for the deterministic produce tests. tokio = { workspace = true, features = ["test-util"] } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 2e1df4f2fd1..5ac64f902dc 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -49,6 +49,7 @@ usage-rs = { workspace = true, features = ["completions", "config"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-c/Cargo.toml b/rs/moq-c/Cargo.toml index 0b116b6fd7b..20276f96d81 100644 --- a/rs/moq-c/Cargo.toml +++ b/rs/moq-c/Cargo.toml @@ -57,6 +57,7 @@ moq-video = { path = "../moq-video", features = ["openh264"] } # Runs the tests that need a variable set or unset in a child process given it. test-env = { path = "../test-env" } tokio = { workspace = true, features = ["test-util"] } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index daa31ee58fc..15dd40bd548 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -170,6 +170,7 @@ test-env = { path = "../test-env" } tokio = { workspace = true, features = ["test-util"] } # Plays the `announced` view's frames on a virtual terminal, scrolling included. vt100 = "0.17.0" +workspace-hack = { path = "../workspace-hack" } [target.'cfg(unix)'.dev-dependencies] # Sends the signals the `auth serve` shutdown test checks. diff --git a/rs/moq-congestion/Cargo.toml b/rs/moq-congestion/Cargo.toml index 594044b6531..9c7618a56e1 100644 --- a/rs/moq-congestion/Cargo.toml +++ b/rs/moq-congestion/Cargo.toml @@ -21,6 +21,7 @@ tracing = { workspace = true } cpu-time = "1.0.0" kio = { path = "../kio", features = ["runtime"] } stats_alloc = "0.1.10" +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index 0fafe3ebbda..84056b6188c 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -28,6 +28,7 @@ hex = { workspace = true } kio = { workspace = true, features = ["runtime"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "protect" diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 736b72e5754..612554c52a6 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/Cargo.toml @@ -76,6 +76,7 @@ pollster = { workspace = true } # Runs the key log tests with `SSLKEYLOGFILE` set or unset, in a child process. test-env = { path = "../test-env" } tokio = { workspace = true, features = ["test-util"] } +workspace-hack = { path = "../workspace-hack" } [target.'cfg(unix)'.dev-dependencies] # Process CPU time for the benchmark's baselines. diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 7200b4a28a2..b37a2eac1b9 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -43,6 +43,7 @@ moq-audio = { path = "../moq-audio", default-features = false } # Runs the tests that need a variable set or unset in a child process given it. test-env = { path = "../test-env" } tokio = { workspace = true, features = ["test-util"] } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "catalog" diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 2252e77246b..c62c3427dd4 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -48,6 +48,7 @@ moq-tokio = { path = "../moq-tokio", default-features = false, features = ["tcp" tokio = { workspace = true, features = ["test-util"] } tower = { version = "0.5", features = ["util"] } tracing-test = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-ice/Cargo.toml b/rs/moq-ice/Cargo.toml index aedb5b0218a..6b533666d2e 100644 --- a/rs/moq-ice/Cargo.toml +++ b/rs/moq-ice/Cargo.toml @@ -22,6 +22,7 @@ tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 37e226ec923..e62e4bc8cb4 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -27,6 +27,7 @@ criterion = { package = "bench-harness", path = "../bench-harness" } futures = { workspace = true } json-patch = "4" proptest = { workspace = true, features = ["handle-panics"] } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "codec" diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index 0507e77e3ea..3ee83e561b6 100644 --- a/rs/moq-loc/Cargo.toml +++ b/rs/moq-loc/Cargo.toml @@ -17,5 +17,8 @@ bytes = { workspace = true } moq-net = { workspace = true } thiserror = { workspace = true } +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } + [lints] workspace = true diff --git a/rs/moq-msf/Cargo.toml b/rs/moq-msf/Cargo.toml index 931c0563b6f..8da5ddeb3ed 100644 --- a/rs/moq-msf/Cargo.toml +++ b/rs/moq-msf/Cargo.toml @@ -17,5 +17,8 @@ serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } + [lints] workspace = true diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 4b24fcbd150..a150682f5a5 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -50,6 +50,7 @@ kio = { workspace = true, features = ["sim"] } proptest = { workspace = true } tokio = { workspace = true, features = ["test-util"] } tracing-test = { workspace = true } +workspace-hack = { path = "../workspace-hack" } # The ts_schedule bench pins glibc's heap. [target.'cfg(all(target_os = "linux", target_env = "gnu"))'.dev-dependencies] diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index c9c38e89c14..1b6bc24ea4a 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -27,5 +27,8 @@ uds = [] ring = [] android-logcat = [] +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } + [lints] workspace = true diff --git a/rs/moq-net-tests/Cargo.toml b/rs/moq-net-tests/Cargo.toml index 9450844734c..cbc6aa233cd 100644 --- a/rs/moq-net-tests/Cargo.toml +++ b/rs/moq-net-tests/Cargo.toml @@ -19,6 +19,7 @@ moq-json = { path = "../moq-json" } moq-net = { path = "../moq-net", features = ["fuzz"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt"] } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index a85d38b1860..43f72880535 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -58,6 +58,7 @@ serde_json = { workspace = true } # dependency feature set. tokio = { workspace = true, features = ["rt", "rt-multi-thread", "test-util", "time"] } tracing-test = { workspace = true } +workspace-hack = { path = "../workspace-hack" } # Thread and process CPU timing in the session benchmark. [target.'cfg(unix)'.dev-dependencies] diff --git a/rs/moq-nvenc/Cargo.toml b/rs/moq-nvenc/Cargo.toml index a608e0681a7..02853b48f65 100644 --- a/rs/moq-nvenc/Cargo.toml +++ b/rs/moq-nvenc/Cargo.toml @@ -21,5 +21,8 @@ cudarc = { workspace = true } libloading = { workspace = true } thiserror = { workspace = true } +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } + [lints] workspace = true diff --git a/rs/moq-pattern/Cargo.toml b/rs/moq-pattern/Cargo.toml index c52e4730bd7..2b735b702c2 100644 --- a/rs/moq-pattern/Cargo.toml +++ b/rs/moq-pattern/Cargo.toml @@ -24,6 +24,7 @@ criterion = { package = "bench-harness", path = "../bench-harness" } # Enable serde so the crate's own tests cover the optional impls in the merge gate. moq-pattern = { path = ".", features = ["serde"] } serde_json = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "matching" diff --git a/rs/moq-quic/Cargo.toml b/rs/moq-quic/Cargo.toml index 4e8ee273038..f4cbc5dbbaa 100644 --- a/rs/moq-quic/Cargo.toml +++ b/rs/moq-quic/Cargo.toml @@ -76,6 +76,7 @@ proptest = { workspace = true } rand_pcg = { workspace = true } rcgen = { workspace = true, features = ["pem", "ring"] } tracing-subscriber = { version = "0.3.0", default-features = false, features = ["env-filter", "fmt", "ansi", "time", "local-time"] } +workspace-hack = { path = "../workspace-hack" } # Writes the generated fuzzer corpus to disk. Needs the `fuzz` feature, so a default # `--all-targets` build skips it rather than failing to find the module. diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 60efb09166a..651572846e3 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -130,6 +130,7 @@ test-env = { path = "../test-env" } time = { workspace = true } tokio = { workspace = true, features = ["test-util"] } tracing-subscriber = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [target.'cfg(target_os = "linux")'.dev-dependencies] # Workers built inside a runtime with a manual clock installed run on that clock. diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index fed15448a0d..b94fa763709 100644 --- a/rs/moq-room/Cargo.toml +++ b/rs/moq-room/Cargo.toml @@ -23,6 +23,7 @@ thiserror = { workspace = true } [dev-dependencies] moq-tokio = { path = "../moq-tokio", default-features = false, features = ["net"] } tokio = { workspace = true, features = ["macros", "rt", "test-util"] } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index 9f73a89af70..110497ebab7 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -49,6 +49,7 @@ aws-lc-rs = { workspace = true } moq-tokio = { workspace = true, features = ["net", "tcp"] } tokio = { workspace = true, features = ["test-util"] } tracing-test = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index 95c02d891b7..3724f83a98b 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -62,6 +62,7 @@ kio = { path = "../kio", features = ["sim"] } moq-tokio = { path = "../moq-tokio", default-features = false, features = ["aws-lc-rs", "net"] } tokio = { workspace = true, features = ["test-util"] } tracing-test = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "chunk_interleaving" diff --git a/rs/moq-rtsp/Cargo.toml b/rs/moq-rtsp/Cargo.toml index df9e23430d9..261e118d89c 100644 --- a/rs/moq-rtsp/Cargo.toml +++ b/rs/moq-rtsp/Cargo.toml @@ -41,6 +41,7 @@ base64 = { workspace = true } kio = { path = "../kio", features = ["manual"] } tokio = { workspace = true, features = ["full", "test-util"] } tracing-test = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-scene/Cargo.toml b/rs/moq-scene/Cargo.toml index aceb936a597..3e7c7996682 100644 --- a/rs/moq-scene/Cargo.toml +++ b/rs/moq-scene/Cargo.toml @@ -50,6 +50,7 @@ tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "test-util"] } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-shaper/Cargo.toml b/rs/moq-shaper/Cargo.toml index 1cbd2e29855..59925805d01 100644 --- a/rs/moq-shaper/Cargo.toml +++ b/rs/moq-shaper/Cargo.toml @@ -34,6 +34,7 @@ toml = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-sign/Cargo.toml b/rs/moq-sign/Cargo.toml index 8b1bff66dbc..bcad6ad0373 100644 --- a/rs/moq-sign/Cargo.toml +++ b/rs/moq-sign/Cargo.toml @@ -28,6 +28,7 @@ hex = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "test-util"] } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "sign" diff --git a/rs/moq-sock/Cargo.toml b/rs/moq-sock/Cargo.toml index 7c01e824ef4..73d540478d1 100644 --- a/rs/moq-sock/Cargo.toml +++ b/rs/moq-sock/Cargo.toml @@ -34,6 +34,7 @@ cfg_aliases = "0.2" criterion = { package = "bench-harness", path = "../bench-harness" } rand = "0.10" tracing-test = "0.2" +workspace-hack = { path = "../workspace-hack" } [target.'cfg(target_vendor = "apple")'.dev-dependencies] nix = { workspace = true } diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index a8a6b7e0064..e9972ee83dd 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/Cargo.toml @@ -31,6 +31,7 @@ hang = { path = "../hang" } kio = { workspace = true, features = ["runtime"] } srt-protocol = "0.4" tracing-subscriber = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index 0bdd053bdfd..6b609c48d8e 100644 --- a/rs/moq-stats/Cargo.toml +++ b/rs/moq-stats/Cargo.toml @@ -33,6 +33,7 @@ tracing = { workspace = true } criterion = { package = "bench-harness", path = "../bench-harness" } futures = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } +workspace-hack = { path = "../workspace-hack" } [[bench]] name = "decode" diff --git a/rs/moq-studio/Cargo.toml b/rs/moq-studio/Cargo.toml index 60b37380595..f68afbc275b 100644 --- a/rs/moq-studio/Cargo.toml +++ b/rs/moq-studio/Cargo.toml @@ -60,6 +60,7 @@ usage-rs = { workspace = true, features = ["completions", "config"] } [dev-dependencies] moq-relay = { path = "../moq-relay", default-features = false, features = ["test-support"] } tempfile = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 194e2dfce93..1591a40fdbb 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -163,6 +163,7 @@ tokio = { workspace = true, features = ["test-util"] } tokio-rustls = { workspace = true } toml = "1.1" tracing-test = { workspace = true } +workspace-hack = { path = "../workspace-hack" } # The examples and integration tests run MoQ sessions, which a driver-only build lacks. [[example]] diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index 2b0bbfc726a..f0edc0e6e9f 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -54,6 +54,7 @@ moq-video = { path = "../moq-video", features = ["openh264"] } tokio = { workspace = true, features = ["full", "test-util"] } url = { workspace = true } usage-rs = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 5d500566e21..c915908af0e 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -57,6 +57,7 @@ moq-net = { path = "../moq-net" } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "net", "rt", "sync", "time"] } web-transport-trait = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [target.'cfg(target_os = "linux")'.dev-dependencies] io-uring = { workspace = true } diff --git a/rs/moq-v4l/Cargo.toml b/rs/moq-v4l/Cargo.toml index 5e8c23eb269..e48891634aa 100644 --- a/rs/moq-v4l/Cargo.toml +++ b/rs/moq-v4l/Cargo.toml @@ -16,5 +16,8 @@ categories = ["multimedia::video", "os::linux-apis"] bitflags = { workspace = true } libc = { workspace = true } +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } + [lints] workspace = true diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index aa603bc021d..768d2ac3bfc 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -286,6 +286,7 @@ pollster = { workspace = true } # `start_paused`, so the capture driver tests run on mocked time. tokio = { workspace = true, features = ["test-util"] } tracing-test = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [lints] workspace = true diff --git a/rs/qmux/Cargo.toml b/rs/qmux/Cargo.toml index adeb0f1b039..c407ca7cbdb 100644 --- a/rs/qmux/Cargo.toml +++ b/rs/qmux/Cargo.toml @@ -50,6 +50,7 @@ rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", tokio = { workspace = true, features = ["full", "test-util"] } # The 0.6 release's stream API, for driving it in the interop tests. web-transport-trait = { workspace = true } +workspace-hack = { path = "../workspace-hack" } [[example]] name = "interop-client" diff --git a/rs/test-env/Cargo.toml b/rs/test-env/Cargo.toml index 2365060b4c6..1765a9aba4a 100644 --- a/rs/test-env/Cargo.toml +++ b/rs/test-env/Cargo.toml @@ -10,3 +10,6 @@ edition = "2024" rust-version.workspace = true publish = false + +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } diff --git a/rs/uniffi-bindgen/Cargo.toml b/rs/uniffi-bindgen/Cargo.toml index 448ea829e81..f4c43377d56 100644 --- a/rs/uniffi-bindgen/Cargo.toml +++ b/rs/uniffi-bindgen/Cargo.toml @@ -23,5 +23,8 @@ cli = ["dep:uniffi"] # published package has no git dependencies. uniffi = { git = "https://github.com/kixelated/uniffi-rs", rev = "88d835d", features = ["cli"], optional = true } +[dev-dependencies] +workspace-hack = { path = "../workspace-hack" } + [lints] workspace = true diff --git a/rs/workspace-hack/.gitattributes b/rs/workspace-hack/.gitattributes new file mode 100644 index 00000000000..3e9dba4b64b --- /dev/null +++ b/rs/workspace-hack/.gitattributes @@ -0,0 +1,4 @@ +# Avoid putting conflict markers in the generated Cargo.toml file, since their presence breaks +# Cargo. +# Also do not check out the file as CRLF on Windows, as that's what hakari needs. +Cargo.toml merge=binary -crlf diff --git a/rs/workspace-hack/Cargo.toml b/rs/workspace-hack/Cargo.toml new file mode 100644 index 00000000000..002efed81d1 --- /dev/null +++ b/rs/workspace-hack/Cargo.toml @@ -0,0 +1,578 @@ +# This file is generated by `cargo hakari`. +# To regenerate, run: +# cargo hakari generate + +# Every member dev-depends on this crate by path (`sh/rs/hakari.py` checks), so +# a test or lint build compiles each third-party crate with one feature set +# whichever packages it selects, and mbx reuses that build across jobs. +# `cargo publish` strips path-only dev-dependencies, so it is never published. +[package] +name = "workspace-hack" +description = "Media over QUIC - One feature set per dependency, managed by cargo hakari" +authors = ["Luke Curley "] +repository = "https://github.com/moq-dev/moq" +license = "MIT OR Apache-2.0" + +version = "0.1.0" +edition = "2024" +rust-version.workspace = true + +publish = false + +# The parts of the file between the BEGIN HAKARI SECTION and END HAKARI SECTION comments +# are managed by hakari. + +### BEGIN HAKARI SECTION +[dependencies] +anyhow = { version = "1", features = ["backtrace"] } +aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] } +aws-lc-sys = { version = "0.46", default-features = false, features = ["prebuilt-nasm"] } +base16ct = { version = "1", default-features = false, features = ["alloc"] } +base64 = { version = "0.23" } +bitflags = { version = "2", default-features = false, features = ["std"] } +bytemuck = { version = "1", default-features = false, features = ["derive", "extern_crate_alloc", "min_const_generics"] } +byteorder = { version = "1" } +bytes = { version = "1", features = ["serde"] } +camino = { version = "1", default-features = false, features = ["serde1"] } +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["cargo", "derive"] } +clap_builder = { version = "4", default-features = false, features = ["cargo", "color", "help", "std", "suggestions", "usage"] } +const-oid = { version = "0.9", default-features = false, features = ["db"] } +cpal = { version = "0.18", features = ["pipewire", "pulseaudio"] } +crc32fast = { version = "1" } +crossbeam-utils = { version = "0.8" } +crypto-common-6f8ce4dd05d13bba = { package = "crypto-common", version = "0.2", default-features = false, features = ["getrandom"] } +crypto-common-c65f7effa3be6d31 = { package = "crypto-common", version = "0.1", default-features = false, features = ["std"] } +ctutils = { version = "0.4", default-features = false, features = ["subtle"] } +data-encoding = { version = "2" } +der-c38e5c1d305a1b54 = { package = "der", version = "0.8", default-features = false, features = ["oid", "pem", "std"] } +der-ca01ad9e24f5d932 = { package = "der", version = "0.7", default-features = false, features = ["derive", "flagset", "oid", "pem", "std"] } +derive_more = { version = "2", features = ["add", "add_assign", "debug", "deref", "deref_mut", "display", "error", "from", "from_str", "into_iterator", "try_into"] } +digest-93f6ce9d446188ac = { package = "digest", version = "0.10", features = ["mac", "oid", "std"] } +digest-a6292c17cd707f01 = { package = "digest", version = "0.11", features = ["alloc", "mac", "oid", "rand_core"] } +either = { version = "1", features = ["use_std"] } +fastrand = { version = "2", features = ["js"] } +flate2 = { version = "1", features = ["zlib-rs"] } +flume = { version = "0.12" } +foldhash = { version = "0.2" } +form_urlencoded = { version = "1" } +fs-err = { version = "3", default-features = false, features = ["tokio"] } +futures-channel = { version = "0.3", features = ["sink"] } +futures-core = { version = "0.3" } +futures-executor = { version = "0.3" } +futures-io = { version = "0.3" } +futures-sink = { version = "0.3" } +futures-task = { version = "0.3", default-features = false, features = ["std"] } +futures-util = { version = "0.3", features = ["channel", "io", "sink"] } +getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["std"] } +hashbrown = { version = "0.17", default-features = false, features = ["default-hasher", "inline-more"] } +hex = { version = "0.4" } +hmac = { version = "0.12", default-features = false, features = ["reset"] } +hybrid-array = { version = "0.4", default-features = false, features = ["alloc", "subtle", "zeroize"] } +hyper = { version = "1", features = ["full"] } +hyper-util = { version = "0.1", features = ["server-auto", "service", "tokio"] } +indexmap = { version = "2", features = ["serde"] } +lazy_static = { version = "1", default-features = false, features = ["spin_no_std"] } +log = { version = "0.4", default-features = false, features = ["std"] } +md-5-93f6ce9d446188ac = { package = "md-5", version = "0.10" } +md-5-a6292c17cd707f01 = { package = "md-5", version = "0.11" } +memchr = { version = "2" } +mio = { version = "1", features = ["net", "os-ext"] } +nom-181b0d20215bb231 = { package = "nom", version = "8" } +num-bigint = { version = "0.4" } +num-complex = { version = "0.4" } +num-integer = { version = "0.1", features = ["i128"] } +num-iter = { version = "0.1", default-features = false, features = ["i128", "std"] } +num-traits = { version = "0.2", features = ["i128", "libm"] } +object_store = { version = "0.14", default-features = false, features = ["aws", "azure-base", "fs", "gcp-base"] } +once_cell = { version = "1" } +percent-encoding = { version = "2" } +pkcs1 = { version = "0.7", default-features = false, features = ["pem", "pkcs8", "std"] } +pkcs8-93f6ce9d446188ac = { package = "pkcs8", version = "0.10", default-features = false, features = ["pem", "std"] } +pkcs8-a6292c17cd707f01 = { package = "pkcs8", version = "0.11", default-features = false, features = ["pem", "std"] } +portable-atomic = { version = "1", features = ["serde"] } +postcard = { version = "1", default-features = false, features = ["experimental-derive", "use-std"] } +ppv-lite86 = { version = "0.2", default-features = false, features = ["simd", "std"] } +proptest = { version = "1", default-features = false, features = ["handle-panics"] } +rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } +rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8" } +rand_chacha = { version = "0.3", default-features = false, features = ["std"] } +rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "pem", "ring", "x509-parser"] } +regex-automata = { version = "0.4", default-features = false, features = ["dfa-build", "dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "std", "unicode"] } +regex-syntax = { version = "0.8" } +reqwest = { version = "0.13", default-features = false, features = ["gzip", "http2", "json", "rustls", "rustls-no-provider", "stream"] } +rustls = { version = "0.23", features = ["ring"] } +rustls-pki-types = { version = "1", features = ["std"] } +rustls-webpki = { version = "0.103", features = ["aws-lc-rs", "ring"] } +semver = { version = "1", features = ["serde"] } +serde = { version = "1", features = ["alloc", "derive", "rc"] } +serde_core = { version = "1", features = ["alloc", "rc"] } +serde_json = { version = "1", features = ["preserve_order", "raw_value", "unbounded_depth"] } +serde_spanned = { version = "1", default-features = false, features = ["serde", "std"] } +serde_with = { version = "3", features = ["base64", "hex", "json"] } +sha2-93f6ce9d446188ac = { package = "sha2", version = "0.10" } +sha2-a6292c17cd707f01 = { package = "sha2", version = "0.11" } +signature-7b89eefb6aaa9bf3 = { package = "signature", version = "3", default-features = false, features = ["alloc", "digest", "rand_core"] } +signature-f595c2ba2a3f28df = { package = "signature", version = "2", default-features = false, features = ["digest", "rand_core", "std"] } +simd-adler32 = { version = "0.3" } +simdutf8 = { version = "0.1", default-features = false, features = ["aarch64_neon", "std"] } +siphasher = { version = "1" } +slab = { version = "0.4" } +smallvec = { version = "1", default-features = false, features = ["const_new", "union", "write"] } +socket2 = { version = "0.6", default-features = false, features = ["all"] } +spin-274715c4dabd11b0 = { package = "spin", version = "0.9" } +spki-c38e5c1d305a1b54 = { package = "spki", version = "0.8", default-features = false, features = ["pem", "std"] } +spki-ca01ad9e24f5d932 = { package = "spki", version = "0.7", default-features = false, features = ["pem", "std"] } +subtle = { version = "2", features = ["const-generics"] } +time = { version = "0.3", features = ["formatting", "local-offset", "macros", "parsing"] } +tokio = { version = "1", features = ["full", "test-util"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "logging"] } +tokio-tungstenite = { version = "0.30", features = ["rustls-tls-native-roots"] } +tokio-util = { version = "0.7", features = ["codec", "io-util", "rt"] } +toml = { version = "1" } +toml_datetime = { version = "1", features = ["serde"] } +toml_parser = { version = "1" } +tower = { version = "0.5", default-features = false, features = ["make", "util"] } +tracing-core = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time"] } +tungstenite = { version = "0.30", default-features = false, features = ["__rustls-tls", "handshake"] } +typenum = { version = "1", default-features = false, features = ["const-generics"] } +url = { version = "2", features = ["serde"] } +usage-argv = { version = "7", default-features = false, features = ["complete", "diagnostics", "help-advanced"] } +usage-rs = { version = "7", features = ["completions", "config"] } +uuid = { version = "1", features = ["v4", "v7"] } +winnow-dff4ba8e3ae991db = { package = "winnow", version = "1", features = ["simd"] } +x509-parser = { version = "0.18", features = ["verify", "verify-aws"] } +zeroize = { version = "1", features = ["derive"] } + +[build-dependencies] +anyhow = { version = "1", features = ["backtrace"] } +aws-lc-rs = { version = "1", features = ["prebuilt-nasm"] } +aws-lc-sys = { version = "0.46", default-features = false, features = ["prebuilt-nasm"] } +base16ct = { version = "1", default-features = false, features = ["alloc"] } +base64 = { version = "0.23" } +bitflags = { version = "2", default-features = false, features = ["std"] } +bytemuck = { version = "1", default-features = false, features = ["derive", "extern_crate_alloc", "min_const_generics"] } +byteorder = { version = "1" } +bytes = { version = "1", features = ["serde"] } +camino = { version = "1", default-features = false, features = ["serde1"] } +cc = { version = "1", default-features = false, features = ["parallel"] } +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["cargo", "derive"] } +clap_builder = { version = "4", default-features = false, features = ["cargo", "color", "help", "std", "suggestions", "usage"] } +const-oid = { version = "0.9", default-features = false, features = ["db"] } +cpal = { version = "0.18", features = ["pipewire", "pulseaudio"] } +crc32fast = { version = "1" } +crossbeam-utils = { version = "0.8" } +crypto-common-6f8ce4dd05d13bba = { package = "crypto-common", version = "0.2", default-features = false, features = ["getrandom"] } +crypto-common-c65f7effa3be6d31 = { package = "crypto-common", version = "0.1", default-features = false, features = ["std"] } +ctutils = { version = "0.4", default-features = false, features = ["subtle"] } +data-encoding = { version = "2" } +der-c38e5c1d305a1b54 = { package = "der", version = "0.8", default-features = false, features = ["oid", "pem", "std"] } +der-ca01ad9e24f5d932 = { package = "der", version = "0.7", default-features = false, features = ["derive", "flagset", "oid", "pem", "std"] } +derive_more = { version = "2", features = ["add", "add_assign", "debug", "deref", "deref_mut", "display", "error", "from", "from_str", "into_iterator", "try_into"] } +derive_more-impl = { version = "2", features = ["add", "add_assign", "debug", "deref", "deref_mut", "display", "error", "from", "from_str", "into_iterator", "try_into"] } +digest-93f6ce9d446188ac = { package = "digest", version = "0.10", features = ["mac", "oid", "std"] } +digest-a6292c17cd707f01 = { package = "digest", version = "0.11", features = ["alloc", "mac", "oid", "rand_core"] } +displaydoc = { version = "0.2" } +either = { version = "1", features = ["use_std"] } +fastrand = { version = "2", features = ["js"] } +flate2 = { version = "1", features = ["zlib-rs"] } +flume = { version = "0.12" } +foldhash = { version = "0.2" } +form_urlencoded = { version = "1" } +fs-err = { version = "3", default-features = false, features = ["tokio"] } +futures-channel = { version = "0.3", features = ["sink"] } +futures-core = { version = "0.3" } +futures-executor = { version = "0.3" } +futures-io = { version = "0.3" } +futures-sink = { version = "0.3" } +futures-task = { version = "0.3", default-features = false, features = ["std"] } +futures-util = { version = "0.3", features = ["channel", "io", "sink"] } +getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["std"] } +hashbrown = { version = "0.17", default-features = false, features = ["default-hasher", "inline-more"] } +hex = { version = "0.4" } +hmac = { version = "0.12", default-features = false, features = ["reset"] } +hybrid-array = { version = "0.4", default-features = false, features = ["alloc", "subtle", "zeroize"] } +hyper = { version = "1", features = ["full"] } +hyper-util = { version = "0.1", features = ["server-auto", "service", "tokio"] } +indexmap = { version = "2", features = ["serde"] } +lazy_static = { version = "1", default-features = false, features = ["spin_no_std"] } +log = { version = "0.4", default-features = false, features = ["std"] } +md-5-93f6ce9d446188ac = { package = "md-5", version = "0.10" } +md-5-a6292c17cd707f01 = { package = "md-5", version = "0.11" } +memchr = { version = "2" } +mio = { version = "1", features = ["net", "os-ext"] } +nom-181b0d20215bb231 = { package = "nom", version = "8" } +num-bigint = { version = "0.4" } +num-complex = { version = "0.4" } +num-integer = { version = "0.1", features = ["i128"] } +num-iter = { version = "0.1", default-features = false, features = ["i128", "std"] } +num-traits = { version = "0.2", features = ["i128", "libm"] } +object_store = { version = "0.14", default-features = false, features = ["aws", "azure-base", "fs", "gcp-base"] } +once_cell = { version = "1" } +percent-encoding = { version = "2" } +pkcs1 = { version = "0.7", default-features = false, features = ["pem", "pkcs8", "std"] } +pkcs8-93f6ce9d446188ac = { package = "pkcs8", version = "0.10", default-features = false, features = ["pem", "std"] } +pkcs8-a6292c17cd707f01 = { package = "pkcs8", version = "0.11", default-features = false, features = ["pem", "std"] } +portable-atomic = { version = "1", features = ["serde"] } +postcard = { version = "1", default-features = false, features = ["experimental-derive", "use-std"] } +ppv-lite86 = { version = "0.2", default-features = false, features = ["simd", "std"] } +proc-macro2 = { version = "1" } +proptest = { version = "1", default-features = false, features = ["handle-panics"] } +quote = { version = "1" } +rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } +rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8" } +rand_chacha = { version = "0.3", default-features = false, features = ["std"] } +rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "pem", "ring", "x509-parser"] } +regex-automata = { version = "0.4", default-features = false, features = ["dfa-build", "dfa-onepass", "hybrid", "meta", "nfa-backtrack", "perf-inline", "perf-literal", "std", "unicode"] } +regex-syntax = { version = "0.8" } +reqwest = { version = "0.13", default-features = false, features = ["gzip", "http2", "json", "rustls", "rustls-no-provider", "stream"] } +rustls = { version = "0.23", features = ["ring"] } +rustls-pki-types = { version = "1", features = ["std"] } +rustls-webpki = { version = "0.103", features = ["aws-lc-rs", "ring"] } +semver = { version = "1", features = ["serde"] } +serde = { version = "1", features = ["alloc", "derive", "rc"] } +serde_core = { version = "1", features = ["alloc", "rc"] } +serde_json = { version = "1", features = ["preserve_order", "raw_value", "unbounded_depth"] } +serde_spanned = { version = "1", default-features = false, features = ["serde", "std"] } +serde_with = { version = "3", features = ["base64", "hex", "json"] } +sha2-93f6ce9d446188ac = { package = "sha2", version = "0.10" } +sha2-a6292c17cd707f01 = { package = "sha2", version = "0.11" } +signature-7b89eefb6aaa9bf3 = { package = "signature", version = "3", default-features = false, features = ["alloc", "digest", "rand_core"] } +signature-f595c2ba2a3f28df = { package = "signature", version = "2", default-features = false, features = ["digest", "rand_core", "std"] } +simd-adler32 = { version = "0.3" } +simdutf8 = { version = "0.1", default-features = false, features = ["aarch64_neon", "std"] } +siphasher = { version = "1" } +slab = { version = "0.4" } +smallvec = { version = "1", default-features = false, features = ["const_new", "union", "write"] } +socket2 = { version = "0.6", default-features = false, features = ["all"] } +spin-274715c4dabd11b0 = { package = "spin", version = "0.9" } +spki-c38e5c1d305a1b54 = { package = "spki", version = "0.8", default-features = false, features = ["pem", "std"] } +spki-ca01ad9e24f5d932 = { package = "spki", version = "0.7", default-features = false, features = ["pem", "std"] } +subtle = { version = "2", features = ["const-generics"] } +syn-7b89eefb6aaa9bf3 = { package = "syn", version = "3", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +time = { version = "0.3", features = ["formatting", "local-offset", "macros", "parsing"] } +tokio = { version = "1", features = ["full", "test-util"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "logging"] } +tokio-tungstenite = { version = "0.30", features = ["rustls-tls-native-roots"] } +tokio-util = { version = "0.7", features = ["codec", "io-util", "rt"] } +toml = { version = "1" } +toml_datetime = { version = "1", features = ["serde"] } +toml_parser = { version = "1" } +tower = { version = "0.5", default-features = false, features = ["make", "util"] } +tracing-core = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time"] } +tungstenite = { version = "0.30", default-features = false, features = ["__rustls-tls", "handshake"] } +typenum = { version = "1", default-features = false, features = ["const-generics"] } +url = { version = "2", features = ["serde"] } +usage-argv = { version = "7", default-features = false, features = ["complete", "diagnostics", "help-advanced"] } +usage-rs = { version = "7", features = ["completions", "config"] } +uuid = { version = "1", features = ["v4", "v7"] } +winnow-ca01ad9e24f5d932 = { package = "winnow", version = "0.7" } +winnow-dff4ba8e3ae991db = { package = "winnow", version = "1", features = ["simd"] } +x509-parser = { version = "0.18", features = ["verify", "verify-aws"] } +zeroize = { version = "1", features = ["derive"] } + +[target.aarch64-apple-darwin.dependencies] +arrayvec = { version = "0.7" } +block2 = { version = "0.6" } +crossbeam-epoch = { version = "0.9" } +dispatch2 = { version = "0.3" } +errno = { version = "0.3" } +getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } +half = { version = "2", features = ["num-traits"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } +hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "client-proxy"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "tiff"] } +libc = { version = "0.2", features = ["extra_traits"] } +miniz_oxide = { version = "0.8", features = ["simd"] } +nix = { version = "0.31", features = ["net", "uio"] } +objc2 = { version = "0.6" } +objc2-core-audio = { version = "0.3", default-features = false, features = ["AudioHardware", "AudioHardwareDeprecated", "objc2", "objc2-core-audio-types", "objc2-foundation", "std"] } +objc2-core-audio-types = { version = "0.3" } +objc2-core-foundation = { version = "0.3", features = ["CFBase"] } +objc2-core-graphics = { version = "0.3" } +objc2-core-video = { version = "0.3" } +objc2-foundation = { version = "0.3" } +objc2-metal = { version = "0.3" } +objc2-quartz-core = { version = "0.3", default-features = false, features = ["CALayer", "CAMediaTiming", "CAMetalLayer", "objc2-core-foundation", "objc2-core-graphics", "objc2-metal", "std"] } +quick-xml-b32c9ddb6d93a9d2 = { package = "quick-xml", version = "0.42", features = ["serialize"] } +regex = { version = "1" } +rustix = { version = "1", features = ["event", "fs", "net", "pipe", "process", "termios", "time"] } +security-framework-sys = { version = "2" } +spin-93f6ce9d446188ac = { package = "spin", version = "0.10", default-features = false, features = ["once", "rwlock", "spin_mutex"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } +tower-http = { version = "0.6", default-features = false, features = ["decompression-gzip", "follow-redirect"] } +tracing = { version = "0.1", features = ["log"] } +wgpu = { version = "30", features = ["fragile-send-sync-non-atomic-wasm", "webgl"] } +wgpu-core = { version = "30", features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "metal", "portable-atomic", "renderdoc", "vulkan", "webgl", "wgsl"] } +wgpu-hal = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "metal", "portable-atomic"] } +wgpu-types = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "std", "web"] } +winit = { version = "0.30" } +zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } + +[target.aarch64-apple-darwin.build-dependencies] +arrayvec = { version = "0.7" } +block2 = { version = "0.6" } +crossbeam-epoch = { version = "0.9" } +dispatch2 = { version = "0.3" } +errno = { version = "0.3" } +getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } +half = { version = "2", features = ["num-traits"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } +hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "client-proxy"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "tiff"] } +libc = { version = "0.2", features = ["extra_traits"] } +miniz_oxide = { version = "0.8", features = ["simd"] } +nix = { version = "0.31", features = ["net", "uio"] } +objc2 = { version = "0.6" } +objc2-core-audio = { version = "0.3", default-features = false, features = ["AudioHardware", "AudioHardwareDeprecated", "objc2", "objc2-core-audio-types", "objc2-foundation", "std"] } +objc2-core-audio-types = { version = "0.3" } +objc2-core-foundation = { version = "0.3", features = ["CFBase"] } +objc2-core-graphics = { version = "0.3" } +objc2-core-video = { version = "0.3" } +objc2-foundation = { version = "0.3" } +objc2-metal = { version = "0.3" } +objc2-quartz-core = { version = "0.3", default-features = false, features = ["CALayer", "CAMediaTiming", "CAMetalLayer", "objc2-core-foundation", "objc2-core-graphics", "objc2-metal", "std"] } +quick-xml-b32c9ddb6d93a9d2 = { package = "quick-xml", version = "0.42", features = ["serialize"] } +regex = { version = "1" } +rustix = { version = "1", features = ["event", "fs", "net", "pipe", "process", "termios", "time"] } +security-framework-sys = { version = "2" } +spin-93f6ce9d446188ac = { package = "spin", version = "0.10", default-features = false, features = ["once", "rwlock", "spin_mutex"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } +tower-http = { version = "0.6", default-features = false, features = ["decompression-gzip", "follow-redirect"] } +tracing = { version = "0.1", features = ["log"] } +wgpu = { version = "30", features = ["fragile-send-sync-non-atomic-wasm", "webgl"] } +wgpu-core = { version = "30", features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "metal", "portable-atomic", "renderdoc", "vulkan", "webgl", "wgsl"] } +wgpu-hal = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "metal", "portable-atomic"] } +wgpu-types = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "std", "web"] } +winit = { version = "0.30" } +zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } + +[target.aarch64-unknown-linux-gnu.dependencies] +arrayvec = { version = "0.7" } +bytemuck = { version = "1", default-features = false, features = ["aarch64_simd"] } +crossbeam-epoch = { version = "0.9" } +cudarc = { version = "0.19", default-features = false, features = ["cuda-12020", "driver", "fallback-dynamic-loading", "nvrtc"] } +event-listener = { version = "5" } +event-listener-strategy = { version = "0.5" } +futures-lite = { version = "2" } +getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } +getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } +half = { version = "2", default-features = false, features = ["num-traits"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } +hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "client-proxy"] } +itertools = { version = "0.13" } +libc = { version = "0.2", features = ["extra_traits"] } +libspa = { version = "0.10", default-features = false, features = ["v0_3_33"] } +linux-raw-sys = { version = "0.12", features = ["auxvec", "elf", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } +log = { version = "0.4", default-features = false, features = ["release_max_level_debug"] } +miniz_oxide = { version = "0.8", features = ["simd"] } +nix = { version = "0.31", features = ["net", "uio"] } +nom-15128469a54ed75a = { package = "nom", version = "7" } +pipewire = { version = "0.10", default-features = false, features = ["v0_3_53"] } +proc-macro2 = { version = "1" } +quick-xml-4beaf6d1349630b6 = { package = "quick-xml", version = "0.41", features = ["overlapped-lists", "serialize"] } +quote = { version = "1" } +regex = { version = "1" } +rustc-hash = { version = "1" } +rustix = { version = "1", features = ["event", "net", "param", "pipe", "process", "shm", "system", "termios", "thread", "time"] } +spin-93f6ce9d446188ac = { package = "spin", version = "0.10", default-features = false, features = ["once", "rwlock", "spin_mutex"] } +syn-7b89eefb6aaa9bf3 = { package = "syn", version = "3", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +tokio = { version = "1", default-features = false, features = ["tracing"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } +tower-http = { version = "0.6", default-features = false, features = ["decompression-gzip", "follow-redirect"] } +tracing = { version = "0.1", features = ["log"] } +uuid = { version = "1", default-features = false, features = ["serde"] } +wgpu = { version = "30", features = ["fragile-send-sync-non-atomic-wasm", "webgl"] } +wgpu-core = { version = "30", features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "metal", "portable-atomic", "renderdoc", "vulkan", "webgl", "wgsl"] } +wgpu-hal = { version = "30", default-features = false, features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "portable-atomic", "renderdoc", "vulkan"] } +wgpu-types = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "std", "web"] } +winit = { version = "0.30" } +zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } + +[target.aarch64-unknown-linux-gnu.build-dependencies] +arrayvec = { version = "0.7" } +bytemuck = { version = "1", default-features = false, features = ["aarch64_simd"] } +clang-sys = { version = "1", default-features = false, features = ["clang_11_0", "runtime"] } +crossbeam-epoch = { version = "0.9" } +cudarc = { version = "0.19", default-features = false, features = ["cuda-12020", "driver", "fallback-dynamic-loading", "nvrtc"] } +event-listener = { version = "5" } +event-listener-strategy = { version = "0.5" } +futures-lite = { version = "2" } +getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } +getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } +half = { version = "2", default-features = false, features = ["num-traits"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } +hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "client-proxy"] } +itertools = { version = "0.13" } +libc = { version = "0.2", features = ["extra_traits"] } +libspa = { version = "0.10", default-features = false, features = ["v0_3_33"] } +linux-raw-sys = { version = "0.12", features = ["auxvec", "elf", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } +log = { version = "0.4", default-features = false, features = ["release_max_level_debug"] } +miniz_oxide = { version = "0.8", features = ["simd"] } +nix = { version = "0.31", features = ["net", "uio"] } +nom-15128469a54ed75a = { package = "nom", version = "7" } +pipewire = { version = "0.10", default-features = false, features = ["v0_3_53"] } +quick-xml-4beaf6d1349630b6 = { package = "quick-xml", version = "0.41", features = ["overlapped-lists", "serialize"] } +regex = { version = "1" } +rustc-hash = { version = "1" } +rustix = { version = "1", features = ["event", "net", "param", "pipe", "process", "shm", "system", "termios", "thread", "time"] } +spin-93f6ce9d446188ac = { package = "spin", version = "0.10", default-features = false, features = ["once", "rwlock", "spin_mutex"] } +tokio = { version = "1", default-features = false, features = ["tracing"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } +tower-http = { version = "0.6", default-features = false, features = ["decompression-gzip", "follow-redirect"] } +tracing = { version = "0.1", features = ["log"] } +uuid = { version = "1", default-features = false, features = ["serde"] } +wgpu = { version = "30", features = ["fragile-send-sync-non-atomic-wasm", "webgl"] } +wgpu-core = { version = "30", features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "metal", "portable-atomic", "renderdoc", "vulkan", "webgl", "wgsl"] } +wgpu-hal = { version = "30", default-features = false, features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "portable-atomic", "renderdoc", "vulkan"] } +wgpu-types = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "std", "web"] } +winit = { version = "0.30" } +zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } + +[target.x86_64-pc-windows-msvc.dependencies] +arrayvec = { version = "0.7" } +crossbeam-epoch = { version = "0.9" } +getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } +half = { version = "2", default-features = false, features = ["num-traits"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } +hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "client-proxy"] } +image = { version = "0.25", default-features = false, features = ["bmp", "jpeg", "png"] } +regex = { version = "1" } +spin-93f6ce9d446188ac = { package = "spin", version = "0.10", default-features = false, features = ["once", "rwlock", "spin_mutex"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } +tower-http = { version = "0.6", default-features = false, features = ["decompression-gzip", "follow-redirect"] } +tracing = { version = "0.1", features = ["log"] } +wgpu = { version = "30", features = ["fragile-send-sync-non-atomic-wasm", "webgl"] } +wgpu-core = { version = "30", features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "metal", "portable-atomic", "renderdoc", "vulkan", "webgl", "wgsl"] } +wgpu-hal = { version = "30", default-features = false, features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "portable-atomic", "renderdoc", "vulkan"] } +wgpu-types = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "std", "web"] } +winapi = { version = "0.3", default-features = false, features = ["cfg", "evntrace", "in6addr", "inaddr", "minwinbase", "minwindef", "ntsecapi", "processthreadsapi", "sysinfoapi", "winbase", "windef", "winioctl"] } +windows = { version = "0.62", features = ["Foundation_Metadata", "Graphics_Capture", "Graphics_DirectX_Direct3D11", "Security_Authorization_AppCapabilityAccess", "Wdk_System_SystemInformation", "Wdk_System_SystemServices", "Wdk_System_Threading", "Win32_Devices_DeviceAndDriverInstallation", "Win32_Devices_Display", "Win32_Devices_Properties", "Win32_Graphics_Direct3D10", "Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D12", "Win32_Graphics_Direct3D_Dxc", "Win32_Graphics_Direct3D_Fxc", "Win32_Graphics_DirectComposition", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_OpenGL", "Win32_Media_Audio", "Win32_Media_KernelStreaming", "Win32_Media_MediaFoundation", "Win32_Media_Multimedia", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_System_Com_StructuredStorage", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Performance", "Win32_System_Power", "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Rpc", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Variant", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", "Win32_System_Wmi", "Win32_UI_HiDpi", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging"] } +windows-core = { version = "0.62" } +windows-result = { version = "0.4" } +windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Win32_Devices_HumanInterfaceDevice", "Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media", "Win32_Networking_WinSock", "Win32_Security", "Win32_System_Com_StructuredStorage", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Ole", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Accessibility", "Win32_UI_Controls", "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_Input_Touch", "Win32_UI_Shell", "Win32_UI_TextServices", "Win32_UI_WindowsAndMessaging"] } +windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Graphics_Gdi", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_DataExchange", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +winit = { version = "0.30" } +zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } + +[target.x86_64-pc-windows-msvc.build-dependencies] +arrayvec = { version = "0.7" } +crossbeam-epoch = { version = "0.9" } +getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } +half = { version = "2", default-features = false, features = ["num-traits"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } +hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "client-proxy"] } +image = { version = "0.25", default-features = false, features = ["bmp", "jpeg", "png"] } +regex = { version = "1" } +spin-93f6ce9d446188ac = { package = "spin", version = "0.10", default-features = false, features = ["once", "rwlock", "spin_mutex"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } +tower-http = { version = "0.6", default-features = false, features = ["decompression-gzip", "follow-redirect"] } +tracing = { version = "0.1", features = ["log"] } +wgpu = { version = "30", features = ["fragile-send-sync-non-atomic-wasm", "webgl"] } +wgpu-core = { version = "30", features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "metal", "portable-atomic", "renderdoc", "vulkan", "webgl", "wgsl"] } +wgpu-hal = { version = "30", default-features = false, features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "portable-atomic", "renderdoc", "vulkan"] } +wgpu-types = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "std", "web"] } +winapi = { version = "0.3", default-features = false, features = ["cfg", "evntrace", "in6addr", "inaddr", "minwinbase", "minwindef", "ntsecapi", "processthreadsapi", "sysinfoapi", "winbase", "windef", "winioctl"] } +windows = { version = "0.62", features = ["Foundation_Metadata", "Graphics_Capture", "Graphics_DirectX_Direct3D11", "Security_Authorization_AppCapabilityAccess", "Wdk_System_SystemInformation", "Wdk_System_SystemServices", "Wdk_System_Threading", "Win32_Devices_DeviceAndDriverInstallation", "Win32_Devices_Display", "Win32_Devices_Properties", "Win32_Graphics_Direct3D10", "Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D12", "Win32_Graphics_Direct3D_Dxc", "Win32_Graphics_Direct3D_Fxc", "Win32_Graphics_DirectComposition", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_OpenGL", "Win32_Media_Audio", "Win32_Media_KernelStreaming", "Win32_Media_MediaFoundation", "Win32_Media_Multimedia", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_System_Com_StructuredStorage", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Performance", "Win32_System_Power", "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Rpc", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Variant", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", "Win32_System_Wmi", "Win32_UI_HiDpi", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging"] } +windows-core = { version = "0.62" } +windows-result = { version = "0.4" } +windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Win32_Devices_HumanInterfaceDevice", "Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media", "Win32_Networking_WinSock", "Win32_Security", "Win32_System_Com_StructuredStorage", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Ole", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Accessibility", "Win32_UI_Controls", "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_Input_Touch", "Win32_UI_Shell", "Win32_UI_TextServices", "Win32_UI_WindowsAndMessaging"] } +windows-sys-d4189bed749088b6 = { package = "windows-sys", version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Graphics_Gdi", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_DataExchange", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +winit = { version = "0.30" } +zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } + +[target.x86_64-unknown-linux-gnu.dependencies] +arrayvec = { version = "0.7" } +bytemuck = { version = "1", default-features = false, features = ["aarch64_simd"] } +crossbeam-epoch = { version = "0.9" } +cudarc = { version = "0.19", default-features = false, features = ["cuda-12020", "driver", "fallback-dynamic-loading", "nvrtc"] } +event-listener = { version = "5" } +event-listener-strategy = { version = "0.5" } +futures-lite = { version = "2" } +getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } +getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } +half = { version = "2", default-features = false, features = ["num-traits"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } +hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "client-proxy"] } +itertools = { version = "0.13" } +libc = { version = "0.2", features = ["extra_traits"] } +libspa = { version = "0.10", default-features = false, features = ["v0_3_33"] } +linux-raw-sys = { version = "0.12", features = ["auxvec", "elf", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } +log = { version = "0.4", default-features = false, features = ["release_max_level_debug"] } +miniz_oxide = { version = "0.8", features = ["simd"] } +nix = { version = "0.31", features = ["net", "uio"] } +nom-15128469a54ed75a = { package = "nom", version = "7" } +pipewire = { version = "0.10", default-features = false, features = ["v0_3_53"] } +proc-macro2 = { version = "1" } +quick-xml-4beaf6d1349630b6 = { package = "quick-xml", version = "0.41", features = ["overlapped-lists", "serialize"] } +quote = { version = "1" } +regex = { version = "1" } +rustc-hash = { version = "1" } +rustix = { version = "1", features = ["event", "net", "param", "pipe", "process", "shm", "system", "termios", "thread", "time"] } +spin-93f6ce9d446188ac = { package = "spin", version = "0.10", default-features = false, features = ["once", "rwlock", "spin_mutex"] } +syn-7b89eefb6aaa9bf3 = { package = "syn", version = "3", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +tokio = { version = "1", default-features = false, features = ["tracing"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } +tower-http = { version = "0.6", default-features = false, features = ["decompression-gzip", "follow-redirect"] } +tracing = { version = "0.1", features = ["log"] } +uuid = { version = "1", default-features = false, features = ["serde"] } +wgpu = { version = "30", features = ["fragile-send-sync-non-atomic-wasm", "webgl"] } +wgpu-core = { version = "30", features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "metal", "portable-atomic", "renderdoc", "vulkan", "webgl", "wgsl"] } +wgpu-hal = { version = "30", default-features = false, features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "portable-atomic", "renderdoc", "vulkan"] } +wgpu-types = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "std", "web"] } +winit = { version = "0.30" } +zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } + +[target.x86_64-unknown-linux-gnu.build-dependencies] +arrayvec = { version = "0.7" } +bytemuck = { version = "1", default-features = false, features = ["aarch64_simd"] } +clang-sys = { version = "1", default-features = false, features = ["clang_11_0", "runtime"] } +crossbeam-epoch = { version = "0.9" } +cudarc = { version = "0.19", default-features = false, features = ["cuda-12020", "driver", "fallback-dynamic-loading", "nvrtc"] } +event-listener = { version = "5" } +event-listener-strategy = { version = "0.5" } +futures-lite = { version = "2" } +getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } +getrandom-9fbad63c4bcf4a8f = { package = "getrandom", version = "0.4", default-features = false, features = ["std", "sys_rng"] } +half = { version = "2", default-features = false, features = ["num-traits"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "http2", "tls12"] } +hyper-util = { version = "0.1", default-features = false, features = ["client-legacy", "client-proxy"] } +itertools = { version = "0.13" } +libc = { version = "0.2", features = ["extra_traits"] } +libspa = { version = "0.10", default-features = false, features = ["v0_3_33"] } +linux-raw-sys = { version = "0.12", features = ["auxvec", "elf", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "system", "xdp"] } +log = { version = "0.4", default-features = false, features = ["release_max_level_debug"] } +miniz_oxide = { version = "0.8", features = ["simd"] } +nix = { version = "0.31", features = ["net", "uio"] } +nom-15128469a54ed75a = { package = "nom", version = "7" } +pipewire = { version = "0.10", default-features = false, features = ["v0_3_53"] } +quick-xml-4beaf6d1349630b6 = { package = "quick-xml", version = "0.41", features = ["overlapped-lists", "serialize"] } +regex = { version = "1" } +rustc-hash = { version = "1" } +rustix = { version = "1", features = ["event", "net", "param", "pipe", "process", "shm", "system", "termios", "thread", "time"] } +spin-93f6ce9d446188ac = { package = "spin", version = "0.10", default-features = false, features = ["once", "rwlock", "spin_mutex"] } +tokio = { version = "1", default-features = false, features = ["tracing"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } +tower = { version = "0.5", default-features = false, features = ["retry", "timeout"] } +tower-http = { version = "0.6", default-features = false, features = ["decompression-gzip", "follow-redirect"] } +tracing = { version = "0.1", features = ["log"] } +uuid = { version = "1", default-features = false, features = ["serde"] } +wgpu = { version = "30", features = ["fragile-send-sync-non-atomic-wasm", "webgl"] } +wgpu-core = { version = "30", features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "metal", "portable-atomic", "renderdoc", "vulkan", "webgl", "wgsl"] } +wgpu-hal = { version = "30", default-features = false, features = ["dx12", "fragile-send-sync-non-atomic-wasm", "gles", "portable-atomic", "renderdoc", "vulkan"] } +wgpu-types = { version = "30", default-features = false, features = ["fragile-send-sync-non-atomic-wasm", "std", "web"] } +winit = { version = "0.30" } +zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } + +### END HAKARI SECTION diff --git a/rs/workspace-hack/build.rs b/rs/workspace-hack/build.rs new file mode 100644 index 00000000000..92518ef04cb --- /dev/null +++ b/rs/workspace-hack/build.rs @@ -0,0 +1,2 @@ +// A build script is required for cargo to consider build dependencies. +fn main() {} diff --git a/rs/workspace-hack/src/lib.rs b/rs/workspace-hack/src/lib.rs new file mode 100644 index 00000000000..22489f632bd --- /dev/null +++ b/rs/workspace-hack/src/lib.rs @@ -0,0 +1 @@ +// This is a stub lib.rs. diff --git a/sh/rs/hakari.py b/sh/rs/hakari.py new file mode 100755 index 00000000000..4fb46d84822 --- /dev/null +++ b/sh/rs/hakari.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Fail when a workspace member's edge to the workspace-hack is missing or wrong. + +The hack is a path-only dev-dependency, so tests and lints share one build of +every third-party crate while `cargo publish` strips it. `cargo hakari +manage-deps` only writes normal dependencies, so this checks the edges instead: +every member dev-depends on the hack, except those `.config/hakari.toml` +excludes, which must not depend on it at all. Run from `just rs lint`. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import tomllib + +ROOT = Path(__file__).resolve().parents[2] + +config = tomllib.loads((ROOT / ".config/hakari.toml").read_text()) +hack = config["hakari-package"] +excluded = {hack} +for section in ("final-excludes", "traversal-excludes"): + excluded |= set(config.get(section, {}).get("workspace-members", [])) + +metadata = json.loads( + subprocess.run( + ["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout +) + +errors = [] +for package in metadata["packages"]: + name = package["name"] + edges = [dep for dep in package["dependencies"] if dep["name"] == hack] + manifest = Path(package["manifest_path"]).relative_to(ROOT) + if name in excluded: + if edges: + errors.append(f"{manifest}: {name} is excluded in .config/hakari.toml but depends on {hack}") + continue + # A version requirement would survive `cargo publish` and point at a crate + # that was never published. + if not any(dep["kind"] == "dev" and dep.get("path") and dep["req"] == "*" for dep in edges): + hack_dir = Path(next(p["manifest_path"] for p in metadata["packages"] if p["name"] == hack)).parent + rel = Path(*[".."] * len(manifest.parent.parts)) / hack_dir.relative_to(ROOT) + errors.append(f'{manifest}: add `{hack} = {{ path = "{rel}" }}` under [dev-dependencies]') + for dep in edges: + if dep["kind"] != "dev": + errors.append(f"{manifest}: {hack} must be a dev-dependency only, or publishing {name} fails") + +if errors: + print("\n".join(errors), file=sys.stderr) + sys.exit(1) From 35b0bc1b8fbe5daf300772b6ce267e6b71410424 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 15:19:53 +0000 Subject: [PATCH 84/86] ci(nightly): delete unreported relay measurements (#7603) Co-authored-by: GPT-6.1 Sol --- .github/workflows/nightly.yml | 23 +- bench/README.md | 9 +- quest/m1/README.md | 1 - quest/m1/prune-ci.md | 28 -- quest/m1/prune-tests.md | 2 +- quest/m2/audit-ci.md | 536 ++++++++++++++++++++++++---------- quest/m2/bench-sources.md | 3 +- 7 files changed, 398 insertions(+), 204 deletions(-) delete mode 100644 quest/m1/prune-ci.md diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3e5a5483061..dc3dc56ec63 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -93,7 +93,7 @@ jobs: # cross-night history on the benchmark site stays informational. nightly: name: Nightly (${{ matrix.name }}) - runs-on: ${{ matrix.runner || 'ubuntu-24.04-arm' }} + runs-on: ubuntu-24.04-arm # Hosted `main` jobs write the Rust cache; see .github/actions/rust-cache. environment: ${{ github.ref == 'refs/heads/main' && 'cache-write' || '' }} timeout-minutes: 60 @@ -104,21 +104,9 @@ jobs: strategy: fail-fast: false matrix: - # Unset keys default to the ARM runner, the plain dev shell, the Rust - # cache, no perf sampling, and nextest's default profile. + # Unset keys default to the plain dev shell, the Rust cache, and + # nextest's default profile. include: - - name: Audio grouping relay benchmark - recipe: bench-audio - - name: Relay shard sweep - recipe: bench-shard - # Smoke-tests each capture path: exact off-CPU waits, CPU stacks, and - # heap snapshots, each refused without resolved relay stacks. - # x86: on the SVE-capable ARM runner, perf's DWARF unwinding asks for the SVE VG - # register, which the kernel refuses for the software context-switches event. - - name: Relay profiles - recipe: bench-profile "lock cpu heap" fanout-heavy 4 - perf: true - runner: ubuntu-24.04 - name: Rust benchmark tests recipe: gh nightly rust-test - name: Binding benchmark runners @@ -221,11 +209,6 @@ jobs: access-key-id: ${{ secrets.MBX_R2_ACCESS_KEY_ID }} secret-access-key: ${{ secrets.MBX_R2_SECRET_ACCESS_KEY }} - # perf samples the relay in kernel context into large buffers; bench-profile refuses without it. - - name: Allow perf kernel sampling - if: matrix.perf - run: sudo sysctl -w kernel.perf_event_paranoid=-1 - - name: Run workload id: workload run: nix develop ${{ matrix.shell }} --command just ${{ matrix.recipe }} diff --git a/bench/README.md b/bench/README.md index 549d6131e01..6716e016f44 100644 --- a/bench/README.md +++ b/bench/README.md @@ -63,7 +63,8 @@ nix develop --command just bench-audio This sweeps room connections (16, 32) and subscriptions per connection (2, 8) independently, plus one publisher serving 64 and 200 subscribers. It uses the -normal relay sampler and runs nightly. Each shape reports delivered frames, +normal relay sampler. Run it locally when changing grouping or relay load; +its measurements are not a nightly series. Each shape reports delivered frames, p99 latency, relay CPU and RSS; the group-size suffix is the number of frames after the keyframe (0, 4, 9), so each group carries 1, 5 or 10 frames. These synthetic payloads measure group overhead; they do not measure codec loss @@ -152,7 +153,8 @@ Zero shards is the shared runtime with one Tokio thread per usable CPU; any other count is that many unpinned Tokio workers. The table reports CPU per connection, so a steering, dispatch, or per-shard cost that grows with either axis reads as a slope. Linux only, since nothing else steers a reuseport -group. It runs nightly. +group. Run the sweep locally when changing relay sharding; its measurements +are not a nightly series. Profile the relay under a workload, in one or more capture modes: `lock` (worker std mutex waits by stack), `cpu` (CPU stacks of every relay thread), @@ -166,7 +168,8 @@ nix develop --command just bench-profile cpu chat 4 io-uring-workers origin/main nix develop --command just bench-profile cpu chat 4 tokio-workers "" ws ``` -This builds the relay with `[profile.profiling]` and frame pointers, then runs +This is an on-demand diagnostic, with captures kept in the local run directory. +It builds the relay with `[profile.profiling]` and frame pointers, then runs the workload over the selected transport (`quic` by default, or `ws`, `wss`, `tcp`, or `tls`) on the selected runtime (`tokio-workers` by default, or `io-uring-workers`) at each count twice per mode: an unprofiled control, then a diff --git a/quest/m1/README.md b/quest/m1/README.md index 96017b5e0bd..4fb51c40c78 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -140,7 +140,6 @@ per-frame CPU growth since #5926. - [Delete the tests that don't earn their cost](/quest/m1/prune-tests.md) - an off-cycle test cost sweep deletes tests whose cost outweighs their signal and makes the rest cheaper - [Remove the dependencies that don't earn their weight](/quest/m1/prune-deps.md) - Rust, JS, and vendored dependencies that are trivial, duplicated, or serve features not worth keeping are removed - [Remove what dominates build time](/quest/m1/prune-build-time.md) - the crates, features, and code that cost the most build time are removed or made cheaper -- [Delete the CI jobs that don't earn their cost](/quest/m1/prune-ci.md) - workflows and jobs that don't earn their runner time are deleted, and the rest made cheaper - [Cluster routing](/quest/m1/cluster-routing/README.md) - prove shared routing contracts, implement per-origin routes, and connect outbound local clusters - [Routing guide](/quest/m1/routing-guide.md) - concepts and practical outbound federation setup - [moq-c at parity](/quest/m1/c/README.md) - the hand-written `moq-c` ships at the cut with every m1 binding feature moq-ffi gains diff --git a/quest/m1/prune-ci.md b/quest/m1/prune-ci.md deleted file mode 100644 index f6ec1d29d96..00000000000 --- a/quest/m1/prune-ci.md +++ /dev/null @@ -1,28 +0,0 @@ -# [M] Delete the CI jobs that don't earn their cost - -## Goal - -Every CI workflow and job, on PRs, the queue, Nightly, and schedules, is worth -its runner time. One that doesn't is deleted. A costly one that earns its -keep is made cheaper or moved off the merge path. Individual tests belong to -[Delete the tests that don't earn their cost](/quest/m1/prune-tests.md). - -## Plan - -This is a one-off, off-cycle run of the [CI workflow audit](/quest/m2/audit-ci.md), -ahead of the cut. The recurring audit keeps its schedule, and this quest -creates no successor. Its PR rewrites that audit's per-workflow table and -baselines with what this run learned. - -Decided 2026-11-01 (maintainer): - -- Default to deleting. A job goes when its runner time outweighs the - regressions it alone catches, or when nobody sees its result. -- Delete inline in this quest's PR, one commit per workflow. The PR body - lists each deletion with its runner minutes and why it wasn't worth it. - Dropping a release target or a published artifact is a follow-up through - `/quest-plan`. - -Use the m2 audit's signal, visibility, and cost verdicts, but treat any job -that hasn't caught anything since the m1 run (2026-10-20) as a deletion -candidate. Only keep it if you can say what it would uniquely catch. diff --git a/quest/m1/prune-tests.md b/quest/m1/prune-tests.md index 6395202b4a8..72d89a05887 100644 --- a/quest/m1/prune-tests.md +++ b/quest/m1/prune-tests.md @@ -6,7 +6,7 @@ Every test in the merge gate and in nightly is worth its cost. A test whose wall time, CPU, or flakiness outweighs the regression it alone catches is deleted. A costly test that earns its keep is made cheaper: fast at its source, or moved to nightly. CI workflows and jobs belong to -[Delete the CI jobs that don't earn their cost](/quest/m1/prune-ci.md). +[CI workflow audit](/quest/m2/audit-ci.md). ## Plan diff --git a/quest/m2/audit-ci.md b/quest/m2/audit-ci.md index 844ab887f52..922370449cf 100644 --- a/quest/m2/audit-ci.md +++ b/quest/m2/audit-ci.md @@ -50,158 +50,396 @@ verdicts: timings, against that signal. macOS is the scarce one (a 5-job concurrency cap). -### Check first (from the m1 run, 2026-10-22) - -- Classify every failed job of the latest scheduled Nightly, Interop, and TS - runs. `gh run view --log-failed` fetches them in one call; job timings - come from `gh api repos/moq-dev/moq/actions/runs//jobs`. A fix merged - after a night is confirmed only by a later night. -- Nightly was red every night of the m1 run. The 2026-10-21 night failed 18 - jobs and the 2026-10-22 night 19 plus a cancelled Loom job. By the end of the - run, merged fixes covered all but these, which had no owner: - - Windows only: `reconnect::a_protocol_violation_ends_reconnect`, added by - [moq-dev/moq#6162](https://github.com/moq-dev/moq/pull/6162), redials - after a protocol violation over TCP. -- The WASM browser harness (since removed) on main `f42b336f0e` failed `ietf: - reads a published broadcast` (`remote error: code=0`). Bisected to - [moq-dev/moq#6348](https://github.com/moq-dev/moq/pull/6348): the harness - finishes its track before the pre-draft-22 joining FETCH arrives, which - [moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306) fixes on the Rust publisher. - - Audio quality: two Chromium Opus `wide` rows exceed their `target_ms_p95` - budget. The [audio jitter target](/quest/m1/audio-jitter-target/README.md) - owns the budgets. -- Fixed after their night, awaiting a green night: Linux release links - ([#6090](https://github.com/moq-dev/moq/pull/6090)), Firefox audio - ([#6321](https://github.com/moq-dev/moq/pull/6321)), macOS Swift SIGBUS - ([#6323](https://github.com/moq-dev/moq/pull/6323)), runner VM user - namespaces ([#6324](https://github.com/moq-dev/moq/pull/6324)), minimal - feature build ([#6312](https://github.com/moq-dev/moq/pull/6312)), CMAF - decoder tests ([#6313](https://github.com/moq-dev/moq/pull/6313)), relay - drain ([#6322](https://github.com/moq-dev/moq/pull/6322)), the impaired - cluster drill ([#6314](https://github.com/moq-dev/moq/pull/6314)), the - browser benchmark build ([#6316](https://github.com/moq-dev/moq/pull/6316)), - the supplemental Criterion list - ([#6326](https://github.com/moq-dev/moq/pull/6326)), setup and feature - fixtures ([#6325](https://github.com/moq-dev/moq/pull/6325)), the - connection fuzz crash ([#6279](https://github.com/moq-dev/moq/pull/6279)), - and the JS and Rust advisories - ([#6315](https://github.com/moq-dev/moq/pull/6315), - [#6317](https://github.com/moq-dev/moq/pull/6317)). The moq-mux large-enum - and moq-net minimal-feature lints pass on main `f42b336f0e`. -- Interop's finite-track tails fail before draft 22 (`Error: old`, a JS - `RESET_STREAM`); [moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306) fixes the Rust publisher's - half, and [JS joining FETCH](/quest/m2/ietf-js-joining-fetch.md) owns the JS one. TS fails T-STD overflow and late audio starts; the TS reliability - quests own them. The relay-log `FAIL` lines in Interop's self-test are - negative controls, not failures. -- Infrastructure flakes in the sample: DNS failures installing Nix - (`cli-export`) and a curl exit 56 downloading BBB. Count them across nights - before acting; one night is not a pattern. -- `just gh test` ran only in Nightly during the m1 run, so a PR that added a - bench without registering it, or changed a workflow a fixture asserts, - merged green and reddened the next night. That happened three times in two - days (the setup fixture, the removed `writer` bench, and the unregistered - jitter and SUBSCRIBE_TRACKS benches the m1 run registered). - [moq-dev/moq#6480](https://github.com/moq-dev/moq/pull/6480) moved it into - every Check; confirm no such break recurs. -- Platform (macOS, Windows) now runs only in Nightly and on release PRs, not - in the merge queue. A Windows- or macOS-only regression surfaces first in - Nightly, as the reconnect test did. -- An upload that requires files must wait for its producing step, or a setup - failure adds a misleading "No files were found" error. The m1 run gated the - Platform, x11, and size uploads; `failure()`-gated diagnostics still assume - their run directory exists. -- A queue failure often belongs to another PR in the same batch. Check the - entry's head and what merged around it before calling it a flake. -- Re-running a single job cannot redeploy Pages: the earlier attempt's - `github-pages` artifact remains and `deploy-pages` refuses two. Delete the - stale artifact first, or wait for the next Nightly. -- Deferred to the maintainer since m0 (coverage trade-off): fold the capture - and play test builds into the main test build (about 75 s per core Rust - queue entry) or move both to Nightly. The queue keeps both builds until - decided. -- Owned elsewhere: Rust and JS advisories by the - [dependency audit](/quest/m2/audit-deps.md); benchmark legs by the - performance audit; binding build cost, local admission, and Pages artifact - cleanup by their own quests. +### Check first (off-cycle m1 audit, 2026-11-01) + +- The latest scheduled [Nightly](https://github.com/moq-dev/moq/actions/runs/41313466339) + is still red: 152 jobs executed, 19 failed and one was cancelled; 28 tag-only + publishers or reused builders were skipped. Read failures by cause, not job count. + A green compile or an alert delivery is not proof of a caught product regression. +- New signal worth retaining on November 1: QUIC connection fuzzing found + `corrupt stream data` at `rs/moq-quic/src/fuzz/connection.rs:835`; the resume + model found missing group 0 after widening an unordered reader's range. + Gate, all-features and Windows exposed the relay stack test failure, already + owned by [moq-dev/moq#7595](https://github.com/moq-dev/moq/pull/7595). + These are reasons to keep the lanes, not reasons to disable their tests. +- Eight moq-net fuzz targets (announce, ietf, lite, pattern, ingest, varint, + request and path) failed to build with Rust E0391 while computing async drop + for `model::bandwidth::Consumer`; they did not run fuzzing. The six other + fuzz jobs ran, including the QUIC corruption above. Do not count a build + failure as eight independent fuzz findings. +- Other November 1 failures: Rust audit reports RUSTSEC-2026-0173; binding + benchmarks fail `benchmark_ffi`; Firefox media fails the codec-rename tone + check (69/78 samples); shared benches confirm six paired slowdowns; + supplemental benches lack 15 base comparisons after the baseline lacks + `moq-sign`; relay session benches panic with `Evicted`; Rust bench targets + reach measurement but are cancelled at 60.4 minutes. Confirm calibration + before calling paired slowdowns product regressions: [bench CI](/quest/m1/bench-ci.md) + owns it. [moq-dev/moq#7592](https://github.com/moq-dev/moq/pull/7592) already + verifies that the old paired-base Cargo alias blocker is past. +- Latest scheduled [Interop](https://github.com/moq-dev/moq/actions/runs/41317901057) + costs 48.2 Linux minutes and fails buffering, a catalog assertion, and the + removed `moq-transport-14` version. [moq-dev/moq#7553](https://github.com/moq-dev/moq/pull/7553) + owns the join-test version matrix. The relay-log self-test's empty, timeout + and pinned-round `FAIL` messages are intentional negative controls. + [TS](https://github.com/moq-dev/moq/actions/runs/41317281352) passes in 42.0 + Linux minutes. [Video](https://github.com/moq-dev/moq/actions/runs/41318496744) + fails an assertion in 7.8 Linux minutes; malformed attribute diagnostics + before it are expected test inputs. These tests stay with their owners. +- The earlier finite-track failures before draft 22 (`Error: old`, a JS + `RESET_STREAM`) retain their split ownership: [moq-dev/moq#7306](https://github.com/moq-dev/moq/pull/7306) + fixes the Rust publisher, including the removed WASM harness's quick-finish + case; [JS joining FETCH](/quest/m2/ietf-js-joining-fetch.md) owns the JS half. +- Visibility gaps retained with existing owners: Rust benchmark tests and + shipping sizes are measured but not on Pages yet; + [moq-dev/moq#7510](https://github.com/moq-dev/moq/pull/7510) publishes both. + Binding runner checks have a current consumer in rust2's pinned comparison; + they smoke-run here rather than claiming cross-night timing evidence. + Native audio rows are still non-enforced and artifact-only; the + [audio jitter target](/quest/m1/audio-jitter-target/README.md) owns their + reporting/budget decision. Do not delete that scope in a workflow cleanup. +- Concrete overlap: [moq-dev/moq#7600](https://github.com/moq-dev/moq/pull/7600) + owns test deletions; [moq-dev/moq#7601](https://github.com/moq-dev/moq/pull/7601) + owns dependencies; [moq-dev/moq#7602](https://github.com/moq-dev/moq/pull/7602) + owns source, generics and generated-code compile cost; + [moq-dev/moq#7598](https://github.com/moq-dev/moq/pull/7598) owns dispatcher + KVM testability; [moq-dev/moq#7593](https://github.com/moq-dev/moq/pull/7593) + owns io_uring grouping/diagnostics; [moq-dev/moq#7541](https://github.com/moq-dev/moq/pull/7541) + owns JS bench reachability. No infrastructure, cache or landing-gate work + is duplicated here. In particular, cache 5xx belongs to + [moq-dev/moq#7537](https://github.com/moq-dev/moq/pull/7537), and publisher + environments to [moq-dev/moq#7431](https://github.com/moq-dev/moq/pull/7431). +- Local full checks can hit upstream mbx E2BIG + ([moq-dev/moq#7594](https://github.com/moq-dev/moq/pull/7594)) and the runner-image + `/proc/self/uid_map` denial already reproduced on main. Neither justifies + bypassing the launcher, skipping a selected module, or claiming a full pass. +- Still deferred to the maintainer since m0: fold capture and play test + builds into the main test build (about 75 s per core Rust queue entry in + the earlier sample) or move both to Nightly. Keep both builds until decided. +- Recheck the previously unowned October 22 Windows-only + `reconnect::a_protocol_violation_ends_reconnect` failure. The test remains; + neither the November 1 relay-stack failure nor a merged fix proves it recovered. +- Keep operational lessons from the earlier audit: count infrastructure flakes + across nights before acting, and make failure uploads wait for their producing + step and run directory. Pages redeploys need the stale `github-pages` artifact + removed first; Nightly now does that through `just gh pages-clean`. +- Keep release target/artifact removal as a maintainer decision. Shared + default-feature FFI builds already feed Go/Kotlin/Swift/C/C++; Dart has a + different target set and packaging contract. Do not count their matching + target names as evidence of redundant outputs. ### Baselines -- Org billing for this repository, October through 2026-10-22 UTC: Linux - 116,552 minutes, Linux ARM 221,937, macOS 54,829, Windows 52,651, all at - zero net charge. The per-workflow and per-run billing endpoints return - nothing for public runs, so attribute cost from job timings, never by - multiplying invocation counts by one sample. -- Nightly elapsed runner-minutes: 1,372 on 2026-10-21 (ARM 636, Linux 260, - macOS 290, Windows 186; 166 jobs, much of it cut short by early failures) and - 2,091 on 2026-10-22 (ARM 1,328, Linux 335, macOS 273, Windows 156; 143 jobs - ran). The longest 2026-10-22 jobs: Audio quality 62 minutes, Loom 60 - (cancelled), binding benchmark 52, Rust bench targets 48, relay profiles 45, - Rust benchmark tests 44, the gate suite 40. -- The merge queue runs one `just ci` job on the self-hosted `moq-queue` runner: - median 5.5 minutes, p90 11.9, at most 24.2 over 32 entries (m0, 2026-10-21). - Its job summary lists seconds per module, and the `cargo-timings` artifact - is the per-crate drill-down. -- Job summaries now carry Rust and JS coverage, the browser backend baseline, - and browser benchmark comparisons (m1). Relay-profile traces stay - artifact-only diagnosis; native audio rows - wait on the audio jitter target's reporting decision. - -### Every workflow (m1, 2026-10-22) - -Invocations count October runs, including skipped ones, capped by GitHub at -2,500. Minutes are the latest direct run's jobs, by runner. A success is -coverage evidence, not a regression caught. - -| Workflow | Signal | Visibility | October invocations | Latest direct job minutes by runner | Last recorded failure | -| --- | --- | --- | ---: | --- | --- | -| [alert](/.github/workflows/alert.yml) | Scheduled/publishing failure delivery; delivery and coverage guards | Discord failure notice; coverage checked locally | at least 2,500 | no executed job sample | [2026-10-05](https://github.com/moq-dev/moq/actions/runs/37369105130) | -| [android](/.github/workflows/android.yml) | Android cross-compilation and packaging beyond the native gate | Caller gate and Nightly alert | at least 2,500 | Linux 1.9 | [2026-10-19](https://github.com/moq-dev/moq/actions/runs/39343452442) | -| [apt-repo](/.github/workflows/apt-repo.yml) | APT repository metadata, signatures and publication | Published repository/installer and alert | 23 | Linux 1.5 | [2026-05-24](https://github.com/moq-dev/moq/actions/runs/26349324650) | -| [audio-quality](/.github/workflows/audio-quality.yml) | Deterministic audio replay correctness; real-time quality is in Nightly | Caller gate and Nightly alert | 1342 | ARM 16.4 | [2026-10-14](https://github.com/moq-dev/moq/actions/runs/38530745049) | -| [check](/.github/workflows/check.yml) | Required diff-scoped merge admission; module timing summary | Required Check and module timing summary | at least 2,500 | no executed job sample | [2026-10-22](https://github.com/moq-dev/moq/actions/runs/39721515505) | -| [cpp](/.github/workflows/cpp.yml) | C++ compiler and ABI consumer builds; not only Rust compilation | Caller gate and Nightly alert | 811 | no executed job sample | [2026-10-19](https://github.com/moq-dev/moq/actions/runs/39343452453) | -| [dependabot](/.github/workflows/dependabot.yml) | Dependency PR policy and maintenance automation | Dependency PR state | at least 2,500 | no executed job sample | No retained standalone failure | -| [docker](/.github/workflows/docker.yml) | Container image build and multi-architecture publication | Caller gate/alert and release artifacts | 16 | ARM 22.6, Linux 29.7 | [2026-10-05](https://github.com/moq-dev/moq/actions/runs/37382213733) | -| [features](/.github/workflows/features.yml) | Feature isolation and missing dependency gates | Caller gate and Nightly alert | 0 | no executed job sample | No retained standalone failure | -| [interop](/.github/workflows/interop.yml) | Cross-language protocol/media behavior | Discord and Pages interop report | 743 | Linux 21.6 | [2026-10-22](https://github.com/moq-dev/moq/actions/runs/39787655240) | -| [moq-c](/.github/workflows/moq-c.yml) | C ABI packaging, fixtures and consumer links | Caller gate/alert and release artifacts | 0 | no executed job sample | No retained standalone failure | -| [moq-cli](/.github/workflows/moq-cli.yml) | CLI release archives and target-specific links | Caller gate/alert and release artifacts | 8 | ARM 6.5, Linux 6.2, Windows 17.1, macOS 2.6 | [2026-10-16](https://github.com/moq-dev/moq/actions/runs/38854842213) | -| [moq-gst](/.github/workflows/moq-gst.yml) | GStreamer plugin packaging and dynamic links | Caller gate/alert and release artifacts | 11 | ARM 9.4, Linux 10.3, macOS 10.8 | [2026-10-17](https://github.com/moq-dev/moq/actions/runs/39000627149) | -| [moq-relay](/.github/workflows/moq-relay.yml) | Relay release archives and target-specific links | Caller gate/alert and release artifacts | 8 | ARM 5.1, Linux 5.3, Windows 9.3, macOS 44.1 | [2026-09-30](https://github.com/moq-dev/moq/actions/runs/36747636505) | -| [moq-sh](/.github/workflows/moq-sh.yml) | Installer publication, fixtures and installed artifact smoke | Published repository/installer and alert | 13 | Linux 0.2 | No retained standalone failure | -| [nightly](/.github/workflows/nightly.yml) | Diff-independent regression suite and release dry-runs | Discord, Pages benchmarks, job summaries | 26 | see Baselines | [2026-10-22](https://github.com/moq-dev/moq/actions/runs/39779729461) | -| [obs](/.github/workflows/obs.yml) | OBS consumer build and plugin packaging | Caller gate and Nightly alert | 2103 | ARM 22.2, Windows 12.9, macOS 10.9 | [2026-10-19](https://github.com/moq-dev/moq/actions/runs/39343452410) | -| [oss-scanner](/.github/workflows/oss-scanner.yml) | OSS Scanner container builds | Caller gate and Nightly alert | 1 | ARM 9.7 | No retained standalone failure | -| [platform](/.github/workflows/platform.yml) | macOS/Windows compile, bindings and native tests | Nightly and release-gate alert | at least 2,500 | Windows 12.3, macOS 7.0 | [2026-10-22](https://github.com/moq-dev/moq/actions/runs/39779729461) | -| [quest](/.github/workflows/quest.yml) | Unscoped quest-tree validation after direct pushes | Quest check and push-failure alert | at least 2,500 | ARM 0.5 | [2026-10-22](https://github.com/moq-dev/moq/actions/runs/39727308653) | -| [release-binary](/.github/workflows/release-binary.yml) | Shared target-specific binary packaging | Calling release workflow; not independently triggered | 0 | no executed job sample | No retained standalone failure | -| [release-brew](/.github/workflows/release-brew.yml) | Homebrew publication | Caller gate/alert and release artifacts | 27 | Linux 0.2 | No retained standalone failure | -| [release-cpp](/.github/workflows/release-cpp.yml) | C++ release artifacts and consumers | Caller gate/alert and release artifacts | 2 | ARM 4.7, Linux 26.4, Windows 32.7, macOS 21.4 | No retained standalone failure | -| [release-dart-ffi](/.github/workflows/release-dart-ffi.yml) | Dart native FFI artifacts | Caller gate/alert and release artifacts | 293 | Linux 45.0 | [2026-10-10](https://github.com/moq-dev/moq/actions/runs/38088792983) | -| [release-dart](/.github/workflows/release-dart.yml) | Dart package publication | Caller gate/alert and release artifacts | 198 | Linux 0.3 | [2026-10-16](https://github.com/moq-dev/moq/actions/runs/38973399435) | -| [release-ffi](/.github/workflows/release-ffi.yml) | Shared FFI target builder | Calling release workflow; not independently triggered | 0 | no executed job sample | No retained standalone failure | -| [release-gate](/.github/workflows/release-gate.yml) | Complete release PR admission, including callable platform lanes | Caller gate/alert and release artifacts | 0 | no executed job sample | No retained standalone failure | -| [release-go-ffi](/.github/workflows/release-go-ffi.yml) | Go native FFI artifacts | Caller gate/alert and release artifacts | 58 | Linux 33.5, Windows 17.4, macOS 14.0 | [2026-10-18](https://github.com/moq-dev/moq/actions/runs/39135382582) | -| [release-go](/.github/workflows/release-go.yml) | Go wrapper publication | Caller gate/alert and release artifacts | 179 | Linux 1.3 | [2026-10-14](https://github.com/moq-dev/moq/actions/runs/38638664735) | -| [release-js](/.github/workflows/release-js.yml) | npm/JSR packages and exports | Caller gate/alert and release artifacts | 1391 | Linux 16.9 | [2026-10-19](https://github.com/moq-dev/moq/actions/runs/39304286923) | -| [release-kt-ffi](/.github/workflows/release-kt-ffi.yml) | Kotlin native FFI artifacts | Caller gate/alert and release artifacts | 8 | Linux 40.4, Windows 16.8, macOS 13.4 | [2026-09-14](https://github.com/moq-dev/moq/actions/runs/34897543024) | -| [release-kt-lib](/.github/workflows/release-kt-lib.yml) | Kotlin wrapper artifacts | Caller gate/alert and release artifacts | 281 | Linux 14.8 | [2026-10-16](https://github.com/moq-dev/moq/actions/runs/38828705077) | -| [release-nix](/.github/workflows/release-nix.yml) | Nix release evaluation/publication | Caller gate/alert and release artifacts | 0 | no executed job sample | No retained standalone failure | -| [release-py-ffi](/.github/workflows/release-py-ffi.yml) | Python native wheels | Caller gate/alert and release artifacts | 8 | ARM 7.0, Linux 8.0, Windows 14.4, macOS 6.9 | [2026-09-14](https://github.com/moq-dev/moq/actions/runs/34881733830) | -| [release-py](/.github/workflows/release-py.yml) | Python wrapper package | Caller gate/alert and release artifacts | 8 | Linux 17.0 | [2026-05-20](https://github.com/moq-dev/moq/actions/runs/26167963152) | -| [release-rs](/.github/workflows/release-rs.yml) | Rust package release policy | Caller gate/alert and release artifacts | 86 | Linux 21.9 | [2026-10-06](https://github.com/moq-dev/moq/actions/runs/37396594590) | -| [release-shared-ffi](/.github/workflows/release-shared-ffi.yml) | Shared native FFI archives | Caller gate/alert and release artifacts | 0 | no executed job sample | No retained standalone failure | -| [release-swift-ffi](/.github/workflows/release-swift-ffi.yml) | Swift native FFI artifacts | Caller gate/alert and release artifacts | 8 | Linux 6.4, macOS 27.6 | [2026-08-08](https://github.com/moq-dev/moq/actions/runs/31232064868) | -| [release-swift-lib](/.github/workflows/release-swift-lib.yml) | Swift wrapper publication | Caller gate/alert and release artifacts | 97 | Linux 0.1, macOS 0.8 | [2026-09-22](https://github.com/moq-dev/moq/actions/runs/35742256991) | -| [release-winget](/.github/workflows/release-winget.yml) | Winget manifests and publication | Caller gate/alert and release artifacts | 16 | Windows 0.3 | [2026-10-17](https://github.com/moq-dev/moq/actions/runs/39066762564) | -| [rpm-repo](/.github/workflows/rpm-repo.yml) | RPM repository metadata/signatures/publication | Published repository/installer and alert | 23 | Linux 2.3 | [2026-05-25](https://github.com/moq-dev/moq/actions/runs/26377814471) | -| [runner-vm](/.github/workflows/runner-vm.yml) | Persistent runner VM configuration and boot/image checks | Caller gate and Nightly alert | 4 | Linux 3.2 | [2026-10-22](https://github.com/moq-dev/moq/actions/runs/39779729461) | -| [smoke-toolchain](/.github/workflows/smoke-toolchain.yml) | Coupled smoke tooling and Playwright update validation | Update PR and scheduled failure alert | 0 | no executed job sample | No retained standalone failure | -| [smoke](/.github/workflows/smoke.yml) | Published distribution and external-relay behavior | Discord and Pages relay report | 21 | Linux 95.6 | [2026-09-25](https://github.com/moq-dev/moq/actions/runs/36076458401) | -| [ts](/.github/workflows/ts.yml) | TS container compliance and round-trip media integrity | Scheduled failure alert and release gate | 1493 | Linux 34.9 | [2026-10-22](https://github.com/moq-dev/moq/actions/runs/39786183107) | -| [uring](/.github/workflows/uring.yml) | io_uring compile and runtime/kernel contract | Caller gate and Nightly alert | 1711 | ARM 7.7 | [2026-10-19](https://github.com/moq-dev/moq/actions/runs/39344299025) | -| [video](/.github/workflows/video.yml) | Browser video renderer behavior | Scheduled failure alert and release gate | 413 | Linux 1.9 | No retained standalone failure | -| [x11](/.github/workflows/x11.yml) | SHM and GetImage native capture paths | Caller gate and Nightly alert | 680 | ARM 5.2 | [2026-10-17](https://github.com/moq-dev/moq/actions/runs/39002028489) | +Timings below are elapsed runner-minutes, summed from every job's +`started_at`/`completed_at` in the REST jobs API, excluding skipped jobs. +They include setup, builds and teardown, not just workloads. They are neither +CPU-minutes nor billed rounded minutes, and overlapping workflow/caller rows +must not be added together. All observations were fetched November 1 local +(November 2 UTC); no workflow was dispatched for this audit. + +- October org billing for `moq`: Linux 309,940 minutes, Linux ARM 243,405, + macOS 62,756 and Windows 55,972; net charge $0. Source: + `GET /organizations/moq-dev/settings/billing/usage?year=2026&month=10`. + This is monthly attribution only. Do not infer a workflow's cost from it, + multiply a sample by invocation count, or confuse $0 with free capacity. +- Nightly on October 31: 1,402.1 minutes (ARM 767.0, Linux 292.2, + macOS 218.1, Windows 124.8). November 1: 1,505.7 (ARM 780.1, + Linux 303.6, macOS 282.1, Windows 139.9). Early failures make these + lower bounds on a fully successful run, not a performance trend. +- At the November 1 sample, main PRs skipped Check and `quest land serve` + ran `just ci` on the maintainer's host. Since that sample, + [moq-dev/moq#7612](https://github.com/moq-dev/moq/pull/7612) replaced it with + GitHub's single-PR merge queue on the self-hosted runner. Main PRs skip Check + and Queue; the merge group runs `just ci`. The prior hosted queue's + 5.5-minute median is obsolete; this audit claims no replacement host baseline. + Release PRs still run the full Check and Release gate. +- Removed from `nightly.yml`: audio grouping, shard sweep and relay profile + measurement jobs. Across all 13 scheduled nights, October 20 to November 1, + every one succeeded and no caught regression was found. They have no + comparison gate, summary or published series; successful numbers remain in + logs and the profile captures disappear with the runner. Local commands + stay useful for a specific investigation. Profile tooling's deterministic + tests still run through `gh test`; the scheduled capture smoke is dropped. + The jobs' actual cost, including setup, is: + +| Deleted Nightly job | Runner | 13-night minutes | November 1 minutes | Verdict | +| --- | --- | ---: | ---: | --- | +| Audio grouping relay benchmark | ARM | 200.5 | 13.8 | Delete scheduled measurement; keep on-demand diagnostic | +| Relay profiles | Linux | 356.4 | 29.6 | Delete scheduled measurement; keep on-demand diagnostic | +| Relay shard sweep | ARM | 190.2 | 12.1 | Delete scheduled measurement; keep on-demand diagnostic | + +The November 1 removed jobs total 55.5 runner-minutes (25.9 ARM, 29.6 Linux), +not a measured reduction in Nightly wall time. The 13-night total is 747.2 +runner-minutes; totals are calculated before rounding individual rows. +This is the only workflow changed in [moq-dev/moq#7603](https://github.com/moq-dev/moq/pull/7603). +The recurring audit and its release prerequisite remain; no successor is made. + +### Every workflow (2026-11-01) + +The 56 workflows checked in at the November 1 audit are listed below, plus +Queue added afterward. `N` costs are nested jobs in November 1 Nightly; `D` costs are the latest completed successful/failed direct +run in the fetched page (up to 100 runs since October 20). A reusable wrapper with no direct run is not zero cost: +its caller pays. Failure dates below are observations, not automatically unique +regressions. For retained lanes without an attributed regression since October +20, the signal column states the distinct boundary that earns their cost. + +All scheduled/publishing failures reach Discord through Alert; direct PR/push +validation reaches its GitHub check, not Discord. Pages or job summaries are +called out where they expose successful measurements. Artifacts are diagnosis, +not a visible report. Main branch cache writes are protected by `cache-write`; +release credentials remain under the existing publisher-environment work above. + +| Workflow | Unique signal / verdict | Result visibility | Sample runner-minutes | +| --- | --- | --- | --- | +| [alert](/.github/workflows/alert.yml) | Keep: delivers failures; coverage/delivery fixtures exercise the contract | Discord; coverage gate | [D 2026-11-01](https://github.com/moq-dev/moq/actions/runs/41322009598) Linux 0.1 | +| [android](/.github/workflows/android.yml) | Keep: Android cross-compilation and packaging beyond the native gate | Caller gate and Nightly alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 3.4 | +| [api-diff](/.github/workflows/api-diff.yml) | Keep: release backport API compatibility, distinct from compilation | Release gate, summary/report | No executed direct sample since October 20; cost belongs to caller | +| [apt-repo](/.github/workflows/apt-repo.yml) | Keep: APT repository metadata, signatures and publication | Published repository/installer and alert | [D 2026-10-30](https://github.com/moq-dev/moq/actions/runs/41042492573) Linux 1.7 | +| [audio-quality](/.github/workflows/audio-quality.yml) | Keep: deterministic audio replay correctness; real-time quality is in Nightly | Caller gate and Nightly alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 4.5 | +| [c](/.github/workflows/c.yml) | Keep: generated C bindings and installed-consumer ABI link | Caller gate / Nightly alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 4.5 | +| [cache](/.github/workflows/cache.yml) | Keep, infrastructure-owned: publishes reusable shells on three hosts | GitHub status; no product regression claim | [D 2026-11-02](https://github.com/moq-dev/moq/actions/runs/41377533846) ARM 1.3, Linux 1.3, macOS 3.5 | +| [check](/.github/workflows/check.yml) | Keep: release admission; main admission now runs through Queue | Required Check; module timing summary | No completed direct sample in fetched page | +| [codspeed](/.github/workflows/codspeed.yml) | Keep: instruction counts separate layout/timing noise; explicit October 28 maintainer decision in bench-ci | CodSpeed dashboard; failure alert | [D 2026-11-02](https://github.com/moq-dev/moq/actions/runs/41368239240) Linux 63.2 | +| [cpp](/.github/workflows/cpp.yml) | Keep: C++ compiler and ABI consumer builds; not only Rust compilation | Caller gate and Nightly alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 6.3 | +| [dependabot](/.github/workflows/dependabot.yml) | Keep: dependency PR policy and maintenance automation | Dependency PR state | [D 2026-11-02](https://github.com/moq-dev/moq/actions/runs/41372931202) Linux 0.6 | +| [docker](/.github/workflows/docker.yml) | Keep: container image build and multi-architecture publication | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 13.1, Linux 15.8 | +| [features](/.github/workflows/features.yml) | Keep: feature isolation and missing dependency gates | Caller gate and Nightly alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 53.1 | +| [ietf-conformance](/.github/workflows/ietf-conformance.yml) | Keep: pinned third-party Moxygen protocol behavior | Nightly failure alert; reports are diagnostics | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 29.8 | +| [interop](/.github/workflows/interop.yml) | Keep: cross-language protocol/media behavior | Discord and Pages interop report | [D 2026-11-01](https://github.com/moq-dev/moq/actions/runs/41317901057) Linux 48.2 | +| [moq-c](/.github/workflows/moq-c.yml) | Keep: C ABI packaging, fixtures and consumer links | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 10.4, Linux 12.2, Windows 14.9, macOS 23.7 | +| [moq-cli](/.github/workflows/moq-cli.yml) | Keep: CLI release archives and target-specific links | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 11.5, Linux 12.4, Windows 21.1, macOS 38.1 | +| [moq-gst](/.github/workflows/moq-gst.yml) | Keep: GStreamer plugin packaging and dynamic links | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 12.2, Linux 15.6, macOS 21.7 | +| [moq-relay](/.github/workflows/moq-relay.yml) | Keep: relay release archives and target-specific links | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 6.6, Linux 7.5, Windows 12.0, macOS 15.0 | +| [moq-sh](/.github/workflows/moq-sh.yml) | Keep: installer publication, fixtures and installed artifact smoke | Published repository/installer and alert | No executed direct sample since October 20; cost belongs to caller | +| [mutants](/.github/workflows/mutants.yml) | Keep: security/grant/resume/expiry test sensitivity, distinct from rerunning tests; newly added, no scheduled sample yet | Survivors in summary and failure alert | No completed direct sample in fetched page | +| [nightly](/.github/workflows/nightly.yml) | Delete three log-only relay measurement jobs; keep diff-independent coverage below | Discord, Pages, job summaries | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 780.1, Linux 303.6, Windows 139.9, macOS 282.1 | +| [obs](/.github/workflows/obs.yml) | Keep: OBS consumer build and plugin packaging | Caller gate and Nightly alert | [D 2026-10-25](https://github.com/moq-dev/moq/actions/runs/40211940636) ARM 20.4, Windows 12.2, macOS 10.6 | +| [oss-scanner](/.github/workflows/oss-scanner.yml) | Keep: OSS Scanner container builds | Caller gate and Nightly alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 9.2 | +| [platform](/.github/workflows/platform.yml) | Keep: macOS/Windows cfg, ABI, Swift and native test boundaries | Nightly/release gate alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 0.2, Windows 18.2, macOS 57.5 | +| [pullfrog](/.github/workflows/pullfrog.yml) | Keep: explicitly dispatched agent work, no standing regression cost | Calling agent result / GitHub posts | [D 2026-11-02](https://github.com/moq-dev/moq/actions/runs/41378234938) Linux 3.5 | +| [queue](/.github/workflows/queue.yml) | Keep: exact-tree merge-group admission on the self-hosted runner; added after the audit sample | Required Queue context; build timings artifact | No cost sample in this audit | +| [quest](/.github/workflows/quest.yml) | Keep: validates direct pushes after quest-tree merges | GitHub Quest status; no Discord | [D 2026-11-02](https://github.com/moq-dev/moq/actions/runs/41378565460) ARM 0.4 | +| [release-binary](/.github/workflows/release-binary.yml) | Keep: shared target-specific binary packaging | Calling release workflow; not independently triggered | No executed direct sample since October 20; cost belongs to caller | +| [release-brew](/.github/workflows/release-brew.yml) | Keep: Homebrew publication | Caller gate/alert and release artifacts | [D 2026-10-30](https://github.com/moq-dev/moq/actions/runs/41042504830) Linux 0.3 | +| [release-c](/.github/workflows/release-c.yml) | Keep: generated C package and installed consumers over validated native inputs | Caller gate / alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 3.0, Linux 2.6, Windows 7.5, macOS 4.6 | +| [release-cpp](/.github/workflows/release-cpp.yml) | Keep: C++ release artifacts and consumers | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 3.3, Linux 3.8, Windows 12.5, macOS 9.1 | +| [release-dart-ffi](/.github/workflows/release-dart-ffi.yml) | Keep: Dart native FFI artifacts | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 41.2, Windows 17.3, macOS 57.7 | +| [release-dart](/.github/workflows/release-dart.yml) | Keep: Dart package publication | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 0.4 | +| [release-ffi](/.github/workflows/release-ffi.yml) | Keep: shared FFI target builder | Calling release workflow; not independently triggered | No executed direct sample since October 20; cost belongs to caller | +| [release-gate](/.github/workflows/release-gate.yml) | Keep: complete release PR admission, including callable platform lanes | Caller gate/alert and release artifacts | [D 2026-11-02](https://github.com/moq-dev/moq/actions/runs/41376436029) ARM 0.2, Windows 6.5, macOS 4.4 | +| [release-go-ffi](/.github/workflows/release-go-ffi.yml) | Keep: Go native FFI artifacts | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 1.0 | +| [release-go](/.github/workflows/release-go.yml) | Keep: Go wrapper publication | Caller gate/alert and release artifacts | [D 2026-10-31](https://github.com/moq-dev/moq/actions/runs/41182420464) Linux 1.4 | +| [release-js](/.github/workflows/release-js.yml) | Keep: npm/JSR packages and exports | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 6.0 | +| [release-kt-ffi](/.github/workflows/release-kt-ffi.yml) | Keep: Kotlin native FFI artifacts | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 1.7 | +| [release-kt-lib](/.github/workflows/release-kt-lib.yml) | Keep: Kotlin wrapper artifacts | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 11.1 | +| [release-nix](/.github/workflows/release-nix.yml) | Keep: Nix release evaluation/publication | Caller gate/alert and release artifacts | No completed direct sample in fetched page | +| [release-py-ffi](/.github/workflows/release-py-ffi.yml) | Keep: Python native wheels | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 11.2, Linux 19.5, Windows 13.8, macOS 8.9 | +| [release-py](/.github/workflows/release-py.yml) | Keep: Python wrapper package | Caller gate/alert and release artifacts | [D 2026-10-31](https://github.com/moq-dev/moq/actions/runs/41182392402) Linux 17.6 | +| [release-rs](/.github/workflows/release-rs.yml) | Keep: Rust package release policy | Caller gate/alert and release artifacts | [D 2026-10-31](https://github.com/moq-dev/moq/actions/runs/41182420560) Linux 22.4 | +| [release-shared-ffi](/.github/workflows/release-shared-ffi.yml) | Keep: one native build per shared target; packages consume these outputs | Caller gate / alert and release artifacts | [N caller total](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 3.7, Linux 74.6, Windows 22.5, macOS 44.9 (includes nested Go/Kotlin/Swift packagers above) | +| [release-swift-ffi](/.github/workflows/release-swift-ffi.yml) | Keep: Swift native FFI artifacts | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 0.2, macOS 1.0 | +| [release-swift-lib](/.github/workflows/release-swift-lib.yml) | Keep: Swift wrapper publication | Caller gate/alert and release artifacts | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 0.3, macOS 0.9 | +| [release-winget](/.github/workflows/release-winget.yml) | Keep: winget manifests and publication | Caller gate/alert and release artifacts | [D 2026-10-30](https://github.com/moq-dev/moq/actions/runs/41041265123) Windows 0.4 | +| [rpm-repo](/.github/workflows/rpm-repo.yml) | Keep: RPM repository metadata/signatures/publication | Published repository/installer and alert | [D 2026-10-30](https://github.com/moq-dev/moq/actions/runs/41042495150) Linux 2.4 | +| [runner-vm](/.github/workflows/runner-vm.yml) | Keep: persistent runner VM configuration and boot/image checks | Caller gate and Nightly alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) Linux 7.1 | +| [smoke-toolchain](/.github/workflows/smoke-toolchain.yml) | Keep: coupled smoke tooling and Playwright update validation | Update PR and scheduled failure alert | [D 2026-10-26](https://github.com/moq-dev/moq/actions/runs/40353286871) Linux 1.3 | +| [smoke](/.github/workflows/smoke.yml) | Keep: published distribution and external-relay behavior | Discord and Pages relay report | [D 2026-11-01](https://github.com/moq-dev/moq/actions/runs/41325786483) Linux 55.6 | +| [ts](/.github/workflows/ts.yml) | Keep: TS container compliance and round-trip media integrity | Scheduled failure alert and release gate | [D 2026-11-01](https://github.com/moq-dev/moq/actions/runs/41317281352) Linux 42.0 | +| [uring](/.github/workflows/uring.yml) | Keep: io_uring compile and runtime/kernel contract | Caller gate and Nightly alert | [D 2026-10-20](https://github.com/moq-dev/moq/actions/runs/39372116327) ARM 7.7 | +| [video](/.github/workflows/video.yml) | Keep: browser video renderer behavior | Scheduled failure alert and release gate | [D 2026-11-01](https://github.com/moq-dev/moq/actions/runs/41318496744) Linux 7.8 | +| [x11](/.github/workflows/x11.yml) | Keep: SHM and GetImage native capture paths | Caller gate and Nightly alert | [N](https://github.com/moq-dev/moq/actions/runs/41313466339) ARM 6.2 | + +### Every job of November 1 scheduled Nightly + +These are the actual run's jobs, including skipped tag-only work. A retained +job's failure is delivered through the parent Nightly alert; `Pages` and +`summary` add successful-result visibility. Package/build rows are kept for +their workflow's distinct target/ABI/package contract above, not because each +successful target has demonstrated a regression. Skipped jobs cost zero and +are not extra nightly work. `Delete` is the decision applied by [moq-dev/moq#7603](https://github.com/moq-dev/moq/pull/7603); +all other rows are retained, with exceptions/owners stated below and above. + +| Job | Runner minutes | Result | Signal / visibility verdict | +| --- | ---: | --- | --- | +| [Audio quality](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517677) | ARM 42.0 | success | Real-time impaired audio budgets / alert; native reporting decision owned elsewhere | +| [Benchmark site](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124885762600) | ARM 0.7 | success | Pages delivery, not another measurement | +| [Docker / Build linux/amd64](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868567167) | Linux 15.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Docker / Build linux/arm64](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868567176) | ARM 13.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Docker / Parse tag](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517747) | Linux 0.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Docker / Publish manifest](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124873286713) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Fuzz (announce)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517606) | ARM 3.2 | failure | Fresh ASan fuzz inputs; E0391 build failure, no fuzzing | +| [Fuzz (connection)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517730) | ARM 3.9 | failure | Fresh ASan fuzz inputs; corrupt stream data caught | +| [Fuzz (frame)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517952) | ARM 8.3 | success | Fresh ASan fuzz inputs; crash alert/corpus | +| [Fuzz (ietf)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517805) | ARM 3.1 | failure | Fresh ASan fuzz inputs; E0391 build failure, no fuzzing | +| [Fuzz (ingest)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517897) | ARM 3.2 | failure | Fresh ASan fuzz inputs; E0391 build failure, no fuzzing | +| [Fuzz (lite)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517868) | ARM 3.1 | failure | Fresh ASan fuzz inputs; E0391 build failure, no fuzzing | +| [Fuzz (packet)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518136) | ARM 8.4 | success | Fresh ASan fuzz inputs; crash alert/corpus | +| [Fuzz (params)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517933) | ARM 8.2 | success | Fresh ASan fuzz inputs; crash alert/corpus | +| [Fuzz (path)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518085) | ARM 3.0 | failure | Fresh ASan fuzz inputs; E0391 build failure, no fuzzing | +| [Fuzz (pattern)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517876) | ARM 3.0 | failure | Fresh ASan fuzz inputs; E0391 build failure, no fuzzing | +| [Fuzz (request)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517993) | ARM 3.2 | failure | Fresh ASan fuzz inputs; E0391 build failure, no fuzzing | +| [Fuzz (streamid)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517890) | ARM 8.4 | success | Fresh ASan fuzz inputs; crash alert/corpus | +| [Fuzz (streams)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517961) | ARM 8.2 | success | Fresh ASan fuzz inputs; crash alert/corpus | +| [Fuzz (varint)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517927) | ARM 3.1 | failure | Fresh ASan fuzz inputs; E0391 build failure, no fuzzing | +| [Media (firefox player)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517779) | ARM 11.8 | failure | Firefox decode/lifecycle beyond Chromium / codec-rename alert | +| [Nightly (Audio grouping relay benchmark)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518130) | ARM 13.8 | success | Delete: no regression gate or visible measurement | +| [Nightly (Audit)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517879) | ARM 1.2 | failure | Time-dependent Rust advisory; RUSTSEC-2026-0173 alert | +| [Nightly (BBB rendition alignment)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518050) | ARM 1.3 | success | Published fixture rendition alignment; correctness alert | +| [Nightly (Binding benchmark runners)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517685) | ARM 37.4 | failure | Binding-runner contract used by rust2; correctness alert | +| [Nightly (Browser benchmark smoke)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517775) | ARM 15.6 | success | Browser benchmark harness/output validity; correctness alert | +| [Nightly (Go audit)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518270) | ARM 5.0 | success | Time-dependent Go advisories; alert | +| [Nightly (JS audit)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517806) | ARM 1.1 | success | Time-dependent JS advisories; alert | +| [Nightly (JS benchmarks)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517631) | ARM 9.8 | success | JS benchmark smoke / Pages series | +| [Nightly (JS coverage)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517764) | ARM 7.0 | success | Instrumented JS test reachability / summary | +| [Nightly (OBS TSan)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517771) | ARM 9.2 | success | Native consumer data races beyond ordinary tests; alert | +| [Nightly (OBS)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517684) | ARM 9.0 | success | External staticlib/native-link consumer; alert | +| [Nightly (Python audit)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517713) | ARM 1.1 | success | Time-dependent Python advisories; alert | +| [Nightly (Relay profiles)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518097) | Linux 29.6 | success | Delete: no regression gate or visible measurement | +| [Nightly (Relay session benchmarks)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517800) | ARM 14.3 | failure | Relay-specific Criterion path / paired gate and Pages; Evicted | +| [Nightly (Relay shard sweep)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517871) | ARM 12.1 | success | Delete: no regression gate or visible measurement | +| [Nightly (Rust bench targets)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518082) | ARM 60.4 | cancelled | Criterion coverage / paired gate and Pages; calibration owner | +| [Nightly (Rust benchmark tests)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518054) | ARM 35.0 | success | Benchmark executable correctness; reporting owned by 7510 | +| [Nightly (Rust coverage)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517751) | ARM 18.8 | success | Instrumented Rust test reachability / summary | +| [Nightly (Rust shared bench targets)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517638) | ARM 46.7 | failure | Shared Criterion targets / paired gate and Pages; six slowdowns | +| [Nightly (Rust supplemental benchmarks)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517667) | ARM 14.9 | failure | Supplemental Criterion targets / paired gate and Pages; missing base | +| [Nightly (io_uring benchmarks)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517673) | ARM 14.6 | success | io_uring-specific Criterion path / paired gate and Pages | +| [Nightly (moq-c C fixtures)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517716) | ARM 5.0 | success | C ABI link/fixtures outside cargo; alert | +| [Release C / Build (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881867000) | macOS 4.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release C / Build (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881867021) | ARM 3.0 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release C / Build (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881866901) | Windows 7.5 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release C / Build (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881866933) | Linux 2.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release C / Release](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124883708893) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Cpp / Build (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881867271) | macOS 5.3 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Cpp / Build (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881867311) | ARM 3.3 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Cpp / Build (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881867285) | Windows 8.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Cpp / Build (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881867267) | Linux 3.8 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Cpp / OBS plugin (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124883956275) | macOS 3.8 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Cpp / OBS plugin (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124883956221) | Windows 3.9 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Cpp / OBS plugin release](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124884924222) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Cpp / Release](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124883956930) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Cpp / native](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881868324) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Dart / Check package](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518572) | Linux 0.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart / Publish moq to pub.dev](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868622624) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Dart FFI / Attach Native Assets](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881135590) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Dart FFI / Check pub package](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881048718) | Linux 0.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / Publish moq_ffi to pub.dev](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881152175) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Dart FFI / Stage Native Assets](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881048709) | Linux 0.3 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566148) | macOS 14.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (aarch64-apple-ios)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566100) | macOS 11.0 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (aarch64-apple-ios-sim)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566160) | macOS 11.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (aarch64-linux-android)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566093) | Linux 6.0 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566120) | Linux 8.9 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (armv7-linux-androideabi)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566149) | Linux 8.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (x86_64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566281) | macOS 9.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (x86_64-apple-ios)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566135) | macOS 11.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (x86_64-linux-android)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566104) | Linux 8.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566189) | Windows 17.3 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Build moq-ffi (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868566158) | Linux 9.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Dart FFI / moq-ffi / Generate bindings (${{ matrix.language }})](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881049144) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Dart FFI / moq-ffi / Parse version](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518655) | Linux 0.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / C++ native input (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879723377) | macOS 5.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / C++ native input (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879723373) | ARM 3.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / C++ native input (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879723423) | Windows 8.8 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / C++ native input (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879723386) | Linux 3.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Publish C++ native inputs](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881867604) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Go FFI / Package](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879765318) | Linux 0.5 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Release Go FFI / Publish dry-run](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879882907) | Linux 0.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Release Go FFI / Publish to Go module mirror](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879883877) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Go FFI / moq-ffi / Build moq-ffi (${{ matrix.target }})](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879765704) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Go FFI / moq-ffi / Generate bindings (${{ matrix.language }})](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879766102) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Go FFI / moq-ffi / Parse version](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879723881) | Linux 0.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Release Kotlin FFI / Package and publish](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879775722) | Linux 1.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Release Kotlin FFI / moq-ffi / Build moq-ffi (${{ matrix.target }})](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879776434) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Kotlin FFI / moq-ffi / Generate bindings (${{ matrix.language }})](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879776251) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Kotlin FFI / moq-ffi / Parse version](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879723767) | Linux 0.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Release Swift FFI / Attach assets to GitHub release](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124880491128) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Swift FFI / Package](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879776245) | macOS 1.0 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Release Swift FFI / Publish to Swift Package mirror](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124880491888) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Swift FFI / Verify staged package resolves](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124880491544) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Swift FFI / moq-ffi / Build moq-ffi (${{ matrix.target }})](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879776532) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Swift FFI / moq-ffi / Generate bindings (${{ matrix.language }})](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879777518) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release FFI / Release Swift FFI / moq-ffi / Parse version](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879723777) | Linux 0.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556663) | macOS 15.8 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (aarch64-apple-ios)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556690) | macOS 12.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (aarch64-apple-ios-sim)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556677) | macOS 10.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (aarch64-linux-android)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556704) | Linux 8.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556694) | Linux 8.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (armv7-linux-androideabi)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556675) | Linux 6.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (x86_64-linux-android)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556731) | Linux 8.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556684) | Windows 13.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Build moq-ffi (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868556712) | Linux 11.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Generate bindings (go)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124875369657) | Linux 17.5 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Generate bindings (kotlin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124875369587) | Linux 3.5 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Generate bindings (swift)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124875369588) | Linux 3.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release FFI / Shared moq-ffi / Parse version](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518619) | Linux 0.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release JS / Release JS Packages](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518542) | Linux 6.0 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Kotlin lib / Check FFI publisher](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517704) | Linux 0.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Kotlin lib / Check version](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868567036) | Linux 0.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Kotlin lib / Publish wrapper](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124869357056) | Linux 10.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Py FFI / Build sdist](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868568272) | Linux 9.0 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Py FFI / Build wheels (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868568282) | macOS 8.9 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Py FFI / Build wheels (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868568291) | ARM 11.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Py FFI / Build wheels (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868568340) | Windows 13.8 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Py FFI / Build wheels (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868568301) | Linux 10.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Py FFI / Parse version](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518440) | Linux 0.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Py FFI / Publish to PyPI](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124876700281) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Swift Lib / Build wrapper package](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868570063) | macOS 0.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Swift Lib / Check FFI publisher](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518447) | Linux 0.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Swift Lib / Publish dry-run](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879095921) | Linux 0.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Release Swift Lib / Publish to Swift Package mirror](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879962652) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [Release Swift Lib / Verify staged package resolves](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124879095935) | macOS 0.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [Shipping sizes](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124881866814) | ARM 23.4 | success | Published artifact sizes / summary; Pages owned by 7510 | +| [Tests (gate)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517727) | ARM 37.6 | failure | Full diff-independent local gate; relay stack failure; alert | +| [Tests (rs audio-devices)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517634) | ARM 3.7 | success | Null-sink audio device contract absent on plain runner; alert | +| [Tests (rs cli-export)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517722) | ARM 5.7 | success | Paced CLI process export contract; alert | +| [Tests (rs doctest --workspace)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517732) | ARM 8.7 | success | Rustdoc examples are not nextest tests; alert | +| [Tests (rs multicast)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517686) | ARM 6.1 | success | Looped-back mDNS discovery contract; alert | +| [Tests (rs quic-sim)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517895) | ARM 10.8 | success | Fresh-seed QUIC workload beyond fixed PR seeds; alert | +| [Tests (rs quic-stress)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517588) | ARM 3.8 | success | QUIC backend pressure and flow-control contract; alert | +| [Tests (rs render-sw)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517583) | ARM 9.2 | success | Software-Vulkan renderer readback; alert | +| [Tests (rs send-depth)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517735) | ARM 2.6 | success | Pinned-nightly Send proof at normal recursion depth; alert | +| [Tests (rs uring)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517772) | ARM 11.7 | success | Different feature/kernel/socket contract; alert | +| [Tests (rs v4l2)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517784) | ARM 3.2 | success | Vivid virtual-camera capture and device lifetime; alert | +| [Tests (test drain)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517760) | ARM 8.0 | success | Two-relay JS viewer drain across the wire; alert | +| [Tests (test drill impaired)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517608) | ARM 5.0 | success | Seeded bufferbloat and packet overflow beyond PR loopback; alert | +| [Tests (test drill-sensitivity)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517783) | ARM 14.2 | success | Drill negative controls prove acceptance catches faults; alert | +| [Tests (test json-properties)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517788) | ARM 6.4 | success | Extended generated JSON protocol cases; alert | +| [Tests (test qmux)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517907) | ARM 6.3 | success | Cross-language per-draft qmux interoperability; alert | +| [Tests (test resume-model)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517809) | ARM 7.1 | failure | Fresh generated resume/range cases; missing-group counterexample; alert | +| [android / Android](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517795) | Linux 3.4 | success | Distinct platform/consumer build or runtime boundary / failure alert | +| [audio-replay / Replay](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518303) | ARM 4.5 | success | Deterministic trace-replay budgets / failure alert | +| [c / C (clang)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518038) | Linux 4.5 | success | Distinct platform/consumer build or runtime boundary / failure alert | +| [cpp / C++ (clang)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518343) | Linux 6.3 | success | Distinct platform/consumer build or runtime boundary / failure alert | +| [features / Features (rs features)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518151) | ARM 53.1 | failure | Feature isolation beyond default gate / relay stack alert | +| [ietf-conformance / moxygen](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517493) | Linux 29.8 | success | Third-party protocol conformance / failure alert | +| [moq-c / Build (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518055) | macOS 23.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-c / Build (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517939) | ARM 10.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-c / Build (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518048) | Windows 14.9 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-c / Build (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518066) | Linux 12.2 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-c / Release](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124874459428) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [moq-cli / release / Attest](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124878066994) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [moq-cli / release / Build (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518682) | ARM 11.5 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-cli / release / Build (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518684) | Linux 12.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-cli / release / Build Windows zip (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518562) | Windows 21.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-cli / release / Build macOS tarball (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518608) | macOS 38.1 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-cli / release / Release](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124878067934) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [moq-gst / Build tarball (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517858) | macOS 21.7 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-gst / Build tarball (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517671) | ARM 7.4 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-gst / Build tarball (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517835) | Linux 9.8 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-gst / Package .deb/.rpm (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517712) | ARM 4.9 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-gst / Package .deb/.rpm (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517785) | Linux 5.9 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-gst / Release](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124873960333) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [moq-relay / release / Attest](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124872230170) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [moq-relay / release / Build (aarch64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518621) | ARM 6.6 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-relay / release / Build (x86_64-unknown-linux-gnu)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518604) | Linux 7.5 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-relay / release / Build Windows zip (x86_64-pc-windows-msvc)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518642) | Windows 12.0 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-relay / release / Build macOS tarball (aarch64-apple-darwin)](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518513) | macOS 15.0 | success | Keep target/package contract above / failure alert; artifact is build input or diagnosis | +| [moq-relay / release / Release](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124872230844) | 0.0 | skipped | Tag-only publication or native reuse; not executed | +| [oss-scanner / Build](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518381) | ARM 9.2 | success | Distinct platform/consumer build or runtime boundary / failure alert | +| [platform / Platform scope](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518486) | ARM 0.2 | success | Platform selection | +| [platform / Windows](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868585460) | Windows 18.2 | failure | Native OS-specific tests / alert; relay stack failure | +| [platform / macOS](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868585440) | macOS 57.5 | success | Native OS-specific tests / alert | +| [runner-vm / check](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868517755) | Linux 7.1 | success | Runner image/boot contract / failure alert; infrastructure-owned | +| [x11 / SHM and GetImage](https://github.com/moq-dev/moq/actions/runs/41313466339/job/124868518062) | ARM 6.2 | success | Distinct platform/consumer build or runtime boundary / failure alert | ## Required diff --git a/quest/m2/bench-sources.md b/quest/m2/bench-sources.md index 407cf4a0bac..66182dbcce2 100644 --- a/quest/m2/bench-sources.md +++ b/quest/m2/bench-sources.md @@ -23,8 +23,7 @@ don't already emit one. - Check each JS bench reports a measurement before recording it. - Nightly size history (today a 45-day artifact) is a natural extra series if it fits cheaply. -- The relay load benches (`bench-audio`, `bench-profile`) and the - audio-quality job's benchmarks can join later through the same input +- The audio-quality job's benchmarks can join later through the same input format. - The paired nightly comparison publishes beside the table from the same `gh-pages` branch (`bench/paired.json`); a new Rust source joins it through From 6b2d9c310591afc32d142d6fb210e5728c7629b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 15:37:11 +0000 Subject: [PATCH 85/86] refactor(moq-mux): parse H.264 SPS with h264-reader and config bits with bitstream-io (#7398) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer Co-authored-by: moq-bot Co-authored-by: GPT-6.1 Sol --- Cargo.lock | 9 +- Cargo.toml | 2 + quest/m2/README.md | 1 - quest/m2/aac-sbr-config.md | 4 - quest/m2/audit-deps.md | 7 +- quest/m2/h264-reader.md | 33 -- quest/m2/intra-refresh/h264-import.md | 5 +- quest/m2/sps-fuzz-h264-reader.md | 1 - rs/moq-audio/Cargo.toml | 1 + rs/moq-audio/src/aac.rs | 38 +-- rs/moq-mux/Cargo.toml | 2 +- rs/moq-mux/src/codec/aac/mod.rs | 33 +- rs/moq-mux/src/codec/annexb.rs | 70 +++- rs/moq-mux/src/codec/color.rs | 32 +- rs/moq-mux/src/codec/h264/import.rs | 2 +- rs/moq-mux/src/codec/h264/mod.rs | 359 +++++++++----------- rs/moq-mux/src/codec/h264/split.rs | 76 ++++- rs/moq-mux/src/codec/h265/sps.rs | 92 ++--- rs/moq-mux/src/codec/vp9/mod.rs | 71 ++-- rs/moq-mux/src/container/ts/import.rs | 71 +--- rs/moq-mux/src/container/ts/video_timing.rs | 41 +-- rs/moq-rtc/src/codec/bitstream_test.rs | 2 +- rs/moq-rtsp/src/fake.rs | 2 +- 23 files changed, 441 insertions(+), 513 deletions(-) delete mode 100644 quest/m2/h264-reader.md diff --git a/Cargo.lock b/Cargo.lock index cd340c67688..eb764c8d8b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3613,12 +3613,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "h264-parser" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c16ee729d5d4a75be0f5181682a17cf0fce29497d0fa5273d57fe477409f02e6" - [[package]] name = "h264-reader" version = "0.8.0" @@ -5359,6 +5353,7 @@ name = "moq-audio" version = "0.1.10" dependencies = [ "bench-harness", + "bitstream-io 4.10.0", "block2 0.6.2", "bytes", "cpal", @@ -5759,11 +5754,11 @@ dependencies = [ "anyhow", "base64 0.23.1", "bench-harness", + "bitstream-io 4.10.0", "bytes", "crc", "ebml-iterable", "futures", - "h264-parser", "h264-reader 0.9.0", "hang", "kio 0.6.2", diff --git a/Cargo.toml b/Cargo.toml index 9e8d4da072c..5420f962874 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,8 @@ axum = { version = "0.8", default-features = false, features = [ axum-server = "0.8" base64 = "0.23" bitflags = "2" +# MSB-first bit reading for codec configs; the version h264-reader builds on. +bitstream-io = "4.10" block2 = "0.6.2" bytes = "1" clap = { version = "4", features = ["derive"] } diff --git a/quest/m2/README.md b/quest/m2/README.md index fd1acc4fc4b..efcf71a4853 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -193,7 +193,6 @@ lite-07; bitrate enforcement is not part of this release. - [Auth session caps cost O(1)](/quest/m2/auth-session-index.md) - per-token and per-remote counts and a time-ordered expiry queue replace the scans, after #7211 - [The relay refuses a cluster peer it cannot dial](/quest/m2/relay-undialable-peers.md) - moq-tokio reports the schemes it can dial, and the relay refuses an undialable peer at startup instead of retrying forever - [Drop moq-auth's iroh transport](/quest/m2/auth-drop-iroh.md) - the next breaking moq-auth release drops `Transport::Iroh` and the JS `"iroh"` value -- [H.264 parsing with maintained crates](/quest/m2/h264-reader.md) - moq-mux parses SPS VUI with h264-reader and shares one bit reader across codec config parsers - [moq-transport finish interop](/quest/m2/ietf-finish.md) - the interop `finish` case runs on every even moq-transport draft instead of being skipped - [JS joining FETCH](/quest/m2/ietf-js-joining-fetch.md) - the JS publisher answers a joining FETCH that arrives after a quick finish, mirroring the Rust bound - [ashpd uses maintained D-Bus crates](/quest/m2/ashpd-maintained.md) - wait for a stable zbus 6 port, then remove the portal dependency exceptions diff --git a/quest/m2/aac-sbr-config.md b/quest/m2/aac-sbr-config.md index a90ea5606ec..3e3cdd25a44 100644 --- a/quest/m2/aac-sbr-config.md +++ b/quest/m2/aac-sbr-config.md @@ -11,7 +11,3 @@ Follow-up of [moq-dev/moq#7398](https://github.com/moq-dev/moq/pull/7398), which Decided 2026-10-30 (maintainer, follow-up planning after the /quest-afk run). Add the field to moq-mux's public config and delete moq-audio's own parse; test an implicit and an explicit SBR config. Public API: an additive field on `moq_mux::codec::aac::Config`. Wire: none. - -## Required - -- [Parse H.264 and codec config bits with maintained crates](/quest/m2/h264-reader.md) - its PR, #7398, rewrites both readers diff --git a/quest/m2/audit-deps.md b/quest/m2/audit-deps.md index 05203a80c1b..a06873de6cb 100644 --- a/quest/m2/audit-deps.md +++ b/quest/m2/audit-deps.md @@ -144,10 +144,9 @@ locks have none. Most duplicates are ecosystem transitions owned upstream: ### Hand-rolled code -Planned: [H.264 parsing](/quest/m2/h264-reader.md); moq-mux's avcC and -hvcC records go through mp4-atom, and relay metrics now render through -prometheus-client. Reviewed and kept, -each small or cold: about eleven `(d * 2).min(max)` backoff loops (moq-tokio +moq-mux's avcC and hvcC records go through mp4-atom, its H.264 SPS through +h264-reader, and relay metrics now render through prometheus-client. Reviewed +and kept, each small or cold: about eleven `(d * 2).min(max)` backoff loops (moq-tokio already has a `Backoff` that moq-relay's cluster loop could reuse), per-byte hex formatting in moq-auth and moq-hls, the STUN token bucket, the JS deep-equal pair, and duplicated WebVTT stamps and `jwt=` lookups. diff --git a/quest/m2/h264-reader.md b/quest/m2/h264-reader.md deleted file mode 100644 index 02ec5a8169d..00000000000 --- a/quest/m2/h264-reader.md +++ /dev/null @@ -1,33 +0,0 @@ -# [M] Parse H.264 and codec config bits with maintained crates - -## Goal - -moq-mux parses H.264 parameter sets with `h264-reader`, which moq-video and -moq-mux's TS video timing (`container/ts/video_timing.rs`) already use, instead of `h264-parser` plus a hand-written VUI and HRD walker, -and its codec config readers share one maintained bit reader. Catalog output -is unchanged. - -## Plan - -Found by the 2026-10-19 dependency audit. `h264-parser` stops at -`vui_parameters_present_flag`, so `rs/moq-mux/src/codec/h264/mod.rs` re-reads -the SPS bit by bit (about 230 lines of `Vui`, `sps_vui`, and `read_hrd`). -`h264-reader` 0.9 exposes the VUI, HRD, and bitstream restriction, and -`rs/moq-video/src/decode/vui.rs` duplicates moq-mux's `sps_color` on top of it. -The H.265 SPS fallback in `codec/h265/sps.rs` borrows `h264-parser`'s reader. - -Check first whether `h264-reader` covers the SEI recovery point and mastering -display parsing moq-mux does (`split.rs`, `color.rs`); keep those small -pieces hand-written if not. [Intra refresh import](/quest/m2/intra-refresh/h264-import.md) -reads `recovery_frame_cnt` through `h264-parser` today, so whichever lands -second adapts. - -Four MSB-first bit readers repeat the same job for codec config: VP9 -(`codec/vp9/mod.rs`), AAC (`codec/aac/mod.rs`, with a recording writer), the -TS Opus channel config (`container/ts/import.rs`), and moq-audio's own -AudioSpecificConfig scan (`rs/moq-audio/src/aac.rs`), which could call -moq-mux's parser instead. `bitstream-io` is already in the lock through -`h264-reader`. These are cold paths, so pick by API fit, not speed. - -The existing codec tests and catalog fixtures are the regression gate. No -public API or wire change. diff --git a/quest/m2/intra-refresh/h264-import.md b/quest/m2/intra-refresh/h264-import.md index 677340d4317..621d7a61eb0 100644 --- a/quest/m2/intra-refresh/h264-import.md +++ b/quest/m2/intra-refresh/h264-import.md @@ -19,8 +19,9 @@ follows the hang draft, which rounds up ([#5212](https://github.com/moq-dev/moq/pull/5212)). - `rs/moq-mux/src/codec/h264/split.rs`: `sei_has_recovery_point` returns the - `recovery_frame_cnt` the `h264-parser` crate already exposes on - `SeiPayload::RecoveryPoint`, and the split frame carries it. A count of zero + `recovery_frame_cnt` that `h264-reader`'s + `nal::sei::recovery_point::RecoveryPoint::read` parses from the message, and + the split frame carries it. A count of zero stays a plain keyframe. Recovery points with `broken_link_flag` set remain out of scope, as before. - The length-prefixed path bypasses the splitter: `rs/moq-mux/src/import/track.rs` diff --git a/quest/m2/sps-fuzz-h264-reader.md b/quest/m2/sps-fuzz-h264-reader.md index 8fb06b343fc..ee6e864b085 100644 --- a/quest/m2/sps-fuzz-h264-reader.md +++ b/quest/m2/sps-fuzz-h264-reader.md @@ -12,5 +12,4 @@ Public API: none. Wire: none. ## Required -- [Parse H.264 with maintained crates](/quest/m2/h264-reader.md) - its PR, #7398, moves SPS parsing to h264-reader - [Media parsers have fuzz targets](/quest/m2/media-fuzz.md) - its PR, #7313, adds the SPS target and the fuzz-findings epic diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 24aac34cf6d..27fa0ebceae 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -68,6 +68,7 @@ pulseaudio = ["cpal?/pulseaudio"] aec = ["capture", "playback"] [dependencies] +bitstream-io = { workspace = true } bytes = { workspace = true } # Pure-Rust cross-platform audio I/O. Optional: only the `capture` and # `playback` features need it. Encode stays on unsafe-libopus, so a build diff --git a/rs/moq-audio/src/aac.rs b/rs/moq-audio/src/aac.rs index 1dab4c9efa5..d30d3b78eef 100644 --- a/rs/moq-audio/src/aac.rs +++ b/rs/moq-audio/src/aac.rs @@ -141,7 +141,7 @@ fn declares_sbr(description: &[u8]) -> bool { // The sync extension itself. Padding can't be mistaken for it: the pattern // has to match, name SBR or PS, and then set the flag. - if bits.left() < 16 || bits.read(11)? != SYNC_EXTENSION { + if bits.read(11)? != SYNC_EXTENSION { return Some(false); } if !matches!(object_type(bits)?, OBJECT_TYPE_SBR | OBJECT_TYPE_PS) { @@ -151,7 +151,7 @@ fn declares_sbr(description: &[u8]) -> bool { Some(bits.read(1)? == 1) } - scan(&mut Bits::new(description)).unwrap_or(false) + scan(&mut Bits::endian(description, bitstream_io::BigEndian)).unwrap_or(false) } /// An audioObjectType, which escapes to a second field once it runs out of room. @@ -163,34 +163,16 @@ fn object_type(bits: &mut Bits) -> Option { } /// A big-endian bit cursor, which is how an AudioSpecificConfig is packed. -struct Bits<'a> { - data: &'a [u8], - pos: usize, -} - -impl<'a> Bits<'a> { - fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0 } - } - - fn left(&self) -> usize { - self.data.len() * 8 - self.pos - } +type Bits<'a> = bitstream_io::BitReader<&'a [u8], bitstream_io::BigEndian>; - /// The next `count` bits, or `None` when the config is shorter than that. - fn read(&mut self, count: usize) -> Option { - if count > 32 || self.left() < count { - return None; - } - - let mut value = 0; - for _ in 0..count { - let bit = (self.data[self.pos / 8] >> (7 - self.pos % 8)) & 1; - value = (value << 1) | u32::from(bit); - self.pos += 1; - } +/// Reads the next `count` bits, or `None` when the config is shorter than that. +trait ReadBits { + fn read(&mut self, count: u32) -> Option; +} - Some(value) +impl ReadBits for Bits<'_> { + fn read(&mut self, count: u32) -> Option { + bitstream_io::BitRead::read_var(self, count).ok() } } diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index a150682f5a5..918cf69f69f 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -15,10 +15,10 @@ categories = ["multimedia", "network-programming", "web-programming"] [dependencies] anyhow = { workspace = true } base64 = { workspace = true } +bitstream-io = { workspace = true } bytes = { workspace = true } crc = "3" ebml-iterable = { workspace = true } -h264-parser = { version = "0.4.2" } h264-reader = "0.9.0" hang = { workspace = true } kio = { workspace = true, features = ["runtime"] } diff --git a/rs/moq-mux/src/codec/aac/mod.rs b/rs/moq-mux/src/codec/aac/mod.rs index b11cb599fdc..50f7175b2c7 100644 --- a/rs/moq-mux/src/codec/aac/mod.rs +++ b/rs/moq-mux/src/codec/aac/mod.rs @@ -162,7 +162,7 @@ impl Config { /// Read an AudioSpecificConfig's leading fields: the audioObjectType, sample rate, and /// channelConfiguration. fn read_header(reader: &mut BitReader) -> Result<(u8, u32, u8)> { - if reader.buf.remaining() < 2 { + if reader.remaining().is_none_or(|remaining| remaining < 2) { return Err(Error::ConfigTooShort); } let object_type = read_object_type(reader)?; @@ -370,15 +370,14 @@ const SAMPLE_RATES: [u32; 13] = [ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, ]; -/// MSB-first bit reader that pulls bytes from a [`Buf`] on demand. +/// MSB-first bit reader that pulls bytes from a [`Buf`] on demand, so the buffer advances only +/// past the bytes it reads. /// /// AudioSpecificConfig is bit-packed: an explicit 24-bit sample rate pushes the /// following channelConfiguration off byte boundaries, so the fields can't be /// read a whole byte at a time. struct BitReader<'a, T: Buf> { - buf: &'a mut T, - current: u8, - bits_left: u8, + bits: bitstream_io::BitReader, bitstream_io::BigEndian>, /// Every bit read is copied here when set, with alignment redone on the writer's own bytes. record: Option, } @@ -386,16 +385,19 @@ struct BitReader<'a, T: Buf> { impl<'a, T: Buf> BitReader<'a, T> { fn new(buf: &'a mut T) -> Self { Self { - buf, - current: 0, - bits_left: 0, + bits: bitstream_io::BitReader::new(buf.reader()), record: None, } } + /// Bytes left in the buffer, `None` partway through one. + fn remaining(&mut self) -> Option { + Some(self.bits.reader()?.get_ref().remaining()) + } + /// Skip to the next byte boundary. fn align(&mut self) { - self.bits_left = 0; + bitstream_io::BitRead::byte_align(&mut self.bits); if let Some(record) = &mut self.record { record.align(); } @@ -403,18 +405,7 @@ impl<'a, T: Buf> BitReader<'a, T> { /// Read `n` bits (n <= 32) MSB-first, returning `short` if the buffer runs dry. fn read(&mut self, n: u8, short: Error) -> Result { - let mut value = 0u32; - for _ in 0..n { - if self.bits_left == 0 { - if !self.buf.has_remaining() { - return Err(short); - } - self.current = self.buf.get_u8(); - self.bits_left = 8; - } - self.bits_left -= 1; - value = (value << 1) | u32::from((self.current >> self.bits_left) & 1); - } + let value = bitstream_io::BitRead::read_var(&mut self.bits, n.into()).map_err(|_| short)?; if let Some(record) = &mut self.record { record.write(n, value); } diff --git a/rs/moq-mux/src/codec/annexb.rs b/rs/moq-mux/src/codec/annexb.rs index bce9437f9d0..5c8e51ac9bb 100644 --- a/rs/moq-mux/src/codec/annexb.rs +++ b/rs/moq-mux/src/codec/annexb.rs @@ -6,6 +6,63 @@ use std::collections::BTreeMap; use bytes::{Buf, Bytes, BytesMut}; +/// A bit reader over an H.264 or H.265 parameter set or SEI NAL unit's RBSP: the bytes after its +/// `header`-byte NAL header, with emulation prevention removed as they are read. +/// +/// These NAL units end in `rbsp_trailing_bits`, so a trailing zero byte is Annex B's +/// `trailing_zero_8bits` left behind by the splitter, and is dropped rather than refused as an +/// emulated start code. +pub(crate) fn rbsp(nal: &[u8], header: usize) -> h264_reader::rbsp::BitReader> { + h264_reader::rbsp::BitReader::new(rbsp_bytes_reader(nal, header)) +} + +/// The RBSP bytes of an H.264 or H.265 parameter set or SEI NAL unit, as [`rbsp`] reads them. +/// `None` when the payload holds a byte sequence emulation prevention forbids. +pub(crate) fn rbsp_bytes(nal: &[u8], header: usize) -> Option> { + use std::io::Read; + + let mut out = Vec::with_capacity(nal.len()); + rbsp_bytes_reader(nal, header).read_to_end(&mut out).ok()?; + Some(out) +} + +/// The `(payloadType, payload)` of each message in an H.264 or H.265 SEI RBSP, which share the +/// `sei_message` syntax. A malformed message ends the iteration. Unlike h264-reader's +/// `SeiReader`, a payload of any size is skipped without a copy. +pub(crate) fn sei_messages(rbsp: &[u8]) -> impl Iterator { + let mut buf = rbsp; + std::iter::from_fn(move || { + // Each message needs at least a type and size byte; the last byte is the RBSP stop bit. + if buf.len() <= 2 { + return None; + } + let kind = sei_value(&mut buf)?; + let size = sei_value(&mut buf)?; + let (payload, rest) = buf.split_at_checked(size as usize)?; + buf = rest; + Some((kind, payload)) + }) +} + +/// An SEI `payloadType` or `payloadSize`: a run of 0xFF bytes, each adding 255, then the remainder. +fn sei_value(buf: &mut &[u8]) -> Option { + let mut value = 0u32; + loop { + let (&byte, rest) = buf.split_first()?; + *buf = rest; + value = value.checked_add(byte.into())?; + if byte != 0xFF { + return Some(value); + } + } +} + +fn rbsp_bytes_reader(nal: &[u8], header: usize) -> h264_reader::rbsp::ByteReader<&[u8]> { + let payload = nal.get(header..).unwrap_or_default(); + let end = payload.iter().rposition(|&b| b != 0).map_or(0, |last| last + 1); + h264_reader::rbsp::ByteReader::without_skip(&payload[..end]) +} + /// The four-byte Annex B start code preceding a NAL unit. pub const START_CODE: Bytes = Bytes::from_static(&[0, 0, 0, 1]); @@ -189,21 +246,22 @@ impl InBandCodec { /// Insert parameter set `nal` (header included) under the id it defines, replacing any /// earlier set with that id. pub(crate) fn insert_parameter_set(self, sets: &mut ParameterSets, nal: &Bytes) -> Result<()> { - use h264_parser::{bitreader::BitReader, eg::read_ue}; + use h264_reader::rbsp::BitRead; let nal_type = self.nal_type(nal); let header = match self { Self::H264 => 1, Self::H265 => 2, }; - let rbsp = h264_parser::nal::ebsp_to_rbsp(nal.get(header..).unwrap_or_default()); - let r = &mut BitReader::new(&rbsp); + let r = &mut rbsp(nal, header); let id = match (self, nal_type) { // seq_parameter_set_id follows profile_idc, the constraint flags, and level_idc. - (Self::H264, 7) => r.skip_bits(24).and_then(|()| read_ue(r)), + (Self::H264, 7) => r + .skip(24, "profile_idc..level_idc") + .and_then(|()| r.read_ue("seq_parameter_set_id")), // pic_parameter_set_id opens a PPS in both codecs. - (Self::H264, 8) | (Self::H265, 34) => read_ue(r), - (Self::H265, 32) => r.read_bits(4), // vps_video_parameter_set_id + (Self::H264, 8) | (Self::H265, 34) => r.read_ue("pic_parameter_set_id"), + (Self::H265, 32) => r.read::<4, u32>("vps_video_parameter_set_id"), (Self::H265, 33) => crate::codec::h265::sps_id(r), _ => return Err(Error::ParameterSetId(nal_type)), }; diff --git a/rs/moq-mux/src/codec/color.rs b/rs/moq-mux/src/codec/color.rs index 92da681ed3f..133ee179acc 100644 --- a/rs/moq-mux/src/codec/color.rs +++ b/rs/moq-mux/src/codec/color.rs @@ -104,8 +104,11 @@ pub(crate) fn access_unit( ) -> Hdr { let mut hdr = Hdr::default(); let mut scan = |nal: &[u8]| { - if nal.len() > header && sei(nal[0]) { - hdr.update(self::sei(&h264_parser::nal::ebsp_to_rbsp(&nal[header..]))); + if nal.len() > header + && sei(nal[0]) + && let Some(rbsp) = crate::codec::annexb::rbsp_bytes(nal, header) + { + hdr.update(self::sei(&rbsp)); } }; match length_size { @@ -137,15 +140,7 @@ const SEI_CONTENT_LIGHT: u32 = 144; /// (ITU-T H.264 D.1.29 and D.1.31, H.265 D.2.28 and D.2.35). A malformed message ends the scan. pub(crate) fn sei(rbsp: &[u8]) -> Hdr { let mut hdr = Hdr::default(); - let mut buf = rbsp; - // Each message needs at least a type and size byte; the last byte is the RBSP stop bit. - while buf.len() > 2 { - let Some(kind) = sei_value(&mut buf) else { break }; - let Some(size) = sei_value(&mut buf) else { break }; - let Some((payload, rest)) = buf.split_at_checked(size as usize) else { - break; - }; - buf = rest; + for (kind, payload) in crate::codec::annexb::sei_messages(rbsp) { match kind { SEI_MASTERING_DISPLAY if payload.len() >= 24 => hdr.mastering = Some(sei_mastering(payload)), SEI_CONTENT_LIGHT if payload.len() >= 4 => { @@ -214,19 +209,6 @@ fn leb128(buf: &mut &[u8]) -> Option { None } -/// An SEI `payloadType` or `payloadSize`: a run of 0xFF bytes, each adding 255, then the remainder. -fn sei_value(buf: &mut &[u8]) -> Option { - let mut value = 0u32; - loop { - let (&byte, rest) = buf.split_first()?; - *buf = rest; - value = value.checked_add(byte.into())?; - if byte != 0xFF { - return Some(value); - } - } -} - /// A `mastering_display_colour_volume` payload. Chromaticities count 0.00002 and luminance /// 0.0001 cd/m². The primaries are conventionally green, blue, red, which libavcodec assumes too. fn sei_mastering(p: &[u8]) -> Mastering { @@ -311,7 +293,7 @@ pub(crate) mod test { use crate::codec::h264::fixtures::{SEI_CONTENT_LIGHT, SEI_MASTERING}; let mut hdr = Hdr::default(); for nal in [SEI_MASTERING, SEI_CONTENT_LIGHT] { - hdr.update(sei(&h264_parser::nal::ebsp_to_rbsp(&nal[1..]))); + hdr.update(sei(&crate::codec::annexb::rbsp_bytes(nal, 1).unwrap())); } assert_eq!(hdr, hdr10()); } diff --git a/rs/moq-mux/src/codec/h264/import.rs b/rs/moq-mux/src/codec/h264/import.rs index 407420cd792..d5afd3ed75e 100644 --- a/rs/moq-mux/src/codec/h264/import.rs +++ b/rs/moq-mux/src/codec/h264/import.rs @@ -714,7 +714,7 @@ mod tests { ]; let pps: &[u8] = &[0x68, 0xce, 0x3c, 0x80]; // recovery-point SEI (payload type 6), then a non-IDR I-slice (type 1). - let sei: &[u8] = &[0x06, 0x06, 0x02, 0x00, 0x40, 0x80]; + let sei: &[u8] = &[0x06, 0x06, 0x01, 0x84, 0x80]; let islice: &[u8] = &[0x61, 0xe0, 0x12, 0x34]; let mut annexb = BytesMut::new(); diff --git a/rs/moq-mux/src/codec/h264/mod.rs b/rs/moq-mux/src/codec/h264/mod.rs index c0bfd689625..ebbd7f42293 100644 --- a/rs/moq-mux/src/codec/h264/mod.rs +++ b/rs/moq-mux/src/codec/h264/mod.rs @@ -19,6 +19,8 @@ pub use import::*; pub use split::*; use bytes::{Buf, Bytes, BytesMut}; +use h264_reader::nal::sps::{FrameMbsFlags, SeqParameterSet}; +use h264_reader::rbsp::{BitRead, BitReaderError}; use crate::codec::annexb::{InBandCodec, ParameterSets}; @@ -146,7 +148,7 @@ pub type Result = std::result::Result; /// Parsed H.264 SPS (Sequence Parameter Set) NAL. /// -/// Wraps [`h264_parser::Sps`] with the codec-config fields that the hang +/// Reads the SPS with [`h264_reader`] and keeps the codec-config fields that the hang /// catalog records: profile_idc, level_idc, and the packed constraint_set /// flags. The first byte of `nal` must be the NAL header. #[derive(Debug, Clone)] @@ -169,18 +171,99 @@ impl Sps { if nal.len() < 4 { return Err(Error::SpsTooShort); } - let rbsp = h264_parser::nal::ebsp_to_rbsp(&nal[1..]); - let sps = h264_parser::Sps::parse(&rbsp).map_err(|_| Error::SpsParse)?; + let sps = read_sps(nal).ok_or(Error::SpsParse)?; + let (coded_width, coded_height) = sps.pixel_dimensions().map_err(|_| Error::SpsParse)?; Ok(Self { - profile: sps.profile_idc, - constraints: pack_constraint_flags(&sps), + profile: sps.profile_idc.into(), + // constraint_set0..5_flag; the two reserved bits after them stay clear. + constraints: u8::from(sps.constraint_flags) & 0xfc, level: sps.level_idc, - coded_width: sps.width, - coded_height: sps.height, + coded_width, + coded_height, }) } } +/// Parse an SPS NAL unit (header included), `None` when it is malformed. +/// +/// h264-reader refuses the whole SPS when its VUI is cut short or breaks a constraint, but the +/// codec string and coded size only need the fields before it. So a refused SPS is read again +/// without its VUI, as though `vui_parameters_present_flag` were 0, and declares nothing the VUI +/// would have. +fn read_sps(nal: &[u8]) -> Option { + use crate::codec::annexb::rbsp; + + SeqParameterSet::from_bits(rbsp(nal, 1)).ok().or_else(|| { + let sps = SeqParameterSet::from_bits(WithoutVui(rbsp(nal, 1))).ok()?; + tracing::debug!("H.264 SPS VUI refused by h264-reader; using the SPS without it"); + Some(sps) + }) +} + +/// A reader that reports `vui_parameters_present_flag` as 0 and leaves the rest of the RBSP +/// unread, so h264-reader parses an SPS up to its VUI. +struct WithoutVui(R); + +impl BitRead for WithoutVui { + fn read_ue(&mut self, name: &'static str) -> std::result::Result { + self.0.read_ue(name) + } + + fn read_se(&mut self, name: &'static str) -> std::result::Result { + self.0.read_se(name) + } + + fn read_bit(&mut self, name: &'static str) -> std::result::Result { + match name { + "vui_parameters_present_flag" => Ok(false), + _ => self.0.read_bit(name), + } + } + + fn read( + &mut self, + name: &'static str, + ) -> std::result::Result { + self.0.read::(name) + } + + fn read_var( + &mut self, + bit_count: u32, + name: &'static str, + ) -> std::result::Result { + self.0.read_var(bit_count, name) + } + + fn read_to( + &mut self, + name: &'static str, + ) -> std::result::Result { + self.0.read_to(name) + } + + fn skip(&mut self, bit_count: u32, name: &'static str) -> std::result::Result<(), BitReaderError> { + self.0.skip(bit_count, name) + } + + fn byte_aligned(&self) -> bool { + self.0.byte_aligned() + } + + fn has_more_rbsp_data(&mut self, name: &'static str) -> std::result::Result { + self.0.has_more_rbsp_data(name) + } + + /// The VUI and the trailing bits after it go unread. + fn finish_rbsp(self) -> std::result::Result<(), BitReaderError> { + Ok(()) + } + + fn finish_sei_payload(self) -> std::result::Result<(), BitReaderError> { + self.0.finish_sei_payload() + } +} + /// The reordering an SPS NAL unit declares in its VUI: `max_num_reorder_frames` from the /// bitstream restriction, and the frame period when `fixed_frame_rate_flag` is set. /// @@ -200,12 +283,8 @@ pub(crate) fn sps_hrd(nal: &[u8]) -> Option { /// Whether an SPS NAL unit codes field pictures or field macroblock pairs (`frame_mbs_only_flag` 0). pub(crate) fn sps_interlaced(nal: &[u8]) -> Option { - if nal.len() < 4 { - return None; - } - let rbsp = h264_parser::nal::ebsp_to_rbsp(&nal[1..]); - let sps = h264_parser::Sps::parse(&rbsp).ok()?; - Some(!sps.frame_mbs_only_flag) + let sps = read_sps(nal)?; + Some(!matches!(sps.frame_mbs_flags, FrameMbsFlags::Frames)) } /// What an SPS VUI declares about picture timing, sample shape, and colour. @@ -225,6 +304,45 @@ struct Vui { } impl Vui { + /// Read the VUI of a parsed SPS (ITU-T H.264 E.1.1), `None` when it carries none. + fn new(sps: &SeqParameterSet) -> Option { + let vui = sps.vui_parameters.as_ref()?; + let sample_aspect = vui + .aspect_ratio_info + .as_ref() + .and_then(|info| info.get()) + .map(|(width, height)| (u32::from(width), u32::from(height))); + let color = vui.video_signal_type.as_ref().and_then(|signal| { + let (primaries, transfer, matrix) = match &signal.colour_description { + Some(c) => (c.colour_primaries, c.transfer_characteristics, c.matrix_coefficients), + None => (2, 2, 2), + }; + crate::codec::color::cicp(primaries, transfer, matrix, Some(signal.video_full_range_flag)) + }); + // With a fixed rate, a frame lasts two ticks (E.2.1). + let period = vui + .timing_info + .as_ref() + .filter(|t| t.fixed_frame_rate_flag && t.num_units_in_tick > 0 && t.time_scale > 0) + .map(|t| (2 * u64::from(t.num_units_in_tick), u64::from(t.time_scale))); + // The last schedule is the one a decoder conforms to (E.2.2). + let hrd = vui.nal_hrd_parameters.as_ref().and_then(|hrd| { + let spec = hrd.cpb_specs.last()?; + Some(crate::codec::video::Hrd { + bit_rate: (u64::from(spec.bit_rate_value_minus1) + 1) << (6 + hrd.bit_rate_scale), + cpb_size: (u64::from(spec.cpb_size_value_minus1) + 1) << (4 + hrd.cpb_size_scale), + }) + }); + Some(Self { + sample_aspect, + color, + frame_only: matches!(sps.frame_mbs_flags, FrameMbsFlags::Frames), + period, + depth: vui.bitstream_restrictions.as_ref().map(|b| b.max_num_reorder_frames), + hrd, + }) + } + /// Only a fixed frame rate on a frame-only SPS fixes the picture period; field pictures /// need their slice header's `pic_struct` (ITU-T H.264 E.2.1, Table E-6). fn framerate(&self) -> Option { @@ -254,187 +372,7 @@ impl Vui { /// `None` when the SPS carries no VUI or fails to parse. fn sps_vui(nal: &[u8]) -> Option { - if nal.len() < 4 { - return None; - } - let rbsp = h264_parser::nal::ebsp_to_rbsp(&nal[1..]); - let sps = h264_parser::Sps::parse(&rbsp).ok()?; - if !sps.vui_parameters_present_flag { - return None; - } - vui(&rbsp).ok().flatten() -} - -/// Walk an SPS RBSP to its VUI (ITU-T H.264 7.3.2.1.1, E.1.1). `h264_parser` stops at -/// `vui_parameters_present_flag` without exposing its position, so this re-reads the fields -/// before it. -fn vui(rbsp: &[u8]) -> h264_parser::Result> { - use h264_parser::bitreader::BitReader; - use h264_parser::eg::{read_se, read_ue}; - - let mut r = BitReader::new(rbsp); - let profile_idc = r.read_u8()?; - r.skip_bits(16)?; // constraint flags, reserved bits, level_idc - read_ue(&mut r)?; // seq_parameter_set_id - if matches!( - profile_idc, - 100 | 110 | 122 | 244 | 44 | 83 | 86 | 118 | 128 | 138 | 139 | 134 | 135 - ) { - let chroma_format_idc = read_ue(&mut r)?; - if chroma_format_idc == 3 { - r.skip_bits(1)?; // separate_colour_plane_flag - } - read_ue(&mut r)?; // bit_depth_luma_minus8 - read_ue(&mut r)?; // bit_depth_chroma_minus8 - r.skip_bits(1)?; // qpprime_y_zero_transform_bypass_flag - if r.read_flag()? { - let lists = if chroma_format_idc == 3 { 12 } else { 8 }; - for i in 0..lists { - if r.read_flag()? { - let size = if i < 6 { 16 } else { 64 }; - let (mut last, mut next) = (8i32, 8i32); - for _ in 0..size { - if next != 0 { - next = (last + read_se(&mut r)?).rem_euclid(256); - } - if next != 0 { - last = next; - } - } - } - } - } - } - read_ue(&mut r)?; // log2_max_frame_num_minus4 - match read_ue(&mut r)? { - 0 => { - read_ue(&mut r)?; // log2_max_pic_order_cnt_lsb_minus4 - } - 1 => { - r.skip_bits(1)?; // delta_pic_order_always_zero_flag - read_se(&mut r)?; // offset_for_non_ref_pic - read_se(&mut r)?; // offset_for_top_to_bottom_field - for _ in 0..read_ue(&mut r)? { - read_se(&mut r)?; // offset_for_ref_frame - } - } - _ => {} - } - read_ue(&mut r)?; // max_num_ref_frames - r.skip_bits(1)?; // gaps_in_frame_num_value_allowed_flag - read_ue(&mut r)?; // pic_width_in_mbs_minus1 - read_ue(&mut r)?; // pic_height_in_map_units_minus1 - let frame_only = r.read_flag()?; // frame_mbs_only_flag - if !frame_only { - r.skip_bits(1)?; // mb_adaptive_frame_field_flag - } - r.skip_bits(1)?; // direct_8x8_inference_flag - if r.read_flag()? { - for _ in 0..4 { - read_ue(&mut r)?; // frame_crop_*_offset - } - } - if !r.read_flag()? { - return Ok(None); - } - - // vui_parameters() - let sample_aspect = match r.read_flag()? { - true => match r.read_u8()? { - 255 => Some((u32::from(r.read_u16()?), u32::from(r.read_u16()?))), - idc => crate::codec::video::sample_aspect(idc), - }, - false => None, - }; - if r.read_flag()? { - r.skip_bits(1)?; // overscan_appropriate_flag - } - let mut color = None; - if r.read_flag()? { - r.skip_bits(3)?; // video_format - let full_range = r.read_flag()?; - let (primaries, transfer, matrix) = match r.read_flag()? { - true => (r.read_u8()?, r.read_u8()?, r.read_u8()?), - false => (2, 2, 2), - }; - color = crate::codec::color::cicp(primaries, transfer, matrix, Some(full_range)); - } - if r.read_flag()? { - read_ue(&mut r)?; // chroma_sample_loc_type_top_field - read_ue(&mut r)?; // chroma_sample_loc_type_bottom_field - } - let mut period = None; - if r.read_flag()? { - let units = r.read_bits(32)?; - let scale = r.read_bits(32)?; - // With a fixed rate, a frame lasts two ticks (E.2.1). - if r.read_flag()? && units > 0 && scale > 0 { - period = Some((2 * u64::from(units), u64::from(scale))); - } - } - let nal_hrd = r.read_flag()?; - let hrd = match nal_hrd { - true => Some(read_hrd(&mut r)?), - false => None, - }; - let vcl_hrd = r.read_flag()?; - if vcl_hrd { - read_hrd(&mut r)?; - } - if nal_hrd || vcl_hrd { - r.skip_bits(1)?; // low_delay_hrd_flag - } - r.skip_bits(1)?; // pic_struct_present_flag - if !r.read_flag()? { - return Ok(Some(Vui { - sample_aspect, - color, - frame_only, - period, - depth: None, - hrd, - })); - } - r.skip_bits(1)?; // motion_vectors_over_pic_boundaries_flag - for _ in 0..4 { - read_ue(&mut r)?; // max_bytes_per_pic_denom .. log2_max_mv_length_vertical - } - let depth = read_ue(&mut r)?; // max_num_reorder_frames - Ok(Some(Vui { - sample_aspect, - color, - frame_only, - period, - depth: Some(depth), - hrd, - })) -} - -/// Read `hrd_parameters()` (ITU-T H.264 E.1.2) down to its last schedule (E.2.2). -fn read_hrd(r: &mut h264_parser::bitreader::BitReader) -> h264_parser::Result { - use h264_parser::eg::read_ue; - - let cpb_cnt_minus1 = read_ue(r)?; - if cpb_cnt_minus1 > 31 { - return Err(h264_parser::Error::MalformedSps("cpb_cnt_minus1 out of range".into())); - } - let bit_rate_scale = r.read_bits(4)?; - let cpb_size_scale = r.read_bits(4)?; - let mut hrd = crate::codec::video::Hrd { - bit_rate: 0, - cpb_size: 0, - }; - for _ in 0..=cpb_cnt_minus1 { - let bit_rate = u64::from(read_ue(r)?) + 1; - let cpb_size = u64::from(read_ue(r)?) + 1; - r.skip_bits(1)?; // cbr_flag - hrd = crate::codec::video::Hrd { - bit_rate: bit_rate << (6 + bit_rate_scale), - cpb_size: cpb_size << (4 + cpb_size_scale), - }; - } - r.skip_bits(20)?; // four delay/offset lengths - Ok(hrd) + Vui::new(&read_sps(nal)?) } /// Parsed AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3.1.2). @@ -519,15 +457,6 @@ fn encode_avcc(record: &mp4_atom::Avcc) -> Bytes { out.freeze() } -fn pack_constraint_flags(sps: &h264_parser::Sps) -> u8 { - ((sps.constraint_set0_flag as u8) << 7) - | ((sps.constraint_set1_flag as u8) << 6) - | ((sps.constraint_set2_flag as u8) << 5) - | ((sps.constraint_set3_flag as u8) << 4) - | ((sps.constraint_set4_flag as u8) << 3) - | ((sps.constraint_set5_flag as u8) << 2) -} - /// Build an AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3.1.2) from the /// given SPS and PPS NALs. At least one SPS is required; the profile/level fields /// are read from the first SPS. A stream may legitimately carry several distinct @@ -841,6 +770,30 @@ mod tests { assert_eq!(sps_color(fixtures::SPS_IPB), None, "a VUI without a video signal type"); } + /// An SPS whose VUI ends early still yields its codec string and size, and declares no VUI. + #[test] + fn sps_with_a_short_vui_keeps_the_fields_before_it() { + let sps: &[u8] = &[ + 0x67, 0x42, 0xc0, 0x1f, 0xda, 0x01, 0x40, 0x16, 0xe9, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x07, 0xd0, 0x00, + 0x01, 0xd4, 0xc0, 0x80, + ]; + assert!(SeqParameterSet::from_bits(crate::codec::annexb::rbsp(sps, 1)).is_err()); + let parsed = Sps::parse(sps).unwrap(); + assert_eq!((parsed.profile, parsed.constraints, parsed.level), (0x42, 0xc0, 0x1f)); + assert_eq!((parsed.coded_width, parsed.coded_height), (1280, 720)); + assert!(sps_vui(sps).is_none()); + assert_eq!(sps_interlaced(sps), Some(false)); + } + + /// Annex B's `trailing_zero_8bits` left on an SPS is not an emulated start code. + #[test] + fn sps_ignores_trailing_zero_bytes() { + let mut padded = fixtures::SPS_IPB.to_vec(); + padded.extend_from_slice(&[0, 0, 0, 0]); + assert_eq!(sps_reorder(&padded), sps_reorder(fixtures::SPS_IPB)); + assert!(sps_reorder(&padded).is_some()); + } + const SC4: &[u8] = &[0, 0, 0, 1]; #[test] diff --git a/rs/moq-mux/src/codec/h264/split.rs b/rs/moq-mux/src/codec/h264/split.rs index 1e687cfa04e..46b09158ab6 100644 --- a/rs/moq-mux/src/codec/h264/split.rs +++ b/rs/moq-mux/src/codec/h264/split.rs @@ -272,19 +272,33 @@ impl Split { } } -/// True if an SEI NAL carries a recovery-point message (payload type 6), the +/// True if an SEI NAL carries a well-formed recovery-point message (payload type 6), the /// open-GOP random-access marker. The NAL header byte precedes the SEI RBSP. fn sei_has_recovery_point(nal: &[u8]) -> bool { - let Some(ebsp) = nal.get(1..) else { - return false; + use h264_reader::nal::sei::{ + HeaderType, SeiMessage, + recovery_point::{RecoveryPoint, RecoveryPointError}, }; - let rbsp = h264_parser::nal::ebsp_to_rbsp(ebsp); - let Ok(messages) = h264_parser::sei::SeiMessage::parse(&rbsp) else { + use h264_reader::rbsp::BitReaderError; + + const SEI_RECOVERY_POINT: u32 = 6; + + let Some(rbsp) = crate::codec::annexb::rbsp_bytes(nal, 1) else { return false; }; - messages - .iter() - .any(|m| matches!(m.payload, h264_parser::sei::SeiPayload::RecoveryPoint { .. })) + crate::codec::annexb::sei_messages(&rbsp) + .filter(|&(kind, _)| kind == SEI_RECOVERY_POINT) + .any(|(_, payload)| { + let msg = SeiMessage { + payload_type: HeaderType::RecoveryPoint, + payload, + }; + // Data after the fields is a payload extension, which a later spec revision may define. + matches!( + RecoveryPoint::read(&msg), + Ok(_) | Err(RecoveryPointError::ReaderError(BitReaderError::RemainingData)) + ) + }) } #[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::TryFromPrimitive)] @@ -460,9 +474,9 @@ mod tests { assert_eq!(tail[0].payload.as_ref(), annexb(&[sps, pps, idr]).freeze().as_ref()); } - /// A recovery-point SEI (payload type 6) with `recovery_frame_cnt == 0`, - /// followed by the recovery point's flag byte, then the RBSP stop bit. - const RECOVERY_SEI: &[u8] = &[0x06, 0x06, 0x02, 0x00, 0x40, 0x80]; + /// A recovery-point SEI (payload type 6, size 1): `recovery_frame_cnt == 0`, clear flags, + /// and payload alignment in `0x84`, then the RBSP stop bit. + const RECOVERY_SEI: &[u8] = &[0x06, 0x06, 0x01, 0x84, 0x80]; /// Open-GOP broadcast H.264: the random-access point is a non-IDR I-slice /// flagged by a recovery-point SEI, not an IDR. The splitter must still flag @@ -509,6 +523,46 @@ mod tests { ); } + /// A recovery point after a payload larger than h264-reader's 1 MiB `SeiReader` cap is still found. + #[test] + fn recovery_point_after_a_large_payload() { + let size = (1 << 20) + 1; + let mut sei = vec![0x06, 0x05]; // user_data_unregistered + sei.extend(std::iter::repeat_n(0xff, size / 255)); + sei.push((size % 255) as u8); + sei.extend(std::iter::repeat_n(0x01, size)); + sei.extend_from_slice(&RECOVERY_SEI[1..]); + assert!(sei_has_recovery_point(&sei)); + assert!(!sei_has_recovery_point(&sei[..sei.len() - RECOVERY_SEI.len() + 1])); + } + + /// A type-6 SEI without the recovery-point fields is not a random-access marker. + #[test] + fn malformed_recovery_point_is_ignored() { + // Empty payload, then a payload cut off inside `recovery_frame_cnt`. + assert!(!sei_has_recovery_point(&[0x06, 0x06, 0x00, 0x80])); + assert!(!sei_has_recovery_point(&[0x06, 0x06, 0x01, 0x00, 0x80])); + // A malformed message before a valid one doesn't hide it. + assert!(sei_has_recovery_point(&[0x06, 0x06, 0x00, 0x06, 0x01, 0x84, 0x80])); + } + + /// An empty recovery-point SEI before a non-IDR slice leaves that slice a delta frame. + #[tokio::test(start_paused = true)] + async fn empty_recovery_point_slice_is_delta() { + let aud: &[u8] = &[0x09, 0x10]; + let sei: &[u8] = &[0x06, 0x06, 0x00, 0x80]; + let pslice: &[u8] = &[0x61, 0xe0, 0x12, 0x34]; + + let mut split = Split::new(); + let frames = decode_one(&mut split, &mut annexb(&[aud, sei, pslice]), ts()); + + assert_eq!(frames.len(), 1); + assert!( + !frames[0].keyframe, + "an empty recovery-point SEI must not flag a keyframe" + ); + } + /// An access unit whose SEI is not a recovery point (e.g. pic_timing) stays a /// delta frame: only recovery-point SEIs mark open-GOP random access. #[tokio::test(start_paused = true)] diff --git a/rs/moq-mux/src/codec/h265/sps.rs b/rs/moq-mux/src/codec/h265/sps.rs index b67f9dabbb4..b071b0b7ecf 100644 --- a/rs/moq-mux/src/codec/h265/sps.rs +++ b/rs/moq-mux/src/codec/h265/sps.rs @@ -7,7 +7,7 @@ //! `bit_depth_chroma_minus8`. So when scuffle_h265 refuses a value (`InvalidData`), [`Sps::parse`] //! reads the head itself. A truncated SPS, or one whose head is invalid, is still an error. -use h264_parser::{bitreader::BitReader, eg::read_ue}; +use h264_reader::rbsp::{BitRead, BitReaderError}; use scuffle_h265::{NALUnitType, SpsNALUnit}; use super::{Error, Result}; @@ -118,56 +118,60 @@ impl SpsHead { /// Read the head of an SPS NAL unit (header included). `None` when the NAL is not an SPS, ends /// before `bit_depth_chroma_minus8`, or breaks a constraint on the head. fn parse(nal: &[u8]) -> Option { - let [header, header2, payload @ ..] = nal else { + let [header, header2, ..] = nal else { return None; }; // forbidden_zero_bit, nal_unit_type, and TemporalId 0 for an SPS (7.4.2.2). if header & 0x80 != 0 || super::split::nal_unit_type(*header) != NALUnitType::SpsNut || header2 & 0x07 != 1 { return None; } - let rbsp = h264_parser::nal::ebsp_to_rbsp(payload); - Self::read(&mut BitReader::new(&rbsp)).ok()? + Self::read(&mut crate::codec::annexb::rbsp(nal, 2)).ok()? } /// Read the head from the SPS RBSP: `Ok(None)` for an out-of-range value. - fn read(r: &mut BitReader<'_>) -> h264_parser::Result> { - r.skip_bits(4)?; // sps_video_parameter_set_id - let max_sub_layers_minus1 = r.read_bits(3)? as u8; - let temporal_id_nesting_flag = r.read_flag()?; + fn read(r: &mut impl BitRead) -> std::result::Result, BitReaderError> { + r.skip(4, "sps_video_parameter_set_id")?; + let max_sub_layers_minus1 = r.read::<3, u8>("sps_max_sub_layers_minus1")?; + let temporal_id_nesting_flag = r.read_bit("sps_temporal_id_nesting_flag")?; // 7.4.3.2.1: at most 7 sub-layers, and a single sub-layer is nested. if max_sub_layers_minus1 > 6 || (max_sub_layers_minus1 == 0 && !temporal_id_nesting_flag) { return Ok(None); } // profile_tier_level(1, sps_max_sub_layers_minus1), 7.3.3 - let profile_space = r.read_bits(2)? as u8; - let tier_flag = r.read_flag()?; - let profile_idc = r.read_bits(5)? as u8; - let profile_compatibility_flags = r.read_bits(32)?.to_be_bytes(); + let profile_space = r.read::<2, u8>("general_profile_space")?; + let tier_flag = r.read_bit("general_tier_flag")?; + let profile_idc = r.read::<5, u8>("general_profile_idc")?; + let profile_compatibility_flags = r.read::<32, u32>("general_profile_compatibility_flags")?.to_be_bytes(); // progressive_source, interlaced_source, non_packed_constraint and frame_only_constraint. - let constraint_flags = [(r.read_bits(4)? as u8) << 4, 0, 0, 0, 0, 0]; - r.skip_bits(44)?; // the other 43 constraint and reserved bits, and general_inbld_flag - let level_idc = r.read_u8()?; + let constraint_flags = [r.read::<4, u8>("general_constraint_flags")? << 4, 0, 0, 0, 0, 0]; + r.skip(44, "general_reserved_zero_43bits")?; // and general_inbld_flag + let level_idc = r.read::<8, u8>("general_level_idc")?; skip_sub_layers(r, max_sub_layers_minus1)?; - let sps_seq_parameter_set_id = read_ue(r)?; - let chroma_format_idc = read_ue(r)?; + let sps_seq_parameter_set_id = r.read_ue("sps_seq_parameter_set_id")?; + let chroma_format_idc = r.read_ue("chroma_format_idc")?; if sps_seq_parameter_set_id > 15 || chroma_format_idc > 3 { return Ok(None); } if chroma_format_idc == 3 { - r.skip_bits(1)?; // separate_colour_plane_flag + r.skip(1, "separate_colour_plane_flag")?; } - let pic_width = read_ue(r)?; - let pic_height = read_ue(r)?; + let pic_width = r.read_ue("pic_width_in_luma_samples")?; + let pic_height = r.read_ue("pic_height_in_luma_samples")?; // conformance_window_flag, then the left, right, top and bottom offsets. - let [left, right, top, bottom] = if r.read_flag()? { - [read_ue(r)?, read_ue(r)?, read_ue(r)?, read_ue(r)?] + let [left, right, top, bottom] = if r.read_bit("conformance_window_flag")? { + [ + r.read_ue("conf_win_left_offset")?, + r.read_ue("conf_win_right_offset")?, + r.read_ue("conf_win_top_offset")?, + r.read_ue("conf_win_bottom_offset")?, + ] } else { [0; 4] }; - let bit_depth_luma_minus8 = read_ue(r)?; - let bit_depth_chroma_minus8 = read_ue(r)?; + let bit_depth_luma_minus8 = r.read_ue("bit_depth_luma_minus8")?; + let bit_depth_chroma_minus8 = r.read_ue("bit_depth_chroma_minus8")?; if bit_depth_luma_minus8 > 8 || bit_depth_chroma_minus8 > 8 { return Ok(None); } @@ -200,34 +204,41 @@ impl SpsHead { } /// Read `sps_seq_parameter_set_id` from an SPS RBSP (the payload after the NAL header). -pub(crate) fn id(r: &mut BitReader<'_>) -> h264_parser::Result { - r.skip_bits(4)?; // sps_video_parameter_set_id - let max_sub_layers_minus1 = r.read_bits(3)? as u8; - r.skip_bits(1)?; // sps_temporal_id_nesting_flag - r.skip_bits(96)?; // the general profile's 88 bits and general_level_idc +pub(crate) fn id(r: &mut impl BitRead) -> std::result::Result { + r.skip(4, "sps_video_parameter_set_id")?; + let max_sub_layers_minus1 = r.read::<3, u8>("sps_max_sub_layers_minus1")?; + r.skip(1, "sps_temporal_id_nesting_flag")?; + r.skip(96, "general_profile_and_level")?; // the general profile's 88 bits and general_level_idc skip_sub_layers(r, max_sub_layers_minus1)?; - read_ue(r) + r.read_ue("sps_seq_parameter_set_id") } /// Skip the sub-layer half of profile_tier_level (7.3.3), which follows `general_level_idc`. -fn skip_sub_layers(r: &mut BitReader<'_>, max_sub_layers_minus1: u8) -> h264_parser::Result<()> { +fn skip_sub_layers(r: &mut impl BitRead, max_sub_layers_minus1: u8) -> std::result::Result<(), BitReaderError> { // 7.4.3.2.1: at most 7 sub-layers. if max_sub_layers_minus1 > 6 { - return Err(h264_parser::Error::MalformedSps( - "sps_max_sub_layers_minus1 out of range".into(), + return Err(BitReaderError::ReaderError( + "sps_max_sub_layers_minus1", + std::io::ErrorKind::InvalidData.into(), )); } let sub_layers = usize::from(max_sub_layers_minus1); let mut present = [(false, false); 6]; for flags in &mut present[..sub_layers] { - *flags = (r.read_flag()?, r.read_flag()?); + *flags = ( + r.read_bit("sub_layer_profile_present_flag")?, + r.read_bit("sub_layer_level_present_flag")?, + ); } if sub_layers > 0 { - r.skip_bits(2 * (8 - u32::from(max_sub_layers_minus1)))?; // reserved_zero_2bits + r.skip(2 * (8 - u32::from(max_sub_layers_minus1)), "reserved_zero_2bits")?; } for &(profile, level) in &present[..sub_layers] { // A sub-layer profile is the same 88 bits as the general one; sub_layer_level_idc is 8. - r.skip_bits(88 * u32::from(profile) + 8 * u32::from(level))?; + r.skip( + 88 * u32::from(profile) + 8 * u32::from(level), + "sub_layer_profile_and_level", + )?; } Ok(()) } @@ -270,14 +281,15 @@ mod tests { fn id_matches_the_full_parse() { for nal in [fixtures::SPS, fixtures::SPS_PYRAMID, SPS_SUB_LAYERS] { let full = SpsNALUnit::parse(&mut &nal[..]).unwrap().rbsp; - let rbsp = h264_parser::nal::ebsp_to_rbsp(&nal[2..]); assert_eq!( - id(&mut BitReader::new(&rbsp)).unwrap(), + id(&mut crate::codec::annexb::rbsp(nal, 2)).unwrap(), full.sps_seq_parameter_set_id as u32 ); } - let rbsp = h264_parser::nal::ebsp_to_rbsp(&SPS_SUB_LAYER_PROFILE_AND_LEVEL[2..]); - assert_eq!(id(&mut BitReader::new(&rbsp)).unwrap(), 0); + assert_eq!( + id(&mut crate::codec::annexb::rbsp(SPS_SUB_LAYER_PROFILE_AND_LEVEL, 2)).unwrap(), + 0 + ); } /// scuffle_h265 0.2.2 reads that `sub_layer_level_idc` twice and refuses the SPS; the head diff --git a/rs/moq-mux/src/codec/vp9/mod.rs b/rs/moq-mux/src/codec/vp9/mod.rs index 76e899a4e0b..4679a94fce6 100644 --- a/rs/moq-mux/src/codec/vp9/mod.rs +++ b/rs/moq-mux/src/codec/vp9/mod.rs @@ -71,30 +71,33 @@ impl FrameHeader { /// Reads only as far as the frame size (the last field we care about); /// everything after it is left untouched. pub fn parse(data: &[u8]) -> Result { - let mut r = BitReader::new(data); + use bitstream_io::BitRead; - if r.read(2)? != 0b10 { + let mut r = bitstream_io::BitReader::endian(data, bitstream_io::BigEndian); + let mut read = |bits: u32| r.read_var::(bits).map_err(|_| Error::Truncated); + + if read(2)? != 0b10 { return Err(Error::InvalidFrameMarker); } - let profile_low = r.read(1)?; - let profile_high = r.read(1)?; + let profile_low = read(1)?; + let profile_high = read(1)?; let profile = ((profile_high << 1) | profile_low) as u8; if profile == 3 { - r.skip(1)?; // reserved_zero + read(1)?; // reserved_zero } // show_existing_frame: displays a previously decoded frame, no header follows. - if r.read(1)? == 1 { - r.skip(3)?; // frame_to_show_map_idx + if read(1)? == 1 { + read(3)?; // frame_to_show_map_idx return Ok(Self { keyframe: false, key: None, }); } - let keyframe = r.read(1)? == 0; // frame_type: 0 = KEY_FRAME - r.skip(2)?; // show_frame, error_resilient_mode + let keyframe = read(1)? == 0; // frame_type: 0 = KEY_FRAME + read(2)?; // show_frame, error_resilient_mode if !keyframe { return Ok(Self { @@ -103,26 +106,26 @@ impl FrameHeader { }); } - if r.read(24)? != SYNC_CODE { + if read(24)? != SYNC_CODE { return Err(Error::InvalidSyncCode); } // color_config (VP9 spec §6.2.2). let bit_depth = if profile >= 2 { - if r.read(1)? == 1 { 12 } else { 10 } + if read(1)? == 1 { 12 } else { 10 } } else { 8 }; - let color_space = r.read(3)? as u8; + let color_space = read(3)? as u8; const CS_RGB: u8 = 7; let (subsampling_x, subsampling_y, full_range); if color_space != CS_RGB { - full_range = r.read(1)? == 1; + full_range = read(1)? == 1; if profile == 1 || profile == 3 { - subsampling_x = r.read(1)? == 1; - subsampling_y = r.read(1)? == 1; - r.skip(1)?; // reserved_zero + subsampling_x = read(1)? == 1; + subsampling_y = read(1)? == 1; + read(1)?; // reserved_zero } else { // Profiles 0 and 2 are 4:2:0 only. (subsampling_x, subsampling_y) = (true, true); @@ -132,13 +135,13 @@ impl FrameHeader { full_range = true; (subsampling_x, subsampling_y) = (false, false); if profile == 1 || profile == 3 { - r.skip(1)?; // reserved_zero + read(1)?; // reserved_zero } } // frame_size (VP9 spec §6.2.3): each field is `value - 1`. - let width = r.read(16)? + 1; - let height = r.read(16)? + 1; + let width = read(16)? + 1; + let height = read(16)? + 1; Ok(Self { keyframe: true, @@ -275,36 +278,6 @@ pub(crate) fn vpcc(vp9: &VP9) -> mp4_atom::VpcC { } } -/// Minimal MSB-first bit reader over a byte slice. -struct BitReader<'a> { - data: &'a [u8], - bit: usize, -} - -impl<'a> BitReader<'a> { - fn new(data: &'a [u8]) -> Self { - Self { data, bit: 0 } - } - - fn read(&mut self, n: u32) -> Result { - let mut value = 0; - for _ in 0..n { - let byte = self.bit / 8; - if byte >= self.data.len() { - return Err(Error::Truncated); - } - let shift = 7 - (self.bit % 8); - value = (value << 1) | u32::from((self.data[byte] >> shift) & 1); - self.bit += 1; - } - Ok(value) - } - - fn skip(&mut self, n: u32) -> Result<()> { - self.read(n).map(|_| ()) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index 621832497f7..e01d3790dbc 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -4182,6 +4182,8 @@ fn mapped(channels: u32, family: u8, streams: u8, coupled: u8, table: &[u8]) -> /// are refused. Retrying with gstreamer's widths could misread a descriptor written to /// the draft, so there is no fallback. fn explicit_layout(data: &[u8]) -> anyhow::Result { + use bitstream_io::BitRead; + let (&channel_count, rest) = data.split_first().context("truncated Opus channel configuration")?; let (&family, rest) = rest.split_first().context("truncated Opus channel configuration")?; anyhow::ensure!(channel_count > 0, "Opus channel_count is zero"); @@ -4197,13 +4199,13 @@ fn explicit_layout(data: &[u8]) -> anyhow::Result { return Ok(opus::Config::new(48_000, u32::from(channel_count))); } - let mut bits = Bits::new(rest); - let stream_count = bits.read(ceil_log2(u32::from(channel_count)))? + 1; + let mut bits = bitstream_io::BitReader::endian(rest, bitstream_io::BigEndian); + let stream_count = read_bits(&mut bits, ceil_log2(u32::from(channel_count)))? + 1; anyhow::ensure!( stream_count <= u32::from(channel_count), "Opus stream_count {stream_count} exceeds channel_count {channel_count}" ); - let coupled = bits.read(ceil_log2(stream_count + 1))?; + let coupled = read_bits(&mut bits, ceil_log2(stream_count + 1))?; anyhow::ensure!( coupled <= stream_count, "Opus coupled_stream_count {coupled} exceeds stream_count {stream_count}" @@ -4217,15 +4219,17 @@ fn explicit_layout(data: &[u8]) -> anyhow::Result { let silence = (1u32 << width) - 1; let mut table = Vec::with_capacity(channel_count as usize); for _ in 0..channel_count { - let entry = bits.read(width)?; + let entry = read_bits(&mut bits, width)?; table.push(if entry == silence { 255 } else { entry as u8 }); } - let pad = (8 - (bits.taken % 8)) % 8; - if pad > 0 { - let reserved = bits.read(pad as u8)?; + while !bits.byte_aligned() { + let reserved = read_bits(&mut bits, 1)?; anyhow::ensure!(reserved == 0, "nonzero reserved bits in Opus channel configuration"); } - bits.finish()?; + anyhow::ensure!( + bits.into_reader().is_empty(), + "trailing bytes in Opus channel configuration" + ); mapped( u32::from(channel_count), @@ -4242,53 +4246,10 @@ fn ceil_log2(n: u32) -> u8 { (u32::BITS - (n - 1).leading_zeros()) as u8 } -/// MSB-first reader over the bit-packed tail of an explicit Opus channel configuration. -struct Bits<'a> { - data: &'a [u8], - index: usize, - current: u8, - left: u8, - /// Bits returned so far, padding included, so the caller can byte-align. - taken: u32, -} - -impl Bits<'_> { - fn new(data: &[u8]) -> Bits<'_> { - Bits { - data, - index: 0, - current: 0, - left: 0, - taken: 0, - } - } - - fn read(&mut self, n: u8) -> anyhow::Result { - let mut value = 0u32; - for _ in 0..n { - if self.left == 0 { - self.current = *self - .data - .get(self.index) - .context("truncated Opus channel configuration")?; - self.index += 1; - self.left = 8; - } - self.left -= 1; - value = (value << 1) | u32::from((self.current >> self.left) & 1); - } - self.taken += u32::from(n); - Ok(value) - } - - /// The descriptor ended on the byte the layout consumed, with nothing after it. - fn finish(self) -> anyhow::Result<()> { - anyhow::ensure!( - self.left == 0 && self.index == self.data.len(), - "trailing bytes in Opus channel configuration" - ); - Ok(()) - } +/// Read an `n`-bit field, MSB-first, from the bit-packed tail of an explicit Opus channel +/// configuration. A zero-width field reads 0. +fn read_bits(bits: &mut impl bitstream_io::BitRead, n: u8) -> anyhow::Result { + bits.read_var(n.into()).context("truncated Opus channel configuration") } /// Validate the complete PES before exposing any of its declared Opus units. diff --git a/rs/moq-mux/src/container/ts/video_timing.rs b/rs/moq-mux/src/container/ts/video_timing.rs index a866d2faa4a..81684a9275f 100644 --- a/rs/moq-mux/src/container/ts/video_timing.rs +++ b/rs/moq-mux/src/container/ts/video_timing.rs @@ -14,6 +14,7 @@ use h264_reader::nal::slice::{ use h264_reader::nal::sps::PicOrderCntType; use h264_reader::nal::{Nal, RefNal, UnitType}; use h264_reader::nal::{pps::PicParameterSet, sps::SeqParameterSet}; +use h264_reader::rbsp::{BitRead, BitReader, BitReaderError}; use moq_net::Timestamp; use super::Unit; @@ -211,7 +212,7 @@ impl Clock { .insert(sps.sps_seq_parameter_set_id, Arc::new(sps)); } 34 => { - let rbsp = h264_parser::nal::ebsp_to_rbsp(bytes.get(2..).ok_or("short HEVC PPS")?); + let rbsp = crate::codec::annexb::rbsp_bytes(&bytes, 2).ok_or("invalid HEVC PPS")?; let pps = HevcPps::parse(&rbsp)?; Arc::make_mut(&mut ctx).pictures.insert(pps.id, pps); } @@ -220,7 +221,7 @@ impl Clock { if bytes[0] & 1 != 0 || second & 0xf8 != 0 { return Err("multilayer HEVC timing is unsupported".to_owned()); } - let rbsp = h264_parser::nal::ebsp_to_rbsp(bytes.get(2..).ok_or("short HEVC slice")?); + let rbsp = crate::codec::annexb::rbsp_bytes(&bytes, 2).ok_or("invalid HEVC slice")?; let (lsb, width, rate) = hevc_prefix(&rbsp, kind, &ctx.pictures, &ctx.sets)?; let idr = matches!(kind, 19 | 20); let reset = idr || matches!(kind, 16..=18) || (kind == 21 && self.hevc.is_none()); @@ -271,15 +272,16 @@ struct HevcPps { impl HevcPps { fn parse(rbsp: &[u8]) -> Result { let parsed = (|| { - let mut r = h264_parser::bitreader::BitReader::new(rbsp); - let id = h264_parser::eg::read_ue(&mut r)?; - let sps = h264_parser::eg::read_ue(&mut r)?; - r.skip_bits(1)?; // dependent_slice_segments_enabled_flag, absent on the first slice - let output = r.read_flag()?; - let extra = r.read_bits(3)? as u8; - h264_parser::Result::Ok(Self { id, sps, output, extra }) + let mut r = BitReader::new(rbsp); + let id = r.read_ue("pps_pic_parameter_set_id")?; + let sps = r.read_ue("pps_seq_parameter_set_id")?; + // Absent on the first slice. + r.skip(1, "dependent_slice_segments_enabled_flag")?; + let output = r.read_bit("output_flag_present_flag")?; + let extra = r.read::<3, u8>("num_extra_slice_header_bits")?; + Ok::<_, BitReaderError>(Self { id, sps, output, extra }) })(); - let pps = parsed.map_err(|err| err.to_string())?; + let pps = parsed.map_err(|err| format!("{err:?}"))?; if pps.id > 63 || pps.sps > 15 { return Err("invalid HEVC parameter-set id".to_owned()); } @@ -293,34 +295,35 @@ fn hevc_prefix( pictures: &BTreeMap, sets: &BTreeMap>, ) -> Result<(i64, u32, Option), String> { - let mut r = h264_parser::bitreader::BitReader::new(rbsp); - if !r.read_flag().map_err(|err| err.to_string())? { + let mut r = BitReader::new(rbsp); + let err = |err: BitReaderError| format!("{err:?}"); + if !r.read_bit("first_slice_segment_in_pic_flag").map_err(err)? { return Err("missing first independent HEVC slice".to_owned()); } if (16..=23).contains(&kind) { - r.skip_bits(1).map_err(|err| err.to_string())?; + r.skip(1, "no_output_of_prior_pics_flag").map_err(err)?; } - let id = h264_parser::eg::read_ue(&mut r).map_err(|err| err.to_string())?; + let id = r.read_ue("slice_pic_parameter_set_id").map_err(err)?; let pps = pictures.get(&id).ok_or("missing HEVC PPS")?; let sps = sets.get(&u64::from(pps.sps)).ok_or("missing HEVC SPS")?; // The first independent slice has no segment address or inherited header. // Stop as soon as its POC is available, before reference lists and slice data. - r.skip_bits(pps.extra.into()).map_err(|err| err.to_string())?; - let slice_type = h264_parser::eg::read_ue(&mut r).map_err(|err| err.to_string())?; + r.skip(pps.extra.into(), "slice_reserved_flag").map_err(err)?; + let slice_type = r.read_ue("slice_type").map_err(err)?; if slice_type > 2 { return Err("invalid HEVC slice type".to_owned()); } if pps.output { - r.skip_bits(1).map_err(|err| err.to_string())?; + r.skip(1, "pic_output_flag").map_err(err)?; } if sps.separate_colour_plane_flag { - r.skip_bits(2).map_err(|err| err.to_string())?; + r.skip(2, "colour_plane_id").map_err(err)?; } let width = u32::from(sps.log2_max_pic_order_cnt_lsb_minus4) + 4; let lsb = if matches!(kind, 19 | 20) { 0 } else { - i64::from(r.read_bits(width).map_err(|err| err.to_string())?) + i64::from(r.read_var::(width, "slice_pic_order_cnt_lsb").map_err(err)?) }; let info = sps.vui_parameters.as_ref().and_then(|vui| vui.vui_timing_info.as_ref()); let rate = info diff --git a/rs/moq-rtc/src/codec/bitstream_test.rs b/rs/moq-rtc/src/codec/bitstream_test.rs index 005a06993b7..3f1e76df018 100644 --- a/rs/moq-rtc/src/codec/bitstream_test.rs +++ b/rs/moq-rtc/src/codec/bitstream_test.rs @@ -31,7 +31,7 @@ fn annexb(nals: &[&[u8]]) -> Bytes { #[tokio::test(start_paused = true)] async fn h264_annexb_frame_publishes_catalog_entry() { // Real SPS+PPS pair lifted from moq-mux's avc3 catalog test. Anything - // shorter and h264-parser's RBSP decoder runs out of bytes parsing + // shorter and the SPS parser runs out of bytes reading // vui_parameters; not worth synthesizing a smaller one by hand. let sps: &[u8] = &[ 0x67, 0x42, 0xc0, 0x1f, 0xda, 0x01, 0x40, 0x16, 0xe9, 0xb8, 0x08, 0x08, 0x0a, 0x00, 0x00, 0x07, 0xd0, 0x00, diff --git a/rs/moq-rtsp/src/fake.rs b/rs/moq-rtsp/src/fake.rs index 862c22e61d1..848390445be 100644 --- a/rs/moq-rtsp/src/fake.rs +++ b/rs/moq-rtsp/src/fake.rs @@ -21,7 +21,7 @@ pub const H264_PPS: &[u8] = &[0x68, 0xcb, 0x83, 0xcb, 0x20]; /// An IDR slice; its contents are never decoded. pub const H264_IDR: &[u8] = &[0x65, 0x88, 0x84, 0x21, 0xa0]; /// A recovery-point SEI: the non-IDR I-picture after it is an open-GOP keyframe. -pub const H264_RECOVERY: &[u8] = &[0x06, 0x06, 0x02, 0x00, 0x40, 0x80]; +pub const H264_RECOVERY: &[u8] = &[0x06, 0x06, 0x01, 0x84, 0x80]; /// A non-IDR slice. pub const H264_SLICE: &[u8] = &[0x41, 0x9a, 0x02, 0x04, 0x80]; From f882be3ded89c8861bdc5f643f49128571a2cafd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 15:46:22 +0000 Subject: [PATCH 86/86] fix(moq-mux): keep fMP4 decode times increasing in sub-GOP fragments (#7491) Co-authored-by: Claude Opus 5.5 Co-authored-by: quest fixer --- quest/m1/README.md | 1 - quest/m1/fmp4-bframe-decode-time.md | 13 -- rs/moq-mux/src/container/fmp4/export.rs | 159 +++++++++++++++-- rs/moq-mux/src/container/fmp4/export_test.rs | 176 +++++++++++++++++++ rs/moq-mux/src/container/fmp4/fragmenter.rs | 11 +- rs/moq-mux/src/container/fmp4/mod.rs | 11 +- rs/moq-mux/src/container/fmp4/muxer.rs | 6 +- 7 files changed, 333 insertions(+), 44 deletions(-) delete mode 100644 quest/m1/fmp4-bframe-decode-time.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 4fb51c40c78..e7d574018a7 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -185,7 +185,6 @@ per-frame CPU growth since #5926. - [JS ordered reads wait only on owed groups](/quest/m1/js-ordered-owed.md) - @moq/net matches Rust's owed-group wait - [Binding announcements always carry captures](/quest/m1/ffi-required-captures.md) - moq-ffi and its generated bindings make `captures` a required list, as #7266 always sets it - [HLS export follows a resolution change](/quest/m1/hls-reinit-same-codec.md) - a decoder-identity change under the same codec string starts a new init and segment tag -- [fMP4 decode times keep increasing](/quest/m1/fmp4-bframe-decode-time.md) - fragments shorter than a GOP of B-frame video never step `tfdt` backwards - [moq-quic deletes its remaining 0-RTT code](/quest/m1/quic-zero-rtt-leftovers.md) - only the code that drops a 0-RTT packet remains, with a test - [Gate the no-em-dash rule](/quest/m1/em-dash-gate.md) - `just check` fails on an em dash in any tracked text file - [Every package shares one minor version](/quest/m1/shared-version.md) - a recipe sets every published package to one minor at the cut (next: 0.22.0), and `just check` enforces it and the `0.0.x` rule for experiments diff --git a/quest/m1/fmp4-bframe-decode-time.md b/quest/m1/fmp4-bframe-decode-time.md deleted file mode 100644 index 30789138dea..00000000000 --- a/quest/m1/fmp4-bframe-decode-time.md +++ /dev/null @@ -1,13 +0,0 @@ -# [M] fMP4 fragments shorter than a GOP keep decode times increasing - -## Goal - -With B-frame video and a fragment duration shorter than a GOP, every fragment the fMP4 exporter writes has a `tfdt` at or after the previous fragment's end, so `moq export fmp4`, HLS packaging, and an unfinished `moq export mp4` file are valid fragmented MP4. - -## Plan - -Found by the agent on [moq-dev/moq#7357](https://github.com/moq-dev/moq/pull/7357): with `--fragment-duration` shorter than a GOP of B-frame video, the exporter writes fragment decode times that step backwards. The finished `export mp4` file is correct because it rebuilds its sample tables, but the fragmented output isn't. - -Decided 2026-10-30 (maintainer, follow-up planning after the /quest-afk run). The fix is in how the exporter infers B-frame sample durations and decode times, which `export fmp4`, HLS packaging, and `export mp4` share; fix it there once rather than per consumer. Test with reordered video cut into fragments shorter than a GOP: decode times never decrease, presentation order and A/V timing are unchanged, and ffprobe reads every fragment without warnings. - -Public API: none. Wire: none. diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index ed9cdad1f93..b52bcaa6660 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -199,6 +199,18 @@ impl Rendition { matches!(self, Rendition::Video(_)) } + /// Whether frames may present out of decode order: what the catalog's `reorder` declares, + /// else whatever the codec allows. + fn may_reorder(&self) -> bool { + match self { + Rendition::Video(config) => match config.reorder { + Some(depth) => depth > 0, + None => crate::codec::video::max_reorder(config) > 0, + }, + Rendition::Audio(_) => false, + } + } + /// Fallback duration for a trailing frame that carries no per-sample duration. fn default_frame(&self) -> Duration { match self { @@ -269,6 +281,14 @@ struct Fmp4Track { /// instead of writing the track's history twice. written: Option, + /// Where the last written fragment's decode timeline ended, in ticks at `timescale`. + /// + /// A fragment cut inside a GOP continues from here rather than anchoring at its first + /// frame's presentation time: with reordered video that frame is often a reference + /// presented after the B-frames decoded next, so its presentation time would put the + /// fragment's `tfdt` ahead of the one that follows. + decode_end: Option, + /// The first presentation time read before the init segment, which bounds how much /// media queues behind it. backlog: Option, @@ -300,6 +320,7 @@ impl Fmp4Track { resume: None, group_finished: false, written: None, + decode_end: None, backlog: None, track_id, timescale, @@ -528,6 +549,12 @@ impl Export { /// marker; audio and samples with explicit durations remain immediate. Otherwise /// the cap applies in addition to the group boundary. /// + /// A fragment cut inside a GOP continues the decode timeline where the previous one + /// ended, so B-frame video keeps increasing decode times. Such a fragment cannot see + /// the reordering around it, so unless the catalog declares `reorder: 0` (or the codec + /// never reorders), a video sample with unknown duration lasts no longer than the + /// catalog frame rate's cadence. + /// /// Accepts either `Duration` or `Option` (where `None` restores /// the per-group default). pub fn with_fragment_duration(mut self, duration: impl Into>) -> Self { @@ -1073,24 +1100,39 @@ fn should_flush(track: &Fmp4Track, frame: &Frame, fragment_duration: Option) -> Result { - if frames.is_empty() { +/// +/// A run that opens a GOP (and every audio run) anchors its `tfdt` at its first frame's +/// presentation time, which re-syncs the decode timeline after a gap the durations did not +/// cover. A run cut inside a GOP continues the timeline where the track's previous fragment +/// ended, so decode times never step back however the GOP is reordered. +fn encode_fragment( + track: &mut Fmp4Track, + sequence_number: &mut u32, + frames: Vec, + opens_gop: bool, +) -> Result { + let Some(first) = frames.first() else { return Err(Error::NoFrames.into()); - } - let seq = *sequence_number; - *sequence_number += 1; + }; let timescale = moq_net::Timescale::new(track.timescale)?; + let base_dts = match track.decode_end { + Some(end) if !opens_gop => end, + _ => super::base_ticks(first, timescale)?, + }; let info = crate::container::fmp4::FragmentInfo { track_id: track.track_id, timescale, - sequence_number: seq, + sequence_number: *sequence_number, kind: if track.is_video { super::Kind::Video } else { super::Kind::Audio }, }; - Ok(crate::container::fmp4::encode_fragment(info, &frames)?) + let (data, decode_end) = super::encode_at(info, base_dts, &frames)?; + *sequence_number += 1; + track.decode_end = Some(decode_end); + Ok(data) } /// Encode a buffered run and wrap it with the metadata a segmenting consumer needs. @@ -1123,9 +1165,16 @@ fn emit_fragment( let latest = frames.iter().map(|frame| Duration::from(frame.timestamp)).max(); track.written = track.written.max(latest); let timescale = moq_net::Timescale::new(track.timescale)?; - infer_missing_durations(&mut frames, successor, track.default_frame, timescale)?; + // A run that starts or ends inside a GOP sees only part of its reordering. + let partial = !track.buffer_independent || successor.is_some_and(|next| !next.keyframe); + let run = if partial && track.config.may_reorder() { + Run::PartialGop + } else { + Run::Whole + }; + infer_missing_durations(&mut frames, successor, track.default_frame, timescale, run)?; let duration = fragment_duration(&frames, track.default_frame, timescale)?; - let data = encode_fragment(track, sequence_number, frames)?; + let data = encode_fragment(track, sequence_number, frames, independent)?; Ok(Fragment { data, independent, @@ -1204,25 +1253,53 @@ pub(crate) fn apply_codec_durations(frames: &mut [Frame], opus: bool) { /// The publisher is the one that actually knows where its group's content ends, and /// [`Producer::cut`](crate::container::Producer::cut) is how it says so: the durations it /// writes arrive already set and are left alone here. +/// +/// Frames arrive in decode order, so a gap is a decode duration only when presentation order +/// matches it. A run whose timestamps go backwards is reordered video, and every frame takes +/// the catalog cadence. A [`Run::PartialGop`] cannot rule reordering out from its own frames, +/// since the B-frames that would reveal it may sit in a neighbouring run, so no gap there may +/// exceed the cadence: in reordered video the gap to the next frame decoded spans the frames +/// decoded after it, and a longer duration would push the decode timeline past the next GOP. pub(crate) fn infer_missing_durations( frames: &mut [Frame], successor: Option<&Frame>, default_frame: Duration, timescale: moq_net::Timescale, + run: Run, ) -> Result<()> { let infer_from_pts = pts_monotonic(frames, successor); + let cadence = fallback_duration(default_frame, timescale); for i in 0..frames.len() { let successor = infer_from_pts .then(|| duration_bound(frames, successor, i)) .flatten() .cloned(); + let stated = frames[i].duration.is_some_and(|duration| !duration.is_zero()); infer_missing_duration(&mut frames[i], successor.as_ref(), default_frame, timescale)?; + if run == Run::PartialGop + && !stated + && let (Some(inferred), Some(cadence)) = (frames[i].duration, cadence) + && Duration::from(inferred) > Duration::from(cadence) + { + frames[i].duration = Some(cadence); + } } Ok(()) } +/// How much of its reordering a run of frames passed to [`infer_missing_durations`] shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Run { + /// Whole GOPs, or media that presents in decode order, so its own timestamps show + /// whether it is reordered. + Whole, + /// Video that may reorder, cut inside a GOP, whose reordering may only show in a + /// neighbouring run. + PartialGop, +} + /// Infer one frame's duration from a known same-group successor, else the catalog cadence. pub(crate) fn infer_missing_duration( frame: &mut Frame, @@ -1373,7 +1450,14 @@ mod tests { #[test] fn infer_missing_durations_uses_default_for_trailing_sample() { let mut frames = vec![frame(0, Some(0)), frame(41_667, None), frame(83_334, None)]; - infer_missing_durations(&mut frames, None, Duration::from_millis(33), moq_net::Timescale::MICRO).unwrap(); + infer_missing_durations( + &mut frames, + None, + Duration::from_millis(33), + moq_net::Timescale::MICRO, + Run::Whole, + ) + .unwrap(); assert_eq!(frames[0].duration, Some(ts(41_667))); assert_eq!(frames[1].duration, Some(ts(41_667))); @@ -1387,7 +1471,14 @@ mod tests { #[test] fn infer_missing_duration_uses_default_for_single_frame() { let mut frames = vec![frame(83_333, Some(0))]; - infer_missing_durations(&mut frames, None, Duration::from_millis(40), moq_net::Timescale::MICRO).unwrap(); + infer_missing_durations( + &mut frames, + None, + Duration::from_millis(40), + moq_net::Timescale::MICRO, + Run::Whole, + ) + .unwrap(); assert_eq!(frames[0].duration, Some(ts(40_000))); assert_eq!( @@ -1397,7 +1488,8 @@ mod tests { } /// The duration cap splits a GOP across fragments, so the successor is a delta frame - /// in the SAME group. That one is a real bound and is still used. + /// in the SAME group. In video that presents in decode order that one is a real bound + /// and is still used. #[test] fn infer_trailing_duration_from_successor_frame() { let successor = frame(83_334, None); @@ -1407,6 +1499,7 @@ mod tests { Some(&successor), Duration::from_millis(33), moq_net::Timescale::MICRO, + Run::Whole, ) .unwrap(); @@ -1417,11 +1510,38 @@ mod tests { ); } + /// Video that may reorder, cut inside a GOP, cannot tell a reference frame's gap to the + /// B-frames decoded after it from a duration, so no inferred duration there exceeds the + /// cadence. A shorter gap still times its frame, and a stated duration is kept. + #[test] + fn a_partial_gop_caps_inferred_durations_at_the_cadence() { + let successor = frame(100_000, None); + let mut frames = vec![frame(0, Some(50_000)), frame(10_000, None), frame(20_000, None)]; + infer_missing_durations( + &mut frames, + Some(&successor), + Duration::from_millis(33), + moq_net::Timescale::MICRO, + Run::PartialGop, + ) + .unwrap(); + + let durations: Vec<_> = frames.iter().map(duration_micros).collect(); + assert_eq!(durations, vec![50_000, 10_000, 33_000]); + } + #[test] fn inferred_microsecond_clock_carries_fractional_ticks_forward() { let timescale = moq_net::Timescale::new(30_000).unwrap(); let mut frames = vec![frame(0, None), frame(33_333, None), frame(66_667, None)]; - infer_missing_durations(&mut frames, None, Duration::from_nanos(33_333_333), timescale).unwrap(); + infer_missing_durations( + &mut frames, + None, + Duration::from_nanos(33_333_333), + timescale, + Run::Whole, + ) + .unwrap(); let durations: Vec<_> = frames .iter() @@ -1446,6 +1566,7 @@ mod tests { Some(&next_group), Duration::from_millis(33), moq_net::Timescale::MICRO, + Run::Whole, ) .unwrap(); @@ -1462,7 +1583,14 @@ mod tests { #[test] fn infer_stops_at_an_interior_group_boundary() { let mut frames = vec![frame(0, None), frame(21_333, None), group_start(600_000_000)]; - infer_missing_durations(&mut frames, None, Duration::from_millis(21), moq_net::Timescale::MICRO).unwrap(); + infer_missing_durations( + &mut frames, + None, + Duration::from_millis(21), + moq_net::Timescale::MICRO, + Run::Whole, + ) + .unwrap(); assert_eq!(frames[0].duration, Some(ts(21_333)), "same group, real delta"); assert_eq!(duration_micros(&frames[1]), 21_000, "bounded by the next group"); @@ -1490,6 +1618,7 @@ mod tests { None, Duration::from_micros(21_333), moq_net::Timescale::MICRO, + Run::Whole, ) .unwrap(); @@ -1513,6 +1642,7 @@ mod tests { Some(&next_group), Duration::from_millis(50), moq_net::Timescale::MICRO, + Run::Whole, ) .unwrap(); @@ -1533,6 +1663,7 @@ mod tests { Some(&successor), Duration::from_millis(33), moq_net::Timescale::MICRO, + Run::Whole, ) .unwrap(); diff --git a/rs/moq-mux/src/container/fmp4/export_test.rs b/rs/moq-mux/src/container/fmp4/export_test.rs index 2d97ebd1793..5f15bae7312 100644 --- a/rs/moq-mux/src/container/fmp4/export_test.rs +++ b/rs/moq-mux/src/container/fmp4/export_test.rs @@ -640,6 +640,27 @@ async fn zero_fragment_duration_uses_the_successor_then_the_cut() { assert!(drain_now(&mut exporter).await.is_empty()); } +/// One sample per fragment hides the B-frames a reference frame's successor gap spans, so H.264 +/// whose catalog declares no `reorder` takes no more than the 30 fps cadence. Declaring +/// `reorder: 0` says it presents in decode order, so the 50 ms gap is a real duration. +#[tokio::test(start_paused = true)] +async fn zero_fragment_duration_trusts_a_long_gap_only_without_reordering() { + let cadence = moq_net::Timestamp::from_scale(1_000, 30_000).unwrap().into(); + for (reorder, expected) in [(None, cadence), (Some(0), std::time::Duration::from_millis(50))] { + let mut live = Live::avc3(); + for config in live.catalog.modify().unwrap().video.renditions.values_mut() { + config.reorder = reorder; + } + live.track.write(video_frame(0, true)).unwrap(); + live.track.write(video_frame(50_000, false)).unwrap(); + let mut exporter = crate::container::fmp4::Export::new(live.source().await, live.catalog_stream().await) + .with_fragment_duration(std::time::Duration::ZERO); + chunk_now(&mut exporter).await.init().expect("init"); + let first = chunk_now(&mut exporter).await.fragment().expect("first sample"); + assert_eq!(first.duration, expected, "reorder {reorder:?}"); + } +} + #[tokio::test(start_paused = true)] async fn ntsc_tail_uses_a_representable_catalog_cadence() { use hang::catalog::{Container, H264, VideoConfig}; @@ -2584,3 +2605,158 @@ async fn a_rendition_moved_to_another_sample_entry_fails() { "{err:?}" ); } + +/// Export `bbb5s_cbr.ts` (HEVC with B-pyramids, and Opus) cut at `cap`, returning the init +/// segment and every fragment it writes. +async fn export_reordered_clip(cap: Option) -> (Bytes, Vec) { + let data = include_bytes!("../ts/test_data/bbb5s_cbr.ts"); + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); + let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve()); + import.decode(&BytesMut::from(&data[..])).unwrap(); + import.finish().unwrap(); + + let catalog_stream = crate::catalog::Consumer::<()>::new(&consumer, crate::catalog::CatalogFormat::Hang) + .await + .expect("catalog consumer"); + let mut exporter = crate::container::fmp4::Export::new(crate::source::announced(&consumer).await, catalog_stream) + .with_max_delay(RECORDING_MAX_DELAY) + .with_fragment_duration(cap); + let mut init = None; + let mut fragments = Vec::new(); + // The tracks finished, but the broadcast stays up: the export is done once it goes quiet. + while let Ok(next) = kio::clock::timeout(RECORDING_MAX_DELAY * 4, exporter.next_chunk()).await { + let Some(chunk) = next.expect("exporter failed") else { + break; + }; + match chunk { + crate::container::fmp4::Chunk::Init(bytes) => init = Some(bytes), + crate::container::fmp4::Chunk::Fragment(fragment) => fragments.push(fragment), + } + } + (init.expect("an init segment"), fragments) +} + +/// Every track's media timescale, by track id, from an init segment. +fn track_scales(init: &Bytes) -> std::collections::BTreeMap { + let mut cursor = Cursor::new(init.as_ref()); + let mut scales = std::collections::BTreeMap::new(); + while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).expect("decode init") { + if let mp4_atom::Any::Moov(moov) = atom { + for trak in &moov.trak { + let scale = moq_net::Timescale::new(trak.mdia.mdhd.timescale.into()).unwrap(); + scales.insert(trak.tkhd.track_id, scale); + } + } + } + scales +} + +/// Every sample's presentation time in microseconds, by track id, in decode order. +fn presentation_times( + init: &Bytes, + fragments: &[crate::container::fmp4::Fragment], +) -> std::collections::BTreeMap> { + let scales = track_scales(init); + let mut times: std::collections::BTreeMap> = Default::default(); + for fragment in fragments { + let id = super::first_traf(&fragment.data).tfhd.track_id; + let kind = if id == 1 { + super::Kind::Video + } else { + super::Kind::Audio + }; + let track = super::Track::new(scales[&id], kind); + let samples = super::decode(fragment.data.clone(), None, track).expect("decode fragment"); + times + .entry(id) + .or_default() + .extend(samples.iter().map(|sample| sample.timestamp.as_micros())); + } + times +} + +/// The warnings ffprobe prints reading every packet of an fMP4 file. +fn ffprobe_warnings(file: Vec) -> String { + use std::io::Write; + + let mut child = std::process::Command::new("ffprobe") + .args([ + "-v", + "warning", + "-show_entries", + "packet=pts,dts", + "-of", + "csv", + "-i", + "pipe:0", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("ffprobe is in the dev shell"); + let mut stdin = child.stdin.take().unwrap(); + let writer = std::thread::spawn(move || stdin.write_all(&file)); + let output = child.wait_with_output().unwrap(); + writer.join().unwrap().expect("feed ffprobe"); + assert!(output.status.success(), "ffprobe failed"); + String::from_utf8_lossy(&output.stderr).into_owned() +} + +/// Fragments shorter than a GOP of B-frame video continue the decode timeline instead of +/// anchoring at a reference frame that presents after the B-frames decoded next, and time each +/// sample by the cadence rather than the gap to that reference. Each track's `tfdt` then starts +/// at or after where its previous fragment ended, every sample presents when it does in the +/// whole-GOP export, and ffprobe reads the file without complaint. +#[tokio::test(start_paused = true)] +async fn reordered_video_cut_inside_a_gop_keeps_decode_times_increasing() { + let video = |fragments: &[crate::container::fmp4::Fragment]| { + fragments + .iter() + .filter(|fragment| super::first_traf(&fragment.data).tfhd.track_id == 1) + .count() + }; + let (init, whole) = export_reordered_clip(None).await; + let reference = presentation_times(&init, &whole); + assert!( + reference[&1].windows(2).any(|pair| pair[1] < pair[0]), + "the clip's video must reorder" + ); + + for cap in [std::time::Duration::ZERO, std::time::Duration::from_millis(100)] { + let (init, fragments) = export_reordered_clip(Some(cap)).await; + assert!(video(&fragments) > video(&whole), "{cap:?} must cut inside a GOP"); + + let mut ends = std::collections::BTreeMap::new(); + for fragment in &fragments { + let traf = super::first_traf(&fragment.data); + let start = traf.tfdt.expect("a tfdt").base_media_decode_time; + let span: u64 = traf.trun[0] + .samples + .iter() + .map(|sample| u64::from(sample.duration.expect("a sample duration"))) + .sum(); + if let Some(end) = ends.insert(traf.tfhd.track_id, start + span) { + assert!( + start >= end, + "{cap:?}: track {} decodes from {start} before its previous fragment ended at {end}", + traf.tfhd.track_id, + ); + } + } + + assert_eq!( + presentation_times(&init, &fragments), + reference, + "{cap:?}: every sample presents as in the whole-GOP export" + ); + + let mut file = init.to_vec(); + for fragment in &fragments { + file.extend_from_slice(&fragment.data); + } + assert_eq!(ffprobe_warnings(file), "", "{cap:?}"); + } +} diff --git a/rs/moq-mux/src/container/fmp4/fragmenter.rs b/rs/moq-mux/src/container/fmp4/fragmenter.rs index 8c19ba403d3..0da9f631737 100644 --- a/rs/moq-mux/src/container/fmp4/fragmenter.rs +++ b/rs/moq-mux/src/container/fmp4/fragmenter.rs @@ -171,15 +171,10 @@ impl Fragmenter { super::Kind::Audio }, }; - let ticks = frame - .duration - .map(|duration| super::trun_duration(duration, self.timescale)) - .transpose()? - .unwrap_or(0); // Advance by the value the trun stores, so the next tfdt continues exactly what // this fragment claimed. - let next_dts = dts.checked_add(u64::from(ticks)).ok_or(super::Error::PtsOverflow)?; - let data = super::encode_at(info, dts, std::slice::from_ref(&frame))?; + let (data, next_dts) = super::encode_at(info, dts, std::slice::from_ref(&frame))?; + let ticks = next_dts - dts; self.sequence = self.sequence.wrapping_add(1); self.dts = Some(next_dts); @@ -190,7 +185,7 @@ impl Fragmenter { independent: !self.is_video || frame.keyframe, // Describe the exact duration written into trun, including its timescale // quantization, so playlist metadata and media advance by the same amount. - duration: Duration::from_secs_f64(f64::from(ticks) / self.timescale.as_u64() as f64), + duration: Duration::from_secs_f64(ticks as f64 / self.timescale.as_u64() as f64), }) } diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index da472731c0b..9f0342d8f7a 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -674,7 +674,7 @@ pub(crate) fn encode_fragment(info: FragmentInfo, frames: &[Frame]) -> Result Resul /// signed composition offset, so a reordered frame keeps its presentation time even when the /// fragment holds a single sample. /// -/// Returns an empty `Bytes` when `frames` is empty. -fn encode_at(info: FragmentInfo, base_dts: u64, frames: &[Frame]) -> Result { +/// Returns the fragment with the decode time it ends at, which is where the next fragment on +/// the same timeline continues, or an empty `Bytes` and `base_dts` when `frames` is empty. +pub(super) fn encode_at(info: FragmentInfo, base_dts: u64, frames: &[Frame]) -> Result<(Bytes, u64)> { let FragmentInfo { track_id, timescale, @@ -721,7 +722,7 @@ fn encode_at(info: FragmentInfo, base_dts: u64, frames: &[Frame]) -> Result Result