diff --git a/quest/m1/README.md b/quest/m1/README.md index 09b3bdd90c..d11e511ab7 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -23,23 +23,30 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine +- [Go cancel test](/quest/m1/go-origin-gc.md) - the Go request-cancel test keeps its origin alive, so the collector can't close it mid-test +- [Worker socket count](/quest/m1/worker-socket-count.md) - the moq-tokio worker test counts only its own listener's sockets - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone - [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name +- [Error messages](/quest/m1/error-display.md) - Python, Go, and Dart print `MoqError` with Rust's message, as Kotlin and Swift do - [Remove finish](/quest/m1/broadcast-remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport +- [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust +- [Live in apps](/quest/m1/announce-live-apps.md) - the demo and `@moq/room` show "no broadcasts" from the `live` marker, which waits for the first session on page load - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included - [Optional max age](/quest/m1/ietf-max-age.md) - max age is optional, set only by the publisher, and crosses moq-transport as MAX_CACHE_DURATION - [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer - [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` +- [Data capture in bindings](/quest/m1/data-capture-bindings.md) - moq-ffi and every wrapper pass a data frame's capture time, and the JSON window producer takes one - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass +- [JS IETF datagrams](/quest/m1/js-ietf-datagram.md) - `@moq/net` sends and receives datagram groups over moq-transport, like Rust - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships - [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability @@ -59,6 +66,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [mp4-atom dOps mapping](/quest/m1/mp4-atom-dops-mapping.md) - a released mp4-atom reads and writes any `dOps` channel mapping family and table - [CMAF surround Opus](/quest/m1/cmaf-opus-surround.md) - fMP4 import and export carry an Opus channel mapping table - [js/hang dOps pre-skip](/quest/m1/js-dops-pre-skip.md) - CMAF encoding in js/hang stops hard-coding a 312-sample pre-skip +- [GPU CI](/quest/m1/gpu-ci.md) - NVIDIA tests run nightly on a self-hosted GPU runner, and `just rs nvidia` runs them locally instead of skipping - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match - [JS rendition ranking](/quest/m1/js-ranked.md) - `@moq/hang` ranks video renditions like Rust, and `@moq/watch`'s fallback uses it - [Audio rendition pick](/quest/m1/audio-ranked.md) - single-track FLV/RTMP and WHEP serve the best audio rendition, not the first by name @@ -97,6 +105,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw - [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies - [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on every version +- [Plan: cache age-out](/quest/m1/cache-wall-eviction.md) - a swept benchmark decides whether the track cache ages groups out on wall time without a write - [Relay memory](/quest/m1/relay-memory.md) - remeasure what an announcement costs after prefix routes - [PoP skipping](/quest/m1/pop-skipping/README.md) - short cold paths for unpopular broadcasts without losing warm backhaul dedup - [Route cost in the JS origin](/quest/m1/route-cost.md) - the browser origin ranks routes by cost and hops like Rust instead of newest-first diff --git a/quest/m1/announce-live-apps.md b/quest/m1/announce-live-apps.md new file mode 100644 index 0000000000..b9e7a4a995 --- /dev/null +++ b/quest/m1/announce-live-apps.md @@ -0,0 +1,42 @@ +# [M] Apps show "no broadcasts" from the live marker + +## Goal + +A browser page listing broadcasts shows an empty state once the relay has +said there are none, never a spinner that never resolves and never a false +empty state before the first session answered. The demo watch page and +`@moq/room` use `@moq/net`'s `live` marker, and `@moq/net` settles when an +origin stream opened before the first connection goes live. + +## Plan + +- #4261 (on `dev`) adds the `live` event; the consumers it touches + (`demo/web/src/index.ts`, `js/room/src/room.ts`, `js/watch/src/broadcast.ts`, + `js/moq-boy`) skip it today. +- Page load: an origin stream opened before any session connects has no + session to wait on, so today it goes `live` at once and broadcasts arrive + after it. Settled: the reconnect loop (`js/net/src/connection/reload.ts`), + which already answers requests through `expect()`, holds the marker until + its first session lands `live` or its first dial gives up. An empty list + then means the relay said so or is unreachable, which a UI can tell apart. + Once a session is up, its own `live` ends the hold: every wire guarantees + one (ANNOUNCE_OK, ANNOUNCE_INIT, or the quiet-stream fallback). A peer that + accepts the announce stream and never answers is a peer bug, so no extra + timeout. + Check whether Rust's reconnecting client has the same gap. +- Apps: loading before `live`, an explicit empty state after it with nothing + announced, and an error state when the connection gives up. Libraries + expose the state as a signal; wording stays in the demo. +- Tests: an origin stream opened before connect is not `live` until the first + session is; a connection that cannot connect ends the wait. + +Public API: when `@moq/net` emits `live` changes; any state signal on +`@moq/room` or `@moq/watch` is additive. Lands on `dev` with #4261. Wire: none. + +## Required + +- [JS caught up](/quest/m1/js-announce-caught-up.md) - the `live` marker (#4261) + +## Related + +- [Bindings caught up](/quest/m1/announce-live-bindings.md) - the same marker in the bindings diff --git a/quest/m1/cache-wall-eviction.md b/quest/m1/cache-wall-eviction.md new file mode 100644 index 0000000000..6451b320b8 --- /dev/null +++ b/quest/m1/cache-wall-eviction.md @@ -0,0 +1,39 @@ +# [S] Plan: wall-clock age-out in the track cache + +## Goal + +A benchmark decides whether a track's cache ages groups out without waiting +for a write, on `max(wall, pts)` like other time decisions in moq-net, or +stays the one write-driven exception. The result is an implementation quest +or a recorded reason to keep the exception. + +## Plan + +Settled scope: a track's `max_age` retention aging groups out on a timer +instead of only on a write. The pool's idle expiry is out of scope. + +- Today a track's `max_age` is media time and is applied only when the track + writes: a group ages out when a later one starts (`is_stale` and the expiry + scans in `rs/moq-net/src/model/track.rs`). A track that stops writing keeps + groups past `max_age` until the pool's idle expiry (`Pool::gc`, driven by + the origin driver without a write) or byte pressure reclaims them. + `max_age_does_not_drive_wall_eviction` pins that behaviour. +- Prototype the alternative behind a bench-only switch: each track keeps a + deadline for its oldest group on `max(wall elapsed, pts)`, armed on the + timers the origin driver already runs, and evicts on expiry. +- Bench in `rs/moq-net/benches/track.rs`, swept over tracks (1 to 10k) and + cached groups per track (1 to 1k): write-path cost, timer cost per driver + pass, and retained memory for a population of idle tracks. A cost that + grows with the table should show as a slope. +- Weigh the semantics too: `max_age` is media time on purpose, so a congestion + stall cannot age content out (`track::Info::max_age`). A wall term changes + that for a stalled but live publisher. +- Record the numbers and the decision in the PR, then rewrite this quest into + the implementation or delete it. + +Public API: none from the plan. Wire: none. + +## Related + +- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - relay memory past the expiry window, in the same cache +- [Cache shard](/quest/m1/perf/cache-shard.md) - the pool's shared counters under many workers diff --git a/quest/m1/data-capture-bindings.md b/quest/m1/data-capture-bindings.md new file mode 100644 index 0000000000..ba9de9d5e5 --- /dev/null +++ b/quest/m1/data-capture-bindings.md @@ -0,0 +1,49 @@ +# [M] Bindings stamp data frames with a capture time + +## Goal + +A moq-ffi publisher, and every wrapper over it (Python, Swift, Kotlin, Go, +Dart), can pass a capture time with a JSON or binary snapshot `update` or +stream `append`, so its data tracks advertise `delay` and `jitter` like a Rust +publisher's. Leaving it out keeps today's behaviour. `moq-json`'s `window` +producer takes a capture time too. Settled scope: moq-ffi and its wrappers, +not libmoq. + +## Plan + +- #4270 gives the Rust producers `moq_net::Timed`, built with + `Timed::from(value).at(t)`. The `moq-mux` data producers take + `Timed<_, Instant>`, map it onto the broadcast clock, and refuse one ahead of + now (`Error::InvalidCapture`). The moq-ffi producers in + `rs/moq-ffi/src/{json,binary}.rs` pass bare values. +- Settled: the capture time is a media timestamp on the broadcast's + timeline. moq-ffi exposes the broadcast clock's `now()` as a timestamp; + callers stamp payloads with values taken from it, and moq refuses one ahead + of now. That keeps a device or process clock out, as the Rust `Instant` + mapping does. The `moq-mux` producers take an `Instant` today, so either + map the timestamp back through the clock inside moq-ffi or give the clock a + typed timestamp moq-mux accepts; keep a raw `Timestamp` from compiling + there. Make it optional on the existing methods, never a `_with_capture` + twin. +- `moq-json` window: `window::Producer::push` stamps `Timestamp::now()` + (`rs/moq-json/src/window/producer.rs`). Accept `Timed` as the snapshot and + stream producers do. Nothing in `moq-mux` publishes window mode, so there is + no estimator to feed. +- Wrappers follow per the cross-package sync table, each with a test that a + past capture time is accepted and a future one refused. Update + `doc/lib/{py,swift,kt,go,dart}`. + +Public API: breaking, so it lands on `dev`. A new parameter on the generated +`update` and `append` breaks every published binding caller (Go, for one, has +no optional arguments), and a `_with_x` twin is ruled out. The broadcast clock +`now()` is additive; `window::Producer::push` accepts `Timed`, source-compatible. +Wire: none. + +## Required + +- [Data jitter](/quest/m1/data-jitter.md) - `Timed` and the mux capture path (#4270) + +## Related + +- [FFI shape](/quest/m1/ffi-shape/README.md) - moves the data producers into a json namespace +- [Generated C bindings](/quest/m1/c/README.md) - replaces libmoq, so C inherits this from moq-ffi diff --git a/quest/m1/error-display.md b/quest/m1/error-display.md new file mode 100644 index 0000000000..894f3581d0 --- /dev/null +++ b/quest/m1/error-display.md @@ -0,0 +1,34 @@ +# [M] Every binding prints MoqError's message + +## Goal + +Python's `str(err)`, Go's `err.Error()`, and Dart's `toString()` on a +`MoqError` return the message from moq-ffi's exported `Display`, as Kotlin's +`toString()` and Swift's `description` already do. No hand-written +per-variant strings and no extra message function. + +## Plan + +- #4292 adds `#[uniffi::export(Display)]` on `MoqError` in + `rs/moq-ffi/src/error.rs`, on the C++ line. If it has not reached `main` + when this starts, add the same attribute here; it is additive. +- Go and Dart are fixed in their generators; Python in its wrapper: + - Go: the `kixelated/uniffi-bindgen-go` fork. The generated `Error()` prints + `MoqError: `. Render the exported `Display` for errors, tag, and + bump every pin site the `flake.nix` comment lists. + - Dart: the `kixelated/uniffi-dart` fork. The regenerated bindings already + carry the unused extern; wire it to `toString()`, tag, bump `flake.nix`, + and regenerate `dart/moq_ffi`. + - Python: upstream `mozilla/uniffi-rs` (0.32.2 here) renders no uniffi + traits on errors (`ErrorTemplate.py`). Settled: no upstream PR. The + hand-written `py/moq-rs` package defines `__str__` on `MoqError` by + calling the exported `Display`, so the message still comes from Rust. +- A test per binding that a known error prints Rust's text (`Closed` prints + `closed`), and `doc/lib/{py,go,dart}` updated where they show error output. + +Public API: the string form of `MoqError` changes in Python, Go, and Dart. +Wire: none. + +## Related + +- [C++ through moq-ffi](/quest/m1/cpp/README.md) - where #4292 adds the export and C++ `to_string()` diff --git a/quest/m1/go-origin-gc.md b/quest/m1/go-origin-gc.md new file mode 100644 index 0000000000..25bc6aae9e --- /dev/null +++ b/quest/m1/go-origin-gc.md @@ -0,0 +1,32 @@ +# [XS] Go cancel test keeps its origin + +## Goal + +`TestRequestBroadcastCancelKeepsTheOrigin` in `go/wrapper/moq_test.go` passes +under load, not only alone (30/30 in isolation, `MoqError: Closed` under a +loaded run). Fixed at the cause, with no retry or longer timeout. + +## Plan + +Likely cause, found by reading and not yet reproduced: the test never touches +`origin` after `origin.Dynamic(...)` and `origin.Consume()`, so Go may collect +it mid-test. The generated finalizer then drops the only `MoqOriginProducer`, +the origin's driver finishes once every producer handle is gone +(`origin::Driver::poll` in `rs/moq-net/src/model/origin.rs`), and the next call +on the consumer or the dynamic handle gets `Error::Closed`. The collector runs +more often under load, which fits. + +- Reproduce first. `runtime.GC()` after `cancel()` or `GOGC=1` only makes the + collection likely: finalizers run later on their own goroutine. For a + deterministic failure, do what the finalizer does: an internal test calls + the inner handle's `Destroy` before the next call. +- Keep the producer reachable to the end of the test (`runtime.KeepAlive`, as + the file already does for `pending`), and audit the other `go/wrapper` tests + that hold an `OriginProducer` only for setup. +- Users hit the same trap: a Go `OriginProducer` has no `Close`, so its origin + ends whenever the collector reaches it. Say so in its doc comment and + `doc/lib/go`. Whether consumers should keep their producer alive, or the + wrapper should gain an explicit `Close`, is an API call: propose it to the + maintainer rather than ship it here. + +Public API: none unless the maintainer picks an API change. Wire: none. diff --git a/quest/m1/gpu-ci.md b/quest/m1/gpu-ci.md new file mode 100644 index 0000000000..0c0a8a054e --- /dev/null +++ b/quest/m1/gpu-ci.md @@ -0,0 +1,57 @@ +# [S] NVIDIA tests run on real hardware + +## Goal + +The NVDEC, NVENC, and CUDA tests run nightly on the maintainer's Linux host +(RTX 3070 Ti) instead of passing without a GPU on hosted runners, and a local +`just rs nvidia` runs them against the host driver instead of silently +skipping inside the Nix shell. + +## Plan + +- Why they skip: the driver libraries are loaded at runtime (`libcuda` by + cudarc, `libnvidia-encode` in `rs/moq-nvenc/src/safe/api.rs`, `libnvcuvid` + in `rs/moq-nvenc/src/cuvid.rs`), and the tests return early when they are + missing (`hw_available` in `rs/moq-video/src/decode/backend/nvdec.rs`). The + Nix shell's loader path lacks Ubuntu's `/usr/lib/x86_64-linux-gnu`. +- Select every test that needs the GPU, not only names containing `nvdec`, + `nvenc`, or `cuda`: `safe::session::tests::failed_submission_releases_the_session` + in `rs/moq-nvenc` needs hardware and matches none of them. Find them by + their driver probes (`hw_available`, `driver_libs_present`, `Api::get` and + friends in `moq-nvenc` and `moq-video`). Recommendation: follow the + existing `#[ignore = "requires ..."]` convention (as `frame/vulkan_test.rs` + does) and put them in `nvidia` test modules, so hosted CI reports them + ignored instead of passed and one filter, `--run-ignored only -E + 'test(/::nvidia::/)'`, selects them all without the other ignored hardware + tests (Android, D3D11, PipeWire). Inside that selection a missing GPU fails + the test instead of returning early. Keep the no-driver tests + (`missing_driver_errors_instead_of_panicking`) outside it. +- `just rs nvidia`: symlink only those three libraries (by soname) from + `/usr/lib/x86_64-linux-gnu` into a private directory, put that on + `LD_LIBRARY_PATH`, and run that selection. Fail when a library is missing + instead of skipping. `just rs vulkan-cuda` puts the whole host directory on + the path, which lets host libraries shadow the Nix ones; fold it into this + recipe, since its `vulkan_cuda_` tests are the same kind. Those also need + the Vulkan loader to find the host NVIDIA ICD: point it at the ICD manifest + and expose the driver libraries it names, or keep them in their own recipe. +- Nightly: a job in `.github/workflows/nightly.yml` runs `just rs nvidia` on + the self-hosted runner. A self-hosted runner on a public repository must + never run untrusted code: only `schedule` and `workflow_dispatch`, with the + job gated to `refs/heads/main`, never `pull_request`; a dedicated label only + this job selects; read-only `permissions`. Read GitHub's self-hosted runner + hardening guidance before wiring it. +- Share the runner with the io_uring one that #4132 plans + (`quest/m1/uring-runner.md` on the drain line, which wants a 6.12+ kernel on + the same host): one registration and one security posture, a label per + capability. Whichever quest lands second reuses the first's job shape. + +Public API: none. Wire: none. + +## Required + +- A self-hosted runner is registered for moq-dev/moq on the maintainer's host, with the NVIDIA driver + +## Related + +- [Video hardware validation](/quest/m3/video-hardware.md) - hardware paths nothing runs yet +- [Runtime QA hosts](/quest/m2/runtime-qa-hosts.md) - on-demand jobs on hardware hosts, a broader contract than a nightly diff --git a/quest/m1/js-ietf-datagram.md b/quest/m1/js-ietf-datagram.md new file mode 100644 index 0000000000..417609fe0a --- /dev/null +++ b/quest/m1/js-ietf-datagram.md @@ -0,0 +1,29 @@ +# [M] @moq/net datagrams over moq-transport + +## Goal + +A `@moq/net` session on moq-transport sends and receives datagram groups as +`OBJECT_DATAGRAM`, one object per group with its sequence kept, as Rust does. +A datagram track crosses IETF between Rust and JS in both directions in +`just test interop`. + +## Plan + +- JS routes datagrams on moq-lite only (`js/net/src/lite/datagram.ts`, + `runDatagrams` from lite-05); `js/net/src/ietf/` reads and writes none. + Mirror the lite path on the IETF session and the Rust mapping from #4274: + receive decodes `OBJECT_DATAGRAM` and inserts on the aliased subscription's + track; send writes object 0 with END_OF_GROUP, the explicit publisher + priority, and the timestamp property when the track has a timescale. +- Keep Rust's edges: an Object ID other than 0, a non-Normal status, or an + unbound alias is dropped; a malformed Type closes the session. Rust covers + drafts 14 and later, whose Type flags differ between 14 and 15+; decide + what draft 07, which JS also speaks, does. +- Add IETF datagram cases beside the lite ones in the interop harness. + +Public API: none expected. Wire: `@moq/net` moq-transport sessions send and +accept `OBJECT_DATAGRAM` as the drafts define; no project draft changes. + +## Required + +- [Datagram groups](/quest/m1/moxygen/datagram.md) - the Rust side this mirrors and interops with diff --git a/quest/m1/qos/README.md b/quest/m1/qos/README.md index b6c84121b6..a5fe3fa32b 100644 --- a/quest/m1/qos/README.md +++ b/quest/m1/qos/README.md @@ -36,6 +36,10 @@ preflight, consumes them downstream. - [Starvation](/quest/m1/qos/starvation.md) - per broadcast, how far behind the acknowledged frontier of its subscriptions is, in media time, plus the media dropped before it was acknowledged +- [Final lag sample](/quest/m1/qos/final-lag-sample.md) - a closing + subscription records its last partial interval instead of losing it +- [Lag dashboard](/quest/m1/qos/lag-dashboard.md) - the demo stats + dashboard shows viewer lag percentiles and dropped media - [Starvation at frame granularity](/quest/m1/qos/starvation-frames.md) - the acknowledged frontier moves at every frame boundary through `poll_acked`, with a delivery-delay histogram for jitter diff --git a/quest/m1/qos/final-lag-sample.md b/quest/m1/qos/final-lag-sample.md new file mode 100644 index 0000000000..6ee05196af --- /dev/null +++ b/quest/m1/qos/final-lag-sample.md @@ -0,0 +1,31 @@ +# [XS] A closing subscription takes its last lag sample + +## Goal + +When an egress subscription ends, the bytes its track produced since the last +stats tick still land in the `lag` histogram at its final lag, instead of +being lost. A subscription that opens and closes between two ticks is sampled +at least once. + +## Plan + +Lag is sampled only on `Registry::report` ticks: `Counters::sample` in +`rs/moq-net/src/stats.rs` walks weak `FrontierInner` references and prunes +the dead ones without sampling them, so a subscription's last partial +interval disappears with it. + +- The subscription guard and each in-flight group `Delivery` hold a strong + reference, so the frontier dies only once the last of them does. Its drop is + the natural place for one final `sample(now)` into the same counters. +- No double counting: `sample` advances each source's `sampled` bytes under the + frontier lock, so a tick racing the drop leaves nothing for it to count again. +- Test: a subscription that opens and closes between ticks while its track + produces lands those bytes in the histogram; one that closes mid-interval + adds exactly the bytes since its last sample. Note the behaviour in + `doc/concept/stats.md`. + +Public API: none. Wire: none, only the histogram's values change. + +## Required + +- [Starvation](/quest/m1/qos/starvation.md) - the sampler this extends (#4298) diff --git a/quest/m1/qos/lag-dashboard.md b/quest/m1/qos/lag-dashboard.md new file mode 100644 index 0000000000..c89e45f38f --- /dev/null +++ b/quest/m1/qos/lag-dashboard.md @@ -0,0 +1,34 @@ +# [S] Demo dashboard shows viewer lag + +## Goal + +The demo stats dashboard (`demo/web/src/stats.ts`) shows the relay's viewer +lag and dropped media: percentiles of the egress `lag` histogram over the +chart window, and `dropped` duration, bytes, and groups as rates, cluster-wide +and per node like the existing counters. + +## Plan + +- The relay writes both on `publisher.json` rows only. `lag` is a cumulative + byte count per bucket keyed by its upper edge (`"50ms"` to `"5s"`, then + `"inf"`, empty buckets omitted); `dropped` is `{ duration, bytes, groups }` + with the duration in fractional milliseconds. `doc/concept/stats.md` on the + QoS line documents them. Diff two samples for an interval's distribution, + as the dashboard already does to turn cumulative bytes into rates, and sum + nodes bucket by bucket. +- A percentile read from buckets is a bucket edge, not a point value, and + `inf` has no upper edge. Show it as "under X" or interpolate inside the + bucket, and say which. +- Skip `.`-prefixed system broadcasts, as the existing aggregate does. Lag is + per broadcast, so a per-broadcast view is worth adding if it stays cheap. +- The [browser stats quest](/quest/m1/qos/stats/js.md) moves the dashboard + onto `@moq/stats` on the same line. If it has landed, read `lag` and + `dropped` through its schemas; otherwise extend the existing interfaces and + let whichever lands second reconcile. + +Public API: none. Wire: none. + +## Required + +- [Starvation](/quest/m1/qos/starvation.md) - the `lag` histogram and + `dropped` counters (#4298) diff --git a/quest/m1/raw-stream-codes.md b/quest/m1/raw-stream-codes.md new file mode 100644 index 0000000000..a4c1b517da --- /dev/null +++ b/quest/m1/raw-stream-codes.md @@ -0,0 +1,38 @@ +# [M] Raw QUIC stream codes stay raw + +## Goal + +On a raw QUIC session (`moqt://`, `moql://`, raw iroh), RESET_STREAM and +STOP_SENDING carry the application's code as-is, not mapped through the +HTTP/3 WebTransport code space, so moq agrees with other raw QUIC MoQ stacks +on stream errors. WebTransport sessions keep the mapping they need. + +## Plan + +- `web-transport-moq` (moq-dev/noq) runs every stream code through + `web_transport_proto::error_to_http3` and `error_from_http3` in `send.rs` + and `recv.rs`, whether or not the session came from `Session::raw`. + `web-transport-iroh` and `web-transport-quinn` (moq-dev/web-transport) do + the same. A raw peer's code 5 reads as `None` or another value, and ours + reaches it as a large HTTP/3 code. +- [Close codes](/quest/m1/close-codes.md) fixed the same mix-up for + `ApplicationClosed` (noq#11, #4262). Let each stream know whether its + session is raw and skip the mapping there, in all three adapters, with a + round-trip test per adapter against a plain QUIC peer. +- Release the fixed crates and bump the pins here in the same quest; published + crates depend on crates.io releases, never a patch. A moq-tokio test over + `moqt://` asserts a reset code arrives verbatim, beside `close_code.rs`. +- Mixed versions: two moq peers on raw QUIC agree today because both map, and + wire changes must stay compatible with published versions. A fixed peer can + read both forms, since a mapped code lands in the HTTP/3 WebTransport range + that no application code reaches. An older peer misreads a fixed peer's raw + codes. Check which codes moq-net acts on (group stream resets, subscribe + STOP_SENDING): if any drives behaviour beyond reporting, this is a wire + break and retargets to `dev`. + +Public API: none expected. Wire: raw QUIC stream error codes become the +application's own values; compatible only if older peers merely report them. + +## Required + +- [Close codes](/quest/m1/close-codes.md) - the adapter releases and trait 0.5 this builds on diff --git a/quest/m1/worker-socket-count.md b/quest/m1/worker-socket-count.md new file mode 100644 index 0000000000..dd6b549c2f --- /dev/null +++ b/quest/m1/worker-socket-count.md @@ -0,0 +1,25 @@ +# [XS] Worker socket count sees only its own listener + +## Goal + +`dropping_a_server_keeps_its_socket` in `rs/moq-tokio/tests/worker.rs` counts +only the sockets its own listener holds, so an unrelated UDP socket elsewhere +on the machine can't fail it. + +## Plan + +`udp_sockets_on(port)` counts every `/proc/net/udp` entry whose local address +ends in the port, so another process's socket on the same port number but a +different address (an ephemeral port on another interface, say) breaks both +the `>= 2` assertion and the final `== 0`. + +- The listener binds `127.0.0.1:0` (`listen_config`), so match the full local + address rather than the port suffix. +- The final `== 0` runs after the port is released, when a parallel test may + bind the same address. If that window matters, count only this process's + sockets by joining the table's inode column with `/proc/self/fd`. Prefer + whichever holds with a stranger on the port at every assertion. +- Reproduce by holding a UDP socket on the same port on another local address + while the test runs. + +Public API: none. Wire: none.