1use std::{
23 collections::{hash_map::Entry, HashMap},
24 sync::{
25 atomic::{AtomicBool, Ordering},
26 Arc,
27 },
28 time::Duration,
29};
30
31use async_trait::async_trait;
32use futures03::{stream::SplitSink, SinkExt, StreamExt};
33use hyper::{
34 header::{
35 AUTHORIZATION, CONNECTION, HOST, SEC_WEBSOCKET_KEY, SEC_WEBSOCKET_VERSION, UPGRADE,
36 USER_AGENT,
37 },
38 Uri,
39};
40#[cfg(test)]
41use mockall::automock;
42use thiserror::Error;
43use tokio::{
44 net::TcpStream,
45 sync::{
46 mpsc::{self, error::TrySendError, Receiver, Sender},
47 oneshot, Mutex, MutexGuard, Notify,
48 },
49 task::JoinHandle,
50 time::sleep,
51};
52use tokio_tungstenite::{
53 connect_async,
54 tungstenite::{
55 self,
56 handshake::client::{generate_key, Request},
57 },
58 MaybeTlsStream, WebSocketStream,
59};
60use tracing::{debug, error, info, instrument, trace, warn};
61use tycho_common::{
62 dto::{self, Command, Response, WebSocketMessage, WebsocketError},
63 models::{blockchain::BlockAggregatedChanges, ExtractorIdentity},
64};
65use uuid::Uuid;
66use zstd;
67
68use crate::TYCHO_SERVER_VERSION;
69
70#[derive(Error, Debug)]
71pub enum DeltasError {
72 #[error("Failed to parse URI: {0}. Error: {1}")]
74 UriParsing(String, String),
75
76 #[error("The requested subscription is already pending")]
78 SubscriptionAlreadyPending,
79
80 #[error("The server replied with an error: {0}")]
81 ServerError(String),
82
83 #[error("{0}")]
86 TransportError(String),
87
88 #[error("The buffer is full!")]
92 BufferFull,
93
94 #[error("The client is not connected!")]
98 NotConnected,
99
100 #[error("The client is already connected!")]
102 AlreadyConnected,
103
104 #[error("The server closed the connection!")]
106 ConnectionClosed,
107
108 #[error("Connection error: {0}")]
110 ConnectionError(#[from] Box<tungstenite::Error>),
111
112 #[error("Tycho FatalError: {0}")]
114 Fatal(String),
115}
116
117#[derive(Clone, Debug)]
118pub struct SubscriptionOptions {
119 include_state: bool,
120 compression: bool,
121 partial_blocks: bool,
122}
123
124impl Default for SubscriptionOptions {
125 fn default() -> Self {
126 Self { include_state: true, compression: true, partial_blocks: false }
127 }
128}
129
130impl SubscriptionOptions {
131 pub fn new() -> Self {
132 Self::default()
133 }
134 pub fn with_state(mut self, val: bool) -> Self {
135 self.include_state = val;
136 self
137 }
138 pub fn with_compression(mut self, val: bool) -> Self {
139 self.compression = val;
140 self
141 }
142 pub fn with_partial_blocks(mut self, val: bool) -> Self {
143 self.partial_blocks = val;
144 self
145 }
146}
147
148#[cfg_attr(test, automock)]
149#[async_trait]
150pub trait DeltasClient {
151 async fn subscribe(
158 &self,
159 extractor_id: ExtractorIdentity,
160 options: SubscriptionOptions,
161 ) -> Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError>;
162
163 async fn unsubscribe(&self, subscription_id: Uuid) -> Result<(), DeltasError>;
165
166 async fn connect(&self) -> Result<JoinHandle<Result<(), DeltasError>>, DeltasError>;
168
169 async fn close(&self) -> Result<(), DeltasError>;
171}
172
173#[derive(Clone)]
174pub struct WsDeltasClient {
175 uri: Uri,
177 auth_key: Option<String>,
179 max_reconnects: u64,
181 retry_cooldown: Duration,
183 ws_buffer_size: usize,
186 subscription_buffer_size: usize,
189 conn_notify: Arc<Notify>,
191 inner: Arc<Mutex<Option<Inner>>>,
193 dead: Arc<AtomicBool>,
195}
196
197type WebSocketSink =
198 SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, tungstenite::protocol::Message>;
199
200#[derive(Debug)]
212enum SubscriptionInfo {
213 RequestedSubscription(
215 oneshot::Sender<Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError>>,
216 ),
217 Active,
219 RequestedUnsubscription(oneshot::Sender<()>),
221}
222
223struct Inner {
225 sink: WebSocketSink,
227 cmd_tx: Sender<()>,
229 pending: HashMap<ExtractorIdentity, SubscriptionInfo>,
231 subscriptions: HashMap<Uuid, SubscriptionInfo>,
233 sender: HashMap<Uuid, Sender<BlockAggregatedChanges>>,
236 buffer_size: usize,
238}
239
240impl Inner {
244 fn new(cmd_tx: Sender<()>, sink: WebSocketSink, buffer_size: usize) -> Self {
245 Self {
246 sink,
247 cmd_tx,
248 pending: HashMap::new(),
249 subscriptions: HashMap::new(),
250 sender: HashMap::new(),
251 buffer_size,
252 }
253 }
254
255 fn new_subscription(
257 &mut self,
258 id: &ExtractorIdentity,
259 ready_tx: oneshot::Sender<Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError>>,
260 ) -> Result<(), DeltasError> {
261 if self.pending.contains_key(id) {
262 return Err(DeltasError::SubscriptionAlreadyPending);
263 }
264 self.pending
265 .insert(id.clone(), SubscriptionInfo::RequestedSubscription(ready_tx));
266 Ok(())
267 }
268
269 fn mark_active(&mut self, extractor_id: ExtractorIdentity, subscription_id: Uuid) {
273 if let Some(info) = self.pending.remove(&extractor_id) {
274 if let SubscriptionInfo::RequestedSubscription(ready_tx) = info {
275 let (tx, rx) = mpsc::channel(self.buffer_size);
276 self.sender.insert(subscription_id, tx);
277 self.subscriptions
278 .insert(subscription_id, SubscriptionInfo::Active);
279 let _ = ready_tx
280 .send(Ok((subscription_id, rx)))
281 .map_err(|_| {
282 warn!(
283 ?extractor_id,
284 ?subscription_id,
285 "Subscriber for has gone away. Ignoring."
286 )
287 });
288 } else {
289 error!(
290 ?extractor_id,
291 ?subscription_id,
292 "Pending subscription was not in the correct state to
293 transition to active. Ignoring!"
294 )
295 }
296 } else {
297 error!(
298 ?extractor_id,
299 ?subscription_id,
300 "Tried to mark an unknown subscription as active. Ignoring!"
301 );
302 }
303 }
304
305 fn send(&mut self, id: &Uuid, msg: BlockAggregatedChanges) -> Result<(), DeltasError> {
307 if let Some(sender) = self.sender.get_mut(id) {
308 sender
309 .try_send(msg)
310 .map_err(|e| match e {
311 TrySendError::Full(_) => DeltasError::BufferFull,
312 TrySendError::Closed(_) => {
313 DeltasError::TransportError("The subscriber has gone away".to_string())
314 }
315 })?;
316 }
317 Ok(())
318 }
319
320 fn end_subscription(&mut self, subscription_id: &Uuid, ready_tx: oneshot::Sender<()>) {
325 if let Some(info) = self
326 .subscriptions
327 .get_mut(subscription_id)
328 {
329 if let SubscriptionInfo::Active = info {
330 *info = SubscriptionInfo::RequestedUnsubscription(ready_tx);
331 }
332 } else {
333 debug!(?subscription_id, "Tried unsubscribing from a non existent subscription");
335 }
336 }
337
338 fn remove_subscription(&mut self, subscription_id: Uuid) -> Result<(), DeltasError> {
345 if let Entry::Occupied(e) = self
346 .subscriptions
347 .entry(subscription_id)
348 {
349 let info = e.remove();
350 if let SubscriptionInfo::RequestedUnsubscription(tx) = info {
351 let _ = tx.send(()).map_err(|_| {
352 debug!(?subscription_id, "failed to notify about removed subscription")
353 });
354 self.sender
355 .remove(&subscription_id)
356 .ok_or_else(|| DeltasError::Fatal("Inconsistent internal client state: `sender` state drifted from `info` while removing a subscription.".to_string()))?;
357 } else {
358 warn!(?subscription_id, "Subscription ended unexpectedly!");
359 self.sender
360 .remove(&subscription_id)
361 .ok_or_else(|| DeltasError::Fatal("sender channel missing".to_string()))?;
362 }
363 } else {
364 trace!(
369 ?subscription_id,
370 "Received `SubscriptionEnded`, but was never subscribed to it. This is likely a bug!"
371 );
372 }
373
374 Ok(())
375 }
376
377 fn cancel_pending(&mut self, extractor_id: ExtractorIdentity, error: &WebsocketError) {
378 if let Some(sub_info) = self.pending.remove(&extractor_id) {
379 match sub_info {
380 SubscriptionInfo::RequestedSubscription(tx) => {
381 let _ = tx
382 .send(Err(DeltasError::ServerError(format!(
383 "Subscription failed: {error}"
384 ))))
385 .map_err(|_| debug!("Cancel pending failed: receiver deallocated!"));
386 }
387 _ => {
388 error!(?extractor_id, "Pending subscription in wrong state")
389 }
390 }
391 } else {
392 debug!(?extractor_id, "Tried cancel on non-existent pending subscription!")
393 }
394 }
395
396 async fn ws_send(&mut self, msg: tungstenite::protocol::Message) -> Result<(), DeltasError> {
398 self.sink.send(msg).await.map_err(|e| {
399 DeltasError::TransportError(format!("Failed to send message to websocket: {e}"))
400 })
401 }
402}
403
404impl WsDeltasClient {
406 pub fn new(ws_uri: &str, auth_key: Option<&str>) -> Result<Self, DeltasError> {
408 let uri = ws_uri
409 .parse::<Uri>()
410 .map_err(|e| DeltasError::UriParsing(ws_uri.to_string(), e.to_string()))?;
411 Ok(Self {
412 uri,
413 auth_key: auth_key.map(|s| s.to_string()),
414 inner: Arc::new(Mutex::new(None)),
415 ws_buffer_size: 256,
416 subscription_buffer_size: 256,
417 conn_notify: Arc::new(Notify::new()),
418 max_reconnects: 5,
419 retry_cooldown: Duration::from_millis(500),
420 dead: Arc::new(AtomicBool::new(false)),
421 })
422 }
423
424 pub fn new_with_reconnects(
426 ws_uri: &str,
427 auth_key: Option<&str>,
428 max_reconnects: u64,
429 retry_cooldown: Duration,
430 ) -> Result<Self, DeltasError> {
431 let uri = ws_uri
432 .parse::<Uri>()
433 .map_err(|e| DeltasError::UriParsing(ws_uri.to_string(), e.to_string()))?;
434
435 Ok(Self {
436 uri,
437 auth_key: auth_key.map(|s| s.to_string()),
438 inner: Arc::new(Mutex::new(None)),
439 ws_buffer_size: 128,
440 subscription_buffer_size: 128,
441 conn_notify: Arc::new(Notify::new()),
442 max_reconnects,
443 retry_cooldown,
444 dead: Arc::new(AtomicBool::new(false)),
445 })
446 }
447
448 #[cfg(test)]
450 pub fn new_with_custom_buffers(
451 ws_uri: &str,
452 auth_key: Option<&str>,
453 ws_buffer_size: usize,
454 subscription_buffer_size: usize,
455 ) -> Result<Self, DeltasError> {
456 let uri = ws_uri
457 .parse::<Uri>()
458 .map_err(|e| DeltasError::UriParsing(ws_uri.to_string(), e.to_string()))?;
459 Ok(Self {
460 uri,
461 auth_key: auth_key.map(|s| s.to_string()),
462 inner: Arc::new(Mutex::new(None)),
463 ws_buffer_size,
464 subscription_buffer_size,
465 conn_notify: Arc::new(Notify::new()),
466 max_reconnects: 5,
467 retry_cooldown: Duration::from_millis(0),
468 dead: Arc::new(AtomicBool::new(false)),
469 })
470 }
471
472 async fn is_connected(&self) -> bool {
476 let guard = self.inner.as_ref().lock().await;
477 guard.is_some()
478 }
479
480 async fn ensure_connection(&self) -> Result<(), DeltasError> {
485 loop {
490 if self.dead.load(Ordering::SeqCst) {
491 return Err(DeltasError::NotConnected);
492 }
493 if self.is_connected().await {
494 return Ok(());
495 }
496 let notified = self.conn_notify.notified();
500 tokio::pin!(notified);
501 notified.as_mut().enable();
502 if !self.is_connected().await {
503 notified.await;
504 }
505 }
508 }
509
510 #[instrument(skip(self, msg))]
515 async fn handle_msg(
516 &self,
517 msg: Result<tungstenite::protocol::Message, tokio_tungstenite::tungstenite::error::Error>,
518 ) -> Result<(), DeltasError> {
519 let mut guard = self.inner.lock().await;
520
521 match msg {
522 Ok(tungstenite::protocol::Message::Text(text)) => match serde_json::from_str::<
527 serde_json::Value,
528 >(&text)
529 {
530 Ok(value) => match serde_json::from_value::<WebSocketMessage>(value) {
531 Ok(ws_message) => match ws_message {
532 WebSocketMessage::BlockAggregatedChanges { subscription_id, deltas } => {
533 Self::handle_block_changes_msg(&mut guard, subscription_id, deltas)
534 .await?;
535 }
536 WebSocketMessage::Response(Response::NewSubscription {
537 extractor_id,
538 subscription_id,
539 }) => {
540 info!(?extractor_id, ?subscription_id, "Received a new subscription");
541 let inner = guard
542 .as_mut()
543 .ok_or_else(|| DeltasError::NotConnected)?;
544 inner.mark_active(extractor_id.into(), subscription_id);
545 }
546 WebSocketMessage::Response(Response::SubscriptionEnded {
547 subscription_id,
548 }) => {
549 info!(?subscription_id, "Received a subscription ended");
550 let inner = guard
551 .as_mut()
552 .ok_or_else(|| DeltasError::NotConnected)?;
553 inner.remove_subscription(subscription_id)?;
554 }
555 WebSocketMessage::Response(Response::Error(error)) => match &error {
556 WebsocketError::ExtractorNotFound(extractor_id) => {
557 let inner = guard
558 .as_mut()
559 .ok_or_else(|| DeltasError::NotConnected)?;
560 inner.cancel_pending(extractor_id.clone().into(), &error);
561 }
562 WebsocketError::SubscriptionNotFound(subscription_id) => {
563 debug!("Received subscription not found, removing subscription");
564 let inner = guard
565 .as_mut()
566 .ok_or_else(|| DeltasError::NotConnected)?;
567 inner.remove_subscription(*subscription_id)?;
568 }
569 WebsocketError::ParseError(raw, e) => {
570 return Err(DeltasError::ServerError(format!(
571 "Server failed to parse client message: {e}, msg: {raw}"
572 )))
573 }
574 WebsocketError::CompressionError(subscription_id, e) => {
575 return Err(DeltasError::ServerError(format!(
576 "Server failed to compress message for subscription: \
577 {subscription_id}, error: {e}"
578 )))
579 }
580 WebsocketError::SubscribeError(extractor_id) => {
581 let inner = guard
582 .as_mut()
583 .ok_or_else(|| DeltasError::NotConnected)?;
584 inner.cancel_pending(extractor_id.clone().into(), &error);
585 }
586 },
587 },
588 Err(e) => {
589 error!(
590 "Failed to deserialize WebSocketMessage: {}. \nMessage: {}",
591 e, text
592 );
593 }
594 },
595 Err(e) => {
596 error!(
597 "Failed to deserialize message: invalid JSON. {} \nMessage: {}",
598 e, text
599 );
600 }
601 },
602 Ok(tungstenite::protocol::Message::Binary(data)) => {
603 match zstd::decode_all(data.as_slice()) {
606 Ok(decompressed) => {
607 match serde_json::from_slice::<serde_json::Value>(decompressed.as_slice()) {
608 Ok(value) => {
609 match serde_json::from_value::<WebSocketMessage>(value.clone()) {
610 Ok(ws_message) => match ws_message {
611 WebSocketMessage::BlockAggregatedChanges {
612 subscription_id,
613 deltas,
614 } => {
615 Self::handle_block_changes_msg(
616 &mut guard,
617 subscription_id,
618 deltas,
619 )
620 .await?;
621 }
622 _ => {
623 error!(
624 "Received unsupported compressed WebSocketMessage variant. \nMessage: {ws_message:?}",
625 );
626 }
627 },
628 Err(e) => {
629 error!(
630 "Failed to deserialize compressed WebSocketMessage: {e}. \nMessage: {value:?}",
631 );
632 }
633 }
634 }
635 Err(e) => {
636 error!(
637 "Failed to deserialize compressed message: invalid JSON. {e}",
638 );
639 }
640 }
641 }
642 Err(e) => {
643 error!("Failed to decompress zstd data: {}", e);
644 }
645 }
646 }
647 Ok(tungstenite::protocol::Message::Ping(_)) => {
648 let inner = guard
650 .as_mut()
651 .ok_or_else(|| DeltasError::NotConnected)?;
652 if let Err(error) = inner
653 .ws_send(tungstenite::protocol::Message::Pong(Vec::new()))
654 .await
655 {
656 debug!(?error, "Failed to send pong!");
657 }
658 }
659 Ok(tungstenite::protocol::Message::Pong(_)) => {
660 }
662 Ok(tungstenite::protocol::Message::Close(frame)) => {
663 match &frame {
664 Some(f) => {
665 warn!(code = ?f.code, reason = %f.reason, "WebSocket closed by server")
666 }
667 None => warn!("WebSocket closed by server (no close frame)"),
668 }
669 return Err(DeltasError::ConnectionClosed);
670 }
671 Ok(unknown_msg) => {
672 info!("Received an unknown message type: {:?}", unknown_msg);
673 }
674 Err(error) => {
675 error!(?error, "Websocket error");
676 return Err(match error {
677 tungstenite::Error::ConnectionClosed => DeltasError::ConnectionClosed,
678 tungstenite::Error::AlreadyClosed => {
679 warn!("Received AlreadyClosed error which is indicative of a bug!");
680 DeltasError::ConnectionError(Box::new(error))
681 }
682 tungstenite::Error::Io(_) | tungstenite::Error::Protocol(_) => {
683 DeltasError::ConnectionError(Box::new(error))
684 }
685 _ => DeltasError::Fatal(error.to_string()),
686 });
687 }
688 };
689 Ok(())
690 }
691
692 async fn handle_block_changes_msg(
693 guard: &mut MutexGuard<'_, Option<Inner>>,
694 subscription_id: Uuid,
695 deltas: dto::BlockAggregatedChanges,
696 ) -> Result<(), DeltasError> {
697 trace!(?deltas, "Received a block state change, sending to channel");
698 let inner = guard
699 .as_mut()
700 .ok_or_else(|| DeltasError::NotConnected)?;
701 match inner.send(&subscription_id, BlockAggregatedChanges::from(deltas)) {
702 Err(DeltasError::BufferFull) => {
703 error!(?subscription_id, "Buffer full, unsubscribing!");
704 Self::force_unsubscribe(subscription_id, inner).await;
705 }
706 Err(_) => {
707 warn!(?subscription_id, "Receiver for has gone away, unsubscribing!");
708 Self::force_unsubscribe(subscription_id, inner).await;
709 }
710 _ => { }
711 }
712 Ok(())
713 }
714
715 async fn force_unsubscribe(subscription_id: Uuid, inner: &mut Inner) {
720 if let Some(SubscriptionInfo::RequestedUnsubscription(_)) = inner
722 .subscriptions
723 .get(&subscription_id)
724 {
725 return;
726 }
727
728 let (tx, rx) = oneshot::channel();
729 if let Err(e) = WsDeltasClient::unsubscribe_inner(inner, subscription_id, tx).await {
730 warn!(?e, ?subscription_id, "Failed to send unsubscribe command");
731 } else {
732 match tokio::time::timeout(Duration::from_secs(5), rx).await {
734 Ok(_) => {
735 debug!(?subscription_id, "Unsubscribe completed successfully");
736 }
737 Err(_) => {
738 warn!(?subscription_id, "Unsubscribe completion timed out");
739 }
740 }
741 }
742 }
743
744 async fn unsubscribe_inner(
750 inner: &mut Inner,
751 subscription_id: Uuid,
752 ready_tx: oneshot::Sender<()>,
753 ) -> Result<(), DeltasError> {
754 debug!(?subscription_id, "Unsubscribing");
755 inner.end_subscription(&subscription_id, ready_tx);
756 let cmd = Command::Unsubscribe { subscription_id };
757 inner
758 .ws_send(tungstenite::protocol::Message::Text(serde_json::to_string(&cmd).map_err(
759 |e| {
760 DeltasError::TransportError(format!(
761 "Failed to serialize unsubscribe command: {e}"
762 ))
763 },
764 )?))
765 .await?;
766 Ok(())
767 }
768}
769
770#[async_trait]
771impl DeltasClient for WsDeltasClient {
772 #[instrument(skip(self))]
773 async fn subscribe(
774 &self,
775 extractor_id: ExtractorIdentity,
776 options: SubscriptionOptions,
777 ) -> Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError> {
778 trace!("Starting subscribe");
779 self.ensure_connection().await?;
780 let (ready_tx, ready_rx) = oneshot::channel();
781 {
782 let mut guard = self.inner.lock().await;
783 let inner = guard
784 .as_mut()
785 .ok_or_else(|| DeltasError::NotConnected)?;
786 trace!("Sending subscribe command");
787 inner.new_subscription(&extractor_id, ready_tx)?;
788 let cmd = Command::Subscribe {
789 extractor_id: extractor_id.into(),
790 include_state: options.include_state,
791 compression: options.compression,
792 partial_blocks: options.partial_blocks,
793 };
794 inner
795 .ws_send(tungstenite::protocol::Message::Text(
796 serde_json::to_string(&cmd).map_err(|e| {
797 DeltasError::TransportError(format!(
798 "Failed to serialize subscribe command: {e}"
799 ))
800 })?,
801 ))
802 .await?;
803 }
804 trace!("Waiting for subscription response");
805 let res = tokio::time::timeout(Duration::from_secs(30), ready_rx)
806 .await
807 .map_err(|_| {
808 DeltasError::TransportError(
809 "Subscribe confirmation timed out after 30s".to_string(),
810 )
811 })?
812 .map_err(|_| {
813 DeltasError::TransportError("Subscription channel closed unexpectedly".to_string())
814 })??;
815 trace!("Subscription successful");
816 Ok(res)
817 }
818
819 #[instrument(skip(self))]
820 async fn unsubscribe(&self, subscription_id: Uuid) -> Result<(), DeltasError> {
821 self.ensure_connection().await?;
822 let (ready_tx, ready_rx) = oneshot::channel();
823 {
824 let mut guard = self.inner.lock().await;
825 let inner = guard
826 .as_mut()
827 .ok_or_else(|| DeltasError::NotConnected)?;
828
829 WsDeltasClient::unsubscribe_inner(inner, subscription_id, ready_tx).await?;
830 }
831 tokio::time::timeout(Duration::from_secs(5), ready_rx)
832 .await
833 .map_err(|_| {
834 warn!(?subscription_id, "Unsubscribe confirmation timed out after 5s");
835 DeltasError::TransportError(
836 "Unsubscribe confirmation timed out after 5s".to_string(),
837 )
838 })?
839 .map_err(|_| {
840 DeltasError::TransportError("Unsubscribe channel closed unexpectedly".to_string())
841 })?;
842
843 Ok(())
844 }
845
846 #[instrument(skip(self))]
847 async fn connect(&self) -> Result<JoinHandle<Result<(), DeltasError>>, DeltasError> {
848 if self.is_connected().await {
849 return Err(DeltasError::AlreadyConnected);
850 }
851 let ws_uri = format!("{uri}{TYCHO_SERVER_VERSION}/ws", uri = self.uri);
852 info!(?ws_uri, "Starting TychoWebsocketClient");
853
854 let (cmd_tx, mut cmd_rx) = mpsc::channel(self.ws_buffer_size);
855 {
856 let mut guard = self.inner.as_ref().lock().await;
857 *guard = None;
858 }
859 let this = self.clone();
860 let jh = tokio::spawn(async move {
861 let mut retry_count = 0;
862 let mut result = Err(DeltasError::NotConnected);
863
864 'retry: while retry_count < this.max_reconnects {
865 info!(?ws_uri, retry_count, "Connecting to WebSocket server");
866 if retry_count > 0 {
867 sleep(this.retry_cooldown).await;
868 }
869
870 let mut request_builder = Request::builder()
872 .uri(&ws_uri)
873 .header(SEC_WEBSOCKET_KEY, generate_key())
874 .header(SEC_WEBSOCKET_VERSION, 13)
875 .header(CONNECTION, "Upgrade")
876 .header(UPGRADE, "websocket")
877 .header(
878 HOST,
879 this.uri.host().ok_or_else(|| {
880 DeltasError::UriParsing(
881 ws_uri.clone(),
882 "No host found in tycho url".to_string(),
883 )
884 })?,
885 )
886 .header(
887 USER_AGENT,
888 format!("tycho-client-{version}", version = env!("CARGO_PKG_VERSION")),
889 );
890
891 if let Some(ref key) = this.auth_key {
893 request_builder = request_builder.header(AUTHORIZATION, key);
894 }
895
896 let request = request_builder.body(()).map_err(|e| {
897 DeltasError::TransportError(format!("Failed to build connection request: {e}"))
898 })?;
899 let (conn, _) = match connect_async(request).await {
900 Ok(conn) => conn,
901 Err(e) => {
902 retry_count += 1;
904 let mut guard = this.inner.as_ref().lock().await;
905 *guard = None;
906
907 if let tungstenite::Error::Http(response) = &e {
908 if response.status() == tungstenite::http::StatusCode::TOO_MANY_REQUESTS
909 {
910 let reason = response
911 .body()
912 .as_deref()
913 .and_then(|b| std::str::from_utf8(b).ok())
914 .unwrap_or("")
915 .to_string();
916 warn!(reason, "WebSocket connection rejected: rate limited");
917 continue 'retry;
918 }
919 }
920
921 warn!(
922 e = e.to_string(),
923 "Failed to connect to WebSocket server; Reconnecting"
924 );
925 continue 'retry;
926 }
927 };
928
929 let (ws_tx_new, ws_rx_new) = conn.split();
930 {
931 let mut guard = this.inner.as_ref().lock().await;
932 *guard =
933 Some(Inner::new(cmd_tx.clone(), ws_tx_new, this.subscription_buffer_size));
934 }
935 let mut msg_rx = ws_rx_new.boxed();
936
937 info!("Connection Successful: TychoWebsocketClient started");
938 this.conn_notify.notify_waiters();
939 result = Ok(());
940
941 const IDLE_TIMEOUT: Duration = Duration::from_secs(60);
946 loop {
947 let res = tokio::select! {
948 msg_result = tokio::time::timeout(IDLE_TIMEOUT, msg_rx.next()) => {
949 match msg_result {
950 Err(_elapsed) => {
951 warn!("No WS frame received for {IDLE_TIMEOUT:?}, \
952 treating connection as stalled; Reconnecting...");
953 retry_count += 1;
954 let mut guard = this.inner.as_ref().lock().await;
955 *guard = None;
956 break; }
958 Ok(Some(msg)) => this.handle_msg(msg).await,
959 Ok(None) => {
960 warn!("Websocket connection silently closed, giving up!");
964 break 'retry
965 }
966 }
967 },
968 _ = cmd_rx.recv() => {break 'retry},
969 };
970 if let Err(error) = res {
971 debug!(?error, "WsError");
972 if matches!(
973 error,
974 DeltasError::ConnectionClosed | DeltasError::ConnectionError { .. }
975 ) {
976 retry_count += 1;
978 let mut guard = this.inner.as_ref().lock().await;
979 *guard = None;
980
981 warn!(
982 ?error,
983 ?retry_count,
984 "Connection dropped unexpectedly; Reconnecting..."
985 );
986 break;
987 } else {
988 error!(?error, "Fatal error; Exiting");
990 result = Err(error);
991 break 'retry;
992 }
993 }
994 }
995 }
996 debug!(
997 retry_count,
998 max_reconnects=?this.max_reconnects,
999 "Reconnection loop ended"
1000 );
1001 let mut guard = this.inner.as_ref().lock().await;
1003 *guard = None;
1004
1005 if retry_count >= this.max_reconnects {
1007 error!("Max reconnection attempts reached; Exiting");
1008 this.dead.store(true, Ordering::SeqCst);
1009 this.conn_notify.notify_waiters(); result = Err(DeltasError::ConnectionClosed);
1011 }
1012
1013 result
1014 });
1015
1016 self.conn_notify.notified().await;
1017
1018 if self.is_connected().await {
1019 Ok(jh)
1020 } else {
1021 Err(DeltasError::NotConnected)
1022 }
1023 }
1024
1025 #[instrument(skip(self))]
1026 async fn close(&self) -> Result<(), DeltasError> {
1027 info!("Closing TychoWebsocketClient");
1028 {
1029 let mut guard = self.inner.lock().await;
1030 if let Some(inner) = guard.as_mut() {
1031 inner
1032 .cmd_tx
1033 .send(())
1034 .await
1035 .map_err(|e| DeltasError::TransportError(e.to_string()))?;
1036 }
1037 }
1038 self.dead.store(true, Ordering::SeqCst);
1041 self.conn_notify.notify_waiters();
1042 Ok(())
1043 }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use std::{net::SocketAddr, str::FromStr};
1049
1050 use tokio::{net::TcpListener, time::timeout};
1051 use tycho_common::models::Chain;
1052
1053 use super::*;
1054
1055 #[derive(Clone)]
1056 enum ExpectedComm {
1057 Receive(u64, tungstenite::protocol::Message),
1058 Send(tungstenite::protocol::Message),
1059 }
1060
1061 async fn mock_tycho_ws(
1062 messages: &[ExpectedComm],
1063 reconnects: usize,
1064 ) -> (SocketAddr, JoinHandle<()>) {
1065 info!("Starting mock webserver");
1066 let server = TcpListener::bind("127.0.0.1:0")
1068 .await
1069 .expect("localhost bind failed");
1070 let addr = server.local_addr().unwrap();
1071 let messages = messages.to_vec();
1072
1073 let jh = tokio::spawn(async move {
1074 info!("mock webserver started");
1075 for _ in 0..(reconnects + 1) {
1076 info!("Awaiting client connections");
1077 if let Ok((stream, _)) = server.accept().await {
1078 info!("Client connected");
1079 let mut websocket = tokio_tungstenite::accept_async(stream)
1080 .await
1081 .unwrap();
1082
1083 info!("Handling messages..");
1084 for c in messages.iter().cloned() {
1085 match c {
1086 ExpectedComm::Receive(t, exp) => {
1087 info!("Awaiting message...");
1088 let msg = timeout(Duration::from_millis(t), websocket.next())
1089 .await
1090 .expect("Receive timeout")
1091 .expect("Stream exhausted")
1092 .expect("Failed to receive message.");
1093 info!("Message received");
1094 assert_eq!(msg, exp)
1095 }
1096 ExpectedComm::Send(data) => {
1097 info!("Sending message");
1098 websocket
1099 .send(data)
1100 .await
1101 .expect("Failed to send message");
1102 info!("Message sent");
1103 }
1104 };
1105 }
1106 info!("Mock communication completed");
1107 sleep(Duration::from_millis(100)).await;
1108 let _ = websocket.close(None).await;
1110 info!("Mock server closed connection");
1111 }
1112 }
1113 info!("mock server ended");
1114 });
1115 (addr, jh)
1116 }
1117
1118 const SUBSCRIPTION_ID: &str = "30b740d1-cf09-4e0e-8cfe-b1434d447ece";
1119
1120 fn subscribe() -> String {
1121 subscribe_with_compression(false)
1122 }
1123
1124 fn subscribe_with_compression(compression: bool) -> String {
1125 format!(
1127 r#"{{"method":"subscribe","extractor_id":{{"chain":"ethereum","name":"vm:ambient"}},"include_state":true,"compression":{compression},"partial_blocks":false}}"#
1128 )
1129 }
1130
1131 fn subscription_confirmation() -> String {
1132 r#"
1133 {
1134 "method": "newsubscription",
1135 "extractor_id":{
1136 "chain": "ethereum",
1137 "name": "vm:ambient"
1138 },
1139 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1140 }
1141 "#
1142 .replace(|c: char| c.is_whitespace(), "")
1143 }
1144
1145 fn block_deltas() -> String {
1146 r#"
1147 {
1148 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1149 "deltas": {
1150 "extractor": "vm:ambient",
1151 "chain": "ethereum",
1152 "block": {
1153 "number": 123,
1154 "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1155 "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1156 "chain": "ethereum",
1157 "ts": "2023-09-14T00:00:00"
1158 },
1159 "finalized_block_height": 0,
1160 "revert": false,
1161 "new_tokens": {},
1162 "account_updates": {
1163 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1164 "address": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1165 "chain": "ethereum",
1166 "slots": {},
1167 "balance": "0x01f4",
1168 "code": "",
1169 "change": "Update"
1170 }
1171 },
1172 "state_updates": {
1173 "component_1": {
1174 "component_id": "component_1",
1175 "updated_attributes": {"attr1": "0x01"},
1176 "deleted_attributes": ["attr2"]
1177 }
1178 },
1179 "new_protocol_components":
1180 { "protocol_1": {
1181 "id": "protocol_1",
1182 "protocol_system": "system_1",
1183 "protocol_type_name": "type_1",
1184 "chain": "ethereum",
1185 "tokens": ["0x01", "0x02"],
1186 "contract_ids": ["0x01", "0x02"],
1187 "static_attributes": {"attr1": "0x01f4"},
1188 "change": "Update",
1189 "creation_tx": "0x01",
1190 "created_at": "2023-09-14T00:00:00"
1191 }
1192 },
1193 "deleted_protocol_components": {},
1194 "component_balances": {
1195 "protocol_1":
1196 {
1197 "0x01": {
1198 "token": "0x01",
1199 "balance": "0x01f4",
1200 "balance_float": 0.0,
1201 "modify_tx": "0x01",
1202 "component_id": "protocol_1"
1203 }
1204 }
1205 },
1206 "account_balances": {
1207 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1208 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1209 "account": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1210 "token": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1211 "balance": "0x01f4",
1212 "modify_tx": "0x01"
1213 }
1214 }
1215 },
1216 "component_tvl": {
1217 "protocol_1": 1000.0
1218 },
1219 "dci_update": {
1220 "new_entrypoints": {},
1221 "new_entrypoint_params": {},
1222 "trace_results": {}
1223 }
1224 }
1225 }
1226 "#.replace(|c: char| c.is_whitespace(), "")
1227 }
1228
1229 fn unsubscribe() -> String {
1230 r#"
1231 {
1232 "method": "unsubscribe",
1233 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1234 }
1235 "#
1236 .replace(|c: char| c.is_whitespace(), "")
1237 }
1238
1239 fn subscription_ended() -> String {
1240 r#"
1241 {
1242 "method": "subscriptionended",
1243 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1244 }
1245 "#
1246 .replace(|c: char| c.is_whitespace(), "")
1247 }
1248
1249 #[tokio::test]
1250 async fn test_uncompressed_subscribe_receive() {
1251 let exp_comm = [
1252 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1253 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1254 ExpectedComm::Send(tungstenite::protocol::Message::Text(block_deltas())),
1255 ];
1256 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1257
1258 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1259 let jh = client
1260 .connect()
1261 .await
1262 .expect("connect failed");
1263 let (_, mut rx) = timeout(
1264 Duration::from_millis(100),
1265 client.subscribe(
1266 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1267 SubscriptionOptions::new().with_compression(false),
1268 ),
1269 )
1270 .await
1271 .expect("subscription timed out")
1272 .expect("subscription failed");
1273 let _ = timeout(Duration::from_millis(100), rx.recv())
1274 .await
1275 .expect("awaiting message timeout out")
1276 .expect("receiving message failed");
1277 timeout(Duration::from_millis(100), client.close())
1278 .await
1279 .expect("close timed out")
1280 .expect("close failed");
1281 jh.await
1282 .expect("ws loop errored")
1283 .unwrap();
1284 server_thread.await.unwrap();
1285 }
1286
1287 #[tokio::test]
1288 async fn test_compressed_subscribe_receive() {
1289 let compressed_block_deltas = zstd::encode_all(
1290 block_deltas().as_bytes(),
1291 0, )
1293 .expect("Failed to compress block deltas message");
1294
1295 let exp_comm = [
1296 ExpectedComm::Receive(
1297 100,
1298 tungstenite::protocol::Message::Text(subscribe_with_compression(true)),
1299 ),
1300 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1301 ExpectedComm::Send(tungstenite::protocol::Message::Binary(compressed_block_deltas)),
1302 ];
1303 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1304
1305 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1306 let jh = client
1307 .connect()
1308 .await
1309 .expect("connect failed");
1310 let (_, mut rx) = timeout(
1311 Duration::from_millis(100),
1312 client.subscribe(
1313 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1314 SubscriptionOptions::new().with_compression(true),
1315 ),
1316 )
1317 .await
1318 .expect("subscription timed out")
1319 .expect("subscription failed");
1320 let _ = timeout(Duration::from_millis(100), rx.recv())
1321 .await
1322 .expect("awaiting message timeout out")
1323 .expect("receiving message failed");
1324 timeout(Duration::from_millis(100), client.close())
1325 .await
1326 .expect("close timed out")
1327 .expect("close failed");
1328 jh.await
1329 .expect("ws loop errored")
1330 .unwrap();
1331 server_thread.await.unwrap();
1332 }
1333
1334 #[tokio::test]
1335 async fn test_unsubscribe() {
1336 let exp_comm = [
1337 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1338 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1339 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
1340 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
1341 ];
1342 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1343
1344 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1345 let jh = client
1346 .connect()
1347 .await
1348 .expect("connect failed");
1349 let (sub_id, mut rx) = timeout(
1350 Duration::from_millis(100),
1351 client.subscribe(
1352 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1353 SubscriptionOptions::new().with_compression(false),
1354 ),
1355 )
1356 .await
1357 .expect("subscription timed out")
1358 .expect("subscription failed");
1359
1360 timeout(Duration::from_millis(100), client.unsubscribe(sub_id))
1361 .await
1362 .expect("unsubscribe timed out")
1363 .expect("unsubscribe failed");
1364 let res = timeout(Duration::from_millis(100), rx.recv())
1365 .await
1366 .expect("awaiting message timeout out");
1367
1368 assert!(res.is_none());
1370
1371 timeout(Duration::from_millis(100), client.close())
1372 .await
1373 .expect("close timed out")
1374 .expect("close failed");
1375 jh.await
1376 .expect("ws loop errored")
1377 .unwrap();
1378 server_thread.await.unwrap();
1379 }
1380
1381 #[tokio::test]
1382 async fn test_subscription_unexpected_end() {
1383 let exp_comm = [
1384 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1385 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1386 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
1387 ];
1388 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1389
1390 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1391 let jh = client
1392 .connect()
1393 .await
1394 .expect("connect failed");
1395 let (_, mut rx) = timeout(
1396 Duration::from_millis(100),
1397 client.subscribe(
1398 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1399 SubscriptionOptions::new().with_compression(false),
1400 ),
1401 )
1402 .await
1403 .expect("subscription timed out")
1404 .expect("subscription failed");
1405 let res = timeout(Duration::from_millis(100), rx.recv())
1406 .await
1407 .expect("awaiting message timeout out");
1408
1409 assert!(res.is_none());
1411
1412 timeout(Duration::from_millis(100), client.close())
1413 .await
1414 .expect("close timed out")
1415 .expect("close failed");
1416 jh.await
1417 .expect("ws loop errored")
1418 .unwrap();
1419 server_thread.await.unwrap();
1420 }
1421
1422 #[test_log::test(tokio::test)]
1423 async fn test_reconnect() {
1424 let exp_comm = [
1425 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe()
1426 )),
1427 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1428 subscription_confirmation()
1429 )),
1430 ExpectedComm::Send(tungstenite::protocol::Message::Text(r#"
1431 {
1432 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1433 "deltas": {
1434 "extractor": "vm:ambient",
1435 "chain": "ethereum",
1436 "block": {
1437 "number": 123,
1438 "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1439 "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1440 "chain": "ethereum",
1441 "ts": "2023-09-14T00:00:00"
1442 },
1443 "finalized_block_height": 0,
1444 "revert": false,
1445 "new_tokens": {},
1446 "account_updates": {
1447 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1448 "address": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1449 "chain": "ethereum",
1450 "slots": {},
1451 "balance": "0x01f4",
1452 "code": "",
1453 "change": "Update"
1454 }
1455 },
1456 "state_updates": {
1457 "component_1": {
1458 "component_id": "component_1",
1459 "updated_attributes": {"attr1": "0x01"},
1460 "deleted_attributes": ["attr2"]
1461 }
1462 },
1463 "new_protocol_components": {
1464 "protocol_1":
1465 {
1466 "id": "protocol_1",
1467 "protocol_system": "system_1",
1468 "protocol_type_name": "type_1",
1469 "chain": "ethereum",
1470 "tokens": ["0x01", "0x02"],
1471 "contract_ids": ["0x01", "0x02"],
1472 "static_attributes": {"attr1": "0x01f4"},
1473 "change": "Update",
1474 "creation_tx": "0x01",
1475 "created_at": "2023-09-14T00:00:00"
1476 }
1477 },
1478 "deleted_protocol_components": {},
1479 "component_balances": {
1480 "protocol_1": {
1481 "0x01": {
1482 "token": "0x01",
1483 "balance": "0x01f4",
1484 "balance_float": 1000.0,
1485 "modify_tx": "0x01",
1486 "component_id": "protocol_1"
1487 }
1488 }
1489 },
1490 "account_balances": {
1491 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1492 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1493 "account": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1494 "token": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1495 "balance": "0x01f4",
1496 "modify_tx": "0x01"
1497 }
1498 }
1499 },
1500 "component_tvl": {
1501 "protocol_1": 1000.0
1502 },
1503 "dci_update": {
1504 "new_entrypoints": {},
1505 "new_entrypoint_params": {},
1506 "trace_results": {}
1507 }
1508 }
1509 }
1510 "#.to_owned()
1511 ))
1512 ];
1513 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 1).await;
1514 let client = WsDeltasClient::new_with_reconnects(
1515 &format!("ws://{addr}"),
1516 None,
1517 3,
1518 Duration::from_millis(110),
1520 )
1521 .unwrap();
1522
1523 let jh: JoinHandle<Result<(), DeltasError>> = client
1524 .connect()
1525 .await
1526 .expect("connect failed");
1527
1528 for _ in 0..2 {
1529 dbg!("loop");
1530 let (_, mut rx) = timeout(
1531 Duration::from_millis(200),
1532 client.subscribe(
1533 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1534 SubscriptionOptions::new().with_compression(false),
1535 ),
1536 )
1537 .await
1538 .expect("subscription timed out")
1539 .expect("subscription failed");
1540
1541 let _ = timeout(Duration::from_millis(100), rx.recv())
1542 .await
1543 .expect("awaiting message timeout out")
1544 .expect("receiving message failed");
1545
1546 let res = timeout(Duration::from_millis(200), rx.recv())
1548 .await
1549 .expect("awaiting closed connection timeout out");
1550 assert!(res.is_none());
1551 }
1552 let res = jh.await.expect("ws client join failed");
1553 assert!(res.is_err());
1555 server_thread
1556 .await
1557 .expect("ws server loop errored");
1558 }
1559
1560 async fn mock_bad_connection_tycho_ws(accept_first: bool) -> (SocketAddr, JoinHandle<()>) {
1561 let server = TcpListener::bind("127.0.0.1:0")
1562 .await
1563 .expect("localhost bind failed");
1564 let addr = server.local_addr().unwrap();
1565 let jh = tokio::spawn(async move {
1566 while let Ok((stream, _)) = server.accept().await {
1567 if accept_first {
1568 let stream = tokio_tungstenite::accept_async(stream)
1570 .await
1571 .unwrap();
1572 sleep(Duration::from_millis(10)).await;
1573 drop(stream)
1574 } else {
1575 drop(stream);
1577 }
1578 }
1579 });
1580 (addr, jh)
1581 }
1582
1583 #[test_log::test(tokio::test)]
1584 async fn test_subscribe_dead_client_after_max_attempts() {
1585 let (addr, _) = mock_bad_connection_tycho_ws(true).await;
1586 let client = WsDeltasClient::new_with_reconnects(
1587 &format!("ws://{addr}"),
1588 None,
1589 3,
1590 Duration::from_secs(0),
1591 )
1592 .unwrap();
1593
1594 let join_handle = client.connect().await.unwrap();
1595 let handle_res = join_handle.await.unwrap();
1596 assert!(handle_res.is_err());
1597 assert!(!client.is_connected().await);
1598
1599 let subscription_res = timeout(
1600 Duration::from_millis(10),
1601 client.subscribe(
1602 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1603 SubscriptionOptions::new(),
1604 ),
1605 )
1606 .await
1607 .unwrap();
1608 assert!(subscription_res.is_err());
1609 }
1610
1611 #[test_log::test(tokio::test)]
1612 async fn test_ws_client_retry_cooldown() {
1613 let start = std::time::Instant::now();
1614 let (addr, _) = mock_bad_connection_tycho_ws(false).await;
1615
1616 let client = WsDeltasClient::new_with_reconnects(
1618 &format!("ws://{addr}"),
1619 None,
1620 3, Duration::from_millis(50), )
1623 .unwrap();
1624
1625 let connect_result = client.connect().await;
1627 let elapsed = start.elapsed();
1628
1629 assert!(connect_result.is_err(), "Expected connection to fail after retries");
1631
1632 assert!(
1634 elapsed >= Duration::from_millis(100),
1635 "Expected at least 100ms elapsed, got {:?}",
1636 elapsed
1637 );
1638
1639 assert!(elapsed < Duration::from_millis(500), "Took too long: {:?}", elapsed);
1641 }
1642
1643 #[test_log::test(tokio::test)]
1644 async fn test_buffer_full_triggers_unsubscribe() {
1645 let exp_comm = {
1647 [
1648 ExpectedComm::Receive(
1650 100,
1651 tungstenite::protocol::Message::Text(
1652 subscribe(),
1653 ),
1654 ),
1655 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1657 subscription_confirmation(),
1658 )),
1659 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1661 r#"
1662 {
1663 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1664 "deltas": {
1665 "extractor": "vm:ambient",
1666 "chain": "ethereum",
1667 "block": {
1668 "number": 123,
1669 "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1670 "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1671 "chain": "ethereum",
1672 "ts": "2023-09-14T00:00:00"
1673 },
1674 "finalized_block_height": 0,
1675 "revert": false,
1676 "new_tokens": {},
1677 "account_updates": {},
1678 "state_updates": {},
1679 "new_protocol_components": {},
1680 "deleted_protocol_components": {},
1681 "component_balances": {},
1682 "account_balances": {},
1683 "component_tvl": {},
1684 "dci_update": {
1685 "new_entrypoints": {},
1686 "new_entrypoint_params": {},
1687 "trace_results": {}
1688 }
1689 }
1690 }
1691 "#.to_owned()
1692 )),
1693 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1695 r#"
1696 {
1697 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1698 "deltas": {
1699 "extractor": "vm:ambient",
1700 "chain": "ethereum",
1701 "block": {
1702 "number": 124,
1703 "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1704 "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1705 "chain": "ethereum",
1706 "ts": "2023-09-14T00:00:01"
1707 },
1708 "finalized_block_height": 0,
1709 "revert": false,
1710 "new_tokens": {},
1711 "account_updates": {},
1712 "state_updates": {},
1713 "new_protocol_components": {},
1714 "deleted_protocol_components": {},
1715 "component_balances": {},
1716 "account_balances": {},
1717 "component_tvl": {},
1718 "dci_update": {
1719 "new_entrypoints": {},
1720 "new_entrypoint_params": {},
1721 "trace_results": {}
1722 }
1723 }
1724 }
1725 "#.to_owned()
1726 )),
1727 ExpectedComm::Receive(
1729 100,
1730 tungstenite::protocol::Message::Text(
1731 unsubscribe(),
1732 ),
1733 ),
1734 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1736 subscription_ended(),
1737 )),
1738 ]
1739 };
1740
1741 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1742
1743 let client = WsDeltasClient::new_with_custom_buffers(
1745 &format!("ws://{addr}"),
1746 None,
1747 128, 1, )
1750 .unwrap();
1751
1752 let jh = client
1753 .connect()
1754 .await
1755 .expect("connect failed");
1756
1757 let (_sub_id, mut rx) = timeout(
1758 Duration::from_millis(100),
1759 client.subscribe(
1760 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1761 SubscriptionOptions::new().with_compression(false),
1762 ),
1763 )
1764 .await
1765 .expect("subscription timed out")
1766 .expect("subscription failed");
1767
1768 tokio::time::sleep(Duration::from_millis(100)).await;
1770
1771 let mut received_msgs = Vec::new();
1773
1774 while received_msgs.len() < 3 {
1776 match timeout(Duration::from_millis(200), rx.recv()).await {
1777 Ok(Some(msg)) => {
1778 received_msgs.push(msg);
1779 }
1780 Ok(None) => {
1781 break;
1783 }
1784 Err(_) => {
1785 break;
1787 }
1788 }
1789 }
1790
1791 assert!(
1793 received_msgs.len() <= 1,
1794 "Expected buffer overflow to limit messages to at most 1, got {}",
1795 received_msgs.len()
1796 );
1797
1798 if let Some(first_msg) = received_msgs.first() {
1799 assert_eq!(first_msg.block.number, 123, "Expected first message with block 123");
1800 }
1801
1802 drop(rx); tokio::time::sleep(Duration::from_millis(50)).await;
1809
1810 jh.abort();
1812 server_thread.abort();
1813
1814 let _ = jh.await;
1815 let _ = server_thread.await;
1816 }
1817
1818 #[tokio::test]
1819 async fn test_server_error_handling() {
1820 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
1821
1822 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
1823
1824 let error_response = WebSocketMessage::Response(Response::Error(
1826 WebsocketError::ExtractorNotFound(extractor_id.clone().into()),
1827 ));
1828 let error_json = serde_json::to_string(&error_response).unwrap();
1829
1830 let exp_comm = [
1831 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1832 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
1833 ];
1834
1835 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1836
1837 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1838 let jh = client
1839 .connect()
1840 .await
1841 .expect("connect failed");
1842
1843 let result = timeout(
1844 Duration::from_millis(100),
1845 client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
1846 )
1847 .await
1848 .expect("subscription timed out");
1849
1850 assert!(result.is_err());
1852 if let Err(DeltasError::ServerError(msg)) = result {
1853 assert!(msg.contains("Subscription failed"));
1854 assert!(msg.contains("Extractor not found"));
1855 } else {
1856 panic!("Expected DeltasError::ServerError, got: {:?}", result);
1857 }
1858
1859 timeout(Duration::from_millis(100), client.close())
1860 .await
1861 .expect("close timed out")
1862 .expect("close failed");
1863 jh.await
1864 .expect("ws loop errored")
1865 .unwrap();
1866 server_thread.await.unwrap();
1867 }
1868
1869 #[test_log::test(tokio::test)]
1870 async fn test_subscription_not_found_error() {
1871 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
1873
1874 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
1875 let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
1876
1877 let error_response = WebSocketMessage::Response(Response::Error(
1878 WebsocketError::SubscriptionNotFound(subscription_id),
1879 ));
1880 let error_json = serde_json::to_string(&error_response).unwrap();
1881
1882 let exp_comm = [
1883 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1885 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1886 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
1888 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
1890 ];
1891
1892 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1893
1894 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1895 let jh = client
1896 .connect()
1897 .await
1898 .expect("connect failed");
1899
1900 let (received_sub_id, _rx) = timeout(
1902 Duration::from_millis(100),
1903 client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
1904 )
1905 .await
1906 .expect("subscription timed out")
1907 .expect("subscription failed");
1908
1909 assert_eq!(received_sub_id, subscription_id);
1910
1911 let unsubscribe_result =
1913 timeout(Duration::from_millis(100), client.unsubscribe(subscription_id))
1914 .await
1915 .expect("unsubscribe timed out");
1916
1917 unsubscribe_result
1921 .expect("Unsubscribe should succeed even if server says subscription not found");
1922
1923 timeout(Duration::from_millis(100), client.close())
1924 .await
1925 .expect("close timed out")
1926 .expect("close failed");
1927 jh.await
1928 .expect("ws loop errored")
1929 .unwrap();
1930 server_thread.await.unwrap();
1931 }
1932
1933 #[test_log::test(tokio::test)]
1934 async fn test_parse_error_handling() {
1935 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
1936
1937 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
1938 let error_response = WebSocketMessage::Response(Response::Error(
1939 WebsocketError::ParseError("}2sdf".to_string(), "malformed JSON".to_string()),
1940 ));
1941 let error_json = serde_json::to_string(&error_response).unwrap();
1942
1943 let exp_comm = [
1944 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1946 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
1947 ];
1948
1949 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1950
1951 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1952 let jh = client
1953 .connect()
1954 .await
1955 .expect("connect failed");
1956
1957 let _ = timeout(
1959 Duration::from_millis(100),
1960 client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
1961 )
1962 .await
1963 .expect("subscription timed out");
1964
1965 let result = jh
1967 .await
1968 .expect("ws loop should complete");
1969 assert!(result.is_err());
1970 if let Err(DeltasError::ServerError(message)) = result {
1971 assert!(message.contains("Server failed to parse client message"));
1972 } else {
1973 panic!("Expected DeltasError::ServerError, got: {:?}", result);
1974 }
1975
1976 server_thread.await.unwrap();
1977 }
1978
1979 #[test_log::test(tokio::test)]
1980 async fn test_compression_error_handling() {
1981 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
1982
1983 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
1984 let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
1985 let error_response = WebSocketMessage::Response(Response::Error(
1986 WebsocketError::CompressionError(subscription_id, "Compression failed".to_string()),
1987 ));
1988 let error_json = serde_json::to_string(&error_response).unwrap();
1989
1990 let exp_comm = [
1991 ExpectedComm::Receive(
1993 100,
1994 tungstenite::protocol::Message::Text(subscribe_with_compression(true)),
1995 ),
1996 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
1997 ];
1998
1999 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2000
2001 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2002 let jh = client
2003 .connect()
2004 .await
2005 .expect("connect failed");
2006
2007 let _ = timeout(
2009 Duration::from_millis(100),
2010 client.subscribe(extractor_id, SubscriptionOptions::new()),
2011 )
2012 .await
2013 .expect("subscription timed out");
2014
2015 let result = jh
2017 .await
2018 .expect("ws loop should complete");
2019 assert!(result.is_err());
2020 if let Err(DeltasError::ServerError(message)) = result {
2021 assert!(message.contains("Server failed to compress message for subscription"));
2022 } else {
2023 panic!("Expected DeltasError::ServerError, got: {:?}", result);
2024 }
2025
2026 server_thread.await.unwrap();
2027 }
2028
2029 #[tokio::test]
2030 async fn test_subscribe_error_handling() {
2031 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2032
2033 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2034
2035 let error_response = WebSocketMessage::Response(Response::Error(
2036 WebsocketError::SubscribeError(extractor_id.clone().into()),
2037 ));
2038 let error_json = serde_json::to_string(&error_response).unwrap();
2039
2040 let exp_comm = [
2041 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2042 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2043 ];
2044
2045 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2046
2047 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2048 let jh = client
2049 .connect()
2050 .await
2051 .expect("connect failed");
2052
2053 let result = timeout(
2054 Duration::from_millis(100),
2055 client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
2056 )
2057 .await
2058 .expect("subscription timed out");
2059
2060 assert!(result.is_err());
2062 if let Err(DeltasError::ServerError(msg)) = result {
2063 assert!(msg.contains("Subscription failed"));
2064 assert!(msg.contains("Failed to subscribe to extractor"));
2065 } else {
2066 panic!("Expected DeltasError::ServerError, got: {:?}", result);
2067 }
2068
2069 timeout(Duration::from_millis(100), client.close())
2070 .await
2071 .expect("close timed out")
2072 .expect("close failed");
2073 jh.await
2074 .expect("ws loop errored")
2075 .unwrap();
2076 server_thread.await.unwrap();
2077 }
2078
2079 #[tokio::test]
2080 async fn test_cancel_pending_subscription() {
2081 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2083
2084 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2085
2086 let error_response = WebSocketMessage::Response(Response::Error(
2087 WebsocketError::ExtractorNotFound(extractor_id.clone().into()),
2088 ));
2089 let error_json = serde_json::to_string(&error_response).unwrap();
2090
2091 let exp_comm = [
2092 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2093 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2094 ];
2095
2096 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2097
2098 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2099 let jh = client
2100 .connect()
2101 .await
2102 .expect("connect failed");
2103
2104 let client_clone = client.clone();
2106 let extractor_id_clone = extractor_id.clone();
2107
2108 let subscription1 = tokio::spawn({
2109 let client_for_spawn = client.clone();
2110 async move {
2111 client_for_spawn
2112 .subscribe(extractor_id, SubscriptionOptions::new().with_compression(false))
2113 .await
2114 }
2115 });
2116
2117 let subscription2 = tokio::spawn(async move {
2118 client_clone
2120 .subscribe(extractor_id_clone, SubscriptionOptions::new())
2121 .await
2122 });
2123
2124 let (result1, result2) = tokio::join!(subscription1, subscription2);
2125
2126 let result1 = result1.unwrap();
2127 let result2 = result2.unwrap();
2128
2129 assert!(result1.is_err() || result2.is_err());
2132
2133 if let Err(DeltasError::SubscriptionAlreadyPending) = result2 {
2134 } else if let Err(DeltasError::ServerError(_)) = result1 {
2136 } else {
2138 panic!("Expected one SubscriptionAlreadyPending and one ServerError");
2139 }
2140
2141 timeout(Duration::from_millis(100), client.close())
2142 .await
2143 .expect("close timed out")
2144 .expect("close failed");
2145 jh.await
2146 .expect("ws loop errored")
2147 .unwrap();
2148 server_thread.await.unwrap();
2149 }
2150
2151 #[tokio::test]
2152 async fn test_force_unsubscribe_prevents_multiple_calls() {
2153 let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
2157
2158 let exp_comm = [
2159 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2160 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
2161 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
2163 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
2164 ];
2165
2166 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2167
2168 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2169 let jh = client
2170 .connect()
2171 .await
2172 .expect("connect failed");
2173
2174 let (received_sub_id, _rx) = timeout(
2175 Duration::from_millis(100),
2176 client.subscribe(
2177 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
2178 SubscriptionOptions::new().with_compression(false),
2179 ),
2180 )
2181 .await
2182 .expect("subscription timed out")
2183 .expect("subscription failed");
2184
2185 assert_eq!(received_sub_id, subscription_id);
2186
2187 {
2189 let mut inner_guard = client.inner.lock().await;
2190 let inner = inner_guard
2191 .as_mut()
2192 .expect("client should be connected");
2193
2194 WsDeltasClient::force_unsubscribe(subscription_id, inner).await;
2196 WsDeltasClient::force_unsubscribe(subscription_id, inner).await;
2197 }
2198
2199 tokio::time::sleep(Duration::from_millis(50)).await;
2201
2202 let _ = timeout(Duration::from_millis(100), client.close()).await;
2204
2205 let _ = jh.await;
2207 let _ = server_thread.await;
2208 }
2209}