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]>
93 lines
6.3 KiB
Markdown
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.
|