From b293bd11ffa2947e157f44605239764a7b1f33ac Mon Sep 17 00:00:00 2001 From: totodore Date: Tue, 10 Jun 2025 00:30:25 +0200 Subject: [PATCH 01/41] wip --- Cargo.lock | 33 ++ crates/engineioxide-client/Cargo.toml | 55 ++++ crates/engineioxide-client/README.md | 0 crates/engineioxide-client/src/lib.rs | 0 crates/engineioxide-core/Cargo.toml | 1 + crates/engineioxide-core/src/lib.rs | 3 + crates/engineioxide-core/src/packet.rs | 354 ++++++++++++++++++++++ crates/engineioxide-core/src/transport.rs | 57 ++++ crates/engineioxide/src/service/parser.rs | 55 +--- 9 files changed, 508 insertions(+), 50 deletions(-) create mode 100644 crates/engineioxide-client/Cargo.toml create mode 100644 crates/engineioxide-client/README.md create mode 100644 crates/engineioxide-client/src/lib.rs create mode 100644 crates/engineioxide-core/src/packet.rs create mode 100644 crates/engineioxide-core/src/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 21e31591..2598ecf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,6 +675,38 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "engineioxide-client" +version = "0.17.0" +dependencies = [ + "axum", + "base64 0.22.1", + "bytes", + "criterion", + "engineioxide-core", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "memchr", + "pin-project-lite", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-subscriber", + "unicode-segmentation", +] + [[package]] name = "engineioxide-core" version = "0.2.0" @@ -683,6 +715,7 @@ dependencies = [ "bytes", "rand 0.9.1", "serde", + "serde_json", ] [[package]] diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml new file mode 100644 index 00000000..6d18b4dd --- /dev/null +++ b/crates/engineioxide-client/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "engineioxide-client" +description = "Engine IO client implementation in rust" +version = "0.17.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license.workspace = true +readme = "README.md" + +[dependencies] +engineioxide-core = { path = "../engineioxide-core", version = "0.2" } +bytes.workspace = true +futures-core.workspace = true +futures-util.workspace = true +http.workspace = true +http-body.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt", "time"] } +hyper.workspace = true +tokio-tungstenite.workspace = true +http-body-util.workspace = true +pin-project-lite.workspace = true +smallvec.workspace = true +hyper-util = { workspace = true, features = ["tokio"] } + +base64 = "0.22" + +# Tracing +tracing = { workspace = true, optional = true } + +# Engine.io V3 payload +itoa = { workspace = true, optional = true } +memchr = { version = "2.7", optional = true } +unicode-segmentation = { version = "1.12", optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "parking_lot"] } +tracing-subscriber.workspace = true +hyper = { workspace = true, features = ["server", "http1"] } +criterion.workspace = true +axum.workspace = true +tokio-stream.workspace = true +tokio-util.workspace = true + +[features] +v3 = ["memchr", "unicode-segmentation", "itoa"] +tracing = ["dep:tracing"] +__test_harness = [] diff --git a/crates/engineioxide-client/README.md b/crates/engineioxide-client/README.md new file mode 100644 index 00000000..e69de29b diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/engineioxide-core/Cargo.toml b/crates/engineioxide-core/Cargo.toml index 58797493..42daaf7a 100644 --- a/crates/engineioxide-core/Cargo.toml +++ b/crates/engineioxide-core/Cargo.toml @@ -17,3 +17,4 @@ rand = "0.9" base64 = "0.22" serde.workspace = true bytes.workspace = true +serde_json.workspace = true diff --git a/crates/engineioxide-core/src/lib.rs b/crates/engineioxide-core/src/lib.rs index 822f89c0..5f4e2ef8 100644 --- a/crates/engineioxide-core/src/lib.rs +++ b/crates/engineioxide-core/src/lib.rs @@ -30,8 +30,11 @@ )] #![doc = include_str!("../README.md")] +mod packet; mod sid; mod str; +mod transport; pub use sid::Sid; pub use str::Str; +pub use transport::{TransportType, UnknownTransportError}; diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs new file mode 100644 index 00000000..0c85a728 --- /dev/null +++ b/crates/engineioxide-core/src/packet.rs @@ -0,0 +1,354 @@ +use std::fmt; + +use base64::{Engine, engine::general_purpose}; +use bytes::Bytes; +use serde::Serialize; + +use crate::{Sid, Str}; + +/// A Packet type to use when receiving and sending data from the client +#[derive(Debug, Clone, PartialEq, PartialOrd)] +pub enum Packet { + /// Open packet used to initiate a connection + Open(OpenPacket), + /// Close packet used to close a connection + Close, + /// Ping packet used to check if the connection is still alive + /// The client never sends this packet, it is only used by the server + Ping, + /// Pong packet used to respond to a Ping packet + /// The server never sends this packet, it is only used by the client + Pong, + + /// Special Ping packet used to initiate a connection + PingUpgrade, + /// Special Pong packet used to respond to a PingUpgrade packet and upgrade the connection + PongUpgrade, + + /// Message packet used to send a message to the client + Message(Str), + /// Upgrade packet to upgrade the connection from polling to websocket + Upgrade, + + /// Noop packet used to send something to a opened polling connection so it gracefully closes to allow the client to upgrade to websocket + Noop, + + /// Binary packet used to send binary data to the client + /// Converts to a String using base64 encoding when using polling connection + /// Or to a websocket binary frame when using websocket connection + /// + /// When receiving, it is only used with polling connection, websocket use binary frame + Binary(Bytes), // Not part of the protocol, used internally + + /// Binary packet used to send binary data to the client + /// Converts to a String using base64 encoding when using polling connection + /// Or to a websocket binary frame when using websocket connection + /// + /// When receiving, it is only used with polling connection, websocket use binary frame + /// + /// This is a special packet, excepionally specific to the V3 protocol. + BinaryV3(Bytes), // Not part of the protocol, used internally +} + +#[derive(Debug)] +pub enum PacketParseError { + InvalidPacketType(Option), + InvalidPacketPayload, + Base64Decode(base64::DecodeError), +} +impl fmt::Display for PacketParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PacketParseError::InvalidPacketType(c) => write!(f, "Invalid packet type: {:?}", c), + PacketParseError::InvalidPacketPayload => write!(f, "Invalid packet payload"), + PacketParseError::Base64Decode(err) => write!(f, "Base64 decode error: {}", err), + } + } +} +impl From for PacketParseError { + fn from(err: base64::DecodeError) -> Self { + PacketParseError::Base64Decode(err) + } +} +impl std::error::Error for PacketParseError {} + +impl Packet { + /// Check if the packet is a binary packet + pub fn is_binary(&self) -> bool { + matches!(self, Packet::Binary(_) | Packet::BinaryV3(_)) + } + + /// If the packet is a message packet (text), it returns the message + pub fn into_message(self) -> Str { + match self { + Packet::Message(msg) => msg, + _ => panic!("Packet is not a message"), + } + } + + /// If the packet is a binary packet, it returns the binary data + pub fn into_binary(self) -> Bytes { + match self { + Packet::Binary(data) => data, + Packet::BinaryV3(data) => data, + _ => panic!("Packet is not a binary"), + } + } + + /// Get the max size the packet could have when serialized + /// + /// If b64 is true, it returns the max size when serialized to base64 + /// + /// The base64 max size factor is `ceil(n / 3) * 4` + pub(crate) fn get_size_hint(&self, b64: bool) -> usize { + match self { + Packet::Open(_) => 156, // max possible size for the open packet serialized + Packet::Close => 1, + Packet::Ping => 1, + Packet::Pong => 1, + Packet::PingUpgrade => 6, + Packet::PongUpgrade => 6, + Packet::Message(msg) => 1 + msg.len(), + Packet::Upgrade => 1, + Packet::Noop => 1, + Packet::Binary(data) => { + if b64 { + 1 + base64::encoded_len(data.len(), true).unwrap_or(usize::MAX - 1) + } else { + 1 + data.len() + } + } + Packet::BinaryV3(data) => { + if b64 { + 2 + base64::encoded_len(data.len(), true).unwrap_or(usize::MAX - 2) + } else { + 1 + data.len() + } + } + } + } +} + +/// Serialize a [Packet] to a [String] according to the Engine.IO protocol +impl From for String { + fn from(packet: Packet) -> String { + let len = packet.get_size_hint(true); + let mut buffer = String::with_capacity(len); + match packet { + Packet::Open(open) => { + buffer.push('0'); + buffer.push_str(&serde_json::to_string(&open).unwrap()); + } + Packet::Close => buffer.push('1'), + Packet::Ping => buffer.push('2'), + Packet::Pong => buffer.push('3'), + Packet::PingUpgrade => buffer.push_str("2probe"), + Packet::PongUpgrade => buffer.push_str("3probe"), + Packet::Message(msg) => { + buffer.push('4'); + buffer.push_str(&msg); + } + Packet::Upgrade => buffer.push('5'), + Packet::Noop => buffer.push('6'), + Packet::Binary(data) => { + buffer.push('b'); + general_purpose::STANDARD.encode_string(data, &mut buffer); + } + Packet::BinaryV3(data) => { + buffer.push_str("b4"); + general_purpose::STANDARD.encode_string(data, &mut buffer); + } + }; + buffer + } +} + +/// Deserialize a [Packet] from a [String] according to the Engine.IO protocol +impl TryFrom for Packet { + type Error = PacketParseError; + fn try_from(value: Str) -> Result { + let packet_type = value + .as_bytes() + .first() + .ok_or(PacketParseError::InvalidPacketType(None))?; + let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; + let res = match packet_type { + b'1' => Packet::Close, + b'2' if is_upgrade => Packet::PingUpgrade, + b'2' => Packet::Ping, + b'3' if is_upgrade => Packet::PongUpgrade, + b'3' => Packet::Pong, + b'4' => Packet::Message(value.slice(1..)), + b'5' => Packet::Upgrade, + b'6' => Packet::Noop, + b'b' if value.as_bytes().get(1) == Some(&b'4') => Packet::BinaryV3( + general_purpose::STANDARD + .decode(value.slice(2..).as_bytes())? + .into(), + ), + b'b' => Packet::Binary( + general_purpose::STANDARD + .decode(value.slice(1..).as_bytes())? + .into(), + ), + c => Err(PacketParseError::InvalidPacketType(Some(*c as char)))?, + }; + Ok(res) + } +} + +impl TryFrom for Packet { + type Error = PacketParseError; + fn try_from(value: String) -> Result { + Packet::try_from(Str::from(value)) + } +} + +/// An OpenPacket is used to initiate a connection +#[derive(Debug, Clone, Serialize, PartialEq, PartialOrd)] +#[serde(rename_all = "camelCase")] +pub struct OpenPacket { + sid: Sid, + upgrades: Vec, + ping_interval: u64, + ping_timeout: u64, + max_payload: u64, +} + +// impl OpenPacket { +// /// Create a new [OpenPacket] +// /// If the current transport is polling, the server will always allow the client to upgrade to websocket +// pub fn new(transport: TransportType, sid: Sid, config: &EngineIoConfig) -> Self { +// let upgrades = if transport == TransportType::Polling { +// vec!["websocket".to_string()] +// } else { +// vec![] +// }; +// OpenPacket { +// sid, +// upgrades, +// ping_interval: config.ping_interval.as_millis() as u64, +// ping_timeout: config.ping_timeout.as_millis() as u64, +// max_payload: config.max_payload, +// } +// } +// } + +#[cfg(test)] +mod tests { + use crate::config::EngineIoConfig; + + use super::*; + use std::{convert::TryInto, time::Duration}; + + #[test] + fn test_open_packet() { + let sid = Sid::new(); + let packet = Packet::Open(OpenPacket::new( + TransportType::Polling, + sid, + &EngineIoConfig::default(), + )); + let packet_str: String = packet.into(); + assert_eq!( + packet_str, + format!( + "0{{\"sid\":\"{sid}\",\"upgrades\":[\"websocket\"],\"pingInterval\":25000,\"pingTimeout\":20000,\"maxPayload\":100000}}" + ) + ); + } + + #[test] + fn test_message_packet() { + let packet = Packet::Message("hello".into()); + let packet_str: String = packet.into(); + assert_eq!(packet_str, "4hello"); + } + + #[test] + fn test_message_packet_deserialize() { + let packet_str = "4hello".to_string(); + let packet: Packet = packet_str.try_into().unwrap(); + assert_eq!(packet, Packet::Message("hello".into())); + } + + #[test] + fn test_binary_packet() { + let packet = Packet::Binary(vec![1, 2, 3].into()); + let packet_str: String = packet.into(); + assert_eq!(packet_str, "bAQID"); + } + + #[test] + fn test_binary_packet_deserialize() { + let packet_str = "bAQID".to_string(); + let packet: Packet = packet_str.try_into().unwrap(); + assert_eq!(packet, Packet::Binary(vec![1, 2, 3].into())); + } + + #[test] + fn test_binary_packet_v3() { + let packet = Packet::BinaryV3(vec![1, 2, 3].into()); + let packet_str: String = packet.into(); + assert_eq!(packet_str, "b4AQID"); + } + + #[test] + fn test_binary_packet_v3_deserialize() { + let packet_str = "b4AQID".to_string(); + let packet: Packet = packet_str.try_into().unwrap(); + assert_eq!(packet, Packet::BinaryV3(vec![1, 2, 3].into())); + } + + #[test] + fn test_packet_get_size_hint() { + // Max serialized packet + let open = OpenPacket::new( + TransportType::Polling, + Sid::new(), + &EngineIoConfig { + max_buffer_size: usize::MAX, + max_payload: u64::MAX, + ping_interval: Duration::MAX, + ping_timeout: Duration::MAX, + transports: TransportType::Polling as u8 | TransportType::Websocket as u8, + ..Default::default() + }, + ); + let size = serde_json::to_string(&open).unwrap().len(); + let packet = Packet::Open(open); + assert_eq!(packet.get_size_hint(false), size); + + let packet = Packet::Close; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::Ping; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::Pong; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::PingUpgrade; + assert_eq!(packet.get_size_hint(false), 6); + + let packet = Packet::PongUpgrade; + assert_eq!(packet.get_size_hint(false), 6); + + let packet = Packet::Message("hello".into()); + assert_eq!(packet.get_size_hint(false), 6); + + let packet = Packet::Upgrade; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::Noop; + assert_eq!(packet.get_size_hint(false), 1); + + let packet = Packet::Binary(vec![1, 2, 3].into()); + assert_eq!(packet.get_size_hint(false), 4); + assert_eq!(packet.get_size_hint(true), 5); + + let packet = Packet::BinaryV3(vec![1, 2, 3].into()); + assert_eq!(packet.get_size_hint(false), 4); + assert_eq!(packet.get_size_hint(true), 6); + } +} diff --git a/crates/engineioxide-core/src/transport.rs b/crates/engineioxide-core/src/transport.rs new file mode 100644 index 00000000..7093d9ff --- /dev/null +++ b/crates/engineioxide-core/src/transport.rs @@ -0,0 +1,57 @@ +use std::str::FromStr; + +/// The type of `transport` used to connect to the client/server. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum TransportType { + /// Polling transport + Polling = 0x01, + /// Websocket transport + Websocket = 0x02, +} + +impl From for TransportType { + fn from(t: u8) -> Self { + match t { + 0x01 => TransportType::Polling, + 0x02 => TransportType::Websocket, + _ => panic!("unknown transport type"), + } + } +} + +#[derive(Debug, Copy, Clone)] +pub struct UnknownTransportError; +impl std::fmt::Display for UnknownTransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unknown transport type") + } +} +impl std::error::Error for UnknownTransportError {} + +impl FromStr for TransportType { + type Err = UnknownTransportError; + + fn from_str(s: &str) -> Result { + match s { + "websocket" => Ok(TransportType::Websocket), + "polling" => Ok(TransportType::Polling), + _ => Err(UnknownTransportError), + } + } +} +impl From for &'static str { + fn from(t: TransportType) -> Self { + match t { + TransportType::Polling => "polling", + TransportType::Websocket => "websocket", + } + } +} +impl From for String { + fn from(t: TransportType) -> Self { + match t { + TransportType::Polling => "polling".into(), + TransportType::Websocket => "websocket".into(), + } + } +} diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index e911d3d5..5adf3594 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -4,7 +4,7 @@ use std::{future::Future, str::FromStr, sync::Arc}; use http::{Method, Request, Response}; -use engineioxide_core::Sid; +use engineioxide_core::{Sid, TransportType}; use crate::{ body::ResponseBody, @@ -82,7 +82,7 @@ where #[derive(thiserror::Error, Debug)] pub enum ParseError { #[error("transport unknown")] - UnknownTransport, + UnknownTransport(#[from] engineioxide_core::UnknownTransportError), #[error("bad handshake method")] BadHandshakeMethod, #[error("transport mismatch")] @@ -105,7 +105,9 @@ impl From for Response> { .unwrap() }; match err { - UnknownTransport => conn_err_resp("{\"code\":\"0\",\"message\":\"Transport unknown\"}"), + UnknownTransport(_) => { + conn_err_resp("{\"code\":\"0\",\"message\":\"Transport unknown\"}") + } BadHandshakeMethod => { conn_err_resp("{\"code\":\"2\",\"message\":\"Bad handshake method\"}") } @@ -147,53 +149,6 @@ impl FromStr for ProtocolVersion { } } -/// The type of `transport` used by the client. -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum TransportType { - /// Polling transport - Polling = 0x01, - /// Websocket transport - Websocket = 0x02, -} - -impl From for TransportType { - fn from(t: u8) -> Self { - match t { - 0x01 => TransportType::Polling, - 0x02 => TransportType::Websocket, - _ => panic!("unknown transport type"), - } - } -} - -impl FromStr for TransportType { - type Err = ParseError; - - fn from_str(s: &str) -> Result { - match s { - "websocket" => Ok(TransportType::Websocket), - "polling" => Ok(TransportType::Polling), - _ => Err(ParseError::UnknownTransport), - } - } -} -impl From for &'static str { - fn from(t: TransportType) -> Self { - match t { - TransportType::Polling => "polling", - TransportType::Websocket => "websocket", - } - } -} -impl From for String { - fn from(t: TransportType) -> Self { - match t { - TransportType::Polling => "polling".into(), - TransportType::Websocket => "websocket".into(), - } - } -} - /// The request information extracted from the request URI. #[derive(Debug)] pub struct RequestInfo { From cb13f2f125ec1b11d00b58de2c888648a189502d Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 20 Jul 2025 21:17:13 +0200 Subject: [PATCH 02/41] feat(engineio): refactor shared types to core --- crates/engineioxide-client/src/lib.rs | 3 + crates/engineioxide-core/src/lib.rs | 1 + crates/engineioxide-core/src/packet.rs | 115 +++--- crates/engineioxide-core/src/transport.rs | 1 + crates/engineioxide/benches/packet_encode.rs | 8 +- crates/engineioxide/src/config.rs | 2 +- crates/engineioxide/src/engine.rs | 4 +- crates/engineioxide/src/errors.rs | 42 +-- crates/engineioxide/src/lib.rs | 5 +- crates/engineioxide/src/packet.rs | 344 ------------------ crates/engineioxide/src/peekable.rs | 2 +- crates/engineioxide/src/service/mod.rs | 2 +- crates/engineioxide/src/service/parser.rs | 15 +- crates/engineioxide/src/socket.rs | 13 +- crates/engineioxide/src/transport/mod.rs | 19 + .../engineioxide/src/transport/polling/mod.rs | 11 +- .../src/transport/polling/payload/decoder.rs | 80 ++-- .../src/transport/polling/payload/encoder.rs | 3 +- .../src/transport/polling/payload/mod.rs | 7 +- crates/engineioxide/src/transport/ws.rs | 33 +- 20 files changed, 214 insertions(+), 496 deletions(-) delete mode 100644 crates/engineioxide/src/packet.rs diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index e69de29b..0689dcf1 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -0,0 +1,3 @@ +#![warn(clippy::pedantic)] + +//! Engine.IO client library for Rust. diff --git a/crates/engineioxide-core/src/lib.rs b/crates/engineioxide-core/src/lib.rs index 8e80eaa9..3149548c 100644 --- a/crates/engineioxide-core/src/lib.rs +++ b/crates/engineioxide-core/src/lib.rs @@ -34,6 +34,7 @@ mod sid; mod str; mod transport; +pub use packet::{OpenPacket, Packet, PacketParseError}; pub use sid::Sid; pub use str::Str; pub use transport::{TransportType, UnknownTransportError}; diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index 0c85a728..4ce60300 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -50,18 +50,39 @@ pub enum Packet { BinaryV3(Bytes), // Not part of the protocol, used internally } +/// An error that occurs when parsing a packet. #[derive(Debug)] pub enum PacketParseError { + /// The packet type is invalid. InvalidPacketType(Option), + /// The packet payload is invalid. InvalidPacketPayload, + /// The packet length is invalid. + InvalidPacketLen, + /// The packet chunk is invalid + InvalidUtf8Boundary(std::str::Utf8Error), + /// The base64 decoding failed. Base64Decode(base64::DecodeError), + /// The payload is too large. + PayloadTooLarge { + /// The maximum allowed payload size. + max: u64, + }, } impl fmt::Display for PacketParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - PacketParseError::InvalidPacketType(c) => write!(f, "Invalid packet type: {:?}", c), - PacketParseError::InvalidPacketPayload => write!(f, "Invalid packet payload"), - PacketParseError::Base64Decode(err) => write!(f, "Base64 decode error: {}", err), + PacketParseError::InvalidPacketType(c) => write!(f, "invalid packet type: {c:?}"), + PacketParseError::InvalidPacketPayload => write!(f, "invalid packet payload"), + PacketParseError::InvalidPacketLen => write!(f, "invalid packet length"), + PacketParseError::InvalidUtf8Boundary(err) => write!( + f, + "invalid utf8 boundary when parsing payload into packet chunks: {err}" + ), + PacketParseError::Base64Decode(err) => write!(f, "base64 decode error: {err}"), + PacketParseError::PayloadTooLarge { max } => { + write!(f, "payload too large: max {max}") + } } } } @@ -70,6 +91,16 @@ impl From for PacketParseError { PacketParseError::Base64Decode(err) } } +impl From for PacketParseError { + fn from(err: std::string::FromUtf8Error) -> Self { + PacketParseError::InvalidUtf8Boundary(err.utf8_error()) + } +} +impl From for PacketParseError { + fn from(err: std::str::Utf8Error) -> Self { + PacketParseError::InvalidUtf8Boundary(err) + } +} impl std::error::Error for PacketParseError {} impl Packet { @@ -100,7 +131,7 @@ impl Packet { /// If b64 is true, it returns the max size when serialized to base64 /// /// The base64 max size factor is `ceil(n / 3) * 4` - pub(crate) fn get_size_hint(&self, b64: bool) -> usize { + pub fn get_size_hint(&self, b64: bool) -> usize { match self { Packet::Open(_) => 156, // max possible size for the open packet serialized Packet::Close => 1, @@ -208,35 +239,34 @@ impl TryFrom for Packet { #[derive(Debug, Clone, Serialize, PartialEq, PartialOrd)] #[serde(rename_all = "camelCase")] pub struct OpenPacket { - sid: Sid, - upgrades: Vec, - ping_interval: u64, - ping_timeout: u64, - max_payload: u64, + /// The session ID. + pub sid: Sid, + /// The list of available transport upgrades. + pub upgrades: Vec, + /// The ping interval, used in the heartbeat mechanism (in milliseconds). + pub ping_interval: u64, + /// The ping timeout, used in the heartbeat mechanism (in milliseconds). + pub ping_timeout: u64, + /// The maximum number of bytes per chunk, used by the client to + /// aggregate packets into payloads. + pub max_payload: u64, } -// impl OpenPacket { -// /// Create a new [OpenPacket] -// /// If the current transport is polling, the server will always allow the client to upgrade to websocket -// pub fn new(transport: TransportType, sid: Sid, config: &EngineIoConfig) -> Self { -// let upgrades = if transport == TransportType::Polling { -// vec!["websocket".to_string()] -// } else { -// vec![] -// }; -// OpenPacket { -// sid, -// upgrades, -// ping_interval: config.ping_interval.as_millis() as u64, -// ping_timeout: config.ping_timeout.as_millis() as u64, -// max_payload: config.max_payload, -// } -// } -// } +/// This default implementation should only be used for testing purposes. +impl Default for OpenPacket { + fn default() -> Self { + Self { + sid: Sid::ZERO, + upgrades: vec!["websocket".to_string()], + ping_interval: 25000, + ping_timeout: 20000, + max_payload: 100000, + } + } +} #[cfg(test)] mod tests { - use crate::config::EngineIoConfig; use super::*; use std::{convert::TryInto, time::Duration}; @@ -244,11 +274,13 @@ mod tests { #[test] fn test_open_packet() { let sid = Sid::new(); - let packet = Packet::Open(OpenPacket::new( - TransportType::Polling, + let packet = Packet::Open(OpenPacket { sid, - &EngineIoConfig::default(), - )); + upgrades: vec!["websocket".to_string()], + ping_interval: Duration::from_millis(25000).as_millis() as u64, + ping_timeout: Duration::from_millis(20000).as_millis() as u64, + max_payload: 100000, + }); let packet_str: String = packet.into(); assert_eq!( packet_str, @@ -303,18 +335,13 @@ mod tests { #[test] fn test_packet_get_size_hint() { // Max serialized packet - let open = OpenPacket::new( - TransportType::Polling, - Sid::new(), - &EngineIoConfig { - max_buffer_size: usize::MAX, - max_payload: u64::MAX, - ping_interval: Duration::MAX, - ping_timeout: Duration::MAX, - transports: TransportType::Polling as u8 | TransportType::Websocket as u8, - ..Default::default() - }, - ); + let open = OpenPacket { + sid: Sid::new(), + ping_interval: u64::MAX, + ping_timeout: u64::MAX, + max_payload: u64::MAX, + upgrades: vec!["websocket".to_string()], + }; let size = serde_json::to_string(&open).unwrap().len(); let packet = Packet::Open(open); assert_eq!(packet.get_size_hint(false), size); diff --git a/crates/engineioxide-core/src/transport.rs b/crates/engineioxide-core/src/transport.rs index 7093d9ff..608b5e02 100644 --- a/crates/engineioxide-core/src/transport.rs +++ b/crates/engineioxide-core/src/transport.rs @@ -19,6 +19,7 @@ impl From for TransportType { } } +/// Cannot determine the transport type to connect to the client/server. #[derive(Debug, Copy, Clone)] pub struct UnknownTransportError; impl std::fmt::Display for UnknownTransportError { diff --git a/crates/engineioxide/benches/packet_encode.rs b/crates/engineioxide/benches/packet_encode.rs index 0cfc8842..f1d8f483 100644 --- a/crates/engineioxide/benches/packet_encode.rs +++ b/crates/engineioxide/benches/packet_encode.rs @@ -1,15 +1,11 @@ use bytes::Bytes; use criterion::{BatchSize, Criterion, black_box, criterion_group, criterion_main}; -use engineioxide::{OpenPacket, Packet, TransportType, config::EngineIoConfig, socket::Sid}; +use engineioxide::{OpenPacket, Packet}; fn criterion_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("engineio_packet/encode"); group.bench_function("Encode packet open", |b| { - let packet = Packet::Open(OpenPacket::new( - black_box(TransportType::Polling), - black_box(Sid::ZERO), - &EngineIoConfig::default(), - )); + let packet = Packet::Open(OpenPacket::default()); b.iter_batched( || packet.clone(), TryInto::::try_into, diff --git a/crates/engineioxide/src/config.rs b/crates/engineioxide/src/config.rs index 3eeb1fbc..abb8dece 100644 --- a/crates/engineioxide/src/config.rs +++ b/crates/engineioxide/src/config.rs @@ -32,7 +32,7 @@ use std::{borrow::Cow, time::Duration}; -use crate::service::TransportType; +use engineioxide_core::TransportType; /// Configuration for the engine.io engine & transports #[derive(Debug, Clone)] diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index 25f2d573..e98daf1f 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -3,13 +3,13 @@ use std::{ sync::{Arc, RwLock}, }; -use engineioxide_core::Sid; +use engineioxide_core::{Sid, TransportType}; use http::request::Parts; use crate::{ config::EngineIoConfig, handler::EngineIoHandler, - service::{ProtocolVersion, TransportType}, + service::ProtocolVersion, socket::{DisconnectReason, Socket}, }; diff --git a/crates/engineioxide/src/errors.rs b/crates/engineioxide/src/errors.rs index 90b976dc..fbb20136 100644 --- a/crates/engineioxide/src/errors.rs +++ b/crates/engineioxide/src/errors.rs @@ -3,26 +3,27 @@ use tokio::sync::mpsc; use tokio_tungstenite::tungstenite; use crate::body::ResponseBody; -use crate::packet::Packet; -use engineioxide_core::Sid; +use engineioxide_core::{Packet, Sid}; + +pub use engineioxide_core::PacketParseError; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("error decoding binary packet from polling request: {0:?}")] - Base64(#[from] base64::DecodeError), - #[error("error decoding packet: {0:?}")] + #[error("error decoding packet from request: {0}")] + PacketParse(#[from] PacketParseError), + #[error("error decoding packet: {0}")] StrUtf8(#[from] std::str::Utf8Error), - #[error("io error: {0:?}")] + #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("bad packet received")] BadPacket(Packet), - #[error("ws transport error: {0:?}")] + #[error("ws transport error: {0}")] WsTransport(#[from] Box), - #[error("http error: {0:?}")] + #[error("http error: {0}")] Http(#[from] http::Error), - #[error("internal channel error: {0:?}")] + #[error("internal channel error: {0}")] SendChannel(#[from] mpsc::error::TrySendError), - #[error("internal channel error: {0:?}")] + #[error("internal channel error: {0}")] RecvChannel(#[from] mpsc::error::TryRecvError), #[error("heartbeat timeout")] HeartbeatTimeout, @@ -31,20 +32,13 @@ pub enum Error { #[error("aborted connection")] Aborted, - #[error("http error response: {0:?}")] + #[error("http error response: {0}")] HttpErrorResponse(StatusCode), #[error("unknown session id")] UnknownSessionID(Sid), #[error("transport mismatch")] TransportMismatch, - #[error("payload too large")] - PayloadTooLarge, - - #[error("Invalid packet length")] - InvalidPacketLength, - #[error("Invalid packet type")] - InvalidPacketType(Option), } /// Convert an error into an http response @@ -64,16 +58,14 @@ impl From for Response> { .status(code) .body(ResponseBody::empty_response()) .unwrap(), - Error::BadPacket(_) | Error::InvalidPacketLength | Error::InvalidPacketType(_) => { - Response::builder() - .status(400) - .body(ResponseBody::empty_response()) - .unwrap() - } - Error::PayloadTooLarge => Response::builder() + Error::PacketParse(PacketParseError::PayloadTooLarge { .. }) => Response::builder() .status(413) .body(ResponseBody::empty_response()) .unwrap(), + Error::BadPacket(_) | Error::PacketParse(_) => Response::builder() + .status(400) + .body(ResponseBody::empty_response()) + .unwrap(), Error::UnknownSessionID(_) => { conn_err_resp("{\"code\":\"1\",\"message\":\"Session ID unknown\"}") diff --git a/crates/engineioxide/src/lib.rs b/crates/engineioxide/src/lib.rs index 3bf3469a..68ad1628 100644 --- a/crates/engineioxide/src/lib.rs +++ b/crates/engineioxide/src/lib.rs @@ -32,12 +32,12 @@ #![doc = include_str!("../Readme.md")] pub use engineioxide_core::Str; -pub use service::{ProtocolVersion, TransportType}; +pub use service::ProtocolVersion; pub use socket::{DisconnectReason, Socket}; #[doc(hidden)] #[cfg(feature = "__test_harness")] -pub use packet::*; +pub use engineioxide_core::{OpenPacket, Packet, PacketParseError, TransportType}; pub mod config; pub mod handler; @@ -54,6 +54,5 @@ pub mod sid { mod body; mod engine; mod errors; -mod packet; mod peekable; mod transport; diff --git a/crates/engineioxide/src/packet.rs b/crates/engineioxide/src/packet.rs deleted file mode 100644 index 40162400..00000000 --- a/crates/engineioxide/src/packet.rs +++ /dev/null @@ -1,344 +0,0 @@ -use base64::{Engine, engine::general_purpose}; -use bytes::Bytes; -use engineioxide_core::{Sid, Str}; -use serde::Serialize; - -use crate::TransportType; -use crate::config::EngineIoConfig; -use crate::errors::Error; - -/// A Packet type to use when receiving and sending data from the client -#[derive(Debug, Clone, PartialEq, PartialOrd)] -pub enum Packet { - /// Open packet used to initiate a connection - Open(OpenPacket), - /// Close packet used to close a connection - Close, - /// Ping packet used to check if the connection is still alive - /// The client never sends this packet, it is only used by the server - Ping, - /// Pong packet used to respond to a Ping packet - /// The server never sends this packet, it is only used by the client - Pong, - - /// Special Ping packet used to initiate a connection - PingUpgrade, - /// Special Pong packet used to respond to a PingUpgrade packet and upgrade the connection - PongUpgrade, - - /// Message packet used to send a message to the client - Message(Str), - /// Upgrade packet to upgrade the connection from polling to websocket - Upgrade, - - /// Noop packet used to send something to a opened polling connection so it gracefully closes to allow the client to upgrade to websocket - Noop, - - /// Binary packet used to send binary data to the client - /// Converts to a String using base64 encoding when using polling connection - /// Or to a websocket binary frame when using websocket connection - /// - /// When receiving, it is only used with polling connection, websocket use binary frame - Binary(Bytes), // Not part of the protocol, used internally - - /// Binary packet used to send binary data to the client - /// Converts to a String using base64 encoding when using polling connection - /// Or to a websocket binary frame when using websocket connection - /// - /// When receiving, it is only used with polling connection, websocket use binary frame - /// - /// This is a special packet, excepionally specific to the V3 protocol. - BinaryV3(Bytes), // Not part of the protocol, used internally -} - -impl Packet { - /// Check if the packet is a binary packet - pub fn is_binary(&self) -> bool { - matches!(self, Packet::Binary(_) | Packet::BinaryV3(_)) - } - - /// If the packet is a message packet (text), it returns the message - pub(crate) fn into_message(self) -> Str { - match self { - Packet::Message(msg) => msg, - _ => panic!("Packet is not a message"), - } - } - - /// If the packet is a binary packet, it returns the binary data - pub(crate) fn into_binary(self) -> Bytes { - match self { - Packet::Binary(data) => data, - Packet::BinaryV3(data) => data, - _ => panic!("Packet is not a binary"), - } - } - - /// Get the max size the packet could have when serialized - /// - /// If b64 is true, it returns the max size when serialized to base64 - /// - /// The base64 max size factor is `ceil(n / 3) * 4` - pub(crate) fn get_size_hint(&self, b64: bool) -> usize { - match self { - Packet::Open(_) => 156, // max possible size for the open packet serialized - Packet::Close => 1, - Packet::Ping => 1, - Packet::Pong => 1, - Packet::PingUpgrade => 6, - Packet::PongUpgrade => 6, - Packet::Message(msg) => 1 + msg.len(), - Packet::Upgrade => 1, - Packet::Noop => 1, - Packet::Binary(data) => { - if b64 { - 1 + base64::encoded_len(data.len(), true).unwrap_or(usize::MAX - 1) - } else { - 1 + data.len() - } - } - Packet::BinaryV3(data) => { - if b64 { - 2 + base64::encoded_len(data.len(), true).unwrap_or(usize::MAX - 2) - } else { - 1 + data.len() - } - } - } - } -} - -/// Serialize a [Packet] to a [String] according to the Engine.IO protocol -impl From for String { - fn from(packet: Packet) -> String { - let len = packet.get_size_hint(true); - let mut buffer = String::with_capacity(len); - match packet { - Packet::Open(open) => { - buffer.push('0'); - buffer.push_str(&serde_json::to_string(&open).unwrap()); - } - Packet::Close => buffer.push('1'), - Packet::Ping => buffer.push('2'), - Packet::Pong => buffer.push('3'), - Packet::PingUpgrade => buffer.push_str("2probe"), - Packet::PongUpgrade => buffer.push_str("3probe"), - Packet::Message(msg) => { - buffer.push('4'); - buffer.push_str(&msg); - } - Packet::Upgrade => buffer.push('5'), - Packet::Noop => buffer.push('6'), - Packet::Binary(data) => { - buffer.push('b'); - general_purpose::STANDARD.encode_string(data, &mut buffer); - } - Packet::BinaryV3(data) => { - buffer.push_str("b4"); - general_purpose::STANDARD.encode_string(data, &mut buffer); - } - }; - buffer - } -} -impl From for tokio_tungstenite::tungstenite::Utf8Bytes { - fn from(value: Packet) -> Self { - String::from(value).into() - } -} -/// Deserialize a [Packet] from a [String] according to the Engine.IO protocol -impl TryFrom for Packet { - type Error = Error; - fn try_from(value: Str) -> Result { - let packet_type = value - .as_bytes() - .first() - .ok_or(Error::InvalidPacketType(None))?; - let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; - let res = match packet_type { - b'1' => Packet::Close, - b'2' if is_upgrade => Packet::PingUpgrade, - b'2' => Packet::Ping, - b'3' if is_upgrade => Packet::PongUpgrade, - b'3' => Packet::Pong, - b'4' => Packet::Message(value.slice(1..)), - b'5' => Packet::Upgrade, - b'6' => Packet::Noop, - b'b' if value.as_bytes().get(1) == Some(&b'4') => Packet::BinaryV3( - general_purpose::STANDARD - .decode(value.slice(2..).as_bytes())? - .into(), - ), - b'b' => Packet::Binary( - general_purpose::STANDARD - .decode(value.slice(1..).as_bytes())? - .into(), - ), - c => Err(Error::InvalidPacketType(Some(*c as char)))?, - }; - Ok(res) - } -} -impl TryFrom for Packet { - type Error = Error; - fn try_from(value: tokio_tungstenite::tungstenite::Utf8Bytes) -> Result { - // SAFETY: The utf8 bytes are guaranteed to be valid utf8 - Packet::try_from(unsafe { Str::from_bytes_unchecked(value.into()) }) - } -} - -impl TryFrom for Packet { - type Error = Error; - fn try_from(value: String) -> Result { - Packet::try_from(Str::from(value)) - } -} - -/// An OpenPacket is used to initiate a connection -#[derive(Debug, Clone, Serialize, PartialEq, PartialOrd)] -#[serde(rename_all = "camelCase")] -pub struct OpenPacket { - sid: Sid, - upgrades: Vec, - ping_interval: u64, - ping_timeout: u64, - max_payload: u64, -} - -impl OpenPacket { - /// Create a new [OpenPacket] - /// If the current transport is polling, the server will always allow the client to upgrade to websocket - pub fn new(transport: TransportType, sid: Sid, config: &EngineIoConfig) -> Self { - let upgrades = if transport == TransportType::Polling { - vec!["websocket".to_string()] - } else { - vec![] - }; - OpenPacket { - sid, - upgrades, - ping_interval: config.ping_interval.as_millis() as u64, - ping_timeout: config.ping_timeout.as_millis() as u64, - max_payload: config.max_payload, - } - } -} - -#[cfg(test)] -mod tests { - use crate::config::EngineIoConfig; - - use super::*; - use std::{convert::TryInto, time::Duration}; - - #[test] - fn test_open_packet() { - let sid = Sid::new(); - let packet = Packet::Open(OpenPacket::new( - TransportType::Polling, - sid, - &EngineIoConfig::default(), - )); - let packet_str: String = packet.into(); - assert_eq!( - packet_str, - format!( - "0{{\"sid\":\"{sid}\",\"upgrades\":[\"websocket\"],\"pingInterval\":25000,\"pingTimeout\":20000,\"maxPayload\":100000}}" - ) - ); - } - - #[test] - fn test_message_packet() { - let packet = Packet::Message("hello".into()); - let packet_str: String = packet.into(); - assert_eq!(packet_str, "4hello"); - } - - #[test] - fn test_message_packet_deserialize() { - let packet_str = "4hello".to_string(); - let packet: Packet = packet_str.try_into().unwrap(); - assert_eq!(packet, Packet::Message("hello".into())); - } - - #[test] - fn test_binary_packet() { - let packet = Packet::Binary(vec![1, 2, 3].into()); - let packet_str: String = packet.into(); - assert_eq!(packet_str, "bAQID"); - } - - #[test] - fn test_binary_packet_deserialize() { - let packet_str = "bAQID".to_string(); - let packet: Packet = packet_str.try_into().unwrap(); - assert_eq!(packet, Packet::Binary(vec![1, 2, 3].into())); - } - - #[test] - fn test_binary_packet_v3() { - let packet = Packet::BinaryV3(vec![1, 2, 3].into()); - let packet_str: String = packet.into(); - assert_eq!(packet_str, "b4AQID"); - } - - #[test] - fn test_binary_packet_v3_deserialize() { - let packet_str = "b4AQID".to_string(); - let packet: Packet = packet_str.try_into().unwrap(); - assert_eq!(packet, Packet::BinaryV3(vec![1, 2, 3].into())); - } - - #[test] - fn test_packet_get_size_hint() { - // Max serialized packet - let open = OpenPacket::new( - TransportType::Polling, - Sid::new(), - &EngineIoConfig { - max_buffer_size: usize::MAX, - max_payload: u64::MAX, - ping_interval: Duration::MAX, - ping_timeout: Duration::MAX, - transports: TransportType::Polling as u8 | TransportType::Websocket as u8, - ..Default::default() - }, - ); - let size = serde_json::to_string(&open).unwrap().len(); - let packet = Packet::Open(open); - assert_eq!(packet.get_size_hint(false), size); - - let packet = Packet::Close; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::Ping; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::Pong; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::PingUpgrade; - assert_eq!(packet.get_size_hint(false), 6); - - let packet = Packet::PongUpgrade; - assert_eq!(packet.get_size_hint(false), 6); - - let packet = Packet::Message("hello".into()); - assert_eq!(packet.get_size_hint(false), 6); - - let packet = Packet::Upgrade; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::Noop; - assert_eq!(packet.get_size_hint(false), 1); - - let packet = Packet::Binary(vec![1, 2, 3].into()); - assert_eq!(packet.get_size_hint(false), 4); - assert_eq!(packet.get_size_hint(true), 5); - - let packet = Packet::BinaryV3(vec![1, 2, 3].into()); - assert_eq!(packet.get_size_hint(false), 4); - assert_eq!(packet.get_size_hint(true), 6); - } -} diff --git a/crates/engineioxide/src/peekable.rs b/crates/engineioxide/src/peekable.rs index bc8ddb6b..87d2d74c 100644 --- a/crates/engineioxide/src/peekable.rs +++ b/crates/engineioxide/src/peekable.rs @@ -47,7 +47,7 @@ mod tests { #[tokio::test] async fn peek() { use super::PeekableReceiver; - use crate::packet::Packet; + use engineioxide_core::Packet; use tokio::sync::mpsc::channel; let (tx, rx) = channel(1); diff --git a/crates/engineioxide/src/service/mod.rs b/crates/engineioxide/src/service/mod.rs index 4bce8fe6..c9b5ab54 100644 --- a/crates/engineioxide/src/service/mod.rs +++ b/crates/engineioxide/src/service/mod.rs @@ -47,7 +47,7 @@ use crate::{ mod futures; mod parser; -pub use self::parser::{ProtocolVersion, TransportType}; +pub use self::parser::ProtocolVersion; use self::{futures::ResponseFuture, parser::dispatch_req}; /// A `Service` that handles engine.io requests as a middleware. diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index 5adf3594..3ab4af82 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -82,7 +82,7 @@ where #[derive(thiserror::Error, Debug)] pub enum ParseError { #[error("transport unknown")] - UnknownTransport(#[from] engineioxide_core::UnknownTransportError), + UnknownTransport, #[error("bad handshake method")] BadHandshakeMethod, #[error("transport mismatch")] @@ -90,6 +90,11 @@ pub enum ParseError { #[error("unsupported protocol version")] UnsupportedProtocolVersion, } +impl From for ParseError { + fn from(_: engineioxide_core::UnknownTransportError) -> Self { + ParseError::UnknownTransport + } +} /// Convert an error into an http response /// If it is a known error, return the appropriate http status code @@ -105,9 +110,7 @@ impl From for Response> { .unwrap() }; match err { - UnknownTransport(_) => { - conn_err_resp("{\"code\":\"0\",\"message\":\"Transport unknown\"}") - } + UnknownTransport => conn_err_resp("{\"code\":\"0\",\"message\":\"Transport unknown\"}"), BadHandshakeMethod => { conn_err_resp("{\"code\":\"2\",\"message\":\"Bad handshake method\"}") } @@ -188,8 +191,8 @@ impl RequestInfo { .split('&') .find(|s| s.starts_with("transport=")) .and_then(|s| s.split('=').nth(1)) - .ok_or(UnknownTransport) - .and_then(|t| t.parse())?; + .and_then(|t| t.parse().ok()) + .ok_or(UnknownTransport)?; if !config.allowed_transport(transport) { return Err(TransportMismatch); diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 56776426..5c833818 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -64,14 +64,10 @@ use std::{ }; use crate::{ - config::EngineIoConfig, - errors::Error, - packet::Packet, - peekable::PeekableReceiver, - service::{ProtocolVersion, TransportType}, + config::EngineIoConfig, errors::Error, peekable::PeekableReceiver, service::ProtocolVersion, }; use bytes::Bytes; -use engineioxide_core::Str; +use engineioxide_core::{Packet, Str, TransportType}; use http::request::Parts; use smallvec::{SmallVec, smallvec}; use tokio::{ @@ -111,8 +107,9 @@ impl From<&Error> for Option { use Error::*; match err { WsTransport(_) | Io(_) => Some(DisconnectReason::TransportError), - BadPacket(_) | Base64(_) | StrUtf8(_) | PayloadTooLarge | InvalidPacketLength - | InvalidPacketType(_) => Some(DisconnectReason::PacketParsingError), + BadPacket(_) | StrUtf8(_) | PacketParse(_) => { + Some(DisconnectReason::PacketParsingError) + } HeartbeatTimeout => Some(DisconnectReason::HeartbeatTimeout), _ => None, } diff --git a/crates/engineioxide/src/transport/mod.rs b/crates/engineioxide/src/transport/mod.rs index 94d843a5..bbdfc589 100644 --- a/crates/engineioxide/src/transport/mod.rs +++ b/crates/engineioxide/src/transport/mod.rs @@ -1,4 +1,23 @@ //! All transports modules available in engineioxide +use engineioxide_core::{OpenPacket, Sid, TransportType}; + +use crate::config::EngineIoConfig; + pub mod polling; pub mod ws; + +fn make_open_packet(transport: TransportType, id: Sid, config: &EngineIoConfig) -> OpenPacket { + let upgrades = if transport == TransportType::Polling { + vec!["websocket".to_string()] + } else { + vec![] + }; + OpenPacket { + sid: id, + upgrades, + ping_timeout: config.ping_timeout.as_millis() as u64, + ping_interval: config.ping_interval.as_millis() as u64, + max_payload: config.max_payload, + } +} diff --git a/crates/engineioxide/src/transport/polling/mod.rs b/crates/engineioxide/src/transport/polling/mod.rs index 075503c7..8f6a5559 100644 --- a/crates/engineioxide/src/transport/polling/mod.rs +++ b/crates/engineioxide/src/transport/polling/mod.rs @@ -7,7 +7,7 @@ use http::{Request, Response, StatusCode}; use http_body::Body; use http_body_util::Full; -use engineioxide_core::Sid; +use engineioxide_core::{Packet, Sid, TransportType}; use crate::{ DisconnectReason, @@ -15,9 +15,8 @@ use crate::{ engine::EngineIo, errors::Error, handler::EngineIoHandler, - packet::{OpenPacket, Packet}, - service::{ProtocolVersion, TransportType}, - transport::polling::payload::Payload, + service::ProtocolVersion, + transport::{make_open_packet, polling::payload::Payload}, }; mod payload; @@ -62,7 +61,7 @@ where supports_binary, ); - let packet = OpenPacket::new(TransportType::Polling, socket.id, &engine.config); + let packet = make_open_packet(TransportType::Polling, socket.id, &engine.config); socket.spawn_heartbeat(engine.config.ping_interval, engine.config.ping_timeout); @@ -182,7 +181,7 @@ where #[cfg(feature = "tracing")] tracing::debug!("[sid={sid}] error parsing packet: {:?}", e); engine.close_session(sid, DisconnectReason::PacketParsingError); - return Err(e); + return Err(e.into()); } }?; } diff --git a/crates/engineioxide/src/transport/polling/payload/decoder.rs b/crates/engineioxide/src/transport/polling/payload/decoder.rs index 3ef454d3..536ce1c4 100644 --- a/crates/engineioxide/src/transport/polling/payload/decoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/decoder.rs @@ -5,11 +5,10 @@ //! - v3_decoder: Decodes the payload stream according to the [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) //! +use engineioxide_core::{Packet, PacketParseError}; use futures_core::Stream; use futures_util::StreamExt; -use http::StatusCode; -use crate::{errors::Error, packet::Packet}; use bytes::Buf; use http_body::Body; use http_body_util::BodyStream; @@ -42,7 +41,7 @@ impl Payload { /// Polls the body stream for data and adds it to the chunk list in the state /// Returns an error if the packet length exceeds the maximum allowed payload size -async fn poll_body(state: &mut Payload, max_payload: u64) -> Result<(), Error> +async fn poll_body(state: &mut Payload, max_payload: u64) -> Result<(), PacketParseError> where B: Body + Unpin, E: std::fmt::Debug, @@ -59,7 +58,7 @@ where Err(_e) => { #[cfg(feature = "tracing")] tracing::debug!("error reading body stream: {:?}", _e); - Err(Error::HttpErrorResponse(StatusCode::BAD_REQUEST)) + Err(PacketParseError::InvalidPacketPayload) } }?; if state.current_payload_size + (data.remaining() as u64) <= max_payload { @@ -67,11 +66,14 @@ where state.buffer.push(data); Ok(()) } else { - Err(Error::PayloadTooLarge) + Err(PacketParseError::PayloadTooLarge { max: max_payload }) } } -pub fn v4_decoder(body: B, max_payload: u64) -> impl Stream> +pub fn v4_decoder( + body: B, + max_payload: u64, +) -> impl Stream> where B: Body + Unpin, E: std::fmt::Debug, @@ -93,11 +95,14 @@ where } // Read from the buffer until the packet separator is found - if let Err(e) = (&mut state.buffer) + if let Err(_err) = (&mut state.buffer) .reader() .read_until(PACKET_SEPARATOR_V4, &mut packet_buf) { - break Some((Err(Error::Io(e)), state)); + #[cfg(feature = "tracing")] + tracing::debug!("failed to read packet payload: {_err}"); + + break Some((Err(PacketParseError::InvalidPacketPayload), state)); } let separator_found = packet_buf.ends_with(&[PACKET_SEPARATOR_V4]); @@ -111,7 +116,7 @@ where || (state.end_of_stream && state.buffer.remaining() == 0 && !packet_buf.is_empty()) { let packet = String::from_utf8(packet_buf) - .map_err(|_| Error::InvalidPacketLength) + .map_err(PacketParseError::from) .and_then(Packet::try_from); // Convert the packet buffer to a Packet object break Some((packet, state)); // Emit the packet and the updated state } else if state.end_of_stream && state.buffer.remaining() == 0 { @@ -125,7 +130,7 @@ where pub fn v3_binary_decoder( body: B, max_payload: u64, -) -> impl Stream> +) -> impl Stream> where B: Body + Unpin, E: std::fmt::Debug, @@ -156,11 +161,14 @@ where // If there is no packet_type found if packet_type.is_none() && state.buffer.remaining() > 0 { // Read from the buffer until the packet separator is found - if let Err(e) = (&mut state.buffer) + if let Err(_err) = (&mut state.buffer) .reader() .read_until(BINARY_PACKET_SEPARATOR_V3, &mut packet_buf) { - break Some((Err(Error::Io(e)), state)); + #[cfg(feature = "tracing")] + tracing::debug!("failed to read packet payload: {_err}"); + + break Some((Err(PacketParseError::InvalidPacketPayload), state)); } // Extract packet_type and packet_size @@ -173,11 +181,11 @@ where Some(&STRING_PACKET_IDENTIFIER_V3) => { packet_type = Some(STRING_PACKET_IDENTIFIER_V3) } - _ => break Some((Err(Error::InvalidPacketLength), state)), + _ => break Some((Err(PacketParseError::InvalidPacketLen), state)), } if packet_buf.len() > 9 { - break Some((Err(Error::InvalidPacketLength), state)); + break Some((Err(PacketParseError::InvalidPacketLen), state)); } let size_str = &packet_buf[1..] @@ -187,7 +195,7 @@ where if let Ok(size) = size_str.parse() { packet_size = size; } else { - break Some((Err(Error::InvalidPacketLength), state)); + break Some((Err(PacketParseError::InvalidPacketLen), state)); } packet_buf.clear(); } @@ -204,10 +212,10 @@ where // Read the packet data let packet = match packet_type.unwrap() { STRING_PACKET_IDENTIFIER_V3 => String::from_utf8(packet_buf) - .map_err(|_| Error::InvalidPacketLength) + .map_err(PacketParseError::from) .and_then(Packet::try_from), // Convert the packet buffer to a Packet object BINARY_PACKET_IDENTIFIER_V3 => Ok(Packet::BinaryV3(packet_buf.into())), - _ => Err(Error::InvalidPacketLength), + _ => Err(PacketParseError::InvalidPacketLen), }; break Some((packet, state)); @@ -222,7 +230,7 @@ where pub fn v3_string_decoder( body: impl Body + Unpin, max_payload: u64, -) -> impl Stream> { +) -> impl Stream> { use std::io::ErrorKind; use unicode_segmentation::UnicodeSegmentation; @@ -245,7 +253,7 @@ pub fn v3_string_decoder( if state.end_of_stream && state.buffer.remaining() == 0 && state.yield_packets > 0 { break None; // Reached end of stream with no more data, end the stream } else if state.end_of_stream && state.buffer.remaining() == 0 { - return Some((Err(Error::InvalidPacketLength), state)); + return Some((Err(PacketParseError::InvalidPacketLen), state)); } let mut reader = (&mut state.buffer).reader(); @@ -258,7 +266,12 @@ pub fn v3_string_decoder( let available = match reader.fill_buf() { Ok(n) => n, Err(ref e) if e.kind() == ErrorKind::Interrupted => continue, - Err(e) => return Some((Err(Error::Io(e)), state)), + Err(_err) => { + #[cfg(feature = "tracing")] + tracing::debug!("failed to read packet payload: {_err}"); + + return Some((Err(PacketParseError::InvalidPacketPayload), state)); + } }; let old_len = packet_buf.len(); packet_buf.extend_from_slice(available); @@ -267,9 +280,10 @@ pub fn v3_string_decoder( Some(i) => { // Extract the packet length from the available data packet_graphemes_len = match std::str::from_utf8(&packet_buf[..i]) - .map_err(|_| Error::InvalidPacketLength) + .map_err(PacketParseError::from) .and_then(|s| { - s.parse::().map_err(|_| Error::InvalidPacketLength) + s.parse::() + .map_err(|_| PacketParseError::InvalidPacketLen) }) { Ok(size) => size, Err(e) => return Some((Err(e), state)), @@ -279,7 +293,7 @@ pub fn v3_string_decoder( (true, i + 1 - old_len) // Mark as done and set the used bytes count } None if state.end_of_stream && remaining - available.len() == 0 => { - return Some((Err(Error::InvalidPacketLength), state)); + return Some((Err(PacketParseError::InvalidPacketLen), state)); } // Reached end of stream and end of bufferered chunks without finding the separator None => (false, available.len()), // Continue reading more data } @@ -336,8 +350,9 @@ pub fn v3_string_decoder( if let Ok(packet) = std::str::from_utf8(&packet_buf) { if packet.graphemes(true).count() == packet_graphemes_len { // SAFETY: packet_buf is a valid utf8 string checkd above + let packet = unsafe { String::from_utf8_unchecked(packet_buf) }; - let packet = Packet::try_from(packet).map_err(|_| Error::InvalidPacketLength); + let packet = Packet::try_from(packet); state.yield_packets += 1; break Some((packet, state)); // Emit the packet and the updated state } @@ -356,8 +371,6 @@ mod tests { use http_body::Frame; use http_body_util::{Full, StreamBody}; - use crate::packet::Packet; - use super::*; const MAX_PAYLOAD: u64 = 100_000; @@ -423,7 +436,10 @@ mod tests { let payload = v4_decoder(stream, MAX_PAYLOAD); futures_util::pin_mut!(payload); let packet = payload.next().await.unwrap(); - assert!(matches!(packet, Err(Error::PayloadTooLarge))); + assert!(matches!( + packet, + Err(PacketParseError::PayloadTooLarge { max: MAX_PAYLOAD }) + )); } } @@ -551,7 +567,10 @@ mod tests { let payload = v3_binary_decoder(stream, MAX_PAYLOAD); futures_util::pin_mut!(payload); let packet = payload.next().await.unwrap(); - assert!(matches!(packet, Err(Error::PayloadTooLarge))); + assert!(matches!( + packet, + Err(PacketParseError::PayloadTooLarge { max: MAX_PAYLOAD }) + )); } for i in 1..DATA.len() { let stream = StreamBody::new(futures_util::stream::iter( @@ -562,7 +581,10 @@ mod tests { let payload = v3_string_decoder(stream, MAX_PAYLOAD); futures_util::pin_mut!(payload); let packet = payload.next().await.unwrap(); - assert!(matches!(packet, Err(Error::PayloadTooLarge))); + assert!(matches!( + packet, + Err(PacketParseError::PayloadTooLarge { max: MAX_PAYLOAD }) + )); } } } diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide/src/transport/polling/payload/encoder.rs index b6c506a6..2115588e 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/encoder.rs @@ -10,9 +10,10 @@ use tokio::sync::MutexGuard; use crate::{ - errors::Error, packet::Packet, peekable::PeekableReceiver, socket::PacketBuf, + errors::Error, peekable::PeekableReceiver, socket::PacketBuf, transport::polling::payload::Payload, }; +use engineioxide_core::Packet; /// Try to immediately poll a new packet buf from the rx channel and check that the new packet can be added to the payload /// diff --git a/crates/engineioxide/src/transport/polling/payload/mod.rs b/crates/engineioxide/src/transport/polling/payload/mod.rs index 40b8f650..9dd2da8a 100644 --- a/crates/engineioxide/src/transport/polling/payload/mod.rs +++ b/crates/engineioxide/src/transport/polling/payload/mod.rs @@ -1,9 +1,10 @@ //! Payload encoder and decoder for polling transport. use crate::{ - errors::Error, packet::Packet, peekable::PeekableReceiver, service::ProtocolVersion, - socket::PacketBuf, + errors::Error, peekable::PeekableReceiver, service::ProtocolVersion, socket::PacketBuf, }; +use engineioxide_core::{Packet, PacketParseError}; + use bytes::Bytes; use futures_core::Stream; use http::Request; @@ -27,7 +28,7 @@ pub fn decoder( body: Request + Unpin>, #[allow(unused_variables)] protocol: ProtocolVersion, max_payload: u64, -) -> impl Stream> { +) -> impl Stream> { #[cfg(feature = "v3")] { use futures_util::future::Either; diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 5c84d46f..924dd080 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -18,24 +18,17 @@ use tokio::{ use tokio_tungstenite::{ WebSocketStream, tungstenite::{ - Message, + self, Message, handshake::derive_accept_key, protocol::{Role, WebSocketConfig}, }, }; -use engineioxide_core::Sid; +use engineioxide_core::{Packet, Sid, Str, TransportType}; use crate::{ - DisconnectReason, Socket, - body::ResponseBody, - config::EngineIoConfig, - engine::EngineIo, - errors::Error, - handler::EngineIoHandler, - packet::{OpenPacket, Packet}, - service::ProtocolVersion, - service::TransportType, + DisconnectReason, Socket, body::ResponseBody, config::EngineIoConfig, engine::EngineIo, + errors::Error, handler::EngineIoHandler, service::ProtocolVersion, transport::make_open_packet, }; /// Create a response for websocket upgrade @@ -171,7 +164,7 @@ where { while let Some(msg) = rx.try_next().await? { match msg { - Message::Text(msg) => match Packet::try_from(msg)? { + Message::Text(msg) => match Packet::try_from(ws_bytes_to_str(msg))? { Packet::Close => { #[cfg(feature = "tracing")] tracing::debug!("[sid={}] closing session", socket.id); @@ -283,7 +276,8 @@ async fn init_handshake( where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - let packet = Packet::Open(OpenPacket::new(TransportType::Websocket, sid, config)); + let packet = Packet::Open(make_open_packet(TransportType::Websocket, sid, config)); + let packet: String = packet.into(); ws.send(Message::Text(packet.into())).await?; Ok(()) } @@ -327,11 +321,12 @@ where Some(Ok(Message::Text(d))) => d, _ => Err(Error::Upgrade)?, }; - match Packet::try_from(msg)? { + match Packet::try_from(ws_bytes_to_str(msg))? { Packet::PingUpgrade => { socket.start_upgrade(); + let pong: String = Packet::PongUpgrade.into(); // Respond with a PongUpgrade packet - ws.send(Message::Text(Packet::PongUpgrade.into())).await?; + ws.send(Message::Text(pong.into())).await?; } p => Err(Error::BadPacket(p))?, }; @@ -353,7 +348,7 @@ where Err(Error::Upgrade)? } }; - match Packet::try_from(msg)? { + match Packet::try_from(ws_bytes_to_str(msg))? { Packet::Upgrade => { #[cfg(feature = "tracing")] tracing::debug!("ws upgraded successful") @@ -366,3 +361,9 @@ where socket.upgrade_to_websocket(); Ok(()) } + +fn ws_bytes_to_str(bytes: tungstenite::Utf8Bytes) -> Str { + // SAFETY: We are converting a valid UTF-8 byte slice + // to a string without checking its validity. + unsafe { Str::from_bytes_unchecked(bytes.into()) } +} From 7266fc8c90f129be8a415d19fe7fd0fdefb9ddae Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 20 Jul 2025 21:51:49 +0200 Subject: [PATCH 03/41] feat(engineio): refactor shared types to core --- Cargo.lock | 39 ++++++--- Cargo.toml | 2 +- crates/engineioxide-client/Cargo.toml | 17 +--- crates/engineioxide-client/src/client.rs | 1 + crates/engineioxide-client/src/io.rs | 1 + crates/engineioxide-client/src/lib.rs | 8 +- .../engineioxide-client/src/transport/mod.rs | 1 + .../src/transport/polling.rs | 33 ++++++++ crates/engineioxide-client/tests/handshake.rs | 27 ++++++ crates/engineioxide-core/Cargo.toml | 22 +++++ crates/engineioxide-core/src/lib.rs | 7 +- crates/engineioxide-core/src/packet.rs | 38 ++++++++- .../src}/payload/buf.rs | 0 .../src}/payload/decoder.rs | 9 +- .../src}/payload/encoder.rs | 82 +++++++++---------- .../src}/payload/mod.rs | 18 ++-- .../src/payload}/peekable.rs | 7 +- .../src/{transport.rs => protocol.rs} | 39 +++++++++ crates/engineioxide/Cargo.toml | 12 +-- crates/engineioxide/src/engine.rs | 3 +- crates/engineioxide/src/errors.rs | 21 ++--- crates/engineioxide/src/lib.rs | 6 +- crates/engineioxide/src/service/mod.rs | 3 +- crates/engineioxide/src/service/parser.rs | 38 +-------- crates/engineioxide/src/socket.rs | 21 ++--- .../transport/{polling/mod.rs => polling.rs} | 20 ++--- crates/engineioxide/src/transport/ws.rs | 6 +- 27 files changed, 297 insertions(+), 184 deletions(-) create mode 100644 crates/engineioxide-client/src/client.rs create mode 100644 crates/engineioxide-client/src/io.rs create mode 100644 crates/engineioxide-client/src/transport/mod.rs create mode 100644 crates/engineioxide-client/src/transport/polling.rs create mode 100644 crates/engineioxide-client/tests/handshake.rs rename crates/{engineioxide/src/transport/polling => engineioxide-core/src}/payload/buf.rs (100%) rename crates/{engineioxide/src/transport/polling => engineioxide-core/src}/payload/decoder.rs (99%) rename crates/{engineioxide/src/transport/polling => engineioxide-core/src}/payload/encoder.rs (88%) rename crates/{engineioxide/src/transport/polling => engineioxide-core/src}/payload/mod.rs (86%) rename crates/{engineioxide/src => engineioxide-core/src/payload}/peekable.rs (90%) rename crates/engineioxide-core/src/{transport.rs => protocol.rs} (60%) rename crates/engineioxide/src/transport/{polling/mod.rs => polling.rs} (93%) diff --git a/Cargo.lock b/Cargo.lock index a5c473cf..b7d9af9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,7 +646,6 @@ name = "engineioxide" version = "0.17.0" dependencies = [ "axum", - "base64 0.22.1", "bytes", "criterion", "engineioxide-core", @@ -657,8 +656,6 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "itoa", - "memchr", "pin-project-lite", "serde", "serde_json", @@ -672,17 +669,14 @@ dependencies = [ "tower-service", "tracing", "tracing-subscriber", - "unicode-segmentation", ] [[package]] name = "engineioxide-client" version = "0.17.0" dependencies = [ - "axum", - "base64 0.22.1", "bytes", - "criterion", + "engineioxide", "engineioxide-core", "futures-core", "futures-util", @@ -691,20 +685,15 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "itoa", - "memchr", "pin-project-lite", "serde", "serde_json", "smallvec", "thiserror 2.0.12", "tokio", - "tokio-stream", "tokio-tungstenite", - "tokio-util", "tracing", "tracing-subscriber", - "unicode-segmentation", ] [[package]] @@ -713,9 +702,19 @@ version = "0.2.0" dependencies = [ "base64 0.22.1", "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "memchr", "rand 0.9.1", "serde", "serde_json", + "smallvec", + "tokio", + "tracing", + "unicode-segmentation", ] [[package]] @@ -1069,6 +1068,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -2719,6 +2719,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.26.2" @@ -2855,6 +2861,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index ffe66ede..4767d5cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ bytes = { version = "1.10", features = ["serde"] } futures-core = "0.3" futures-util = { version = "0.3", default-features = false, features = ["std"] } tokio = "1.46" -tokio-tungstenite = "0.26" +tokio-tungstenite = { version = "0.26", default-features = false } serde = { version = "1.0", features = ["derive"] } smallvec = { version = "1.15", features = ["union"] } serde_json = "1.0" diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index 6d18b4dd..14fd4920 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -23,33 +23,22 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt", "time"] } -hyper.workspace = true +hyper = { workspace = true, features = ["client", "http1"] } tokio-tungstenite.workspace = true http-body-util.workspace = true pin-project-lite.workspace = true smallvec.workspace = true hyper-util = { workspace = true, features = ["tokio"] } -base64 = "0.22" - # Tracing tracing = { workspace = true, optional = true } -# Engine.io V3 payload -itoa = { workspace = true, optional = true } -memchr = { version = "2.7", optional = true } -unicode-segmentation = { version = "1.12", optional = true } - [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } tracing-subscriber.workspace = true -hyper = { workspace = true, features = ["server", "http1"] } -criterion.workspace = true -axum.workspace = true -tokio-stream.workspace = true -tokio-util.workspace = true +engineioxide = { path = "../engineioxide" } [features] -v3 = ["memchr", "unicode-segmentation", "itoa"] +v3 = ["engineioxide-core/v3"] tracing = ["dep:tracing"] __test_harness = [] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/engineioxide-client/src/client.rs @@ -0,0 +1 @@ + diff --git a/crates/engineioxide-client/src/io.rs b/crates/engineioxide-client/src/io.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/engineioxide-client/src/io.rs @@ -0,0 +1 @@ + diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 0689dcf1..2743c3c9 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -1,3 +1,9 @@ #![warn(clippy::pedantic)] - +#![allow(clippy::similar_names)] //! Engine.IO client library for Rust. + +mod client; +mod io; +mod transport; + +pub use crate::transport::polling::PollingClient; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs new file mode 100644 index 00000000..505916a0 --- /dev/null +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -0,0 +1 @@ +pub mod polling; diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs new file mode 100644 index 00000000..58e1d33e --- /dev/null +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -0,0 +1,33 @@ +use bytes::Bytes; +use engineioxide_core::Packet; +use http::Request; +use http_body_util::BodyExt; +use http_body_util::Full; +use hyper::service::Service as HyperSvc; + +pub struct PollingClient { + svc: S, +} +impl PollingClient +where + S: HyperSvc>>, + S::Response: hyper::body::Body, + ::Error: std::fmt::Debug, + ::Data: std::fmt::Debug, + S::Error: std::fmt::Debug, +{ + pub fn new(svc: S) -> Self { + Self { svc } + } + pub async fn handshake(&self) -> Packet { + let req = Request::builder() + .method("GET") + .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") + .body(Full::default()) + .unwrap(); + let res = self.svc.call(req).await; + let body = res.unwrap().collect().await.unwrap(); + let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap()); + packet.unwrap() + } +} diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs new file mode 100644 index 00000000..3ef72fcb --- /dev/null +++ b/crates/engineioxide-client/tests/handshake.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use bytes::Bytes; +use engineioxide::handler::EngineIoHandler; +use engineioxide::{DisconnectReason, service::EngineIoService}; +use engineioxide::{Socket, Str}; +use engineioxide_client::PollingClient; + +#[derive(Debug)] +struct Handler; +impl EngineIoHandler for Handler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) {} + + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) {} + + fn on_message(self: &Arc, msg: Str, socket: Arc>) {} + + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) {} +} +#[tokio::test] +async fn handshake() { + let svc = EngineIoService::new(Arc::new(Handler)); + let client = PollingClient::new(svc); + let packet = client.handshake().await; + dbg!(packet); +} diff --git a/crates/engineioxide-core/Cargo.toml b/crates/engineioxide-core/Cargo.toml index 42daaf7a..33d2d6d8 100644 --- a/crates/engineioxide-core/Cargo.toml +++ b/crates/engineioxide-core/Cargo.toml @@ -18,3 +18,25 @@ base64 = "0.22" serde.workspace = true bytes.workspace = true serde_json.workspace = true +http-body.workspace = true +http-body-util.workspace = true +http.workspace = true +futures-util.workspace = true +tokio = { workspace = true, features = ["sync"] } +smallvec.workspace = true + +# Engine.io V3 payload +itoa = { workspace = true, optional = true } +memchr = { version = "2.7", optional = true } +unicode-segmentation = { version = "1.12", optional = true } + +# Tracing +tracing = { workspace = true, optional = true } + + +[features] +v3 = ["dep:memchr", "dep:unicode-segmentation", "dep:itoa"] +tracing = ["dep:tracing"] + +[dev-dependencies] +tokio = { workspace = true, features = ["sync", "macros", "rt"] } diff --git a/crates/engineioxide-core/src/lib.rs b/crates/engineioxide-core/src/lib.rs index 3149548c..9cd36e90 100644 --- a/crates/engineioxide-core/src/lib.rs +++ b/crates/engineioxide-core/src/lib.rs @@ -30,11 +30,12 @@ #![doc = include_str!("../README.md")] mod packet; +mod protocol; mod sid; mod str; -mod transport; -pub use packet::{OpenPacket, Packet, PacketParseError}; +pub use packet::{OpenPacket, Packet, PacketBuf, PacketParseError}; +pub use protocol::{ProtocolVersion, TransportType, UnknownTransportError}; pub use sid::Sid; pub use str::Str; -pub use transport::{TransportType, UnknownTransportError}; +pub mod payload; diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index 4ce60300..ec48c370 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -2,7 +2,8 @@ use std::fmt; use base64::{Engine, engine::general_purpose}; use bytes::Bytes; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use smallvec::SmallVec; use crate::{Sid, Str}; @@ -53,6 +54,8 @@ pub enum Packet { /// An error that occurs when parsing a packet. #[derive(Debug)] pub enum PacketParseError { + /// Invalid connect packet + InvalidConnectPacket(serde_json::Error), /// The packet type is invalid. InvalidPacketType(Option), /// The packet payload is invalid. @@ -72,6 +75,7 @@ pub enum PacketParseError { impl fmt::Display for PacketParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + PacketParseError::InvalidConnectPacket(e) => write!(f, "invalid connect packet: {e}"), PacketParseError::InvalidPacketType(c) => write!(f, "invalid packet type: {c:?}"), PacketParseError::InvalidPacketPayload => write!(f, "invalid packet payload"), PacketParseError::InvalidPacketLen => write!(f, "invalid packet length"), @@ -101,6 +105,11 @@ impl From for PacketParseError { PacketParseError::InvalidUtf8Boundary(err) } } +impl From for PacketParseError { + fn from(err: serde_json::Error) -> Self { + PacketParseError::InvalidConnectPacket(err) + } +} impl std::error::Error for PacketParseError {} impl Packet { @@ -204,6 +213,7 @@ impl TryFrom for Packet { .ok_or(PacketParseError::InvalidPacketType(None))?; let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; let res = match packet_type { + b'0' => Packet::Open(serde_json::from_str(value.slice(1..).as_str())?), b'1' => Packet::Close, b'2' if is_upgrade => Packet::PingUpgrade, b'2' => Packet::Ping, @@ -236,7 +246,7 @@ impl TryFrom for Packet { } /// An OpenPacket is used to initiate a connection -#[derive(Debug, Clone, Serialize, PartialEq, PartialOrd)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, PartialOrd)] #[serde(rename_all = "camelCase")] pub struct OpenPacket { /// The session ID. @@ -265,6 +275,13 @@ impl Default for OpenPacket { } } +/// Buffered packets to send to the client. +/// It is used to ensure atomicity when sending multiple packets to the client. +/// +/// The [`PacketBuf`] stack size will impact the dynamically allocated buffer +/// of the internal mpsc channel. +pub type PacketBuf = SmallVec<[Packet; 2]>; + #[cfg(test)] mod tests { @@ -290,6 +307,23 @@ mod tests { ); } + #[test] + fn test_open_packet_deserialize() { + let sid = Sid::new(); + let ref_packet = OpenPacket { + sid, + upgrades: vec!["websocket".to_string()], + ping_interval: Duration::from_millis(25000).as_millis() as u64, + ping_timeout: Duration::from_millis(20000).as_millis() as u64, + max_payload: 100000, + }; + let packet_str = format!( + "0{{\"sid\":\"{sid}\",\"upgrades\":[\"websocket\"],\"pingInterval\":25000,\"pingTimeout\":20000,\"maxPayload\":100000}}" + ); + let packet = Packet::try_from(packet_str).unwrap(); + assert!(matches!(packet, Packet::Open(p) if p == ref_packet)); + } + #[test] fn test_message_packet() { let packet = Packet::Message("hello".into()); diff --git a/crates/engineioxide/src/transport/polling/payload/buf.rs b/crates/engineioxide-core/src/payload/buf.rs similarity index 100% rename from crates/engineioxide/src/transport/polling/payload/buf.rs rename to crates/engineioxide-core/src/payload/buf.rs diff --git a/crates/engineioxide/src/transport/polling/payload/decoder.rs b/crates/engineioxide-core/src/payload/decoder.rs similarity index 99% rename from crates/engineioxide/src/transport/polling/payload/decoder.rs rename to crates/engineioxide-core/src/payload/decoder.rs index 536ce1c4..555a94ba 100644 --- a/crates/engineioxide/src/transport/polling/payload/decoder.rs +++ b/crates/engineioxide-core/src/payload/decoder.rs @@ -5,9 +5,8 @@ //! - v3_decoder: Decodes the payload stream according to the [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) //! -use engineioxide_core::{Packet, PacketParseError}; -use futures_core::Stream; -use futures_util::StreamExt; +use crate::{Packet, PacketParseError}; +use futures_util::{Stream, StreamExt}; use bytes::Buf; use http_body::Body; @@ -137,7 +136,7 @@ where { use std::io::Read; - use crate::transport::polling::payload::{ + use crate::payload::{ BINARY_PACKET_IDENTIFIER_V3, BINARY_PACKET_SEPARATOR_V3, STRING_PACKET_IDENTIFIER_V3, }; @@ -234,7 +233,7 @@ pub fn v3_string_decoder( use std::io::ErrorKind; use unicode_segmentation::UnicodeSegmentation; - use crate::transport::polling::payload::STRING_PACKET_SEPARATOR_V3; + use crate::payload::STRING_PACKET_SEPARATOR_V3; #[cfg(feature = "tracing")] tracing::debug!("decoding payload with v3 string decoder"); diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs similarity index 88% rename from crates/engineioxide/src/transport/polling/payload/encoder.rs rename to crates/engineioxide-core/src/payload/encoder.rs index 2115588e..c0fae946 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -7,13 +7,14 @@ //! * binary encoder (used when there are binary packets and the client supports binary) //! +use smallvec::smallvec; use tokio::sync::MutexGuard; use crate::{ - errors::Error, peekable::PeekableReceiver, socket::PacketBuf, - transport::polling::payload::Payload, + Packet, + packet::PacketBuf, + payload::{Payload, peekable::PeekableReceiver}, }; -use engineioxide_core::Packet; /// Try to immediately poll a new packet buf from the rx channel and check that the new packet can be added to the payload /// @@ -56,10 +57,9 @@ fn try_recv_packet( /// Same as [`try_recv_packet`] /// but wait for a new packet if there is no packet in the buffer -async fn recv_packet( - rx: &mut MutexGuard<'_, PeekableReceiver>, -) -> Result { - let packet = rx.recv().await.ok_or(Error::Aborted)?; +async fn recv_packet(rx: &mut MutexGuard<'_, PeekableReceiver>) -> PacketBuf { + let packet = rx.recv().await.unwrap_or(smallvec![]); + if Some(&Packet::Close) == packet.first() { #[cfg(feature = "tracing")] tracing::debug!("Received close packet, closing channel"); @@ -68,7 +68,7 @@ async fn recv_packet( #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", packet); - Ok(packet) + packet } /// Encode multiple packets into a string payload according to the @@ -76,8 +76,8 @@ async fn recv_packet( pub async fn v4_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, -) -> Result { - use crate::transport::polling::payload::PACKET_SEPARATOR_V4; +) -> Payload { + use crate::payload::PACKET_SEPARATOR_V4; #[cfg(feature = "tracing")] tracing::debug!("encoding payload with v4 encoder"); @@ -100,21 +100,21 @@ pub async fn v4_encoder( // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await?; + let packets = recv_packet(&mut rx).await; for packet in packets { let packet: String = packet.into(); data.push_str(&packet); } } - Ok(Payload::new(data.into(), false)) + Payload::new(data.into(), false) } /// Encode one packet into a *binary* payload according to the /// [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) #[cfg(feature = "v3")] -pub fn v3_bin_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> Result<(), Error> { - use crate::transport::polling::payload::BINARY_PACKET_SEPARATOR_V3; +pub fn v3_bin_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) { + use crate::payload::BINARY_PACKET_SEPARATOR_V3; use bytes::BufMut; let mut itoa = itoa::Buffer::new(); @@ -148,14 +148,13 @@ pub fn v3_bin_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> Resu data.extend_from_slice(packet.as_bytes()); // packet } }; - Ok(()) } /// Encode one packet into a *string* payload according to the /// [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) #[cfg(feature = "v3")] -pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> Result<(), Error> { - use crate::transport::polling::payload::STRING_PACKET_SEPARATOR_V3; +pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) { + use crate::payload::STRING_PACKET_SEPARATOR_V3; use bytes::BufMut; let packet: String = packet.into(); let packet = format!( @@ -165,7 +164,6 @@ pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> R packet ); data.put_slice(packet.as_bytes()); - Ok(()) } /// Encode multiple packet packet into a *string* payload if there is no binary packet or into a *binary* payload if there are binary packets @@ -174,7 +172,7 @@ pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) -> R pub async fn v3_binary_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, -) -> Result { +) -> Payload { let mut data = bytes::BytesMut::new(); let mut packet_buffer: Vec = Vec::new(); @@ -203,25 +201,25 @@ pub async fn v3_binary_encoder( if has_binary { for packet in packet_buffer { - v3_bin_packet_encoder(packet, &mut data)?; + v3_bin_packet_encoder(packet, &mut data); } } else { for packet in packet_buffer { - v3_string_packet_encoder(packet, &mut data)?; + v3_string_packet_encoder(packet, &mut data); } } // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await?; + let packets = recv_packet(&mut rx).await; for packet in packets { match packet { Packet::BinaryV3(_) | Packet::Binary(_) => { - v3_bin_packet_encoder(packet, &mut data)?; + v3_bin_packet_encoder(packet, &mut data); has_binary = true; } packet => { - v3_string_packet_encoder(packet, &mut data)?; + v3_string_packet_encoder(packet, &mut data); } }; } @@ -229,7 +227,7 @@ pub async fn v3_binary_encoder( #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", &data); - Ok(Payload::new(data.freeze(), has_binary)) + Payload::new(data.freeze(), has_binary) } /// Encode multiple packet packet into a *string* payload according to the @@ -238,7 +236,7 @@ pub async fn v3_binary_encoder( pub async fn v3_string_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, -) -> Result { +) -> Payload { let mut data = bytes::BytesMut::new(); #[cfg(feature = "tracing")] @@ -251,19 +249,19 @@ pub async fn v3_string_encoder( let current_size = data.len() + PUNCTUATION_LEN + max_packet_size_len; while let Some(packets) = try_recv_packet(&mut rx, current_size, max_payload, true) { for packet in packets { - v3_string_packet_encoder(packet, &mut data)?; + v3_string_packet_encoder(packet, &mut data); } } // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await?; + let packets = recv_packet(&mut rx).await; for packet in packets { - v3_string_packet_encoder(packet, &mut data)?; + v3_string_packet_encoder(packet, &mut data); } } - Ok(Payload::new(data.freeze(), false)) + Payload::new(data.freeze(), false) } #[cfg(test)] @@ -290,7 +288,7 @@ mod tests { .unwrap(); tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) .unwrap(); - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); } @@ -311,17 +309,17 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, "4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await; assert_eq!(data, "bAQIDBA==\x1e4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await; assert_eq!(data, "4hello€".as_bytes()); } } @@ -342,9 +340,7 @@ mod tests { .unwrap(); tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) .unwrap(); - let Payload { - data, has_binary, .. - } = v3_string_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, has_binary } = v3_string_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); assert!(!has_binary); } @@ -368,12 +364,12 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, "7:4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10).await.unwrap(); + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10).await; assert_eq!(data, "10:b4AQIDBA==7:4hello€7:4hello€".as_bytes()); } } @@ -394,9 +390,7 @@ mod tests { &[1, 2, 3, 4] ))]) .unwrap(); - let Payload { - data, has_binary, .. - } = v3_binary_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, has_binary } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); assert!(has_binary); } @@ -424,12 +418,12 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); } { let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, "7:4hello€7:4hello€".as_bytes()); } } diff --git a/crates/engineioxide/src/transport/polling/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs similarity index 86% rename from crates/engineioxide/src/transport/polling/payload/mod.rs rename to crates/engineioxide-core/src/payload/mod.rs index 9dd2da8a..bd6cc4cf 100644 --- a/crates/engineioxide/src/transport/polling/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -1,18 +1,18 @@ //! Payload encoder and decoder for polling transport. -use crate::{ - errors::Error, peekable::PeekableReceiver, service::ProtocolVersion, socket::PacketBuf, -}; -use engineioxide_core::{Packet, PacketParseError}; +use crate::{Packet, PacketParseError, ProtocolVersion, packet::PacketBuf}; use bytes::Bytes; -use futures_core::Stream; +use futures_util::Stream; use http::Request; use tokio::sync::MutexGuard; mod buf; mod decoder; mod encoder; +mod peekable; + +pub use peekable::PeekableReceiver; const PACKET_SEPARATOR_V4: u8 = b'\x1e'; #[cfg(feature = "v3")] @@ -24,6 +24,7 @@ const STRING_PACKET_IDENTIFIER_V3: u8 = 0x00; #[cfg(feature = "v3")] const BINARY_PACKET_IDENTIFIER_V3: u8 = 0x01; +/// Decode a payload into a stream of packets. pub fn decoder( body: Request + Unpin>, #[allow(unused_variables)] protocol: ProtocolVersion, @@ -55,23 +56,26 @@ pub fn decoder( } /// A payload to transmit to the client through http polling -#[non_exhaustive] pub struct Payload { + /// The data of the payload. pub data: Bytes, + /// Whether the payload contains binary data. pub has_binary: bool, } impl Payload { + /// Creates a new payload with the given data and binary flag. pub fn new(data: Bytes, has_binary: bool) -> Self { Self { data, has_binary } } } +/// Encodes a payload into a byte stream. pub async fn encoder( rx: MutexGuard<'_, PeekableReceiver>, #[allow(unused_variables)] protocol: ProtocolVersion, #[cfg(feature = "v3")] supports_binary: bool, max_payload: u64, -) -> Result { +) -> Payload { #[cfg(feature = "v3")] { match protocol { diff --git a/crates/engineioxide/src/peekable.rs b/crates/engineioxide-core/src/payload/peekable.rs similarity index 90% rename from crates/engineioxide/src/peekable.rs rename to crates/engineioxide-core/src/payload/peekable.rs index 87d2d74c..d4761a57 100644 --- a/crates/engineioxide/src/peekable.rs +++ b/crates/engineioxide-core/src/payload/peekable.rs @@ -11,15 +11,18 @@ pub struct PeekableReceiver { next: Option, } impl PeekableReceiver { + /// Create a new peekable receiver pub fn new(rx: Receiver) -> Self { Self { rx, next: None } } + /// Peek the next packet without consuming it pub fn peek(&mut self) -> Option<&T> { if self.next.is_none() { self.next = self.rx.try_recv().ok(); } self.next.as_ref() } + /// Receive the next packet, consuming it pub async fn recv(&mut self) -> Option { if self.next.is_none() { self.rx.recv().await @@ -27,6 +30,7 @@ impl PeekableReceiver { self.next.take() } } + /// Try to receive the next packet without blocking pub fn try_recv(&mut self) -> Result { if self.next.is_none() { self.rx.try_recv() @@ -35,6 +39,7 @@ impl PeekableReceiver { } } + /// Close the receiver pub fn close(&mut self) { self.rx.close() } @@ -47,7 +52,7 @@ mod tests { #[tokio::test] async fn peek() { use super::PeekableReceiver; - use engineioxide_core::Packet; + use crate::Packet; use tokio::sync::mpsc::channel; let (tx, rx) = channel(1); diff --git a/crates/engineioxide-core/src/transport.rs b/crates/engineioxide-core/src/protocol.rs similarity index 60% rename from crates/engineioxide-core/src/transport.rs rename to crates/engineioxide-core/src/protocol.rs index 608b5e02..1a7474c6 100644 --- a/crates/engineioxide-core/src/transport.rs +++ b/crates/engineioxide-core/src/protocol.rs @@ -56,3 +56,42 @@ impl From for String { } } } + +#[derive(Debug)] +pub struct UnknownProtocolVersionError; +impl std::fmt::Display for UnknownProtocolVersionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unknown protocol version") + } +} +impl std::error::Error for UnknownProtocolVersionError {} + +/// The engine.io protocol version +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum ProtocolVersion { + /// The protocol version 3 + V3 = 3, + /// The protocol version 4 + V4 = 4, +} + +impl FromStr for ProtocolVersion { + type Err = UnknownProtocolVersionError; + + #[cfg(feature = "v3")] + fn from_str(s: &str) -> Result { + match s { + "3" => Ok(ProtocolVersion::V3), + "4" => Ok(ProtocolVersion::V4), + _ => Err(UnknownProtocolVersionError), + } + } + + #[cfg(not(feature = "v3"))] + fn from_str(s: &str) -> Result { + match s { + "4" => Ok(ProtocolVersion::V4), + _ => Err(UnknownProtocolVersionError), + } + } +} diff --git a/crates/engineioxide/Cargo.toml b/crates/engineioxide/Cargo.toml index 44165616..336616df 100644 --- a/crates/engineioxide/Cargo.toml +++ b/crates/engineioxide/Cargo.toml @@ -32,22 +32,15 @@ tokio = { workspace = true, features = ["rt", "time"] } tower-service.workspace = true tower-layer.workspace = true hyper.workspace = true -tokio-tungstenite.workspace = true +tokio-tungstenite = { workspace = true, features = ["handshake"] } http-body-util.workspace = true pin-project-lite.workspace = true smallvec.workspace = true hyper-util = { workspace = true, features = ["tokio"] } -base64 = "0.22" - # Tracing tracing = { workspace = true, optional = true } -# Engine.io V3 payload -itoa = { workspace = true, optional = true } -memchr = { version = "2.7", optional = true } -unicode-segmentation = { version = "1.12", optional = true } - [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } tracing-subscriber.workspace = true @@ -56,8 +49,9 @@ criterion.workspace = true axum.workspace = true tokio-stream.workspace = true tokio-util.workspace = true + [features] -v3 = ["memchr", "unicode-segmentation", "itoa"] +v3 = ["engineioxide-core/v3"] tracing = ["dep:tracing"] __test_harness = [] diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index e98daf1f..38e2e19d 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -3,13 +3,12 @@ use std::{ sync::{Arc, RwLock}, }; -use engineioxide_core::{Sid, TransportType}; +use engineioxide_core::{ProtocolVersion, Sid, TransportType}; use http::request::Parts; use crate::{ config::EngineIoConfig, handler::EngineIoHandler, - service::ProtocolVersion, socket::{DisconnectReason, Socket}, }; diff --git a/crates/engineioxide/src/errors.rs b/crates/engineioxide/src/errors.rs index fbb20136..38a6495e 100644 --- a/crates/engineioxide/src/errors.rs +++ b/crates/engineioxide/src/errors.rs @@ -11,10 +11,6 @@ pub use engineioxide_core::PacketParseError; pub enum Error { #[error("error decoding packet from request: {0}")] PacketParse(#[from] PacketParseError), - #[error("error decoding packet: {0}")] - StrUtf8(#[from] std::str::Utf8Error), - #[error("io error: {0}")] - Io(#[from] std::io::Error), #[error("bad packet received")] BadPacket(Packet), #[error("ws transport error: {0}")] @@ -29,11 +25,11 @@ pub enum Error { HeartbeatTimeout, #[error("upgrade error")] Upgrade, - #[error("aborted connection")] - Aborted, - #[error("http error response: {0}")] - HttpErrorResponse(StatusCode), + #[error("multiple http polling error")] + MultipleHttpPolling, + #[error("invalid websocket Sec-WebSocket-Key http header")] + InvalidWebSocketKey, #[error("unknown session id")] UnknownSessionID(Sid), @@ -54,15 +50,14 @@ impl From for Response> { .unwrap() }; match err { - Error::HttpErrorResponse(code) => Response::builder() - .status(code) - .body(ResponseBody::empty_response()) - .unwrap(), Error::PacketParse(PacketParseError::PayloadTooLarge { .. }) => Response::builder() .status(413) .body(ResponseBody::empty_response()) .unwrap(), - Error::BadPacket(_) | Error::PacketParse(_) => Response::builder() + Error::BadPacket(_) + | Error::PacketParse(_) + | Error::MultipleHttpPolling + | Error::InvalidWebSocketKey => Response::builder() .status(400) .body(ResponseBody::empty_response()) .unwrap(), diff --git a/crates/engineioxide/src/lib.rs b/crates/engineioxide/src/lib.rs index 68ad1628..b0c9f0ef 100644 --- a/crates/engineioxide/src/lib.rs +++ b/crates/engineioxide/src/lib.rs @@ -31,13 +31,12 @@ )] #![doc = include_str!("../Readme.md")] -pub use engineioxide_core::Str; -pub use service::ProtocolVersion; +pub use engineioxide_core::{ProtocolVersion, Str, TransportType}; pub use socket::{DisconnectReason, Socket}; #[doc(hidden)] #[cfg(feature = "__test_harness")] -pub use engineioxide_core::{OpenPacket, Packet, PacketParseError, TransportType}; +pub use engineioxide_core::{OpenPacket, Packet, PacketParseError}; pub mod config; pub mod handler; @@ -54,5 +53,4 @@ pub mod sid { mod body; mod engine; mod errors; -mod peekable; mod transport; diff --git a/crates/engineioxide/src/service/mod.rs b/crates/engineioxide/src/service/mod.rs index c9b5ab54..6b9d1c13 100644 --- a/crates/engineioxide/src/service/mod.rs +++ b/crates/engineioxide/src/service/mod.rs @@ -33,6 +33,8 @@ use std::{ }; use bytes::Bytes; +#[cfg(feature = "__test_harness")] +use engineioxide_core::ProtocolVersion; use futures_util::future::{self, Ready}; use http::{Request, Response}; use http_body::Body; @@ -47,7 +49,6 @@ use crate::{ mod futures; mod parser; -pub use self::parser::ProtocolVersion; use self::{futures::ResponseFuture, parser::dispatch_req}; /// A `Service` that handles engine.io requests as a middleware. diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index 3ab4af82..c015f5e2 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -1,10 +1,10 @@ //! A Parser module to parse any `EngineIo` query -use std::{future::Future, str::FromStr, sync::Arc}; +use std::{future::Future, sync::Arc}; use http::{Method, Request, Response}; -use engineioxide_core::{Sid, TransportType}; +use engineioxide_core::{ProtocolVersion, Sid, TransportType}; use crate::{ body::ResponseBody, @@ -122,36 +122,6 @@ impl From for Response> { } } -/// The engine.io protocol version -#[derive(Debug, Copy, Clone, PartialEq)] -pub enum ProtocolVersion { - /// The protocol version 3 - V3 = 3, - /// The protocol version 4 - V4 = 4, -} - -impl FromStr for ProtocolVersion { - type Err = ParseError; - - #[cfg(feature = "v3")] - fn from_str(s: &str) -> Result { - match s { - "3" => Ok(ProtocolVersion::V3), - "4" => Ok(ProtocolVersion::V4), - _ => Err(ParseError::UnsupportedProtocolVersion), - } - } - - #[cfg(not(feature = "v3"))] - fn from_str(s: &str) -> Result { - match s { - "4" => Ok(ProtocolVersion::V4), - _ => Err(ParseError::UnsupportedProtocolVersion), - } - } -} - /// The request information extracted from the request URI. #[derive(Debug)] pub struct RequestInfo { @@ -178,8 +148,8 @@ impl RequestInfo { .split('&') .find(|s| s.starts_with("EIO=")) .and_then(|s| s.split('=').nth(1)) - .ok_or(UnsupportedProtocolVersion) - .and_then(|t| t.parse())?; + .and_then(|t| t.parse().ok()) + .ok_or(UnsupportedProtocolVersion)?; let sid = query .split('&') diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 5c833818..b33725a8 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -63,11 +63,11 @@ use std::{ time::Duration, }; -use crate::{ - config::EngineIoConfig, errors::Error, peekable::PeekableReceiver, service::ProtocolVersion, -}; +use crate::{config::EngineIoConfig, errors::Error}; use bytes::Bytes; -use engineioxide_core::{Packet, Str, TransportType}; +use engineioxide_core::{ + Packet, PacketBuf, ProtocolVersion, Str, TransportType, payload::PeekableReceiver, +}; use http::request::Parts; use smallvec::{SmallVec, smallvec}; use tokio::{ @@ -106,10 +106,8 @@ impl From<&Error> for Option { fn from(err: &Error) -> Self { use Error::*; match err { - WsTransport(_) | Io(_) => Some(DisconnectReason::TransportError), - BadPacket(_) | StrUtf8(_) | PacketParse(_) => { - Some(DisconnectReason::PacketParsingError) - } + WsTransport(_) => Some(DisconnectReason::TransportError), + BadPacket(_) | PacketParse(_) => Some(DisconnectReason::PacketParsingError), HeartbeatTimeout => Some(DisconnectReason::HeartbeatTimeout), _ => None, } @@ -158,13 +156,6 @@ impl Permit<'_> { } } -/// Buffered packets to send to the client. -/// It is used to ensure atomicity when sending multiple packets to the client. -/// -/// The [`PacketBuf`] stack size will impact the dynamically allocated buffer -/// of the internal mpsc channel. -pub(crate) type PacketBuf = SmallVec<[Packet; 2]>; - /// A [`Socket`] represents a client connection to the server. /// It is agnostic to the [`TransportType`]. /// diff --git a/crates/engineioxide/src/transport/polling/mod.rs b/crates/engineioxide/src/transport/polling.rs similarity index 93% rename from crates/engineioxide/src/transport/polling/mod.rs rename to crates/engineioxide/src/transport/polling.rs index 8f6a5559..74196b07 100644 --- a/crates/engineioxide/src/transport/polling/mod.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -2,25 +2,19 @@ use std::sync::Arc; use bytes::Bytes; +use engineioxide_core::payload::{self, Payload}; use futures_util::StreamExt; use http::{Request, Response, StatusCode}; use http_body::Body; use http_body_util::Full; -use engineioxide_core::{Packet, Sid, TransportType}; +use engineioxide_core::{Packet, ProtocolVersion, Sid, TransportType}; use crate::{ - DisconnectReason, - body::ResponseBody, - engine::EngineIo, - errors::Error, - handler::EngineIoHandler, - service::ProtocolVersion, - transport::{make_open_packet, polling::payload::Payload}, + DisconnectReason, body::ResponseBody, engine::EngineIo, errors::Error, + handler::EngineIoHandler, transport::make_open_packet, }; -mod payload; - /// Create a response for http request fn http_response( code: StatusCode, @@ -107,7 +101,7 @@ where Ok(s) => s, Err(_) => { socket.close(DisconnectReason::MultipleHttpPollingError); - return Err(Error::HttpErrorResponse(StatusCode::BAD_REQUEST)); + return Err(Error::MultipleHttpPolling); } }; @@ -118,9 +112,9 @@ where #[cfg(feature = "v3")] let Payload { data, has_binary } = - payload::encoder(rx, protocol, socket.supports_binary, max_payload).await?; + payload::encoder(rx, protocol, socket.supports_binary, max_payload).await; #[cfg(not(feature = "v3"))] - let Payload { data, has_binary } = payload::encoder(rx, protocol, max_payload).await?; + let Payload { data, has_binary } = payload::encoder(rx, protocol, max_payload).await; #[cfg(feature = "tracing")] tracing::debug!("[sid={sid}] sending data: {:?}", data); diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 924dd080..9b69c72e 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -24,11 +24,11 @@ use tokio_tungstenite::{ }, }; -use engineioxide_core::{Packet, Sid, Str, TransportType}; +use engineioxide_core::{Packet, ProtocolVersion, Sid, Str, TransportType}; use crate::{ DisconnectReason, Socket, body::ResponseBody, config::EngineIoConfig, engine::EngineIo, - errors::Error, handler::EngineIoHandler, service::ProtocolVersion, transport::make_open_packet, + errors::Error, handler::EngineIoHandler, transport::make_open_packet, }; /// Create a response for websocket upgrade @@ -62,7 +62,7 @@ pub fn new_req( let ws_key = parts .headers .get("Sec-WebSocket-Key") - .ok_or(Error::HttpErrorResponse(StatusCode::BAD_REQUEST))? + .ok_or(Error::InvalidWebSocketKey)? .clone(); tokio::spawn(async move { From a7f23e0f5e44ba96515826b114d5fcf3f12d5b2b Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 26 Jul 2025 23:17:18 +0200 Subject: [PATCH 04/41] feat(client): wip --- crates/engineioxide-client/src/client.rs | 71 +++++++++++++++++++ crates/engineioxide-client/src/lib.rs | 3 +- .../src/transport/polling.rs | 24 +++++-- crates/engineioxide-client/tests/handshake.rs | 52 +++++++++++--- crates/engineioxide-core/src/packet.rs | 6 ++ 5 files changed, 139 insertions(+), 17 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 8b137891..f9ea2c1a 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -1 +1,72 @@ +use std::{ + pin::Pin, + sync::Mutex, + task::{Context, Poll}, +}; +use bytes::Bytes; +use engineioxide_core::{Packet, PacketBuf, Sid, Str, TransportType}; +use futures_core::Stream; +use http::Request; +use http_body_util::Full; +use hyper::service::Service as HyperSvc; +use smallvec::smallvec; +use tokio::sync::mpsc; + +use crate::HttpClient; + +pub struct Client { + pub transport: TransportType, + pub sid: Sid, + tx: mpsc::Sender, + pub(crate) rx: Mutex>, + inner: HttpClient, +} + +pub struct ClientStream {} + +impl Stream for ClientStream { + type Item = Packet; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } +} + +impl Client +where + S: HyperSvc>>, + S::Response: hyper::body::Body, + ::Error: std::fmt::Debug, + ::Data: std::fmt::Debug, + S::Error: std::fmt::Debug, +{ + pub async fn connect(svc: S) -> Result<(Self, ClientStream), ()> { + let (tx, rx) = mpsc::channel(255); + let inner = HttpClient::new(svc); + let packet = inner.handshake().await.unwrap(); + + let client = Client { + transport: TransportType::Polling, + sid: packet.sid, + tx, + rx: Mutex::new(rx), + inner, + }; + + let stream = ClientStream {}; + + Ok((client, stream)) + } + + pub fn emit(&self, msg: Str) { + self.tx.try_send(smallvec![Packet::Message(msg)]).unwrap(); + } + pub fn emit_binary(&self, data: Bytes) { + self.tx.try_send(smallvec![Packet::Binary(data)]).unwrap(); + } + + pub fn connected(&self) -> bool { + self.rx.try_lock().is_ok() + } +} diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 2743c3c9..469cca32 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -5,5 +5,4 @@ mod client; mod io; mod transport; - -pub use crate::transport::polling::PollingClient; +pub use crate::transport::polling::HttpClient; diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 58e1d33e..2cf7ee7b 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,14 +1,18 @@ use bytes::Bytes; +use engineioxide_core::OpenPacket; use engineioxide_core::Packet; +use engineioxide_core::PacketParseError; +use engineioxide_core::Sid; use http::Request; use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; -pub struct PollingClient { +pub struct HttpClient { svc: S, } -impl PollingClient + +impl HttpClient where S: HyperSvc>>, S::Response: hyper::body::Body, @@ -19,7 +23,7 @@ where pub fn new(svc: S) -> Self { Self { svc } } - pub async fn handshake(&self) -> Packet { + pub async fn handshake(&self) -> Result { let req = Request::builder() .method("GET") .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") @@ -27,7 +31,17 @@ where .unwrap(); let res = self.svc.call(req).await; let body = res.unwrap().collect().await.unwrap(); - let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap()); - packet.unwrap() + let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap())?; + match packet { + Packet::Open(open) => Ok(open), + _ => Err(PacketParseError::InvalidPacketType(Some('1'))), + } + } + + pub async fn post(&self, id: Sid, packet: impl Into) -> Result<(), S::Error> { + let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); + let req = Request::post(uri).body(Full::from(packet.into())).unwrap(); + self.svc.call(req).await?; + Ok(()) } } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 3ef72fcb..c3372bc6 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,24 +4,56 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::PollingClient; +use engineioxide_client::HttpClient; +use engineioxide_core::{Packet, Sid}; +use futures_util::TryFutureExt; +use tokio::sync::mpsc; + +#[derive(Debug, PartialEq, Eq)] +enum Event { + Connect(Sid), + Disconnect(Sid, DisconnectReason), + Message(Sid, Str), + Binary(Sid, Bytes), +} #[derive(Debug)] -struct Handler; +struct Handler { + tx: mpsc::Sender, +} + +impl Handler { + fn new() -> (Self, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(100); + (Self { tx }, rx) + } +} + impl EngineIoHandler for Handler { type Data = (); - fn on_connect(self: Arc, socket: Arc>) {} + fn on_connect(self: Arc, socket: Arc>) { + self.tx.try_send(Event::Connect(socket.id)).unwrap(); + } - fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) {} + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { + self.tx + .try_send(Event::Disconnect(socket.id, reason)) + .unwrap(); + } - fn on_message(self: &Arc, msg: Str, socket: Arc>) {} + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + self.tx.try_send(Event::Message(socket.id, msg)).unwrap(); + } - fn on_binary(self: &Arc, data: Bytes, socket: Arc>) {} + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { + self.tx.try_send(Event::Binary(socket.id, data)).unwrap(); + } } + #[tokio::test] async fn handshake() { - let svc = EngineIoService::new(Arc::new(Handler)); - let client = PollingClient::new(svc); - let packet = client.handshake().await; - dbg!(packet); + let (handler, mut rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + let packet = HttpClient::new(svc).handshake().await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(packet.sid)); } diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index ec48c370..6cb1950d 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -169,6 +169,12 @@ impl Packet { } } +impl From for Bytes { + fn from(value: Packet) -> Self { + String::from(value).into() + } +} + /// Serialize a [Packet] to a [String] according to the Engine.IO protocol impl From for String { fn from(packet: Packet) -> String { From 63011e0aab67da14e875dc60ecec656c58b4bbc6 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 27 Jul 2025 19:21:53 +0200 Subject: [PATCH 05/41] feat(client): wip --- Cargo.lock | 1 + crates/engineioxide-client/src/client.rs | 75 +++++++----- crates/engineioxide-client/src/lib.rs | 10 ++ .../engineioxide-client/src/transport/mod.rs | 19 +++ .../src/transport/polling.rs | 108 +++++++++++++++++- crates/engineioxide-core/Cargo.toml | 1 + crates/engineioxide-core/src/payload/mod.rs | 11 +- 7 files changed, 187 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7d9af9b..4b7cc7c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -708,6 +708,7 @@ dependencies = [ "http-body-util", "itoa", "memchr", + "pin-project-lite", "rand 0.9.1", "serde", "serde_json", diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index f9ea2c1a..f1ff8d77 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -5,31 +5,25 @@ use std::{ }; use bytes::Bytes; -use engineioxide_core::{Packet, PacketBuf, Sid, Str, TransportType}; +use engineioxide_core::{PacketBuf, Sid, TransportType}; use futures_core::Stream; +use futures_util::Sink; use http::Request; use http_body_util::Full; use hyper::service::Service as HyperSvc; -use smallvec::smallvec; -use tokio::sync::mpsc; +use tokio::sync::mpsc::{self, error::TrySendError}; -use crate::HttpClient; +use crate::{HttpClient, transport::Transport}; -pub struct Client { - pub transport: TransportType, - pub sid: Sid, - tx: mpsc::Sender, - pub(crate) rx: Mutex>, - inner: HttpClient, -} - -pub struct ClientStream {} - -impl Stream for ClientStream { - type Item = Packet; +pin_project_lite::pin_project! { + pub struct Client { + pub transport: Transport, + pub sid: Sid, + pub tx: mpsc::Sender, + pub(crate) rx: Mutex>, - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Poll::Pending + #[pin] + rrx: mpsc::Receiver, } } @@ -41,32 +35,55 @@ where ::Data: std::fmt::Debug, S::Error: std::fmt::Debug, { - pub async fn connect(svc: S) -> Result<(Self, ClientStream), ()> { + pub async fn connect(svc: S) -> Result { let (tx, rx) = mpsc::channel(255); let inner = HttpClient::new(svc); let packet = inner.handshake().await.unwrap(); + let (rtx, rrx) = mpsc::channel(100); let client = Client { - transport: TransportType::Polling, + transport: Transport::Polling(inner), sid: packet.sid, tx, rx: Mutex::new(rx), - inner, + rrx, }; - let stream = ClientStream {}; + Ok(client) + } + + pub fn connected(&self) -> bool { + self.rx.try_lock().is_ok() + } + + pub fn transport_type(&self) -> TransportType { + self.transport.transport_type() + } +} + +impl Sink for Client { + type Error = TrySendError; - Ok((client, stream)) + fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) } - pub fn emit(&self, msg: Str) { - self.tx.try_send(smallvec![Packet::Message(msg)]).unwrap(); + fn start_send(self: Pin<&mut Self>, packets: PacketBuf) -> Result<(), Self::Error> { + self.tx.try_send(packets) } - pub fn emit_binary(&self, data: Bytes) { - self.tx.try_send(smallvec![Packet::Binary(data)]).unwrap(); + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) } - pub fn connected(&self) -> bool { - self.rx.try_lock().is_ok() + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl Stream for Client { + type Item = PacketBuf; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.rrx.poll_recv(cx) } } diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 469cca32..9c252391 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -6,3 +6,13 @@ mod client; mod io; mod transport; pub use crate::transport::polling::HttpClient; + +#[macro_export] +macro_rules! poll { + ($expr:expr) => { + match $expr { + std::task::Poll::Pending => return std::task::Poll::Pending, + std::task::Poll::Ready(value) => value, + } + }; +} diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 505916a0..ba3a866b 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -1 +1,20 @@ +use engineioxide_core::TransportType; +use tokio_tungstenite::WebSocketStream; + +use crate::HttpClient; + pub mod polling; + +pub enum Transport { + Polling(HttpClient), + Websocket(), +} + +impl Transport { + pub fn transport_type(&self) -> TransportType { + match self { + Transport::Polling(_) => TransportType::Polling, + Transport::Websocket() => TransportType::Websocket, + } + } +} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 2cf7ee7b..c4ed3c79 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,15 +1,48 @@ +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; + use bytes::Bytes; use engineioxide_core::OpenPacket; use engineioxide_core::Packet; +use engineioxide_core::PacketBuf; use engineioxide_core::PacketParseError; +use engineioxide_core::ProtocolVersion; use engineioxide_core::Sid; +use engineioxide_core::payload; +use futures_core::Stream; +use futures_util::FutureExt; +use futures_util::Sink; +use futures_util::StreamExt; +use futures_util::TryStreamExt; use http::Request; use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; -pub struct HttpClient { - svc: S, +use crate::poll; + +pin_project_lite::pin_project! { + #[project = PollStateProj] + enum PollState { + No, + Pending { + #[pin] + fut: F + }, + Decoding { + stream: Pin>>> + } + } +} +pin_project_lite::pin_project! { + pub struct HttpClient + where + S: HyperSvc>>, + { + svc: S, + poll_state: PollState, + } } impl HttpClient @@ -21,7 +54,10 @@ where S::Error: std::fmt::Debug, { pub fn new(svc: S) -> Self { - Self { svc } + Self { + svc, + poll_state: PollState::No, + } } pub async fn handshake(&self) -> Result { let req = Request::builder() @@ -45,3 +81,69 @@ where Ok(()) } } + +impl Sink for HttpClient { + type Error = (); + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} + + fn start_send(self: Pin<&mut Self>, item: PacketBuf) -> Result<(), Self::Error> {} + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} +} + +impl Stream for HttpClient +where + S: HyperSvc>>, + S::Response: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, + S::Error: std::fmt::Debug, +{ + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.as_mut().poll_state { + PollState::No => { + let id = Sid::new(); + let uri = + format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); + let req = Request::get(uri).body(Full::new(Bytes::new())).unwrap(); + let fut = self.svc.call(req); + self.poll_state = PollState::Pending { fut }; + Poll::Pending + } + PollState::Pending { ref mut fut } => { + match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { + Ok(body) => { + let body = Box::pin(body); + let stream = + payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); + self.poll_state = PollState::Decoding { stream }; + Poll::Pending + } + Err(err) => todo!(), + } + } + PollState::Decoding { ref mut stream } => match poll!(stream.poll_next_unpin(cx)) { + Some(packet) => Poll::Ready(Some(packet)), + None => { + self.poll_state = PollState::No; + cx.waker().wake_by_ref(); + Poll::Pending + } + }, + } + } +} + +impl PollState { + fn get_decoding(self) -> Pin>>> { + match self { + PollState::Decoding { stream } => stream, + _ => unreachable!(), + } + } +} diff --git a/crates/engineioxide-core/Cargo.toml b/crates/engineioxide-core/Cargo.toml index 33d2d6d8..e9c23bde 100644 --- a/crates/engineioxide-core/Cargo.toml +++ b/crates/engineioxide-core/Cargo.toml @@ -24,6 +24,7 @@ http.workspace = true futures-util.workspace = true tokio = { workspace = true, features = ["sync"] } smallvec.workspace = true +pin-project-lite.workspace = true # Engine.io V3 payload itoa = { workspace = true, optional = true } diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index bd6cc4cf..b029d3bb 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -4,7 +4,7 @@ use crate::{Packet, PacketParseError, ProtocolVersion, packet::PacketBuf}; use bytes::Bytes; use futures_util::Stream; -use http::Request; +use http::HeaderValue; use tokio::sync::MutexGuard; mod buf; @@ -26,18 +26,17 @@ const BINARY_PACKET_IDENTIFIER_V3: u8 = 0x01; /// Decode a payload into a stream of packets. pub fn decoder( - body: Request + Unpin>, + body: impl http_body::Body + Unpin, + content_type: Option<&HeaderValue>, #[allow(unused_variables)] protocol: ProtocolVersion, max_payload: u64, ) -> impl Stream> { #[cfg(feature = "v3")] { use futures_util::future::Either; - use http::header::CONTENT_TYPE; #[cfg(feature = "tracing")] - tracing::debug!("decoding payload {:?}", body.headers().get(CONTENT_TYPE)); - let is_binary = - body.headers().get(CONTENT_TYPE) == Some(&"application/octet-stream".parse().unwrap()); + tracing::debug!("decoding payload {:?}", content_type); + let is_binary = content_type == Some(&"application/octet-stream".parse().unwrap()); match protocol { ProtocolVersion::V4 => Either::Left(decoder::v4_decoder(body, max_payload)), ProtocolVersion::V3 if is_binary => { From 60d27881863557c0152ce8c6770bd05a6dd422d5 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 3 Aug 2025 20:19:20 +0200 Subject: [PATCH 06/41] feat(client): wip --- crates/engineioxide-client/src/client.rs | 40 ++--- crates/engineioxide-client/src/lib.rs | 2 +- .../engineioxide-client/src/transport/mod.rs | 48 ++++-- .../src/transport/polling.rs | 142 ++++++++++++++---- 4 files changed, 172 insertions(+), 60 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index f1ff8d77..ab1a597b 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -4,32 +4,28 @@ use std::{ task::{Context, Poll}, }; -use bytes::Bytes; -use engineioxide_core::{PacketBuf, Sid, TransportType}; +use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; -use http::Request; -use http_body_util::Full; -use hyper::service::Service as HyperSvc; use tokio::sync::mpsc::{self, error::TrySendError}; -use crate::{HttpClient, transport::Transport}; +use crate::{ + HttpClient, + transport::{Transport, polling::PollingSvc}, +}; pin_project_lite::pin_project! { - pub struct Client { + pub struct Client { + #[pin] pub transport: Transport, pub sid: Sid, pub tx: mpsc::Sender, pub(crate) rx: Mutex>, - - #[pin] - rrx: mpsc::Receiver, } } -impl Client +impl Client where - S: HyperSvc>>, S::Response: hyper::body::Body, ::Error: std::fmt::Debug, ::Data: std::fmt::Debug, @@ -40,13 +36,11 @@ where let inner = HttpClient::new(svc); let packet = inner.handshake().await.unwrap(); - let (rtx, rrx) = mpsc::channel(100); let client = Client { - transport: Transport::Polling(inner), + transport: Transport::Polling { inner }, sid: packet.sid, tx, rx: Mutex::new(rx), - rrx, }; Ok(client) @@ -61,7 +55,7 @@ where } } -impl Sink for Client { +impl Sink for Client { type Error = TrySendError; fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { @@ -81,9 +75,15 @@ impl Sink for Client { } } -impl Stream for Client { - type Item = PacketBuf; - fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.rrx.poll_recv(cx) +impl Stream for Client +where + S::Response: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, + S::Error: std::fmt::Debug, +{ + type Item = Result; + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().transport.poll_next(cx) } } diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 9c252391..7cca1e2b 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -1,4 +1,4 @@ -#![warn(clippy::pedantic)] +// #![warn(clippy::pedantic)] #![allow(clippy::similar_names)] //! Engine.IO client library for Rust. diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index ba3a866b..24a03bfb 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -1,20 +1,50 @@ -use engineioxide_core::TransportType; -use tokio_tungstenite::WebSocketStream; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; -use crate::HttpClient; +use engineioxide_core::{Packet, PacketParseError, TransportType}; +use futures_core::Stream; + +use crate::{HttpClient, transport::polling::PollingSvc}; pub mod polling; -pub enum Transport { - Polling(HttpClient), - Websocket(), +pin_project_lite::pin_project! { + #[project = TransportProj] + pub enum Transport { + Polling { + #[pin] + inner: HttpClient + }, + Websocket { + #[pin] + inner: HttpClient + } + } } -impl Transport { +impl Transport { pub fn transport_type(&self) -> TransportType { match self { - Transport::Polling(_) => TransportType::Polling, - Transport::Websocket() => TransportType::Websocket, + Transport::Polling { .. } => TransportType::Polling, + Transport::Websocket { .. } => TransportType::Websocket, + } + } +} + +impl Stream for Transport +where + S::Response: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, + S::Error: std::fmt::Debug, +{ + type Item = Result; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.as_mut().project() { + TransportProj::Polling { inner } => inner.poll_next(cx), + TransportProj::Websocket { inner } => inner.poll_next(cx), } } } diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index c4ed3c79..a856a232 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -3,28 +3,32 @@ use std::task::Context; use std::task::Poll; use bytes::Bytes; +use bytes::BytesMut; use engineioxide_core::OpenPacket; use engineioxide_core::Packet; -use engineioxide_core::PacketBuf; use engineioxide_core::PacketParseError; use engineioxide_core::ProtocolVersion; use engineioxide_core::Sid; use engineioxide_core::payload; use futures_core::Stream; -use futures_util::FutureExt; use futures_util::Sink; use futures_util::StreamExt; -use futures_util::TryStreamExt; use http::Request; use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; +use pin_project_lite::pin_project; use crate::poll; -pin_project_lite::pin_project! { +pub trait PollingSvc: HyperSvc>> {} +impl>>> PollingSvc for S {} + +pin_project! { #[project = PollStateProj] + #[derive(Default)] enum PollState { + #[default] No, Pending { #[pin] @@ -35,19 +39,40 @@ pin_project_lite::pin_project! { } } } -pin_project_lite::pin_project! { - pub struct HttpClient - where - S: HyperSvc>>, + +pin_project! { + #[project = PostStateProj] + enum PostState { + Encoding { + body: BytesMut + }, + Pending { + #[pin] + fut: F + } + } +} + +impl Default for PostState { + fn default() -> Self { + PostState::Encoding { + body: BytesMut::default(), + } + } +} + +pin_project! { + pub struct HttpClient { svc: S, poll_state: PollState, + post_state: PostState, + sid: Option, } } -impl HttpClient +impl HttpClient where - S: HyperSvc>>, S::Response: hyper::body::Body, ::Error: std::fmt::Debug, ::Data: std::fmt::Debug, @@ -56,9 +81,12 @@ where pub fn new(svc: S) -> Self { Self { svc, - poll_state: PollState::No, + poll_state: PollState::default(), + post_state: PostState::default(), + sid: None, } } + pub async fn handshake(&self) -> Result { let req = Request::builder() .method("GET") @@ -82,21 +110,8 @@ where } } -impl Sink for HttpClient { - type Error = (); - - fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} - - fn start_send(self: Pin<&mut Self>, item: PacketBuf) -> Result<(), Self::Error> {} - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} - - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {} -} - -impl Stream for HttpClient +impl Stream for HttpClient where - S: HyperSvc>>, S::Response: hyper::body::Body + 'static, ::Error: std::fmt::Debug + 'static, ::Data: Send + std::fmt::Debug + 'static, @@ -119,26 +134,93 @@ where match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { Ok(body) => { let body = Box::pin(body); + //TODO: implement limited body let stream = payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); self.poll_state = PollState::Decoding { stream }; Poll::Pending } - Err(err) => todo!(), + Err(err) => { + tracing::debug!(?err, "got body error"); + Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload))) + } } } - PollState::Decoding { ref mut stream } => match poll!(stream.poll_next_unpin(cx)) { - Some(packet) => Poll::Ready(Some(packet)), - None => { + PollState::Decoding { ref mut stream } => { + if let Some(packet) = poll!(stream.poll_next_unpin(cx)) { + Poll::Ready(Some(packet)) + } else { self.poll_state = PollState::No; + // Should not be needed. cx.waker().wake_by_ref(); Poll::Pending } - }, + } } } } +impl Sink for HttpClient +where + S::Response: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, + S::Error: std::fmt::Debug, +{ + type Error = (); + + fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + match self.post_state { + PostState::Encoding { .. } => Poll::Ready(Ok(())), + _ => Poll::Pending, + } + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + let body = match &self.post_state { + PostState::Encoding { body } => body, + _ => panic!( + "unexpected state, Sink::poll_ready should always be called before Sink::start_send" + ), + }; + //TODO: write packet + + Ok(()) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.post_state { + PostState::Encoding { ref body } => { + let req = Request::post( + "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", + ) + .body(Full::new(body.clone().freeze())) //TODO: fix cloning + .unwrap(); + let fut = self.svc.call(req); + self.post_state = PostState::Pending { fut }; + cx.waker().wake_by_ref(); + Poll::Pending + } + PostState::Pending { ref mut fut } => { + match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { + Ok(res) => { + self.post_state = PostState::default(); + Poll::Ready(Ok(())) // TODO: check response == ok + } + Err(err) => { + self.post_state = PostState::default(); + todo!("handle error") + } + } + } + } + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + impl PollState { fn get_decoding(self) -> Pin>>> { match self { From 9dd59885ebdbb26e6c16f3b8d91d1e2a9147938a Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 3 Aug 2025 20:33:17 +0200 Subject: [PATCH 07/41] fix(engineio): add content type to decoder --- crates/engineioxide/src/transport/polling.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index 74196b07..732f157d 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use bytes::Bytes; use engineioxide_core::payload::{self, Payload}; use futures_util::StreamExt; -use http::{Request, Response, StatusCode}; +use http::{Request, Response, StatusCode, header::CONTENT_TYPE}; use http_body::Body; use http_body_util::Full; @@ -128,7 +128,7 @@ pub async fn post_req( engine: Arc>, protocol: ProtocolVersion, sid: Sid, - body: Request, + req: Request, ) -> Result>, Error> where H: EngineIoHandler, @@ -142,7 +142,9 @@ where return Err(Error::TransportMismatch); } - let packets = payload::decoder(body, protocol, engine.config.max_payload); + let (parts, body) = req.into_parts(); + let content_type = parts.headers.get(CONTENT_TYPE); + let packets = payload::decoder(body, content_type, protocol, engine.config.max_payload); futures_util::pin_mut!(packets); while let Some(packet) = packets.next().await { From 24ea9c51208cd529f081cc01d3908562b9c3c4b0 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 3 Aug 2025 22:01:45 +0200 Subject: [PATCH 08/41] feat(client): wip --- crates/engineioxide-client/Cargo.toml | 6 +- crates/engineioxide-client/src/client.rs | 73 +++++++++--- crates/engineioxide-client/src/lib.rs | 1 + .../engineioxide-client/src/transport/mod.rs | 39 ++++++- .../src/transport/polling.rs | 108 +++++++++++++----- crates/engineioxide-client/tests/handshake.rs | 30 ++++- 6 files changed, 200 insertions(+), 57 deletions(-) diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index 14fd4920..2d7b7fcb 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -35,10 +35,10 @@ tracing = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } -tracing-subscriber.workspace = true -engineioxide = { path = "../engineioxide" } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +engineioxide = { path = "../engineioxide", features = ["tracing", "v3"] } [features] v3 = ["engineioxide-core/v3"] -tracing = ["dep:tracing"] +tracing = ["dep:tracing", "engineioxide-core/tracing"] __test_harness = [] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index ab1a597b..74606e1f 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -1,23 +1,32 @@ use std::{ + fmt, pin::Pin, sync::Mutex, task::{Context, Poll}, }; -use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid, TransportType}; +use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid}; use futures_core::Stream; -use futures_util::Sink; +use futures_util::{ + Sink, StreamExt, + stream::{SplitSink, SplitStream}, +}; use tokio::sync::mpsc::{self, error::TrySendError}; use crate::{ - HttpClient, + HttpClient, poll, transport::{Transport, polling::PollingSvc}, }; pin_project_lite::pin_project! { pub struct Client { #[pin] - pub transport: Transport, + pub transport_rx: SplitStream>, + // TODO: is this the right implementation? We need something that can be driven itself. + // Otherwise we need a way to drive the transport_tx. Normally it should be driven by the user. + // But what if we need to send a PONG packet from the inner lib? + #[pin] + pub transport_tx: SplitSink, Packet>, pub sid: Sid, pub tx: mpsc::Sender, pub(crate) rx: Mutex>, @@ -26,18 +35,19 @@ pin_project_lite::pin_project! { impl Client where - S::Response: hyper::body::Body, - ::Error: std::fmt::Debug, - ::Data: std::fmt::Debug, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { pub async fn connect(svc: S) -> Result { let (tx, rx) = mpsc::channel(255); - let inner = HttpClient::new(svc); + let mut inner = HttpClient::new(svc); let packet = inner.handshake().await.unwrap(); + let transport = Transport::Polling { inner }; + let (transport_tx, transport_rx) = transport.split(); let client = Client { - transport: Transport::Polling { inner }, + transport_tx, + transport_rx, sid: packet.sid, tx, rx: Mutex::new(rx), @@ -49,10 +59,6 @@ where pub fn connected(&self) -> bool { self.rx.try_lock().is_ok() } - - pub fn transport_type(&self) -> TransportType { - self.transport.transport_type() - } } impl Sink for Client { @@ -77,13 +83,42 @@ impl Sink for Client { impl Stream for Client where - S::Response: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { type Item = Result; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().transport.poll_next(cx) + let project = self.project(); + match poll!(project.transport_rx.poll_next(cx)) { + Some(Ok(Packet::Ping)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + packet => Poll::Ready(packet), + } + } +} + +impl Sink for Client +where + S::Error: fmt::Debug, + ::Error: fmt::Debug, +{ + type Error = (); + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().transport_tx.poll_ready(cx) + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + self.project().transport_tx.start_send(item) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().transport_tx.poll_flush(cx) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().transport_tx.poll_close(cx) } } diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 7cca1e2b..fb39e455 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -5,6 +5,7 @@ mod client; mod io; mod transport; +pub use crate::client::Client; pub use crate::transport::polling::HttpClient; #[macro_export] diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 24a03bfb..d7bd1b1f 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -1,10 +1,12 @@ use std::{ + fmt, pin::Pin, task::{Context, Poll}, }; use engineioxide_core::{Packet, PacketParseError, TransportType}; use futures_core::Stream; +use futures_util::Sink; use crate::{HttpClient, transport::polling::PollingSvc}; @@ -35,10 +37,8 @@ impl Transport { impl Stream for Transport where - S::Response: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -48,3 +48,34 @@ where } } } +impl Sink for Transport { + type Error = (); + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_ready(cx), + TransportProj::Websocket { inner } => inner.poll_ready(cx), + } + } + + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + match self.project() { + TransportProj::Polling { inner } => inner.start_send(item), + TransportProj::Websocket { inner } => inner.start_send(item), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_flush(cx), + TransportProj::Websocket { inner } => inner.poll_flush(cx), + } + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.project() { + TransportProj::Polling { inner } => inner.poll_close(cx), + TransportProj::Websocket { inner } => inner.poll_close(cx), + } + } +} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index a856a232..145db955 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::pin::Pin; use std::task::Context; use std::task::Poll; @@ -14,6 +15,8 @@ use futures_core::Stream; use futures_util::Sink; use futures_util::StreamExt; use http::Request; +use http::Response; +use http::StatusCode; use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; @@ -21,8 +24,20 @@ use pin_project_lite::pin_project; use crate::poll; -pub trait PollingSvc: HyperSvc>> {} -impl>>> PollingSvc for S {} +pub trait PollingSvc: HyperSvc>, Response = Response> { + type Body: hyper::body::Body + 'static; +} + +impl PollingSvc for S +where + S: HyperSvc>, Response = Response>, + >>>::Error: fmt::Debug, + B: hyper::body::Body + 'static, + ::Error: std::fmt::Debug + 'static, + ::Data: Send + std::fmt::Debug + 'static, +{ + type Body = B; +} pin_project! { #[project = PollStateProj] @@ -73,10 +88,8 @@ pin_project! { impl HttpClient where - S::Response: hyper::body::Body, - ::Error: std::fmt::Debug, - ::Data: std::fmt::Debug, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { pub fn new(svc: S) -> Self { Self { @@ -87,7 +100,10 @@ where } } - pub async fn handshake(&self) -> Result { + pub async fn handshake(&mut self) -> Result { + #[cfg(feature = "tracing")] + tracing::trace!(?self, "handshake request"); + let req = Request::builder() .method("GET") .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") @@ -96,51 +112,55 @@ where let res = self.svc.call(req).await; let body = res.unwrap().collect().await.unwrap(); let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap())?; + match packet { - Packet::Open(open) => Ok(open), + Packet::Open(open) => { + self.sid = Some(open.sid); + Ok(open) + } _ => Err(PacketParseError::InvalidPacketType(Some('1'))), } } - - pub async fn post(&self, id: Sid, packet: impl Into) -> Result<(), S::Error> { - let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); - let req = Request::post(uri).body(Full::from(packet.into())).unwrap(); - self.svc.call(req).await?; - Ok(()) - } } impl Stream for HttpClient where - S::Response: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, - S::Error: std::fmt::Debug, + S::Error: fmt::Debug, + ::Error: fmt::Debug, { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + #[cfg(feature = "tracing")] + tracing::trace!(poll_state = ?self.poll_state, "polling"); + match self.as_mut().poll_state { PollState::No => { - let id = Sid::new(); + let id = self.sid.unwrap(); let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); let req = Request::get(uri).body(Full::new(Bytes::new())).unwrap(); let fut = self.svc.call(req); self.poll_state = PollState::Pending { fut }; + cx.waker().wake_by_ref(); Poll::Pending } PollState::Pending { ref mut fut } => { match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { - Ok(body) => { + Ok(res) => { + let (parts, body) = res.into_parts(); + dbg!(&parts); + assert!(parts.status == StatusCode::OK); let body = Box::pin(body); - //TODO: implement limited body + //TODO: implement limited body + Content-Type let stream = payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); self.poll_state = PollState::Decoding { stream }; + cx.waker().wake_by_ref(); Poll::Pending } Err(err) => { + #[cfg(feature = "tracing")] tracing::debug!(?err, "got body error"); Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload))) } @@ -160,13 +180,7 @@ where } } -impl Sink for HttpClient -where - S::Response: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, - S::Error: std::fmt::Debug, -{ +impl Sink for HttpClient { type Error = (); fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { @@ -177,6 +191,9 @@ where } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + #[cfg(feature = "tracing")] + tracing::trace!(post_state = ?self.post_state, "sending packet"); + let body = match &self.post_state { PostState::Encoding { body } => body, _ => panic!( @@ -189,6 +206,9 @@ where } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + #[cfg(feature = "tracing")] + tracing::trace!(post_state = ?self.post_state, "flushing"); + match self.post_state { PostState::Encoding { ref body } => { let req = Request::post( @@ -229,3 +249,33 @@ impl PollState { } } } + +impl fmt::Debug for PollState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::No => write!(f, "No"), + Self::Pending { .. } => write!(f, "Pending"), + Self::Decoding { .. } => write!(f, "Decoding"), + } + } +} +impl fmt::Debug for PostState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Encoding { body } => f + .debug_struct("Encoding") + .field("body_len", &body.len()) + .finish(), + Self::Pending { .. } => write!(f, "Pending"), + } + } +} +impl fmt::Debug for HttpClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HttpClient") + .field("poll_state", &self.poll_state) + .field("post_state", &self.post_state) + .field("sid", &self.sid) + .finish() + } +} diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index c3372bc6..63b42e29 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,13 +1,15 @@ +use std::str::FromStr; use std::sync::Arc; use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::HttpClient; +use engineioxide_client::{Client, HttpClient}; use engineioxide_core::{Packet, Sid}; -use futures_util::TryFutureExt; +use futures_util::{StreamExt, TryFutureExt}; use tokio::sync::mpsc; +use tracing_subscriber::EnvFilter; #[derive(Debug, PartialEq, Eq)] enum Event { @@ -52,8 +54,32 @@ impl EngineIoHandler for Handler { #[tokio::test] async fn handshake() { + tracing_subscriber::fmt::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init(); let (handler, mut rx) = Handler::new(); let svc = EngineIoService::new(Arc::new(handler)); let packet = HttpClient::new(svc).handshake().await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(packet.sid)); } + +#[tokio::test] +async fn connect() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); + let (handler, mut rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + let client = Client::connect(svc).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); + let (ctx, mut crx) = client.split(); + while let Some(event) = crx.next().await { + match event { + Ok(event) => { + dbg!(event); + } + Err(e) => panic!("Error: {e}"), + } + } +} From 1fac3aa092da431aadb7a6e061baa3e08abaf0c5 Mon Sep 17 00:00:00 2001 From: totodore Date: Fri, 10 Oct 2025 23:55:41 +0200 Subject: [PATCH 09/41] wip --- Cargo.lock | 2 +- crates/engineioxide-client/src/client.rs | 52 ++++++---------- .../src/transport/polling.rs | 62 ++++++++++++------- crates/engineioxide-client/tests/handshake.rs | 42 ++++++++++++- crates/engineioxide/src/transport/ws.rs | 3 +- 5 files changed, 102 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc0421c0..3bb9a4fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -695,7 +695,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.15", "tokio", "tokio-tungstenite", "tracing", diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 74606e1f..ea6f4223 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -8,7 +8,7 @@ use std::{ use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid}; use futures_core::Stream; use futures_util::{ - Sink, StreamExt, + Sink, SinkExt, StreamExt, stream::{SplitSink, SplitStream}, }; use tokio::sync::mpsc::{self, error::TrySendError}; @@ -18,6 +18,13 @@ use crate::{ transport::{Transport, polling::PollingSvc}, }; +type SendPongFut = Pin< + Box< + dyn Future, Packet> as Sink>::Error>> + + 'static, + >, +>; + pin_project_lite::pin_project! { pub struct Client { #[pin] @@ -27,9 +34,10 @@ pin_project_lite::pin_project! { // But what if we need to send a PONG packet from the inner lib? #[pin] pub transport_tx: SplitSink, Packet>, + pub sid: Sid, - pub tx: mpsc::Sender, - pub(crate) rx: Mutex>, + // pub tx: mpsc::Sender, + // pub(crate) rx: Mutex>, } } @@ -39,7 +47,6 @@ where ::Error: fmt::Debug, { pub async fn connect(svc: S) -> Result { - let (tx, rx) = mpsc::channel(255); let mut inner = HttpClient::new(svc); let packet = inner.handshake().await.unwrap(); @@ -49,36 +56,10 @@ where transport_tx, transport_rx, sid: packet.sid, - tx, - rx: Mutex::new(rx), }; Ok(client) } - - pub fn connected(&self) -> bool { - self.rx.try_lock().is_ok() - } -} - -impl Sink for Client { - type Error = TrySendError; - - fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn start_send(self: Pin<&mut Self>, packets: PacketBuf) -> Result<(), Self::Error> { - self.tx.try_send(packets) - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } } impl Stream for Client @@ -88,10 +69,17 @@ where { type Item = Result; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let project = self.project(); - match poll!(project.transport_rx.poll_next(cx)) { + let mut this = self.project(); + match poll!(this.transport_rx.poll_next(cx)) { Some(Ok(Packet::Ping)) => { cx.waker().wake_by_ref(); + // let mut tx = self.transport_tx.clone(); + // let fut = async move { + // tx.send(Packet::Pong).await?; + // tx.flush().await + // }; + // this.pending_pong.set(Some(Box::pin(fut))); + Poll::Pending } packet => Poll::Ready(packet), diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 145db955..31e1f692 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::fmt; use std::pin::Pin; use std::task::Context; @@ -58,6 +59,10 @@ pin_project! { pin_project! { #[project = PostStateProj] enum PostState { + /// Ideally the queue should gradually encode packets in an async fashion + Queuing { + queue: VecDeque + }, Encoding { body: BytesMut }, @@ -80,7 +85,9 @@ pin_project! { pub struct HttpClient { svc: S, + #[pin] poll_state: PollState, + #[pin] post_state: PostState, sid: Option, } @@ -109,6 +116,7 @@ where .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") .body(Full::default()) .unwrap(); + let res = self.svc.call(req).await; let body = res.unwrap().collect().await.unwrap(); let packet = Packet::try_from(String::from_utf8(body.to_bytes().to_vec()).unwrap())?; @@ -134,19 +142,20 @@ where #[cfg(feature = "tracing")] tracing::trace!(poll_state = ?self.poll_state, "polling"); - match self.as_mut().poll_state { - PollState::No => { + let mut poll_state_proj = self.as_mut().project().poll_state.project(); + match poll_state_proj { + PollStateProj::No => { let id = self.sid.unwrap(); let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); let req = Request::get(uri).body(Full::new(Bytes::new())).unwrap(); let fut = self.svc.call(req); - self.poll_state = PollState::Pending { fut }; + self.project().poll_state.set(PollState::Pending { fut }); cx.waker().wake_by_ref(); Poll::Pending } - PollState::Pending { ref mut fut } => { - match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { + PollStateProj::Pending { ref mut fut } => { + match poll!(fut.as_mut().poll(cx)) { Ok(res) => { let (parts, body) = res.into_parts(); dbg!(&parts); @@ -155,7 +164,11 @@ where //TODO: implement limited body + Content-Type let stream = payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); - self.poll_state = PollState::Decoding { stream }; + + self.project() + .poll_state + .set(PollState::Decoding { stream }); + cx.waker().wake_by_ref(); Poll::Pending } @@ -166,11 +179,12 @@ where } } } - PollState::Decoding { ref mut stream } => { + PollStateProj::Decoding { ref mut stream } => { if let Some(packet) = poll!(stream.poll_next_unpin(cx)) { + dbg!(&packet); Poll::Ready(Some(packet)) } else { - self.poll_state = PollState::No; + self.project().poll_state.set(PollState::No); // Should not be needed. cx.waker().wake_by_ref(); Poll::Pending @@ -194,12 +208,8 @@ impl Sink for HttpClient { #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "sending packet"); - let body = match &self.post_state { - PostState::Encoding { body } => body, - _ => panic!( - "unexpected state, Sink::poll_ready should always be called before Sink::start_send" - ), - }; + self.project().post_state.set(PostState::Encoding { body }); + //TODO: write packet Ok(()) @@ -209,26 +219,34 @@ impl Sink for HttpClient { #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "flushing"); - match self.post_state { - PostState::Encoding { ref body } => { + match self.as_mut().project().post_state.project() { + PostStateProj::Queuing { queue } => { + // payload::encoder(rx, protocol, supports_binary, max_payload) + if let Some(packet) = packet { + self.project().post_state.set(PostState::Encoding { body }); + } + Poll::Ready(Ok(())) + } + PostStateProj::Encoding { body } => { let req = Request::post( "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", ) - .body(Full::new(body.clone().freeze())) //TODO: fix cloning + .body(Full::new(body.clone())) //TODO: fix cloning .unwrap(); let fut = self.svc.call(req); - self.post_state = PostState::Pending { fut }; + self.project().post_state.set(PostState::Pending { fut }); cx.waker().wake_by_ref(); Poll::Pending } - PostState::Pending { ref mut fut } => { - match poll!(unsafe { Pin::new_unchecked(fut) }.poll(cx)) { + PostStateProj::Pending { fut } => { + match poll!(fut.poll(cx)) { Ok(res) => { - self.post_state = PostState::default(); + assert!(res.status().is_success()); + self.project().post_state.set(PostState::default()); Poll::Ready(Ok(())) // TODO: check response == ok } Err(err) => { - self.post_state = PostState::default(); + self.project().post_state.set(PostState::default()); todo!("handle error") } } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 63b42e29..4ab7a104 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,5 +1,6 @@ use std::str::FromStr; use std::sync::Arc; +use std::time::Duration; use bytes::Bytes; use engineioxide::handler::EngineIoHandler; @@ -7,7 +8,7 @@ use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; use engineioxide_client::{Client, HttpClient}; use engineioxide_core::{Packet, Sid}; -use futures_util::{StreamExt, TryFutureExt}; +use futures_util::{SinkExt, StreamExt, TryFutureExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -56,7 +57,8 @@ impl EngineIoHandler for Handler { async fn handshake() { tracing_subscriber::fmt::fmt() .with_env_filter(EnvFilter::from_default_env()) - .try_init(); + .try_init() + .ok(); let (handler, mut rx) = Handler::new(); let svc = EngineIoService::new(Arc::new(handler)); let packet = HttpClient::new(svc).handshake().await.unwrap(); @@ -73,7 +75,8 @@ async fn connect() { let svc = EngineIoService::new(Arc::new(handler)); let client = Client::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); - let (ctx, mut crx) = client.split(); + let (ctx, mut crx) = client.split::(); + while let Some(event) = crx.next().await { match event { Ok(event) => { @@ -83,3 +86,36 @@ async fn connect() { } } } + +#[tokio::test] +async fn spaaam() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); + let (handler, mut rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + let client = Client::connect(svc).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); + let (mut ctx, mut crx) = client.split::(); + + tokio::task::LocalSet::new() + .run_until(async move { + tokio::task::spawn_local(async move { + loop { + ctx.send(Packet::Message("Hello".into())).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + while let Some(event) = crx.next().await { + match event { + Ok(event) => { + dbg!(event); + } + Err(e) => panic!("Error: {e}"), + } + } + }) + .await; +} diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 2992f7bf..38156037 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -337,7 +337,8 @@ where #[cfg(feature = "tracing")] tracing::debug!("received first ping upgrade"); // Respond with a PongUpgrade packet - ws.send(Message::Text(pong.into())).await?; + ws.send(Message::Text(String::from(Packet::PongUpgrade).into())) + .await?; } p => Err(Error::BadPacket(p))?, }; From 4a4439f753a177e2a71610ec99969f8a9ce4b4c6 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 11 Oct 2025 23:23:56 +0200 Subject: [PATCH 10/41] wip --- .../src/transport/polling.rs | 65 +++--- crates/engineioxide-client/tests/handshake.rs | 2 +- crates/engineioxide-core/Cargo.toml | 3 +- .../engineioxide-core/src/payload/encoder.rs | 196 ++++++++---------- crates/engineioxide-core/src/payload/mod.rs | 10 +- .../engineioxide-core/src/payload/peekable.rs | 84 -------- crates/engineioxide/src/socket.rs | 10 +- crates/engineioxide/src/transport/polling.rs | 37 +++- 8 files changed, 164 insertions(+), 243 deletions(-) delete mode 100644 crates/engineioxide-core/src/payload/peekable.rs diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 31e1f692..a365988a 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -5,16 +5,18 @@ use std::task::Context; use std::task::Poll; use bytes::Bytes; -use bytes::BytesMut; use engineioxide_core::OpenPacket; use engineioxide_core::Packet; +use engineioxide_core::PacketBuf; use engineioxide_core::PacketParseError; use engineioxide_core::ProtocolVersion; use engineioxide_core::Sid; use engineioxide_core::payload; +use engineioxide_core::payload::Payload; use futures_core::Stream; use futures_util::Sink; use futures_util::StreamExt; +use futures_util::stream; use http::Request; use http::Response; use http::StatusCode; @@ -22,6 +24,7 @@ use http_body_util::BodyExt; use http_body_util::Full; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; +use smallvec::smallvec; use crate::poll; @@ -59,12 +62,13 @@ pin_project! { pin_project! { #[project = PostStateProj] enum PostState { - /// Ideally the queue should gradually encode packets in an async fashion + /// TODO: Ideally the queue should gradually encode packets in an async fashion Queuing { - queue: VecDeque + queue: VecDeque }, Encoding { - body: BytesMut + #[pin] + fut: Pin>>, }, Pending { #[pin] @@ -75,8 +79,8 @@ pin_project! { impl Default for PostState { fn default() -> Self { - PostState::Encoding { - body: BytesMut::default(), + PostState::Queuing { + queue: VecDeque::new(), } } } @@ -85,10 +89,13 @@ pin_project! { pub struct HttpClient { svc: S, + #[pin] poll_state: PollState, + #[pin] post_state: PostState, + sid: Option, } } @@ -199,7 +206,7 @@ impl Sink for HttpClient { fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { match self.post_state { - PostState::Encoding { .. } => Poll::Ready(Ok(())), + PostState::Queuing { .. } => Poll::Ready(Ok(())), _ => Poll::Pending, } } @@ -208,9 +215,10 @@ impl Sink for HttpClient { #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "sending packet"); - self.project().post_state.set(PostState::Encoding { body }); - - //TODO: write packet + match self.project().post_state.project() { + PostStateProj::Queuing { queue } => queue.push_back(smallvec![item]), + _ => panic!("unexpected post state"), + } Ok(()) } @@ -220,18 +228,26 @@ impl Sink for HttpClient { tracing::trace!(post_state = ?self.post_state, "flushing"); match self.as_mut().project().post_state.project() { + PostStateProj::Queuing { queue } if queue.is_empty() => Poll::Ready(Ok(())), PostStateProj::Queuing { queue } => { - // payload::encoder(rx, protocol, supports_binary, max_payload) - if let Some(packet) = packet { - self.project().post_state.set(PostState::Encoding { body }); - } - Poll::Ready(Ok(())) + let fut = payload::encoder( + stream::iter(queue.clone()), + ProtocolVersion::V4, + true, + 102400000, + ); + self.project() + .post_state + .set(PostState::Encoding { fut: Box::pin(fut) }); + cx.waker().wake_by_ref(); + Poll::Pending } - PostStateProj::Encoding { body } => { + PostStateProj::Encoding { fut } => { + let body = poll!(fut.poll(cx)); let req = Request::post( "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", ) - .body(Full::new(body.clone())) //TODO: fix cloning + .body(Full::new(body.data)) //TODO: fix cloning .unwrap(); let fut = self.svc.call(req); self.project().post_state.set(PostState::Pending { fut }); @@ -259,15 +275,6 @@ impl Sink for HttpClient { } } -impl PollState { - fn get_decoding(self) -> Pin>>> { - match self { - PollState::Decoding { stream } => stream, - _ => unreachable!(), - } - } -} - impl fmt::Debug for PollState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -280,11 +287,9 @@ impl fmt::Debug for PollState { impl fmt::Debug for PostState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Encoding { body } => f - .debug_struct("Encoding") - .field("body_len", &body.len()) - .finish(), + Self::Encoding { .. } => f.debug_struct("Encoding").finish(), Self::Pending { .. } => write!(f, "Pending"), + Self::Queuing { .. } => write!(f, "Queuing"), } } } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 4ab7a104..70d9d50b 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -103,7 +103,7 @@ async fn spaaam() { .run_until(async move { tokio::task::spawn_local(async move { loop { - ctx.send(Packet::Message("Hello".into())).await.unwrap(); + ctx.send(Packet::Pong).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; } }); diff --git a/crates/engineioxide-core/Cargo.toml b/crates/engineioxide-core/Cargo.toml index e9c23bde..ea146aff 100644 --- a/crates/engineioxide-core/Cargo.toml +++ b/crates/engineioxide-core/Cargo.toml @@ -22,7 +22,6 @@ http-body.workspace = true http-body-util.workspace = true http.workspace = true futures-util.workspace = true -tokio = { workspace = true, features = ["sync"] } smallvec.workspace = true pin-project-lite.workspace = true @@ -40,4 +39,4 @@ v3 = ["dep:memchr", "dep:unicode-segmentation", "dep:itoa"] tracing = ["dep:tracing"] [dev-dependencies] -tokio = { workspace = true, features = ["sync", "macros", "rt"] } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index c0fae946..30de4190 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -7,14 +7,12 @@ //! * binary encoder (used when there are binary packets and the client supports binary) //! +use std::pin::Pin; + +use futures_util::{FutureExt, Stream, StreamExt, stream::Peekable}; use smallvec::smallvec; -use tokio::sync::MutexGuard; -use crate::{ - Packet, - packet::PacketBuf, - payload::{Payload, peekable::PeekableReceiver}, -}; +use crate::{Packet, packet::PacketBuf, payload::Payload}; /// Try to immediately poll a new packet buf from the rx channel and check that the new packet can be added to the payload /// @@ -27,12 +25,12 @@ use crate::{ /// * `max_payload` - The maximum payload length /// * `b64` - If binary packets should be encoded in base64 fn try_recv_packet( - rx: &mut MutexGuard<'_, PeekableReceiver>, + mut rx: Pin<&mut Peekable>>, payload_len: usize, max_payload: u64, b64: bool, ) -> Option { - if let Some(packets) = rx.peek() { + if let Some(packets) = rx.as_mut().peek().now_or_never().flatten() { let size = packets.iter().map(|p| p.get_size_hint(b64)).sum::(); if (payload_len + size) as u64 > max_payload { #[cfg(feature = "tracing")] @@ -41,14 +39,14 @@ fn try_recv_packet( } } - let packets = rx.try_recv().ok(); + let packets = rx.next().now_or_never().flatten(); - if Some(&Packet::Close) == packets.as_ref().and_then(|p| p.first()) { - #[cfg(feature = "tracing")] - tracing::debug!("Received close packet, closing channel"); - rx.try_recv().ok(); - rx.close(); - } + // if Some(&Packet::Close) == packets.as_ref().and_then(|p| p.first()) { + // #[cfg(feature = "tracing")] + // tracing::debug!("Received close packet, closing channel"); + // rx.try_recv().ok(); + // rx.close(); + // } #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", packets); @@ -57,13 +55,14 @@ fn try_recv_packet( /// Same as [`try_recv_packet`] /// but wait for a new packet if there is no packet in the buffer -async fn recv_packet(rx: &mut MutexGuard<'_, PeekableReceiver>) -> PacketBuf { - let packet = rx.recv().await.unwrap_or(smallvec![]); +async fn recv_packet(mut rx: Pin<&mut Peekable>>) -> PacketBuf { + let packet = rx.next().await.unwrap_or(smallvec![]); if Some(&Packet::Close) == packet.first() { #[cfg(feature = "tracing")] tracing::debug!("Received close packet, closing channel"); - rx.close(); + + // rx.close(); } #[cfg(feature = "tracing")] @@ -74,7 +73,7 @@ async fn recv_packet(rx: &mut MutexGuard<'_, PeekableReceiver>) -> Pa /// Encode multiple packets into a string payload according to the /// [engine.io v4 protocol](https://socket.io/fr/docs/v4/engine-io-protocol/#http-long-polling-1) pub async fn v4_encoder( - mut rx: MutexGuard<'_, PeekableReceiver>, + mut rx: Pin<&mut Peekable>>, max_payload: u64, ) -> Payload { use crate::payload::PACKET_SEPARATOR_V4; @@ -86,7 +85,7 @@ pub async fn v4_encoder( // Send all packets in the buffer const PUNCTUATION_LEN: usize = 1; while let Some(packets) = - try_recv_packet(&mut rx, data.len() + PUNCTUATION_LEN, max_payload, true) + try_recv_packet(rx.as_mut(), data.len() + PUNCTUATION_LEN, max_payload, true) { for packet in packets { let packet: String = packet.into(); @@ -100,7 +99,7 @@ pub async fn v4_encoder( // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await; + let packets = recv_packet(rx.as_mut()).await; for packet in packets { let packet: String = packet.into(); data.push_str(&packet); @@ -170,7 +169,7 @@ pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) { /// according to the [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) #[cfg(feature = "v3")] pub async fn v3_binary_encoder( - mut rx: MutexGuard<'_, PeekableReceiver>, + mut rx: Pin<&mut Peekable>>, max_payload: u64, ) -> Payload { let mut data = bytes::BytesMut::new(); @@ -186,7 +185,7 @@ pub async fn v3_binary_encoder( // buffer all packets to find if there is binary packets let mut has_binary = false; - while let Some(packets) = try_recv_packet(&mut rx, estimated_size, max_payload, false) { + while let Some(packets) = try_recv_packet(rx.as_mut(), estimated_size, max_payload, false) { for packet in packets { if packet.is_binary() { has_binary = true; @@ -211,7 +210,7 @@ pub async fn v3_binary_encoder( // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await; + let packets = recv_packet(rx.as_mut()).await; for packet in packets { match packet { Packet::BinaryV3(_) | Packet::Binary(_) => { @@ -234,7 +233,7 @@ pub async fn v3_binary_encoder( /// [engine.io v3 protocol](https://github.com/socketio/engine.io-protocol/tree/v3#payload) #[cfg(feature = "v3")] pub async fn v3_string_encoder( - mut rx: MutexGuard<'_, PeekableReceiver>, + mut rx: Pin<&mut Peekable>>, max_payload: u64, ) -> Payload { let mut data = bytes::BytesMut::new(); @@ -247,7 +246,7 @@ pub async fn v3_string_encoder( let max_packet_size_len = max_payload.checked_ilog10().unwrap_or(0) as usize + 1; // Current size of the payload let current_size = data.len() + PUNCTUATION_LEN + max_packet_size_len; - while let Some(packets) = try_recv_packet(&mut rx, current_size, max_payload, true) { + while let Some(packets) = try_recv_packet(rx.as_mut(), current_size, max_payload, true) { for packet in packets { v3_string_packet_encoder(packet, &mut data); } @@ -255,7 +254,7 @@ pub async fn v3_string_encoder( // If there is no packet in the buffer, wait for the next packet if data.is_empty() { - let packets = recv_packet(&mut rx).await; + let packets = recv_packet(rx.as_mut()).await; for packet in packets { v3_string_packet_encoder(packet, &mut data); } @@ -267,9 +266,7 @@ pub async fn v3_string_encoder( #[cfg(test)] mod tests { use bytes::Bytes; - use tokio::sync::Mutex; - - use PacketBuf; + use futures_util::stream; use super::*; const MAX_PAYLOAD: u64 = 100_000; @@ -277,17 +274,14 @@ mod tests { #[tokio::test] async fn encode_v4_payload() { const PAYLOAD: &str = "4hello€\x1ebAQIDBA==\x1e4hello€"; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let rx = Mutex::new(PeekableReceiver::new(rx)); - let rx = rx.lock().await; - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Binary(Bytes::from_static(&[ - 1, 2, 3, 4 - ]))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + + let rx = stream::iter([ + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::Binary(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec![Packet::Message("hello€".into())], + ]); + let rx = std::pin::pin!(rx.peekable()); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); } @@ -295,31 +289,26 @@ mod tests { #[tokio::test] async fn max_payload_v4() { const MAX_PAYLOAD: u64 = 10; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Binary(Bytes::from_static(&[ - 1, 2, 3, 4 - ]))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + + let rx = stream::iter([ + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::Binary(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::Message("hello€".into())], + ]); + + let mut rx = std::pin::pin!(rx.peekable()); + { - let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; + let Payload { data, .. } = v4_encoder(rx.as_mut(), MAX_PAYLOAD).await; assert_eq!(data, "4hello€".as_bytes()); } { - let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await; + let Payload { data, .. } = v4_encoder(rx.as_mut(), MAX_PAYLOAD + 10).await; assert_eq!(data, "bAQIDBA==\x1e4hello€".as_bytes()); } { - let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10).await; + let Payload { data, .. } = v4_encoder(rx.as_mut(), MAX_PAYLOAD + 10).await; assert_eq!(data, "4hello€".as_bytes()); } } @@ -328,18 +317,13 @@ mod tests { #[tokio::test] async fn encode_v3b64_payload() { const PAYLOAD: &str = "7:4hello€10:b4AQIDBA==7:4hello€"; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - let rx = mutex.lock().await; - - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::BinaryV3(Bytes::from_static( - &[1, 2, 3, 4] - ))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + let rx = stream::iter([ + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec![Packet::Message("hello€".into())], + ]); + + let rx = std::pin::pin!(rx.peekable()); let Payload { data, has_binary } = v3_string_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); assert!(!has_binary); @@ -350,26 +334,20 @@ mod tests { async fn max_payload_v3_b64() { const MAX_PAYLOAD: u64 = 10; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::BinaryV3(Bytes::from_static( - &[1, 2, 3, 4] - ))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + let rx = stream::iter(vec![ + smallvec::smallvec![Packet::Message("hello€".into())], + smallvec::smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec::smallvec![Packet::Message("hello€".into())], + smallvec::smallvec![Packet::Message("hello€".into())], + ]); + let mut rx = std::pin::pin!(rx.peekable()); + { - let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD).await; + let Payload { data, .. } = v3_string_encoder(rx.as_mut(), MAX_PAYLOAD).await; assert_eq!(data, "7:4hello€".as_bytes()); } { - let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10).await; + let Payload { data, .. } = v3_string_encoder(rx.as_mut(), MAX_PAYLOAD + 10).await; assert_eq!(data, "10:b4AQIDBA==7:4hello€7:4hello€".as_bytes()); } } @@ -380,16 +358,13 @@ mod tests { const PAYLOAD: [u8; 20] = [ 0, 9, 255, 52, 104, 101, 108, 108, 111, 226, 130, 172, 1, 5, 255, 4, 1, 2, 3, 4, ]; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - let rx = mutex.lock().await; - - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::BinaryV3(Bytes::from_static( - &[1, 2, 3, 4] - ))]) - .unwrap(); + + let rx = stream::iter([ + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], + ]); + let rx = std::pin::pin!(rx.peekable()); + let Payload { data, has_binary } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); assert!(has_binary); @@ -404,26 +379,21 @@ mod tests { 0, 1, 1, 255, 52, 104, 101, 108, 108, 111, 111, 111, 226, 130, 172, 1, 5, 255, 4, 1, 2, 3, 4, ]; - let (tx, rx) = tokio::sync::mpsc::channel::(10); - let mutex = Mutex::new(PeekableReceiver::new(rx)); - tx.try_send(smallvec::smallvec![Packet::Message("hellooo€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::BinaryV3(Bytes::from_static( - &[1, 2, 3, 4] - ))]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); - tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) - .unwrap(); + + let rx = stream::iter([ + smallvec![Packet::Message("hellooo€".into())], + smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], + smallvec![Packet::Message("hello€".into())], + smallvec![Packet::Message("hello€".into())], + ]); + let mut rx = std::pin::pin!(rx.peekable()); + { - let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await; + let Payload { data, .. } = v3_binary_encoder(rx.as_mut(), MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); } { - let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD).await; + let Payload { data, .. } = v3_binary_encoder(rx.as_mut(), MAX_PAYLOAD).await; assert_eq!(data, "7:4hello€7:4hello€".as_bytes()); } } diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index 977dcef3..32e4a700 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -3,16 +3,12 @@ use crate::{Packet, PacketParseError, ProtocolVersion, packet::PacketBuf}; use bytes::Bytes; -use futures_util::Stream; +use futures_util::{Stream, StreamExt}; use http::HeaderValue; -use tokio::sync::MutexGuard; mod buf; mod decoder; mod encoder; -mod peekable; - -pub use peekable::PeekableReceiver; const PACKET_SEPARATOR_V4: u8 = b'\x1e'; #[cfg(feature = "v3")] @@ -70,11 +66,13 @@ impl Payload { /// Encodes a payload into a byte stream. pub async fn encoder( - rx: MutexGuard<'_, PeekableReceiver>, + rx: impl Stream, #[allow(unused_variables)] protocol: ProtocolVersion, #[cfg(feature = "v3")] supports_binary: bool, max_payload: u64, ) -> Payload { + let rx = std::pin::pin!(rx.peekable()); + #[cfg(feature = "v3")] { match protocol { diff --git a/crates/engineioxide-core/src/payload/peekable.rs b/crates/engineioxide-core/src/payload/peekable.rs deleted file mode 100644 index d4761a57..00000000 --- a/crates/engineioxide-core/src/payload/peekable.rs +++ /dev/null @@ -1,84 +0,0 @@ -use tokio::sync::mpsc::{Receiver, error::TryRecvError}; - -/// Peekable receiver for polling transport -/// It is a thin wrapper around a [`Receiver`](tokio::sync::mpsc::Receiver) that allows to peek the next packet without consuming it -/// -/// Its main goal is to be able to peek the next packet without consuming it to calculate the -/// packet length when using polling transport to check if it fits according to the max_payload setting -#[derive(Debug)] -pub struct PeekableReceiver { - rx: Receiver, - next: Option, -} -impl PeekableReceiver { - /// Create a new peekable receiver - pub fn new(rx: Receiver) -> Self { - Self { rx, next: None } - } - /// Peek the next packet without consuming it - pub fn peek(&mut self) -> Option<&T> { - if self.next.is_none() { - self.next = self.rx.try_recv().ok(); - } - self.next.as_ref() - } - /// Receive the next packet, consuming it - pub async fn recv(&mut self) -> Option { - if self.next.is_none() { - self.rx.recv().await - } else { - self.next.take() - } - } - /// Try to receive the next packet without blocking - pub fn try_recv(&mut self) -> Result { - if self.next.is_none() { - self.rx.try_recv() - } else { - Ok(self.next.take().unwrap()) - } - } - - /// Close the receiver - pub fn close(&mut self) { - self.rx.close() - } -} - -#[cfg(test)] -mod tests { - use tokio::sync::Mutex; - - #[tokio::test] - async fn peek() { - use super::PeekableReceiver; - use crate::Packet; - use tokio::sync::mpsc::channel; - - let (tx, rx) = channel(1); - let rx = Mutex::new(PeekableReceiver::new(rx)); - let mut rx = rx.lock().await; - - assert!(rx.peek().is_none()); - - tx.send(Packet::Ping).await.unwrap(); - assert_eq!(rx.peek(), Some(&Packet::Ping)); - assert_eq!(rx.recv().await, Some(Packet::Ping)); - assert!(rx.peek().is_none()); - - tx.send(Packet::Pong).await.unwrap(); - assert_eq!(rx.peek(), Some(&Packet::Pong)); - assert_eq!(rx.recv().await, Some(Packet::Pong)); - assert!(rx.peek().is_none()); - - tx.send(Packet::Close).await.unwrap(); - assert_eq!(rx.peek(), Some(&Packet::Close)); - assert_eq!(rx.recv().await, Some(Packet::Close)); - assert!(rx.peek().is_none()); - - tx.send(Packet::Close).await.unwrap(); - assert_eq!(rx.peek(), Some(&Packet::Close)); - assert_eq!(rx.recv().await, Some(Packet::Close)); - assert!(rx.peek().is_none()); - } -} diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index b33725a8..846233d6 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -65,9 +65,7 @@ use std::{ use crate::{config::EngineIoConfig, errors::Error}; use bytes::Bytes; -use engineioxide_core::{ - Packet, PacketBuf, ProtocolVersion, Str, TransportType, payload::PeekableReceiver, -}; +use engineioxide_core::{Packet, PacketBuf, ProtocolVersion, Str, TransportType}; use http::request::Parts; use smallvec::{SmallVec, smallvec}; use tokio::{ @@ -197,7 +195,7 @@ where /// Because with polling transport, if the client is not currently polling then the encoder will never be able to close the channel /// /// The channel is made of a [`SmallVec`] of [`Packet`]s so that adjacent packets can be sent atomically. - pub(crate) internal_rx: Mutex>, + pub(crate) internal_rx: Mutex>, /// Channel to send [PacketBuf] to the internal connection internal_tx: mpsc::Sender, @@ -245,7 +243,7 @@ where transport: AtomicU8::new(transport as u8), upgrading: AtomicBool::new(false), - internal_rx: Mutex::new(PeekableReceiver::new(internal_rx)), + internal_rx: Mutex::new(internal_rx), internal_tx, heartbeat_rx: Mutex::new(heartbeat_rx), @@ -544,7 +542,7 @@ where transport: AtomicU8::new(TransportType::Websocket as u8), upgrading: AtomicBool::new(false), - internal_rx: Mutex::new(PeekableReceiver::new(internal_rx)), + internal_rx: Mutex::new(internal_rx), internal_tx, heartbeat_rx: Mutex::new(heartbeat_rx), diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index c7df4f15..c759ca0a 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -110,7 +110,7 @@ where // If the socket is already locked, it means that the socket is being used by another request // In case of multiple http polling, session should be closed - let rx = match socket.internal_rx.try_lock() { + let mut rx = match socket.internal_rx.try_lock() { Ok(s) => s, Err(_) => { socket.close(DisconnectReason::MultipleHttpPollingError); @@ -118,14 +118,19 @@ where } }; + //TODO: handle closing channel packet better than in the encoding process. + #[cfg(feature = "tracing")] tracing::debug!("[sid={sid}] polling request"); let max_payload = engine.config.max_payload; + let rx = rx_stream::ReceiverStream::new(&mut rx); + #[cfg(feature = "v3")] let Payload { data, has_binary } = payload::encoder(rx, protocol, socket.supports_binary, max_payload).await; + #[cfg(not(feature = "v3"))] let Payload { data, has_binary } = payload::encoder(rx, protocol, max_payload).await; @@ -196,3 +201,33 @@ where } Ok(http_response(StatusCode::OK, "ok", false)?) } + +mod rx_stream { + use std::{ + pin::Pin, + task::{Context, Poll}, + }; + + use futures_core::Stream; + use tokio::sync::mpsc::Receiver; + + /// [`ReceiverStream`] is a stream that wraps a tokio::sync::mpsc::Receiver by reference. + /// Allowing to use it as a stream even if it is behind a mutex. + pub struct ReceiverStream<'a, T> { + inner: &'a mut Receiver, + } + + impl<'a, T> ReceiverStream<'a, T> { + pub fn new(inner: &'a mut Receiver) -> Self { + Self { inner } + } + } + + impl<'a, T> Stream for ReceiverStream<'a, T> { + type Item = T; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_recv(cx) + } + } +} From 5da9f12454c444ca0b95cad3c48b7a4027f88cf6 Mon Sep 17 00:00:00 2001 From: totodore Date: Fri, 10 Jul 2026 01:42:57 +0200 Subject: [PATCH 11/41] wip --- crates/engineioxide-client/Cargo.toml | 4 +- crates/engineioxide-client/src/client.rs | 95 ++++++++++---- crates/engineioxide-client/src/lib.rs | 20 ++- .../src/transport/polling.rs | 61 ++++----- crates/engineioxide-client/tests/handshake.rs | 3 +- crates/engineioxide-core/src/packet.rs | 1 + .../engineioxide-core/src/payload/encoder.rs | 116 +++++++++++++++++- crates/engineioxide-core/src/payload/mod.rs | 6 + 8 files changed, 229 insertions(+), 77 deletions(-) diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index 2d7b7fcb..e2547e8b 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -31,7 +31,7 @@ smallvec.workspace = true hyper-util = { workspace = true, features = ["tokio"] } # Tracing -tracing = { workspace = true, optional = true } +tracing = { workspace = true } # TODO: make optional [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } @@ -40,5 +40,5 @@ engineioxide = { path = "../engineioxide", features = ["tracing", "v3"] } [features] v3 = ["engineioxide-core/v3"] -tracing = ["dep:tracing", "engineioxide-core/tracing"] +# tracing = ["dep:tracing", "engineioxide-core/tracing"] __test_harness = [] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index ea6f4223..5eef4c80 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -1,20 +1,18 @@ use std::{ fmt, pin::Pin, - sync::Mutex, - task::{Context, Poll}, + task::{Context, Poll, ready}, }; -use engineioxide_core::{Packet, PacketBuf, PacketParseError, Sid}; +use engineioxide_core::{Packet, PacketParseError, Sid}; use futures_core::Stream; use futures_util::{ Sink, SinkExt, StreamExt, stream::{SplitSink, SplitStream}, }; -use tokio::sync::mpsc::{self, error::TrySendError}; use crate::{ - HttpClient, poll, + HttpClient, transport::{Transport, polling::PollingSvc}, }; @@ -25,6 +23,14 @@ type SendPongFut = Pin< >, >; +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("packet parse error")] + PacketParse(#[from] PacketParseError), + #[error("transport error")] + Transport(#[from] Box), +} + pin_project_lite::pin_project! { pub struct Client { #[pin] @@ -35,6 +41,8 @@ pin_project_lite::pin_project! { #[pin] pub transport_tx: SplitSink, Packet>, + should_send_pong: bool, + should_flush: bool, pub sid: Sid, // pub tx: mpsc::Sender, // pub(crate) rx: Mutex>, @@ -46,9 +54,9 @@ where S::Error: fmt::Debug, ::Error: fmt::Debug, { - pub async fn connect(svc: S) -> Result { + pub async fn connect(svc: S) -> Result { let mut inner = HttpClient::new(svc); - let packet = inner.handshake().await.unwrap(); + let packet = inner.handshake().await?; let transport = Transport::Polling { inner }; let (transport_tx, transport_rx) = transport.split(); @@ -56,33 +64,68 @@ where transport_tx, transport_rx, sid: packet.sid, + + should_flush: false, + should_send_pong: false, }; Ok(client) } } +impl Client +where + S::Error: fmt::Debug, + ::Error: fmt::Debug, +{ + #[tracing::instrument(skip(cx))] + fn heartbeat(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + ready!(this.transport_tx.as_mut().poll_ready(cx)); + let res = this.transport_tx.as_mut().start_send(Packet::Pong); + *this.should_send_pong = false; + *this.should_flush = true; + Poll::Ready(Ok(res.unwrap())) + } + + #[tracing::instrument(skip(cx))] + fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + ready!(this.transport_tx.as_mut().poll_flush(cx)); + *this.should_flush = false; + Poll::Ready(Ok(())) + } +} impl Stream for Client where S::Error: fmt::Debug, ::Error: fmt::Debug, { - type Item = Result; - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - match poll!(this.transport_rx.poll_next(cx)) { + type Item = Result; + + #[tracing::instrument(skip(cx))] + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.should_send_pong { + //TODO: ret err + self.as_mut().heartbeat(cx); + } + + if self.should_flush { + //TODO: ret err + self.as_mut().flush(cx); + } + + match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { Some(Ok(Packet::Ping)) => { - cx.waker().wake_by_ref(); - // let mut tx = self.transport_tx.clone(); - // let fut = async move { - // tx.send(Packet::Pong).await?; - // tx.flush().await - // }; - // this.pending_pong.set(Some(Box::pin(fut))); - - Poll::Pending + dbg!("got ping packet"); + if let Poll::Pending = self.as_mut().heartbeat(cx) { + self.should_send_pong = true; + } + self.poll_next(cx) } - packet => Poll::Ready(packet), + Some(Ok(packet)) => Poll::Ready(Some(Ok(packet))), + Some(Err(e)) => Poll::Ready(Some(Err(e.into()))), + None => Poll::Ready(None), } } } @@ -110,3 +153,13 @@ where self.project().transport_tx.poll_close(cx) } } + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("should_send_pong", &self.should_send_pong) + .field("should_flush", &self.should_flush) + .field("sid", &self.sid) + .finish() + } +} diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index fb39e455..efeed7df 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -1,5 +1,11 @@ -// #![warn(clippy::pedantic)] -#![allow(clippy::similar_names)] +#![allow(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![doc( + html_logo_url = "https://raw.githubusercontent.com/Totodore/socketioxide/refs/heads/main/.github/logo_dark.svg" +)] +#![doc( + html_favicon_url = "https://raw.githubusercontent.com/Totodore/socketioxide/refs/heads/main/.github/logo_dark.ico" +)] //! Engine.IO client library for Rust. mod client; @@ -7,13 +13,3 @@ mod io; mod transport; pub use crate::client::Client; pub use crate::transport::polling::HttpClient; - -#[macro_export] -macro_rules! poll { - ($expr:expr) => { - match $expr { - std::task::Poll::Pending => return std::task::Poll::Pending, - std::task::Poll::Ready(value) => value, - } - }; -} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 7d63e33c..9d9a7742 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,10 +1,13 @@ use std::collections::VecDeque; +use std::convert::Infallible; use std::fmt; use std::pin::Pin; use std::task::Context; use std::task::Poll; +use std::task::ready; use bytes::Bytes; +use bytes::BytesMut; use engineioxide_core::OpenPacket; use engineioxide_core::Packet; use engineioxide_core::PacketBuf; @@ -12,7 +15,6 @@ use engineioxide_core::PacketParseError; use engineioxide_core::ProtocolVersion; use engineioxide_core::Sid; use engineioxide_core::payload; -use engineioxide_core::payload::Payload; use futures_core::Stream; use futures_util::Sink; use futures_util::StreamExt; @@ -22,20 +24,21 @@ use http::Response; use http::StatusCode; use http_body_util::BodyExt; use http_body_util::Full; +use http_body_util::combinators::BoxBody; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; use smallvec::smallvec; -use crate::poll; - -pub trait PollingSvc: HyperSvc>, Response = Response> { +pub trait PollingSvc: + HyperSvc>, Response = Response> +{ type Body: hyper::body::Body + 'static; } impl PollingSvc for S where - S: HyperSvc>, Response = Response>, - >>>::Error: fmt::Debug, + S: HyperSvc>, Response = Response>, + >>>::Error: fmt::Debug, B: hyper::body::Body + 'static, ::Error: std::fmt::Debug + 'static, ::Data: Send + std::fmt::Debug + 'static, @@ -64,11 +67,8 @@ pin_project! { enum PostState { /// TODO: Ideally the queue should gradually encode packets in an async fashion Queuing { - queue: VecDeque - }, - Encoding { - #[pin] - fut: Pin>>, + // queue: VecDeque + bytes: BytesMut, }, Pending { #[pin] @@ -80,7 +80,7 @@ pin_project! { impl Default for PostState { fn default() -> Self { PostState::Queuing { - queue: VecDeque::new(), + bytes: BytesMut::new(), } } } @@ -115,13 +115,12 @@ where } pub async fn handshake(&mut self) -> Result { - #[cfg(feature = "tracing")] tracing::trace!(?self, "handshake request"); let req = Request::builder() .method("GET") .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") - .body(Full::default()) + .body(BoxBody::new(Full::default())) .unwrap(); let res = self.svc.call(req).await; @@ -149,7 +148,6 @@ where type Item = Result; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - #[cfg(feature = "tracing")] tracing::trace!(poll_state = ?self.poll_state, "polling"); let mut poll_state_proj = self.as_mut().project().poll_state.project(); @@ -158,14 +156,16 @@ where let id = self.sid.unwrap(); let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); - let req = Request::get(uri).body(Full::new(Bytes::new())).unwrap(); + let req = Request::get(uri) + .body(BoxBody::new(Full::default())) + .unwrap(); let fut = self.svc.call(req); self.project().poll_state.set(PollState::Pending { fut }); cx.waker().wake_by_ref(); Poll::Pending } PollStateProj::Pending { ref mut fut } => { - match poll!(fut.as_mut().poll(cx)) { + match ready!(fut.as_mut().poll(cx)) { Ok(res) => { let (parts, body) = res.into_parts(); dbg!(&parts); @@ -183,14 +183,13 @@ where Poll::Pending } Err(err) => { - #[cfg(feature = "tracing")] tracing::debug!(?err, "got body error"); Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload))) } } } PollStateProj::Decoding { ref mut stream } => { - if let Some(packet) = poll!(stream.poll_next_unpin(cx)) { + if let Some(packet) = ready!(stream.poll_next_unpin(cx)) { dbg!(&packet); Poll::Ready(Some(packet)) } else { @@ -215,7 +214,6 @@ impl Sink for HttpClient { } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { - #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "sending packet"); match self.project().post_state.project() { @@ -227,38 +225,26 @@ impl Sink for HttpClient { } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - #[cfg(feature = "tracing")] tracing::trace!(post_state = ?self.post_state, "flushing"); match self.as_mut().project().post_state.project() { PostStateProj::Queuing { queue } if queue.is_empty() => Poll::Ready(Ok(())), PostStateProj::Queuing { queue } => { - let fut = payload::encoder( - stream::iter(queue.clone()), - ProtocolVersion::V4, - true, - 102400000, - ); - self.project() - .post_state - .set(PostState::Encoding { fut: Box::pin(fut) }); - cx.waker().wake_by_ref(); - Poll::Pending - } - PostStateProj::Encoding { fut } => { - let body = poll!(fut.poll(cx)); + let queue = std::mem::take(queue); + let body = payload::encoder_2(stream::iter(queue), 9999999); let req = Request::post( "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", ) - .body(Full::new(body.data)) //TODO: fix cloning + .body(BoxBody::new(body)) .unwrap(); + let fut = self.svc.call(req); self.project().post_state.set(PostState::Pending { fut }); cx.waker().wake_by_ref(); Poll::Pending } PostStateProj::Pending { fut } => { - match poll!(fut.poll(cx)) { + match ready!(fut.poll(cx)) { Ok(res) => { assert!(res.status().is_success()); self.project().post_state.set(PostState::default()); @@ -290,7 +276,6 @@ impl fmt::Debug for PollState { impl fmt::Debug for PostState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Encoding { .. } => f.debug_struct("Encoding").finish(), Self::Pending { .. } => write!(f, "Pending"), Self::Queuing { .. } => write!(f, "Queuing"), } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 70d9d50b..db1c7b8b 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,4 +1,3 @@ -use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -8,7 +7,7 @@ use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; use engineioxide_client::{Client, HttpClient}; use engineioxide_core::{Packet, Sid}; -use futures_util::{SinkExt, StreamExt, TryFutureExt}; +use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index 0d16416d..1b275a70 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -223,6 +223,7 @@ impl Packet { .ok_or(PacketParseError::InvalidPacketType(None))?; let is_upgrade = value.len() == 6 && &value[1..6] == "probe"; let res = match packet_type { + b'0' => Packet::Open(serde_json::from_slice(&value.as_bytes()[1..])?), b'1' => Packet::Close, b'2' if is_upgrade => Packet::PingUpgrade, b'2' => Packet::Ping, diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index af0ac536..469acf75 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -7,14 +7,21 @@ //! * binary encoder (used when there are binary packets and the client supports binary) //! -use std::pin::Pin; +use std::{ + convert::Infallible, + pin::Pin, + task::{Poll, ready}, +}; +use bytes::Bytes; use futures_util::{FutureExt, Stream, StreamExt}; +use http_body::Body; +use pin_project_lite::pin_project; use smallvec::smallvec; use crate::{ packet::PacketBuf, - payload::{Payload, peekable::Peekable}, + payload::{PACKET_SEPARATOR_V4, Payload, peekable::Peekable}, }; #[cfg(feature = "v3")] @@ -54,6 +61,111 @@ async fn poll_packet(mut rx: Pin<&mut Peekable>>) packet } +pin_project! { + pub struct V4StreamEncoder> { + #[pin] + rx: Peekable, + max_payload: u64, + sent_bytes: u64 + } +} + +impl> V4StreamEncoder { + pub fn new(rx: Peekable, max_payload: u64) -> Self { + Self { + rx, + max_payload, + sent_bytes: 0, + } + } +} + +impl> V4StreamEncoder { + fn encode_packets(packets: PacketBuf, size: u64, sent_bytes: &mut u64) -> Option { + let mut data = String::with_capacity(size as usize); + if packets.is_empty() { + return None; + } + + for packet in packets { + let packet: String = packet.into(); + + if *sent_bytes > 0 { + data.push(std::char::from_u32(PACKET_SEPARATOR_V4 as u32).unwrap()); + } + data.push_str(&packet); + } + + *sent_bytes += data.len() as u64 + PACKET_SEPARATOR_V4 as u64; + + Some(data.into()) + } + + fn validate_payload_size( + packets: &PacketBuf, + sent_bytes: &u64, + max_payload: &u64, + ) -> Option { + const PUNCTUATION_LEN: u64 = 1; + let size = packets + .iter() + .map(|p| p.get_size_hint(false)) + .sum::() as u64 + + PUNCTUATION_LEN; + + if *sent_bytes + size > *max_payload { + None + } else { + Some(size) + } + } +} + +impl> Stream for V4StreamEncoder { + type Item = Bytes; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let mut this = self.project(); + + match this.rx.as_mut().poll_peek(cx) { + Poll::Ready(Some(v)) + if let Some(size) = + Self::validate_payload_size(v, this.sent_bytes, this.max_payload) => + { + *this.sent_bytes += size as u64; + let packets = ready!(this.rx.poll_next(cx)).expect("we just peeked Some packets"); + Poll::Ready(Self::encode_packets(packets, size, this.sent_bytes)) + } + Poll::Ready(Some(_)) => { + #[cfg(feature = "tracing")] + tracing::debug!("payload too big, stopping encoding for this payload"); + Poll::Ready(None) + } + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending if *this.sent_bytes == 0 => Poll::Pending, + Poll::Pending => Poll::Ready(None), + } + } +} + +impl> Body for V4StreamEncoder { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + match ready!(self.poll_next(cx)) { + Some(frame) => Poll::Ready(Some(Ok(http_body::Frame::data(frame)))), + None => Poll::Ready(None), + } + } +} + /// Encode multiple packets into a string payload according to the /// [engine.io v4 protocol](https://socket.io/fr/docs/v4/engine-io-protocol/#http-long-polling-1) pub async fn v4_encoder( diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index 23f153b7..0dc6d808 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -10,6 +10,8 @@ mod buf; mod decoder; mod encoder; +pub use encoder::V4StreamEncoder; + const PACKET_SEPARATOR_V4: u8 = b'\x1e'; #[cfg(feature = "v3")] const STRING_PACKET_SEPARATOR_V3: u8 = b':'; @@ -74,6 +76,10 @@ impl Payload { } } +pub fn encoder_2<'a, S: Stream>(rx: S, max_payload: u64) -> V4StreamEncoder { + V4StreamEncoder::new(peekable::Peekable::new(rx), max_payload) +} + /// Encodes a payload into a byte stream. pub async fn encoder( rx: impl Stream, From 98ab73fcbfacdbb2955aba68e350ac88b37b8d6c Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 11 Jul 2026 17:15:32 +0200 Subject: [PATCH 12/41] feat: polling transport --- crates/engineioxide-client/src/client.rs | 54 +++--- .../engineioxide-client/src/transport/mod.rs | 2 +- .../src/transport/polling.rs | 167 +++++++++--------- crates/engineioxide-client/tests/handshake.rs | 99 +++++------ crates/engineioxide-client/tests/heartbeat.rs | 137 ++++++++++++++ 5 files changed, 282 insertions(+), 177 deletions(-) create mode 100644 crates/engineioxide-client/tests/heartbeat.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 5eef4c80..fdb87cae 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -7,7 +7,7 @@ use std::{ use engineioxide_core::{Packet, PacketParseError, Sid}; use futures_core::Stream; use futures_util::{ - Sink, SinkExt, StreamExt, + Sink, StreamExt, stream::{SplitSink, SplitStream}, }; @@ -16,36 +16,24 @@ use crate::{ transport::{Transport, polling::PollingSvc}, }; -type SendPongFut = Pin< - Box< - dyn Future, Packet> as Sink>::Error>> - + 'static, - >, ->; - #[derive(Debug, thiserror::Error)] -pub enum ClientError { +pub enum ClientError { #[error("packet parse error")] PacketParse(#[from] PacketParseError), #[error("transport error")] - Transport(#[from] Box), + Transport(S::Error), } pin_project_lite::pin_project! { pub struct Client { #[pin] pub transport_rx: SplitStream>, - // TODO: is this the right implementation? We need something that can be driven itself. - // Otherwise we need a way to drive the transport_tx. Normally it should be driven by the user. - // But what if we need to send a PONG packet from the inner lib? #[pin] pub transport_tx: SplitSink, Packet>, should_send_pong: bool, should_flush: bool, pub sid: Sid, - // pub tx: mpsc::Sender, - // pub(crate) rx: Mutex>, } } @@ -55,15 +43,13 @@ where ::Error: fmt::Debug, { pub async fn connect(svc: S) -> Result { - let mut inner = HttpClient::new(svc); - let packet = inner.handshake().await?; - + let (inner, open) = HttpClient::connect(svc).await?; let transport = Transport::Polling { inner }; let (transport_tx, transport_rx) = transport.split(); let client = Client { transport_tx, transport_rx, - sid: packet.sid, + sid: open.sid, should_flush: false, should_send_pong: false, @@ -80,17 +66,24 @@ where #[tracing::instrument(skip(cx))] fn heartbeat(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let mut this = self.project(); - ready!(this.transport_tx.as_mut().poll_ready(cx)); - let res = this.transport_tx.as_mut().start_send(Packet::Pong); + if let Err(e) = ready!(this.transport_tx.as_mut().poll_ready(cx)) { + return Poll::Ready(Err(e)); + } + if let Err(e) = this.transport_tx.as_mut().start_send(Packet::Pong) { + return Poll::Ready(Err(e)); + } + *this.should_send_pong = false; *this.should_flush = true; - Poll::Ready(Ok(res.unwrap())) + Poll::Ready(Ok(())) } #[tracing::instrument(skip(cx))] fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let mut this = self.project(); - ready!(this.transport_tx.as_mut().poll_flush(cx)); + if let Err(e) = ready!(this.transport_tx.as_mut().poll_flush(cx)) { + return Poll::Ready(Err(e)); + } *this.should_flush = false; Poll::Ready(Ok(())) } @@ -101,24 +94,27 @@ where S::Error: fmt::Debug, ::Error: fmt::Debug, { - type Item = Result; + type Item = Result>; #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { if self.should_send_pong { //TODO: ret err - self.as_mut().heartbeat(cx); + if let Err(e) = ready!(self.as_mut().heartbeat(cx)) { + return Poll::Ready(Some(Err(ClientError::Transport(e)))); + } } if self.should_flush { //TODO: ret err - self.as_mut().flush(cx); + if let Err(e) = ready!(self.as_mut().flush(cx)) { + return Poll::Ready(Some(Err(ClientError::Transport(e)))); + } } match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { Some(Ok(Packet::Ping)) => { - dbg!("got ping packet"); - if let Poll::Pending = self.as_mut().heartbeat(cx) { + if self.as_mut().heartbeat(cx).is_pending() { self.should_send_pong = true; } self.poll_next(cx) @@ -135,7 +131,7 @@ where S::Error: fmt::Debug, ::Error: fmt::Debug, { - type Error = (); + type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().transport_tx.poll_ready(cx) diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index d7bd1b1f..3cb3450e 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -49,7 +49,7 @@ where } } impl Sink for Transport { - type Error = (); + type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 9d9a7742..6f52192e 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -1,33 +1,18 @@ -use std::collections::VecDeque; -use std::convert::Infallible; -use std::fmt; -use std::pin::Pin; -use std::task::Context; -use std::task::Poll; -use std::task::ready; +use std::{ + convert::Infallible, + fmt, + pin::Pin, + task::{Context, Poll, ready}, +}; -use bytes::Bytes; -use bytes::BytesMut; -use engineioxide_core::OpenPacket; -use engineioxide_core::Packet; -use engineioxide_core::PacketBuf; -use engineioxide_core::PacketParseError; -use engineioxide_core::ProtocolVersion; -use engineioxide_core::Sid; -use engineioxide_core::payload; +use bytes::{BufMut, Bytes, BytesMut}; +use engineioxide_core::{OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, payload}; use futures_core::Stream; -use futures_util::Sink; -use futures_util::StreamExt; -use futures_util::stream; -use http::Request; -use http::Response; -use http::StatusCode; -use http_body_util::BodyExt; -use http_body_util::Full; -use http_body_util::combinators::BoxBody; +use futures_util::{Sink, StreamExt}; +use http::{Request, Response, StatusCode}; +use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; -use smallvec::smallvec; pub trait PollingSvc: HyperSvc>, Response = Response> @@ -48,10 +33,7 @@ where pin_project! { #[project = PollStateProj] - #[derive(Default)] enum PollState { - #[default] - No, Pending { #[pin] fut: F @@ -65,14 +47,14 @@ pin_project! { pin_project! { #[project = PostStateProj] enum PostState { - /// TODO: Ideally the queue should gradually encode packets in an async fashion Queuing { - // queue: VecDeque bytes: BytesMut, }, Pending { #[pin] - fut: F + fut: F, + // TODO: BytesList + bytes: BytesMut, } } } @@ -85,6 +67,17 @@ impl Default for PostState { } } +impl PollState { + fn new_request>(svc: &S, sid: Sid) -> Self { + let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={sid}"); + let req = Request::get(uri) + .body(BoxBody::new(Full::default())) + .unwrap(); + let fut = svc.call(req); + PollState::Pending { fut } + } +} + pin_project! { pub struct HttpClient { @@ -96,7 +89,7 @@ pin_project! { #[pin] post_state: PostState, - sid: Option, + sid: Sid, } } @@ -105,17 +98,8 @@ where S::Error: fmt::Debug, ::Error: fmt::Debug, { - pub fn new(svc: S) -> Self { - Self { - svc, - poll_state: PollState::default(), - post_state: PostState::default(), - sid: None, - } - } - - pub async fn handshake(&mut self) -> Result { - tracing::trace!(?self, "handshake request"); + pub async fn connect(svc: S) -> Result<(Self, OpenPacket), PacketParseError> { + tracing::trace!("handshake request"); let req = Request::builder() .method("GET") @@ -123,7 +107,7 @@ where .body(BoxBody::new(Full::default())) .unwrap(); - let res = self.svc.call(req).await; + let res = svc.call(req).await; let body = res.unwrap().collect().await.unwrap(); let packet = Packet::parse( ProtocolVersion::V4, @@ -132,8 +116,15 @@ where match packet { Packet::Open(open) => { - self.sid = Some(open.sid); - Ok(open) + let poll_state = PollState::new_request(&svc, open.sid); + let client = HttpClient { + svc, + poll_state, + post_state: PostState::default(), + sid: open.sid, + }; + + Ok((client, open)) } _ => Err(PacketParseError::InvalidPacketType(Some('1'))), } @@ -152,23 +143,10 @@ where let mut poll_state_proj = self.as_mut().project().poll_state.project(); match poll_state_proj { - PollStateProj::No => { - let id = self.sid.unwrap(); - let uri = - format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}"); - let req = Request::get(uri) - .body(BoxBody::new(Full::default())) - .unwrap(); - let fut = self.svc.call(req); - self.project().poll_state.set(PollState::Pending { fut }); - cx.waker().wake_by_ref(); - Poll::Pending - } PollStateProj::Pending { ref mut fut } => { match ready!(fut.as_mut().poll(cx)) { Ok(res) => { let (parts, body) = res.into_parts(); - dbg!(&parts); assert!(parts.status == StatusCode::OK); let body = Box::pin(body); //TODO: implement limited body + Content-Type @@ -190,11 +168,11 @@ where } PollStateProj::Decoding { ref mut stream } => { if let Some(packet) = ready!(stream.poll_next_unpin(cx)) { - dbg!(&packet); Poll::Ready(Some(packet)) } else { - self.project().poll_state.set(PollState::No); - // Should not be needed. + let request = PollState::new_request(&self.svc, self.sid); + self.project().poll_state.set(request); + //check if wake is needed cx.waker().wake_by_ref(); Poll::Pending } @@ -204,46 +182,43 @@ where } impl Sink for HttpClient { - type Error = (); + type Error = S::Error; fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - match self.post_state { - PostState::Queuing { .. } => Poll::Ready(Ok(())), - _ => Poll::Pending, - } + Poll::Ready(Ok(())) } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { tracing::trace!(post_state = ?self.post_state, "sending packet"); - - match self.project().post_state.project() { - PostStateProj::Queuing { queue } => queue.push_back(smallvec![item]), - _ => panic!("unexpected post state"), - } - + self.project().post_state.encode(item); Ok(()) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { tracing::trace!(post_state = ?self.post_state, "flushing"); + let proj = self.as_mut().project().post_state.project(); - match self.as_mut().project().post_state.project() { - PostStateProj::Queuing { queue } if queue.is_empty() => Poll::Ready(Ok(())), - PostStateProj::Queuing { queue } => { - let queue = std::mem::take(queue); - let body = payload::encoder_2(stream::iter(queue), 9999999); - let req = Request::post( - "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", - ) - .body(BoxBody::new(body)) + match proj { + PostStateProj::Queuing { bytes } if bytes.is_empty() => Poll::Ready(Ok(())), + PostStateProj::Queuing { bytes } => { + let body = std::mem::take(bytes).freeze(); + //TODO: handle max body size from open packet + let req = Request::post(format!( + "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={}", + self.sid // TODO: unwrap + )) + .body(BoxBody::new(Full::new(body))) .unwrap(); let fut = self.svc.call(req); - self.project().post_state.set(PostState::Pending { fut }); + self.project().post_state.set(PostState::Pending { + fut, + bytes: BytesMut::new(), + }); cx.waker().wake_by_ref(); Poll::Pending } - PostStateProj::Pending { fut } => { + PostStateProj::Pending { fut, .. } => { match ready!(fut.poll(cx)) { Ok(res) => { assert!(res.status().is_success()); @@ -252,22 +227,38 @@ impl Sink for HttpClient { } Err(err) => { self.project().post_state.set(PostState::default()); - todo!("handle error") + Poll::Ready(Err(err)) } } } } } - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + //TODO: close behavior Poll::Ready(Ok(())) } } +impl PostState { + pub fn encode(self: Pin<&mut Self>, item: Packet) { + const PACKET_SEPARATOR_V4: u8 = b'\x1e'; + let packet: Bytes = item.into(); + let bytes = match self.project() { + PostStateProj::Queuing { bytes } => bytes, + PostStateProj::Pending { bytes, .. } => bytes, + }; + + if !bytes.is_empty() { + bytes.put_u8(PACKET_SEPARATOR_V4); + } + bytes.extend_from_slice(&packet); + } +} + impl fmt::Debug for PollState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::No => write!(f, "No"), Self::Pending { .. } => write!(f, "Pending"), Self::Decoding { .. } => write!(f, "Decoding"), } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index db1c7b8b..20388866 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::time::Duration; use bytes::Bytes; use engineioxide::handler::EngineIoHandler; @@ -21,100 +20,82 @@ enum Event { #[derive(Debug)] struct Handler { - tx: mpsc::Sender, + tx: mpsc::UnboundedSender, } impl Handler { - fn new() -> (Self, mpsc::Receiver) { - let (tx, rx) = mpsc::channel(100); + fn new() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); (Self { tx }, rx) } } +fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); +} + +/// Build an [`EngineIoService`] with a short ping interval/timeout so the +/// heartbeat fires quickly during tests. +fn service() -> (EngineIoService, mpsc::UnboundedReceiver) { + init_tracing(); + let (handler, rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + (svc, rx) +} + impl EngineIoHandler for Handler { type Data = (); fn on_connect(self: Arc, socket: Arc>) { - self.tx.try_send(Event::Connect(socket.id)).unwrap(); + self.tx.send(Event::Connect(socket.id)).unwrap(); } fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { - self.tx - .try_send(Event::Disconnect(socket.id, reason)) - .unwrap(); + self.tx.send(Event::Disconnect(socket.id, reason)).unwrap(); } fn on_message(self: &Arc, msg: Str, socket: Arc>) { - self.tx.try_send(Event::Message(socket.id, msg)).unwrap(); + self.tx + .send(Event::Message(socket.id, msg.clone())) + .unwrap(); + socket.emit(msg).unwrap(); } fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { - self.tx.try_send(Event::Binary(socket.id, data)).unwrap(); + self.tx + .send(Event::Binary(socket.id, data.clone())) + .unwrap(); + socket.emit_binary(data).unwrap(); } } #[tokio::test] async fn handshake() { - tracing_subscriber::fmt::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); - let (handler, mut rx) = Handler::new(); - let svc = EngineIoService::new(Arc::new(handler)); - let packet = HttpClient::new(svc).handshake().await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(packet.sid)); + let (svc, mut rx) = service(); + let (_, open) = HttpClient::connect(svc).await.unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } #[tokio::test] async fn connect() { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); - let (handler, mut rx) = Handler::new(); - let svc = EngineIoService::new(Arc::new(handler)); + let (svc, mut rx) = service(); let client = Client::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); - let (ctx, mut crx) = client.split::(); + let (mut ctx, mut crx) = client.split::(); + + ctx.send(Packet::Message("Hello".into())).await.unwrap(); + ctx.send(Packet::Binary(Bytes::from("Hello".to_string()))) + .await + .unwrap(); while let Some(event) = crx.next().await { match event { Ok(event) => { - dbg!(event); + ctx.send(dbg!(event)).await.unwrap(); } Err(e) => panic!("Error: {e}"), } } } - -#[tokio::test] -async fn spaaam() { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); - let (handler, mut rx) = Handler::new(); - let svc = EngineIoService::new(Arc::new(handler)); - let client = Client::connect(svc).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); - let (mut ctx, mut crx) = client.split::(); - - tokio::task::LocalSet::new() - .run_until(async move { - tokio::task::spawn_local(async move { - loop { - ctx.send(Packet::Pong).await.unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - } - }); - - while let Some(event) = crx.next().await { - match event { - Ok(event) => { - dbg!(event); - } - Err(e) => panic!("Error: {e}"), - } - } - }) - .await; -} diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs new file mode 100644 index 00000000..a5b56b6f --- /dev/null +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -0,0 +1,137 @@ +//! Heartbeat mechanism tests. +//! +//! In the engine.io v4 protocol the *server* drives the heartbeat: every +//! `ping_interval` it sends a `Packet::Ping` and expects a `Packet::Pong` back +//! within `ping_timeout`, otherwise it closes the socket with +//! [`DisconnectReason::HeartbeatTimeout`]. +//! +//! The pong is emitted transparently by `Client::poll_next`: a `Ping` is +//! intercepted, a `Pong` is sent and the `Ping` is never surfaced to the user. + +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use engineioxide::config::EngineIoConfig; +use engineioxide::handler::EngineIoHandler; +use engineioxide::service::EngineIoService; +use engineioxide::{DisconnectReason, Socket, Str}; +use engineioxide_client::Client; +use engineioxide_core::{Packet, Sid}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tracing_subscriber::EnvFilter; + +const PING_INTERVAL: Duration = Duration::from_millis(100); +const PING_TIMEOUT: Duration = Duration::from_millis(100); + +#[derive(Debug, PartialEq, Eq)] +enum Event { + Connect(Sid), + Disconnect(Sid, DisconnectReason), + Message(Sid, Str), +} + +#[derive(Debug)] +struct Handler { + tx: mpsc::Sender, +} + +impl Handler { + fn new() -> (Self, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(100); + (Self { tx }, rx) + } +} + +impl EngineIoHandler for Handler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) { + self.tx.try_send(Event::Connect(socket.id)).unwrap(); + } + + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { + self.tx + .try_send(Event::Disconnect(socket.id, reason)) + .unwrap(); + } + + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + self.tx + .try_send(Event::Message(socket.id, msg.clone())) + .unwrap(); + // Echo the message back so the client can observe liveness. + socket.emit(msg).unwrap(); + } + + fn on_binary(self: &Arc, _data: Bytes, _socket: Arc>) {} +} + +fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); +} + +/// Build an [`EngineIoService`] with a short ping interval/timeout so the +/// heartbeat fires quickly during tests. +fn service() -> (EngineIoService, mpsc::Receiver) { + init_tracing(); + let (handler, rx) = Handler::new(); + let config = EngineIoConfig::builder() + .ping_interval(PING_INTERVAL) + .ping_timeout(PING_TIMEOUT) + .build(); + let svc = EngineIoService::with_config(Arc::new(handler), config); + (svc, rx) +} + +/// A [`Client`] that is continuously polled must auto-respond to the server's +/// `Ping`s with `Pong`s, keeping the connection alive across several ping +/// cycles. The connection must also still be usable afterwards. +#[tokio::test] +async fn heartbeat_keeps_connection_alive() { + let (svc, mut rx) = service(); + + let mut client = Client::connect(svc).await.unwrap(); + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + + // Drive the client for several ping cycles. Any server event during this + // window can only be a disconnect (we send nothing), which would mean the + // heartbeat failed. + let window = PING_INTERVAL * 5; + let deadline = tokio::time::sleep(window); + tokio::pin!(deadline); + tokio::select! { + _ = &mut deadline => (), + ev = rx.recv() => panic!("unexpected server event during heartbeat: {ev:?}"), + // Polling the stream is what lets the client receive `Ping`s and + // emit `Pong`s. `Ping` is consumed internally and never yielded, + // so this branch effectively never resolves. + packet = client.next() => match packet { + Some(Ok(p)) => panic!("unexpected packet during heartbeat: {p:?}"), + Some(Err(e)) => panic!("client stream error during heartbeat: {e:?}"), + None => panic!("client stream ended unexpectedly"), + }, + } + + // The connection survived several ping cycles: prove it is still healthy + // by round-tripping a message (the handler echoes it back). + client.send(Packet::Message("hb".into())).await.unwrap(); + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "hb".into()), + "server should still receive messages after the heartbeat window", + ); + match client.next().await { + Some(Ok(Packet::Message(msg))) => { + assert_eq!(msg, "hb") + } + // Ignore any other packet (should not happen, `Ping` is internal). + Some(Ok(p)) => panic!("unexpected packet: {p:?}"), + Some(Err(e)) => panic!("client stream error: {e:?}"), + None => panic!("client stream ended before echo"), + } +} From fc95b114250f14376f854dfeea2c434d55dbf4b0 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 11 Jul 2026 17:33:59 +0200 Subject: [PATCH 13/41] feat: polling transport --- crates/engineioxide-client/src/client.rs | 32 ++--- crates/engineioxide-client/tests/handshake.rs | 27 +---- crates/engineioxide-client/tests/polling.rs | 112 ++++++++++++++++++ 3 files changed, 124 insertions(+), 47 deletions(-) create mode 100644 crates/engineioxide-client/tests/polling.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index fdb87cae..fb60a9d3 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -65,26 +65,20 @@ where { #[tracing::instrument(skip(cx))] fn heartbeat(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - if let Err(e) = ready!(this.transport_tx.as_mut().poll_ready(cx)) { - return Poll::Ready(Err(e)); - } - if let Err(e) = this.transport_tx.as_mut().start_send(Packet::Pong) { - return Poll::Ready(Err(e)); - } + let mut proj = self.project(); + ready!(proj.transport_tx.as_mut().poll_ready(cx))?; + proj.transport_tx.as_mut().start_send(Packet::Pong)?; - *this.should_send_pong = false; - *this.should_flush = true; + *proj.should_send_pong = false; + *proj.should_flush = true; Poll::Ready(Ok(())) } #[tracing::instrument(skip(cx))] fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let mut this = self.project(); - if let Err(e) = ready!(this.transport_tx.as_mut().poll_flush(cx)) { - return Poll::Ready(Err(e)); - } - *this.should_flush = false; + let mut proj = self.project(); + ready!(proj.transport_tx.as_mut().poll_flush(cx))?; + *proj.should_flush = false; Poll::Ready(Ok(())) } } @@ -99,17 +93,11 @@ where #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { if self.should_send_pong { - //TODO: ret err - if let Err(e) = ready!(self.as_mut().heartbeat(cx)) { - return Poll::Ready(Some(Err(ClientError::Transport(e)))); - } + ready!(self.as_mut().heartbeat(cx)).map_err(ClientError::Transport)?; } if self.should_flush { - //TODO: ret err - if let Err(e) = ready!(self.as_mut().flush(cx)) { - return Poll::Ready(Some(Err(ClientError::Transport(e)))); - } + ready!(self.as_mut().flush(cx)).map_err(ClientError::Transport)?; } match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 20388866..34b19248 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,9 +4,8 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::{Client, HttpClient}; -use engineioxide_core::{Packet, Sid}; -use futures_util::{SinkExt, StreamExt}; +use engineioxide_client::HttpClient; +use engineioxide_core::Sid; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -77,25 +76,3 @@ async fn handshake() { let (_, open) = HttpClient::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } - -#[tokio::test] -async fn connect() { - let (svc, mut rx) = service(); - let client = Client::connect(svc).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(client.sid)); - let (mut ctx, mut crx) = client.split::(); - - ctx.send(Packet::Message("Hello".into())).await.unwrap(); - ctx.send(Packet::Binary(Bytes::from("Hello".to_string()))) - .await - .unwrap(); - - while let Some(event) = crx.next().await { - match event { - Ok(event) => { - ctx.send(dbg!(event)).await.unwrap(); - } - Err(e) => panic!("Error: {e}"), - } - } -} diff --git a/crates/engineioxide-client/tests/polling.rs b/crates/engineioxide-client/tests/polling.rs new file mode 100644 index 00000000..19e2e190 --- /dev/null +++ b/crates/engineioxide-client/tests/polling.rs @@ -0,0 +1,112 @@ +//! Polling mechanism tests. +//! +//! These tests exercise the [`Client`] read/write halves obtained via +//! [`Client::split`]: packets sent through the sink must reach the server and +//! the echoed packets must be surfaced back through the stream. + +use std::sync::Arc; + +use bytes::Bytes; +use engineioxide::handler::EngineIoHandler; +use engineioxide::{DisconnectReason, service::EngineIoService}; +use engineioxide::{Socket, Str}; +use engineioxide_client::Client; +use engineioxide_core::{Packet, Sid}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, PartialEq, Eq)] +enum Event { + Connect(Sid), + Disconnect(Sid, DisconnectReason), + Message(Sid, Str), + Binary(Sid, Bytes), +} + +#[derive(Debug)] +struct Handler { + tx: mpsc::UnboundedSender, +} + +impl Handler { + fn new() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self { tx }, rx) + } +} + +fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); +} + +fn service() -> (EngineIoService, mpsc::UnboundedReceiver) { + init_tracing(); + let (handler, rx) = Handler::new(); + let svc = EngineIoService::new(Arc::new(handler)); + (svc, rx) +} + +impl EngineIoHandler for Handler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) { + self.tx.send(Event::Connect(socket.id)).unwrap(); + } + + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { + self.tx.send(Event::Disconnect(socket.id, reason)).unwrap(); + } + + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + self.tx + .send(Event::Message(socket.id, msg.clone())) + .unwrap(); + socket.emit(msg).unwrap(); + } + + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { + self.tx + .send(Event::Binary(socket.id, data.clone())) + .unwrap(); + socket.emit_binary(data).unwrap(); + } +} + +/// Packets sent through the write half must reach the server, and the packets +/// the handler echoes back must be surfaced in order through the read half. +#[tokio::test] +async fn round_trip() { + let (svc, mut rx) = service(); + let client = Client::connect(svc).await.unwrap(); + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + let (mut ctx, mut crx) = client.split::(); + + ctx.send(Packet::Message("Hello".into())).await.unwrap(); + ctx.send(Packet::Binary(Bytes::from_static(b"Hello"))) + .await + .unwrap(); + + // The server observes both packets. + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "Hello".into()) + ); + assert_eq!( + rx.recv().await.unwrap(), + Event::Binary(sid, Bytes::from_static(b"Hello")) + ); + + // And echoes them back through the read half, in order. + match crx.next().await { + Some(Ok(Packet::Message(msg))) => assert_eq!(msg, "Hello"), + other => panic!("expected echoed message, got {other:?}"), + } + match crx.next().await { + Some(Ok(Packet::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + other => panic!("expected echoed binary, got {other:?}"), + } +} From 3b252ed1a75ff1cb6960d16e9199f88f128bcf82 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 11 Jul 2026 19:39:11 +0200 Subject: [PATCH 14/41] feat: polling transport --- crates/engineioxide-client/src/client.rs | 5 +- crates/engineioxide-client/src/lib.rs | 2 +- .../engineioxide-client/src/transport/mod.rs | 5 +- .../engineioxide-client/src/transport/ws.rs | 94 +++++++++++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 crates/engineioxide-client/src/transport/ws.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index fb60a9d3..fc738acd 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -11,10 +11,7 @@ use futures_util::{ stream::{SplitSink, SplitStream}, }; -use crate::{ - HttpClient, - transport::{Transport, polling::PollingSvc}, -}; +use crate::transport::{HttpClient, PollingSvc, Transport}; #[derive(Debug, thiserror::Error)] pub enum ClientError { diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index efeed7df..22b2ba74 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -12,4 +12,4 @@ mod client; mod io; mod transport; pub use crate::client::Client; -pub use crate::transport::polling::HttpClient; +pub use crate::transport::HttpClient; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 3cb3450e..338f682c 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -8,9 +8,10 @@ use engineioxide_core::{Packet, PacketParseError, TransportType}; use futures_core::Stream; use futures_util::Sink; -use crate::{HttpClient, transport::polling::PollingSvc}; +pub use crate::transport::polling::{HttpClient, PollingSvc}; -pub mod polling; +mod polling; +mod ws; pin_project_lite::pin_project! { #[project = TransportProj] diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs new file mode 100644 index 00000000..7e3415f2 --- /dev/null +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -0,0 +1,94 @@ +use std::{ + pin::Pin, + task::{Context, Poll, ready}, +}; + +use bytes::Bytes; +use engineioxide::Str; +use futures_core::Stream; +use futures_util::Sink; +use pin_project_lite::pin_project; +use tokio_tungstenite::tungstenite::{self, Message, Utf8Bytes}; + +pub struct WsTransport {} + +pub trait SocketIoWebSocket: + Stream> + Sink +{ + type Error; +} + +pub enum SocketIoWsMessage { + Text(Str), + Binary(Bytes), + Close, +} + +impl From for tungstenite::Message { + fn from(value: SocketIoWsMessage) -> Self { + match value { + SocketIoWsMessage::Text(v) => { + tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) + } + SocketIoWsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes.into()), + SocketIoWsMessage::Close => tungstenite::Message::Close(None), + } + } +} +pin_project! { + struct TokioTungsteniteWebSocket { + #[pin] + inner: tokio_tungstenite::WebSocketStream, + } +} + +impl SocketIoWebSocket + for TokioTungsteniteWebSocket +{ + type Error = tungstenite::Error; +} + +impl Sink + for TokioTungsteniteWebSocket +{ + type Error = tungstenite::Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_ready(cx) + } + + fn start_send(self: Pin<&mut Self>, item: Item) -> Result<(), Self::Error> { + self.project().inner.poll_ready(cx) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + todo!() + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + todo!() + } +} +impl Stream + for TokioTungsteniteWebSocket +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.project().inner.poll_next(cx)) { + Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(SocketIoWsMessage::Text(unsafe { + Str::from_bytes_unchecked(v.into()) + })))), + Some(Ok(Message::Binary(v))) => { + Poll::Ready(Some(Ok(SocketIoWsMessage::Binary(v.into())))) + } + Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(SocketIoWsMessage::Close))), + Some(Ok(_)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Err(e)) => Poll::Ready(Some(Err(e))), + None => Poll::Pending, + } + } +} From 3e7ca009f8202ea60d4aa28689375c90d98baa78 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 12 Jul 2026 00:25:39 +0200 Subject: [PATCH 15/41] feat: websocket --- Cargo.lock | 2 + crates/engineioxide-client/Cargo.toml | 8 +- crates/engineioxide-client/src/client.rs | 89 +++--- crates/engineioxide-client/src/lib.rs | 4 +- .../engineioxide-client/src/transport/mod.rs | 87 ++++-- .../src/transport/polling.rs | 85 ++++-- .../engineioxide-client/src/transport/ws.rs | 284 ++++++++++++++---- crates/engineioxide-client/tests/fixture.rs | 102 +++++++ crates/engineioxide-client/tests/handshake.rs | 4 +- crates/engineioxide-client/tests/heartbeat.rs | 53 ++++ .../tests/{polling.rs => round_trip.rs} | 38 +++ 11 files changed, 601 insertions(+), 155 deletions(-) create mode 100644 crates/engineioxide-client/tests/fixture.rs rename crates/engineioxide-client/tests/{polling.rs => round_trip.rs} (72%) diff --git a/Cargo.lock b/Cargo.lock index fd681f1a..3f3a3565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,7 +899,9 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-tungstenite", + "tokio-util", "tracing", "tracing-subscriber", ] diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index e2547e8b..d2a963f8 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -35,8 +35,14 @@ tracing = { workspace = true } # TODO: make optional [dev-dependencies] tokio = { workspace = true, features = ["macros", "parking_lot"] } +tokio-stream.workspace = true +tokio-util.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } -engineioxide = { path = "../engineioxide", features = ["tracing", "v3"] } +engineioxide = { path = "../engineioxide", features = [ + "tracing", + "v3", + "__test_harness", +] } [features] v3 = ["engineioxide-core/v3"] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index fc738acd..61d11bb5 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -4,29 +4,24 @@ use std::{ task::{Context, Poll, ready}, }; -use engineioxide_core::{Packet, PacketParseError, Sid}; +use engineioxide_core::{Packet, Sid}; use futures_core::Stream; use futures_util::{ Sink, StreamExt, stream::{SplitSink, SplitStream}, }; -use crate::transport::{HttpClient, PollingSvc, Transport}; - -#[derive(Debug, thiserror::Error)] -pub enum ClientError { - #[error("packet parse error")] - PacketParse(#[from] PacketParseError), - #[error("transport error")] - Transport(S::Error), -} +use crate::transport::{ + PollingSvc, PollingTransport, PollingTransportError, Transport, TransportError, WebSocket, + WsTransport, WsTransportError, noop_impl::NoopWebSocket, +}; pin_project_lite::pin_project! { - pub struct Client { + pub struct Client { #[pin] - pub transport_rx: SplitStream>, + pub transport_rx: SplitStream>, #[pin] - pub transport_tx: SplitSink, Packet>, + pub transport_tx: SplitSink, Packet>, should_send_pong: bool, should_flush: bool, @@ -34,13 +29,26 @@ pin_project_lite::pin_project! { } } -impl Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - pub async fn connect(svc: S) -> Result { - let (inner, open) = HttpClient::connect(svc).await?; +impl Client { + pub async fn connect_ws(ws: impl Into) -> Result> { + let (inner, open) = WsTransport::connect(ws).await?; + let transport = Transport::Websocket { inner }; + let (transport_tx, transport_rx) = transport.split(); + let client = Client { + transport_tx, + transport_rx, + sid: open.sid, + + should_flush: false, + should_send_pong: false, + }; + + Ok(client) + } +} +impl Client { + pub async fn connect(svc: S) -> Result> { + let (inner, open) = PollingTransport::connect(svc).await?; let transport = Transport::Polling { inner }; let (transport_tx, transport_rx) = transport.split(); let client = Client { @@ -55,13 +63,13 @@ where Ok(client) } } -impl Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ + +impl Client { #[tracing::instrument(skip(cx))] - fn heartbeat(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + fn heartbeat( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { let mut proj = self.project(); ready!(proj.transport_tx.as_mut().poll_ready(cx))?; proj.transport_tx.as_mut().start_send(Packet::Pong)?; @@ -72,7 +80,10 @@ where } #[tracing::instrument(skip(cx))] - fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + fn flush( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { let mut proj = self.project(); ready!(proj.transport_tx.as_mut().poll_flush(cx))?; *proj.should_flush = false; @@ -80,21 +91,17 @@ where } } -impl Stream for Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - type Item = Result>; +impl Stream for Client { + type Item = Result>; #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { if self.should_send_pong { - ready!(self.as_mut().heartbeat(cx)).map_err(ClientError::Transport)?; + ready!(self.as_mut().heartbeat(cx))?; } if self.should_flush { - ready!(self.as_mut().flush(cx)).map_err(ClientError::Transport)?; + ready!(self.as_mut().flush(cx))?; } match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { @@ -105,18 +112,14 @@ where self.poll_next(cx) } Some(Ok(packet)) => Poll::Ready(Some(Ok(packet))), - Some(Err(e)) => Poll::Ready(Some(Err(e.into()))), + Some(Err(e)) => Poll::Ready(Some(Err(e))), None => Poll::Ready(None), } } } -impl Sink for Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - type Error = as Sink>::Error; +impl Sink for Client { + type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().transport_tx.poll_ready(cx) @@ -135,7 +138,7 @@ where } } -impl fmt::Debug for Client { +impl fmt::Debug for Client { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Client") .field("should_send_pong", &self.should_send_pong) diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 22b2ba74..67307ced 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -12,4 +12,6 @@ mod client; mod io; mod transport; pub use crate::client::Client; -pub use crate::transport::HttpClient; +pub use crate::transport::PollingTransport; + +pub use transport::{noop_impl, tungstenite_impl}; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 338f682c..52334186 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -4,30 +4,53 @@ use std::{ task::{Context, Poll}, }; -use engineioxide_core::{Packet, PacketParseError, TransportType}; +use engineioxide_core::{Packet, TransportType}; use futures_core::Stream; use futures_util::Sink; -pub use crate::transport::polling::{HttpClient, PollingSvc}; +pub use crate::transport::polling::{PollingSvc, PollingTransport, PollingTransportError}; +pub use crate::transport::ws::{ + WebSocket, WsTransport, WsTransportError, noop_impl, tungstenite_impl, +}; mod polling; mod ws; +pub enum TransportError { + Polling(PollingTransportError), + Websocket(WsTransportError), +} + +impl fmt::Debug for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} +impl fmt::Display for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TransportError::Polling(e) => write!(f, "polling error: {}", e), + TransportError::Websocket(e) => write!(f, "ws error: {}", e), + } + } +} +impl std::error::Error for TransportError {} + pin_project_lite::pin_project! { #[project = TransportProj] - pub enum Transport { + pub enum Transport { Polling { #[pin] - inner: HttpClient + inner: PollingTransport }, Websocket { #[pin] - inner: HttpClient + inner: WsTransport } } } -impl Transport { +impl Transport { pub fn transport_type(&self) -> TransportType { match self { Transport::Polling { .. } => TransportType::Polling, @@ -36,47 +59,63 @@ impl Transport { } } -impl Stream for Transport -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - type Item = Result; +impl Stream for Transport { + type Item = Result>; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.as_mut().project() { - TransportProj::Polling { inner } => inner.poll_next(cx), - TransportProj::Websocket { inner } => inner.poll_next(cx), + TransportProj::Polling { inner } => { + inner.poll_next(cx).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.poll_next(cx).map_err(TransportError::Websocket) + } } } } -impl Sink for Transport { - type Error = as Sink>::Error; +impl Sink for Transport { + type Error = TransportError; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { - TransportProj::Polling { inner } => inner.poll_ready(cx), - TransportProj::Websocket { inner } => inner.poll_ready(cx), + TransportProj::Polling { inner } => { + inner.poll_ready(cx).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.poll_ready(cx).map_err(TransportError::Websocket) + } } } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { match self.project() { - TransportProj::Polling { inner } => inner.start_send(item), - TransportProj::Websocket { inner } => inner.start_send(item), + TransportProj::Polling { inner } => { + inner.start_send(item).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.start_send(item).map_err(TransportError::Websocket) + } } } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { - TransportProj::Polling { inner } => inner.poll_flush(cx), - TransportProj::Websocket { inner } => inner.poll_flush(cx), + TransportProj::Polling { inner } => { + inner.poll_flush(cx).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.poll_flush(cx).map_err(TransportError::Websocket) + } } } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { - TransportProj::Polling { inner } => inner.poll_close(cx), - TransportProj::Websocket { inner } => inner.poll_close(cx), + TransportProj::Polling { inner } => { + inner.poll_close(cx).map_err(TransportError::Polling) + } + TransportProj::Websocket { inner } => { + inner.poll_close(cx).map_err(TransportError::Websocket) + } } } } diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 6f52192e..3f0ae1f8 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -15,20 +15,28 @@ use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; pub trait PollingSvc: - HyperSvc>, Response = Response> + HyperSvc< + Request>, + Response = Response, + Error = ::Error, + > { - type Body: hyper::body::Body + 'static; + type Body: http_body::Body + 'static; + type Error: fmt::Debug + std::error::Error; + type ResBodyError: fmt::Debug + std::error::Error + 'static; } impl PollingSvc for S where S: HyperSvc>, Response = Response>, - >>>::Error: fmt::Debug, - B: hyper::body::Body + 'static, - ::Error: std::fmt::Debug + 'static, - ::Data: Send + std::fmt::Debug + 'static, + >>>::Error: fmt::Debug + std::error::Error, + B: http_body::Body + 'static, + ::Error: fmt::Debug + std::error::Error + 'static, + ::Data: Send + fmt::Debug + 'static, { type Body = B; + type Error = >>>::Error; + type ResBodyError = ::Error; } pin_project! { @@ -78,8 +86,18 @@ impl PollState { } } +#[derive(Debug, thiserror::Error)] +pub enum PollingTransportError { + #[error("polling error: {0}")] + Polling(::Error), + #[error("polling body error: {0}")] + PollingBody(::ResBodyError), + #[error("packet error: {0}")] + Packet(#[from] PacketParseError), +} + pin_project! { - pub struct HttpClient + pub struct PollingTransport { svc: S, @@ -93,12 +111,8 @@ pin_project! { } } -impl HttpClient -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - pub async fn connect(svc: S) -> Result<(Self, OpenPacket), PacketParseError> { +impl PollingTransport { + pub async fn connect(svc: S) -> Result<(Self, OpenPacket), PollingTransportError> { tracing::trace!("handshake request"); let req = Request::builder() @@ -107,8 +121,15 @@ where .body(BoxBody::new(Full::default())) .unwrap(); - let res = svc.call(req).await; - let body = res.unwrap().collect().await.unwrap(); + let res = svc + .call(req) + .await + .map_err(PollingTransportError::Polling)?; + let body = res + .collect() + .await + .map_err(PollingTransportError::PollingBody)?; + let packet = Packet::parse( ProtocolVersion::V4, String::from_utf8(body.to_bytes().to_vec()).unwrap(), @@ -117,26 +138,24 @@ where match packet { Packet::Open(open) => { let poll_state = PollState::new_request(&svc, open.sid); - let client = HttpClient { + let transport = PollingTransport { svc, poll_state, post_state: PostState::default(), sid: open.sid, }; - Ok((client, open)) + Ok((transport, open)) } - _ => Err(PacketParseError::InvalidPacketType(Some('1'))), + _ => Err(PollingTransportError::Packet( + PacketParseError::InvalidPacketType(None), + )), } } } -impl Stream for HttpClient -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - type Item = Result; +impl Stream for PollingTransport { + type Item = Result>; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { tracing::trace!(poll_state = ?self.poll_state, "polling"); @@ -162,13 +181,13 @@ where } Err(err) => { tracing::debug!(?err, "got body error"); - Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload))) + Poll::Ready(Some(Err(PacketParseError::InvalidPacketPayload.into()))) } } } PollStateProj::Decoding { ref mut stream } => { if let Some(packet) = ready!(stream.poll_next_unpin(cx)) { - Poll::Ready(Some(packet)) + Poll::Ready(Some(packet.map_err(PollingTransportError::from))) } else { let request = PollState::new_request(&self.svc, self.sid); self.project().poll_state.set(request); @@ -181,8 +200,8 @@ where } } -impl Sink for HttpClient { - type Error = S::Error; +impl Sink for PollingTransport { + type Error = PollingTransportError; fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) @@ -203,9 +222,9 @@ impl Sink for HttpClient { PostStateProj::Queuing { bytes } => { let body = std::mem::take(bytes).freeze(); //TODO: handle max body size from open packet + let sid = self.sid; let req = Request::post(format!( - "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={}", - self.sid // TODO: unwrap + "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={sid}", )) .body(BoxBody::new(Full::new(body))) .unwrap(); @@ -227,7 +246,7 @@ impl Sink for HttpClient { } Err(err) => { self.project().post_state.set(PostState::default()); - Poll::Ready(Err(err)) + Poll::Ready(Err(PollingTransportError::Polling(err))) } } } @@ -272,9 +291,9 @@ impl fmt::Debug for PostState { } } } -impl fmt::Debug for HttpClient { +impl fmt::Debug for PollingTransport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("HttpClient") + f.debug_struct("PollingTransport") .field("poll_state", &self.poll_state) .field("post_state", &self.post_state) .field("sid", &self.sid) diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index 7e3415f2..9cd642c3 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -1,94 +1,276 @@ use std::{ + fmt, pin::Pin, task::{Context, Poll, ready}, }; use bytes::Bytes; -use engineioxide::Str; +use engineioxide_core::{OpenPacket, Packet, PacketParseError, ProtocolVersion, Str}; use futures_core::Stream; -use futures_util::Sink; +use futures_util::{Sink, StreamExt}; use pin_project_lite::pin_project; use tokio_tungstenite::tungstenite::{self, Message, Utf8Bytes}; -pub struct WsTransport {} +pin_project! { + pub struct WsTransport { + #[pin] + inner: S, + } +} + +pub enum WsTransportError { + Websocket(::Error), + Packet(PacketParseError), + Closed, +} +impl From for WsTransportError { + fn from(e: PacketParseError) -> Self { + WsTransportError::Packet(e) + } +} +impl fmt::Debug for WsTransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} +impl fmt::Display for WsTransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WsTransportError::Websocket(e) => write!(f, "websocket error: {}", e), + WsTransportError::Packet(e) => write!(f, "packet error: {}", e), + WsTransportError::Closed => write!(f, "websocket closed"), + } + } +} +impl std::error::Error for WsTransportError {} + +impl WsTransport { + pub fn new(inner: impl Into) -> Self { + Self { + inner: inner.into(), + } + } + + pub async fn connect(inner: impl Into) -> Result<(Self, OpenPacket), WsTransportError> { + tracing::trace!("handshake request"); + let mut ws = Self::new(inner); + + match ws.next().await.ok_or(WsTransportError::Closed)?? { + Packet::Open(open_packet) => Ok((ws, open_packet)), + _ => Err(WsTransportError::Packet( + PacketParseError::InvalidPacketType(None), + )), + } + } +} -pub trait SocketIoWebSocket: - Stream> + Sink +pub trait WebSocket: + Stream::Error>> + + Sink::Error> + + Sized + + Unpin { - type Error; + type Error: fmt::Debug + std::error::Error; } -pub enum SocketIoWsMessage { +pub enum WsMessage { Text(Str), Binary(Bytes), Close, } -impl From for tungstenite::Message { - fn from(value: SocketIoWsMessage) -> Self { - match value { - SocketIoWsMessage::Text(v) => { - tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) +impl WsTransport { + fn parse_packet(&self, msg: WsMessage) -> Result> { + match msg { + WsMessage::Text(msg) => { + let msg_str = unsafe { Str::from_bytes_unchecked(msg.into()) }; + let packet = Packet::parse(ProtocolVersion::V4, msg_str)?; + Ok(packet) + } + WsMessage::Binary(data) => Ok(Packet::Binary(data)), + WsMessage::Close => { + todo!("impl ws close"); } - SocketIoWsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes.into()), - SocketIoWsMessage::Close => tungstenite::Message::Close(None), } } } -pin_project! { - struct TokioTungsteniteWebSocket { - #[pin] - inner: tokio_tungstenite::WebSocketStream, - } -} -impl SocketIoWebSocket - for TokioTungsteniteWebSocket -{ - type Error = tungstenite::Error; +impl Stream for WsTransport { + type Item = Result>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.as_mut().project().inner.poll_next(cx)) { + Some(Ok(msg)) => match self.parse_packet(msg) { + Ok(packet) => Poll::Ready(Some(Ok(packet))), + Err(e) => Poll::Ready(Some(Err(e))), + }, + Some(Err(e)) => Poll::Ready(Some(Err(WsTransportError::Websocket(e)))), + None => Poll::Ready(None), + } + } } -impl Sink - for TokioTungsteniteWebSocket -{ - type Error = tungstenite::Error; +impl Sink for WsTransport { + type Error = WsTransportError; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().inner.poll_ready(cx) + self.project() + .inner + .poll_ready(cx) + .map_err(WsTransportError::Websocket) } - fn start_send(self: Pin<&mut Self>, item: Item) -> Result<(), Self::Error> { - self.project().inner.poll_ready(cx) + fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { + let msg = match item { + Packet::Binary(bin) => WsMessage::Binary(bin), + Packet::Noop => return Ok(()), + p => WsMessage::Text(String::from(p).into()), + }; + self.project() + .inner + .start_send(msg) + .map_err(WsTransportError::Websocket) } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - todo!() + self.project() + .inner + .poll_flush(cx) + .map_err(WsTransportError::Websocket) } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - todo!() + self.project() + .inner + .poll_close(cx) + .map_err(WsTransportError::Websocket) } } -impl Stream - for TokioTungsteniteWebSocket -{ - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match ready!(self.project().inner.poll_next(cx)) { - Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(SocketIoWsMessage::Text(unsafe { - Str::from_bytes_unchecked(v.into()) - })))), - Some(Ok(Message::Binary(v))) => { - Poll::Ready(Some(Ok(SocketIoWsMessage::Binary(v.into())))) + +pub mod noop_impl { + use std::convert::Infallible; + + use super::*; + + #[derive(Debug, Default)] + pub struct NoopWebSocket; + + impl WebSocket for NoopWebSocket { + type Error = Infallible; + } + impl Stream for NoopWebSocket { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(None) + } + } + impl Sink for NoopWebSocket { + type Error = Infallible; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, _item: WsMessage) -> Result<(), Self::Error> { + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } +} +pub mod tungstenite_impl { + use super::*; + + impl From for tungstenite::Message { + fn from(value: WsMessage) -> Self { + match value { + WsMessage::Text(v) => { + tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) + } + WsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes), + WsMessage::Close => tungstenite::Message::Close(None), } - Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(SocketIoWsMessage::Close))), - Some(Ok(_)) => { - cx.waker().wake_by_ref(); - Poll::Pending + } + } + pin_project! { + pub struct TokioTungsteniteWebSocket { + #[pin] + inner: tokio_tungstenite::WebSocketStream, + } + } + + impl From> for TokioTungsteniteWebSocket + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + fn from(inner: tokio_tungstenite::WebSocketStream) -> Self { + Self { inner } + } + } + + impl WebSocket + for TokioTungsteniteWebSocket + { + type Error = tungstenite::Error; + } + + impl Sink + for TokioTungsteniteWebSocket + { + type Error = tungstenite::Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_ready(cx) + } + + fn start_send(self: Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { + self.project().inner.start_send(item.into()) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_flush(cx) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_close(cx) + } + } + + impl Stream + for TokioTungsteniteWebSocket + { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.project().inner.poll_next(cx)) { + Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(WsMessage::Text(unsafe { + Str::from_bytes_unchecked(v.into()) + })))), + Some(Ok(Message::Binary(v))) => Poll::Ready(Some(Ok(WsMessage::Binary(v)))), + Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(WsMessage::Close))), + Some(Ok(_)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Err(e)) => Poll::Ready(Some(Err(e))), + None => Poll::Pending, } - Some(Err(e)) => Poll::Ready(Some(Err(e))), - None => Poll::Pending, } } } diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs new file mode 100644 index 00000000..196169ae --- /dev/null +++ b/crates/engineioxide-client/tests/fixture.rs @@ -0,0 +1,102 @@ +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Bytes; +use engineioxide::handler::EngineIoHandler; +use engineioxide::service::EngineIoService; +use engineioxide_client::Client; +use engineioxide_client::tungstenite_impl::TokioTungsteniteWebSocket; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key}; +use tokio_tungstenite::tungstenite::protocol::Role; +use tokio_util::io::StreamReader; + +pin_project_lite::pin_project! { + pub struct StreamImpl { + tx: mpsc::UnboundedSender>, + #[pin] + rx: StreamReader>, Bytes>, + } +} +impl StreamImpl { + pub fn new( + tx: mpsc::UnboundedSender>, + rx: mpsc::UnboundedReceiver>, + ) -> Self { + Self { + tx, + rx: StreamReader::new(UnboundedReceiverStream::new(rx)), + } + } +} + +impl AsyncRead for StreamImpl { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.project().rx.poll_read(cx, buf) + } +} +impl AsyncWrite for StreamImpl { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let len = buf.len(); + self.project() + .tx + .send(Ok(Bytes::copy_from_slice(buf))) + .unwrap(); + Poll::Ready(Ok(len)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +pub async fn client_ws_connect( + svc: EngineIoService, +) -> Client, TokioTungsteniteWebSocket> { + let (tx, rx) = mpsc::unbounded_channel(); + let (tx1, rx1) = mpsc::unbounded_channel(); + + let parts = Request::builder() + .method("GET") + .header("Host", "127.0.0.1") + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()) + .uri("ws://127.0.0.1/engine.io/?EIO=4&transport=websocket") + .body(http_body_util::Empty::::new()) + .unwrap() + .into_parts() + .0; + + let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( + StreamImpl::new(tx1, rx), + Role::Client, + Default::default(), + ) + .await; + + tokio::spawn(svc.ws_init( + StreamImpl::new(tx, rx1), + engineioxide::ProtocolVersion::V4, + None, + parts, + )); + + Client::connect_ws(ws).await.unwrap() +} diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 34b19248..0834468f 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,7 +4,7 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::HttpClient; +use engineioxide_client::PollingTransport; use engineioxide_core::Sid; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -73,6 +73,6 @@ impl EngineIoHandler for Handler { #[tokio::test] async fn handshake() { let (svc, mut rx) = service(); - let (_, open) = HttpClient::connect(svc).await.unwrap(); + let (_, open) = PollingTransport::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index a5b56b6f..c5ec10f6 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -22,6 +22,10 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +use crate::fixture::client_ws_connect; + +mod fixture; + const PING_INTERVAL: Duration = Duration::from_millis(100); const PING_TIMEOUT: Duration = Duration::from_millis(100); @@ -135,3 +139,52 @@ async fn heartbeat_keeps_connection_alive() { None => panic!("client stream ended before echo"), } } + +/// A [`Client`] that is continuously polled must auto-respond to the server's +/// `Ping`s with `Pong`s, keeping the connection alive across several ping +/// cycles. The connection must also still be usable afterwards. +#[tokio::test] +async fn heartbeat_keeps_connection_alive_websocket() { + let (svc, mut rx) = service(); + + let mut client = client_ws_connect(svc).await; + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + + // Drive the client for several ping cycles. Any server event during this + // window can only be a disconnect (we send nothing), which would mean the + // heartbeat failed. + let window = PING_INTERVAL * 5; + let deadline = tokio::time::sleep(window); + tokio::pin!(deadline); + tokio::select! { + _ = &mut deadline => (), + ev = rx.recv() => panic!("unexpected server event during heartbeat: {ev:?}"), + // Polling the stream is what lets the client receive `Ping`s and + // emit `Pong`s. `Ping` is consumed internally and never yielded, + // so this branch effectively never resolves. + packet = client.next() => match packet { + Some(Ok(p)) => panic!("unexpected packet during heartbeat: {p:?}"), + Some(Err(e)) => panic!("client stream error during heartbeat: {e:?}"), + None => panic!("client stream ended unexpectedly"), + }, + } + + // The connection survived several ping cycles: prove it is still healthy + // by round-tripping a message (the handler echoes it back). + client.send(Packet::Message("hb".into())).await.unwrap(); + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "hb".into()), + "server should still receive messages after the heartbeat window", + ); + match client.next().await { + Some(Ok(Packet::Message(msg))) => { + assert_eq!(msg, "hb") + } + // Ignore any other packet (should not happen, `Ping` is internal). + Some(Ok(p)) => panic!("unexpected packet: {p:?}"), + Some(Err(e)) => panic!("client stream error: {e:?}"), + None => panic!("client stream ended before echo"), + } +} diff --git a/crates/engineioxide-client/tests/polling.rs b/crates/engineioxide-client/tests/round_trip.rs similarity index 72% rename from crates/engineioxide-client/tests/polling.rs rename to crates/engineioxide-client/tests/round_trip.rs index 19e2e190..1534816d 100644 --- a/crates/engineioxide-client/tests/polling.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -16,6 +16,8 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +mod fixture; + #[derive(Debug, PartialEq, Eq)] enum Event { Connect(Sid), @@ -110,3 +112,39 @@ async fn round_trip() { other => panic!("expected echoed binary, got {other:?}"), } } + +/// Packets sent through the write half must reach the server, and the packets +/// the handler echoes back must be surfaced in order through the read half. +#[tokio::test] +async fn round_trip_ws() { + let (svc, mut rx) = service(); + let client = fixture::client_ws_connect(svc).await; + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + let (mut ctx, mut crx) = client.split::(); + + ctx.send(Packet::Message("Hello".into())).await.unwrap(); + ctx.send(Packet::Binary(Bytes::from_static(b"Hello"))) + .await + .unwrap(); + + // The server observes both packets. + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "Hello".into()) + ); + assert_eq!( + rx.recv().await.unwrap(), + Event::Binary(sid, Bytes::from_static(b"Hello")) + ); + + // And echoes them back through the read half, in order. + match crx.next().await { + Some(Ok(Packet::Message(msg))) => assert_eq!(msg, "Hello"), + other => panic!("expected echoed message, got {other:?}"), + } + match crx.next().await { + Some(Ok(Packet::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + other => panic!("expected echoed binary, got {other:?}"), + } +} From efb4d771ed06730dabef11058e0638a4e52bbe6e Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 12 Jul 2026 00:46:59 +0200 Subject: [PATCH 16/41] feat: eioevent --- crates/engineioxide-client/src/client.rs | 74 +++++++++++-------- crates/engineioxide-client/src/event.rs | 20 +++++ crates/engineioxide-client/src/lib.rs | 2 + crates/engineioxide-client/tests/heartbeat.rs | 12 +-- .../engineioxide-client/tests/round_trip.rs | 24 +++--- 5 files changed, 82 insertions(+), 50 deletions(-) create mode 100644 crates/engineioxide-client/src/event.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 61d11bb5..f48b5508 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -6,25 +6,24 @@ use std::{ use engineioxide_core::{Packet, Sid}; use futures_core::Stream; -use futures_util::{ - Sink, StreamExt, - stream::{SplitSink, SplitStream}, -}; - -use crate::transport::{ - PollingSvc, PollingTransport, PollingTransportError, Transport, TransportError, WebSocket, - WsTransport, WsTransportError, noop_impl::NoopWebSocket, +use futures_util::Sink; + +use crate::{ + EioEvent, + transport::{ + PollingSvc, PollingTransport, PollingTransportError, Transport, TransportError, WebSocket, + WsTransport, WsTransportError, noop_impl::NoopWebSocket, + }, }; pin_project_lite::pin_project! { pub struct Client { #[pin] - pub transport_rx: SplitStream>, - #[pin] - pub transport_tx: SplitSink, Packet>, + pub transport: Transport, should_send_pong: bool, should_flush: bool, + closing: bool, pub sid: Sid, } } @@ -33,14 +32,13 @@ impl Client { pub async fn connect_ws(ws: impl Into) -> Result> { let (inner, open) = WsTransport::connect(ws).await?; let transport = Transport::Websocket { inner }; - let (transport_tx, transport_rx) = transport.split(); let client = Client { - transport_tx, - transport_rx, + transport, sid: open.sid, should_flush: false, should_send_pong: false, + closing: false, }; Ok(client) @@ -50,14 +48,13 @@ impl Client { pub async fn connect(svc: S) -> Result> { let (inner, open) = PollingTransport::connect(svc).await?; let transport = Transport::Polling { inner }; - let (transport_tx, transport_rx) = transport.split(); let client = Client { - transport_tx, - transport_rx, + transport, sid: open.sid, should_flush: false, should_send_pong: false, + closing: false, }; Ok(client) @@ -71,8 +68,8 @@ impl Client { cx: &mut Context<'_>, ) -> Poll>> { let mut proj = self.project(); - ready!(proj.transport_tx.as_mut().poll_ready(cx))?; - proj.transport_tx.as_mut().start_send(Packet::Pong)?; + ready!(proj.transport.as_mut().poll_ready(cx))?; + proj.transport.as_mut().start_send(Packet::Pong)?; *proj.should_send_pong = false; *proj.should_flush = true; @@ -85,17 +82,22 @@ impl Client { cx: &mut Context<'_>, ) -> Poll>> { let mut proj = self.project(); - ready!(proj.transport_tx.as_mut().poll_flush(cx))?; + ready!(proj.transport.as_mut().poll_flush(cx))?; *proj.should_flush = false; Poll::Ready(Ok(())) } } impl Stream for Client { - type Item = Result>; + type Item = Result>; #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.closing { + ready!(self.project().transport.poll_close(cx))?; + return Poll::Ready(None); + } + if self.should_send_pong { ready!(self.as_mut().heartbeat(cx))?; } @@ -104,37 +106,45 @@ impl Stream for Client { ready!(self.as_mut().flush(cx))?; } - match ready!(self.as_mut().project().transport_rx.poll_next(cx)) { + match ready!(self.as_mut().project().transport.poll_next(cx)) { Some(Ok(Packet::Ping)) => { - if self.as_mut().heartbeat(cx).is_pending() { - self.should_send_pong = true; - } + *self.as_mut().project().should_send_pong = true; self.poll_next(cx) } - Some(Ok(packet)) => Poll::Ready(Some(Ok(packet))), + Some(Ok(Packet::Close)) => { + *self.as_mut().project().closing = true; + cx.waker().wake_by_ref(); // wake up to close the transport + Poll::Ready(Some(Ok(EioEvent::Disconnect))) + } + Some(Ok(Packet::Message(v))) => Poll::Ready(Some(Ok(EioEvent::Message(v)))), + Some(Ok(Packet::Binary(v))) => Poll::Ready(Some(Ok(EioEvent::Binary(v)))), + Some(Ok(v)) => unreachable!("unexpected msg {v:?}"), Some(Err(e)) => Poll::Ready(Some(Err(e))), None => Poll::Ready(None), } } } -impl Sink for Client { +impl Sink for Client { type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().transport_tx.poll_ready(cx) + self.project().transport.poll_ready(cx) } - fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { - self.project().transport_tx.start_send(item) + fn start_send(self: Pin<&mut Self>, event: EioEvent) -> Result<(), Self::Error> { + if let Some(packet) = event.into() { + self.project().transport.start_send(packet)?; + } + Ok(()) } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().transport_tx.poll_flush(cx) + self.project().transport.poll_flush(cx) } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().transport_tx.poll_close(cx) + self.project().transport.poll_close(cx) } } diff --git a/crates/engineioxide-client/src/event.rs b/crates/engineioxide-client/src/event.rs new file mode 100644 index 00000000..5894a962 --- /dev/null +++ b/crates/engineioxide-client/src/event.rs @@ -0,0 +1,20 @@ +use bytes::Bytes; +use engineioxide_core::{Packet, Str}; + +#[derive(Debug, PartialEq, Eq)] +pub enum EioEvent { + Connect, + Disconnect, + Message(Str), + Binary(Bytes), +} + +impl From for Option { + fn from(value: EioEvent) -> Option { + match value { + EioEvent::Message(msg) => Some(Packet::Message(msg)), + EioEvent::Binary(bin) => Some(Packet::Binary(bin)), + _ => None, + } + } +} diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 67307ced..372014db 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -9,9 +9,11 @@ //! Engine.IO client library for Rust. mod client; +mod event; mod io; mod transport; pub use crate::client::Client; +pub use crate::event::EioEvent; pub use crate::transport::PollingTransport; pub use transport::{noop_impl, tungstenite_impl}; diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index c5ec10f6..d44ca913 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -16,8 +16,8 @@ use engineioxide::config::EngineIoConfig; use engineioxide::handler::EngineIoHandler; use engineioxide::service::EngineIoService; use engineioxide::{DisconnectReason, Socket, Str}; -use engineioxide_client::Client; -use engineioxide_core::{Packet, Sid}; +use engineioxide_client::{Client, EioEvent}; +use engineioxide_core::Sid; use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -123,14 +123,14 @@ async fn heartbeat_keeps_connection_alive() { // The connection survived several ping cycles: prove it is still healthy // by round-tripping a message (the handler echoes it back). - client.send(Packet::Message("hb".into())).await.unwrap(); + client.send(EioEvent::Message("hb".into())).await.unwrap(); assert_eq!( rx.recv().await.unwrap(), Event::Message(sid, "hb".into()), "server should still receive messages after the heartbeat window", ); match client.next().await { - Some(Ok(Packet::Message(msg))) => { + Some(Ok(EioEvent::Message(msg))) => { assert_eq!(msg, "hb") } // Ignore any other packet (should not happen, `Ping` is internal). @@ -172,14 +172,14 @@ async fn heartbeat_keeps_connection_alive_websocket() { // The connection survived several ping cycles: prove it is still healthy // by round-tripping a message (the handler echoes it back). - client.send(Packet::Message("hb".into())).await.unwrap(); + client.send(EioEvent::Message("hb".into())).await.unwrap(); assert_eq!( rx.recv().await.unwrap(), Event::Message(sid, "hb".into()), "server should still receive messages after the heartbeat window", ); match client.next().await { - Some(Ok(Packet::Message(msg))) => { + Some(Ok(EioEvent::Message(msg))) => { assert_eq!(msg, "hb") } // Ignore any other packet (should not happen, `Ping` is internal). diff --git a/crates/engineioxide-client/tests/round_trip.rs b/crates/engineioxide-client/tests/round_trip.rs index 1534816d..9d3c6bac 100644 --- a/crates/engineioxide-client/tests/round_trip.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -10,8 +10,8 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::Client; -use engineioxide_core::{Packet, Sid}; +use engineioxide_client::{Client, EioEvent}; +use engineioxide_core::Sid; use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -85,10 +85,10 @@ async fn round_trip() { let client = Client::connect(svc).await.unwrap(); let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - let (mut ctx, mut crx) = client.split::(); + let (mut ctx, mut crx) = client.split::(); - ctx.send(Packet::Message("Hello".into())).await.unwrap(); - ctx.send(Packet::Binary(Bytes::from_static(b"Hello"))) + ctx.send(EioEvent::Message("Hello".into())).await.unwrap(); + ctx.send(EioEvent::Binary(Bytes::from_static(b"Hello"))) .await .unwrap(); @@ -104,11 +104,11 @@ async fn round_trip() { // And echoes them back through the read half, in order. match crx.next().await { - Some(Ok(Packet::Message(msg))) => assert_eq!(msg, "Hello"), + Some(Ok(EioEvent::Message(msg))) => assert_eq!(msg, "Hello"), other => panic!("expected echoed message, got {other:?}"), } match crx.next().await { - Some(Ok(Packet::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + Some(Ok(EioEvent::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), other => panic!("expected echoed binary, got {other:?}"), } } @@ -121,10 +121,10 @@ async fn round_trip_ws() { let client = fixture::client_ws_connect(svc).await; let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - let (mut ctx, mut crx) = client.split::(); + let (mut ctx, mut crx) = client.split::(); - ctx.send(Packet::Message("Hello".into())).await.unwrap(); - ctx.send(Packet::Binary(Bytes::from_static(b"Hello"))) + ctx.send(EioEvent::Message("Hello".into())).await.unwrap(); + ctx.send(EioEvent::Binary(Bytes::from_static(b"Hello"))) .await .unwrap(); @@ -140,11 +140,11 @@ async fn round_trip_ws() { // And echoes them back through the read half, in order. match crx.next().await { - Some(Ok(Packet::Message(msg))) => assert_eq!(msg, "Hello"), + Some(Ok(EioEvent::Message(msg))) => assert_eq!(msg, "Hello"), other => panic!("expected echoed message, got {other:?}"), } match crx.next().await { - Some(Ok(Packet::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + Some(Ok(EioEvent::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), other => panic!("expected echoed binary, got {other:?}"), } } From dec543b45d3baeaf79dbf066e0f2bc8c9cc685a7 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 12 Jul 2026 00:59:06 +0200 Subject: [PATCH 17/41] feat: eioevent --- crates/engineioxide-client/src/io.rs | 1 - crates/engineioxide-client/src/lib.rs | 5 ++-- crates/engineioxide-client/tests/fixture.rs | 30 ++++++++++++------- crates/engineioxide-client/tests/handshake.rs | 19 ++++++++++-- crates/engineioxide-client/tests/heartbeat.rs | 4 +-- 5 files changed, 39 insertions(+), 20 deletions(-) delete mode 100644 crates/engineioxide-client/src/io.rs diff --git a/crates/engineioxide-client/src/io.rs b/crates/engineioxide-client/src/io.rs deleted file mode 100644 index 8b137891..00000000 --- a/crates/engineioxide-client/src/io.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 372014db..7babd7b9 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -10,10 +10,9 @@ mod client; mod event; -mod io; mod transport; + pub use crate::client::Client; pub use crate::event::EioEvent; pub use crate::transport::PollingTransport; - -pub use transport::{noop_impl, tungstenite_impl}; +pub use transport::{WsTransport, noop_impl, tungstenite_impl}; diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index 196169ae..fd1a1881 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -21,6 +21,7 @@ pin_project_lite::pin_project! { rx: StreamReader>, Bytes>, } } + impl StreamImpl { pub fn new( tx: mpsc::UnboundedSender>, @@ -65,9 +66,9 @@ impl AsyncWrite for StreamImpl { } } -pub async fn client_ws_connect( +pub async fn tungstenite_client( svc: EngineIoService, -) -> Client, TokioTungsteniteWebSocket> { +) -> tokio_tungstenite::WebSocketStream { let (tx, rx) = mpsc::unbounded_channel(); let (tx1, rx1) = mpsc::unbounded_channel(); @@ -84,19 +85,26 @@ pub async fn client_ws_connect( .into_parts() .0; - let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( - StreamImpl::new(tx1, rx), - Role::Client, - Default::default(), - ) - .await; - - tokio::spawn(svc.ws_init( + svc.ws_init( StreamImpl::new(tx, rx1), engineioxide::ProtocolVersion::V4, None, parts, - )); + ) + .await + .unwrap(); + tokio_tungstenite::WebSocketStream::from_raw_socket( + StreamImpl::new(tx1, rx), + Role::Client, + Default::default(), + ) + .await +} + +pub async fn client_ws_connect( + svc: EngineIoService, +) -> Client, TokioTungsteniteWebSocket> { + let ws = tungstenite_client(svc).await; Client::connect_ws(ws).await.unwrap() } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 0834468f..811befaf 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,11 +4,16 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::PollingTransport; +use engineioxide_client::tungstenite_impl::TokioTungsteniteWebSocket; +use engineioxide_client::{PollingTransport, WsTransport}; use engineioxide_core::Sid; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +use crate::fixture::tungstenite_client; + +mod fixture; + #[derive(Debug, PartialEq, Eq)] enum Event { Connect(Sid), @@ -71,8 +76,18 @@ impl EngineIoHandler for Handler { } #[tokio::test] -async fn handshake() { +async fn handshake_polling() { let (svc, mut rx) = service(); let (_, open) = PollingTransport::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } + +#[tokio::test] +async fn handshake_websocket() { + let (svc, mut rx) = service(); + let ws = tungstenite_client(svc).await; + let (_, open) = WsTransport::>::connect(ws) + .await + .unwrap(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); +} diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index d44ca913..010cd942 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -22,8 +22,6 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; -use crate::fixture::client_ws_connect; - mod fixture; const PING_INTERVAL: Duration = Duration::from_millis(100); @@ -147,7 +145,7 @@ async fn heartbeat_keeps_connection_alive() { async fn heartbeat_keeps_connection_alive_websocket() { let (svc, mut rx) = service(); - let mut client = client_ws_connect(svc).await; + let mut client = fixture::client_ws_connect(svc).await; let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); From 417964bc8c8f9188eea978b49939ef8f005ef839 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 12 Jul 2026 14:53:20 +0200 Subject: [PATCH 18/41] feat: websocket abstractions --- crates/engineioxide-client/Cargo.toml | 7 +- crates/engineioxide-client/src/client.rs | 78 ++++-- .../engineioxide-client/src/transport/mod.rs | 65 +++-- .../src/transport/polling.rs | 6 +- .../engineioxide-client/src/transport/ws.rs | 232 +++++++++++++----- crates/engineioxide-client/tests/fixture.rs | 104 +++++--- crates/engineioxide-client/tests/handshake.rs | 12 +- crates/engineioxide-client/tests/heartbeat.rs | 10 +- .../engineioxide-client/tests/round_trip.rs | 10 +- crates/engineioxide/src/transport/ws.rs | 2 + 10 files changed, 366 insertions(+), 160 deletions(-) diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index d2a963f8..2e22cfc2 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -22,21 +22,20 @@ http-body.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["rt", "time"] } +tokio = { workspace = true, features = ["rt", "time", "macros", "parking_lot"] } hyper = { workspace = true, features = ["client", "http1"] } tokio-tungstenite.workspace = true http-body-util.workspace = true pin-project-lite.workspace = true smallvec.workspace = true hyper-util = { workspace = true, features = ["tokio"] } +tokio-stream.workspace = true +tokio-util.workspace = true # Tracing tracing = { workspace = true } # TODO: make optional [dev-dependencies] -tokio = { workspace = true, features = ["macros", "parking_lot"] } -tokio-stream.workspace = true -tokio-util.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } engineioxide = { path = "../engineioxide", features = [ "tracing", diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index f48b5508..2a78204b 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -4,33 +4,36 @@ use std::{ task::{Context, Poll, ready}, }; -use engineioxide_core::{Packet, Sid}; +use engineioxide_core::{Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; use crate::{ EioEvent, transport::{ - PollingSvc, PollingTransport, PollingTransportError, Transport, TransportError, WebSocket, - WsTransport, WsTransportError, noop_impl::NoopWebSocket, + PollingTransport, PollingTransportError, Transport, TransportError, TransportSvc, + WsTransport, WsTransportError, }, }; - pin_project_lite::pin_project! { - pub struct Client { + pub struct Client { #[pin] - pub transport: Transport, + pub transport: Transport, should_send_pong: bool, should_flush: bool, closing: bool, pub sid: Sid, + pub should_upgrade: bool, } } -impl Client { - pub async fn connect_ws(ws: impl Into) -> Result> { - let (inner, open) = WsTransport::connect(ws).await?; +impl Client { + pub async fn connect_default(svc: S) -> Result> { + Self::connect(svc, &[TransportType::Polling, TransportType::Websocket]).await + } + pub async fn connect_ws(svc: S) -> Result> { + let (inner, open) = WsTransport::connect(svc.clone()).await?; let transport = Transport::Websocket { inner }; let client = Client { transport, @@ -39,13 +42,17 @@ impl Client { should_flush: false, should_send_pong: false, closing: false, + should_upgrade: false, }; Ok(client) } -} -impl Client { - pub async fn connect(svc: S) -> Result> { + + pub async fn connect( + svc: impl Into, + transports: &[TransportType], + ) -> Result> { + let svc = svc.into(); let (inner, open) = PollingTransport::connect(svc).await?; let transport = Transport::Polling { inner }; let client = Client { @@ -55,18 +62,26 @@ impl Client { should_flush: false, should_send_pong: false, closing: false, + should_upgrade: transports.contains(&TransportType::Websocket) + && open.upgrades.contains(&TransportType::Websocket), }; Ok(client) } } -impl Client { +impl Client { + pub async fn connect_polling(svc: S) -> Result> { + Self::connect(svc, &[TransportType::Polling]).await + } +} + +impl Client { #[tracing::instrument(skip(cx))] fn heartbeat( self: Pin<&mut Self>, cx: &mut Context<'_>, - ) -> Poll>> { + ) -> Poll>> { let mut proj = self.project(); ready!(proj.transport.as_mut().poll_ready(cx))?; proj.transport.as_mut().start_send(Packet::Pong)?; @@ -77,10 +92,7 @@ impl Client { } #[tracing::instrument(skip(cx))] - fn flush( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll>> { + fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>> { let mut proj = self.project(); ready!(proj.transport.as_mut().poll_flush(cx))?; *proj.should_flush = false; @@ -88,8 +100,8 @@ impl Client { } } -impl Stream for Client { - type Item = Result>; +impl Stream for Client { + type Item = Result>; #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -98,6 +110,22 @@ impl Stream for Client { return Poll::Ready(None); } + if self.should_upgrade { + let sid = self.sid; + return match ready!(self.as_mut().project().transport.upgrade(cx, sid)) { + Some(Ok(())) => { + *self.as_mut().project().should_upgrade = false; + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Err(e)) => Poll::Ready(Some(Err(e))), + None => { + *self.as_mut().project().should_upgrade = false; + Poll::Ready(None) + } // TODO: fallback to polling if upgrade fails, + }; + } + if self.should_send_pong { ready!(self.as_mut().heartbeat(cx))?; } @@ -125,10 +153,14 @@ impl Stream for Client { } } -impl Sink for Client { - type Error = as Sink>::Error; +impl Sink for Client { + type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.should_upgrade || self.closing { + return Poll::Pending; + } + self.project().transport.poll_ready(cx) } @@ -148,7 +180,7 @@ impl Sink for Client { } } -impl fmt::Debug for Client { +impl fmt::Debug for Client { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Client") .field("should_send_pong", &self.should_send_pong) diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 52334186..72f0f2dc 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -1,32 +1,30 @@ use std::{ fmt, pin::Pin, - task::{Context, Poll}, + task::{Context, Poll, ready}, }; -use engineioxide_core::{Packet, TransportType}; +use engineioxide_core::{Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; pub use crate::transport::polling::{PollingSvc, PollingTransport, PollingTransportError}; -pub use crate::transport::ws::{ - WebSocket, WsTransport, WsTransportError, noop_impl, tungstenite_impl, -}; +pub use crate::transport::ws::{WsSvc, WsTransport, WsTransportError, noop_impl, tungstenite_impl}; mod polling; mod ws; -pub enum TransportError { +pub enum TransportError { Polling(PollingTransportError), - Websocket(WsTransportError), + Websocket(WsTransportError), } -impl fmt::Debug for TransportError { +impl fmt::Debug for TransportError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self) } } -impl fmt::Display for TransportError { +impl fmt::Display for TransportError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { TransportError::Polling(e) => write!(f, "polling error: {}", e), @@ -34,23 +32,26 @@ impl fmt::Display for TransportError { } } } -impl std::error::Error for TransportError {} +impl std::error::Error for TransportError {} + +pub trait TransportSvc: PollingSvc + WsSvc {} +impl TransportSvc for S {} pin_project_lite::pin_project! { #[project = TransportProj] - pub enum Transport { + pub enum Transport { Polling { #[pin] inner: PollingTransport }, Websocket { #[pin] - inner: WsTransport + inner: WsTransport } } } -impl Transport { +impl Transport { pub fn transport_type(&self) -> TransportType { match self { Transport::Polling { .. } => TransportType::Polling, @@ -58,9 +59,39 @@ impl Transport { } } } +impl Transport { + pub fn upgrade( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + sid: Sid, + ) -> Poll>>> { + match self.as_mut().project() { + TransportProj::Polling { mut inner } => { + // start by flushing polling transport to ensure there is no pending data + ready!(inner.as_mut().poll_flush(cx)).map_err(TransportError::Polling)?; + let svc = inner.svc.clone(); + + self.set(Transport::Websocket { + inner: WsTransport::connect_with_upgrade(svc, sid), + }); + cx.waker().wake_by_ref(); + Poll::Pending + } + + TransportProj::Websocket { inner } => match ready!(inner.poll_next(cx)) { + Some(Ok(packet)) => { + dbg!(packet); + Poll::Ready(Some(Ok(()))) + } + Some(Err(err)) => Poll::Ready(Some(Err(TransportError::Websocket(err)))), + None => Poll::Ready(None), + }, + } + } +} -impl Stream for Transport { - type Item = Result>; +impl Stream for Transport { + type Item = Result>; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.as_mut().project() { TransportProj::Polling { inner } => { @@ -72,8 +103,8 @@ impl Stream for Transport { } } } -impl Sink for Transport { - type Error = TransportError; +impl Sink for Transport { + type Error = TransportError; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 3f0ae1f8..b9eb4bb3 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -14,6 +14,10 @@ use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; +// TODO: rework the polling svc abstraction so that it it is possible to have conn upgrades. +// or do not support TCP conn upgrades (best option), but in this case I still have a stream issue. +// Because I cant instantiate an abstract stream from the inner lib. Maybe just drop the dynamic stream entirely except +// from testing. pub trait PollingSvc: HyperSvc< Request>, @@ -99,7 +103,7 @@ pub enum PollingTransportError { pin_project! { pub struct PollingTransport { - svc: S, + pub(crate) svc: S, #[pin] poll_state: PollState, diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index 9cd642c3..8a2df51e 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -5,35 +5,52 @@ use std::{ }; use bytes::Bytes; -use engineioxide_core::{OpenPacket, Packet, PacketParseError, ProtocolVersion, Str}; +use engineioxide_core::{OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, Str}; use futures_core::Stream; use futures_util::{Sink, StreamExt}; +use http::Request; +use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; -use tokio_tungstenite::tungstenite::{self, Message, Utf8Bytes}; +use tokio_tungstenite::tungstenite::{self, Message, Utf8Bytes, handshake::client::generate_key}; pin_project! { - pub struct WsTransport { + pub struct WsTransport { + svc: S, + #[pin] - inner: S, + state: WsTransportState } } -pub enum WsTransportError { - Websocket(::Error), +pin_project! { + #[project = WsTransportStateProj] + enum WsTransportState { + Connecting { + #[pin] + fut: S::Future, + }, + Stream { + #[pin] + stream: S::WebSocket, + }, + } +} +pub enum WsTransportError { + Websocket(::Error), Packet(PacketParseError), Closed, } -impl From for WsTransportError { +impl From for WsTransportError { fn from(e: PacketParseError) -> Self { WsTransportError::Packet(e) } } -impl fmt::Debug for WsTransportError { +impl fmt::Debug for WsTransportError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self) } } -impl fmt::Display for WsTransportError { +impl fmt::Display for WsTransportError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { WsTransportError::Websocket(e) => write!(f, "websocket error: {}", e), @@ -42,20 +59,61 @@ impl fmt::Display for WsTransportError { } } } -impl std::error::Error for WsTransportError {} - -impl WsTransport { - pub fn new(inner: impl Into) -> Self { +impl std::error::Error for WsTransportError {} + +impl WsTransport { + #[tracing::instrument(skip(svc))] + pub fn connect_with_upgrade(svc: S, sid: Sid) -> Self { + let req = Request::builder() + .method("GET") + .header("Host", "127.0.0.1") + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()) + .uri(format!( + "ws://127.0.0.1/engine.io/?EIO=4&transport=websocket&sid={sid}" + )) + .body(()) + .unwrap(); + + let fut = svc.call(req); Self { - inner: inner.into(), + svc, + state: WsTransportState::Connecting { fut }, } } - pub async fn connect(inner: impl Into) -> Result<(Self, OpenPacket), WsTransportError> { + #[tracing::instrument(skip(svc))] + pub async fn connect(svc: S) -> Result<(Self, OpenPacket), WsTransportError> { tracing::trace!("handshake request"); - let mut ws = Self::new(inner); - match ws.next().await.ok_or(WsTransportError::Closed)?? { + let req = Request::builder() + .method("GET") + .header("Host", "127.0.0.1") + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()) + .uri("ws://127.0.0.1/engine.io/?EIO=4&transport=websocket") + .body(()) + .unwrap(); + + let mut stream = svc.call(req).await.map_err(WsTransportError::Websocket)?; + tracing::debug!("handshake response received, waiting for open packet"); + + let packet = match stream.next().await.ok_or(WsTransportError::Closed)? { + Ok(msg) => Self::parse_packet(msg)?, + Err(e) => return Err(WsTransportError::Websocket(e)), + }; + tracing::debug!("open packet received, switching to streaming"); + + let ws = Self { + svc, + state: WsTransportState::Stream { stream }, + }; + + match packet { Packet::Open(open_packet) => Ok((ws, open_packet)), _ => Err(WsTransportError::Packet( PacketParseError::InvalidPacketType(None), @@ -64,6 +122,23 @@ impl WsTransport { } } +pub trait WsSvc: + HyperSvc, Response = Self::WebSocket, Error = ::Error> + Clone +{ + type Error: fmt::Debug + std::error::Error; + type WebSocket: WebSocket::Error>; +} + +impl WsSvc for S +where + S: HyperSvc, Response = WS> + Clone, + WS: WebSocket>>::Error>, + >>::Error: fmt::Debug + std::error::Error, +{ + type Error = >>::Error; + type WebSocket = WS; +} + pub trait WebSocket: Stream::Error>> + Sink::Error> @@ -79,8 +154,8 @@ pub enum WsMessage { Close, } -impl WsTransport { - fn parse_packet(&self, msg: WsMessage) -> Result> { +impl WsTransport { + fn parse_packet(msg: WsMessage) -> Result> { match msg { WsMessage::Text(msg) => { let msg_str = unsafe { Str::from_bytes_unchecked(msg.into()) }; @@ -95,55 +170,79 @@ impl WsTransport { } } -impl Stream for WsTransport { - type Item = Result>; +impl Stream for WsTransport { + type Item = Result>; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match ready!(self.as_mut().project().inner.poll_next(cx)) { - Some(Ok(msg)) => match self.parse_packet(msg) { - Ok(packet) => Poll::Ready(Some(Ok(packet))), - Err(e) => Poll::Ready(Some(Err(e))), + match self.as_mut().project().state.project() { + WsTransportStateProj::Connecting { fut } => { + match ready!(fut.poll(cx)) { + Ok(stream) => { + self.project() + .state + .set(WsTransportState::Stream { stream }); + cx.waker().wake_by_ref(); + Poll::Pending + } + Err(_e) => Poll::Ready(None), // stop everything but find a way to report the error + } + } + WsTransportStateProj::Stream { stream } => match ready!(stream.poll_next(cx)) { + Some(Ok(msg)) => match Self::parse_packet(msg) { + Ok(packet) => Poll::Ready(Some(Ok(packet))), + Err(e) => Poll::Ready(Some(Err(e))), + }, + Some(Err(e)) => Poll::Ready(Some(Err(WsTransportError::Websocket(e)))), + None => Poll::Ready(None), }, - Some(Err(e)) => Poll::Ready(Some(Err(WsTransportError::Websocket(e)))), - None => Poll::Ready(None), } } } -impl Sink for WsTransport { - type Error = WsTransportError; +impl Sink for WsTransport { + type Error = WsTransportError; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project() - .inner - .poll_ready(cx) - .map_err(WsTransportError::Websocket) + match self.project().state.project() { + WsTransportStateProj::Connecting { .. } => Poll::Pending, + WsTransportStateProj::Stream { stream } => { + stream.poll_ready(cx).map_err(WsTransportError::Websocket) + } + } } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { - let msg = match item { - Packet::Binary(bin) => WsMessage::Binary(bin), - Packet::Noop => return Ok(()), - p => WsMessage::Text(String::from(p).into()), - }; - self.project() - .inner - .start_send(msg) - .map_err(WsTransportError::Websocket) + match self.project().state.project() { + WsTransportStateProj::Connecting { .. } => { + panic!("poll_ready just returned Poll::Pending") + } + WsTransportStateProj::Stream { stream } => { + let msg = match item { + Packet::Binary(bin) => WsMessage::Binary(bin), + Packet::Noop => return Ok(()), + p => WsMessage::Text(String::from(p).into()), + }; + stream.start_send(msg).map_err(WsTransportError::Websocket) + } + } } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project() - .inner - .poll_flush(cx) - .map_err(WsTransportError::Websocket) + match self.project().state.project() { + WsTransportStateProj::Connecting { .. } => Poll::Ready(Ok(())), + WsTransportStateProj::Stream { stream } => { + stream.poll_flush(cx).map_err(WsTransportError::Websocket) + } + } } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project() - .inner - .poll_close(cx) - .map_err(WsTransportError::Websocket) + match self.project().state.project() { + WsTransportStateProj::Connecting { .. } => Poll::Ready(Ok(())), + WsTransportStateProj::Stream { stream } => { + stream.poll_close(cx).map_err(WsTransportError::Websocket) + } + } } } @@ -152,12 +251,22 @@ pub mod noop_impl { use super::*; - #[derive(Debug, Default)] + #[derive(Debug, Default, Clone)] pub struct NoopWebSocket; + impl HyperSvc> for NoopWebSocket { + type Response = NoopWebSocket; + type Error = Infallible; + type Future = std::future::Ready>; + + fn call(&self, _: http::Request<()>) -> Self::Future { + std::future::ready(Ok(NoopWebSocket)) + } + } impl WebSocket for NoopWebSocket { type Error = Infallible; } + impl Stream for NoopWebSocket { type Item = Result; @@ -209,13 +318,19 @@ pub mod tungstenite_impl { } } pin_project! { - pub struct TokioTungsteniteWebSocket { + pub struct TokioTungsteniteWS { #[pin] inner: tokio_tungstenite::WebSocketStream, } + + impl PinnedDrop for TokioTungsteniteWS { + fn drop(this: Pin<&mut Self>) { + tracing::error!("dropping TokioTungsteniteWS"); + } + } } - impl From> for TokioTungsteniteWebSocket + impl From> for TokioTungsteniteWS where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { @@ -224,14 +339,15 @@ pub mod tungstenite_impl { } } - impl WebSocket - for TokioTungsteniteWebSocket + impl WebSocket for TokioTungsteniteWS + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { type Error = tungstenite::Error; } impl Sink - for TokioTungsteniteWebSocket + for TokioTungsteniteWS { type Error = tungstenite::Error; @@ -252,9 +368,7 @@ pub mod tungstenite_impl { } } - impl Stream - for TokioTungsteniteWebSocket - { + impl Stream for TokioTungsteniteWS { type Item = Result; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index fd1a1881..e894a5d8 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -1,3 +1,4 @@ +use std::convert::Infallible; use std::io; use std::pin::Pin; use std::task::{Context, Poll}; @@ -5,12 +6,15 @@ use std::task::{Context, Poll}; use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::service::EngineIoService; -use engineioxide_client::Client; -use engineioxide_client::tungstenite_impl::TokioTungsteniteWebSocket; +use engineioxide_client::tungstenite_impl::TokioTungsteniteWS; +use futures_core::future::BoxFuture; +use futures_util::FutureExt; +use http::Response; +use http_body_util::combinators::BoxBody; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; -use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key}; +use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::protocol::Role; use tokio_util::io::StreamReader; @@ -66,45 +70,65 @@ impl AsyncWrite for StreamImpl { } } -pub async fn tungstenite_client( - svc: EngineIoService, -) -> tokio_tungstenite::WebSocketStream { - let (tx, rx) = mpsc::unbounded_channel(); - let (tx1, rx1) = mpsc::unbounded_channel(); - - let parts = Request::builder() - .method("GET") - .header("Host", "127.0.0.1") - .header("Connection", "Upgrade") - .header("Upgrade", "websocket") - .header("Sec-WebSocket-Version", "13") - .header("Sec-WebSocket-Key", generate_key()) - .uri("ws://127.0.0.1/engine.io/?EIO=4&transport=websocket") - .body(http_body_util::Empty::::new()) - .unwrap() - .into_parts() - .0; +#[derive(Debug)] +pub struct EngineIoTestSvc { + inner: EngineIoService, +} +impl Clone for EngineIoTestSvc { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} +impl From> for EngineIoTestSvc { + fn from(inner: EngineIoService) -> Self { + Self { inner } + } +} - svc.ws_init( - StreamImpl::new(tx, rx1), - engineioxide::ProtocolVersion::V4, - None, - parts, - ) - .await - .unwrap(); +impl hyper::service::Service>> + for EngineIoTestSvc +{ + type Response = Response>; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; - tokio_tungstenite::WebSocketStream::from_raw_socket( - StreamImpl::new(tx1, rx), - Role::Client, - Default::default(), - ) - .await + fn call(&self, req: http::Request>) -> Self::Future { + let svc = self.inner.clone(); + async move { svc.call(req).await.map(|r| r.map(BoxBody::new)) }.boxed() + } } +impl hyper::service::Service> for EngineIoTestSvc { + type Response = TokioTungsteniteWS; + type Error = tungstenite::Error; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: http::Request<()>) -> Self::Future { + let (tx, rx) = mpsc::unbounded_channel(); + let (tx1, rx1) = mpsc::unbounded_channel(); + + let (parts, _) = req.into_parts(); + let svc = self.inner.clone(); + async move { + svc.ws_init( + StreamImpl::new(tx, rx1), + engineioxide::ProtocolVersion::V4, + None, + parts, + ) + .await + .unwrap(); -pub async fn client_ws_connect( - svc: EngineIoService, -) -> Client, TokioTungsteniteWebSocket> { - let ws = tungstenite_client(svc).await; - Client::connect_ws(ws).await.unwrap() + let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( + StreamImpl::new(tx1, rx), + Role::Client, + Default::default(), + ) + .await; + + Ok(TokioTungsteniteWS::from(ws)) + } + .boxed() + } } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 811befaf..9367c7e2 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -4,13 +4,12 @@ use bytes::Bytes; use engineioxide::handler::EngineIoHandler; use engineioxide::{DisconnectReason, service::EngineIoService}; use engineioxide::{Socket, Str}; -use engineioxide_client::tungstenite_impl::TokioTungsteniteWebSocket; use engineioxide_client::{PollingTransport, WsTransport}; use engineioxide_core::Sid; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; -use crate::fixture::tungstenite_client; +use crate::fixture::EngineIoTestSvc; mod fixture; @@ -43,11 +42,11 @@ fn init_tracing() { /// Build an [`EngineIoService`] with a short ping interval/timeout so the /// heartbeat fires quickly during tests. -fn service() -> (EngineIoService, mpsc::UnboundedReceiver) { +fn service() -> (EngineIoTestSvc, mpsc::UnboundedReceiver) { init_tracing(); let (handler, rx) = Handler::new(); let svc = EngineIoService::new(Arc::new(handler)); - (svc, rx) + (svc.into(), rx) } impl EngineIoHandler for Handler { @@ -85,9 +84,6 @@ async fn handshake_polling() { #[tokio::test] async fn handshake_websocket() { let (svc, mut rx) = service(); - let ws = tungstenite_client(svc).await; - let (_, open) = WsTransport::>::connect(ws) - .await - .unwrap(); + let (_, open) = WsTransport::connect(svc).await.unwrap(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); } diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index 010cd942..9bd82bad 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -22,6 +22,8 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +use crate::fixture::EngineIoTestSvc; + mod fixture; const PING_INTERVAL: Duration = Duration::from_millis(100); @@ -78,7 +80,7 @@ fn init_tracing() { /// Build an [`EngineIoService`] with a short ping interval/timeout so the /// heartbeat fires quickly during tests. -fn service() -> (EngineIoService, mpsc::Receiver) { +fn service() -> (EngineIoTestSvc, mpsc::Receiver) { init_tracing(); let (handler, rx) = Handler::new(); let config = EngineIoConfig::builder() @@ -86,7 +88,7 @@ fn service() -> (EngineIoService, mpsc::Receiver) { .ping_timeout(PING_TIMEOUT) .build(); let svc = EngineIoService::with_config(Arc::new(handler), config); - (svc, rx) + (svc.into(), rx) } /// A [`Client`] that is continuously polled must auto-respond to the server's @@ -96,7 +98,7 @@ fn service() -> (EngineIoService, mpsc::Receiver) { async fn heartbeat_keeps_connection_alive() { let (svc, mut rx) = service(); - let mut client = Client::connect(svc).await.unwrap(); + let mut client = Client::connect_polling(svc).await.unwrap(); let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); @@ -145,7 +147,7 @@ async fn heartbeat_keeps_connection_alive() { async fn heartbeat_keeps_connection_alive_websocket() { let (svc, mut rx) = service(); - let mut client = fixture::client_ws_connect(svc).await; + let mut client = Client::connect_ws(svc).await.unwrap(); let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); diff --git a/crates/engineioxide-client/tests/round_trip.rs b/crates/engineioxide-client/tests/round_trip.rs index 9d3c6bac..0df4b7e2 100644 --- a/crates/engineioxide-client/tests/round_trip.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -16,6 +16,8 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +use crate::fixture::EngineIoTestSvc; + mod fixture; #[derive(Debug, PartialEq, Eq)] @@ -45,11 +47,11 @@ fn init_tracing() { .ok(); } -fn service() -> (EngineIoService, mpsc::UnboundedReceiver) { +fn service() -> (EngineIoTestSvc, mpsc::UnboundedReceiver) { init_tracing(); let (handler, rx) = Handler::new(); let svc = EngineIoService::new(Arc::new(handler)); - (svc, rx) + (svc.into(), rx) } impl EngineIoHandler for Handler { @@ -82,7 +84,7 @@ impl EngineIoHandler for Handler { #[tokio::test] async fn round_trip() { let (svc, mut rx) = service(); - let client = Client::connect(svc).await.unwrap(); + let client = Client::connect_polling(svc).await.unwrap(); let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); let (mut ctx, mut crx) = client.split::(); @@ -118,7 +120,7 @@ async fn round_trip() { #[tokio::test] async fn round_trip_ws() { let (svc, mut rx) = service(); - let client = fixture::client_ws_connect(svc).await; + let client = Client::connect_ws(svc).await.unwrap(); let sid = client.sid; assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); let (mut ctx, mut crx) = client.split::(); diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index ea7ad418..d2b76aa0 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -346,7 +346,9 @@ where { let packet = Packet::Open(make_open_packet(TransportType::Websocket, sid, config)); let packet: String = packet.into(); + tracing::debug!(%sid, "sending open packet"); ws.send(Message::Text(packet.into())).await?; + tracing::debug!(%sid, "open packet sent"); Ok(()) } From e0f8cb9085e9c269decaf4d028d5ce5000f927c7 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 12 Jul 2026 17:35:32 +0200 Subject: [PATCH 19/41] fix: tests --- crates/engineioxide-client/src/client.rs | 3 +- .../src/transport/polling.rs | 4 --- .../engineioxide-client/src/transport/ws.rs | 6 ---- crates/engineioxide-client/tests/fixture.rs | 32 ++++++++++++------- crates/engineioxide-client/tests/handshake.rs | 27 +++++++++++++--- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 2a78204b..ded366aa 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -15,6 +15,7 @@ use crate::{ WsTransport, WsTransportError, }, }; + pin_project_lite::pin_project! { pub struct Client { #[pin] @@ -24,7 +25,7 @@ pin_project_lite::pin_project! { should_flush: bool, closing: bool, pub sid: Sid, - pub should_upgrade: bool, + should_upgrade: bool, } } diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index b9eb4bb3..5efcb2f0 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -14,10 +14,6 @@ use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; -// TODO: rework the polling svc abstraction so that it it is possible to have conn upgrades. -// or do not support TCP conn upgrades (best option), but in this case I still have a stream issue. -// Because I cant instantiate an abstract stream from the inner lib. Maybe just drop the dynamic stream entirely except -// from testing. pub trait PollingSvc: HyperSvc< Request>, diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index 8a2df51e..ea7cd27b 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -322,12 +322,6 @@ pub mod tungstenite_impl { #[pin] inner: tokio_tungstenite::WebSocketStream, } - - impl PinnedDrop for TokioTungsteniteWS { - fn drop(this: Pin<&mut Self>) { - tracing::error!("dropping TokioTungsteniteWS"); - } - } } impl From> for TokioTungsteniteWS diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index e894a5d8..92cf5056 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -18,7 +18,16 @@ use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::protocol::Role; use tokio_util::io::StreamReader; +/// Create a stub TCP Stream implemented with two unbounded channels +fn duplex_stream() -> (StreamImpl, StreamImpl) { + let (tx, rx) = mpsc::unbounded_channel(); + let (tx1, rx1) = mpsc::unbounded_channel(); + (StreamImpl::new(tx, rx1), StreamImpl::new(tx1, rx)) +} + pin_project_lite::pin_project! { + /// Half of a full-duplex bytes stream used to fake + /// a TCP stream to use [`tokio_tungstenite`] in local. pub struct StreamImpl { tx: mpsc::UnboundedSender>, #[pin] @@ -87,6 +96,7 @@ impl From> for EngineIoTestSvc { } } +/// HTTP Service implementation impl hyper::service::Service>> for EngineIoTestSvc { @@ -99,34 +109,32 @@ impl hyper::service::Service hyper::service::Service> for EngineIoTestSvc { type Response = TokioTungsteniteWS; type Error = tungstenite::Error; type Future = BoxFuture<'static, Result>; fn call(&self, req: http::Request<()>) -> Self::Future { - let (tx, rx) = mpsc::unbounded_channel(); - let (tx1, rx1) = mpsc::unbounded_channel(); - let (parts, _) = req.into_parts(); let svc = self.inner.clone(); + let (client, server) = duplex_stream(); + tracing::debug!("initializing duplex stream"); async move { - svc.ws_init( - StreamImpl::new(tx, rx1), - engineioxide::ProtocolVersion::V4, - None, - parts, - ) - .await - .unwrap(); + tokio::spawn(svc.ws_init(server, engineioxide::ProtocolVersion::V4, None, parts)); + + tracing::debug!("server connected, wiring websocket client"); let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( - StreamImpl::new(tx1, rx), + client, Role::Client, Default::default(), ) .await; + tracing::debug!("stub ws client wired up"); + Ok(TokioTungsteniteWS::from(ws)) } .boxed() diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 9367c7e2..c02ccbd5 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Duration; use bytes::Bytes; use engineioxide::handler::EngineIoHandler; @@ -77,13 +78,31 @@ impl EngineIoHandler for Handler { #[tokio::test] async fn handshake_polling() { let (svc, mut rx) = service(); - let (_, open) = PollingTransport::connect(svc).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); + let (_transport, open) = + tokio::time::timeout(Duration::from_secs(1), PollingTransport::connect(svc)) + .await + .expect("timeout while initializing polling transport conn") + .unwrap(); + + let event = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timeout while receiving server connect event"); + + assert_eq!(event, Some(Event::Connect(open.sid))); } #[tokio::test] async fn handshake_websocket() { let (svc, mut rx) = service(); - let (_, open) = WsTransport::connect(svc).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(open.sid)); + let (_transport, open) = + tokio::time::timeout(Duration::from_secs(1), WsTransport::connect(svc)) + .await + .expect("timeout while initializing polling transport conn") + .unwrap(); + + let event = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("timeout while receiving server connect event"); + + assert_eq!(event, Some(Event::Connect(open.sid))); } From ce741ed0429e62a06f3fa6b57c618b8e486661eb Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 12 Jul 2026 19:29:10 +0200 Subject: [PATCH 20/41] fix: are u awake? --- crates/engineioxide-client/src/client.rs | 17 +- crates/engineioxide-client/src/event.rs | 5 +- .../engineioxide-client/src/transport/ws.rs | 195 ++++++++++++++---- crates/engineioxide-client/tests/fixture.rs | 81 +++++++- crates/engineioxide-client/tests/handshake.rs | 71 +------ crates/engineioxide-client/tests/heartbeat.rs | 76 +------ .../engineioxide-client/tests/round_trip.rs | 69 +------ crates/engineioxide-client/tests/upgrade.rs | 58 ++++++ crates/engineioxide-core/src/packet.rs | 5 + 9 files changed, 329 insertions(+), 248 deletions(-) create mode 100644 crates/engineioxide-client/tests/upgrade.rs diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index ded366aa..9d206efb 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -69,6 +69,10 @@ impl Client { Ok(client) } + + pub fn transport(&self) -> TransportType { + self.transport.transport_type() + } } impl Client { @@ -113,8 +117,10 @@ impl Stream for Client { if self.should_upgrade { let sid = self.sid; + tracing::debug!(%sid, "transport should upgrade"); return match ready!(self.as_mut().project().transport.upgrade(cx, sid)) { Some(Ok(())) => { + tracing::debug!(%sid, "transport upgraded"); *self.as_mut().project().should_upgrade = false; cx.waker().wake_by_ref(); Poll::Pending @@ -147,6 +153,11 @@ impl Stream for Client { } Some(Ok(Packet::Message(v))) => Poll::Ready(Some(Ok(EioEvent::Message(v)))), Some(Ok(Packet::Binary(v))) => Poll::Ready(Some(Ok(EioEvent::Binary(v)))), + Some(Ok(Packet::Upgrade)) => { + dbg!(self.should_upgrade); + *self.as_mut().project().should_upgrade = false; + Poll::Ready(Some(Ok(EioEvent::Upgrade(self.transport())))) + } Some(Ok(v)) => unreachable!("unexpected msg {v:?}"), Some(Err(e)) => Poll::Ready(Some(Err(e))), None => Poll::Ready(None), @@ -158,14 +169,15 @@ impl Sink for Client { type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - if self.should_upgrade || self.closing { + if dbg!(self.should_upgrade || self.closing) { return Poll::Pending; } - self.project().transport.poll_ready(cx) + dbg!(self.project().transport.poll_ready(cx)) } fn start_send(self: Pin<&mut Self>, event: EioEvent) -> Result<(), Self::Error> { + dbg!("send"); if let Some(packet) = event.into() { self.project().transport.start_send(packet)?; } @@ -173,6 +185,7 @@ impl Sink for Client { } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + dbg!("flush"); self.project().transport.poll_flush(cx) } diff --git a/crates/engineioxide-client/src/event.rs b/crates/engineioxide-client/src/event.rs index 5894a962..e38e31eb 100644 --- a/crates/engineioxide-client/src/event.rs +++ b/crates/engineioxide-client/src/event.rs @@ -1,12 +1,13 @@ use bytes::Bytes; -use engineioxide_core::{Packet, Str}; +use engineioxide_core::{Packet, Str, TransportType}; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq)] pub enum EioEvent { Connect, Disconnect, Message(Str), Binary(Bytes), + Upgrade(TransportType), } impl From for Option { diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index ea7cd27b..3fe8aad2 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -1,7 +1,7 @@ use std::{ fmt, pin::Pin, - task::{Context, Poll, ready}, + task::{Context, Poll, Waker, ready}, }; use bytes::Bytes; @@ -18,7 +18,8 @@ pin_project! { svc: S, #[pin] - state: WsTransportState + state: WsTransportState, + sink_waker: Option, } } @@ -32,14 +33,38 @@ pin_project! { Stream { #[pin] stream: S::WebSocket, + upgrade: UpgradeHandshakeState, }, } } +#[derive(Debug)] +enum UpgradeHandshakeState { + ShouldSendPingUpgrade, + ShouldFlushPingUpgrade, + WaitingPong, + ShouldSendUpgrade, + ShouldFlushUpgrade, + Done, +} + pub enum WsTransportError { Websocket(::Error), Packet(PacketParseError), + InvalidPacket { + expected: Box, + got: Box, + }, Closed, } + +impl WsTransportError { + fn invalid_packet(expected: Packet, got: Packet) -> Self { + Self::InvalidPacket { + expected: Box::new(expected), + got: Box::new(got), + } + } +} impl From for WsTransportError { fn from(e: PacketParseError) -> Self { WsTransportError::Packet(e) @@ -55,6 +80,10 @@ impl fmt::Display for WsTransportError { match self { WsTransportError::Websocket(e) => write!(f, "websocket error: {}", e), WsTransportError::Packet(e) => write!(f, "packet error: {}", e), + WsTransportError::InvalidPacket { expected, got } => write!( + f, + "invalid packet received, expected {expected:?}, got {got:?}" + ), WsTransportError::Closed => write!(f, "websocket closed"), } } @@ -64,6 +93,7 @@ impl std::error::Error for WsTransportError {} impl WsTransport { #[tracing::instrument(skip(svc))] pub fn connect_with_upgrade(svc: S, sid: Sid) -> Self { + tracing::trace!("websocket connection with upgrade"); let req = Request::builder() .method("GET") .header("Host", "127.0.0.1") @@ -80,13 +110,14 @@ impl WsTransport { let fut = svc.call(req); Self { svc, + sink_waker: None, state: WsTransportState::Connecting { fut }, } } #[tracing::instrument(skip(svc))] pub async fn connect(svc: S) -> Result<(Self, OpenPacket), WsTransportError> { - tracing::trace!("handshake request"); + tracing::trace!("websocket connection without upgrade"); let req = Request::builder() .method("GET") @@ -103,14 +134,18 @@ impl WsTransport { tracing::debug!("handshake response received, waiting for open packet"); let packet = match stream.next().await.ok_or(WsTransportError::Closed)? { - Ok(msg) => Self::parse_packet(msg)?, + Ok(msg) => parse_packet(msg)?, Err(e) => return Err(WsTransportError::Websocket(e)), }; tracing::debug!("open packet received, switching to streaming"); let ws = Self { svc, - state: WsTransportState::Stream { stream }, + sink_waker: None, + state: WsTransportState::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + }, }; match packet { @@ -154,19 +189,66 @@ pub enum WsMessage { Close, } -impl WsTransport { - fn parse_packet(msg: WsMessage) -> Result> { - match msg { - WsMessage::Text(msg) => { - let msg_str = unsafe { Str::from_bytes_unchecked(msg.into()) }; - let packet = Packet::parse(ProtocolVersion::V4, msg_str)?; - Ok(packet) - } - WsMessage::Binary(data) => Ok(Packet::Binary(data)), - WsMessage::Close => { - todo!("impl ws close"); +fn parse_packet(msg: WsMessage) -> Result> { + match msg { + WsMessage::Text(msg) => { + let msg_str = unsafe { Str::from_bytes_unchecked(msg.into()) }; + let packet = Packet::parse(ProtocolVersion::V4, msg_str)?; + Ok(packet) + } + WsMessage::Binary(data) => Ok(Packet::Binary(data)), + WsMessage::Close => { + todo!("impl ws close"); + } + } +} + +#[tracing::instrument(skip(cx, stream), ret)] +fn poll_upgrade( + cx: &mut Context<'_>, + mut stream: Pin<&mut S::WebSocket>, + curr: &mut UpgradeHandshakeState, +) -> Poll>> { + match curr { + UpgradeHandshakeState::ShouldSendPingUpgrade => { + ready!(stream.as_mut().poll_ready(cx)).map_err(WsTransportError::Websocket)?; + stream + .start_send(WsMessage::Text(Packet::PingUpgrade.into())) + .map_err(WsTransportError::Websocket)?; + Poll::Ready(Ok(UpgradeHandshakeState::ShouldFlushPingUpgrade)) + } + UpgradeHandshakeState::ShouldFlushPingUpgrade => { + ready!(stream.as_mut().poll_flush(cx)).map_err(WsTransportError::Websocket)?; + Poll::Ready(Ok(UpgradeHandshakeState::WaitingPong)) + } + UpgradeHandshakeState::WaitingPong => { + match ready!(stream.as_mut().poll_next(cx)).map(|v| v.map(parse_packet::)) { + Some(Ok(Ok(Packet::PongUpgrade))) => { + Poll::Ready(Ok(UpgradeHandshakeState::ShouldSendUpgrade)) + } + Some(Ok(Ok(p))) => Poll::Ready(Err(WsTransportError::invalid_packet( + Packet::PongUpgrade, + p, + ))), + Some(Ok(Err(parsing_err))) => Poll::Ready(Err(parsing_err)), + Some(Err(err)) => Poll::Ready(Err(WsTransportError::Websocket(err))), + None => Poll::Ready(Err(WsTransportError::Closed)), } } + UpgradeHandshakeState::ShouldSendUpgrade => { + ready!(stream.as_mut().poll_ready(cx)).map_err(WsTransportError::Websocket)?; + stream + .start_send(WsMessage::Text(Packet::Upgrade.into())) + .map_err(WsTransportError::Websocket)?; + Poll::Ready(Ok(UpgradeHandshakeState::ShouldFlushUpgrade)) + } + UpgradeHandshakeState::ShouldFlushUpgrade => { + ready!(stream.as_mut().poll_flush(cx)).map_err(WsTransportError::Websocket)?; + Poll::Ready(Ok(UpgradeHandshakeState::Done)) + } + UpgradeHandshakeState::Done => { + unreachable!("poll_upgrade should never be called once upgrade as been performed") + } } } @@ -175,26 +257,53 @@ impl Stream for WsTransport { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.as_mut().project().state.project() { + // if we were connecting it means that's an upgrade. + // TODO: this assertion might be brittle, add a test to prove that. WsTransportStateProj::Connecting { fut } => { match ready!(fut.poll(cx)) { Ok(stream) => { - self.project() - .state - .set(WsTransportState::Stream { stream }); + self.project().state.set(WsTransportState::Stream { + stream, + upgrade: UpgradeHandshakeState::ShouldSendPingUpgrade, + }); cx.waker().wake_by_ref(); Poll::Pending } Err(_e) => Poll::Ready(None), // stop everything but find a way to report the error } } - WsTransportStateProj::Stream { stream } => match ready!(stream.poll_next(cx)) { - Some(Ok(msg)) => match Self::parse_packet(msg) { + WsTransportStateProj::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + .. + } => match ready!(stream.poll_next(cx)) { + Some(Ok(msg)) => match parse_packet(msg) { Ok(packet) => Poll::Ready(Some(Ok(packet))), Err(e) => Poll::Ready(Some(Err(e))), }, Some(Err(e)) => Poll::Ready(Some(Err(WsTransportError::Websocket(e)))), None => Poll::Ready(None), }, + WsTransportStateProj::Stream { stream, upgrade } => { + match ready!(poll_upgrade(cx, stream, upgrade)) { + Ok(UpgradeHandshakeState::Done) => { + *upgrade = UpgradeHandshakeState::Done; + tracing::debug!("upgrade done, switching in nominal state"); + cx.waker().wake_by_ref(); + if let Some(waker) = self.as_mut().project().sink_waker.take() { + waker.wake(); + }; + Poll::Ready(Some(Ok(Packet::Upgrade))) + } + Ok(next) => { + // switch to next upgrade state + *upgrade = next; + cx.waker().wake_by_ref(); + Poll::Pending + } + Err(err) => Poll::Ready(Some(Err(err))), + } + } } } } @@ -202,21 +311,33 @@ impl Stream for WsTransport { impl Sink for WsTransport { type Error = WsTransportError; - fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.project().state.project() { - WsTransportStateProj::Connecting { .. } => Poll::Pending, - WsTransportStateProj::Stream { stream } => { - stream.poll_ready(cx).map_err(WsTransportError::Websocket) + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + dbg!("ready ws"); + match self.as_mut().project().state.project() { + WsTransportStateProj::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + .. + } => stream.poll_ready(cx).map_err(WsTransportError::Websocket), + _ => { + // save sink waker so that when stream status change we can wake + // task that are waiting for polling. + self.as_mut() + .project() + .sink_waker + .replace(cx.waker().clone()); + + Poll::Pending } } } fn start_send(self: Pin<&mut Self>, item: Packet) -> Result<(), Self::Error> { match self.project().state.project() { - WsTransportStateProj::Connecting { .. } => { - panic!("poll_ready just returned Poll::Pending") - } - WsTransportStateProj::Stream { stream } => { + WsTransportStateProj::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + } => { let msg = match item { Packet::Binary(bin) => WsMessage::Binary(bin), Packet::Noop => return Ok(()), @@ -224,22 +345,26 @@ impl Sink for WsTransport { }; stream.start_send(msg).map_err(WsTransportError::Websocket) } + _ => { + panic!("Sink is not ready") + } } } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project().state.project() { - WsTransportStateProj::Connecting { .. } => Poll::Ready(Ok(())), - WsTransportStateProj::Stream { stream } => { - stream.poll_flush(cx).map_err(WsTransportError::Websocket) - } + WsTransportStateProj::Stream { + stream, + upgrade: UpgradeHandshakeState::Done, + } => stream.poll_flush(cx).map_err(WsTransportError::Websocket), + _ => Poll::Ready(Ok(())), } } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project().state.project() { WsTransportStateProj::Connecting { .. } => Poll::Ready(Ok(())), - WsTransportStateProj::Stream { stream } => { + WsTransportStateProj::Stream { stream, .. } => { stream.poll_close(cx).map_err(WsTransportError::Websocket) } } diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index 92cf5056..90835fe2 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -1,12 +1,16 @@ use std::convert::Infallible; use std::io; use std::pin::Pin; +use std::sync::Arc; use std::task::{Context, Poll}; use bytes::Bytes; +use engineioxide::config::EngineIoConfig; use engineioxide::handler::EngineIoHandler; use engineioxide::service::EngineIoService; +use engineioxide::{DisconnectReason, Socket}; use engineioxide_client::tungstenite_impl::TokioTungsteniteWS; +use engineioxide_core::{Sid, Str}; use futures_core::future::BoxFuture; use futures_util::FutureExt; use http::Response; @@ -17,6 +21,73 @@ use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::protocol::Role; use tokio_util::io::StreamReader; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, PartialEq, Eq)] +pub enum Event { + Connect(Sid), + Disconnect(Sid, DisconnectReason), + Message(Sid, Str), + Binary(Sid, Bytes), +} + +#[derive(Debug)] +pub struct EchoHandler { + tx: mpsc::UnboundedSender, +} + +impl EchoHandler { + fn new() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self { tx }, rx) + } +} + +fn init_tracing() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .try_init() + .ok(); +} + +pub fn service_with_config( + config: EngineIoConfig, +) -> (EngineIoTestSvc, mpsc::UnboundedReceiver) { + init_tracing(); + let (handler, rx) = EchoHandler::new(); + let svc = EngineIoService::with_config(Arc::new(handler), config); + (svc.into(), rx) +} + +#[allow(unused)] +pub fn service() -> (EngineIoTestSvc, mpsc::UnboundedReceiver) { + service_with_config(EngineIoConfig::default()) +} + +impl EngineIoHandler for EchoHandler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) { + self.tx.send(Event::Connect(socket.id)).unwrap(); + } + + fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { + self.tx.send(Event::Disconnect(socket.id, reason)).unwrap(); + } + + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + self.tx + .send(Event::Message(socket.id, msg.clone())) + .unwrap(); + socket.emit(msg).unwrap(); + } + + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { + self.tx + .send(Event::Binary(socket.id, data.clone())) + .unwrap(); + socket.emit_binary(data).unwrap(); + } +} /// Create a stub TCP Stream implemented with two unbounded channels fn duplex_stream() -> (StreamImpl, StreamImpl) { @@ -121,8 +192,16 @@ impl hyper::service::Service> for EngineIo let svc = self.inner.clone(); let (client, server) = duplex_stream(); tracing::debug!("initializing duplex stream"); + let query = parts.uri.query().unwrap(); + + let sid: Option = query + .split('&') + .find(|s| s.starts_with("sid=")) + .and_then(|s| s.split('=').nth(1).map(|s1| s1.parse().ok())) + .flatten(); + async move { - tokio::spawn(svc.ws_init(server, engineioxide::ProtocolVersion::V4, None, parts)); + tokio::spawn(svc.ws_init(server, engineioxide::ProtocolVersion::V4, sid, parts)); tracing::debug!("server connected, wiring websocket client"); diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index c02ccbd5..9990a369 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,80 +1,11 @@ -use std::sync::Arc; use std::time::Duration; -use bytes::Bytes; -use engineioxide::handler::EngineIoHandler; -use engineioxide::{DisconnectReason, service::EngineIoService}; -use engineioxide::{Socket, Str}; use engineioxide_client::{PollingTransport, WsTransport}; -use engineioxide_core::Sid; -use tokio::sync::mpsc; -use tracing_subscriber::EnvFilter; -use crate::fixture::EngineIoTestSvc; +use crate::fixture::{Event, service}; mod fixture; -#[derive(Debug, PartialEq, Eq)] -enum Event { - Connect(Sid), - Disconnect(Sid, DisconnectReason), - Message(Sid, Str), - Binary(Sid, Bytes), -} - -#[derive(Debug)] -struct Handler { - tx: mpsc::UnboundedSender, -} - -impl Handler { - fn new() -> (Self, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - (Self { tx }, rx) - } -} - -fn init_tracing() { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); -} - -/// Build an [`EngineIoService`] with a short ping interval/timeout so the -/// heartbeat fires quickly during tests. -fn service() -> (EngineIoTestSvc, mpsc::UnboundedReceiver) { - init_tracing(); - let (handler, rx) = Handler::new(); - let svc = EngineIoService::new(Arc::new(handler)); - (svc.into(), rx) -} - -impl EngineIoHandler for Handler { - type Data = (); - fn on_connect(self: Arc, socket: Arc>) { - self.tx.send(Event::Connect(socket.id)).unwrap(); - } - - fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { - self.tx.send(Event::Disconnect(socket.id, reason)).unwrap(); - } - - fn on_message(self: &Arc, msg: Str, socket: Arc>) { - self.tx - .send(Event::Message(socket.id, msg.clone())) - .unwrap(); - socket.emit(msg).unwrap(); - } - - fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { - self.tx - .send(Event::Binary(socket.id, data.clone())) - .unwrap(); - socket.emit_binary(data).unwrap(); - } -} - #[tokio::test] async fn handshake_polling() { let (svc, mut rx) = service(); diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index 9bd82bad..480ab2c7 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -8,95 +8,31 @@ //! The pong is emitted transparently by `Client::poll_next`: a `Ping` is //! intercepted, a `Pong` is sent and the `Ping` is never surfaced to the user. -use std::sync::Arc; use std::time::Duration; -use bytes::Bytes; use engineioxide::config::EngineIoConfig; -use engineioxide::handler::EngineIoHandler; -use engineioxide::service::EngineIoService; -use engineioxide::{DisconnectReason, Socket, Str}; use engineioxide_client::{Client, EioEvent}; -use engineioxide_core::Sid; use futures_util::{SinkExt, StreamExt}; -use tokio::sync::mpsc; -use tracing_subscriber::EnvFilter; -use crate::fixture::EngineIoTestSvc; +use crate::fixture::{Event, service_with_config}; mod fixture; const PING_INTERVAL: Duration = Duration::from_millis(100); const PING_TIMEOUT: Duration = Duration::from_millis(100); -#[derive(Debug, PartialEq, Eq)] -enum Event { - Connect(Sid), - Disconnect(Sid, DisconnectReason), - Message(Sid, Str), -} - -#[derive(Debug)] -struct Handler { - tx: mpsc::Sender, -} - -impl Handler { - fn new() -> (Self, mpsc::Receiver) { - let (tx, rx) = mpsc::channel(100); - (Self { tx }, rx) - } -} - -impl EngineIoHandler for Handler { - type Data = (); - fn on_connect(self: Arc, socket: Arc>) { - self.tx.try_send(Event::Connect(socket.id)).unwrap(); - } - - fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { - self.tx - .try_send(Event::Disconnect(socket.id, reason)) - .unwrap(); - } - - fn on_message(self: &Arc, msg: Str, socket: Arc>) { - self.tx - .try_send(Event::Message(socket.id, msg.clone())) - .unwrap(); - // Echo the message back so the client can observe liveness. - socket.emit(msg).unwrap(); - } - - fn on_binary(self: &Arc, _data: Bytes, _socket: Arc>) {} -} - -fn init_tracing() { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); -} - -/// Build an [`EngineIoService`] with a short ping interval/timeout so the -/// heartbeat fires quickly during tests. -fn service() -> (EngineIoTestSvc, mpsc::Receiver) { - init_tracing(); - let (handler, rx) = Handler::new(); - let config = EngineIoConfig::builder() +fn config() -> EngineIoConfig { + EngineIoConfig::builder() .ping_interval(PING_INTERVAL) .ping_timeout(PING_TIMEOUT) - .build(); - let svc = EngineIoService::with_config(Arc::new(handler), config); - (svc.into(), rx) + .build() } - /// A [`Client`] that is continuously polled must auto-respond to the server's /// `Ping`s with `Pong`s, keeping the connection alive across several ping /// cycles. The connection must also still be usable afterwards. #[tokio::test] async fn heartbeat_keeps_connection_alive() { - let (svc, mut rx) = service(); + let (svc, mut rx) = service_with_config(config()); let mut client = Client::connect_polling(svc).await.unwrap(); let sid = client.sid; @@ -145,7 +81,7 @@ async fn heartbeat_keeps_connection_alive() { /// cycles. The connection must also still be usable afterwards. #[tokio::test] async fn heartbeat_keeps_connection_alive_websocket() { - let (svc, mut rx) = service(); + let (svc, mut rx) = service_with_config(config()); let mut client = Client::connect_ws(svc).await.unwrap(); let sid = client.sid; diff --git a/crates/engineioxide-client/tests/round_trip.rs b/crates/engineioxide-client/tests/round_trip.rs index 0df4b7e2..82e7b9e7 100644 --- a/crates/engineioxide-client/tests/round_trip.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -4,81 +4,14 @@ //! [`Client::split`]: packets sent through the sink must reach the server and //! the echoed packets must be surfaced back through the stream. -use std::sync::Arc; - use bytes::Bytes; -use engineioxide::handler::EngineIoHandler; -use engineioxide::{DisconnectReason, service::EngineIoService}; -use engineioxide::{Socket, Str}; use engineioxide_client::{Client, EioEvent}; -use engineioxide_core::Sid; use futures_util::{SinkExt, StreamExt}; -use tokio::sync::mpsc; -use tracing_subscriber::EnvFilter; -use crate::fixture::EngineIoTestSvc; +use crate::fixture::{Event, service}; mod fixture; -#[derive(Debug, PartialEq, Eq)] -enum Event { - Connect(Sid), - Disconnect(Sid, DisconnectReason), - Message(Sid, Str), - Binary(Sid, Bytes), -} - -#[derive(Debug)] -struct Handler { - tx: mpsc::UnboundedSender, -} - -impl Handler { - fn new() -> (Self, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - (Self { tx }, rx) - } -} - -fn init_tracing() { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .try_init() - .ok(); -} - -fn service() -> (EngineIoTestSvc, mpsc::UnboundedReceiver) { - init_tracing(); - let (handler, rx) = Handler::new(); - let svc = EngineIoService::new(Arc::new(handler)); - (svc.into(), rx) -} - -impl EngineIoHandler for Handler { - type Data = (); - fn on_connect(self: Arc, socket: Arc>) { - self.tx.send(Event::Connect(socket.id)).unwrap(); - } - - fn on_disconnect(&self, socket: Arc>, reason: DisconnectReason) { - self.tx.send(Event::Disconnect(socket.id, reason)).unwrap(); - } - - fn on_message(self: &Arc, msg: Str, socket: Arc>) { - self.tx - .send(Event::Message(socket.id, msg.clone())) - .unwrap(); - socket.emit(msg).unwrap(); - } - - fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { - self.tx - .send(Event::Binary(socket.id, data.clone())) - .unwrap(); - socket.emit_binary(data).unwrap(); - } -} - /// Packets sent through the write half must reach the server, and the packets /// the handler echoes back must be surfaced in order through the read half. #[tokio::test] diff --git a/crates/engineioxide-client/tests/upgrade.rs b/crates/engineioxide-client/tests/upgrade.rs new file mode 100644 index 00000000..ef4283e8 --- /dev/null +++ b/crates/engineioxide-client/tests/upgrade.rs @@ -0,0 +1,58 @@ +use bytes::Bytes; +use engineioxide::TransportType; +use engineioxide_client::{Client, EioEvent}; +use futures_util::{SinkExt, StreamExt}; + +use crate::fixture::{Event, service}; + +mod fixture; + +#[tokio::test] +async fn upgrade() { + let (svc, mut rx) = service(); + let mut client = Client::connect_default(svc).await.unwrap(); + let sid = client.sid; + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Upgrade(TransportType::Websocket) + ); + + assert_eq!(client.transport(), TransportType::Websocket); + dbg!("a"); + client + .send(EioEvent::Message("Hello".into())) + .await + .unwrap(); + dbg!("a"); + client + .send(EioEvent::Binary(Bytes::from_static(b"Hello"))) + .await + .unwrap(); + dbg!("a"); + + // The server observes both packets. + assert_eq!( + rx.recv().await.unwrap(), + Event::Message(sid, "Hello".into()) + ); + dbg!("a"); + assert_eq!( + rx.recv().await.unwrap(), + Event::Binary(sid, Bytes::from_static(b"Hello")) + ); + + dbg!("a"); + // And echoes them back through the read half, in order. + match client.next().await { + Some(Ok(EioEvent::Message(msg))) => assert_eq!(msg, "Hello"), + other => panic!("expected echoed message, got {other:?}"), + } + dbg!("a"); + match client.next().await { + Some(Ok(EioEvent::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), + other => panic!("expected echoed binary, got {other:?}"), + } + dbg!("a"); +} diff --git a/crates/engineioxide-core/src/packet.rs b/crates/engineioxide-core/src/packet.rs index 1b275a70..91631e62 100644 --- a/crates/engineioxide-core/src/packet.rs +++ b/crates/engineioxide-core/src/packet.rs @@ -174,6 +174,11 @@ impl From for Bytes { String::from(value).into() } } +impl From for Str { + fn from(value: Packet) -> Self { + Str::from(String::from(value)) + } +} /// Serialize a [Packet] to a [String] according to the Engine.IO protocol impl From for String { From 42cafa9c80a8a5653431fe681e47d64781ce8d23 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 12 Jul 2026 20:03:24 +0200 Subject: [PATCH 21/41] fix: --- crates/engineioxide-client/src/client.rs | 16 ++++--------- .../engineioxide-client/src/transport/mod.rs | 6 ++--- .../engineioxide-client/src/transport/ws.rs | 23 ++++--------------- crates/engineioxide-client/tests/upgrade.rs | 7 ------ 4 files changed, 10 insertions(+), 42 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 9d206efb..21273af9 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -117,13 +117,12 @@ impl Stream for Client { if self.should_upgrade { let sid = self.sid; - tracing::debug!(%sid, "transport should upgrade"); return match ready!(self.as_mut().project().transport.upgrade(cx, sid)) { Some(Ok(())) => { - tracing::debug!(%sid, "transport upgraded"); + tracing::debug!(%sid, "websocket transport upgraded"); *self.as_mut().project().should_upgrade = false; cx.waker().wake_by_ref(); - Poll::Pending + Poll::Ready(Some(Ok(EioEvent::Upgrade(self.transport())))) } Some(Err(e)) => Poll::Ready(Some(Err(e))), None => { @@ -153,11 +152,6 @@ impl Stream for Client { } Some(Ok(Packet::Message(v))) => Poll::Ready(Some(Ok(EioEvent::Message(v)))), Some(Ok(Packet::Binary(v))) => Poll::Ready(Some(Ok(EioEvent::Binary(v)))), - Some(Ok(Packet::Upgrade)) => { - dbg!(self.should_upgrade); - *self.as_mut().project().should_upgrade = false; - Poll::Ready(Some(Ok(EioEvent::Upgrade(self.transport())))) - } Some(Ok(v)) => unreachable!("unexpected msg {v:?}"), Some(Err(e)) => Poll::Ready(Some(Err(e))), None => Poll::Ready(None), @@ -169,15 +163,14 @@ impl Sink for Client { type Error = as Sink>::Error; fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - if dbg!(self.should_upgrade || self.closing) { + if self.should_upgrade || self.closing { return Poll::Pending; } - dbg!(self.project().transport.poll_ready(cx)) + self.project().transport.poll_ready(cx) } fn start_send(self: Pin<&mut Self>, event: EioEvent) -> Result<(), Self::Error> { - dbg!("send"); if let Some(packet) = event.into() { self.project().transport.start_send(packet)?; } @@ -185,7 +178,6 @@ impl Sink for Client { } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - dbg!("flush"); self.project().transport.poll_flush(cx) } diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 72f0f2dc..93a9514b 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -79,10 +79,8 @@ impl Transport { } TransportProj::Websocket { inner } => match ready!(inner.poll_next(cx)) { - Some(Ok(packet)) => { - dbg!(packet); - Poll::Ready(Some(Ok(()))) - } + Some(Ok(Packet::Upgrade)) => Poll::Ready(Some(Ok(()))), + Some(Ok(p)) => todo!("handle err: {p:?}"), Some(Err(err)) => Poll::Ready(Some(Err(TransportError::Websocket(err)))), None => Poll::Ready(None), }, diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index 3fe8aad2..e3f982cc 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -1,7 +1,7 @@ use std::{ fmt, pin::Pin, - task::{Context, Poll, Waker, ready}, + task::{Context, Poll, ready}, }; use bytes::Bytes; @@ -12,6 +12,7 @@ use http::Request; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; use tokio_tungstenite::tungstenite::{self, Message, Utf8Bytes, handshake::client::generate_key}; +use tracing::Level; pin_project! { pub struct WsTransport { @@ -19,7 +20,6 @@ pin_project! { #[pin] state: WsTransportState, - sink_waker: Option, } } @@ -110,7 +110,6 @@ impl WsTransport { let fut = svc.call(req); Self { svc, - sink_waker: None, state: WsTransportState::Connecting { fut }, } } @@ -141,7 +140,6 @@ impl WsTransport { let ws = Self { svc, - sink_waker: None, state: WsTransportState::Stream { stream, upgrade: UpgradeHandshakeState::Done, @@ -203,7 +201,7 @@ fn parse_packet(msg: WsMessage) -> Result> } } -#[tracing::instrument(skip(cx, stream), ret)] +#[tracing::instrument(level = Level::TRACE, skip(cx, stream), ret)] fn poll_upgrade( cx: &mut Context<'_>, mut stream: Pin<&mut S::WebSocket>, @@ -290,9 +288,6 @@ impl Stream for WsTransport { *upgrade = UpgradeHandshakeState::Done; tracing::debug!("upgrade done, switching in nominal state"); cx.waker().wake_by_ref(); - if let Some(waker) = self.as_mut().project().sink_waker.take() { - waker.wake(); - }; Poll::Ready(Some(Ok(Packet::Upgrade))) } Ok(next) => { @@ -312,23 +307,13 @@ impl Sink for WsTransport { type Error = WsTransportError; fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - dbg!("ready ws"); match self.as_mut().project().state.project() { WsTransportStateProj::Stream { stream, upgrade: UpgradeHandshakeState::Done, .. } => stream.poll_ready(cx).map_err(WsTransportError::Websocket), - _ => { - // save sink waker so that when stream status change we can wake - // task that are waiting for polling. - self.as_mut() - .project() - .sink_waker - .replace(cx.waker().clone()); - - Poll::Pending - } + _ => Poll::Pending, } } diff --git a/crates/engineioxide-client/tests/upgrade.rs b/crates/engineioxide-client/tests/upgrade.rs index ef4283e8..9cc81eac 100644 --- a/crates/engineioxide-client/tests/upgrade.rs +++ b/crates/engineioxide-client/tests/upgrade.rs @@ -20,39 +20,32 @@ async fn upgrade() { ); assert_eq!(client.transport(), TransportType::Websocket); - dbg!("a"); client .send(EioEvent::Message("Hello".into())) .await .unwrap(); - dbg!("a"); client .send(EioEvent::Binary(Bytes::from_static(b"Hello"))) .await .unwrap(); - dbg!("a"); // The server observes both packets. assert_eq!( rx.recv().await.unwrap(), Event::Message(sid, "Hello".into()) ); - dbg!("a"); assert_eq!( rx.recv().await.unwrap(), Event::Binary(sid, Bytes::from_static(b"Hello")) ); - dbg!("a"); // And echoes them back through the read half, in order. match client.next().await { Some(Ok(EioEvent::Message(msg))) => assert_eq!(msg, "Hello"), other => panic!("expected echoed message, got {other:?}"), } - dbg!("a"); match client.next().await { Some(Ok(EioEvent::Binary(data))) => assert_eq!(data, Bytes::from_static(b"Hello")), other => panic!("expected echoed binary, got {other:?}"), } - dbg!("a"); } From a1d530ce1c125c0f2c5cc31899e772716f3330b1 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 12 Jul 2026 20:03:54 +0200 Subject: [PATCH 22/41] fix: --- crates/engineioxide-client/src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 21273af9..ccfe452f 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -42,8 +42,8 @@ impl Client { should_flush: false, should_send_pong: false, - closing: false, should_upgrade: false, + closing: false, }; Ok(client) From 13f844f2f59622ec0c6c173255fcd0e4df0b8a77 Mon Sep 17 00:00:00 2001 From: tprevot Date: Tue, 14 Jul 2026 11:08:40 +0200 Subject: [PATCH 23/41] fix: --- Cargo.lock | 33 +++- Cargo.toml | 2 +- crates/engineioxide-client/Cargo.toml | 14 +- crates/engineioxide-client/src/client.rs | 20 ++- .../engineioxide-client/src/flavors/hyper.rs | 52 ++++++ .../src/flavors/hyper_tungstenite.rs | 146 +++++++++++++++++ crates/engineioxide-client/src/flavors/mod.rs | 60 +++++++ .../src/flavors/testing.rs | 155 ++++++++++++++++++ .../engineioxide-client/src/flavors/wasm.rs | 1 + crates/engineioxide-client/src/lib.rs | 5 +- .../engineioxide-client/src/transport/mod.rs | 10 +- .../engineioxide-client/src/transport/ws.rs | 139 +--------------- crates/engineioxide-client/tests/fixture.rs | 146 +---------------- crates/engineioxide-client/tests/handshake.rs | 2 +- e2e/engineioxide/Cargo.toml | 6 + e2e/engineioxide/client.rs | 39 +++++ 16 files changed, 524 insertions(+), 306 deletions(-) create mode 100644 crates/engineioxide-client/src/flavors/hyper.rs create mode 100644 crates/engineioxide-client/src/flavors/hyper_tungstenite.rs create mode 100644 crates/engineioxide-client/src/flavors/mod.rs create mode 100644 crates/engineioxide-client/src/flavors/testing.rs create mode 100644 crates/engineioxide-client/src/flavors/wasm.rs create mode 100644 e2e/engineioxide/client.rs diff --git a/Cargo.lock b/Cargo.lock index 3f3a3565..a5ebda1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -934,6 +934,8 @@ version = "0.1.0" dependencies = [ "bytes", "engineioxide", + "engineioxide-client", + "futures-util", "hyper", "hyper-util", "serde_json", @@ -1225,6 +1227,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.9.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.6.0" @@ -1389,6 +1410,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1402,18 +1424,23 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.15" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", - "futures-core", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", + "socket2 0.5.10", "tokio", + "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2dd4f1cf..cf33c6d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ http-body-util = "0.1" thiserror = "2.0" tracing = "0.1" itoa = "1.0" -hyper-util.version = "0.1" +hyper-util.version = "0.1.20" hyper = "1.9" pin-project-lite = "0.2" matchit = "0.9" diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index 2e22cfc2..b8c9526a 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -28,20 +28,22 @@ tokio-tungstenite.workspace = true http-body-util.workspace = true pin-project-lite.workspace = true smallvec.workspace = true -hyper-util = { workspace = true, features = ["tokio"] } +hyper-util = { workspace = true, features = ["tokio", "client", "client-pool", "client-legacy", "http1", "http2"] } tokio-stream.workspace = true tokio-util.workspace = true +# Testing +engineioxide = { path = "../engineioxide", features = [ + "tracing", + "__test_harness", +] } + # Tracing tracing = { workspace = true } # TODO: make optional [dev-dependencies] tracing-subscriber = { workspace = true, features = ["env-filter"] } -engineioxide = { path = "../engineioxide", features = [ - "tracing", - "v3", - "__test_harness", -] } + [features] v3 = ["engineioxide-core/v3"] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index ccfe452f..2b638cde 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -9,23 +9,33 @@ use futures_core::Stream; use futures_util::Sink; use crate::{ - EioEvent, + event::EioEvent, + flavors::hyper_tungstenite::HyperTungsteniteFlavor, transport::{ - PollingTransport, PollingTransportError, Transport, TransportError, TransportSvc, - WsTransport, WsTransportError, + Transport, TransportError, TransportSvc, + polling::{PollingTransport, PollingTransportError}, + ws::{WsTransport, WsTransportError}, }, }; pin_project_lite::pin_project! { pub struct Client { #[pin] - pub transport: Transport, + transport: Transport, should_send_pong: bool, should_flush: bool, closing: bool, - pub sid: Sid, should_upgrade: bool, + pub sid: Sid, + } +} + +impl Client { + pub async fn connect_with_hyper() -> Result> + { + let svc = HyperTungsteniteFlavor::new(); + Self::connect_default(svc).await } } diff --git a/crates/engineioxide-client/src/flavors/hyper.rs b/crates/engineioxide-client/src/flavors/hyper.rs new file mode 100644 index 00000000..6d255fa6 --- /dev/null +++ b/crates/engineioxide-client/src/flavors/hyper.rs @@ -0,0 +1,52 @@ +use std::{convert::Infallible, future::Ready}; + +use bytes::Bytes; +use http::Response; +use http_body_util::combinators::BoxBody; +use hyper::body::Incoming; +use hyper_util::client::legacy::{ + Client, ResponseFuture, + connect::{HttpConnector, dns::GaiResolver}, +}; + +use crate::flavors::noop_impl::NoopWebSocket; + +#[derive(Debug, Clone)] +pub struct HyperFlavor { + client: Client, BoxBody>, +} + +impl HyperFlavor { + pub fn new() -> Self { + Self { + client: Client::builder(hyper_util::rt::TokioExecutor::new()).build_http(), + } + } +} +impl Default for HyperFlavor { + fn default() -> Self { + Self::new() + } +} + +/// HTTP Service implementation +impl hyper::service::Service>> for HyperFlavor { + type Response = Response; + type Error = hyper_util::client::legacy::Error; + type Future = ResponseFuture; + + fn call(&self, req: http::Request>) -> Self::Future { + self.client.request(req) + } +} + +/// WS Service Implementation +impl hyper::service::Service> for HyperFlavor { + type Response = NoopWebSocket; + type Error = Infallible; + type Future = Ready>; + + fn call(&self, _: http::Request<()>) -> Self::Future { + std::future::ready(Ok(NoopWebSocket)) + } +} diff --git a/crates/engineioxide-client/src/flavors/hyper_tungstenite.rs b/crates/engineioxide-client/src/flavors/hyper_tungstenite.rs new file mode 100644 index 00000000..42e5d84a --- /dev/null +++ b/crates/engineioxide-client/src/flavors/hyper_tungstenite.rs @@ -0,0 +1,146 @@ +use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll, ready}, +}; + +use bytes::Bytes; +use engineioxide_core::Str; +use futures_core::{Stream, future::BoxFuture}; +use futures_util::{FutureExt, Sink}; +use http::Response; +use http_body_util::combinators::BoxBody; +use hyper::body::Incoming; +use hyper_util::client::legacy::ResponseFuture; +use pin_project_lite::pin_project; +use tokio::net::TcpStream; +use tokio_tungstenite::{ + MaybeTlsStream, + tungstenite::{self, Message, Utf8Bytes}, +}; + +use crate::{ + flavors::hyper::HyperFlavor, + transport::ws::{WebSocket, WsMessage}, +}; + +impl From for tungstenite::Message { + fn from(value: WsMessage) -> Self { + match value { + WsMessage::Text(v) => { + tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) + } + WsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes), + WsMessage::Close => tungstenite::Message::Close(None), + } + } +} + +#[derive(Debug, Clone)] +pub struct HyperTungsteniteFlavor { + hyper_svc: HyperFlavor, +} + +impl HyperTungsteniteFlavor { + pub fn new() -> Self { + Self { + hyper_svc: HyperFlavor::new(), + } + } +} +impl Default for HyperTungsteniteFlavor { + fn default() -> Self { + Self::new() + } +} + +/// HTTP Service implementation +impl hyper::service::Service>> for HyperTungsteniteFlavor { + type Response = Response; + type Error = hyper_util::client::legacy::Error; + type Future = ResponseFuture; + + fn call(&self, req: http::Request>) -> Self::Future { + self.hyper_svc.call(req) + } +} + +/// WS Service Implementation +impl hyper::service::Service> for HyperTungsteniteFlavor { + type Response = TokioTungsteniteWS>; + type Error = tungstenite::Error; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: http::Request<()>) -> Self::Future { + async move { + let (ws, _) = tokio_tungstenite::connect_async(req).await?; + Ok(ws.into()) + } + .boxed() + } +} + +pin_project! { + pub struct TokioTungsteniteWS { + #[pin] + inner: tokio_tungstenite::WebSocketStream, + } +} + +impl From> for TokioTungsteniteWS +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + fn from(inner: tokio_tungstenite::WebSocketStream) -> Self { + Self { inner } + } +} + +impl WebSocket for TokioTungsteniteWS +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + type Error = tungstenite::Error; +} + +impl Sink + for TokioTungsteniteWS +{ + type Error = tungstenite::Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_ready(cx) + } + + fn start_send(self: Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { + self.project().inner.start_send(item.into()) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_flush(cx) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.project().inner.poll_close(cx) + } +} + +impl Stream for TokioTungsteniteWS { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match ready!(self.project().inner.poll_next(cx)) { + Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(WsMessage::Text(unsafe { + Str::from_bytes_unchecked(v.into()) + })))), + Some(Ok(Message::Binary(v))) => Poll::Ready(Some(Ok(WsMessage::Binary(v)))), + Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(WsMessage::Close))), + Some(Ok(_)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Some(Err(e)) => Poll::Ready(Some(Err(e))), + None => Poll::Pending, + } + } +} diff --git a/crates/engineioxide-client/src/flavors/mod.rs b/crates/engineioxide-client/src/flavors/mod.rs new file mode 100644 index 00000000..b385ebce --- /dev/null +++ b/crates/engineioxide-client/src/flavors/mod.rs @@ -0,0 +1,60 @@ +pub mod hyper; +pub mod hyper_tungstenite; +pub mod testing; +pub mod wasm; + +mod noop_impl { + use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll}, + }; + + use futures_core::Stream; + use futures_util::Sink; + + use crate::transport::ws::{WebSocket, WsMessage}; + + #[derive(Debug, Default, Clone)] + pub struct NoopWebSocket; + impl WebSocket for NoopWebSocket { + type Error = Infallible; + } + + impl Stream for NoopWebSocket { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(None) + } + } + + impl Sink for NoopWebSocket { + type Error = Infallible; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, _item: WsMessage) -> Result<(), Self::Error> { + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + } +} diff --git a/crates/engineioxide-client/src/flavors/testing.rs b/crates/engineioxide-client/src/flavors/testing.rs new file mode 100644 index 00000000..ecfa15c5 --- /dev/null +++ b/crates/engineioxide-client/src/flavors/testing.rs @@ -0,0 +1,155 @@ +use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use engineioxide::{ProtocolVersion, handler::EngineIoHandler, service::EngineIoService}; +use engineioxide_core::Sid; +use futures_core::future::BoxFuture; +use futures_util::FutureExt; +use http::Response; +use http_body_util::combinators::BoxBody; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::{ + io::{self, ReadBuf}, + sync::mpsc, +}; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tokio_tungstenite::tungstenite::{self, protocol::Role}; +use tokio_util::io::StreamReader; + +use crate::flavors::hyper_tungstenite::TokioTungsteniteWS; + +/// Create a stub TCP Stream implemented with two unbounded channels +fn duplex_stream() -> (StreamImpl, StreamImpl) { + let (tx, rx) = mpsc::unbounded_channel(); + let (tx1, rx1) = mpsc::unbounded_channel(); + (StreamImpl::new(tx, rx1), StreamImpl::new(tx1, rx)) +} + +pin_project_lite::pin_project! { + /// Half of a full-duplex bytes stream used to fake + /// a TCP stream to use [`tokio_tungstenite`] in local. + pub struct StreamImpl { + tx: mpsc::UnboundedSender>, + #[pin] + rx: StreamReader>, Bytes>, + } +} + +impl StreamImpl { + pub fn new( + tx: mpsc::UnboundedSender>, + rx: mpsc::UnboundedReceiver>, + ) -> Self { + Self { + tx, + rx: StreamReader::new(UnboundedReceiverStream::new(rx)), + } + } +} + +impl AsyncRead for StreamImpl { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.project().rx.poll_read(cx, buf) + } +} + +impl AsyncWrite for StreamImpl { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let len = buf.len(); + self.project() + .tx + .send(Ok(Bytes::copy_from_slice(buf))) + .unwrap(); + Poll::Ready(Ok(len)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +#[derive(Debug)] +pub struct EngineIoTestSvc { + inner: EngineIoService, +} +impl Clone for EngineIoTestSvc { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} +impl From> for EngineIoTestSvc { + fn from(inner: EngineIoService) -> Self { + Self { inner } + } +} + +/// HTTP Service implementation +impl hyper::service::Service>> + for EngineIoTestSvc +{ + type Response = Response>; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: http::Request>) -> Self::Future { + let svc = self.inner.clone(); + async move { svc.call(req).await.map(|r| r.map(BoxBody::new)) }.boxed() + } +} + +/// Websocket service implementation +impl hyper::service::Service> for EngineIoTestSvc { + type Response = TokioTungsteniteWS; + type Error = tungstenite::Error; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: http::Request<()>) -> Self::Future { + let (parts, _) = req.into_parts(); + let svc = self.inner.clone(); + let (client, server) = duplex_stream(); + tracing::debug!("initializing duplex stream"); + let query = parts.uri.query().unwrap(); + + let sid: Option = query + .split('&') + .find(|s| s.starts_with("sid=")) + .and_then(|s| s.split('=').nth(1).map(|s1| s1.parse().ok())) + .flatten(); + + async move { + tokio::spawn(svc.ws_init(server, ProtocolVersion::V4, sid, parts)); + + tracing::debug!("server connected, wiring websocket client"); + + let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( + client, + Role::Client, + Default::default(), + ) + .await; + + tracing::debug!("stub ws client wired up"); + + Ok(TokioTungsteniteWS::from(ws)) + } + .boxed() + } +} diff --git a/crates/engineioxide-client/src/flavors/wasm.rs b/crates/engineioxide-client/src/flavors/wasm.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/engineioxide-client/src/flavors/wasm.rs @@ -0,0 +1 @@ + diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index 7babd7b9..ac957d66 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -10,9 +10,8 @@ mod client; mod event; -mod transport; +pub mod flavors; +pub mod transport; pub use crate::client::Client; pub use crate::event::EioEvent; -pub use crate::transport::PollingTransport; -pub use transport::{WsTransport, noop_impl, tungstenite_impl}; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 93a9514b..b18c509c 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -8,11 +8,13 @@ use engineioxide_core::{Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; -pub use crate::transport::polling::{PollingSvc, PollingTransport, PollingTransportError}; -pub use crate::transport::ws::{WsSvc, WsTransport, WsTransportError, noop_impl, tungstenite_impl}; +use crate::transport::{polling::PollingTransportError, ws::WsTransportError}; -mod polling; -mod ws; +pub use polling::{PollingSvc, PollingTransport}; +pub use ws::{WebSocket, WsSvc, WsTransport}; + +pub mod polling; +pub mod ws; pub enum TransportError { Polling(PollingTransportError), diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index e3f982cc..bc47c362 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -11,7 +11,7 @@ use futures_util::{Sink, StreamExt}; use http::Request; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; -use tokio_tungstenite::tungstenite::{self, Message, Utf8Bytes, handshake::client::generate_key}; +use tokio_tungstenite::tungstenite::handshake::client::generate_key; use tracing::Level; pin_project! { @@ -355,140 +355,3 @@ impl Sink for WsTransport { } } } - -pub mod noop_impl { - use std::convert::Infallible; - - use super::*; - - #[derive(Debug, Default, Clone)] - pub struct NoopWebSocket; - impl HyperSvc> for NoopWebSocket { - type Response = NoopWebSocket; - type Error = Infallible; - type Future = std::future::Ready>; - - fn call(&self, _: http::Request<()>) -> Self::Future { - std::future::ready(Ok(NoopWebSocket)) - } - } - - impl WebSocket for NoopWebSocket { - type Error = Infallible; - } - - impl Stream for NoopWebSocket { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(None) - } - } - impl Sink for NoopWebSocket { - type Error = Infallible; - - fn poll_ready( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - - fn start_send(self: Pin<&mut Self>, _item: WsMessage) -> Result<(), Self::Error> { - Ok(()) - } - - fn poll_flush( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_close( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - } -} -pub mod tungstenite_impl { - use super::*; - - impl From for tungstenite::Message { - fn from(value: WsMessage) -> Self { - match value { - WsMessage::Text(v) => { - tungstenite::Message::Text(unsafe { Utf8Bytes::from_bytes_unchecked(v.into()) }) - } - WsMessage::Binary(bytes) => tungstenite::Message::Binary(bytes), - WsMessage::Close => tungstenite::Message::Close(None), - } - } - } - pin_project! { - pub struct TokioTungsteniteWS { - #[pin] - inner: tokio_tungstenite::WebSocketStream, - } - } - - impl From> for TokioTungsteniteWS - where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, - { - fn from(inner: tokio_tungstenite::WebSocketStream) -> Self { - Self { inner } - } - } - - impl WebSocket for TokioTungsteniteWS - where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, - { - type Error = tungstenite::Error; - } - - impl Sink - for TokioTungsteniteWS - { - type Error = tungstenite::Error; - - fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().inner.poll_ready(cx) - } - - fn start_send(self: Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { - self.project().inner.start_send(item.into()) - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().inner.poll_flush(cx) - } - - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - self.project().inner.poll_close(cx) - } - } - - impl Stream for TokioTungsteniteWS { - type Item = Result; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match ready!(self.project().inner.poll_next(cx)) { - Some(Ok(Message::Text(v))) => Poll::Ready(Some(Ok(WsMessage::Text(unsafe { - Str::from_bytes_unchecked(v.into()) - })))), - Some(Ok(Message::Binary(v))) => Poll::Ready(Some(Ok(WsMessage::Binary(v)))), - Some(Ok(Message::Close(_))) => Poll::Ready(Some(Ok(WsMessage::Close))), - Some(Ok(_)) => { - cx.waker().wake_by_ref(); - Poll::Pending - } - Some(Err(e)) => Poll::Ready(Some(Err(e))), - None => Poll::Pending, - } - } - } -} diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index 90835fe2..19727178 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -1,26 +1,13 @@ -use std::convert::Infallible; -use std::io; -use std::pin::Pin; use std::sync::Arc; -use std::task::{Context, Poll}; use bytes::Bytes; use engineioxide::config::EngineIoConfig; use engineioxide::handler::EngineIoHandler; use engineioxide::service::EngineIoService; use engineioxide::{DisconnectReason, Socket}; -use engineioxide_client::tungstenite_impl::TokioTungsteniteWS; +use engineioxide_client::flavors::testing::EngineIoTestSvc; use engineioxide_core::{Sid, Str}; -use futures_core::future::BoxFuture; -use futures_util::FutureExt; -use http::Response; -use http_body_util::combinators::BoxBody; -use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::sync::mpsc; -use tokio_stream::wrappers::UnboundedReceiverStream; -use tokio_tungstenite::tungstenite; -use tokio_tungstenite::tungstenite::protocol::Role; -use tokio_util::io::StreamReader; use tracing_subscriber::EnvFilter; #[derive(Debug, PartialEq, Eq)] @@ -88,134 +75,3 @@ impl EngineIoHandler for EchoHandler { socket.emit_binary(data).unwrap(); } } - -/// Create a stub TCP Stream implemented with two unbounded channels -fn duplex_stream() -> (StreamImpl, StreamImpl) { - let (tx, rx) = mpsc::unbounded_channel(); - let (tx1, rx1) = mpsc::unbounded_channel(); - (StreamImpl::new(tx, rx1), StreamImpl::new(tx1, rx)) -} - -pin_project_lite::pin_project! { - /// Half of a full-duplex bytes stream used to fake - /// a TCP stream to use [`tokio_tungstenite`] in local. - pub struct StreamImpl { - tx: mpsc::UnboundedSender>, - #[pin] - rx: StreamReader>, Bytes>, - } -} - -impl StreamImpl { - pub fn new( - tx: mpsc::UnboundedSender>, - rx: mpsc::UnboundedReceiver>, - ) -> Self { - Self { - tx, - rx: StreamReader::new(UnboundedReceiverStream::new(rx)), - } - } -} - -impl AsyncRead for StreamImpl { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - self.project().rx.poll_read(cx, buf) - } -} -impl AsyncWrite for StreamImpl { - fn poll_write( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - let len = buf.len(); - self.project() - .tx - .send(Ok(Bytes::copy_from_slice(buf))) - .unwrap(); - Poll::Ready(Ok(len)) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } -} - -#[derive(Debug)] -pub struct EngineIoTestSvc { - inner: EngineIoService, -} -impl Clone for EngineIoTestSvc { - fn clone(&self) -> Self { - Self { - inner: self.inner.clone(), - } - } -} -impl From> for EngineIoTestSvc { - fn from(inner: EngineIoService) -> Self { - Self { inner } - } -} - -/// HTTP Service implementation -impl hyper::service::Service>> - for EngineIoTestSvc -{ - type Response = Response>; - type Error = Infallible; - type Future = BoxFuture<'static, Result>; - - fn call(&self, req: http::Request>) -> Self::Future { - let svc = self.inner.clone(); - async move { svc.call(req).await.map(|r| r.map(BoxBody::new)) }.boxed() - } -} - -/// Websocket service implementation -impl hyper::service::Service> for EngineIoTestSvc { - type Response = TokioTungsteniteWS; - type Error = tungstenite::Error; - type Future = BoxFuture<'static, Result>; - - fn call(&self, req: http::Request<()>) -> Self::Future { - let (parts, _) = req.into_parts(); - let svc = self.inner.clone(); - let (client, server) = duplex_stream(); - tracing::debug!("initializing duplex stream"); - let query = parts.uri.query().unwrap(); - - let sid: Option = query - .split('&') - .find(|s| s.starts_with("sid=")) - .and_then(|s| s.split('=').nth(1).map(|s1| s1.parse().ok())) - .flatten(); - - async move { - tokio::spawn(svc.ws_init(server, engineioxide::ProtocolVersion::V4, sid, parts)); - - tracing::debug!("server connected, wiring websocket client"); - - let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( - client, - Role::Client, - Default::default(), - ) - .await; - - tracing::debug!("stub ws client wired up"); - - Ok(TokioTungsteniteWS::from(ws)) - } - .boxed() - } -} diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 9990a369..9d04ffe3 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use engineioxide_client::{PollingTransport, WsTransport}; +use engineioxide_client::transport::{PollingTransport, WsTransport}; use crate::fixture::{Event, service}; diff --git a/e2e/engineioxide/Cargo.toml b/e2e/engineioxide/Cargo.toml index d79ec8b2..220314fe 100644 --- a/e2e/engineioxide/Cargo.toml +++ b/e2e/engineioxide/Cargo.toml @@ -16,7 +16,9 @@ bytes.workspace = true engineioxide = { path = "../../crates/engineioxide", default-features = false, features = [ "tracing", ] } +engineioxide-client = { path = "../../crates/engineioxide-client" } hyper-util = { workspace = true, features = ["tokio"] } +futures-util.workspace = true hyper = { workspace = true, features = ["server", "http1"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tracing-subscriber.workspace = true @@ -27,6 +29,10 @@ serde_json.workspace = true name = "engineioxide-e2e" path = "engineioxide.rs" +[[bin]] +name = "engineioxide-e2e-client" +path = "client.rs" + [features] v3 = ["engineioxide/v3"] v4 = [] diff --git a/e2e/engineioxide/client.rs b/e2e/engineioxide/client.rs new file mode 100644 index 00000000..21e41727 --- /dev/null +++ b/e2e/engineioxide/client.rs @@ -0,0 +1,39 @@ +use engineioxide_client::{Client, EioEvent}; +use futures_util::{SinkExt, StreamExt}; +use tracing::Level; +use tracing_subscriber::FmtSubscriber; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let subscriber = FmtSubscriber::builder() + .with_line_number(true) + .with_max_level(Level::TRACE) + .finish(); + tracing::subscriber::set_global_default(subscriber)?; + let client = Client::connect_with_hyper().await?; + let (mut tx, mut rx) = client.split(); + + tx.send(EioEvent::Message("hello".into())).await?; + while let Some(Ok(event)) = rx.next().await { + match event { + EioEvent::Connect => { + println!("socket connect"); + } + EioEvent::Disconnect => { + println!("socket disconnect"); + } + EioEvent::Message(msg) => { + println!("Ping pong message {:?}", msg); + tx.send(EioEvent::Message(msg)).await?; + } + EioEvent::Binary(data) => { + println!("Ping pong binary message {:?}", data); + tx.send(EioEvent::Binary(data)).await?; + } + EioEvent::Upgrade(transport_type) => { + println!("transport upgraded to {transport_type:?}") + } + } + } + Ok(()) +} From 6f5521592bbb79e3fa4e9e76919a85096cbd6982 Mon Sep 17 00:00:00 2001 From: tprevot Date: Tue, 14 Jul 2026 16:57:06 +0200 Subject: [PATCH 24/41] fix: --- crates/engineioxide-client/src/client.rs | 26 ++++++++- crates/engineioxide-client/src/event.rs | 4 +- .../engineioxide-client/src/transport/mod.rs | 15 +++++ .../src/transport/polling.rs | 14 +++-- .../engineioxide-client/src/transport/ws.rs | 57 ++++++++++++++----- e2e/engineioxide/client.rs | 11 ++-- 6 files changed, 98 insertions(+), 29 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 2b638cde..8161bbb8 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -1,12 +1,13 @@ use std::{ fmt, pin::Pin, - task::{Context, Poll, ready}, + task::{Context, Poll, Waker, ready}, }; use engineioxide_core::{Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; +use tracing::Level; use crate::{ event::EioEvent, @@ -22,7 +23,9 @@ pin_project_lite::pin_project! { pub struct Client { #[pin] transport: Transport, + sink_waker: Option, + opened: bool, should_send_pong: bool, should_flush: bool, closing: bool, @@ -49,7 +52,11 @@ impl Client { let client = Client { transport, sid: open.sid, + sink_waker: None, + //TODO: refactor this. + //TODO: move upgrade to transport. + opened: true, should_flush: false, should_send_pong: false, should_upgrade: false, @@ -69,7 +76,9 @@ impl Client { let client = Client { transport, sid: open.sid, + sink_waker: None, + opened: true, should_flush: false, should_send_pong: false, closing: false, @@ -120,6 +129,10 @@ impl Stream for Client { #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.opened { + *self.as_mut().project().opened = false; + return Poll::Ready(Some(Ok(EioEvent::Connect(self.sid)))); + } if self.closing { ready!(self.project().transport.poll_close(cx))?; return Poll::Ready(None); @@ -131,6 +144,11 @@ impl Stream for Client { Some(Ok(())) => { tracing::debug!(%sid, "websocket transport upgraded"); *self.as_mut().project().should_upgrade = false; + if let Some(waker) = self.as_mut().project().sink_waker.take() { + tracing::debug!("waking up sink after end of upgrade"); + waker.wake(); + } + cx.waker().wake_by_ref(); Poll::Ready(Some(Ok(EioEvent::Upgrade(self.transport())))) } @@ -172,14 +190,18 @@ impl Stream for Client { impl Sink for Client { type Error = as Sink>::Error; + #[tracing::instrument(level = Level::TRACE, ret)] fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { if self.should_upgrade || self.closing { + // save waker to wake the task when should_upgrade is set to ok. + self.project().sink_waker.replace(cx.waker().clone()); return Poll::Pending; } self.project().transport.poll_ready(cx) } + #[tracing::instrument(level = Level::TRACE, ret)] fn start_send(self: Pin<&mut Self>, event: EioEvent) -> Result<(), Self::Error> { if let Some(packet) = event.into() { self.project().transport.start_send(packet)?; @@ -187,10 +209,12 @@ impl Sink for Client { Ok(()) } + #[tracing::instrument(level = Level::TRACE, ret)] fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().transport.poll_flush(cx) } + #[tracing::instrument(level = Level::TRACE, ret)] fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.project().transport.poll_close(cx) } diff --git a/crates/engineioxide-client/src/event.rs b/crates/engineioxide-client/src/event.rs index e38e31eb..1676aa58 100644 --- a/crates/engineioxide-client/src/event.rs +++ b/crates/engineioxide-client/src/event.rs @@ -1,9 +1,9 @@ use bytes::Bytes; -use engineioxide_core::{Packet, Str, TransportType}; +use engineioxide_core::{Packet, Sid, Str, TransportType}; #[derive(Debug, PartialEq)] pub enum EioEvent { - Connect, + Connect(Sid), Disconnect, Message(Str), Binary(Bytes), diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index b18c509c..c1797641 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -7,6 +7,7 @@ use std::{ use engineioxide_core::{Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; +use tracing::Level; use crate::transport::{polling::PollingTransportError, ws::WsTransportError}; @@ -62,6 +63,7 @@ impl Transport { } } impl Transport { + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] pub fn upgrade( mut self: Pin<&mut Self>, cx: &mut Context<'_>, @@ -92,6 +94,7 @@ impl Transport { impl Stream for Transport { type Item = Result>; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.as_mut().project() { TransportProj::Polling { inner } => { @@ -106,6 +109,7 @@ impl Stream for Transport { impl Sink for Transport { type Error = TransportError; + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { TransportProj::Polling { inner } => { @@ -128,6 +132,7 @@ impl Sink for Transport { } } + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { TransportProj::Polling { inner } => { @@ -139,6 +144,7 @@ impl Sink for Transport { } } + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project() { TransportProj::Polling { inner } => { @@ -150,3 +156,12 @@ impl Sink for Transport { } } } + +impl fmt::Debug for Transport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Polling { inner } => f.debug_struct("Polling").field("inner", inner).finish(), + Self::Websocket { inner } => f.debug_struct("Websocket").field("inner", inner).finish(), + } + } +} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 5efcb2f0..f4afa6fc 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -47,6 +47,7 @@ pin_project! { fut: F }, Decoding { + #[pin] stream: Pin>>> } } @@ -145,6 +146,7 @@ impl PollingTransport { sid: open.sid, }; + tracing::debug!(?transport, ?open, "polling transport intialized"); Ok((transport, open)) } _ => Err(PollingTransportError::Packet( @@ -160,8 +162,8 @@ impl Stream for PollingTransport { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { tracing::trace!(poll_state = ?self.poll_state, "polling"); - let mut poll_state_proj = self.as_mut().project().poll_state.project(); - match poll_state_proj { + let mut proj = self.as_mut().project().poll_state.project(); + match proj { PollStateProj::Pending { ref mut fut } => { match ready!(fut.as_mut().poll(cx)) { Ok(res) => { @@ -185,10 +187,14 @@ impl Stream for PollingTransport { } } } - PollStateProj::Decoding { ref mut stream } => { - if let Some(packet) = ready!(stream.poll_next_unpin(cx)) { + PollStateProj::Decoding { stream } => { + if let Some(packet) = ready!(stream.poll_next(cx)) { Poll::Ready(Some(packet.map_err(PollingTransportError::from))) } else { + tracing::debug!( + sid = %self.sid, + "decoding stream ended, new polling req" + ); let request = PollState::new_request(&self.svc, self.sid); self.project().poll_state.set(request); //check if wake is needed diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index bc47c362..ac58edea 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -1,7 +1,7 @@ use std::{ fmt, pin::Pin, - task::{Context, Poll, ready}, + task::{Context, Poll, Waker, ready}, }; use bytes::Bytes; @@ -17,6 +17,7 @@ use tracing::Level; pin_project! { pub struct WsTransport { svc: S, + sink_waker: Option, #[pin] state: WsTransportState, @@ -102,7 +103,7 @@ impl WsTransport { .header("Sec-WebSocket-Version", "13") .header("Sec-WebSocket-Key", generate_key()) .uri(format!( - "ws://127.0.0.1/engine.io/?EIO=4&transport=websocket&sid={sid}" + "ws://localhost:3000/engine.io/?EIO=4&transport=websocket&sid={sid}" )) .body(()) .unwrap(); @@ -110,6 +111,7 @@ impl WsTransport { let fut = svc.call(req); Self { svc, + sink_waker: None, state: WsTransportState::Connecting { fut }, } } @@ -125,7 +127,7 @@ impl WsTransport { .header("Upgrade", "websocket") .header("Sec-WebSocket-Version", "13") .header("Sec-WebSocket-Key", generate_key()) - .uri("ws://127.0.0.1/engine.io/?EIO=4&transport=websocket") + .uri("ws://localhost:3000/engine.io/?EIO=4&transport=websocket") .body(()) .unwrap(); @@ -140,6 +142,7 @@ impl WsTransport { let ws = Self { svc, + sink_waker: None, state: WsTransportState::Stream { stream, upgrade: UpgradeHandshakeState::Done, @@ -253,23 +256,22 @@ fn poll_upgrade( impl Stream for WsTransport { type Item = Result>; + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.as_mut().project().state.project() { // if we were connecting it means that's an upgrade. // TODO: this assertion might be brittle, add a test to prove that. - WsTransportStateProj::Connecting { fut } => { - match ready!(fut.poll(cx)) { - Ok(stream) => { - self.project().state.set(WsTransportState::Stream { - stream, - upgrade: UpgradeHandshakeState::ShouldSendPingUpgrade, - }); - cx.waker().wake_by_ref(); - Poll::Pending - } - Err(_e) => Poll::Ready(None), // stop everything but find a way to report the error + WsTransportStateProj::Connecting { fut } => match ready!(fut.poll(cx)) { + Ok(stream) => { + self.project().state.set(WsTransportState::Stream { + stream, + upgrade: UpgradeHandshakeState::ShouldSendPingUpgrade, + }); + cx.waker().wake_by_ref(); + Poll::Pending } - } + Err(e) => Poll::Ready(Some(Err(WsTransportError::Websocket(e)))), + }, WsTransportStateProj::Stream { stream, upgrade: UpgradeHandshakeState::Done, @@ -287,6 +289,9 @@ impl Stream for WsTransport { Ok(UpgradeHandshakeState::Done) => { *upgrade = UpgradeHandshakeState::Done; tracing::debug!("upgrade done, switching in nominal state"); + if let Some(waker) = self.project().sink_waker.take() { + waker.wake(); + } cx.waker().wake_by_ref(); Poll::Ready(Some(Ok(Packet::Upgrade))) } @@ -306,6 +311,7 @@ impl Stream for WsTransport { impl Sink for WsTransport { type Error = WsTransportError; + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.as_mut().project().state.project() { WsTransportStateProj::Stream { @@ -336,6 +342,7 @@ impl Sink for WsTransport { } } + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project().state.project() { WsTransportStateProj::Stream { @@ -346,6 +353,7 @@ impl Sink for WsTransport { } } + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.project().state.project() { WsTransportStateProj::Connecting { .. } => Poll::Ready(Ok(())), @@ -355,3 +363,22 @@ impl Sink for WsTransport { } } } + +impl fmt::Debug for WsTransport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WsTransport") + .field("state", &self.state) + .finish() + } +} + +impl fmt::Debug for WsTransportState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connecting { .. } => f.debug_struct("Connecting").finish(), + Self::Stream { upgrade, .. } => { + f.debug_struct("Stream").field("upgrade", upgrade).finish() + } + } + } +} diff --git a/e2e/engineioxide/client.rs b/e2e/engineioxide/client.rs index 21e41727..fa9696d4 100644 --- a/e2e/engineioxide/client.rs +++ b/e2e/engineioxide/client.rs @@ -1,23 +1,20 @@ use engineioxide_client::{Client, EioEvent}; use futures_util::{SinkExt, StreamExt}; -use tracing::Level; -use tracing_subscriber::FmtSubscriber; +use tracing_subscriber::{EnvFilter, FmtSubscriber}; #[tokio::main] async fn main() -> Result<(), Box> { let subscriber = FmtSubscriber::builder() - .with_line_number(true) - .with_max_level(Level::TRACE) + .with_env_filter(EnvFilter::from_default_env()) .finish(); tracing::subscriber::set_global_default(subscriber)?; let client = Client::connect_with_hyper().await?; let (mut tx, mut rx) = client.split(); - tx.send(EioEvent::Message("hello".into())).await?; while let Some(Ok(event)) = rx.next().await { match event { - EioEvent::Connect => { - println!("socket connect"); + EioEvent::Connect(sid) => { + println!("socket connect {sid}"); } EioEvent::Disconnect => { println!("socket disconnect"); From dd3362d4be796e9d2d77b45c81a43e8470db2b3d Mon Sep 17 00:00:00 2001 From: tprevot Date: Tue, 14 Jul 2026 17:44:22 +0200 Subject: [PATCH 25/41] fix: --- crates/engineioxide-client/src/client.rs | 168 +++++++++++------- .../engineioxide-client/src/transport/mod.rs | 4 + .../engineioxide-client/src/transport/ws.rs | 8 +- crates/engineioxide-client/tests/heartbeat.rs | 4 +- .../engineioxide-client/tests/round_trip.rs | 4 +- crates/engineioxide-client/tests/upgrade.rs | 2 +- 6 files changed, 121 insertions(+), 69 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 8161bbb8..e418f29a 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -4,7 +4,7 @@ use std::{ task::{Context, Poll, Waker, ready}, }; -use engineioxide_core::{Packet, Sid, TransportType}; +use engineioxide_core::{OpenPacket, Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; use tracing::Level; @@ -25,15 +25,23 @@ pin_project_lite::pin_project! { transport: Transport, sink_waker: Option, - opened: bool, + open_packet: OpenPacket, + available_transports: Vec, + state: ClientState, should_send_pong: bool, should_flush: bool, - closing: bool, - should_upgrade: bool, - pub sid: Sid, } } +#[derive(Debug)] +enum ClientState { + Open, // connected; owe the caller a Connect event + Upgrading, // driving the ws upgrade handshake + Running, // steady state + Closing, // draining the transport toward close + Closed, +} + impl Client { pub async fn connect_with_hyper() -> Result> { @@ -47,20 +55,18 @@ impl Client { Self::connect(svc, &[TransportType::Polling, TransportType::Websocket]).await } pub async fn connect_ws(svc: S) -> Result> { - let (inner, open) = WsTransport::connect(svc.clone()).await?; + let (inner, open_packet) = WsTransport::connect(svc.clone()).await?; let transport = Transport::Websocket { inner }; let client = Client { transport, - sid: open.sid, sink_waker: None, + state: ClientState::Open, + open_packet, + available_transports: vec![], // move to a client config - //TODO: refactor this. //TODO: move upgrade to transport. - opened: true, should_flush: false, should_send_pong: false, - should_upgrade: false, - closing: false, }; Ok(client) @@ -71,19 +77,17 @@ impl Client { transports: &[TransportType], ) -> Result> { let svc = svc.into(); - let (inner, open) = PollingTransport::connect(svc).await?; + let (inner, open_packet) = PollingTransport::connect(svc).await?; let transport = Transport::Polling { inner }; let client = Client { transport, - sid: open.sid, + open_packet, sink_waker: None, + state: ClientState::Open, + available_transports: transports.to_vec(), - opened: true, should_flush: false, should_send_pong: false, - closing: false, - should_upgrade: transports.contains(&TransportType::Websocket) - && open.upgrades.contains(&TransportType::Websocket), }; Ok(client) @@ -124,42 +128,26 @@ impl Client { } } +impl Client { + pub fn sid(&self) -> Sid { + self.open_packet.sid + } + + fn should_upgrade(&self) -> bool { + self.open_packet + .upgrades + .contains(&TransportType::Websocket) + && self + .available_transports + .contains(&TransportType::Websocket) + } +} + impl Stream for Client { type Item = Result>; #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - if self.opened { - *self.as_mut().project().opened = false; - return Poll::Ready(Some(Ok(EioEvent::Connect(self.sid)))); - } - if self.closing { - ready!(self.project().transport.poll_close(cx))?; - return Poll::Ready(None); - } - - if self.should_upgrade { - let sid = self.sid; - return match ready!(self.as_mut().project().transport.upgrade(cx, sid)) { - Some(Ok(())) => { - tracing::debug!(%sid, "websocket transport upgraded"); - *self.as_mut().project().should_upgrade = false; - if let Some(waker) = self.as_mut().project().sink_waker.take() { - tracing::debug!("waking up sink after end of upgrade"); - waker.wake(); - } - - cx.waker().wake_by_ref(); - Poll::Ready(Some(Ok(EioEvent::Upgrade(self.transport())))) - } - Some(Err(e)) => Poll::Ready(Some(Err(e))), - None => { - *self.as_mut().project().should_upgrade = false; - Poll::Ready(None) - } // TODO: fallback to polling if upgrade fails, - }; - } - if self.should_send_pong { ready!(self.as_mut().heartbeat(cx))?; } @@ -168,13 +156,40 @@ impl Stream for Client { ready!(self.as_mut().flush(cx))?; } - match ready!(self.as_mut().project().transport.poll_next(cx)) { + match self.state { + ClientState::Open => { + *self.as_mut().project().state = if self.should_upgrade() { + ClientState::Upgrading + } else { + ClientState::Running + }; + Poll::Ready(Some(Ok(EioEvent::Connect(self.open_packet.sid)))) + } + ClientState::Upgrading => self.poll_upgrade(cx), + ClientState::Running => self.poll_transport(cx), + ClientState::Closing => { + ready!(self.as_mut().project().transport.poll_close(cx))?; + *self.as_mut().project().state = ClientState::Closed; + Poll::Ready(None) + } + ClientState::Closed => Poll::Ready(None), + } + } +} + +impl Client { + fn poll_transport( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>>> { + let proj = self.as_mut().project(); + match ready!(proj.transport.poll_next(cx)) { Some(Ok(Packet::Ping)) => { - *self.as_mut().project().should_send_pong = true; + *proj.should_send_pong = true; self.poll_next(cx) } Some(Ok(Packet::Close)) => { - *self.as_mut().project().closing = true; + *proj.state = ClientState::Closing; cx.waker().wake_by_ref(); // wake up to close the transport Poll::Ready(Some(Ok(EioEvent::Disconnect))) } @@ -185,6 +200,36 @@ impl Stream for Client { None => Poll::Ready(None), } } + + fn poll_upgrade( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>>> { + let sid = self.sid(); + let proj = self.as_mut().project(); + match ready!(proj.transport.upgrade(cx, sid)) { + Some(Ok(())) => { + tracing::debug!(%sid, "websocket transport upgraded"); + *proj.state = ClientState::Running; + if let Some(waker) = proj.sink_waker.take() { + tracing::debug!("waking up sink after end of upgrade"); + waker.wake(); + } + + cx.waker().wake_by_ref(); + Poll::Ready(Some(Ok(EioEvent::Upgrade(self.transport())))) + } + //TODO: handle upgrade failures gracefully + Some(Err(e)) => { + *proj.state = ClientState::Closed; + Poll::Ready(Some(Err(e))) + } + None => { + *proj.state = ClientState::Closed; + Poll::Ready(None) + } // TODO: fallback to polling if upgrade fails, + } + } } impl Sink for Client { @@ -192,13 +237,16 @@ impl Sink for Client { #[tracing::instrument(level = Level::TRACE, ret)] fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - if self.should_upgrade || self.closing { - // save waker to wake the task when should_upgrade is set to ok. - self.project().sink_waker.replace(cx.waker().clone()); - return Poll::Pending; + match self.state { + ClientState::Open | ClientState::Upgrading => { + // save waker to wake the task when client is running. + self.project().sink_waker.replace(cx.waker().clone()); + Poll::Pending + } + ClientState::Running => self.project().transport.poll_ready(cx), + ClientState::Closing => todo!("err, transport is closing"), + ClientState::Closed => todo!("err, transport closed"), } - - self.project().transport.poll_ready(cx) } #[tracing::instrument(level = Level::TRACE, ret)] @@ -222,10 +270,6 @@ impl Sink for Client { impl fmt::Debug for Client { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Client") - .field("should_send_pong", &self.should_send_pong) - .field("should_flush", &self.should_flush) - .field("sid", &self.sid) - .finish() + f.debug_struct("Client").finish() } } diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index c1797641..3590204b 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -63,6 +63,10 @@ impl Transport { } } impl Transport { + /// Upgrade the current transport to [`WsTransport`]. + /// + /// Starts by flushing the polling transport, once done switch to websocket + /// and drive the websocket protocol upgrade. #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] pub fn upgrade( mut self: Pin<&mut Self>, diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index ac58edea..ed52ff4a 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -313,13 +313,17 @@ impl Sink for WsTransport { #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.as_mut().project().state.project() { + let proj = self.as_mut().project(); + match proj.state.project() { WsTransportStateProj::Stream { stream, upgrade: UpgradeHandshakeState::Done, .. } => stream.poll_ready(cx).map_err(WsTransportError::Websocket), - _ => Poll::Pending, + _ => { + proj.sink_waker.replace(cx.waker().clone()); + Poll::Pending + } } } diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index 480ab2c7..796ed635 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -35,7 +35,7 @@ async fn heartbeat_keeps_connection_alive() { let (svc, mut rx) = service_with_config(config()); let mut client = Client::connect_polling(svc).await.unwrap(); - let sid = client.sid; + let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); // Drive the client for several ping cycles. Any server event during this @@ -84,7 +84,7 @@ async fn heartbeat_keeps_connection_alive_websocket() { let (svc, mut rx) = service_with_config(config()); let mut client = Client::connect_ws(svc).await.unwrap(); - let sid = client.sid; + let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); // Drive the client for several ping cycles. Any server event during this diff --git a/crates/engineioxide-client/tests/round_trip.rs b/crates/engineioxide-client/tests/round_trip.rs index 82e7b9e7..8e6121c0 100644 --- a/crates/engineioxide-client/tests/round_trip.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -18,7 +18,7 @@ mod fixture; async fn round_trip() { let (svc, mut rx) = service(); let client = Client::connect_polling(svc).await.unwrap(); - let sid = client.sid; + let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); let (mut ctx, mut crx) = client.split::(); @@ -54,7 +54,7 @@ async fn round_trip() { async fn round_trip_ws() { let (svc, mut rx) = service(); let client = Client::connect_ws(svc).await.unwrap(); - let sid = client.sid; + let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); let (mut ctx, mut crx) = client.split::(); diff --git a/crates/engineioxide-client/tests/upgrade.rs b/crates/engineioxide-client/tests/upgrade.rs index 9cc81eac..552e0b4f 100644 --- a/crates/engineioxide-client/tests/upgrade.rs +++ b/crates/engineioxide-client/tests/upgrade.rs @@ -11,7 +11,7 @@ mod fixture; async fn upgrade() { let (svc, mut rx) = service(); let mut client = Client::connect_default(svc).await.unwrap(); - let sid = client.sid; + let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); assert_eq!( From af24c5a920f78cfba12ea078fd16bae519e805c9 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 14:46:52 +0200 Subject: [PATCH 26/41] test: engine poll --- crates/engineioxide-client/src/client.rs | 64 +++++++------------ crates/engineioxide-client/tests/heartbeat.rs | 8 +++ 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index e418f29a..4851ab01 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -28,8 +28,7 @@ pin_project_lite::pin_project! { open_packet: OpenPacket, available_transports: Vec, state: ClientState, - should_send_pong: bool, - should_flush: bool, + pending_pong: bool, } } @@ -63,10 +62,7 @@ impl Client { state: ClientState::Open, open_packet, available_transports: vec![], // move to a client config - - //TODO: move upgrade to transport. - should_flush: false, - should_send_pong: false, + pending_pong: false, }; Ok(client) @@ -85,9 +81,7 @@ impl Client { sink_waker: None, state: ClientState::Open, available_transports: transports.to_vec(), - - should_flush: false, - should_send_pong: false, + pending_pong: false, }; Ok(client) @@ -104,30 +98,6 @@ impl Client { } } -impl Client { - #[tracing::instrument(skip(cx))] - fn heartbeat( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll>> { - let mut proj = self.project(); - ready!(proj.transport.as_mut().poll_ready(cx))?; - proj.transport.as_mut().start_send(Packet::Pong)?; - - *proj.should_send_pong = false; - *proj.should_flush = true; - Poll::Ready(Ok(())) - } - - #[tracing::instrument(skip(cx))] - fn flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>> { - let mut proj = self.project(); - ready!(proj.transport.as_mut().poll_flush(cx))?; - *proj.should_flush = false; - Poll::Ready(Ok(())) - } -} - impl Client { pub fn sid(&self) -> Sid { self.open_packet.sid @@ -148,13 +118,7 @@ impl Stream for Client { #[tracing::instrument(skip(cx))] fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - if self.should_send_pong { - ready!(self.as_mut().heartbeat(cx))?; - } - - if self.should_flush { - ready!(self.as_mut().flush(cx))?; - } + let _ = self.as_mut().poll_heartbeat(cx); match self.state { ClientState::Open => { @@ -185,8 +149,9 @@ impl Client { let proj = self.as_mut().project(); match ready!(proj.transport.poll_next(cx)) { Some(Ok(Packet::Ping)) => { - *proj.should_send_pong = true; - self.poll_next(cx) + *proj.pending_pong = true; + cx.waker().wake_by_ref(); + Poll::Pending } Some(Ok(Packet::Close)) => { *proj.state = ClientState::Closing; @@ -230,6 +195,21 @@ impl Client { } // TODO: fallback to polling if upgrade fails, } } + + fn poll_heartbeat( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { + let mut proj = self.project(); + if *proj.pending_pong { + ready!(proj.transport.as_mut().poll_ready(cx))?; + *proj.pending_pong = false; + proj.transport.as_mut().start_send(Packet::Pong)?; + } + + // idempotent: continues an in-flight flush, or Ready immediately if clean + proj.transport.poll_flush(cx) + } } impl Sink for Client { diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index 796ed635..d0ca4511 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -37,6 +37,10 @@ async fn heartbeat_keeps_connection_alive() { let mut client = Client::connect_polling(svc).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); // Drive the client for several ping cycles. Any server event during this // window can only be a disconnect (we send nothing), which would mean the @@ -86,6 +90,10 @@ async fn heartbeat_keeps_connection_alive_websocket() { let mut client = Client::connect_ws(svc).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); // Drive the client for several ping cycles. Any server event during this // window can only be a disconnect (we send nothing), which would mean the From e1d69931f0ba382d694994f36879a999494f1639 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 15:41:07 +0200 Subject: [PATCH 27/41] test: engine poll --- crates/engineioxide-client/src/client.rs | 15 ++++++++++++--- crates/engineioxide-client/src/flavors/testing.rs | 15 ++++++++++----- crates/engineioxide-client/tests/fixture.rs | 6 +++--- e2e/engineioxide/client.rs | 2 +- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 4851ab01..7491556d 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -4,6 +4,7 @@ use std::{ task::{Context, Poll, Waker, ready}, }; +use engineioxide::{handler::EngineIoHandler, service::EngineIoService}; use engineioxide_core::{OpenPacket, Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; @@ -11,7 +12,7 @@ use tracing::Level; use crate::{ event::EioEvent, - flavors::hyper_tungstenite::HyperTungsteniteFlavor, + flavors::{hyper_tungstenite::HyperTungsteniteFlavor, testing::TestingFlavor}, transport::{ Transport, TransportError, TransportSvc, polling::{PollingTransport, PollingTransportError}, @@ -42,12 +43,20 @@ enum ClientState { } impl Client { - pub async fn connect_with_hyper() -> Result> - { + pub async fn connect_with_hyper_ws() + -> Result> { let svc = HyperTungsteniteFlavor::new(); Self::connect_default(svc).await } } +impl Client> { + pub async fn connect_with_testbed( + svc: EngineIoService, + ) -> Result>> { + let svc = TestingFlavor::new(svc); + Self::connect_default(svc).await + } +} impl Client { pub async fn connect_default(svc: S) -> Result> { diff --git a/crates/engineioxide-client/src/flavors/testing.rs b/crates/engineioxide-client/src/flavors/testing.rs index ecfa15c5..bb2ffda6 100644 --- a/crates/engineioxide-client/src/flavors/testing.rs +++ b/crates/engineioxide-client/src/flavors/testing.rs @@ -85,17 +85,22 @@ impl AsyncWrite for StreamImpl { } #[derive(Debug)] -pub struct EngineIoTestSvc { +pub struct TestingFlavor { inner: EngineIoService, } -impl Clone for EngineIoTestSvc { +impl TestingFlavor { + pub fn new(inner: EngineIoService) -> Self { + Self { inner } + } +} +impl Clone for TestingFlavor { fn clone(&self) -> Self { Self { inner: self.inner.clone(), } } } -impl From> for EngineIoTestSvc { +impl From> for TestingFlavor { fn from(inner: EngineIoService) -> Self { Self { inner } } @@ -103,7 +108,7 @@ impl From> for EngineIoTestSvc { /// HTTP Service implementation impl hyper::service::Service>> - for EngineIoTestSvc + for TestingFlavor { type Response = Response>; type Error = Infallible; @@ -116,7 +121,7 @@ impl hyper::service::Service hyper::service::Service> for EngineIoTestSvc { +impl hyper::service::Service> for TestingFlavor { type Response = TokioTungsteniteWS; type Error = tungstenite::Error; type Future = BoxFuture<'static, Result>; diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index 19727178..1d58617a 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -5,7 +5,7 @@ use engineioxide::config::EngineIoConfig; use engineioxide::handler::EngineIoHandler; use engineioxide::service::EngineIoService; use engineioxide::{DisconnectReason, Socket}; -use engineioxide_client::flavors::testing::EngineIoTestSvc; +use engineioxide_client::flavors::testing::TestingFlavor; use engineioxide_core::{Sid, Str}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; @@ -39,7 +39,7 @@ fn init_tracing() { pub fn service_with_config( config: EngineIoConfig, -) -> (EngineIoTestSvc, mpsc::UnboundedReceiver) { +) -> (TestingFlavor, mpsc::UnboundedReceiver) { init_tracing(); let (handler, rx) = EchoHandler::new(); let svc = EngineIoService::with_config(Arc::new(handler), config); @@ -47,7 +47,7 @@ pub fn service_with_config( } #[allow(unused)] -pub fn service() -> (EngineIoTestSvc, mpsc::UnboundedReceiver) { +pub fn service() -> (TestingFlavor, mpsc::UnboundedReceiver) { service_with_config(EngineIoConfig::default()) } diff --git a/e2e/engineioxide/client.rs b/e2e/engineioxide/client.rs index fa9696d4..148c4c4b 100644 --- a/e2e/engineioxide/client.rs +++ b/e2e/engineioxide/client.rs @@ -8,7 +8,7 @@ async fn main() -> Result<(), Box> { .with_env_filter(EnvFilter::from_default_env()) .finish(); tracing::subscriber::set_global_default(subscriber)?; - let client = Client::connect_with_hyper().await?; + let client = Client::connect_with_hyper_ws().await?; let (mut tx, mut rx) = client.split(); while let Some(Ok(event)) = rx.next().await { From d61251aa867a9c127149ddf31ca8380f152bc2ca Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 15:42:23 +0200 Subject: [PATCH 28/41] test: engine poll --- .../engineioxide-core/src/payload/encoder.rs | 116 +----------------- crates/engineioxide-core/src/payload/mod.rs | 6 - crates/engineioxide/src/service/parser.rs | 5 - crates/engineioxide/src/transport/ws.rs | 2 - 4 files changed, 2 insertions(+), 127 deletions(-) diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index 469acf75..af0ac536 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -7,21 +7,14 @@ //! * binary encoder (used when there are binary packets and the client supports binary) //! -use std::{ - convert::Infallible, - pin::Pin, - task::{Poll, ready}, -}; +use std::pin::Pin; -use bytes::Bytes; use futures_util::{FutureExt, Stream, StreamExt}; -use http_body::Body; -use pin_project_lite::pin_project; use smallvec::smallvec; use crate::{ packet::PacketBuf, - payload::{PACKET_SEPARATOR_V4, Payload, peekable::Peekable}, + payload::{Payload, peekable::Peekable}, }; #[cfg(feature = "v3")] @@ -61,111 +54,6 @@ async fn poll_packet(mut rx: Pin<&mut Peekable>>) packet } -pin_project! { - pub struct V4StreamEncoder> { - #[pin] - rx: Peekable, - max_payload: u64, - sent_bytes: u64 - } -} - -impl> V4StreamEncoder { - pub fn new(rx: Peekable, max_payload: u64) -> Self { - Self { - rx, - max_payload, - sent_bytes: 0, - } - } -} - -impl> V4StreamEncoder { - fn encode_packets(packets: PacketBuf, size: u64, sent_bytes: &mut u64) -> Option { - let mut data = String::with_capacity(size as usize); - if packets.is_empty() { - return None; - } - - for packet in packets { - let packet: String = packet.into(); - - if *sent_bytes > 0 { - data.push(std::char::from_u32(PACKET_SEPARATOR_V4 as u32).unwrap()); - } - data.push_str(&packet); - } - - *sent_bytes += data.len() as u64 + PACKET_SEPARATOR_V4 as u64; - - Some(data.into()) - } - - fn validate_payload_size( - packets: &PacketBuf, - sent_bytes: &u64, - max_payload: &u64, - ) -> Option { - const PUNCTUATION_LEN: u64 = 1; - let size = packets - .iter() - .map(|p| p.get_size_hint(false)) - .sum::() as u64 - + PUNCTUATION_LEN; - - if *sent_bytes + size > *max_payload { - None - } else { - Some(size) - } - } -} - -impl> Stream for V4StreamEncoder { - type Item = Bytes; - - fn poll_next( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - let mut this = self.project(); - - match this.rx.as_mut().poll_peek(cx) { - Poll::Ready(Some(v)) - if let Some(size) = - Self::validate_payload_size(v, this.sent_bytes, this.max_payload) => - { - *this.sent_bytes += size as u64; - let packets = ready!(this.rx.poll_next(cx)).expect("we just peeked Some packets"); - Poll::Ready(Self::encode_packets(packets, size, this.sent_bytes)) - } - Poll::Ready(Some(_)) => { - #[cfg(feature = "tracing")] - tracing::debug!("payload too big, stopping encoding for this payload"); - Poll::Ready(None) - } - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending if *this.sent_bytes == 0 => Poll::Pending, - Poll::Pending => Poll::Ready(None), - } - } -} - -impl> Body for V4StreamEncoder { - type Data = Bytes; - type Error = Infallible; - - fn poll_frame( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll, Self::Error>>> { - match ready!(self.poll_next(cx)) { - Some(frame) => Poll::Ready(Some(Ok(http_body::Frame::data(frame)))), - None => Poll::Ready(None), - } - } -} - /// Encode multiple packets into a string payload according to the /// [engine.io v4 protocol](https://socket.io/fr/docs/v4/engine-io-protocol/#http-long-polling-1) pub async fn v4_encoder( diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index 0dc6d808..23f153b7 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -10,8 +10,6 @@ mod buf; mod decoder; mod encoder; -pub use encoder::V4StreamEncoder; - const PACKET_SEPARATOR_V4: u8 = b'\x1e'; #[cfg(feature = "v3")] const STRING_PACKET_SEPARATOR_V3: u8 = b':'; @@ -76,10 +74,6 @@ impl Payload { } } -pub fn encoder_2<'a, S: Stream>(rx: S, max_payload: u64) -> V4StreamEncoder { - V4StreamEncoder::new(peekable::Peekable::new(rx), max_payload) -} - /// Encodes a payload into a byte stream. pub async fn encoder( rx: impl Stream, diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index 093629c5..2cb5623a 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -83,11 +83,6 @@ pub enum ParseError { #[error("unsupported protocol version")] UnsupportedProtocolVersion, } -impl From for ParseError { - fn from(_: engineioxide_core::UnknownTransportError) -> Self { - ParseError::UnknownTransport - } -} /// Convert an error into an http response /// If it is a known error, return the appropriate http status code diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index d2b76aa0..ea7ad418 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -346,9 +346,7 @@ where { let packet = Packet::Open(make_open_packet(TransportType::Websocket, sid, config)); let packet: String = packet.into(); - tracing::debug!(%sid, "sending open packet"); ws.send(Message::Text(packet.into())).await?; - tracing::debug!(%sid, "open packet sent"); Ok(()) } From 9995f82291d2c887680543a5b496a2e4f5262c3e Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 17:37:51 +0200 Subject: [PATCH 29/41] test: engine poll --- crates/engineioxide-client/Cargo.toml | 8 +- crates/engineioxide-client/src/client.rs | 104 +++++----- crates/engineioxide-client/src/config.rs | 62 ++++++ .../src/flavors/testing.rs | 182 ++++++++++-------- crates/engineioxide-client/src/lib.rs | 3 + .../engineioxide-client/src/transport/mod.rs | 25 ++- crates/engineioxide-client/tests/fixture.rs | 4 +- crates/engineioxide-client/tests/heartbeat.rs | 18 +- .../engineioxide-client/tests/round_trip.rs | 21 +- crates/engineioxide-client/tests/upgrade.rs | 6 +- crates/engineioxide/src/service/mod.rs | 25 +++ crates/engineioxide/src/service/parser.rs | 2 +- 12 files changed, 301 insertions(+), 159 deletions(-) create mode 100644 crates/engineioxide-client/src/config.rs diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index b8c9526a..ab3fda24 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -32,17 +32,13 @@ hyper-util = { workspace = true, features = ["tokio", "client", "client-pool", " tokio-stream.workspace = true tokio-util.workspace = true -# Testing -engineioxide = { path = "../engineioxide", features = [ - "tracing", - "__test_harness", -] } - # Tracing tracing = { workspace = true } # TODO: make optional [dev-dependencies] tracing-subscriber = { workspace = true, features = ["env-filter"] } +# Testing +engineioxide = { path = "../engineioxide", features = ["tracing"] } [features] diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 7491556d..5507155b 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -4,20 +4,16 @@ use std::{ task::{Context, Poll, Waker, ready}, }; -use engineioxide::{handler::EngineIoHandler, service::EngineIoService}; use engineioxide_core::{OpenPacket, Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; use tracing::Level; use crate::{ + EngineIoClientConfig, event::EioEvent, - flavors::{hyper_tungstenite::HyperTungsteniteFlavor, testing::TestingFlavor}, - transport::{ - Transport, TransportError, TransportSvc, - polling::{PollingTransport, PollingTransportError}, - ws::{WsTransport, WsTransportError}, - }, + flavors, + transport::{Transport, TransportError, TransportSvc, WsTransport, polling::PollingTransport}, }; pin_project_lite::pin_project! { @@ -25,9 +21,9 @@ pin_project_lite::pin_project! { #[pin] transport: Transport, sink_waker: Option, + config: EngineIoClientConfig, open_packet: OpenPacket, - available_transports: Vec, state: ClientState, pending_pong: bool, } @@ -42,54 +38,51 @@ enum ClientState { Closed, } -impl Client { - pub async fn connect_with_hyper_ws() - -> Result> { - let svc = HyperTungsteniteFlavor::new(); - Self::connect_default(svc).await +impl Client { + pub async fn connect_with_hyper_ws( + config: EngineIoClientConfig, + ) -> Result> { + let svc = flavors::hyper_tungstenite::HyperTungsteniteFlavor::new(); + Self::connect_with_config(svc, config).await } } -impl Client> { + +impl Client> { pub async fn connect_with_testbed( - svc: EngineIoService, - ) -> Result>> { - let svc = TestingFlavor::new(svc); - Self::connect_default(svc).await + svc: Svc, + config: EngineIoClientConfig, + ) -> Result>> { + let svc = flavors::testing::TestingFlavor::new(svc); + Self::connect_with_config(svc, config).await } } impl Client { - pub async fn connect_default(svc: S) -> Result> { - Self::connect(svc, &[TransportType::Polling, TransportType::Websocket]).await + pub async fn connect(svc: S) -> Result> { + Self::connect_with_config(svc, EngineIoClientConfig::default()).await } - pub async fn connect_ws(svc: S) -> Result> { - let (inner, open_packet) = WsTransport::connect(svc.clone()).await?; - let transport = Transport::Websocket { inner }; - let client = Client { - transport, - sink_waker: None, - state: ClientState::Open, - open_packet, - available_transports: vec![], // move to a client config - pending_pong: false, - }; - Ok(client) - } + pub async fn connect_with_config( + svc: S, + config: EngineIoClientConfig, + ) -> Result> { + let (transport, open_packet) = match config.initial_transport() { + TransportType::Polling => { + let (transport, open_packet) = PollingTransport::connect(svc).await?; + (transport.into(), open_packet) + } + TransportType::Websocket => { + let (transport, open_packet) = WsTransport::connect(svc).await?; + (transport.into(), open_packet) + } + }; - pub async fn connect( - svc: impl Into, - transports: &[TransportType], - ) -> Result> { - let svc = svc.into(); - let (inner, open_packet) = PollingTransport::connect(svc).await?; - let transport = Transport::Polling { inner }; let client = Client { transport, open_packet, + config, sink_waker: None, state: ClientState::Open, - available_transports: transports.to_vec(), pending_pong: false, }; @@ -101,24 +94,18 @@ impl Client { } } -impl Client { - pub async fn connect_polling(svc: S) -> Result> { - Self::connect(svc, &[TransportType::Polling]).await - } -} - impl Client { pub fn sid(&self) -> Sid { self.open_packet.sid } fn should_upgrade(&self) -> bool { - self.open_packet - .upgrades - .contains(&TransportType::Websocket) + self.transport() != TransportType::Websocket && self - .available_transports + .open_packet + .upgrades .contains(&TransportType::Websocket) + && self.config.transports.contains(&TransportType::Websocket) } } @@ -224,15 +211,15 @@ impl Client { impl Sink for Client { type Error = as Sink>::Error; - #[tracing::instrument(level = Level::TRACE, ret)] + #[tracing::instrument(level = Level::TRACE, skip(cx), ret)] fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.state { - ClientState::Open | ClientState::Upgrading => { - // save waker to wake the task when client is running. + ClientState::Upgrading => { + // save waker to wake the task when transport is migrated. self.project().sink_waker.replace(cx.waker().clone()); Poll::Pending } - ClientState::Running => self.project().transport.poll_ready(cx), + ClientState::Open | ClientState::Running => self.project().transport.poll_ready(cx), ClientState::Closing => todo!("err, transport is closing"), ClientState::Closed => todo!("err, transport closed"), } @@ -259,6 +246,11 @@ impl Sink for Client { impl fmt::Debug for Client { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Client").finish() + f.debug_struct("Client") + .field("sink_waker", &self.sink_waker) + .field("state", &self.state) + .field("open_packet", &self.open_packet) + .field("config", &self.config) + .finish() } } diff --git a/crates/engineioxide-client/src/config.rs b/crates/engineioxide-client/src/config.rs new file mode 100644 index 00000000..47672be7 --- /dev/null +++ b/crates/engineioxide-client/src/config.rs @@ -0,0 +1,62 @@ +use engineioxide_core::TransportType; +use http::Uri; + +#[derive(Debug)] +pub struct EngineIoClientConfig { + /// A list of transports to try (in order). Engine.io always attempts to + /// connect directly with the first one, provided the feature detection test + /// for it passes. + /// + /// Defaults to `[Polling, Websocket]`. + pub transports: Vec, + + /// The uri to use to connect to the server. + /// + /// Defaults to `http://localhost/engine.io`. + pub uri: Uri, +} + +impl Default for EngineIoClientConfig { + fn default() -> Self { + Self { + uri: Uri::from_static("http://localhost/engine.io"), + transports: vec![TransportType::Polling, TransportType::Websocket], + } + } +} + +impl EngineIoClientConfig { + pub fn builder() -> EngineIoClientConfigBuilder { + EngineIoClientConfigBuilder::new() + } + + pub(crate) fn initial_transport(&self) -> TransportType { + *self + .transports + .first() + .expect("transport list should never be empty") + } +} + +#[derive(Default)] +pub struct EngineIoClientConfigBuilder { + config: EngineIoClientConfig, +} +impl EngineIoClientConfigBuilder { + pub fn new() -> Self { + Self::default() + } + pub fn uri(mut self, uri: impl Into) -> Self { + self.config.uri = uri.into(); + self + } + pub fn transports(mut self, transports: [TransportType; N]) -> Self { + const { assert!(N > 0, "transports list should be non-empty") }; + + self.config.transports = transports.to_vec(); + self + } + pub fn build(self) -> EngineIoClientConfig { + self.config + } +} diff --git a/crates/engineioxide-client/src/flavors/testing.rs b/crates/engineioxide-client/src/flavors/testing.rs index bb2ffda6..259d330f 100644 --- a/crates/engineioxide-client/src/flavors/testing.rs +++ b/crates/engineioxide-client/src/flavors/testing.rs @@ -5,11 +5,8 @@ use std::{ }; use bytes::Bytes; -use engineioxide::{ProtocolVersion, handler::EngineIoHandler, service::EngineIoService}; -use engineioxide_core::Sid; use futures_core::future::BoxFuture; use futures_util::FutureExt; -use http::Response; use http_body_util::combinators::BoxBody; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::{ @@ -17,10 +14,110 @@ use tokio::{ sync::mpsc, }; use tokio_stream::wrappers::UnboundedReceiverStream; -use tokio_tungstenite::tungstenite::{self, protocol::Role}; +use tokio_tungstenite::tungstenite::protocol::Role; use tokio_util::io::StreamReader; -use crate::flavors::hyper_tungstenite::TokioTungsteniteWS; +use hyper::service::Service as HyperSvc; + +use crate::{flavors::hyper_tungstenite::TokioTungsteniteWS, transport::PollingSvc}; + +/// Trait alias for [`TestingFlavor`] inner service. +/// +/// Typically this wil be satisfied by the engineioxide service. +pub trait EngineSvc: + PollingSvc> + + HyperSvc< + (StreamImpl, http::Request<()>), + Response = (), + Error: std::error::Error + Send + 'static, + Future: Send, + > + Send + + Clone + + 'static +{ +} + +impl EngineSvc for Svc where + Svc: PollingSvc> + + HyperSvc< + (StreamImpl, http::Request<()>), + Response = (), + Error: std::error::Error + Send + 'static, + Future: Send, + > + Send + + Clone + + 'static +{ +} + +#[derive(Debug, Clone)] +pub struct TestingFlavor { + inner: Svc, +} +impl TestingFlavor { + pub fn new(inner: Svc) -> Self { + Self { inner } + } +} +impl From for TestingFlavor { + fn from(inner: Svc) -> Self { + Self { inner } + } +} + +/// HTTP Service implementation +impl HyperSvc>> for TestingFlavor +where + Svc: HyperSvc>>, + Svc: Clone, +{ + type Response = Svc::Response; + type Error = Svc::Error; + type Future = Svc::Future; + + fn call(&self, req: http::Request>) -> Self::Future { + self.inner.clone().call(req) + } +} + +/// Websocket service implementation +impl HyperSvc> for TestingFlavor +where + Svc: HyperSvc<(StreamImpl, http::Request<()>), Response = (), Error = E, Future = Fut> + Clone, + Svc: Clone + Send + 'static, + E: std::error::Error + Send + 'static, + Fut: Future> + Send, +{ + type Response = TokioTungsteniteWS; + type Error = tokio_tungstenite::tungstenite::Error; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: http::Request<()>) -> Self::Future { + let svc = self.inner.clone(); + let (client, server) = duplex_stream(); + tracing::debug!("initializing duplex stream"); + + async move { + svc.call((server, req)) + .await + .map_err(|e| io::Error::other(e.to_string()))?; + + tracing::debug!("server connected, wiring websocket client"); + + let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( + client, + Role::Client, + Default::default(), + ) + .await; + + tracing::debug!("stub ws client wired up"); + + Ok(TokioTungsteniteWS::from(ws)) + } + .boxed() + } +} /// Create a stub TCP Stream implemented with two unbounded channels fn duplex_stream() -> (StreamImpl, StreamImpl) { @@ -83,78 +180,3 @@ impl AsyncWrite for StreamImpl { Poll::Ready(Ok(())) } } - -#[derive(Debug)] -pub struct TestingFlavor { - inner: EngineIoService, -} -impl TestingFlavor { - pub fn new(inner: EngineIoService) -> Self { - Self { inner } - } -} -impl Clone for TestingFlavor { - fn clone(&self) -> Self { - Self { - inner: self.inner.clone(), - } - } -} -impl From> for TestingFlavor { - fn from(inner: EngineIoService) -> Self { - Self { inner } - } -} - -/// HTTP Service implementation -impl hyper::service::Service>> - for TestingFlavor -{ - type Response = Response>; - type Error = Infallible; - type Future = BoxFuture<'static, Result>; - - fn call(&self, req: http::Request>) -> Self::Future { - let svc = self.inner.clone(); - async move { svc.call(req).await.map(|r| r.map(BoxBody::new)) }.boxed() - } -} - -/// Websocket service implementation -impl hyper::service::Service> for TestingFlavor { - type Response = TokioTungsteniteWS; - type Error = tungstenite::Error; - type Future = BoxFuture<'static, Result>; - - fn call(&self, req: http::Request<()>) -> Self::Future { - let (parts, _) = req.into_parts(); - let svc = self.inner.clone(); - let (client, server) = duplex_stream(); - tracing::debug!("initializing duplex stream"); - let query = parts.uri.query().unwrap(); - - let sid: Option = query - .split('&') - .find(|s| s.starts_with("sid=")) - .and_then(|s| s.split('=').nth(1).map(|s1| s1.parse().ok())) - .flatten(); - - async move { - tokio::spawn(svc.ws_init(server, ProtocolVersion::V4, sid, parts)); - - tracing::debug!("server connected, wiring websocket client"); - - let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( - client, - Role::Client, - Default::default(), - ) - .await; - - tracing::debug!("stub ws client wired up"); - - Ok(TokioTungsteniteWS::from(ws)) - } - .boxed() - } -} diff --git a/crates/engineioxide-client/src/lib.rs b/crates/engineioxide-client/src/lib.rs index ac957d66..4e24d3f3 100644 --- a/crates/engineioxide-client/src/lib.rs +++ b/crates/engineioxide-client/src/lib.rs @@ -9,9 +9,12 @@ //! Engine.IO client library for Rust. mod client; +mod config; mod event; + pub mod flavors; pub mod transport; pub use crate::client::Client; pub use crate::event::EioEvent; +pub use config::{EngineIoClientConfig, EngineIoClientConfigBuilder}; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index 3590204b..b8cccc53 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -37,6 +37,17 @@ impl fmt::Display for TransportError { } impl std::error::Error for TransportError {} +impl From> for TransportError { + fn from(err: PollingTransportError) -> Self { + Self::Polling(err) + } +} +impl From> for TransportError { + fn from(err: WsTransportError) -> Self { + Self::Websocket(err) + } +} + pub trait TransportSvc: PollingSvc + WsSvc {} impl TransportSvc for S {} @@ -76,7 +87,7 @@ impl Transport { match self.as_mut().project() { TransportProj::Polling { mut inner } => { // start by flushing polling transport to ensure there is no pending data - ready!(inner.as_mut().poll_flush(cx)).map_err(TransportError::Polling)?; + ready!(inner.as_mut().poll_flush(cx))?; let svc = inner.svc.clone(); self.set(Transport::Websocket { @@ -89,7 +100,7 @@ impl Transport { TransportProj::Websocket { inner } => match ready!(inner.poll_next(cx)) { Some(Ok(Packet::Upgrade)) => Poll::Ready(Some(Ok(()))), Some(Ok(p)) => todo!("handle err: {p:?}"), - Some(Err(err)) => Poll::Ready(Some(Err(TransportError::Websocket(err)))), + Some(Err(err)) => Poll::Ready(Some(Err(err.into()))), None => Poll::Ready(None), }, } @@ -161,6 +172,16 @@ impl Sink for Transport { } } +impl From> for Transport { + fn from(inner: PollingTransport) -> Self { + Self::Polling { inner } + } +} +impl From> for Transport { + fn from(inner: WsTransport) -> Self { + Self::Websocket { inner } + } +} impl fmt::Debug for Transport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index 1d58617a..f10556f9 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -39,7 +39,7 @@ fn init_tracing() { pub fn service_with_config( config: EngineIoConfig, -) -> (TestingFlavor, mpsc::UnboundedReceiver) { +) -> (TestingFlavor>, mpsc::UnboundedReceiver) { init_tracing(); let (handler, rx) = EchoHandler::new(); let svc = EngineIoService::with_config(Arc::new(handler), config); @@ -47,7 +47,7 @@ pub fn service_with_config( } #[allow(unused)] -pub fn service() -> (TestingFlavor, mpsc::UnboundedReceiver) { +pub fn service() -> (TestingFlavor>, mpsc::UnboundedReceiver) { service_with_config(EngineIoConfig::default()) } diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index d0ca4511..3047cef1 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -10,8 +10,8 @@ use std::time::Duration; -use engineioxide::config::EngineIoConfig; -use engineioxide_client::{Client, EioEvent}; +use engineioxide::{TransportType, config::EngineIoConfig}; +use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; use futures_util::{SinkExt, StreamExt}; use crate::fixture::{Event, service_with_config}; @@ -31,10 +31,12 @@ fn config() -> EngineIoConfig { /// `Ping`s with `Pong`s, keeping the connection alive across several ping /// cycles. The connection must also still be usable afterwards. #[tokio::test] -async fn heartbeat_keeps_connection_alive() { +async fn heartbeat_keeps_connection_alive_polling() { let (svc, mut rx) = service_with_config(config()); - - let mut client = Client::connect_polling(svc).await.unwrap(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect_with_config(svc, config).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); assert_eq!( @@ -87,7 +89,11 @@ async fn heartbeat_keeps_connection_alive() { async fn heartbeat_keeps_connection_alive_websocket() { let (svc, mut rx) = service_with_config(config()); - let mut client = Client::connect_ws(svc).await.unwrap(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + + let mut client = Client::connect_with_config(svc, config).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); assert_eq!( diff --git a/crates/engineioxide-client/tests/round_trip.rs b/crates/engineioxide-client/tests/round_trip.rs index 8e6121c0..922c7a16 100644 --- a/crates/engineioxide-client/tests/round_trip.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -5,7 +5,8 @@ //! the echoed packets must be surfaced back through the stream. use bytes::Bytes; -use engineioxide_client::{Client, EioEvent}; +use engineioxide::TransportType; +use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; use futures_util::{SinkExt, StreamExt}; use crate::fixture::{Event, service}; @@ -15,13 +16,18 @@ mod fixture; /// Packets sent through the write half must reach the server, and the packets /// the handler echoes back must be surfaced in order through the read half. #[tokio::test] -async fn round_trip() { +async fn round_trip_polling() { let (svc, mut rx) = service(); - let client = Client::connect_polling(svc).await.unwrap(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let client = Client::connect_with_config(svc, config).await.unwrap(); let sid = client.sid(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); let (mut ctx, mut crx) = client.split::(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!(crx.next().await.unwrap().unwrap(), EioEvent::Connect(sid)); + ctx.send(EioEvent::Message("Hello".into())).await.unwrap(); ctx.send(EioEvent::Binary(Bytes::from_static(b"Hello"))) .await @@ -53,10 +59,15 @@ async fn round_trip() { #[tokio::test] async fn round_trip_ws() { let (svc, mut rx) = service(); - let client = Client::connect_ws(svc).await.unwrap(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + + let client = Client::connect_with_config(svc, config).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); let (mut ctx, mut crx) = client.split::(); + assert_eq!(crx.next().await.unwrap().unwrap(), EioEvent::Connect(sid)); ctx.send(EioEvent::Message("Hello".into())).await.unwrap(); ctx.send(EioEvent::Binary(Bytes::from_static(b"Hello"))) diff --git a/crates/engineioxide-client/tests/upgrade.rs b/crates/engineioxide-client/tests/upgrade.rs index 552e0b4f..eac54901 100644 --- a/crates/engineioxide-client/tests/upgrade.rs +++ b/crates/engineioxide-client/tests/upgrade.rs @@ -10,9 +10,13 @@ mod fixture; #[tokio::test] async fn upgrade() { let (svc, mut rx) = service(); - let mut client = Client::connect_default(svc).await.unwrap(); + let mut client = Client::connect(svc).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); assert_eq!( client.next().await.unwrap().unwrap(), diff --git a/crates/engineioxide/src/service/mod.rs b/crates/engineioxide/src/service/mod.rs index 6b9d1c13..17ce6f61 100644 --- a/crates/engineioxide/src/service/mod.rs +++ b/crates/engineioxide/src/service/mod.rs @@ -44,6 +44,7 @@ use tower_service::Service as TowerSvc; use crate::{ body::ResponseBody, config::EngineIoConfig, engine::EngineIo, handler::EngineIoHandler, + service::parser::RequestInfo, }; mod futures; @@ -160,6 +161,30 @@ where } } +impl HyperSvc<(St, http::Request<()>)> for EngineIoService +where + H: EngineIoHandler, + St: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + type Response = (); + type Error = parser::ParseError; + type Future = std::future::Ready>; + + fn call(&self, (conn, req): (St, http::Request<()>)) -> Self::Future { + let engine = self.engine.clone(); + let RequestInfo { protocol, sid, .. } = match RequestInfo::parse(&req, &engine.config) { + Ok(infos) => infos, + Err(e) => return std::future::ready(Err(e)), + }; + + let (parts, _) = req.into_parts(); + tokio::spawn(crate::transport::ws::on_init( + engine, conn, protocol, sid, parts, + )); + std::future::ready(Ok(())) + } +} + #[cfg(feature = "__test_harness")] #[doc(hidden)] impl EngineIoService diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index 2cb5623a..045bc39f 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -127,7 +127,7 @@ pub struct RequestInfo { impl RequestInfo { /// Parse the request URI to extract the [`TransportType`](crate::service::TransportType) and the socket id. - fn parse(req: &Request, config: &EngineIoConfig) -> Result { + pub(super) fn parse(req: &Request, config: &EngineIoConfig) -> Result { use ParseError::*; let query = req.uri().query().ok_or(UnknownTransport)?; From 4a2842f06f4176643d983a4dcc6bb2c999abb6cd Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 18:28:11 +0200 Subject: [PATCH 30/41] test: engine poll --- crates/engineioxide-client/src/client.rs | 6 +- .../engineioxide-client/src/transport/mod.rs | 82 ++++++++++++++++++- .../src/transport/polling.rs | 70 ++++++++++------ .../engineioxide-client/src/transport/ws.rs | 28 ++++--- crates/engineioxide-core/src/protocol.rs | 7 ++ 5 files changed, 147 insertions(+), 46 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 5507155b..ee484b1f 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -68,11 +68,11 @@ impl Client { ) -> Result> { let (transport, open_packet) = match config.initial_transport() { TransportType::Polling => { - let (transport, open_packet) = PollingTransport::connect(svc).await?; + let (transport, open_packet) = PollingTransport::connect(svc, &config).await?; (transport.into(), open_packet) } TransportType::Websocket => { - let (transport, open_packet) = WsTransport::connect(svc).await?; + let (transport, open_packet) = WsTransport::connect(svc, &config).await?; (transport.into(), open_packet) } }; @@ -168,7 +168,7 @@ impl Client { ) -> Poll>>> { let sid = self.sid(); let proj = self.as_mut().project(); - match ready!(proj.transport.upgrade(cx, sid)) { + match ready!(proj.transport.upgrade(cx, proj.config, sid)) { Some(Ok(())) => { tracing::debug!(%sid, "websocket transport upgraded"); *proj.state = ClientState::Running; diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index b8cccc53..eef9b931 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -1,15 +1,25 @@ use std::{ + convert::Infallible, fmt, pin::Pin, task::{Context, Poll, ready}, }; -use engineioxide_core::{Packet, Sid, TransportType}; +use bytes::Bytes; +use engineioxide_core::{Packet, ProtocolVersion, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; +use http::{ + Request, Uri, + uri::{PathAndQuery, Scheme}, +}; +use http_body_util::{Empty, combinators::BoxBody}; use tracing::Level; -use crate::transport::{polling::PollingTransportError, ws::WsTransportError}; +use crate::{ + EngineIoClientConfig, + transport::{polling::PollingTransportError, ws::WsTransportError}, +}; pub use polling::{PollingSvc, PollingTransport}; pub use ws::{WebSocket, WsSvc, WsTransport}; @@ -82,6 +92,7 @@ impl Transport { pub fn upgrade( mut self: Pin<&mut Self>, cx: &mut Context<'_>, + config: &EngineIoClientConfig, sid: Sid, ) -> Poll>>> { match self.as_mut().project() { @@ -91,7 +102,7 @@ impl Transport { let svc = inner.svc.clone(); self.set(Transport::Websocket { - inner: WsTransport::connect_with_upgrade(svc, sid), + inner: WsTransport::connect_with_upgrade(svc, config, sid), }); cx.waker().wake_by_ref(); Poll::Pending @@ -190,3 +201,68 @@ impl fmt::Debug for Transport { } } } + +fn build_connect_req( + base_uri: &Uri, + transport: TransportType, +) -> Request> { + let uri = with_mandatory_query(base_uri, transport, None); + + Request::builder() + .method(http::Method::GET) + .uri(uri) + .body(BoxBody::new(Empty::new())) + .unwrap() +} + +/// Merges the user-provided `base_uri` (scheme + authority + path, and any +/// pre-existing query) with the query parameters engine.io mandates on every +/// request: the protocol version (`EIO`) and the `transport` in use. +fn with_mandatory_query(base_uri: &Uri, transport: TransportType, sid: Option) -> Uri { + let secure = is_uri_secure(base_uri); + let mut parts = base_uri.clone().into_parts(); + + parts.scheme = match (transport, secure) { + (TransportType::Polling, Some(true)) => Some(Scheme::HTTPS), + (TransportType::Websocket, Some(true)) => Some("wss".parse().unwrap()), + (TransportType::Polling, Some(false)) => Some(Scheme::HTTPS), + (TransportType::Websocket, Some(false)) => Some("ws".parse().unwrap()), + (_, None) => None, + }; + + let path = parts + .path_and_query + .as_ref() + .map(|pq| pq.path()) + .unwrap_or("/") + .to_owned(); + + let existing_query = parts + .path_and_query + .as_ref() + .and_then(|pq| pq.query()) + .filter(|q| !q.is_empty()); + + let protocol = format_args!("EIO={}&transport={transport}", ProtocolVersion::V4); + + let path = match (existing_query, sid) { + (Some(existing), Some(sid)) => format!("{path}?sid={sid}&{protocol}&{existing}"), + (Some(existing), None) => format!("{path}?{protocol}&{existing}"), + (None, Some(sid)) => format!("{path}?sid={sid}&{protocol}"), + (None, None) => format!("{path}?{protocol}"), + }; + + parts.path_and_query = + Some(PathAndQuery::try_from(path).expect("base uri path should be valid")); + + Uri::from_parts(parts).expect("base uri should produce a valid uri") +} + +//TODO: invalid scheme err +fn is_uri_secure(uri: &Uri) -> Option { + match uri.scheme_str()? { + "http" | "ws" => Some(false), + "https" | "wss" => Some(true), + _ => None, + } +} diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index f4afa6fc..45502ef8 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -6,14 +6,18 @@ use std::{ }; use bytes::{BufMut, Bytes, BytesMut}; -use engineioxide_core::{OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, payload}; +use engineioxide_core::{ + OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, TransportType, payload, +}; use futures_core::Stream; use futures_util::{Sink, StreamExt}; -use http::{Request, Response, StatusCode}; -use http_body_util::{BodyExt, Full, combinators::BoxBody}; +use http::{Request, Response, StatusCode, Uri}; +use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; use hyper::service::Service as HyperSvc; use pin_project_lite::pin_project; +use crate::EngineIoClientConfig; + pub trait PollingSvc: HyperSvc< Request>, @@ -77,15 +81,36 @@ impl Default for PostState { } impl PollState { - fn new_request>(svc: &S, sid: Sid) -> Self { - let uri = format!("http://localhost:3000/engine.io?EIO=4&transport=polling&sid={sid}"); - let req = Request::get(uri) - .body(BoxBody::new(Full::default())) + fn new_request>(svc: &S, base_uri: &Uri, sid: Sid) -> Self { + let uri = super::with_mandatory_query(base_uri, TransportType::Polling, Some(sid)); + + let req = Request::builder() + .method(http::Method::GET) + .uri(uri) + .body(BoxBody::new(Empty::new())) .unwrap(); + let fut = svc.call(req); PollState::Pending { fut } } } +impl PostState { + fn new_request>(svc: &S, uri: &Uri, sid: Sid, body: Bytes) -> Self { + let uri = super::with_mandatory_query(uri, TransportType::Polling, Some(sid)); + + let req = Request::builder() + .method(http::Method::POST) + .uri(uri) + .body(BoxBody::new(Full::new(body))) + .unwrap(); + + let fut = svc.call(req); + PostState::Pending { + fut, + bytes: BytesMut::new(), + } + } +} #[derive(Debug, thiserror::Error)] pub enum PollingTransportError { @@ -108,19 +133,19 @@ pin_project! { #[pin] post_state: PostState, + base_uri: Uri, sid: Sid, } } impl PollingTransport { - pub async fn connect(svc: S) -> Result<(Self, OpenPacket), PollingTransportError> { + pub async fn connect( + svc: S, + config: &EngineIoClientConfig, + ) -> Result<(Self, OpenPacket), PollingTransportError> { tracing::trace!("handshake request"); - let req = Request::builder() - .method("GET") - .uri("http://localhost:3000/engine.io?EIO=4&transport=polling") - .body(BoxBody::new(Full::default())) - .unwrap(); + let req = super::build_connect_req(&config.uri, TransportType::Polling); let res = svc .call(req) @@ -138,12 +163,13 @@ impl PollingTransport { match packet { Packet::Open(open) => { - let poll_state = PollState::new_request(&svc, open.sid); + let poll_state = PollState::new_request(&svc, &config.uri, open.sid); let transport = PollingTransport { svc, poll_state, post_state: PostState::default(), sid: open.sid, + base_uri: config.uri.clone(), }; tracing::debug!(?transport, ?open, "polling transport intialized"); @@ -195,7 +221,7 @@ impl Stream for PollingTransport { sid = %self.sid, "decoding stream ended, new polling req" ); - let request = PollState::new_request(&self.svc, self.sid); + let request = PollState::new_request(&self.svc, &self.base_uri, self.sid); self.project().poll_state.set(request); //check if wake is needed cx.waker().wake_by_ref(); @@ -228,18 +254,8 @@ impl Sink for PollingTransport { PostStateProj::Queuing { bytes } => { let body = std::mem::take(bytes).freeze(); //TODO: handle max body size from open packet - let sid = self.sid; - let req = Request::post(format!( - "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={sid}", - )) - .body(BoxBody::new(Full::new(body))) - .unwrap(); - - let fut = self.svc.call(req); - self.project().post_state.set(PostState::Pending { - fut, - bytes: BytesMut::new(), - }); + let post_state = PostState::new_request(&self.svc, &self.base_uri, self.sid, body); + self.project().post_state.set(post_state); cx.waker().wake_by_ref(); Poll::Pending } diff --git a/crates/engineioxide-client/src/transport/ws.rs b/crates/engineioxide-client/src/transport/ws.rs index ed52ff4a..22bee30e 100644 --- a/crates/engineioxide-client/src/transport/ws.rs +++ b/crates/engineioxide-client/src/transport/ws.rs @@ -5,7 +5,9 @@ use std::{ }; use bytes::Bytes; -use engineioxide_core::{OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, Str}; +use engineioxide_core::{ + OpenPacket, Packet, PacketParseError, ProtocolVersion, Sid, Str, TransportType, +}; use futures_core::Stream; use futures_util::{Sink, StreamExt}; use http::Request; @@ -14,6 +16,8 @@ use pin_project_lite::pin_project; use tokio_tungstenite::tungstenite::handshake::client::generate_key; use tracing::Level; +use crate::EngineIoClientConfig; + pin_project! { pub struct WsTransport { svc: S, @@ -93,18 +97,15 @@ impl std::error::Error for WsTransportError {} impl WsTransport { #[tracing::instrument(skip(svc))] - pub fn connect_with_upgrade(svc: S, sid: Sid) -> Self { + pub fn connect_with_upgrade(svc: S, config: &EngineIoClientConfig, sid: Sid) -> Self { tracing::trace!("websocket connection with upgrade"); - let req = Request::builder() - .method("GET") + let uri = super::with_mandatory_query(&config.uri, TransportType::Websocket, Some(sid)); + let req = Request::get(uri) .header("Host", "127.0.0.1") .header("Connection", "Upgrade") .header("Upgrade", "websocket") .header("Sec-WebSocket-Version", "13") .header("Sec-WebSocket-Key", generate_key()) - .uri(format!( - "ws://localhost:3000/engine.io/?EIO=4&transport=websocket&sid={sid}" - )) .body(()) .unwrap(); @@ -117,23 +118,24 @@ impl WsTransport { } #[tracing::instrument(skip(svc))] - pub async fn connect(svc: S) -> Result<(Self, OpenPacket), WsTransportError> { + pub async fn connect( + svc: S, + config: &EngineIoClientConfig, + ) -> Result<(Self, OpenPacket), WsTransportError> { tracing::trace!("websocket connection without upgrade"); + let uri = super::with_mandatory_query(&config.uri, TransportType::Websocket, None); - let req = Request::builder() - .method("GET") - .header("Host", "127.0.0.1") + let req = Request::get(uri) .header("Connection", "Upgrade") .header("Upgrade", "websocket") .header("Sec-WebSocket-Version", "13") .header("Sec-WebSocket-Key", generate_key()) - .uri("ws://localhost:3000/engine.io/?EIO=4&transport=websocket") .body(()) .unwrap(); let mut stream = svc.call(req).await.map_err(WsTransportError::Websocket)?; - tracing::debug!("handshake response received, waiting for open packet"); + tracing::debug!("handshake response received, waiting for open packet"); let packet = match stream.next().await.ok_or(WsTransportError::Closed)? { Ok(msg) => parse_packet(msg)?, Err(e) => return Err(WsTransportError::Websocket(e)), diff --git a/crates/engineioxide-core/src/protocol.rs b/crates/engineioxide-core/src/protocol.rs index acc9714f..8fddfad0 100644 --- a/crates/engineioxide-core/src/protocol.rs +++ b/crates/engineioxide-core/src/protocol.rs @@ -68,6 +68,13 @@ impl<'de> Deserialize<'de> for TransportType { } } +impl std::fmt::Display for TransportType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let v: &'static str = (*self).into(); + write!(f, "{}", v) + } +} + /// Cannot determine the transport type to connect to the client/server. #[derive(Debug, Copy, Clone)] pub struct UnknownTransportError; From 51971e52dfada3a52bbcc012281c74139fcb0aa2 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 18:36:55 +0200 Subject: [PATCH 31/41] test: engine poll --- crates/engineioxide-client/src/config.rs | 8 +++-- .../engineioxide-client/src/transport/mod.rs | 2 +- .../src/transport/polling.rs | 3 +- crates/engineioxide-client/tests/handshake.rs | 29 ++++++++++++------- e2e/engineioxide/client.rs | 8 +++-- 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/crates/engineioxide-client/src/config.rs b/crates/engineioxide-client/src/config.rs index 47672be7..4c0ac47a 100644 --- a/crates/engineioxide-client/src/config.rs +++ b/crates/engineioxide-client/src/config.rs @@ -41,13 +41,14 @@ impl EngineIoClientConfig { #[derive(Default)] pub struct EngineIoClientConfigBuilder { config: EngineIoClientConfig, + uri: String, } impl EngineIoClientConfigBuilder { pub fn new() -> Self { Self::default() } - pub fn uri(mut self, uri: impl Into) -> Self { - self.config.uri = uri.into(); + pub fn uri(mut self, uri: &str) -> Self { + self.uri = uri.to_string(); self } pub fn transports(mut self, transports: [TransportType; N]) -> Self { @@ -56,7 +57,8 @@ impl EngineIoClientConfigBuilder { self.config.transports = transports.to_vec(); self } - pub fn build(self) -> EngineIoClientConfig { + pub fn build(mut self) -> EngineIoClientConfig { + self.config.uri = self.uri.parse().unwrap(); //TODO: err self.config } } diff --git a/crates/engineioxide-client/src/transport/mod.rs b/crates/engineioxide-client/src/transport/mod.rs index eef9b931..746cc98f 100644 --- a/crates/engineioxide-client/src/transport/mod.rs +++ b/crates/engineioxide-client/src/transport/mod.rs @@ -225,7 +225,7 @@ fn with_mandatory_query(base_uri: &Uri, transport: TransportType, sid: Option Some(Scheme::HTTPS), (TransportType::Websocket, Some(true)) => Some("wss".parse().unwrap()), - (TransportType::Polling, Some(false)) => Some(Scheme::HTTPS), + (TransportType::Polling, Some(false)) => Some(Scheme::HTTP), (TransportType::Websocket, Some(false)) => Some("ws".parse().unwrap()), (_, None) => None, }; diff --git a/crates/engineioxide-client/src/transport/polling.rs b/crates/engineioxide-client/src/transport/polling.rs index 45502ef8..d18e92e5 100644 --- a/crates/engineioxide-client/src/transport/polling.rs +++ b/crates/engineioxide-client/src/transport/polling.rs @@ -143,9 +143,8 @@ impl PollingTransport { svc: S, config: &EngineIoClientConfig, ) -> Result<(Self, OpenPacket), PollingTransportError> { - tracing::trace!("handshake request"); - let req = super::build_connect_req(&config.uri, TransportType::Polling); + tracing::trace!(?req, "handshake request"); let res = svc .call(req) diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index 9d04ffe3..a3822c19 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,6 +1,9 @@ use std::time::Duration; -use engineioxide_client::transport::{PollingTransport, WsTransport}; +use engineioxide_client::{ + EngineIoClientConfig, + transport::{PollingTransport, WsTransport}, +}; use crate::fixture::{Event, service}; @@ -9,11 +12,13 @@ mod fixture; #[tokio::test] async fn handshake_polling() { let (svc, mut rx) = service(); - let (_transport, open) = - tokio::time::timeout(Duration::from_secs(1), PollingTransport::connect(svc)) - .await - .expect("timeout while initializing polling transport conn") - .unwrap(); + let (_transport, open) = tokio::time::timeout( + Duration::from_secs(1), + PollingTransport::connect(svc, &EngineIoClientConfig::default()), + ) + .await + .expect("timeout while initializing polling transport conn") + .unwrap(); let event = tokio::time::timeout(Duration::from_secs(1), rx.recv()) .await @@ -25,11 +30,13 @@ async fn handshake_polling() { #[tokio::test] async fn handshake_websocket() { let (svc, mut rx) = service(); - let (_transport, open) = - tokio::time::timeout(Duration::from_secs(1), WsTransport::connect(svc)) - .await - .expect("timeout while initializing polling transport conn") - .unwrap(); + let (_transport, open) = tokio::time::timeout( + Duration::from_secs(1), + WsTransport::connect(svc, &EngineIoClientConfig::default()), + ) + .await + .expect("timeout while initializing polling transport conn") + .unwrap(); let event = tokio::time::timeout(Duration::from_secs(1), rx.recv()) .await diff --git a/e2e/engineioxide/client.rs b/e2e/engineioxide/client.rs index 148c4c4b..4fee0d51 100644 --- a/e2e/engineioxide/client.rs +++ b/e2e/engineioxide/client.rs @@ -1,4 +1,4 @@ -use engineioxide_client::{Client, EioEvent}; +use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; use futures_util::{SinkExt, StreamExt}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; @@ -8,7 +8,11 @@ async fn main() -> Result<(), Box> { .with_env_filter(EnvFilter::from_default_env()) .finish(); tracing::subscriber::set_global_default(subscriber)?; - let client = Client::connect_with_hyper_ws().await?; + let config = EngineIoClientConfig::builder() + .uri("http://localhost:3000/engine.io") + .build(); + + let client = Client::connect_with_hyper_ws(config).await?; let (mut tx, mut rx) = client.split(); while let Some(Ok(event)) = rx.next().await { From b34c38b118da5db2edc4a17b21d45a5695167057 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 18:45:49 +0200 Subject: [PATCH 32/41] test: engine poll --- Cargo.lock | 1309 +++++++++++++++++++++++----------------------------- 1 file changed, 568 insertions(+), 741 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b29834b3..0b20fee2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -33,9 +33,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -46,21 +46,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anes" version = "0.1.6" @@ -69,9 +54,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anyhow" @@ -99,9 +84,12 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] name = "arcstr" @@ -122,13 +110,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -148,15 +136,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.8.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -173,8 +161,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -188,9 +175,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -199,25 +186,12 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", "tracing", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -226,21 +200,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.9.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -273,15 +241,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" dependencies = [ "ahash", - "base64 0.22.1", + "base64", "bitvec", - "getrandom 0.2.16", - "getrandom 0.3.3", + "getrandom 0.2.17", + "getrandom 0.3.4", "hex", - "indexmap 2.9.0", + "indexmap", "js-sys", "once_cell", - "rand 0.9.1", + "rand 0.9.5", "serde", "serde_bytes", "serde_json", @@ -296,26 +264,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3f109694c4f45353972af96bf97d8a057f82e2d6e496457f4d135b9867a518c" dependencies = [ "ahash", - "base64 0.22.1", + "base64", "bitvec", - "getrandom 0.3.3", + "getrandom 0.3.4", "hex", - "indexmap 2.9.0", + "indexmap", "js-sys", - "rand 0.9.1", + "rand 0.9.5", "serde", "serde_bytes", "simdutf8", - "thiserror 2.0.18", + "thiserror", "time", "uuid", ] [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -325,9 +293,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -350,24 +318,25 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.26" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -380,19 +349,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "chrono" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "num-traits", - "serde", - "windows-link 0.1.1", -] - [[package]] name = "ciborium" version = "0.2.2" @@ -422,18 +378,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.40" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstyle", "clap_lex", @@ -441,9 +397,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmov" @@ -460,7 +416,7 @@ dependencies = [ "anyhow", "cc", "colored", - "getrandom 0.2.16", + "getrandom 0.2.17", "glob", "libc", "nix", @@ -563,16 +519,19 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] [[package]] name = "convert_case" -version = "0.4.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "cookie-factory" @@ -580,12 +539,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -637,9 +590,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -647,39 +600,39 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -705,9 +658,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -715,44 +668,39 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.101", + "syn", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", - "serde", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "derive-syn-parse" @@ -762,42 +710,52 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "derive-where" -version = "1.4.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73f2692d4bd3cac41dca28934a39894200c9fabf49586d77d0e5954af1d7902" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "derive_arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "derive_more" -version = "0.99.20" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 2.0.101", + "syn", + "unicode-xid", ] [[package]] @@ -807,8 +765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common 0.1.6", - "subtle", + "crypto-common 0.1.7", ] [[package]] @@ -825,13 +782,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -842,9 +799,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -868,7 +825,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tokio-tungstenite", @@ -897,7 +854,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tokio-tungstenite", @@ -910,7 +867,7 @@ dependencies = [ name = "engineioxide-core" version = "0.2.2" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-util", "http", @@ -919,7 +876,7 @@ dependencies = [ "itoa", "memchr", "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "smallvec", @@ -950,6 +907,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "etcetera" version = "0.11.0" @@ -987,6 +954,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "float-cmp" version = "0.10.0" @@ -1010,9 +983,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1032,7 +1005,7 @@ dependencies = [ "futures", "log", "parking_lot", - "rand 0.8.5", + "rand 0.8.7", "redis-protocol", "semver", "socket2 0.5.10", @@ -1051,7 +1024,7 @@ checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1062,9 +1035,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1077,9 +1050,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1087,15 +1060,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1115,38 +1088,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1184,28 +1157,28 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.2.0", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 5.3.0", + "wasip2", "wasm-bindgen", ] @@ -1239,7 +1212,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.9.0", + "indexmap", "slab", "tokio", "tokio-util", @@ -1248,26 +1221,15 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" - [[package]] name = "hashbrown" version = "0.16.1" @@ -1279,6 +1241,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.11.1" @@ -1294,7 +1262,7 @@ version = "0.1.0" dependencies = [ "hyper", "hyper-util", - "rand 0.10.1", + "rand 0.10.2", "serde_json", "socketioxide", "tokio", @@ -1309,9 +1277,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -1325,16 +1293,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", + "hmac", ] [[package]] @@ -1358,9 +1317,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1368,9 +1327,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1436,45 +1395,22 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "tokio", "tower-layer", "tower-service", "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1482,9 +1418,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1495,11 +1431,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1510,42 +1445,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -1561,9 +1492,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1572,9 +1503,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1582,35 +1513,23 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.9.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.15.4", - "serde", + "hashbrown 0.17.1", ] [[package]] name = "is-terminal" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1624,9 +1543,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" @@ -1653,34 +1572,33 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -1706,7 +1624,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1720,7 +1638,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1731,7 +1649,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1742,16 +1660,16 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -1766,16 +1684,6 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", -] - [[package]] name = "md-5" version = "0.11.0" @@ -1788,9 +1696,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1806,54 +1714,68 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] +[[package]] +name = "mongocrypt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8426a875ded61430d4a811dbfda7633b6b8af0225c547fc6c28b8b0aa7d79a13" +dependencies = [ + "bson 2.15.0", + "mongocrypt-sys", + "once_cell", + "serde", +] + +[[package]] +name = "mongocrypt-sys" +version = "0.1.6+1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" + [[package]] name = "mongodb" -version = "3.2.4" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f8c69f13acf07eae386a2974f48ffd9187ea2aba8defbea9aa34e7e272c5f3" +checksum = "b814038f367d212f55de0a630cb35102a9b8ca23785a86955d62c0087c93846d" dependencies = [ - "async-trait", - "base64 0.13.1", - "bitflags 1.3.2", + "base64", + "bitflags", "bson 2.15.0", - "chrono", "derive-where", "derive_more", "futures-core", - "futures-executor", "futures-io", "futures-util", "hex", - "hmac 0.12.1", + "hmac", "macro_magic", - "md-5 0.10.6", + "md-5", + "mongocrypt", "mongodb-internal-macros", - "once_cell", "pbkdf2", "percent-encoding", - "rand 0.8.5", + "rand 0.9.5", "rustc_version_runtime", "rustls", - "rustls-pemfile", "serde", "serde_bytes", "serde_with", - "sha1", - "sha2 0.10.9", - "socket2 0.5.10", + "sha1 0.11.0", + "sha2 0.11.0", + "socket2 0.6.5", "stringprep", "strsim", "take_mut", - "thiserror 1.0.69", + "thiserror", "tokio", "tokio-rustls", "tokio-util", @@ -1864,14 +1786,14 @@ dependencies = [ [[package]] name = "mongodb-internal-macros" -version = "3.2.4" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9202de265a3a8bbb43f9fe56db27c93137d4f9fb04c093f47e9c7de0c61ac7d" +checksum = "f736d2fbc56e0011a341fbb9172bd822fda75c5f93b82fae1c7aab1e2613c810" dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1880,7 +1802,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.9.1", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -1898,19 +1820,18 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -1927,7 +1848,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.1", + "bitflags", ] [[package]] @@ -1941,9 +1862,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oorandom" @@ -1951,12 +1872,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "parking" version = "2.2.1" @@ -1965,9 +1880,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -1975,37 +1890,31 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pbkdf2" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" @@ -2028,9 +1937,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "plotters" @@ -2066,14 +1975,14 @@ version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ - "base64 0.22.1", + "base64", "byteorder", "bytes", "fallible-iterator", - "hmac 0.13.0", - "md-5 0.11.0", + "hmac", + "md-5", "memchr", - "rand 0.10.1", + "rand 0.10.2", "sha2 0.11.0", "stringprep", ] @@ -2091,9 +2000,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2115,27 +2024,27 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "r-efi" @@ -2151,9 +2060,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2162,19 +2071,19 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2198,7 +2107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2207,16 +2116,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -2227,9 +2136,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rayon" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2237,9 +2146,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -2247,9 +2156,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.3.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fa6f8e4b491d7a8ef3a9550a4d71969bd0064f46e32b8dbbcc7fc60dad94fed" +checksum = "b0b9503711b03773e43b31668c7b5bd279ee7cd9b7d18cff7c23a42cc1d08e5a" dependencies = [ "arcstr", "async-lock", @@ -2262,9 +2171,9 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "ryu", - "socket2 0.6.4", + "socket2 0.6.5", "tokio", "tokio-util", "url", @@ -2287,56 +2196,41 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.12" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.1", + "bitflags", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "ring" @@ -2346,7 +2240,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2354,33 +2248,29 @@ dependencies = [ [[package]] name = "rmp" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" dependencies = [ - "byteorder", "num-traits", - "paste", ] [[package]] name = "rmp-serde" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" dependencies = [ - "byteorder", "rmp", "serde", ] [[package]] name = "rmpv" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58450723cd9ee93273ce44a20b6ec4efe17f8ed2e3631474387bfdecf18bb2a9" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" dependencies = [ - "num-traits", "rmp", "serde", "serde_bytes", @@ -2407,46 +2297,50 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.12" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", + "once_cell", "ring", + "rustls-pki-types", "rustls-webpki", - "sct", + "subtle", + "zeroize", ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls-pki-types" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ - "base64 0.21.7", + "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", + "rustls-pki-types", "untrusted", ] [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2469,72 +2363,75 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] [[package]] name = "serde_bytes" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap 2.9.0", + "indexmap", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -2555,16 +2452,8 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.9.0", - "serde", - "serde_derive", - "serde_json", + "serde_core", "serde_with_macros", - "time", ] [[package]] @@ -2576,20 +2465,31 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2623,16 +2523,17 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -2650,18 +2551,15 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -2678,9 +2576,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2702,14 +2600,14 @@ dependencies = [ "hyper", "matchit 0.9.2", "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "socketioxide-core", "socketioxide-parser-common", "socketioxide-parser-msgpack", "state", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tokio-tungstenite", @@ -2732,7 +2630,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -2765,7 +2663,7 @@ dependencies = [ "smallvec", "socketioxide", "socketioxide-core", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -2813,7 +2711,7 @@ dependencies = [ "socketioxide", "socketioxide-core", "sqlx", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-postgres", "tracing", @@ -2838,7 +2736,7 @@ dependencies = [ "smallvec", "socketioxide", "socketioxide-core", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -2861,7 +2759,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "cfg-if", "crc", @@ -2874,7 +2772,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap 2.9.0", + "indexmap", "log", "memchr", "percent-encoding", @@ -2882,7 +2780,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tracing", @@ -2899,7 +2797,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.101", + "syn", ] [[package]] @@ -2920,7 +2818,7 @@ dependencies = [ "sha2 0.10.9", "sqlx-core", "sqlx-postgres", - "syn 2.0.101", + "syn", "tokio", "url", ] @@ -2932,8 +2830,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64 0.22.1", - "bitflags 2.9.1", + "base64", + "bitflags", "byteorder", "crc", "dotenvy", @@ -2943,28 +2841,28 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac 0.13.0", + "hmac", "itoa", "log", - "md-5 0.11.0", + "md-5", "memchr", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "whoami", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "state" @@ -3010,20 +2908,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -3044,7 +2931,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -3059,33 +2946,13 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "thiserror-impl", ] [[package]] @@ -3096,45 +2963,43 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -3151,9 +3016,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3171,9 +3036,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3186,9 +3051,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -3196,20 +3061,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -3231,8 +3096,8 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.10.1", - "socket2 0.6.4", + "rand 0.10.2", + "socket2 0.6.5", "tokio", "tokio-util", "whoami", @@ -3240,9 +3105,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.24.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -3250,9 +3115,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -3281,15 +3146,16 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -3315,9 +3181,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -3327,20 +3193,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.29" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -3359,14 +3225,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -3392,20 +3258,29 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.1", - "sha1", - "thiserror 2.0.18", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror", ] [[package]] name = "typed-builder" -version = "0.10.0" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89851716b67b937e393b3daa8423e67ddfc4bbbf1654bcf05488e95e0828db0c" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn", ] [[package]] @@ -3422,24 +3297,36 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -3449,13 +3336,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -3472,13 +3360,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.17.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.3", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -3515,17 +3403,26 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen-rt", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", ] [[package]] @@ -3534,7 +3431,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" dependencies = [ - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -3569,7 +3466,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.101", + "syn", "wasm-bindgen-shared", ] @@ -3594,9 +3491,12 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.25.4" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "whoami" @@ -3611,37 +3511,15 @@ dependencies = [ "web-sys", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.48.0" @@ -3651,71 +3529,12 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.1", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - -[[package]] -name = "windows-link" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.1", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3740,7 +3559,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3865,19 +3684,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -3890,17 +3706,16 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -3908,62 +3723,68 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3972,9 +3793,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3983,11 +3804,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From d4c66185924a3eaf250a2f1703a8b1d63893c6d1 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 18:46:46 +0200 Subject: [PATCH 33/41] test: engine poll --- crates/engineioxide-client/tests/fixture.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index f10556f9..7268dab2 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -39,7 +39,10 @@ fn init_tracing() { pub fn service_with_config( config: EngineIoConfig, -) -> (TestingFlavor>, mpsc::UnboundedReceiver) { +) -> ( + TestingFlavor>, + mpsc::UnboundedReceiver, +) { init_tracing(); let (handler, rx) = EchoHandler::new(); let svc = EngineIoService::with_config(Arc::new(handler), config); @@ -47,7 +50,10 @@ pub fn service_with_config( } #[allow(unused)] -pub fn service() -> (TestingFlavor>, mpsc::UnboundedReceiver) { +pub fn service() -> ( + TestingFlavor>, + mpsc::UnboundedReceiver, +) { service_with_config(EngineIoConfig::default()) } From 7d2a9797f99080b43e80fc0613b22305a07fd7cf Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 18:59:23 +0200 Subject: [PATCH 34/41] test: engine poll --- crates/engineioxide/src/service/mod.rs | 48 ++++++++++++++++++++------ crates/socketioxide/src/service.rs | 7 ++-- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/crates/engineioxide/src/service/mod.rs b/crates/engineioxide/src/service/mod.rs index 17ce6f61..e5f1adf0 100644 --- a/crates/engineioxide/src/service/mod.rs +++ b/crates/engineioxide/src/service/mod.rs @@ -137,6 +137,24 @@ where } } +impl TowerSvc<(St, http::Request<()>)> for EngineIoService +where + H: EngineIoHandler, + St: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + type Response = (); + type Error = parser::ParseError; + type Future = std::future::Ready>; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, (conn, req): (St, http::Request<()>)) -> Self::Future { + std::future::ready(ws_conn_inner(self.engine.clone(), conn, req)) + } +} + /// Hyper 1.0 Service implementation. impl HyperSvc> for EngineIoService where @@ -171,20 +189,28 @@ where type Future = std::future::Ready>; fn call(&self, (conn, req): (St, http::Request<()>)) -> Self::Future { - let engine = self.engine.clone(); - let RequestInfo { protocol, sid, .. } = match RequestInfo::parse(&req, &engine.config) { - Ok(infos) => infos, - Err(e) => return std::future::ready(Err(e)), - }; - - let (parts, _) = req.into_parts(); - tokio::spawn(crate::transport::ws::on_init( - engine, conn, protocol, sid, parts, - )); - std::future::ready(Ok(())) + std::future::ready(ws_conn_inner(self.engine.clone(), conn, req)) } } +fn ws_conn_inner( + engine: Arc>, + conn: St, + req: http::Request<()>, +) -> Result<(), parser::ParseError> +where + H: EngineIoHandler, + St: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let RequestInfo { protocol, sid, .. } = RequestInfo::parse(&req, &engine.config)?; + + let (parts, _) = req.into_parts(); + let fut = crate::transport::ws::on_init(engine, conn, protocol, sid, parts); + tokio::spawn(fut); + + Ok(()) +} + #[cfg(feature = "__test_harness")] #[doc(hidden)] impl EngineIoService diff --git a/crates/socketioxide/src/service.rs b/crates/socketioxide/src/service.rs index 55ea5225..0c28c3fe 100644 --- a/crates/socketioxide/src/service.rs +++ b/crates/socketioxide/src/service.rs @@ -56,11 +56,12 @@ where #[inline(always)] fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.engine_svc.poll_ready(cx) + TowerSvc::>::poll_ready(&mut self.engine_svc, cx) } + #[inline(always)] fn call(&mut self, req: Request) -> Self::Future { - self.engine_svc.call(req) + TowerSvc::>::call(&mut self.engine_svc, req) } } @@ -80,7 +81,7 @@ where #[inline(always)] fn call(&self, req: Request) -> Self::Future { - self.engine_svc.call(req) + HyperSvc::>::call(&self.engine_svc, req) } } From 7b70c1fbb57a4d60ee4a4490333259b5e47369c7 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sat, 18 Jul 2026 19:03:23 +0200 Subject: [PATCH 35/41] test: engine poll --- Cargo.lock | 2 +- crates/engineioxide-client/Cargo.toml | 2 +- crates/engineioxide-client/src/config.rs | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b20fee2..453912ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -838,7 +838,7 @@ dependencies = [ [[package]] name = "engineioxide-client" -version = "0.17.0" +version = "0.1.0" dependencies = [ "bytes", "engineioxide", diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml index ab3fda24..43727d12 100644 --- a/crates/engineioxide-client/Cargo.toml +++ b/crates/engineioxide-client/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "engineioxide-client" description = "Engine IO client implementation in rust" -version = "0.17.0" +version = "0.1.0" edition.workspace = true rust-version.workspace = true authors.workspace = true diff --git a/crates/engineioxide-client/src/config.rs b/crates/engineioxide-client/src/config.rs index 4c0ac47a..5611920d 100644 --- a/crates/engineioxide-client/src/config.rs +++ b/crates/engineioxide-client/src/config.rs @@ -41,14 +41,14 @@ impl EngineIoClientConfig { #[derive(Default)] pub struct EngineIoClientConfigBuilder { config: EngineIoClientConfig, - uri: String, + uri: Option, } impl EngineIoClientConfigBuilder { pub fn new() -> Self { Self::default() } pub fn uri(mut self, uri: &str) -> Self { - self.uri = uri.to_string(); + self.uri = Some(uri.to_string()); self } pub fn transports(mut self, transports: [TransportType; N]) -> Self { @@ -58,7 +58,9 @@ impl EngineIoClientConfigBuilder { self } pub fn build(mut self) -> EngineIoClientConfig { - self.config.uri = self.uri.parse().unwrap(); //TODO: err + if let Some(uri) = self.uri { + self.config.uri = uri.parse().unwrap(); //TODO: err + } self.config } } From da522e92ae9d93b8815471b34a79d7a6005c1509 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 19 Jul 2026 00:18:12 +0200 Subject: [PATCH 36/41] feat(config): improve config ergonomics --- crates/engineioxide-client/src/client.rs | 67 ++++++++++++++++-------- crates/engineioxide-client/src/config.rs | 39 ++++++++++++-- e2e/engineioxide/client.rs | 7 +-- 3 files changed, 81 insertions(+), 32 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index ee484b1f..68e31b52 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -7,10 +7,13 @@ use std::{ use engineioxide_core::{OpenPacket, Packet, Sid, TransportType}; use futures_core::Stream; use futures_util::Sink; +use http::uri; +use thiserror::Error; use tracing::Level; use crate::{ EngineIoClientConfig, + config::IntoEngineIoClientConfig, event::EioEvent, flavors, transport::{Transport, TransportError, TransportSvc, WsTransport, polling::PollingTransport}, @@ -40,42 +43,42 @@ enum ClientState { impl Client { pub async fn connect_with_hyper_ws( - config: EngineIoClientConfig, - ) -> Result> { + config: impl IntoEngineIoClientConfig, + ) -> Result> { let svc = flavors::hyper_tungstenite::HyperTungsteniteFlavor::new(); - Self::connect_with_config(svc, config).await + Self::connect(svc, config).await } } impl Client> { pub async fn connect_with_testbed( svc: Svc, - config: EngineIoClientConfig, - ) -> Result>> { + config: impl IntoEngineIoClientConfig, + ) -> Result>> { let svc = flavors::testing::TestingFlavor::new(svc); - Self::connect_with_config(svc, config).await + Self::connect(svc, config).await } } -impl Client { - pub async fn connect(svc: S) -> Result> { - Self::connect_with_config(svc, EngineIoClientConfig::default()).await +#[derive(Debug, Error)] +pub enum ConnectError { + #[error(transparent)] + Transport(TransportError), + #[error("failed to build client, invalid uri: {0}")] + Config(#[from] uri::InvalidUri), +} +impl From> for ConnectError { + fn from(value: TransportError) -> Self { + Self::Transport(value) } - - pub async fn connect_with_config( +} +impl Client { + pub async fn connect( svc: S, - config: EngineIoClientConfig, - ) -> Result> { - let (transport, open_packet) = match config.initial_transport() { - TransportType::Polling => { - let (transport, open_packet) = PollingTransport::connect(svc, &config).await?; - (transport.into(), open_packet) - } - TransportType::Websocket => { - let (transport, open_packet) = WsTransport::connect(svc, &config).await?; - (transport.into(), open_packet) - } - }; + config: impl IntoEngineIoClientConfig, + ) -> Result> { + let config = config.into_config()?; + let (transport, open_packet) = Self::connect_inner(svc, &config).await?; let client = Client { transport, @@ -89,6 +92,24 @@ impl Client { Ok(client) } + async fn connect_inner( + svc: S, + config: &EngineIoClientConfig, + ) -> Result<(Transport, OpenPacket), TransportError> { + let (transport, packet) = match config.initial_transport() { + TransportType::Polling => { + let (transport, open_packet) = PollingTransport::connect(svc, config).await?; + (transport.into(), open_packet) + } + TransportType::Websocket => { + let (transport, open_packet) = WsTransport::connect(svc, config).await?; + (transport.into(), open_packet) + } + }; + + Ok((transport, packet)) + } + pub fn transport(&self) -> TransportType { self.transport.transport_type() } diff --git a/crates/engineioxide-client/src/config.rs b/crates/engineioxide-client/src/config.rs index 5611920d..67c4ca44 100644 --- a/crates/engineioxide-client/src/config.rs +++ b/crates/engineioxide-client/src/config.rs @@ -1,5 +1,7 @@ +use std::str::FromStr; + use engineioxide_core::TransportType; -use http::Uri; +use http::{Uri, uri}; #[derive(Debug)] pub struct EngineIoClientConfig { @@ -38,6 +40,35 @@ impl EngineIoClientConfig { } } +pub trait IntoEngineIoClientConfig { + fn into_config(self) -> Result; +} +impl IntoEngineIoClientConfig for EngineIoClientConfig { + fn into_config(self) -> Result { + Ok(self) + } +} +impl IntoEngineIoClientConfig for &str { + fn into_config(self) -> Result { + Ok(EngineIoClientConfig { + uri: self.parse()?, + ..Default::default() + }) + } +} +impl IntoEngineIoClientConfig for Result { + fn into_config(self) -> Result { + self + } +} +impl FromStr for EngineIoClientConfig { + type Err = uri::InvalidUri; + + fn from_str(s: &str) -> Result { + s.parse() + } +} + #[derive(Default)] pub struct EngineIoClientConfigBuilder { config: EngineIoClientConfig, @@ -57,10 +88,10 @@ impl EngineIoClientConfigBuilder { self.config.transports = transports.to_vec(); self } - pub fn build(mut self) -> EngineIoClientConfig { + pub fn build(mut self) -> Result { if let Some(uri) = self.uri { - self.config.uri = uri.parse().unwrap(); //TODO: err + self.config.uri = uri.parse()?; //TODO: err } - self.config + Ok(self.config) } } diff --git a/e2e/engineioxide/client.rs b/e2e/engineioxide/client.rs index 4fee0d51..60761348 100644 --- a/e2e/engineioxide/client.rs +++ b/e2e/engineioxide/client.rs @@ -1,4 +1,4 @@ -use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; +use engineioxide_client::{Client, EioEvent}; use futures_util::{SinkExt, StreamExt}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; @@ -8,11 +8,8 @@ async fn main() -> Result<(), Box> { .with_env_filter(EnvFilter::from_default_env()) .finish(); tracing::subscriber::set_global_default(subscriber)?; - let config = EngineIoClientConfig::builder() - .uri("http://localhost:3000/engine.io") - .build(); - let client = Client::connect_with_hyper_ws(config).await?; + let client = Client::connect_with_hyper_ws("http://localhost:3000/engine.io").await?; let (mut tx, mut rx) = client.split(); while let Some(Ok(event)) = rx.next().await { From b3b7de45075685e5bf339dcef07883a0deecaee5 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 19 Jul 2026 00:18:21 +0200 Subject: [PATCH 37/41] test: test suite --- crates/engineioxide-client/tests/close.rs | 492 ++++++++++++++++++ crates/engineioxide-client/tests/errors.rs | 215 ++++++++ crates/engineioxide-client/tests/fixture.rs | 57 +- crates/engineioxide-client/tests/handshake.rs | 219 +++++++- crates/engineioxide-client/tests/heartbeat.rs | 206 +++++++- crates/engineioxide-client/tests/mock.rs | 491 +++++++++++++++++ crates/engineioxide-client/tests/payload.rs | 307 +++++++++++ crates/engineioxide-client/tests/reconnect.rs | 134 +++++ .../engineioxide-client/tests/round_trip.rs | 4 +- crates/engineioxide-client/tests/upgrade.rs | 470 ++++++++++++++++- 10 files changed, 2580 insertions(+), 15 deletions(-) create mode 100644 crates/engineioxide-client/tests/close.rs create mode 100644 crates/engineioxide-client/tests/errors.rs create mode 100644 crates/engineioxide-client/tests/mock.rs create mode 100644 crates/engineioxide-client/tests/payload.rs create mode 100644 crates/engineioxide-client/tests/reconnect.rs diff --git a/crates/engineioxide-client/tests/close.rs b/crates/engineioxide-client/tests/close.rs new file mode 100644 index 00000000..6f337f6e --- /dev/null +++ b/crates/engineioxide-client/tests/close.rs @@ -0,0 +1,492 @@ +//! Closing behavior tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * Server-initiated close: a `1` (close) packet ends the session — the +//! reference client fires `close` with reason "transport close". Mapped to +//! this API: the stream yields [`EioEvent::Disconnect`], then terminates. +//! * Client-initiated close: buffered packets are flushed first, then the +//! polling transport POSTs a `1` (close) packet, while the websocket +//! transport simply closes the connection (no close packet). The reference +//! reason is "forced close". +//! * Packets submitted after a close are never delivered (the reference +//! client silently discards them). +//! * An abruptly closed websocket is a *clean* close ("transport close"), +//! not an error. + +use std::time::Duration; + +use engineioxide::{DisconnectReason, TransportType}; +use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; +use engineioxide_core::Packet; +use futures_util::{SinkExt, StreamExt}; + +use crate::fixture::{Event, service, service_with_registry}; + +mod fixture; +mod mock; + +/// A `1` (close) packet received on a poll ends the session: `Disconnect` +/// then end-of-stream, and the client stops issuing requests. +#[tokio::test] +async fn server_close_packet_polling() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let (disconnect, _) = tokio::join!(mock::within("disconnect event", client.next()), async { + server.next_http().await.respond_packets([Packet::Close]) + },); + assert_eq!(disconnect.unwrap().unwrap(), EioEvent::Disconnect); + assert_eq!( + mock::within("end of stream", client.next()) + .await + .map(|r| r.ok()), + None, + "the stream must terminate after the server closed the session" + ); +} + +/// A `1` (close) packet received on the websocket ends the session the same +/// way. +#[tokio::test] +async fn server_close_packet_ws() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, ws) = mock::connect_ws(&open).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + ws.send_packet(Packet::Close); + assert_eq!( + mock::within("disconnect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Disconnect + ); + assert_eq!( + mock::within("end of stream", client.next()) + .await + .map(|r| r.ok()), + None, + "the stream must terminate after the server closed the session" + ); +} + +/// Server-initiated close against the real server, polling transport. +#[tokio::test] +async fn server_close_real_server_polling() { + let (svc, mut rx, registry) = service_with_registry(Default::default()); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + registry.lock().unwrap()[&sid].close(DisconnectReason::TransportClose); + + assert_eq!( + fixture::within("disconnect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Disconnect + ); + assert!( + fixture::within("end of stream", client.next()) + .await + .is_none(), + "the stream must terminate after the server closed the session" + ); +} + +/// Server-initiated close against the real server, websocket transport. +#[tokio::test] +async fn server_close_real_server_ws() { + let (svc, mut rx, registry) = service_with_registry(Default::default()); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + registry.lock().unwrap()[&sid].close(DisconnectReason::TransportClose); + + assert_eq!( + fixture::within("disconnect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Disconnect + ); + assert!( + fixture::within("end of stream", client.next()) + .await + .is_none(), + "the stream must terminate after the server closed the session" + ); +} + +/// Closing a polling client must send a `1` (close) packet so the server +/// learns about the disconnection immediately (reference `Polling.doClose` +/// writes a close packet). +#[tokio::test] +async fn client_close_polling_sends_close_packet() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + tokio::join!( + async { + mock::within("client close", client.close()) + .await + .expect("close must succeed"); + }, + async { + let post = server.next_post_parking_get().await; + assert_eq!( + post.packets(), + vec![Packet::Close], + "closing a polling client must POST a close packet" + ); + post.respond_ok(); + }, + ); +} + +/// Closing a websocket client closes the connection without sending a `1` +/// packet (reference `WS.doClose` just closes the socket). +#[tokio::test] +async fn client_close_ws_closes_the_connection() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, mut ws) = mock::connect_ws(&open).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + mock::within("client close", client.close()) + .await + .expect("close must succeed"); + + // The server observes the websocket closing, with no engine.io close + // packet beforehand. + match mock::within("ws close", ws.recv()).await { + Some(engineioxide_client::transport::ws::WsMessage::Close) | None => (), + Some(engineioxide_client::transport::ws::WsMessage::Text(t)) => { + assert_ne!(&*t, "1", "no close packet is sent over websocket"); + panic!("unexpected frame while closing: {t:?}"); + } + Some(engineioxide_client::transport::ws::WsMessage::Binary(_)) => { + panic!("unexpected binary frame while closing") + } + } +} + +/// After a client-side close the real server must observe the disconnection +/// promptly (i.e. via the close packet, not a heartbeat timeout — the +/// default ping budget is 45s while the test deadline is 5s), polling +/// transport. +#[tokio::test] +async fn client_close_notifies_server_polling() { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + fixture::within("client close", client.close()) + .await + .expect("close must succeed"); + + assert_eq!( + fixture::within("server disconnect event", rx.recv()) + .await + .unwrap(), + Event::Disconnect(sid, DisconnectReason::TransportClose), + "the server must observe a graceful close" + ); +} + +/// Same requirement over websocket: closing the connection is enough for +/// the server to observe a graceful disconnection. +#[tokio::test] +async fn client_close_notifies_server_ws() { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + fixture::within("client close", client.close()) + .await + .expect("close must succeed"); + + assert_eq!( + fixture::within("server disconnect event", rx.recv()) + .await + .unwrap(), + Event::Disconnect(sid, DisconnectReason::TransportClose), + "the server must observe a graceful close" + ); +} + +/// Packets buffered before a close must be flushed before the transport +/// closes (reference client waits for `drain` before closing), polling +/// transport. +#[tokio::test] +async fn close_flushes_buffered_packets_polling() { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + // feed() queues without flushing; close() must flush then close. + client.feed(EioEvent::Message("one".into())).await.unwrap(); + client.feed(EioEvent::Message("two".into())).await.unwrap(); + fixture::within("client close", client.close()) + .await + .expect("close must succeed"); + + assert_eq!( + fixture::within("first buffered message", rx.recv()) + .await + .unwrap(), + Event::Message(sid, "one".into()) + ); + assert_eq!( + fixture::within("second buffered message", rx.recv()) + .await + .unwrap(), + Event::Message(sid, "two".into()) + ); + assert_eq!( + fixture::within("server disconnect event", rx.recv()) + .await + .unwrap(), + Event::Disconnect(sid, DisconnectReason::TransportClose), + ); +} + +/// Same flush-before-close requirement over websocket. +#[tokio::test] +async fn close_flushes_buffered_packets_ws() { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + client.feed(EioEvent::Message("one".into())).await.unwrap(); + client.feed(EioEvent::Message("two".into())).await.unwrap(); + fixture::within("client close", client.close()) + .await + .expect("close must succeed"); + + assert_eq!( + fixture::within("first buffered message", rx.recv()) + .await + .unwrap(), + Event::Message(sid, "one".into()) + ); + assert_eq!( + fixture::within("second buffered message", rx.recv()) + .await + .unwrap(), + Event::Message(sid, "two".into()) + ); + assert_eq!( + fixture::within("server disconnect event", rx.recv()) + .await + .unwrap(), + Event::Disconnect(sid, DisconnectReason::TransportClose), + ); +} + +/// Packets submitted after a local close must never reach the server (the +/// reference client silently discards them; a sink error is also +/// acceptable — but panicking or delivering is not). +#[tokio::test] +async fn send_after_close_is_not_delivered() { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + fixture::within("client close", client.close()) + .await + .expect("close must succeed"); + + // Whether this returns Ok (discarded) or Err (sink closed) is API + // flavor; what matters is that nothing is delivered and nothing panics. + let _ = fixture::within( + "send after close", + client.send(EioEvent::Message("late".into())), + ) + .await; + + if let Ok(Some(Event::Message(_, msg))) = + tokio::time::timeout(Duration::from_millis(300), rx.recv()).await + { + panic!("a message sent after close must not be delivered: {msg:?}") + } +} + +/// Sending after the *server* closed the session must surface a sink error +/// (the session is gone), not panic. +#[tokio::test] +async fn send_after_server_close_is_an_error() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let (disconnect, _) = tokio::join!(mock::within("disconnect event", client.next()), async { + server.next_http().await.respond_packets([Packet::Close]) + },); + assert_eq!(disconnect.unwrap().unwrap(), EioEvent::Disconnect); + assert!( + mock::within("end of stream", client.next()).await.is_none(), + "the stream must terminate after the server closed the session" + ); + + let res = mock::within( + "send after server close", + client.send(EioEvent::Message("late".into())), + ) + .await; + assert!( + res.is_err(), + "sending on a closed session must surface an error" + ); +} + +/// An abrupt websocket termination is a clean close for the reference +/// client ("transport close"), not an error: the stream must terminate +/// without yielding one. +#[tokio::test] +async fn abrupt_ws_termination_ends_the_stream() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, ws) = mock::connect_ws(&open).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + ws.close(); + + mock::within("stream termination", async { + loop { + match client.next().await { + // Surfacing the disconnection as an event first is fine. + Some(Ok(EioEvent::Disconnect)) => continue, + Some(Ok(ev)) => panic!("unexpected event on abrupt close: {ev:?}"), + Some(Err(e)) => panic!("an abrupt close is not an error: {e}"), + None => break, + } + } + }) + .await; +} + +/// A websocket-level error must surface as a stream error, then the stream +/// must terminate (reference client: `error` event, then close with reason +/// "transport error"). +#[tokio::test] +async fn ws_error_surfaces_then_stream_terminates() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, ws) = mock::connect_ws(&open).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + ws.send_error("connection reset by peer"); + + let err = mock::within("stream error", client.next()).await; + assert!( + matches!(err, Some(Err(_))), + "the websocket error must surface: {err:?}" + ); + // Keep `ws` alive: termination must come from the client closing itself + // after the error, not from the mock dropping the connection. + assert!( + mock::within("stream termination after error", client.next()) + .await + .is_none(), + "the stream must terminate after a transport error" + ); + drop(ws); +} diff --git a/crates/engineioxide-client/tests/errors.rs b/crates/engineioxide-client/tests/errors.rs new file mode 100644 index 00000000..af290e1f --- /dev/null +++ b/crates/engineioxide-client/tests/errors.rs @@ -0,0 +1,215 @@ +//! Transport error handling tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * A non-2xx HTTP status on a polling GET/POST is a transport error: the +//! client emits `error` then closes with reason "transport error". Mapped +//! to this API: the stream (or the sink call) yields an `Err`, then the +//! stream terminates. It must never panic, and never keep polling. +//! * A payload that cannot be decoded produces `Error("server error")` and +//! the same error-then-close sequence. +//! * `noop` packets are ignored (their only purpose is releasing a held +//! poll during upgrades). +//! * The client never retries or reconnects on its own. + +use std::time::Duration; + +use engineioxide_client::EioEvent; +use engineioxide_core::{Packet, TransportType}; +use futures_util::{SinkExt, StreamExt}; + +mod mock; + +/// Drive the stream expecting exactly one error and then termination. +async fn expect_error_then_termination(client: &mut engineioxide_client::Client) { + let first = mock::within("stream error", client.next()).await; + assert!( + matches!(first, Some(Err(_))), + "a transport error must surface as a stream error, got: {first:?}" + ); + assert!( + mock::within("stream termination after the error", client.next()) + .await + .is_none(), + "the stream must terminate after a transport error" + ); +} + +/// A 500 on a mid-session poll must surface an error and close the session. +#[tokio::test] +async fn polling_get_http_error_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + tokio::join!(expect_error_then_termination(&mut client), async { + server.next_http().await.respond(500, "") + },); + server + .assert_no_call(Duration::from_millis(300), "the session is closed") + .await; +} + +/// A 400 `{"code":1,"message":"Session ID unknown"}` (e.g. the server +/// restarted and lost the session) must surface an error and close: this is +/// what a higher layer observes to decide to reconnect. +#[tokio::test] +async fn polling_get_session_unknown_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + tokio::join!(expect_error_then_termination(&mut client), async { + server + .next_http() + .await + .respond(400, "{\"code\":1,\"message\":\"Session ID unknown\"}") + },); + server + .assert_no_call(Duration::from_millis(300), "the session is closed") + .await; +} + +/// A network-level failure on a mid-session poll must surface an error and +/// close the session. +#[tokio::test] +async fn polling_get_network_error_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + tokio::join!(expect_error_then_termination(&mut client), async { + server.next_http().await.fail("connection reset") + },); + server + .assert_no_call(Duration::from_millis(300), "the session is closed") + .await; +} + +/// An HTTP error on a POST write must surface as a sink error (reference: +/// "xhr post error" → close "transport error") — not a panic. +#[tokio::test] +async fn polling_post_http_error_surfaces() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let (res, _) = tokio::join!( + mock::within("send", client.send(EioEvent::Message("hello".into()))), + async { + let post = server.next_post_parking_get().await; + post.respond(413, ""); + }, + ); + assert!( + res.is_err(), + "an HTTP error on a write must surface as a sink error" + ); +} + +/// An undecodable poll payload must surface an error and close the session +/// (reference: `Error("server error")`, close "transport error"). +#[tokio::test] +async fn polling_parse_error_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + tokio::join!(expect_error_then_termination(&mut client), async { + server.next_http().await.respond(200, "garbage!") + },); + server + .assert_no_call(Duration::from_millis(300), "the session is closed") + .await; +} + +/// An invalid packet received over websocket must surface an error and +/// close the session. +#[tokio::test] +async fn ws_parse_error_surfaces_and_closes() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, ws) = mock::connect_ws(&open).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + ws.send_text("garbage!"); + + let first = mock::within("stream error", client.next()).await; + assert!( + matches!(first, Some(Err(_))), + "an invalid packet must surface as a stream error, got: {first:?}" + ); + // `ws` stays alive: the termination must come from the client itself. + assert!( + mock::within("stream termination after the error", client.next()) + .await + .is_none(), + "the stream must terminate after a parse error" + ); + drop(ws); +} + +/// `noop` packets must be ignored: no user-visible event, no error, and the +/// poll loop keeps running. +#[tokio::test] +async fn noop_packet_is_ignored_polling() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let (event, _) = tokio::join!( + mock::within("message after the noop", client.next()), + async { + server.next_http().await.respond_packets([Packet::Noop]); + // The client must keep polling after the noop... + let poll = server.next_http().await; + // ...and the noop must not have surfaced anything. + poll.respond_packets([Packet::Message("after-noop".into())]); + }, + ); + assert_eq!( + event.unwrap().unwrap(), + EioEvent::Message("after-noop".into()), + "the noop must be skipped silently" + ); +} diff --git a/crates/engineioxide-client/tests/fixture.rs b/crates/engineioxide-client/tests/fixture.rs index 7268dab2..bcc2b695 100644 --- a/crates/engineioxide-client/tests/fixture.rs +++ b/crates/engineioxide-client/tests/fixture.rs @@ -1,4 +1,8 @@ -use std::sync::Arc; +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use bytes::Bytes; use engineioxide::config::EngineIoConfig; @@ -10,6 +14,22 @@ use engineioxide_core::{Sid, Str}; use tokio::sync::mpsc; use tracing_subscriber::EnvFilter; +/// Default deadline for every await in the tests: an enforcement test must +/// fail fast instead of hanging the whole suite. +pub const DEADLINE: Duration = Duration::from_secs(5); + +/// Await `fut` with the global [`DEADLINE`], panicking with `what` on expiry. +pub async fn within(what: &str, fut: F) -> F::Output { + match tokio::time::timeout(DEADLINE, fut).await { + Ok(v) => v, + Err(_) => panic!("timed out after {DEADLINE:?}: {what}"), + } +} + +/// Handle over the sockets currently connected to the test server, letting +/// tests drive server-side actions (e.g. closing a session). +pub type SocketRegistry = Arc>>>>; + #[derive(Debug, PartialEq, Eq)] pub enum Event { Connect(Sid), @@ -21,12 +41,21 @@ pub enum Event { #[derive(Debug)] pub struct EchoHandler { tx: mpsc::UnboundedSender, + sockets: SocketRegistry, } impl EchoHandler { - fn new() -> (Self, mpsc::UnboundedReceiver) { + fn new() -> (Self, mpsc::UnboundedReceiver, SocketRegistry) { let (tx, rx) = mpsc::unbounded_channel(); - (Self { tx }, rx) + let sockets = SocketRegistry::default(); + ( + Self { + tx, + sockets: sockets.clone(), + }, + rx, + sockets, + ) } } @@ -42,14 +71,26 @@ pub fn service_with_config( ) -> ( TestingFlavor>, mpsc::UnboundedReceiver, +) { + let (svc, rx, _) = service_with_registry(config); + (svc, rx) +} + +/// Same as [`service_with_config`] but also returns the [`SocketRegistry`] +/// so tests can act on server-side sockets (e.g. close them). +pub fn service_with_registry( + config: EngineIoConfig, +) -> ( + TestingFlavor>, + mpsc::UnboundedReceiver, + SocketRegistry, ) { init_tracing(); - let (handler, rx) = EchoHandler::new(); + let (handler, rx, sockets) = EchoHandler::new(); let svc = EngineIoService::with_config(Arc::new(handler), config); - (svc.into(), rx) + (svc.into(), rx, sockets) } -#[allow(unused)] pub fn service() -> ( TestingFlavor>, mpsc::UnboundedReceiver, @@ -60,6 +101,10 @@ pub fn service() -> ( impl EngineIoHandler for EchoHandler { type Data = (); fn on_connect(self: Arc, socket: Arc>) { + self.sockets + .lock() + .unwrap() + .insert(socket.id, socket.clone()); self.tx.send(Event::Connect(socket.id)).unwrap(); } diff --git a/crates/engineioxide-client/tests/handshake.rs b/crates/engineioxide-client/tests/handshake.rs index a3822c19..9ec65786 100644 --- a/crates/engineioxide-client/tests/handshake.rs +++ b/crates/engineioxide-client/tests/handshake.rs @@ -1,13 +1,30 @@ +//! Handshake tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * The handshake request is a `GET` carrying `EIO=4` and `transport=...` +//! query parameters, and no `sid` (the session does not exist yet). +//! User-provided query parameters must be preserved. +//! * The server answers with an `open` packet: +//! `0{"sid":...,"upgrades":[...],"pingInterval":...,"pingTimeout":...,"maxPayload":...}`. +//! * The `sid` is then attached to every subsequent request. +//! * Anything else — a non-open packet, malformed JSON, an HTTP error status, +//! a network failure, a closed websocket — must surface as a connection +//! error. + use std::time::Duration; use engineioxide_client::{ - EngineIoClientConfig, + Client, EioEvent, EngineIoClientConfig, transport::{PollingTransport, WsTransport}, }; +use engineioxide_core::{Packet, TransportType}; +use futures_util::StreamExt; +use http::Method; use crate::fixture::{Event, service}; mod fixture; +mod mock; #[tokio::test] async fn handshake_polling() { @@ -44,3 +61,203 @@ async fn handshake_websocket() { assert_eq!(event, Some(Event::Connect(open.sid))); } + +/// The polling handshake must be a `GET` on the configured path with +/// `EIO=4&transport=polling` and no `sid`. +#[tokio::test] +async fn polling_handshake_request_format() { + let (svc, mut server) = mock::mock(); + let config = EngineIoClientConfig::builder() + .uri("http://example.com/engine.io") + .transports([TransportType::Polling]) + .build(); + + let (client, _) = tokio::join!( + mock::within("handshake", Client::connect(svc, config)), + async { + let call = server.next_http().await; + assert_eq!(call.method, Method::GET); + assert_eq!(call.uri.path(), "/engine.io"); + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("polling")); + assert_eq!(call.query("sid"), None, "no sid before the session exists"); + assert!(call.body.is_empty(), "the handshake GET has no body"); + call.respond_open(&mock::open_packet()); + }, + ); + let client = client.expect("handshake should succeed"); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// The websocket handshake must carry `EIO=4&transport=websocket` and no +/// `sid` when connecting directly (no polling session to upgrade). +#[tokio::test] +async fn ws_handshake_request_format() { + let (svc, mut server) = mock::mock(); + let config = EngineIoClientConfig::builder() + .uri("http://example.com/engine.io") + .transports([TransportType::Websocket]) + .build(); + + let (client, _ws) = tokio::join!( + mock::within("handshake", Client::connect(svc, config)), + async { + let call = server.next_ws().await; + assert_eq!(call.req.uri().path(), "/engine.io"); + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("websocket")); + assert_eq!(call.query("sid"), None, "no sid before the session exists"); + let ws = call.accept(); + ws.send_packet(Packet::Open(mock::open_packet())); + ws + }, + ); + let client = client.expect("handshake should succeed"); + assert_eq!(client.transport(), TransportType::Websocket); +} + +/// Once the open packet is received, its `sid` must be exposed and attached +/// to every subsequent request. +#[tokio::test] +async fn handshake_sid_is_attached_to_subsequent_requests() { + let open = mock::open_packet(); + let sid = open.sid.to_string(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + + assert_eq!(client.sid(), open.sid); + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let (event, _) = tokio::join!(mock::within("first message", client.next()), async { + let call = server.next_http().await; + assert_eq!(call.method, Method::GET); + assert_eq!(call.query("sid").map(str::to_owned), Some(sid)); + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("polling")); + call.respond_packets([Packet::Message("hi".into())]); + },); + assert_eq!(event.unwrap().unwrap(), EioEvent::Message("hi".into())); +} + +/// Query parameters provided in the configured uri must be preserved +/// alongside the mandatory engine.io parameters. +#[tokio::test] +async fn handshake_preserves_custom_query_params() { + let (svc, mut server) = mock::mock(); + let config = EngineIoClientConfig::builder() + .uri("http://example.com/engine.io?token=s3cret") + .transports([TransportType::Polling]) + .build(); + + let (client, _) = tokio::join!( + mock::within("handshake", Client::connect(svc, config)), + async { + let call = server.next_http().await; + assert_eq!(call.query("token"), Some("s3cret")); + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("polling")); + call.respond_open(&mock::open_packet()); + }, + ); + client.expect("handshake should succeed"); +} + +/// Run a polling connect against a scripted handshake answer and require it +/// to fail. +async fn polling_connect_must_fail(answer: impl FnOnce(mock::HttpCall)) { + let (svc, mut server) = mock::mock(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let (res, _) = tokio::join!( + mock::within( + "handshake attempt", + Client::connect(svc, config) + ), + async { answer(server.next_http().await) }, + ); + assert!(res.is_err(), "connect must surface an error"); +} + +/// Run a websocket connect against a scripted handshake answer and require +/// it to fail. +async fn ws_connect_must_fail(answer: impl FnOnce(mock::WsCall)) { + let (svc, mut server) = mock::mock(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + let (res, _) = tokio::join!( + mock::within( + "handshake attempt", + Client::connect(svc, config) + ), + async { answer(server.next_ws().await) }, + ); + assert!(res.is_err(), "connect must surface an error"); +} + +/// A first packet that is not an `open` packet is a protocol violation. +#[tokio::test] +async fn handshake_rejects_non_open_packet() { + polling_connect_must_fail(|call| call.respond(200, "4hello")).await; +} + +/// An open packet with a malformed JSON payload must be rejected. +#[tokio::test] +async fn handshake_rejects_malformed_open_packet() { + polling_connect_must_fail(|call| call.respond(200, "0{\"sid\":")).await; +} + +/// An HTTP error status on the handshake (e.g. the server refuses the +/// transport) must be surfaced as a connection error. +#[tokio::test] +async fn handshake_rejects_http_error_status() { + polling_connect_must_fail(|call| { + call.respond(400, "{\"code\":0,\"message\":\"Transport unknown\"}") + }) + .await; +} + +/// An empty 500 response must be surfaced as a connection error. +#[tokio::test] +async fn handshake_rejects_server_error_status() { + polling_connect_must_fail(|call| call.respond(500, "")).await; +} + +/// A network-level failure during the handshake must be surfaced. +#[tokio::test] +async fn handshake_network_error() { + polling_connect_must_fail(|call| call.fail("connection refused")).await; +} + +/// Over websocket, a first packet that is not an `open` packet is a protocol +/// violation. +#[tokio::test] +async fn ws_handshake_rejects_non_open_packet() { + ws_connect_must_fail(|call| { + let ws = call.accept(); + ws.send_text("4hello"); + }) + .await; +} + +/// A websocket closed before sending the open packet must surface an error. +#[tokio::test] +async fn ws_handshake_rejects_immediate_close() { + ws_connect_must_fail(|call| { + let ws = call.accept(); + ws.close(); + }) + .await; +} + +/// A refused websocket connection must surface an error. +#[tokio::test] +async fn ws_handshake_rejects_refused_connection() { + ws_connect_must_fail(|call| call.reject("connection refused")).await; +} diff --git a/crates/engineioxide-client/tests/heartbeat.rs b/crates/engineioxide-client/tests/heartbeat.rs index 3047cef1..dead02a9 100644 --- a/crates/engineioxide-client/tests/heartbeat.rs +++ b/crates/engineioxide-client/tests/heartbeat.rs @@ -8,15 +8,17 @@ //! The pong is emitted transparently by `Client::poll_next`: a `Ping` is //! intercepted, a `Pong` is sent and the `Ping` is never surfaced to the user. -use std::time::Duration; +use std::time::{Duration, Instant}; use engineioxide::{TransportType, config::EngineIoConfig}; use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; +use engineioxide_core::{OpenPacket, Packet}; use futures_util::{SinkExt, StreamExt}; use crate::fixture::{Event, service_with_config}; mod fixture; +mod mock; const PING_INTERVAL: Duration = Duration::from_millis(100); const PING_TIMEOUT: Duration = Duration::from_millis(100); @@ -36,7 +38,7 @@ async fn heartbeat_keeps_connection_alive_polling() { let config = EngineIoClientConfig::builder() .transports([TransportType::Polling]) .build(); - let mut client = Client::connect_with_config(svc, config).await.unwrap(); + let mut client = Client::connect(svc, config).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); assert_eq!( @@ -82,6 +84,204 @@ async fn heartbeat_keeps_connection_alive_polling() { } } +/// Wire-level pong check over websocket: a server `2` (ping) frame must be +/// answered with a `3` (pong) frame, and the ping must never surface as a +/// user-visible event. +#[tokio::test] +async fn ping_answered_with_pong_ws() { + let open = mock::open_packet_no_upgrade(); + let (mut client, _server, mut ws) = mock::connect_ws(&open).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + tokio::select! { + // Polling the stream is what drives the ping/pong exchange; the ping + // is internal so this branch must never resolve. + ev = client.next() => panic!("the ping must not surface: {ev:?}"), + _ = async { + for _ in 0..3 { + ws.send_packet(Packet::Ping); + assert_eq!(ws.recv_packet().await, Packet::Pong); + } + } => (), + } +} + +/// Wire-level pong check over polling: a `2` (ping) packet received on a GET +/// must be answered with a `3` (pong) POST, and never surface as an event. +#[tokio::test] +async fn ping_answered_with_pong_polling() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + tokio::select! { + ev = client.next() => panic!("the ping must not surface: {ev:?}"), + _ = async { + let poll = server.next_http().await; + poll.respond_packets([Packet::Ping]); + let post = server.next_post_parking_get().await; + assert_eq!(post.packets(), vec![Packet::Pong], "the ping must be answered with a pong"); + post.respond_ok(); + } => (), + } +} + +/// Reference client behavior: after the handshake the client arms a liveness +/// timer of `pingInterval + pingTimeout`; if no ping arrives in that window +/// it closes itself (official close reason: "ping timeout"). +/// +/// Here the server never pings: the client must terminate its stream within +/// the budget instead of waiting forever. +#[tokio::test] +async fn ping_timeout_closes_the_socket_polling() { + let open = OpenPacket { + ping_interval: Duration::from_millis(100), + ping_timeout: Duration::from_millis(100), + ..mock::open_packet_no_upgrade() + }; + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + let started = Instant::now(); + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let drive = async { + loop { + match client.next().await { + Some(Ok(ev)) => panic!("no event expected from a silent server: {ev:?}"), + Some(Err(_)) => (), // an error surfacing the timeout is acceptable + None => break, // stream terminated: the socket closed itself + } + } + }; + // The server stays silent: polls are held forever, no ping is ever sent. + let park = async { + loop { + match server.next_call().await { + mock::ServerCall::Http(c) => c.park(), + mock::ServerCall::Ws(c) => c.park(), + } + } + }; + tokio::time::timeout(Duration::from_millis(1500), async { + tokio::select! { _ = drive => (), _ = park => unreachable!() } + }) + .await + .expect( + "client must close itself within pingInterval + pingTimeout when the server stops pinging", + ); + + let elapsed = started.elapsed(); + assert!( + elapsed >= Duration::from_millis(150), + "client closed before the pingInterval + pingTimeout budget: {elapsed:?}" + ); +} + +/// Same liveness requirement over websocket. +#[tokio::test] +async fn ping_timeout_closes_the_socket_ws() { + let open = OpenPacket { + ping_interval: Duration::from_millis(100), + ping_timeout: Duration::from_millis(100), + ..mock::open_packet_no_upgrade() + }; + // `ws` must stay alive: dropping it would close the stream and make the + // test pass without exercising the client-side timer. + let (mut client, _server, ws) = mock::connect_ws(&open).await; + let started = Instant::now(); + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + tokio::time::timeout(Duration::from_millis(1500), async { + loop { + match client.next().await { + Some(Ok(ev)) => panic!("no event expected from a silent server: {ev:?}"), + Some(Err(_)) => (), + None => break, + } + } + }) + .await + .expect( + "client must close itself within pingInterval + pingTimeout when the server stops pinging", + ); + + let elapsed = started.elapsed(); + assert!( + elapsed >= Duration::from_millis(150), + "client closed before the pingInterval + pingTimeout budget: {elapsed:?}" + ); + drop(ws); +} + +/// The liveness timer must be re-armed by every received ping: as long as +/// the server keeps pinging (even past the initial budget), the client stays +/// alive; once pings stop, it must close within the budget. +#[tokio::test] +async fn ping_timeout_is_reset_by_each_ping() { + let open = OpenPacket { + ping_interval: Duration::from_millis(150), + ping_timeout: Duration::from_millis(100), + ..mock::open_packet_no_upgrade() + }; + let (mut client, _server, mut ws) = mock::connect_ws(&open).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + // 4 pings every 120ms: t=480ms, well past the 250ms budget. The client + // must still be alive because each ping re-arms the timer. + tokio::select! { + ev = client.next() => panic!("client must stay alive while pings keep coming: {ev:?}"), + _ = async { + for _ in 0..4 { + tokio::time::sleep(Duration::from_millis(120)).await; + ws.send_packet(Packet::Ping); + assert_eq!(ws.recv_packet().await, Packet::Pong); + } + } => (), + } + + // Now the server goes silent: the client must close within the budget. + tokio::time::timeout(Duration::from_millis(1500), async { + loop { + match client.next().await { + Some(Ok(ev)) => panic!("no event expected from a silent server: {ev:?}"), + Some(Err(_)) => (), + None => break, + } + } + }) + .await + .expect("client must close with a ping timeout once pings stop"); +} + /// A [`Client`] that is continuously polled must auto-respond to the server's /// `Ping`s with `Pong`s, keeping the connection alive across several ping /// cycles. The connection must also still be usable afterwards. @@ -93,7 +293,7 @@ async fn heartbeat_keeps_connection_alive_websocket() { .transports([TransportType::Websocket]) .build(); - let mut client = Client::connect_with_config(svc, config).await.unwrap(); + let mut client = Client::connect(svc, config).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); assert_eq!( diff --git a/crates/engineioxide-client/tests/mock.rs b/crates/engineioxide-client/tests/mock.rs new file mode 100644 index 00000000..67f91a5c --- /dev/null +++ b/crates/engineioxide-client/tests/mock.rs @@ -0,0 +1,491 @@ +//! A scripted mock engine.io server. +//! +//! Unlike the `fixture` module (which runs the real `engineioxide` server +//! behind the [`TestingFlavor`]), this harness hands every HTTP request and +//! every websocket connection straight to the test, which decides how to +//! answer (or not answer) each one. It is the "granular control" path used to +//! exercise edge cases the real server never produces: malformed handshakes, +//! dropped pings, failed upgrades, error status codes, abrupt closes... +//! +//! Test binaries using it must declare `mod mock;`. +#![allow(dead_code)] + +use std::{ + convert::Infallible, + fmt, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; + +use bytes::Bytes; +use engineioxide_client::{ + Client, EngineIoClientConfig, + transport::{WebSocket, ws::WsMessage}, +}; +use engineioxide_core::{OpenPacket, Packet, ProtocolVersion, Sid, TransportType}; +use futures_core::{Stream, future::BoxFuture}; +use futures_util::{FutureExt, Sink}; +use http::{Method, Request, Response, Uri}; +use http_body_util::{BodyExt, Full, combinators::BoxBody}; +use hyper::service::Service as HyperSvc; +use tokio::sync::{mpsc, oneshot}; + +/// Default deadline for every await in the tests: an enforcement test must +/// fail fast instead of hanging the whole suite. +pub const DEADLINE: Duration = Duration::from_secs(5); + +/// Await `fut` with the global [`DEADLINE`], panicking with `what` on expiry. +pub async fn within(what: &str, fut: F) -> F::Output { + match tokio::time::timeout(DEADLINE, fut).await { + Ok(v) => v, + Err(_) => panic!("timed out after {DEADLINE:?}: {what}"), + } +} + +/// The engine.io v4 payload record separator. +pub const SEP: char = '\x1e'; + +#[derive(Debug, Clone)] +pub struct MockError(pub String); +impl fmt::Display for MockError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "mock error: {}", self.0) + } +} +impl std::error::Error for MockError {} + +/// Create a connected `(client service, scripted server)` pair. +pub fn mock() -> (MockSvc, MockServer) { + let (tx, rx) = mpsc::unbounded_channel(); + (MockSvc { tx }, MockServer { rx }) +} + +/// The transport service handed to [`Client`]. Every request it receives is +/// forwarded to the paired [`MockServer`] for the test to answer. +#[derive(Debug, Clone)] +pub struct MockSvc { + tx: mpsc::UnboundedSender, +} + +/// HTTP (polling) side: satisfies `PollingSvc`. +impl HyperSvc>> for MockSvc { + type Response = Response>; + type Error = MockError; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: Request>) -> Self::Future { + let calls = self.tx.clone(); + // The request is surfaced to the test only once the client actually + // polls the future, mirroring "the request was sent on the wire". + async move { + let (parts, body) = req.into_parts(); + let body = body.collect().await.unwrap().to_bytes(); + let (respond, rx) = oneshot::channel(); + let call = HttpCall { + method: parts.method, + uri: parts.uri, + body, + respond, + }; + calls + .send(ServerCall::Http(call)) + .map_err(|_| MockError("mock server dropped".into()))?; + rx.await + .map_err(|_| MockError("mock server dropped the response".into()))? + } + .boxed() + } +} + +/// Websocket side: satisfies `WsSvc`. +impl HyperSvc> for MockSvc { + type Response = MockWs; + type Error = MockError; + type Future = BoxFuture<'static, Result>; + + fn call(&self, req: Request<()>) -> Self::Future { + let calls = self.tx.clone(); + async move { + let (respond, rx) = oneshot::channel(); + calls + .send(ServerCall::Ws(WsCall { req, respond })) + .map_err(|_| MockError("mock server dropped".into()))?; + rx.await + .map_err(|_| MockError("mock server dropped the connection".into()))? + } + .boxed() + } +} + +/// A channel-backed [`WebSocket`] implementation handed to the client when +/// the test accepts a websocket connection. +pub struct MockWs { + to_server: mpsc::UnboundedSender, + from_server: mpsc::UnboundedReceiver>, + closed: bool, +} + +impl WebSocket for MockWs { + type Error = MockError; +} + +impl Stream for MockWs { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.from_server.poll_recv(cx) + } +} + +impl Sink for MockWs { + type Error = MockError; + + fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + if self.closed { + Poll::Ready(Err(MockError("websocket already closed".into()))) + } else { + Poll::Ready(Ok(())) + } + } + + fn start_send(self: Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> { + if self.closed { + return Err(MockError("websocket already closed".into())); + } + self.to_server + .send(item) + .map_err(|_| MockError("server closed the connection".into())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + if !self.closed { + // Emulate the websocket close handshake: the peer observes a + // close frame. + let _ = self.to_server.send(WsMessage::Close); + self.closed = true; + } + Poll::Ready(Ok(())) + } +} + +/// A request the client issued, waiting for the test to decide its fate. +pub enum ServerCall { + Http(HttpCall), + Ws(WsCall), +} + +impl ServerCall { + pub fn describe(&self) -> String { + match self { + ServerCall::Http(c) => format!("http {} {}", c.method, c.uri), + ServerCall::Ws(c) => format!("ws connect {}", c.req.uri()), + } + } +} + +/// An in-flight HTTP request (polling GET or POST) with its collected body. +pub struct HttpCall { + pub method: Method, + pub uri: Uri, + pub body: Bytes, + respond: oneshot::Sender>, MockError>>, +} + +impl HttpCall { + /// Value of a query parameter, if present. + pub fn query(&self, key: &str) -> Option<&str> { + query_param(&self.uri, key) + } + + /// Decode the request body as engine.io packets (separated by `\x1e`). + pub fn packets(&self) -> Vec { + decode_payload(&self.body) + } + + pub fn respond(self, status: u16, body: impl Into) { + let res = Response::builder() + .status(status) + .body(Full::new(body.into())) + .unwrap(); + // The client may legitimately have dropped the request (e.g. a poll + // abandoned on upgrade), so a send failure is not an error. + let _ = self.respond.send(Ok(res)); + } + + /// Respond 200 with the given packets joined by the record separator. + pub fn respond_packets(self, packets: impl IntoIterator) { + let body = encode_payload(packets); + self.respond(200, body); + } + + /// Respond 200 with an open packet (handshake). + pub fn respond_open(self, open: &OpenPacket) { + self.respond_packets([Packet::Open(open.clone())]); + } + + /// Respond 200 "ok", as the reference server does for POST writes. + pub fn respond_ok(self) { + self.respond(200, "ok"); + } + + /// Make the request fail with a transport-level (network) error. + pub fn fail(self, msg: &str) { + let _ = self.respond.send(Err(MockError(msg.into()))); + } + + /// Never answer: the request stays in flight forever (e.g. a held poll). + pub fn park(self) { + std::mem::forget(self.respond); + } +} + +/// An in-flight websocket connection request. +pub struct WsCall { + pub req: Request<()>, + respond: oneshot::Sender>, +} + +impl WsCall { + pub fn query(&self, key: &str) -> Option<&str> { + query_param(self.req.uri(), key) + } + + /// Accept the connection and return the server-side handle. + pub fn accept(self) -> ServerWs { + let (to_client, from_server) = mpsc::unbounded_channel(); + let (to_server, from_client) = mpsc::unbounded_channel(); + let ws = MockWs { + to_server, + from_server, + closed: false, + }; + let _ = self.respond.send(Ok(ws)); + ServerWs { + tx: to_client, + rx: from_client, + } + } + + /// Refuse the connection with an error. + pub fn reject(self, msg: &str) { + let _ = self.respond.send(Err(MockError(msg.into()))); + } + + /// Never answer the connection request. + pub fn park(self) { + std::mem::forget(self.respond); + } +} + +/// Server-side handle over an accepted mock websocket. +pub struct ServerWs { + tx: mpsc::UnboundedSender>, + rx: mpsc::UnboundedReceiver, +} + +impl ServerWs { + pub fn send_packet(&self, packet: Packet) { + match packet { + Packet::Binary(data) => self.send_message(WsMessage::Binary(data)), + p => self.send_text(String::from(p)), + } + } + + pub fn send_text(&self, text: impl Into) { + self.send_message(WsMessage::Text(text.into().into())); + } + + pub fn send_message(&self, msg: WsMessage) { + self.tx.send(Ok(msg)).expect("client dropped the websocket"); + } + + /// Surface a websocket-level error to the client. + pub fn send_error(&self, msg: &str) { + self.tx + .send(Err(MockError(msg.into()))) + .expect("client dropped the websocket"); + } + + /// Next raw message sent by the client, `None` once the client closed. + pub async fn recv(&mut self) -> Option { + within("waiting for a client websocket message", self.rx.recv()).await + } + + /// Next client message decoded as a packet (binary frames are messages). + pub async fn recv_packet(&mut self) -> Packet { + match self.recv().await { + Some(WsMessage::Text(text)) => { + Packet::parse(ProtocolVersion::V4, text).expect("client sent an invalid packet") + } + Some(WsMessage::Binary(data)) => Packet::Binary(data), + Some(WsMessage::Close) => panic!("expected a packet, got a websocket close frame"), + None => panic!("expected a packet, but the client closed the websocket"), + } + } + + /// Close the connection from the server side (client stream terminates). + pub fn close(self) {} +} + +/// Test-side handle receiving every request made by the client. +pub struct MockServer { + rx: mpsc::UnboundedReceiver, +} + +impl MockServer { + pub async fn next_call(&mut self) -> ServerCall { + within("waiting for a client request", self.rx.recv()) + .await + .expect("client service dropped, no more requests will come") + } + + pub async fn next_http(&mut self) -> HttpCall { + match self.next_call().await { + ServerCall::Http(call) => call, + ServerCall::Ws(call) => { + panic!("expected an http request, got a ws connect: {:?}", call.req) + } + } + } + + pub async fn next_ws(&mut self) -> WsCall { + match self.next_call().await { + ServerCall::Ws(call) => call, + ServerCall::Http(call) => panic!( + "expected a ws connect, got an http request: {} {}", + call.method, call.uri + ), + } + } + + /// Wait for a ws connect, leaving any interleaved polling request parked + /// (the reference client keeps a poll in flight while probing). + pub async fn next_ws_parking_http(&mut self) -> WsCall { + loop { + match self.next_call().await { + ServerCall::Ws(call) => return call, + ServerCall::Http(call) => call.park(), + } + } + } + + /// Wait for a POST write; polling GETs received meanwhile are parked. + pub async fn next_post_parking_get(&mut self) -> HttpCall { + loop { + let call = self.next_http().await; + if call.method == Method::POST { + return call; + } + call.park() + } + } + + /// Assert the client stays silent (no http request, no ws connect) for + /// the whole window. + pub async fn assert_no_call(&mut self, window: Duration, what: &str) { + match tokio::time::timeout(window, self.rx.recv()).await { + Err(_) => (), // silence: all good + Ok(None) => (), // client service dropped: silent forever + Ok(Some(call)) => panic!( + "unexpected client request while {what}: {}", + call.describe() + ), + } + } +} + +/// An open packet advertising a websocket upgrade. +pub fn open_packet() -> OpenPacket { + OpenPacket { + sid: Sid::new(), + upgrades: [TransportType::Websocket].into_iter().collect(), + ping_interval: Duration::from_millis(25000), + ping_timeout: Duration::from_millis(20000), + max_payload: 100_000, + } +} + +/// An open packet advertising no upgrade at all. +pub fn open_packet_no_upgrade() -> OpenPacket { + OpenPacket { + upgrades: std::iter::empty().collect(), + ..open_packet() + } +} + +/// Connect a client over polling against the mock, answering the handshake +/// with `open`. Returns the connected client and the scripted server. +pub async fn connect_polling( + open: &OpenPacket, + transports: [TransportType; N], +) -> (Client, MockServer) { + let (svc, mut server) = mock(); + let config = EngineIoClientConfig::builder() + .transports(transports) + .build(); + let (client, _) = tokio::join!( + within("polling handshake", Client::connect(svc, config)), + async { + let call = server.next_http().await; + assert_eq!(call.method, Method::GET, "handshake must be a GET"); + call.respond_open(open); + } + ); + (client.expect("handshake should succeed"), server) +} + +/// Connect a client directly over websocket against the mock. +pub async fn connect_ws(open: &OpenPacket) -> (Client, MockServer, ServerWs) { + let (svc, mut server) = mock(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + let (client, ws) = tokio::join!( + within("websocket handshake", Client::connect(svc, config)), + async { + let ws = server.next_ws().await.accept(); + ws.send_packet(Packet::Open(open.clone())); + ws + } + ); + (client.expect("handshake should succeed"), server, ws) +} + +/// Split a `\x1e`-separated payload into packets. +pub fn decode_payload(body: &[u8]) -> Vec { + let body = std::str::from_utf8(body).expect("payload should be valid utf8"); + if body.is_empty() { + return Vec::new(); + } + body.split(SEP) + .map(|part| { + Packet::parse(ProtocolVersion::V4, part.to_owned()) + .expect("payload part should be a valid packet") + }) + .collect() +} + +/// Join packets with the `\x1e` record separator. +pub fn encode_payload(packets: impl IntoIterator) -> String { + packets + .into_iter() + .map(String::from) + .collect::>() + .join("\x1e") +} + +fn query_param<'a>(uri: &'a Uri, key: &str) -> Option<&'a str> { + uri.query()? + .split('&') + .filter_map(|pair| pair.split_once('=')) + .find(|(k, _)| *k == key) + .map(|(_, v)| v) +} diff --git a/crates/engineioxide-client/tests/payload.rs b/crates/engineioxide-client/tests/payload.rs new file mode 100644 index 00000000..c6be0d68 --- /dev/null +++ b/crates/engineioxide-client/tests/payload.rs @@ -0,0 +1,307 @@ +//! Payload encoding and size-limit tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * Polling payloads are packets joined with the `\x1e` record separator; +//! binary packets are base64 with a `b` prefix. Websocket sends binary +//! data as raw binary frames. +//! * `maxPayload` (from the handshake) bounds the size of a multi-packet +//! polling POST: the write buffer is split so each POST stays under the +//! limit. A single packet bigger than the limit is sent anyway and the +//! server rejects it (HTTP 413) — which is a transport error. + +use bytes::Bytes; +use engineioxide::{TransportType, config::EngineIoConfig}; +use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; +use engineioxide_core::Packet; +use futures_util::{SinkExt, StreamExt}; + +use crate::fixture::{Event, service, service_with_config}; + +mod fixture; +mod mock; + +/// Several packets received in a single poll response (separated by `\x1e`) +/// must be surfaced in order. +#[tokio::test] +async fn multiple_packets_in_a_single_poll_response() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let (events, _) = tokio::join!( + mock::within("three events", async { + let mut events = Vec::new(); + for _ in 0..3 { + events.push(client.next().await.unwrap().unwrap()); + } + events + }), + async { + server.next_http().await.respond_packets([ + Packet::Message("first".into()), + Packet::Message("second".into()), + Packet::Binary(Bytes::from_static(&[1, 2, 3])), + ]); + }, + ); + assert_eq!( + events, + vec![ + EioEvent::Message("first".into()), + EioEvent::Message("second".into()), + EioEvent::Binary(Bytes::from_static(&[1, 2, 3])), + ] + ); +} + +/// Packets queued before a flush must be batched in a single POST, joined +/// with the record separator. +#[tokio::test] +async fn flush_batches_packets_with_record_separator() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + client.feed(EioEvent::Message("a".into())).await.unwrap(); + client.feed(EioEvent::Message("b".into())).await.unwrap(); + client.feed(EioEvent::Message("c".into())).await.unwrap(); + + tokio::join!( + async { + mock::within("flush", client.flush()).await.unwrap(); + }, + async { + let post = server.next_post_parking_get().await; + assert_eq!(&post.body[..], b"4a\x1e4b\x1e4c"); + post.respond_ok(); + }, + ); +} + +/// Binary packets on the polling transport are base64-encoded with a `b` +/// prefix, in both directions. +#[tokio::test] +async fn binary_is_base64_on_polling() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + // Inbound: `bAQID` is [1, 2, 3]. + let (event, _) = tokio::join!(mock::within("binary event", client.next()), async { + server.next_http().await.respond(200, "bAQID") + },); + assert_eq!( + event.unwrap().unwrap(), + EioEvent::Binary(Bytes::from_static(&[1, 2, 3])) + ); + + // Outbound: [4, 5, 6] must be POSTed as `bBAUG`. + tokio::join!( + async { + mock::within( + "send binary", + client.send(EioEvent::Binary(Bytes::from_static(&[4, 5, 6]))), + ) + .await + .unwrap(); + }, + async { + let post = server.next_post_parking_get().await; + assert_eq!(&post.body[..], b"bBAUG"); + post.respond_ok(); + }, + ); +} + +/// A multi-packet flush must be split so that each polling POST stays under +/// the handshake `maxPayload` (reference `_getWritablePackets`). The server +/// enforces the limit with HTTP 413, so all four messages arriving proves +/// the client split the batch. +#[tokio::test] +async fn flush_splits_batches_at_max_payload() { + let config = EngineIoConfig::builder().max_payload(100).build(); + let (svc, mut rx) = service_with_config(config); + let client_config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect(svc, client_config) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + // 4 messages of 30 bytes each: 127 wire bytes in a single payload, + // which must be split into (at least) two POSTs of <= 100 bytes. + let msg = "a".repeat(30); + for _ in 0..4 { + client + .feed(EioEvent::Message(msg.clone().into())) + .await + .unwrap(); + } + fixture::within("flush", client.flush()).await.unwrap(); + + for i in 0..4 { + assert_eq!( + fixture::within("batched message", rx.recv()).await.unwrap(), + Event::Message(sid, msg.clone().into()), + "message {i} must arrive: batches must be split under maxPayload" + ); + } +} + +/// A single packet over `maxPayload` cannot be split: the reference client +/// sends it anyway, the server rejects it (413) and the failure surfaces as +/// a transport error — never a panic. +#[tokio::test] +async fn oversized_packet_surfaces_a_transport_error() { + let config = EngineIoConfig::builder().max_payload(100).build(); + let (svc, mut rx) = service_with_config(config); + let client_config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect(svc, client_config) + .await + .unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + let res = fixture::within( + "oversized send", + client.send(EioEvent::Message("a".repeat(300).into())), + ) + .await; + assert!( + res.is_err(), + "an oversized write rejected by the server must surface an error" + ); +} + +/// An empty message must round-trip unchanged. +#[tokio::test] +async fn empty_message_round_trip() { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + client.send(EioEvent::Message("".into())).await.unwrap(); + assert_eq!( + fixture::within("server side message", rx.recv()) + .await + .unwrap(), + Event::Message(sid, "".into()) + ); + assert_eq!( + fixture::within("echo", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Message("".into()) + ); +} + +/// A multibyte utf-8 message must round-trip unchanged on both transports. +#[tokio::test] +async fn utf8_message_round_trip() { + for transport in [TransportType::Polling, TransportType::Websocket] { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([transport]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + let text = "héllo 🌍 世界"; + client.send(EioEvent::Message(text.into())).await.unwrap(); + assert_eq!( + fixture::within("server side message", rx.recv()) + .await + .unwrap(), + Event::Message(sid, text.into()), + "failed over {transport:?}" + ); + assert_eq!( + fixture::within("echo", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Message(text.into()), + "failed over {transport:?}" + ); + } +} + +/// A large binary payload must round-trip unchanged over websocket. +#[tokio::test] +async fn large_binary_round_trip_ws() { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Websocket]) + .build(); + let mut client = Client::connect(svc, config).await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + assert_eq!( + client.next().await.unwrap().unwrap(), + EioEvent::Connect(sid) + ); + + // 8KiB crosses the server's default 4KiB websocket read buffer. + let data: Bytes = (0..8192u32) + .map(|i| (i % 251) as u8) + .collect::>() + .into(); + client.send(EioEvent::Binary(data.clone())).await.unwrap(); + assert_eq!( + fixture::within("server side binary", rx.recv()) + .await + .unwrap(), + Event::Binary(sid, data.clone()) + ); + assert_eq!( + fixture::within("echo", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Binary(data) + ); +} diff --git a/crates/engineioxide-client/tests/reconnect.rs b/crates/engineioxide-client/tests/reconnect.rs new file mode 100644 index 00000000..bbb5f647 --- /dev/null +++ b/crates/engineioxide-client/tests/reconnect.rs @@ -0,0 +1,134 @@ +//! Reconnection behavior tests. +//! +//! Reference behavior: **engine.io-client never reconnects on its own** — +//! reconnection (backoff, retries) belongs to the layer above (socket.io's +//! `Manager`). After any close the client goes fully silent: no request may +//! ever be issued again on that instance. Reconnecting means creating a new +//! client, which starts a brand new session (new handshake, new sid). + +use std::time::Duration; + +use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; +use engineioxide_core::{Packet, TransportType}; +use futures_util::{SinkExt, StreamExt}; + +use crate::fixture::{Event, service}; + +mod fixture; +mod mock; + +/// After a server-initiated close, the client must go silent: no polling +/// request, no websocket connect, ever. +#[tokio::test] +async fn no_auto_reconnect_after_server_close() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let (disconnect, _) = tokio::join!(mock::within("disconnect event", client.next()), async { + server.next_http().await.respond_packets([Packet::Close]) + },); + assert_eq!(disconnect.unwrap().unwrap(), EioEvent::Disconnect); + assert!(mock::within("end of stream", client.next()).await.is_none()); + + // Keep polling the terminated stream while watching for any request. + tokio::join!( + server.assert_no_call(Duration::from_millis(400), "the session is closed"), + async { + for _ in 0..3 { + assert!( + client.next().await.is_none(), + "a terminated stream stays terminated" + ); + } + }, + ); +} + +/// After a transport error, the client must go silent as well: retrying is +/// the responsibility of the caller. +#[tokio::test] +async fn no_auto_reconnect_after_transport_error() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let (err, _) = tokio::join!(mock::within("stream error", client.next()), async { + server.next_http().await.respond(500, "") + },); + assert!( + matches!(err, Some(Err(_))), + "expected a transport error, got: {err:?}" + ); + assert!( + mock::within("stream termination", client.next()) + .await + .is_none(), + "the stream must terminate after a transport error" + ); + + server + .assert_no_call(Duration::from_millis(400), "the session errored out") + .await; +} + +/// Reconnecting is done by creating a new client: it performs a fresh +/// handshake and gets a brand new session. +#[tokio::test] +async fn manual_reconnect_creates_a_new_session() { + let (svc, mut rx) = service(); + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + + let mut first = Client::connect(svc.clone(), config) + .await + .unwrap(); + let first_sid = first.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(first_sid)); + assert_eq!( + first.next().await.unwrap().unwrap(), + EioEvent::Connect(first_sid) + ); + fixture::within("close first client", first.close()) + .await + .expect("close must succeed"); + drop(first); + + // A new client establishes a brand new session on the same server. + let config = EngineIoClientConfig::builder() + .transports([TransportType::Polling]) + .build(); + let mut second = Client::connect(svc, config).await.unwrap(); + let second_sid = second.sid(); + assert_ne!(second_sid, first_sid, "a reconnection is a new session"); + assert_eq!( + second.next().await.unwrap().unwrap(), + EioEvent::Connect(second_sid) + ); + + // The new session is fully functional. + second + .send(EioEvent::Message("again".into())) + .await + .unwrap(); + assert_eq!( + fixture::within("echo on the new session", second.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Message("again".into()) + ); +} diff --git a/crates/engineioxide-client/tests/round_trip.rs b/crates/engineioxide-client/tests/round_trip.rs index 922c7a16..8edef046 100644 --- a/crates/engineioxide-client/tests/round_trip.rs +++ b/crates/engineioxide-client/tests/round_trip.rs @@ -21,7 +21,7 @@ async fn round_trip_polling() { let config = EngineIoClientConfig::builder() .transports([TransportType::Polling]) .build(); - let client = Client::connect_with_config(svc, config).await.unwrap(); + let client = Client::connect(svc, config).await.unwrap(); let sid = client.sid(); let (mut ctx, mut crx) = client.split::(); @@ -63,7 +63,7 @@ async fn round_trip_ws() { .transports([TransportType::Websocket]) .build(); - let client = Client::connect_with_config(svc, config).await.unwrap(); + let client = Client::connect(svc, config).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); let (mut ctx, mut crx) = client.split::(); diff --git a/crates/engineioxide-client/tests/upgrade.rs b/crates/engineioxide-client/tests/upgrade.rs index eac54901..3d041fdf 100644 --- a/crates/engineioxide-client/tests/upgrade.rs +++ b/crates/engineioxide-client/tests/upgrade.rs @@ -1,16 +1,32 @@ +//! Transport upgrade tests. +//! +//! Reference behavior (engine.io-client, protocol v4): +//! * After the handshake, if the server offers `websocket` in `upgrades` and +//! the client is configured for it, the client probes: it connects a +//! websocket with `EIO=4&transport=websocket&sid=...`, sends `2probe`, +//! expects `3probe` back, pauses polling, then confirms with `5`. +//! * Polling stays active while probing: packets keep flowing until the +//! probe succeeds, and nothing may be lost across the switch. +//! * **A failed probe never kills the session**: the client emits an +//! `upgradeError` and keeps running on polling. There is no retry. +//! * No probe is attempted when the server offers no upgrade or when the +//! client is not configured for websocket. + use bytes::Bytes; -use engineioxide::TransportType; use engineioxide_client::{Client, EioEvent}; +use engineioxide_core::{Packet, TransportType}; use futures_util::{SinkExt, StreamExt}; +use http::Method; -use crate::fixture::{Event, service}; +use crate::fixture::{Event, service, service_with_registry}; mod fixture; +mod mock; #[tokio::test] async fn upgrade() { let (svc, mut rx) = service(); - let mut client = Client::connect(svc).await.unwrap(); + let mut client = Client::connect(svc, "localhost/engine.io").await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); assert_eq!( @@ -53,3 +69,451 @@ async fn upgrade() { other => panic!("expected echoed binary, got {other:?}"), } } + +/// Wire-level probe sequence: ws connect with the session `sid`, `2probe` +/// out, `3probe` in, `5` out. After the upgrade all traffic flows over the +/// websocket. +#[tokio::test] +async fn upgrade_probe_wire_sequence() { + let open = mock::open_packet(); + let sid = open.sid.to_string(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let script = async { + let call = server.next_ws_parking_http().await; + assert_eq!(call.query("EIO"), Some("4")); + assert_eq!(call.query("transport"), Some("websocket")); + assert_eq!( + call.query("sid").map(str::to_owned), + Some(sid), + "the probe must join the existing session" + ); + let mut ws = call.accept(); + assert_eq!( + ws.recv_packet().await, + Packet::PingUpgrade, + "probe ping expected" + ); + ws.send_packet(Packet::PongUpgrade); + assert_eq!( + ws.recv_packet().await, + Packet::Upgrade, + "upgrade confirmation expected" + ); + ws + }; + let (event, mut ws) = tokio::join!(mock::within("upgrade event", client.next()), script); + assert_eq!( + event.unwrap().unwrap(), + EioEvent::Upgrade(TransportType::Websocket) + ); + assert_eq!(client.transport(), TransportType::Websocket); + + // Traffic now flows over the websocket, in both directions. + mock::within( + "send over ws", + client.send(EioEvent::Message("hello".into())), + ) + .await + .unwrap(); + assert_eq!(ws.recv_packet().await, Packet::Message("hello".into())); + ws.send_packet(Packet::Message("world".into())); + assert_eq!( + mock::within("ws message", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Message("world".into()) + ); +} + +/// Polling must remain active while the probe is in flight: a message +/// arriving on the held poll during the probe must be delivered. +#[tokio::test] +async fn polling_stays_active_during_probe() { + let open = mock::open_packet(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let script = async { + // Wait for the probe connect, keeping track of the in-flight poll. + let mut held_poll = None; + let ws = loop { + match server.next_call().await { + mock::ServerCall::Ws(c) => break c, + mock::ServerCall::Http(c) => held_poll = Some(c), + } + }; + let mut ws = ws.accept(); + assert_eq!(ws.recv_packet().await, Packet::PingUpgrade); + + // Mid-probe, the server delivers a message on the polling transport: + // the reference client processes it (polling is only paused *after* + // the probe succeeds). + let poll = match held_poll { + Some(c) => c, + None => server.next_http().await, + }; + poll.respond_packets([Packet::Message("during-probe".into())]); + ws + }; + + // The client must yield the polled message even though it is probing. + let (event, ws) = tokio::join!( + mock::within("message delivered during probe", client.next()), + script, + ); + assert_eq!( + event.unwrap().unwrap(), + EioEvent::Message("during-probe".into()) + ); + + // And the upgrade must still complete afterwards. + let mut ws = ws; + let finish = async { + ws.send_packet(Packet::PongUpgrade); + // A pausing client waits for its in-flight poll to complete: the + // server releases it with a noop (reference server behavior). + loop { + tokio::select! { + p = ws.recv_packet() => { + assert_eq!(p, Packet::Upgrade); + break; + } + call = server.next_call() => match call { + mock::ServerCall::Http(c) => c.respond_packets([Packet::Noop]), + mock::ServerCall::Ws(c) => { + panic!("unexpected second ws connect: {:?}", c.req) + } + }, + } + } + }; + let (event, _) = tokio::join!( + mock::within("upgrade event", async { + match client.next().await.unwrap().unwrap() { + EioEvent::Upgrade(t) => t, + ev => panic!("unexpected event while finishing the probe: {ev:?}"), + } + }), + finish, + ); + assert_eq!(event, TransportType::Websocket); +} + +/// A probe that cannot even connect must not kill the session: the client +/// keeps running on polling (official behavior: `upgradeError` event, no +/// close, no retry). +#[tokio::test] +async fn failed_ws_connect_falls_back_to_polling() { + let open = mock::open_packet(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let script = async { + let mut held_poll = None; + let ws = loop { + match server.next_call().await { + mock::ServerCall::Ws(c) => break c, + mock::ServerCall::Http(c) => held_poll = Some(c), + } + }; + ws.reject("connection refused"); + // The session must keep working over polling. + let poll = match held_poll { + Some(c) => c, + None => server.next_http().await, + }; + poll.respond_packets([Packet::Message("still-alive".into())]); + }; + + let (event, _) = tokio::join!( + mock::within("message after failed probe", async { + match client.next().await { + Some(Ok(EioEvent::Message(msg))) => msg, + Some(Ok(ev)) => panic!("unexpected event after failed probe: {ev:?}"), + Some(Err(e)) => panic!("a failed probe must not surface an error: {e}"), + None => panic!("a failed probe must not close the session"), + } + }), + script, + ); + assert_eq!(event, "still-alive"); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// A probe answered with the wrong packet (a plain pong instead of +/// `3probe`) must abort the upgrade and keep the session on polling. +#[tokio::test] +async fn wrong_probe_reply_falls_back_to_polling() { + let open = mock::open_packet(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let script = async { + let mut held_poll = None; + let ws = loop { + match server.next_call().await { + mock::ServerCall::Ws(c) => break c, + mock::ServerCall::Http(c) => held_poll = Some(c), + } + }; + let mut ws = ws.accept(); + assert_eq!(ws.recv_packet().await, Packet::PingUpgrade); + ws.send_packet(Packet::Pong); // wrong reply: plain pong, not "3probe" + let poll = match held_poll { + Some(c) => c, + None => server.next_http().await, + }; + poll.respond_packets([Packet::Message("still-alive".into())]); + ws + }; + + let (msg, _ws) = tokio::join!( + mock::within("message after failed probe", async { + match client.next().await { + Some(Ok(EioEvent::Message(msg))) => msg, + Some(Ok(ev)) => panic!("unexpected event after failed probe: {ev:?}"), + Some(Err(e)) => panic!("a failed probe must not surface an error: {e}"), + None => panic!("a failed probe must not close the session"), + } + }), + script, + ); + assert_eq!(msg, "still-alive"); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// A websocket closed mid-probe must abort the upgrade and keep the session +/// on polling. +#[tokio::test] +async fn ws_closed_during_probe_falls_back_to_polling() { + let open = mock::open_packet(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let script = async { + let mut held_poll = None; + let ws = loop { + match server.next_call().await { + mock::ServerCall::Ws(c) => break c, + mock::ServerCall::Http(c) => held_poll = Some(c), + } + }; + let mut ws = ws.accept(); + assert_eq!(ws.recv_packet().await, Packet::PingUpgrade); + ws.close(); // the probe transport dies before answering + let poll = match held_poll { + Some(c) => c, + None => server.next_http().await, + }; + poll.respond_packets([Packet::Message("still-alive".into())]); + }; + + let (msg, _) = tokio::join!( + mock::within("message after failed probe", async { + match client.next().await { + Some(Ok(EioEvent::Message(msg))) => msg, + Some(Ok(ev)) => panic!("unexpected event after failed probe: {ev:?}"), + Some(Err(e)) => panic!("a failed probe must not surface an error: {e}"), + None => panic!("a failed probe must not close the session"), + } + }), + script, + ); + assert_eq!(msg, "still-alive"); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// No probe may be attempted when the server offers no upgrade. +#[tokio::test] +async fn no_probe_when_server_offers_no_upgrade() { + let open = mock::open_packet_no_upgrade(); + let (mut client, mut server) = + mock::connect_polling(&open, [TransportType::Polling, TransportType::Websocket]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let script = async { + let poll = match server.next_call().await { + mock::ServerCall::Http(c) => c, + mock::ServerCall::Ws(c) => { + panic!( + "no ws connect expected without server upgrades: {:?}", + c.req + ) + } + }; + assert_eq!(poll.method, Method::GET); + server + .assert_no_call( + std::time::Duration::from_millis(300), + "no upgrade is offered", + ) + .await; + poll.respond_packets([Packet::Message("plain-polling".into())]); + }; + let (event, _) = tokio::join!(mock::within("polled message", client.next()), script); + assert_eq!( + event.unwrap().unwrap(), + EioEvent::Message("plain-polling".into()) + ); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// No probe may be attempted when the client is configured for polling only, +/// even if the server offers the websocket upgrade. +#[tokio::test] +async fn no_probe_when_client_is_polling_only() { + let open = mock::open_packet(); // server offers websocket + let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; + assert_eq!( + mock::within("connect event", client.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Connect(open.sid) + ); + + let script = async { + let poll = match server.next_call().await { + mock::ServerCall::Http(c) => c, + mock::ServerCall::Ws(c) => { + panic!( + "no ws connect expected for a polling-only client: {:?}", + c.req + ) + } + }; + server + .assert_no_call( + std::time::Duration::from_millis(300), + "client is polling-only", + ) + .await; + poll.respond_packets([Packet::Message("plain-polling".into())]); + }; + let (event, _) = tokio::join!(mock::within("polled message", client.next()), script); + assert_eq!( + event.unwrap().unwrap(), + EioEvent::Message("plain-polling".into()) + ); + assert_eq!(client.transport(), TransportType::Polling); +} + +/// A message sent while the upgrade is in progress must be buffered and +/// delivered once the new transport is live (the reference client inhibits +/// `flush()` while `upgrading` and flushes right after the upgrade). +#[tokio::test] +async fn send_during_upgrade_is_delivered_after_upgrade() { + let (svc, mut rx) = service(); + let client = Client::connect(svc, "localhost/engine.io").await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + let (mut ctx, mut crx) = client.split::(); + assert_eq!(crx.next().await.unwrap().unwrap(), EioEvent::Connect(sid)); + + // The send blocks until the upgrade completes, so it must be driven + // concurrently with the stream. + tokio::join!( + async { + fixture::within( + "send during upgrade", + ctx.send(EioEvent::Message("buffered".into())), + ) + .await + .unwrap(); + }, + async { + assert_eq!( + fixture::within("upgrade event", crx.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Upgrade(TransportType::Websocket) + ); + assert_eq!( + fixture::within("echo after upgrade", crx.next()) + .await + .unwrap() + .unwrap(), + EioEvent::Message("buffered".into()), + "the buffered message must be delivered once upgraded" + ); + }, + ); + + assert_eq!( + fixture::within("server side message", rx.recv()) + .await + .unwrap(), + Event::Message(sid, "buffered".into()) + ); +} + +/// A message emitted by the server right at connection time must not be lost +/// even though the client immediately upgrades to websocket. +#[tokio::test] +async fn no_message_loss_across_upgrade() { + let (svc, mut rx, registry) = service_with_registry(Default::default()); + let mut client = Client::connect(svc, "localhost/engine.io").await.unwrap(); + let sid = client.sid(); + assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); + + // Emit while the client is (most likely) still on polling. + registry.lock().unwrap()[&sid].emit("early").unwrap(); + + let received = fixture::within("early message", async { + loop { + match client.next().await { + Some(Ok(EioEvent::Message(msg))) => break msg, + Some(Ok(_)) => continue, // Connect / Upgrade events + Some(Err(e)) => panic!("client stream error: {e}"), + None => panic!("stream ended before the early message"), + } + } + }) + .await; + assert_eq!(received, "early"); +} From b85c4800243f90d8a0eccf4d8b1ee1ebf83a1be7 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 19 Jul 2026 00:27:12 +0200 Subject: [PATCH 38/41] test: test suite --- crates/engineioxide-client/src/flavors/testing.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engineioxide-client/src/flavors/testing.rs b/crates/engineioxide-client/src/flavors/testing.rs index 259d330f..819f3bbf 100644 --- a/crates/engineioxide-client/src/flavors/testing.rs +++ b/crates/engineioxide-client/src/flavors/testing.rs @@ -168,7 +168,7 @@ impl AsyncWrite for StreamImpl { self.project() .tx .send(Ok(Bytes::copy_from_slice(buf))) - .unwrap(); + .map_err(|_| io::Error::other("channel closed"))?; Poll::Ready(Ok(len)) } From ded8161aeda4b30ba4585d3b7b8a65aee4608ba6 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 19 Jul 2026 00:35:47 +0200 Subject: [PATCH 39/41] test: test suite --- crates/engineioxide-client/src/client.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index 68e31b52..e7cc88d4 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -2,11 +2,12 @@ use std::{ fmt, pin::Pin, task::{Context, Poll, Waker, ready}, + time::Instant, }; use engineioxide_core::{OpenPacket, Packet, Sid, TransportType}; use futures_core::Stream; -use futures_util::Sink; +use futures_util::{Sink, SinkExt}; use http::uri; use thiserror::Error; use tracing::Level; @@ -27,6 +28,7 @@ pin_project_lite::pin_project! { config: EngineIoClientConfig, open_packet: OpenPacket, + last_ping: Instant, state: ClientState, pending_pong: bool, } @@ -85,6 +87,7 @@ impl Client { open_packet, config, sink_waker: None, + last_ping: Instant::now(), state: ClientState::Open, pending_pong: false, }; @@ -167,6 +170,7 @@ impl Client { match ready!(proj.transport.poll_next(cx)) { Some(Ok(Packet::Ping)) => { *proj.pending_pong = true; + *proj.last_ping = Instant::now(); cx.waker().wake_by_ref(); Poll::Pending } @@ -217,6 +221,14 @@ impl Client { self: Pin<&mut Self>, cx: &mut Context<'_>, ) -> Poll>> { + if self.last_ping.elapsed() + <= self.open_packet.ping_interval + self.open_packet.ping_timeout + { + todo!("error + closing + better wake"); + // self.close(); + // Err() + } + let mut proj = self.project(); if *proj.pending_pong { ready!(proj.transport.as_mut().poll_ready(cx))?; From 7c83cdbc80f8235e383726e0ed3be7e7a656af75 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 19 Jul 2026 11:36:23 +0200 Subject: [PATCH 40/41] config: better config dx --- crates/engineioxide-client/src/client.rs | 2 +- crates/engineioxide-client/src/config.rs | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs index e7cc88d4..243d4d24 100644 --- a/crates/engineioxide-client/src/client.rs +++ b/crates/engineioxide-client/src/client.rs @@ -7,7 +7,7 @@ use std::{ use engineioxide_core::{OpenPacket, Packet, Sid, TransportType}; use futures_core::Stream; -use futures_util::{Sink, SinkExt}; +use futures_util::Sink; use http::uri; use thiserror::Error; use tracing::Level; diff --git a/crates/engineioxide-client/src/config.rs b/crates/engineioxide-client/src/config.rs index 67c4ca44..2680b62d 100644 --- a/crates/engineioxide-client/src/config.rs +++ b/crates/engineioxide-client/src/config.rs @@ -50,10 +50,7 @@ impl IntoEngineIoClientConfig for EngineIoClientConfig { } impl IntoEngineIoClientConfig for &str { fn into_config(self) -> Result { - Ok(EngineIoClientConfig { - uri: self.parse()?, - ..Default::default() - }) + EngineIoClientConfigBuilder::new().uri(self).build() } } impl IntoEngineIoClientConfig for Result { @@ -61,6 +58,11 @@ impl IntoEngineIoClientConfig for Result self } } +impl IntoEngineIoClientConfig for [TransportType; N] { + fn into_config(self) -> Result { + EngineIoClientConfigBuilder::new().transports(self).build() + } +} impl FromStr for EngineIoClientConfig { type Err = uri::InvalidUri; From 39028c32e4bf5bc7d20b080465b07cec644676b9 Mon Sep 17 00:00:00 2001 From: tprevot Date: Sun, 19 Jul 2026 11:36:38 +0200 Subject: [PATCH 41/41] test: closing --- crates/engineioxide-client/tests/close.rs | 347 ++++++-------------- crates/engineioxide-client/tests/mock.rs | 113 +++++-- crates/engineioxide-client/tests/payload.rs | 8 +- 3 files changed, 182 insertions(+), 286 deletions(-) diff --git a/crates/engineioxide-client/tests/close.rs b/crates/engineioxide-client/tests/close.rs index 6f337f6e..bf228ed3 100644 --- a/crates/engineioxide-client/tests/close.rs +++ b/crates/engineioxide-client/tests/close.rs @@ -16,11 +16,14 @@ use std::time::Duration; use engineioxide::{DisconnectReason, TransportType}; -use engineioxide_client::{Client, EioEvent, EngineIoClientConfig}; +use engineioxide_client::{Client, EioEvent}; use engineioxide_core::Packet; use futures_util::{SinkExt, StreamExt}; -use crate::fixture::{Event, service, service_with_registry}; +use crate::{ + fixture::{Event, service, service_with_registry}, + mock::{ClientTestExt, FutureTestExt}, +}; mod fixture; mod mock; @@ -31,25 +34,13 @@ mod mock; async fn server_close_packet_polling() { let open = mock::open_packet_no_upgrade(); let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; - assert_eq!( - mock::within("connect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Connect(open.sid) - ); + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); - let (disconnect, _) = tokio::join!(mock::within("disconnect event", client.next()), async { + let (disconnect, _) = tokio::join!(client.next_ok(), async { server.next_http().await.respond_packets([Packet::Close]) - },); - assert_eq!(disconnect.unwrap().unwrap(), EioEvent::Disconnect); - assert_eq!( - mock::within("end of stream", client.next()) - .await - .map(|r| r.ok()), - None, - "the stream must terminate after the server closed the session" - ); + }); + assert_eq!(disconnect, EioEvent::Disconnect); + client.next_close().await; } /// A `1` (close) packet received on the websocket ends the session the same @@ -58,93 +49,46 @@ async fn server_close_packet_polling() { async fn server_close_packet_ws() { let open = mock::open_packet_no_upgrade(); let (mut client, _server, ws) = mock::connect_ws(&open).await; - assert_eq!( - mock::within("connect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Connect(open.sid) - ); + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); ws.send_packet(Packet::Close); - assert_eq!( - mock::within("disconnect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Disconnect - ); - assert_eq!( - mock::within("end of stream", client.next()) - .await - .map(|r| r.ok()), - None, - "the stream must terminate after the server closed the session" - ); + assert_eq!(client.next_ok().await, EioEvent::Disconnect); + client.next_close().await; } /// Server-initiated close against the real server, polling transport. #[tokio::test] async fn server_close_real_server_polling() { let (svc, mut rx, registry) = service_with_registry(Default::default()); - let config = EngineIoClientConfig::builder() - .transports([TransportType::Polling]) - .build(); - let mut client = Client::connect(svc, config).await.unwrap(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .timeout() + .await + .unwrap(); let sid = client.sid(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - assert_eq!( - client.next().await.unwrap().unwrap(), - EioEvent::Connect(sid) - ); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); registry.lock().unwrap()[&sid].close(DisconnectReason::TransportClose); - assert_eq!( - fixture::within("disconnect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Disconnect - ); - assert!( - fixture::within("end of stream", client.next()) - .await - .is_none(), - "the stream must terminate after the server closed the session" - ); + assert_eq!(client.next_ok().await, EioEvent::Disconnect); + client.next_close().await; } /// Server-initiated close against the real server, websocket transport. #[tokio::test] async fn server_close_real_server_ws() { let (svc, mut rx, registry) = service_with_registry(Default::default()); - let config = EngineIoClientConfig::builder() - .transports([TransportType::Websocket]) - .build(); - let mut client = Client::connect(svc, config).await.unwrap(); + let mut client = Client::connect(svc, [TransportType::Websocket]) + .await + .unwrap(); let sid = client.sid(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - assert_eq!( - client.next().await.unwrap().unwrap(), - EioEvent::Connect(sid) - ); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); registry.lock().unwrap()[&sid].close(DisconnectReason::TransportClose); - assert_eq!( - fixture::within("disconnect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Disconnect - ); - assert!( - fixture::within("end of stream", client.next()) - .await - .is_none(), - "the stream must terminate after the server closed the session" - ); + assert_eq!(client.next_ok().await, EioEvent::Disconnect); + client.next_close().await; } /// Closing a polling client must send a `1` (close) packet so the server @@ -154,20 +98,10 @@ async fn server_close_real_server_ws() { async fn client_close_polling_sends_close_packet() { let open = mock::open_packet_no_upgrade(); let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; - assert_eq!( - mock::within("connect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Connect(open.sid) - ); + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); tokio::join!( - async { - mock::within("client close", client.close()) - .await - .expect("close must succeed"); - }, + async { client.close().timeout().await.expect("close must succeed") }, async { let post = server.next_post_parking_get().await; assert_eq!( @@ -186,21 +120,13 @@ async fn client_close_polling_sends_close_packet() { async fn client_close_ws_closes_the_connection() { let open = mock::open_packet_no_upgrade(); let (mut client, _server, mut ws) = mock::connect_ws(&open).await; - assert_eq!( - mock::within("connect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Connect(open.sid) - ); + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); - mock::within("client close", client.close()) - .await - .expect("close must succeed"); + client.close().timeout().await.unwrap(); // The server observes the websocket closing, with no engine.io close // packet beforehand. - match mock::within("ws close", ws.recv()).await { + match ws.recv().timeout().await { Some(engineioxide_client::transport::ws::WsMessage::Close) | None => (), Some(engineioxide_client::transport::ws::WsMessage::Text(t)) => { assert_ne!(&*t, "1", "no close packet is sent over websocket"); @@ -219,25 +145,17 @@ async fn client_close_ws_closes_the_connection() { #[tokio::test] async fn client_close_notifies_server_polling() { let (svc, mut rx) = service(); - let config = EngineIoClientConfig::builder() - .transports([TransportType::Polling]) - .build(); - let mut client = Client::connect(svc, config).await.unwrap(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); let sid = client.sid(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - assert_eq!( - client.next().await.unwrap().unwrap(), - EioEvent::Connect(sid) - ); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); - fixture::within("client close", client.close()) - .await - .expect("close must succeed"); + client.close().timeout().await.unwrap(); assert_eq!( - fixture::within("server disconnect event", rx.recv()) - .await - .unwrap(), + rx.next_ok().await, Event::Disconnect(sid, DisconnectReason::TransportClose), "the server must observe a graceful close" ); @@ -248,25 +166,17 @@ async fn client_close_notifies_server_polling() { #[tokio::test] async fn client_close_notifies_server_ws() { let (svc, mut rx) = service(); - let config = EngineIoClientConfig::builder() - .transports([TransportType::Websocket]) - .build(); - let mut client = Client::connect(svc, config).await.unwrap(); + let mut client = Client::connect(svc, [TransportType::Websocket]) + .await + .unwrap(); let sid = client.sid(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - assert_eq!( - client.next().await.unwrap().unwrap(), - EioEvent::Connect(sid) - ); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); - fixture::within("client close", client.close()) - .await - .expect("close must succeed"); + client.close().timeout().await.unwrap(); assert_eq!( - fixture::within("server disconnect event", rx.recv()) - .await - .unwrap(), + rx.next_ok().await, Event::Disconnect(sid, DisconnectReason::TransportClose), "the server must observe a graceful close" ); @@ -278,83 +188,49 @@ async fn client_close_notifies_server_ws() { #[tokio::test] async fn close_flushes_buffered_packets_polling() { let (svc, mut rx) = service(); - let config = EngineIoClientConfig::builder() - .transports([TransportType::Polling]) - .build(); - let mut client = Client::connect(svc, config).await.unwrap(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); let sid = client.sid(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - assert_eq!( - client.next().await.unwrap().unwrap(), - EioEvent::Connect(sid) - ); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); // feed() queues without flushing; close() must flush then close. client.feed(EioEvent::Message("one".into())).await.unwrap(); client.feed(EioEvent::Message("two".into())).await.unwrap(); - fixture::within("client close", client.close()) - .await - .expect("close must succeed"); + client.close().timeout().await.expect("close must succeed"); + assert_eq!(rx.next_ok().await, Event::Message(sid, "one".into())); + assert_eq!(rx.next_ok().await, Event::Message(sid, "two".into())); assert_eq!( - fixture::within("first buffered message", rx.recv()) - .await - .unwrap(), - Event::Message(sid, "one".into()) - ); - assert_eq!( - fixture::within("second buffered message", rx.recv()) - .await - .unwrap(), - Event::Message(sid, "two".into()) - ); - assert_eq!( - fixture::within("server disconnect event", rx.recv()) - .await - .unwrap(), + rx.next_ok().await, Event::Disconnect(sid, DisconnectReason::TransportClose), ); + //TODO: assert client / server closing } /// Same flush-before-close requirement over websocket. #[tokio::test] async fn close_flushes_buffered_packets_ws() { let (svc, mut rx) = service(); - let config = EngineIoClientConfig::builder() - .transports([TransportType::Websocket]) - .build(); - let mut client = Client::connect(svc, config).await.unwrap(); + let mut client = Client::connect(svc, [TransportType::Websocket]) + .await + .unwrap(); let sid = client.sid(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - assert_eq!( - client.next().await.unwrap().unwrap(), - EioEvent::Connect(sid) - ); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); client.feed(EioEvent::Message("one".into())).await.unwrap(); client.feed(EioEvent::Message("two".into())).await.unwrap(); - fixture::within("client close", client.close()) - .await - .expect("close must succeed"); + client.close().timeout().await.unwrap(); + assert_eq!(rx.next_ok().await, Event::Message(sid, "one".into())); + assert_eq!(rx.next_ok().await, Event::Message(sid, "two".into())); assert_eq!( - fixture::within("first buffered message", rx.recv()) - .await - .unwrap(), - Event::Message(sid, "one".into()) - ); - assert_eq!( - fixture::within("second buffered message", rx.recv()) - .await - .unwrap(), - Event::Message(sid, "two".into()) - ); - assert_eq!( - fixture::within("server disconnect event", rx.recv()) - .await - .unwrap(), + rx.next_ok().await, Event::Disconnect(sid, DisconnectReason::TransportClose), ); + //TODO: assert client / server closing } /// Packets submitted after a local close must never reach the server (the @@ -363,28 +239,22 @@ async fn close_flushes_buffered_packets_ws() { #[tokio::test] async fn send_after_close_is_not_delivered() { let (svc, mut rx) = service(); - let config = EngineIoClientConfig::builder() - .transports([TransportType::Polling]) - .build(); - let mut client = Client::connect(svc, config).await.unwrap(); + let mut client = Client::connect(svc, [TransportType::Polling]) + .await + .unwrap(); let sid = client.sid(); - assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); - assert_eq!( - client.next().await.unwrap().unwrap(), - EioEvent::Connect(sid) - ); + assert_eq!(rx.next_ok().await, Event::Connect(sid)); + assert_eq!(client.next_ok().await, EioEvent::Connect(sid)); - fixture::within("client close", client.close()) - .await - .expect("close must succeed"); + client.close().timeout().await.unwrap(); // Whether this returns Ok (discarded) or Err (sink closed) is API // flavor; what matters is that nothing is delivered and nothing panics. - let _ = fixture::within( - "send after close", - client.send(EioEvent::Message("late".into())), - ) - .await; + //TODO: uniform flavor + let _ = client + .send(EioEvent::Message("late".into())) + .timeout() + .await; if let Ok(Some(Event::Message(_, msg))) = tokio::time::timeout(Duration::from_millis(300), rx.recv()).await @@ -399,28 +269,18 @@ async fn send_after_close_is_not_delivered() { async fn send_after_server_close_is_an_error() { let open = mock::open_packet_no_upgrade(); let (mut client, mut server) = mock::connect_polling(&open, [TransportType::Polling]).await; - assert_eq!( - mock::within("connect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Connect(open.sid) - ); + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); - let (disconnect, _) = tokio::join!(mock::within("disconnect event", client.next()), async { + let (disconnect, _) = tokio::join!(client.next_ok(), async { server.next_http().await.respond_packets([Packet::Close]) },); - assert_eq!(disconnect.unwrap().unwrap(), EioEvent::Disconnect); - assert!( - mock::within("end of stream", client.next()).await.is_none(), - "the stream must terminate after the server closed the session" - ); + assert_eq!(disconnect, EioEvent::Disconnect); + client.next_close().await; - let res = mock::within( - "send after server close", - client.send(EioEvent::Message("late".into())), - ) - .await; + let res = client + .send(EioEvent::Message("late".into())) + .timeout() + .await; assert!( res.is_err(), "sending on a closed session must surface an error" @@ -434,17 +294,11 @@ async fn send_after_server_close_is_an_error() { async fn abrupt_ws_termination_ends_the_stream() { let open = mock::open_packet_no_upgrade(); let (mut client, _server, ws) = mock::connect_ws(&open).await; - assert_eq!( - mock::within("connect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Connect(open.sid) - ); + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); ws.close(); - mock::within("stream termination", async { + async { loop { match client.next().await { // Surfacing the disconnection as an event first is fine. @@ -454,7 +308,8 @@ async fn abrupt_ws_termination_ends_the_stream() { None => break, } } - }) + } + .timeout() .await; } @@ -465,28 +320,14 @@ async fn abrupt_ws_termination_ends_the_stream() { async fn ws_error_surfaces_then_stream_terminates() { let open = mock::open_packet_no_upgrade(); let (mut client, _server, ws) = mock::connect_ws(&open).await; - assert_eq!( - mock::within("connect event", client.next()) - .await - .unwrap() - .unwrap(), - EioEvent::Connect(open.sid) - ); + assert_eq!(client.next_ok().await, EioEvent::Connect(open.sid)); ws.send_error("connection reset by peer"); - let err = mock::within("stream error", client.next()).await; - assert!( - matches!(err, Some(Err(_))), - "the websocket error must surface: {err:?}" - ); + dbg!(client.next_err().await); //TODO: assert error eq + // Keep `ws` alive: termination must come from the client closing itself // after the error, not from the mock dropping the connection. - assert!( - mock::within("stream termination after error", client.next()) - .await - .is_none(), - "the stream must terminate after a transport error" - ); + client.next_close().await; drop(ws); } diff --git a/crates/engineioxide-client/tests/mock.rs b/crates/engineioxide-client/tests/mock.rs index 67f91a5c..342d2956 100644 --- a/crates/engineioxide-client/tests/mock.rs +++ b/crates/engineioxide-client/tests/mock.rs @@ -13,6 +13,7 @@ use std::{ convert::Infallible, fmt, + marker::PhantomData, pin::Pin, task::{Context, Poll}, time::Duration, @@ -25,7 +26,7 @@ use engineioxide_client::{ }; use engineioxide_core::{OpenPacket, Packet, ProtocolVersion, Sid, TransportType}; use futures_core::{Stream, future::BoxFuture}; -use futures_util::{FutureExt, Sink}; +use futures_util::{FutureExt, Sink, StreamExt}; use http::{Method, Request, Response, Uri}; use http_body_util::{BodyExt, Full, combinators::BoxBody}; use hyper::service::Service as HyperSvc; @@ -35,11 +36,76 @@ use tokio::sync::{mpsc, oneshot}; /// fail fast instead of hanging the whole suite. pub const DEADLINE: Duration = Duration::from_secs(5); -/// Await `fut` with the global [`DEADLINE`], panicking with `what` on expiry. -pub async fn within(what: &str, fut: F) -> F::Output { - match tokio::time::timeout(DEADLINE, fut).await { - Ok(v) => v, - Err(_) => panic!("timed out after {DEADLINE:?}: {what}"), +pub trait FutureTestExt: Future { + fn timeout(self) -> impl Future; + fn timeout_with(self, duration: Duration) -> impl Future; +} +impl FutureTestExt for F { + async fn timeout(self) -> Self::Output { + self.timeout_with(DEADLINE).await + } + + async fn timeout_with(self, duration: Duration) -> Self::Output { + match tokio::time::timeout(duration, self).await { + Ok(v) => v, + Err(_) => panic!("timed out after {duration:?}"), + } + } +} +mod via { + pub enum Stream {} + pub enum Concrete {} +} +pub trait ClientTestExt { + fn next_ok(&mut self) -> impl Future; + fn next_err(&mut self) -> impl Future; + fn next_close(&mut self) -> impl Future; + + fn phantom() -> PhantomData { + PhantomData + } +} +impl> + Unpin, T: fmt::Debug, E: fmt::Debug> + ClientTestExt for S +{ + async fn next_ok(&mut self) -> T { + self.next() + .timeout() + .await + .expect("called next_ok on a closed stream") + .expect("stream yield an error") + } + + async fn next_err(&mut self) -> E { + self.next() + .timeout() + .await + .expect("called next_err on a closed stream") + .expect_err("stream yielded a value, we're expecting an error") + } + + async fn next_close(&mut self) { + if let Some(i) = self.next().timeout().await { + panic!("stream yielded an item, we're expecting to be closed: {i:?}") + } + } +} +impl ClientTestExt for mpsc::UnboundedReceiver { + async fn next_ok(&mut self) -> T { + self.recv() + .timeout() + .await + .expect("call recv on a close chan") + } + + async fn next_err(&mut self) -> Infallible { + const { panic!("called next_err on an infallible receiver") } + } + + async fn next_close(&mut self) { + if let Some(i) = self.recv().timeout().await { + panic!("chan yielded an item, we're expecting to be closed: {i:?}") + } } } @@ -316,7 +382,7 @@ impl ServerWs { /// Next raw message sent by the client, `None` once the client closed. pub async fn recv(&mut self) -> Option { - within("waiting for a client websocket message", self.rx.recv()).await + self.rx.recv().timeout().await } /// Next client message decoded as a packet (binary frames are messages). @@ -342,7 +408,9 @@ pub struct MockServer { impl MockServer { pub async fn next_call(&mut self) -> ServerCall { - within("waiting for a client request", self.rx.recv()) + self.rx + .recv() + .timeout() .await .expect("client service dropped, no more requests will come") } @@ -428,17 +496,11 @@ pub async fn connect_polling( transports: [TransportType; N], ) -> (Client, MockServer) { let (svc, mut server) = mock(); - let config = EngineIoClientConfig::builder() - .transports(transports) - .build(); - let (client, _) = tokio::join!( - within("polling handshake", Client::connect(svc, config)), - async { - let call = server.next_http().await; - assert_eq!(call.method, Method::GET, "handshake must be a GET"); - call.respond_open(open); - } - ); + let (client, _) = tokio::join!(Client::connect(svc, transports).timeout(), async { + let call = server.next_http().await; + assert_eq!(call.method, Method::GET, "handshake must be a GET"); + call.respond_open(open); + }); (client.expect("handshake should succeed"), server) } @@ -448,14 +510,11 @@ pub async fn connect_ws(open: &OpenPacket) -> (Client, MockServer, Serv let config = EngineIoClientConfig::builder() .transports([TransportType::Websocket]) .build(); - let (client, ws) = tokio::join!( - within("websocket handshake", Client::connect(svc, config)), - async { - let ws = server.next_ws().await.accept(); - ws.send_packet(Packet::Open(open.clone())); - ws - } - ); + let (client, ws) = tokio::join!(Client::connect(svc, config).timeout(), async { + let ws = server.next_ws().await.accept(); + ws.send_packet(Packet::Open(open.clone())); + ws + }); (client.expect("handshake should succeed"), server, ws) } diff --git a/crates/engineioxide-client/tests/payload.rs b/crates/engineioxide-client/tests/payload.rs index c6be0d68..d587ec59 100644 --- a/crates/engineioxide-client/tests/payload.rs +++ b/crates/engineioxide-client/tests/payload.rs @@ -142,9 +142,7 @@ async fn flush_splits_batches_at_max_payload() { let client_config = EngineIoClientConfig::builder() .transports([TransportType::Polling]) .build(); - let mut client = Client::connect(svc, client_config) - .await - .unwrap(); + let mut client = Client::connect(svc, client_config).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); assert_eq!( @@ -182,9 +180,7 @@ async fn oversized_packet_surfaces_a_transport_error() { let client_config = EngineIoClientConfig::builder() .transports([TransportType::Polling]) .build(); - let mut client = Client::connect(svc, client_config) - .await - .unwrap(); + let mut client = Client::connect(svc, client_config).await.unwrap(); let sid = client.sid(); assert_eq!(rx.recv().await.unwrap(), Event::Connect(sid)); assert_eq!(