From c8d0c384ce2164800d8e3b8d72d42751c79f85cf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 14:02:58 -0700 Subject: [PATCH 1/4] chore(quest): plan the ten unlabeled issues Every open issue without the `quest` label becomes a quest, settles an existing plan quest, or is retired: - #3533 TS export stall: remove the bystander fence #3375 added, after #3529. - #3534 publisher priority survives a moq-transport hop, track-scoped. - #3492 TRACK_STATUS is refused with NOT_SUPPORTED once the error registry lands; a real implementation stays out (#3519 closed). - #3532 the connect race fails on auth only when both arms have; moq-ffi gains the WebSocket toggles. Blind subscribe stays refused. - #2388 an engine-wide WebKit gate now, and a dormant re-enable quest gated on WebKit 319818. - #2815 moq-cli lifts the one-adaptive-stage refusal the allocator obsoleted. - #2933 route cold cost: MoqRoute gains `cold`, defaulting to warm. - #2857 binding rate control: the bindings mirror Allocator and Reservation; OBS adoption is its own quest under obs-moq-video. - #3058 revalidation: tier applies in place, an alias change closes with its own reason. - #3452 is retired without a quest: transcoding is invisible to OBS. Co-Authored-By: Claude Fable 5.1 --- quest/m0/3492-ietf-track-status-refusal.md | 37 +++++++++++ quest/m0/3532-connect-auth-race.md | 40 ++++++++++++ quest/m0/3533-ts-export-restart-stall.md | 59 ++++++++++++++++++ quest/m0/3534-ietf-publisher-priority.md | 62 +++++++++++++++++++ quest/m0/README.md | 5 ++ quest/m0/webkit-webtransport-gate.md | 23 +++++++ ...ast-bandwidth-estimates-and-reservation.md | 2 +- quest/m1/2815-lift-adaptive-stage-refusal.md | 38 ++++++++++++ quest/m1/README.md | 5 +- quest/m1/binding-rate-control.md | 59 ++++++++++++++++++ quest/m1/ladder/README.md | 2 +- quest/m1/plan-binding-rate-control.md | 44 ------------- quest/m1/plan-route-cold-cost.md | 43 ------------- quest/m1/route-cold-cost.md | 40 ++++++++++++ quest/m2/README.md | 3 +- quest/m2/obs-moq-video/README.md | 1 + quest/m2/obs-moq-video/rate-control.md | 25 ++++++++ quest/m2/plan-revalidation-updates.md | 48 -------------- quest/m2/revalidation-updates.md | 44 +++++++++++++ quest/m2/safari-webtransport.md | 29 +++++++++ quest/m2/wildcard/README.md | 4 -- 21 files changed, 469 insertions(+), 144 deletions(-) create mode 100644 quest/m0/3492-ietf-track-status-refusal.md create mode 100644 quest/m0/3532-connect-auth-race.md create mode 100644 quest/m0/3533-ts-export-restart-stall.md create mode 100644 quest/m0/3534-ietf-publisher-priority.md create mode 100644 quest/m0/webkit-webtransport-gate.md create mode 100644 quest/m1/2815-lift-adaptive-stage-refusal.md create mode 100644 quest/m1/binding-rate-control.md delete mode 100644 quest/m1/plan-binding-rate-control.md delete mode 100644 quest/m1/plan-route-cold-cost.md create mode 100644 quest/m1/route-cold-cost.md create mode 100644 quest/m2/obs-moq-video/rate-control.md delete mode 100644 quest/m2/plan-revalidation-updates.md create mode 100644 quest/m2/revalidation-updates.md create mode 100644 quest/m2/safari-webtransport.md diff --git a/quest/m0/3492-ietf-track-status-refusal.md b/quest/m0/3492-ietf-track-status-refusal.md new file mode 100644 index 0000000000..df9fd4b02f --- /dev/null +++ b/quest/m0/3492-ietf-track-status-refusal.md @@ -0,0 +1,37 @@ +# [XS] TRACK_STATUS is refused, not dropped + +## Goal + +A moq-transport peer that sends TRACK_STATUS gets an immediate refusal with the +registered NOT_SUPPORTED code on every negotiated draft, instead of a warning in +our log and a request that hangs until the peer's timeout. TRACK_STATUS itself +stays unimplemented: all it adds over TRACK_INFO is a snapshot of the latest +object, and answering it honestly needs either a new API or a throwaway +SUBSCRIBE. PR #3519 built the latter and was closed for it. + +## Plan + +`rs/moq-net/src/ietf/publisher.rs` matches `ietf::TrackStatus::ID` with a +warning and an empty future; `session.rs` already routes the stream there, so +nothing is session-fatal. Mirror `run_publish_stream` in `subscriber.rs`, which +answers PUBLISH with NOT_SUPPORTED and closes the writer explicitly (#3348): + +- Decode the request so the stream is consumed, reply with the per-draft + refusal (TRACK_STATUS_ERROR on draft-14, REQUEST_ERROR from draft-15 on) using + the code type [IETF error codes](/quest/m0/ietf-error-codes.md) introduces, + and close the writer. On draft-14 and 15 the request rides a virtual stream + over the control stream whose reset is a no-op, so the explicit reply is the + only way bytes reach the peer. +- `js/net/src/ietf/publisher.ts` already replies; make it send the same code. +- Tests: a byte-exact transport-log test per version that a TRACK_STATUS request + yields the refusal and nothing else. + +Branch from dev, where the error registry lands. + +## Required + +- [IETF error codes](/quest/m0/ietf-error-codes.md) - the registered NOT_SUPPORTED value per draft comes from its code type + +## Closes + +- [#3492](https://github.com/moq-dev/moq/issues/3492) - close this issue when the quest finishes diff --git a/quest/m0/3532-connect-auth-race.md b/quest/m0/3532-connect-auth-race.md new file mode 100644 index 0000000000..7e5d8cee29 --- /dev/null +++ b/quest/m0/3532-connect-auth-race.md @@ -0,0 +1,40 @@ +# [S] A connect fails on auth only once every transport has + +## Goal + +`moq_native::Client::connect` against a WebTransport-only endpoint succeeds +whenever the QUIC dial succeeds, even when the WebSocket fallback is refused +first. Cloudflare's relays answer every non-WebTransport request with 403, and +today that 403 landing inside the 200 ms fallback delay fails the whole connect +as Forbidden while the QUIC arm is still in flight, indistinguishable from a bad +token. An auth error ends the race only when the other arm has also failed, and +a moq-ffi caller can disable the WebSocket fallback the way libmoq and the CLI +already can. + +Boundaries: blind subscription, consuming a broadcast the peer never announced, +stays refused. A client here may hold several connections and cannot pick one +for a bare path, so the announcement is the signal a broadcast is online; a +relay that accepts SUBSCRIBE_NAMESPACE and never publishes a namespace is +non-conformant, not a gap in this repo. + +## Plan + +- `race_transport_connect` in `rs/moq-native/src/client.rs` returns on + `err.is_auth()` from either arm. Record an auth error like any other failure + and keep polling the other arm; when both are done, report the auth error if + either arm produced one, since a genuine bad token fails both. Flip + `race_transport_connect_stops_on_quic_auth_error` and add: WebSocket 403 then + QUIC success connects; both arms refusing reports Forbidden; a QUIC failure + after a WebSocket 403 reports Forbidden, not the QUIC error. +- `MoqClient` in `rs/moq-ffi/src/session.rs` gains `set_websocket_enabled` and + `set_websocket_delay` beside `set_tls_disable_verify`, mapping onto + `websocket::Client::{enabled, delay}`. libmoq already exports + `moq_client_set_websocket_enabled` and `_delay`; mirror the two setters in + `py/moq-rs`, `swift`, `kt`, `go/wrapper/moq`, `dart/moq`, and + `doc/lib/{py,swift,kt,go,dart}`. + +On main, additive. + +## Closes + +- [#3532](https://github.com/moq-dev/moq/issues/3532) - close this issue when the quest finishes diff --git a/quest/m0/3533-ts-export-restart-stall.md b/quest/m0/3533-ts-export-restart-stall.md new file mode 100644 index 0000000000..b0457639c5 --- /dev/null +++ b/quest/m0/3533-ts-export-restart-stall.md @@ -0,0 +1,59 @@ +# [S] TS export survives a content restart on a continuous timeline + +## Goal + +`moq export ts` keeps emitting every elementary stream across a content join +on a source whose timeline is continuous: PCR, PTS and DTS monotone, continuity +counters unbroken, no `discontinuity_indicator`. That is what a real encoder +produces at a hard cut. A rewind detected on one track may cost the program one +clock and PSI reset, but it never fences another track for good. The true +rewind recovery #3375 added, a looping file where every track steps backwards, +keeps working. + +Boundaries: the consumer's rewind detection and the legacy audio importer's +resync are untouched here. Retiring inferred rewinds altogether is +[Monotonic timeline](/quest/m1/monotonic-timeline.md). + +## Plan + +Since #3375, `rewind(backwards)` in `rs/moq-mux/src/container/ts/export.rs` +bumps the program epoch and, on a backwards boundary, leaves every track that +already has a timeline in the old epoch. `Track::admit` then discards that +track's frames until it both changes its discontinuity counter and steps below +its own high-water mark. A continuous source supplies neither. The consumer's +rewind check in `rs/moq-mux/src/container/consumer.rs` has no tolerance, so the +sub-frame backwards step the legacy importer produces on an MPEG-1 audio resync +at the join is read as a rewind, and video plus primary audio are fenced +permanently while the passthrough PSI, AC-3 and teletext continue. #3533 +measured 0.31 Mb/s against a 9.5 Mb/s source with no recovery over 40 minutes, +bisected to #3375, and showed a single-track source is immune because the fence +needs a bystander. + +- Every track joins the new epoch on every rewind: delete the `backwards` + branch in `rewind()`, its parameter, and the epoch test in `Track::admit`, + keeping the watermark, clock, counter, PSI and PCR reset that #3375 introduced + for #2833. A bystander that still holds media from before the boundary emits + it under the reset clock instead of being discarded, and its own next + boundary resets again. Delete whatever only the fence kept alive. +- Regression test in `export_test.rs`: two tracks on a continuous timeline + whose content restarts, the audio track alone stepping back by less than one + frame at the join; video and audio keep emitting across it, and the join + costs at most one PCR discontinuity and one PSI re-emission. The existing + `rewind_re_emits_tables_and_resumes_the_clock` and + `rewind_flags_the_break_once_across_tracks` keep passing. +- Land after [TS timebase discontinuity](/quest/m0/ts-forward-discontinuity.md) + (PR #3529), which edits the same functions and makes the legacy importer + declare its breaks. + +## Required + +- [TS timebase discontinuity](/quest/m0/ts-forward-discontinuity.md) - PR #3529 rewrites the same boundary handling; rebase on it rather than race it + +## Closes + +- [#3533](https://github.com/moq-dev/moq/issues/3533) - close this issue when the quest finishes + +## Related + +- [Monotonic timeline](/quest/m1/monotonic-timeline.md) - deletes the inferred rewind that triggers the false boundary +- [Gap discontinuity](/quest/m1/gap-discontinuity.md) - the declared-break model the exporter will read instead diff --git a/quest/m0/3534-ietf-publisher-priority.md b/quest/m0/3534-ietf-publisher-priority.md new file mode 100644 index 0000000000..644dc6f528 --- /dev/null +++ b/quest/m0/3534-ietf-publisher-priority.md @@ -0,0 +1,62 @@ +# [M] Publisher priority survives a moq-transport hop + +## Goal + +A track's publisher priority reaches a moq-transport subscriber the way it +already reaches a moq-lite one. A relay that ingests over either protocol and +serves over moq-transport stamps every group header with the track's priority +instead of 0, a peer that declares DEFAULT_PUBLISHER_PRIORITY (0x21) has it +recorded on the track, and a subgroup header that omits its priority resolves +to that declared default. The audio-ahead-of-video intent of a linear SSAI +publisher is what the next relay sees, and the moqtest conformance requirement +that a relay preserve publisher priority is met. + +Boundaries: priority is track-scoped, as `track::Info::priority` and +draft-lcurley-moq-lite define it, fixed for the lifetime of the track. A +subgroup priority that differs from its track's collapses to the track value. +The local send queue keeps ranking by each subscription's own priority. + +## Plan + +The model has the field: `track::Info::priority` (higher first) rides TRACK_INFO +on moq-lite, and `rs/moq-net/src/ietf/priority.rs` converts to the wire's +lower-first byte. The IETF side ignores it in both directions: + +- `rs/moq-net/src/ietf/publisher.rs` builds the group header with + `publisher_priority: 0`. +- `rs/moq-net/src/ietf/properties.rs` names TIMESCALE (0x08) and + DEFAULT_PUBLISHER_GROUP_ORDER (0x22); 0x21 falls through the unknown path. +- `rs/moq-net/src/ietf/group.rs` decodes an absent priority flag as a literal + 128. +- `rs/moq-net/src/ietf/subscriber.rs` builds `track::Info::default()` from + SUBSCRIBE_OK with only timescale and latency. + +Steps: + +- Properties: add DEFAULT_PUBLISHER_PRIORITY (0x21) beside 0x22. Encode it on + SUBSCRIBE_OK and PUBLISH from `info.priority` through `priority::to_wire`; + decode it into `track::Info::priority` on the subscriber through + `priority::from_wire`. +- Group header: the publisher stamps `priority::to_wire(track.info().priority)` + where it reads the timescale today. The decoder resolves an absent flag to the + track's declared default first and only then to the draft's fallback; confirm + that fallback against each negotiated draft's text instead of keeping 128 by + assumption, and cite the section in the type's docs. +- Mirror in `js/net/src/ietf/publisher.ts`, `object.ts`, and `properties.ts`. +- Tests: 0x21 round-trips on SUBSCRIBE_OK per version; a lite-ingested track + with priority N serves over moq-transport with header priority `to_wire(N)`; + a subgroup without the flag decodes to the declared default; a relay + integration test where hang audio (priority 80) and video (60) arrive at a + moq-transport subscriber with distinct header priorities. Run the interop + runner's priority case if it has one. + +Additive, on main. No draft change: 0x21 is IETF-registered and moq-lite already +specifies the field. + +## Closes + +- [#3534](https://github.com/moq-dev/moq/issues/3534) - close this issue when the quest finishes + +## Related + +- [IETF error codes](/quest/m0/ietf-error-codes.md) - the sibling sweep of the moq-transport registries diff --git a/quest/m0/README.md b/quest/m0/README.md index 1eeb293150..fbb4350f30 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -17,10 +17,15 @@ regression test per Root Cause First. - [Auto latency](/quest/m0/3477-watch-auto-latency.md) - js/watch: auto latency follows measured arrivals, and the audio ring holds slack and re-buffers - [Jitter estimator](/quest/m0/3479-mux-jitter-flush-span.md) - moq-mux: catalog jitter is the publisher's maximum flush span, never a running minimum +- [WebKit gate](/quest/m0/webkit-webtransport-gate.md) - js/net: every WebKit engine takes the WebSocket path, not just the Safari brand, so iOS Chrome and Firefox stop freezing after two minutes - [Encoder lag](/quest/m0/publish-audio-lag-measure.md) - js/publish: measure the audio encoder's input-to-output lag on a high-RTT path - [IETF error codes](/quest/m0/ietf-error-codes.md) - every code on a moq-transport wire is a registered value for the negotiated draft, requests and stream resets alike +- [Publisher priority](/quest/m0/3534-ietf-publisher-priority.md) - moq-net: a track's publisher priority survives a moq-transport hop instead of being flattened to 0 +- [TRACK_STATUS refusal](/quest/m0/3492-ietf-track-status-refusal.md) - moq-net: TRACK_STATUS gets a NOT_SUPPORTED refusal instead of a silent drop +- [Connect auth race](/quest/m0/3532-connect-auth-race.md) - moq-native: a 403 on the WebSocket arm no longer fails a connect whose QUIC arm is still in flight; moq-ffi can disable the fallback - [Resume info](/quest/m0/resume-info-newest.md) - moq-net: resume reports segment zero's track info, so a replaced broadcast rescales timestamps on the predecessor's timescale - [TS timebase discontinuity](/quest/m0/ts-forward-discontinuity.md) - preserve source-signalled clock changes through import and export +- [TS restart stall](/quest/m0/3533-ts-export-restart-stall.md) - moq export ts: a content restart on a continuous timeline no longer fences video and primary audio for good - [Group charge](/quest/m0/group-charge.md) - charge real per-group cost so MOQ_CACHE_CAPACITY bounds real memory - [uring all-features](/quest/m0/uring-all-features-build.md) - moq-uring does not compile with `--all-features`, so the nightly features gate fails on it - [Failure artifacts](/quest/m0/qa-failure-artifacts.md) - retain inspectable traces, logs, and rerun commands when QA fails diff --git a/quest/m0/webkit-webtransport-gate.md b/quest/m0/webkit-webtransport-gate.md new file mode 100644 index 0000000000..f78894531d --- /dev/null +++ b/quest/m0/webkit-webtransport-gate.md @@ -0,0 +1,23 @@ +# [XS] Every WebKit browser takes the WebSocket path + +## Goal + +js/net refuses WebTransport on every WebKit engine, not only the Safari brand. +Chrome, Firefox and Edge on iOS and iPadOS are WKWebView and hit the same WebKit +bug (319818: flow-control credit never refills, so a session dies after roughly +7,600 streams or 16 MiB), yet `isWebTransportUserAgentSupported` admits them +today and their sessions freeze after about two minutes of playback. + +## Plan + +`js/net/src/connection/browser.ts` gates with Bowser `satisfies({ safari: "<0" })`. +Add the engine check: a browser whose OS is iOS or iPadOS, or whose engine is +WebKit, returns false, and Firefox keeps its version gate. Tests in +`browser.test.ts` with user agents for Safari on macOS, Chrome on iOS, Firefox on +iOS, Edge on iOS, and desktop Chrome and Firefox. Extend the Safari note in +`doc/lib/js/index.md` to WebKit. + +## Related + +- [Safari WebTransport](/quest/m2/safari-webtransport.md) - relaxes this gate once WebKit ships the fix +- [#2388](https://github.com/moq-dev/moq/issues/2388) - the WebKit tracking issue that gate quest closes diff --git a/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md b/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md index 28fb1ef83a..355c6008a7 100644 --- a/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md +++ b/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md @@ -60,4 +60,4 @@ Branch from `dev`, where the shared connection and `forward.ts` live. - [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant, the Rust half - [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - passthrough imports reserving nothing -- [#2857](/quest/m1/plan-binding-rate-control.md) - the same gap for the native bindings +- [#2857](/quest/m1/binding-rate-control.md) - the same gap for the native bindings diff --git a/quest/m1/2815-lift-adaptive-stage-refusal.md b/quest/m1/2815-lift-adaptive-stage-refusal.md new file mode 100644 index 0000000000..a29854573d --- /dev/null +++ b/quest/m1/2815-lift-adaptive-stage-refusal.md @@ -0,0 +1,38 @@ +# [XS] moq-cli runs several adaptive import stages on one connection + +## Goal + +Two encoding stages over one connection, such as `moq ... capture -- capture`, +are accepted, and both encoders target shares of the connection's estimate that +sum to it. The refusal in `rs/moq-cli/src/args.rs`, "a stage that encodes to fit +the connection's bandwidth estimate assumes it's the only publisher", is gone +with the test that asserts it. + +## Plan + +The allocator #2854 landed on dev is what the refusal stood in for: `main.rs` +mints one `bandwidth::Allocator` per connection and every encoding sender +reserves against it (the `moq-video` encode producer, `moq-audio` capture), so +the guard refuses a configuration the allocator already divides by track +priority. + +- Delete the `adaptive` / `imports == 1` ensure and + `an_adaptive_capture_must_be_the_only_import`. Keep + `audio_only_capture_is_not_bandwidth_adaptive` only if `uses_bandwidth` still + has a reader; otherwise delete both. +- Regression: two capture stages on one connection whose grants sum to at most + the estimate and rank by priority, next to the allocator's + `concurrent_tracks_split_the_estimate`, plus an args test that the + combination parses. +- `doc/bin/cli.md` "Multiple stages" drops any mention of the limit. + +Branch from dev. + +## Closes + +- [#2815](https://github.com/moq-dev/moq/issues/2815) - close this issue when the quest finishes + +## Related + +- [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant +- [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - passthrough imports joining the same allocator diff --git a/quest/m1/README.md b/quest/m1/README.md index 30d04f3891..f4e6d76b85 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -41,10 +41,11 @@ with the current dev tree before starting. - [Dart announce](/quest/m1/dart-announce.md) - the Dart wrapper mirrors the same three operations once dev merges - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS; gates the dev merge - [Playable](/quest/m1/hls-playable.md) - a 24/7 broadcast never becomes permanently unplayable over HLS +- [#2815](/quest/m1/2815-lift-adaptive-stage-refusal.md) - moq-cli runs several adaptive import stages on one connection now that the allocator divides the estimate - [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - Follow the bandwidth grant in moq-audio instead of holding a fixed reservation - [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - Passthrough imports reserve no bandwidth, so a co-resident encoder over-targets - [Ladder](/quest/m1/ladder/README.md) - a transcode ladder adapts to the uplink it publishes over, instead of encoding every live rung at its ceiling -- [Plan: binding rate control](/quest/m1/plan-binding-rate-control.md) - settle how a non-Rust publisher follows the send estimate before wiring five bindings +- [Binding rate control](/quest/m1/binding-rate-control.md) - the bindings mirror the allocator and reservation, so a non-Rust publisher follows its bandwidth share - [#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - js/net mirrors the send-side bandwidth allocator so each publisher encodes against its own share - [#3000](/quest/m1/3000-track-teardown-on-poll-unused-is-not-atomic-against-a.md) - Track teardown on poll_unused is not atomic against a consumer reattaching - [JS stream codes](/quest/m1/js-net-stream-error-codes.md) - js/net: a locally raised group error reaches the wire as INTERNAL_ERROR @@ -67,7 +68,7 @@ with the current dev tree before starting. - [Native Go context](/quest/m1/go-native-context.md) - the Go generator emits context.Context itself, retiring the hand-rolled cancellation token - [#3208](/quest/m1/3208-make-2-5-ms-opus-frame-durations-work-across-bindings.md) - Make 2.5 ms Opus frame durations work across bindings - [#2152](/quest/m1/2152-libmoq-c-abi-catch-up-with-the-moq-ffi-surface.md) - libmoq: C ABI catch-up with the moq-ffi surface -- [Plan: route cold cost](/quest/m1/plan-route-cold-cost.md) - settle how a route's cold cost crosses the bindings without being rewritten on the way back +- [Route cold cost](/quest/m1/route-cold-cost.md) - MoqRoute carries warm and cold, so an observed route re-announces intact - [#3060](/quest/m1/3060-moq-net-ban-hop-id-0-from-hop-chains.md) - moq-net: ban Hop ID 0 from hop chains - [#2248](/quest/m1/2248-moq-mux-rebase-fmp4-export-timestamps-for-late-subscribers.md) - moq-mux: rebase fMP4 export timestamps for late subscribers - [Merge dev](/quest/m1/merge-dev.md) - dev lands on main with a closing keyword for every issue it fixed diff --git a/quest/m1/binding-rate-control.md b/quest/m1/binding-rate-control.md new file mode 100644 index 0000000000..6cb08c985f --- /dev/null +++ b/quest/m1/binding-rate-control.md @@ -0,0 +1,59 @@ +# [M] Bindings follow the connection's bandwidth share + +## Goal + +A non-Rust publisher follows the connection's send estimate the way Rust does. +The bindings mirror `bandwidth::Allocator` and `bandwidth::Reservation`: a +session mints the allocator, a publisher reserves a share for a track at its +configured ceiling, the built-in video and audio encoders in moq-ffi and libmoq +follow the grant when handed a reservation, and an application that owns its +encoder reads the reservation's current grant. A Python, Swift, Kotlin, Go or C +publisher stops holding its configured bitrate through congestion. + +Boundaries: the allocation rules are Rust's (strict priority tiers, max-min fair +within a tier, ceilings never observed rates) and the bindings add no policy. +OBS adopting the surface is [OBS rate control](/quest/m2/obs-moq-video/rate-control.md). + +## Plan + +`MoqBroadcastProducer` holds only the broadcast, and one origin can serve +several sessions, so a "follow this connection" flag on the publish call has no +connection to follow; the handle comes from the session. On dev +`rs/moq-net/src/model/bandwidth.rs` provides `Allocator::new(estimate)`, +`reserve(&track::Demand, max) -> Reservation`, and +`Reservation::{peek, consumer, update}`, and `Session::send_bandwidth()` is the +estimate. Today `rs/moq-ffi/src/video.rs` and `audio.rs` never set +`Options::bandwidth`, and the estimate reaches a binding only as the +`send_rate_bps` snapshot on the connection stats. + +- moq-ffi: `MoqSession::bandwidth() -> MoqBandwidth`, the allocator over the + session's estimate. `MoqBandwidth::reserve(track, max_bps) -> MoqReservation` + keyed on the track producer's demand. `MoqReservation::grant() -> Option` + is the current share (a snapshot; an encoder that asks before each frame needs + nothing more), `update(max_bps)` moves the ceiling, and dropping it releases + the share. Video and audio publish options accept an optional reservation, and + the built-in encoders take `Options::bandwidth` from it. `set_bitrate` stays + as the manual ceiling. +- libmoq: `moq_session_bandwidth`, `moq_bandwidth_reserve`, + `moq_reservation_grant`, `moq_reservation_update`, `moq_reservation_close`, + and a reservation parameter on the raw video and audio publish calls. + Regenerate `moq.h`. +- Wrappers `py/moq-rs`, `swift`, `kt`, `go/wrapper/moq` and + `doc/lib/{py,swift,kt,go,c}` per the Cross-Package Sync table; dart after dev + merges. Run `just test smoke-full`. +- Tests: two video producers on one session reserving 4 and 2 Mbps against a + 3 Mbps estimate get grants summing to at most 3 Mbps; a dropped reservation + frees its share; the built-in encoder's applied bitrate follows a shrinking + grant. + +Branch from dev. + +## Closes + +- [#2857](https://github.com/moq-dev/moq/issues/2857) - close this issue when the quest finishes + +## Related + +- [#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - the same allocator mirrored in js/net +- [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - audio following its grant in Rust +- [Ladder](/quest/m1/ladder/README.md) - the transcode consumer of the same estimate diff --git a/quest/m1/ladder/README.md b/quest/m1/ladder/README.md index f34dffb033..f291c2dbf8 100644 --- a/quest/m1/ladder/README.md +++ b/quest/m1/ladder/README.md @@ -77,4 +77,4 @@ encoders on every target change. - [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md) - the other sender that reserves but never follows its grant - [#2859](/quest/m1/2859-passthrough-imports-reserve-no-bandwidth-so-a-co-resident.md) - passthrough tracks that reserve nothing at all -- [#2857](/quest/m1/plan-binding-rate-control.md) - non-Rust publishers cannot reach rate control regardless of what the ladder does +- [#2857](/quest/m1/binding-rate-control.md) - non-Rust publishers cannot reach rate control regardless of what the ladder does diff --git a/quest/m1/plan-binding-rate-control.md b/quest/m1/plan-binding-rate-control.md deleted file mode 100644 index 5253fd00a2..0000000000 --- a/quest/m1/plan-binding-rate-control.md +++ /dev/null @@ -1,44 +0,0 @@ -# [S] Plan: encoder rate control from the bindings - -## Goal - -A settled shape for how a non-Rust publisher follows the connection's send -estimate with its configured bitrate as the ceiling, the contract -`moq_video::encode::Options::{bitrate, bandwidth}` already gives Rust, so an -OBS, Python, Swift, Kotlin, or Go publisher stops holding that ceiling through -congestion. Run `/plan-quests`; the settled -plan becomes the implementing quest that closes the issue. - -## Plan - -`moq-ffi` and `libmoq` publish video (`moq_publish_video_raw`) and audio with -default encoder options and never set `Options::bandwidth`. The send estimate -reaches a binding only as a snapshot on `moq_connection_stats` / -`Session::stats`, which a caller can read but cannot hand to an encoder. The -Rust path has followed the estimate since `moq_video::encode::rate` landed, and -the allocator ([moq#2854](https://github.com/moq-dev/moq/pull/2854)) gave it a -second live handle bindings cannot reach. - -The awkward part is that `bandwidth::Consumer` and `bandwidth::Allocator` are -live handles with wakeups, which UniFFI and a C ABI do not carry naturally. -Candidates, cheapest first, and the first two compose: - -- A flag on the publish call: "follow this connection's estimate", with the - binding creating the allocator and registering the track. No new handle - crosses the boundary; loses sharing one allocator across separately created - publishers. -- An opaque allocator handle minted from a session and passed into each - publish call. Mirrors the Rust API and composes, at the cost of a new object - in five wrappers. -- A polled getter returning the current target for a track, for applications - that own their encoder (OBS does); useless for the built-in encode path. - -Whichever shape wins touches `rs/moq-ffi`, `rs/libmoq`, every wrapper, and -`doc/lib/*` per the Cross-Package Sync table, plus `cpp/obs` if the plugin -adopts it. - -## Related - -- [#2857](https://github.com/moq-dev/moq/issues/2857) - the issue the implementing quest closes -- [#2709](/quest/m1/2709-per-broadcast-bandwidth-estimates-and-reservation.md) - the same allocator mirrored in js/net -- [Ladder](/quest/m1/ladder/README.md) - the transcode consumer of the same estimate diff --git a/quest/m1/plan-route-cold-cost.md b/quest/m1/plan-route-cold-cost.md deleted file mode 100644 index ed52a5cc45..0000000000 --- a/quest/m1/plan-route-cold-cost.md +++ /dev/null @@ -1,43 +0,0 @@ -# [S] Plan: the route cold cost across the bindings - -## Goal - -A settled shape for how a route's `{warm, cold}` cost crosses `moq-ffi` and -the language wrappers, so observing a route and re-announcing it cannot -silently rewrite a truthful cold cost. Run `/plan-quests`; the settled plan -becomes the implementing quest that closes the issue. - -## Plan - -`MoqOriginProducer::announce(prefix, MoqRoute)` is the input and -`MoqAnnouncement.route` the output. `From` reports -`route.cost.warm` and drops `cold`, while `TryFrom` calls -`with_cost(u64)`, which sets both halves. So a truthful `{warm: 0, cold: N}` -observed through `announced()` and announced again becomes -`{warm: 0, cold: 0}`, claiming to be the publisher. `route_order` still ranks -on cold, so an understated cold wins ties it should lose. - -Candidates, none chosen: - -- Add `cold` to the `MoqRoute` record. Additive for the generated bindings - and no C break, but it touches `{py,swift,kt,go,dart}/` and - `doc/lib/*`; an omitted cold could default to the warm value, which is right - for a publisher seeding a production cost. -- Keep the scalar and make the asymmetry unrepresentable: separate the - seed-a-production-cost input from the observe-a-route output so an observed - route cannot be re-announced as-is. -- Document `MoqRoute.cost` as a production cost that must not be echoed. The - weakest option; `CLAUDE.md` prefers unrepresentable over documented. - -Decide against `announce` / `announced` on `dev`, after -[Announce handle](/quest/m1/announce-handle.md) settles the surface the -bindings mirror. - -## Required - -- [Announce handle](/quest/m1/announce-handle.md) - the `announce` / `announced` surface the bindings mirror must be settled before the cost shape is decided against it - -## Related - -- [#2933](https://github.com/moq-dev/moq/issues/2933) - the issue the implementing quest closes -- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - the bindings surface this rides on diff --git a/quest/m1/route-cold-cost.md b/quest/m1/route-cold-cost.md new file mode 100644 index 0000000000..7522497eb6 --- /dev/null +++ b/quest/m1/route-cold-cost.md @@ -0,0 +1,40 @@ +# [S] The route cold cost crosses moq-ffi intact + +## Goal + +An application that observes a route through the bindings and announces it +again reproduces the route it saw. `MoqRoute` carries both halves of +`origin::Cost { warm, cold }`, so a truthful `{warm: 0, cold: N}` is never +rewritten to `{warm: 0, cold: 0}`, the publisher's own cost, on its way back +through `dynamic(prefix, route)` or `broadcast.announce(route)`. A publisher +seeding only a production cost still sets one number. + +## Plan + +On dev `rs/moq-ffi/src/origin.rs` maps the pair onto one scalar in each +direction: `From` reports `cost.warm` and drops `cold`, while +`TryFrom` calls `with_cost(u64)`, which sets both halves. +`route_order` ranks on cold, so an understated cold wins ties it should lose. + +- `MoqRoute` gains `cold: Option` with a uniffi default of `None`, meaning + "same as `cost`" when omitted, which is right for a publisher seeding a + production cost. `cost` stays the warm half under its current name. Both + conversions become lossless: `From` fills both fields, `TryFrom` builds + `Cost { warm: cost, cold: cold.unwrap_or(cost) }`. +- Additive in every generated binding. `rs/libmoq` exposes no route over C, so + `moq.h` is untouched. `py/moq-rs`, `swift`, `kt`, `go/wrapper/moq`, and + `doc/lib/{py,swift,kt,go}` show the field wherever they show `cost`; dart + follows once dev merges, as it exists only on main. +- Tests: a route observed through the announcement stream and announced again + compares equal including cold; an omitted cold equals warm. + +Land with or after [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md), +which rewrites the same records. Branch from dev. + +## Required + +- [#3190](/quest/m1/3190-align-origin-broadcast-creation-naming-across-language.md) - the bindings surface these records ride on + +## Closes + +- [#2933](https://github.com/moq-dev/moq/issues/2933) - close this issue when the quest finishes diff --git a/quest/m2/README.md b/quest/m2/README.md index 9fbd346912..7f4fd701de 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -30,6 +30,7 @@ can act on. Each still carries its own plan and regression test. ACK progress, reliable reset, hierarchical scheduling, and qmux - [Bandwidth estimate release](/quest/m2/web-transport-bandwidth-estimate.md) - web-transport-quinn reports quinn's BBR bandwidth estimate and ships a release carrying it - [#2847](/quest/m2/2847-the-quinn-backends-send-bandwidth-estimate-is-cwnd-rtt.md) - quinn backend: bump to the releases that report the controller bandwidth estimate instead of cwnd/rtt +- [Safari WebTransport](/quest/m2/safari-webtransport.md) - WebKit browsers return to WebTransport once WebKit 319818 ships fixed - [Benchmark comparisons](/quest/m2/performance-comparisons.md) - retained evidence, repeated paired runs, and uncertainty for performance claims - [Relay profiling](/quest/m2/performance-profiles.md) - reproducible CPU and allocation captures under the existing workloads - [Browser benchmarks](/quest/m2/browser-benchmarks.md) - measure JS transport, container, decode, and render costs in an identified browser @@ -75,7 +76,7 @@ can act on. Each still carries its own plan and regression test. - [#2907](/quest/m2/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - Bind the browser through moq-ffi/UniFFI instead of a second hand-written wasm API - [#2850](/quest/m2/2850-js-net-give-reader-a-synchronous-decode-so-the-publisher.md) - js/net: decode messages synchronously from buffered bytes and delete the publisher read-ahead queue (dev) - [Cluster flags](/quest/m2/cluster-flags.md) - a discovery mechanism carries its own prerequisites, so an incomplete cluster config cannot be expressed -- [Plan: revalidation updates](/quest/m2/plan-revalidation-updates.md) - settle which fields an auth re-check may update on a live session and which force a reconnect +- [Revalidation updates](/quest/m2/revalidation-updates.md) - an auth re-check moves the tier in place and names an alias change when it closes the session - [#3137](/quest/m2/3137-moqsrc-bound-the-pending-rendition-subscriptions-a.md) - moqsrc: bound the pending rendition subscriptions a catalog can open - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink: a flushing restart after EOS opens a new publication generation - [#709](/quest/m2/709-automatic-letsencrypt-support.md) - the relay provisions and renews its own ACME certificate over HTTP-01, persisted on disk diff --git a/quest/m2/obs-moq-video/README.md b/quest/m2/obs-moq-video/README.md index 40240b39ba..63b6818218 100644 --- a/quest/m2/obs-moq-video/README.md +++ b/quest/m2/obs-moq-video/README.md @@ -25,6 +25,7 @@ The quests separate portable decoding, platform GPU delivery, audio, and publish - [Encoder presets](/quest/m2/obs-moq-video/presets.md) - define and measure shared low-latency, balanced, and quality policies - [Audio publishing](/quest/m2/obs-moq-video/audio-publish.md) - back an internal OBS Opus encoder with moq-audio - [Video publishing](/quest/m2/obs-moq-video/adapter.md) - back an internal OBS video encoder with moq-video and expose the combined opt-in mode +- [Rate control](/quest/m2/obs-moq-video/rate-control.md) - the plugin reserves its bitrate and retunes the OBS encoder to the grant - [macOS GPU input](/quest/m2/obs-moq-video/macos.md) - feed the encoder from the OBS compositor without CPU readback - [Windows GPU input](/quest/m2/obs-moq-video/windows.md) - import or blit OBS D3D11 textures with explicit synchronization - [Linux GPU input](/quest/m2/obs-moq-video/linux.md) - export OBS allocations and connect a real hardware encoder import path diff --git a/quest/m2/obs-moq-video/rate-control.md b/quest/m2/obs-moq-video/rate-control.md new file mode 100644 index 0000000000..0c89904a0c --- /dev/null +++ b/quest/m2/obs-moq-video/rate-control.md @@ -0,0 +1,25 @@ +# [S] OBS follows its bandwidth grant + +## Goal + +The OBS plugin's video output follows the connection's bandwidth share: it +reserves the Quality tab's bitrate for the video track through libmoq, reads +the grant before each encoded frame, and retunes the OBS encoder when the grant +moves, with the configured bitrate as the ceiling. Audio reserves its bitrate +and never follows. The dock's Stream stats show the current target beside the +configured rate. + +## Plan + +Uses the reservation surface from +[Binding rate control](/quest/m1/binding-rate-control.md). Apply grants through +the shape `moq_video::encode::rate::Control` uses (drops at once, raises ramp, +hysteresis) rather than pushing every change into `obs_encoder_update`; whether +that policy sits in libmoq behind the reservation or in the plugin depends on +whether a second C consumer wants it. Verify against a shaped uplink and with +`just obs compile` and `just obs test`; document the behaviour in +`doc/bin/obs.md`. + +## Required + +- [Binding rate control](/quest/m1/binding-rate-control.md) - the reservation the plugin reads diff --git a/quest/m2/plan-revalidation-updates.md b/quest/m2/plan-revalidation-updates.md deleted file mode 100644 index c1041af294..0000000000 --- a/quest/m2/plan-revalidation-updates.md +++ /dev/null @@ -1,48 +0,0 @@ -# [S] Plan: what an auth re-check may update in place - -## Goal - -A settled contract for which fields a revalidation reply can change on a live -session and which force a reconnect. Scope narrowing is settled by the -[relay auth](/quest/m2/path-patterns/relay-auth.md) contract: the session -stays up, resized to the narrower grant, and only the subscriptions and -publications the grant no longer covers are closed; `tier` and `alias` are -not settled. -Run `/plan-quests`; the settled plan becomes the implementing quest that closes -the issue. - -## Plan - -`Auth::recheck` scores a reply by `Scope::covered_by` (root, subscribe, -publish), then drops the `AuthToken` and propagates only the `CacheHints`. Two -fields the auth API can legitimately change therefore behave inconsistently: - -- `tier` is silently ignored. An endpoint that re-buckets a connection onto or - off a billing tier has no effect until the session reconnects, and the tier - decides which meter pays. Applying it mid-session means rebuilding the - session's stats handle, and usage already recorded under the old tier stays - there. -- `alias` closes the session, because the alias becomes the token's `root` and - `covered_by` fails. Arguably correct, since the broadcast would otherwise keep - announcing under a root the API no longer assigns, but it is a silent hard - disconnect for what may be a benign rename. - -Two things are fixed before this decision is made. The -[relay auth](/quest/m2/path-patterns/relay-auth.md) quest already requires a -re-check to compare the full versioned grant, reject unsupported, mixed, -invalid, and out-of-scope grants, and resize a live session with no prefix-only -widening window; this plan inherits that invariant rather than restating a -weaker one. And "the alias becomes the token's `root`" describes today's v0 -behavior only: under v1 grants a literal root aliases or rebases the patterns -and never becomes one, so the canonical alias transformation has to be stated -before deciding whether an alias change closes or updates a session. - -Then decide deliberately, per field: update in place, close, or refuse the -change. -Then the implementing quest applies it in `rs/moq-relay/src/auth.rs` with a -test per field and documents the contract in `doc/bin/relay/auth.md`. - -## Related - -- [#3058](https://github.com/moq-dev/moq/issues/3058) - the issue the implementing quest closes -- [Auth verdict](/quest/m2/auth-verdict.md) - the proxy mode whose re-check this also governs diff --git a/quest/m2/revalidation-updates.md b/quest/m2/revalidation-updates.md new file mode 100644 index 0000000000..af75d736e7 --- /dev/null +++ b/quest/m2/revalidation-updates.md @@ -0,0 +1,44 @@ +# [S] An auth re-check moves the tier and names an alias change + +## Goal + +A revalidation reply that changes a session's `tier` takes effect on the live +session: later usage records under the new tier, what was already recorded stays +under the old one, and the session stays up. A reply that changes `alias` still +closes the session, but with its own expiry reason in the log and to the client +instead of a generic revocation, so a benign rename is distinguishable from a +refusal. Scope narrowing keeps the +[relay auth](/quest/m2/path-patterns/relay-auth.md) contract, and +`doc/bin/relay/auth.md` states the outcome per field. + +## Plan + +`Auth::recheck` in `rs/moq-relay/src/auth.rs` scores a reply with +`Scope::covered_by` (root, subscribe, publish), drops the `AuthToken`, and +propagates only `CacheHints`. The stats handle is built once at admission in +`connection.rs` from `token.tier` and `token.root`. + +- `Recheck::Valid` carries the reply's `tier`. The connection compares it with + the live handle's and, on change, rebuilds the session stats handle under the + new tier and swaps it into the session's traffic accounting. Earlier counters + are not migrated: the old tier was truthfully what paid until then. +- `Expired` gains an `Alias` variant, additive under `#[non_exhaustive]`, raised + when the reply's alias no longer matches the admitted root (v0) or the + canonical alias transform relay auth defines for v1 grants. `covered_by` + keeps failing it; only the reason changes. +- Tests: a re-check that moves the tier records subsequent bytes under the new + meter and leaves earlier bytes where they were; a re-check that changes the + alias closes with `Expired::Alias`; a reply that changes both closes. +- Docs: the revalidation section of `doc/bin/relay/auth.md` gains a per-field + table: scope narrowing resizes, tier updates in place, alias closes. + +On main, additive. + +## Closes + +- [#3058](https://github.com/moq-dev/moq/issues/3058) - close this issue when the quest finishes + +## Related + +- [Relay auth](/quest/m2/path-patterns/relay-auth.md) - the scope contract this inherits +- [Auth verdict](/quest/m2/auth-verdict.md) - the proxy mode whose re-check this also governs diff --git a/quest/m2/safari-webtransport.md b/quest/m2/safari-webtransport.md new file mode 100644 index 0000000000..affc26ba41 --- /dev/null +++ b/quest/m2/safari-webtransport.md @@ -0,0 +1,29 @@ +# [XS] Safari WebTransport returns when WebKit refills its windows + +## Goal + +Once a shipping Safari refills MAX_DATA and MAX_STREAMS credit, js/net stops +routing WebKit browsers to the WebSocket and qmux fallback: the gate admits the +fixed version and later, and a Safari watcher plays for hours over WebTransport +as Chrome does. #2388 has been WebKit tracking since PR #3345 and closes with +this. + +## Plan + +- Gate `js/net/src/connection/browser.ts` on the version that ships the fix, + for Safari and for the iOS WebKit browsers + [WebKit gate](/quest/m0/webkit-webtransport-gate.md) added, keeping older + versions on the fallback. +- Before flipping, rerun the raw WebTransport reproduction from #2388 (about + 7,600 eleven-byte unidirectional streams, and about 16 MiB of data) on the + fixed Safari, then a watch longer than two minutes in the QA harness. +- Update `doc/lib/js/index.md`. + +## Required + +- WebKit bug 319818 (https://bugs.webkit.org/show_bug.cgi?id=319818) is fixed and shipping in a Safari release +- [WebKit gate](/quest/m0/webkit-webtransport-gate.md) - the engine-wide gate this relaxes + +## Closes + +- [#2388](https://github.com/moq-dev/moq/issues/2388) - close this issue when the quest finishes diff --git a/quest/m2/wildcard/README.md b/quest/m2/wildcard/README.md index 91ff27dc87..9f7ef4d44b 100644 --- a/quest/m2/wildcard/README.md +++ b/quest/m2/wildcard/README.md @@ -261,7 +261,3 @@ than announce state. pattern, and its catalog names the generations a wildcard cannot - [pop-skipping](/quest/m2/pop-skipping/README.md) - it owns the route cost and the rank hash this reuses -- [#3452](https://github.com/moq-dev/moq/issues/3452) - an OBS dock control to - request a transcode ladder; demand is viewer-driven here, so it waits on - [Resolve](/quest/m2/wildcard/resolve.md) and the `.pro` output convention - before any UI hard-codes a path From 4cedf01abab7826dd92e60add299b37806cd2b5f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 14:25:03 -0700 Subject: [PATCH 2/4] chore(quest): address review findings on the plan pass The TS export fix keeps the peer fence and gives it a clock-driven exit, so the #3375 true-rewind test keeps passing. The publisher-priority property is scoped to the drafts that carry a property block. The rate-control quest defines the allocator's lifecycle across reconnects and keeps None distinct from a zero grant. The revalidation quest names the data path a tier retag needs and grows to [M]. Co-Authored-By: Claude Fable 5.1 --- quest/m0/3533-ts-export-restart-stall.md | 41 ++++++++++++-------- quest/m0/3534-ietf-publisher-priority.md | 8 +++- quest/m1/2815-lift-adaptive-stage-refusal.md | 11 ++++-- quest/m1/binding-rate-control.md | 31 +++++++++------ quest/m2/revalidation-updates.md | 35 +++++++++++------ quest/m2/safari-webtransport.md | 13 ++++--- 6 files changed, 89 insertions(+), 50 deletions(-) diff --git a/quest/m0/3533-ts-export-restart-stall.md b/quest/m0/3533-ts-export-restart-stall.md index b0457639c5..ff56333936 100644 --- a/quest/m0/3533-ts-export-restart-stall.md +++ b/quest/m0/3533-ts-export-restart-stall.md @@ -3,12 +3,15 @@ ## Goal `moq export ts` keeps emitting every elementary stream across a content join -on a source whose timeline is continuous: PCR, PTS and DTS monotone, continuity -counters unbroken, no `discontinuity_indicator`. That is what a real encoder -produces at a hard cut. A rewind detected on one track may cost the program one -clock and PSI reset, but it never fences another track for good. The true -rewind recovery #3375 added, a looping file where every track steps backwards, -keeps working. +on a source whose transport timeline is continuous: PCR, PTS and DTS monotone +on the wire, continuity counters unbroken, no `discontinuity_indicator`. That is +what a real encoder produces at a hard cut. The only backwards step in that +scenario is the importer's: the legacy audio importer extrapolates timestamps +from the last PES header, so after a resync at the join it re-locks a frame a +few milliseconds below its own extrapolated high-water mark. A rewind detected +on one track may cost the program one clock and PSI reset, but it never fences +another track for good, and the true rewind recovery #3375 added, a looping +file where every track steps backwards, keeps withholding stale frames. Boundaries: the consumer's rewind detection and the legacy audio importer's resync are untouched here. Retiring inferred rewinds altogether is @@ -29,18 +32,22 @@ measured 0.31 Mb/s against a 9.5 Mb/s source with no recovery over 40 minutes, bisected to #3375, and showed a single-track source is immune because the fence needs a bystander. -- Every track joins the new epoch on every rewind: delete the `backwards` - branch in `rewind()`, its parameter, and the epoch test in `Track::admit`, - keeping the watermark, clock, counter, PSI and PCR reset that #3375 introduced - for #2833. A bystander that still holds media from before the boundary emits - it under the reset clock instead of being discarded, and its own next - boundary resets again. Delete whatever only the fence kept alive. -- Regression test in `export_test.rs`: two tracks on a continuous timeline - whose content restarts, the audio track alone stepping back by less than one - frame at the join; video and audio keep emitting across it, and the join - costs at most one PCR discontinuity and one PSI re-emission. The existing +- Give the fence an exit. A fenced track joins the new generation when its own + timeline steps back, as today, or when the program clock driven by the joined + tracks passes its pending frame's timestamp. On a true rewind the video + track's own boundary arrives long before the reset clock climbs back to its + stale frames, so they are still discarded, which is what + `rewind_flags_the_break_once_across_tracks` asserts. On the #3533 join the + reset clock sits a few milliseconds below video's next frame, so video + re-joins within one frame. Implement it in `Track::admit` against the + exporter's watermark rather than as a timer: the exit is a clock comparison, + never a deadline. +- Regression test in `export_test.rs`: two tracks on a continuous transport + timeline whose content restarts, the audio track alone stepping back by less + than one frame at the join; video and audio keep emitting across it, and the + join costs at most one PCR discontinuity and one PSI re-emission. The existing `rewind_re_emits_tables_and_resumes_the_clock` and - `rewind_flags_the_break_once_across_tracks` keep passing. + `rewind_flags_the_break_once_across_tracks` keep passing unchanged. - Land after [TS timebase discontinuity](/quest/m0/ts-forward-discontinuity.md) (PR #3529), which edits the same functions and makes the legacy importer declare its breaks. diff --git a/quest/m0/3534-ietf-publisher-priority.md b/quest/m0/3534-ietf-publisher-priority.md index 644dc6f528..cc4555d421 100644 --- a/quest/m0/3534-ietf-publisher-priority.md +++ b/quest/m0/3534-ietf-publisher-priority.md @@ -36,14 +36,18 @@ Steps: - Properties: add DEFAULT_PUBLISHER_PRIORITY (0x21) beside 0x22. Encode it on SUBSCRIBE_OK and PUBLISH from `info.priority` through `priority::to_wire`; decode it into `track::Info::priority` on the subscriber through - `priority::from_wire`. + `priority::from_wire`. The property block is written from draft-17 on and + read from draft-16 on, as `Properties::encode` already gates; draft-14 and 15 + have no block at all, so on those drafts the priority travels only in the + group header and an absent flag resolves straight to the draft's fallback. - Group header: the publisher stamps `priority::to_wire(track.info().priority)` where it reads the timescale today. The decoder resolves an absent flag to the track's declared default first and only then to the draft's fallback; confirm that fallback against each negotiated draft's text instead of keeping 128 by assumption, and cite the section in the type's docs. - Mirror in `js/net/src/ietf/publisher.ts`, `object.ts`, and `properties.ts`. -- Tests: 0x21 round-trips on SUBSCRIBE_OK per version; a lite-ingested track +- Tests: 0x21 round-trips on SUBSCRIBE_OK on every draft that carries the + block and is absent from the bytes on the ones that do not; a lite-ingested track with priority N serves over moq-transport with header priority `to_wire(N)`; a subgroup without the flag decodes to the declared default; a relay integration test where hang audio (priority 80) and video (60) arrive at a diff --git a/quest/m1/2815-lift-adaptive-stage-refusal.md b/quest/m1/2815-lift-adaptive-stage-refusal.md index a29854573d..c84f211e3e 100644 --- a/quest/m1/2815-lift-adaptive-stage-refusal.md +++ b/quest/m1/2815-lift-adaptive-stage-refusal.md @@ -4,7 +4,7 @@ Two encoding stages over one connection, such as `moq ... capture -- capture`, are accepted, and both encoders target shares of the connection's estimate that -sum to it. The refusal in `rs/moq-cli/src/args.rs`, "a stage that encodes to fit +sum to at most it, surplus left unclaimed when a ceiling binds. The refusal in `rs/moq-cli/src/args.rs`, "a stage that encodes to fit the connection's bandwidth estimate assumes it's the only publisher", is gone with the test that asserts it. @@ -22,8 +22,13 @@ priority. has a reader; otherwise delete both. - Regression: two capture stages on one connection whose grants sum to at most the estimate and rank by priority, next to the allocator's - `concurrent_tracks_split_the_estimate`, plus an args test that the - combination parses. + `concurrent_tracks_split_the_estimate`, plus an args test that runs the same + validation entry point the CLI does and accepts the combination, not one that + only parses it. +- Audio reserves its configured rate and does not follow a smaller grant until + [#2848](/quest/m1/2848-follow-the-bandwidth-grant-in-moq-audio-instead-of.md); + that is the allocator's documented advisory contract and holds for one + capture stage exactly as for two, so it is not a reason to keep the refusal. - `doc/bin/cli.md` "Multiple stages" drops any mention of the limit. Branch from dev. diff --git a/quest/m1/binding-rate-control.md b/quest/m1/binding-rate-control.md index 6cb08c985f..6f901f39d8 100644 --- a/quest/m1/binding-rate-control.md +++ b/quest/m1/binding-rate-control.md @@ -26,14 +26,22 @@ estimate. Today `rs/moq-ffi/src/video.rs` and `audio.rs` never set `Options::bandwidth`, and the estimate reaches a binding only as the `send_rate_bps` snapshot on the connection stats. -- moq-ffi: `MoqSession::bandwidth() -> MoqBandwidth`, the allocator over the - session's estimate. `MoqBandwidth::reserve(track, max_bps) -> MoqReservation` - keyed on the track producer's demand. `MoqReservation::grant() -> Option` - is the current share (a snapshot; an encoder that asks before each frame needs - nothing more), `update(max_bps)` moves the ceiling, and dropping it releases - the share. Video and audio publish options accept an optional reservation, and - the built-in encoders take `Options::bandwidth` from it. `set_bitrate` stays - as the manual ceiling. +- moq-ffi: `MoqSession::bandwidth() -> MoqBandwidth` returns a handle to the + one allocator the session owns, so every handle shares one reservation + registry. The allocator consumes the live `bandwidth::Consumer`, and because a + moq-ffi session reconnects on its own, that consumer is the reconnecting + one: it reports no estimate while disconnected and resumes on the next + connection, and reservations survive the gap. `MoqBandwidth::reserve(track, + max_bps) -> MoqReservation` is keyed on the track producer's demand. + `MoqReservation::grant() -> Option` is the current share as a snapshot + (an encoder that asks before each frame needs nothing more); `None` means the + allocator has no estimate or the track is not demanded, hold the current + rate, and `Some(0)` is a real zero grant, exactly the distinction + `Reservation::peek` draws. `update(max_bps)` moves the ceiling and dropping + the reservation releases the share. Video and audio publish options accept an + optional reservation; the built-in encoders feed its consumer to + `Options::bandwidth` unchanged, so the same `None` versus zero semantics + reach `rate::Control`. `set_bitrate` stays as the manual ceiling. - libmoq: `moq_session_bandwidth`, `moq_bandwidth_reserve`, `moq_reservation_grant`, `moq_reservation_update`, `moq_reservation_close`, and a reservation parameter on the raw video and audio publish calls. @@ -42,9 +50,10 @@ estimate. Today `rs/moq-ffi/src/video.rs` and `audio.rs` never set `doc/lib/{py,swift,kt,go,c}` per the Cross-Package Sync table; dart after dev merges. Run `just test smoke-full`. - Tests: two video producers on one session reserving 4 and 2 Mbps against a - 3 Mbps estimate get grants summing to at most 3 Mbps; a dropped reservation - frees its share; the built-in encoder's applied bitrate follows a shrinking - grant. + 3 Mbps estimate get grants summing to at most 3 Mbps; two `bandwidth()` + handles see each other's reservations; a dropped reservation frees its share; + a reservation reports `None` across a reconnect and a grant again after it; + the built-in encoder's applied bitrate follows a shrinking grant. Branch from dev. diff --git a/quest/m2/revalidation-updates.md b/quest/m2/revalidation-updates.md index af75d736e7..fda21e0cda 100644 --- a/quest/m2/revalidation-updates.md +++ b/quest/m2/revalidation-updates.md @@ -1,4 +1,4 @@ -# [S] An auth re-check moves the tier and names an alias change +# [M] An auth re-check moves the tier and names an alias change ## Goal @@ -13,24 +13,35 @@ refusal. Scope narrowing keeps the ## Plan -`Auth::recheck` in `rs/moq-relay/src/auth.rs` scores a reply with +Today `Auth::recheck` in `rs/moq-relay/src/auth.rs` scores a reply with `Scope::covered_by` (root, subscribe, publish), drops the `AuthToken`, and -propagates only `CacheHints`. The stats handle is built once at admission in -`connection.rs` from `token.tier` and `token.root`. - -- `Recheck::Valid` carries the reply's `tier`. The connection compares it with - the live handle's and, on change, rebuilds the session stats handle under the - new tier and swaps it into the session's traffic accounting. Earlier counters - are not migrated: the old tier was truthfully what paid until then. +propagates only `CacheHints`; the revalidation loop consumes `Recheck::Valid` +to reschedule itself and nothing reaches the connection. The stats handle is +built once at admission in `connection.rs` from `token.tier` and `token.root`, +handed into the request before acceptance, and cloned into the origins, scopes +and meters, so rebuilding a local handle would retag nothing. + +- `Recheck::Valid` carries the reply's `tier` beside the hints, never the whole + `AuthToken`. The revalidation loop compares it with the tier the session was + admitted under and, on change, retags the session's accounting: the tier + becomes a label the shared `stats::Session` state swaps in place, so every + clone the origins and meters hold records subsequent bytes under the new + tier. Earlier counters are not migrated: the old tier was truthfully what + paid until then. This is the substantive piece; size it before the alias + half. - `Expired` gains an `Alias` variant, additive under `#[non_exhaustive]`, raised when the reply's alias no longer matches the admitted root (v0) or the canonical alias transform relay auth defines for v1 grants. `covered_by` keeps failing it; only the reason changes. - Tests: a re-check that moves the tier records subsequent bytes under the new - meter and leaves earlier bytes where they were; a re-check that changes the - alias closes with `Expired::Alias`; a reply that changes both closes. + meter through an already-cloned handle and leaves earlier bytes where they + were; a re-check that changes the alias closes with `Expired::Alias`; a reply + that changes both closes. - Docs: the revalidation section of `doc/bin/relay/auth.md` gains a per-field - table: scope narrowing resizes, tier updates in place, alias closes. + table: tier updates in place, alias closes, and scope narrowing closes today + (`covered_by` failing maps to `Recheck::Revoked`) and resizes once + [relay auth](/quest/m2/path-patterns/relay-auth.md) lands; say which is in + effect. On main, additive. diff --git a/quest/m2/safari-webtransport.md b/quest/m2/safari-webtransport.md index affc26ba41..60171cbdbd 100644 --- a/quest/m2/safari-webtransport.md +++ b/quest/m2/safari-webtransport.md @@ -10,13 +10,16 @@ this. ## Plan -- Gate `js/net/src/connection/browser.ts` on the version that ships the fix, - for Safari and for the iOS WebKit browsers - [WebKit gate](/quest/m0/webkit-webtransport-gate.md) added, keeping older - versions on the fallback. +- Name the exact Safari and iOS or iPadOS releases that ship the fix in the + quest and in the `browser.ts` comment, then gate on them for Safari and for + the iOS WebKit browsers [WebKit gate](/quest/m0/webkit-webtransport-gate.md) + added, keeping older versions on the fallback. As of 2026-09-08 the bug is + still NEW with no fix released. - Before flipping, rerun the raw WebTransport reproduction from #2388 (about 7,600 eleven-byte unidirectional streams, and about 16 MiB of data) on the - fixed Safari, then a watch longer than two minutes in the QA harness. + fixed Safari, then a watch longer than two minutes in the QA harness on + Safari for macOS and on Chrome, Firefox and Edge for iOS, with desktop Chrome + and Firefox as controls. - Update `doc/lib/js/index.md`. ## Required From 1c9b206de022fcd12a4e3c2d7c332cdb9c06415d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 14:42:53 -0700 Subject: [PATCH 3/4] chore(quest): second review round on the plan pass An auth error ends the connect race only when both arms rejected authentication, so a fallback 403 cannot stop QUIC retries. The rate-control publish calls take the bandwidth handle and reserve themselves, since a reservation needs the track the call creates; audio holds its rate until #2848. The revalidation re-check compares the alias explicitly and swaps the live tier in shared state as a no-op when unchanged. The subgroup header priority never overrides the track's, and the fence exit is defined against the watermark. Co-Authored-By: Claude Fable 5.1 --- quest/m0/3532-connect-auth-race.md | 13 +++++++++---- quest/m0/3534-ietf-publisher-priority.md | 6 +++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/quest/m0/3532-connect-auth-race.md b/quest/m0/3532-connect-auth-race.md index 7e5d8cee29..5453084a67 100644 --- a/quest/m0/3532-connect-auth-race.md +++ b/quest/m0/3532-connect-auth-race.md @@ -21,11 +21,16 @@ non-conformant, not a gap in this repo. - `race_transport_connect` in `rs/moq-native/src/client.rs` returns on `err.is_auth()` from either arm. Record an auth error like any other failure - and keep polling the other arm; when both are done, report the auth error if - either arm produced one, since a genuine bad token fails both. Flip + and keep polling the other arm. When both are done, the result is an auth + error only if both arms rejected authentication; otherwise report the + non-auth failure, which stays retryable, because `Reconnect::run` exits on + `is_auth()` and a fallback endpoint that answers every non-WebTransport + request with 403 must not stop the client from retrying a QUIC dial that + merely failed transiently. Flip `race_transport_connect_stops_on_quic_auth_error` and add: WebSocket 403 then - QUIC success connects; both arms refusing reports Forbidden; a QUIC failure - after a WebSocket 403 reports Forbidden, not the QUIC error. + QUIC success connects; both arms refusing reports Forbidden; a transient QUIC + failure after a WebSocket 403 reports the QUIC error and the reconnect loop + retries. - `MoqClient` in `rs/moq-ffi/src/session.rs` gains `set_websocket_enabled` and `set_websocket_delay` beside `set_tls_disable_verify`, mapping onto `websocket::Client::{enabled, delay}`. libmoq already exports diff --git a/quest/m0/3534-ietf-publisher-priority.md b/quest/m0/3534-ietf-publisher-priority.md index cc4555d421..80bd2d1a7f 100644 --- a/quest/m0/3534-ietf-publisher-priority.md +++ b/quest/m0/3534-ietf-publisher-priority.md @@ -44,7 +44,11 @@ Steps: where it reads the timescale today. The decoder resolves an absent flag to the track's declared default first and only then to the draft's fallback; confirm that fallback against each negotiated draft's text instead of keeping 128 by - assumption, and cite the section in the type's docs. + assumption, and cite the section in the type's docs. On the subscriber the + header value is decoded and then dropped: the model has no per-group + priority, so an explicit subgroup value that disagrees with the track's + declared priority never overrides `track::Info::priority`, and a test pins + that a conflicting header leaves the track's priority unchanged. - Mirror in `js/net/src/ietf/publisher.ts`, `object.ts`, and `properties.ts`. - Tests: 0x21 round-trips on SUBSCRIBE_OK on every draft that carries the block and is absent from the bytes on the ones that do not; a lite-ingested track From 05d8b391b60f760c46f26cae48bdbacc381f097c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 8 Sep 2026 14:45:23 -0700 Subject: [PATCH 4/4] chore(quest): finish the second review round The rate-control publish calls take the bandwidth handle and reserve themselves, since a reservation needs the track the call creates; audio holds its rate until #2848. The revalidation re-check compares the alias explicitly and swaps the live tier in shared state as a no-op when unchanged. The fence exit is defined against the watermark. Co-Authored-By: Claude Fable 5.1 --- quest/m0/3533-ts-export-restart-stall.md | 5 +++- quest/m1/binding-rate-control.md | 33 ++++++++++++++---------- quest/m2/revalidation-updates.md | 25 ++++++++++-------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/quest/m0/3533-ts-export-restart-stall.md b/quest/m0/3533-ts-export-restart-stall.md index ff56333936..2b72931572 100644 --- a/quest/m0/3533-ts-export-restart-stall.md +++ b/quest/m0/3533-ts-export-restart-stall.md @@ -41,7 +41,10 @@ needs a bystander. reset clock sits a few milliseconds below video's next frame, so video re-joins within one frame. Implement it in `Track::admit` against the exporter's watermark rather than as a timer: the exit is a clock comparison, - never a deadline. + never a deadline. A frame at or below the watermark is admitted, a frame + above it stays fenced, and `rewind()` clears the watermark, so nothing + re-joins until a joined track has emitted; `fill` passes the current + watermark into admission so the rule has one definition. - Regression test in `export_test.rs`: two tracks on a continuous transport timeline whose content restarts, the audio track alone stepping back by less than one frame at the join; video and audio keep emitting across it, and the diff --git a/quest/m1/binding-rate-control.md b/quest/m1/binding-rate-control.md index 6f901f39d8..990b3d475d 100644 --- a/quest/m1/binding-rate-control.md +++ b/quest/m1/binding-rate-control.md @@ -4,11 +4,12 @@ A non-Rust publisher follows the connection's send estimate the way Rust does. The bindings mirror `bandwidth::Allocator` and `bandwidth::Reservation`: a -session mints the allocator, a publisher reserves a share for a track at its -configured ceiling, the built-in video and audio encoders in moq-ffi and libmoq -follow the grant when handed a reservation, and an application that owns its -encoder reads the reservation's current grant. A Python, Swift, Kotlin, Go or C -publisher stops holding its configured bitrate through congestion. +session mints the allocator, the built-in video encoder in moq-ffi and libmoq +reserves its configured bitrate against it and follows the grant, the built-in +audio encoder reserves its bitrate and holds it (following comes with #2848, as +in Rust today), and an application that owns its encoder reserves a share for +its own track and reads the current grant. A Python, Swift, Kotlin, Go or C +video publisher stops holding its configured bitrate through congestion. Boundaries: the allocation rules are Rust's (strict priority tiers, max-min fair within a tier, ceilings never observed rates) and the bindings add no policy. @@ -31,21 +32,27 @@ estimate. Today `rs/moq-ffi/src/video.rs` and `audio.rs` never set registry. The allocator consumes the live `bandwidth::Consumer`, and because a moq-ffi session reconnects on its own, that consumer is the reconnecting one: it reports no estimate while disconnected and resumes on the next - connection, and reservations survive the gap. `MoqBandwidth::reserve(track, - max_bps) -> MoqReservation` is keyed on the track producer's demand. + connection, and reservations survive the gap. Two ways in, because a + reservation needs the track's demand and the built-in encoders create their + track inside the publish call: an app-owned encoder calls + `MoqBandwidth::reserve(track, max_bps) -> MoqReservation` on a track producer + it already holds, while the video and audio publish options take the + `MoqBandwidth` handle and the publish call reserves at the configured bitrate + itself, exposing the result as `producer.reservation()`. `MoqReservation::grant() -> Option` is the current share as a snapshot (an encoder that asks before each frame needs nothing more); `None` means the allocator has no estimate or the track is not demanded, hold the current rate, and `Some(0)` is a real zero grant, exactly the distinction `Reservation::peek` draws. `update(max_bps)` moves the ceiling and dropping - the reservation releases the share. Video and audio publish options accept an - optional reservation; the built-in encoders feed its consumer to - `Options::bandwidth` unchanged, so the same `None` versus zero semantics - reach `rate::Control`. `set_bitrate` stays as the manual ceiling. + the reservation releases the share. The built-in video encoder feeds the + reservation's consumer to `Options::bandwidth` unchanged, so the same `None` + versus zero semantics reach `rate::Control`; audio registers the reservation + and ignores the grant until #2848 lands. `set_bitrate` stays as the manual + ceiling. - libmoq: `moq_session_bandwidth`, `moq_bandwidth_reserve`, `moq_reservation_grant`, `moq_reservation_update`, `moq_reservation_close`, - and a reservation parameter on the raw video and audio publish calls. - Regenerate `moq.h`. + a bandwidth-handle parameter on the raw video and audio publish calls, and a + reservation accessor on their producers. Regenerate `moq.h`. - Wrappers `py/moq-rs`, `swift`, `kt`, `go/wrapper/moq` and `doc/lib/{py,swift,kt,go,c}` per the Cross-Package Sync table; dart after dev merges. Run `just test smoke-full`. diff --git a/quest/m2/revalidation-updates.md b/quest/m2/revalidation-updates.md index fda21e0cda..7033216483 100644 --- a/quest/m2/revalidation-updates.md +++ b/quest/m2/revalidation-updates.md @@ -22,17 +22,20 @@ handed into the request before acceptance, and cloned into the origins, scopes and meters, so rebuilding a local handle would retag nothing. - `Recheck::Valid` carries the reply's `tier` beside the hints, never the whole - `AuthToken`. The revalidation loop compares it with the tier the session was - admitted under and, on change, retags the session's accounting: the tier - becomes a label the shared `stats::Session` state swaps in place, so every - clone the origins and meters hold records subsequent bytes under the new - tier. Earlier counters are not migrated: the old tier was truthfully what - paid until then. This is the substantive piece; size it before the alias - half. -- `Expired` gains an `Alias` variant, additive under `#[non_exhaustive]`, raised - when the reply's alias no longer matches the admitted root (v0) or the - canonical alias transform relay auth defines for v1 grants. `covered_by` - keeps failing it; only the reason changes. + `AuthToken`. The revalidation loop hands it to the shared `stats::Session` + state, which holds the current tier behind an in-place swap and treats an + unchanged tier as a no-op, so `A -> B -> B` retags once and `A -> B -> C` + twice, and every clone the origins and meters hold records subsequent bytes + under the current tier. Earlier counters are not migrated: the old tier was + truthfully what paid until then. This is the substantive piece; size it + before the alias half. +- `Auth::recheck` compares the alias explicitly before scoring coverage, and a + changed alias becomes its own `Recheck` outcome rather than falling through + `covered_by` into `Recheck::Revoked`. The loop maps it to a new + `Expired::Alias` variant, additive under `#[non_exhaustive]`; scope loss and + refusals keep `Expired::Revoked`. Under v0 the comparison is against the + admitted root; under v1 it is the canonical alias transform relay auth + defines. Alias takes precedence when both alias and tier change. - Tests: a re-check that moves the tier records subsequent bytes under the new meter through an already-cloned handle and leaves earlier bytes where they were; a re-check that changes the alias closes with `Expired::Alias`; a reply