1use std::{sync::atomic::Ordering, time::Duration};
17
18use nautilus_core::python::{clone_py_object, to_pyruntime_err, to_pyvalue_err};
19use pyo3::{Py, prelude::*};
20use tokio_tungstenite::tungstenite::stream::Mode;
21
22use crate::{
23 mode::ConnectionMode,
24 socket::{SocketClient, SocketConfig, TcpMessageHandler, WriterCommand},
25};
26
27#[pymethods]
28#[pyo3_stub_gen::derive::gen_stub_pymethods]
29impl SocketConfig {
30 #[new]
32 #[expect(clippy::too_many_arguments, clippy::needless_pass_by_value)]
33 #[pyo3(signature = (url, ssl, suffix, handler, heartbeat=None, reconnect_timeout_ms=10_000, reconnect_delay_initial_ms=2_000, reconnect_delay_max_ms=30_000, reconnect_backoff_factor=1.5, reconnect_jitter_ms=100, connection_max_retries=5, reconnect_max_attempts=None, idle_timeout_ms=None, certs_dir=None))]
34 fn py_new(
35 url: String,
36 ssl: bool,
37 suffix: Vec<u8>,
38 handler: Py<PyAny>,
39 heartbeat: Option<(u64, Vec<u8>)>,
40 reconnect_timeout_ms: Option<u64>,
41 reconnect_delay_initial_ms: Option<u64>,
42 reconnect_delay_max_ms: Option<u64>,
43 reconnect_backoff_factor: Option<f64>,
44 reconnect_jitter_ms: Option<u64>,
45 connection_max_retries: Option<u32>,
46 reconnect_max_attempts: Option<u32>,
47 idle_timeout_ms: Option<u64>,
48 certs_dir: Option<String>,
49 ) -> PyResult<Self> {
50 let mode = if ssl { Mode::Tls } else { Mode::Plain };
51
52 let handler_clone = clone_py_object(&handler);
54 let message_handler: TcpMessageHandler = std::sync::Arc::new(move |data: &[u8]| {
55 Python::attach(|py| {
56 if let Err(e) = handler_clone.call1(py, (data,)) {
57 log::error!("Error calling Python message handler: {e}");
58 }
59 });
60 });
61
62 let config = Self {
63 url,
64 mode,
65 suffix,
66 message_handler: Some(message_handler),
67 heartbeat,
68 reconnect_timeout_ms,
69 reconnect_delay_initial_ms,
70 reconnect_delay_max_ms,
71 reconnect_backoff_factor,
72 reconnect_jitter_ms,
73 connection_max_retries,
74 reconnect_max_attempts,
75 idle_timeout_ms,
76 certs_dir,
77 };
78 config.validate().map_err(to_pyvalue_err)?;
79 Ok(config)
80 }
81}
82
83#[pymethods]
84#[pyo3_stub_gen::derive::gen_stub_pymethods]
85impl SocketClient {
86 #[staticmethod]
88 #[pyo3(name = "connect")]
89 #[pyo3(signature = (config, post_connection=None, post_reconnection=None, post_disconnection=None))]
90 fn py_connect(
91 config: SocketConfig,
92 post_connection: Option<Py<PyAny>>,
93 post_reconnection: Option<Py<PyAny>>,
94 post_disconnection: Option<Py<PyAny>>,
95 py: Python<'_>,
96 ) -> PyResult<Bound<'_, PyAny>> {
97 let post_connection_fn = post_connection.map(|callback| {
99 let callback_clone = clone_py_object(&callback);
100 std::sync::Arc::new(move || {
101 Python::attach(|py| {
102 if let Err(e) = callback_clone.call0(py) {
103 log::error!("Error calling post_connection handler: {e}");
104 }
105 });
106 }) as std::sync::Arc<dyn Fn() + Send + Sync>
107 });
108
109 let post_reconnection_fn = post_reconnection.map(|callback| {
110 let callback_clone = clone_py_object(&callback);
111 std::sync::Arc::new(move || {
112 Python::attach(|py| {
113 if let Err(e) = callback_clone.call0(py) {
114 log::error!("Error calling post_reconnection handler: {e}");
115 }
116 });
117 }) as std::sync::Arc<dyn Fn() + Send + Sync>
118 });
119
120 let post_disconnection_fn = post_disconnection.map(|callback| {
121 let callback_clone = clone_py_object(&callback);
122 std::sync::Arc::new(move || {
123 Python::attach(|py| {
124 if let Err(e) = callback_clone.call0(py) {
125 log::error!("Error calling post_disconnection handler: {e}");
126 }
127 });
128 }) as std::sync::Arc<dyn Fn() + Send + Sync>
129 });
130
131 pyo3_async_runtimes::tokio::future_into_py(py, async move {
132 Self::connect(
133 config,
134 post_connection_fn,
135 post_reconnection_fn,
136 post_disconnection_fn,
137 )
138 .await
139 .map_err(to_pyruntime_err)
140 })
141 }
142
143 #[pyo3(name = "is_active")]
148 #[expect(clippy::needless_pass_by_value)]
149 fn py_is_active(slf: PyRef<'_, Self>) -> bool {
150 slf.is_active()
151 }
152
153 #[pyo3(name = "is_reconnecting")]
158 #[expect(clippy::needless_pass_by_value)]
159 fn py_is_reconnecting(slf: PyRef<'_, Self>) -> bool {
160 slf.is_reconnecting()
161 }
162
163 #[pyo3(name = "is_disconnecting")]
167 #[expect(clippy::needless_pass_by_value)]
168 fn py_is_disconnecting(slf: PyRef<'_, Self>) -> bool {
169 slf.is_disconnecting()
170 }
171
172 #[pyo3(name = "is_closed")]
178 #[expect(clippy::needless_pass_by_value)]
179 fn py_is_closed(slf: PyRef<'_, Self>) -> bool {
180 slf.is_closed()
181 }
182
183 #[pyo3(name = "mode")]
184 #[expect(clippy::needless_pass_by_value)]
185 fn py_mode(slf: PyRef<'_, Self>) -> String {
186 slf.connection_mode().to_string()
187 }
188
189 #[pyo3(name = "reconnect")]
191 #[expect(clippy::needless_pass_by_value)]
192 fn py_reconnect<'py>(slf: PyRef<'_, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
193 let connection_mode = slf.connection_mode.clone();
194 let state_notify = slf.state_notify.clone();
195 let mode_str = ConnectionMode::from_atomic(&connection_mode).to_string();
196 log::debug!("Reconnect from mode {mode_str}");
197
198 pyo3_async_runtimes::tokio::future_into_py(py, async move {
199 match ConnectionMode::from_atomic(&connection_mode) {
200 ConnectionMode::Reconnect => {
201 log::warn!("Cannot reconnect - socket already reconnecting");
202 }
203 ConnectionMode::Disconnect => {
204 log::warn!("Cannot reconnect - socket disconnecting");
205 }
206 ConnectionMode::Closed => {
207 log::warn!("Cannot reconnect - socket closed");
208 }
209 ConnectionMode::Active => {
210 if !ConnectionMode::request_reconnect(&connection_mode) {
212 log::warn!("Cannot reconnect - socket no longer active");
213 return Ok(());
214 }
215 state_notify.notify_one();
216
217 let fallback_interval = Duration::from_millis(100);
218 let timeout = tokio::time::timeout(Duration::from_secs(30), async {
219 loop {
220 let notified = state_notify.notified();
221
222 let current = ConnectionMode::from_atomic(&connection_mode);
223 if current.is_active() {
224 return Ok(());
225 }
226
227 if current.is_closed() || current.is_disconnect() {
228 return Err("Connection closed during reconnect");
229 }
230
231 tokio::select! {
232 biased;
233 () = notified => {}
234 () = tokio::time::sleep(fallback_interval) => {}
235 }
236 }
237 })
238 .await;
239
240 match timeout {
241 Ok(Ok(())) => log::debug!("Reconnected successfully"),
242 Ok(Err(e)) => log::warn!("Reconnect aborted: {e}"),
243 Err(_) => log::warn!("Reconnect timed out after 30s"),
244 }
245 }
246 }
247
248 Ok(())
249 })
250 }
251
252 #[pyo3(name = "close")]
257 #[expect(clippy::needless_pass_by_value)]
258 fn py_close<'py>(slf: PyRef<'_, Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
259 let connection_mode = slf.connection_mode.clone();
260 let state_notify = slf.state_notify.clone();
261 let mode_str = ConnectionMode::from_atomic(&connection_mode).to_string();
262 log::debug!("Close from mode {mode_str}");
263
264 pyo3_async_runtimes::tokio::future_into_py(py, async move {
265 match ConnectionMode::from_atomic(&connection_mode) {
266 ConnectionMode::Closed => {
267 log::debug!("Socket already closed");
268 }
269 ConnectionMode::Disconnect => {
270 log::debug!("Socket already disconnecting");
271 }
272 _ => {
273 ConnectionMode::request_disconnect(&connection_mode);
275 state_notify.notify_one();
276
277 let timeout = tokio::time::timeout(Duration::from_secs(5), async {
278 while !ConnectionMode::from_atomic(&connection_mode).is_closed() {
279 tokio::time::sleep(Duration::from_millis(10)).await;
280 }
281 })
282 .await;
283
284 if timeout.is_err() {
285 log::warn!("Timeout waiting for socket to close, forcing closed state");
286 connection_mode.store(ConnectionMode::Closed.as_u8(), Ordering::SeqCst);
287 }
288 }
289 }
290
291 Ok(())
292 })
293 }
294
295 #[pyo3(name = "send")]
301 #[expect(clippy::needless_pass_by_value)]
302 fn py_send<'py>(
303 slf: PyRef<'_, Self>,
304 data: Vec<u8>,
305 py: Python<'py>,
306 ) -> PyResult<Bound<'py, PyAny>> {
307 log::trace!("Sending {}", String::from_utf8_lossy(&data));
308
309 let connection_mode = slf.connection_mode.clone();
310 let state_notify = slf.state_notify.clone();
311 let writer_tx = slf.writer_tx.clone();
312
313 pyo3_async_runtimes::tokio::future_into_py(py, async move {
314 match ConnectionMode::from_atomic(&connection_mode) {
315 ConnectionMode::Disconnect | ConnectionMode::Closed => {
316 let msg = format!(
317 "Cannot send data ({}): socket closed",
318 String::from_utf8_lossy(&data)
319 );
320
321 let io_err = std::io::Error::new(std::io::ErrorKind::NotConnected, msg);
322 return Err(to_pyruntime_err(io_err));
323 }
324 mode if !mode.is_active() => {
325 let timeout = Duration::from_secs(2);
326 let fallback_interval = Duration::from_millis(100);
327
328 log::debug!("Waiting for client to become ACTIVE before sending (2s)...");
329
330 match tokio::time::timeout(timeout, async {
331 loop {
332 let notified = state_notify.notified();
333
334 let mode = ConnectionMode::from_atomic(&connection_mode);
335 if mode.is_active() {
336 return Ok(());
337 }
338
339 if matches!(mode, ConnectionMode::Disconnect | ConnectionMode::Closed) {
340 return Err("Client disconnected waiting to send");
341 }
342
343 tokio::select! {
344 biased;
345 () = notified => {}
346 () = tokio::time::sleep(fallback_interval) => {}
347 }
348 }
349 })
350 .await
351 {
352 Ok(Ok(())) => log::debug!("Client now active"),
353 Ok(Err(e)) => {
354 let err_msg = format!(
355 "Failed sending data ({}): {e}",
356 String::from_utf8_lossy(&data)
357 );
358
359 let io_err =
360 std::io::Error::new(std::io::ErrorKind::NotConnected, err_msg);
361 return Err(to_pyruntime_err(io_err));
362 }
363 Err(_) => {
364 let err_msg = format!(
365 "Failed sending data ({}): timeout waiting to become ACTIVE",
366 String::from_utf8_lossy(&data)
367 );
368
369 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, err_msg);
370 return Err(to_pyruntime_err(io_err));
371 }
372 }
373 }
374 _ => {}
375 }
376
377 let msg = WriterCommand::Send(data.into());
378 writer_tx.send(msg).map_err(to_pyruntime_err)
379 })
380 }
381}