Skip to main content

nautilus_network/socket/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! High-performance raw TCP client implementation with TLS capability, automatic reconnection
17//! with exponential backoff and state management.
18//!
19//! **Key features**:
20//! - Connection state tracking (ACTIVE/RECONNECTING/DISCONNECTING/CLOSED).
21//! - Synchronized reconnection with backoff.
22//! - Split read/write architecture.
23//! - Python callback integration.
24//!
25//! **Design**:
26//! - Single reader, multiple writer model.
27//! - Read half runs in dedicated task.
28//! - Write half runs in dedicated task connected with channel.
29//! - Controller task manages lifecycle.
30//! - Event-driven state notification via `Notify` for immediate wakeup on transitions.
31
32use std::{
33    collections::VecDeque,
34    fmt::Debug,
35    path::Path,
36    pin::pin,
37    sync::{
38        Arc,
39        atomic::{AtomicU8, Ordering},
40    },
41    time::Duration,
42};
43
44use bytes::Bytes;
45use nautilus_core::CleanDrop;
46use nautilus_cryptography::providers::install_cryptographic_provider;
47use tokio::io::{AsyncReadExt, AsyncWriteExt};
48use tokio_tungstenite::tungstenite::{Error, client::IntoClientRequest, stream::Mode};
49
50use super::{SocketConfig, TcpMessageHandler, TcpReader, TcpWriter, WriterCommand};
51use crate::{
52    backoff::ExponentialBackoff,
53    dst,
54    error::{SendError, is_connection_drop_io_error},
55    logging::{log_task_aborted, log_task_started, log_task_stopped},
56    mode::ConnectionMode,
57    net::TcpStream,
58    tls::{Connector, create_tls_config_from_certs_dir, tcp_tls},
59};
60
61// Connection timing constants
62const CONNECTION_STATE_CHECK_INTERVAL_MS: u64 = 10;
63const GRACEFUL_SHUTDOWN_DELAY_MS: u64 = 100;
64const GRACEFUL_SHUTDOWN_TIMEOUT_SECS: u64 = 5;
65
66// Maximum buffer size for read operations (10 MB)
67const MAX_READ_BUFFER_BYTES: usize = 10 * 1024 * 1024;
68
69/// Creates a `TcpStream` with the server.
70///
71/// The stream can be encrypted with TLS or Plain. The stream is split into
72/// read and write ends:
73/// - The read end is passed to the task that keeps receiving
74///   messages from the server and passing them to a handler.
75/// - The write end is passed to a task which receives messages over a channel
76///   to send to the server.
77///
78/// The heartbeat is optional and can be configured with an interval and data to
79/// send.
80///
81/// The client uses a suffix to separate messages on the byte stream. It is
82/// appended to all sent messages and heartbeats. It is also used to split
83/// the received byte stream.
84#[cfg_attr(
85    feature = "python",
86    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network")
87)]
88struct SocketClientInner {
89    config: SocketConfig,
90    connector: Option<Connector>,
91    read_task: Arc<tokio::task::JoinHandle<()>>,
92    write_task: tokio::task::JoinHandle<()>,
93    writer_tx: tokio::sync::mpsc::UnboundedSender<WriterCommand>,
94    heartbeat_task: Option<tokio::task::JoinHandle<()>>,
95    connection_mode: Arc<AtomicU8>,
96    state_notify: Arc<tokio::sync::Notify>,
97    reconnect_timeout: Duration,
98    backoff: ExponentialBackoff,
99    handler: Option<TcpMessageHandler>,
100    reconnect_max_attempts: Option<u32>,
101    reconnect_attempt_count: u32,
102}
103
104impl SocketClientInner {
105    /// Connect to a URL with the specified configuration.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if connection fails or configuration is invalid.
110    pub(crate) async fn connect_url(config: SocketConfig) -> anyhow::Result<Self> {
111        const CONNECTION_TIMEOUT_SECS: u64 = 10;
112
113        install_cryptographic_provider();
114
115        // Validate suffix is non-empty to prevent panic in read loop (windows(0) panics)
116        if config.suffix.is_empty() {
117            anyhow::bail!("Socket suffix cannot be empty: suffix is required for message framing");
118        }
119
120        if let Some((interval_secs, _)) = &config.heartbeat
121            && *interval_secs == 0
122        {
123            anyhow::bail!("Heartbeat interval cannot be zero");
124        }
125
126        if config.idle_timeout_ms == Some(0) {
127            anyhow::bail!("Idle timeout cannot be zero");
128        }
129
130        let SocketConfig {
131            url,
132            mode,
133            heartbeat,
134            suffix,
135            message_handler,
136            reconnect_timeout_ms,
137            reconnect_delay_initial_ms,
138            reconnect_delay_max_ms,
139            reconnect_backoff_factor,
140            reconnect_jitter_ms,
141            connection_max_retries,
142            reconnect_max_attempts,
143            idle_timeout_ms,
144            certs_dir,
145        } = &config.clone();
146        let connector = if let Some(dir) = certs_dir {
147            let config = create_tls_config_from_certs_dir(Path::new(dir), false)?;
148            Some(Connector::Rustls(Arc::new(config)))
149        } else {
150            None
151        };
152
153        // Retry initial connection with exponential backoff to handle transient DNS/network issues
154        let max_retries = connection_max_retries.unwrap_or(5);
155
156        let mut backoff = ExponentialBackoff::new(
157            Duration::from_millis(500),
158            Duration::from_secs(5),
159            2.0,
160            250,
161            false,
162        )?;
163
164        #[allow(unused_assignments)]
165        let mut last_error = String::new();
166        let mut attempt = 0;
167        let (reader, writer) = loop {
168            attempt += 1;
169
170            match dst::time::timeout(
171                Duration::from_secs(CONNECTION_TIMEOUT_SECS),
172                Self::tls_connect_with_server(url, *mode, connector.clone()),
173            )
174            .await
175            {
176                Ok(Ok(result)) => {
177                    if attempt > 1 {
178                        log::info!("Socket connection established after {attempt} attempts");
179                    }
180                    break result;
181                }
182                Ok(Err(e)) => {
183                    last_error = e.to_string();
184                    log::warn!(
185                        "Socket connection attempt {attempt}/{max_retries} to {url} failed: {last_error}"
186                    );
187                }
188                Err(_) => {
189                    last_error = format!(
190                        "Connection timeout after {CONNECTION_TIMEOUT_SECS}s (possible DNS resolution failure)"
191                    );
192                    log::warn!(
193                        "Socket connection attempt {attempt}/{max_retries} to {url} timed out"
194                    );
195                }
196            }
197
198            if attempt >= max_retries {
199                anyhow::bail!(
200                    "Failed to connect to {} after {} attempts: {}. \
201                    If this is a DNS error, check your network configuration and DNS settings.",
202                    url,
203                    max_retries,
204                    if last_error.is_empty() {
205                        "unknown error"
206                    } else {
207                        &last_error
208                    }
209                );
210            }
211
212            let delay = backoff.next_duration();
213            log::debug!(
214                "Retrying in {delay:?} (attempt {}/{})",
215                attempt + 1,
216                max_retries
217            );
218            dst::time::sleep(delay).await;
219        };
220
221        log::debug!("Connected");
222
223        let connection_mode = Arc::new(AtomicU8::new(ConnectionMode::Active.as_u8()));
224        let state_notify = Arc::new(tokio::sync::Notify::new());
225
226        let read_task = Arc::new(Self::spawn_read_task(
227            connection_mode.clone(),
228            reader,
229            message_handler.clone(),
230            suffix.clone(),
231            *idle_timeout_ms,
232        ));
233
234        let (writer_tx, writer_rx) = tokio::sync::mpsc::unbounded_channel::<WriterCommand>();
235
236        let write_task = Self::spawn_write_task(
237            connection_mode.clone(),
238            state_notify.clone(),
239            writer,
240            writer_rx,
241            suffix.clone(),
242        );
243
244        // Optionally spawn a heartbeat task to periodically ping server
245        let heartbeat_task = heartbeat.as_ref().map(|heartbeat| {
246            Self::spawn_heartbeat_task(
247                connection_mode.clone(),
248                heartbeat.clone(),
249                writer_tx.clone(),
250            )
251        });
252
253        let reconnect_timeout = Duration::from_millis(reconnect_timeout_ms.unwrap_or(10_000));
254        let backoff = ExponentialBackoff::new(
255            Duration::from_millis(reconnect_delay_initial_ms.unwrap_or(2_000)),
256            Duration::from_millis(reconnect_delay_max_ms.unwrap_or(30_000)),
257            reconnect_backoff_factor.unwrap_or(1.5),
258            reconnect_jitter_ms.unwrap_or(100),
259            true, // immediate-first
260        )?;
261
262        Ok(Self {
263            config,
264            connector,
265            read_task,
266            write_task,
267            writer_tx,
268            heartbeat_task,
269            connection_mode,
270            state_notify,
271            reconnect_timeout,
272            backoff,
273            handler: message_handler.clone(),
274            reconnect_max_attempts: *reconnect_max_attempts,
275            reconnect_attempt_count: 0,
276        })
277    }
278
279    /// Parse URL and extract socket address and request URL.
280    ///
281    /// Accepts either:
282    /// - Raw socket address: "host:port" → returns ("host:port", "scheme://host:port")
283    /// - Full URL: "scheme://host:port/path" → returns ("host:port", original URL)
284    ///
285    /// # Errors
286    ///
287    /// Returns an error if the URL is invalid or missing required components.
288    fn parse_socket_url(url: &str, mode: Mode) -> Result<(String, String), Error> {
289        if url.contains("://") {
290            // URL with scheme (e.g., "wss://host:port/path")
291            let parsed = url.parse::<http::Uri>().map_err(|e| {
292                Error::Io(std::io::Error::new(
293                    std::io::ErrorKind::InvalidInput,
294                    format!("Invalid URL: {e}"),
295                ))
296            })?;
297
298            let host = parsed.host().ok_or_else(|| {
299                Error::Io(std::io::Error::new(
300                    std::io::ErrorKind::InvalidInput,
301                    "URL missing host",
302                ))
303            })?;
304
305            let port = parsed
306                .port_u16()
307                .unwrap_or_else(|| match parsed.scheme_str() {
308                    Some("wss" | "https") => 443,
309                    Some("ws" | "http") => 80,
310                    _ => match mode {
311                        Mode::Tls => 443,
312                        Mode::Plain => 80,
313                    },
314                });
315
316            Ok((format!("{host}:{port}"), url.to_string()))
317        } else {
318            // Raw socket address (e.g., "host:port")
319            // Construct a proper URL for the request based on mode
320            let scheme = match mode {
321                Mode::Tls => "wss",
322                Mode::Plain => "ws",
323            };
324            Ok((url.to_string(), format!("{scheme}://{url}")))
325        }
326    }
327
328    /// Establish a TLS or plain TCP connection with the server.
329    ///
330    /// Accepts either a raw socket address (e.g., "host:port") or a full URL with scheme
331    /// (e.g., "wss://host:port"). For FIX/raw socket connections, use the host:port format.
332    /// For WebSocket-style connections, include the scheme.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if the connection cannot be established.
337    pub(crate) async fn tls_connect_with_server(
338        url: &str,
339        mode: Mode,
340        connector: Option<Connector>,
341    ) -> Result<(TcpReader, TcpWriter), Error> {
342        log::debug!("Connecting to {url}");
343
344        let (socket_addr, request_url) = Self::parse_socket_url(url, mode)?;
345        let tcp_result = TcpStream::connect(&socket_addr).await;
346
347        match tcp_result {
348            Ok(stream) => {
349                log::debug!("TCP connection established to {socket_addr}, proceeding with TLS");
350
351                if let Err(e) = stream.set_nodelay(true) {
352                    log::warn!("Failed to enable TCP_NODELAY for socket client: {e:?}");
353                }
354                let request = request_url.into_client_request()?;
355                tcp_tls(&request, mode, stream, connector)
356                    .await
357                    .map(tokio::io::split)
358            }
359            Err(e) => {
360                log::warn!("TCP connection failed to {socket_addr}: {e:?}");
361                Err(Error::Io(e))
362            }
363        }
364    }
365
366    /// Reconnect with server.
367    ///
368    /// Makes a new connection with server, uses the new read and write halves
369    /// to update the reader and writer.
370    ///
371    /// The reconnect timeout bounds only connection establishment. Once the
372    /// new writer is handed to the writer task the swap runs to completion,
373    /// so buffered messages can never drain into a connection that lost its
374    /// reader to a timeout; the writer task bounds both the old-writer
375    /// shutdown and the buffer drain with its graceful-shutdown timeout.
376    async fn reconnect(&mut self) -> Result<(), Error> {
377        log::debug!("Reconnecting");
378
379        if ConnectionMode::from_atomic(&self.connection_mode).is_disconnect() {
380            log::debug!("Reconnect aborted due to disconnect state");
381            return Ok(());
382        }
383
384        let SocketConfig {
385            url,
386            mode,
387            heartbeat: _,
388            suffix,
389            message_handler: _,
390            reconnect_timeout_ms: _,
391            reconnect_delay_initial_ms: _,
392            reconnect_backoff_factor: _,
393            reconnect_delay_max_ms: _,
394            reconnect_jitter_ms: _,
395            connection_max_retries: _,
396            reconnect_max_attempts: _,
397            idle_timeout_ms,
398            certs_dir: _,
399        } = &self.config;
400        // Create a fresh connection
401        let connector = self.connector.clone();
402
403        // Bound only connection establishment; the swap below must run to completion
404        let (reader, new_writer) = dst::time::timeout(
405            self.reconnect_timeout,
406            Self::tls_connect_with_server(url, *mode, connector),
407        )
408        .await
409        .map_err(|_| {
410            Error::Io(std::io::Error::new(
411                std::io::ErrorKind::TimedOut,
412                format!(
413                    "reconnection timed out after {}s",
414                    self.reconnect_timeout.as_secs_f64()
415                ),
416            ))
417        })??;
418
419        if ConnectionMode::from_atomic(&self.connection_mode).is_disconnect() {
420            log::debug!("Reconnect aborted mid-flight (after connect)");
421            return Ok(());
422        }
423        log::debug!("Connected");
424
425        // Use a oneshot channel to synchronize with the writer task.
426        // We must verify that the buffer was successfully drained before transitioning to ACTIVE
427        // to prevent silent message loss if the new connection drops immediately.
428        let (tx, rx) = tokio::sync::oneshot::channel();
429        if let Err(e) = self.writer_tx.send(WriterCommand::Update(new_writer, tx)) {
430            log::error!("{e}");
431            return Err(Error::Io(std::io::Error::new(
432                std::io::ErrorKind::BrokenPipe,
433                format!("Failed to send update command: {e}"),
434            )));
435        }
436
437        // Wait for writer to confirm it has drained the buffer
438        match rx.await {
439            Ok(true) => log::debug!("Writer confirmed buffer drain success"),
440            Ok(false) => {
441                log::warn!("Writer failed to drain buffer, aborting reconnect");
442                // Return error to trigger retry logic in controller
443                return Err(Error::Io(std::io::Error::other(
444                    "Failed to drain reconnection buffer",
445                )));
446            }
447            Err(e) => {
448                log::error!("Writer dropped update channel: {e}");
449                return Err(Error::Io(std::io::Error::new(
450                    std::io::ErrorKind::BrokenPipe,
451                    "Writer task dropped response channel",
452                )));
453            }
454        }
455
456        // Delay before closing connection
457        dst::time::sleep(Duration::from_millis(GRACEFUL_SHUTDOWN_DELAY_MS)).await;
458
459        if ConnectionMode::from_atomic(&self.connection_mode).is_disconnect() {
460            log::debug!("Reconnect aborted mid-flight (after delay)");
461            return Ok(());
462        }
463
464        if !self.read_task.is_finished() {
465            self.read_task.abort();
466            log_task_aborted("read");
467        }
468
469        // Atomically transition from Reconnect to Active
470        // This prevents race condition where disconnect could be requested between check and store
471        if self
472            .connection_mode
473            .compare_exchange(
474                ConnectionMode::Reconnect.as_u8(),
475                ConnectionMode::Active.as_u8(),
476                Ordering::SeqCst,
477                Ordering::SeqCst,
478            )
479            .is_err()
480        {
481            log::debug!("Reconnect aborted (state changed during reconnect)");
482            return Ok(());
483        }
484
485        // Spawn new read task
486        self.read_task = Arc::new(Self::spawn_read_task(
487            self.connection_mode.clone(),
488            reader,
489            self.handler.clone(),
490            suffix.clone(),
491            *idle_timeout_ms,
492        ));
493
494        log::debug!("Reconnect succeeded");
495        Ok(())
496    }
497
498    /// Check if the client is still alive.
499    ///
500    /// Returns `true` if both the read and write tasks are still running.
501    /// There may be some delay between the connection closing and the
502    /// client detecting it.
503    #[inline]
504    #[must_use]
505    pub(crate) fn is_alive(&self) -> bool {
506        !self.read_task.is_finished() && !self.write_task.is_finished()
507    }
508
509    #[must_use]
510    fn spawn_read_task(
511        connection_state: Arc<AtomicU8>,
512        mut reader: TcpReader,
513        handler: Option<TcpMessageHandler>,
514        suffix: Vec<u8>,
515        idle_timeout_ms: Option<u64>,
516    ) -> tokio::task::JoinHandle<()> {
517        log_task_started("read");
518
519        // Interval between checking the connection mode
520        let check_interval = Duration::from_millis(CONNECTION_STATE_CHECK_INTERVAL_MS);
521        let idle_timeout = idle_timeout_ms.map(Duration::from_millis);
522
523        tokio::task::spawn(async move {
524            let mut buf = Vec::new();
525            let mut last_data_time = dst::time::Instant::now();
526
527            loop {
528                if !ConnectionMode::from_atomic(&connection_state).is_active() {
529                    break;
530                }
531
532                match dst::time::timeout(check_interval, reader.read_buf(&mut buf)).await {
533                    // Connection has been terminated or vector buffer is complete
534                    Ok(Ok(0)) => {
535                        log::debug!("Connection closed by server");
536                        break;
537                    }
538                    Ok(Err(e)) => {
539                        log::debug!("Connection ended: {e}");
540                        break;
541                    }
542                    // Received bytes of data
543                    Ok(Ok(bytes)) => {
544                        log::trace!("Received <binary> {bytes} bytes");
545                        last_data_time = dst::time::Instant::now();
546
547                        while let Some((i, _)) = &buf
548                            .windows(suffix.len())
549                            .enumerate()
550                            .find(|(_, pair)| pair.eq(&suffix))
551                        {
552                            let mut data: Vec<u8> = buf.drain(0..i + suffix.len()).collect();
553                            data.truncate(data.len() - suffix.len());
554
555                            if let Some(ref handler) = handler {
556                                handler(&data);
557                            }
558                        }
559
560                        if buf.len() > MAX_READ_BUFFER_BYTES {
561                            log::error!(
562                                "Read buffer exceeded maximum size ({MAX_READ_BUFFER_BYTES} bytes), closing connection"
563                            );
564                            break;
565                        }
566                    }
567                    Err(_) => {
568                        if let Some(timeout) = idle_timeout {
569                            let idle_duration = last_data_time.elapsed();
570                            if idle_duration >= timeout {
571                                log::warn!(
572                                    "Read idle timeout: no data received for {:.1}s",
573                                    idle_duration.as_secs_f64()
574                                );
575                                break;
576                            }
577                        }
578                    }
579                }
580            }
581
582            log_task_stopped("read");
583        })
584    }
585
586    /// Drains buffered messages after reconnection completes.
587    ///
588    /// Attempts to send all buffered messages that were queued during reconnection.
589    /// Uses a peek-and-pop pattern to preserve messages if sending fails midway through the buffer.
590    ///
591    /// # Returns
592    ///
593    /// Returns `true` if a send error occurred (buffer may still contain unsent messages),
594    /// `false` if all messages were sent successfully (buffer is empty).
595    async fn drain_reconnect_buffer(
596        buffer: &mut VecDeque<Bytes>,
597        writer: &mut TcpWriter,
598        suffix: &[u8],
599    ) -> bool {
600        if buffer.is_empty() {
601            return false;
602        }
603
604        let initial_buffer_len = buffer.len();
605        log::info!("Sending {initial_buffer_len} buffered messages after reconnection");
606
607        let mut send_error_occurred = false;
608
609        while let Some(buffered_msg) = buffer.front() {
610            let mut combined_msg = Vec::with_capacity(buffered_msg.len() + suffix.len());
611            combined_msg.extend_from_slice(buffered_msg);
612            combined_msg.extend_from_slice(suffix);
613
614            if let Err(e) = writer.write_all(&combined_msg).await {
615                if is_connection_drop_io_error(&e) {
616                    log::warn!(
617                        "Failed to send buffered message with suffix after reconnection: {e}, {} messages remain in buffer",
618                        buffer.len()
619                    );
620                } else {
621                    log::error!(
622                        "Failed to send buffered message with suffix after reconnection: {e}, {} messages remain in buffer",
623                        buffer.len()
624                    );
625                }
626                send_error_occurred = true;
627                break;
628            }
629
630            buffer.pop_front();
631        }
632
633        if buffer.is_empty() {
634            log::info!("Successfully sent all {initial_buffer_len} buffered messages");
635        }
636
637        send_error_occurred
638    }
639
640    fn spawn_write_task(
641        connection_state: Arc<AtomicU8>,
642        state_notify: Arc<tokio::sync::Notify>,
643        writer: TcpWriter,
644        mut writer_rx: tokio::sync::mpsc::UnboundedReceiver<WriterCommand>,
645        suffix: Vec<u8>,
646    ) -> tokio::task::JoinHandle<()> {
647        log_task_started("write");
648
649        // Interval between checking the connection mode
650        let check_interval = Duration::from_millis(CONNECTION_STATE_CHECK_INTERVAL_MS);
651
652        tokio::task::spawn(async move {
653            let mut active_writer = writer;
654            let mut reconnect_buffer: VecDeque<Bytes> = VecDeque::new();
655            let mut write_buf: Vec<u8> = Vec::new();
656
657            loop {
658                if matches!(
659                    ConnectionMode::from_atomic(&connection_state),
660                    ConnectionMode::Disconnect | ConnectionMode::Closed
661                ) {
662                    break;
663                }
664
665                match dst::time::timeout(check_interval, writer_rx.recv()).await {
666                    Ok(Some(msg)) => {
667                        // Re-check connection mode after receiving a message
668                        let mode = ConnectionMode::from_atomic(&connection_state);
669                        if matches!(mode, ConnectionMode::Disconnect | ConnectionMode::Closed) {
670                            break;
671                        }
672
673                        match msg {
674                            WriterCommand::Update(new_writer, tx) => {
675                                log::debug!("Received new writer");
676
677                                // Delay before closing connection
678                                dst::time::sleep(Duration::from_millis(100)).await;
679
680                                // Attempt to shutdown the writer gracefully before updating,
681                                // we ignore any error as the writer may already be closed.
682                                _ = dst::time::timeout(
683                                    Duration::from_secs(GRACEFUL_SHUTDOWN_TIMEOUT_SECS),
684                                    active_writer.shutdown(),
685                                )
686                                .await;
687
688                                active_writer = new_writer;
689                                log::debug!("Updated writer");
690
691                                // Bound the drain: a peer that accepts the connection but stops
692                                // reading must not wedge the writer task.
693                                let drain_result = dst::time::timeout(
694                                    Duration::from_secs(GRACEFUL_SHUTDOWN_TIMEOUT_SECS),
695                                    Self::drain_reconnect_buffer(
696                                        &mut reconnect_buffer,
697                                        &mut active_writer,
698                                        &suffix,
699                                    ),
700                                )
701                                .await;
702                                let send_error = drain_result.unwrap_or_else(|_| {
703                                    log::warn!(
704                                        "Timed out draining reconnect buffer, {} messages remain",
705                                        reconnect_buffer.len()
706                                    );
707                                    true
708                                });
709
710                                if let Err(e) = tx.send(!send_error) {
711                                    log::error!(
712                                        "Failed to report drain status to controller: {e:?}"
713                                    );
714                                }
715                            }
716                            _ if mode.is_reconnect() => {
717                                if let WriterCommand::Send(data) = msg {
718                                    log::debug!(
719                                        "Buffering message while reconnecting ({} bytes)",
720                                        data.len()
721                                    );
722                                    reconnect_buffer.push_back(data);
723                                }
724                            }
725                            WriterCommand::Send(msg) => {
726                                write_buf.clear();
727                                write_buf.extend_from_slice(&msg);
728                                write_buf.extend_from_slice(&suffix);
729
730                                if let Err(e) = active_writer.write_all(&write_buf).await {
731                                    if is_connection_drop_io_error(&e) {
732                                        log::warn!("Failed to send message: {e}");
733                                    } else {
734                                        log::error!("Failed to send message: {e}");
735                                    }
736                                    reconnect_buffer.push_back(msg);
737
738                                    // CAS: a disconnect landing mid-write must not be overwritten
739                                    if ConnectionMode::request_reconnect(&connection_state) {
740                                        log::warn!("Writer triggering reconnect");
741                                        state_notify.notify_one();
742                                    }
743                                }
744                            }
745                        }
746                    }
747                    Ok(None) => {
748                        // Channel closed - writer task should terminate
749                        log::debug!("Writer channel closed, terminating writer task");
750                        break;
751                    }
752                    Err(_) => {
753                        // Timeout - just continue the loop
754                    }
755                }
756            }
757
758            // Attempt to shutdown the writer gracefully before exiting,
759            // we ignore any error as the writer may already be closed.
760            _ = dst::time::timeout(
761                Duration::from_secs(GRACEFUL_SHUTDOWN_TIMEOUT_SECS),
762                active_writer.shutdown(),
763            )
764            .await;
765
766            log_task_stopped("write");
767        })
768    }
769
770    fn spawn_heartbeat_task(
771        connection_state: Arc<AtomicU8>,
772        heartbeat: (u64, Vec<u8>),
773        writer_tx: tokio::sync::mpsc::UnboundedSender<WriterCommand>,
774    ) -> tokio::task::JoinHandle<()> {
775        log_task_started("heartbeat");
776        let (interval_secs, message) = heartbeat;
777
778        tokio::task::spawn(async move {
779            let interval = Duration::from_secs(interval_secs);
780
781            loop {
782                dst::time::sleep(interval).await;
783
784                match ConnectionMode::from_u8(connection_state.load(Ordering::SeqCst)) {
785                    ConnectionMode::Active => {
786                        let msg = WriterCommand::Send(message.clone().into());
787
788                        match writer_tx.send(msg) {
789                            Ok(()) => log::trace!("Sent heartbeat to writer task"),
790                            Err(e) => {
791                                log::error!("Failed to send heartbeat to writer task: {e}");
792                            }
793                        }
794                    }
795                    ConnectionMode::Reconnect => {}
796                    ConnectionMode::Disconnect | ConnectionMode::Closed => break,
797                }
798            }
799
800            log_task_stopped("heartbeat");
801        })
802    }
803}
804
805impl Drop for SocketClientInner {
806    fn drop(&mut self) {
807        // Delegate to explicit cleanup handler
808        self.clean_drop();
809    }
810}
811
812/// Cleanup on drop: aborts background tasks and clears handlers to break reference cycles.
813impl CleanDrop for SocketClientInner {
814    fn clean_drop(&mut self) {
815        if !self.read_task.is_finished() {
816            self.read_task.abort();
817            log_task_aborted("read");
818        }
819
820        if !self.write_task.is_finished() {
821            self.write_task.abort();
822            log_task_aborted("write");
823        }
824
825        if let Some(ref handle) = self.heartbeat_task.take()
826            && !handle.is_finished()
827        {
828            handle.abort();
829            log_task_aborted("heartbeat");
830        }
831
832        #[cfg(feature = "python")]
833        {
834            // Remove stored handler to break ref cycle
835            self.config.message_handler = None;
836        }
837    }
838}
839
840#[cfg_attr(
841    feature = "python",
842    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.network")
843)]
844#[cfg_attr(
845    feature = "python",
846    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.network")
847)]
848pub struct SocketClient {
849    pub(crate) controller_task: tokio::task::JoinHandle<()>,
850    pub(crate) connection_mode: Arc<AtomicU8>,
851    pub(crate) state_notify: Arc<tokio::sync::Notify>,
852    pub(crate) reconnect_timeout: Duration,
853    pub writer_tx: tokio::sync::mpsc::UnboundedSender<WriterCommand>,
854}
855
856impl Debug for SocketClient {
857    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
858        f.debug_struct(stringify!(SocketClient)).finish()
859    }
860}
861
862impl SocketClient {
863    /// Connect to the server.
864    ///
865    /// # Errors
866    ///
867    /// Returns any error connecting to the server.
868    pub async fn connect(
869        config: SocketConfig,
870        post_connection: Option<Arc<dyn Fn() + Send + Sync>>,
871        post_reconnection: Option<Arc<dyn Fn() + Send + Sync>>,
872        post_disconnection: Option<Arc<dyn Fn() + Send + Sync>>,
873    ) -> anyhow::Result<Self> {
874        let inner = SocketClientInner::connect_url(config).await?;
875        let writer_tx = inner.writer_tx.clone();
876        let connection_mode = inner.connection_mode.clone();
877        let state_notify = inner.state_notify.clone();
878        let reconnect_timeout = inner.reconnect_timeout;
879
880        let controller_task = Self::spawn_controller_task(
881            inner,
882            connection_mode.clone(),
883            state_notify.clone(),
884            post_reconnection,
885            post_disconnection,
886        );
887
888        if let Some(handler) = post_connection {
889            handler();
890            log::debug!("Called `post_connection` handler");
891        }
892
893        Ok(Self {
894            controller_task,
895            connection_mode,
896            state_notify,
897            reconnect_timeout,
898            writer_tx,
899        })
900    }
901
902    /// Returns the current connection mode.
903    #[must_use]
904    pub fn connection_mode(&self) -> ConnectionMode {
905        ConnectionMode::from_atomic(&self.connection_mode)
906    }
907
908    /// Check if the client connection is active.
909    ///
910    /// Returns `true` if the client is connected and has not been signalled to disconnect.
911    /// The client will automatically retry connection based on its configuration.
912    #[inline]
913    #[must_use]
914    pub fn is_active(&self) -> bool {
915        self.connection_mode().is_active()
916    }
917
918    /// Check if the client is reconnecting.
919    ///
920    /// Returns `true` if the client lost connection and is attempting to reestablish it.
921    /// The client will automatically retry connection based on its configuration.
922    #[inline]
923    #[must_use]
924    pub fn is_reconnecting(&self) -> bool {
925        self.connection_mode().is_reconnect()
926    }
927
928    /// Check if the client is disconnecting.
929    ///
930    /// Returns `true` if the client is in disconnect mode.
931    #[inline]
932    #[must_use]
933    pub fn is_disconnecting(&self) -> bool {
934        self.connection_mode().is_disconnect()
935    }
936
937    /// Check if the client is closed.
938    ///
939    /// Returns `true` if the client has been explicitly disconnected or reached
940    /// maximum reconnection attempts. In this state, the client cannot be reused
941    /// and a new client must be created for further connections.
942    #[inline]
943    #[must_use]
944    pub fn is_closed(&self) -> bool {
945        self.connection_mode().is_closed()
946    }
947
948    /// Close the client.
949    ///
950    /// Controller task will periodically check the disconnect mode
951    /// and shutdown the client if it is not alive.
952    pub async fn close(&self) {
953        // Preserve a CLOSED terminal state; the controller has already exited
954        ConnectionMode::request_disconnect(&self.connection_mode);
955        self.state_notify.notify_waiters();
956
957        if dst::time::timeout(Duration::from_secs(GRACEFUL_SHUTDOWN_TIMEOUT_SECS), async {
958            while !self.is_closed() {
959                dst::time::sleep(Duration::from_millis(CONNECTION_STATE_CHECK_INTERVAL_MS)).await;
960            }
961
962            if !self.controller_task.is_finished() {
963                self.controller_task.abort();
964                log_task_aborted("controller");
965            }
966        })
967        .await
968            == Ok(())
969        {
970            log_task_stopped("controller");
971        } else {
972            log::warn!("Timeout waiting for controller task to finish");
973
974            if !self.controller_task.is_finished() {
975                self.controller_task.abort();
976                log_task_aborted("controller");
977            }
978            self.connection_mode
979                .store(ConnectionMode::Closed.as_u8(), Ordering::SeqCst);
980        }
981    }
982
983    /// Checks whether the connection is in a terminal state (disconnecting or closed).
984    ///
985    /// Single atomic load to fail fast before waiting.
986    #[inline]
987    fn check_not_terminal(&self) -> Result<(), SendError> {
988        match self.connection_mode() {
989            ConnectionMode::Disconnect | ConnectionMode::Closed => Err(SendError::Closed),
990            _ => Ok(()),
991        }
992    }
993
994    /// Waits for the client to become active before sending.
995    ///
996    /// Uses `state_notify` for event-driven wakeup so sends resume immediately
997    /// after reconnection completes. A fallback interval guards against missed
998    /// notifications.
999    async fn wait_for_active(&self) -> Result<(), SendError> {
1000        const FALLBACK_INTERVAL_MS: u64 = 100;
1001
1002        let mode = self.connection_mode();
1003        if mode.is_active() {
1004            return Ok(());
1005        }
1006
1007        if matches!(mode, ConnectionMode::Disconnect | ConnectionMode::Closed) {
1008            return Err(SendError::Closed);
1009        }
1010
1011        log::debug!("Waiting for client to become ACTIVE before sending...");
1012
1013        let fallback_interval = Duration::from_millis(FALLBACK_INTERVAL_MS);
1014
1015        dst::time::timeout(self.reconnect_timeout, async {
1016            loop {
1017                // Enable before the state check: an unpolled Notified is unregistered and misses notifies
1018                let mut notified = pin!(self.state_notify.notified());
1019                notified.as_mut().enable();
1020
1021                let mode = self.connection_mode();
1022                if mode.is_active() {
1023                    return Ok(());
1024                }
1025
1026                if matches!(mode, ConnectionMode::Disconnect | ConnectionMode::Closed) {
1027                    return Err(());
1028                }
1029
1030                tokio::select! {
1031                    biased;
1032                    () = notified => {}
1033                    () = dst::time::sleep(fallback_interval) => {}
1034                }
1035            }
1036        })
1037        .await
1038        .map_err(|_| SendError::Timeout)?
1039        .map_err(|()| SendError::Closed)
1040    }
1041
1042    /// Sends a message of the given `data`.
1043    ///
1044    /// Returns `Ok(())` when the message is enqueued to the writer channel. This does NOT
1045    /// guarantee delivery: if a disconnect occurs concurrently, the writer task may drop the
1046    /// message. During reconnection, messages are buffered and replayed on the new connection.
1047    ///
1048    /// # Errors
1049    ///
1050    /// Returns an error if sending fails.
1051    pub async fn send_bytes(&self, data: Vec<u8>) -> Result<(), SendError> {
1052        self.check_not_terminal()?;
1053        self.wait_for_active().await?;
1054
1055        let msg = WriterCommand::Send(data.into());
1056        self.writer_tx
1057            .send(msg)
1058            .map_err(|e| SendError::BrokenPipe(e.to_string()))
1059    }
1060
1061    fn spawn_controller_task(
1062        mut inner: SocketClientInner,
1063        connection_mode: Arc<AtomicU8>,
1064        state_notify: Arc<tokio::sync::Notify>,
1065        post_reconnection: Option<Arc<dyn Fn() + Send + Sync>>,
1066        post_disconnection: Option<Arc<dyn Fn() + Send + Sync>>,
1067    ) -> tokio::task::JoinHandle<()> {
1068        const CONTROLLER_FALLBACK_INTERVAL_MS: u64 = 100;
1069
1070        tokio::task::spawn(async move {
1071            log_task_started("controller");
1072
1073            let fallback_interval = Duration::from_millis(CONTROLLER_FALLBACK_INTERVAL_MS);
1074
1075            loop {
1076                tokio::select! {
1077                    biased;
1078                    () = state_notify.notified() => {}
1079                    () = dst::time::sleep(fallback_interval) => {}
1080                }
1081
1082                let mut mode = ConnectionMode::from_atomic(&connection_mode);
1083
1084                if mode.is_disconnect() {
1085                    log::debug!("Disconnecting");
1086
1087                    let timeout = Duration::from_secs(GRACEFUL_SHUTDOWN_TIMEOUT_SECS);
1088                    if dst::time::timeout(timeout, async {
1089                        // Delay awaiting graceful shutdown
1090                        dst::time::sleep(Duration::from_millis(GRACEFUL_SHUTDOWN_DELAY_MS)).await;
1091
1092                        if !inner.read_task.is_finished() {
1093                            inner.read_task.abort();
1094                            log_task_aborted("read");
1095                        }
1096
1097                        if let Some(task) = &inner.heartbeat_task
1098                            && !task.is_finished()
1099                        {
1100                            task.abort();
1101                            log_task_aborted("heartbeat");
1102                        }
1103                    })
1104                    .await
1105                    .is_err()
1106                    {
1107                        log::warn!("Shutdown timed out after {}s", timeout.as_secs());
1108                    }
1109
1110                    log::debug!("Closed");
1111
1112                    if let Some(ref handler) = post_disconnection {
1113                        handler();
1114                        log::debug!("Called `post_disconnection` handler");
1115                    }
1116                    break; // Controller finished
1117                }
1118
1119                if mode.is_closed() {
1120                    log::debug!("Connection closed");
1121                    break;
1122                }
1123
1124                if mode.is_active() && !inner.is_alive() {
1125                    if connection_mode
1126                        .compare_exchange(
1127                            ConnectionMode::Active.as_u8(),
1128                            ConnectionMode::Reconnect.as_u8(),
1129                            Ordering::SeqCst,
1130                            Ordering::SeqCst,
1131                        )
1132                        .is_ok()
1133                    {
1134                        log::debug!("Detected dead read task, transitioning to RECONNECT");
1135                    }
1136                    mode = ConnectionMode::from_atomic(&connection_mode);
1137                }
1138
1139                if mode.is_reconnect() {
1140                    // Check max reconnection attempts before attempting reconnect
1141                    if let Some(max_attempts) = inner.reconnect_max_attempts
1142                        && inner.reconnect_attempt_count >= max_attempts
1143                    {
1144                        log::error!(
1145                            "Max reconnection attempts ({max_attempts}) exceeded, transitioning to CLOSED"
1146                        );
1147                        connection_mode.store(ConnectionMode::Closed.as_u8(), Ordering::SeqCst);
1148                        state_notify.notify_waiters();
1149                        break;
1150                    }
1151
1152                    inner.reconnect_attempt_count += 1;
1153
1154                    // Race reconnect against disconnect notification
1155                    let reconnect_result = tokio::select! {
1156                        biased;
1157                        result = inner.reconnect() => Some(result),
1158                        () = async {
1159                            loop {
1160                                // Enable before the check so a disconnect notify between iterations is not missed
1161                                let mut notified = pin!(state_notify.notified());
1162                                notified.as_mut().enable();
1163
1164                                if ConnectionMode::from_atomic(&connection_mode).is_disconnect() {
1165                                    break;
1166                                }
1167                                notified.await;
1168                            }
1169                        } => None,
1170                    };
1171
1172                    match reconnect_result {
1173                        None => {
1174                            log::debug!("Reconnect interrupted by disconnect");
1175                        }
1176                        Some(Ok(())) => {
1177                            log::debug!("Reconnected successfully");
1178                            inner.backoff.reset();
1179                            inner.reconnect_attempt_count = 0;
1180
1181                            state_notify.notify_waiters();
1182
1183                            if ConnectionMode::from_atomic(&connection_mode).is_active() {
1184                                if let Some(ref handler) = post_reconnection {
1185                                    handler();
1186                                    log::debug!("Called `post_reconnection` handler");
1187                                }
1188                            } else {
1189                                log::debug!(
1190                                    "Skipping post_reconnection handlers due to disconnect state"
1191                                );
1192                            }
1193                        }
1194                        Some(Err(e)) => {
1195                            let duration = inner.backoff.next_duration();
1196                            log::warn!(
1197                                "Reconnect attempt {} failed: {e}",
1198                                inner.reconnect_attempt_count
1199                            );
1200
1201                            if !duration.is_zero() {
1202                                log::warn!("Backing off for {}s...", duration.as_secs_f64());
1203                                // Race backoff sleep against disconnect
1204                                tokio::select! {
1205                                    biased;
1206                                    () = dst::time::sleep(duration) => {}
1207                                    () = async {
1208                                        loop {
1209                                            // Enable before the check so a disconnect notify between iterations is not missed
1210                                            let mut notified = pin!(state_notify.notified());
1211                                            notified.as_mut().enable();
1212
1213                                            if ConnectionMode::from_atomic(&connection_mode).is_disconnect() {
1214                                                break;
1215                                            }
1216                                            notified.await;
1217                                        }
1218                                    } => {
1219                                        log::debug!("Backoff interrupted by disconnect");
1220                                    }
1221                                }
1222                            }
1223                        }
1224                    }
1225                }
1226            }
1227            inner
1228                .connection_mode
1229                .store(ConnectionMode::Closed.as_u8(), Ordering::SeqCst);
1230
1231            log_task_stopped("controller");
1232        })
1233    }
1234}
1235
1236// Abort controller task on drop to clean up background tasks
1237impl Drop for SocketClient {
1238    fn drop(&mut self) {
1239        if !self.controller_task.is_finished() {
1240            self.controller_task.abort();
1241            log_task_aborted("controller");
1242        }
1243    }
1244}
1245
1246#[cfg(test)]
1247#[cfg(feature = "python")]
1248#[cfg(not(feature = "turmoil"))]
1249#[cfg(not(all(feature = "simulation", madsim)))] // transport-layer I/O not simulated
1250#[cfg(target_os = "linux")] // Only run network tests on Linux (CI stability)
1251mod tests {
1252    use nautilus_common::testing::wait_until_async;
1253    use pyo3::Python;
1254    use tokio::{
1255        io::{AsyncReadExt, AsyncWriteExt},
1256        net::{TcpListener, TcpStream},
1257        sync::Mutex,
1258        task,
1259        time::{Duration, sleep},
1260    };
1261
1262    use super::*;
1263
1264    async fn bind_test_server() -> (u16, TcpListener) {
1265        let listener = TcpListener::bind("127.0.0.1:0")
1266            .await
1267            .expect("Failed to bind ephemeral port");
1268        let port = listener.local_addr().unwrap().port();
1269        (port, listener)
1270    }
1271
1272    async fn run_echo_server(mut socket: TcpStream) {
1273        let mut buf = Vec::new();
1274        loop {
1275            match socket.read_buf(&mut buf).await {
1276                Ok(0) => {
1277                    break;
1278                }
1279                Ok(_n) => {
1280                    while let Some(idx) = buf.array_windows().position(|w| w == b"\r\n") {
1281                        let mut line = buf.drain(..idx + 2).collect::<Vec<u8>>();
1282                        // Remove trailing \r\n
1283                        line.truncate(line.len() - 2);
1284
1285                        if line == b"close" {
1286                            let _ = socket.shutdown().await;
1287                            return;
1288                        }
1289
1290                        let mut echo_data = line;
1291                        echo_data.extend_from_slice(b"\r\n");
1292                        if socket.write_all(&echo_data).await.is_err() {
1293                            break;
1294                        }
1295                    }
1296                }
1297                Err(e) => {
1298                    eprintln!("Server read error: {e}");
1299                    break;
1300                }
1301            }
1302        }
1303    }
1304
1305    #[tokio::test]
1306    async fn test_basic_send_receive() {
1307        Python::initialize();
1308
1309        let (port, listener) = bind_test_server().await;
1310        let server_task = task::spawn(async move {
1311            let (socket, _) = listener.accept().await.unwrap();
1312            run_echo_server(socket).await;
1313        });
1314
1315        let config = SocketConfig {
1316            url: format!("127.0.0.1:{port}"),
1317            mode: Mode::Plain,
1318            suffix: b"\r\n".to_vec(),
1319            message_handler: None,
1320            heartbeat: None,
1321            reconnect_timeout_ms: None,
1322            reconnect_delay_initial_ms: None,
1323            reconnect_backoff_factor: None,
1324            reconnect_delay_max_ms: None,
1325            reconnect_jitter_ms: None,
1326            reconnect_max_attempts: None,
1327            connection_max_retries: None,
1328            idle_timeout_ms: None,
1329            certs_dir: None,
1330        };
1331
1332        let client = SocketClient::connect(config, None, None, None)
1333            .await
1334            .expect("Client connect failed unexpectedly");
1335
1336        client.send_bytes(b"Hello".into()).await.unwrap();
1337        client.send_bytes(b"World".into()).await.unwrap();
1338
1339        // Wait a bit for the server to echo them back
1340        sleep(Duration::from_millis(100)).await;
1341
1342        client.send_bytes(b"close".into()).await.unwrap();
1343        server_task.await.unwrap();
1344        assert!(!client.is_closed());
1345    }
1346
1347    #[tokio::test]
1348    async fn test_reconnect_fail_exhausted() {
1349        Python::initialize();
1350
1351        let (port, listener) = bind_test_server().await;
1352        drop(listener); // We drop it immediately -> no server is listening
1353
1354        // Wait until port is truly unavailable (OS has released it)
1355        wait_until_async(
1356            || async {
1357                TcpStream::connect(format!("127.0.0.1:{port}"))
1358                    .await
1359                    .is_err()
1360            },
1361            Duration::from_secs(2),
1362        )
1363        .await;
1364
1365        let config = SocketConfig {
1366            url: format!("127.0.0.1:{port}"),
1367            mode: Mode::Plain,
1368            suffix: b"\r\n".to_vec(),
1369            message_handler: None,
1370            heartbeat: None,
1371            reconnect_timeout_ms: Some(100),
1372            reconnect_delay_initial_ms: Some(50),
1373            reconnect_backoff_factor: Some(1.0),
1374            reconnect_delay_max_ms: Some(50),
1375            reconnect_jitter_ms: Some(0),
1376            connection_max_retries: Some(1),
1377            reconnect_max_attempts: None,
1378            idle_timeout_ms: None,
1379            certs_dir: None,
1380        };
1381
1382        let client_res = SocketClient::connect(config, None, None, None).await;
1383        assert!(
1384            client_res.is_err(),
1385            "Should fail quickly with no server listening"
1386        );
1387    }
1388
1389    #[tokio::test]
1390    async fn test_user_disconnect() {
1391        Python::initialize();
1392
1393        let (port, listener) = bind_test_server().await;
1394        let server_task = task::spawn(async move {
1395            let (socket, _) = listener.accept().await.unwrap();
1396            let mut buf = [0u8; 1024];
1397            let _ = socket.try_read(&mut buf);
1398
1399            loop {
1400                sleep(Duration::from_secs(1)).await;
1401            }
1402        });
1403
1404        let config = SocketConfig {
1405            url: format!("127.0.0.1:{port}"),
1406            mode: Mode::Plain,
1407            suffix: b"\r\n".to_vec(),
1408            message_handler: None,
1409            heartbeat: None,
1410            reconnect_timeout_ms: None,
1411            reconnect_delay_initial_ms: None,
1412            reconnect_backoff_factor: None,
1413            reconnect_delay_max_ms: None,
1414            reconnect_jitter_ms: None,
1415            reconnect_max_attempts: None,
1416            connection_max_retries: None,
1417            idle_timeout_ms: None,
1418            certs_dir: None,
1419        };
1420
1421        let client = SocketClient::connect(config, None, None, None)
1422            .await
1423            .unwrap();
1424
1425        client.close().await;
1426        assert!(client.is_closed());
1427        server_task.abort();
1428    }
1429
1430    #[tokio::test]
1431    async fn test_close_after_closed_returns_fast_and_preserves_state() {
1432        Python::initialize();
1433
1434        let (port, listener) = bind_test_server().await;
1435
1436        let server_task = task::spawn(async move {
1437            // Accept the first connection then drop it; never accept again so
1438            // the client exhausts its reconnect attempts and transitions to CLOSED
1439            let (socket, _) = listener.accept().await.unwrap();
1440            drop(socket);
1441            drop(listener);
1442            sleep(Duration::from_secs(5)).await;
1443        });
1444
1445        let config = SocketConfig {
1446            url: format!("127.0.0.1:{port}"),
1447            mode: Mode::Plain,
1448            suffix: b"\r\n".to_vec(),
1449            message_handler: None,
1450            heartbeat: None,
1451            reconnect_timeout_ms: Some(200),
1452            reconnect_delay_initial_ms: Some(50),
1453            reconnect_backoff_factor: Some(1.0),
1454            reconnect_delay_max_ms: Some(50),
1455            reconnect_jitter_ms: Some(0),
1456            connection_max_retries: None,
1457            reconnect_max_attempts: Some(1),
1458            idle_timeout_ms: None,
1459            certs_dir: None,
1460        };
1461
1462        let client = SocketClient::connect(config, None, None, None)
1463            .await
1464            .unwrap();
1465
1466        wait_until_async(|| async { client.is_closed() }, Duration::from_secs(5)).await;
1467
1468        // Closing an already CLOSED client must return promptly (no 5s spin
1469        // waiting for a controller that has already exited) and must not
1470        // regress the terminal state to DISCONNECT
1471        let start = std::time::Instant::now();
1472        client.close().await;
1473        let elapsed = start.elapsed();
1474
1475        assert!(client.is_closed(), "Client should remain CLOSED");
1476        assert!(
1477            !client.is_disconnecting(),
1478            "Closed client should not report DISCONNECT after close()"
1479        );
1480        assert!(
1481            elapsed < Duration::from_secs(2),
1482            "close() on a closed client should return fast, took {elapsed:?}"
1483        );
1484
1485        server_task.abort();
1486    }
1487
1488    #[tokio::test]
1489    async fn test_heartbeat() {
1490        Python::initialize();
1491
1492        let (port, listener) = bind_test_server().await;
1493        let received = Arc::new(Mutex::new(Vec::new()));
1494        let received2 = received.clone();
1495
1496        let server_task = task::spawn(async move {
1497            let (socket, _) = listener.accept().await.unwrap();
1498
1499            let mut buf = Vec::new();
1500            loop {
1501                match socket.try_read_buf(&mut buf) {
1502                    Ok(0) => break,
1503                    Ok(_) => {
1504                        while let Some(idx) = buf.array_windows().position(|w| w == b"\r\n") {
1505                            let mut line = buf.drain(..idx + 2).collect::<Vec<u8>>();
1506                            line.truncate(line.len() - 2);
1507                            received2.lock().await.push(line);
1508                        }
1509                    }
1510                    Err(_) => {
1511                        tokio::time::sleep(Duration::from_millis(10)).await;
1512                    }
1513                }
1514            }
1515        });
1516
1517        // Heartbeat every 1 second
1518        let heartbeat = Some((1, b"ping".to_vec()));
1519
1520        let config = SocketConfig {
1521            url: format!("127.0.0.1:{port}"),
1522            mode: Mode::Plain,
1523            suffix: b"\r\n".to_vec(),
1524            message_handler: None,
1525            heartbeat,
1526            reconnect_timeout_ms: None,
1527            reconnect_delay_initial_ms: None,
1528            reconnect_backoff_factor: None,
1529            reconnect_delay_max_ms: None,
1530            reconnect_jitter_ms: None,
1531            reconnect_max_attempts: None,
1532            connection_max_retries: None,
1533            idle_timeout_ms: None,
1534            certs_dir: None,
1535        };
1536
1537        let client = SocketClient::connect(config, None, None, None)
1538            .await
1539            .unwrap();
1540
1541        // Wait ~3 seconds to collect some heartbeats
1542        sleep(Duration::from_secs(3)).await;
1543
1544        {
1545            let lock = received.lock().await;
1546            let pings = lock
1547                .iter()
1548                .filter(|line| line == &&b"ping".to_vec())
1549                .count();
1550            assert!(
1551                pings >= 2,
1552                "Expected at least 2 heartbeat pings; got {pings}"
1553            );
1554        }
1555
1556        client.close().await;
1557        server_task.abort();
1558    }
1559
1560    #[tokio::test]
1561    async fn test_reconnect_success() {
1562        Python::initialize();
1563
1564        let (port, listener) = bind_test_server().await;
1565
1566        // Spawn a server task that:
1567        // 1. Accepts the first connection and then drops it after a short delay (simulate disconnect)
1568        // 2. Waits a bit and then accepts a new connection and runs the echo server
1569        let server_task = task::spawn(async move {
1570            // Accept first connection
1571            let (mut socket, _) = listener.accept().await.expect("First accept failed");
1572
1573            // Wait briefly and then force-close the connection
1574            sleep(Duration::from_millis(500)).await;
1575            let _ = socket.shutdown().await;
1576
1577            // Wait for the client's reconnect attempt
1578            sleep(Duration::from_millis(500)).await;
1579
1580            // Run the echo server on the new connection
1581            let (socket, _) = listener.accept().await.expect("Second accept failed");
1582            run_echo_server(socket).await;
1583        });
1584
1585        let config = SocketConfig {
1586            url: format!("127.0.0.1:{port}"),
1587            mode: Mode::Plain,
1588            suffix: b"\r\n".to_vec(),
1589            message_handler: None,
1590            heartbeat: None,
1591            reconnect_timeout_ms: Some(5_000),
1592            reconnect_delay_initial_ms: Some(500),
1593            reconnect_delay_max_ms: Some(5_000),
1594            reconnect_backoff_factor: Some(2.0),
1595            reconnect_jitter_ms: Some(50),
1596            reconnect_max_attempts: None,
1597            connection_max_retries: None,
1598            idle_timeout_ms: None,
1599            certs_dir: None,
1600        };
1601
1602        let client = SocketClient::connect(config, None, None, None)
1603            .await
1604            .expect("Client connect failed unexpectedly");
1605
1606        // Initially, the client should be active
1607        assert!(client.is_active(), "Client should start as active");
1608
1609        // Wait until the client loses connection (i.e. not active),
1610        // then wait until it reconnects (active again).
1611        wait_until_async(|| async { client.is_active() }, Duration::from_secs(10)).await;
1612
1613        client
1614            .send_bytes(b"TestReconnect".into())
1615            .await
1616            .expect("Send failed");
1617
1618        client.close().await;
1619        server_task.abort();
1620    }
1621}
1622
1623#[cfg(test)]
1624#[cfg(not(feature = "turmoil"))]
1625#[cfg(not(all(feature = "simulation", madsim)))] // transport-layer I/O not simulated
1626mod rust_tests {
1627    use nautilus_common::testing::wait_until_async;
1628    use rstest::rstest;
1629    use tokio::{
1630        io::{AsyncReadExt, AsyncWriteExt},
1631        net::TcpListener,
1632        task,
1633        time::{Duration, sleep},
1634    };
1635
1636    use super::*;
1637
1638    #[rstest]
1639    #[tokio::test]
1640    async fn test_reconnect_then_close() {
1641        // Bind an ephemeral port
1642        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1643        let port = listener.local_addr().unwrap().port();
1644
1645        // Server task: accept one connection and then drop it
1646        let server = task::spawn(async move {
1647            if let Ok((mut sock, _)) = listener.accept().await {
1648                drop(sock.shutdown());
1649            }
1650            // Keep listener alive briefly to avoid premature exit
1651            sleep(Duration::from_secs(1)).await;
1652        });
1653
1654        // Configure client with a short reconnect backoff
1655        let config = SocketConfig {
1656            url: format!("127.0.0.1:{port}"),
1657            mode: Mode::Plain,
1658            suffix: b"\r\n".to_vec(),
1659            message_handler: None,
1660            heartbeat: None,
1661            reconnect_timeout_ms: Some(1_000),
1662            reconnect_delay_initial_ms: Some(50),
1663            reconnect_delay_max_ms: Some(100),
1664            reconnect_backoff_factor: Some(1.0),
1665            reconnect_jitter_ms: Some(0),
1666            connection_max_retries: Some(1),
1667            reconnect_max_attempts: None,
1668            idle_timeout_ms: None,
1669            certs_dir: None,
1670        };
1671
1672        // Connect client (handler=None)
1673        let client = SocketClient::connect(config.clone(), None, None, None)
1674            .await
1675            .unwrap();
1676
1677        // Wait for client to detect dropped connection and enter reconnect state
1678        wait_until_async(
1679            || async { client.is_reconnecting() },
1680            Duration::from_secs(2),
1681        )
1682        .await;
1683
1684        // Now close the client
1685        client.close().await;
1686        assert!(client.is_closed());
1687        server.abort();
1688    }
1689
1690    #[rstest]
1691    #[tokio::test]
1692    async fn test_reconnect_state_flips_when_reader_stops() {
1693        // Bind an ephemeral port and accept a single connection which we immediately close.
1694        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1695        let port = listener.local_addr().unwrap().port();
1696
1697        let server = task::spawn(async move {
1698            if let Ok((sock, _)) = listener.accept().await {
1699                drop(sock);
1700            }
1701            // Give the client a moment to observe the closed connection.
1702            sleep(Duration::from_millis(50)).await;
1703        });
1704
1705        let config = SocketConfig {
1706            url: format!("127.0.0.1:{port}"),
1707            mode: Mode::Plain,
1708            suffix: b"\r\n".to_vec(),
1709            message_handler: None,
1710            heartbeat: None,
1711            reconnect_timeout_ms: Some(1_000),
1712            reconnect_delay_initial_ms: Some(50),
1713            reconnect_delay_max_ms: Some(100),
1714            reconnect_backoff_factor: Some(1.0),
1715            reconnect_jitter_ms: Some(0),
1716            connection_max_retries: Some(1),
1717            reconnect_max_attempts: None,
1718            idle_timeout_ms: None,
1719            certs_dir: None,
1720        };
1721
1722        let client = SocketClient::connect(config, None, None, None)
1723            .await
1724            .unwrap();
1725
1726        wait_until_async(
1727            || async { client.is_reconnecting() },
1728            Duration::from_secs(2),
1729        )
1730        .await;
1731
1732        client.close().await;
1733        server.abort();
1734    }
1735
1736    #[rstest]
1737    fn test_parse_socket_url_raw_address() {
1738        // Raw socket address with TLS mode
1739        let (socket_addr, request_url) =
1740            SocketClientInner::parse_socket_url("example.com:6130", Mode::Tls).unwrap();
1741        assert_eq!(socket_addr, "example.com:6130");
1742        assert_eq!(request_url, "wss://example.com:6130");
1743
1744        // Raw socket address with Plain mode
1745        let (socket_addr, request_url) =
1746            SocketClientInner::parse_socket_url("localhost:8080", Mode::Plain).unwrap();
1747        assert_eq!(socket_addr, "localhost:8080");
1748        assert_eq!(request_url, "ws://localhost:8080");
1749    }
1750
1751    #[rstest]
1752    fn test_parse_socket_url_with_scheme() {
1753        // Full URL with wss scheme
1754        let (socket_addr, request_url) =
1755            SocketClientInner::parse_socket_url("wss://example.com:443/path", Mode::Tls).unwrap();
1756        assert_eq!(socket_addr, "example.com:443");
1757        assert_eq!(request_url, "wss://example.com:443/path");
1758
1759        // Full URL with ws scheme
1760        let (socket_addr, request_url) =
1761            SocketClientInner::parse_socket_url("ws://localhost:8080", Mode::Plain).unwrap();
1762        assert_eq!(socket_addr, "localhost:8080");
1763        assert_eq!(request_url, "ws://localhost:8080");
1764    }
1765
1766    #[rstest]
1767    fn test_parse_socket_url_default_ports() {
1768        // wss without explicit port defaults to 443
1769        let (socket_addr, _) =
1770            SocketClientInner::parse_socket_url("wss://example.com", Mode::Tls).unwrap();
1771        assert_eq!(socket_addr, "example.com:443");
1772
1773        // ws without explicit port defaults to 80
1774        let (socket_addr, _) =
1775            SocketClientInner::parse_socket_url("ws://example.com", Mode::Plain).unwrap();
1776        assert_eq!(socket_addr, "example.com:80");
1777
1778        // https defaults to 443
1779        let (socket_addr, _) =
1780            SocketClientInner::parse_socket_url("https://example.com", Mode::Tls).unwrap();
1781        assert_eq!(socket_addr, "example.com:443");
1782
1783        // http defaults to 80
1784        let (socket_addr, _) =
1785            SocketClientInner::parse_socket_url("http://example.com", Mode::Plain).unwrap();
1786        assert_eq!(socket_addr, "example.com:80");
1787    }
1788
1789    #[rstest]
1790    fn test_parse_socket_url_unknown_scheme_uses_mode() {
1791        // Unknown scheme defaults to mode-based port
1792        let (socket_addr, _) =
1793            SocketClientInner::parse_socket_url("custom://example.com", Mode::Tls).unwrap();
1794        assert_eq!(socket_addr, "example.com:443");
1795
1796        let (socket_addr, _) =
1797            SocketClientInner::parse_socket_url("custom://example.com", Mode::Plain).unwrap();
1798        assert_eq!(socket_addr, "example.com:80");
1799    }
1800
1801    #[rstest]
1802    fn test_parse_socket_url_ipv6() {
1803        // IPv6 address with port
1804        let (socket_addr, request_url) =
1805            SocketClientInner::parse_socket_url("[::1]:8080", Mode::Plain).unwrap();
1806        assert_eq!(socket_addr, "[::1]:8080");
1807        assert_eq!(request_url, "ws://[::1]:8080");
1808
1809        // IPv6 in URL
1810        let (socket_addr, _) =
1811            SocketClientInner::parse_socket_url("ws://[::1]:8080", Mode::Plain).unwrap();
1812        assert_eq!(socket_addr, "[::1]:8080");
1813    }
1814
1815    #[rstest]
1816    #[tokio::test]
1817    async fn test_url_parsing_raw_socket_address() {
1818        // Test that raw socket addresses (host:port) work correctly
1819        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1820        let port = listener.local_addr().unwrap().port();
1821
1822        let server = task::spawn(async move {
1823            if let Ok((sock, _)) = listener.accept().await {
1824                drop(sock);
1825            }
1826            sleep(Duration::from_millis(50)).await;
1827        });
1828
1829        let config = SocketConfig {
1830            url: format!("127.0.0.1:{port}"), // Raw socket address format
1831            mode: Mode::Plain,
1832            suffix: b"\r\n".to_vec(),
1833            message_handler: None,
1834            heartbeat: None,
1835            reconnect_timeout_ms: Some(1_000),
1836            reconnect_delay_initial_ms: Some(50),
1837            reconnect_delay_max_ms: Some(100),
1838            reconnect_backoff_factor: Some(1.0),
1839            reconnect_jitter_ms: Some(0),
1840            connection_max_retries: Some(1),
1841            reconnect_max_attempts: None,
1842            idle_timeout_ms: None,
1843            certs_dir: None,
1844        };
1845
1846        // Should successfully connect with raw socket address
1847        let client = SocketClient::connect(config, None, None, None).await;
1848        assert!(
1849            client.is_ok(),
1850            "Client should connect with raw socket address format"
1851        );
1852
1853        if let Ok(client) = client {
1854            client.close().await;
1855        }
1856        server.abort();
1857    }
1858
1859    #[rstest]
1860    #[tokio::test]
1861    async fn test_url_parsing_with_scheme() {
1862        // Test that URLs with schemes also work
1863        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1864        let port = listener.local_addr().unwrap().port();
1865
1866        let server = task::spawn(async move {
1867            if let Ok((sock, _)) = listener.accept().await {
1868                drop(sock);
1869            }
1870            sleep(Duration::from_millis(50)).await;
1871        });
1872
1873        let config = SocketConfig {
1874            url: format!("ws://127.0.0.1:{port}"), // URL with scheme
1875            mode: Mode::Plain,
1876            suffix: b"\r\n".to_vec(),
1877            message_handler: None,
1878            heartbeat: None,
1879            reconnect_timeout_ms: Some(1_000),
1880            reconnect_delay_initial_ms: Some(50),
1881            reconnect_delay_max_ms: Some(100),
1882            reconnect_backoff_factor: Some(1.0),
1883            reconnect_jitter_ms: Some(0),
1884            connection_max_retries: Some(1),
1885            reconnect_max_attempts: None,
1886            idle_timeout_ms: None,
1887            certs_dir: None,
1888        };
1889
1890        // Should successfully connect with URL format
1891        let client = SocketClient::connect(config, None, None, None).await;
1892        assert!(
1893            client.is_ok(),
1894            "Client should connect with URL scheme format"
1895        );
1896
1897        if let Ok(client) = client {
1898            client.close().await;
1899        }
1900        server.abort();
1901    }
1902
1903    #[rstest]
1904    fn test_parse_socket_url_ipv6_with_zone() {
1905        // IPv6 with zone ID (link-local address)
1906        let (socket_addr, request_url) =
1907            SocketClientInner::parse_socket_url("[fe80::1%eth0]:8080", Mode::Plain).unwrap();
1908        assert_eq!(socket_addr, "[fe80::1%eth0]:8080");
1909        assert_eq!(request_url, "ws://[fe80::1%eth0]:8080");
1910
1911        // Verify zone is preserved in URL format too
1912        let (socket_addr, request_url) =
1913            SocketClientInner::parse_socket_url("ws://[fe80::1%lo]:9090", Mode::Plain).unwrap();
1914        assert_eq!(socket_addr, "[fe80::1%lo]:9090");
1915        assert_eq!(request_url, "ws://[fe80::1%lo]:9090");
1916    }
1917
1918    #[rstest]
1919    #[tokio::test]
1920    async fn test_ipv6_loopback_connection() {
1921        // Test IPv6 loopback address connection
1922        // Skip if IPv6 is not available on the system
1923        if TcpListener::bind("[::1]:0").await.is_err() {
1924            eprintln!("IPv6 not available, skipping test");
1925            return;
1926        }
1927
1928        let listener = TcpListener::bind("[::1]:0").await.unwrap();
1929        let port = listener.local_addr().unwrap().port();
1930
1931        let server = task::spawn(async move {
1932            if let Ok((mut sock, _)) = listener.accept().await {
1933                let mut buf = vec![0u8; 1024];
1934                if let Ok(n) = sock.read(&mut buf).await {
1935                    // Echo back
1936                    let _ = sock.write_all(&buf[..n]).await;
1937                }
1938            }
1939            sleep(Duration::from_millis(50)).await;
1940        });
1941
1942        let config = SocketConfig {
1943            url: format!("[::1]:{port}"), // IPv6 loopback
1944            mode: Mode::Plain,
1945            suffix: b"\r\n".to_vec(),
1946            message_handler: None,
1947            heartbeat: None,
1948            reconnect_timeout_ms: Some(1_000),
1949            reconnect_delay_initial_ms: Some(50),
1950            reconnect_delay_max_ms: Some(100),
1951            reconnect_backoff_factor: Some(1.0),
1952            reconnect_jitter_ms: Some(0),
1953            connection_max_retries: Some(1),
1954            reconnect_max_attempts: None,
1955            idle_timeout_ms: None,
1956            certs_dir: None,
1957        };
1958
1959        let client = SocketClient::connect(config, None, None, None).await;
1960        assert!(
1961            client.is_ok(),
1962            "Client should connect to IPv6 loopback address"
1963        );
1964
1965        if let Ok(client) = client {
1966            client.close().await;
1967        }
1968        server.abort();
1969    }
1970
1971    #[rstest]
1972    #[tokio::test]
1973    async fn test_send_waits_during_reconnection() {
1974        // Test that send operations wait for reconnection to complete (up to configured timeout)
1975        use nautilus_common::testing::wait_until_async;
1976
1977        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1978        let port = listener.local_addr().unwrap().port();
1979
1980        let server = task::spawn(async move {
1981            // First connection - accept and immediately close
1982            if let Ok((sock, _)) = listener.accept().await {
1983                drop(sock);
1984            }
1985
1986            // Wait before accepting second connection
1987            sleep(Duration::from_millis(500)).await;
1988
1989            // Second connection - accept and keep alive
1990            if let Ok((mut sock, _)) = listener.accept().await {
1991                // Echo messages
1992                let mut buf = vec![0u8; 1024];
1993                while let Ok(n) = sock.read(&mut buf).await {
1994                    if n == 0 {
1995                        break;
1996                    }
1997
1998                    if sock.write_all(&buf[..n]).await.is_err() {
1999                        break;
2000                    }
2001                }
2002            }
2003        });
2004
2005        let config = SocketConfig {
2006            url: format!("127.0.0.1:{port}"),
2007            mode: Mode::Plain,
2008            suffix: b"\r\n".to_vec(),
2009            message_handler: None,
2010            heartbeat: None,
2011            reconnect_timeout_ms: Some(5_000), // 5s timeout - enough for reconnect
2012            reconnect_delay_initial_ms: Some(100),
2013            reconnect_delay_max_ms: Some(200),
2014            reconnect_backoff_factor: Some(1.0),
2015            reconnect_jitter_ms: Some(0),
2016            connection_max_retries: Some(1),
2017            reconnect_max_attempts: None,
2018            idle_timeout_ms: None,
2019            certs_dir: None,
2020        };
2021
2022        let client = SocketClient::connect(config, None, None, None)
2023            .await
2024            .unwrap();
2025
2026        // Wait for reconnection to trigger
2027        wait_until_async(
2028            || async { client.is_reconnecting() },
2029            Duration::from_secs(2),
2030        )
2031        .await;
2032
2033        // Try to send while reconnecting - should wait and succeed after reconnect
2034        let send_result = tokio::time::timeout(
2035            Duration::from_secs(3),
2036            client.send_bytes(b"test_message".to_vec()),
2037        )
2038        .await;
2039
2040        assert!(
2041            send_result.is_ok() && send_result.unwrap().is_ok(),
2042            "Send should succeed after waiting for reconnection"
2043        );
2044
2045        client.close().await;
2046        server.abort();
2047    }
2048
2049    #[rstest]
2050    #[tokio::test]
2051    async fn test_send_bytes_timeout_uses_configured_reconnect_timeout() {
2052        // Test that send_bytes operations respect the configured reconnect_timeout.
2053        // When a client is stuck in RECONNECT longer than the timeout, sends should fail with Timeout.
2054        use nautilus_common::testing::wait_until_async;
2055
2056        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2057        let port = listener.local_addr().unwrap().port();
2058
2059        let server = task::spawn(async move {
2060            // Accept first connection and immediately close it
2061            if let Ok((sock, _)) = listener.accept().await {
2062                drop(sock);
2063            }
2064            // Drop listener entirely so reconnection fails completely
2065            drop(listener);
2066            sleep(Duration::from_mins(1)).await;
2067        });
2068
2069        let config = SocketConfig {
2070            url: format!("127.0.0.1:{port}"),
2071            mode: Mode::Plain,
2072            suffix: b"\r\n".to_vec(),
2073            message_handler: None,
2074            heartbeat: None,
2075            reconnect_timeout_ms: Some(1_000), // 1s timeout for faster test
2076            reconnect_delay_initial_ms: Some(200), // Short backoff (but > timeout) to keep client in RECONNECT
2077            reconnect_delay_max_ms: Some(200),
2078            reconnect_backoff_factor: Some(1.0),
2079            reconnect_jitter_ms: Some(0),
2080            connection_max_retries: Some(1),
2081            reconnect_max_attempts: None,
2082            idle_timeout_ms: None,
2083            certs_dir: None,
2084        };
2085
2086        let client = SocketClient::connect(config, None, None, None)
2087            .await
2088            .unwrap();
2089
2090        // Wait for client to enter RECONNECT state
2091        wait_until_async(
2092            || async { client.is_reconnecting() },
2093            Duration::from_secs(3),
2094        )
2095        .await;
2096
2097        // Attempt send while stuck in RECONNECT - should timeout after 1s (configured timeout)
2098        // The client will try to reconnect for 1s, fail, then wait 5s backoff before next attempt
2099        let start = std::time::Instant::now();
2100        let send_result = client.send_bytes(b"test".to_vec()).await;
2101        let elapsed = start.elapsed();
2102
2103        assert!(
2104            send_result.is_err(),
2105            "Send should fail when client stuck in RECONNECT, was: {send_result:?}"
2106        );
2107        assert!(
2108            matches!(send_result, Err(crate::error::SendError::Timeout)),
2109            "Send should return Timeout error, was: {send_result:?}"
2110        );
2111        // Verify timeout respects configured value (1s), but don't check upper bound
2112        // as CI scheduler jitter can cause legitimate delays beyond the timeout
2113        assert!(
2114            elapsed >= Duration::from_millis(900),
2115            "Send should timeout after at least 1s (configured timeout), took {elapsed:?}"
2116        );
2117
2118        client.close().await;
2119        server.abort();
2120    }
2121
2122    #[rstest]
2123    #[tokio::test]
2124    async fn test_idle_timeout_triggers_reconnect() {
2125        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2126        let port = listener.local_addr().unwrap().port();
2127
2128        // Server accepts connection but sends nothing (simulates silent death)
2129        let server = task::spawn(async move {
2130            let (_sock1, _) = listener.accept().await.unwrap();
2131            // Hold connection open but send nothing, wait for reconnect attempt
2132            sleep(Duration::from_secs(5)).await;
2133        });
2134
2135        let config = SocketConfig {
2136            url: format!("127.0.0.1:{port}"),
2137            mode: Mode::Plain,
2138            suffix: b"\r\n".to_vec(),
2139            message_handler: None,
2140            heartbeat: None,
2141            reconnect_timeout_ms: Some(2_000),
2142            reconnect_delay_initial_ms: Some(50),
2143            reconnect_delay_max_ms: Some(100),
2144            reconnect_backoff_factor: Some(1.0),
2145            reconnect_jitter_ms: Some(0),
2146            connection_max_retries: Some(1),
2147            reconnect_max_attempts: Some(1),
2148            idle_timeout_ms: Some(500),
2149            certs_dir: None,
2150        };
2151
2152        let client = SocketClient::connect(config, None, None, None)
2153            .await
2154            .unwrap();
2155
2156        assert!(client.is_active());
2157
2158        // Wait for idle timeout to fire and client to enter reconnect
2159        wait_until_async(
2160            || async { client.is_reconnecting() || client.is_closed() },
2161            Duration::from_secs(3),
2162        )
2163        .await;
2164
2165        assert!(
2166            !client.is_active(),
2167            "Client should not be active after idle timeout"
2168        );
2169
2170        client.close().await;
2171        server.abort();
2172    }
2173
2174    #[rstest]
2175    #[tokio::test]
2176    async fn test_idle_timeout_resets_on_data() {
2177        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2178        let port = listener.local_addr().unwrap().port();
2179
2180        // Server sends data every 200ms (well within the 1s idle timeout)
2181        let server = task::spawn(async move {
2182            let (mut sock, _) = listener.accept().await.unwrap();
2183            for _ in 0..10 {
2184                sleep(Duration::from_millis(200)).await;
2185
2186                if sock.write_all(b"ping\r\n").await.is_err() {
2187                    break;
2188                }
2189            }
2190        });
2191
2192        let config = SocketConfig {
2193            url: format!("127.0.0.1:{port}"),
2194            mode: Mode::Plain,
2195            suffix: b"\r\n".to_vec(),
2196            message_handler: None,
2197            heartbeat: None,
2198            reconnect_timeout_ms: Some(2_000),
2199            reconnect_delay_initial_ms: Some(50),
2200            reconnect_delay_max_ms: Some(100),
2201            reconnect_backoff_factor: Some(1.0),
2202            reconnect_jitter_ms: Some(0),
2203            connection_max_retries: Some(1),
2204            reconnect_max_attempts: Some(1),
2205            idle_timeout_ms: Some(1_000),
2206            certs_dir: None,
2207        };
2208
2209        let client = SocketClient::connect(config, None, None, None)
2210            .await
2211            .unwrap();
2212
2213        assert!(client.is_active());
2214
2215        // Wait 1.5s - data arrives every 200ms so idle timeout (1s) should NOT fire
2216        sleep(Duration::from_millis(1_500)).await;
2217
2218        assert!(
2219            client.is_active(),
2220            "Client should remain active when data is flowing"
2221        );
2222
2223        client.close().await;
2224        server.abort();
2225    }
2226
2227    #[rstest]
2228    #[tokio::test]
2229    async fn test_close_during_backoff_exits_promptly() {
2230        // Verify that close() interrupts backoff sleep (Finding 1).
2231        // Server accepts then drops, no second listener -> reconnect fails -> enters backoff.
2232        // We close while backing off and assert the client shuts down quickly.
2233        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2234        let port = listener.local_addr().unwrap().port();
2235
2236        let server = task::spawn(async move {
2237            // Accept first connection, close immediately
2238            if let Ok((mut sock, _)) = listener.accept().await {
2239                drop(sock.shutdown());
2240            }
2241            // Don't accept again so reconnect fails and enters backoff
2242            sleep(Duration::from_mins(1)).await;
2243        });
2244
2245        let config = SocketConfig {
2246            url: format!("127.0.0.1:{port}"),
2247            mode: Mode::Plain,
2248            suffix: b"\r\n".to_vec(),
2249            message_handler: None,
2250            heartbeat: None,
2251            reconnect_timeout_ms: Some(1_000),
2252            reconnect_delay_initial_ms: Some(10_000), // 10s backoff to ensure we're sleeping
2253            reconnect_delay_max_ms: Some(10_000),
2254            reconnect_backoff_factor: Some(1.0),
2255            reconnect_jitter_ms: Some(0),
2256            connection_max_retries: None,
2257            reconnect_max_attempts: None,
2258            idle_timeout_ms: None,
2259            certs_dir: None,
2260        };
2261
2262        let client = SocketClient::connect(config, None, None, None)
2263            .await
2264            .unwrap();
2265
2266        // Wait for client to enter reconnect
2267        wait_until_async(
2268            || async { client.is_reconnecting() },
2269            Duration::from_secs(3),
2270        )
2271        .await;
2272
2273        // Wait for the reconnect attempt to fail and enter backoff sleep
2274        sleep(Duration::from_millis(1_500)).await;
2275
2276        // Close while backing off
2277        let start = std::time::Instant::now();
2278        client.close().await;
2279        let elapsed = start.elapsed();
2280
2281        assert!(client.is_closed(), "Client should be closed");
2282        // Should exit well before the 10s backoff sleep completes
2283        assert!(
2284            elapsed < Duration::from_secs(2),
2285            "Close should interrupt backoff sleep, took {elapsed:?}"
2286        );
2287
2288        server.abort();
2289    }
2290
2291    #[rstest]
2292    #[tokio::test]
2293    async fn test_zero_idle_timeout_rejected() {
2294        let config = SocketConfig {
2295            url: "127.0.0.1:9999".to_string(),
2296            mode: Mode::Plain,
2297            suffix: b"\r\n".to_vec(),
2298            message_handler: None,
2299            heartbeat: None,
2300            reconnect_timeout_ms: None,
2301            reconnect_delay_initial_ms: None,
2302            reconnect_delay_max_ms: None,
2303            reconnect_backoff_factor: None,
2304            reconnect_jitter_ms: None,
2305            reconnect_max_attempts: None,
2306            connection_max_retries: Some(1),
2307            idle_timeout_ms: Some(0),
2308            certs_dir: None,
2309        };
2310
2311        let result = SocketClient::connect(config, None, None, None).await;
2312
2313        assert!(result.is_err(), "Zero idle timeout should be rejected");
2314        let err_msg = result.unwrap_err().to_string();
2315        assert!(
2316            err_msg.contains("Idle timeout cannot be zero"),
2317            "Error should mention zero idle timeout, was: {err_msg}"
2318        );
2319    }
2320
2321    #[rstest]
2322    #[tokio::test]
2323    async fn test_empty_suffix_rejected() {
2324        let config = SocketConfig {
2325            url: "127.0.0.1:9999".to_string(),
2326            mode: Mode::Plain,
2327            suffix: vec![],
2328            message_handler: None,
2329            heartbeat: None,
2330            reconnect_timeout_ms: None,
2331            reconnect_delay_initial_ms: None,
2332            reconnect_delay_max_ms: None,
2333            reconnect_backoff_factor: None,
2334            reconnect_jitter_ms: None,
2335            reconnect_max_attempts: None,
2336            connection_max_retries: Some(1),
2337            idle_timeout_ms: None,
2338            certs_dir: None,
2339        };
2340
2341        let result = SocketClient::connect(config, None, None, None).await;
2342
2343        assert!(
2344            result.is_err(),
2345            "Empty suffix should cause connection to fail"
2346        );
2347        let err_msg = result.unwrap_err().to_string();
2348        assert!(
2349            err_msg.contains("suffix cannot be empty"),
2350            "Error should mention empty suffix, was: {err_msg}"
2351        );
2352    }
2353}