Skip to content

fix(tokio): WebSocket upgrade edge cases - #4296

Draft
kixelated wants to merge 4 commits into
quest/m1/transport-upgrade/READMEfrom
quest/m1/transport-upgrade/polish
Draft

kixelated wants to merge 4 commits into
quest/m1/transport-upgrade/READMEfrom
quest/m1/transport-upgrade/polish

Conversation

@kixelated

@kixelated kixelated commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Completes /quest/m1/transport-upgrade/polish.md on the transport-upgrade line (#4180).

Draft until qmux 0.6.0 publishes. The GOAWAY fix ships in the next web-transport release from main (web-transport-trait 0.5, qmux 0.6.0, with web-transport#399 and #401). This PR then bumps qmux to 0.6. That is fine here because the line targets dev.

Problem

  • Every WebSocket-to-QUIC upgrade logs WARN failed to send goaway: transport: connection closed, although the server receives the GOAWAY.
  • When WebSocket wins the race but its MoQ handshake fails, the attempt fails and drops the QUIC dial that was still pending.

Approach

  • GOAWAY warning. Root cause is in qmux 0.5.1: SendStream::closed() returns Err(Closed) for every cleanly finished stream, because retiring the stream after its FIN drops the channel closed() waits on. moq-net's GOAWAY sender waits on closed() after the FIN, so it warns every time; the lite SETUP stream hits the same error at debug. Fixed upstream in fix(qmux): report a finished send stream as closed cleanly web-transport#402 with a qmux regression test. Against a patched qmux 0.5.1, websocket_upgrades_to_quic logs neither warning. Not in this PR yet: the qmux 0.6 bump, which waits on that release.
  • Handshake fallback. race_moq_connect now falls back to the pending QUIC dial when the MoQ handshake over WebSocket fails. The existing connect deadline still bounds that dial. If QUIC also fails, the error combines the same way as when both race arms fail (race_error, pulled out of race_transport_connect). A QUIC dial that lands this way also clears the "WebSocket won" memo, like a successful upgrade (confirmed by the maintainer).
  • Test. failed_websocket_handshake_falls_back_to_quic reuses the held-QUIC forwarder from feat(tokio)!: upgrade a WebSocket fallback session to WebTransport #4189. A fake WebSocket endpoint accepts the client's SETUP stream and closes the session, and only then lets QUIC through. The test is one-shot so a redial can't hide the failure, and it uses draft 16, the newest version whose client handshake waits for the server's SETUP. It fails without the fix (Transport("connection closed")) and passed 15 of 15 stress runs. Fallback::start now takes a Quic mode enum instead of an open: bool.

Impact

  • Public API: none.
  • Wire: none.
  • Behavior: a Connection whose WebSocket handshake fails now connects over QUIC if that dial lands before the connect deadline, instead of failing the attempt.

Alternatives

  • Treat qmux's Err(Closed) as success in moq-net after the FIN. Rejected: that works around a bug in the dependency.
  • Also adopt the pending QUIC dial when the WebSocket session closes right after connecting. On moq-lite and IETF draft 17+, the client handshake does not wait for the server, so a server-side refusal shows up that way rather than as a handshake error. Today the dial is dropped and the redial races again. Left as a follow-up.

Follow-ups

  • Bump qmux to 0.6 once web-transport's next release publishes (this PR).
  • New quests on this line:
    • Closed fallback: adopt the pending QUIC dial when a freshly connected WebSocket session closes at once (moq-lite and draft 17+).
    • JS qmux finish: check that @moq/qmux reports a finished writable stream as clean.

(Written by Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits September 26, 2026 13:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dshake fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ow-ups

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review September 27, 2026 00:45
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-27T01:33:49.190364Z 3d38d33 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: cacaf25ce4

ℹ️ 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 +62 to +63
- [Closed fallback](/quest/m1/transport-upgrade/closed-fallback.md) - a WebSocket session that closes right after connecting falls back to the pending QUIC dial instead of redialing
- [JS qmux finish](/quest/m1/transport-upgrade/js-qmux-finish.md) - `@moq/qmux` reports a cleanly finished send stream as closed without 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.

P2 Badge Consume the qmux fix before completing the quest

Removing the Rust edge-case quest leaves its GOAWAY-warning goal unresolved: the reviewed tree still pins qmux = 0.5.1 in Cargo.toml, while the commit description states that the clean-finish fix will only arrive with qmux 0.6. Consequently every WebSocket-to-QUIC upgrade can still emit the false failed to send goaway warning from rs/moq-net/src/lite/session.rs; keep this quest open or include the dependency update and regression coverage.

AGENTS.md reference: AGENTS.md:L16-L18

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. qmux 0.6.0 is not published yet (moq-dev/web-transport#402 is still open), so this PR is blocked on that release and will not merge until the qmux bump lands here.

(Written by Claude Opus 5.5)

Comment thread rs/moq-tokio/src/client.rs Outdated
let quic = quic.await.map_err(|quic| race_error(quic, err))?;
// UDP gets through after all, so the next dial gives QUIC its head start.
crate::websocket::forget(&url);
return Ok(Dialed::new(connect_session(moq, quic).await?, transport));

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 Preserve both handshake errors before classifying the failure

When the WebSocket MoQ handshake fails, the pending QUIC transport connects, and the subsequent QUIC MoQ handshake also fails, this expression returns only the QUIC handshake error and discards err. In a mixed failure such as a transient WebSocket error plus a QUIC Unauthorized, the reconnect loop therefore treats the result as terminal even though race_error explicitly keeps mixed auth/non-auth races retryable; route this second handshake failure through the same two-arm error classification.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, fixed in 3d38d33: a failed QUIC handshake after a failed WebSocket handshake now goes through race_error, so a mixed auth pair stays retryable.

(Written by Claude Opus 5.5)

When the WebSocket handshake fails and the fallback QUIC handshake also
fails, combine both errors through race_error so a mixed auth pair stays
retryable instead of returning only the QUIC error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as draft September 27, 2026 01:31

@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: 3d38d33589

ℹ️ 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".

// The fallback got through but its MoQ handshake did not. A QUIC dial still
// pending may yet connect, bounded by the same deadline as the race.
let err = Error::from(err);
let Some(quic) = quic else { return Err(err) };

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 Preserve a QUIC error that predates WebSocket success

When a non-auth QUIC failure finishes before the WebSocket transport connects, race_transport_connect returns TransportRace::WebSocket { quic: None } and discards quic_err; if the subsequent WebSocket MoQ handshake fails with Unauthorized, this branch returns that auth error alone, causing dial_any and the reconnect loop to stop even though race_error explicitly classifies a mixed non-auth/auth loss as retryable. Preserve the completed QUIC error in TransportRace and combine it here. This is fresh evidence beyond the earlier double-handshake finding because the QUIC arm has already failed before WebSocket wins, rather than failing during the later fallback handshake.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
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