joule-moq-relay: a WebTransport session closes with its MOQT code and reason (WT_CLOSE_SESSION), on a vendored wtransport 0.7.2 #365

Merged
dcharlot merged 11 commits from joule-moq/wt-session-close into main 2026-10-20 17:21:17 -04:00
Owner

What this is

A MOQT session over WebTransport is now closed by closing the WebTransport session
(draft-ietf-moq-transport-22 §6.6), not the QUIC connection under it. The relay sends a
WT_CLOSE_SESSION capsule (0x2843) and a FIN on the session's CONNECT stream
(draft-ietf-webtrans-http3-16 §6). The capsule carries two things:

  • the MOQT session termination code, as the capsule's 32-bit application error code, unmapped;
  • the reason, as the capsule's message.

A page now reads the relay's code and reason over WebTransport exactly as it does over the
WebSocket binding. For example, a grant presented without its proof gives UNAUTHORIZED (2)
with live-rooms §10.5: pop_missing. Before this change the page saw only Connection lost.

The release of wtransport (0.7.2, the newest) reads that capsule but cannot send it. Its CONNECT
stream belongs to the driver's worker, and Connection::close closes the whole QUIC connection.
So this PR vendors wtransport 0.7.2 and adds a session close to it.

The vendored crate: joule-code/joule-code-rs/vendor/wtransport

  • The source. 09700dfdf is the crate exactly as published. Its .crate SHA-256 is
    b4273ce3…, the checksum the lock held. The commit adds the upstream's LICENSE-MIT and
    LICENSE-APACHE, taken from the commit that .cargo_vcs_info.json names.
  • The wiring. [patch.crates-io] is set in joule-code-rs/Cargo.toml and in
    crates/joule-moq-room/Cargo.toml (a workspace of its own that builds the relay). In both locks
    the only change is that the wtransport entry loses its registry source and checksum lines.
  • The lock check. scripts/check-lockfiles.sh skips the vendored crate's own lock, as it
    already skips the other vendored tree's.
  • The patch is in 7e6c0ccc4 and c357d5d12; VENDORED.md lists every change and why.
    • Connection::close_session(code: u32, reason: &str). The worker writes one DATA frame
      holding the capsule on the CONNECT stream, at the highest send priority, and sends a FIN. It
      then waits at most 2 s for the peer to close its side of that stream. Only after that does it
      close the QUIC connection, with H3_NO_ERROR. §6 asks for this wait so that 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 the close, this end opens no stream and sends no datagram (§6).
    • A close requested as the last handle is dropped still goes out.
    • ConnectionError::SessionClosed reports a session the peer closed. The release reported
      that case as ApplicationClosed, the same variant it uses for the QUIC connection's close,
      whose code under HTTP/3 is an HTTP/3 error code, so the two could not be told apart.
    • Connection::closed() reports how the session ended, as the driver saw it, instead of how
      the QUIC connection closed afterwards.
    • Examples and dev-dependencies are left out, so that cargo test --locked -p wtransport --lib runs from the workspace. That command runs the upstream's unit tests and the new ones,
      including the capsule's bytes checked against the draft's layout. The relay's CI job now runs
      it.
    • wtransport-proto is not vendored, because nothing in it changes.

The relay

  • WtConnection::close calls close_session.
    • The code is the MOQT code as a u32. Session codes are not put through wt_to_h3: that
      mapping is for stream resets (§4.4), while session codes travel unmapped in the capsule.
  • Reading a peer's close. Connection errors read a peer's SessionClosed as
    Closed { code, reason, by_peer: true }. This covers the relay's own client and the relay's
    sessions alike. As a result, a page's close code now reaches the session's signed summary.
  • A QUIC close with the session still open gives code: u64::MAX, the relay's existing
    "no MOQT code" value.
  • Nothing of the session overtakes its close. A stream the relay drops after asking to close
    is held until the QUIC connection has closed (Closing in the binding, bounded at 10 s). Without
    this, the stream's FIN or STOP_SENDING would go out at once and reach the page before the capsule.
    read_setup likewise keeps a stream it refuses in early, so that stream is also dropped only
    after the close.

The browser finding behind the stream hold

The initial browser run of this change hung at the bare-grant step. Alice's page had lost its
renderer ("Target crashed"), about 10 ms after the relay refused the session.

Reproduction. I reproduced it on loopback in a small harness, with a page and the relay
binary in the host's browser. The page opened a stream, and the relay refused that stream and
dropped it before writing the capsule. The page lost its renderer in most runs; a page that sent
nothing never did.

Fix. With the relay holding the streams until the QUIC close, the same harness passed 6 of 6,
and both full browser runs pass. In a second probe the relay closed every page at a 7 s session
limit while all of them were publishing. Every page read (0, "the relay's session limit of 7 s"),
and no renderer was lost.

Residual case. One case still loses the renderer. A page that FINs a stream which the relay
refuses within the same instant can still hit it, because the page's own FIN completes after the
close. This failure is inside the browser and happens only for a page that breaks a rule. The
relay cannot order the page's own stream events.

Design decisions

  1. Wait for the peer's side of the CONNECT stream. -16 §6 asks the endpoint to "wait until all
    CONNECT streams have been closed by the peer" before CONNECTION_CLOSE. The wait is bounded at
    2 s.
  2. reason: &str, not &[u8]. §6 requires UTF-8, so the type enforces it. The relay passes
    from_utf8_lossy of its reason bytes.
  3. A new error variant, not an overload of ApplicationClosed. The tests rely on this: with
    one variant, a QUIC close that carries code 2 looks exactly like a session closed with 2, and
    the old close would pass them.
  4. No per-stream WT_SESSION_GONE resets. The driver does not track the session's streams, and
    quinn offers no reset by stream ID. The streams end with the QUIC connection instead. This is
    enough because the relay serves one session per connection.
  5. Hold streams in the relay's binding, not in the vendored crate. The relay's binding owns
    the stream wrappers, so the hold costs nothing on the media path: a stream is boxed only when it
    is dropped during a close.
  6. CI paths. The browser and room workflows now also run on changes under
    joule-code/joule-code-rs/vendor/**.
  7. Merged origin/main at d9a614dd, which includes #351 (DVR), #358 and #360. The test sets
    the grant rule's new seek field, and the changelog entry sits above DVR's. Both locks were
    re-validated.

Checks (on the build host through rb, unless marked local; all on the merged tree)

Check Command Result
relay, every target (DVR's tests/dvr.rs included) cargo test --locked -p joule-moq-relay --all-targets ok, every target
new relay test, repeated --test webtransport_close, 10 runs 10 of 10
vendored crate's unit tests cargo test --locked -p wtransport --lib 9 passed (6 upstream, 3 new)
ingest cargo test --locked -p joule-moq-ingest --all-targets ok
room service (own workspace and lock) cd crates/joule-moq-room && cargo test --locked --all-targets ok
clippy cargo clippy --locked -p joule-moq-relay --all-targets; cargo clippy --locked -p wtransport --lib --tests no new notes (the two relay notes at server.rs:167 and session.rs:311 are on main already)
locks (local) cargo metadata --locked --offline in both workspaces; scripts/check-lockfiles.sh on both current
browser e2e, real browser, run A BROWSER_BIN=… MOQ_WEB_OUT=…/wt-close-e2e/full1 e2e/run.sh --runs 2 2 of 2 runs, 238 of 238 checks ok
browser e2e, run B the same, …/full2 2 of 2 runs, 238 of 238 checks ok
repo checks (local) check-tests-in-ci, workflow-concurrency, spec-refs, wai-section-refs, no-vanity-metrics, test-public-text-checks, comparative-prose, orphan-sources, check-lockfiles-selftest, pr-touches-paths-selftest all exit 0

Falsifications

All mutations ran on committed code, each reverted after its run.

F1: the old WtConnection::close (the QUIC connection's close). Each check of
tests/webtransport_close.rs fails on its own; for each run, the checks before it were made
non-fatal.

  • Bare grant. The client read u64::MAX with "the connection closed without closing the
    session (HTTP/3 error 0x2): live-rooms §10.5: pop_missing".
  • The client's own close. The relay recorded [("webtransport", None)].
  • A session past the count. The session never opened.
  • The time limit. The client read u64::MAX with "(HTTP/3 error 0x0): the relay's session
    limit of 3 s".

F2: the capsule's code written little-endian. 3 capsule unit tests fail.

F3: the 1024-byte cut without the character-boundary check. The truncation test fails.

F4: the old Connection::closed(). The client read (0, "closed by this endpoint", by_peer: false).

F5a: the binding holds nothing. The new ordering check fails: "the relay stopped the stream
ahead of the session's close: Ok(Some(0))".

F5b: read_setup drops the refused stream before the close. The same check fails the same
way.

Browser: the old close, --only webtransport. The flipped check FAILs. The page read
{"code":-1,"reason":"Connection lost."}; 30 of 31 checks were ok.

What is not done

  • No per-stream WT_SESSION_GONE resets (decision 4).
  • The residual browser case above: a page FINning a stream that the relay refuses in the same
    instant.
  • The vendored crate now has to be kept current with upstream by hand. VENDORED.md lists what to
    carry forward.

🤖 Generated with Claude Code

## What this is A MOQT session over WebTransport is now closed by closing the **WebTransport session** (`draft-ietf-moq-transport-22` §6.6), not the QUIC connection under it. The relay sends a `WT_CLOSE_SESSION` capsule (`0x2843`) and a FIN on the session's CONNECT stream (`draft-ietf-webtrans-http3-16` §6). The capsule carries two things: - the MOQT session termination code, as the capsule's 32-bit application error code, unmapped; - the reason, as the capsule's message. A page now reads the relay's code and reason over WebTransport exactly as it does over the WebSocket binding. For example, a grant presented without its proof gives `UNAUTHORIZED` (2) with `live-rooms §10.5: pop_missing`. Before this change the page saw only `Connection lost.` The release of `wtransport` (0.7.2, the newest) reads that capsule but cannot send it. Its CONNECT stream belongs to the driver's worker, and `Connection::close` closes the whole QUIC connection. So this PR **vendors wtransport 0.7.2** and adds a session close to it. ### The vendored crate: `joule-code/joule-code-rs/vendor/wtransport` - **The source.** `09700dfdf` is the crate exactly as published. Its `.crate` SHA-256 is `b4273ce3…`, the checksum the lock held. The commit adds the upstream's `LICENSE-MIT` and `LICENSE-APACHE`, taken from the commit that `.cargo_vcs_info.json` names. - **The wiring.** `[patch.crates-io]` is set in `joule-code-rs/Cargo.toml` and in `crates/joule-moq-room/Cargo.toml` (a workspace of its own that builds the relay). In both locks the only change is that the wtransport entry loses its registry `source` and `checksum` lines. - **The lock check.** `scripts/check-lockfiles.sh` skips the vendored crate's own lock, as it already skips the other vendored tree's. - **The patch** is in `7e6c0ccc4` and `c357d5d12`; `VENDORED.md` lists every change and why. - **`Connection::close_session(code: u32, reason: &str)`.** The worker writes one DATA frame holding the capsule on the CONNECT stream, at the highest send priority, and sends a FIN. It then waits at most 2 s for the peer to close its side of that stream. Only after that does it close the QUIC connection, with `H3_NO_ERROR`. §6 asks for this wait so that 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 the close**, this end opens no stream and sends no datagram (§6). - **A close requested as the last handle is dropped** still goes out. - **`ConnectionError::SessionClosed`** reports a session the peer closed. The release reported that case as `ApplicationClosed`, the same variant it uses for the QUIC connection's close, whose code under HTTP/3 is an HTTP/3 error code, so the two could not be told apart. - **`Connection::closed()`** reports how the session ended, as the driver saw it, instead of how the QUIC connection closed afterwards. - **Examples and dev-dependencies are left out**, so that `cargo test --locked -p wtransport --lib` runs from the workspace. That command runs the upstream's unit tests and the new ones, including the capsule's bytes checked against the draft's layout. The relay's CI job now runs it. - **`wtransport-proto` is not vendored**, because nothing in it changes. ### The relay - **`WtConnection::close`** calls `close_session`. - The code is the MOQT code as a `u32`. Session codes are not put through `wt_to_h3`: that mapping is for stream resets (§4.4), while session codes travel unmapped in the capsule. - **Reading a peer's close.** Connection errors read a peer's `SessionClosed` as `Closed { code, reason, by_peer: true }`. This covers the relay's own client and the relay's sessions alike. As a result, a page's close code now reaches the session's signed summary. - **A QUIC close with the session still open** gives `code: u64::MAX`, the relay's existing "no MOQT code" value. - **Nothing of the session overtakes its close.** A stream the relay drops after asking to close is held until the QUIC connection has closed (`Closing` in the binding, bounded at 10 s). Without this, the stream's FIN or STOP_SENDING would go out at once and reach the page before the capsule. `read_setup` likewise keeps a stream it refuses in `early`, so that stream is also dropped only after the close. ## The browser finding behind the stream hold The initial browser run of this change hung at the bare-grant step. Alice's page had lost its renderer ("Target crashed"), about 10 ms after the relay refused the session. **Reproduction.** I reproduced it on loopback in a small harness, with a page and the relay binary in the host's browser. The page opened a stream, and the relay refused that stream and dropped it before writing the capsule. The page lost its renderer in most runs; a page that sent nothing never did. **Fix.** With the relay holding the streams until the QUIC close, the same harness passed 6 of 6, and both full browser runs pass. In a second probe the relay closed every page at a 7 s session limit while all of them were publishing. Every page read `(0, "the relay's session limit of 7 s")`, and no renderer was lost. **Residual case.** One case still loses the renderer. A page that FINs a stream which the relay refuses within the same instant can still hit it, because the page's own FIN completes after the close. This failure is inside the browser and happens only for a page that breaks a rule. The relay cannot order the page's own stream events. ## Design decisions 1. **Wait for the peer's side of the CONNECT stream.** -16 §6 asks the endpoint to "wait until all CONNECT streams have been closed by the peer" before CONNECTION_CLOSE. The wait is bounded at 2 s. 2. **`reason: &str`, not `&[u8]`.** §6 requires UTF-8, so the type enforces it. The relay passes `from_utf8_lossy` of its reason bytes. 3. **A new error variant, not an overload of `ApplicationClosed`.** The tests rely on this: with one variant, a QUIC close that carries code 2 looks exactly like a session closed with 2, and the old close would pass them. 4. **No per-stream WT_SESSION_GONE resets.** The driver does not track the session's streams, and quinn offers no reset by stream ID. The streams end with the QUIC connection instead. This is enough because the relay serves one session per connection. 5. **Hold streams in the relay's binding, not in the vendored crate.** The relay's binding owns the stream wrappers, so the hold costs nothing on the media path: a stream is boxed only when it is dropped during a close. 6. **CI paths.** The browser and room workflows now also run on changes under `joule-code/joule-code-rs/vendor/**`. 7. **Merged `origin/main`** at `d9a614dd`, which includes #351 (DVR), #358 and #360. The test sets the grant rule's new `seek` field, and the changelog entry sits above DVR's. Both locks were re-validated. ## Checks (on the build host through `rb`, unless marked local; all on the merged tree) | Check | Command | Result | |---|---|---| | relay, every target (DVR's `tests/dvr.rs` included) | `cargo test --locked -p joule-moq-relay --all-targets` | ok, every target | | new relay test, repeated | `--test webtransport_close`, 10 runs | 10 of 10 | | vendored crate's unit tests | `cargo test --locked -p wtransport --lib` | 9 passed (6 upstream, 3 new) | | ingest | `cargo test --locked -p joule-moq-ingest --all-targets` | ok | | room service (own workspace and lock) | `cd crates/joule-moq-room && cargo test --locked --all-targets` | ok | | clippy | `cargo clippy --locked -p joule-moq-relay --all-targets`; `cargo clippy --locked -p wtransport --lib --tests` | no new notes (the two relay notes at `server.rs:167` and `session.rs:311` are on main already) | | locks (local) | `cargo metadata --locked --offline` in both workspaces; `scripts/check-lockfiles.sh` on both | current | | browser e2e, real browser, run A | `BROWSER_BIN=… MOQ_WEB_OUT=…/wt-close-e2e/full1 e2e/run.sh --runs 2` | 2 of 2 runs, 238 of 238 checks ok | | browser e2e, run B | the same, `…/full2` | 2 of 2 runs, 238 of 238 checks ok | | repo checks (local) | check-tests-in-ci, workflow-concurrency, spec-refs, wai-section-refs, no-vanity-metrics, test-public-text-checks, comparative-prose, orphan-sources, check-lockfiles-selftest, pr-touches-paths-selftest | all exit 0 | ## Falsifications All mutations ran on committed code, each reverted after its run. **F1: the old `WtConnection::close`** (the QUIC connection's close). Each check of `tests/webtransport_close.rs` fails on its own; for each run, the checks before it were made non-fatal. - **Bare grant.** The client read `u64::MAX` with "the connection closed without closing the session (HTTP/3 error 0x2): live-rooms §10.5: pop_missing". - **The client's own close.** The relay recorded `[("webtransport", None)]`. - **A session past the count.** The session never opened. - **The time limit.** The client read `u64::MAX` with "(HTTP/3 error 0x0): the relay's session limit of 3 s". **F2: the capsule's code written little-endian.** 3 capsule unit tests fail. **F3: the 1024-byte cut without the character-boundary check.** The truncation test fails. **F4: the old `Connection::closed()`.** The client read `(0, "closed by this endpoint", by_peer: false)`. **F5a: the binding holds nothing.** The new ordering check fails: "the relay stopped the stream ahead of the session's close: `Ok(Some(0))`". **F5b: `read_setup` drops the refused stream before the close.** The same check fails the same way. **Browser: the old close, `--only webtransport`.** The flipped check FAILs. The page read `{"code":-1,"reason":"Connection lost."}`; 30 of 31 checks were ok. ## What is not done - No per-stream WT_SESSION_GONE resets (decision 4). - The residual browser case above: a page FINning a stream that the relay refuses in the same instant. - The vendored crate now has to be kept current with upstream by hand. `VENDORED.md` lists what to carry forward. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The crate exactly as published (its .crate's SHA-256 is the lock's checksum,
b4273ce3...), with the upstream's MIT and Apache-2.0 licence files from the
commit its .cargo_vcs_info.json names. Patched into the joule-code workspace
and into joule-moq-room, a workspace of its own that builds the relay; both
locks lose only the registry source and checksum. check-lockfiles.sh skips
the vendored crate's own lock, as it does the other vendored tree's.

No change to the crate yet: the next commit adds the session close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Connection::close_session(code, reason) has the driver's worker write one
DATA frame holding a WT_CLOSE_SESSION capsule (draft-ietf-webtrans-http3-16
§6) on the CONNECT stream at the highest send priority, FIN it, and wait at
most two seconds for the peer to close its side before closing the QUIC
connection with H3_NO_ERROR, so the connection's close cannot overtake the
capsule. The reason is cut to 1024 bytes at a character boundary. After it,
this end opens no stream and sends no datagram; a close asked for just as
the last handle drops still goes out.

A session the peer closes is ConnectionError::SessionClosed (the release
reported it as ApplicationClosed, the variant of the QUIC connection's
close, whose code under HTTP/3 is an HTTP/3 error code), and
Connection::closed() reports the session's end as the driver saw it.

Unit tests of the capsule's bytes against the draft's layout. The
upstream's examples and dev-dependencies are left out so the crate's unit
tests run from the workspace (cargo test -p wtransport --lib). VENDORED.md
lists every change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
WtConnection::close closes the WebTransport session (moq-transport-22 §6.6):
the MOQT code is the WT_CLOSE_SESSION capsule's 32-bit application error
code, unmapped, with the reason. closed() reads a peer's capsule as its code
and reason (by_peer); a QUIC close with the session still open gives no
MOQT code (u64::MAX), since its code is HTTP/3's.

tests/webtransport_close.rs: the relay refusing a grant without its proof,
refusing a session past its count, and ending one at its time limit, each
read by the client as the exact code and reason; a client's close recorded
as the peer's in the relay's summary. The browser e2e check of a bare grant
now requires code 2 and the reason over WebTransport too.

CI: the vendored crate's unit tests in the relay job; the browser and room
workflows also run on a change under vendor/. README and CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A stream dropped once the relay has asked to close a WebTransport session
would send its FIN or STOP_SENDING at once, ahead of the WT_CLOSE_SESSION
capsule. In a browser on the build host, a page whose stream was stopped
just before the close lost its renderer (reproduced on loopback: a page
writing a stream the relay refused crashed in most runs). The binding now
keeps such streams (WtSend, WtRecv hold the connection's Closing) until the
QUIC connection has closed, at most ten seconds, when they end with nothing
more on the wire; read_setup keeps the stream it refuses in `early`, so it
too is dropped after the close.

tests/webtransport_close.rs: a stream that breaks a rule before SETUP is
refused with PROTOCOL_VIOLATION, and the client reads the close and no
STOP_SENDING ahead of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
joule-moq-relay README and the changelog: a closing session's streams are held until its connection closes
Some checks failed
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 53s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 1m24s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 7s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 1m36s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 4m2s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 8m7s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 6s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 5s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 2m52s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 59s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Failing after 6m6s
1eb03e0513
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!365
No description provided.