Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
b293bd1
wip
Totodore Jun 9, 2025
b1c6445
Merge branch 'main' into engineioxide-client
Totodore Jul 20, 2025
a387107
Merge remote-tracking branch 'origin/main' into engineioxide-client
Totodore Jul 20, 2025
cb13f2f
feat(engineio): refactor shared types to core
Totodore Jul 20, 2025
7266fc8
feat(engineio): refactor shared types to core
Totodore Jul 20, 2025
a7f23e0
feat(client): wip
Totodore Jul 26, 2025
63011e0
feat(client): wip
Totodore Jul 27, 2025
60d2788
feat(client): wip
Totodore Aug 3, 2025
765d055
Merge remote-tracking branch 'origin/main' into engineioxide-client
Totodore Aug 3, 2025
9dd5988
fix(engineio): add content type to decoder
Totodore Aug 3, 2025
24ea9c5
feat(client): wip
Totodore Aug 3, 2025
c27e3b1
Merge remote-tracking branch 'origin/main' into engineioxide-client
Totodore Aug 9, 2025
d111347
Merge branch 'main' into engineioxide-client
Totodore Aug 9, 2025
edc3354
Merge branch 'main' into engineioxide-client
Totodore Aug 20, 2025
1fac3aa
wip
Totodore Oct 10, 2025
4a4439f
wip
Totodore Oct 11, 2025
0ed449c
feat: implement support for volatile events with new VolatileSocket w…
LiamCarPer Jun 20, 2026
35d867b
feat(socketio,engineio): add volatile flag for emitting events
LiamCarPer Jun 21, 2026
78e4f28
docs: add warnings about potential message ordering issues with volat…
LiamCarPer Jun 21, 2026
8861111
refactor: replace VolatileSocket with operator flag and optimize vola…
LiamCarPer Jun 26, 2026
91a04de
test: update v3_string_encoder call to include missing argument in po…
LiamCarPer Jun 26, 2026
fabc57b
refactor: replace volatile packet collection with lazy watch receiver…
LiamCarPer Jun 26, 2026
06f547f
trigger ci
LiamCarPer Jun 27, 2026
0a12cca
feat: implement comprehensive test coverage
LiamCarPer Jun 29, 2026
47ad1b6
Merge remote-tracking branch 'origin/main' into engineioxide-client
Totodore Jun 29, 2026
f41d1c3
refactor(engineio): move shareable code with client to core
Totodore Jun 29, 2026
c54bbca
fix(engineio/core): minor fixes
Totodore Jun 29, 2026
e16fb26
fix(engineio/core): minor fixes
Totodore Jun 29, 2026
b150bb4
fix: move websocket flush after volatile check and add payload encode…
LiamCarPer Jun 29, 2026
c6a4123
fix(engineio/core): minor fixes
Totodore Jun 29, 2026
804212c
fix: ensure volatile packets are correctly captured and flushed durin…
LiamCarPer Jun 29, 2026
097e20e
fix(engineio/core): minor fixes
Totodore Jul 3, 2026
f9776f6
fix(engineio/core): remove hacky closing for internal chan
Totodore Jul 3, 2026
0ea5049
fix(engineio): add proper transport closing with cancellation token
Totodore Jul 3, 2026
5c1c3cc
fix(engineio/polling): remove yield_packets
Totodore Jul 3, 2026
7acb6b0
fix(engineio): improve errors
Totodore Jul 5, 2026
b08ea4d
fix(engineio): improve errors
Totodore Jul 5, 2026
c98ccab
fix(engineio): improve errors
Totodore Jul 5, 2026
3036192
fix(engineio): improve errors
Totodore Jul 5, 2026
316468b
fix(engineio): improve errors
Totodore Jul 5, 2026
06aa35c
fix(engineio): improve errors
Totodore Jul 5, 2026
d652161
fix(engineio): improve errors
Totodore Jul 5, 2026
155696b
fix(engineio): improve errors
Totodore Jul 5, 2026
0473d04
fix(engineio): improve errors
Totodore Jul 5, 2026
e40d1ad
refactor: improve volatile event handling in engineioxide transport u…
LiamCarPer Jul 6, 2026
d23d951
Merge branch 'feat/volatile-flag' into volatile-impl-test
Totodore Jul 9, 2026
6e7aa40
Merge remote-tracking branch 'origin/main' into volatile-impl-test
Totodore Jul 9, 2026
dcd255c
fix(engineio): keep watch behind a mutex
Totodore Jul 9, 2026
bf6059f
tests(engineio): improve e2e test to check for volatile packets
Totodore Jul 9, 2026
43a41e1
fix: minor fixes
Totodore Jul 9, 2026
e8b5187
fix: minor fixes
Totodore Jul 9, 2026
c5669fd
fix: minor fixes
Totodore Jul 9, 2026
d491222
fix: minor fixes
Totodore Jul 9, 2026
50601d1
fix: minor fixes
Totodore Jul 9, 2026
d0aab10
fix: minor fixes
Totodore Jul 9, 2026
7bb4e47
fix: minor fixes
Totodore Jul 9, 2026
40fa42b
fix: minor fixes
Totodore Jul 9, 2026
d7f8545
fix: minor fixes
Totodore Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/engineioxide/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/engineioxide/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ impl<H: EngineIoHandler> EngineIo<H> {
// 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");
Expand Down
98 changes: 78 additions & 20 deletions crates/engineioxide/src/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
use std::{
collections::VecDeque,
fmt::Debug,
ops::{Deref, DerefMut},
sync::{
Arc,
atomic::{AtomicBool, AtomicU8, Ordering},
Expand All @@ -74,6 +73,7 @@ use smallvec::{SmallVec, smallvec};
use tokio::sync::{
Mutex,
mpsc::{self, Receiver, error::TrySendError},
watch,
};

pub use engineioxide_core::Sid;
Expand Down Expand Up @@ -156,24 +156,21 @@ impl Permit<'_> {

#[derive(Debug)]
pub(crate) struct InternalRx {
pub rx: Receiver<PacketBuf>,
pub peeked: Option<PacketBuf>,
pub buffered_rx: Receiver<PacketBuf>,
pub peeked_packet: Option<PacketBuf>,
pub volatile_rx: watch::Receiver<Option<PacketBuf>>,
}
impl InternalRx {
fn new(rx: Receiver<PacketBuf>) -> Self {
Self { rx, peeked: None }
}
}
impl Deref for InternalRx {
type Target = Receiver<PacketBuf>;

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<PacketBuf>,
volatile_rx: watch::Receiver<Option<PacketBuf>>,
) -> Self {
Self {
buffered_rx,
peeked_packet: None,
volatile_rx,
}
}
}

Expand Down Expand Up @@ -219,6 +216,11 @@ where
/// Channel to send [PacketBuf] to the internal connection
pub(crate) internal_tx: mpsc::Sender<PacketBuf>,

/// 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<Option<PacketBuf>>,

/// 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<Receiver<()>>,
Expand Down Expand Up @@ -255,16 +257,19 @@ 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(),
protocol,
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_tx,

heartbeat_rx: Mutex::new(heartbeat_rx),
heartbeat_tx,
cancellation_token: CancellationToken::new(),
Expand Down Expand Up @@ -457,6 +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.
#[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();
Expand Down Expand Up @@ -493,6 +499,55 @@ 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.
///
/// 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<Str>) -> 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.
///
/// Returns `true` if the message was queued for sending, `false` if it
/// was dropped.
#[inline]
pub fn emit_binary_volatile<B: Into<Bytes>>(&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.
///
/// 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<Bytes>) -> 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<D: Default + Send + Sync + 'static> std::fmt::Debug for Socket<D> {
Expand Down Expand Up @@ -553,16 +608,19 @@ where
) -> (Arc<Socket<D>>, tokio::sync::mpsc::Receiver<Packet>) {
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,
protocol: ProtocolVersion::V4,
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_tx,

heartbeat_rx: Mutex::new(heartbeat_rx),
heartbeat_tx,
cancellation_token: CancellationToken::new(),
Expand All @@ -579,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();
}
Expand Down
127 changes: 112 additions & 15 deletions crates/engineioxide/src/transport/polling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -116,16 +116,24 @@ where
tracing::debug!(%sid, %protocol, supports_binary = socket.supports_binary, "polling request");

let max_payload = engine.config.max_payload;
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(rx.peeked.take()).chain(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(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.
let payload = payload::encoder(rx_stream, protocol, socket.supports_binary, max_payload)
.with_cancellation_token(&socket.cancellation_token)
.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.
payload::encoder(rx_stream, protocol, socket.supports_binary, max_payload)
.with_cancellation_token(&socket.cancellation_token)
.await
};

let Some(Payload {
data,
Expand All @@ -140,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);
Expand Down Expand Up @@ -214,20 +222,22 @@ where
mod rx_stream {
use std::{
pin::Pin,
task::{Context, Poll},
task::{Context, Poll, ready},
};

use futures_core::Stream;
use tokio::sync::mpsc::Receiver;
use pin_project_lite::pin_project;
use tokio::sync::{mpsc, watch};
use tokio_util::sync::ReusableBoxFuture;

/// [`ReceiverStream`] is a stream that wraps a tokio::sync::mpsc::Receiver by reference.
/// [`ReceiverStream`] is a stream that wraps a [`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<T>,
inner: &'a mut mpsc::Receiver<T>,
}

impl<'a, T> ReceiverStream<'a, T> {
pub fn new(inner: &'a mut Receiver<T>) -> Self {
pub fn new(inner: &'a mut mpsc::Receiver<T>) -> Self {
Self { inner }
}
}
Expand All @@ -239,4 +249,91 @@ mod rx_stream {
self.inner.poll_recv(cx)
}
}

type WatchFutOutput<'a, T> = (
Result<(), watch::error::RecvError>,
&'a mut watch::Receiver<T>,
);

/// 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 <https://docs.rs/tokio-stream/latest/tokio_stream/wrappers/struct.WatchStream.html>
pub struct WatchStream<'a, T> {
inner: ReusableBoxFuture<'a, WatchFutOutput<'a, T>>,
}
impl<'a, T: Send + Sync + 'static> WatchStream<'a, T> {
pub fn new(rx: &'a mut watch::Receiver<T>) -> Self {
Self {
inner: ReusableBoxFuture::new(make_future(rx)),
}
}
}

async fn make_future<T>(
rx: &mut watch::Receiver<T>,
) -> (Result<(), watch::error::RecvError>, &mut watch::Receiver<T>) {
let result = rx.changed().await;
(result, rx)
}

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<Option<Self::Item>> {
let (result, rx) = ready!(self.inner.poll(cx));
match result {
Ok(_) => {
let received = (*rx.borrow_and_update()).clone();
self.inner.set(make_future(rx));
Poll::Ready(Some(received))
}
Err(_) => {
self.inner.set(make_future(rx));
Poll::Ready(None)
}
}
}
}

impl<T> 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<T>>,
#[pin]
rx: ReceiverStream<'a, T>,
}
}

impl<'a, T: Clone + Send + Sync + 'static> EncoderStream<'a, T> {
pub fn new(
rx: &'a mut mpsc::Receiver<T>,
watch: &'a mut watch::Receiver<Option<T>>,
) -> Self {
Self {
rx: ReceiverStream::new(rx),
watch: WatchStream::new(watch),
}
}
}

impl<'a, T: Clone + Send + Sync + 'static> Stream for EncoderStream<'a, T> {
type Item = T;

fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.project();
// 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,
},
}
}
}
}
Loading