open-standards/joule-code/joule-code-rs/vendor/wtransport/VENDORED.md
Transaction Science fff82093dc WebTransport: a data stream's header written with its first bytes, in one STREAM frame
The vendored wtransport wrote a data stream's header (0x54 or 0x41, then the session ID) as the
stream opened, each varint a write of its own, and the stream's first bytes after it, so a packet
could leave between the type, the session ID and the MOQT stream header. QUIC delivers a stream as
a byte stream (RFC 9000 §2.2) and a reader must join them; two other implementations' readers do
not (the interop record of 12 November 2026: quic-zig stops a stream whose first read lacks the
session ID with H3_STREAM_CREATION_ERROR, imquic misparses a header that arrives ahead of the
rest). That stays recorded as theirs; this stack now writes so that they read it.

A stream this end opens holds its header until its first write, which puts what is left of the
header and the bytes into QUIC in one call (quinn's write_chunks), so both leave in one STREAM
frame. The header alone is then marked as the reliable prefix by the vendored quinn's new
SendStream::set_reliable_size, so a reset is still RESET_STREAM_AT whose Reliable Size is the
header (draft-ietf-webtrans-http3-17 §4.4). A stream finished, reset or dropped with nothing
written writes its header first; so does quic_stream_mut(). joule-moq-relay's WebTransport
transport writes every stream through write_all_quic, and sets priority and waits on stopped()
without writing the header early.

Tests: tests/webtransport_stream_header.rs counts the STREAM frames a client of the relay sends:
a unidirectional and a bidirectional stream opened and left 150 ms send nothing, and their first
write sends one frame, through the relay's transport and through wtransport's own SendStream; a
stream reset unwritten goes as RESET_STREAM_AT, and one finished unwritten carries its header and
FIN in one frame, which the relay reads as the session's. tests/reset_stream_at.rs: a reliable
size set on the first three bytes of one write sends RESET_STREAM_AT and delivers them first.

Planted faults, each alone: the header written at the opening (caught: nothing must leave before
the first write); the reliable size never set (caught: RESET_STREAM instead of RESET_STREAM_AT,
two tests); quic_stream_mut() not writing a held header first (caught: a stream reset unwritten
goes as RESET_STREAM). Not caught: the relay's transport writing a held header and the first
bytes as two writes back to back (flush, then write) rather than one: on loopback both are
queued before QUIC builds a packet, so the frame count is the same; only the atomicity differs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 07:33:10 -05:00

93 lines
6.3 KiB
Markdown

# wtransport 0.7.2, vendored
**Upstream.** The `wtransport` crate, version 0.7.2, exactly as published on crates.io: the
`.crate` file's SHA-256 is `b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036`,
the checksum the joule-code lock held for it, and `.cargo_vcs_info.json` names the upstream
commit it was packaged from (`d022a6ad5f6260cb526e06d8ca42d09516ce6440`, directory
`wtransport`). `LICENSE-MIT` and `LICENSE-APACHE` are the upstream's, from that commit; the
crate is MIT OR Apache-2.0, and these changes are offered under the same terms. The
crate's companion, `wtransport-proto` 0.7.2, is not vendored: nothing in it changes.
**Where it is used.** `[patch.crates-io]` in `joule-code/joule-code-rs/Cargo.toml` and in
`crates/joule-moq-room/Cargo.toml` (a workspace of its own that builds the relay). Its
`Cargo.lock` and `Cargo.toml.orig` are the upstream's, kept with the source; cargo reads
neither, and `scripts/check-lockfiles.sh` skips the lock.
## Why
A MOQT session over WebTransport is closed by closing the WebTransport session
(`draft-ietf-moq-transport-22` §6.6, which calls the capsule by its earlier name,
CLOSE_WEBTRANSPORT_SESSION): a WT_CLOSE_SESSION capsule on the session's CONNECT stream
carries a 32-bit application error code and a UTF-8 reason of at most 1024 bytes, followed
by a FIN (`draft-ietf-webtrans-http3-16` §6). The release reads that capsule but cannot
send it: its CONNECT stream belongs to the driver's worker, and `Connection::close` closes
the QUIC connection, whose code a browser's WebTransport API does not expose. A page whose
session joule-moq-relay refused saw only that the session was lost.
## What changed
- `Connection::close_session(error_code: u32, reason: &str)`. It hands the driver's worker
a request (`Driver::close_session`); the worker writes one HTTP/3 DATA frame holding the
capsule (`close_session_capsule`, in `src/driver/streams/connect.rs`) on the CONNECT
stream at the highest send priority, sends the FIN, and then reads that stream until the
peer's FIN or reset, at most two seconds (`SESSION_CLOSE_LINGER`), before it closes the
QUIC connection with H3_NO_ERROR: §6 asks an endpoint to wait for the peer to close its
CONNECT stream first, so the connection's close cannot overtake the capsule. The reason
is cut to 1024 bytes at a UTF-8 character boundary, as §6 requires of a sender that
truncates.
- After `close_session` this end opens no stream and sends no datagram (§6): `open_uni`,
`open_bi` and `send_datagram` fail at once.
- A close requested just as the session's last handle is dropped still goes out: the
worker's loop can see the handles gone first, so `Worker::run` takes a pending request
before it ends.
- `ConnectionError::SessionClosed`, for a session the peer closed (the capsule's code and
reason, or code 0 and no reason for a FIN alone, §6). The release reported that as
`ApplicationClosed`, the variant it also uses for the QUIC connection's close, whose code
under HTTP/3 is an HTTP/3 error code; the two could not be told apart.
- `Connection::closed()` resolves with how the session ended, as the driver saw it, rather
than with how the QUIC connection closed after: `SessionClosed` with the peer's code and
reason, or `LocallyClosed` as soon as this end's capsule is queued. The release's
`closed()` gave the QUIC close, so a peer's capsule was never reported there.
- The tokio feature `time` is named, for the wait's bound (it was on already through quinn).
- The upstream's examples and dev-dependencies are left out, so that the crate's unit tests,
the upstream's and those of the capsule's layout, run from the joule-code workspace:
`cargo test --locked -p wtransport --lib` (cargo tests a non-member crate only when it
has no dev-dependencies). The CI job of joule-moq-relay runs them.
### A data stream's header written with its first bytes (12 November 2026)
The release writes a data stream's header (`0x54` and the session ID on a unidirectional
stream, `0x41` and the session ID on a bidirectional one, `draft-ietf-webtrans-http3-17`
§4.2, §4.3) as its stream opens, each varint a write of its own, and the stream's first bytes
after it. QUIC delivers a stream as a byte stream (RFC 9000 §2.2), so where the header and what
follows leave in different packets a reader must join them; two other implementations' readers
do not (the MoQ interop record of 12 November 2026, D30: one stops a stream whose first read
lacks the session ID, H3_STREAM_CREATION_ERROR; one misparses a header that arrives ahead of the
rest). Their fault, and recorded as theirs; this stack now writes so that they read it:
- A stream this end opens (`open_uni`, `open_bi`) holds its header, unwritten, until its first
write; that write puts what is left of the header and the bytes into QUIC in one call
(quinn's `write_chunks`), so both leave in one STREAM frame. Once the header is written whole,
it alone is marked as the stream's reliable prefix (quinn's `set_reliable_size`), so a reset
is still sent as RESET_STREAM_AT whose Reliable Size is the header (§4.4).
- `SendStream::write_all_quic` writes so with quinn's error (joule-moq-relay's WebTransport
transport writes every stream through it); `write`, `write_all` and the `AsyncWrite` impls
do the same.
- A stream finished, reset or dropped with nothing written writes its header first (where QUIC
takes it without waiting), so the peer can still tell which session the stream was for; so
does `quic_stream_mut()`, so bytes written to the QUIC stream directly follow the header.
- The opening futures no longer write, so they no longer fail with `NotConnected` or
`Refused`; a write or a finish after the session is gone fails as it did.
- `SendStream::flush_header()` and `holds_header()`.
Held end to end by `crates/joule-moq-relay/tests/webtransport_stream_header.rs` (STREAM frames
counted at a client of the relay, both stream kinds, through the relay's transport and through
wtransport's own `SendStream`, a stream finished or reset unwritten) and
`tests/reset_stream_at.rs`.
## What did not change
The streams of a session this end closes are not reset one by one with WT_SESSION_GONE, as
§6 asks of an endpoint whose connection carries on; the driver does not track them. They end
with the QUIC connection, at most two seconds later. joule-moq-relay serves one session per
connection, and the session ends at the capsule for both ends.