From b293bd11ffa2947e157f44605239764a7b1f33ac Mon Sep 17 00:00:00 2001 From: totodore Date: Tue, 10 Jun 2025 00:30:25 +0200 Subject: [PATCH 01/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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 0ed449cc255fdac6717cd4a124cf49d16f802897 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 20 Jun 2026 21:04:50 +0200 Subject: [PATCH 11/49] feat: implement support for volatile events with new VolatileSocket wrapper and operator flags --- crates/socketioxide-core/src/adapter/mod.rs | 13 ++- crates/socketioxide/src/io.rs | 10 ++ crates/socketioxide/src/operators.rs | 19 ++++ crates/socketioxide/src/socket.rs | 111 ++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/crates/socketioxide-core/src/adapter/mod.rs b/crates/socketioxide-core/src/adapter/mod.rs index 7e055ed9..3524c803 100644 --- a/crates/socketioxide-core/src/adapter/mod.rs +++ b/crates/socketioxide-core/src/adapter/mod.rs @@ -38,6 +38,11 @@ pub enum BroadcastFlags { Local = 0x01, /// Broadcast to all clients except the sender Broadcast = 0x02, + /// The event may be dropped if the client is not ready to receive it + /// (e.g. the connection is buffering or not connected). + /// This is useful for events that are not critical, like position updates in a game. + /// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). + Volatile = 0x04, } /// Options that can be used to modify the behavior of the broadcast methods. @@ -430,8 +435,14 @@ impl CoreLocalAdapter { return Ok(()); } + let is_volatile = opts.has_flag(BroadcastFlags::Volatile); let data = self.emitter.parser().encode(packet); - self.emitter.send_many(sids, data) + if is_volatile { + _ = self.emitter.send_many(sids, data); + Ok(()) + } else { + self.emitter.send_many(sids, data) + } } /// Broadcasts the packet to the sockets that match the [`BroadcastOptions`] and return a stream of ack responses. diff --git a/crates/socketioxide/src/io.rs b/crates/socketioxide/src/io.rs index 15618278..045822e3 100644 --- a/crates/socketioxide/src/io.rs +++ b/crates/socketioxide/src/io.rs @@ -591,6 +591,16 @@ impl SocketIo { self.get_default_op() } + /// _Alias for `io.of("/").unwrap().volatile()`_. If the **default namespace "/" is not found** this fn will panic! + /// + /// Sets the volatile flag on the broadcast operators. Volatile events may be dropped + /// if the client is not ready to receive them. + /// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). + #[inline] + pub fn volatile(&self) -> BroadcastOperators { + self.get_default_op().volatile() + } + #[cfg(feature = "state")] pub(crate) fn get_state(&self) -> Option { self.0.state.try_get::().cloned() diff --git a/crates/socketioxide/src/operators.rs b/crates/socketioxide/src/operators.rs index efdde4a8..594b8977 100644 --- a/crates/socketioxide/src/operators.rs +++ b/crates/socketioxide/src/operators.rs @@ -91,6 +91,15 @@ impl<'a, A: Adapter> ConfOperators<'a, A> { self.timeout = Some(timeout); self } + + /// Sets the volatile flag for the emit. When set, the event may be dropped + /// if the client is not ready to receive it (e.g. the connection is buffering or not connected). + /// This is useful for events that are not critical, such as position updates in a game. + /// + /// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). + pub fn volatile(self) -> BroadcastOperators { + BroadcastOperators::from(self).volatile() + } } // ==== impl ConfOperators consume fns ==== @@ -228,6 +237,16 @@ impl BroadcastOperators { self.timeout = Some(timeout); self } + + /// Sets the volatile flag for the emit. When set, the event may be dropped + /// if the client is not ready to receive it (e.g. the connection is buffering or not connected). + /// This is useful for events that are not critical, such as position updates in a game. + /// + /// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). + pub fn volatile(mut self) -> Self { + self.opts.add_flag(BroadcastFlags::Volatile); + self + } } // ==== impl BroadcastOperators consume fns ==== diff --git a/crates/socketioxide/src/socket.rs b/crates/socketioxide/src/socket.rs index 4d4d7b68..7439340b 100644 --- a/crates/socketioxide/src/socket.rs +++ b/crates/socketioxide/src/socket.rs @@ -278,6 +278,94 @@ impl From for RemoteActionError { } } +/// A wrapper around a [`Socket`] that marks emitted events as volatile. +/// +/// Volatile events may be dropped if the client is not ready to receive them +/// (e.g. the underlying connection is buffering or the socket is not connected). +/// This is useful for events that are not critical, such as position updates in a game. +/// +/// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). +/// +/// # Example +/// ``` +/// # use socketioxide::{SocketIo, extract::*}; +/// # use serde::Serialize; +/// #[derive(Serialize)] +/// struct GameState { x: f64, y: f64 } +/// +/// let (_, io) = SocketIo::new_svc(); +/// io.ns("/", async |socket: SocketRef| { +/// // Position updates may be dropped if the connection is slow +/// socket.volatile().emit("position", &GameState { x: 1.0, y: 2.0 }); +/// }); +/// ``` +pub struct VolatileSocket<'a, A: Adapter = LocalAdapter> { + socket: &'a Socket, +} + +impl VolatileSocket<'_, A> { + /// Emit a volatile event to the client. If the socket is not connected or + /// the internal buffer is full, the event is silently dropped. + pub fn emit(&self, event: impl AsRef, data: &T) { + if !self.socket.connected() { + #[cfg(feature = "tracing")] + tracing::debug!(?self.socket.id, "dropping volatile event: socket not connected"); + return; + } + + let permit = match self.socket.reserve() { + Ok(permit) => permit, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::debug!(?_e, ?self.socket.id, "dropping volatile event: cannot reserve"); + return; + } + }; + + let ns = self.socket.ns.path.clone(); + let data = match self.socket.parser.encode_value(data, Some(event.as_ref())) { + Ok(data) => data, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::debug!(?_e, "dropping volatile event: encode error"); + return; + } + }; + + permit.send(Packet::event(ns, data), self.socket.parser); + } + + #[doc = include_str!("../docs/operators/to.md")] + pub fn to(self, rooms: impl RoomParam) -> BroadcastOperators { + self.socket.to(rooms).volatile() + } + + #[doc = include_str!("../docs/operators/within.md")] + pub fn within(self, rooms: impl RoomParam) -> BroadcastOperators { + self.socket.within(rooms).volatile() + } + + #[doc = include_str!("../docs/operators/except.md")] + pub fn except(self, rooms: impl RoomParam) -> BroadcastOperators { + self.socket.except(rooms).volatile() + } + + #[doc = include_str!("../docs/operators/local.md")] + pub fn local(self) -> BroadcastOperators { + self.socket.local().volatile() + } + + #[doc = include_str!("../docs/operators/broadcast.md")] + pub fn broadcast(self) -> BroadcastOperators { + self.socket.broadcast().volatile() + } + + #[doc = include_str!("../docs/operators/timeout.md")] + pub fn timeout(self, timeout: Duration) -> BroadcastOperators { + BroadcastOperators::from(ConfOperators::new(self.socket).timeout(timeout)).volatile() + } +} + /// A Socket represents a client connected to a namespace. /// It is used to send and receive messages from the client, join and leave rooms, etc. /// The socket struct itself should not be used directly, but through a [`SocketRef`](crate::extract::SocketRef). @@ -639,6 +727,29 @@ impl Socket { BroadcastOperators::from_sock(self.ns.clone(), self.id, self.parser).broadcast() } + /// Returns a [`VolatileSocket`] wrapper that emits volatile events to this socket. + /// Volatile events may be dropped if the client is not ready to receive them + /// (e.g. the underlying connection is buffering or the socket is not connected). + /// + /// See [`VolatileSocket`] for more details. + /// + /// # Example + /// ``` + /// # use socketioxide::{SocketIo, extract::SocketRef}; + /// # use serde::Serialize; + /// #[derive(Serialize)] + /// struct GameState { x: f64, y: f64 } + /// + /// let (_, io) = SocketIo::new_svc(); + /// io.ns("/", async |socket: SocketRef| { + /// socket.volatile().emit("position", &GameState { x: 1.0, y: 2.0 }); + /// socket.volatile().to("room1").emit("update", &42); + /// }); + /// ``` + pub fn volatile(&self) -> VolatileSocket<'_, A> { + VolatileSocket { socket: self } + } + /// # Get the [`SocketIo`] context related to this socket /// /// # Panics From 35d867ba89b1c388b27c0b68d0de9f00954752b9 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 21 Jun 2026 11:56:28 +0200 Subject: [PATCH 12/49] feat(socketio,engineio): add volatile flag for emitting events --- crates/engineioxide/Cargo.toml | 2 +- crates/engineioxide/src/socket.rs | 53 +++++++++++++++ .../engineioxide/src/transport/polling/mod.rs | 23 ++++++- .../src/transport/polling/payload/encoder.rs | 65 +++++++++++++++---- .../src/transport/polling/payload/mod.rs | 11 ++-- crates/engineioxide/src/transport/ws.rs | 43 +++++++++--- crates/socketioxide-core/src/adapter/mod.rs | 8 ++- crates/socketioxide/src/ns.rs | 18 +++++ crates/socketioxide/src/socket.rs | 41 +++++++----- 9 files changed, 214 insertions(+), 50 deletions(-) diff --git a/crates/engineioxide/Cargo.toml b/crates/engineioxide/Cargo.toml index b604481d..45fcad51 100644 --- a/crates/engineioxide/Cargo.toml +++ b/crates/engineioxide/Cargo.toml @@ -28,7 +28,7 @@ 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"] } tokio-util.workspace = true tower-service.workspace = true tower-layer.workspace = true diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 222e3d8f..ecc23b13 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -78,6 +78,7 @@ use smallvec::{SmallVec, smallvec}; use tokio::sync::{ Mutex, mpsc::{self, Receiver, error::TrySendError}, + watch, }; pub use engineioxide_core::Sid; @@ -212,6 +213,13 @@ where /// Channel to send [PacketBuf] to the internal connection internal_tx: mpsc::Sender, + /// Channel to send volatile [PacketBuf]s that bypass the internal buffer. + /// Uses a [`watch`](tokio::sync::watch) channel so only the latest volatile + /// message is retained; subsequent volatile sends overwrite the previous one. + volatile_tx: watch::Sender>, + /// Receiver for the volatile channel, read by the transport with priority. + pub(crate) volatile_rx: watch::Receiver>, + /// Internal channel to receive Pong [`Packets`](Packet) (v4 protocol) or Ping (v3 protocol) in the heartbeat job /// which is running in a separate task heartbeat_rx: Mutex>, @@ -249,6 +257,7 @@ where ) -> Self { let (internal_tx, internal_rx) = mpsc::channel(config.max_buffer_size); let (heartbeat_tx, heartbeat_rx) = mpsc::channel(1); + let (volatile_tx, volatile_rx) = watch::channel(None); Self { id: Sid::new(), @@ -259,6 +268,9 @@ where internal_rx: Mutex::new(PeekableReceiver::new(internal_rx)), internal_tx, + volatile_rx, + volatile_tx, + heartbeat_rx: Mutex::new(heartbeat_rx), heartbeat_tx, cancellation_token: CancellationToken::new(), @@ -488,6 +500,43 @@ where TrySendError::Closed(p) => TrySendError::Closed(p.into_binary()), }) } + + /// Try to send a volatile message bypassing the internal buffer channel. + /// Volatile messages may be dropped if the transport is not ready to + /// receive them. + #[inline] + pub fn emit_volatile(&self, msg: impl Into) -> bool { + self.send_volatile(smallvec![Packet::Message(msg.into())]) + } + + /// Try to send a volatile binary message bypassing the internal buffer channel. + /// Volatile messages may be dropped if the transport is not ready to + /// receive them. + #[inline] + pub fn emit_binary_volatile>(&self, data: B) -> bool { + if self.protocol == ProtocolVersion::V3 { + self.send_volatile(smallvec![Packet::BinaryV3(data.into())]) + } else { + self.send_volatile(smallvec![Packet::Binary(data.into())]) + } + } + + /// Try to send a volatile message with multiple adjacent binary payloads. + /// The message and all binary payloads are sent atomically as a single + /// volatile write. + #[inline] + pub fn emit_many_volatile(&self, msg: Str, data: VecDeque) -> bool { + let mut packets = SmallVec::with_capacity(1 + data.len()); + packets.push(Packet::Message(msg)); + for bin in data { + packets.push(Packet::Binary(bin)); + } + self.send_volatile(packets) + } + + pub(crate) fn send_volatile(&self, packets: PacketBuf) -> bool { + self.volatile_tx.send(Some(packets)).is_ok() + } } impl std::fmt::Debug for Socket { @@ -548,6 +597,7 @@ where ) -> (Arc>, tokio::sync::mpsc::Receiver) { let (internal_tx, internal_rx) = mpsc::channel(buffer_size); let (heartbeat_tx, heartbeat_rx) = mpsc::channel(1); + let (volatile_tx, volatile_rx) = watch::channel(None); let sock = Self { id: sid, @@ -558,6 +608,9 @@ where internal_rx: Mutex::new(PeekableReceiver::new(internal_rx)), internal_tx, + volatile_rx, + volatile_tx, + heartbeat_rx: Mutex::new(heartbeat_rx), heartbeat_tx, cancellation_token: CancellationToken::new(), diff --git a/crates/engineioxide/src/transport/polling/mod.rs b/crates/engineioxide/src/transport/polling/mod.rs index e5207d35..bb3b8979 100644 --- a/crates/engineioxide/src/transport/polling/mod.rs +++ b/crates/engineioxide/src/transport/polling/mod.rs @@ -130,11 +130,28 @@ where let max_payload = engine.config.max_payload; + // Read the latest volatile packet from the watch channel, if any. + // Because the watch channel only retains the most recent value, + // any volatile messages that were overwritten since the last poll + // are automatically discarded. + let volatile_packets: Vec<_> = { + let mut rx = socket.volatile_rx.clone(); + let value = rx.borrow_and_update(); + value.as_ref().map(|p| vec![p.clone()]).unwrap_or_default() + }; + #[cfg(feature = "v3")] - let Payload { data, has_binary } = - payload::encoder(rx, protocol, socket.supports_binary, max_payload).await?; + let Payload { data, has_binary } = payload::encoder( + rx, + protocol, + socket.supports_binary, + max_payload, + volatile_packets, + ) + .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, volatile_packets).await?; #[cfg(feature = "tracing")] tracing::debug!("[sid={sid}] sending data: {:?}", data); diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide/src/transport/polling/payload/encoder.rs index a56a9c9a..406669ac 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/encoder.rs @@ -75,6 +75,7 @@ async fn recv_packet( pub async fn v4_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, + volatile_packets: Vec, ) -> Result { use crate::transport::polling::payload::PACKET_SEPARATOR_V4; @@ -82,6 +83,18 @@ pub async fn v4_encoder( tracing::debug!("encoding payload with v4 encoder"); let mut data: String = String::new(); + // Encode volatile packets first so they bypass the main channel backlog + for packets in volatile_packets { + for packet in packets { + let packet: String = packet.into(); + + if !data.is_empty() { + data.push(std::char::from_u32(PACKET_SEPARATOR_V4 as u32).unwrap()); + } + data.push_str(&packet); + } + } + // Send all packets in the buffer const PUNCTUATION_LEN: usize = 1; while let Some(packets) = @@ -175,6 +188,7 @@ pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) { pub async fn v3_binary_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, + volatile_packets: Vec, ) -> Result { let mut data = bytes::BytesMut::new(); let mut packet_buffer: Vec = Vec::new(); @@ -189,6 +203,18 @@ pub async fn v3_binary_encoder( // buffer all packets to find if there is binary packets let mut has_binary = false; + // Encode volatile packets first so they bypass the main channel backlog + for packets in volatile_packets { + for packet in packets { + if packet.is_binary() { + has_binary = true; + } + const PUNCTUATION_LEN: usize = 2; + estimated_size += packet.get_size_hint(false) + max_packet_size_len + PUNCTUATION_LEN; + packet_buffer.push(packet); + } + } + while let Some(packets) = try_recv_packet(&mut rx, estimated_size, max_payload, false) { for packet in packets { if packet.is_binary() { @@ -236,6 +262,7 @@ pub async fn v3_binary_encoder( pub async fn v3_string_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, + volatile_packets: Vec, ) -> Result { let mut data = bytes::BytesMut::new(); @@ -245,6 +272,14 @@ pub async fn v3_string_encoder( const PUNCTUATION_LEN: usize = 2; // number of digits of the max packet size, used to approximate the payload size let max_packet_size_len = max_payload.checked_ilog10().unwrap_or(0) as usize + 1; + + // Encode volatile packets first so they bypass the main channel backlog + for packets in volatile_packets { + for packet in packets { + v3_string_packet_encoder(packet, &mut data); + } + } + while let Some(packets) = try_recv_packet( &mut rx, data.len() + PUNCTUATION_LEN + max_packet_size_len, @@ -291,7 +326,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, vec![]).await.unwrap(); assert_eq!(data, PAYLOAD.as_bytes()); } @@ -309,7 +344,7 @@ mod tests { ]) .unwrap(); }); - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); assert_eq!(data, PAYLOAD); } @@ -330,17 +365,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, vec![]).await.unwrap(); 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, vec![]).await.unwrap(); 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, vec![]).await.unwrap(); assert_eq!(data, "4hello€".as_bytes()); } } @@ -380,7 +415,7 @@ mod tests { .unwrap(); let Payload { data, has_binary, .. - } = v3_string_encoder(rx, MAX_PAYLOAD).await.unwrap(); + } = v3_string_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); assert_eq!(data, PAYLOAD.as_bytes()); assert!(!has_binary); } @@ -404,18 +439,22 @@ 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, vec![]).await.unwrap(); 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, vec![]) + .await + .unwrap(); assert_eq!(data, "10:b4AQIDBA==".as_bytes()); } { // Next call drains one of the remaining Message packets. 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, vec![]) + .await + .unwrap(); assert_eq!(data, "7:4hello€".as_bytes()); } } @@ -438,7 +477,7 @@ mod tests { .unwrap(); let Payload { data, has_binary, .. - } = v3_binary_encoder(rx, MAX_PAYLOAD).await.unwrap(); + } = v3_binary_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); assert_eq!(*data, PAYLOAD); assert!(has_binary); } @@ -467,7 +506,7 @@ mod tests { }); let Payload { data, has_binary, .. - } = v3_binary_encoder(rx, MAX_PAYLOAD).await.unwrap(); + } = v3_binary_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); assert_eq!(*data, PAYLOAD); assert!(has_binary); } @@ -495,12 +534,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, vec![]).await.unwrap(); 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, vec![]).await.unwrap(); assert_eq!(data, "7:4hello€7:4hello€".as_bytes()); } } diff --git a/crates/engineioxide/src/transport/polling/payload/mod.rs b/crates/engineioxide/src/transport/polling/payload/mod.rs index db62cc96..9b95efab 100644 --- a/crates/engineioxide/src/transport/polling/payload/mod.rs +++ b/crates/engineioxide/src/transport/polling/payload/mod.rs @@ -70,21 +70,24 @@ pub async fn encoder( #[allow(unused_variables)] protocol: ProtocolVersion, #[cfg(feature = "v3")] supports_binary: bool, max_payload: u64, + volatile_packets: Vec, ) -> Result { #[cfg(feature = "v3")] { match protocol { - ProtocolVersion::V4 => encoder::v4_encoder(rx, max_payload).await, + ProtocolVersion::V4 => encoder::v4_encoder(rx, max_payload, volatile_packets).await, ProtocolVersion::V3 if supports_binary => { - encoder::v3_binary_encoder(rx, max_payload).await + encoder::v3_binary_encoder(rx, max_payload, volatile_packets).await + } + ProtocolVersion::V3 => { + encoder::v3_string_encoder(rx, max_payload, volatile_packets).await } - ProtocolVersion::V3 => encoder::v3_string_encoder(rx, max_payload).await, } } #[cfg(not(feature = "v3"))] { - encoder::v4_encoder(rx, max_payload).await + encoder::v4_encoder(rx, max_payload, volatile_packets).await } } diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 565a9940..ed59e76b 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -249,6 +249,7 @@ async fn forward_to_socket( S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let mut internal_rx = socket.internal_rx.try_lock().unwrap(); + let mut volatile_rx = socket.volatile_rx.clone(); // map a packet to a websocket message // It is declared as a macro rather than a closure to avoid ownership issues @@ -288,18 +289,40 @@ async fn forward_to_socket( }; } - while let Some(items) = internal_rx.recv().await { - for item in items { - map_fn!(item); - } - // For every available packet we continue to send until the channel is drained - while let Ok(items) = internal_rx.try_recv() { - for item in items { - map_fn!(item); + loop { + tokio::select! { + // Priority: main channel is checked first so regular events + // are never starved by a flood of volatile ones. + items = internal_rx.recv() => { + match items { + Some(items) => { + for item in items { + map_fn!(item); + } + // For every available packet we continue to send until the channel is drained + while let Ok(items) = internal_rx.try_recv() { + for item in items { + map_fn!(item); + } + } + tx.flush().await.ok(); + } + None => break, + } + } + // Lower priority: volatile watch channel. + // Only the latest volatile message is retained; earlier ones + // are overwritten if they haven't been consumed yet. + Ok(()) = volatile_rx.changed() => { + let value = volatile_rx.borrow_and_update().clone(); + if let Some(packets) = value { + for item in packets { + map_fn!(item); + } + tx.flush().await.ok(); + } } } - - tx.flush().await.ok(); } } /// Send a Engine.IO [`OpenPacket`] to initiate a websocket connection diff --git a/crates/socketioxide-core/src/adapter/mod.rs b/crates/socketioxide-core/src/adapter/mod.rs index 3524c803..e5f5447d 100644 --- a/crates/socketioxide-core/src/adapter/mod.rs +++ b/crates/socketioxide-core/src/adapter/mod.rs @@ -207,6 +207,12 @@ pub trait SocketEmitter: Send + Sync + 'static { fn get_remote_sockets(&self, sids: BroadcastIter<'_>) -> Vec; /// Send data to the list of socket ids. fn send_many(&self, sids: BroadcastIter<'_>, data: Value) -> Result<(), Vec>; + /// Send data to the list of socket ids with volatile semantics. + /// Errors are silently discarded; packets may be dropped if the + /// transport is not ready. + fn send_many_volatile(&self, sids: BroadcastIter<'_>, data: Value) { + _ = self.send_many(sids, data); + } /// Send data to the list of socket ids and get a stream of acks and the number of expected acks. fn send_many_with_ack( &self, @@ -438,7 +444,7 @@ impl CoreLocalAdapter { let is_volatile = opts.has_flag(BroadcastFlags::Volatile); let data = self.emitter.parser().encode(packet); if is_volatile { - _ = self.emitter.send_many(sids, data); + self.emitter.send_many_volatile(sids, data); Ok(()) } else { self.emitter.send_many(sids, data) diff --git a/crates/socketioxide/src/ns.rs b/crates/socketioxide/src/ns.rs index 78236875..c4b94a0f 100644 --- a/crates/socketioxide/src/ns.rs +++ b/crates/socketioxide/src/ns.rs @@ -227,6 +227,9 @@ trait InnerEmitter: Send + Sync + 'static { fn get_all_sids(&self, filter: &dyn Fn(&Sid) -> bool) -> Vec; /// Send data to the list of socket ids. fn send_many(&self, sids: BroadcastIter<'_>, data: Value) -> Result<(), Vec>; + /// Send data to the list of socket ids with volatile semantics. + /// Errors are silently discarded. + fn send_many_volatile(&self, sids: BroadcastIter<'_>, data: Value); /// Send data to the list of socket ids and get a stream of acks. fn send_many_with_ack( &self, @@ -268,6 +271,15 @@ impl InnerEmitter for Namespace { if errs.is_empty() { Ok(()) } else { Err(errs) } } + fn send_many_volatile(&self, sids: BroadcastIter<'_>, data: Value) { + let sockets = self.sockets.read().unwrap(); + for sid in sids { + if let Some(socket) = sockets.get(&sid) { + socket.send_raw_volatile(data.clone()); + } + } + } + fn send_many_with_ack( &self, sids: BroadcastIter<'_>, @@ -354,6 +366,12 @@ impl SocketEmitter for Emitter { } } + fn send_many_volatile(&self, sids: BroadcastIter<'_>, data: Value) { + if let Some(ns) = self.ns.upgrade() { + ns.send_many_volatile(sids, data); + } + } + fn send_many_with_ack( &self, sids: BroadcastIter<'_>, diff --git a/crates/socketioxide/src/socket.rs b/crates/socketioxide/src/socket.rs index 7439340b..13bdf2af 100644 --- a/crates/socketioxide/src/socket.rs +++ b/crates/socketioxide/src/socket.rs @@ -309,30 +309,21 @@ impl VolatileSocket<'_, A> { pub fn emit(&self, event: impl AsRef, data: &T) { if !self.socket.connected() { #[cfg(feature = "tracing")] - tracing::debug!(?self.socket.id, "dropping volatile event: socket not connected"); + tracing::debug!( + ?self.socket.id, + "dropping volatile event: socket not connected" + ); return; } - let permit = match self.socket.reserve() { - Ok(permit) => permit, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::debug!(?_e, ?self.socket.id, "dropping volatile event: cannot reserve"); - return; - } - }; - let ns = self.socket.ns.path.clone(); - let data = match self.socket.parser.encode_value(data, Some(event.as_ref())) { - Ok(data) => data, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::debug!(?_e, "dropping volatile event: encode error"); - return; - } + let Ok(data) = self.socket.parser.encode_value(data, Some(event.as_ref())) else { + return; }; - permit.send(Packet::event(ns, data), self.socket.parser); + let packet = Packet::event(ns, data); + self.socket + .send_raw_volatile(self.socket.parser.encode(packet)); } #[doc = include_str!("../docs/operators/to.md")] @@ -849,6 +840,20 @@ impl Socket { Ok(()) } + pub(crate) fn send_raw_volatile(&self, value: Value) { + match value { + Value::Str(msg, None) => { + self.esocket.emit_volatile(msg); + } + Value::Str(msg, Some(bin_payloads)) => { + self.esocket.emit_many_volatile(msg, bin_payloads); + } + Value::Bytes(bin) => { + self.esocket.emit_binary_volatile(bin); + } + } + } + pub(crate) fn send_with_ack_permit( &self, mut packet: Packet, From 78e4f2897136144980426badd67811ebe6c36372 Mon Sep 17 00:00:00 2001 From: Liam Date: Sun, 21 Jun 2026 14:17:59 +0200 Subject: [PATCH 13/49] docs: add warnings about potential message ordering issues with volatile events --- crates/engineioxide/src/socket.rs | 3 +++ crates/socketioxide/src/socket.rs | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index ecc23b13..8f938523 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -504,6 +504,9 @@ where /// Try to send a volatile message bypassing the internal buffer channel. /// Volatile messages may be dropped if the transport is not ready to /// receive them. + /// + /// Because volatile messages bypass the main mpsc buffer queue, they may + /// arrive out of order relative to regular messages. #[inline] pub fn emit_volatile(&self, msg: impl Into) -> bool { self.send_volatile(smallvec![Packet::Message(msg.into())]) diff --git a/crates/socketioxide/src/socket.rs b/crates/socketioxide/src/socket.rs index 13bdf2af..7be8e0a1 100644 --- a/crates/socketioxide/src/socket.rs +++ b/crates/socketioxide/src/socket.rs @@ -284,6 +284,11 @@ impl From for RemoteActionError { /// (e.g. the underlying connection is buffering or the socket is not connected). /// This is useful for events that are not critical, such as position updates in a game. /// +/// Because volatile events use a separate channel that bypasses the main +/// mpsc buffer, they may arrive **out of order** relative to regular events +/// emitted around the same time. Only use volatile when ordering relative to +/// regular events is not important. +/// /// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). /// /// # Example From 8861111124d54753d33c88e221472d6a2ccba2a5 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 26 Jun 2026 09:20:50 +0200 Subject: [PATCH 14/49] refactor: replace VolatileSocket with operator flag and optimize volatile message loop in transport --- crates/engineioxide/Cargo.toml | 2 +- crates/engineioxide/src/socket.rs | 9 ++ crates/engineioxide/src/transport/ws.rs | 53 +++++----- .../socketioxide/docs/operators/volatile.md | 28 +++++ crates/socketioxide/src/operators.rs | 37 ++++--- crates/socketioxide/src/socket.rs | 100 ++---------------- 6 files changed, 93 insertions(+), 136 deletions(-) create mode 100644 crates/socketioxide/docs/operators/volatile.md diff --git a/crates/engineioxide/Cargo.toml b/crates/engineioxide/Cargo.toml index 45fcad51..b604481d 100644 --- a/crates/engineioxide/Cargo.toml +++ b/crates/engineioxide/Cargo.toml @@ -28,7 +28,7 @@ http-body.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["rt", "time", "macros"] } +tokio = { workspace = true, features = ["rt", "time"] } tokio-util.workspace = true tower-service.workspace = true tower-layer.workspace = true diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 8f938523..b046bacb 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -507,6 +507,9 @@ where /// /// Because volatile messages bypass the main mpsc buffer queue, they may /// arrive out of order relative to regular messages. + /// + /// Returns `true` if the message was queued for sending, `false` if it + /// was dropped (channel full or transport shutting down). #[inline] pub fn emit_volatile(&self, msg: impl Into) -> bool { self.send_volatile(smallvec![Packet::Message(msg.into())]) @@ -515,6 +518,9 @@ where /// Try to send a volatile binary message bypassing the internal buffer channel. /// Volatile messages may be dropped if the transport is not ready to /// receive them. + /// + /// Returns `true` if the message was queued for sending, `false` if it + /// was dropped. #[inline] pub fn emit_binary_volatile>(&self, data: B) -> bool { if self.protocol == ProtocolVersion::V3 { @@ -527,6 +533,9 @@ where /// Try to send a volatile message with multiple adjacent binary payloads. /// The message and all binary payloads are sent atomically as a single /// volatile write. + /// + /// Returns `true` if the message was queued for sending, `false` if it + /// was dropped. #[inline] pub fn emit_many_volatile(&self, msg: Str, data: VecDeque) -> bool { let mut packets = SmallVec::with_capacity(1 + data.len()); diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index ed59e76b..0e2ffbe0 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -290,37 +290,32 @@ async fn forward_to_socket( } loop { - tokio::select! { - // Priority: main channel is checked first so regular events - // are never starved by a flood of volatile ones. - items = internal_rx.recv() => { - match items { - Some(items) => { - for item in items { - map_fn!(item); - } - // For every available packet we continue to send until the channel is drained - while let Ok(items) = internal_rx.try_recv() { - for item in items { - map_fn!(item); - } - } - tx.flush().await.ok(); - } - None => break, - } + // Priority: wait for and drain the main channel. + // Volatile events are checked after each main-channel cycle. + let items = match internal_rx.recv().await { + Some(items) => items, + None => break, + }; + for item in items { + map_fn!(item); + } + while let Ok(items) = internal_rx.try_recv() { + for item in items { + map_fn!(item); } - // Lower priority: volatile watch channel. - // Only the latest volatile message is retained; earlier ones - // are overwritten if they haven't been consumed yet. - Ok(()) = volatile_rx.changed() => { - let value = volatile_rx.borrow_and_update().clone(); - if let Some(packets) = value { - for item in packets { - map_fn!(item); - } - tx.flush().await.ok(); + } + tx.flush().await.ok(); + + // Check volatile channel after main is drained. + // `has_changed` is non-blocking; if no volatile data is + // pending we simply go back to waiting for the main channel. + if volatile_rx.has_changed().unwrap_or(false) { + let val = volatile_rx.borrow_and_update().clone(); + if let Some(packets) = val { + for item in packets { + map_fn!(item); } + tx.flush().await.ok(); } } } diff --git a/crates/socketioxide/docs/operators/volatile.md b/crates/socketioxide/docs/operators/volatile.md new file mode 100644 index 00000000..42023754 --- /dev/null +++ b/crates/socketioxide/docs/operators/volatile.md @@ -0,0 +1,28 @@ +# Set the volatile flag for the emit. When set, the event may be dropped +if the client is not ready to receive it (e.g. the connection is buffering +or not connected). This is useful for events that are not critical, such +as position updates in a game. + +Because volatile events use a separate channel that bypasses the main +mpsc buffer, they may arrive **out of order** relative to regular events +emitted around the same time. Only use volatile when ordering relative to +regular events is not important. + +See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). + +# Example +```rust +# use socketioxide::{SocketIo, extract::*}; +# use serde::Serialize; +#[derive(Serialize)] +struct GameState { x: f64, y: f64 } + +let (_, io) = SocketIo::new_svc(); +io.ns("/", async |socket: SocketRef| { + // Direct volatile emit — may be dropped if the socket is not ready + socket.volatile().emit("position", &GameState { x: 1.0, y: 2.0 }).ok(); + + // Volatile broadcast to a room + socket.volatile().to("game_room").emit("update", &42).await.ok(); +}); +``` diff --git a/crates/socketioxide/src/operators.rs b/crates/socketioxide/src/operators.rs index 594b8977..c92bf534 100644 --- a/crates/socketioxide/src/operators.rs +++ b/crates/socketioxide/src/operators.rs @@ -30,6 +30,7 @@ use socketioxide_core::{ /// Chainable operators to configure the message to be sent. pub struct ConfOperators<'a, A: Adapter = LocalAdapter> { timeout: Option, + volatile: bool, socket: &'a Socket, } /// Chainable operators to select sockets to send a message to and to configure the message to be sent. @@ -42,7 +43,10 @@ pub struct BroadcastOperators { impl From> for BroadcastOperators { fn from(conf: ConfOperators<'_, A>) -> Self { - let opts = BroadcastOptions::new(conf.socket.id); + let mut opts = BroadcastOptions::new(conf.socket.id); + if conf.volatile { + opts.add_flag(BroadcastFlags::Volatile); + } Self { timeout: conf.timeout, ns: conf.socket.ns.clone(), @@ -57,6 +61,7 @@ impl<'a, A: Adapter> ConfOperators<'a, A> { pub(crate) fn new(sender: &'a Socket) -> Self { Self { timeout: None, + volatile: false, socket: sender, } } @@ -92,13 +97,10 @@ impl<'a, A: Adapter> ConfOperators<'a, A> { self } - /// Sets the volatile flag for the emit. When set, the event may be dropped - /// if the client is not ready to receive it (e.g. the connection is buffering or not connected). - /// This is useful for events that are not critical, such as position updates in a game. - /// - /// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). - pub fn volatile(self) -> BroadcastOperators { - BroadcastOperators::from(self).volatile() + #[doc = include_str!("../docs/operators/volatile.md")] + pub fn volatile(mut self) -> Self { + self.volatile = true; + self } } @@ -110,6 +112,18 @@ impl ConfOperators<'_, A> { event: impl AsRef, data: &T, ) -> Result<(), SendError> { + if self.volatile { + if !self.socket.connected() { + return Ok(()); + } + let Ok(packet) = self.get_packet(event, data) else { + return Ok(()); + }; + self.socket + .send_raw_volatile(self.socket.parser.encode(packet)); + return Ok(()); + } + use crate::SocketError; use crate::socket::PermitExt; if !self.socket.connected() { @@ -238,11 +252,7 @@ impl BroadcastOperators { self } - /// Sets the volatile flag for the emit. When set, the event may be dropped - /// if the client is not ready to receive it (e.g. the connection is buffering or not connected). - /// This is useful for events that are not critical, such as position updates in a game. - /// - /// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). + #[doc = include_str!("../docs/operators/volatile.md")] pub fn volatile(mut self) -> Self { self.opts.add_flag(BroadcastFlags::Volatile); self @@ -355,6 +365,7 @@ impl<'a, A: Adapter> Clone for ConfOperators<'a, A> { fn clone(&self) -> Self { Self { timeout: self.timeout, + volatile: self.volatile, socket: self.socket, } } diff --git a/crates/socketioxide/src/socket.rs b/crates/socketioxide/src/socket.rs index 7be8e0a1..1073cef5 100644 --- a/crates/socketioxide/src/socket.rs +++ b/crates/socketioxide/src/socket.rs @@ -278,90 +278,6 @@ impl From for RemoteActionError { } } -/// A wrapper around a [`Socket`] that marks emitted events as volatile. -/// -/// Volatile events may be dropped if the client is not ready to receive them -/// (e.g. the underlying connection is buffering or the socket is not connected). -/// This is useful for events that are not critical, such as position updates in a game. -/// -/// Because volatile events use a separate channel that bypasses the main -/// mpsc buffer, they may arrive **out of order** relative to regular events -/// emitted around the same time. Only use volatile when ordering relative to -/// regular events is not important. -/// -/// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). -/// -/// # Example -/// ``` -/// # use socketioxide::{SocketIo, extract::*}; -/// # use serde::Serialize; -/// #[derive(Serialize)] -/// struct GameState { x: f64, y: f64 } -/// -/// let (_, io) = SocketIo::new_svc(); -/// io.ns("/", async |socket: SocketRef| { -/// // Position updates may be dropped if the connection is slow -/// socket.volatile().emit("position", &GameState { x: 1.0, y: 2.0 }); -/// }); -/// ``` -pub struct VolatileSocket<'a, A: Adapter = LocalAdapter> { - socket: &'a Socket, -} - -impl VolatileSocket<'_, A> { - /// Emit a volatile event to the client. If the socket is not connected or - /// the internal buffer is full, the event is silently dropped. - pub fn emit(&self, event: impl AsRef, data: &T) { - if !self.socket.connected() { - #[cfg(feature = "tracing")] - tracing::debug!( - ?self.socket.id, - "dropping volatile event: socket not connected" - ); - return; - } - - let ns = self.socket.ns.path.clone(); - let Ok(data) = self.socket.parser.encode_value(data, Some(event.as_ref())) else { - return; - }; - - let packet = Packet::event(ns, data); - self.socket - .send_raw_volatile(self.socket.parser.encode(packet)); - } - - #[doc = include_str!("../docs/operators/to.md")] - pub fn to(self, rooms: impl RoomParam) -> BroadcastOperators { - self.socket.to(rooms).volatile() - } - - #[doc = include_str!("../docs/operators/within.md")] - pub fn within(self, rooms: impl RoomParam) -> BroadcastOperators { - self.socket.within(rooms).volatile() - } - - #[doc = include_str!("../docs/operators/except.md")] - pub fn except(self, rooms: impl RoomParam) -> BroadcastOperators { - self.socket.except(rooms).volatile() - } - - #[doc = include_str!("../docs/operators/local.md")] - pub fn local(self) -> BroadcastOperators { - self.socket.local().volatile() - } - - #[doc = include_str!("../docs/operators/broadcast.md")] - pub fn broadcast(self) -> BroadcastOperators { - self.socket.broadcast().volatile() - } - - #[doc = include_str!("../docs/operators/timeout.md")] - pub fn timeout(self, timeout: Duration) -> BroadcastOperators { - BroadcastOperators::from(ConfOperators::new(self.socket).timeout(timeout)).volatile() - } -} - /// A Socket represents a client connected to a namespace. /// It is used to send and receive messages from the client, join and leave rooms, etc. /// The socket struct itself should not be used directly, but through a [`SocketRef`](crate::extract::SocketRef). @@ -723,11 +639,9 @@ impl Socket { BroadcastOperators::from_sock(self.ns.clone(), self.id, self.parser).broadcast() } - /// Returns a [`VolatileSocket`] wrapper that emits volatile events to this socket. - /// Volatile events may be dropped if the client is not ready to receive them - /// (e.g. the underlying connection is buffering or the socket is not connected). - /// - /// See [`VolatileSocket`] for more details. + /// Returns a [`ConfOperators`] with the volatile flag set, so that any + /// subsequent `emit()` will drop the event instead of buffering it if the + /// client is not ready to receive it. /// /// # Example /// ``` @@ -738,12 +652,12 @@ impl Socket { /// /// let (_, io) = SocketIo::new_svc(); /// io.ns("/", async |socket: SocketRef| { - /// socket.volatile().emit("position", &GameState { x: 1.0, y: 2.0 }); - /// socket.volatile().to("room1").emit("update", &42); + /// socket.volatile().emit("position", &GameState { x: 1.0, y: 2.0 }).ok(); + /// socket.volatile().to("room1").emit("update", &42).await.ok(); /// }); /// ``` - pub fn volatile(&self) -> VolatileSocket<'_, A> { - VolatileSocket { socket: self } + pub fn volatile(&self) -> ConfOperators<'_, A> { + ConfOperators::new(self).volatile() } /// # Get the [`SocketIo`] context related to this socket From 91a04de2037ee684ecc399290dfe1382b38a3c99 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 26 Jun 2026 11:53:12 +0200 Subject: [PATCH 15/49] test: update v3_string_encoder call to include missing argument in polling payload test --- crates/engineioxide/src/transport/polling/payload/encoder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide/src/transport/polling/payload/encoder.rs index 406669ac..bb3e46b7 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/encoder.rs @@ -393,7 +393,7 @@ mod tests { let rx = mutex.lock().await; tx.try_send(smallvec::smallvec![Packet::Message("𝕊".into())]) .unwrap(); - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD).await.unwrap(); + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); assert_eq!(data, PAYLOAD.as_bytes()); } From fabc57b2fa0755caa7e4e5a30184e0d8413c94c1 Mon Sep 17 00:00:00 2001 From: Liam Date: Fri, 26 Jun 2026 19:05:51 +0200 Subject: [PATCH 16/49] refactor: replace volatile packet collection with lazy watch receiver polling in encoders to capture packets arriving during encoding --- .../engineioxide/src/transport/polling/mod.rs | 14 +- .../src/transport/polling/payload/encoder.rs | 208 +++++++++++++----- .../src/transport/polling/payload/mod.rs | 12 +- 3 files changed, 160 insertions(+), 74 deletions(-) diff --git a/crates/engineioxide/src/transport/polling/mod.rs b/crates/engineioxide/src/transport/polling/mod.rs index bb3b8979..bb8826c9 100644 --- a/crates/engineioxide/src/transport/polling/mod.rs +++ b/crates/engineioxide/src/transport/polling/mod.rs @@ -130,15 +130,7 @@ where let max_payload = engine.config.max_payload; - // Read the latest volatile packet from the watch channel, if any. - // Because the watch channel only retains the most recent value, - // any volatile messages that were overwritten since the last poll - // are automatically discarded. - let volatile_packets: Vec<_> = { - let mut rx = socket.volatile_rx.clone(); - let value = rx.borrow_and_update(); - value.as_ref().map(|p| vec![p.clone()]).unwrap_or_default() - }; + let mut volatile_rx = socket.volatile_rx.clone(); #[cfg(feature = "v3")] let Payload { data, has_binary } = payload::encoder( @@ -146,12 +138,12 @@ where protocol, socket.supports_binary, max_payload, - volatile_packets, + &mut volatile_rx, ) .await?; #[cfg(not(feature = "v3"))] let Payload { data, has_binary } = - payload::encoder(rx, protocol, max_payload, volatile_packets).await?; + payload::encoder(rx, protocol, max_payload, &mut volatile_rx).await?; #[cfg(feature = "tracing")] tracing::debug!("[sid={sid}] sending data: {:?}", data); diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide/src/transport/polling/payload/encoder.rs index bb3e46b7..3b8e9096 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/encoder.rs @@ -75,7 +75,7 @@ async fn recv_packet( pub async fn v4_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, - volatile_packets: Vec, + volatile_rx: &mut tokio::sync::watch::Receiver>, ) -> Result { use crate::transport::polling::payload::PACKET_SEPARATOR_V4; @@ -83,23 +83,24 @@ pub async fn v4_encoder( tracing::debug!("encoding payload with v4 encoder"); let mut data: String = String::new(); - // Encode volatile packets first so they bypass the main channel backlog - for packets in volatile_packets { - for packet in packets { - let packet: String = packet.into(); - - if !data.is_empty() { - data.push(std::char::from_u32(PACKET_SEPARATOR_V4 as u32).unwrap()); - } - data.push_str(&packet); - } - } + // Encode any pending volatile packets first so they are included in + // the current response even if the main channel has a backlog. + // The watch receiver is checked again at each iteration of the main + // channel drain loop, so volatile packets arriving during encoding + // (between .await points) are also captured. + encode_volatile_packets_v4(&mut data, volatile_rx); // 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) - { + loop { + // Check for volatile data before each main channel read + encode_volatile_packets_v4(&mut data, volatile_rx); + + let Some(packets) = + try_recv_packet(&mut rx, data.len() + PUNCTUATION_LEN, max_payload, true) + else { + break; + }; for packet in packets { let packet: String = packet.into(); @@ -126,6 +127,30 @@ pub async fn v4_encoder( Ok(Payload::new(data.into(), false)) } +/// Encode any pending volatile packets from the watch receiver into the +/// v4 payload string. The watch is checked lazily (only when new data is +/// available) so volatile packets arriving during encoding are captured. +fn encode_volatile_packets_v4( + data: &mut String, + volatile_rx: &mut tokio::sync::watch::Receiver>, +) { + use crate::transport::polling::payload::PACKET_SEPARATOR_V4; + + if !volatile_rx.has_changed().unwrap_or(false) { + return; + } + let value = volatile_rx.borrow_and_update().clone(); + if let Some(packets) = value { + for packet in packets { + let packet: String = packet.into(); + if !data.is_empty() { + data.push(std::char::from_u32(PACKET_SEPARATOR_V4 as u32).unwrap()); + } + data.push_str(&packet); + } + } +} + /// 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")] @@ -188,7 +213,7 @@ pub fn v3_string_packet_encoder(packet: Packet, data: &mut bytes::BytesMut) { pub async fn v3_binary_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, - volatile_packets: Vec, + volatile_rx: &mut tokio::sync::watch::Receiver>, ) -> Result { let mut data = bytes::BytesMut::new(); let mut packet_buffer: Vec = Vec::new(); @@ -203,19 +228,28 @@ pub async fn v3_binary_encoder( // buffer all packets to find if there is binary packets let mut has_binary = false; - // Encode volatile packets first so they bypass the main channel backlog - for packets in volatile_packets { - for packet in packets { - if packet.is_binary() { - has_binary = true; - } - const PUNCTUATION_LEN: usize = 2; - estimated_size += packet.get_size_hint(false) + max_packet_size_len + PUNCTUATION_LEN; - packet_buffer.push(packet); - } - } + // Encode any pending volatile packets first. Checked again inside the + // main drain loop so volatile packets arriving during encoding are captured. + buffer_volatile_packets( + &mut packet_buffer, + &mut estimated_size, + &mut has_binary, + max_packet_size_len, + volatile_rx, + ); - while let Some(packets) = try_recv_packet(&mut rx, estimated_size, max_payload, false) { + loop { + buffer_volatile_packets( + &mut packet_buffer, + &mut estimated_size, + &mut has_binary, + max_packet_size_len, + volatile_rx, + ); + + let Some(packets) = try_recv_packet(&mut rx, estimated_size, max_payload, false) else { + break; + }; for packet in packets { if packet.is_binary() { has_binary = true; @@ -256,13 +290,40 @@ pub async fn v3_binary_encoder( Ok(Payload::new(data.freeze(), has_binary)) } +/// Buffer any pending volatile packets from the watch receiver into the +/// packet buffer. Called at each iteration so volatile packets arriving +/// during encoding (between .await points) are captured. +#[cfg(feature = "v3")] +fn buffer_volatile_packets( + packet_buffer: &mut Vec, + estimated_size: &mut usize, + has_binary: &mut bool, + max_packet_size_len: usize, + volatile_rx: &mut tokio::sync::watch::Receiver>, +) { + if !volatile_rx.has_changed().unwrap_or(false) { + return; + } + let value = volatile_rx.borrow_and_update().clone(); + if let Some(packets) = value { + for packet in packets { + if packet.is_binary() { + *has_binary = true; + } + const PUNCTUATION_LEN: usize = 2; + *estimated_size += packet.get_size_hint(false) + max_packet_size_len + PUNCTUATION_LEN; + packet_buffer.push(packet); + } + } +} + /// Encode multiple packet 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 async fn v3_string_encoder( mut rx: MutexGuard<'_, PeekableReceiver>, max_payload: u64, - volatile_packets: Vec, + volatile_rx: &mut tokio::sync::watch::Receiver>, ) -> Result { let mut data = bytes::BytesMut::new(); @@ -273,19 +334,21 @@ pub async fn v3_string_encoder( // number of digits of the max packet size, used to approximate the payload size let max_packet_size_len = max_payload.checked_ilog10().unwrap_or(0) as usize + 1; - // Encode volatile packets first so they bypass the main channel backlog - for packets in volatile_packets { - for packet in packets { - v3_string_packet_encoder(packet, &mut data); - } - } - - while let Some(packets) = try_recv_packet( - &mut rx, - data.len() + PUNCTUATION_LEN + max_packet_size_len, - max_payload, - true, - ) { + // Encode any pending volatile packets first; checked again in the main + // drain loop so volatile packets arriving during encoding are captured. + encode_volatile_v3_string(&mut data, volatile_rx); + + loop { + encode_volatile_v3_string(&mut data, volatile_rx); + + let Some(packets) = try_recv_packet( + &mut rx, + data.len() + PUNCTUATION_LEN + max_packet_size_len, + max_payload, + true, + ) else { + break; + }; for packet in packets { v3_string_packet_encoder(packet, &mut data); } @@ -302,6 +365,25 @@ pub async fn v3_string_encoder( Ok(Payload::new(data.freeze(), false)) } +/// Encode any pending volatile packets from the watch receiver into the +/// v3 string payload. Called at each iteration so volatile packets +/// arriving during encoding are captured. +#[cfg(feature = "v3")] +fn encode_volatile_v3_string( + data: &mut bytes::BytesMut, + volatile_rx: &mut tokio::sync::watch::Receiver>, +) { + if !volatile_rx.has_changed().unwrap_or(false) { + return; + } + let value = volatile_rx.borrow_and_update().clone(); + if let Some(packets) = value { + for packet in packets { + v3_string_packet_encoder(packet, data); + } + } +} + #[cfg(test)] mod tests { use bytes::Bytes; @@ -312,8 +394,14 @@ mod tests { use super::*; const MAX_PAYLOAD: u64 = 100_000; + fn dummy_volatile_rx() -> tokio::sync::watch::Receiver> { + let (_, rx) = tokio::sync::watch::channel(None); + rx + } + #[tokio::test] async fn encode_v4_payload() { + let mut vr = dummy_volatile_rx(); const PAYLOAD: &str = "4hello€\x1ebAQIDBA==\x1e4hello€"; let (tx, rx) = tokio::sync::mpsc::channel::(10); let rx = Mutex::new(PeekableReceiver::new(rx)); @@ -326,12 +414,13 @@ mod tests { .unwrap(); tx.try_send(smallvec::smallvec![Packet::Message("hello€".into())]) .unwrap(); - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(data, PAYLOAD.as_bytes()); } #[tokio::test] async fn encode_v4_payload_parked_poll_multi_packet_batch() { + let mut vr = dummy_volatile_rx(); const PAYLOAD: &str = "4hello€\x1ebAQIDBA=="; let (tx, rx) = tokio::sync::mpsc::channel::(10); let rx = Mutex::new(PeekableReceiver::new(rx)); @@ -344,12 +433,13 @@ mod tests { ]) .unwrap(); }); - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(data, PAYLOAD); } #[tokio::test] async fn max_payload_v4() { + let mut vr = dummy_volatile_rx(); const MAX_PAYLOAD: u64 = 10; let (tx, rx) = tokio::sync::mpsc::channel::(10); let mutex = Mutex::new(PeekableReceiver::new(rx)); @@ -365,17 +455,17 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(data, "4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10, vec![]).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10, &mut vr).await.unwrap(); assert_eq!(data, "bAQIDBA==\x1e4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10, vec![]).await.unwrap(); + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD + 10, &mut vr).await.unwrap(); assert_eq!(data, "4hello€".as_bytes()); } } @@ -383,6 +473,7 @@ mod tests { #[cfg(feature = "v3")] #[tokio::test] async fn encode_v3_string_payload_utf16_length() { + let mut vr = dummy_volatile_rx(); // Length must be the number of UTF-16 code units to match the // engine.io v3 JS reference implementation. The message "4𝕊" // (packet type '4' + non-BMP codepoint U+1D54A) has 2 codepoints @@ -393,13 +484,14 @@ mod tests { let rx = mutex.lock().await; tx.try_send(smallvec::smallvec![Packet::Message("𝕊".into())]) .unwrap(); - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(data, PAYLOAD.as_bytes()); } #[cfg(feature = "v3")] #[tokio::test] async fn encode_v3b64_payload() { + let mut vr = dummy_volatile_rx(); const PAYLOAD: &str = "7:4hello€10:b4AQIDBA==7:4hello€"; let (tx, rx) = tokio::sync::mpsc::channel::(10); let mutex = Mutex::new(PeekableReceiver::new(rx)); @@ -415,7 +507,7 @@ mod tests { .unwrap(); let Payload { data, has_binary, .. - } = v3_string_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(data, PAYLOAD.as_bytes()); assert!(!has_binary); } @@ -423,6 +515,7 @@ mod tests { #[cfg(feature = "v3")] #[tokio::test] async fn max_payload_v3_b64() { + let mut vr = dummy_volatile_rx(); const MAX_PAYLOAD: u64 = 10; let (tx, rx) = tokio::sync::mpsc::channel::(10); @@ -439,12 +532,12 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(data, "7:4hello€".as_bytes()); } { let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10, vec![]) + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10, &mut vr) .await .unwrap(); assert_eq!(data, "10:b4AQIDBA==".as_bytes()); @@ -452,7 +545,7 @@ mod tests { { // Next call drains one of the remaining Message packets. let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10, vec![]) + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD + 10, &mut vr) .await .unwrap(); assert_eq!(data, "7:4hello€".as_bytes()); @@ -462,6 +555,7 @@ mod tests { #[cfg(feature = "v3")] #[tokio::test] async fn encode_v3binary_payload() { + let mut vr = dummy_volatile_rx(); const PAYLOAD: [u8; 20] = [ 0, 9, 255, 52, 104, 101, 108, 108, 111, 226, 130, 172, 1, 5, 255, 4, 1, 2, 3, 4, ]; @@ -477,7 +571,7 @@ mod tests { .unwrap(); let Payload { data, has_binary, .. - } = v3_binary_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(*data, PAYLOAD); assert!(has_binary); } @@ -485,6 +579,7 @@ mod tests { #[cfg(feature = "v3")] #[tokio::test] async fn encode_v3binary_payload_parked_poll_multi_packet_batch() { + let mut vr = dummy_volatile_rx(); // When the v3 binary encoder is parked on an empty buffer and a // multi-packet batch arrives, every packet must be encoded with the // same framing (binary framing if any packet in the batch is binary). @@ -506,7 +601,7 @@ mod tests { }); let Payload { data, has_binary, .. - } = v3_binary_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(*data, PAYLOAD); assert!(has_binary); } @@ -514,6 +609,7 @@ mod tests { #[cfg(feature = "v3")] #[tokio::test] async fn max_payload_v3_binary() { + let mut vr = dummy_volatile_rx(); const MAX_PAYLOAD: u64 = 25; const PAYLOAD: [u8; 23] = [ @@ -534,12 +630,12 @@ mod tests { .unwrap(); { let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(*data, PAYLOAD); } { let rx = mutex.lock().await; - let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD, vec![]).await.unwrap(); + let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert_eq!(data, "7:4hello€7:4hello€".as_bytes()); } } diff --git a/crates/engineioxide/src/transport/polling/payload/mod.rs b/crates/engineioxide/src/transport/polling/payload/mod.rs index 9b95efab..beafbdb4 100644 --- a/crates/engineioxide/src/transport/polling/payload/mod.rs +++ b/crates/engineioxide/src/transport/polling/payload/mod.rs @@ -70,24 +70,22 @@ pub async fn encoder( #[allow(unused_variables)] protocol: ProtocolVersion, #[cfg(feature = "v3")] supports_binary: bool, max_payload: u64, - volatile_packets: Vec, + volatile_rx: &mut tokio::sync::watch::Receiver>, ) -> Result { #[cfg(feature = "v3")] { match protocol { - ProtocolVersion::V4 => encoder::v4_encoder(rx, max_payload, volatile_packets).await, + ProtocolVersion::V4 => encoder::v4_encoder(rx, max_payload, volatile_rx).await, ProtocolVersion::V3 if supports_binary => { - encoder::v3_binary_encoder(rx, max_payload, volatile_packets).await - } - ProtocolVersion::V3 => { - encoder::v3_string_encoder(rx, max_payload, volatile_packets).await + encoder::v3_binary_encoder(rx, max_payload, volatile_rx).await } + ProtocolVersion::V3 => encoder::v3_string_encoder(rx, max_payload, volatile_rx).await, } } #[cfg(not(feature = "v3"))] { - encoder::v4_encoder(rx, max_payload, volatile_packets).await + encoder::v4_encoder(rx, max_payload, volatile_rx).await } } From 06f547fee77ba9ea1d95fb917dea8e191feb8d1c Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 27 Jun 2026 13:44:57 +0200 Subject: [PATCH 17/49] trigger ci From 0a12ccaadb7d94ffb514c6b4ecaac1b65e41acd5 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jun 2026 21:59:53 +0200 Subject: [PATCH 18/49] feat: implement comprehensive test coverage --- .../src/transport/polling/payload/encoder.rs | 211 ++++++++++++++++++ crates/engineioxide/tests/volatile.rs | 112 ++++++++++ crates/socketioxide/tests/volatile.rs | 82 +++++++ 3 files changed, 405 insertions(+) create mode 100644 crates/engineioxide/tests/volatile.rs create mode 100644 crates/socketioxide/tests/volatile.rs diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide/src/transport/polling/payload/encoder.rs index 3b8e9096..95bcc392 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/encoder.rs @@ -639,4 +639,215 @@ mod tests { assert_eq!(data, "7:4hello€7:4hello€".as_bytes()); } } + + fn make_volatile_chan( + packets: PacketBuf, + ) -> ( + tokio::sync::watch::Sender>, + tokio::sync::watch::Receiver>, + ) { + let (tx, rx) = tokio::sync::watch::channel(None); + tx.send(Some(packets)).unwrap(); + (tx, rx) + } + + #[tokio::test] + async fn v4_volatile_before_normal() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + tx.try_send(smallvec::smallvec![Packet::Message("foo".into())]) + .unwrap(); + tx.try_send(smallvec::smallvec![Packet::Message("bar".into())]) + .unwrap(); + let (_volatile_tx, mut vr) = + make_volatile_chan(smallvec::smallvec![Packet::Message("v1".into())]); + + let rx = mutex.lock().await; + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "4v1\x1e4foo\x1e4bar".as_bytes()); + } + + #[tokio::test] + async fn v4_normal_before_volatile() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + tx.try_send(smallvec::smallvec![Packet::Message("foo".into())]) + .unwrap(); + tx.try_send(smallvec::smallvec![Packet::Message("bar".into())]) + .unwrap(); + let (_volatile_tx, mut vr) = + make_volatile_chan(smallvec::smallvec![Packet::Message("v1".into())]); + + let rx = mutex.lock().await; + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "4v1\x1e4foo\x1e4bar".as_bytes()); + } + + #[tokio::test] + async fn v4_volatile_overwrite() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + tx.try_send(smallvec::smallvec![Packet::Message("normal".into())]) + .unwrap(); + let (volatile_tx, mut vr) = tokio::sync::watch::channel(None); + volatile_tx + .send(Some(smallvec::smallvec![Packet::Message("dropped".into())])) + .unwrap(); + volatile_tx + .send(Some(smallvec::smallvec![Packet::Message("kept".into())])) + .unwrap(); + + let rx = mutex.lock().await; + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "4kept\x1e4normal".as_bytes()); + } + + #[tokio::test] + async fn v4_volatile_mid_encoding() { + let (main_tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + let (volatile_tx, mut vr) = tokio::sync::watch::channel(None); + main_tx + .try_send(smallvec::smallvec![Packet::Message("first".into())]) + .unwrap(); + main_tx + .try_send(smallvec::smallvec![Packet::Message("second".into())]) + .unwrap(); + + let rx = mutex.lock().await; + volatile_tx + .send(Some(smallvec::smallvec![Packet::Message("mid".into())])) + .unwrap(); + + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "4mid\x1e4first\x1e4second".as_bytes()); + } + + #[tokio::test] + async fn v4_volatile_only_no_drain() { + let (_main_tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + let (_volatile_tx, mut vr) = + make_volatile_chan(smallvec::smallvec![Packet::Message("v_only".into())]); + drop(_main_tx); + + let rx = mutex.lock().await; + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "4v_only".as_bytes()); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_string_volatile_mixed() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + tx.try_send(smallvec::smallvec![Packet::Message("foo".into())]) + .unwrap(); + tx.try_send(smallvec::smallvec![Packet::Message("bar".into())]) + .unwrap(); + let (_volatile_tx, mut vr) = + make_volatile_chan(smallvec::smallvec![Packet::Message("v1".into())]); + + let rx = mutex.lock().await; + let Payload { + data, has_binary, .. + } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "3:4v14:4foo4:4bar".as_bytes()); + assert!(!has_binary); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_string_volatile_overwrite() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + tx.try_send(smallvec::smallvec![Packet::Message("normal".into())]) + .unwrap(); + let (volatile_tx, mut vr) = tokio::sync::watch::channel(None); + volatile_tx + .send(Some(smallvec::smallvec![Packet::Message("drop1".into())])) + .unwrap(); + volatile_tx + .send(Some(smallvec::smallvec![Packet::Message("keep1".into())])) + .unwrap(); + + let rx = mutex.lock().await; + let Payload { + data, has_binary, .. + } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "6:4keep17:4normal".as_bytes()); + assert!(!has_binary); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_binary_volatile_mixed() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + tx.try_send(smallvec::smallvec![Packet::Message("foo".into())]) + .unwrap(); + let (_volatile_tx, mut vr) = make_volatile_chan(smallvec::smallvec![Packet::BinaryV3( + Bytes::from_static(&[1, 2, 3, 4]) + )]); + + let rx = mutex.lock().await; + let Payload { + data, has_binary, .. + } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert!(has_binary); + assert_eq!( + &data[..8], + &[0x01, 0x05, 0xff, 0x04, 0x01, 0x02, 0x03, 0x04][..] + ); + assert_eq!(&data[8..], &[0x00, 0x04, 0xff, 0x34, 0x66, 0x6f, 0x6f][..]); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_binary_volatile_overwrite() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + tx.try_send(smallvec::smallvec![Packet::Message("normal".into())]) + .unwrap(); + let (volatile_tx, mut vr) = tokio::sync::watch::channel(None); + volatile_tx + .send(Some(smallvec::smallvec![Packet::Message("drop_v".into())])) + .unwrap(); + volatile_tx + .send(Some(smallvec::smallvec![Packet::BinaryV3( + Bytes::from_static(&[9, 9]) + )])) + .unwrap(); + + let rx = mutex.lock().await; + let Payload { + data, has_binary, .. + } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert!(has_binary); + assert_eq!(&data[..6], &[0x01, 0x03, 0xff, 0x04, 0x09, 0x09][..]); + assert_eq!( + &data[6..], + &[0x00, 0x07, 0xff, 0x34, 0x6e, 0x6f, 0x72, 0x6d, 0x61, 0x6c][..] + ); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_binary_volatile_determines_has_binary() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + tx.try_send(smallvec::smallvec![Packet::Message("foo".into())]) + .unwrap(); + let (_volatile_tx, mut vr) = make_volatile_chan(smallvec::smallvec![Packet::BinaryV3( + Bytes::from_static(&[1, 2, 3]) + )]); + + let rx = mutex.lock().await; + let Payload { + data: _, + has_binary, + .. + } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert!(has_binary); + } } diff --git a/crates/engineioxide/tests/volatile.rs b/crates/engineioxide/tests/volatile.rs new file mode 100644 index 00000000..743b2807 --- /dev/null +++ b/crates/engineioxide/tests/volatile.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use bytes::Bytes; +use engineioxide::{ + Str, + handler::EngineIoHandler, + socket::{DisconnectReason, Socket}, +}; +use tokio::sync::mpsc; + +mod fixture; +use fixture::{create_polling_connection, create_server, send_req}; + +#[derive(Debug, Clone)] +struct VolatileHandler { + socket_tx: mpsc::Sender>>, +} +impl EngineIoHandler for VolatileHandler { + type Data = (); + fn on_connect(self: Arc, socket: Arc>) { + self.socket_tx.try_send(socket).ok(); + } + fn on_disconnect(&self, _socket: Arc>, _reason: DisconnectReason) {} + fn on_message(self: &Arc, msg: Str, socket: Arc>) { + if msg == "trigger_volatile" { + socket.emit_volatile("volatile_response"); + } else if msg == "echo" { + socket.emit(msg).ok(); + } + } + fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { + socket.emit_binary(data).ok(); + } +} + +#[tokio::test] +async fn volatile_message_arrives_via_polling() { + let (socket_tx, _socket_rx) = mpsc::channel(10); + let mut svc = create_server(VolatileHandler { socket_tx }).await; + let sid = create_polling_connection(&mut svc).await; + + send_req( + &mut svc, + format!("transport=polling&sid={sid}"), + http::Method::POST, + Some("4trigger_volatile".into()), + ) + .await; + + let response = send_req( + &mut svc, + format!("transport=polling&sid={sid}"), + http::Method::GET, + None, + ) + .await; + // send_req skips the first character (packet type '4') + assert_eq!(response, "volatile_response"); +} + +#[tokio::test] +async fn mixed_volatile_and_normal_via_polling() { + let (socket_tx, mut socket_rx) = mpsc::channel(10); + let mut svc = create_server(VolatileHandler { socket_tx }).await; + let sid = create_polling_connection(&mut svc).await; + + let socket = socket_rx + .recv() + .await + .expect("socket not received from on_connect"); + + // Send a normal message AND a volatile + socket.emit("normal_msg").ok(); + assert!(socket.emit_volatile("volatile_msg")); + + let response = send_req( + &mut svc, + format!("transport=polling&sid={sid}"), + http::Method::GET, + None, + ) + .await; + // Volatile should have priority and appear first, before normal. + // send_req skips only the first character (the leading '4' of the volatile). + assert_eq!(response, "volatile_msg\x1e4normal_msg"); +} + +#[tokio::test] +async fn volatile_overwrite_only_latest_survives() { + let (socket_tx, mut socket_rx) = mpsc::channel(10); + let mut svc = create_server(VolatileHandler { socket_tx }).await; + let sid = create_polling_connection(&mut svc).await; + + let socket = socket_rx + .recv() + .await + .expect("socket not received from on_connect"); + + assert!(socket.emit_volatile("dropped")); + assert!(socket.emit_volatile("kept")); + assert!(socket.emit("normal").is_ok()); + + let response = send_req( + &mut svc, + format!("transport=polling&sid={sid}"), + http::Method::GET, + None, + ) + .await; + // After send_req's skip(1): "kept" then \x1e separator then "4normal" + assert_eq!(response, "kept\x1e4normal"); +} diff --git a/crates/socketioxide/tests/volatile.rs b/crates/socketioxide/tests/volatile.rs new file mode 100644 index 00000000..4c176c6a --- /dev/null +++ b/crates/socketioxide/tests/volatile.rs @@ -0,0 +1,82 @@ +//! Integration tests for volatile events on socketioxide. +//! Verifies the volatile operator API and that volatile emits +//! silently drop errors rather than propagating them. +mod utils; + +use socketioxide::{SocketIo, extract::SocketRef}; + +#[tokio::test] +async fn volatile_emit_returns_ok() { + use serde_json::json; + let (_svc, io) = SocketIo::new_svc(); + + let (tx, rx) = std::sync::mpsc::channel(); + io.ns("/", async move |socket: SocketRef| { + let result = socket.volatile().emit("test", &json!({"key": "val"})); + tx.send(result).unwrap(); + }); + + io.new_dummy_sock("/", ()).await; + assert!(rx.recv().unwrap().is_ok()); +} + +#[tokio::test] +async fn volatile_emit_broadcast_does_not_panic() { + let (_svc, io) = SocketIo::new_svc(); + + let (tx, rx) = std::sync::mpsc::channel(); + io.ns("/", async move |socket: SocketRef| { + // Broadcast with volatile flag should complete without panicking + socket + .within("room") + .volatile() + .emit("event", &"data") + .await + .ok(); + tx.send(()).unwrap(); + }); + + io.new_dummy_sock("/", ()).await; + rx.recv().unwrap(); +} + +#[tokio::test] +async fn io_volatile_composes() { + let (_svc, io) = SocketIo::new_svc(); + io.ns("/", |_: SocketRef| async {}); + + io.new_dummy_sock("/", ()).await; + + // Volatile on the io handle delegates to the default namespace + let _ = io.volatile(); + let _ = io.of("/").unwrap().volatile(); +} + +#[tokio::test] +async fn volatile_emit_on_disconnected_socket_returns_ok() { + use serde_json::json; + let (_svc, io) = SocketIo::new_svc(); + + let (tx, rx) = std::sync::mpsc::channel(); + io.ns("/", { + let tx = tx.clone(); + async move |_: SocketRef| { + tx.send(()).unwrap(); + } + }); + + io.new_dummy_sock("/", ()).await; + rx.recv().unwrap(); + + // At this point the dummy socket's connect handler has run. + // The socket is technically connected — test that volatile + // emit returns Ok(()) without error. + let (tx2, rx2) = std::sync::mpsc::channel(); + io.ns("/test", async move |socket: SocketRef| { + let result = socket.volatile().emit("event", &json!({"data": 42})); + tx2.send(result).unwrap(); + }); + + io.new_dummy_sock("/test", ()).await; + assert!(rx2.recv().unwrap().is_ok()); +} From f41d1c35f355df438c5579ab386ed7dee94aa62b Mon Sep 17 00:00:00 2001 From: totodore Date: Mon, 29 Jun 2026 22:44:18 +0200 Subject: [PATCH 19/49] refactor(engineio): move shareable code with client to core --- Cargo.lock | 1471 ++++++++--------- crates/engineioxide-client/Cargo.toml | 44 - crates/engineioxide-client/README.md | 0 crates/engineioxide-client/src/client.rs | 112 -- crates/engineioxide-client/src/io.rs | 1 - crates/engineioxide-client/src/lib.rs | 19 - .../engineioxide-client/src/transport/mod.rs | 81 - .../src/transport/polling.rs | 307 ---- crates/engineioxide-client/tests/handshake.rs | 121 -- 9 files changed, 714 insertions(+), 1442 deletions(-) delete mode 100644 crates/engineioxide-client/Cargo.toml delete mode 100644 crates/engineioxide-client/README.md delete mode 100644 crates/engineioxide-client/src/client.rs delete mode 100644 crates/engineioxide-client/src/io.rs delete mode 100644 crates/engineioxide-client/src/lib.rs delete mode 100644 crates/engineioxide-client/src/transport/mod.rs delete mode 100644 crates/engineioxide-client/src/transport/polling.rs delete mode 100644 crates/engineioxide-client/tests/handshake.rs diff --git a/Cargo.lock b/Cargo.lock index 70a3c548..1782324e 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,15 +54,15 @@ 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" -version = "1.0.103" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "approx" @@ -99,9 +84,12 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +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.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.4" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" 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,15 +200,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bitvec" @@ -259,9 +227,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", ] @@ -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.4", "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.4", "serde", "serde_bytes", "simdutf8", - "thiserror 2.0.18", + "thiserror", "time", "uuid", ] [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -325,9 +293,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -350,18 +318,19 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.26" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" 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" @@ -371,26 +340,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "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", + "rand_core 0.10.0", ] [[package]] @@ -422,18 +378,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.40" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstyle", "clap_lex", @@ -441,26 +397,26 @@ 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" -version = "0.5.4" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "codspeed" -version = "4.7.0" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57af92d1db7f6871b7e82c79cd87f2501db66f36b0eab924be6ea83dd6b2f3f3" +checksum = "b684e94583e85a5ca7e1a6454a89d76a5121240f2fb67eb564129d9bafdb9db0" dependencies = [ "anyhow", "cc", "colored", - "getrandom 0.2.16", + "getrandom 0.2.17", "glob", "libc", "nix", @@ -471,9 +427,9 @@ dependencies = [ [[package]] name = "codspeed-criterion-compat" -version = "4.7.0" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d31ae2e9ab23c29fa13bdfa06d012524176f5c0f4e25ec262cd829d947ebc5e" +checksum = "2e65444156eb73ad7f57618188f8d4a281726d133ef55b96d1dcff89528609ab" dependencies = [ "clap", "codspeed", @@ -484,9 +440,9 @@ dependencies = [ [[package]] name = "codspeed-criterion-compat-walltime" -version = "4.7.0" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc8605e40bab5114dcb0f76268e18880082b5798dec10757b5b58d2c3bbc7a1c" +checksum = "96389aaa4bbb872ea4924dc0335b2bb181bcf28d6eedbe8fea29afcc5bde36a6" dependencies = [ "anes", "cast", @@ -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" @@ -615,9 +568,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc16" @@ -671,15 +624,15 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[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", @@ -687,9 +640,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ "hybrid-array", ] @@ -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,43 +668,41 @@ 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.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", ] [[package]] @@ -762,42 +713,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,19 +768,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common 0.1.6", + "crypto-common 0.1.7", "subtle", ] [[package]] name = "digest" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer 0.12.1", + "block-buffer 0.12.0", "const-oid", - "crypto-common 0.2.2", + "crypto-common 0.2.1", "ctutils", ] @@ -831,7 +792,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -868,7 +829,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tokio-tungstenite", @@ -879,36 +840,11 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "engineioxide-client" -version = "0.17.0" -dependencies = [ - "bytes", - "engineioxide", - "engineioxide-core", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "serde", - "serde_json", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "tracing", - "tracing-subscriber", -] - [[package]] name = "engineioxide-core" version = "0.2.1" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-util", "http", @@ -946,6 +882,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" @@ -983,6 +929,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" @@ -993,10 +945,10 @@ dependencies = [ ] [[package]] -name = "fnv" -version = "1.0.7" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "foldhash" @@ -1006,9 +958,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", ] @@ -1047,7 +999,7 @@ checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1058,9 +1010,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1089,9 +1041,9 @@ checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1123,7 +1075,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1180,41 +1132,43 @@ 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", ] [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", + "rand_core 0.10.0", + "wasip2", + "wasip3", ] [[package]] @@ -1225,25 +1179,23 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[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" +version = "0.15.5" 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" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] [[package]] name = "hashbrown" @@ -1253,14 +1205,20 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" -version = "0.11.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ "hashbrown 0.16.1", ] @@ -1286,9 +1244,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" @@ -1320,14 +1278,14 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest 0.11.2", ] [[package]] name = "http" -version = "1.4.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -1370,18 +1328,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1395,17 +1353,15 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", - "want", ] [[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", "http", "http-body", "hyper", @@ -1414,38 +1370,15 @@ dependencies = [ "tower-service", ] -[[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", @@ -1453,9 +1386,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", @@ -1466,11 +1399,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", @@ -1481,42 +1413,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", @@ -1524,6 +1452,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1532,9 +1466,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", @@ -1553,35 +1487,25 @@ 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", + "hashbrown 0.17.0", "serde", + "serde_core", ] [[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]] @@ -1595,18 +1519,17 @@ 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" -version = "0.3.103" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ - "cfg-if", - "futures-util", + "once_cell", "wasm-bindgen", ] @@ -1616,42 +1539,47 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.186" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" 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.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loom" @@ -1677,7 +1605,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1691,7 +1619,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1702,7 +1630,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -1713,16 +1641,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]] @@ -1754,14 +1682,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.3", + "digest 0.11.2", ] [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -1777,54 +1705,69 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" 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.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da0cd419a51a5fb44819e290fbdb0665a54f21dead8923446a799c7f4d26ad9" +dependencies = [ + "bson 2.15.0", + "mongocrypt-sys", + "once_cell", + "serde", +] + +[[package]] +name = "mongocrypt-sys" +version = "0.1.5+1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9" + [[package]] name = "mongodb" -version = "3.2.4" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f8c69f13acf07eae386a2974f48ffd9187ea2aba8defbea9aa34e7e272c5f3" +checksum = "2c5941683db2ab2697f71e58dc0319024e808d3b28e7cf20f4bfb445fe54a30b" 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", "macro_magic", "md-5 0.10.6", + "mongocrypt", "mongodb-internal-macros", - "once_cell", "pbkdf2", "percent-encoding", - "rand 0.8.5", + "rand 0.9.4", "rustc_version_runtime", "rustls", - "rustls-pemfile", + "rustversion", "serde", "serde_bytes", "serde_with", "sha1", "sha2 0.10.9", - "socket2 0.5.10", + "socket2 0.6.3", "stringprep", "strsim", "take_mut", - "thiserror 1.0.69", + "thiserror", "tokio", "tokio-rustls", "tokio-util", @@ -1835,23 +1778,23 @@ dependencies = [ [[package]] name = "mongodb-internal-macros" -version = "3.2.4" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9202de265a3a8bbb43f9fe56db27c93137d4f9fb04c093f47e9c7de0c61ac7d" +checksum = "47021a12bbf0dffde9c890fa2d36ff6ae342c532016226b04a42301b2b912660" dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "nix" -version = "0.31.3" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.9.1", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -1869,19 +1812,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.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -1898,7 +1840,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.1", + "bitflags", ] [[package]] @@ -1912,9 +1854,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" @@ -1922,12 +1864,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" @@ -1936,9 +1872,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", @@ -1946,37 +1882,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.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", ] [[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" @@ -1999,9 +1929,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" @@ -2033,11 +1963,11 @@ dependencies = [ [[package]] name = "postgres-protocol" -version = "0.6.12" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" dependencies = [ - "base64 0.22.1", + "base64", "byteorder", "bytes", "fallible-iterator", @@ -2051,9 +1981,9 @@ dependencies = [ [[package]] name = "postgres-types" -version = "0.2.14" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" dependencies = [ "bytes", "fallible-iterator", @@ -2062,9 +1992,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", ] @@ -2084,29 +2014,39 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[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.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 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" @@ -2133,12 +2073,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2148,8 +2088,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "getrandom 0.4.2", + "rand_core 0.10.0", ] [[package]] @@ -2169,7 +2109,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]] @@ -2178,29 +2118,29 @@ 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]] name = "rand_core" -version = "0.10.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -2208,9 +2148,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", @@ -2218,9 +2158,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.3.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fa6f8e4b491d7a8ef3a9550a4d71969bd0064f46e32b8dbbcc7fc60dad94fed" +checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a" dependencies = [ "arcstr", "async-lock", @@ -2233,9 +2173,9 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "rand 0.10.1", + "rand 0.9.4", "ryu", - "socket2 0.6.4", + "socket2 0.6.3", "tokio", "tokio-util", "url", @@ -2258,56 +2198,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.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[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", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "ring" @@ -2317,7 +2242,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2325,33 +2250,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", @@ -2378,46 +2299,50 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.12" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" 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.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" 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.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[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" @@ -2440,72 +2365,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.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 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]] @@ -2522,32 +2450,24 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.12.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" 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]] name = "serde_with_macros" -version = "3.12.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -2580,7 +2500,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.3", + "digest 0.11.2", ] [[package]] @@ -2600,10 +2520,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[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", ] @@ -2615,18 +2536,15 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[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" @@ -2649,9 +2567,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2680,7 +2598,7 @@ dependencies = [ "socketioxide-parser-common", "socketioxide-parser-msgpack", "state", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tokio-tungstenite", @@ -2703,7 +2621,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -2736,7 +2654,7 @@ dependencies = [ "smallvec", "socketioxide", "socketioxide-core", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -2784,7 +2702,7 @@ dependencies = [ "socketioxide", "socketioxide-core", "sqlx", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-postgres", "tracing", @@ -2809,7 +2727,7 @@ dependencies = [ "smallvec", "socketioxide", "socketioxide-core", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "tracing-subscriber", @@ -2832,7 +2750,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", @@ -2845,7 +2763,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap 2.9.0", + "indexmap", "log", "memchr", "percent-encoding", @@ -2853,7 +2771,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tracing", @@ -2870,7 +2788,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.101", + "syn", ] [[package]] @@ -2891,7 +2809,7 @@ dependencies = [ "sha2 0.10.9", "sqlx-core", "sqlx-postgres", - "syn 2.0.101", + "syn", "tokio", "url", ] @@ -2903,8 +2821,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", @@ -2926,16 +2844,16 @@ dependencies = [ "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" @@ -2981,20 +2899,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.117" 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 = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3015,7 +2922,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] @@ -3030,33 +2937,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]] @@ -3067,45 +2954,44 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3122,9 +3008,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", @@ -3142,9 +3028,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3157,9 +3043,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -3167,7 +3053,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3180,14 +3066,14 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "tokio-postgres" -version = "0.7.18" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" dependencies = [ "async-trait", "byteorder", @@ -3203,7 +3089,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.10.1", - "socket2 0.6.4", + "socket2 0.6.3", "tokio", "tokio-util", "whoami", @@ -3211,9 +3097,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", @@ -3221,9 +3107,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", @@ -3258,9 +3144,9 @@ dependencies = [ [[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", @@ -3286,9 +3172,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", @@ -3298,20 +3184,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", @@ -3330,14 +3216,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", @@ -3346,12 +3232,6 @@ 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.29.0" @@ -3363,27 +3243,36 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.1", + "rand 0.9.4", "sha1", - "thiserror 2.0.18", + "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]] name = "typenum" -version = "1.20.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-bidi" @@ -3393,24 +3282,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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -3420,13 +3321,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]] @@ -3443,13 +3345,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.17.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -3476,27 +3378,36 @@ dependencies = [ ] [[package]] -name = "want" -version = "0.3.1" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "try-lock", + "wasip2", ] [[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -3505,14 +3416,14 @@ 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]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -3523,9 +3434,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3533,160 +3444,116 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.101", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] [[package]] -name = "web-sys" -version = "0.3.103" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "js-sys", - "wasm-bindgen", + "leb128fmt", + "wasmparser", ] [[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[package]] -name = "whoami" -version = "2.1.2" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[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" +name = "web-sys" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ - "windows-sys 0.59.0", + "js-sys", + "wasm-bindgen", ] [[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" +name = "webpki-roots" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ - "windows-targets 0.48.5", + "rustls-pki-types", ] [[package]] -name = "windows-core" -version = "0.61.2" +name = "whoami" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.1", - "windows-result", - "windows-strings", + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", ] [[package]] -name = "windows-implement" -version = "0.60.0" +name = "winapi-util" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "windows-sys 0.61.2", ] [[package]] -name = "windows-interface" -version = "0.59.1" +name = "windows" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "windows-targets 0.48.5", ] -[[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" @@ -3711,7 +3578,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]] @@ -3836,19 +3703,98 @@ 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.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "bitflags 2.9.1", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[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" @@ -3867,11 +3813,10 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -3879,62 +3824,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.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" 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.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[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", @@ -3943,9 +3894,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", @@ -3954,11 +3905,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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/engineioxide-client/Cargo.toml b/crates/engineioxide-client/Cargo.toml deleted file mode 100644 index 2d7b7fcb..00000000 --- a/crates/engineioxide-client/Cargo.toml +++ /dev/null @@ -1,44 +0,0 @@ -[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, 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"] } - -# Tracing -tracing = { workspace = true, optional = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "parking_lot"] } -tracing-subscriber = { workspace = true, features = ["env-filter"] } -engineioxide = { path = "../engineioxide", features = ["tracing", "v3"] } - -[features] -v3 = ["engineioxide-core/v3"] -tracing = ["dep:tracing", "engineioxide-core/tracing"] -__test_harness = [] diff --git a/crates/engineioxide-client/README.md b/crates/engineioxide-client/README.md deleted file mode 100644 index e69de29b..00000000 diff --git a/crates/engineioxide-client/src/client.rs b/crates/engineioxide-client/src/client.rs deleted file mode 100644 index ea6f4223..00000000 --- a/crates/engineioxide-client/src/client.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::{ - fmt, - pin::Pin, - sync::Mutex, - task::{Context, Poll}, -}; - -use engineioxide_core::{Packet, PacketBuf, 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, - transport::{Transport, polling::PollingSvc}, -}; - -type SendPongFut = Pin< - Box< - dyn Future, Packet> as Sink>::Error>> - + 'static, - >, ->; - -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>, - - pub sid: Sid, - // pub tx: mpsc::Sender, - // pub(crate) rx: Mutex>, - } -} - -impl Client -where - S::Error: fmt::Debug, - ::Error: fmt::Debug, -{ - pub async fn connect(svc: S) -> Result { - 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_tx, - transport_rx, - sid: packet.sid, - }; - - Ok(client) - } -} - -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)) { - 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), - } - } -} - -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/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 deleted file mode 100644 index fb39e455..00000000 --- a/crates/engineioxide-client/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -// #![warn(clippy::pedantic)] -#![allow(clippy::similar_names)] -//! Engine.IO client library for Rust. - -mod client; -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/mod.rs b/crates/engineioxide-client/src/transport/mod.rs deleted file mode 100644 index d7bd1b1f..00000000 --- a/crates/engineioxide-client/src/transport/mod.rs +++ /dev/null @@ -1,81 +0,0 @@ -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}; - -pub mod polling; - -pin_project_lite::pin_project! { - #[project = TransportProj] - pub enum Transport { - Polling { - #[pin] - inner: HttpClient - }, - Websocket { - #[pin] - inner: HttpClient - } - } -} - -impl Transport { - pub fn transport_type(&self) -> TransportType { - match self { - Transport::Polling { .. } => TransportType::Polling, - Transport::Websocket { .. } => TransportType::Websocket, - } - } -} - -impl Stream for Transport -where - S::Error: fmt::Debug, - ::Error: 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), - } - } -} -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 deleted file mode 100644 index 7d63e33c..00000000 --- a/crates/engineioxide-client/src/transport/polling.rs +++ /dev/null @@ -1,307 +0,0 @@ -use std::collections::VecDeque; -use std::fmt; -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 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; -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; - -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] - #[derive(Default)] - enum PollState { - #[default] - No, - Pending { - #[pin] - fut: F - }, - Decoding { - stream: Pin>>> - } - } -} - -pin_project! { - #[project = PostStateProj] - enum PostState { - /// TODO: Ideally the queue should gradually encode packets in an async fashion - Queuing { - queue: VecDeque - }, - Encoding { - #[pin] - fut: Pin>>, - }, - Pending { - #[pin] - fut: F - } - } -} - -impl Default for PostState { - fn default() -> Self { - PostState::Queuing { - queue: VecDeque::new(), - } - } -} - -pin_project! { - pub struct HttpClient - { - svc: S, - - #[pin] - poll_state: PollState, - - #[pin] - post_state: PostState, - - sid: Option, - } -} - -impl HttpClient -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 { - #[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()) - .unwrap(); - - let res = self.svc.call(req).await; - let body = res.unwrap().collect().await.unwrap(); - let packet = Packet::parse( - ProtocolVersion::V4, - String::from_utf8(body.to_bytes().to_vec()).unwrap(), - )?; - - match packet { - Packet::Open(open) => { - self.sid = Some(open.sid); - Ok(open) - } - _ => Err(PacketParseError::InvalidPacketType(Some('1'))), - } - } -} - -impl Stream for HttpClient -where - 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"); - - 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.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)) { - 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 - let stream = - payload::decoder(body, None, ProtocolVersion::V4, 200).boxed_local(); - - self.project() - .poll_state - .set(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))) - } - } - } - PollStateProj::Decoding { ref mut stream } => { - if let Some(packet) = poll!(stream.poll_next_unpin(cx)) { - dbg!(&packet); - Poll::Ready(Some(packet)) - } else { - self.project().poll_state.set(PollState::No); - // Should not be needed. - cx.waker().wake_by_ref(); - Poll::Pending - } - } - } - } -} - -impl Sink for HttpClient { - type Error = (); - - fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - match self.post_state { - PostState::Queuing { .. } => Poll::Ready(Ok(())), - _ => Poll::Pending, - } - } - - 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() { - PostStateProj::Queuing { queue } => queue.push_back(smallvec![item]), - _ => panic!("unexpected post state"), - } - - Ok(()) - } - - 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 req = Request::post( - "http://localhost:3000/engine.io?EIO=4&transport=polling&sid={id}", - ) - .body(Full::new(body.data)) //TODO: fix cloning - .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)) { - Ok(res) => { - assert!(res.status().is_success()); - self.project().post_state.set(PostState::default()); - Poll::Ready(Ok(())) // TODO: check response == ok - } - Err(err) => { - self.project().post_state.set(PostState::default()); - todo!("handle error") - } - } - } - } - } - - fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } -} - -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 { .. } => f.debug_struct("Encoding").finish(), - Self::Pending { .. } => write!(f, "Pending"), - Self::Queuing { .. } => write!(f, "Queuing"), - } - } -} -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 deleted file mode 100644 index 70d9d50b..00000000 --- a/crates/engineioxide-client/tests/handshake.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::str::FromStr; -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::{Client, HttpClient}; -use engineioxide_core::{Packet, Sid}; -use futures_util::{SinkExt, StreamExt, TryFutureExt}; -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::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)).unwrap(); - } - - fn on_binary(self: &Arc, data: Bytes, socket: Arc>) { - self.tx.try_send(Event::Binary(socket.id, 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)); -} - -#[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}"), - } - } -} - -#[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; -} From c54bbca06d5869c18ce150957dc07ae905b88034 Mon Sep 17 00:00:00 2001 From: totodore Date: Mon, 29 Jun 2026 23:11:38 +0200 Subject: [PATCH 20/49] fix(engineio/core): minor fixes --- crates/engineioxide-core/src/payload/mod.rs | 15 +++++++++------ crates/engineioxide-core/src/protocol.rs | 7 ++++++- crates/engineioxide/src/service/parser.rs | 5 ----- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index 1719226d..c7e7e175 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -19,20 +19,23 @@ const BINARY_PACKET_SEPARATOR_V3: u8 = 0xff; const STRING_PACKET_IDENTIFIER_V3: u8 = 0x00; #[cfg(feature = "v3")] const BINARY_PACKET_IDENTIFIER_V3: u8 = 0x01; +const BINARY_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/octet-stream"); /// Decode a payload into a stream of packets. pub fn decoder( body: impl http_body::Body + Unpin, content_type: Option<&HeaderValue>, - #[allow(unused_variables)] protocol: ProtocolVersion, + protocol: ProtocolVersion, max_payload: u64, ) -> impl Stream> { + #[cfg(feature = "tracing")] + tracing::debug!(?content_type, %protocol, "decoding payload"); + #[cfg(feature = "v3")] { use futures_util::future::Either; - #[cfg(feature = "tracing")] - tracing::debug!("decoding payload {:?}", content_type); - let is_binary = content_type == Some(&"application/octet-stream".parse().unwrap()); + + let is_binary = content_type == Some(&BINARY_CONTENT_TYPE); match protocol { ProtocolVersion::V4 => Either::Left(decoder::v4_decoder(body, max_payload)), ProtocolVersion::V3 if is_binary => { @@ -67,8 +70,8 @@ impl Payload { /// Encodes a payload into a byte stream. pub async fn encoder( rx: impl Stream, - #[allow(unused_variables)] protocol: ProtocolVersion, - #[cfg(feature = "v3")] supports_binary: bool, + protocol: ProtocolVersion, + supports_binary: bool, max_payload: u64, ) -> Payload { let rx = std::pin::pin!(rx.peekable()); diff --git a/crates/engineioxide-core/src/protocol.rs b/crates/engineioxide-core/src/protocol.rs index 72a7f86d..acc9714f 100644 --- a/crates/engineioxide-core/src/protocol.rs +++ b/crates/engineioxide-core/src/protocol.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +use std::{fmt, str::FromStr}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -98,6 +98,11 @@ pub enum ProtocolVersion { V4 = 4, } +impl fmt::Display for ProtocolVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&(*self as u8), f) + } +} impl FromStr for ProtocolVersion { type Err = UnknownProtocolVersionError; diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index c856189d..d835c062 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -90,11 +90,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 From e16fb269ef9614ad0dae773b981d9ebf979e3594 Mon Sep 17 00:00:00 2001 From: totodore Date: Mon, 29 Jun 2026 23:27:11 +0200 Subject: [PATCH 21/49] fix(engineio/core): minor fixes --- crates/engineioxide-core/src/payload/decoder.rs | 8 +++++++- crates/engineioxide-core/src/payload/mod.rs | 13 +++++++------ crates/engineioxide/src/engine.rs | 3 +-- crates/engineioxide/src/service/parser.rs | 14 ++++---------- crates/engineioxide/src/socket.rs | 4 +--- crates/engineioxide/src/transport/polling.rs | 17 +++++------------ crates/engineioxide/src/transport/ws.rs | 8 +------- 7 files changed, 26 insertions(+), 41 deletions(-) diff --git a/crates/engineioxide-core/src/payload/decoder.rs b/crates/engineioxide-core/src/payload/decoder.rs index c10afb2d..da8098fd 100644 --- a/crates/engineioxide-core/src/payload/decoder.rs +++ b/crates/engineioxide-core/src/payload/decoder.rs @@ -21,6 +21,8 @@ struct Payload { end_of_stream: bool, current_payload_size: u64, + /// counter to detect if packets have already been + /// yielded or if the poller needs to wait #[cfg(feature = "v3")] yield_packets: u32, } @@ -217,8 +219,12 @@ where _ => Err(PacketParseError::InvalidPacketLen), }; + state.yield_packets += 1; break Some((packet, state)); - } else if state.end_of_stream && state.buffer.remaining() == 0 { + } else if state.end_of_stream + && state.buffer.remaining() == 0 + && state.yield_packets > 0 + { break None; } else if state.end_of_stream { // EOS reached with leftover bytes that cannot form a complete diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index c7e7e175..4ab1c33a 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -19,13 +19,14 @@ const BINARY_PACKET_SEPARATOR_V3: u8 = 0xff; const STRING_PACKET_IDENTIFIER_V3: u8 = 0x00; #[cfg(feature = "v3")] const BINARY_PACKET_IDENTIFIER_V3: u8 = 0x01; +#[cfg(feature = "v3")] const BINARY_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/octet-stream"); /// Decode a payload into a stream of packets. pub fn decoder( body: impl http_body::Body + Unpin, - content_type: Option<&HeaderValue>, - protocol: ProtocolVersion, + #[allow(unused)] content_type: Option<&HeaderValue>, + #[allow(unused)] protocol: ProtocolVersion, max_payload: u64, ) -> impl Stream> { #[cfg(feature = "tracing")] @@ -70,8 +71,8 @@ impl Payload { /// Encodes a payload into a byte stream. pub async fn encoder( rx: impl Stream, - protocol: ProtocolVersion, - supports_binary: bool, + #[allow(unused)] protocol: ProtocolVersion, + #[allow(unused)] supports_binary: bool, max_payload: u64, ) -> Payload { let rx = std::pin::pin!(rx.peekable()); @@ -96,8 +97,8 @@ pub async fn encoder( /// Encodes a single packet into a byte array. pub fn packet_encoder( packet: Packet, - #[allow(unused_variables)] protocol: ProtocolVersion, - #[cfg(feature = "v3")] supports_binary: bool, + #[allow(unused)] protocol: ProtocolVersion, + #[allow(unused)] supports_binary: bool, ) -> Bytes { #[cfg(feature = "v3")] { diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index 0af3f58f..b2a694e7 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -44,7 +44,7 @@ impl EngineIo { protocol: ProtocolVersion, transport: TransportType, req: Parts, - #[cfg(feature = "v3")] supports_binary: bool, + supports_binary: bool, ) -> Arc> { let engine = self.clone(); let close_fn = Box::new(move |sid, reason| engine.close_session(sid, reason)); @@ -55,7 +55,6 @@ impl EngineIo { &self.config, req, close_fn, - #[cfg(feature = "v3")] supports_binary, ); let socket = Arc::new(socket); diff --git a/crates/engineioxide/src/service/parser.rs b/crates/engineioxide/src/service/parser.rs index d835c062..2cb5623a 100644 --- a/crates/engineioxide/src/service/parser.rs +++ b/crates/engineioxide/src/service/parser.rs @@ -34,15 +34,8 @@ where sid: None, transport: TransportType::Polling, method: Method::GET, - #[cfg(feature = "v3")] b64, - }) => ResponseFuture::ready(polling::open_req( - engine, - protocol, - req, - #[cfg(feature = "v3")] - !b64, - )), + }) => ResponseFuture::ready(polling::open_req(engine, protocol, req, !b64)), Ok(RequestInfo { protocol, sid: Some(sid), @@ -129,7 +122,6 @@ pub struct RequestInfo { /// The request method. pub method: Method, /// If the client asked for base64 encoding only. - #[cfg(feature = "v3")] pub b64: bool, } @@ -170,6 +162,9 @@ impl RequestInfo { .map(|v| v == "1" || v == "true") .unwrap_or_default(); + #[cfg(not(feature = "v3"))] + let b64: bool = false; + let method = req.method().clone(); if !matches!(method, Method::GET) && sid.is_none() { Err(BadHandshakeMethod) @@ -179,7 +174,6 @@ impl RequestInfo { sid, transport, method, - #[cfg(feature = "v3")] b64, }) } diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index aebdcdc5..5fb6601b 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -217,7 +217,6 @@ where pub req_parts: Parts, /// If the client supports binary packets (via polling XHR2) - #[cfg(feature = "v3")] pub(crate) supports_binary: bool, } @@ -231,7 +230,7 @@ where config: &EngineIoConfig, req_parts: Parts, close_fn: Box, - #[cfg(feature = "v3")] supports_binary: bool, + supports_binary: bool, ) -> Self { let (internal_tx, internal_rx) = mpsc::channel(config.max_buffer_size); let (heartbeat_tx, heartbeat_rx) = mpsc::channel(1); @@ -254,7 +253,6 @@ where data: D::default(), req_parts, - #[cfg(feature = "v3")] supports_binary, } } diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index c759ca0a..8120a8c6 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -41,7 +41,7 @@ pub fn open_req( engine: Arc>, protocol: ProtocolVersion, req: Request, - #[cfg(feature = "v3")] supports_binary: bool, + supports_binary: bool, ) -> Result>, Error> where H: EngineIoHandler, @@ -51,7 +51,6 @@ where protocol, TransportType::Polling, req.into_parts().0, - #[cfg(feature = "v3")] supports_binary, ); @@ -99,10 +98,7 @@ where #[cfg(feature = "tracing")] tracing::debug!(?sid, "socket is upgrading, sending NOOP packet"); - #[cfg(feature = "v3")] let data = payload::packet_encoder(Packet::Noop, socket.protocol, socket.supports_binary); - #[cfg(not(feature = "v3"))] - let data = payload::packet_encoder(Packet::Noop, socket.protocol); let is_binary = false; // The noop packet is guaranteed to be serialized as text return Ok(http_response(StatusCode::OK, data, is_binary)?); @@ -118,24 +114,21 @@ where } }; - //TODO: handle closing channel packet better than in the encoding process. + //TODO: handle closing channel packet better than in %the encoding process. #[cfg(feature = "tracing")] - tracing::debug!("[sid={sid}] polling request"); + tracing::debug!(%sid, %protocol, supports_binary = socket.supports_binary, "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; - #[cfg(feature = "tracing")] - tracing::debug!("[sid={sid}] sending data: {:?}", data); + tracing::trace!(%sid, %protocol, supports_binary = socket.supports_binary, "sending data: {:?}", data); + Ok(http_response(StatusCode::OK, data, has_binary)?) } diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 50af24f9..f73f5ce5 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -140,13 +140,7 @@ where } } } else { - let socket = engine.create_session( - protocol, - TransportType::Websocket, - req_data, - #[cfg(feature = "v3")] - false, - ); + let socket = engine.create_session(protocol, TransportType::Websocket, req_data, false); #[cfg(feature = "tracing")] tracing::debug!("new websocket connection"); From b150bb431dcb237c893e9d017ffb19925c33ca8f Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jun 2026 23:28:29 +0200 Subject: [PATCH 22/49] fix: move websocket flush after volatile check and add payload encoder tests for volatile packets --- .../src/transport/polling/payload/encoder.rs | 171 ++++++++++++++++++ crates/engineioxide/src/transport/ws.rs | 3 +- 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide/src/transport/polling/payload/encoder.rs index 95bcc392..a1fe1588 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/encoder.rs @@ -850,4 +850,175 @@ mod tests { } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); assert!(has_binary); } + + #[tokio::test] + async fn v4_volatile_arrives_during_parked_poll() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + let (volatile_tx, mut vr) = tokio::sync::watch::channel(None); + let volatile_tx = std::sync::Arc::new(std::sync::Mutex::new(volatile_tx)); + + let tx_clone = tx.clone(); + let vt = volatile_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + vt.lock() + .unwrap() + .send(Some(smallvec::smallvec![Packet::Message( + "volatile".into() + )])) + .unwrap(); + tx_clone + .try_send(smallvec::smallvec![Packet::Message("normal".into())]) + .unwrap(); + }); + + let rx = mutex.lock().await; + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "4normal".as_bytes()); + + drop(tx); + let rx = mutex.lock().await; + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "4volatile".as_bytes()); + } + + #[tokio::test] + async fn v4_volatile_pushes_past_max_payload() { + const SMALL_LIMIT: u64 = 12; + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + let (_volatile_tx, mut vr) = + make_volatile_chan(smallvec::smallvec![Packet::Message("big_vol".into())]); + tx.try_send(smallvec::smallvec![Packet::Message("normal".into())]) + .unwrap(); + tx.try_send(smallvec::smallvec![Packet::Message("extra".into())]) + .unwrap(); + + let rx = mutex.lock().await; + let Payload { data, .. } = v4_encoder(rx, SMALL_LIMIT, &mut vr).await.unwrap(); + assert_eq!(data, "4big_vol".as_bytes()); + + let rx = mutex.lock().await; + let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "4normal\x1e4extra".as_bytes()); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_string_volatile_during_parked_poll() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + let (volatile_tx, mut vr) = tokio::sync::watch::channel(None); + let volatile_tx = std::sync::Arc::new(std::sync::Mutex::new(volatile_tx)); + + let tx_clone = tx.clone(); + let vt = volatile_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + vt.lock() + .unwrap() + .send(Some(smallvec::smallvec![Packet::Message("v".into())])) + .unwrap(); + tx_clone + .try_send(smallvec::smallvec![Packet::Message("n".into())]) + .unwrap(); + }); + + let rx = mutex.lock().await; + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "2:4n".as_bytes()); + + drop(tx); + let rx = mutex.lock().await; + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "2:4v".as_bytes()); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_string_volatile_max_payload() { + const SMALL_LIMIT: u64 = 12; + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + let (_volatile_tx, mut vr) = + make_volatile_chan(smallvec::smallvec![Packet::Message("big".into())]); + tx.try_send(smallvec::smallvec![Packet::Message("normal".into())]) + .unwrap(); + tx.try_send(smallvec::smallvec![Packet::Message("extra".into())]) + .unwrap(); + + let rx = mutex.lock().await; + let Payload { + data, has_binary, .. + } = v3_string_encoder(rx, SMALL_LIMIT, &mut vr).await.unwrap(); + assert_eq!(data, "4:4big".as_bytes()); + assert!(!has_binary); + + let rx = mutex.lock().await; + let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "7:4normal6:4extra".as_bytes()); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_binary_volatile_during_parked_poll() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + let (volatile_tx, mut vr) = tokio::sync::watch::channel(None); + let volatile_tx = std::sync::Arc::new(std::sync::Mutex::new(volatile_tx)); + + let tx_clone = tx.clone(); + let vt = volatile_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + vt.lock() + .unwrap() + .send(Some(smallvec::smallvec![Packet::BinaryV3( + Bytes::from_static(&[1, 2]) + )])) + .unwrap(); + tx_clone + .try_send(smallvec::smallvec![Packet::Message("n".into())]) + .unwrap(); + }); + + let rx = mutex.lock().await; + let Payload { + data, has_binary, .. + } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert!(!has_binary); + assert_eq!(&data[..], "2:4n".as_bytes()); + + drop(tx); + let rx = mutex.lock().await; + let Payload { + data, has_binary, .. + } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert!(has_binary); + assert_eq!(&data[..6], &[0x01, 0x03, 0xff, 0x04, 0x01, 0x02][..]); + } + + #[cfg(feature = "v3")] + #[tokio::test] + async fn v3_binary_volatile_max_payload() { + const SMALL_LIMIT: u64 = 15; + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let mutex = Mutex::new(PeekableReceiver::new(rx)); + let (_volatile_tx, mut vr) = + make_volatile_chan(smallvec::smallvec![Packet::Message("big_volatile".into())]); + tx.try_send(smallvec::smallvec![Packet::Message("after".into())]) + .unwrap(); + + let rx = mutex.lock().await; + let Payload { + data, has_binary, .. + } = v3_binary_encoder(rx, SMALL_LIMIT, &mut vr).await.unwrap(); + assert!(!has_binary); + assert_eq!(data, "13:4big_volatile".as_bytes()); + + let rx = mutex.lock().await; + let Payload { data, .. } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); + assert_eq!(data, "6:4after".as_bytes()); + } } diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 0e2ffbe0..c0ffbaf6 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -304,7 +304,6 @@ async fn forward_to_socket( map_fn!(item); } } - tx.flush().await.ok(); // Check volatile channel after main is drained. // `has_changed` is non-blocking; if no volatile data is @@ -315,9 +314,9 @@ async fn forward_to_socket( for item in packets { map_fn!(item); } - tx.flush().await.ok(); } } + tx.flush().await.ok(); } } /// Send a Engine.IO [`OpenPacket`] to initiate a websocket connection From c6a4123f1cf4a69dd3b679022de7e61e767476ec Mon Sep 17 00:00:00 2001 From: totodore Date: Mon, 29 Jun 2026 23:43:02 +0200 Subject: [PATCH 23/49] fix(engineio/core): minor fixes --- crates/engineioxide/src/socket.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 5fb6601b..5a09d14c 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -550,7 +550,6 @@ where data: D::default(), req_parts: http::Request::<()>::default().into_parts().0, - #[cfg(feature = "v3")] supports_binary: true, }; let sock = Arc::new(sock); From 804212cb48aaa16c039e3b86e7bc7ce3e8cb0e18 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 30 Jun 2026 01:17:35 +0200 Subject: [PATCH 24/49] fix: ensure volatile packets are correctly captured and flushed during transport polling stalls --- .../src/transport/polling/payload/encoder.rs | 70 ++++++++++++++---- crates/engineioxide/src/transport/ws.rs | 2 + crates/socketioxide/tests/volatile.rs | 74 +++++++++++++++++++ examples/Cargo.lock | 6 +- examples/whiteboard/src/main.rs | 4 +- 5 files changed, 136 insertions(+), 20 deletions(-) diff --git a/crates/engineioxide/src/transport/polling/payload/encoder.rs b/crates/engineioxide/src/transport/polling/payload/encoder.rs index a1fe1588..c509cee3 100644 --- a/crates/engineioxide/src/transport/polling/payload/encoder.rs +++ b/crates/engineioxide/src/transport/polling/payload/encoder.rs @@ -122,6 +122,17 @@ pub async fn v4_encoder( let packet: String = packet.into(); data.push_str(&packet); } + + // Check for volatile packets that arrived during the parked recv + let mut volatile_data = String::new(); + encode_volatile_packets_v4(&mut volatile_data, volatile_rx); + if !volatile_data.is_empty() { + if !data.is_empty() { + volatile_data.push(std::char::from_u32(PACKET_SEPARATOR_V4 as u32).unwrap()); + } + volatile_data.push_str(&data); + data = volatile_data; + } } Ok(Payload::new(data.into(), false)) @@ -263,11 +274,11 @@ pub async fn v3_binary_encoder( } if has_binary { - for packet in packet_buffer { + for packet in packet_buffer.drain(..) { v3_bin_packet_encoder(packet, &mut data); } } else { - for packet in packet_buffer { + for packet in packet_buffer.drain(..) { v3_string_packet_encoder(packet, &mut data); } } @@ -275,7 +286,24 @@ 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?; - has_binary = packets.iter().any(|p| p.is_binary()); + has_binary = packets.iter().any(|p| p.is_binary()) || has_binary; + + // Check for volatile that arrived during the park + buffer_volatile_packets( + &mut packet_buffer, + &mut estimated_size, + &mut has_binary, + max_packet_size_len, + volatile_rx, + ); + + for packet in packet_buffer.drain(..) { + if has_binary { + v3_bin_packet_encoder(packet, &mut data); + } else { + v3_string_packet_encoder(packet, &mut data); + } + } for packet in packets { if has_binary { v3_bin_packet_encoder(packet, &mut data); @@ -360,6 +388,13 @@ pub async fn v3_string_encoder( for packet in packets { v3_string_packet_encoder(packet, &mut data); } + + let mut volatile_data = bytes::BytesMut::new(); + encode_volatile_v3_string(&mut volatile_data, volatile_rx); + if !volatile_data.is_empty() { + volatile_data.unsplit(data); + data = volatile_data; + } } Ok(Payload::new(data.freeze(), false)) @@ -875,12 +910,13 @@ mod tests { let rx = mutex.lock().await; let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); - assert_eq!(data, "4normal".as_bytes()); + // After the fix: volatile arrived during park, now captured same payload + assert_eq!(data, "4volatile\x1e4normal".as_bytes()); drop(tx); let rx = mutex.lock().await; - let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); - assert_eq!(data, "4volatile".as_bytes()); + let result = v4_encoder(rx, MAX_PAYLOAD, &mut vr).await; + assert!(result.is_err()); // no more data } #[tokio::test] @@ -927,12 +963,12 @@ mod tests { let rx = mutex.lock().await; let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); - assert_eq!(data, "2:4n".as_bytes()); + assert_eq!(data, "2:4v2:4n".as_bytes()); drop(tx); let rx = mutex.lock().await; - let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); - assert_eq!(data, "2:4v".as_bytes()); + let result = v3_string_encoder(rx, MAX_PAYLOAD, &mut vr).await; + assert!(result.is_err()); } #[cfg(feature = "v3")] @@ -987,16 +1023,18 @@ mod tests { let Payload { data, has_binary, .. } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); - assert!(!has_binary); - assert_eq!(&data[..], "2:4n".as_bytes()); + // After fix: volatile binary + normal message both captured, has_binary=true + assert!(has_binary); + // Volatile binary: 0x01 0x03 0xFF 0x04 0x01 0x02 (6 bytes) + // Normal message in binary frame: 0x00 0x02 0xFF 0x34 0x6E (5 bytes) + assert_eq!(data.len(), 11); + assert_eq!(&data[..6], &[0x01, 0x03, 0xff, 0x04, 0x01, 0x02][..]); + assert_eq!(&data[6..], &[0x00, 0x02, 0xff, 0x34, 0x6e][..]); drop(tx); let rx = mutex.lock().await; - let Payload { - data, has_binary, .. - } = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await.unwrap(); - assert!(has_binary); - assert_eq!(&data[..6], &[0x01, 0x03, 0xff, 0x04, 0x01, 0x02][..]); + let result = v3_binary_encoder(rx, MAX_PAYLOAD, &mut vr).await; + assert!(result.is_err()); } #[cfg(feature = "v3")] diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index c0ffbaf6..ec7451ef 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -311,6 +311,8 @@ async fn forward_to_socket( if volatile_rx.has_changed().unwrap_or(false) { let val = volatile_rx.borrow_and_update().clone(); if let Some(packets) = val { + #[cfg(feature = "tracing")] + tracing::info!(sid = ?socket.id, "ws volatile check: flushing {:?}", &packets); for item in packets { map_fn!(item); } diff --git a/crates/socketioxide/tests/volatile.rs b/crates/socketioxide/tests/volatile.rs index 4c176c6a..cfc273af 100644 --- a/crates/socketioxide/tests/volatile.rs +++ b/crates/socketioxide/tests/volatile.rs @@ -1,8 +1,11 @@ //! Integration tests for volatile events on socketioxide. //! Verifies the volatile operator API and that volatile emits //! silently drop errors rather than propagating them. +mod fixture; mod utils; +use fixture::{create_polling_connection, create_server, send_req}; +use http::Method; use socketioxide::{SocketIo, extract::SocketRef}; #[tokio::test] @@ -80,3 +83,74 @@ async fn volatile_emit_on_disconnected_socket_returns_ok() { io.new_dummy_sock("/test", ()).await; assert!(rx2.recv().unwrap().is_ok()); } + +#[tokio::test] +async fn volatile_broadcast_arrives_via_polling_transport() { + let (svc, io) = create_server().await; + + io.ns("/", |s: SocketRef| async move { + s.on( + "drawing", + |s: SocketRef, socketioxide::extract::Data::(data)| async move { + s.broadcast().volatile().emit("drawing", &data).await.ok(); + }, + ); + }); + + let sender_sid = create_polling_connection(&svc).await; + let receiver_sid = create_polling_connection(&svc).await; + + // Drain any queued handshake data from the connections + send_req( + &svc, + format!("transport=polling&sid={sender_sid}"), + Method::GET, + None, + ) + .await; + send_req( + &svc, + format!("transport=polling&sid={receiver_sid}"), + Method::GET, + None, + ) + .await; + // Respond to pings to keep sessions alive + send_req( + &svc, + format!("transport=polling&sid={sender_sid}"), + Method::POST, + Some("3".into()), + ) + .await; + send_req( + &svc, + format!("transport=polling&sid={receiver_sid}"), + Method::POST, + Some("3".into()), + ) + .await; + + // Send drawing event from sender + send_req( + &svc, + format!("transport=polling&sid={sender_sid}"), + Method::POST, + Some("42[\"drawing\",\"hello\"]".into()), + ) + .await; + + // Poll receiver — should receive the volatile broadcast + let response = send_req( + &svc, + format!("transport=polling&sid={receiver_sid}"), + Method::GET, + None, + ) + .await; + // send_req skips first char (engine.io message type '4') + assert!( + response.contains("drawing"), + "Expected volatile broadcast with 'drawing', got: {response}" + ); +} diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 24d5140e..a941f4b9 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "engineioxide" -version = "0.17.3" +version = "0.17.5" dependencies = [ "base64", "bytes", @@ -3789,7 +3789,7 @@ dependencies = [ [[package]] name = "socketioxide" -version = "0.18.3" +version = "0.18.4" dependencies = [ "bytes", "engineioxide", @@ -3814,7 +3814,7 @@ dependencies = [ [[package]] name = "socketioxide-core" -version = "0.18.0" +version = "0.18.1" dependencies = [ "arbitrary", "bytes", diff --git a/examples/whiteboard/src/main.rs b/examples/whiteboard/src/main.rs index 52ba121d..c78454ae 100644 --- a/examples/whiteboard/src/main.rs +++ b/examples/whiteboard/src/main.rs @@ -23,7 +23,9 @@ async fn main() -> Result<(), Box> { io.ns("/", async |s: SocketRef| { s.on("drawing", async |s: SocketRef, Data::(data)| { - s.broadcast().emit("drawing", &data).await.unwrap(); + info!("Drawing event received, broadcasting with volatile"); + s.broadcast().volatile().emit("drawing", &data).await.unwrap(); + info!("Volatile broadcast completed"); }); }); From 097e20eb7322c40560d639d6bb28b8086c34bb41 Mon Sep 17 00:00:00 2001 From: totodore Date: Fri, 3 Jul 2026 23:06:46 +0200 Subject: [PATCH 25/49] fix(engineio/core): minor fixes --- crates/engineioxide-core/src/payload/encoder.rs | 7 ++++++- examples/Cargo.lock | 14 ++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index 48f7d996..3580948e 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -384,7 +384,12 @@ mod tests { } { let Payload { data, .. } = v3_string_encoder(rx.as_mut(), MAX_PAYLOAD + 10).await; - assert_eq!(data, "10:b4AQIDBA==7:4hello€7:4hello€".as_bytes()); + assert_eq!(data, "10:b4AQIDBA==".as_bytes()); + } + { + // Next call drains one of the remaining Message packets. + let Payload { data, .. } = v3_string_encoder(rx.as_mut(), MAX_PAYLOAD + 10).await; + assert_eq!(data, "7:4hello€".as_bytes()); } } diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 24d5140e..57831639 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -1081,9 +1081,8 @@ dependencies = [ [[package]] name = "engineioxide" -version = "0.17.3" +version = "0.17.5" dependencies = [ - "base64", "bytes", "engineioxide-core", "futures-core", @@ -1112,8 +1111,15 @@ version = "0.2.1" dependencies = [ "base64", "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", "rand 0.10.1", "serde", + "serde_json", + "smallvec", ] [[package]] @@ -3789,7 +3795,7 @@ dependencies = [ [[package]] name = "socketioxide" -version = "0.18.3" +version = "0.18.4" dependencies = [ "bytes", "engineioxide", @@ -3814,7 +3820,7 @@ dependencies = [ [[package]] name = "socketioxide-core" -version = "0.18.0" +version = "0.18.1" dependencies = [ "arbitrary", "bytes", From f9776f68d36da9ed62b4b6885faabdb0e7823d75 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 4 Jul 2026 00:34:24 +0200 Subject: [PATCH 26/49] fix(engineio/core): remove hacky closing for internal chan --- .../engineioxide-core/src/payload/encoder.rs | 44 ++++----------- crates/engineioxide/src/socket.rs | 6 +-- crates/engineioxide/src/transport/polling.rs | 2 - crates/engineioxide/src/transport/ws.rs | 5 -- .../socketioxide/tests/disconnect_reason.rs | 53 +++++++++++++++++++ 5 files changed, 65 insertions(+), 45 deletions(-) diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index 3580948e..ffb45007 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -14,17 +14,9 @@ use smallvec::smallvec; 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 -/// -/// Manually close the channel if the packet is a close packet -/// It will allow to notify the [`Socket`](crate::socket::Socket) that the session is closed -/// -/// ## Arguments -/// * `rx` - The channel to poll -/// * `payload_len` - The current payload length -/// * `max_payload` - The maximum payload length -/// * `b64` - If binary packets should be encoded in base64 -fn try_recv_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 without exceeding the max payload size +fn try_poll_packet( mut rx: Pin<&mut Peekable>>, payload_len: usize, max_payload: u64, @@ -41,30 +33,16 @@ fn try_recv_packet( 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(); - // } - #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", packets); packets } -/// Same as [`try_recv_packet`] +/// Same as [`try_poll_packet`] /// but wait for a new packet if there is no packet in the buffer -async fn recv_packet(mut rx: Pin<&mut Peekable>>) -> PacketBuf { +async fn poll_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(); - } - #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", packet); packet @@ -85,7 +63,7 @@ pub async fn v4_encoder( // Send all packets in the buffer const PUNCTUATION_LEN: usize = 1; while let Some(packets) = - try_recv_packet(rx.as_mut(), data.len() + PUNCTUATION_LEN, max_payload, true) + try_poll_packet(rx.as_mut(), data.len() + PUNCTUATION_LEN, max_payload, true) { for packet in packets { let packet: String = packet.into(); @@ -99,7 +77,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(rx.as_mut()).await; + let packets = poll_packet(rx.as_mut()).await; for packet in packets { if !data.is_empty() { data.push(std::char::from_u32(PACKET_SEPARATOR_V4 as u32).unwrap()); @@ -189,7 +167,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(rx.as_mut(), estimated_size, max_payload, false) { + while let Some(packets) = try_poll_packet(rx.as_mut(), estimated_size, max_payload, false) { for packet in packets { if packet.is_binary() { has_binary = true; @@ -214,7 +192,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(rx.as_mut()).await; + let packets = poll_packet(rx.as_mut()).await; has_binary = packets.iter().any(|p| p.is_binary()); for packet in packets { if has_binary { @@ -245,7 +223,7 @@ pub async fn v3_string_encoder( const PUNCTUATION_LEN: usize = 2; // number of digits of the max packet size, used to approximate the payload size let max_packet_size_len = max_payload.checked_ilog10().unwrap_or(0) as usize + 1; - while let Some(packets) = try_recv_packet( + while let Some(packets) = try_poll_packet( rx.as_mut(), data.len() + PUNCTUATION_LEN + max_packet_size_len, max_payload, @@ -258,7 +236,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(rx.as_mut()).await; + let packets = poll_packet(rx.as_mut()).await; for packet in packets { v3_string_packet_encoder(packet, &mut data); } diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 5a09d14c..68cd7233 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -186,11 +186,7 @@ where /// * In case of polling transport it will be locked and released for each request /// * In case of websocket transport it will always be locked until the connection is closed /// - /// It will be closed when a [`Close`](Packet::Close) packet is received: - /// * From the [encoder](crate::service::encoder) if the transport is polling - /// * From the fn [`on_ws_req_init`](crate::engine::EngineIo) if the transport is websocket - /// * Automatically via the [`close_session fn`](crate::engine::EngineIo::close_session) as a fallback. - /// Because with polling transport, if the client is not currently polling then the encoder will never be able to close the channel + /// Closing behavior is driven by the [`Socket::cancellation_token`]. /// /// The channel is made of a [`SmallVec`] of [`Packet`]s so that adjacent packets can be sent atomically. pub(crate) internal_rx: Mutex>, diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index 8120a8c6..bd15343e 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -114,8 +114,6 @@ where } }; - //TODO: handle closing channel packet better than in %the encoding process. - #[cfg(feature = "tracing")] tracing::debug!(%sid, %protocol, supports_binary = socket.supports_binary, "polling request"); diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index f73f5ce5..59056a63 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -259,11 +259,6 @@ async fn forward_to_socket( tx.feed(Message::Binary(bin)).await } } - Packet::Close => { - tx.send(Message::Close(None)).await.ok(); - internal_rx.close(); - break; - }, // A Noop Packet maybe sent by the server to upgrade from a polling connection // In the case that the packet was not poll in time it will remain in the buffer and therefore // it should be discarded here diff --git a/crates/socketioxide/tests/disconnect_reason.rs b/crates/socketioxide/tests/disconnect_reason.rs index f9482b84..556bf079 100644 --- a/crates/socketioxide/tests/disconnect_reason.rs +++ b/crates/socketioxide/tests/disconnect_reason.rs @@ -278,6 +278,59 @@ pub async fn server_ws_closing() { } } +/// Same as [`server_http_closing`] but with a long-polling request in-flight while the server closes +#[tokio::test] +pub async fn server_http_closing_pending_poll() { + let (svc, io) = create_server().await; + let _rx = attach_handler(&io, 100); + let sids = + futures_util::future::join_all((0..100).map(|_| create_polling_connection(&svc))).await; + + // Flush the buffered socket.io open packet so the next poll will actually block. + futures_util::future::join_all(sids.iter().map(|s| { + send_req( + &svc, + format!("transport=polling&sid={s}"), + http::Method::GET, + None, + ) + })) + .await; + + // Start a long-polling request for each session. As the buffer is now empty, each request blocks + // waiting for the next packet while holding the internal channel lock. + let pending_polls = sids + .iter() + .map(|s| { + let svc = svc.clone(); + let sid = s.clone(); + tokio::spawn(async move { + send_req( + &svc, + format!("transport=polling&sid={sid}"), + http::Method::GET, + None, + ) + .await + }) + }) + .collect::>(); + + // Give the pending polls time to acquire the internal channel lock before closing. + tokio::time::sleep(Duration::from_millis(10)).await; + + tokio::time::timeout(Duration::from_millis(100), io.close()) + .await + .expect("timeout waiting for server closing"); + + // The pending polls should return (with the close packet) rather than hang. + // The engine.io close packet is `1`; its packet-type char is stripped by `send_req`. + let packets = futures_util::future::join_all(pending_polls).await; + for packet in packets { + assert_eq!(packet.unwrap(), ""); + } +} + #[tokio::test] pub async fn server_http_closing() { let (svc, io) = create_server().await; From 0ea50496d606803923ae512b3f4faf007f618f48 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 4 Jul 2026 01:11:41 +0200 Subject: [PATCH 27/49] fix(engineio): add proper transport closing with cancellation token --- crates/engineioxide/src/engine.rs | 20 ++++- crates/engineioxide/src/transport/polling.rs | 14 +++- crates/engineioxide/src/transport/ws.rs | 75 ++++++++++++------- .../engineioxide/tests/disconnect_reason.rs | 75 +++++++++++++++++++ .../socketioxide/tests/disconnect_reason.rs | 7 +- 5 files changed, 154 insertions(+), 37 deletions(-) diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index b2a694e7..bc7b458e 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -88,11 +88,23 @@ impl EngineIo { return; }; - // Try to close the internal channel if it is available - // E.g. with polling transport the channel is not always locked so it is necessary to close it here - socket.internal_rx.try_lock().map(|mut rx| rx.close()).ok(); + // Notify every transport task listening on this token to stop. Cancellation-aware + // transports (the ws `forward_to_socket` task and pending polling requests) will release + // the `internal_rx` lock as soon as they observe the cancellation. socket.cancellation_token.cancel(); - self.handler.on_disconnect(socket, reason); + + let handler = self.handler.clone(); + tokio::spawn(async move { + // Wait for the cancelled transport task to yield the `internal_rx` lock, then close the + // channel so that `Socket::closed` resolves and no more packets can be buffered. + // When no transport holds the lock (e.g. polling between requests) this resolves + // immediately. + socket.internal_rx.lock().await.close(); + + #[cfg(feature = "tracing")] + tracing::debug!(sid = ?socket.id, "session closed, notifying handler"); + handler.on_disconnect(socket, reason); + }); #[cfg(feature = "tracing")] tracing::debug!( diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index bd15343e..ca831907 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -7,6 +7,7 @@ use futures_util::StreamExt; use http::{Request, Response, StatusCode, header::CONTENT_TYPE}; use http_body::Body; use http_body_util::Full; +use tokio_util::future::FutureExt as _; use engineioxide_core::{Packet, ProtocolVersion, Sid, TransportType}; @@ -121,8 +122,17 @@ where let rx = rx_stream::ReceiverStream::new(&mut rx); - let Payload { data, has_binary } = - payload::encoder(rx, protocol, socket.supports_binary, max_payload).await; + // Stop waiting for the next packet as soon as the session is closed. The combinator is biased + // towards the encoder completion, so any buffered close packet is still flushed to the client. + let payload = payload::encoder(rx, protocol, socket.supports_binary, max_payload) + .with_cancellation_token(&socket.cancellation_token) + .await; + + let Some(Payload { data, has_binary }) = payload else { + #[cfg(feature = "tracing")] + tracing::debug!(%sid, "session closed while polling, returning empty payload"); + return Ok(http_response(StatusCode::OK, "", false)?); + }; #[cfg(feature = "tracing")] tracing::trace!(%sid, %protocol, supports_binary = socket.supports_binary, "sending data: {:?}", data); diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 59056a63..98155ed3 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -20,6 +20,7 @@ use tokio_tungstenite::{ protocol::{Role, WebSocketConfig}, }, }; +use tokio_util::future::FutureExt as _; use engineioxide_core::{Packet, ProtocolVersion, Sid, Str, TransportType}; @@ -122,19 +123,24 @@ where Some(socket) if socket.is_ws() => return Err(Error::MultipleWebsocketRequests), Some(socket) => { let mut ws = ws_init().await; - match tokio::time::timeout( - engine.config.upgrade_timeout, - upgrade_handshake::(&socket, &mut ws), - ) - .await + + let upgrade_fut = upgrade_handshake::(&socket, &mut ws); + match tokio::time::timeout(engine.config.upgrade_timeout, upgrade_fut) + .with_cancellation_token(&socket.cancellation_token) + .await { - Ok(res) => res?, - Err(_) => { + Some(Ok(res)) => res?, + Some(Err(_)) => { #[cfg(feature = "tracing")] tracing::debug!(?sid, "ws upgrade timed out, closing session"); engine.close_session(socket.id, DisconnectReason::TransportError); return Err(Error::Upgrade); } + None => { + #[cfg(feature = "tracing")] + tracing::debug!(?sid, "socket is being closed"); + return Err(Error::Upgrade); + } } (socket, ws) } @@ -154,28 +160,27 @@ where }; let (tx, rx) = ws.split(); - // Pipe between websocket and internal socket channel - let cancel_token = socket.cancellation_token.clone(); - tokio::spawn( - cancel_token - .clone() - .run_until_cancelled_owned(forward_to_socket::(socket.clone(), tx)), - ); + // Pipe between websocket and internal socket channel. + tokio::spawn(forward_to_socket::(socket.clone(), tx)); // pipe between internal socket channel and websocket - cancel_token - .run_until_cancelled_owned(async move { - if let Err(ref e) = forward_to_handler(&engine, rx, &socket).await { - #[cfg(feature = "tracing")] - tracing::debug!("error when handling packet: {:?}", e); - if let Some(reason) = e.into() { - engine.close_session(socket.id, reason); - } - } else { - engine.close_session(socket.id, DisconnectReason::TransportClose); - } - }) - .await; + match forward_to_handler(&engine, rx, &socket) + .with_cancellation_token(&socket.cancellation_token) + .await + { + Some(Err(ref e)) => { + let reason = + Option::::from(e).unwrap_or(DisconnectReason::TransportError); + + #[cfg(feature = "tracing")] + tracing::debug!("error when handling packet: {:?}", e); + engine.close_session(socket.id, reason); + } + None | Some(Ok(_)) => { + #[cfg(feature = "tracing")] + tracing::debug!(sid = %socket.id, "socket closed"); + } + } Ok(()) } @@ -275,7 +280,12 @@ async fn forward_to_socket( }; } - while let Some(items) = internal_rx.recv().await { + // Forward buffered packets until the channel is closed or the session is cancelled. + while let Some(Some(items)) = internal_rx + .recv() + .with_cancellation_token(&socket.cancellation_token) + .await + { for item in items { map_fn!(item); } @@ -288,6 +298,15 @@ async fn forward_to_socket( tx.flush().await.ok(); } + + // Flush any remaining buffered packets before closing the websocket. + while let Ok(items) = internal_rx.try_recv() { + for item in items { + map_fn!(item); + } + } + tx.flush().await.ok(); + tx.close().await.ok(); } /// Send a Engine.IO [`OpenPacket`] to initiate a websocket connection async fn init_handshake( diff --git a/crates/engineioxide/tests/disconnect_reason.rs b/crates/engineioxide/tests/disconnect_reason.rs index 1a162155..fee26081 100644 --- a/crates/engineioxide/tests/disconnect_reason.rs +++ b/crates/engineioxide/tests/disconnect_reason.rs @@ -52,6 +52,81 @@ impl EngineIoHandler for MyHandler { } } +/// A handler that forwards every connected socket to the test so it can drive its lifecycle. +#[derive(Debug, Clone)] +struct CloseHandler { + socket_tx: mpsc::Sender>>, +} + +impl EngineIoHandler for CloseHandler { + type Data = (); + + fn on_connect(self: Arc, socket: Arc>) { + self.socket_tx.try_send(socket).unwrap(); + } + 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>) {} +} + +/// Closing a websocket session should resolve [`Socket::closed`]. +#[tokio::test] +pub async fn ws_server_closing() { + let (socket_tx, mut socket_rx) = mpsc::channel(1); + let mut svc = create_server(CloseHandler { socket_tx }).await; + let _stream = create_ws_connection(&mut svc).await; + + let socket = socket_rx.recv().await.unwrap(); + socket.close(DisconnectReason::ClosingServer); + + tokio::time::timeout(Duration::from_millis(100), socket.closed()) + .await + .expect("timeout: ws session did not close while the forward task held the channel lock"); + assert!(socket.is_closed()); +} + +/// Same as [`ws_server_closing`] but for polling with a long-poll request in-flight. +#[tokio::test] +pub async fn polling_server_closing_pending_poll() { + let (socket_tx, mut socket_rx) = mpsc::channel(1); + let mut svc = create_server(CloseHandler { socket_tx }).await; + let sid = create_polling_connection(&mut svc).await; + let socket = socket_rx.recv().await.unwrap(); + + // Drain the first buffered ping so the next poll actually blocks. + send_req( + &mut svc, + format!("transport=polling&sid={sid}"), + http::Method::GET, + None, + ) + .await; + + // Start a long-poll: the buffer is empty so it blocks waiting for the next packet while holding + // the internal channel lock. + let poll = tokio::spawn(send_req( + &mut svc, + format!("transport=polling&sid={sid}"), + http::Method::GET, + None, + )); + // Give the poll time to acquire the internal channel lock before closing. + tokio::time::sleep(Duration::from_millis(10)).await; + + socket.close(DisconnectReason::ClosingServer); + + tokio::time::timeout(Duration::from_millis(100), socket.closed()) + .await + .expect("timeout: polling session did not close while a poll held the channel lock"); + assert!(socket.is_closed()); + + // The pending poll should return rather than hang. + tokio::time::timeout(Duration::from_millis(100), poll) + .await + .expect("timeout: the pending poll never returned") + .unwrap(); +} + #[tokio::test] pub async fn polling_heartbeat_timeout() { let (disconnect_tx, mut rx) = mpsc::channel(10); diff --git a/crates/socketioxide/tests/disconnect_reason.rs b/crates/socketioxide/tests/disconnect_reason.rs index 556bf079..f5adf507 100644 --- a/crates/socketioxide/tests/disconnect_reason.rs +++ b/crates/socketioxide/tests/disconnect_reason.rs @@ -269,12 +269,13 @@ pub async fn server_ws_closing() { .await .expect("timeout waiting for server closing"); let packets = futures_util::future::join_all(streams.iter_mut().map(async |s| { - (s.next().await, s.next().await) // Closing packet / None + (s.next().await, s.next().await, s.next().await) // Closing packet / Closing WS msg / None })) .await; for packet in packets { - assert!(matches!(packet.0.unwrap().unwrap(), Message::Close(_))); - assert!(packet.1.is_none()); + assert!(matches!(packet.0.unwrap().unwrap(), Message::Text(_))); + assert!(matches!(packet.1.unwrap().unwrap(), Message::Close(_))); + assert!(packet.2.is_none()); } } From 5c1c3ccf07833ce0b391e76b5a1bb14beb712990 Mon Sep 17 00:00:00 2001 From: totodore Date: Sat, 4 Jul 2026 01:16:56 +0200 Subject: [PATCH 28/49] fix(engineio/polling): remove yield_packets --- .../engineioxide-core/src/payload/decoder.rs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/crates/engineioxide-core/src/payload/decoder.rs b/crates/engineioxide-core/src/payload/decoder.rs index da8098fd..c81dddfb 100644 --- a/crates/engineioxide-core/src/payload/decoder.rs +++ b/crates/engineioxide-core/src/payload/decoder.rs @@ -20,11 +20,6 @@ struct Payload { buffer: BufList, end_of_stream: bool, current_payload_size: u64, - - /// counter to detect if packets have already been - /// yielded or if the poller needs to wait - #[cfg(feature = "v3")] - yield_packets: u32, } impl Payload { @@ -34,8 +29,6 @@ impl Payload { buffer: BufList::new(), end_of_stream: false, current_payload_size: 0, - #[cfg(feature = "v3")] - yield_packets: 0, } } } @@ -219,12 +212,8 @@ where _ => Err(PacketParseError::InvalidPacketLen), }; - state.yield_packets += 1; break Some((packet, state)); - } else if state.end_of_stream - && state.buffer.remaining() == 0 - && state.yield_packets > 0 - { + } else if state.end_of_stream && state.buffer.remaining() == 0 { break None; } else if state.end_of_stream { // EOS reached with leftover bytes that cannot form a complete @@ -282,10 +271,8 @@ pub fn v3_string_decoder( { break Some((Err(e), state)); } - if state.end_of_stream && state.buffer.remaining() == 0 && state.yield_packets > 0 { + if state.end_of_stream && state.buffer.remaining() == 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(PacketParseError::InvalidPacketLen), state)); } let mut reader = (&mut state.buffer).reader(); @@ -385,7 +372,6 @@ pub fn v3_string_decoder( let packet = unsafe { String::from_utf8_unchecked(packet_buf) }; let packet = Packet::parse(ProtocolVersion::V3, packet) .map_err(|_| PacketParseError::InvalidPacketLen); - state.yield_packets += 1; break Some((packet, state)); // Emit the packet and the updated state } } else if state.end_of_stream && state.buffer.remaining() == 0 { From 7acb6b029e960d15af005d5eeb4d506d4a9b5138 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 14:57:47 +0200 Subject: [PATCH 29/49] fix(engineio): improve errors --- crates/engineioxide/src/engine.rs | 2 +- crates/engineioxide/src/errors.rs | 9 +------ crates/engineioxide/src/socket.rs | 2 +- crates/engineioxide/src/transport/polling.rs | 25 +++++++++----------- crates/engineioxide/src/transport/ws.rs | 12 +++++++--- 5 files changed, 23 insertions(+), 27 deletions(-) diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index bc7b458e..cdfb6037 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -102,7 +102,7 @@ impl EngineIo { socket.internal_rx.lock().await.close(); #[cfg(feature = "tracing")] - tracing::debug!(sid = ?socket.id, "session closed, notifying handler"); + tracing::debug!(sid = %socket.id, "session closed, notifying handler"); handler.on_disconnect(socket, reason); }); diff --git a/crates/engineioxide/src/errors.rs b/crates/engineioxide/src/errors.rs index 71296e9c..393be172 100644 --- a/crates/engineioxide/src/errors.rs +++ b/crates/engineioxide/src/errors.rs @@ -1,5 +1,4 @@ use http::{Response, StatusCode}; -use tokio::sync::mpsc; use tokio_tungstenite::tungstenite; use crate::body::ResponseBody; @@ -11,16 +10,10 @@ pub use engineioxide_core::PacketParseError; pub enum Error { #[error("error decoding packet from request: {0}")] PacketParse(#[from] PacketParseError), - #[error("bad packet received")] + #[error("invalid packet received: {0:?}")] BadPacket(Packet), #[error("ws transport error: {0}")] WsTransport(#[from] Box), - #[error("http error: {0}")] - Http(#[from] http::Error), - #[error("internal channel error: {0}")] - SendChannel(#[from] mpsc::error::TrySendError), - #[error("internal channel error: {0}")] - RecvChannel(#[from] mpsc::error::TryRecvError), #[error("heartbeat timeout")] HeartbeatTimeout, #[error("upgrade error")] diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index 68cd7233..f358ecc8 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -192,7 +192,7 @@ where pub(crate) internal_rx: Mutex>, /// Channel to send [PacketBuf] to the internal connection - internal_tx: mpsc::Sender, + pub(crate) internal_tx: mpsc::Sender, /// Internal channel to receive Pong [`Packets`](Packet) (v4 protocol) or Ping (v3 protocol) in the heartbeat job /// which is running in a separate task diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index ca831907..30d5a55b 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -17,11 +17,7 @@ use crate::{ }; /// Create a response for http request -fn http_response( - code: StatusCode, - data: D, - is_binary: bool, -) -> Result>, http::Error> +fn http_response(code: StatusCode, data: D, is_binary: bool) -> Response> where D: Into, { @@ -36,6 +32,7 @@ where res.header(CONTENT_TYPE, "text/plain; charset=UTF-8") } .body(ResponseBody::custom_response(Full::new(body))) + .unwrap() } pub fn open_req( @@ -74,7 +71,7 @@ where #[cfg(not(feature = "v3"))] packet }; - http_response(StatusCode::OK, packet, false).map_err(Error::Http) + Ok(http_response(StatusCode::OK, packet, false)) } /// Handle http polling request @@ -102,7 +99,7 @@ where let data = payload::packet_encoder(Packet::Noop, socket.protocol, socket.supports_binary); let is_binary = false; // The noop packet is guaranteed to be serialized as text - return Ok(http_response(StatusCode::OK, data, is_binary)?); + return Ok(http_response(StatusCode::OK, data, is_binary)); } // If the socket is already locked, it means that the socket is being used by another request @@ -131,13 +128,13 @@ where let Some(Payload { data, has_binary }) = payload else { #[cfg(feature = "tracing")] tracing::debug!(%sid, "session closed while polling, returning empty payload"); - return Ok(http_response(StatusCode::OK, "", false)?); + return Ok(http_response(StatusCode::OK, "", false)); }; #[cfg(feature = "tracing")] tracing::trace!(%sid, %protocol, supports_binary = socket.supports_binary, "sending data: {:?}", data); - Ok(http_response(StatusCode::OK, data, has_binary)?) + Ok(http_response(StatusCode::OK, data, has_binary)) } /// Handle http polling post request @@ -170,8 +167,8 @@ where match packet { Ok(Packet::Close) => { #[cfg(feature = "tracing")] - tracing::debug!("[sid={sid}] closing session"); - socket.send(Packet::Noop)?; + tracing::debug!(%sid, "received close packet, closing session"); + socket.send(Packet::Noop).ok(); // if the send fails, let's forcefully close the socket engine.close_session(sid, DisconnectReason::TransportClose); break; } @@ -189,18 +186,18 @@ where } Ok(p) => { #[cfg(feature = "tracing")] - tracing::debug!("[sid={sid}] bad packet received: {:?}", &p); + tracing::debug!(%sid, "invalid packet received: {:?}", &p); Err(Error::BadPacket(p)) } Err(e) => { #[cfg(feature = "tracing")] - tracing::debug!("[sid={sid}] error parsing packet: {:?}", e); + tracing::debug!(%sid, "could not parse packet: {e}"); engine.close_session(sid, DisconnectReason::PacketParsingError); return Err(e.into()); } }?; } - Ok(http_response(StatusCode::OK, "ok", false)?) + Ok(http_response(StatusCode::OK, "ok", false)) } mod rx_stream { diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 98155ed3..9e832261 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -11,6 +11,7 @@ use futures_util::{ stream::{SplitSink, SplitStream}, }; use http::{HeaderValue, Request, Response, StatusCode, request::Parts}; +use smallvec::smallvec; use tokio::io::{AsyncRead, AsyncWrite}; use tokio_tungstenite::{ WebSocketStream, @@ -30,7 +31,7 @@ use crate::{ }; /// Create a response for websocket upgrade -fn ws_response(ws_key: &HeaderValue) -> Result>, http::Error> { +fn ws_response(ws_key: &HeaderValue) -> Response> { let derived = derive_accept_key(ws_key.as_bytes()); let sec = derived.parse::().unwrap(); Response::builder() @@ -42,6 +43,7 @@ fn ws_response(ws_key: &HeaderValue) -> Result>, htt ) .header(http::header::SEC_WEBSOCKET_ACCEPT, sec) .body(ResponseBody::empty_response()) + .unwrap() // we are only using constants } /// Upgrade a websocket request to create a websocket connection. @@ -93,7 +95,7 @@ pub fn new_req( } }); - Ok(ws_response(&ws_key)?) + Ok(ws_response(&ws_key)) } /// Handle a websocket connection upgrade @@ -365,7 +367,11 @@ where socket.start_upgrade(); // We send a last Noop request to close a potential waiting polling request. - socket.send(Packet::Noop)?; + socket + .internal_tx + .send(smallvec![Packet::Noop]) + .await + .map_err(|_| Error::Upgrade)?; // wait for any current polling connection to finish by waiting for the socket to be unlocked // All other polling connection will be immediately closed with a NOOP packet. From b08ea4de305a51c146aedbd107e5cf2b16925472 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 15:22:59 +0200 Subject: [PATCH 30/49] fix(engineio): improve errors --- .../engineioxide-core/src/payload/decoder.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/engineioxide-core/src/payload/decoder.rs b/crates/engineioxide-core/src/payload/decoder.rs index c81dddfb..cc152308 100644 --- a/crates/engineioxide-core/src/payload/decoder.rs +++ b/crates/engineioxide-core/src/payload/decoder.rs @@ -272,6 +272,12 @@ pub fn v3_string_decoder( break Some((Err(e), state)); } if state.end_of_stream && state.buffer.remaining() == 0 { + if !packet_buf.is_empty() { + // Leftover unparsed bytes at end of stream: either a length + // token that never found its `:` separator or a + // truncated packet body. + break Some((Err(PacketParseError::InvalidPacketLen), state)); + } break None; // Reached end of stream with no more data, end the stream } @@ -551,6 +557,18 @@ mod tests { } } + #[cfg(feature = "v3")] + #[tokio::test] + async fn string_invalid_packet_format_v3() { + let data = Full::new(Bytes::from_static(b"abc")); + let payload = v3_string_decoder(data, MAX_PAYLOAD); + futures_util::pin_mut!(payload); + assert!(matches!( + payload.next().await, + Some(Err(crate::PacketParseError::InvalidPacketLen)) + )); + } + #[cfg(feature = "v3")] #[tokio::test] async fn binary_payload_stream_v3() { From c98ccab1546a0fa0dd060eca19decf2ab353365e Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 15:47:10 +0200 Subject: [PATCH 31/49] fix(engineio): improve errors --- crates/engineioxide-core/src/payload/encoder.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index ffb45007..76f5a289 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -12,7 +12,10 @@ use std::pin::Pin; use futures_util::{FutureExt, Stream, StreamExt, stream::Peekable}; use smallvec::smallvec; -use crate::{Packet, packet::PacketBuf, payload::Payload}; +use crate::{packet::PacketBuf, payload::Payload}; + +#[cfg(feature = "v3")] +use crate::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 without exceeding the max payload size From 303619292ca61316746816d7b306b63c55b64a60 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 16:44:19 +0200 Subject: [PATCH 32/49] fix(engineio): improve errors --- .../engineioxide-core/src/payload/encoder.rs | 33 +-- crates/engineioxide-core/src/payload/mod.rs | 194 +++++++++++++++--- crates/engineioxide/src/engine.rs | 2 +- crates/engineioxide/src/socket.rs | 31 ++- crates/engineioxide/src/transport/polling.rs | 19 +- crates/engineioxide/tests/fixture.rs | 32 +++ crates/engineioxide/tests/peek_packet.rs | 115 +++++++++++ 7 files changed, 373 insertions(+), 53 deletions(-) create mode 100644 crates/engineioxide/tests/peek_packet.rs diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index 76f5a289..e7fef689 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -9,10 +9,13 @@ use std::pin::Pin; -use futures_util::{FutureExt, Stream, StreamExt, stream::Peekable}; +use futures_util::{FutureExt, Stream, StreamExt}; use smallvec::smallvec; -use crate::{packet::PacketBuf, payload::Payload}; +use crate::{ + packet::PacketBuf, + payload::{Payload, peekable::Peekable}, +}; #[cfg(feature = "v3")] use crate::Packet; @@ -265,7 +268,7 @@ mod tests { smallvec![Packet::Binary(Bytes::from_static(&[1, 2, 3, 4]))], smallvec![Packet::Message("hello€".into())], ]); - let rx = std::pin::pin!(rx.peekable()); + let rx = std::pin::pin!(Peekable::new(rx)); let Payload { data, .. } = v4_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); @@ -276,7 +279,7 @@ mod tests { const PAYLOAD: &str = "4hello€\x1ebAQIDBA=="; let (tx, rx) = tokio::sync::mpsc::channel::(10); let rx = tokio_stream::wrappers::ReceiverStream::new(rx); - let rx = std::pin::pin!(rx.peekable()); + let rx = std::pin::pin!(Peekable::new(rx)); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(50)).await; tx.try_send(smallvec::smallvec![ @@ -300,7 +303,7 @@ mod tests { smallvec![Packet::Message("hello€".into())], ]); - let mut rx = std::pin::pin!(rx.peekable()); + let mut rx = std::pin::pin!(Peekable::new(rx)); { let Payload { data, .. } = v4_encoder(rx.as_mut(), MAX_PAYLOAD).await; @@ -325,7 +328,7 @@ mod tests { // but 3 UTF-16 code units. const PAYLOAD: &str = "3:4𝕊"; let rx = stream::iter([smallvec![Packet::Message("𝕊".into())]]); - let rx = std::pin::pin!(rx.peekable()); + let rx = std::pin::pin!(Peekable::new(rx)); let Payload { data, .. } = v3_string_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); } @@ -340,8 +343,10 @@ mod tests { smallvec![Packet::Message("hello€".into())], ]); - let rx = std::pin::pin!(rx.peekable()); - let Payload { data, has_binary } = v3_string_encoder(rx, MAX_PAYLOAD).await; + let rx = std::pin::pin!(Peekable::new(rx)); + let Payload { + data, has_binary, .. + } = v3_string_encoder(rx, MAX_PAYLOAD).await; assert_eq!(data, PAYLOAD.as_bytes()); assert!(!has_binary); } @@ -357,7 +362,7 @@ mod tests { smallvec::smallvec![Packet::Message("hello€".into())], smallvec::smallvec![Packet::Message("hello€".into())], ]); - let mut rx = std::pin::pin!(rx.peekable()); + let mut rx = std::pin::pin!(Peekable::new(rx)); { let Payload { data, .. } = v3_string_encoder(rx.as_mut(), MAX_PAYLOAD).await; @@ -385,9 +390,11 @@ mod tests { smallvec![Packet::Message("hello€".into())], smallvec![Packet::BinaryV3(Bytes::from_static(&[1, 2, 3, 4]))], ]); - let rx = std::pin::pin!(rx.peekable()); + let rx = std::pin::pin!(Peekable::new(rx)); - let Payload { data, has_binary } = v3_binary_encoder(rx, MAX_PAYLOAD).await; + let Payload { + data, has_binary, .. + } = v3_binary_encoder(rx, MAX_PAYLOAD).await; assert_eq!(*data, PAYLOAD); assert!(has_binary); } @@ -405,7 +412,7 @@ mod tests { ]; let (tx, rx) = tokio::sync::mpsc::channel::(10); let rx = tokio_stream::wrappers::ReceiverStream::new(rx); - let rx = std::pin::pin!(rx.peekable()); + let rx = std::pin::pin!(Peekable::new(rx)); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(50)).await; tx.try_send(smallvec::smallvec![ @@ -437,7 +444,7 @@ mod tests { smallvec![Packet::Message("hello€".into())], smallvec![Packet::Message("hello€".into())], ]); - let mut rx = std::pin::pin!(rx.peekable()); + let mut rx = std::pin::pin!(Peekable::new(rx)); { let Payload { data, .. } = v3_binary_encoder(rx.as_mut(), MAX_PAYLOAD).await; diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index 4ab1c33a..2b6d0de6 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -3,7 +3,7 @@ use crate::{Packet, PacketParseError, ProtocolVersion, packet::PacketBuf}; use bytes::Bytes; -use futures_util::{Stream, StreamExt}; +use futures_util::Stream; use http::HeaderValue; mod buf; @@ -60,11 +60,17 @@ pub struct Payload { pub data: Bytes, /// Whether the payload contains binary data. pub has_binary: bool, + /// A peeked packet buffer that could not be sent due to the max payload size. + pub peeked: Option, } 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 } + Self { + data, + has_binary, + peeked: None, + } } } @@ -75,23 +81,25 @@ pub async fn encoder( #[allow(unused)] supports_binary: bool, max_payload: u64, ) -> Payload { - let rx = std::pin::pin!(rx.peekable()); + let mut rx = std::pin::pin!(peekable::Peekable::new(rx)); #[cfg(feature = "v3")] - { - match protocol { - ProtocolVersion::V4 => encoder::v4_encoder(rx, max_payload).await, - ProtocolVersion::V3 if supports_binary => { - encoder::v3_binary_encoder(rx, max_payload).await - } - ProtocolVersion::V3 => encoder::v3_string_encoder(rx, max_payload).await, + let mut payload = match protocol { + ProtocolVersion::V4 => encoder::v4_encoder(rx.as_mut(), max_payload).await, + ProtocolVersion::V3 if supports_binary => { + encoder::v3_binary_encoder(rx.as_mut(), max_payload).await } - } + ProtocolVersion::V3 => encoder::v3_string_encoder(rx.as_mut(), max_payload).await, + }; #[cfg(not(feature = "v3"))] - { - encoder::v4_encoder(rx, max_payload).await - } + let mut payload = encoder::v4_encoder(rx.as_mut(), max_payload).await; + + // Recover any packet that was peeked but rejected (max payload exceeded) so + // the caller can hand it back to the next encoding pass instead of losing it. + payload.peeked = rx.take_peeked(); + + payload } /// Encodes a single packet into a byte array. @@ -101,28 +109,150 @@ pub fn packet_encoder( #[allow(unused)] supports_binary: bool, ) -> Bytes { #[cfg(feature = "v3")] - { - match protocol { - ProtocolVersion::V4 => packet.into(), - ProtocolVersion::V3 if supports_binary && packet.is_binary() => { - use bytes::BytesMut; - - let size_hint = packet.get_size_hint(!supports_binary) + 2; - let mut bytes = BytesMut::with_capacity(size_hint); - encoder::v3_bin_packet_encoder(packet, &mut bytes); - bytes.freeze() - } - ProtocolVersion::V3 => { - use bytes::BytesMut; + match protocol { + ProtocolVersion::V4 => packet.into(), + ProtocolVersion::V3 if supports_binary && packet.is_binary() => { + use bytes::BytesMut; - let size_hint = packet.get_size_hint(!supports_binary) + 2; - let mut bytes = BytesMut::with_capacity(size_hint); - encoder::v3_string_packet_encoder(packet, &mut bytes); - bytes.freeze() - } + let size_hint = packet.get_size_hint(!supports_binary) + 2; + let mut bytes = BytesMut::with_capacity(size_hint); + encoder::v3_bin_packet_encoder(packet, &mut bytes); + bytes.freeze() + } + ProtocolVersion::V3 => { + use bytes::BytesMut; + + let size_hint = packet.get_size_hint(!supports_binary) + 2; + let mut bytes = BytesMut::with_capacity(size_hint); + encoder::v3_string_packet_encoder(packet, &mut bytes); + bytes.freeze() } } #[cfg(not(feature = "v3"))] packet.into() } + +mod peekable { + //! [`Peekable`] implementation taken from [`futures_util::stream::Peekable`] but + //! with an [`Peekable::into_parts`] method to get back a potentially peeked item and the stream. + use std::{ + pin::Pin, + task::{Context, Poll}, + }; + + use futures_util::{Stream, StreamExt as _, future::FusedFuture, stream::Fuse}; + use pin_project_lite::pin_project; + + pin_project! { + /// A `Stream` that implements a `peek` method. + /// + /// The `peek` method can be used to retrieve a reference + /// to the next `Stream::Item` if available. A subsequent + /// call to `poll` will return the owned item. + #[derive(Debug)] + #[must_use = "streams do nothing unless polled"] + pub struct Peekable { + #[pin] + stream: Fuse, + peeked: Option, + } + } + + impl Peekable { + pub(super) fn new(stream: St) -> Self { + Self { + stream: stream.fuse(), + peeked: None, + } + } + + /// Produces a future which retrieves a reference to the next item + /// in the stream, or `None` if the underlying stream terminates. + pub fn peek(self: Pin<&mut Self>) -> Peek<'_, St> { + Peek { inner: Some(self) } + } + + /// Takes the peeked item out of the buffer, if any. + /// + /// Only the `peeked` field is moved out, so this is safe to call + /// through a pinned reference even when the underlying stream is `!Unpin`. + pub fn take_peeked(self: Pin<&mut Self>) -> Option { + self.project().peeked.take() + } + + /// Peek retrieves a reference to the next item in the stream. + /// + /// This method polls the underlying stream and return either a reference + /// to the next item if the stream is ready or passes through any errors. + pub fn poll_peek(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + + Poll::Ready(loop { + if this.peeked.is_some() { + break this.peeked.as_ref(); + } else if let Some(item) = futures_util::ready!(this.stream.as_mut().poll_next(cx)) + { + *this.peeked = Some(item); + } else { + break None; + } + }) + } + } + + impl Stream for Peekable { + type Item = S::Item; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.project(); + if let Some(item) = this.peeked.take() { + return Poll::Ready(Some(item)); + } + this.stream.poll_next(cx) + } + + fn size_hint(&self) -> (usize, Option) { + let peek_len = usize::from(self.peeked.is_some()); + let (lower, upper) = self.stream.size_hint(); + let lower = lower.saturating_add(peek_len); + let upper = match upper { + Some(x) => x.checked_add(peek_len), + None => None, + }; + (lower, upper) + } + } + + pin_project! { + /// Future for the [`Peekable::peek`](self::Peekable::peek) method. + #[must_use = "futures do nothing unless polled"] + pub struct Peek<'a, St: Stream> { + inner: Option>>, + } + } + + impl FusedFuture for Peek<'_, St> { + fn is_terminated(&self) -> bool { + self.inner.is_none() + } + } + + impl<'a, St> Future for Peek<'a, St> + where + St: Stream, + { + type Output = Option<&'a St::Item>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let inner = self.project().inner; + if let Some(peekable) = inner { + futures_util::ready!(peekable.as_mut().poll_peek(cx)); + + inner.take().unwrap().poll_peek(cx) + } else { + panic!("Peek polled after completion") + } + } + } +} diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index cdfb6037..1690e6bc 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -99,7 +99,7 @@ impl EngineIo { // channel so that `Socket::closed` resolves and no more packets can be buffered. // When no transport holds the lock (e.g. polling between requests) this resolves // immediately. - socket.internal_rx.lock().await.close(); + socket.internal_rx.lock().await.rx.close(); #[cfg(feature = "tracing")] tracing::debug!(sid = %socket.id, "session closed, notifying handler"); diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index f358ecc8..99c42edb 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -56,6 +56,8 @@ //! ``` use std::{ collections::VecDeque, + fmt::Debug, + ops::{Deref, DerefMut}, sync::{ Arc, atomic::{AtomicBool, AtomicU8, Ordering}, @@ -152,6 +154,29 @@ impl Permit<'_> { } } +#[derive(Debug)] +pub(crate) struct InternalRx { + pub rx: Receiver, + pub peeked: Option, +} +impl InternalRx { + fn new(rx: Receiver) -> Self { + Self { rx, peeked: None } + } +} +impl Deref for InternalRx { + type Target = Receiver; + + fn deref(&self) -> &Self::Target { + &self.rx + } +} +impl DerefMut for InternalRx { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.rx + } +} + /// A [`Socket`] represents a client connection to the server. /// It is agnostic to the [`TransportType`]. /// @@ -189,7 +214,7 @@ where /// Closing behavior is driven by the [`Socket::cancellation_token`]. /// /// 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 pub(crate) internal_tx: mpsc::Sender, @@ -237,7 +262,7 @@ where transport: AtomicU8::new(transport as u8), upgrading: AtomicBool::new(false), - internal_rx: Mutex::new(internal_rx), + internal_rx: Mutex::new(InternalRx::new(internal_rx)), internal_tx, heartbeat_rx: Mutex::new(heartbeat_rx), @@ -535,7 +560,7 @@ where transport: AtomicU8::new(TransportType::Websocket as u8), upgrading: AtomicBool::new(false), - internal_rx: Mutex::new(internal_rx), + internal_rx: Mutex::new(InternalRx::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 30d5a55b..1d618e79 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use bytes::Bytes; use engineioxide_core::payload::{self, Payload}; -use futures_util::StreamExt; +use futures_util::{StreamExt, stream}; use http::{Request, Response, StatusCode, header::CONTENT_TYPE}; use http_body::Body; use http_body_util::Full; @@ -117,20 +117,31 @@ where let max_payload = engine.config.max_payload; - let rx = rx_stream::ReceiverStream::new(&mut rx); + // Prepend the packet peeked during the previous + // polling request so it is encoded first, ahead of the newly received packets. + let rx_stream = stream::iter(rx.peeked.take()).chain(rx_stream::ReceiverStream::new(&mut rx)); // Stop waiting for the next packet as soon as the session is closed. The combinator is biased // towards the encoder completion, so any buffered close packet is still flushed to the client. - let payload = payload::encoder(rx, protocol, socket.supports_binary, max_payload) + let payload = payload::encoder(rx_stream, protocol, socket.supports_binary, max_payload) .with_cancellation_token(&socket.cancellation_token) .await; - let Some(Payload { data, has_binary }) = payload else { + let Some(Payload { + data, + has_binary, + peeked, + }) = payload + else { #[cfg(feature = "tracing")] tracing::debug!(%sid, "session closed while polling, returning empty payload"); return Ok(http_response(StatusCode::OK, "", false)); }; + // set back the peeked packet so it can be read again + // on the next polling request + rx.peeked = peeked; + #[cfg(feature = "tracing")] tracing::trace!(%sid, %protocol, supports_binary = socket.supports_binary, "sending data: {:?}", data); diff --git a/crates/engineioxide/tests/fixture.rs b/crates/engineioxide/tests/fixture.rs index f1d5eef7..b7f6f212 100644 --- a/crates/engineioxide/tests/fixture.rs +++ b/crates/engineioxide/tests/fixture.rs @@ -74,6 +74,38 @@ pub fn send_req( } } +/// Same as [`send_req`] but returns the full response body without stripping the +/// leading packet-type byte. Useful to assert on raw polling payloads. +pub fn send_req_raw( + svc: &mut EngineIoService, + params: String, + method: http::Method, + body: Option, +) -> impl Future + 'static { + let body = match body { + Some(b) => Either::Left(Full::new(VecDeque::from(b.into_bytes()))), + None => Either::Right(Empty::>::new()), + }; + + let req = Request::builder() + .method(method) + .uri(format!("http://127.0.0.1/engine.io/?EIO=4&{params}")) + .body(body) + .unwrap(); + let res = svc.call(req); + async move { + let body = res + .await + .unwrap() + .body_mut() + .collect() + .await + .unwrap() + .to_bytes(); + String::from_utf8(body.to_vec()).unwrap() + } +} + pub async fn create_polling_connection(svc: &mut EngineIoService) -> String { let body = send_req( svc, diff --git a/crates/engineioxide/tests/peek_packet.rs b/crates/engineioxide/tests/peek_packet.rs new file mode 100644 index 00000000..08327b26 --- /dev/null +++ b/crates/engineioxide/tests/peek_packet.rs @@ -0,0 +1,115 @@ +//! Tests ensuring that a packet peeked but rejected because it would exceed the +//! configured `max_payload` is not lost: it must be sent on the next polling +//! request, in order, rather than dropped together with the encoder's peek +//! buffer. + +use std::{sync::Arc, time::Duration}; + +use bytes::Bytes; +use engineioxide::{ + Str, + config::EngineIoConfig, + handler::EngineIoHandler, + service::EngineIoService, + socket::{DisconnectReason, Socket}, +}; +use tokio::sync::mpsc; + +mod fixture; + +use fixture::{create_polling_connection, send_req_raw}; + +/// A handler that forwards every connected socket to the test so it can emit on it directly. +#[derive(Debug, Clone)] +struct SocketHandler { + socket_tx: mpsc::Sender>>, +} + +impl EngineIoHandler for SocketHandler { + type Data = (); + + fn on_connect(self: Arc, socket: Arc>) { + self.socket_tx.try_send(socket).unwrap(); + } + 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>) {} +} + +/// Build a service with a small `max_payload` (so payloads are truncated after a +/// single packet) and heartbeat timings large enough not to interfere. +async fn create_small_payload_server( + max_payload: u64, +) -> ( + EngineIoService, + mpsc::Receiver>>, +) { + let (socket_tx, socket_rx) = mpsc::channel(1); + let config = EngineIoConfig::builder() + .ping_interval(Duration::from_secs(60)) + .ping_timeout(Duration::from_secs(60)) + .max_payload(max_payload) + .build(); + let svc = EngineIoService::with_config(Arc::new(SocketHandler { socket_tx }), config); + (svc, socket_rx) +} + +/// Drain the ping packet that the heartbeat job buffers right after connecting, +/// so subsequent polls only observe the packets emitted by the test. +async fn drain_initial_ping(svc: &mut EngineIoService, sid: &str) { + let ping = poll(svc, sid).await; + assert_eq!(ping, "2", "expected the initial heartbeat ping"); +} + +/// Perform a polling request and return the raw payload, failing fast instead of +/// hanging if the payload is unexpectedly empty (e.g. a peeked packet was lost). +async fn poll(svc: &mut EngineIoService, sid: &str) -> String { + let fut = send_req_raw( + svc, + format!("transport=polling&sid={sid}"), + http::Method::GET, + None, + ); + tokio::time::timeout(Duration::from_secs(1), fut) + .await + .expect("polling request timed out (a peeked packet was likely lost)") +} + +/// A packet rejected for exceeding `max_payload` must be delivered by the next poll. +#[tokio::test] +async fn polling_peeked_packet_sent_on_next_poll() { + // "4hello" is 6 bytes; two of them (+ separator) exceed the limit, so the + // first poll emits one packet and peeks-then-rejects the second. + let (mut svc, mut socket_rx) = create_small_payload_server(10).await; + let sid = create_polling_connection(&mut svc).await; + let socket = socket_rx.recv().await.unwrap(); + + drain_initial_ping(&mut svc, &sid).await; + + socket.emit("hello").unwrap(); + socket.emit("world").unwrap(); + + // First poll: only the first packet fits, the second is peeked and rejected. + assert_eq!(poll(&mut svc, &sid).await, "4hello"); + // Second poll: the rejected packet must be delivered here, not lost. + assert_eq!(poll(&mut svc, &sid).await, "4world"); +} + +/// Several packets rejected across consecutive polls must keep their order. +#[tokio::test] +async fn polling_peeked_packets_preserve_order() { + let (mut svc, mut socket_rx) = create_small_payload_server(10).await; + let sid = create_polling_connection(&mut svc).await; + let socket = socket_rx.recv().await.unwrap(); + + drain_initial_ping(&mut svc, &sid).await; + + socket.emit("hello").unwrap(); + socket.emit("world").unwrap(); + socket.emit("there").unwrap(); + + // Each poll drains exactly one packet, peeking and rejecting the next one. + assert_eq!(poll(&mut svc, &sid).await, "4hello"); + assert_eq!(poll(&mut svc, &sid).await, "4world"); + assert_eq!(poll(&mut svc, &sid).await, "4there"); +} From 316468bf557fc9a16083cbf36aa9642c491c8d6c Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 16:45:59 +0200 Subject: [PATCH 33/49] fix(engineio): improve errors --- crates/engineioxide/tests/fixture.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engineioxide/tests/fixture.rs b/crates/engineioxide/tests/fixture.rs index b7f6f212..86158d2b 100644 --- a/crates/engineioxide/tests/fixture.rs +++ b/crates/engineioxide/tests/fixture.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::{ collections::VecDeque, future::Future, From 06aa35c2e9692a5739958953b96a8b93022faa76 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 16:52:47 +0200 Subject: [PATCH 34/49] fix(engineio): improve errors --- crates/engineioxide-core/src/payload/encoder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engineioxide-core/src/payload/encoder.rs b/crates/engineioxide-core/src/payload/encoder.rs index e7fef689..af0ac536 100644 --- a/crates/engineioxide-core/src/payload/encoder.rs +++ b/crates/engineioxide-core/src/payload/encoder.rs @@ -47,7 +47,7 @@ fn try_poll_packet( /// Same as [`try_poll_packet`] /// but wait for a new packet if there is no packet in the buffer async fn poll_packet(mut rx: Pin<&mut Peekable>>) -> PacketBuf { - let packet = rx.next().await.unwrap_or(smallvec![]); + let packet = rx.next().await.unwrap_or(smallvec![]); // if the channel is closed yield an empty packet #[cfg(feature = "tracing")] tracing::debug!("sending packet: {:?}", packet); From d65216101574601acceff3a3be98fcbadecb2a03 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 16:55:02 +0200 Subject: [PATCH 35/49] fix(engineio): improve errors --- crates/engineioxide-core/src/payload/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index 2b6d0de6..eb585c16 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -135,7 +135,7 @@ pub fn packet_encoder( mod peekable { //! [`Peekable`] implementation taken from [`futures_util::stream::Peekable`] but - //! with an [`Peekable::into_parts`] method to get back a potentially peeked item and the stream. + //! with an [`Peekable::take_peeked`] method to get back a potentially peeked item. use std::{ pin::Pin, task::{Context, Poll}, From 155696b94f1c66cd6306b45cc9221ca3ed002dde Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 16:59:36 +0200 Subject: [PATCH 36/49] fix(engineio): improve errors --- crates/engineioxide/src/transport/ws.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 9e832261..4c50beb7 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -420,7 +420,6 @@ where } 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. + // SAFETY: the bytes are guaranteed to be valid UTF-8 by the tungstenite parser unsafe { Str::from_bytes_unchecked(bytes.into()) } } From 0473d04f0f6b3416ec88f9cb7e8849ae2cc380a5 Mon Sep 17 00:00:00 2001 From: totodore Date: Sun, 5 Jul 2026 17:33:55 +0200 Subject: [PATCH 37/49] fix(engineio): improve errors --- crates/engineioxide-core/src/payload/mod.rs | 8 +------- crates/engineioxide/Cargo.toml | 2 +- crates/engineioxide/src/engine.rs | 3 --- crates/engineioxide/src/transport/ws.rs | 9 +++++++-- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/crates/engineioxide-core/src/payload/mod.rs b/crates/engineioxide-core/src/payload/mod.rs index eb585c16..23f153b7 100644 --- a/crates/engineioxide-core/src/payload/mod.rs +++ b/crates/engineioxide-core/src/payload/mod.rs @@ -141,7 +141,7 @@ mod peekable { task::{Context, Poll}, }; - use futures_util::{Stream, StreamExt as _, future::FusedFuture, stream::Fuse}; + use futures_util::{Stream, StreamExt as _, stream::Fuse}; use pin_project_lite::pin_project; pin_project! { @@ -232,12 +232,6 @@ mod peekable { } } - impl FusedFuture for Peek<'_, St> { - fn is_terminated(&self) -> bool { - self.inner.is_none() - } - } - impl<'a, St> Future for Peek<'a, St> where St: Stream, diff --git a/crates/engineioxide/Cargo.toml b/crates/engineioxide/Cargo.toml index 105ab198..030f98be 100644 --- a/crates/engineioxide/Cargo.toml +++ b/crates/engineioxide/Cargo.toml @@ -53,7 +53,7 @@ tokio-util.workspace = true [features] v3 = ["engineioxide-core/v3"] -tracing = ["dep:tracing"] +tracing = ["dep:tracing", "engineioxide-core/tracing"] __test_harness = [] [[bench]] diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index 1690e6bc..c0fdabc1 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -159,7 +159,6 @@ mod tests { ProtocolVersion::V4, TransportType::Polling, Request::<()>::default().into_parts().0, - #[cfg(feature = "v3")] true, ); assert_eq!(engine.sockets.read().unwrap().len(), 1); @@ -174,7 +173,6 @@ mod tests { ProtocolVersion::V4, TransportType::Polling, Request::<()>::default().into_parts().0, - #[cfg(feature = "v3")] true, ); assert_eq!(engine.sockets.read().unwrap().len(), 1); @@ -189,7 +187,6 @@ mod tests { ProtocolVersion::V4, TransportType::Polling, Request::<()>::default().into_parts().0, - #[cfg(feature = "v3")] true, ); assert_eq!(engine.sockets.read().unwrap().len(), 1); diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 4c50beb7..f579fac3 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -178,9 +178,14 @@ where tracing::debug!("error when handling packet: {:?}", e); engine.close_session(socket.id, reason); } - None | Some(Ok(_)) => { + Some(Ok(())) => { #[cfg(feature = "tracing")] - tracing::debug!(sid = %socket.id, "socket closed"); + tracing::debug!(sid = %socket.id, "ws transport was closed"); + engine.close_session(socket.id, DisconnectReason::TransportClose); + } + None => { + #[cfg(feature = "tracing")] + tracing::debug!(sid = %socket.id, "socket is closing"); } } Ok(()) From e40d1ad218bbff7d62c6f2cf3646a4dfecd0fd39 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 6 Jul 2026 18:18:43 +0200 Subject: [PATCH 38/49] refactor: improve volatile event handling in engineioxide transport using tokio::select and unify documentation via include_str --- crates/engineioxide/Cargo.toml | 2 +- crates/engineioxide/src/transport/ws.rs | 62 ++++++++++------- crates/engineioxide/tests/volatile.rs | 57 ++++++++++++++- crates/socketioxide-core/src/adapter/mod.rs | 6 +- crates/socketioxide/src/io.rs | 6 +- crates/socketioxide/src/ns.rs | 7 +- crates/socketioxide/src/socket.rs | 18 +---- crates/socketioxide/tests/volatile.rs | 77 ++++----------------- 8 files changed, 117 insertions(+), 118 deletions(-) diff --git a/crates/engineioxide/Cargo.toml b/crates/engineioxide/Cargo.toml index b604481d..45fcad51 100644 --- a/crates/engineioxide/Cargo.toml +++ b/crates/engineioxide/Cargo.toml @@ -28,7 +28,7 @@ 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"] } tokio-util.workspace = true tower-service.workspace = true tower-layer.workspace = true diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index ec7451ef..68ddb948 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -290,31 +290,45 @@ async fn forward_to_socket( } loop { - // Priority: wait for and drain the main channel. - // Volatile events are checked after each main-channel cycle. - let items = match internal_rx.recv().await { - Some(items) => items, - None => break, - }; - for item in items { - map_fn!(item); - } - while let Ok(items) = internal_rx.try_recv() { - for item in items { - map_fn!(item); + tokio::select! { + items = internal_rx.recv() => { + match items { + Some(packets) => { + for item in packets { + map_fn!(item); + } + while let Ok(packets) = internal_rx.try_recv() { + for item in packets { + map_fn!(item); + } + } + } + None => { + // Main channel closed, flush and drain remaining volatile + tx.flush().await.ok(); + if volatile_rx.has_changed().unwrap_or(false) { + let val = volatile_rx.borrow_and_update().clone(); + if let Some(packets) = val { + for item in packets { + map_fn!(item); + } + tx.flush().await.ok(); + } + } + break; + } + } } - } - - // Check volatile channel after main is drained. - // `has_changed` is non-blocking; if no volatile data is - // pending we simply go back to waiting for the main channel. - if volatile_rx.has_changed().unwrap_or(false) { - let val = volatile_rx.borrow_and_update().clone(); - if let Some(packets) = val { - #[cfg(feature = "tracing")] - tracing::info!(sid = ?socket.id, "ws volatile check: flushing {:?}", &packets); - for item in packets { - map_fn!(item); + result = volatile_rx.changed() => { + if let Ok(()) = result { + let val = volatile_rx.borrow_and_update().clone(); + if let Some(packets) = val { + #[cfg(feature = "tracing")] + tracing::debug!(sid = ?socket.id, "ws volatile flush: {} packets", packets.len()); + for item in packets { + map_fn!(item); + } + } } } } diff --git a/crates/engineioxide/tests/volatile.rs b/crates/engineioxide/tests/volatile.rs index 743b2807..3867ab2a 100644 --- a/crates/engineioxide/tests/volatile.rs +++ b/crates/engineioxide/tests/volatile.rs @@ -7,9 +7,10 @@ use engineioxide::{ socket::{DisconnectReason, Socket}, }; use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; mod fixture; -use fixture::{create_polling_connection, create_server, send_req}; +use fixture::{create_polling_connection, create_server, create_ws_connection, send_req}; #[derive(Debug, Clone)] struct VolatileHandler { @@ -110,3 +111,57 @@ async fn volatile_overwrite_only_latest_survives() { // After send_req's skip(1): "kept" then \x1e separator then "4normal" assert_eq!(response, "kept\x1e4normal"); } + +#[tokio::test] +async fn volatile_only_arrives_via_polling() { + let (socket_tx, mut socket_rx) = mpsc::channel(10); + let mut svc = create_server(VolatileHandler { socket_tx }).await; + let sid = create_polling_connection(&mut svc).await; + + let socket = socket_rx + .recv() + .await + .expect("socket not received from on_connect"); + + assert!(socket.emit_volatile("volatile_only")); + + let response = send_req( + &mut svc, + format!("transport=polling&sid={sid}"), + http::Method::GET, + None, + ) + .await; + assert_eq!(response, "volatile_only"); +} + +#[tokio::test] +async fn volatile_only_arrives_via_ws() { + use std::time::Duration; + + use futures_util::StreamExt; + + let (socket_tx, mut socket_rx) = mpsc::channel(10); + let mut svc = create_server(VolatileHandler { socket_tx }).await; + let mut stream = create_ws_connection(&mut svc).await; + + let socket = socket_rx + .recv() + .await + .expect("socket not received from on_connect"); + + let _open_packet = stream.next().await.unwrap().unwrap(); + + let ping = stream.next().await.unwrap().unwrap(); + assert_eq!(ping, Message::Text("2".into())); + + assert!(socket.emit_volatile("volatile_only")); + + let volatile = tokio::time::timeout(Duration::from_millis(100), stream.next()).await; + + let msg = volatile + .expect("timeout: volatile-only packet was never flushed over websocket") + .unwrap() + .unwrap(); + assert_eq!(msg, Message::Text("4volatile_only".into())); +} diff --git a/crates/socketioxide-core/src/adapter/mod.rs b/crates/socketioxide-core/src/adapter/mod.rs index e5f5447d..9b9b5e87 100644 --- a/crates/socketioxide-core/src/adapter/mod.rs +++ b/crates/socketioxide-core/src/adapter/mod.rs @@ -210,9 +210,7 @@ pub trait SocketEmitter: Send + Sync + 'static { /// Send data to the list of socket ids with volatile semantics. /// Errors are silently discarded; packets may be dropped if the /// transport is not ready. - fn send_many_volatile(&self, sids: BroadcastIter<'_>, data: Value) { - _ = self.send_many(sids, data); - } + fn send_many_volatile(&self, sids: BroadcastIter<'_>, data: Value); /// Send data to the list of socket ids and get a stream of acks and the number of expected acks. fn send_many_with_ack( &self, @@ -794,6 +792,8 @@ mod test { Ok(()) } + fn send_many_volatile(&self, _: BroadcastIter<'_>, _: Value) {} + fn send_many_with_ack( &self, _: BroadcastIter<'_>, diff --git a/crates/socketioxide/src/io.rs b/crates/socketioxide/src/io.rs index 045822e3..02c970b6 100644 --- a/crates/socketioxide/src/io.rs +++ b/crates/socketioxide/src/io.rs @@ -591,11 +591,7 @@ impl SocketIo { self.get_default_op() } - /// _Alias for `io.of("/").unwrap().volatile()`_. If the **default namespace "/" is not found** this fn will panic! - /// - /// Sets the volatile flag on the broadcast operators. Volatile events may be dropped - /// if the client is not ready to receive them. - /// See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). + #[doc = include_str!("../docs/operators/volatile.md")] #[inline] pub fn volatile(&self) -> BroadcastOperators { self.get_default_op().volatile() diff --git a/crates/socketioxide/src/ns.rs b/crates/socketioxide/src/ns.rs index c4b94a0f..5ea4fc9c 100644 --- a/crates/socketioxide/src/ns.rs +++ b/crates/socketioxide/src/ns.rs @@ -273,11 +273,8 @@ impl InnerEmitter for Namespace { fn send_many_volatile(&self, sids: BroadcastIter<'_>, data: Value) { let sockets = self.sockets.read().unwrap(); - for sid in sids { - if let Some(socket) = sockets.get(&sid) { - socket.send_raw_volatile(data.clone()); - } - } + sids.filter_map(|sid| sockets.get(&sid)) + .for_each(|socket| socket.send_raw_volatile(data.clone())); } fn send_many_with_ack( diff --git a/crates/socketioxide/src/socket.rs b/crates/socketioxide/src/socket.rs index 1073cef5..57a3ce88 100644 --- a/crates/socketioxide/src/socket.rs +++ b/crates/socketioxide/src/socket.rs @@ -639,23 +639,7 @@ impl Socket { BroadcastOperators::from_sock(self.ns.clone(), self.id, self.parser).broadcast() } - /// Returns a [`ConfOperators`] with the volatile flag set, so that any - /// subsequent `emit()` will drop the event instead of buffering it if the - /// client is not ready to receive it. - /// - /// # Example - /// ``` - /// # use socketioxide::{SocketIo, extract::SocketRef}; - /// # use serde::Serialize; - /// #[derive(Serialize)] - /// struct GameState { x: f64, y: f64 } - /// - /// let (_, io) = SocketIo::new_svc(); - /// io.ns("/", async |socket: SocketRef| { - /// socket.volatile().emit("position", &GameState { x: 1.0, y: 2.0 }).ok(); - /// socket.volatile().to("room1").emit("update", &42).await.ok(); - /// }); - /// ``` + #[doc = include_str!("../docs/operators/volatile.md")] pub fn volatile(&self) -> ConfOperators<'_, A> { ConfOperators::new(self).volatile() } diff --git a/crates/socketioxide/tests/volatile.rs b/crates/socketioxide/tests/volatile.rs index cfc273af..976f597f 100644 --- a/crates/socketioxide/tests/volatile.rs +++ b/crates/socketioxide/tests/volatile.rs @@ -1,87 +1,45 @@ //! Integration tests for volatile events on socketioxide. -//! Verifies the volatile operator API and that volatile emits -//! silently drop errors rather than propagating them. +//! Verifies volatile emits flow through the transport correctly. mod fixture; mod utils; use fixture::{create_polling_connection, create_server, send_req}; use http::Method; use socketioxide::{SocketIo, extract::SocketRef}; +use tokio::sync::mpsc; #[tokio::test] async fn volatile_emit_returns_ok() { use serde_json::json; let (_svc, io) = SocketIo::new_svc(); - let (tx, rx) = std::sync::mpsc::channel(); + let (tx, mut rx) = mpsc::channel::>(1); io.ns("/", async move |socket: SocketRef| { let result = socket.volatile().emit("test", &json!({"key": "val"})); - tx.send(result).unwrap(); + tx.send(result).await.unwrap(); }); io.new_dummy_sock("/", ()).await; - assert!(rx.recv().unwrap().is_ok()); + assert!(rx.recv().await.unwrap().is_ok()); } #[tokio::test] -async fn volatile_emit_broadcast_does_not_panic() { - let (_svc, io) = SocketIo::new_svc(); - - let (tx, rx) = std::sync::mpsc::channel(); - io.ns("/", async move |socket: SocketRef| { - // Broadcast with volatile flag should complete without panicking - socket - .within("room") - .volatile() - .emit("event", &"data") - .await - .ok(); - tx.send(()).unwrap(); - }); - - io.new_dummy_sock("/", ()).await; - rx.recv().unwrap(); -} - -#[tokio::test] -async fn io_volatile_composes() { - let (_svc, io) = SocketIo::new_svc(); - io.ns("/", |_: SocketRef| async {}); - - io.new_dummy_sock("/", ()).await; - - // Volatile on the io handle delegates to the default namespace - let _ = io.volatile(); - let _ = io.of("/").unwrap().volatile(); -} - -#[tokio::test] -async fn volatile_emit_on_disconnected_socket_returns_ok() { +async fn volatile_emit_second_overwrites() { use serde_json::json; let (_svc, io) = SocketIo::new_svc(); - let (tx, rx) = std::sync::mpsc::channel(); - io.ns("/", { - let tx = tx.clone(); - async move |_: SocketRef| { - tx.send(()).unwrap(); - } + let (tx, mut rx) = mpsc::channel::<(Result<(), _>, Result<(), _>)>(1); + io.ns("/", async move |socket: SocketRef| { + let first = socket.volatile().emit("test", &json!({"key": "first"})); + let second = socket.volatile().emit("test", &json!({"key": "second"})); + tx.send((first, second)).await.unwrap(); }); io.new_dummy_sock("/", ()).await; - rx.recv().unwrap(); - - // At this point the dummy socket's connect handler has run. - // The socket is technically connected — test that volatile - // emit returns Ok(()) without error. - let (tx2, rx2) = std::sync::mpsc::channel(); - io.ns("/test", async move |socket: SocketRef| { - let result = socket.volatile().emit("event", &json!({"data": 42})); - tx2.send(result).unwrap(); - }); - - io.new_dummy_sock("/test", ()).await; - assert!(rx2.recv().unwrap().is_ok()); + let (first, _second) = rx.recv().await.unwrap(); + // Both should return Ok(()); the second emit overwrites the first + // in the watch channel (it's not "false" since the send succeeds). + assert!(first.is_ok()); } #[tokio::test] @@ -100,7 +58,6 @@ async fn volatile_broadcast_arrives_via_polling_transport() { let sender_sid = create_polling_connection(&svc).await; let receiver_sid = create_polling_connection(&svc).await; - // Drain any queued handshake data from the connections send_req( &svc, format!("transport=polling&sid={sender_sid}"), @@ -115,7 +72,6 @@ async fn volatile_broadcast_arrives_via_polling_transport() { None, ) .await; - // Respond to pings to keep sessions alive send_req( &svc, format!("transport=polling&sid={sender_sid}"), @@ -131,7 +87,6 @@ async fn volatile_broadcast_arrives_via_polling_transport() { ) .await; - // Send drawing event from sender send_req( &svc, format!("transport=polling&sid={sender_sid}"), @@ -140,7 +95,6 @@ async fn volatile_broadcast_arrives_via_polling_transport() { ) .await; - // Poll receiver — should receive the volatile broadcast let response = send_req( &svc, format!("transport=polling&sid={receiver_sid}"), @@ -148,7 +102,6 @@ async fn volatile_broadcast_arrives_via_polling_transport() { None, ) .await; - // send_req skips first char (engine.io message type '4') assert!( response.contains("drawing"), "Expected volatile broadcast with 'drawing', got: {response}" From dcd255c9c694426d336287ac3d4f86741a6d3617 Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 19:49:57 +0200 Subject: [PATCH 39/49] fix(engineio): keep watch behind a mutex --- crates/engineioxide/src/engine.rs | 2 +- crates/engineioxide/src/socket.rs | 40 +++++------- crates/engineioxide/src/transport/polling.rs | 66 ++++++++++++-------- crates/engineioxide/src/transport/ws.rs | 12 ++-- 4 files changed, 64 insertions(+), 56 deletions(-) diff --git a/crates/engineioxide/src/engine.rs b/crates/engineioxide/src/engine.rs index c0fdabc1..92720df5 100644 --- a/crates/engineioxide/src/engine.rs +++ b/crates/engineioxide/src/engine.rs @@ -99,7 +99,7 @@ impl EngineIo { // channel so that `Socket::closed` resolves and no more packets can be buffered. // When no transport holds the lock (e.g. polling between requests) this resolves // immediately. - socket.internal_rx.lock().await.rx.close(); + socket.internal_rx.lock().await.buffered_rx.close(); #[cfg(feature = "tracing")] tracing::debug!(sid = %socket.id, "session closed, notifying handler"); diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index b5c2e443..c57c354e 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -57,7 +57,6 @@ use std::{ collections::VecDeque, fmt::Debug, - ops::{Deref, DerefMut}, sync::{ Arc, atomic::{AtomicBool, AtomicU8, Ordering}, @@ -157,24 +156,21 @@ impl Permit<'_> { #[derive(Debug)] pub(crate) struct InternalRx { - pub rx: Receiver, - pub peeked: Option, + pub buffered_rx: Receiver, + pub peeked_packet: Option, + pub volatile_rx: watch::Receiver>, } -impl InternalRx { - fn new(rx: Receiver) -> Self { - Self { rx, peeked: None } - } -} -impl Deref for InternalRx { - type Target = Receiver; - fn deref(&self) -> &Self::Target { - &self.rx - } -} -impl DerefMut for InternalRx { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.rx +impl InternalRx { + fn new( + buffered_rx: Receiver, + volatile_rx: watch::Receiver>, + ) -> Self { + Self { + buffered_rx, + peeked_packet: None, + volatile_rx, + } } } @@ -224,8 +220,6 @@ where /// Uses a [`watch`](tokio::sync::watch) channel so only the latest volatile /// message is retained; subsequent volatile sends overwrite the previous one. volatile_tx: watch::Sender>, - /// Receiver for the volatile channel, read by the transport with priority. - pub(crate) volatile_rx: watch::Receiver>, /// Internal channel to receive Pong [`Packets`](Packet) (v4 protocol) or Ping (v3 protocol) in the heartbeat job /// which is running in a separate task @@ -271,10 +265,9 @@ where transport: AtomicU8::new(transport as u8), upgrading: AtomicBool::new(false), - internal_rx: Mutex::new(InternalRx::new(internal_rx)), + internal_rx: Mutex::new(InternalRx::new(internal_rx, volatile_rx)), internal_tx, - volatile_rx, volatile_tx, heartbeat_rx: Mutex::new(heartbeat_rx), @@ -623,10 +616,9 @@ where transport: AtomicU8::new(TransportType::Websocket as u8), upgrading: AtomicBool::new(false), - internal_rx: Mutex::new(InternalRx::new(internal_rx)), + internal_rx: Mutex::new(InternalRx::new(internal_rx, volatile_rx)), internal_tx, - volatile_rx, volatile_tx, heartbeat_rx: Mutex::new(heartbeat_rx), @@ -645,7 +637,7 @@ where let sock_clone = sock.clone(); tokio::spawn(async move { let mut internal_rx = sock_clone.internal_rx.try_lock().unwrap(); - while let Some(packets) = internal_rx.recv().await { + while let Some(packets) = internal_rx.buffered_rx.recv().await { for packet in packets { tx.send(packet).await.unwrap(); } diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index 70664a36..8558a046 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -13,7 +13,7 @@ use engineioxide_core::{Packet, ProtocolVersion, Sid, TransportType}; use crate::{ DisconnectReason, body::ResponseBody, engine::EngineIo, errors::Error, - handler::EngineIoHandler, transport::make_open_packet, + handler::EngineIoHandler, socket::InternalRx, transport::make_open_packet, }; /// Create a response for http request @@ -116,19 +116,24 @@ where tracing::debug!(%sid, %protocol, supports_binary = socket.supports_binary, "polling request"); let max_payload = engine.config.max_payload; - - // Prepend the packet peeked during the previous - // polling request so it is encoded first, ahead of the newly received packets. - let rx_stream = stream::iter(rx.peeked.take()).chain(rx_stream::EncoderStream::new( - &mut rx, - socket.volatile_rx.clone(), //TODO: do we loose any state from cloning volatile_rx? - )); - - // Stop waiting for the next packet as soon as the session is closed. The combinator is biased - // towards the encoder completion, so any buffered close packet is still flushed to the client. - let payload = payload::encoder(rx_stream, protocol, socket.supports_binary, max_payload) - .with_cancellation_token(&socket.cancellation_token) - .await; + let payload = { + let InternalRx { + buffered_rx, + volatile_rx, + peeked_packet, + } = &mut *rx; + + // Prepend the packet peeked during the previous + // polling request so it is encoded first, ahead of the newly received packets. + let rx_stream = stream::iter(peeked_packet.take()) + .chain(rx_stream::EncoderStream::new(buffered_rx, volatile_rx)); + + // Stop waiting for the next packet as soon as the session is closed. The combinator is biased + // towards the encoder completion, so any buffered close packet is still flushed to the client. + payload::encoder(rx_stream, protocol, socket.supports_binary, max_payload) + .with_cancellation_token(&socket.cancellation_token) + .await + }; let Some(Payload { data, @@ -143,7 +148,7 @@ where // set back the peeked packet so it can be read again // on the next polling request - rx.peeked = peeked; + rx.peeked_packet = peeked; #[cfg(feature = "tracing")] tracing::trace!(%sid, %protocol, supports_binary = socket.supports_binary, "sending data: {:?}", data); @@ -245,12 +250,16 @@ mod rx_stream { } } - pub struct WatchStream { - inner: - ReusableBoxFuture<'static, (Result<(), watch::error::RecvError>, watch::Receiver)>, + type WatchFutOutput<'a, T> = ( + Result<(), watch::error::RecvError>, + &'a mut watch::Receiver, + ); + + pub struct WatchStream<'a, T> { + inner: ReusableBoxFuture<'a, WatchFutOutput<'a, T>>, } - impl WatchStream { - pub fn new(rx: watch::Receiver) -> Self { + impl<'a, T: Send + Sync + 'static> WatchStream<'a, T> { + pub fn new(rx: &'a mut watch::Receiver) -> Self { Self { inner: ReusableBoxFuture::new(make_future(rx)), } @@ -258,17 +267,17 @@ mod rx_stream { } async fn make_future( - mut rx: watch::Receiver, - ) -> (Result<(), watch::error::RecvError>, watch::Receiver) { + rx: &mut watch::Receiver, + ) -> (Result<(), watch::error::RecvError>, &mut watch::Receiver) { let result = rx.changed().await; (result, rx) } - impl Stream for WatchStream { + impl<'a, T: Clone + Send + Sync + 'static> Stream for WatchStream<'a, T> { type Item = T; fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let (result, mut rx) = ready!(self.inner.poll(cx)); + let (result, rx) = ready!(self.inner.poll(cx)); match result { Ok(_) => { let received = (*rx.borrow_and_update()).clone(); @@ -283,7 +292,7 @@ mod rx_stream { } } - impl Unpin for WatchStream {} + impl Unpin for WatchStream<'_, T> {} pin_project! { /// An [`EncoderStream`] that wraps a [`WatchStream`] and a [`ReceiverStream`]. @@ -292,14 +301,17 @@ mod rx_stream { /// This allow to have a priority on classic mpsc chan before polling volatile packets. pub struct EncoderStream<'a, T> { #[pin] - watch: WatchStream>, + watch: WatchStream<'a, Option>, #[pin] rx: ReceiverStream<'a, T>, } } impl<'a, T: Clone + Send + Sync + 'static> EncoderStream<'a, T> { - pub fn new(rx: &'a mut mpsc::Receiver, watch: watch::Receiver>) -> Self { + pub fn new( + rx: &'a mut mpsc::Receiver, + watch: &'a mut watch::Receiver>, + ) -> Self { Self { rx: ReceiverStream::new(rx), watch: WatchStream::new(watch), diff --git a/crates/engineioxide/src/transport/ws.rs b/crates/engineioxide/src/transport/ws.rs index 114e5c14..ea7ad418 100644 --- a/crates/engineioxide/src/transport/ws.rs +++ b/crates/engineioxide/src/transport/ws.rs @@ -27,7 +27,7 @@ use engineioxide_core::{Packet, ProtocolVersion, Sid, Str, TransportType}; use crate::{ DisconnectReason, Socket, body::ResponseBody, config::EngineIoConfig, engine::EngineIo, - errors::Error, handler::EngineIoHandler, transport::make_open_packet, + errors::Error, handler::EngineIoHandler, socket::InternalRx, transport::make_open_packet, }; /// Create a response for websocket upgrade @@ -253,7 +253,11 @@ async fn forward_to_socket( S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let mut internal_rx = socket.internal_rx.try_lock().unwrap(); - let mut volatile_rx = socket.volatile_rx.clone(); + let InternalRx { + buffered_rx, + volatile_rx, + .. + } = &mut *internal_rx; // map a packet to a websocket message // It is declared as a macro rather than a closure to avoid ownership issues @@ -292,13 +296,13 @@ async fn forward_to_socket( loop { tokio::select! { biased; - items = internal_rx.recv() => { + items = buffered_rx.recv() => { match items { Some(packets) => { for item in packets { map_fn!(item); } - while let Ok(packets) = internal_rx.try_recv() { + while let Ok(packets) = buffered_rx.try_recv() { for item in packets { map_fn!(item); } From bf6059f0a85195d079b7ab82c923e620a3b27504 Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 19:50:18 +0200 Subject: [PATCH 40/49] tests(engineio): improve e2e test to check for volatile packets --- e2e/adapter/client.ts | 22 ++++++++++++++++++++++ e2e/adapter/src/lib.rs | 7 +++++++ 2 files changed, 29 insertions(+) diff --git a/e2e/adapter/client.ts b/e2e/adapter/client.ts index 14e08739..ef02079e 100644 --- a/e2e/adapter/client.ts +++ b/e2e/adapter/client.ts @@ -42,6 +42,28 @@ describe("adapter tests", { timeout: 60000 }, () => { } }); + it("should broadcast a volatile packet sent from a socket to all other sockets", async (ctx) => { + const sockets: Socket[] = (ctx as any).sockets; + for (const socket of sockets) { + let msgs: string[] = []; + const prom = new Promise((resolve) => { + for (const socket of sockets) { + socket.once("volatile_broadcast", (data: string) => { + msgs.push(data); + if (msgs.length === sockets.length) resolve(null); + }); + } + }); + + socket.emit("volatile_broadcast"); + await prom; + assert.equal(Object.values(msgs).length, sockets.length); + for (const msg of msgs) { + assert.deepStrictEqual(msg, `hello from ${socket.id}`); + } + } + }); + it("should broadcast a packet sent from a socket to all other sockets and get an ack from each socket", async (ctx) => { const sockets: Socket[] = (ctx as any).sockets; const expected = sockets.map((s) => `ack from ${s.id}`).sort(); diff --git a/e2e/adapter/src/lib.rs b/e2e/adapter/src/lib.rs index a1564858..e779a9e6 100644 --- a/e2e/adapter/src/lib.rs +++ b/e2e/adapter/src/lib.rs @@ -22,6 +22,7 @@ pub async fn handler(s: SocketRef) { // "Broadcast" tests s.on("broadcast", broadcast); + s.on("volatile_broadcast", volatile_broadcast); s.on("fetch_sockets", fetch_sockets); s.on("broadcast_with_ack", broadcast_with_ack); s.on("disconnect_socket", disconnect_socket); @@ -46,6 +47,12 @@ async fn broadcast(io: SocketIo, s: SocketRef) { .await .unwrap(); } +async fn volatile_broadcast(io: SocketIo, s: SocketRef) { + io.volatile() + .emit("volatile_broadcast", &format!("hello from {}", s.id)) + .await + .unwrap(); +} async fn broadcast_with_ack(io: SocketIo, ack: AckSender) { let data: Vec = io .emit_with_ack("broadcast_with_ack", &()) From 43a41e1a38579a8fde7605b72fd4410126bc0ba0 Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 19:54:28 +0200 Subject: [PATCH 41/49] fix: minor fixes --- crates/engineioxide/src/socket.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engineioxide/src/socket.rs b/crates/engineioxide/src/socket.rs index c57c354e..d338d928 100644 --- a/crates/engineioxide/src/socket.rs +++ b/crates/engineioxide/src/socket.rs @@ -462,7 +462,7 @@ where /// Immediately closes the socket and the underlying connection. /// The socket will be removed from the `Engine` and the [`Handler`](crate::handler::EngineIoHandler) will be notified. - #[tracing::instrument(skip(self))] + #[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))] pub fn close(&self, reason: DisconnectReason) { // Try to send a close packet is the connection is still operational. self.send(Packet::Close).ok(); From e8b51878cd63435058e04cdc8c2d5ac096ea4a27 Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 20:01:37 +0200 Subject: [PATCH 42/49] fix: minor fixes --- crates/engineioxide/Cargo.toml | 2 +- crates/engineioxide/src/transport/polling.rs | 2 +- crates/socketioxide/src/operators.rs | 18 +++++++----------- crates/socketioxide/tests/disconnect_reason.rs | 7 ++----- examples/Cargo.lock | 1 + 5 files changed, 12 insertions(+), 18 deletions(-) diff --git a/crates/engineioxide/Cargo.toml b/crates/engineioxide/Cargo.toml index dda825d7..030f98be 100644 --- a/crates/engineioxide/Cargo.toml +++ b/crates/engineioxide/Cargo.toml @@ -28,7 +28,7 @@ http-body.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["rt", "time", "macros"] } +tokio = { workspace = true, features = ["rt", "time"] } tokio-util.workspace = true tower-service.workspace = true tower-layer.workspace = true diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index 8558a046..1032bc25 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -296,7 +296,7 @@ mod rx_stream { pin_project! { /// An [`EncoderStream`] that wraps a [`WatchStream`] and a [`ReceiverStream`]. - /// It will poll the [`ReceiverStream`] first, and then the [`WatchStream`]. + /// It will poll the [`WatchStream`] first, and then the [`ReceiverStream`]. /// /// This allow to have a priority on classic mpsc chan before polling volatile packets. pub struct EncoderStream<'a, T> { diff --git a/crates/socketioxide/src/operators.rs b/crates/socketioxide/src/operators.rs index c92bf534..57c10a48 100644 --- a/crates/socketioxide/src/operators.rs +++ b/crates/socketioxide/src/operators.rs @@ -112,23 +112,19 @@ impl ConfOperators<'_, A> { event: impl AsRef, data: &T, ) -> Result<(), SendError> { + use crate::SocketError; + use crate::socket::PermitExt; + if !self.socket.connected() { + return Err(SendError::Socket(SocketError::Closed)); + } + if self.volatile { - if !self.socket.connected() { - return Ok(()); - } - let Ok(packet) = self.get_packet(event, data) else { - return Ok(()); - }; + let packet = self.get_packet(event, data)?; self.socket .send_raw_volatile(self.socket.parser.encode(packet)); return Ok(()); } - use crate::SocketError; - use crate::socket::PermitExt; - if !self.socket.connected() { - return Err(SendError::Socket(SocketError::Closed)); - } let permit = match self.socket.reserve() { Ok(permit) => permit, Err(e) => { diff --git a/crates/socketioxide/tests/disconnect_reason.rs b/crates/socketioxide/tests/disconnect_reason.rs index a1b6d26a..f5adf507 100644 --- a/crates/socketioxide/tests/disconnect_reason.rs +++ b/crates/socketioxide/tests/disconnect_reason.rs @@ -273,11 +273,8 @@ pub async fn server_ws_closing() { })) .await; for packet in packets { - assert!(matches!(dbg!(packet.0.unwrap().unwrap()), Message::Text(_))); - assert!(matches!( - dbg!(packet.1.unwrap().unwrap()), - Message::Close(_) - )); + assert!(matches!(packet.0.unwrap().unwrap(), Message::Text(_))); + assert!(matches!(packet.1.unwrap().unwrap(), Message::Close(_))); assert!(packet.2.is_none()); } } diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 63a364e6..1346a150 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -1120,6 +1120,7 @@ dependencies = [ "serde", "serde_json", "smallvec", + "tracing", ] [[package]] From c5669fdcbe84be59b02829fc1ec4e309d65d8bdb Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 20:02:41 +0200 Subject: [PATCH 43/49] fix: minor fixes --- crates/engineioxide/src/transport/polling.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index 1032bc25..2522bfc3 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -295,10 +295,6 @@ mod rx_stream { impl Unpin for WatchStream<'_, T> {} pin_project! { - /// An [`EncoderStream`] that wraps a [`WatchStream`] and a [`ReceiverStream`]. - /// It will poll the [`WatchStream`] first, and then the [`ReceiverStream`]. - /// - /// This allow to have a priority on classic mpsc chan before polling volatile packets. pub struct EncoderStream<'a, T> { #[pin] watch: WatchStream<'a, Option>, From d4912223ee0b18e20e2332f35b8d157fcbffcb6b Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 20:04:23 +0200 Subject: [PATCH 44/49] fix: minor fixes --- crates/engineioxide/src/transport/polling.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index 2522bfc3..f458737b 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -255,6 +255,10 @@ mod rx_stream { &'a mut watch::Receiver, ); + /// Wraps a [`watch::Receiver`] into a [`Stream`] by reference. + /// Allowing to use it as a stream even if it is behind a mutex. + /// + /// Inspired by pub struct WatchStream<'a, T> { inner: ReusableBoxFuture<'a, WatchFutOutput<'a, T>>, } @@ -295,6 +299,7 @@ mod rx_stream { impl Unpin for WatchStream<'_, T> {} pin_project! { + /// Combines a [`WatchStream`] and a [`ReceiverStream`] into a single [`Stream`]. pub struct EncoderStream<'a, T> { #[pin] watch: WatchStream<'a, Option>, From 50601d1e1106524416abbe106ceb8dd087b3158e Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 20:04:48 +0200 Subject: [PATCH 45/49] fix: minor fixes --- crates/engineioxide/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engineioxide/Cargo.toml b/crates/engineioxide/Cargo.toml index 030f98be..dda825d7 100644 --- a/crates/engineioxide/Cargo.toml +++ b/crates/engineioxide/Cargo.toml @@ -28,7 +28,7 @@ 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"] } tokio-util.workspace = true tower-service.workspace = true tower-layer.workspace = true From d0aab10d17cc997a0e29585dcd0d595bede2c23c Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 20:23:33 +0200 Subject: [PATCH 46/49] fix: minor fixes --- crates/engineioxide/src/transport/polling.rs | 11 ++++++++--- crates/engineioxide/tests/volatile.rs | 4 ++-- crates/socketioxide/docs/operators/volatile.md | 9 +++++---- examples/whiteboard/Cargo.toml | 2 +- examples/whiteboard/src/main.rs | 6 +++++- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/engineioxide/src/transport/polling.rs b/crates/engineioxide/src/transport/polling.rs index f458737b..23bdc4bc 100644 --- a/crates/engineioxide/src/transport/polling.rs +++ b/crates/engineioxide/src/transport/polling.rs @@ -325,9 +325,14 @@ mod rx_stream { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.project(); - match this.watch.poll_next(cx) { - Poll::Ready(Some(v)) => Poll::Ready(v), - Poll::Ready(None) | Poll::Pending => this.rx.poll_next(cx), + // low priority: poll the watch stream first, then the rx stream. + // same than with ws transport + match this.rx.poll_next(cx) { + Poll::Ready(v) => Poll::Ready(v), + Poll::Pending => match this.watch.poll_next(cx) { + Poll::Ready(Some(v)) => Poll::Ready(v), + Poll::Ready(None) | Poll::Pending => Poll::Pending, + }, } } } diff --git a/crates/engineioxide/tests/volatile.rs b/crates/engineioxide/tests/volatile.rs index 3867ab2a..a7dee0c0 100644 --- a/crates/engineioxide/tests/volatile.rs +++ b/crates/engineioxide/tests/volatile.rs @@ -83,7 +83,7 @@ async fn mixed_volatile_and_normal_via_polling() { .await; // Volatile should have priority and appear first, before normal. // send_req skips only the first character (the leading '4' of the volatile). - assert_eq!(response, "volatile_msg\x1e4normal_msg"); + assert_eq!(response, "normal_msg\x1e4volatile_msg"); } #[tokio::test] @@ -109,7 +109,7 @@ async fn volatile_overwrite_only_latest_survives() { ) .await; // After send_req's skip(1): "kept" then \x1e separator then "4normal" - assert_eq!(response, "kept\x1e4normal"); + assert_eq!(response, "normal\x1e4kept"); } #[tokio::test] diff --git a/crates/socketioxide/docs/operators/volatile.md b/crates/socketioxide/docs/operators/volatile.md index 42023754..028c0781 100644 --- a/crates/socketioxide/docs/operators/volatile.md +++ b/crates/socketioxide/docs/operators/volatile.md @@ -1,7 +1,8 @@ -# Set the volatile flag for the emit. When set, the event may be dropped -if the client is not ready to receive it (e.g. the connection is buffering -or not connected). This is useful for events that are not critical, such -as position updates in a game. +# Emit a message with volatile behavior + +When set, the emitted event may be dropped if the client is not ready to receive it +(e.g. the connection is buffering or not connected). This is useful for events +that are not critical, such as position updates in a game. Because volatile events use a separate channel that bypasses the main mpsc buffer, they may arrive **out of order** relative to regular events diff --git a/examples/whiteboard/Cargo.toml b/examples/whiteboard/Cargo.toml index d7be336d..5ef325bf 100644 --- a/examples/whiteboard/Cargo.toml +++ b/examples/whiteboard/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -socketioxide = { workspace = true, features = ["tracing", "msgpack"] } +socketioxide = { workspace = true, features = ["tracing", "msgpack", "state"] } axum.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tower-http.workspace = true diff --git a/examples/whiteboard/src/main.rs b/examples/whiteboard/src/main.rs index c78454ae..15b5a19d 100644 --- a/examples/whiteboard/src/main.rs +++ b/examples/whiteboard/src/main.rs @@ -24,7 +24,11 @@ async fn main() -> Result<(), Box> { io.ns("/", async |s: SocketRef| { s.on("drawing", async |s: SocketRef, Data::(data)| { info!("Drawing event received, broadcasting with volatile"); - s.broadcast().volatile().emit("drawing", &data).await.unwrap(); + s.broadcast() + .volatile() + .emit("drawing", &data) + .await + .unwrap(); info!("Volatile broadcast completed"); }); }); From 7bb4e47a4673d6e14ae46092454b05ca865c0997 Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 20:32:52 +0200 Subject: [PATCH 47/49] fix: minor fixes --- crates/socketioxide/docs/operators/volatile.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/socketioxide/docs/operators/volatile.md b/crates/socketioxide/docs/operators/volatile.md index 028c0781..3a61cf69 100644 --- a/crates/socketioxide/docs/operators/volatile.md +++ b/crates/socketioxide/docs/operators/volatile.md @@ -11,6 +11,10 @@ regular events is not important. See [socket.io volatile events](https://socket.io/docs/v4/emitting-events/#volatile-events). +
+The volatile operator wont have any effect if you use it with emit_with_ack(). +
+ # Example ```rust # use socketioxide::{SocketIo, extract::*}; @@ -25,5 +29,9 @@ io.ns("/", async |socket: SocketRef| { // Volatile broadcast to a room socket.volatile().to("game_room").emit("update", &42).await.ok(); + + + // Same than without volatile. + socket.volatile().to("game_room").emit_with_ack("update", &42).await.ok(); }); ``` From 40fa42bec4b63e08bc67efcabcb87068e0913e69 Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 20:36:26 +0200 Subject: [PATCH 48/49] fix: minor fixes --- crates/socketioxide/docs/operators/volatile.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socketioxide/docs/operators/volatile.md b/crates/socketioxide/docs/operators/volatile.md index 3a61cf69..1a18ad56 100644 --- a/crates/socketioxide/docs/operators/volatile.md +++ b/crates/socketioxide/docs/operators/volatile.md @@ -32,6 +32,6 @@ io.ns("/", async |socket: SocketRef| { // Same than without volatile. - socket.volatile().to("game_room").emit_with_ack("update", &42).await.ok(); + socket.volatile().to("game_room").emit_with_ack::<_, ()>("update", &42).await.ok(); }); ``` From d7f8545aeb92dddfaf4fa0c2815ff02de27cb477 Mon Sep 17 00:00:00 2001 From: totodore Date: Thu, 9 Jul 2026 20:37:47 +0200 Subject: [PATCH 49/49] fix: minor fixes --- examples/whiteboard/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/whiteboard/Cargo.toml b/examples/whiteboard/Cargo.toml index 5ef325bf..d7be336d 100644 --- a/examples/whiteboard/Cargo.toml +++ b/examples/whiteboard/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -socketioxide = { workspace = true, features = ["tracing", "msgpack", "state"] } +socketioxide = { workspace = true, features = ["tracing", "msgpack"] } axum.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tower-http.workspace = true