1use std::{
23 collections::{hash_map::Entry, HashMap},
24 sync::{
25 atomic::{AtomicBool, Ordering},
26 Arc,
27 },
28 time::{Duration, Instant},
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 let mut last_attempt = Instant::now();
895
896 'retry: while retry_count < this.max_reconnects {
897 info!(?ws_uri, retry_count, "Connecting to WebSocket server");
898 if retry_count > 0 {
901 sleep(
902 this.retry_cooldown
903 .saturating_sub(last_attempt.elapsed()),
904 )
905 .await;
906 }
907 last_attempt = Instant::now();
908
909 let request = build_ws_handshake_request(
910 &ws_uri,
911 &this.uri,
912 this.auth_key.as_deref(),
913 this.client_metadata_header.as_deref(),
914 )?;
915 let (conn, _) = match connect_async(request).await {
916 Ok(conn) => conn,
917 Err(e) => {
918 retry_count += 1;
920 let mut guard = this.inner.as_ref().lock().await;
921 *guard = None;
922
923 if let tungstenite::Error::Http(response) = &e {
924 if response.status() == tungstenite::http::StatusCode::TOO_MANY_REQUESTS
925 {
926 let reason = response
927 .body()
928 .as_deref()
929 .and_then(|b| std::str::from_utf8(b).ok())
930 .unwrap_or("")
931 .to_string();
932 warn!(reason, "WebSocket connection rejected: rate limited");
933 continue 'retry;
934 }
935 }
936
937 warn!(
938 e = e.to_string(),
939 "Failed to connect to WebSocket server; Reconnecting"
940 );
941 continue 'retry;
942 }
943 };
944
945 let (ws_tx_new, ws_rx_new) = conn.split();
946 {
947 let mut guard = this.inner.as_ref().lock().await;
948 *guard =
949 Some(Inner::new(cmd_tx.clone(), ws_tx_new, this.subscription_buffer_size));
950 }
951 let mut msg_rx = ws_rx_new.boxed();
952
953 info!("Connection Successful: TychoWebsocketClient started");
954 this.conn_notify.notify_waiters();
955 result = Ok(());
956
957 const IDLE_TIMEOUT: Duration = Duration::from_secs(60);
962 loop {
963 let res = tokio::select! {
964 msg_result = tokio::time::timeout(IDLE_TIMEOUT, msg_rx.next()) => {
965 match msg_result {
966 Err(_elapsed) => {
967 warn!("No WS frame received for {IDLE_TIMEOUT:?}, \
968 treating connection as stalled; Reconnecting...");
969 retry_count += 1;
970 let mut guard = this.inner.as_ref().lock().await;
971 *guard = None;
972 break; }
974 Ok(Some(msg)) => this.handle_msg(msg).await,
975 Ok(None) => {
976 warn!("Websocket connection silently closed, giving up!");
980 break 'retry
981 }
982 }
983 },
984 _ = cmd_rx.recv() => {break 'retry},
985 };
986 if let Err(error) = res {
987 debug!(?error, "WsError");
988 if matches!(
989 error,
990 DeltasError::ConnectionClosed | DeltasError::ConnectionError { .. }
991 ) {
992 retry_count += 1;
994 let mut guard = this.inner.as_ref().lock().await;
995 *guard = None;
996
997 warn!(
998 ?error,
999 ?retry_count,
1000 "Connection dropped unexpectedly; Reconnecting..."
1001 );
1002 break;
1003 } else {
1004 error!(?error, "Fatal error; Exiting");
1006 result = Err(error);
1007 break 'retry;
1008 }
1009 }
1010 }
1011 }
1012 debug!(
1013 retry_count,
1014 max_reconnects=?this.max_reconnects,
1015 "Reconnection loop ended"
1016 );
1017 let mut guard = this.inner.as_ref().lock().await;
1019 *guard = None;
1020
1021 if retry_count >= this.max_reconnects {
1023 error!("Max reconnection attempts reached; Exiting");
1024 this.dead.store(true, Ordering::SeqCst);
1025 this.conn_notify.notify_waiters(); result = Err(DeltasError::ConnectionClosed);
1027 }
1028
1029 result
1030 });
1031
1032 self.conn_notify.notified().await;
1033
1034 if self.is_connected().await {
1035 Ok(jh)
1036 } else {
1037 Err(DeltasError::NotConnected)
1038 }
1039 }
1040
1041 #[instrument(skip(self))]
1042 async fn close(&self) -> Result<(), DeltasError> {
1043 info!("Closing TychoWebsocketClient");
1044 {
1045 let mut guard = self.inner.lock().await;
1046 if let Some(inner) = guard.as_mut() {
1047 inner
1048 .cmd_tx
1049 .send(())
1050 .await
1051 .map_err(|e| DeltasError::TransportError(e.to_string()))?;
1052 }
1053 }
1054 self.dead.store(true, Ordering::SeqCst);
1057 self.conn_notify.notify_waiters();
1058 Ok(())
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use std::{net::SocketAddr, str::FromStr};
1065
1066 use tokio::{net::TcpListener, time::timeout};
1067 use tycho_common::models::Chain;
1068
1069 use super::*;
1070 use crate::stream::TychoStreamBuilder;
1071
1072 #[derive(Clone)]
1073 enum ExpectedComm {
1074 Receive(u64, tungstenite::protocol::Message),
1075 Send(tungstenite::protocol::Message),
1076 }
1077
1078 async fn mock_tycho_ws(
1079 messages: &[ExpectedComm],
1080 reconnects: usize,
1081 ) -> (SocketAddr, JoinHandle<()>) {
1082 info!("Starting mock webserver");
1083 let server = TcpListener::bind("127.0.0.1:0")
1085 .await
1086 .expect("localhost bind failed");
1087 let addr = server.local_addr().unwrap();
1088 let messages = messages.to_vec();
1089
1090 let jh = tokio::spawn(async move {
1091 info!("mock webserver started");
1092 for _ in 0..(reconnects + 1) {
1093 info!("Awaiting client connections");
1094 if let Ok((stream, _)) = server.accept().await {
1095 info!("Client connected");
1096 let mut websocket = tokio_tungstenite::accept_async(stream)
1097 .await
1098 .unwrap();
1099
1100 info!("Handling messages..");
1101 for c in messages.iter().cloned() {
1102 match c {
1103 ExpectedComm::Receive(t, exp) => {
1104 info!("Awaiting message...");
1105 let msg = timeout(Duration::from_millis(t), websocket.next())
1106 .await
1107 .expect("Receive timeout")
1108 .expect("Stream exhausted")
1109 .expect("Failed to receive message.");
1110 info!("Message received");
1111 assert_eq!(msg, exp)
1112 }
1113 ExpectedComm::Send(data) => {
1114 info!("Sending message");
1115 websocket
1116 .send(data)
1117 .await
1118 .expect("Failed to send message");
1119 info!("Message sent");
1120 }
1121 };
1122 }
1123 info!("Mock communication completed");
1124 sleep(Duration::from_millis(100)).await;
1125 let _ = websocket.close(None).await;
1127 info!("Mock server closed connection");
1128 }
1129 }
1130 info!("mock server ended");
1131 });
1132 (addr, jh)
1133 }
1134
1135 const SUBSCRIPTION_ID: &str = "30b740d1-cf09-4e0e-8cfe-b1434d447ece";
1136
1137 fn subscribe() -> String {
1138 subscribe_with_compression(false)
1139 }
1140
1141 fn subscribe_with_compression(compression: bool) -> String {
1142 format!(
1144 r#"{{"method":"subscribe","extractor_id":{{"chain":"ethereum","name":"vm:ambient"}},"include_state":true,"compression":{compression},"partial_blocks":false}}"#
1145 )
1146 }
1147
1148 fn subscription_confirmation() -> String {
1149 r#"
1150 {
1151 "method": "newsubscription",
1152 "extractor_id":{
1153 "chain": "ethereum",
1154 "name": "vm:ambient"
1155 },
1156 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1157 }
1158 "#
1159 .replace(|c: char| c.is_whitespace(), "")
1160 }
1161
1162 fn block_deltas() -> String {
1163 r#"
1164 {
1165 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1166 "deltas": {
1167 "extractor": "vm:ambient",
1168 "chain": "ethereum",
1169 "block": {
1170 "number": 123,
1171 "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1172 "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1173 "chain": "ethereum",
1174 "ts": "2023-09-14T00:00:00"
1175 },
1176 "finalized_block_height": 0,
1177 "revert": false,
1178 "new_tokens": {},
1179 "account_updates": {
1180 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1181 "address": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1182 "chain": "ethereum",
1183 "slots": {},
1184 "balance": "0x01f4",
1185 "code": "",
1186 "change": "Update"
1187 }
1188 },
1189 "state_updates": {
1190 "component_1": {
1191 "component_id": "component_1",
1192 "updated_attributes": {"attr1": "0x01"},
1193 "deleted_attributes": ["attr2"]
1194 }
1195 },
1196 "new_protocol_components":
1197 { "protocol_1": {
1198 "id": "protocol_1",
1199 "protocol_system": "system_1",
1200 "protocol_type_name": "type_1",
1201 "chain": "ethereum",
1202 "tokens": ["0x01", "0x02"],
1203 "contract_ids": ["0x01", "0x02"],
1204 "static_attributes": {"attr1": "0x01f4"},
1205 "change": "Update",
1206 "creation_tx": "0x01",
1207 "created_at": "2023-09-14T00:00:00"
1208 }
1209 },
1210 "deleted_protocol_components": {},
1211 "component_balances": {
1212 "protocol_1":
1213 {
1214 "0x01": {
1215 "token": "0x01",
1216 "balance": "0x01f4",
1217 "balance_float": 0.0,
1218 "modify_tx": "0x01",
1219 "component_id": "protocol_1"
1220 }
1221 }
1222 },
1223 "account_balances": {
1224 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1225 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1226 "account": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1227 "token": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1228 "balance": "0x01f4",
1229 "modify_tx": "0x01"
1230 }
1231 }
1232 },
1233 "component_tvl": {
1234 "protocol_1": 1000.0
1235 },
1236 "dci_update": {
1237 "new_entrypoints": {},
1238 "new_entrypoint_params": {},
1239 "trace_results": {}
1240 }
1241 }
1242 }
1243 "#.replace(|c: char| c.is_whitespace(), "")
1244 }
1245
1246 fn unsubscribe() -> String {
1247 r#"
1248 {
1249 "method": "unsubscribe",
1250 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1251 }
1252 "#
1253 .replace(|c: char| c.is_whitespace(), "")
1254 }
1255
1256 fn subscription_ended() -> String {
1257 r#"
1258 {
1259 "method": "subscriptionended",
1260 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1261 }
1262 "#
1263 .replace(|c: char| c.is_whitespace(), "")
1264 }
1265
1266 #[test]
1267 fn test_ws_handshake_sends_client_metadata_when_set() {
1268 let uri = Uri::from_str("ws://localhost:4242/").unwrap();
1269 let request = build_ws_handshake_request(
1270 "ws://localhost:4242/v1/ws",
1271 &uri,
1272 None,
1273 Some("fynd_version=0.57.0"),
1274 )
1275 .unwrap();
1276 assert_eq!(
1277 request
1278 .headers()
1279 .get(CLIENT_METADATA_HEADER)
1280 .map(|v| v.to_str().unwrap()),
1281 Some("fynd_version=0.57.0")
1282 );
1283 assert_eq!(
1284 request
1285 .headers()
1286 .get(USER_AGENT)
1287 .unwrap(),
1288 format!("tycho-client-{}", env!("CARGO_PKG_VERSION")).as_str()
1289 );
1290 }
1291
1292 #[test]
1293 fn test_ws_handshake_omits_client_metadata_when_unset() {
1294 let uri = Uri::from_str("ws://localhost:4242/").unwrap();
1295 let request =
1296 build_ws_handshake_request("ws://localhost:4242/v1/ws", &uri, None, None).unwrap();
1297 assert!(request
1298 .headers()
1299 .get(CLIENT_METADATA_HEADER)
1300 .is_none());
1301 assert_eq!(
1302 request
1303 .headers()
1304 .get(USER_AGENT)
1305 .unwrap(),
1306 format!("tycho-client-{}", env!("CARGO_PKG_VERSION")).as_str()
1307 );
1308 }
1309
1310 #[test]
1311 fn test_new_with_reconnects_preserves_default_subscription_buffer_size() {
1312 let client = WsDeltasClient::new_with_reconnects(
1313 "ws://localhost:4242",
1314 None,
1315 3,
1316 Duration::from_secs(1),
1317 )
1318 .expect("a valid websocket URI should construct a client");
1319
1320 assert_eq!(client.subscription_buffer_size, DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE);
1321 }
1322
1323 #[tokio::test]
1324 async fn test_uncompressed_subscribe_receive() {
1325 let exp_comm = [
1326 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1327 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1328 ExpectedComm::Send(tungstenite::protocol::Message::Text(block_deltas())),
1329 ];
1330 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1331
1332 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1333 let jh = client
1334 .connect()
1335 .await
1336 .expect("connect failed");
1337 let (_, mut rx) = timeout(
1338 Duration::from_millis(100),
1339 client.subscribe(
1340 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1341 SubscriptionOptions::new().with_compression(false),
1342 ),
1343 )
1344 .await
1345 .expect("subscription timed out")
1346 .expect("subscription failed");
1347 let _ = timeout(Duration::from_millis(100), rx.recv())
1348 .await
1349 .expect("awaiting message timeout out")
1350 .expect("receiving message failed");
1351 timeout(Duration::from_millis(100), client.close())
1352 .await
1353 .expect("close timed out")
1354 .expect("close failed");
1355 jh.await
1356 .expect("ws loop errored")
1357 .unwrap();
1358 server_thread.await.unwrap();
1359 }
1360
1361 #[tokio::test]
1362 async fn test_compressed_subscribe_receive() {
1363 let compressed_block_deltas = zstd::encode_all(
1364 block_deltas().as_bytes(),
1365 0, )
1367 .expect("Failed to compress block deltas message");
1368
1369 let exp_comm = [
1370 ExpectedComm::Receive(
1371 100,
1372 tungstenite::protocol::Message::Text(subscribe_with_compression(true)),
1373 ),
1374 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1375 ExpectedComm::Send(tungstenite::protocol::Message::Binary(compressed_block_deltas)),
1376 ];
1377 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1378
1379 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1380 let jh = client
1381 .connect()
1382 .await
1383 .expect("connect failed");
1384 let (_, mut rx) = timeout(
1385 Duration::from_millis(100),
1386 client.subscribe(
1387 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1388 SubscriptionOptions::new().with_compression(true),
1389 ),
1390 )
1391 .await
1392 .expect("subscription timed out")
1393 .expect("subscription failed");
1394 let _ = timeout(Duration::from_millis(100), rx.recv())
1395 .await
1396 .expect("awaiting message timeout out")
1397 .expect("receiving message failed");
1398 timeout(Duration::from_millis(100), client.close())
1399 .await
1400 .expect("close timed out")
1401 .expect("close failed");
1402 jh.await
1403 .expect("ws loop errored")
1404 .unwrap();
1405 server_thread.await.unwrap();
1406 }
1407
1408 #[tokio::test]
1409 async fn test_unsubscribe() {
1410 let exp_comm = [
1411 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1412 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1413 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
1414 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
1415 ];
1416 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1417
1418 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1419 let jh = client
1420 .connect()
1421 .await
1422 .expect("connect failed");
1423 let (sub_id, mut rx) = timeout(
1424 Duration::from_millis(100),
1425 client.subscribe(
1426 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1427 SubscriptionOptions::new().with_compression(false),
1428 ),
1429 )
1430 .await
1431 .expect("subscription timed out")
1432 .expect("subscription failed");
1433
1434 timeout(Duration::from_millis(100), client.unsubscribe(sub_id))
1435 .await
1436 .expect("unsubscribe timed out")
1437 .expect("unsubscribe failed");
1438 let res = timeout(Duration::from_millis(100), rx.recv())
1439 .await
1440 .expect("awaiting message timeout out");
1441
1442 assert!(res.is_none());
1444
1445 timeout(Duration::from_millis(100), client.close())
1446 .await
1447 .expect("close timed out")
1448 .expect("close failed");
1449 jh.await
1450 .expect("ws loop errored")
1451 .unwrap();
1452 server_thread.await.unwrap();
1453 }
1454
1455 #[tokio::test]
1456 async fn test_subscription_unexpected_end() {
1457 let exp_comm = [
1458 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1459 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1460 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
1461 ];
1462 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1463
1464 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1465 let jh = client
1466 .connect()
1467 .await
1468 .expect("connect failed");
1469 let (_, mut rx) = timeout(
1470 Duration::from_millis(100),
1471 client.subscribe(
1472 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1473 SubscriptionOptions::new().with_compression(false),
1474 ),
1475 )
1476 .await
1477 .expect("subscription timed out")
1478 .expect("subscription failed");
1479 let res = timeout(Duration::from_millis(100), rx.recv())
1480 .await
1481 .expect("awaiting message timeout out");
1482
1483 assert!(res.is_none());
1485
1486 timeout(Duration::from_millis(100), client.close())
1487 .await
1488 .expect("close timed out")
1489 .expect("close failed");
1490 jh.await
1491 .expect("ws loop errored")
1492 .unwrap();
1493 server_thread.await.unwrap();
1494 }
1495
1496 #[test_log::test(tokio::test)]
1497 async fn test_reconnect() {
1498 let exp_comm = [
1499 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe()
1500 )),
1501 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1502 subscription_confirmation()
1503 )),
1504 ExpectedComm::Send(tungstenite::protocol::Message::Text(r#"
1505 {
1506 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1507 "deltas": {
1508 "extractor": "vm:ambient",
1509 "chain": "ethereum",
1510 "block": {
1511 "number": 123,
1512 "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1513 "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1514 "chain": "ethereum",
1515 "ts": "2023-09-14T00:00:00"
1516 },
1517 "finalized_block_height": 0,
1518 "revert": false,
1519 "new_tokens": {},
1520 "account_updates": {
1521 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1522 "address": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1523 "chain": "ethereum",
1524 "slots": {},
1525 "balance": "0x01f4",
1526 "code": "",
1527 "change": "Update"
1528 }
1529 },
1530 "state_updates": {
1531 "component_1": {
1532 "component_id": "component_1",
1533 "updated_attributes": {"attr1": "0x01"},
1534 "deleted_attributes": ["attr2"]
1535 }
1536 },
1537 "new_protocol_components": {
1538 "protocol_1":
1539 {
1540 "id": "protocol_1",
1541 "protocol_system": "system_1",
1542 "protocol_type_name": "type_1",
1543 "chain": "ethereum",
1544 "tokens": ["0x01", "0x02"],
1545 "contract_ids": ["0x01", "0x02"],
1546 "static_attributes": {"attr1": "0x01f4"},
1547 "change": "Update",
1548 "creation_tx": "0x01",
1549 "created_at": "2023-09-14T00:00:00"
1550 }
1551 },
1552 "deleted_protocol_components": {},
1553 "component_balances": {
1554 "protocol_1": {
1555 "0x01": {
1556 "token": "0x01",
1557 "balance": "0x01f4",
1558 "balance_float": 1000.0,
1559 "modify_tx": "0x01",
1560 "component_id": "protocol_1"
1561 }
1562 }
1563 },
1564 "account_balances": {
1565 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1566 "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1567 "account": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1568 "token": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1569 "balance": "0x01f4",
1570 "modify_tx": "0x01"
1571 }
1572 }
1573 },
1574 "component_tvl": {
1575 "protocol_1": 1000.0
1576 },
1577 "dci_update": {
1578 "new_entrypoints": {},
1579 "new_entrypoint_params": {},
1580 "trace_results": {}
1581 }
1582 }
1583 }
1584 "#.to_owned()
1585 ))
1586 ];
1587 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 1).await;
1588 let client = WsDeltasClient::new_with_reconnects(
1589 &format!("ws://{addr}"),
1590 None,
1591 3,
1592 Duration::from_millis(110),
1594 )
1595 .unwrap();
1596
1597 let jh: JoinHandle<Result<(), DeltasError>> = client
1598 .connect()
1599 .await
1600 .expect("connect failed");
1601
1602 for _ in 0..2 {
1603 dbg!("loop");
1604 let (_, mut rx) = timeout(
1605 Duration::from_millis(200),
1606 client.subscribe(
1607 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1608 SubscriptionOptions::new().with_compression(false),
1609 ),
1610 )
1611 .await
1612 .expect("subscription timed out")
1613 .expect("subscription failed");
1614
1615 let _ = timeout(Duration::from_millis(100), rx.recv())
1616 .await
1617 .expect("awaiting message timeout out")
1618 .expect("receiving message failed");
1619
1620 let res = timeout(Duration::from_millis(200), rx.recv())
1622 .await
1623 .expect("awaiting closed connection timeout out");
1624 assert!(res.is_none());
1625 }
1626 let res = jh.await.expect("ws client join failed");
1627 assert!(res.is_err());
1629 server_thread
1630 .await
1631 .expect("ws server loop errored");
1632 }
1633
1634 async fn mock_bad_connection_tycho_ws(accept_first: bool) -> (SocketAddr, JoinHandle<()>) {
1635 let server = TcpListener::bind("127.0.0.1:0")
1636 .await
1637 .expect("localhost bind failed");
1638 let addr = server.local_addr().unwrap();
1639 let jh = tokio::spawn(async move {
1640 while let Ok((stream, _)) = server.accept().await {
1641 if accept_first {
1642 let stream = tokio_tungstenite::accept_async(stream)
1644 .await
1645 .unwrap();
1646 sleep(Duration::from_millis(10)).await;
1647 drop(stream)
1648 } else {
1649 drop(stream);
1651 }
1652 }
1653 });
1654 (addr, jh)
1655 }
1656
1657 #[test_log::test(tokio::test)]
1658 async fn test_reconnect_skips_cooldown_after_long_connection() {
1659 let hold = Duration::from_secs(1);
1660 let server = TcpListener::bind("127.0.0.1:0")
1661 .await
1662 .unwrap();
1663 let addr = server.local_addr().unwrap();
1664 let (accepted_tx, mut accepted_rx) = mpsc::unbounded_channel();
1665 tokio::spawn(async move {
1666 while let Ok((stream, _)) = server.accept().await {
1667 let stream = tokio_tungstenite::accept_async(stream)
1668 .await
1669 .unwrap();
1670 let _ = accepted_tx.send(std::time::Instant::now());
1671 sleep(hold).await;
1672 drop(stream);
1673 }
1674 });
1675 let client =
1676 WsDeltasClient::new_with_reconnects(&format!("ws://{addr}"), None, 3, hold * 4 / 5)
1677 .unwrap();
1678
1679 let _jh = client.connect().await.unwrap();
1680
1681 let first = accepted_rx.recv().await.unwrap();
1682 let second = timeout(Duration::from_secs(5), accepted_rx.recv())
1683 .await
1684 .unwrap()
1685 .unwrap();
1686 let gap = second - first;
1688 assert!(gap < hold + hold / 2, "reconnect waited {:?}", gap - hold);
1689 }
1690
1691 #[test_log::test(tokio::test)]
1692 async fn test_subscribe_dead_client_after_max_attempts() {
1693 let (addr, _) = mock_bad_connection_tycho_ws(true).await;
1694 let client = WsDeltasClient::new_with_reconnects(
1695 &format!("ws://{addr}"),
1696 None,
1697 3,
1698 Duration::from_secs(0),
1699 )
1700 .unwrap();
1701
1702 let join_handle = client.connect().await.unwrap();
1703 let handle_res = join_handle.await.unwrap();
1704 assert!(handle_res.is_err());
1705 assert!(!client.is_connected().await);
1706
1707 let subscription_res = timeout(
1708 Duration::from_millis(10),
1709 client.subscribe(
1710 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1711 SubscriptionOptions::new(),
1712 ),
1713 )
1714 .await
1715 .unwrap();
1716 assert!(subscription_res.is_err());
1717 }
1718
1719 #[test_log::test(tokio::test)]
1720 async fn test_ws_client_retry_cooldown() {
1721 let start = std::time::Instant::now();
1722 let (addr, _) = mock_bad_connection_tycho_ws(false).await;
1723
1724 let client = WsDeltasClient::new_with_reconnects(
1726 &format!("ws://{addr}"),
1727 None,
1728 3, Duration::from_millis(50), )
1731 .unwrap();
1732
1733 let connect_result = client.connect().await;
1735 let elapsed = start.elapsed();
1736
1737 assert!(connect_result.is_err(), "Expected connection to fail after retries");
1739
1740 assert!(
1742 elapsed >= Duration::from_millis(100),
1743 "Expected at least 100ms elapsed, got {:?}",
1744 elapsed
1745 );
1746
1747 assert!(elapsed < Duration::from_millis(500), "Took too long: {:?}", elapsed);
1749 }
1750
1751 #[test_log::test(tokio::test)]
1752 async fn test_stream_builder_buffer_size_limits_subscription_channel() {
1753 let exp_comm = {
1755 [
1756 ExpectedComm::Receive(
1758 100,
1759 tungstenite::protocol::Message::Text(
1760 subscribe(),
1761 ),
1762 ),
1763 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1765 subscription_confirmation(),
1766 )),
1767 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1769 r#"
1770 {
1771 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1772 "deltas": {
1773 "extractor": "vm:ambient",
1774 "chain": "ethereum",
1775 "block": {
1776 "number": 123,
1777 "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1778 "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1779 "chain": "ethereum",
1780 "ts": "2023-09-14T00:00:00"
1781 },
1782 "finalized_block_height": 0,
1783 "revert": false,
1784 "new_tokens": {},
1785 "account_updates": {},
1786 "state_updates": {},
1787 "new_protocol_components": {},
1788 "deleted_protocol_components": {},
1789 "component_balances": {},
1790 "account_balances": {},
1791 "component_tvl": {},
1792 "dci_update": {
1793 "new_entrypoints": {},
1794 "new_entrypoint_params": {},
1795 "trace_results": {}
1796 }
1797 }
1798 }
1799 "#.to_owned()
1800 )),
1801 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1803 r#"
1804 {
1805 "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1806 "deltas": {
1807 "extractor": "vm:ambient",
1808 "chain": "ethereum",
1809 "block": {
1810 "number": 124,
1811 "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1812 "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1813 "chain": "ethereum",
1814 "ts": "2023-09-14T00:00:01"
1815 },
1816 "finalized_block_height": 0,
1817 "revert": false,
1818 "new_tokens": {},
1819 "account_updates": {},
1820 "state_updates": {},
1821 "new_protocol_components": {},
1822 "deleted_protocol_components": {},
1823 "component_balances": {},
1824 "account_balances": {},
1825 "component_tvl": {},
1826 "dci_update": {
1827 "new_entrypoints": {},
1828 "new_entrypoint_params": {},
1829 "trace_results": {}
1830 }
1831 }
1832 }
1833 "#.to_owned()
1834 )),
1835 ExpectedComm::Receive(
1837 100,
1838 tungstenite::protocol::Message::Text(
1839 unsubscribe(),
1840 ),
1841 ),
1842 ExpectedComm::Send(tungstenite::protocol::Message::Text(
1844 subscription_ended(),
1845 )),
1846 ]
1847 };
1848
1849 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1850
1851 let client = TychoStreamBuilder::new("unused", Chain::Ethereum)
1854 .subscription_buffer_size(1)
1855 .build_ws_deltas_client(&format!("ws://{addr}"), None, None)
1856 .expect("stream builder should construct a websocket client");
1857
1858 let jh = client
1859 .connect()
1860 .await
1861 .expect("connect failed");
1862
1863 let (_sub_id, mut rx) = timeout(
1864 Duration::from_millis(100),
1865 client.subscribe(
1866 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1867 SubscriptionOptions::new().with_compression(false),
1868 ),
1869 )
1870 .await
1871 .expect("subscription timed out")
1872 .expect("subscription failed");
1873
1874 tokio::time::sleep(Duration::from_millis(100)).await;
1876
1877 let mut received_msgs = Vec::new();
1879
1880 while received_msgs.len() < 3 {
1882 match timeout(Duration::from_millis(200), rx.recv()).await {
1883 Ok(Some(msg)) => {
1884 received_msgs.push(msg);
1885 }
1886 Ok(None) => {
1887 break;
1889 }
1890 Err(_) => {
1891 break;
1893 }
1894 }
1895 }
1896
1897 assert!(
1899 received_msgs.len() <= 1,
1900 "Expected buffer overflow to limit messages to at most 1, got {}",
1901 received_msgs.len()
1902 );
1903
1904 if let Some(first_msg) = received_msgs.first() {
1905 assert_eq!(first_msg.block.number, 123, "Expected first message with block 123");
1906 }
1907
1908 drop(rx); tokio::time::sleep(Duration::from_millis(50)).await;
1915
1916 jh.abort();
1918 server_thread.abort();
1919
1920 let _ = jh.await;
1921 let _ = server_thread.await;
1922 }
1923
1924 #[tokio::test]
1925 async fn test_server_error_handling() {
1926 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
1927
1928 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
1929
1930 let error_response = WebSocketMessage::Response(Response::Error(
1932 WebsocketError::ExtractorNotFound(extractor_id.clone().into()),
1933 ));
1934 let error_json = serde_json::to_string(&error_response).unwrap();
1935
1936 let exp_comm = [
1937 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1938 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
1939 ];
1940
1941 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1942
1943 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1944 let jh = client
1945 .connect()
1946 .await
1947 .expect("connect failed");
1948
1949 let result = timeout(
1950 Duration::from_millis(100),
1951 client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
1952 )
1953 .await
1954 .expect("subscription timed out");
1955
1956 assert!(result.is_err());
1958 if let Err(DeltasError::ServerError(msg)) = result {
1959 assert!(msg.contains("Subscription failed"));
1960 assert!(msg.contains("Extractor not found"));
1961 } else {
1962 panic!("Expected DeltasError::ServerError, got: {:?}", result);
1963 }
1964
1965 timeout(Duration::from_millis(100), client.close())
1966 .await
1967 .expect("close timed out")
1968 .expect("close failed");
1969 jh.await
1970 .expect("ws loop errored")
1971 .unwrap();
1972 server_thread.await.unwrap();
1973 }
1974
1975 #[test_log::test(tokio::test)]
1976 async fn test_subscription_not_found_error() {
1977 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
1979
1980 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
1981 let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
1982
1983 let error_response = WebSocketMessage::Response(Response::Error(
1984 WebsocketError::SubscriptionNotFound(subscription_id),
1985 ));
1986 let error_json = serde_json::to_string(&error_response).unwrap();
1987
1988 let exp_comm = [
1989 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1991 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1992 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
1994 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
1996 ];
1997
1998 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1999
2000 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2001 let jh = client
2002 .connect()
2003 .await
2004 .expect("connect failed");
2005
2006 let (received_sub_id, _rx) = timeout(
2008 Duration::from_millis(100),
2009 client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
2010 )
2011 .await
2012 .expect("subscription timed out")
2013 .expect("subscription failed");
2014
2015 assert_eq!(received_sub_id, subscription_id);
2016
2017 let unsubscribe_result =
2019 timeout(Duration::from_millis(100), client.unsubscribe(subscription_id))
2020 .await
2021 .expect("unsubscribe timed out");
2022
2023 unsubscribe_result
2027 .expect("Unsubscribe should succeed even if server says subscription not found");
2028
2029 timeout(Duration::from_millis(100), client.close())
2030 .await
2031 .expect("close timed out")
2032 .expect("close failed");
2033 jh.await
2034 .expect("ws loop errored")
2035 .unwrap();
2036 server_thread.await.unwrap();
2037 }
2038
2039 #[test_log::test(tokio::test)]
2040 async fn test_parse_error_handling() {
2041 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2042
2043 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2044 let error_response = WebSocketMessage::Response(Response::Error(
2045 WebsocketError::ParseError("}2sdf".to_string(), "malformed JSON".to_string()),
2046 ));
2047 let error_json = serde_json::to_string(&error_response).unwrap();
2048
2049 let exp_comm = [
2050 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2052 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2053 ];
2054
2055 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2056
2057 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2058 let jh = client
2059 .connect()
2060 .await
2061 .expect("connect failed");
2062
2063 let _ = timeout(
2065 Duration::from_millis(100),
2066 client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
2067 )
2068 .await
2069 .expect("subscription timed out");
2070
2071 let result = jh
2073 .await
2074 .expect("ws loop should complete");
2075 assert!(result.is_err());
2076 if let Err(DeltasError::ServerError(message)) = result {
2077 assert!(message.contains("Server failed to parse client message"));
2078 } else {
2079 panic!("Expected DeltasError::ServerError, got: {:?}", result);
2080 }
2081
2082 server_thread.await.unwrap();
2083 }
2084
2085 #[test_log::test(tokio::test)]
2086 async fn test_compression_error_handling() {
2087 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2088
2089 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2090 let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
2091 let error_response = WebSocketMessage::Response(Response::Error(
2092 WebsocketError::CompressionError(subscription_id, "Compression failed".to_string()),
2093 ));
2094 let error_json = serde_json::to_string(&error_response).unwrap();
2095
2096 let exp_comm = [
2097 ExpectedComm::Receive(
2099 100,
2100 tungstenite::protocol::Message::Text(subscribe_with_compression(true)),
2101 ),
2102 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2103 ];
2104
2105 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2106
2107 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2108 let jh = client
2109 .connect()
2110 .await
2111 .expect("connect failed");
2112
2113 let _ = timeout(
2115 Duration::from_millis(100),
2116 client.subscribe(extractor_id, SubscriptionOptions::new()),
2117 )
2118 .await
2119 .expect("subscription timed out");
2120
2121 let result = jh
2123 .await
2124 .expect("ws loop should complete");
2125 assert!(result.is_err());
2126 if let Err(DeltasError::ServerError(message)) = result {
2127 assert!(message.contains("Server failed to compress message for subscription"));
2128 } else {
2129 panic!("Expected DeltasError::ServerError, got: {:?}", result);
2130 }
2131
2132 server_thread.await.unwrap();
2133 }
2134
2135 #[tokio::test]
2136 async fn test_subscribe_error_handling() {
2137 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2138
2139 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2140
2141 let error_response = WebSocketMessage::Response(Response::Error(
2142 WebsocketError::SubscribeError(extractor_id.clone().into()),
2143 ));
2144 let error_json = serde_json::to_string(&error_response).unwrap();
2145
2146 let exp_comm = [
2147 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2148 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2149 ];
2150
2151 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2152
2153 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2154 let jh = client
2155 .connect()
2156 .await
2157 .expect("connect failed");
2158
2159 let result = timeout(
2160 Duration::from_millis(100),
2161 client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
2162 )
2163 .await
2164 .expect("subscription timed out");
2165
2166 assert!(result.is_err());
2168 if let Err(DeltasError::ServerError(msg)) = result {
2169 assert!(msg.contains("Subscription failed"));
2170 assert!(msg.contains("Failed to subscribe to extractor"));
2171 } else {
2172 panic!("Expected DeltasError::ServerError, got: {:?}", result);
2173 }
2174
2175 timeout(Duration::from_millis(100), client.close())
2176 .await
2177 .expect("close timed out")
2178 .expect("close failed");
2179 jh.await
2180 .expect("ws loop errored")
2181 .unwrap();
2182 server_thread.await.unwrap();
2183 }
2184
2185 #[tokio::test]
2186 async fn test_cancel_pending_subscription() {
2187 use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2189
2190 let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2191
2192 let error_response = WebSocketMessage::Response(Response::Error(
2193 WebsocketError::ExtractorNotFound(extractor_id.clone().into()),
2194 ));
2195 let error_json = serde_json::to_string(&error_response).unwrap();
2196
2197 let exp_comm = [
2198 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2199 ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2200 ];
2201
2202 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2203
2204 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2205 let jh = client
2206 .connect()
2207 .await
2208 .expect("connect failed");
2209
2210 let client_clone = client.clone();
2212 let extractor_id_clone = extractor_id.clone();
2213
2214 let subscription1 = tokio::spawn({
2215 let client_for_spawn = client.clone();
2216 async move {
2217 client_for_spawn
2218 .subscribe(extractor_id, SubscriptionOptions::new().with_compression(false))
2219 .await
2220 }
2221 });
2222
2223 let subscription2 = tokio::spawn(async move {
2224 client_clone
2226 .subscribe(extractor_id_clone, SubscriptionOptions::new())
2227 .await
2228 });
2229
2230 let (result1, result2) = tokio::join!(subscription1, subscription2);
2231
2232 let result1 = result1.unwrap();
2233 let result2 = result2.unwrap();
2234
2235 assert!(result1.is_err() || result2.is_err());
2238
2239 if let Err(DeltasError::SubscriptionAlreadyPending) = result2 {
2240 } else if let Err(DeltasError::ServerError(_)) = result1 {
2242 } else {
2244 panic!("Expected one SubscriptionAlreadyPending and one ServerError");
2245 }
2246
2247 timeout(Duration::from_millis(100), client.close())
2248 .await
2249 .expect("close timed out")
2250 .expect("close failed");
2251 jh.await
2252 .expect("ws loop errored")
2253 .unwrap();
2254 server_thread.await.unwrap();
2255 }
2256
2257 #[tokio::test]
2258 async fn test_force_unsubscribe_prevents_multiple_calls() {
2259 let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
2263
2264 let exp_comm = [
2265 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2266 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
2267 ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
2269 ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
2270 ];
2271
2272 let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2273
2274 let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2275 let jh = client
2276 .connect()
2277 .await
2278 .expect("connect failed");
2279
2280 let (received_sub_id, _rx) = timeout(
2281 Duration::from_millis(100),
2282 client.subscribe(
2283 ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
2284 SubscriptionOptions::new().with_compression(false),
2285 ),
2286 )
2287 .await
2288 .expect("subscription timed out")
2289 .expect("subscription failed");
2290
2291 assert_eq!(received_sub_id, subscription_id);
2292
2293 {
2295 let mut inner_guard = client.inner.lock().await;
2296 let inner = inner_guard
2297 .as_mut()
2298 .expect("client should be connected");
2299
2300 WsDeltasClient::force_unsubscribe(subscription_id, inner).await;
2302 WsDeltasClient::force_unsubscribe(subscription_id, inner).await;
2303 }
2304
2305 tokio::time::sleep(Duration::from_millis(50)).await;
2307
2308 let _ = timeout(Duration::from_millis(100), client.close()).await;
2310
2311 let _ = jh.await;
2313 let _ = server_thread.await;
2314 }
2315}