Skip to content

feat(uring): speak WebTransport for browser peers - #3081

Open
kixelated wants to merge 2 commits into
devfrom
uring-m4-web
Open

feat(uring): speak WebTransport for browser peers#3081
kixelated wants to merge 2 commits into
devfrom
uring-m4-web

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Summary

M4 PR 3 on #2875, stacked on #3078: the WebTransport-over-quiche adapter the epic's M4 requires ("implement the WebTransport-over-quiche adapter from M3c as web_transport_trait::poll session and stream types"). A browser cannot speak raw QUIC, so without this the relay integration could only serve native peers; with it, h3 connections get the HTTP/3 CONNECT handshake on the same adapter #3033 built.

  • quic::web::Request runs the server handshake using web-transport-proto's sans-IO state machines over the existing poll streams (its async helpers want tokio I/O, so decoding is incremental over buffered bytes): SETTINGS exchange, the CONNECT request with its URL (what the relay routes and authenticates on), and subprotocol selection, the WebTransport equivalent of ALPN that carries the moq version, mirroring the moq-tokio dispatch.
  • quic::web::Session is now the runtime's one transport type (Handle::Transport changed from quic::Connection), following web-transport-quinn's Session::raw pattern rather than a delegation enum: raw QUIC and WebTransport run the same machinery, with the layering compiled down to an Option. Web mode frames opened streams (0x54/0x41 + session id, written lazily ahead of the first payload byte), classifies accepted streams by reading header varints exactly (never past them), prefixes and filters datagrams, holds the peer's control and QPACK streams open for the session's life, and maps stream/close codes through the HTTP/3 error space in both directions.
  • Close semantics for browsers: close(code, reason) writes a CloseWebTransportSession capsule in a DATA frame on the CONNECT stream (what WebTransport.closed reads), then closes the connection after a one-second grace or the peer acting, whichever first. Inbound, a capsule reader task surfaces the peer's code and reason as the session error, so moq's close codes survive the H3 mapping round trip; the echo test asserts code 42 and its reason arrive intact.
  • Pipelined peers: a client may open WebTransport streams before its CONNECT is answered. The handshake classifies every early arrival by its header (settings, QPACK, WT-with-claimed-session-id, noise) and seeds matching early streams into the session's accept queue, rather than dropping them. Bidirectional order is safe by construction: the accept queue is in stream-id order, so the client's first bidi stream (CONNECT) always arrives first.

Validation

tests/web.rs runs interop against web-transport-quinn, the reference stack browsers interoperate with, on a tokio thread dialing the uring server:

  • Hand-driven echo through the framing: bidirectional and unidirectional streams both ways, datagrams, URL path/query and subprotocol assertions, and the close code/reason through the capsule and back out of the H3 error mapping.
  • A full moq-lite session over WebTransport: subprotocol negotiation, SETUP on the CONNECT-adjacent bidi stream, announce, subscribe, and a group on a unidirectional stream, with the tokio stack as subscriber. This is the browser-to-relay path end to end.

All 27 moq-uring tests pass; just check and just test are green. Existing raw-QUIC call sites wrap with Session::raw (deliberate API change to the unreleased runtime surface).

Notes

  • moq-tokio and web-transport-quinn join as Linux dev-dependencies only (the client side of the interop tests); the library gains web-transport-proto, http, and url.
  • Client-side WebTransport (dialing out over H3) is not implemented: browsers are the client on this path, and cluster dials are raw QUIC by design.
  • Capsules split across DATA frames are treated as unparseable (logged); nothing we interoperate with splits them.

(Written by Claude Fable 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_01XLJU7rEfPe7hn7K5jX77mb

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 937a2b6e71

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let n = std::future::poll_fn(|cx| web_transport_trait::poll::RecvStream::poll_read(recv, cx, &mut chunk)).await?;
match n {
Some(n) => {
buf.extend_from_slice(&chunk[..n]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound buffered HTTP/3 handshake data

When an unauthenticated peer declares an arbitrarily large SETTINGS or CONNECT frame and continues supplying an incomplete body, both handshake loops repeatedly append here without enforcing a size limit; because reads keep the connection active and replenish flow-control credit, a client can grow this BytesMut until the process exhausts memory. Reject handshake frames once a reasonable configured limit is exceeded. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment thread rs/moq-uring/src/quic/web.rs Outdated
Comment on lines +1034 to +1037
// A capsule split across DATA frames is legal but nothing we talk to
// does it; treat a partial capsule as unparseable.
let mut payload: &[u8] = &peek[..len];
let capsule = proto::Capsule::decode(&mut payload).map_err(|err| Error::Web(err.to_string()))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse capsules across DATA-frame boundaries

When a peer splits a CloseWebTransportSession capsule across HTTP/3 DATA frames, which Capsule Protocol permits, decoding only the first frame's payload returns an incomplete-capsule error. read_capsules then treats that as no capsule and closes with code 0, losing the peer's close code and reason; the parser should treat DATA payloads as one continuous capsule byte stream and also preserve multiple capsules carried in one DATA frame. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b093dcf296

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

response.encode(&mut buf).map_err(|err| Error::Web(err.to_string()))?;
write_all(&mut self.send, &buf).await?;
web_transport_trait::poll::SendStream::finish(&mut self.send)?;
Ok(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep critical H3 streams alive through rejection

When a server calls reject, returning here immediately drops the consumed Request; its unfinished local control stream and held peer control/QPACK receive streams then invoke the underlying stream Drop implementations, sending RESET_STREAM/STOP_SENDING. HTTP/3 treats attempts to close these critical streams as a connection error, so the client can receive an H3 connection failure instead of the intended rejected CONNECT response. Retain the critical streams until the response is delivered and shut the connection down deliberately.

Useful? React with 👍 / 👎.

Comment thread rs/moq-uring/src/quic/web.rs Outdated
Comment on lines +1007 to +1008
// The CONNECT stream ending closes the session with no error.
_ => conn.shared().close_code(proto::error_to_http3(0), ""),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore unknown capsules instead of closing the session

When a peer sends a valid extension or greased capsule, any decoded non-close capsule reaches this fallback and closes the session with code 0; if the dependency rejects the unknown type, the parse-error path has the same result. Capsule Protocol requires unknown capsule types to be silently ignored, so an otherwise interoperable client can have its session terminated before a later close capsule is read. Continue scanning after unsupported capsule types rather than treating them as end-of-session.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b5362fb07

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +846 to +847
Poll::Ready(UniClass::Control | UniClass::Qpack) => {
web.state.borrow_mut().held_recv.push(stream.recv);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound retained HTTP/3 critical streams

After establishment, a peer can repeatedly open finished unidirectional streams with control or QPACK type headers; every such stream is appended to held_recv, which is never drained or capped. Because completed QUIC streams restore stream credit, one active peer can repeat this indefinitely and grow the process heap despite the configured concurrent-stream limit. Track the single permitted stream of each critical type and terminate or otherwise bound the connection on duplicates instead of retaining every arrival. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

let mut framed = Vec::with_capacity(web.header_datagram.len() + payload.len());
framed.extend_from_slice(&web.header_datagram);
framed.extend_from_slice(payload);
web_transport_trait::poll::Session::poll_send_datagram(&mut self.conn, cx, &framed)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unmap errors returned while sending WebTransport datagrams

When the peer closes a WebTransport session with a nonzero code, the capsule reader closes QUIC using error_to_http3(code), so a subsequent datagram send returns an Error::App containing that mapped HTTP/3 value. This branch returns the error unchanged, unlike receive/open/stream operations, and session_error() can consequently clamp the large mapped value to u32::MAX instead of reporting the peer's original close code. Apply unmap_err to the result before returning it. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a3144f4c1b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

inner: super::SendStream,
/// Header bytes still owed to the wire before any payload.
prefix: Bytes,
web: bool,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map cancellation before dropping web streams

When a web-mode SendStream or RecvStream is dropped unfinished, the wrapper drops its inner stream without applying the WebTransport error mapping, so the inner Drop sends raw code 0 instead of error_to_http3(0). This makes routine application cancellation appear to browsers as an HTTP/3 stream error rather than a WebTransport cancellation; add wrapper drop handling that maps code 0 before the inner handle runs its fallback. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment on lines +529 to +532
let wrote = send.try_write(&frame);
if wrote == frame.len() {
let _ = web_transport_trait::poll::SendStream::finish(&mut send);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Finish writing the close capsule before resetting CONNECT

When connection or stream flow-control capacity is exhausted, or the close reason is larger than the available capacity, try_write can write only part of this frame. The unfinished send is then dropped and resets the CONNECT stream immediately, so the browser cannot decode the close capsule and loses the intended close code or reason; keep the stream alive and asynchronously finish the capsule until the grace deadline instead of abandoning every partial write. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a50ef4d7cb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// Accept with a `200`, selecting `protocol` from the peer's
/// [`protocols`](Self::protocols) (or none, for peers that negotiate in
/// band instead).
pub async fn respond(mut self, protocol: Option<&str>) -> Result<Session, Error> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Put response options in a config type

The new public respond API exposes its sole optional setting as a positional parameter. When successful CONNECT responses need another header or policy knob, extending this signature will break consumers or produce an awkward parameter list. Introduce a future-proof response options type now while this API is new. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L167-L167

Useful? React with 👍 / 👎.

}

/// Refuse with `status`, ending the handshake.
pub async fn reject(mut self, status: http::StatusCode) -> Result<(), Error> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid exposing StatusCode without a re-export

External callers of reject must add their own matching http dependency to construct this argument, coupling the public API to a third-party crate and its selected major version. Use a project-owned rejection type, or re-export http and document that dependency as part of the compatibility contract. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L171-L171

Useful? React with 👍 / 👎.

Comment on lines +104 to +106
let recv = accept_uni(&mut conn).await?;
let mut pending = PendingUni::new(recv);
let class = std::future::poll_fn(|cx| pending.poll_classify(cx)).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify handshake streams without head-of-line blocking

If an early WebTransport unidirectional stream sends its type but stalls before completing the session-ID varint, awaiting its classification here prevents the loop from accepting a fully available control stream with SETTINGS. A peer can therefore keep the handshake stuck indefinitely while continuing to keep the QUIC connection active, and the stream-count bound never applies because only this one stream has been adopted. Track and poll partially classified streams concurrently, as the established-session path does. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Comment on lines +613 to +616
let n = self.inner.try_write(&self.prefix);
self.prefix.advance(n);
if !self.prefix.is_empty() {
return Err(Error::Web("no capacity to frame the stream before finishing".into()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer finishing until the stream prefix can be sent

When connection-level flow-control credit is exhausted, finishing a newly opened empty WebTransport stream makes try_write return zero and reports a terminal Error::Web, even though capacity may become available normally after the peer advances flow control. Callers generally drop the stream after this unexpected failure, resetting a stream that should have finished cleanly. Ensure the prefix is queued before returning the opened stream, or retain enough state to complete it asynchronously instead of converting transient backpressure into an error. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Base automatically changed from uring-m4-shard to dev August 27, 2026 07:43
kixelated and others added 2 commits August 27, 2026 00:43
quic::web::Request runs the server side of the HTTP/3 CONNECT handshake
over the raw quiche adapter using web-transport-proto: SETTINGS exchange,
CONNECT with URL and subprotocol selection, and the capsule close carrying
codes and reasons to WebTransport.closed. quic::web::Session is the one
transport type the runtime drives, wrapping raw QUIC (Session::raw) and
WebTransport in the same machinery: web mode frames streams and datagrams
with the session id, tolerates pipelined peers whose streams outrun the
CONNECT response, holds the peer's control and QPACK streams open, and maps
stream and close codes through the HTTP/3 error space both ways.

Validated against web-transport-quinn (the stack browsers interop with):
stream and datagram echo through the framing, close codes through the
capsule, and a full moq-lite session over WebTransport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLJU7rEfPe7hn7K5jX77mb
Two problems on the CONNECT path, both reachable before a peer has
authenticated anything.

The handshake buffers grew without limit. A peer declares an HTTP/3
frame's length before sending its body, and reading what does arrive
replenishes its flow-control credit, so announcing an enormous SETTINGS,
CONNECT, or capsule frame and then dribbling it grew a `BytesMut` until
the process ran out of memory. `read_some` now refuses past 64 KiB, which
is far more than anything parsed here needs. The stream count before the
control stream arrives is bounded the same way: a peer that never sends
one could otherwise open unidirectional streams without end, since each
is held or parked as an early WebTransport stream.

Capsules were read one DATA frame at a time. HTTP/3 framing and the
Capsule Protocol are independent layers: a capsule may span DATA frames
and one frame may carry several, so a peer that split its
`CloseWebTransportSession` across frames lost its close code and reason
to a "close with 0", and a second capsule in one frame was dropped with
the frame. `Capsules` splits the framing off and parses the concatenated
payloads as the one continuous byte stream they are, skipping non-DATA
frames and now also skipping GREASE and unknown capsule types rather than
stopping at them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82e4d52037

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let mut early = Vec::new();
loop {
if held.len() + early.len() >= HANDSHAKE_STREAMS {
return Err(Error::Web("too many streams before the control stream".into()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close connections when WebTransport handshake validation fails

When a peer reaches this stream cap, or triggers any later SETTINGS/CONNECT validation error, Request::accept returns without closing conn. In the inspected Endpoint::launch lifecycle, dropping the public Connection does not close QUIC: the endpoint retains its shared state until the driver observes a terminal connection state, and the backlog no longer counts the connection after it has been accepted. A peer can therefore keep each rejected handshake alive with valid traffic to prevent the idle timeout, causing endpoint connections and driver tasks to accumulate without bound; close the underlying connection on every handshake failure path. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L110-L110

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant