nautilus_tardis/machine/
mod.rs1pub mod cache;
17pub mod client;
18pub mod message;
19pub mod parse;
20pub mod types;
21
22use std::{
23 sync::{
24 Arc,
25 atomic::{AtomicBool, Ordering},
26 },
27 time::Duration,
28};
29
30use async_stream::stream;
31use futures_util::{Sink, SinkExt, Stream, StreamExt, pin_mut};
32use message::WsMessage;
33use nautilus_core::string::urlencoding;
34use tokio_tungstenite::{
35 connect_async,
36 tungstenite::{self, protocol::frame::coding::CloseCode},
37};
38use tokio_util::sync::CancellationToken;
39use types::{ReplayNormalizedRequestOptions, StreamNormalizedRequestOptions};
40
41pub use crate::machine::client::TardisMachineClient;
42
43pub type Result<T> = std::result::Result<T, Error>;
44
45#[derive(Debug, thiserror::Error)]
47pub enum Error {
48 #[error("Options cannot be empty")]
50 EmptyOptions,
51 #[error("Failed to connect: {0}")]
53 ConnectFailed(#[from] tungstenite::Error),
54 #[error("Connection rejected: {reason}")]
56 ConnectRejected {
57 status: tungstenite::http::StatusCode,
59 reason: String,
61 },
62 #[error("Connection closed: {reason}")]
64 ConnectionClosed {
65 reason: String,
67 },
68 #[error("Failed to deserialize message: {0}")]
70 Deserialization(#[from] serde_json::Error),
71}
72
73pub async fn replay_normalized(
80 base_url: &str,
81 options: Vec<ReplayNormalizedRequestOptions>,
82 signal: Arc<AtomicBool>,
83) -> Result<impl Stream<Item = Result<WsMessage>>> {
84 if options.is_empty() {
85 return Err(Error::EmptyOptions);
86 }
87
88 let path = format!("{base_url}/ws-replay-normalized?options=");
89 let options = serde_json::to_string(&options)?;
90
91 let plain_url = format!("{path}{options}");
92 log::debug!("Connecting to {plain_url}");
93
94 let url = format!("{path}{}", urlencoding::encode(&options));
95 stream_from_websocket(base_url, url, signal).await
96}
97
98pub async fn stream_normalized(
105 base_url: &str,
106 options: Vec<StreamNormalizedRequestOptions>,
107 signal: Arc<AtomicBool>,
108) -> Result<impl Stream<Item = Result<WsMessage>>> {
109 if options.is_empty() {
110 return Err(Error::EmptyOptions);
111 }
112
113 let path = format!("{base_url}/ws-stream-normalized?options=");
114 let options = serde_json::to_string(&options)?;
115
116 let plain_url = format!("{path}{options}");
117 log::debug!("Connecting to {plain_url}");
118
119 let url = format!("{path}{}", urlencoding::encode(&options));
120 stream_from_websocket(base_url, url, signal).await
121}
122
123async fn stream_from_websocket(
124 base_url: &str,
125 url: String,
126 signal: Arc<AtomicBool>,
127) -> Result<impl Stream<Item = Result<WsMessage>>> {
128 let (ws_stream, ws_resp) = connect_async(url).await?;
129
130 handle_connection_response(&ws_resp)?;
131 log::debug!("Connected to {base_url}");
132
133 Ok(stream! {
134 let (writer, mut reader) = ws_stream.split();
135 let cancel = CancellationToken::new();
136 let heartbeat = heartbeat(writer, cancel.child_token());
137 pin_mut!(heartbeat);
138 let mut heartbeat_active = true;
139 let _cancel_heartbeat = cancel.drop_guard();
140
141 let timeout = Duration::from_millis(10);
143
144 log::debug!("Streaming from websocket...");
145
146 loop {
147 if signal.load(Ordering::Relaxed) {
148 log::debug!("Shutdown signal received");
149 break;
150 }
151
152 let result = tokio::select! {
153 result = tokio::time::timeout(timeout, reader.next()) => result,
154 () = &mut heartbeat, if heartbeat_active => {
155 heartbeat_active = false;
156 continue;
157 }
158 };
159 let msg = match result {
160 Ok(msg) => msg,
161 Err(_) => continue, };
163
164 match msg {
165 Some(Ok(msg)) => match msg {
166 tungstenite::Message::Frame(_)
167 | tungstenite::Message::Binary(_)
168 | tungstenite::Message::Pong(_)
169 | tungstenite::Message::Ping(_) => {
170 log::trace!("Received {msg:?}");
171 }
172 tungstenite::Message::Close(Some(frame)) => {
173 let reason = frame.reason.to_string();
174 if frame.code == CloseCode::Normal {
175 log::debug!("Connection closed normally: {reason}");
176 } else {
177 log::warn!(
178 "Connection closed abnormally with code: {:?}, reason: {reason}", frame.code
179 );
180 yield Err(Error::ConnectionClosed { reason });
181 }
182 break;
183 }
184 tungstenite::Message::Close(None) => {
185 log::warn!("Connection closed without a frame");
186 yield Err(Error::ConnectionClosed {
187 reason: "No close frame provided".to_string()
188 });
189 break;
190 }
191 tungstenite::Message::Text(msg) => {
192 match serde_json::from_str::<WsMessage>(&msg) {
193 Ok(parsed_msg) => yield Ok(parsed_msg),
194 Err(e) => {
195 log::error!("Failed to deserialize message: {msg}. Error: {e}");
196 yield Err(Error::Deserialization(e));
197 }
198 }
199 }
200 },
201 Some(Err(e)) => {
202 log::warn!("WebSocket error: {e}");
203 yield Err(Error::ConnectFailed(e));
204 break;
205 }
206 None => {
207 log::warn!("Connection closed unexpectedly");
208 yield Err(Error::ConnectionClosed {
209 reason: "Unexpected connection close".to_string(),
210 });
211 break;
212 }
213 }
214 }
215
216 log::debug!("Shutdown stream");
217 })
218}
219
220fn handle_connection_response(
221 ws_resp: &tungstenite::http::Response<Option<Vec<u8>>>,
222) -> Result<()> {
223 if ws_resp.status() != tungstenite::http::StatusCode::SWITCHING_PROTOCOLS {
224 return match ws_resp.body() {
225 Some(resp) => Err(Error::ConnectRejected {
226 status: ws_resp.status(),
227 reason: String::from_utf8_lossy(resp).to_string(),
228 }),
229 None => Err(Error::ConnectRejected {
230 status: ws_resp.status(),
231 reason: "Unknown reason".to_string(),
232 }),
233 };
234 }
235 Ok(())
236}
237
238async fn heartbeat<S>(mut sender: S, cancel: CancellationToken)
239where
240 S: Sink<tungstenite::Message> + Unpin,
241 S::Error: std::fmt::Display,
242{
243 let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(10));
244
245 loop {
246 tokio::select! {
247 _ = heartbeat_interval.tick() => {}
248 () = cancel.cancelled() => break,
249 }
250
251 log::trace!("Sending PING");
252
253 tokio::select! {
254 result = sender.send(tungstenite::Message::Ping(vec![].into())) => {
255 if let Err(e) = result {
256 log::debug!("Heartbeat send failed (connection closed): {e}");
257 break;
258 }
259 }
260 () = cancel.cancelled() => break,
261 }
262 }
263
264 log::debug!("Heartbeat task exiting");
265}
266
267#[cfg(test)]
268mod tests {
269 use std::{
270 convert::Infallible,
271 pin::Pin,
272 task::{Context, Poll},
273 time::Duration,
274 };
275
276 use futures_util::Sink;
277 use rstest::rstest;
278 use tokio_tungstenite::tungstenite::Message;
279 use tokio_util::sync::CancellationToken;
280
281 use super::heartbeat;
282
283 struct StallSink;
284
285 impl Sink<Message> for StallSink {
286 type Error = Infallible;
287
288 fn poll_ready(
289 self: Pin<&mut Self>,
290 _cx: &mut Context<'_>,
291 ) -> Poll<Result<(), Self::Error>> {
292 Poll::Pending
293 }
294
295 fn start_send(self: Pin<&mut Self>, _item: Message) -> Result<(), Self::Error> {
296 Ok(())
297 }
298
299 fn poll_flush(
300 self: Pin<&mut Self>,
301 _cx: &mut Context<'_>,
302 ) -> Poll<Result<(), Self::Error>> {
303 Poll::Pending
304 }
305
306 fn poll_close(
307 self: Pin<&mut Self>,
308 _cx: &mut Context<'_>,
309 ) -> Poll<Result<(), Self::Error>> {
310 Poll::Ready(Ok(()))
311 }
312 }
313
314 #[rstest]
315 #[tokio::test]
316 async fn test_heartbeat_exits_on_cancel_during_stalled_send() {
317 let cancel = CancellationToken::new();
318 let task = tokio::spawn(heartbeat(StallSink, cancel.clone()));
319
320 tokio::task::yield_now().await;
321 cancel.cancel();
322
323 tokio::time::timeout(Duration::from_secs(1), task)
324 .await
325 .expect("heartbeat should exit after cancel during a stalled send")
326 .unwrap();
327 }
328}