Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0351d8f
chore(quest): claim quest/m1/quic-api/native-web.md
kixelated Nov 3, 2026
37733c2
refactor(quic)!: implement native WebTransport poll transports
kixelated Nov 3, 2026
9d39cc4
fix(quic): preserve wakes and buffered stream tails
kixelated Nov 3, 2026
4a399ca
Merge remote-tracking branch 'origin/main' into quest/m1/quic-api/nat…
kixelated Nov 3, 2026
cfa0123
quest: retain native WebTransport validation work
kixelated Nov 3, 2026
4a8d596
test(quic): keep peers alive through FIN acknowledgement
kixelated Nov 3, 2026
808e269
style(quic): format header completion fixture
kixelated Nov 3, 2026
b49f2eb
docs(quic): describe terminal stream polling
kixelated Nov 3, 2026
374c8b2
docs(uring): link the shared CONNECT request URL field
kixelated Nov 3, 2026
daa2736
test(uring): recycle ready-stream credit with partial headers held
kixelated Nov 3, 2026
b5f43b7
fix(quic): return stream credit before the update batch can deadlock
kixelated Nov 3, 2026
814249d
quest: complete native WebTransport poll transports
kixelated Nov 4, 2026
20e9450
quest: refresh references to the completed native transport work
kixelated Nov 4, 2026
5d47c85
fix(quic): validate rejections and retain header stop codes
kixelated Nov 4, 2026
fd5d25e
docs(quic): attach the FIN test comment to its test
kixelated Nov 4, 2026
5710e15
Merge remote-tracking branch 'origin/main' into iterate-7771
kixelated Nov 4, 2026
57b6382
fix(quic): keep reset, stop, lease, and datagram outcomes in native W…
kixelated Nov 4, 2026
32e6224
test(quic): use the ambient clock in the stray datagram test
kixelated Nov 4, 2026
01cf8b1
docs(quic): align io_uring's reset watch and reset-stop notes with th…
kixelated Nov 4, 2026
24e2220
Merge remote-tracking branch 'origin/main' into iterate-7771
kixelated Nov 4, 2026
b5c37c2
Merge remote-tracking branch 'origin/main' into iterate-7771
kixelated Nov 4, 2026
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
4 changes: 1 addition & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,29 @@ It reports offered and lost datagrams, bottleneck queue drops, QUIC DATAGRAM fra
that delivered no datagram, reordered arrivals and append-to-receive latency per viewer, and fails if the loopback
viewer loses any. It runs nightly.

Native transport allocation cost is measured through moq-net's real poll interface,
including QUIC opens, writes, receive leases and FINs, on long-lived loopback connections:

```bash
nix develop --command cargo bench --locked -p moq-tokio --bench fanout -- native_transport
nix develop --command env NATIVE_ALLOCS=1 cargo bench --locked -p moq-tokio --bench fanout -- native_transport
```

The allocation mode warms each shape, then counts allocations and requested allocation/reallocation
bytes over 64 batches, per delivered stream. Setup and destruction are outside the counters;
all QUIC tasks run on the benchmark's current-thread runtime. Payload size and streams per batch
vary independently. Every delivered payload is compared byte for byte. The ordinary timing
series runs nightly with `fanout`.

The io\_uring `web_headers` target varies stalled partial headers independently of ready uni/bi
streams. It then completes every fragmented header and checks its payload and FIN, so a busy
peer cannot hide a parser or stream-credit failure behind fast ready streams. It runs in the
nightly io\_uring leg:

```bash
nix develop --command cargo bench --locked -p moq-uring --bench web_headers
```

Measure 50 fps audio at per-packet, 100 and 200 ms grouping, plus 400 fps
(2.5 ms packets) at per-packet and the 20 ms default. Frames are 200 bytes:

Expand Down
5 changes: 4 additions & 1 deletion doc/concept/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ support it. The TypeScript client is pickier than the browsers: it uses
WebTransport on Firefox only from 153 (earlier releases allow too few incoming
streams) and keeps Safari on the WebSocket fallback, since WebKit bugs stall
long sessions even though Safari 26.4 ships the API. Native
clients can use WebTransport too, but usually skip it.
clients can use WebTransport too, but usually skip it. Both Rust runtime
crates expose `quic::web` as a WebTransport library usable without MoQ;
see [moq-tokio](https://docs.rs/moq-tokio/latest/moq_tokio/quic/web/) and
[moq-uring](https://docs.rs/moq-uring/latest/moq_uring/quic/web/).

## WebSocket fallback

Expand Down
6 changes: 1 addition & 5 deletions quest/m1/quic-api/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# moq-quic's API improves on quinn's
# [S] moq-quic's API improves on quinn's

## Goal

Expand Down Expand Up @@ -44,10 +44,6 @@ This README's own work, after the children: read the three layers' rustdoc
side by side and fix any name or signature that still differs, and check
`doc/lib/rs/index.md`'s driver line still holds.

## Required

- [Native WebTransport](/quest/m1/quic-api/native-web.md) - `quic::web` in both crates implements moq-net's poll traits, deleting the generic `web_transport_trait` adapters and the dependency on it

## Related

- [poll_acked](/quest/m2/quic-ack-hook.md) - the m2 consumer that keeps `send::Stream::acked` in the API
70 changes: 0 additions & 70 deletions quest/m1/quic-api/native-web.md

This file was deleted.

2 changes: 1 addition & 1 deletion quest/m2/net-wrap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ moq-flate, so moq-flate is out of scope.

Non-goals:
- A runtime seam: libraries poll and spawn nothing ([the remaining library spawns](/quest/m2/spawn-audit.md)).
- The transport seam: custom transports implement moq-net's `transport::poll` traits ([native WebTransport](/quest/m1/quic-api/native-web.md) moves the last backend onto them).
- The transport seam: custom transports implement moq-net's `transport::poll` traits, including both native WebTransport backends.

## Plan

Expand Down
4 changes: 0 additions & 4 deletions quest/m2/perf/quic-lock-contention.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,6 @@ weighs.
A measured no-win is a valid outcome. Report public API and wire impact; the
staging queue and batch dispatch should need none.

## Required

- [Native WebTransport](/quest/m1/quic-api/native-web.md) - removes the adapter's per-operation boxing, which would otherwise dominate the profile

## Related

- [Relay egress profile](/quest/m2/quic-egress-profile.md) - the transmit side of the same path
Expand Down
10 changes: 3 additions & 7 deletions quest/m2/perf/relay-allocs.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,8 @@ Decided 2026-10-17 (maintainer): m2, under perf.

Decided 2026-10-22 (maintainer): this waits on the work that replaces the
session clones and boxed opens at the top of this profile; re-profile after
it lands before fixing what remains. Since 2026-10-23 the QUIC half of that
removal is [native WebTransport](/quest/m1/quic-api/native-web.md), so the
relay's QUIC profile waits on it.
it lands before fixing what remains. The QUIC adapters are now removed by
[moq-dev/moq#7771](https://github.com/moq-dev/moq/pull/7771); re-profile the
native poll implementations before fixing what remains.

Public API: none expected. Wire: none.

## Required

- [Native WebTransport](/quest/m1/quic-api/native-web.md) - removes the QUIC path's per-stream session clone and boxed future, so the profile is retaken after it
2 changes: 1 addition & 1 deletion quest/m2/quic-sansio-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Open, for whoever starts it:
- Where it lives: a module in moq-quic, which doesn't depend on moq-net today, or a crate
of its own.
- Whether it ships with WebTransport over HTTP/3, sharing
[native WebTransport](/quest/m1/quic-api/native-web.md)'s `quic::web`, or with raw
the drivers' supported `quic::web` API, or with raw
QUIC's moq ALPN first.
- How it builds its TLS configs (identity, roots, fingerprint pinning) without
moq-tokio's `tls` module.
Expand Down
1 change: 1 addition & 0 deletions rs/moq-quic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Changes on top of the upstream commit, besides the renames:
- **Stopped after a local reset**: moq-tokio's driver resolves `send::Finished::stopped` with `None` once the stream is reset locally, as it already did once the reset was acknowledged and the stream freed. A local reset wakes `send::Stream::poll_end`, where quinn reports no event for it, so quinn leaves a pending `stopped` waiting, and its waker registered, until the connection closes. Not offered upstream yet; quinn 0.11 has the same gap.
- **Namespaced streams** (`send`, `recv`, `stream`): quinn's `SendStream`, `RecvStream`, and their error zoo become `send::Stream` and `recv::Stream` with one `send::Error` (streams and datagrams) and one `recv::Error` (reads and leases), adding `Blocked`, and `Closed` for an ID the stream no longer has state for. `send::Stream::write` takes `&mut impl Buf`, splitting a `Bytes` source off without a copy, in place of `write` and `write_chunks`, and `Datagrams::send` takes `impl Buf` the same way, so a chained header costs no copy. An outgoing stream's `send::End` (finished and acknowledged, stopped, or reset) stays queryable until `send::Stream::release`, so a driver needs no table of its own for how a stream ended. A `send::Offset` comes only from `offset()` and is tagged to its stream, so `reset_at` and `acked` cannot be handed one past the data written. A port touching a public stream method has to be redone against these names.
- **Connection limits** (`Limits`, `Connection::set_limits`, and the drivers' `set_limits`): one validated call changes a live connection's stream limit and windows, refusing values a peer would reject and changing nothing then. moq-tokio and moq-uring re-export the type, so both runtimes check limits the same way. Not offered upstream: it only bundles quinn's existing setters.
- **Stream credit with held streams**: batch `MAX_STREAMS` updates only while the peer still has advertised stream credit. Once it opens the last advertised stream, return freed credit even below the batch threshold, so long-lived streams cannot deadlock the others. The same threshold remains in quinn `main`; not offered upstream yet.
- **Indexed send buffer** (`SendBuffer::get`): each retained write keeps its stream offset, and a STREAM frame's data comes from one binary search, instead of quinn walking every write from the oldest unacknowledged one for each piece, which costs a triangular number of visits when many small writes are in flight. Not offered upstream yet.
- **No receive-state recycling** (`StreamsState::stream_recv_freed`): a completed stream's `Recv` is dropped, where quinn keeps it in `free_recv` with its chunk list at peak capacity for the next stream. Recycled state stayed allocated per peak concurrent stream for the connection's lifetime: about 550 B per stream, and about 113 KiB for one that once buffered a 2,048-packet backlog. Reusing it saved only 0.1 to 0.3% of a burst's instructions. `benches/receive_bursts.rs` measures both. Stream ID hash tables and waiter tables contract geometrically as streams go away and release every slot when empty; drained control queues and obsolete credit updates are dropped when nothing remains to send. A surviving stream cannot pin a burst's hash-table capacity. A cherry-pick touching `StreamRecv`, `free_recv`, or `reinit` has nothing to apply to. Not offered upstream yet.
- **Bandwidth probing** (`Connection::set_probe_target`, `connection/bandwidth_probe.rs`): an application-limited sender below its target fills the headroom BBR3 opens, in Startup and in one ProbeBW\_UP per probe wait, with duplicates of STREAM data still in flight, paced to the target, so the controller's samples measure the path rather than the application. That inflight bounds an attempt like a window once it sends its first duplicate; media alone past it, as beside another flow's standing queue, leaves the sender application-limited. `Controller::probes` and `Metrics::probe_inflight` tell the sender when and how far it may fill; CUBIC never does. A duplicate is never retransmitted, a reset or stopped stream's data is never resent, and a loss retransmits only the ranges no copy delivered (`SendBuffer::retransmit`). An attempt that raises nothing for two round trips, or ends short of the target, sits out 3 s and then 4 s before retrying the same target; reaching the target or a new one retries at once. `PathStats::sent_bandwidth_probe_bytes` counts the redundant bytes. A cherry-pick touching `poll_transmit`'s space loop or `populate_packet` has to keep the probe after the ordinary frames. Not offered upstream.
Expand Down
6 changes: 6 additions & 0 deletions rs/moq-quic/src/connection/streams/lease.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ impl Leased {
matches!(self.end, Some(End::Fin))
}

/// Whether the reader leased bytes it has not read: they sit here, or are out in a
/// [`Lease`] that may hand some back.
pub(super) fn has_unread(&self) -> bool {
self.len > 0 || self.out
}

/// The number of leased bytes not out in a [`Lease`].
#[cfg(test)]
pub(super) fn len(&self) -> usize {
Expand Down
7 changes: 5 additions & 2 deletions rs/moq-quic/src/connection/streams/recv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,8 @@ impl<'a> Chunks<'a> {

/// The next contiguous chunk of at most `max_length` bytes, or `None` once the stream finished
///
/// Fails with [`Error::Blocked`] until more arrives, or [`Error::Reset`]. Call
/// Fails with [`Error::Blocked`] until more arrives, or [`Error::Reset`]. A finished stream
/// whose leased bytes are not all read stays blocked rather than finish without them. Call
/// [`Self::finalize()`] when done.
pub fn next(&mut self, max_length: usize) -> Result<Option<Chunk>, Error> {
let rs = match &mut self.state {
Expand Down Expand Up @@ -490,7 +491,9 @@ impl<'a> Chunks<'a> {
Err(Error::Reset(error_code))
}
RecvState::Recv { size } => {
if size == Some(rs.end) && rs.assembler.bytes_read() == rs.end {
// Leased bytes left the assembler unread, so the end is not reached until they
// are read: freeing the stream now would discard them.
if size == Some(rs.end) && rs.assembler.bytes_read() == rs.end && !rs.lease.has_unread() {
let state = mem::replace(&mut self.state, ChunksState::Finished);
// At this point if we have `rs` self.state must be `ChunksState::Readable`
let recv = match state {
Expand Down
84 changes: 81 additions & 3 deletions rs/moq-quic/src/connection/streams/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -958,9 +958,12 @@ impl StreamsState {
let mut queued = false;
for dir in Dir::iter() {
let diff = self.max_remote[dir as usize] - self.sent_max_remote[dir as usize];
// To reduce traffic, only announce updates if at least 1/8 of the flow control window
// has been consumed.
if diff > self.max_concurrent_remote_count[dir as usize] / 8 {
// Batch updates while the peer still has advertised credit. Long-lived streams can
// hold most of the window, so waiting for a batch after it is spent would deadlock.
if diff > 0
&& (diff > self.max_concurrent_remote_count[dir as usize] / 8
|| self.next_remote[dir as usize] >= self.sent_max_remote[dir as usize])
{
pending.max_stream_id[dir as usize] = true;
queued = true;
}
Expand Down Expand Up @@ -1364,6 +1367,58 @@ mod tests {
)
}

/// Long-lived streams cannot hold the update batch hostage when one other stream turns over.
#[test]
fn held_streams_do_not_withhold_returned_credit() {
for dir in Dir::iter() {
let mut server = make(Side::Server);
let mut pending = Retransmits::default();
for index in 0..127 {
let id = StreamId::new(Side::Client, dir, index);
data(&mut server, id, 0, 1, false);
assert_eq!(Streams::new(&mut server, &ConnState::Established).accept(dir), Some(id));
}
for index in 127..131 {
let id = StreamId::new(Side::Client, dir, index);
data(&mut server, id, 0, 1, true);
assert_eq!(Streams::new(&mut server, &ConnState::Established).accept(dir), Some(id));
let mut stream = recv(&mut server, &mut pending, id);
let mut chunks = stream.read().unwrap();
assert_eq!(chunks.next(1).unwrap().unwrap().bytes.len(), 1);
assert!(chunks.next(1).unwrap().is_none());
chunks.finalize();
if dir == Dir::Bi {
let mut stream = send::Stream {
id,
state: &mut server,
pending: &mut pending,
conn_state: &ConnState::Established,
};
stream.finish().unwrap();
stream.release();
server.received_ack_of(frame::StreamMeta {
id,
offsets: 0..0,
fin: true,
});
}
server.queue_max_stream_id(&mut pending);
assert!(
pending.max_stream_id[dir as usize],
"the peer has spent every advertised stream"
);
server.write_control_frames(
&mut Vec::new(),
&mut pending,
&mut ThinRetransmits::default(),
&mut FrameStats::default(),
1200,
);
assert_eq!(server.sent_max_remote[dir as usize], index + 2);
}
}
}

/// Notifications coalesce while a reader is parked, and freed streams leave no table storage.
#[test]
fn receive_bursts_release_stream_storage() {
Expand Down Expand Up @@ -4433,6 +4488,29 @@ mod tests {
assert_eq!(stream.lease(0).unwrap().remaining(), 350);
}

/// A read cannot reach the end of a finished stream past leased bytes nobody read, since
/// freeing the stream then would discard them. Once they are read, it ends.
#[test]
fn unread_lease_holds_the_end_off() {
let mut client = make(Side::Client);
let id = StreamId::new(Side::Server, Dir::Uni, 0);
data(&mut client, id, 0, 32, true);
let mut pending = Retransmits::default();
let mut stream = recv(&mut client, &mut pending, id);

let lease = stream.lease(0).unwrap();
assert!(lease.is_fin());
stream.restore(lease);
assert!(matches!(stream.read().unwrap().next(0), Err(recv::Error::Blocked)));

let mut lease = stream.lease(0).unwrap();
assert_eq!(lease.remaining(), 32, "the bytes survive");
lease.advance(32);
stream.release(lease);
assert!(matches!(stream.read().unwrap().next(0), Ok(None)));
assert!(!client.recv.contains_key(&id), "freed once read");
}

/// Leasing again before handing the last lease back would lose its bytes.
#[test]
#[should_panic(expected = "leased again before the last lease was handed back")]
Expand Down
1 change: 0 additions & 1 deletion rs/moq-relay/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ tower-service = "0.3"
tracing = { workspace = true }
url = { workspace = true, features = ["serde"] }
usage-rs = { workspace = true, features = ["completions", "config"] }
web-transport-trait = { workspace = true }

[target.'cfg(target_os = "linux")'.dependencies]
moq-sock = { workspace = true, optional = true }
Expand Down
Loading
Loading