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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,9 @@ However, it is ultimately the other peer's responsibility to close their send di
There are two independent error code spaces, one for terminating the session and one for resetting a stream.
The same numeric value means different things in each, so an endpoint MUST select the code from the space matching what it is terminating.

Both spaces reuse the codes moq-transport assigns, unchanged and with the same meaning, so an endpoint that speaks both protocols has one vocabulary and a relay can forward a peer's code without translating it.
The shared codes match moq-transport draft-18 and later.
Earlier drafts differ: 0x4 denotes UNKNOWN_OBJECT_STATUS in draft-16/17 and is unassigned in draft-14/15; TOO_FAR_BEHIND was added in draft-17 and MALFORMED_TRACK in draft-16.
An endpoint bridging protocols MUST translate codes according to the negotiated version and error-code space; moq-lite's provisional and application ranges have no corresponding moq-transport ranges.
The codes moq-lite uses are listed in full below; an endpoint MUST NOT assign a moq-lite specific meaning to any code below 32.

Codes 64 and above are the application's, opaque to moq-lite.
Expand Down Expand Up @@ -1384,6 +1386,8 @@ The `Message Length` describes the payload size on the wire.
# Appendix A: Changelog

## moq-lite-06

- Require error-code translation when bridging protocols and draft versions.
- Made a repeated non-zero Hop ID in one announcement's Hop ID list a PROTOCOL_VIOLATION, matching draft-lcurley-moq-cluster. Repeated 0 entries stay legal.
- Moved the Qmux-over-WebSocket binding details to draft-lcurley-qmux-websocket; the binding itself is unchanged.
- Extended the SETUP `Path` parameter to carry the URI query: a client appends `?` and the query component after the path, matching moq-transport's PATH option. The credential a deployment puts in the query was previously unrepresentable on a binding with no request URI.
Expand Down
2 changes: 1 addition & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ regression test per Root Cause First.
- [#3360](/quest/m0/3360-js-watch-broadcast-is-undefined-at-initialization.md) - js/watch: a framework binding the element reads `broadcast` before the custom element is upgraded
- [Adapter namespace map](/quest/m0/rs-adapter-namespace-map.md) - moq-net: a duplicate PUBLISH_NAMESPACE on draft-14/15 strands the first request, and the map never shrinks
- [Playout clock](/quest/m0/playout-clock.md) - moq play presents on a clock it controls, with a `--delay` offset and forward re-anchoring
- [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
- [IETF error codes](/quest/m0/ietf-error-codes.md) - every request error a moq-transport wire carries is a registered value for the negotiated draft, in Rust and js/net
- [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
- [#3080](/quest/m0/3080-fix-watch-audio-ring-truncate-can-race-the-worklet-reader.md) - watch: an audio ring truncate can race the worklet reader for one quantum
- [#3363](/quest/m0/3363-js-watch-a-broadcast-republished-on-one-session-keeps-resuming.md) - js/watch: a broadcast republished under its name on one session keeps resuming
Expand Down
50 changes: 26 additions & 24 deletions quest/m0/ietf-error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ registered value for the negotiated draft, in both directions: the
SUBSCRIBE_ERROR, FETCH_ERROR, and REQUEST_ERROR payloads, and the RESET_STREAM
and STOP_SENDING codes on data and request streams. A relay built on these
crates passes the interop runner's subscribe-error and subscribe-before-announce
cases without the peer's compatibility branch, and a routine cancellation is
read as CANCELLED rather than INTERNAL_ERROR.
cases without the peer's compatibility branch.

Boundaries: moq-lite's own code spaces are untouched. js/net already carries a
code on a locally raised stream error, via the `toStreamCode` / `fromTransport`
Expand All @@ -17,55 +16,58 @@ registry.

## Plan

The Rust stream reset half is done: `rs/moq-net/src/ietf/error.rs` is the
per-draft `StreamError` <-> code mapping in both directions, and the
`coding::StreamCodes` trait picks a stream's registry from the version its
`Reader`/`Writer` already carries. What is left is the request errors in both
languages and the richer per-draft JS stream mappings.

Current implementation paths (verify against the target branch before starting):

- `rs/moq-net/src/ietf/publisher.rs` `run_subscribe` rejects with the literal
`404` at three sites and `run_fetch_stream` with `500`;
`rs/moq-net/src/ietf/subscriber.rs` `write_error` uses `400`.
`reject_subscribe`, `reject_fetch`, and `write_error` take a bare `u64`, and
`ietf::SubscribeError`, `ietf::FetchError`, and `ietf::RequestError` carry a
bare `error_code`. The only named IETF code type is `TrackStatusCode`
(`rs/moq-net/src/ietf/track.rs`), for a different registry, plus a
function-local `NOT_SUPPORTED: u64 = 0x3` in the subscriber.
bare `error_code`. The only named request-error code type is
`TrackStatusCode` (`rs/moq-net/src/ietf/track.rs`), for a different registry,
plus a function-local `NOT_SUPPORTED: u64 = 0x3` in the subscriber.
- `js/net/src/ietf/publisher.ts` `runSubscribe` writes `errorCode: 404` on both
the draft-14 `SubscribeError` and the draft-15+ `RequestError` branch;
`js/net/src/ietf/subscriber.ts` uses `400` and `409`; `errorCode` is a plain
number everywhere.
- `js/net/src/ietf/subscriber.ts` `runPublish` writes a draft-14 `PublishError`
with the function-local `NOT_SUPPORTED` and a `RequestError` on later
drafts; the Rust subscriber's publish handling is the same shape.
- Rust's `ietf/error.rs::to_stream_code` and JS's `toStreamCode` with an IETF
version send CANCELLED (1) for cancellation and INTERNAL_ERROR (0) otherwise.
JS keeps received IETF resets opaque rather than decoding them as moq-lite
local error classes. Per-draft typed decoding and richer mappings remain here.
- JS's `toStreamCode` with an IETF version sends CANCELLED (1) for cancellation
and INTERNAL_ERROR (0) otherwise. JS keeps received IETF resets opaque rather
than decoding them as moq-lite local error classes. Per-draft typed decoding
and richer mappings remain here.
- `Version::Draft14` through `Draft20` are all negotiated, straddling the
draft-19 consolidation into REQUEST_ERROR, so the values are per version.

The work:

- One named code type per registry (request errors, stream errors), with
`Encode<Version>` and `Decode<Version>` in the `TrackStatusCode` style, that
maps `Error` variants to the registered value for the negotiated draft and
back. An incoming code with no named value decodes to the remote/opaque
variant, never to a named one. Cite the draft section each value comes from
in the type's docs.
- Use it at every construction site above, in Rust and JS, and in
`Reader::abort` / `Writer::abort` on IETF sessions. Delete the literals and
the function-local constant. `Error::NotFound`'s IETF wire value comes from
the same mapping.
- One named code type per registry (request errors, and the richer JS stream
error mappings), with `Encode<Version>` and
`Decode<Version>` in the `TrackStatusCode` style, that maps `Error` variants
to the registered value for the negotiated draft and back. An incoming code
with no named value decodes to the remote/opaque variant, never to a named
one. Cite the draft section each value comes from in the type's docs.
- Use it at every construction site above, in Rust and JS. Delete the literals
and the function-local constants. `Error::NotFound`'s IETF wire value comes
from the same mapping. `rs/moq-net/src/ietf/error.rs` is the shape to follow.
- Tests: encode/decode round-trips per version; a draft-14 PublishError and a
draft-15+ RequestError round-trip for the publish path; a subscribe for a missing
broadcast rejects with the not-found value of each draft; a cancelled
subscription resets with CANCELLED and a moq-net peer reads it back as
`Cancel`; the two interop runner cases pass without their COMPAT branch.
draft-15+ RequestError round-trip for the publish path; a subscribe for a
missing broadcast rejects with the not-found value of each draft; the two
interop runner cases pass without their COMPAT branch.

The reporter of #3359 offered a PR and asked whether to prefer a version-aware
enum over a flat one: version-aware, per the above.

## Closes

- [#3359](https://github.com/moq-dev/moq/issues/3359) - close this issue when the quest finishes
- [#3001](https://github.com/moq-dev/moq/issues/3001) - close this issue when the quest finishes

## Related

Expand Down
2 changes: 2 additions & 0 deletions quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ with the current dev tree before starting.
- [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
- [#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
- [IETF stream types](/quest/m1/ietf-uni-stream-types.md) - accept padding and close sessions for genuinely unknown uni-stream types
- [HLS cache misses](/quest/m1/hls-cache-miss-codes.md) - moq-hls: a segment the relay dropped is served as a 500, because the miss is matched against a table the wire stopped using
- [#3187](/quest/m1/3187-preserve-structured-protocol-error-codes-across-ffi-and-c.md) - Preserve structured protocol error codes across FFI and C bindings
- [#2318](/quest/m1/2318-js-net-remaining-capability-gaps-vs-rs-moq-net-setup-role.md) - js/net: remaining capability gaps vs rs/moq-net (SETUP role, finish_at and final sequence, range controls, typed errors)
- [#2774](/quest/m1/2774-collapse-reload-and-shared-into-one-connection-class.md) - Collapse Reload and Shared into one Connection class
Expand Down
54 changes: 54 additions & 0 deletions quest/m1/hls-cache-miss-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# [S] moq-hls reads cache misses against a table the wire stopped using

## Goal

`moq-hls` answers 404 when the decoded error identifies a cache miss, and 500
for genuine failures or an IETF stream reset that cannot distinguish a miss.
Returning 404 over IETF depends on the request-error path preserving that
classification. Decode using the negotiated registry so classification cannot
drift from `moq-net` again.

## Plan

`is_cache_miss` in `rs/moq-hls/src/export/rendition.rs` compares a wire code
against `moq_net::Error::to_code()`:

```rust
let code = err.to_code();
code == moq_net::Error::NotFound.to_code() // 13
|| code == moq_net::Error::Old.to_code() // 2
|| code == moq_net::Error::Evicted.to_code() // 31
```

That table is the crate's own legacy numbering, and no stream reset has carried
it since #2620 replaced it with the `StreamError` registry. A remote miss now
arrives as `Error::Remote(0x20 | 0x22 | 0x23)` on a moq-lite wire, and as
`Error::Remote(0)` on a moq-transport one, since that registry has no value for
any of the three. None of those match, so every miss that crossed a session,
which in a relay is all of them, is served as a 500 instead of a 404.

Worse, one value collides: `Error::Old.to_code()` is 2, which is DELIVERY_TIMEOUT
on both wires, so a peer's delivery timeout classifies as a cache miss.

The tests do not catch it because they build the remote shape out of the same
stale table (`Error::Remote(local.to_code())`), so they agree with the code
rather than with the wire.

The work:

- Classify on the decoded error, not a hand-compared code. `StreamError` already
names `NotFound`, `Old`, and `Evicted`, so the fix is for `moq-net` to keep
them named through `Error` rather than flattening them into `Remote(code)`,
and for `moq-hls` to match variants.
- Decide what a moq-transport upstream can say at all: that registry has no
value for a cache miss on a stream reset, so a relay fetching over it cannot
distinguish one from a failure. Either the miss travels as a request error
rather than a stream reset, or the 500 is correct there and only the moq-lite
path is fixable.
- Rewrite the tests to build the remote shape from the wire registry
(`StreamError::to_code`, `ietf::error::to_stream_code`) so they fail when the
two drift again.

## Related

- [IETF error codes](/quest/m0/ietf-error-codes.md) - the registry work that this classification has to follow
17 changes: 17 additions & 0 deletions quest/m1/ietf-uni-stream-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# [S] Validate IETF unidirectional stream types

## Goal

Accept valid padding streams and close the session for genuinely unknown stream types according to the negotiated moq-transport draft.

## Plan

`rs/moq-net/src/ietf/session.rs` routes every non-SETUP uni stream to `run_uni_group`, which rejects padding and unknown types alike while leaving the session alive. The stream-only rejection uses INTERNAL_ERROR because SESSION_CLOSED would falsely claim a session shutdown.

Classify stream types before spawning a group handler. Handle PADDING according to each supported draft, including draining it where required, and propagate genuinely unknown types to the session driver. Keep ordinary group failures scoped to their streams. Add regressions for padding, unknown types causing session shutdown, and group failures preserving the session.

Consult [draft-19 section 3.4 and section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-19.html) and [draft-20 section 11.5.1](https://www.ietf.org/archive/id/draft-ietf-moq-transport-20.html), which explicitly permits cancelling padding streams. Check the earlier supported drafts too.

## Related

- [IETF error codes](/quest/m0/ietf-error-codes.md) - request and session error registry follow-ups
181 changes: 181 additions & 0 deletions rs/moq-net/src/coding/codes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! Which stream reset registry a stream's codes are written and read with.

use crate::{Error, StreamError, ietf, lite};

/// The stream reset registry of the protocol a stream belongs to.
///
/// Every [`Reader`](super::Reader) and [`Writer`](super::Writer) carries the negotiated
/// version, which is what picks the registry here. The two wires draw on the same names,
/// but they do not agree on every value: moq-lite's are fixed by
/// [`StreamError::to_code`], while moq-transport's moved across the drafts we negotiate
/// (see the `ietf::error` module). Sending a code from the wrong table is silent, because the
/// number is valid in both.
///
/// Encoding and decoding live on one trait so a peer cannot be told a code from one table
/// and read against another: implement both halves or neither.
pub trait StreamCodes {
/// The code to reset a stream, or send STOP_SENDING, with.
fn encode_stream_code(&self, err: &StreamError) -> u32;

/// Read a code the peer reset a stream (or sent STOP_SENDING) with.
fn decode_stream_code(&self, code: u32) -> StreamError;

/// Turn a transport failure into an [`Error`], reading a stream reset with this
/// registry.
///
/// A session close is not stream-scoped, so it decodes through
/// [`SessionError`](crate::SessionError) exactly as [`Error::from_transport`] does.
fn transport_error<E: web_transport_trait::Error>(&self, err: E) -> Error {
if let Some((code, _reason)) = err.session_error() {
return crate::SessionError::from_code(code).into();
}

if let Some(code) = err.stream_error() {
return self.decode_stream_code(code).into();
}

Error::Transport(err.to_string())
}
}

/// moq-lite's registry, specified by draft-lcurley-moq-lite (Error Codes) and identical
/// across the versions we negotiate.
impl StreamCodes for lite::Version {
fn encode_stream_code(&self, err: &StreamError) -> u32 {
err.to_code()
}

fn decode_stream_code(&self, code: u32) -> StreamError {
StreamError::from_code(code)
}
}

/// moq-transport's registry, which is per draft.
impl StreamCodes for ietf::Version {
fn encode_stream_code(&self, err: &StreamError) -> u32 {
ietf::error::to_stream_code(err, *self)
}

fn decode_stream_code(&self, code: u32) -> StreamError {
ietf::error::from_stream_code(code, *self)
}
}

/// The negotiated version before it is narrowed to one protocol, e.g. the SETUP stream a
/// pre-lite-05 session opens.
impl StreamCodes for crate::Version {
fn encode_stream_code(&self, err: &StreamError) -> u32 {
match self {
Self::Lite(version) => version.encode_stream_code(err),
Self::Ietf(version) => version.encode_stream_code(err),
}
}

fn decode_stream_code(&self, code: u32) -> StreamError {
match self {
Self::Lite(version) => version.decode_stream_code(code),
Self::Ietf(version) => version.decode_stream_code(code),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

/// The registry follows the negotiated protocol, not the other way around: a group
/// dropped for being old is a moq-lite placeholder and an INTERNAL_ERROR on the IETF
/// wire, and the same number read back on each wire has to mean what that wire said.
#[test]
fn the_version_picks_the_registry() {
let lite = crate::Version::Lite(lite::Version::Lite05);
let ietf = crate::Version::Ietf(ietf::Version::Draft20);

assert_eq!(lite.encode_stream_code(&StreamError::Old), StreamError::Old.to_code());
assert_eq!(ietf.encode_stream_code(&StreamError::Old), ietf::error::INTERNAL_ERROR);

// Both agree on a cancellation, which is the one code that has to be right.
assert_eq!(lite.encode_stream_code(&StreamError::Cancel), ietf::error::CANCELLED);
assert_eq!(ietf.encode_stream_code(&StreamError::Cancel), ietf::error::CANCELLED);

// GOING_AWAY is moq-lite's 0x4 and draft-20's, but draft-17 gives 0x4 to
// UNKNOWN_OBJECT_STATUS, so it is not sent there and not read back there.
let draft17 = crate::Version::Ietf(ietf::Version::Draft17);
assert_eq!(lite.decode_stream_code(0x4), StreamError::GoingAway);
assert_eq!(ietf.decode_stream_code(0x4), StreamError::GoingAway);
assert_eq!(draft17.decode_stream_code(0x4), StreamError::Unknown(0x4));
assert_eq!(
draft17.encode_stream_code(&StreamError::GoingAway),
ietf::error::INTERNAL_ERROR
);
}

/// A dropped [`Writer`](super::Writer) resets with a cancellation, and `Drop` cannot
/// carry the bound that would reach the negotiated registry. It does not need one only
/// while every registry we speak agrees on the value, so pin that.
#[test]
fn both_registries_agree_about_a_cancellation() {
// Every negotiable version, plus the work-in-progress one `Versions::all` holds back.
let versions = crate::Versions::all()
.iter()
.copied()
.chain([crate::Version::Lite(lite::Version::Lite06Wip)])
.collect::<Vec<_>>();

for version in versions {
assert_eq!(
version.encode_stream_code(&StreamError::Cancel),
StreamError::Cancel.to_code(),
"{version:?} cancels with a different code than the Writer's Drop sends"
);
}
}

/// A session close is not stream-scoped, so it keeps the session registry whichever
/// wire the stream is on.
#[test]
fn a_session_close_keeps_the_session_registry() {
#[derive(Debug, thiserror::Error)]
#[error("failed")]
struct Failed {
session: Option<u32>,
stream: Option<u32>,
}

impl web_transport_trait::Error for Failed {
fn session_error(&self) -> Option<(u32, String)> {
self.session.map(|code| (code, "closed".to_string()))
}
fn stream_error(&self) -> Option<u32> {
self.stream
}
}

let version = ietf::Version::Draft20;

// 0x0 ends a session cleanly, but fails a stream.
assert!(matches!(
version.transport_error(Failed {
session: Some(0x0),
stream: None
}),
Error::Cancel
));
assert!(matches!(
version.transport_error(Failed {
session: None,
stream: Some(0x0)
}),
Error::Remote(0)
));

// Neither: the transport itself failed.
assert!(matches!(
version.transport_error(Failed {
session: None,
stream: None
}),
Error::Transport(_)
));
}
}
Loading
Loading