diff --git a/src/lib.rs b/src/lib.rs index 62b989a..84bf5ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,24 @@ pub enum Input { /// Connection attempt result ConnectionResult { id: Id, result: ConnectionResult }, + + /// HTTP settings received on a connection (HTTP/2 SETTINGS frame, HTTP/3 + /// SETTINGS). + /// + /// Must be reported after [`ConnectionResult::Success`] for HTTP/2 and + /// HTTP/3 connections when [`Protocol::WebSocket`] or + /// [`Protocol::WebTransport`] is requested. + /// + /// Not applicable for HTTP/1.1 connections (no settings exchange). + HttpSettings { id: Id, settings: HttpSettings }, + + /// Result of a WebSocket opening handshake or WebTransport session + /// establishment, reported after the state machine emits + /// [`Output::EstablishProtocolSession`]. + ProtocolSessionResult { + id: Id, + result: ProtocolSessionResult, + }, } /// An ECH (Encrypted Client Hello) configuration. @@ -104,11 +122,98 @@ impl AsRef<[u8]> for EchConfig { } } +/// HTTP settings received from the server on an HTTP/2 or HTTP/3 connection. +/// +/// The state machine uses these to decide whether the server supports the +/// requested protocol (WebSocket or WebTransport) before emitting +/// [`Output::EstablishProtocolSession`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpSettings { + /// Whether the server advertised `SETTINGS_ENABLE_CONNECT_PROTOCOL` (0x08). + /// + /// Required for WebSocket over HTTP/2 ([RFC 8441]) and HTTP/3 ([RFC 9220]), + /// and for WebTransport. + /// + /// [RFC 8441]: https://datatracker.ietf.org/doc/html/rfc8441 + /// [RFC 9220]: https://datatracker.ietf.org/doc/html/rfc9220 + pub extended_connect_supported: bool, + + /// Whether the server supports WebTransport. + /// + /// For HTTP/3: `SETTINGS_WT_ENABLED` is non-zero. + /// For HTTP/2: `SETTINGS_WT_MAX_SESSIONS` is greater than zero. + pub webtransport_supported: bool, +} + +/// Result of a WebSocket opening handshake or WebTransport session +/// establishment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolSessionResult { + /// The protocol session was established successfully. + /// + /// For WebSocket: received 101 (HTTP/1.1) or 200 (HTTP/2, HTTP/3). + /// For WebTransport: received 2xx response to Extended CONNECT. + Success, + + /// The protocol session could not be established. + /// + /// The server rejected the WebSocket upgrade / Extended CONNECT request, + /// or the handshake failed for another reason. + Failure, +} + +/// The kind of session to establish on top of the transport connection. +/// +/// Specified at construction time via [`NetworkConfig::session`]. When set, +/// the state machine drives a multi-phase connection flow: +/// +/// 1. Transport connection (TCP+TLS or QUIC) +/// 2. HTTP settings discovery (HTTP/2, HTTP/3 only) +/// 3. Protocol session establishment (opening handshake / Extended CONNECT) +/// +/// When [`NetworkConfig::session`] is `None` (the default), the state machine +/// reports success as soon as the transport connection is established (plain +/// HTTP behavior). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Session { + /// WebSocket ([RFC 6455], [RFC 8441], [RFC 9220]). + /// + /// - Over HTTP/1.1: opening handshake via HTTP Upgrade. + /// - Over HTTP/2: Extended CONNECT with `:protocol = websocket` + /// (requires `SETTINGS_ENABLE_CONNECT_PROTOCOL`). + /// - Over HTTP/3: Extended CONNECT with `:protocol = websocket` + /// (requires `SETTINGS_ENABLE_CONNECT_PROTOCOL`). + /// + /// [RFC 6455]: https://datatracker.ietf.org/doc/html/rfc6455 + /// [RFC 8441]: https://datatracker.ietf.org/doc/html/rfc8441 + /// [RFC 9220]: https://datatracker.ietf.org/doc/html/rfc9220 + WebSocket, + + /// WebTransport ([draft-ietf-webtrans-http3], [draft-ietf-webtrans-http2]). + /// + /// - Over HTTP/3: Extended CONNECT with `:protocol = webtransport-h3` + /// (requires `SETTINGS_ENABLE_CONNECT_PROTOCOL` and `SETTINGS_WT_ENABLED`). + /// - Over HTTP/2: Extended CONNECT with `:protocol = webtransport` + /// (requires `SETTINGS_ENABLE_CONNECT_PROTOCOL` and + /// `SETTINGS_WT_MAX_SESSIONS > 0`). + /// - HTTP/1.1 is not supported; H1 endpoints are automatically excluded. + /// + /// [draft-ietf-webtrans-http3]: https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http3 + /// [draft-ietf-webtrans-http2]: https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http2 + WebTransport, +} + /// Result of a connection attempt. #[derive(Debug, Clone, PartialEq)] pub enum ConnectionResult { /// Connection succeeded. - Success, + /// + /// `http_version` is the HTTP version actually negotiated on this + /// connection (via TLS ALPN or equivalent). For + /// [`ConnectionAttemptHttpVersions::H2OrH1`] attempts this tells the state + /// machine which version was selected; for single-version attempts it + /// should match the attempt's version. + Success { http_version: HttpVersion }, /// Connection failed. Failure(String), /// The server rejected ECH but provided `retry_configs` (per [RFC 9849 @@ -225,6 +330,36 @@ pub enum Output { /// Cancel a connection attempt CancelConnection { id: Id }, + /// Establish a WebSocket or WebTransport session on this connection. + /// + /// The `id` matches the one from the earlier [`Output::AttemptConnection`] + /// that created this connection. The caller should use this to identify + /// which transport connection to perform the handshake on. + /// + /// The caller should perform the protocol-specific handshake based on + /// `session` and the connection's HTTP version: + /// + /// - **[`Session::WebSocket`] over HTTP/1.1**: send the opening handshake + /// ([RFC 6455]). + /// - **[`Session::WebSocket`] over HTTP/2 or HTTP/3**: send an Extended + /// CONNECT request with `:protocol = websocket` + /// ([RFC 8441], [RFC 9220]). + /// - **[`Session::WebTransport`] over HTTP/3**: send an Extended CONNECT + /// request with `:protocol = webtransport-h3` + /// ([draft-ietf-webtrans-http3]). + /// - **[`Session::WebTransport`] over HTTP/2**: send an Extended CONNECT + /// request with `:protocol = webtransport` + /// ([draft-ietf-webtrans-http2]). + /// + /// Report the result via [`Input::ProtocolSessionResult`]. + /// + /// [RFC 6455]: https://datatracker.ietf.org/doc/html/rfc6455 + /// [RFC 8441]: https://datatracker.ietf.org/doc/html/rfc8441 + /// [RFC 9220]: https://datatracker.ietf.org/doc/html/rfc9220 + /// [draft-ietf-webtrans-http3]: https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http3 + /// [draft-ietf-webtrans-http2]: https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http2 + EstablishProtocolSession { id: Id, session: Session }, + /// Connection attempt succeeded Succeeded, @@ -240,6 +375,13 @@ pub enum FailureReason { DnsResolution, /// All connection attempts failed. Connection, + /// At least one transport connection succeeded, but the server does not + /// support the requested protocol (WebSocket or WebTransport). + /// + /// This means the server's HTTP settings did not advertise the required + /// capabilities (e.g. `SETTINGS_ENABLE_CONNECT_PROTOCOL`) or the protocol + /// session establishment was rejected on every connection. + ProtocolNotSupported, } impl Output { @@ -526,19 +668,18 @@ pub struct AltSvc { // see whether the remote supports HTTP/3? Should it first do MASQUE connect-udp // and HTTP/3 and then HTTP CONNECT with HTTP/2? // -// TODO: Should we make HappyEyeballs aware of whether this is a WebSocket -// connection? That way we could e.g. track EXTENDED CONNECT support, or -// fallback to a different connection in case WebSocket doesn't work? Likely for -// v2 of the project. -// -// TODO: Should we make HappyEyeballs aware of whether this is a WebTransport -// connection? That way we could e.g. track EXTENDED CONNECT support, or -// fallback to a different connection in case WebTransport doesn't work? Likely -// for v2 of the project. -// /// Network configuration for Happy Eyeballs behavior #[derive(Debug, Clone)] pub struct NetworkConfig { + /// The session to establish on top of the transport connection. + /// + /// `None` (the default) means plain HTTP — the state machine reports + /// success as soon as the transport connection is established. + /// + /// When set to [`Some(Session::WebSocket)`] or + /// [`Some(Session::WebTransport)`], the state machine drives a multi-phase + /// flow: transport → settings discovery → session establishment. + pub session: Option, /// Supported HTTP versions pub http_versions: HttpVersions, /// IP connectivity and preference @@ -569,6 +710,7 @@ pub struct NetworkConfig { impl Default for NetworkConfig { fn default() -> Self { NetworkConfig { + session: None, http_versions: HttpVersions::default(), ip: IpPreference::DualStackPreferV6, alt_svc: Vec::new(), @@ -605,7 +747,15 @@ impl NetworkConfig { #[derive(Debug, Clone, PartialEq)] pub enum ConnectionState { + /// Transport connection in progress (TCP+TLS handshake or QUIC handshake). InProgress, + /// Transport connection established, waiting for HTTP settings or session + /// establishment. Only used when [`NetworkConfig::session`] is `Some`. + Connected { http_version: HttpVersion }, + /// Session establishment in progress — the state machine has emitted + /// [`Output::EstablishProtocolSession`] and is waiting for + /// [`Input::ProtocolSessionResult`]. + SessionEstablishment, Succeeded, Failed, Cancelled, @@ -621,6 +771,12 @@ pub struct ConnectionAttempt { /// Per RFC 9849 Section 6.1.6, a second EchRetry on such an attempt /// must be treated as a failure. pub is_ech_retry: bool, + /// HTTP settings received from the server (HTTP/2 or HTTP/3 only). + pub settings: Option, + /// Whether this connection failed because the server does not support the + /// requested session (WebSocket or WebTransport). Tracked separately to + /// report [`FailureReason::ProtocolNotSupported`]. + pub protocol_not_supported: bool, } impl ConnectionAttempt { @@ -786,6 +942,12 @@ impl HappyEyeballs { Input::ConnectionResult { id, result } => { self.on_connection_result(id, result); } + Input::HttpSettings { id, settings } => { + self.on_http_settings(id, settings); + } + Input::ProtocolSessionResult { id, result } => { + self.on_protocol_session_result(id, result); + } } } @@ -809,6 +971,11 @@ impl HappyEyeballs { return Some(o); } + // Establish protocol sessions on eligible connections. + if let Some(o) = self.establish_protocol_session() { + return Some(o); + } + // Attempt connections. if let Some(o) = self.connection_attempt(now) { return Some(o); @@ -1006,7 +1173,10 @@ impl HappyEyeballs { log::debug!("ignoring connection result for cancelled attempt {id:?}: {result:?}"); return; } - ConnectionState::Succeeded | ConnectionState::Failed => { + ConnectionState::Succeeded + | ConnectionState::Failed + | ConnectionState::Connected { .. } + | ConnectionState::SessionEstablishment => { debug_assert!( false, "got connection result but attempt is in unexpected state: {attempt:?}" @@ -1016,8 +1186,18 @@ impl HappyEyeballs { } match result { - ConnectionResult::Success => { - attempt.state = ConnectionState::Succeeded; + ConnectionResult::Success { http_version } => { + match self.network_config.session { + None => { + // Plain HTTP: transport success = overall success. + attempt.state = ConnectionState::Succeeded; + } + Some(_) => { + // WebSocket / WebTransport: enter Connected phase, + // wait for settings (H2/H3) or proceed directly (H1). + attempt.state = ConnectionState::Connected { http_version }; + } + } // Cancellations will be issued by cancel_remaining_attempts() } ConnectionResult::Failure(_error) => { @@ -1053,28 +1233,163 @@ impl HappyEyeballs { } } - /// If a connection has succeeded, cancel all remaining in-progress attempts. + fn on_http_settings(&mut self, id: Id, settings: HttpSettings) { + let Some(attempt) = self.connection_attempts.iter_mut().find(|a| a.id == id) else { + debug_assert!(false, "got HttpSettings for unknown id {id:?}"); + return; + }; + + match attempt.state { + ConnectionState::Connected { .. } => {} + ConnectionState::Cancelled => { + log::debug!("ignoring HttpSettings for cancelled attempt {id:?}"); + return; + } + _ => { + debug_assert!( + false, + "got HttpSettings but attempt is in unexpected state: {attempt:?}" + ); + return; + } + } + + attempt.settings = Some(settings); + // The state machine will check eligibility for session establishment + // in the next process_output() call. + } + + fn on_protocol_session_result(&mut self, id: Id, result: ProtocolSessionResult) { + let Some(attempt) = self.connection_attempts.iter_mut().find(|a| a.id == id) else { + debug_assert!(false, "got ProtocolSessionResult for unknown id {id:?}"); + return; + }; + + match attempt.state { + ConnectionState::SessionEstablishment => {} + ConnectionState::Cancelled => { + log::debug!("ignoring ProtocolSessionResult for cancelled attempt {id:?}"); + return; + } + _ => { + debug_assert!( + false, + "got ProtocolSessionResult but attempt is in unexpected state: {attempt:?}" + ); + return; + } + } + + match result { + ProtocolSessionResult::Success => { + attempt.state = ConnectionState::Succeeded; + } + ProtocolSessionResult::Failure => { + attempt.state = ConnectionState::Failed; + attempt.protocol_not_supported = true; + } + } + } + + /// If a connection has succeeded, cancel all remaining non-terminal attempts. + /// + /// Cancels connections in any active state: `InProgress`, `Connected`, or + /// `SessionEstablishment`. fn cancel_remaining_attempts(&mut self) -> Option { - // Check if we have a successful connection if !self.has_successful_connection() { return None; } - // Find the first in-progress attempt to cancel - if let Some(attempt) = self - .connection_attempts - .iter_mut() - .find(|a| a.state == ConnectionState::InProgress) - { + if let Some(attempt) = self.connection_attempts.iter_mut().find(|a| { + matches!( + a.state, + ConnectionState::InProgress + | ConnectionState::Connected { .. } + | ConnectionState::SessionEstablishment + ) + }) { let id = attempt.id; attempt.state = ConnectionState::Cancelled; return Some(Output::CancelConnection { id }); } - // All connections have been canceled, return Succeeded + // All non-terminal attempts have been canceled, return Succeeded Some(Output::Succeeded) } + /// Check connected attempts for session establishment eligibility. + /// + /// For each connection in the `Connected` state, determines whether the + /// server supports the requested session based on the HTTP version and + /// received settings: + /// + /// - **HTTP/1.1 + WebSocket**: eligible immediately (no settings needed). + /// - **HTTP/1.1 + WebTransport**: not possible — marked as failed. + /// - **HTTP/2 or HTTP/3**: eligible once [`HttpSettings`] arrives and + /// confirms the required capabilities. + fn establish_protocol_session(&mut self) -> Option { + let session = self.network_config.session?; + + for i in 0..self.connection_attempts.len() { + let attempt = &self.connection_attempts[i]; + + let http_version = match attempt.state { + ConnectionState::Connected { http_version } => http_version, + _ => continue, + }; + + match http_version { + HttpVersion::H1 => { + match session { + Session::WebSocket => { + // H1 WebSocket: no settings needed, go directly + // to session establishment. + self.connection_attempts[i].state = + ConnectionState::SessionEstablishment; + let id = self.connection_attempts[i].id; + return Some(Output::EstablishProtocolSession { id, session }); + } + Session::WebTransport => { + // WebTransport is not possible over HTTP/1.1. + self.connection_attempts[i].state = ConnectionState::Failed; + self.connection_attempts[i].protocol_not_supported = true; + continue; + } + } + } + HttpVersion::H2 | HttpVersion::H3 => { + let Some(settings) = &self.connection_attempts[i].settings else { + // Settings not received yet — keep waiting. + continue; + }; + + let supported = match session { + Session::WebSocket => settings.extended_connect_supported, + Session::WebTransport => { + settings.extended_connect_supported + && settings.webtransport_supported + } + }; + + if supported { + self.connection_attempts[i].state = + ConnectionState::SessionEstablishment; + let id = self.connection_attempts[i].id; + return Some(Output::EstablishProtocolSession { id, session }); + } else { + // Settings received but server doesn't support the + // requested session. + self.connection_attempts[i].state = ConnectionState::Failed; + self.connection_attempts[i].protocol_not_supported = true; + continue; + } + } + } + } + + None + } + /// > The client moves onto sorting addresses and establishing connections /// > once one of the following condition sets is met: /// > @@ -1125,6 +1440,8 @@ impl HappyEyeballs { started: now, state: ConnectionState::InProgress, is_ech_retry: false, + settings: None, + protocol_not_supported: false, }); Some(Output::AttemptConnection { id, endpoint }) @@ -1152,6 +1469,8 @@ impl HappyEyeballs { started: now, state: ConnectionState::InProgress, is_ech_retry: true, + settings: None, + protocol_not_supported: false, }); Some(Output::AttemptConnection { id, endpoint }) @@ -1268,16 +1587,26 @@ impl HappyEyeballs { fn failed(&self) -> Option { if self.has_successful_connection() || self.dns_queries.iter().any(|q| !q.is_completed()) - || self - .connection_attempts - .iter() - .any(|a| a.state == ConnectionState::InProgress) + || self.connection_attempts.iter().any(|a| { + matches!( + a.state, + ConnectionState::InProgress + | ConnectionState::Connected { .. } + | ConnectionState::SessionEstablishment + ) + }) { return None; } Some( if self + .connection_attempts + .iter() + .any(|a| a.protocol_not_supported) + { + FailureReason::ProtocolNotSupported + } else if self .connection_attempts .iter() .any(|a| a.state == ConnectionState::Failed) @@ -1392,6 +1721,17 @@ impl HappyEyeballs { if !self.network_config.http_versions.h1 { http_versions.remove(&HttpVersion::H1); } + // WebTransport is not possible over HTTP/1.1. If the caller enabled + // H1 with a WebTransport session, that's a configuration error. + if self.network_config.session == Some(Session::WebTransport) + && http_versions.contains(&HttpVersion::H1) + { + debug_assert!( + false, + "HTTP/1.1 is enabled but WebTransport is not possible over HTTP/1.1" + ); + http_versions.remove(&HttpVersion::H1); + } } /// Whether to move on to the connection attempt phase based on the received diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e4fc75f..365a0aa 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -8,8 +8,8 @@ use std::{ use happy_eyeballs::{ CONNECTION_ATTEMPT_DELAY, ConnectionAttemptHttpVersions, ConnectionResult, DnsRecordType, - DnsResult, EchConfig, Endpoint, HappyEyeballs, HttpVersion, Id, Input, NetworkConfig, Output, - RESOLUTION_DELAY, ServiceInfo, + DnsResult, EchConfig, Endpoint, HappyEyeballs, HttpSettings, HttpVersion, Id, Input, + NetworkConfig, Output, ProtocolSessionResult, RESOLUTION_DELAY, ServiceInfo, Session, }; pub const HOSTNAME: &str = "example.com"; @@ -182,9 +182,13 @@ pub fn in_dns_a_negative(id: Id) -> Input { } pub fn in_connection_result_positive(id: Id) -> Input { + in_connection_result_positive_with_version(id, HttpVersion::H2) +} + +pub fn in_connection_result_positive_with_version(id: Id, http_version: HttpVersion) -> Input { Input::ConnectionResult { id, - result: ConnectionResult::Success, + result: ConnectionResult::Success { http_version }, } } @@ -372,6 +376,147 @@ pub fn out_connection_attempt_delay() -> Output { } } +pub fn in_http_settings(id: Id, extended_connect: bool, webtransport: bool) -> Input { + Input::HttpSettings { + id, + settings: HttpSettings { + extended_connect_supported: extended_connect, + webtransport_supported: webtransport, + }, + } +} + +pub fn in_http_settings_extended_connect(id: Id) -> Input { + in_http_settings(id, true, false) +} + +pub fn in_http_settings_webtransport(id: Id) -> Input { + in_http_settings(id, true, true) +} + +pub fn in_http_settings_no_extended_connect(id: Id) -> Input { + in_http_settings(id, false, false) +} + +pub fn in_session_result_success(id: Id) -> Input { + Input::ProtocolSessionResult { + id, + result: ProtocolSessionResult::Success, + } +} + +pub fn in_session_result_failure(id: Id) -> Input { + Input::ProtocolSessionResult { + id, + result: ProtocolSessionResult::Failure, + } +} + +pub fn out_establish_websocket(id: Id) -> Output { + Output::EstablishProtocolSession { + id, + session: Session::WebSocket, + } +} + +pub fn out_establish_webtransport(id: Id) -> Output { + Output::EstablishProtocolSession { + id, + session: Session::WebTransport, + } +} + +/// Send DNS queries, provide HTTPS negative + AAAA positive + A negative, +/// arrive at the first connection attempt. +/// +/// Assumes default dual-stack-prefer-v6 with HOSTNAME. +pub fn dns_phase_aaaa_only( + he: &mut HappyEyeballs, + now: Instant, + expected_attempt: Output, +) { + he.expect( + vec![ + (None, Some(out_send_dns_https(Id::from(0)))), + (None, Some(out_send_dns_aaaa(Id::from(1)))), + (None, Some(out_send_dns_a(Id::from(2)))), + ( + Some(in_dns_aaaa_positive(Id::from(1))), + Some(out_resolution_delay()), + ), + ( + Some(in_dns_a_negative(Id::from(2))), + Some(out_resolution_delay()), + ), + ( + Some(in_dns_https_negative(Id::from(0))), + Some(expected_attempt), + ), + ], + now, + ); +} + +/// Send DNS queries, provide HTTPS positive + AAAA positive + A negative. +/// For tests that need HTTPS service records (e.g. H3). +pub fn dns_phase_aaaa_only_https_positive( + he: &mut HappyEyeballs, + now: Instant, + expected_attempt: Output, +) { + he.expect( + vec![ + (None, Some(out_send_dns_https(Id::from(0)))), + (None, Some(out_send_dns_aaaa(Id::from(1)))), + (None, Some(out_send_dns_a(Id::from(2)))), + ( + Some(in_dns_aaaa_positive(Id::from(1))), + Some(out_resolution_delay()), + ), + ( + Some(in_dns_a_negative(Id::from(2))), + Some(out_resolution_delay()), + ), + ( + Some(in_dns_https_positive(Id::from(0))), + Some(expected_attempt), + ), + ], + now, + ); +} + +/// Send DNS queries, provide HTTPS negative + AAAA positive + A positive. +/// Two endpoints available (v6 + v4), expects connection attempt delay after +/// the first attempt. +pub fn dns_phase_aaaa_and_a( + he: &mut HappyEyeballs, + now: Instant, + expected_attempt: Output, +) { + he.expect( + vec![ + (None, Some(out_send_dns_https(Id::from(0)))), + (None, Some(out_send_dns_aaaa(Id::from(1)))), + (None, Some(out_send_dns_a(Id::from(2)))), + ( + Some(in_dns_aaaa_positive(Id::from(1))), + Some(out_resolution_delay()), + ), + ( + Some(in_dns_a_positive(Id::from(2))), + Some(out_resolution_delay()), + ), + ( + Some(in_dns_https_negative(Id::from(0))), + Some(expected_attempt), + ), + (None, Some(out_connection_attempt_delay())), + ], + now, + ); +} + pub fn setup() -> (Instant, HappyEyeballs) { setup_with_config(NetworkConfig::default()) } diff --git a/tests/session.rs b/tests/session.rs new file mode 100644 index 0000000..1e5d9f2 --- /dev/null +++ b/tests/session.rs @@ -0,0 +1,524 @@ +mod common; + +use common::*; +use happy_eyeballs::{ + ConnectionAttemptHttpVersions, FailureReason, HttpVersion, Id, NetworkConfig, Output, Session, + CONNECTION_ATTEMPT_DELAY, +}; + +fn websocket_config() -> NetworkConfig { + NetworkConfig { + session: Some(Session::WebSocket), + ..NetworkConfig::default() + } +} + +/// WebSocket over H1: transport connects → EstablishProtocolSession immediately +/// (no settings needed) → session success → Succeeded. +#[test] +fn websocket_h1_happy_path() { + let config = NetworkConfig { + session: Some(Session::WebSocket), + http_versions: happy_eyeballs::HttpVersions { + h1: true, + h2: false, + h3: false, + }, + ..NetworkConfig::default() + }; + let (now, mut he) = setup_with_config(config); + + dns_phase_aaaa_only( + &mut he, + now, + out_attempt( + Id::from(3), + V6_ADDR.into(), + PORT, + ConnectionAttemptHttpVersions::H1, + ), + ); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H1, + )), + Some(out_establish_websocket(Id::from(3))), + ), + ( + Some(in_session_result_success(Id::from(3))), + Some(Output::Succeeded), + ), + ], + now, + ); +} + +/// WebSocket over H2: transport → wait for settings → establish → succeed. +#[test] +fn websocket_h2_happy_path() { + let config = NetworkConfig { + session: Some(Session::WebSocket), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: true, + h3: false, + }, + ..NetworkConfig::default() + }; + let (now, mut he) = setup_with_config(config); + + dns_phase_aaaa_only(&mut he, now, out_attempt_v6_h2(Id::from(3))); + + he.expect( + vec![( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H2, + )), + None, + )], + now, + ); + + he.expect( + vec![ + ( + Some(in_http_settings_extended_connect(Id::from(3))), + Some(out_establish_websocket(Id::from(3))), + ), + ( + Some(in_session_result_success(Id::from(3))), + Some(Output::Succeeded), + ), + ], + now, + ); +} + +/// WebSocket over H2: settings without Extended CONNECT → tries next endpoint. +#[test] +fn websocket_h2_no_extended_connect() { + let config = NetworkConfig { + session: Some(Session::WebSocket), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: true, + h3: false, + }, + ..NetworkConfig::default() + }; + let (now, mut he) = setup_with_config(config); + + dns_phase_aaaa_and_a(&mut he, now, out_attempt_v6_h2(Id::from(3))); + + // Transport connects → no delay on InProgress connections anymore → + // next endpoint starts immediately. + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H2, + )), + Some(out_attempt_v4_h2(Id::from(4))), + ), + (None, Some(out_connection_attempt_delay())), + ], + now, + ); + + // Settings say no Extended CONNECT → connection 3 fails. Connection 4 + // is already in-progress, so just the delay timer. + he.expect( + vec![( + Some(in_http_settings_no_extended_connect(Id::from(3))), + Some(out_connection_attempt_delay()), + )], + now, + ); +} + +/// WebSocket: all connections lack Extended CONNECT → ProtocolNotSupported. +#[test] +fn websocket_all_protocol_not_supported() { + let config = NetworkConfig { + session: Some(Session::WebSocket), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: true, + h3: false, + }, + ..NetworkConfig::default() + }; + let (now, mut he) = setup_with_config(config); + + dns_phase_aaaa_only(&mut he, now, out_attempt_v6_h2(Id::from(3))); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H2, + )), + None, + ), + ( + Some(in_http_settings_no_extended_connect(Id::from(3))), + Some(Output::Failed(FailureReason::ProtocolNotSupported)), + ), + ], + now, + ); +} + +/// WebSocket session establishment fails → try next endpoint. +#[test] +fn websocket_session_failure_tries_next() { + let config = NetworkConfig { + session: Some(Session::WebSocket), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: true, + h3: false, + }, + ..NetworkConfig::default() + }; + let (now, mut he) = setup_with_config(config); + + dns_phase_aaaa_and_a(&mut he, now, out_attempt_v6_h2(Id::from(3))); + + // Transport success → second endpoint starts (no InProgress delay). + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H2, + )), + Some(out_attempt_v4_h2(Id::from(4))), + ), + (None, Some(out_connection_attempt_delay())), + ( + Some(in_http_settings_extended_connect(Id::from(3))), + Some(out_establish_websocket(Id::from(3))), + ), + ], + now, + ); + + // Session fails on connection 3 — connection 4 already in progress, + // delay timer emitted. + he.expect( + vec![( + Some(in_session_result_failure(Id::from(3))), + Some(out_connection_attempt_delay()), + )], + now, + ); +} + +/// Two connections race. First fully succeeds → cancel the second. +#[test] +fn websocket_racing_first_wins() { + let config = NetworkConfig { + session: Some(Session::WebSocket), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: true, + h3: false, + }, + ..NetworkConfig::default() + }; + let (mut now, mut he) = setup_with_config(config); + + dns_phase_aaaa_and_a(&mut he, now, out_attempt_v6_h2(Id::from(3))); + + now += CONNECTION_ATTEMPT_DELAY; + he.expect( + vec![ + (None, Some(out_attempt_v4_h2(Id::from(4)))), + (None, Some(out_connection_attempt_delay())), + ], + now, + ); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H2, + )), + // Connection 4 still in-progress → delay timer. + Some(out_connection_attempt_delay()), + ), + ( + Some(in_http_settings_extended_connect(Id::from(3))), + Some(out_establish_websocket(Id::from(3))), + ), + ( + Some(in_session_result_success(Id::from(3))), + Some(Output::CancelConnection { id: Id::from(4) }), + ), + (None, Some(Output::Succeeded)), + ], + now, + ); +} + +/// Two connections race. Both get transport + settings. Second completes +/// session first → cancel the first. +#[test] +fn websocket_racing_second_wins() { + let config = NetworkConfig { + session: Some(Session::WebSocket), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: true, + h3: false, + }, + ..NetworkConfig::default() + }; + let (mut now, mut he) = setup_with_config(config); + + dns_phase_aaaa_and_a(&mut he, now, out_attempt_v6_h2(Id::from(3))); + + now += CONNECTION_ATTEMPT_DELAY; + he.expect( + vec![ + (None, Some(out_attempt_v4_h2(Id::from(4)))), + (None, Some(out_connection_attempt_delay())), + ], + now, + ); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H2, + )), + // Connection 4 still InProgress → delay timer. + Some(out_connection_attempt_delay()), + ), + ( + Some(in_connection_result_positive_with_version( + Id::from(4), + HttpVersion::H2, + )), + // Both Connected but no settings yet. + None, + ), + ( + Some(in_http_settings_extended_connect(Id::from(3))), + Some(out_establish_websocket(Id::from(3))), + ), + ( + Some(in_http_settings_extended_connect(Id::from(4))), + Some(out_establish_websocket(Id::from(4))), + ), + ], + now, + ); + + he.expect( + vec![ + ( + Some(in_session_result_success(Id::from(4))), + Some(Output::CancelConnection { id: Id::from(3) }), + ), + (None, Some(Output::Succeeded)), + ], + now, + ); +} + +/// WebSocket over H3: transport → settings → establish → succeed. +#[test] +fn websocket_h3_happy_path() { + let config = NetworkConfig { + session: Some(Session::WebSocket), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: false, + h3: true, + }, + ..NetworkConfig::default() + }; + let (now, mut he) = setup_with_config(config); + + dns_phase_aaaa_only_https_positive(&mut he, now, out_attempt_v6_h3(Id::from(3))); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H3, + )), + None, + ), + ( + Some(in_http_settings_extended_connect(Id::from(3))), + Some(out_establish_websocket(Id::from(3))), + ), + ( + Some(in_session_result_success(Id::from(3))), + Some(Output::Succeeded), + ), + ], + now, + ); +} + +/// WebTransport over H3 happy path. +#[test] +fn webtransport_h3_happy_path() { + let config = NetworkConfig { + session: Some(Session::WebTransport), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: false, + h3: true, + }, + ..NetworkConfig::default() + }; + let (now, mut he) = setup_with_config(config); + + dns_phase_aaaa_only_https_positive(&mut he, now, out_attempt_v6_h3(Id::from(3))); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H3, + )), + None, + ), + ( + Some(in_http_settings_webtransport(Id::from(3))), + Some(out_establish_webtransport(Id::from(3))), + ), + ( + Some(in_session_result_success(Id::from(3))), + Some(Output::Succeeded), + ), + ], + now, + ); +} + +/// WebTransport: settings have extended_connect but NOT webtransport → +/// ProtocolNotSupported. +#[test] +fn webtransport_h3_no_webtransport_setting() { + let config = NetworkConfig { + session: Some(Session::WebTransport), + http_versions: happy_eyeballs::HttpVersions { + h1: false, + h2: false, + h3: true, + }, + ..NetworkConfig::default() + }; + let (now, mut he) = setup_with_config(config); + + dns_phase_aaaa_only_https_positive(&mut he, now, out_attempt_v6_h3(Id::from(3))); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H3, + )), + None, + ), + ( + Some(in_http_settings_extended_connect(Id::from(3))), + Some(Output::Failed(FailureReason::ProtocolNotSupported)), + ), + ], + now, + ); +} + +/// WebSocket with H2OrH1: caller negotiates H2 via ALPN → settings flow. +#[test] +fn websocket_h2orh1_negotiated_h2() { + let (now, mut he) = setup_with_config(websocket_config()); + + dns_phase_aaaa_only(&mut he, now, out_attempt_v6_h1_h2(Id::from(3))); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H2, + )), + None, + ), + ( + Some(in_http_settings_extended_connect(Id::from(3))), + Some(out_establish_websocket(Id::from(3))), + ), + ( + Some(in_session_result_success(Id::from(3))), + Some(Output::Succeeded), + ), + ], + now, + ); +} + +/// WebSocket with H2OrH1: caller negotiates H1 → immediate session +/// establishment (no settings). +#[test] +fn websocket_h2orh1_negotiated_h1() { + let (now, mut he) = setup_with_config(websocket_config()); + + dns_phase_aaaa_only(&mut he, now, out_attempt_v6_h1_h2(Id::from(3))); + + he.expect( + vec![ + ( + Some(in_connection_result_positive_with_version( + Id::from(3), + HttpVersion::H1, + )), + Some(out_establish_websocket(Id::from(3))), + ), + ( + Some(in_session_result_success(Id::from(3))), + Some(Output::Succeeded), + ), + ], + now, + ); +} + +/// Plain HTTP (session: None) — transport success = overall success. +#[test] +fn plain_http_transport_success_is_overall_success() { + let (now, mut he) = setup(); + + dns_phase_aaaa_only(&mut he, now, out_attempt_v6_h1_h2(Id::from(3))); + + he.expect( + vec![ + ( + Some(in_connection_result_positive(Id::from(3))), + Some(Output::Succeeded), + ), + ], + now, + ); +}