diff --git a/quest/m1/README.md b/quest/m1/README.md index d8d61c89148..e09e6d7b703 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -52,6 +52,7 @@ lands before the cut. - [qmux credit](/quest/m0/qmux-credit.md) - dropped/stopped streams return credit and close is delivered - [Per-package MIGRATE.md](/quest/m1/migrate.md) - every published package documents how to migrate each breaking change, and breaking PRs add their entry +- [moq-net follows announced instances](/quest/m1/net-announced-follow.md) - the Rust equivalent of JS `request(path, { announced: true })`, replacing the follow loops in moq-ffi and moq-c - [Sink restart follow](/quest/m1/sink-restart-follow.md) - moq-cli's server and push sinks follow an epochless `Restart` where their output can mark it, and fail loud otherwise - [OBS restart follow](/quest/m1/obs-restart-follow.md) - the OBS source keeps playing across a GOAWAY drain or a publisher replacement - [Cluster routing](/quest/m1/cluster-routing/README.md) - prove shared routing contracts, implement per-origin routes, and connect outbound local clusters @@ -84,7 +85,10 @@ lands before the cut. - [IETF takeover](/quest/m1/ietf-takeover.md) - carry the shared takeover contract through the negotiated IETF epoch extension - [Identity and recovery guide](/quest/m1/identity-guide.md) - explain pinned catalogs, lazy jobs, takeover, restart and version compatibility - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped`; a dead route ends Unroutable, and the TS follower lingers on announcements instead of matching errors +- [A partial group is never cached as whole](/quest/m1/partial-group-reset.md) - every reader, local or served, gets `Old` when an unfinished subscription group expires, so no hop caches the prefix as complete - [Generated C bindings](/quest/m1/c/README.md) - C generated from moq-ffi ships as `moq-c` 0.8.0 and replaces the hand-written libmoq +- [No rustls panic on an http dial](/quest/m1/http-dial-crypto-provider.md) - the `http://` certificate fetch installs a crypto provider first +- [moq-ffi reports why a first dial fails](/quest/m1/ffi-first-dial-failure.md) - OBS names the reason while the first dial retries, as moq-c already can - [Typed refusal reason](/quest/m1/refusal-reason.md) - an auth server's 403 names the reason (including `expired`) and the root and tier, so the relay's session outcomes count and attribute refusals - [JS track handover](/quest/m1/js-group-handover.md) - a JS track subscription resumes across a route swap from the first frame it lacks, so `test/drain` passes at zero latency budget - [Scope track priority](/quest/m1/track-priority-scope.md) - priority orders one owner's streams, and a shared cluster session is fair across tenants @@ -96,6 +100,7 @@ lands before the cut. - [Container reads drop backfill](/quest/m1/read-backfill.md) - the JS container consumer drops groups below its read cursor like Rust - [Rust request credit](/quest/m1/rs-request-credit.md) - a moq-net request fails at once without stream credit instead of waiting - [qmux no-wait opens](/quest/m1/qmux-no-wait.md) - @moq/qmux rejects an over-limit create when waitUntilAvailable is false, like Chrome +- [Timed-out requests clean up](/quest/m1/legacy-request-timeout-cleanup.md) - on drafts 14-16, Rust and JS send PUBLISH_NAMESPACE_DONE or UNSUBSCRIBE_NAMESPACE for a timed-out request before its local reset - [TS fidelity and recovery](/quest/m1/ts-reliability/README.md) - both modes have explicit profiles, automated validation, loss tests, and a dedicated guide - [Resume reorder](/quest/m1/resume-reorder.md) - a group whose stream header arrives after its successor's still resumes on failover; only a dropped or long-gone group fails - [Received timestamps are media time](/quest/m1/lite-time-provenance.md) - a received timed track's timestamps are media time, so the rewind check can run across hops diff --git a/quest/m1/ffi-first-dial-failure.md b/quest/m1/ffi-first-dial-failure.md new file mode 100644 index 00000000000..3e664e1ded1 --- /dev/null +++ b/quest/m1/ffi-first-dial-failure.md @@ -0,0 +1,28 @@ +# [S] moq-ffi reports why a first dial fails + +## Goal + +While a moq-ffi client's first dial keeps failing and retrying, the caller can +read why, so the OBS dock names the reason instead of showing only +"Connecting…". moq-c already does this: `moq_session_connect` returns a +session before the first connect, and `moq_session_failure` names the +retrying dial. + +Today the native `connect` in `rs/moq-ffi/src/session.rs` awaits +`connection.established()` before it returns a `MoqSession`, so +`MoqSession::failure()` (#5723) is unreachable until the first success. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, in m1 +because OBS needs it. + +The API shape is the maintainer's call; bring options with a recommendation. +Candidates: a failure accessor on `MoqClient` readable while `connect()` is +pending, returning the session before the first connect as moq-c does, or +failing the first dial fast with its error. The first is additive and keeps +`connect()` surfacing auth refusals. Follow the moq-ffi row of the +Cross-Package Sync table, ending with the dock in `cpp/obs`. + +Test: a dial to a listener that hangs up reports its reason before any +session connects, and the report clears once one does. diff --git a/quest/m1/http-dial-crypto-provider.md b/quest/m1/http-dial-crypto-provider.md new file mode 100644 index 00000000000..b579cf89338 --- /dev/null +++ b/quest/m1/http-dial-crypto-provider.md @@ -0,0 +1,24 @@ +# [XS] No rustls panic on an http dial + +## Goal + +An `http://` dial through moq-tokio's QUIC backend never panics for want of a +rustls crypto provider: it installs one before fetching the certificate +fingerprint. moq-ffi and moq-c reach this path. + +Today the fetch of `/certificate.sha256` (`reqwest::get` in +`rs/moq-tokio/src/quic/backend.rs`) panics inside reqwest when the build +unifies reqwest's `rustls` feature and no process default is installed, for +example `cargo test -p moq-tokio -p moq-ffi -p moq-c --all-features`. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, from the +follow-ups of [#5723](https://github.com/moq-dev/moq/pull/5723). + +`moq_tokio::crypto::install_default()` installs the provider the crate +features select, and returns an error that can be ignored when one is already +installed. Add a regression test that dials `http://` under the unified +features and fails without the fix; moq-c's +`session_failure_names_a_failing_dial` dials `ws://` only to stay clear of +this. Make sure a CI lane builds that feature combination. diff --git a/quest/m1/lazy-discovery/rust.md b/quest/m1/lazy-discovery/rust.md index 87e248b5c22..24de0ace746 100644 --- a/quest/m1/lazy-discovery/rust.md +++ b/quest/m1/lazy-discovery/rust.md @@ -24,6 +24,10 @@ Facts (2026-10-10, `origin/main` e5446b036): equivalent. - `OriginConsumer::follow` exists (`rs/moq-net/src/model/origin.rs:4473`); Rust has no announced-gated request like JS's, so the fold's shape is open. +- Added 2026-10-17: [moq-net follows announced instances](/quest/m1/net-announced-follow.md) + lands first and gives Rust its announced request. This quest then makes + that request register discovery interest for its path, since the session + stops asking for its whole scope. - moq-relay reads `origin.consume().announced()` for the root in its web, internal, and cluster paths, so a relay's watch set is the root. @@ -50,3 +54,7 @@ fallout (see the Cross-Package Sync table in `AGENTS.md`). Tests must pin the relay side: an origin in relay mode (cluster and upstream sessions on the client subscriber path) still sends a root request, so lazy discovery never narrows what a relay discovers. + +## Required + +- [moq-net follows announced instances](/quest/m1/net-announced-follow.md) - the Rust announced request this quest registers discovery interest for diff --git a/quest/m1/legacy-request-timeout-cleanup.md b/quest/m1/legacy-request-timeout-cleanup.md new file mode 100644 index 00000000000..47f9e210ca5 --- /dev/null +++ b/quest/m1/legacy-request-timeout-cleanup.md @@ -0,0 +1,39 @@ +# [S] Timed-out requests clean up on drafts 14-16 + +## Goal + +When a request on moq-transport drafts 14-16 times out after it reached the +peer, Rust and JS both send the legacy cleanup message before the local +reset: PUBLISH_NAMESPACE_DONE for an advertisement, and UNSUBSCRIBE_NAMESPACE +for a namespace subscription on drafts 14-15. Unanswered advertisements stay +terminal, as #5741 decided; only the cleanup changes. + +On drafts 14-16 a request rides a virtual control-stream stream whose reset +never reaches the peer, so today a timed-out PUBLISH_NAMESPACE is never +withdrawn and the peer can hold the namespace until the session closes: + +- JS: `Deadline` (`js/net/src/util/deadline.ts`) aborts every stream it + recorded when the deadline fires, before the caller can write the cleanup. +- Rust: when `read_response` in `rs/moq-net/src/ietf/publisher.rs` gives up + on an advertisement, the request is dropped without PUBLISH_NAMESPACE_DONE. + +## Plan + +Decided with the maintainer on 2026-10-17: + +- The goal comes from the review of + [#5741](https://github.com/moq-dev/moq/pull/5741), which put the JS fix in + `Deadline`'s timeout path rather than in each caller. +- Widened to Rust and JS in one quest, rather than a separate Rust quest, + since both have the same gap on the same drafts. + +The JS legacy SUBSCRIBE path opens its virtual stream outside `Deadline`, and +its timeout cleanup already sends UNSUBSCRIBE (`#cancelSubscribe`); keep that +covered by a test while moving the mechanism. Check every other timeout on +these drafts, in both languages, for the same gap. Draft 17 and later keep +the stream reset alone. + +Test with mocked time on drafts 14, 15 and 16, in Rust and JS: a timed-out +PUBLISH_NAMESPACE whose message was sent is followed by PUBLISH_NAMESPACE_DONE +on the control stream, and the same for UNSUBSCRIBE_NAMESPACE on drafts +14-15. Wire: no new messages. diff --git a/quest/m1/net-announced-follow.md b/quest/m1/net-announced-follow.md new file mode 100644 index 00000000000..85187c17ea0 --- /dev/null +++ b/quest/m1/net-announced-follow.md @@ -0,0 +1,53 @@ +# [M] moq-net follows announced instances + +## Goal + +moq-net offers the Rust equivalent of JS `request(path, { announced: true })` +(`js/net/src/origin.ts`): one request that follows each publisher instance +serving a path, unless an epoch is pinned. moq-ffi's +`MoqAnnouncedBroadcast::available()` (`rs/moq-ffi/src/origin.rs`) and moq-c's +`moq_origin_announced_broadcast` (`rs/moq-c/src/origin.rs`) use it and drop +their hand-rolled follow loops. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, from +decision 5 of [#5700](https://github.com/moq-dev/moq/pull/5700), which kept +the loops per binding until moq-net owns them. Mirror the JS name. + +The sink follow helper waits on this quest so it does not add a third loop; +the OBS source keeps calling `available()`, whose API does not change. + +The loops #5700 added drain `origin::Consumer::follow`, resolve the path with +`routed_broadcast`, and settle three cases moq-net should own once: + +- An `End` forgets the last instance, so the same instance returning after + a gap is handed out again. +- The last instance is held weakly, so a handle the caller let go of never + keeps its instance from retiring. +- A change that arrives while a resolution is in flight, such as an end and + a start for the instance it resolved, resolves afresh; and a resolution + that runs ahead of its held `Restart` is not handed out twice. + +Two JS behaviors have no Rust counterpart to mirror. JS falls back to blind +resolution while a `consume` session lacks discovery; moq-net has no blind +requests. Decided with the maintainer on 2026-10-17: no blind fallback. The +mode waits on an announced route as `routed_broadcast` does, and blind +requests stay out of scope, so it differs from JS when discovery is +unavailable. JS also watches the path so a `consume` session asks its peer about +it. A moq-net session asks for its whole granted scope at setup today +(`interest_prefixes` in `rs/moq-net/src/lite/subscriber.rs`), so this quest +registers no interest. [Rust lazy discovery](/quest/m1/lazy-discovery/rust.md) +narrows that to watched prefixes, lands after this quest, and owns making +this mode watch its path, as JS does. + +JS returns a handle whose current broadcast changes per instance +(`Request.active`); Rust's `request_broadcast` takes only an optional epoch +today. Propose the Rust shape in the PR. Keep #5700's binding tests passing +unchanged as the regression, and add moq-net tests for the three cases plus +a pinned epoch: an announced request that pins epoch A refuses a replacement +announced with epoch B instead of following it, mirroring JS `an +epoch-pinned announced request refuses another instance` +(`js/net/src/origin-request.test.ts`). +Public API: a new moq-net request mode or handle, and simpler binding +internals with no binding API change. Wire: none. diff --git a/quest/m1/partial-group-reset.md b/quest/m1/partial-group-reset.md new file mode 100644 index 00000000000..6e906e3c4f0 --- /dev/null +++ b/quest/m1/partial-group-reset.md @@ -0,0 +1,42 @@ +# [S] A partial group is never cached as whole + +## Goal + +A subscription group whose max-delay budget expires before the publisher +finishes it fails with `Old` instead of ending normally, for every reader: +a local reader at the group's tail and a publisher serving it alike, on every +hop, in Rust and JS. A serving publisher resets the stream with `Old`, so a +relay that receives it never caches the prefix as the complete group. + +Today a reader that has taken every frame the group holds treats expiry as +losing nothing and ends the group cleanly: `expired_truncates` in +`rs/moq-net/src/model/group.rs` and `#expire` in `js/net/src/group.ts`. The +lite publisher already resets with `Old` when a group expires before its +stream opens or while a write is blocked, but when it is parked at the tail +waiting for the next frame, `poll_next_frame` returns `Ok(None)` and +`GroupServe::poll_serve` (`rs/moq-net/src/lite/publisher.rs`) finishes the +stream. The next hop then stores the truncated group as finished. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, from the +follow-up to [#5680](https://github.com/moq-dev/moq/pull/5680), which made an +expiring FETCH reset with `OLD` for the same reason. + +Decided with the maintainer on 2026-10-17: every reader, local or served, +gets `Old` when an unfinished group expires. Rejected: resetting only on the +serving path, which leaves a local reader unable to tell a truncated group +from a whole one, and a separate expired-at-tail state, which adds API for +the same outcome. The clean end was chosen for a local reader stalled at a +group's tail; it goes, along with `expired_truncates`' distinction. Readers +already treat `Old` like a latency skip. + +Add regression tests that fail without the fix: + +- A Rust model test in `rs/moq-net/src/model/group.rs` and its JS twin for + `js/net/src/group.ts`: a local reader that has taken every frame of an + unfinished group receives `Old` when the group expires. +- An upstream group stalls past the budget after its last frame, and the + downstream hop holds no finished group for it. + +Wire: no new codes; lite already resets with `Old`. diff --git a/quest/m1/sink-restart-follow.md b/quest/m1/sink-restart-follow.md index 06746a63e75..dde69b0bdee 100644 --- a/quest/m1/sink-restart-follow.md +++ b/quest/m1/sink-restart-follow.md @@ -34,3 +34,7 @@ rather than one rule per sink. Each fix lands with a test that replaces the publisher while the sink runs. Public API: possibly a shared follow helper in `moq-mux`. Wire: none. + +## Required + +- [moq-net follows announced instances](/quest/m1/net-announced-follow.md) - the shared follow helper builds on moq-net's announced request, not a third hand-rolled loop diff --git a/quest/m1/ts-reliability/README.md b/quest/m1/ts-reliability/README.md index 789a802c0c8..8f67b26d85d 100644 --- a/quest/m1/ts-reliability/README.md +++ b/quest/m1/ts-reliability/README.md @@ -102,6 +102,7 @@ remain with their current quests. - [Plan: semantic fidelity](/quest/m1/ts-reliability/fidelity.md) - explicit profiles and PMT lifecycle contracts produce implementation quests - [Whole-PES import](/quest/m1/ts-reliability/pes-import.md) - private PES travels whole at its source placement, a missing PTS stays missing, and the `pes` framing is deleted - [Section placement](/quest/m1/ts-reliability/section-placement.md) - sections share the PCR placement clock instead of the video PTS +- [Sequence holes get a time budget](/quest/m1/ts-reliability/hole-budget.md) - a hole gives up on max(wall, pts), so a break marker behind it on a paused track is delivered - [Loss and recovery tests](/quest/m1/ts-reliability/recovery.md) - actual MoQ omissions exercise reacquisition and unaffected tracks ## Related diff --git a/quest/m1/ts-reliability/hole-budget.md b/quest/m1/ts-reliability/hole-budget.md new file mode 100644 index 00000000000..dcd31839ea4 --- /dev/null +++ b/quest/m1/ts-reliability/hole-budget.md @@ -0,0 +1,44 @@ +# [S] Sequence holes get a time budget + +## Goal + +A container consumer waiting on a missing group sequence gives up on it once +a budget expires on max(wall clock, pts), then reports the gap and what +follows. A break marker behind a hole on a paused track is delivered without +waiting for media to resume or the track to finish. Rust +(`rs/moq-mux/src/container/consumer.rs`) and JS +(`js/hang/src/container/consumer.ts`) agree. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, from the +review of [#5815](https://github.com/moq-dev/moq/pull/5815). The budget covers +every kind of hole, not only one before a data marker, and runs on +max(wall, pts) so it fires on a paused track. + +Today a marker carries no timestamp, so a hole before it on an open, paused +track waits indefinitely. A buffered higher sequence alone proves nothing, +since a late group is not a lost one; the budget is what decides. + +The two anchors, matching the timers the TS line already uses: + +- PTS: the existing age measure, how far the newest content + (`max_timestamp`) runs past where the next stamped group begins + (`next_start`), in `rs/moq-mux/src/container/consumer.rs`, and its + counterpart in `js/hang/src/container/consumer.ts`. A bare marker has no + timestamp, so this side never fires behind one. +- Wall: the `moq_clock` instant the reader first found itself blocked on + the hole, the way the TS export anchors a frame's arrival at the instant + its source was last found empty (`Track::empty` in + `rs/moq-mux/src/container/ts/export.rs`, judged by `jitter::Buffer` in + `rs/moq-mux/src/jitter.rs`). A timer wakes the reader at the anchor plus + the budget, with no new group needed. + +The hole is lost once either side reaches the budget. Decided with the +maintainer on 2026-10-17: the budget is the subscription's max delay, which +already drives latency skips, so both sides share one number. With the +default of zero, a hole counts as lost as soon as the reader blocks on it. + +Test with mocked time in both languages: read group N, lose N+1, receive a +closed marker as N+2 on a paused track; after the budget the reader reports a +gap, then the end. It fails without the fix. diff --git a/quest/m2/README.md b/quest/m2/README.md index d85313c648e..0c14e2ef049 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -41,6 +41,7 @@ ranked just below deterministic TS legs, which it requires. - [Vulkan Video encode on AMD](/quest/m2/vulkan-encode.md) - H.264 and H.265 from an external Vulkan image on RADV, hand-rolled on ash, proven on an RX 9070 - [VA-API encodes an external Vulkan image](/quest/m2/vaapi-vulkan-import.md) - an explicit-sync DMA-BUF reaches Intel's encoder, proven on Arrow Lake - [C++ FFI reuse](/quest/m2/cpp-ffi-reuse.md) - C++ packages link the shared default-feature FFI build once it is proven compatible +- [Unprefixed C++ free functions](/quest/m2/cpp-free-function-prefix.md) - generated free functions drop `moq_`, and `moq.hpp` deletes its five pass-through wrappers - [Time test sweep](/quest/m2/time-test-sweep.md) - unrelated test conversion and the workspace lint ratchet @@ -48,6 +49,7 @@ ranked just below deterministic TS legs, which it requires. - [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 - [OBS multitrack](/quest/m2/obs-multitrack.md) - the obs-moq output publishes OBS's native multitrack video encoders as simulcast renditions, configured like OBS multitrack and over obs-websocket - [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 +- [CMake qlog option for OBS](/quest/m2/obs-qlog.md) - an OBS build can opt into qlog trace capture through moq-ffi's `qlog` feature - [AudioToolbox decode](/quest/m2/audio-decode-audiotoolbox.md) - macOS and iOS decode HE-AAC, multichannel AAC, and what else the framework offers - [AudioToolbox encode](/quest/m2/audio-encode-audiotoolbox.md) - macOS and iOS encode AAC-LC - [mp4-atom dOps mapping](/quest/m2/mp4-atom-dops-mapping.md) - a released mp4-atom reads and writes any `dOps` channel mapping family and table @@ -56,6 +58,7 @@ ranked just below deterministic TS legs, which it requires. - [Simulcast rung disable](/quest/m2/simulcast-rung-disable.md) - JS, Rust/FFI, and OBS publishers stop encoding the top renditions their grant cannot fund, advertise `enabled: false`, and re-enable with hysteresis - [Discover media headroom](/quest/m2/quic-probe.md) - test useful-media pacing before adding redundant probe traffic - [Viewer up-switch](/quest/m2/viewer-upswitch.md) - a viewer capped by its small rendition finds headroom through PROBE and moves up +- [A finished PROBE stops reporting](/quest/m2/probe-end-stale.md) - the Rust receive estimate reads unknown once its PROBE stream ends - [Wire stats](/quest/m2/wire-stats/README.md) - `Presence` counts each root's transport bytes on the wire, `wire_tx` and `wire_rx`, from QUIC, WebSocket, and embedder gateway sources - [Media stats](/quest/m2/stats/README.md) - publishers announce a stats track in the catalog, viewers answer a soliciting catalog through a per-catalog `.echo` broadcast, and one model turns both into a health verdict and a preflight report - [Perf](/quest/m2/perf/README.md) - eliminate measured hot-path costs across moq-uring, kio, and the moq-net model @@ -95,6 +98,7 @@ ranked just below deterministic TS legs, which it requires. - [moqsrc A/V sync](/quest/m2/moqsrc-av-sync.md) - a nightly test measures A/V skew from `moqsrc` through clock-synced sinks - [MP4 export](/quest/m2/mp4-export.md) - `moq export mp4 --output` records crash-safe fragments, then finishes a regular MP4 with moov at the end - [fMP4 edit lists](/quest/m2/fmp4-edit-lists.md) - the fMP4 importer applies edit lists to frame timestamps +- [Container aspect signals](/quest/m2/container-aspect.md) - fMP4 `pasp` and Matroska display size feed the catalog display aspect - [Linux decoded frames](/quest/m2/obs-decode-linux.md) - present supported native decoded surfaces with visible CPU fallback - [Windows decoded frames](/quest/m2/obs-decode-windows.md) - present decoded D3D11 surfaces in OBS without CPU readback - [macOS GPU input](/quest/m2/obs-macos.md) - feed the encoder from the OBS compositor without CPU readback @@ -168,3 +172,5 @@ ranked just below deterministic TS legs, which it requires. - [Relay exposes a verified peer address](/quest/m2/relay-peer-addr.md) - WebRTC and other embedded routes know the client's socket address - [RTMP client reports a publish or play error](/quest/m2/rtmp-client-error.md) - a refused publish or play surfaces its reason - [Adopt usage-rs 7](/quest/m2/usage-rs-7.md) - the workspace builds on usage-rs 7 +- [Dead routes on IETF name their cause](/quest/m2/js-ietf-dead-route-error.md) - a JS subscription on a dead moq-transport session ends with the session error, not `unauthorized` +- [Error cleanups](/quest/m2/error-cleanups.md) - delete the unused `Error::SessionClosed`, and Rust and JS both reset a served stream over a raw transport failure with `Unroutable` diff --git a/quest/m2/container-aspect.md b/quest/m2/container-aspect.md new file mode 100644 index 00000000000..e25d4680779 --- /dev/null +++ b/quest/m2/container-aspect.md @@ -0,0 +1,59 @@ +# [S] Container aspect signals + +## Goal + +The fMP4 `pasp` box and Matroska `DisplayWidth`/`DisplayHeight` feed the +catalog's display aspect (`display_aspect_width`/`display_aspect_height`), so +AV1 renditions and headerless avc3/hev1 initializers that carry them import +with the right display shape. Today only SPS metadata sets it (#5755). + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, from the +follow-ups of [#5755](https://github.com/moq-dev/moq/pull/5755). + +The two signals mean different things, so each maps on its own: + +- fMP4 `pasp` is a pixel aspect (`hSpacing:vSpacing`, like the SPS sample + aspect). The display aspect is the picture's width times `hSpacing` to its + height times `vSpacing`. A 1:1 `pasp` adds nothing beyond the coded size, so + it leaves the field unset, as a square SPS does since #5755. mp4-atom + already parses `pasp` on visual sample entries. +- Matroska `DisplayWidth`/`DisplayHeight` are display dimensions after + cropping, so their ratio is the display aspect itself. Set it whenever it + differs from the coded `PixelWidth`/`PixelHeight` ratio, even when it is + 1:1. Every `DisplayUnit` gives a ratio, since both fields share the unit: + pixels, centimeters, inches, or a bare aspect ratio. With `DisplayUnit` 0, + an absent field defaults to the cropped size (`PixelWidth` minus + `PixelCropLeft` and `PixelCropRight`, `PixelHeight` minus `PixelCropTop` + and `PixelCropBottom`), so an uneven crop alone can change the aspect. Any + other unit requires both fields, so a track missing one fails the import + as malformed. + +Decided with the maintainer on 2026-10-17: the SPS wins, and the container +fills only an SPS that signals no aspect, logging any conflict, as #5846 +orders colour in `rs/moq-mux/src/codec/color.rs` (a muxer may get the box +wrong). Rejected: the container first, as FFmpeg's +`av_guess_sample_aspect_ratio` does to honour a remux fix. + +Tests, through both importers, including an AV1 track and headerless avc3 +and hev1 initializers, since `init_h264` and `init_h265` in +`rs/moq-mux/src/container/fmp4/import.rs` are separate paths: + +- 1440x1080 with `pasp` 4:3 imports as 16:9. +- 1440x1080 with `pasp` 1:1 leaves the field unset. +- Matroska 1440x1080 with a 1920x1080 display imports as 16:9. +- Matroska 1440x1080 with a 1080x1080 display imports as 1:1. +- Matroska 1440x1080 with `PixelCropRight` 360 and no display dimensions + imports as 1:1. +- Matroska with a 16x9 display in centimeters or inches imports as 16:9. +- Matroska with a non-pixel `DisplayUnit` and no display dimensions fails + the import. +- Matroska without crop or display dimensions, or with ones matching the + pixel ratio, leaves the field unset. +- An avc1 entry and an hvc1 entry, each with a 1:1 `pasp` against a + non-square SPS, keep the SPS aspect and log the conflict. +- An avc1 entry and an hvc1 entry whose SPS signals no aspect take `pasp` + 4:3 on 1440x1080 as 16:9. + +In-band SPS acquisition for headerless initializers is separate work. diff --git a/quest/m2/cpp-free-function-prefix.md b/quest/m2/cpp-free-function-prefix.md new file mode 100644 index 00000000000..4ad5746bf2b --- /dev/null +++ b/quest/m2/cpp-free-function-prefix.md @@ -0,0 +1,26 @@ +# [S] Strip moq_ from generated C++ free functions + +## Goal + +Generated C++ free functions drop moq-ffi's `moq_` prefix the way generated +types already drop `Moq` (`strip_type_prefix` in `cpp/moq/uniffi.toml`), so +`moq::log_level` is the generated function itself. `moq.hpp` +(`cpp/moq/include/moq/moq.hpp`) then deletes its five pass-through wrappers: +`log_level`, `mint_epoch`, `client_defaults`, `versions` and +`qlog_supported`. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, the follow-up +#5727 deferred. + +The option belongs in the uniffi-bindgen-cpp fork, released and pinned the +way #5727 pinned `strip_type_prefix` (every place that PR moved the pin). +`epoch_time` stays in `moq.hpp`, since it converts milliseconds to a +`time_point`, and calls the stripped `epoch_time_ms`. Callers in `cpp/obs` and +`doc/lib/cpp` already use the short names. The C++ package is unreleased, so +no upgrade note. Public API: C++ only, `moq::moq_*` free functions are gone. + +## Related + +- [Hard-fork the binding generator](/quest/m2/uniffi-fork.md) - where the generator option will live once the fork moves diff --git a/quest/m2/e2ee/README.md b/quest/m2/e2ee/README.md index 5d533d2c83a..827277ce7e3 100644 --- a/quest/m2/e2ee/README.md +++ b/quest/m2/e2ee/README.md @@ -43,6 +43,10 @@ The Rust and TypeScript cores expose the same surface, and nothing else: - A platform that forwards and meters protected bytes must never preview, record, archive, transmux, transcode, transcribe, compose, or inspect them, rejecting those paths before opening a processing session or writing product state. Applications needing those operations terminate E2EE outside the platform. A platform classifies protected broadcasts by its own credential or product state, never by name; the moq.pro (downstream) exclusion classifier and dashboard work stay downstream. - The first proof covers browser TypeScript and native Rust publication and playback in both directions, with grouped audio and video over both moq-lite and MoQ Transport. Shared vectors cover groups and moq-lite datagrams. JavaScript carries MoQ Transport datagrams since [#4979](https://github.com/moq-dev/moq/pull/4979), so the proof covers them too. +### Dynamic names + +Decided with the maintainer on 2026-10-17: physical names are a one-way hash, so a publisher cannot answer a request for a name it never declared. [Dynamic names](/quest/m2/e2ee/dynamic-names.md) designs that before any implementation, and [#5876](https://github.com/moq-dev/moq/pull/5876) (the broadcast wrappers, whose `reserve_track` covers only names declared in advance) is held until it decides. + ## Required - [Rust protected publisher seams](/quest/m2/e2ee/rust-publish.md) - Rust media @@ -57,6 +61,9 @@ The Rust and TypeScript cores expose the same surface, and nothing else: - [Cross-language encrypted proof](/quest/m2/e2ee/interop.md) - browser TypeScript and native Rust exchange encrypted media both ways, with relays forwarding and metering blind +- [Dynamic names](/quest/m2/e2ee/dynamic-names.md) - a reviewed design for + answering requests for undeclared track names, split into implementation + quests; #5876 waits on it ## Closes diff --git a/quest/m2/e2ee/dynamic-names.md b/quest/m2/e2ee/dynamic-names.md new file mode 100644 index 00000000000..b88c9e3b541 --- /dev/null +++ b/quest/m2/e2ee/dynamic-names.md @@ -0,0 +1,39 @@ +# [M] Design dynamic names under e2ee + +## Goal + +A written design, approved by the maintainer before any implementation, for +how an e2ee publisher answers a request for a track name it never declared. +It compares at least three options against the threat model of +[draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md): + +- reversible physical names, such as SIV encryption of the semantic name; +- a name-directory track; +- requests that carry an encrypted semantic name. + +It ends by splitting the chosen design's implementation into follow-up +quests. This quest writes no implementation code. + +## Plan + +Decided with the maintainer on 2026-10-17: + +- Design-only, with a design-review gate: open the PR as a draft and get the + maintainer's approval of the choice before any implementation quest starts. +- [#5876](https://github.com/moq-dev/moq/pull/5876) (the e2ee broadcast + wrappers, with `reserve_track`) is held until this quest decides. + +Background: a physical track name is a one-way HKDF of the semantic name +(the draft's Key Derivation section). Only the CDN sees physical names, and a +wrapper over `moq-net` translates them. Because the hash is one-way, a +publisher cannot map a request for an undeclared name back to its semantic +name, so #5876's `reserve_track` covers only names declared in advance. +`moq-net` serves undeclared names through `broadcast::Producer::dynamic()`. + +For each option, weigh what a relay learns (name length, linkability across +epochs and subscribers, request patterns), whether identical requests still +share one physical name so relays dedupe and cache them, the effect on key +derivation and the nonce rules, wire and draft changes, the Rust and JS API +shape, and cost. Record the chosen design, with the rejected options and why, +where it will live: the e2ee draft if it changes the format, otherwise the +epic README. diff --git a/quest/m2/error-cleanups.md b/quest/m2/error-cleanups.md new file mode 100644 index 00000000000..7cd83c9809f --- /dev/null +++ b/quest/m2/error-cleanups.md @@ -0,0 +1,30 @@ +# [XS] Error cleanups + +## Goal + +`moq_net::Error::SessionClosed`, which nothing outside tests constructs, is +deleted. A raw transport failure or idle timeout (a session that failed +without a close code) resets a served stream with `Unroutable` in both Rust +and JS, so one condition sends one code. Today both send `Internal`: Rust's +`StreamError::from(&Error)` in `rs/moq-net/src/error.rs` reaches its +catch-all arm for `Transport` and `IdleTimeout`, and `localStreamCode` in +`js/net/src/error.ts` falls through. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, from the +follow-ups of [#5729](https://github.com/moq-dev/moq/pull/5729), widened the +same day to Rust after review found Rust sends `Internal` too. + +#5729 was still open at planning, and its head already maps `Transport`, +`IdleTimeout` and a codeless session failure to `Unroutable` in both +languages, with unit tests in `rs/moq-net/src/error.rs` and +`js/net/src/error.test.ts`. If it lands that way, confirm those tests and +finish only the `SessionClosed` removal; otherwise add the mapping and a +test on each side here. + +`SessionClosed` has match arms in `rs/moq-net` (`error.rs`, +`model/resume.rs`), `rs/moq-ffi/src/error.rs` and `rs/moq-relay`, and +test-only uses in `rs/moq-hls`, `rs/moq-gst` and `rs/moq-relay`. Removing a +public variant is a breaking Rust change: add the `rs/moq-net/MIGRATE.md` +entry. Wire: none. diff --git a/quest/m2/js-ietf-dead-route-error.md b/quest/m2/js-ietf-dead-route-error.md new file mode 100644 index 00000000000..96eaab2e1c7 --- /dev/null +++ b/quest/m2/js-ietf-dead-route-error.md @@ -0,0 +1,26 @@ +# [XS] js/net names the real cause of a dead route on IETF + +## Goal + +When a moq-transport session dies under a JS subscription, the subscription +ends with the session's own error, as it does in Rust, instead of +`unauthorized: `, such as `unauthorized: room`. A grant +that lapses because its session closed is not a revocation. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, from the +follow-ups of [#5700](https://github.com/moq-dev/moq/pull/5700). + +The cause, as read at planning: `#close` in `js/net/src/ietf/connection.ts` +closes the session's auth first, which empties the grant union +(`#recompute` in `js/net/src/auth_session.ts`). The IETF subscriber's grant +watcher (`#denied` in `js/net/src/ietf/subscriber.ts`) then closes each live +subscription with `unauthorized(broadcast)` before anything ends it with the +session error. Rust has the same grant shape, but its IETF driver +(`rs/moq-net/src/ietf/session.rs`) aborts every received track with the +session's error in the same pass that closes auth, so the session error wins. + +Check the lite subscriber for the same race. Add a test that kills an IETF +session under a live subscription and asserts the session error, failing +without the fix. Wire: none. diff --git a/quest/m2/obs-qlog.md b/quest/m2/obs-qlog.md new file mode 100644 index 00000000000..3dac0c293ca --- /dev/null +++ b/quest/m2/obs-qlog.md @@ -0,0 +1,33 @@ +# [XS] CMake qlog option for OBS + +## Goal + +A CMake option builds moq-ffi with `--features qlog`, so an OBS build shows +the qlog directory setting (`moq::qlog_supported()`) and can capture traces. +It is off by default. + +## Plan + +Decided with the maintainer on 2026-10-17: + +- The goal comes from the follow-ups of + [#5726](https://github.com/moq-dev/moq/pull/5726). +- A standalone m2 quest, not a child of the obs-studio epic, which is about + the native port into obs-studio rather than the plugin's build. + +`cpp/moq/CMakeLists.txt` runs the `cargo build` for moq-ffi, but only on a +source build. `cpp/obs/CMakeLists.txt` builds `cpp/moq` in-tree only when +`MOQ_LOCAL` contains it; otherwise it fetches a published `moq-cpp` archive +for `MOQ_VERSION` with `FetchContent`. Both that archive and a prebuilt +`MOQ_FFI_DIR` package carry default features only, so the option requires +the in-tree source build and fails configuration with `FATAL_ERROR` on the +fetched archive or `MOQ_FFI_DIR` path instead of silently dropping the +feature. + +Test: a CI configure of `cpp/obs` with the option on and `MOQ_LOCAL` +cleared must fail, and the in-tree build with it on must report +`moq::qlog_supported()` true. Mention the option in `doc/bin/obs.md`. + +## Related + +- [C++ FFI reuse](/quest/m2/cpp-ffi-reuse.md) - the shared prebuilt FFI carries default features only diff --git a/quest/m2/probe-end-stale.md b/quest/m2/probe-end-stale.md new file mode 100644 index 00000000000..3a4a0c5c74c --- /dev/null +++ b/quest/m2/probe-end-stale.md @@ -0,0 +1,25 @@ +# [S] A finished PROBE stops reporting its estimate + +## Goal + +When a PROBE stream ends, because the peer finished or reset it, the Rust +receive estimate reads as unknown instead of reporting the last value for the +rest of the session. + +Today `RecvBandwidth` in `rs/moq-net/src/lite/subscriber.rs` gives up after +the stream ends without clearing the estimate, so every `recv_bandwidth()` +consumer keeps the last rate. JS `runProbe` already clears its estimate on +exit. + +## Plan + +Decided with the maintainer on 2026-10-17: the goal as drafted, from the +review of [#5777](https://github.com/moq-dev/moq/pull/5777). + +#5777 has since merged: `RecvBandwidth::poll` now clears the estimate when +the last consumer leaves, but still returns without clearing when the stream +ends. Clear on the stream's end the same way, including moq-tokio's +forwarder across reconnects. + +Test: the peer finishes its PROBE stream after one report, and a held +consumer then reads no estimate; it fails without the fix. Wire: none.