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