Skip to main content

tycho_client/
deltas.rs

1//! # Deltas Client
2//!
3//! This module focuses on implementing the Real-Time Deltas client for the Tycho Indexer service.
4//! Utilizing this client facilitates efficient, instant communication with the indexing service,
5//! promoting seamless data synchronization.
6//!
7//! ## Websocket Implementation
8//!
9//! The present WebSocket implementation is cloneable, which enables it to be shared
10//! across multiple asynchronous tasks without creating separate instances for each task. This
11//! unique feature boosts efficiency as it:
12//!
13//! - **Reduces Server Load:** By maintaining a single universal client, the load on the server is
14//!   significantly reduced. This is because fewer connections are made to the server, preventing it
15//!   from getting overwhelmed by numerous simultaneous requests.
16//! - **Conserves Resource Usage:** A single shared client requires fewer system resources than if
17//!   multiple clients were instantiated and used separately as there is some overhead for websocket
18//!   handshakes and message.
19//!
20//! Therefore, sharing one client among multiple tasks ensures optimal performance, reduces resource
21//! consumption, and enhances overall software scalability.
22use 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    /// Failed to parse the provided URI.
75    #[error("Failed to parse URI: {0}. Error: {1}")]
76    UriParsing(String, String),
77
78    /// The requested subscription is already pending and is awaiting confirmation from the server.
79    #[error("The requested subscription is already pending")]
80    SubscriptionAlreadyPending,
81
82    #[error("The server replied with an error: {0}")]
83    ServerError(String),
84
85    /// A message failed to send via an internal channel or through the websocket channel.
86    /// This is typically a fatal error and might indicate a bug in the implementation.
87    #[error("{0}")]
88    TransportError(String),
89
90    /// The internal message buffer is full. This likely means that messages are not being consumed
91    /// fast enough. If the incoming load emits messages in bursts, consider increasing the buffer
92    /// size.
93    #[error("The buffer is full!")]
94    BufferFull,
95
96    /// The client has no active connections but was accessed (e.g., by calling subscribe).
97    /// This typically occurs when trying to use the client before calling connect() or
98    /// after the connection has been closed.
99    #[error("The client is not connected!")]
100    NotConnected,
101
102    /// The connect method was called while the client already had an active connection.
103    #[error("The client is already connected!")]
104    AlreadyConnected,
105
106    /// The connection was closed orderly by the server, e.g. because it restarted.
107    #[error("The server closed the connection!")]
108    ConnectionClosed,
109
110    /// The connection was closed unexpectedly by the server or encountered a network error.
111    #[error("Connection error: {0}")]
112    ConnectionError(#[from] Box<tungstenite::Error>),
113
114    /// A fatal error occurred that cannot be recovered from.
115    #[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    /// Subscribe to an extractor and receive realtime messages
154    ///
155    /// Will request a subscription from tycho and wait for confirmation of it. If the caller
156    /// cancels while waiting for confirmation the subscription may still be registered. If the
157    /// receiver was deallocated though, the first message from the subscription will remove it
158    /// again - since there is no one to inform about these messages.
159    async fn subscribe(
160        &self,
161        extractor_id: ExtractorIdentity,
162        options: SubscriptionOptions,
163    ) -> Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError>;
164
165    /// Unsubscribe from an subscription
166    async fn unsubscribe(&self, subscription_id: Uuid) -> Result<(), DeltasError>;
167
168    /// Start the clients message handling loop.
169    async fn connect(&self) -> Result<JoinHandle<Result<(), DeltasError>>, DeltasError>;
170
171    /// Close the clients message handling loop.
172    async fn close(&self) -> Result<(), DeltasError>;
173}
174
175#[derive(Clone)]
176pub struct WsDeltasClient {
177    /// The tycho indexer websocket uri.
178    uri: Uri,
179    /// Authorization key for the websocket connection.
180    auth_key: Option<String>,
181    /// Maximum amount of reconnects to try before giving up.
182    max_reconnects: u64,
183    /// Duration to wait before attempting to reconnect
184    retry_cooldown: Duration,
185    /// The client will buffer this many messages incoming from the websocket
186    /// before starting to drop them.
187    ws_buffer_size: usize,
188    /// The client will buffer that many messages for each subscription before it starts dropping
189    /// them.
190    subscription_buffer_size: usize,
191    /// Notify tasks waiting for a connection to be established.
192    conn_notify: Arc<Notify>,
193    /// Shared client instance state.
194    inner: Arc<Mutex<Option<Inner>>>,
195    /// If set the client has exhausted its reconnection attempts
196    dead: Arc<AtomicBool>,
197    /// Pre-serialized `X-Tycho-Client-Metadata` header value. `None` sends no header.
198    client_metadata_header: Option<String>,
199}
200
201type WebSocketSink =
202    SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, tungstenite::protocol::Message>;
203
204/// Subscription State
205///
206/// Subscription go through a lifecycle:
207///
208/// ```text
209/// O ---> requested subscribe ----> active ----> requested unsub ---> ended
210/// ```
211///
212/// We use oneshot channels to inform the client struct about when these transition happened. E.g.
213/// because for `subscribe`` to finish, we want the state to have transition to `active` and similar
214/// for `unsubscribe`.
215#[derive(Debug)]
216enum SubscriptionInfo {
217    /// Subscription was requested we wait for server confirmation and uuid assignment.
218    RequestedSubscription(
219        oneshot::Sender<Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError>>,
220    ),
221    /// Subscription is active.
222    Active,
223    /// Unsubscription was requested, we wait for server confirmation.
224    RequestedUnsubscription(oneshot::Sender<()>),
225}
226
227/// Internal struct containing shared state between of WsDeltaClient instances.
228struct Inner {
229    /// Websocket sender handle.
230    sink: WebSocketSink,
231    /// Command channel sender handle.
232    cmd_tx: Sender<()>,
233    /// Currently pending subscriptions, keyed by model-layer extractor identity.
234    pending: HashMap<ExtractorIdentity, SubscriptionInfo>,
235    /// Active subscriptions.
236    subscriptions: HashMap<Uuid, SubscriptionInfo>,
237    /// For eachs subscription we keep a sender handle, the receiver is returned to the caller of
238    /// subscribe.
239    sender: HashMap<Uuid, Sender<BlockAggregatedChanges>>,
240    /// How many messages to buffer per subscription before starting to drop new messages.
241    buffer_size: usize,
242}
243
244/// Shared state between all client instances.
245///
246/// This state is behind a mutex and requires synchronization to be read of modified.
247impl 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    /// Registers a new pending subscription.
260    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    /// Transitions a pending subscription to active.
274    ///
275    /// Will ignore any request to do so for subscriptions that are not pending.
276    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    /// Sends a message to a subscription's receiver.
310    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    /// Requests a subscription to end.
325    ///
326    /// The subscription needs to exist and be active for this to have any effect. Wll use
327    /// `ready_tx` to notify the receiver once the transition to ended completed.
328    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            // no big deal imo so only debug lvl...
338            debug!(?subscription_id, "Tried unsubscribing from a non existent subscription");
339        }
340    }
341
342    /// Removes and fully ends a subscription
343    ///
344    /// Any calls for non-existing subscriptions will be simply ignored. May panic on internal state
345    /// inconsistencies: e.g. if the subscription exists but there is no sender for it.
346    /// Will remove a subscription even it was in active or pending state before, this is to support
347    /// any server side failure of the subscription.
348    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            // TODO: There is a race condition that can trigger multiple unsubscribes
369            //  if server doesn't respond quickly enough leading to some ugly logs but
370            //  doesn't affect behaviour negatively. E.g. BufferFull and multiple
371            //  messages from the ws connection are queued.
372            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    /// Sends a message through the websocket.
401    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
408/// Builds the WebSocket handshake request, including the optional auth and client-metadata
409/// headers. The metadata value is pre-validated by the serializer, so `.header()` cannot fail on
410/// it. `User-Agent` is always `tycho-client-{version}`.
411fn 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
446/// Tycho client websocket implementation.
447impl WsDeltasClient {
448    // Construct a new client with 5 reconnection attempts.
449    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    // Construct a new client with a custom number of reconnection attempts.
468    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    /// Sets the pre-serialized client-metadata header value. `None` sends no header.
493    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    /// Ensures that the client is connected.
504    ///
505    /// This method will acquire the lock for inner.
506    async fn is_connected(&self) -> bool {
507        let guard = self.inner.as_ref().lock().await;
508        guard.is_some()
509    }
510
511    /// Waits for the client to be connected
512    ///
513    /// This method acquires the lock for inner for a short period, then waits until the
514    /// connection is established if not already connected.
515    async fn ensure_connection(&self) -> Result<(), DeltasError> {
516        // Loop until either permanently dead or successfully connected. A single wait-and-check
517        // is not enough: the WS can reconnect briefly then drop again (e.g. server restart),
518        // which would fire conn_notify but leave is_connected() false. Looping retries
519        // automatically on that transient race.
520        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            // Enable the future BEFORE re-checking is_connected to close the race window where
528            // the reconnect task calls notify_waiters() between is_connected() returning false
529            // and notified().await — without enable(), that notification would be lost.
530            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            // Loop back: recheck dead and is_connected. If the WS dropped again between the
537            // notification and here, we wait for the next reconnect rather than failing.
538        }
539    }
540
541    /// Main message handling logic
542    ///
543    /// If the message returns an error, a reconnect attempt may be considered depending on the
544    /// error type.
545    #[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            // We do not deserialize the message directly into a WebSocketMessage. This is because
554            // the serde arbitrary_precision feature (often included in many
555            // dependencies we use) breaks some untagged enum deserializations. Instead,
556            // we deserialize the message into a serde_json::Value and convert that into a WebSocketMessage. For more info on this issue, see: https://github.com/serde-rs/json/issues/740
557            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                // Decompress the zstd-compressed data,
635                // Note that we only support compressed BlockAggregatedChanges messages for now.
636                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                // Respond to pings with pongs.
680                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                // Do nothing.
692            }
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            _ => { /* Do nothing */ }
742        }
743        Ok(())
744    }
745
746    /// Forcefully ends a (client) stream by unsubscribing.
747    ///
748    /// Is used only if the message can't be processed due to an error that might resolve
749    /// itself by resubscribing.
750    async fn force_unsubscribe(subscription_id: Uuid, inner: &mut Inner) {
751        // avoid unsubscribing multiple times
752        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            // Wait for unsubscribe completion with timeout
764            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    /// Helper method to force an unsubscription
776    ///
777    /// This method expects to receive a mutable reference to `Inner` so it does not acquire a
778    /// lock. Used for normal unsubscribes as well to remove any subscriptions with deallocated
779    /// receivers.
780    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                // The cooldown limits attempts to one per `retry_cooldown`. After a connection
899                // that outlived it, reconnect at once.
900                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                        // Prepare for reconnection
919                        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                // If no WS frame arrives within this window the TCP connection is
958                // considered stalled (e.g. network cut without a TCP RST/FIN). The OS
959                // default keepalive fires after ~2 hours; this gives us an
960                // application-level detection that is orders of magnitude faster.
961                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; // break inner loop → reconnect
973                                }
974                                Ok(Some(msg)) => this.handle_msg(msg).await,
975                                Ok(None) => {
976                                    // This code should not be reachable since the stream
977                                    // should return ConnectionClosed in the case above
978                                    // before it returns None here.
979                                    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                            // Prepare for reconnection
993                            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                            // Other errors are considered fatal
1005                            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            // Clean up before exiting
1018            let mut guard = this.inner.as_ref().lock().await;
1019            *guard = None;
1020
1021            // Check if max retries has been reached.
1022            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(); // Notify that the task is done
1026                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        // Mark dead and notify so any ensure_connection() callers blocked on conn_notify
1055        // unblock immediately and return NotConnected rather than hanging forever.
1056        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        // zero port here means the OS chooses an open port
1084        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                    // Close the WebSocket connection
1126                    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        // Field order matches Command::Subscribe's serde tag + struct field declaration order.
1143        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, // default compression level
1366        )
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        // If the subscription ended, the channel should have been closed.
1443        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        // If the subscription ended, the channel should have been closed.
1484        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            // server stays down for 100ms on connection drop
1593            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            // wait for the connection to drop
1621            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        // 5th client reconnect attempt should fail
1628        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                    // Send connection handshake to accept the connection (fail later)
1643                    let stream = tokio_tungstenite::accept_async(stream)
1644                        .await
1645                        .unwrap();
1646                    sleep(Duration::from_millis(10)).await;
1647                    drop(stream)
1648                } else {
1649                    // Close the connection to simulate a failure
1650                    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        // The connection outlived the cooldown, so the reconnect does not wait for it.
1687        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        // Use the mock server that immediately drops connections
1725        let client = WsDeltasClient::new_with_reconnects(
1726            &format!("ws://{addr}"),
1727            None,
1728            3,                         // 3 attempts total (so 2 retries with cooldowns)
1729            Duration::from_millis(50), // 50ms cooldown
1730        )
1731        .unwrap();
1732
1733        // Try to connect - this should fail after retries but still measure the time
1734        let connect_result = client.connect().await;
1735        let elapsed = start.elapsed();
1736
1737        // Connection should fail after exhausting retries
1738        assert!(connect_result.is_err(), "Expected connection to fail after retries");
1739
1740        // Should have waited at least 100ms total (2 retries × 50ms cooldown each)
1741        assert!(
1742            elapsed >= Duration::from_millis(100),
1743            "Expected at least 100ms elapsed, got {:?}",
1744            elapsed
1745        );
1746
1747        // Should not take too long (max ~300ms for 3 attempts with some tolerance)
1748        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        // Expected communication sequence for buffer full scenario
1754        let exp_comm = {
1755            [
1756            // 1. Client subscribes
1757            ExpectedComm::Receive(
1758                100,
1759                tungstenite::protocol::Message::Text(
1760                    subscribe(),
1761                ),
1762            ),
1763            // 2. Server confirms subscription
1764            ExpectedComm::Send(tungstenite::protocol::Message::Text(
1765                subscription_confirmation(),
1766            )),
1767            // 3. Server sends first message (fills buffer)
1768            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            // 4. Server sends second message (triggers buffer overflow and force unsubscribe)
1802            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            // 5. Expect unsubscribe command due to buffer full
1836            ExpectedComm::Receive(
1837                100,
1838                tungstenite::protocol::Message::Text(
1839                    unsubscribe(),
1840                ),
1841            ),
1842            // 6. Server confirms unsubscription
1843            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        // Build the client through the public stream builder so the test observes its configured
1852        // capacity on the real subscription channel.
1853        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        // Allow time for messages to be processed and buffer to fill up
1875        tokio::time::sleep(Duration::from_millis(100)).await;
1876
1877        // Collect all messages until channel closes or we get a reasonable number
1878        let mut received_msgs = Vec::new();
1879
1880        // Use a single longer timeout to collect messages until channel closes
1881        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                    // Channel closed - this is what we expect after buffer overflow
1888                    break;
1889                }
1890                Err(_) => {
1891                    // Timeout - no more messages coming
1892                    break;
1893                }
1894            }
1895        }
1896
1897        // Verify the key behavior: buffer overflow should limit messages and close channel
1898        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        // Test passed! The key behavior we're testing (buffer full causes force unsubscribe) has
1909        // been verified We don't need to explicitly close the client as it will be cleaned
1910        // up when dropped
1911
1912        // Just wait for the tasks to finish cleanly
1913        drop(rx); // Explicitly drop the receiver
1914        tokio::time::sleep(Duration::from_millis(50)).await;
1915
1916        // Abort the tasks to clean up
1917        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        // Test ExtractorNotFound error
1931        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        // Verify that we get a ServerError
1957        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        // Test scenario: Server restart causes subscription loss
1978        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            // 1. Client subscribes successfully
1990            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1991            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1992            // 2. Client tries to unsubscribe (server has "restarted" and lost subscription)
1993            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
1994            // 3. Server responds with SubscriptionNotFound (simulating server restart)
1995            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        // Subscribe successfully
2007        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        // Now try to unsubscribe - this should fail because server "restarted"
2018        let unsubscribe_result =
2019            timeout(Duration::from_millis(100), client.unsubscribe(subscription_id))
2020                .await
2021                .expect("unsubscribe timed out");
2022
2023        // The unsubscribe should handle the SubscriptionNotFound error gracefully
2024        // In this case, the client should treat it as successful since the subscription
2025        // is effectively gone (whether due to server restart or other reasons)
2026        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            // subscribe first so connect can finish successfully
2051            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        // Subscribe successfully
2064        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        // The client should receive the parse error and close the connection
2072        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            // subscribe first so connect can finish successfully
2098            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        // Subscribe successfully with compression disabled
2114        let _ = timeout(
2115            Duration::from_millis(100),
2116            client.subscribe(extractor_id, SubscriptionOptions::new()),
2117        )
2118        .await
2119        .expect("subscription timed out");
2120
2121        // The client should receive the parse error
2122        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        // Verify that we get a ServerError for subscribe failure
2167        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        // This test verifies that pending subscriptions are properly cancelled when errors occur
2188        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        // Start two subscription attempts simultaneously
2211        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            // This should fail because there's already a pending subscription
2225            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        // One should fail due to ExtractorNotFound error from server
2236        // The other should fail due to SubscriptionAlreadyPending
2237        assert!(result1.is_err() || result2.is_err());
2238
2239        if let Err(DeltasError::SubscriptionAlreadyPending) = result2 {
2240            // This is expected for the second subscription
2241        } else if let Err(DeltasError::ServerError(_)) = result1 {
2242            // This is expected for the first subscription that gets the server error
2243        } 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        // Test that force_unsubscribe prevents sending duplicate unsubscribe commands
2260        // when called multiple times for the same subscription_id
2261
2262        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            // Expect only ONE unsubscribe message, even though force_unsubscribe is called twice
2268            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        // Access the inner state to call force_unsubscribe directly
2294        {
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            // Call force_unsubscribe twice - only the first should send an unsubscribe message
2301            WsDeltasClient::force_unsubscribe(subscription_id, inner).await;
2302            WsDeltasClient::force_unsubscribe(subscription_id, inner).await;
2303        }
2304
2305        // Give time for messages to be processed
2306        tokio::time::sleep(Duration::from_millis(50)).await;
2307
2308        // Close may fail if client disconnected after unsubscribe, which is fine
2309        let _ = timeout(Duration::from_millis(100), client.close()).await;
2310
2311        // Wait for tasks to complete
2312        let _ = jh.await;
2313        let _ = server_thread.await;
2314    }
2315}