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