-
-
Notifications
You must be signed in to change notification settings - Fork 248
fix(tokio): WebSocket upgrade edge cases #4296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: quest/m1/transport-upgrade/README
Are you sure you want to change the base?
Changes from all commits
abc5f71
54e1787
cacaf25
3d38d33
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # [S] Fall back to QUIC when the WebSocket session closes at once | ||
|
|
||
| ## Goal | ||
|
|
||
| When WebSocket wins the dial race and its session closes right after | ||
| connecting, the `Connection` moves onto the QUIC dial that is still pending | ||
| instead of dropping it and redialing. On moq-lite and IETF draft 17+ the | ||
| client handshake does not wait for the server, so a server refusing the | ||
| WebSocket session looks like this rather than like a handshake failure, which | ||
| already falls back. | ||
|
|
||
| ## Plan | ||
|
|
||
| - Today `run_session` in `moq-tokio`'s `connection.rs` returns when the | ||
| WebSocket session closes, and the pending upgrade is dropped. The redial | ||
| starts a fresh QUIC dial and has lost QUIC's head start. | ||
| - Decide what counts as "at once". The unhealthy-session threshold that | ||
| already feeds the backoff may be the right line. Keep the connect deadline | ||
| that bounds the pending dial as the only deadline. | ||
| - An auth close on the WebSocket session is probably still terminal, the way | ||
| it is for a lone session. Check how the race treats a mixed auth pair and | ||
| match it. | ||
| - Test with the held-QUIC forwarder in the connection tests, as the handshake | ||
| fallback test does, with a default moq-lite client. | ||
|
|
||
| Public API: none. Wire: none. | ||
|
|
||
| ## Related | ||
|
|
||
| - [#4296](https://github.com/moq-dev/moq/pull/4296) - the handshake-failure fallback this extends |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # [XS] Clean finish in @moq/qmux | ||
|
|
||
| ## Goal | ||
|
|
||
| `@moq/qmux` reports a send stream that finished cleanly as closed without | ||
| error, so `@moq/net` does not treat a delivered GOAWAY or SETUP as a failure | ||
| over the WebSocket fallback. | ||
|
|
||
| ## Plan | ||
|
|
||
| The Rust qmux crate reported every finished stream as `connection closed` | ||
| (fixed in moq-dev/web-transport#402). Check whether the TypeScript peer in | ||
| `moq-dev/web-transport` (`js/qmux`) has the same bug, and whether `@moq/net` | ||
| waits on a finished writable anywhere it would notice. If it does, fix it | ||
| there with a regression test, release, and bump `@moq/qmux` in `js/net`. If | ||
| not, note that in the PR that deletes this quest. | ||
|
|
||
| Public API: none. Wire: none. | ||
|
|
||
| ## Related | ||
|
|
||
| - [JavaScript upgrade](/quest/m1/transport-upgrade/js.md) - sends a GOAWAY over the WebSocket session on every upgrade |
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -443,7 +443,8 @@ impl Client { | |
| /// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins. | ||
| /// | ||
| /// When WebSocket wins while QUIC is still dialing, the QUIC dial carries on as | ||
| /// [`Dialed::upgrade`] rather than being dropped. | ||
| /// [`Dialed::upgrade`] rather than being dropped, and the attempt falls back to it | ||
| /// if the MoQ handshake over WebSocket fails. | ||
| /// | ||
| /// `moq` is the QUIC-side builder, which carries the SETUP path for a raw QUIC dial. | ||
| /// The WebSocket fallback uses the plain builder: qmux over WebSocket carries the | ||
|
|
@@ -462,7 +463,8 @@ impl Client { | |
| Q: Future<Output = crate::Result<S>> + Unpin + Send + 'static, | ||
| S: moq_net::transport::poll::Boxable, | ||
| { | ||
| let transport = quic_transport(addr.url()); | ||
| let url = addr.url().clone(); | ||
| let transport = quic_transport(&url); | ||
| let alpns = self.versions.alpns(); | ||
| let ws_config = self.websocket.clone(); | ||
| let ws_tls = self.tls.clone(); | ||
|
|
@@ -476,7 +478,27 @@ impl Client { | |
| match race_transport_connect(quic, websocket).await? { | ||
| TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic).await?, transport)), | ||
| TransportRace::WebSocket { session, quic } => { | ||
| let session = connect_session(&self.moq, crate::transport::Session::new(session)).await?; | ||
| let session = match connect_session(&self.moq, crate::transport::Session::new(session)).await { | ||
| Ok(session) => session, | ||
| Err(err) => { | ||
| // 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) }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a non-auth QUIC failure finishes before the WebSocket transport connects, Useful? React with 👍 / 👎. |
||
| tracing::warn!(%err, "WebSocket handshake failed; waiting on QUIC"); | ||
| let quic = match quic.await { | ||
| Ok(quic) => quic, | ||
| Err(quic) => return Err(race_error(quic, err)), | ||
| }; | ||
| // UDP gets through after all, so the next dial gives QUIC its head start. | ||
| crate::websocket::forget(&url); | ||
| // Both handshakes failing is still a two-arm loss: a mixed auth pair stays retryable. | ||
| let session = connect_session(moq, quic) | ||
| .await | ||
| .map_err(|quic| race_error(quic.into(), err))?; | ||
| return Ok(Dialed::new(session, transport)); | ||
| } | ||
| }; | ||
| let mut dialed = Dialed::new(session, crate::Transport::WebSocket); | ||
| dialed.upgrade = quic.map(|quic| { | ||
| let moq = moq.clone(); | ||
|
|
@@ -719,23 +741,30 @@ where | |
| } | ||
| } | ||
|
|
||
| // Auth is terminal only when both arms refused. A WebTransport-only endpoint | ||
| // answers the fallback with 403 while QUIC is still in flight, and reconnect | ||
| // treats is_auth() as terminal, so a mixed pair reports the retryable error. | ||
| match (quic_err, websocket_err) { | ||
| (Some(quic), Some(websocket)) => Err(match (quic.is_auth(), websocket.is_auth()) { | ||
| (false, false) => Error::TransportRace { | ||
| quic: std::sync::Arc::new(quic), | ||
| websocket: std::sync::Arc::new(websocket), | ||
| }, | ||
| (true, false) => websocket, | ||
| _ => quic, | ||
| }), | ||
| (Some(quic), Some(websocket)) => Err(race_error(quic, websocket)), | ||
| (Some(err), None) | (None, Some(err)) => Err(err), | ||
| (None, None) => Err(Error::ConnectFailed), | ||
| } | ||
| } | ||
|
|
||
| /// The error for a race both arms lost. | ||
| /// | ||
| /// Auth is terminal only when both arms refused. A WebTransport-only endpoint | ||
| /// answers the fallback with 403 while QUIC is still in flight, and reconnect | ||
| /// treats is_auth() as terminal, so a mixed pair reports the retryable error. | ||
| #[cfg(all(feature = "websocket", feature = "noq"))] | ||
| fn race_error(quic: Error, websocket: Error) -> Error { | ||
| match (quic.is_auth(), websocket.is_auth()) { | ||
| (false, false) => Error::TransportRace { | ||
| quic: std::sync::Arc::new(quic), | ||
| websocket: std::sync::Arc::new(websocket), | ||
| }, | ||
| (true, false) => websocket, | ||
| _ => quic, | ||
| } | ||
| } | ||
|
|
||
| #[cfg(any( | ||
| feature = "noq", | ||
| feature = "iroh", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removing the Rust edge-case quest leaves its GOAWAY-warning goal unresolved: the reviewed tree still pins
qmux = 0.5.1inCargo.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 falsefailed to send goawaywarning fromrs/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 👍 / 👎.
There was a problem hiding this comment.
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)