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,
29};
30
31use async_trait::async_trait;
32use futures03::{stream::SplitSink, SinkExt, StreamExt};
33use hyper::{
34    header::{
35        AUTHORIZATION, CONNECTION, HOST, SEC_WEBSOCKET_KEY, SEC_WEBSOCKET_VERSION, UPGRADE,
36        USER_AGENT,
37    },
38    Uri,
39};
40#[cfg(test)]
41use mockall::automock;
42use thiserror::Error;
43use tokio::{
44    net::TcpStream,
45    sync::{
46        mpsc::{self, error::TrySendError, Receiver, Sender},
47        oneshot, Mutex, MutexGuard, Notify,
48    },
49    task::JoinHandle,
50    time::sleep,
51};
52use tokio_tungstenite::{
53    connect_async,
54    tungstenite::{
55        self,
56        handshake::client::{generate_key, Request},
57    },
58    MaybeTlsStream, WebSocketStream,
59};
60use tracing::{debug, error, info, instrument, trace, warn};
61use tycho_common::{
62    dto::{self, Command, Response, WebSocketMessage, WebsocketError},
63    models::{blockchain::BlockAggregatedChanges, ExtractorIdentity},
64};
65use uuid::Uuid;
66use zstd;
67
68use crate::{client_metadata::CLIENT_METADATA_HEADER, TYCHO_SERVER_VERSION};
69
70#[derive(Error, Debug)]
71pub enum DeltasError {
72    /// Failed to parse the provided URI.
73    #[error("Failed to parse URI: {0}. Error: {1}")]
74    UriParsing(String, String),
75
76    /// The requested subscription is already pending and is awaiting confirmation from the server.
77    #[error("The requested subscription is already pending")]
78    SubscriptionAlreadyPending,
79
80    #[error("The server replied with an error: {0}")]
81    ServerError(String),
82
83    /// A message failed to send via an internal channel or through the websocket channel.
84    /// This is typically a fatal error and might indicate a bug in the implementation.
85    #[error("{0}")]
86    TransportError(String),
87
88    /// The internal message buffer is full. This likely means that messages are not being consumed
89    /// fast enough. If the incoming load emits messages in bursts, consider increasing the buffer
90    /// size.
91    #[error("The buffer is full!")]
92    BufferFull,
93
94    /// The client has no active connections but was accessed (e.g., by calling subscribe).
95    /// This typically occurs when trying to use the client before calling connect() or
96    /// after the connection has been closed.
97    #[error("The client is not connected!")]
98    NotConnected,
99
100    /// The connect method was called while the client already had an active connection.
101    #[error("The client is already connected!")]
102    AlreadyConnected,
103
104    /// The connection was closed orderly by the server, e.g. because it restarted.
105    #[error("The server closed the connection!")]
106    ConnectionClosed,
107
108    /// The connection was closed unexpectedly by the server or encountered a network error.
109    #[error("Connection error: {0}")]
110    ConnectionError(#[from] Box<tungstenite::Error>),
111
112    /// A fatal error occurred that cannot be recovered from.
113    #[error("Tycho FatalError: {0}")]
114    Fatal(String),
115}
116
117#[derive(Clone, Debug)]
118pub struct SubscriptionOptions {
119    include_state: bool,
120    compression: bool,
121    partial_blocks: bool,
122}
123
124impl Default for SubscriptionOptions {
125    fn default() -> Self {
126        Self { include_state: true, compression: true, partial_blocks: false }
127    }
128}
129
130impl SubscriptionOptions {
131    pub fn new() -> Self {
132        Self::default()
133    }
134    pub fn with_state(mut self, val: bool) -> Self {
135        self.include_state = val;
136        self
137    }
138    pub fn with_compression(mut self, val: bool) -> Self {
139        self.compression = val;
140        self
141    }
142    pub fn with_partial_blocks(mut self, val: bool) -> Self {
143        self.partial_blocks = val;
144        self
145    }
146}
147
148#[cfg_attr(test, automock)]
149#[async_trait]
150pub trait DeltasClient {
151    /// Subscribe to an extractor and receive realtime messages
152    ///
153    /// Will request a subscription from tycho and wait for confirmation of it. If the caller
154    /// cancels while waiting for confirmation the subscription may still be registered. If the
155    /// receiver was deallocated though, the first message from the subscription will remove it
156    /// again - since there is no one to inform about these messages.
157    async fn subscribe(
158        &self,
159        extractor_id: ExtractorIdentity,
160        options: SubscriptionOptions,
161    ) -> Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError>;
162
163    /// Unsubscribe from an subscription
164    async fn unsubscribe(&self, subscription_id: Uuid) -> Result<(), DeltasError>;
165
166    /// Start the clients message handling loop.
167    async fn connect(&self) -> Result<JoinHandle<Result<(), DeltasError>>, DeltasError>;
168
169    /// Close the clients message handling loop.
170    async fn close(&self) -> Result<(), DeltasError>;
171}
172
173#[derive(Clone)]
174pub struct WsDeltasClient {
175    /// The tycho indexer websocket uri.
176    uri: Uri,
177    /// Authorization key for the websocket connection.
178    auth_key: Option<String>,
179    /// Maximum amount of reconnects to try before giving up.
180    max_reconnects: u64,
181    /// Duration to wait before attempting to reconnect
182    retry_cooldown: Duration,
183    /// The client will buffer this many messages incoming from the websocket
184    /// before starting to drop them.
185    ws_buffer_size: usize,
186    /// The client will buffer that many messages for each subscription before it starts dropping
187    /// them.
188    subscription_buffer_size: usize,
189    /// Notify tasks waiting for a connection to be established.
190    conn_notify: Arc<Notify>,
191    /// Shared client instance state.
192    inner: Arc<Mutex<Option<Inner>>>,
193    /// If set the client has exhausted its reconnection attempts
194    dead: Arc<AtomicBool>,
195    /// Pre-serialized `X-Tycho-Client-Metadata` header value. `None` sends no header.
196    client_metadata_header: Option<String>,
197}
198
199type WebSocketSink =
200    SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, tungstenite::protocol::Message>;
201
202/// Subscription State
203///
204/// Subscription go through a lifecycle:
205///
206/// ```text
207/// O ---> requested subscribe ----> active ----> requested unsub ---> ended
208/// ```
209///
210/// We use oneshot channels to inform the client struct about when these transition happened. E.g.
211/// because for `subscribe`` to finish, we want the state to have transition to `active` and similar
212/// for `unsubscribe`.
213#[derive(Debug)]
214enum SubscriptionInfo {
215    /// Subscription was requested we wait for server confirmation and uuid assignment.
216    RequestedSubscription(
217        oneshot::Sender<Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError>>,
218    ),
219    /// Subscription is active.
220    Active,
221    /// Unsubscription was requested, we wait for server confirmation.
222    RequestedUnsubscription(oneshot::Sender<()>),
223}
224
225/// Internal struct containing shared state between of WsDeltaClient instances.
226struct Inner {
227    /// Websocket sender handle.
228    sink: WebSocketSink,
229    /// Command channel sender handle.
230    cmd_tx: Sender<()>,
231    /// Currently pending subscriptions, keyed by model-layer extractor identity.
232    pending: HashMap<ExtractorIdentity, SubscriptionInfo>,
233    /// Active subscriptions.
234    subscriptions: HashMap<Uuid, SubscriptionInfo>,
235    /// For eachs subscription we keep a sender handle, the receiver is returned to the caller of
236    /// subscribe.
237    sender: HashMap<Uuid, Sender<BlockAggregatedChanges>>,
238    /// How many messages to buffer per subscription before starting to drop new messages.
239    buffer_size: usize,
240}
241
242/// Shared state between all client instances.
243///
244/// This state is behind a mutex and requires synchronization to be read of modified.
245impl Inner {
246    fn new(cmd_tx: Sender<()>, sink: WebSocketSink, buffer_size: usize) -> Self {
247        Self {
248            sink,
249            cmd_tx,
250            pending: HashMap::new(),
251            subscriptions: HashMap::new(),
252            sender: HashMap::new(),
253            buffer_size,
254        }
255    }
256
257    /// Registers a new pending subscription.
258    fn new_subscription(
259        &mut self,
260        id: &ExtractorIdentity,
261        ready_tx: oneshot::Sender<Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError>>,
262    ) -> Result<(), DeltasError> {
263        if self.pending.contains_key(id) {
264            return Err(DeltasError::SubscriptionAlreadyPending);
265        }
266        self.pending
267            .insert(id.clone(), SubscriptionInfo::RequestedSubscription(ready_tx));
268        Ok(())
269    }
270
271    /// Transitions a pending subscription to active.
272    ///
273    /// Will ignore any request to do so for subscriptions that are not pending.
274    fn mark_active(&mut self, extractor_id: ExtractorIdentity, subscription_id: Uuid) {
275        if let Some(info) = self.pending.remove(&extractor_id) {
276            if let SubscriptionInfo::RequestedSubscription(ready_tx) = info {
277                let (tx, rx) = mpsc::channel(self.buffer_size);
278                self.sender.insert(subscription_id, tx);
279                self.subscriptions
280                    .insert(subscription_id, SubscriptionInfo::Active);
281                let _ = ready_tx
282                    .send(Ok((subscription_id, rx)))
283                    .map_err(|_| {
284                        warn!(
285                            ?extractor_id,
286                            ?subscription_id,
287                            "Subscriber for has gone away. Ignoring."
288                        )
289                    });
290            } else {
291                error!(
292                    ?extractor_id,
293                    ?subscription_id,
294                    "Pending subscription was not in the correct state to 
295                    transition to active. Ignoring!"
296                )
297            }
298        } else {
299            error!(
300                ?extractor_id,
301                ?subscription_id,
302                "Tried to mark an unknown subscription as active. Ignoring!"
303            );
304        }
305    }
306
307    /// Sends a message to a subscription's receiver.
308    fn send(&mut self, id: &Uuid, msg: BlockAggregatedChanges) -> Result<(), DeltasError> {
309        if let Some(sender) = self.sender.get_mut(id) {
310            sender
311                .try_send(msg)
312                .map_err(|e| match e {
313                    TrySendError::Full(_) => DeltasError::BufferFull,
314                    TrySendError::Closed(_) => {
315                        DeltasError::TransportError("The subscriber has gone away".to_string())
316                    }
317                })?;
318        }
319        Ok(())
320    }
321
322    /// Requests a subscription to end.
323    ///
324    /// The subscription needs to exist and be active for this to have any effect. Wll use
325    /// `ready_tx` to notify the receiver once the transition to ended completed.
326    fn end_subscription(&mut self, subscription_id: &Uuid, ready_tx: oneshot::Sender<()>) {
327        if let Some(info) = self
328            .subscriptions
329            .get_mut(subscription_id)
330        {
331            if let SubscriptionInfo::Active = info {
332                *info = SubscriptionInfo::RequestedUnsubscription(ready_tx);
333            }
334        } else {
335            // no big deal imo so only debug lvl...
336            debug!(?subscription_id, "Tried unsubscribing from a non existent subscription");
337        }
338    }
339
340    /// Removes and fully ends a subscription
341    ///
342    /// Any calls for non-existing subscriptions will be simply ignored. May panic on internal state
343    /// inconsistencies: e.g. if the subscription exists but there is no sender for it.
344    /// Will remove a subscription even it was in active or pending state before, this is to support
345    /// any server side failure of the subscription.
346    fn remove_subscription(&mut self, subscription_id: Uuid) -> Result<(), DeltasError> {
347        if let Entry::Occupied(e) = self
348            .subscriptions
349            .entry(subscription_id)
350        {
351            let info = e.remove();
352            if let SubscriptionInfo::RequestedUnsubscription(tx) = info {
353                let _ = tx.send(()).map_err(|_| {
354                    debug!(?subscription_id, "failed to notify about removed subscription")
355                });
356                self.sender
357                    .remove(&subscription_id)
358                    .ok_or_else(|| DeltasError::Fatal("Inconsistent internal client state: `sender` state drifted from `info` while removing a subscription.".to_string()))?;
359            } else {
360                warn!(?subscription_id, "Subscription ended unexpectedly!");
361                self.sender
362                    .remove(&subscription_id)
363                    .ok_or_else(|| DeltasError::Fatal("sender channel missing".to_string()))?;
364            }
365        } else {
366            // TODO: There is a race condition that can trigger multiple unsubscribes
367            //  if server doesn't respond quickly enough leading to some ugly logs but
368            //  doesn't affect behaviour negatively. E.g. BufferFull and multiple
369            //  messages from the ws connection are queued.
370            trace!(
371                ?subscription_id,
372                "Received `SubscriptionEnded`, but was never subscribed to it. This is likely a bug!"
373            );
374        }
375
376        Ok(())
377    }
378
379    fn cancel_pending(&mut self, extractor_id: ExtractorIdentity, error: &WebsocketError) {
380        if let Some(sub_info) = self.pending.remove(&extractor_id) {
381            match sub_info {
382                SubscriptionInfo::RequestedSubscription(tx) => {
383                    let _ = tx
384                        .send(Err(DeltasError::ServerError(format!(
385                            "Subscription failed: {error}"
386                        ))))
387                        .map_err(|_| debug!("Cancel pending failed: receiver deallocated!"));
388                }
389                _ => {
390                    error!(?extractor_id, "Pending subscription in wrong state")
391                }
392            }
393        } else {
394            debug!(?extractor_id, "Tried cancel on non-existent pending subscription!")
395        }
396    }
397
398    /// Sends a message through the websocket.
399    async fn ws_send(&mut self, msg: tungstenite::protocol::Message) -> Result<(), DeltasError> {
400        self.sink.send(msg).await.map_err(|e| {
401            DeltasError::TransportError(format!("Failed to send message to websocket: {e}"))
402        })
403    }
404}
405
406/// Builds the WebSocket handshake request, including the optional auth and client-metadata
407/// headers. The metadata value is pre-validated by the serializer, so `.header()` cannot fail on
408/// it. `User-Agent` is always `tycho-client-{version}`.
409fn build_ws_handshake_request(
410    ws_uri: &str,
411    uri: &Uri,
412    auth_key: Option<&str>,
413    client_metadata_header: Option<&str>,
414) -> Result<Request, DeltasError> {
415    let mut request_builder = Request::builder()
416        .uri(ws_uri)
417        .header(SEC_WEBSOCKET_KEY, generate_key())
418        .header(SEC_WEBSOCKET_VERSION, 13)
419        .header(CONNECTION, "Upgrade")
420        .header(UPGRADE, "websocket")
421        .header(
422            HOST,
423            uri.host().ok_or_else(|| {
424                DeltasError::UriParsing(
425                    ws_uri.to_string(),
426                    "No host found in tycho url".to_string(),
427                )
428            })?,
429        )
430        .header(USER_AGENT, format!("tycho-client-{version}", version = env!("CARGO_PKG_VERSION")));
431
432    if let Some(key) = auth_key {
433        request_builder = request_builder.header(AUTHORIZATION, key);
434    }
435    if let Some(meta) = client_metadata_header {
436        request_builder = request_builder.header(CLIENT_METADATA_HEADER, meta);
437    }
438
439    request_builder.body(()).map_err(|e| {
440        DeltasError::TransportError(format!("Failed to build connection request: {e}"))
441    })
442}
443
444/// Tycho client websocket implementation.
445impl WsDeltasClient {
446    // Construct a new client with 5 reconnection attempts.
447    pub fn new(ws_uri: &str, auth_key: Option<&str>) -> Result<Self, DeltasError> {
448        let uri = ws_uri
449            .parse::<Uri>()
450            .map_err(|e| DeltasError::UriParsing(ws_uri.to_string(), e.to_string()))?;
451        Ok(Self {
452            uri,
453            auth_key: auth_key.map(|s| s.to_string()),
454            inner: Arc::new(Mutex::new(None)),
455            ws_buffer_size: 256,
456            subscription_buffer_size: 256,
457            conn_notify: Arc::new(Notify::new()),
458            max_reconnects: 5,
459            retry_cooldown: Duration::from_millis(500),
460            dead: Arc::new(AtomicBool::new(false)),
461            client_metadata_header: None,
462        })
463    }
464
465    // Construct a new client with a custom number of reconnection attempts.
466    pub fn new_with_reconnects(
467        ws_uri: &str,
468        auth_key: Option<&str>,
469        max_reconnects: u64,
470        retry_cooldown: Duration,
471    ) -> Result<Self, DeltasError> {
472        let uri = ws_uri
473            .parse::<Uri>()
474            .map_err(|e| DeltasError::UriParsing(ws_uri.to_string(), e.to_string()))?;
475
476        Ok(Self {
477            uri,
478            auth_key: auth_key.map(|s| s.to_string()),
479            inner: Arc::new(Mutex::new(None)),
480            ws_buffer_size: 128,
481            subscription_buffer_size: 128,
482            conn_notify: Arc::new(Notify::new()),
483            max_reconnects,
484            retry_cooldown,
485            dead: Arc::new(AtomicBool::new(false)),
486            client_metadata_header: None,
487        })
488    }
489
490    /// Sets the pre-serialized client-metadata header value. `None` sends no header.
491    pub fn with_client_metadata_header(mut self, header: Option<String>) -> Self {
492        self.client_metadata_header = header;
493        self
494    }
495
496    // Construct a new client with custom buffer sizes (for testing)
497    #[cfg(test)]
498    pub fn new_with_custom_buffers(
499        ws_uri: &str,
500        auth_key: Option<&str>,
501        ws_buffer_size: usize,
502        subscription_buffer_size: usize,
503    ) -> Result<Self, DeltasError> {
504        let uri = ws_uri
505            .parse::<Uri>()
506            .map_err(|e| DeltasError::UriParsing(ws_uri.to_string(), e.to_string()))?;
507        Ok(Self {
508            uri,
509            auth_key: auth_key.map(|s| s.to_string()),
510            inner: Arc::new(Mutex::new(None)),
511            ws_buffer_size,
512            subscription_buffer_size,
513            conn_notify: Arc::new(Notify::new()),
514            max_reconnects: 5,
515            retry_cooldown: Duration::from_millis(0),
516            dead: Arc::new(AtomicBool::new(false)),
517            client_metadata_header: None,
518        })
519    }
520
521    /// Ensures that the client is connected.
522    ///
523    /// This method will acquire the lock for inner.
524    async fn is_connected(&self) -> bool {
525        let guard = self.inner.as_ref().lock().await;
526        guard.is_some()
527    }
528
529    /// Waits for the client to be connected
530    ///
531    /// This method acquires the lock for inner for a short period, then waits until the
532    /// connection is established if not already connected.
533    async fn ensure_connection(&self) -> Result<(), DeltasError> {
534        // Loop until either permanently dead or successfully connected. A single wait-and-check
535        // is not enough: the WS can reconnect briefly then drop again (e.g. server restart),
536        // which would fire conn_notify but leave is_connected() false. Looping retries
537        // automatically on that transient race.
538        loop {
539            if self.dead.load(Ordering::SeqCst) {
540                return Err(DeltasError::NotConnected);
541            }
542            if self.is_connected().await {
543                return Ok(());
544            }
545            // Enable the future BEFORE re-checking is_connected to close the race window where
546            // the reconnect task calls notify_waiters() between is_connected() returning false
547            // and notified().await — without enable(), that notification would be lost.
548            let notified = self.conn_notify.notified();
549            tokio::pin!(notified);
550            notified.as_mut().enable();
551            if !self.is_connected().await {
552                notified.await;
553            }
554            // Loop back: recheck dead and is_connected. If the WS dropped again between the
555            // notification and here, we wait for the next reconnect rather than failing.
556        }
557    }
558
559    /// Main message handling logic
560    ///
561    /// If the message returns an error, a reconnect attempt may be considered depending on the
562    /// error type.
563    #[instrument(skip(self, msg))]
564    async fn handle_msg(
565        &self,
566        msg: Result<tungstenite::protocol::Message, tokio_tungstenite::tungstenite::error::Error>,
567    ) -> Result<(), DeltasError> {
568        let mut guard = self.inner.lock().await;
569
570        match msg {
571            // We do not deserialize the message directly into a WebSocketMessage. This is because
572            // the serde arbitrary_precision feature (often included in many
573            // dependencies we use) breaks some untagged enum deserializations. Instead,
574            // 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
575            Ok(tungstenite::protocol::Message::Text(text)) => match serde_json::from_str::<
576                serde_json::Value,
577            >(&text)
578            {
579                Ok(value) => match serde_json::from_value::<WebSocketMessage>(value) {
580                    Ok(ws_message) => match ws_message {
581                        WebSocketMessage::BlockAggregatedChanges { subscription_id, deltas } => {
582                            Self::handle_block_changes_msg(&mut guard, subscription_id, deltas)
583                                .await?;
584                        }
585                        WebSocketMessage::Response(Response::NewSubscription {
586                            extractor_id,
587                            subscription_id,
588                        }) => {
589                            info!(?extractor_id, ?subscription_id, "Received a new subscription");
590                            let inner = guard
591                                .as_mut()
592                                .ok_or_else(|| DeltasError::NotConnected)?;
593                            inner.mark_active(extractor_id.into(), subscription_id);
594                        }
595                        WebSocketMessage::Response(Response::SubscriptionEnded {
596                            subscription_id,
597                        }) => {
598                            info!(?subscription_id, "Received a subscription ended");
599                            let inner = guard
600                                .as_mut()
601                                .ok_or_else(|| DeltasError::NotConnected)?;
602                            inner.remove_subscription(subscription_id)?;
603                        }
604                        WebSocketMessage::Response(Response::Error(error)) => match &error {
605                            WebsocketError::ExtractorNotFound(extractor_id) => {
606                                let inner = guard
607                                    .as_mut()
608                                    .ok_or_else(|| DeltasError::NotConnected)?;
609                                inner.cancel_pending(extractor_id.clone().into(), &error);
610                            }
611                            WebsocketError::SubscriptionNotFound(subscription_id) => {
612                                debug!("Received subscription not found, removing subscription");
613                                let inner = guard
614                                    .as_mut()
615                                    .ok_or_else(|| DeltasError::NotConnected)?;
616                                inner.remove_subscription(*subscription_id)?;
617                            }
618                            WebsocketError::ParseError(raw, e) => {
619                                return Err(DeltasError::ServerError(format!(
620                                    "Server failed to parse client message: {e}, msg: {raw}"
621                                )))
622                            }
623                            WebsocketError::CompressionError(subscription_id, e) => {
624                                return Err(DeltasError::ServerError(format!(
625                                    "Server failed to compress message for subscription: \
626                                     {subscription_id}, error: {e}"
627                                )))
628                            }
629                            WebsocketError::SubscribeError(extractor_id) => {
630                                let inner = guard
631                                    .as_mut()
632                                    .ok_or_else(|| DeltasError::NotConnected)?;
633                                inner.cancel_pending(extractor_id.clone().into(), &error);
634                            }
635                        },
636                    },
637                    Err(e) => {
638                        error!(
639                            "Failed to deserialize WebSocketMessage: {}. \nMessage: {}",
640                            e, text
641                        );
642                    }
643                },
644                Err(e) => {
645                    error!(
646                        "Failed to deserialize message: invalid JSON. {} \nMessage: {}",
647                        e, text
648                    );
649                }
650            },
651            Ok(tungstenite::protocol::Message::Binary(data)) => {
652                // Decompress the zstd-compressed data,
653                // Note that we only support compressed BlockAggregatedChanges messages for now.
654                match zstd::decode_all(data.as_slice()) {
655                    Ok(decompressed) => {
656                        match serde_json::from_slice::<serde_json::Value>(decompressed.as_slice()) {
657                            Ok(value) => {
658                                match serde_json::from_value::<WebSocketMessage>(value.clone()) {
659                                    Ok(ws_message) => match ws_message {
660                                        WebSocketMessage::BlockAggregatedChanges {
661                                            subscription_id,
662                                            deltas,
663                                        } => {
664                                            Self::handle_block_changes_msg(
665                                                &mut guard,
666                                                subscription_id,
667                                                deltas,
668                                            )
669                                            .await?;
670                                        }
671                                        _ => {
672                                            error!(
673                                                "Received unsupported compressed WebSocketMessage variant. \nMessage: {ws_message:?}",
674                                            );
675                                        }
676                                    },
677                                    Err(e) => {
678                                        error!(
679                                            "Failed to deserialize compressed WebSocketMessage: {e}. \nMessage: {value:?}",
680                                        );
681                                    }
682                                }
683                            }
684                            Err(e) => {
685                                error!(
686                                    "Failed to deserialize compressed message: invalid JSON. {e}",
687                                );
688                            }
689                        }
690                    }
691                    Err(e) => {
692                        error!("Failed to decompress zstd data: {}", e);
693                    }
694                }
695            }
696            Ok(tungstenite::protocol::Message::Ping(_)) => {
697                // Respond to pings with pongs.
698                let inner = guard
699                    .as_mut()
700                    .ok_or_else(|| DeltasError::NotConnected)?;
701                if let Err(error) = inner
702                    .ws_send(tungstenite::protocol::Message::Pong(Vec::new()))
703                    .await
704                {
705                    debug!(?error, "Failed to send pong!");
706                }
707            }
708            Ok(tungstenite::protocol::Message::Pong(_)) => {
709                // Do nothing.
710            }
711            Ok(tungstenite::protocol::Message::Close(frame)) => {
712                match &frame {
713                    Some(f) => {
714                        warn!(code = ?f.code, reason = %f.reason, "WebSocket closed by server")
715                    }
716                    None => warn!("WebSocket closed by server (no close frame)"),
717                }
718                return Err(DeltasError::ConnectionClosed);
719            }
720            Ok(unknown_msg) => {
721                info!("Received an unknown message type: {:?}", unknown_msg);
722            }
723            Err(error) => {
724                error!(?error, "Websocket error");
725                return Err(match error {
726                    tungstenite::Error::ConnectionClosed => DeltasError::ConnectionClosed,
727                    tungstenite::Error::AlreadyClosed => {
728                        warn!("Received AlreadyClosed error which is indicative of a bug!");
729                        DeltasError::ConnectionError(Box::new(error))
730                    }
731                    tungstenite::Error::Io(_) | tungstenite::Error::Protocol(_) => {
732                        DeltasError::ConnectionError(Box::new(error))
733                    }
734                    _ => DeltasError::Fatal(error.to_string()),
735                });
736            }
737        };
738        Ok(())
739    }
740
741    async fn handle_block_changes_msg(
742        guard: &mut MutexGuard<'_, Option<Inner>>,
743        subscription_id: Uuid,
744        deltas: dto::BlockAggregatedChanges,
745    ) -> Result<(), DeltasError> {
746        trace!(?deltas, "Received a block state change, sending to channel");
747        let inner = guard
748            .as_mut()
749            .ok_or_else(|| DeltasError::NotConnected)?;
750        match inner.send(&subscription_id, BlockAggregatedChanges::from(deltas)) {
751            Err(DeltasError::BufferFull) => {
752                error!(?subscription_id, "Buffer full, unsubscribing!");
753                Self::force_unsubscribe(subscription_id, inner).await;
754            }
755            Err(_) => {
756                warn!(?subscription_id, "Receiver for has gone away, unsubscribing!");
757                Self::force_unsubscribe(subscription_id, inner).await;
758            }
759            _ => { /* Do nothing */ }
760        }
761        Ok(())
762    }
763
764    /// Forcefully ends a (client) stream by unsubscribing.
765    ///
766    /// Is used only if the message can't be processed due to an error that might resolve
767    /// itself by resubscribing.
768    async fn force_unsubscribe(subscription_id: Uuid, inner: &mut Inner) {
769        // avoid unsubscribing multiple times
770        if let Some(SubscriptionInfo::RequestedUnsubscription(_)) = inner
771            .subscriptions
772            .get(&subscription_id)
773        {
774            return;
775        }
776
777        let (tx, rx) = oneshot::channel();
778        if let Err(e) = WsDeltasClient::unsubscribe_inner(inner, subscription_id, tx).await {
779            warn!(?e, ?subscription_id, "Failed to send unsubscribe command");
780        } else {
781            // Wait for unsubscribe completion with timeout
782            match tokio::time::timeout(Duration::from_secs(5), rx).await {
783                Ok(_) => {
784                    debug!(?subscription_id, "Unsubscribe completed successfully");
785                }
786                Err(_) => {
787                    warn!(?subscription_id, "Unsubscribe completion timed out");
788                }
789            }
790        }
791    }
792
793    /// Helper method to force an unsubscription
794    ///
795    /// This method expects to receive a mutable reference to `Inner` so it does not acquire a
796    /// lock. Used for normal unsubscribes as well to remove any subscriptions with deallocated
797    /// receivers.
798    async fn unsubscribe_inner(
799        inner: &mut Inner,
800        subscription_id: Uuid,
801        ready_tx: oneshot::Sender<()>,
802    ) -> Result<(), DeltasError> {
803        debug!(?subscription_id, "Unsubscribing");
804        inner.end_subscription(&subscription_id, ready_tx);
805        let cmd = Command::Unsubscribe { subscription_id };
806        inner
807            .ws_send(tungstenite::protocol::Message::Text(serde_json::to_string(&cmd).map_err(
808                |e| {
809                    DeltasError::TransportError(format!(
810                        "Failed to serialize unsubscribe command: {e}"
811                    ))
812                },
813            )?))
814            .await?;
815        Ok(())
816    }
817}
818
819#[async_trait]
820impl DeltasClient for WsDeltasClient {
821    #[instrument(skip(self))]
822    async fn subscribe(
823        &self,
824        extractor_id: ExtractorIdentity,
825        options: SubscriptionOptions,
826    ) -> Result<(Uuid, Receiver<BlockAggregatedChanges>), DeltasError> {
827        trace!("Starting subscribe");
828        self.ensure_connection().await?;
829        let (ready_tx, ready_rx) = oneshot::channel();
830        {
831            let mut guard = self.inner.lock().await;
832            let inner = guard
833                .as_mut()
834                .ok_or_else(|| DeltasError::NotConnected)?;
835            trace!("Sending subscribe command");
836            inner.new_subscription(&extractor_id, ready_tx)?;
837            let cmd = Command::Subscribe {
838                extractor_id: extractor_id.into(),
839                include_state: options.include_state,
840                compression: options.compression,
841                partial_blocks: options.partial_blocks,
842            };
843            inner
844                .ws_send(tungstenite::protocol::Message::Text(
845                    serde_json::to_string(&cmd).map_err(|e| {
846                        DeltasError::TransportError(format!(
847                            "Failed to serialize subscribe command: {e}"
848                        ))
849                    })?,
850                ))
851                .await?;
852        }
853        trace!("Waiting for subscription response");
854        let res = tokio::time::timeout(Duration::from_secs(30), ready_rx)
855            .await
856            .map_err(|_| {
857                DeltasError::TransportError(
858                    "Subscribe confirmation timed out after 30s".to_string(),
859                )
860            })?
861            .map_err(|_| {
862                DeltasError::TransportError("Subscription channel closed unexpectedly".to_string())
863            })??;
864        trace!("Subscription successful");
865        Ok(res)
866    }
867
868    #[instrument(skip(self))]
869    async fn unsubscribe(&self, subscription_id: Uuid) -> Result<(), DeltasError> {
870        self.ensure_connection().await?;
871        let (ready_tx, ready_rx) = oneshot::channel();
872        {
873            let mut guard = self.inner.lock().await;
874            let inner = guard
875                .as_mut()
876                .ok_or_else(|| DeltasError::NotConnected)?;
877
878            WsDeltasClient::unsubscribe_inner(inner, subscription_id, ready_tx).await?;
879        }
880        tokio::time::timeout(Duration::from_secs(5), ready_rx)
881            .await
882            .map_err(|_| {
883                warn!(?subscription_id, "Unsubscribe confirmation timed out after 5s");
884                DeltasError::TransportError(
885                    "Unsubscribe confirmation timed out after 5s".to_string(),
886                )
887            })?
888            .map_err(|_| {
889                DeltasError::TransportError("Unsubscribe channel closed unexpectedly".to_string())
890            })?;
891
892        Ok(())
893    }
894
895    #[instrument(skip(self))]
896    async fn connect(&self) -> Result<JoinHandle<Result<(), DeltasError>>, DeltasError> {
897        if self.is_connected().await {
898            return Err(DeltasError::AlreadyConnected);
899        }
900        let ws_uri = format!("{uri}{TYCHO_SERVER_VERSION}/ws", uri = self.uri);
901        info!(?ws_uri, "Starting TychoWebsocketClient");
902
903        let (cmd_tx, mut cmd_rx) = mpsc::channel(self.ws_buffer_size);
904        {
905            let mut guard = self.inner.as_ref().lock().await;
906            *guard = None;
907        }
908        let this = self.clone();
909        let jh = tokio::spawn(async move {
910            let mut retry_count = 0;
911            let mut result = Err(DeltasError::NotConnected);
912
913            'retry: while retry_count < this.max_reconnects {
914                info!(?ws_uri, retry_count, "Connecting to WebSocket server");
915                if retry_count > 0 {
916                    sleep(this.retry_cooldown).await;
917                }
918
919                let request = build_ws_handshake_request(
920                    &ws_uri,
921                    &this.uri,
922                    this.auth_key.as_deref(),
923                    this.client_metadata_header.as_deref(),
924                )?;
925                let (conn, _) = match connect_async(request).await {
926                    Ok(conn) => conn,
927                    Err(e) => {
928                        // Prepare for reconnection
929                        retry_count += 1;
930                        let mut guard = this.inner.as_ref().lock().await;
931                        *guard = None;
932
933                        if let tungstenite::Error::Http(response) = &e {
934                            if response.status() == tungstenite::http::StatusCode::TOO_MANY_REQUESTS
935                            {
936                                let reason = response
937                                    .body()
938                                    .as_deref()
939                                    .and_then(|b| std::str::from_utf8(b).ok())
940                                    .unwrap_or("")
941                                    .to_string();
942                                warn!(reason, "WebSocket connection rejected: rate limited");
943                                continue 'retry;
944                            }
945                        }
946
947                        warn!(
948                            e = e.to_string(),
949                            "Failed to connect to WebSocket server; Reconnecting"
950                        );
951                        continue 'retry;
952                    }
953                };
954
955                let (ws_tx_new, ws_rx_new) = conn.split();
956                {
957                    let mut guard = this.inner.as_ref().lock().await;
958                    *guard =
959                        Some(Inner::new(cmd_tx.clone(), ws_tx_new, this.subscription_buffer_size));
960                }
961                let mut msg_rx = ws_rx_new.boxed();
962
963                info!("Connection Successful: TychoWebsocketClient started");
964                this.conn_notify.notify_waiters();
965                result = Ok(());
966
967                // If no WS frame arrives within this window the TCP connection is
968                // considered stalled (e.g. network cut without a TCP RST/FIN). The OS
969                // default keepalive fires after ~2 hours; this gives us an
970                // application-level detection that is orders of magnitude faster.
971                const IDLE_TIMEOUT: Duration = Duration::from_secs(60);
972                loop {
973                    let res = tokio::select! {
974                        msg_result = tokio::time::timeout(IDLE_TIMEOUT, msg_rx.next()) => {
975                            match msg_result {
976                                Err(_elapsed) => {
977                                    warn!("No WS frame received for {IDLE_TIMEOUT:?}, \
978                                           treating connection as stalled; Reconnecting...");
979                                    retry_count += 1;
980                                    let mut guard = this.inner.as_ref().lock().await;
981                                    *guard = None;
982                                    break; // break inner loop → reconnect
983                                }
984                                Ok(Some(msg)) => this.handle_msg(msg).await,
985                                Ok(None) => {
986                                    // This code should not be reachable since the stream
987                                    // should return ConnectionClosed in the case above
988                                    // before it returns None here.
989                                    warn!("Websocket connection silently closed, giving up!");
990                                    break 'retry
991                                }
992                            }
993                        },
994                        _ = cmd_rx.recv() => {break 'retry},
995                    };
996                    if let Err(error) = res {
997                        debug!(?error, "WsError");
998                        if matches!(
999                            error,
1000                            DeltasError::ConnectionClosed | DeltasError::ConnectionError { .. }
1001                        ) {
1002                            // Prepare for reconnection
1003                            retry_count += 1;
1004                            let mut guard = this.inner.as_ref().lock().await;
1005                            *guard = None;
1006
1007                            warn!(
1008                                ?error,
1009                                ?retry_count,
1010                                "Connection dropped unexpectedly; Reconnecting..."
1011                            );
1012                            break;
1013                        } else {
1014                            // Other errors are considered fatal
1015                            error!(?error, "Fatal error; Exiting");
1016                            result = Err(error);
1017                            break 'retry;
1018                        }
1019                    }
1020                }
1021            }
1022            debug!(
1023                retry_count,
1024                max_reconnects=?this.max_reconnects,
1025                "Reconnection loop ended"
1026            );
1027            // Clean up before exiting
1028            let mut guard = this.inner.as_ref().lock().await;
1029            *guard = None;
1030
1031            // Check if max retries has been reached.
1032            if retry_count >= this.max_reconnects {
1033                error!("Max reconnection attempts reached; Exiting");
1034                this.dead.store(true, Ordering::SeqCst);
1035                this.conn_notify.notify_waiters(); // Notify that the task is done
1036                result = Err(DeltasError::ConnectionClosed);
1037            }
1038
1039            result
1040        });
1041
1042        self.conn_notify.notified().await;
1043
1044        if self.is_connected().await {
1045            Ok(jh)
1046        } else {
1047            Err(DeltasError::NotConnected)
1048        }
1049    }
1050
1051    #[instrument(skip(self))]
1052    async fn close(&self) -> Result<(), DeltasError> {
1053        info!("Closing TychoWebsocketClient");
1054        {
1055            let mut guard = self.inner.lock().await;
1056            if let Some(inner) = guard.as_mut() {
1057                inner
1058                    .cmd_tx
1059                    .send(())
1060                    .await
1061                    .map_err(|e| DeltasError::TransportError(e.to_string()))?;
1062            }
1063        }
1064        // Mark dead and notify so any ensure_connection() callers blocked on conn_notify
1065        // unblock immediately and return NotConnected rather than hanging forever.
1066        self.dead.store(true, Ordering::SeqCst);
1067        self.conn_notify.notify_waiters();
1068        Ok(())
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use std::{net::SocketAddr, str::FromStr};
1075
1076    use tokio::{net::TcpListener, time::timeout};
1077    use tycho_common::models::Chain;
1078
1079    use super::*;
1080
1081    #[derive(Clone)]
1082    enum ExpectedComm {
1083        Receive(u64, tungstenite::protocol::Message),
1084        Send(tungstenite::protocol::Message),
1085    }
1086
1087    async fn mock_tycho_ws(
1088        messages: &[ExpectedComm],
1089        reconnects: usize,
1090    ) -> (SocketAddr, JoinHandle<()>) {
1091        info!("Starting mock webserver");
1092        // zero port here means the OS chooses an open port
1093        let server = TcpListener::bind("127.0.0.1:0")
1094            .await
1095            .expect("localhost bind failed");
1096        let addr = server.local_addr().unwrap();
1097        let messages = messages.to_vec();
1098
1099        let jh = tokio::spawn(async move {
1100            info!("mock webserver started");
1101            for _ in 0..(reconnects + 1) {
1102                info!("Awaiting client connections");
1103                if let Ok((stream, _)) = server.accept().await {
1104                    info!("Client connected");
1105                    let mut websocket = tokio_tungstenite::accept_async(stream)
1106                        .await
1107                        .unwrap();
1108
1109                    info!("Handling messages..");
1110                    for c in messages.iter().cloned() {
1111                        match c {
1112                            ExpectedComm::Receive(t, exp) => {
1113                                info!("Awaiting message...");
1114                                let msg = timeout(Duration::from_millis(t), websocket.next())
1115                                    .await
1116                                    .expect("Receive timeout")
1117                                    .expect("Stream exhausted")
1118                                    .expect("Failed to receive message.");
1119                                info!("Message received");
1120                                assert_eq!(msg, exp)
1121                            }
1122                            ExpectedComm::Send(data) => {
1123                                info!("Sending message");
1124                                websocket
1125                                    .send(data)
1126                                    .await
1127                                    .expect("Failed to send message");
1128                                info!("Message sent");
1129                            }
1130                        };
1131                    }
1132                    info!("Mock communication completed");
1133                    sleep(Duration::from_millis(100)).await;
1134                    // Close the WebSocket connection
1135                    let _ = websocket.close(None).await;
1136                    info!("Mock server closed connection");
1137                }
1138            }
1139            info!("mock server ended");
1140        });
1141        (addr, jh)
1142    }
1143
1144    const SUBSCRIPTION_ID: &str = "30b740d1-cf09-4e0e-8cfe-b1434d447ece";
1145
1146    fn subscribe() -> String {
1147        subscribe_with_compression(false)
1148    }
1149
1150    fn subscribe_with_compression(compression: bool) -> String {
1151        // Field order matches Command::Subscribe's serde tag + struct field declaration order.
1152        format!(
1153            r#"{{"method":"subscribe","extractor_id":{{"chain":"ethereum","name":"vm:ambient"}},"include_state":true,"compression":{compression},"partial_blocks":false}}"#
1154        )
1155    }
1156
1157    fn subscription_confirmation() -> String {
1158        r#"
1159        {
1160            "method": "newsubscription",
1161            "extractor_id":{
1162                "chain": "ethereum",
1163                "name": "vm:ambient"
1164            },
1165            "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1166        }
1167        "#
1168        .replace(|c: char| c.is_whitespace(), "")
1169    }
1170
1171    fn block_deltas() -> String {
1172        r#"
1173        {
1174            "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1175            "deltas": {
1176                "extractor": "vm:ambient",
1177                "chain": "ethereum",
1178                "block": {
1179                    "number": 123,
1180                    "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1181                    "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1182                    "chain": "ethereum",
1183                    "ts": "2023-09-14T00:00:00"
1184                },
1185                "finalized_block_height": 0,
1186                "revert": false,
1187                "new_tokens": {},
1188                "account_updates": {
1189                    "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1190                        "address": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1191                        "chain": "ethereum",
1192                        "slots": {},
1193                        "balance": "0x01f4",
1194                        "code": "",
1195                        "change": "Update"
1196                    }
1197                },
1198                "state_updates": {
1199                    "component_1": {
1200                        "component_id": "component_1",
1201                        "updated_attributes": {"attr1": "0x01"},
1202                        "deleted_attributes": ["attr2"]
1203                    }
1204                },
1205                "new_protocol_components":
1206                    { "protocol_1": {
1207                            "id": "protocol_1",
1208                            "protocol_system": "system_1",
1209                            "protocol_type_name": "type_1",
1210                            "chain": "ethereum",
1211                            "tokens": ["0x01", "0x02"],
1212                            "contract_ids": ["0x01", "0x02"],
1213                            "static_attributes": {"attr1": "0x01f4"},
1214                            "change": "Update",
1215                            "creation_tx": "0x01",
1216                            "created_at": "2023-09-14T00:00:00"
1217                        }
1218                    },
1219                "deleted_protocol_components": {},
1220                "component_balances": {
1221                    "protocol_1":
1222                        {
1223                            "0x01": {
1224                                "token": "0x01",
1225                                "balance": "0x01f4",
1226                                "balance_float": 0.0,
1227                                "modify_tx": "0x01",
1228                                "component_id": "protocol_1"
1229                            }
1230                        }
1231                },
1232                "account_balances": {
1233                    "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1234                        "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1235                            "account": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1236                            "token": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1237                            "balance": "0x01f4",
1238                            "modify_tx": "0x01"
1239                        }
1240                    }
1241                },
1242                "component_tvl": {
1243                    "protocol_1": 1000.0
1244                },
1245                "dci_update": {
1246                    "new_entrypoints": {},
1247                    "new_entrypoint_params": {},
1248                    "trace_results": {}
1249                }
1250            }
1251        }
1252        "#.replace(|c: char| c.is_whitespace(), "")
1253    }
1254
1255    fn unsubscribe() -> String {
1256        r#"
1257        {
1258            "method": "unsubscribe",
1259            "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1260        }
1261        "#
1262        .replace(|c: char| c.is_whitespace(), "")
1263    }
1264
1265    fn subscription_ended() -> String {
1266        r#"
1267        {
1268            "method": "subscriptionended",
1269            "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece"
1270        }
1271        "#
1272        .replace(|c: char| c.is_whitespace(), "")
1273    }
1274
1275    #[test]
1276    fn test_ws_handshake_sends_client_metadata_when_set() {
1277        let uri = Uri::from_str("ws://localhost:4242/").unwrap();
1278        let request = build_ws_handshake_request(
1279            "ws://localhost:4242/v1/ws",
1280            &uri,
1281            None,
1282            Some("fynd_version=0.57.0"),
1283        )
1284        .unwrap();
1285        assert_eq!(
1286            request
1287                .headers()
1288                .get(CLIENT_METADATA_HEADER)
1289                .map(|v| v.to_str().unwrap()),
1290            Some("fynd_version=0.57.0")
1291        );
1292        assert_eq!(
1293            request
1294                .headers()
1295                .get(USER_AGENT)
1296                .unwrap(),
1297            format!("tycho-client-{}", env!("CARGO_PKG_VERSION")).as_str()
1298        );
1299    }
1300
1301    #[test]
1302    fn test_ws_handshake_omits_client_metadata_when_unset() {
1303        let uri = Uri::from_str("ws://localhost:4242/").unwrap();
1304        let request =
1305            build_ws_handshake_request("ws://localhost:4242/v1/ws", &uri, None, None).unwrap();
1306        assert!(request
1307            .headers()
1308            .get(CLIENT_METADATA_HEADER)
1309            .is_none());
1310        assert_eq!(
1311            request
1312                .headers()
1313                .get(USER_AGENT)
1314                .unwrap(),
1315            format!("tycho-client-{}", env!("CARGO_PKG_VERSION")).as_str()
1316        );
1317    }
1318
1319    #[tokio::test]
1320    async fn test_uncompressed_subscribe_receive() {
1321        let exp_comm = [
1322            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1323            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1324            ExpectedComm::Send(tungstenite::protocol::Message::Text(block_deltas())),
1325        ];
1326        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1327
1328        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1329        let jh = client
1330            .connect()
1331            .await
1332            .expect("connect failed");
1333        let (_, mut rx) = timeout(
1334            Duration::from_millis(100),
1335            client.subscribe(
1336                ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1337                SubscriptionOptions::new().with_compression(false),
1338            ),
1339        )
1340        .await
1341        .expect("subscription timed out")
1342        .expect("subscription failed");
1343        let _ = timeout(Duration::from_millis(100), rx.recv())
1344            .await
1345            .expect("awaiting message timeout out")
1346            .expect("receiving message failed");
1347        timeout(Duration::from_millis(100), client.close())
1348            .await
1349            .expect("close timed out")
1350            .expect("close failed");
1351        jh.await
1352            .expect("ws loop errored")
1353            .unwrap();
1354        server_thread.await.unwrap();
1355    }
1356
1357    #[tokio::test]
1358    async fn test_compressed_subscribe_receive() {
1359        let compressed_block_deltas = zstd::encode_all(
1360            block_deltas().as_bytes(),
1361            0, // default compression level
1362        )
1363        .expect("Failed to compress block deltas message");
1364
1365        let exp_comm = [
1366            ExpectedComm::Receive(
1367                100,
1368                tungstenite::protocol::Message::Text(subscribe_with_compression(true)),
1369            ),
1370            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1371            ExpectedComm::Send(tungstenite::protocol::Message::Binary(compressed_block_deltas)),
1372        ];
1373        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1374
1375        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1376        let jh = client
1377            .connect()
1378            .await
1379            .expect("connect failed");
1380        let (_, mut rx) = timeout(
1381            Duration::from_millis(100),
1382            client.subscribe(
1383                ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1384                SubscriptionOptions::new().with_compression(true),
1385            ),
1386        )
1387        .await
1388        .expect("subscription timed out")
1389        .expect("subscription failed");
1390        let _ = timeout(Duration::from_millis(100), rx.recv())
1391            .await
1392            .expect("awaiting message timeout out")
1393            .expect("receiving message failed");
1394        timeout(Duration::from_millis(100), client.close())
1395            .await
1396            .expect("close timed out")
1397            .expect("close failed");
1398        jh.await
1399            .expect("ws loop errored")
1400            .unwrap();
1401        server_thread.await.unwrap();
1402    }
1403
1404    #[tokio::test]
1405    async fn test_unsubscribe() {
1406        let exp_comm = [
1407            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1408            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1409            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
1410            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
1411        ];
1412        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1413
1414        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1415        let jh = client
1416            .connect()
1417            .await
1418            .expect("connect failed");
1419        let (sub_id, mut rx) = timeout(
1420            Duration::from_millis(100),
1421            client.subscribe(
1422                ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1423                SubscriptionOptions::new().with_compression(false),
1424            ),
1425        )
1426        .await
1427        .expect("subscription timed out")
1428        .expect("subscription failed");
1429
1430        timeout(Duration::from_millis(100), client.unsubscribe(sub_id))
1431            .await
1432            .expect("unsubscribe timed out")
1433            .expect("unsubscribe failed");
1434        let res = timeout(Duration::from_millis(100), rx.recv())
1435            .await
1436            .expect("awaiting message timeout out");
1437
1438        // If the subscription ended, the channel should have been closed.
1439        assert!(res.is_none());
1440
1441        timeout(Duration::from_millis(100), client.close())
1442            .await
1443            .expect("close timed out")
1444            .expect("close failed");
1445        jh.await
1446            .expect("ws loop errored")
1447            .unwrap();
1448        server_thread.await.unwrap();
1449    }
1450
1451    #[tokio::test]
1452    async fn test_subscription_unexpected_end() {
1453        let exp_comm = [
1454            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1455            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1456            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
1457        ];
1458        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1459
1460        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1461        let jh = client
1462            .connect()
1463            .await
1464            .expect("connect failed");
1465        let (_, mut rx) = timeout(
1466            Duration::from_millis(100),
1467            client.subscribe(
1468                ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1469                SubscriptionOptions::new().with_compression(false),
1470            ),
1471        )
1472        .await
1473        .expect("subscription timed out")
1474        .expect("subscription failed");
1475        let res = timeout(Duration::from_millis(100), rx.recv())
1476            .await
1477            .expect("awaiting message timeout out");
1478
1479        // If the subscription ended, the channel should have been closed.
1480        assert!(res.is_none());
1481
1482        timeout(Duration::from_millis(100), client.close())
1483            .await
1484            .expect("close timed out")
1485            .expect("close failed");
1486        jh.await
1487            .expect("ws loop errored")
1488            .unwrap();
1489        server_thread.await.unwrap();
1490    }
1491
1492    #[test_log::test(tokio::test)]
1493    async fn test_reconnect() {
1494        let exp_comm = [
1495            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe()
1496            )),
1497            ExpectedComm::Send(tungstenite::protocol::Message::Text(
1498                subscription_confirmation()
1499            )),
1500            ExpectedComm::Send(tungstenite::protocol::Message::Text(r#"
1501                {
1502                    "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1503                    "deltas": {
1504                        "extractor": "vm:ambient",
1505                        "chain": "ethereum",
1506                        "block": {
1507                            "number": 123,
1508                            "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1509                            "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1510                            "chain": "ethereum",             
1511                            "ts": "2023-09-14T00:00:00"
1512                        },
1513                        "finalized_block_height": 0,
1514                        "revert": false,
1515                        "new_tokens": {},
1516                        "account_updates": {
1517                            "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1518                                "address": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1519                                "chain": "ethereum",
1520                                "slots": {},
1521                                "balance": "0x01f4",
1522                                "code": "",
1523                                "change": "Update"
1524                            }
1525                        },
1526                        "state_updates": {
1527                            "component_1": {
1528                                "component_id": "component_1",
1529                                "updated_attributes": {"attr1": "0x01"},
1530                                "deleted_attributes": ["attr2"]
1531                            }
1532                        },
1533                        "new_protocol_components": {
1534                            "protocol_1":
1535                                {
1536                                    "id": "protocol_1",
1537                                    "protocol_system": "system_1",
1538                                    "protocol_type_name": "type_1",
1539                                    "chain": "ethereum",
1540                                    "tokens": ["0x01", "0x02"],
1541                                    "contract_ids": ["0x01", "0x02"],
1542                                    "static_attributes": {"attr1": "0x01f4"},
1543                                    "change": "Update",
1544                                    "creation_tx": "0x01",
1545                                    "created_at": "2023-09-14T00:00:00"
1546                                }
1547                            },
1548                        "deleted_protocol_components": {},
1549                        "component_balances": {
1550                            "protocol_1": {
1551                                "0x01": {
1552                                    "token": "0x01",
1553                                    "balance": "0x01f4",
1554                                    "balance_float": 1000.0,
1555                                    "modify_tx": "0x01",
1556                                    "component_id": "protocol_1"
1557                                }
1558                            }
1559                        },
1560                        "account_balances": {
1561                            "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1562                                "0x7a250d5630b4cf539739df2c5dacb4c659f2488d": {
1563                                    "account": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1564                                    "token": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
1565                                    "balance": "0x01f4",
1566                                    "modify_tx": "0x01"
1567                                }
1568                            }
1569                        },
1570                        "component_tvl": {
1571                            "protocol_1": 1000.0
1572                        },
1573                        "dci_update": {
1574                            "new_entrypoints": {},
1575                            "new_entrypoint_params": {},
1576                            "trace_results": {}
1577                        }
1578                    }
1579                }
1580                "#.to_owned()
1581            ))
1582        ];
1583        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 1).await;
1584        let client = WsDeltasClient::new_with_reconnects(
1585            &format!("ws://{addr}"),
1586            None,
1587            3,
1588            // server stays down for 100ms on connection drop
1589            Duration::from_millis(110),
1590        )
1591        .unwrap();
1592
1593        let jh: JoinHandle<Result<(), DeltasError>> = client
1594            .connect()
1595            .await
1596            .expect("connect failed");
1597
1598        for _ in 0..2 {
1599            dbg!("loop");
1600            let (_, mut rx) = timeout(
1601                Duration::from_millis(200),
1602                client.subscribe(
1603                    ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1604                    SubscriptionOptions::new().with_compression(false),
1605                ),
1606            )
1607            .await
1608            .expect("subscription timed out")
1609            .expect("subscription failed");
1610
1611            let _ = timeout(Duration::from_millis(100), rx.recv())
1612                .await
1613                .expect("awaiting message timeout out")
1614                .expect("receiving message failed");
1615
1616            // wait for the connection to drop
1617            let res = timeout(Duration::from_millis(200), rx.recv())
1618                .await
1619                .expect("awaiting closed connection timeout out");
1620            assert!(res.is_none());
1621        }
1622        let res = jh.await.expect("ws client join failed");
1623        // 5th client reconnect attempt should fail
1624        assert!(res.is_err());
1625        server_thread
1626            .await
1627            .expect("ws server loop errored");
1628    }
1629
1630    async fn mock_bad_connection_tycho_ws(accept_first: bool) -> (SocketAddr, JoinHandle<()>) {
1631        let server = TcpListener::bind("127.0.0.1:0")
1632            .await
1633            .expect("localhost bind failed");
1634        let addr = server.local_addr().unwrap();
1635        let jh = tokio::spawn(async move {
1636            while let Ok((stream, _)) = server.accept().await {
1637                if accept_first {
1638                    // Send connection handshake to accept the connection (fail later)
1639                    let stream = tokio_tungstenite::accept_async(stream)
1640                        .await
1641                        .unwrap();
1642                    sleep(Duration::from_millis(10)).await;
1643                    drop(stream)
1644                } else {
1645                    // Close the connection to simulate a failure
1646                    drop(stream);
1647                }
1648            }
1649        });
1650        (addr, jh)
1651    }
1652
1653    #[test_log::test(tokio::test)]
1654    async fn test_subscribe_dead_client_after_max_attempts() {
1655        let (addr, _) = mock_bad_connection_tycho_ws(true).await;
1656        let client = WsDeltasClient::new_with_reconnects(
1657            &format!("ws://{addr}"),
1658            None,
1659            3,
1660            Duration::from_secs(0),
1661        )
1662        .unwrap();
1663
1664        let join_handle = client.connect().await.unwrap();
1665        let handle_res = join_handle.await.unwrap();
1666        assert!(handle_res.is_err());
1667        assert!(!client.is_connected().await);
1668
1669        let subscription_res = timeout(
1670            Duration::from_millis(10),
1671            client.subscribe(
1672                ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1673                SubscriptionOptions::new(),
1674            ),
1675        )
1676        .await
1677        .unwrap();
1678        assert!(subscription_res.is_err());
1679    }
1680
1681    #[test_log::test(tokio::test)]
1682    async fn test_ws_client_retry_cooldown() {
1683        let start = std::time::Instant::now();
1684        let (addr, _) = mock_bad_connection_tycho_ws(false).await;
1685
1686        // Use the mock server that immediately drops connections
1687        let client = WsDeltasClient::new_with_reconnects(
1688            &format!("ws://{addr}"),
1689            None,
1690            3,                         // 3 attempts total (so 2 retries with cooldowns)
1691            Duration::from_millis(50), // 50ms cooldown
1692        )
1693        .unwrap();
1694
1695        // Try to connect - this should fail after retries but still measure the time
1696        let connect_result = client.connect().await;
1697        let elapsed = start.elapsed();
1698
1699        // Connection should fail after exhausting retries
1700        assert!(connect_result.is_err(), "Expected connection to fail after retries");
1701
1702        // Should have waited at least 100ms total (2 retries × 50ms cooldown each)
1703        assert!(
1704            elapsed >= Duration::from_millis(100),
1705            "Expected at least 100ms elapsed, got {:?}",
1706            elapsed
1707        );
1708
1709        // Should not take too long (max ~300ms for 3 attempts with some tolerance)
1710        assert!(elapsed < Duration::from_millis(500), "Took too long: {:?}", elapsed);
1711    }
1712
1713    #[test_log::test(tokio::test)]
1714    async fn test_buffer_full_triggers_unsubscribe() {
1715        // Expected communication sequence for buffer full scenario
1716        let exp_comm = {
1717            [
1718            // 1. Client subscribes
1719            ExpectedComm::Receive(
1720                100,
1721                tungstenite::protocol::Message::Text(
1722                    subscribe(),
1723                ),
1724            ),
1725            // 2. Server confirms subscription
1726            ExpectedComm::Send(tungstenite::protocol::Message::Text(
1727                subscription_confirmation(),
1728            )),
1729            // 3. Server sends first message (fills buffer)
1730            ExpectedComm::Send(tungstenite::protocol::Message::Text(
1731                r#"
1732                {
1733                    "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1734                    "deltas": {
1735                        "extractor": "vm:ambient",
1736                        "chain": "ethereum",
1737                        "block": {
1738                            "number": 123,
1739                            "hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1740                            "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1741                            "chain": "ethereum",
1742                            "ts": "2023-09-14T00:00:00"
1743                        },
1744                        "finalized_block_height": 0,
1745                        "revert": false,
1746                        "new_tokens": {},
1747                        "account_updates": {},
1748                        "state_updates": {},
1749                        "new_protocol_components": {},
1750                        "deleted_protocol_components": {},
1751                        "component_balances": {},
1752                        "account_balances": {},
1753                        "component_tvl": {},
1754                        "dci_update": {
1755                            "new_entrypoints": {},
1756                            "new_entrypoint_params": {},
1757                            "trace_results": {}
1758                        }
1759                    }
1760                }
1761                "#.to_owned()
1762            )),
1763            // 4. Server sends second message (triggers buffer overflow and force unsubscribe)
1764            ExpectedComm::Send(tungstenite::protocol::Message::Text(
1765                r#"
1766                {
1767                    "subscription_id": "30b740d1-cf09-4e0e-8cfe-b1434d447ece",
1768                    "deltas": {
1769                        "extractor": "vm:ambient",
1770                        "chain": "ethereum",
1771                        "block": {
1772                            "number": 124,
1773                            "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1774                            "parent_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
1775                            "chain": "ethereum",
1776                            "ts": "2023-09-14T00:00:01"
1777                        },
1778                        "finalized_block_height": 0,
1779                        "revert": false,
1780                        "new_tokens": {},
1781                        "account_updates": {},
1782                        "state_updates": {},
1783                        "new_protocol_components": {},
1784                        "deleted_protocol_components": {},
1785                        "component_balances": {},
1786                        "account_balances": {},
1787                        "component_tvl": {},
1788                        "dci_update": {
1789                            "new_entrypoints": {},
1790                            "new_entrypoint_params": {},
1791                            "trace_results": {}
1792                        }
1793                    }
1794                }
1795                "#.to_owned()
1796            )),
1797            // 5. Expect unsubscribe command due to buffer full
1798            ExpectedComm::Receive(
1799                100,
1800                tungstenite::protocol::Message::Text(
1801                    unsubscribe(),
1802                ),
1803            ),
1804            // 6. Server confirms unsubscription
1805            ExpectedComm::Send(tungstenite::protocol::Message::Text(
1806                subscription_ended(),
1807            )),
1808        ]
1809        };
1810
1811        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1812
1813        // Create client with very small buffer size (1) to easily trigger BufferFull
1814        let client = WsDeltasClient::new_with_custom_buffers(
1815            &format!("ws://{addr}"),
1816            None,
1817            128, // ws_buffer_size
1818            1,   // subscription_buffer_size - this will trigger BufferFull easily
1819        )
1820        .unwrap();
1821
1822        let jh = client
1823            .connect()
1824            .await
1825            .expect("connect failed");
1826
1827        let (_sub_id, mut rx) = timeout(
1828            Duration::from_millis(100),
1829            client.subscribe(
1830                ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
1831                SubscriptionOptions::new().with_compression(false),
1832            ),
1833        )
1834        .await
1835        .expect("subscription timed out")
1836        .expect("subscription failed");
1837
1838        // Allow time for messages to be processed and buffer to fill up
1839        tokio::time::sleep(Duration::from_millis(100)).await;
1840
1841        // Collect all messages until channel closes or we get a reasonable number
1842        let mut received_msgs = Vec::new();
1843
1844        // Use a single longer timeout to collect messages until channel closes
1845        while received_msgs.len() < 3 {
1846            match timeout(Duration::from_millis(200), rx.recv()).await {
1847                Ok(Some(msg)) => {
1848                    received_msgs.push(msg);
1849                }
1850                Ok(None) => {
1851                    // Channel closed - this is what we expect after buffer overflow
1852                    break;
1853                }
1854                Err(_) => {
1855                    // Timeout - no more messages coming
1856                    break;
1857                }
1858            }
1859        }
1860
1861        // Verify the key behavior: buffer overflow should limit messages and close channel
1862        assert!(
1863            received_msgs.len() <= 1,
1864            "Expected buffer overflow to limit messages to at most 1, got {}",
1865            received_msgs.len()
1866        );
1867
1868        if let Some(first_msg) = received_msgs.first() {
1869            assert_eq!(first_msg.block.number, 123, "Expected first message with block 123");
1870        }
1871
1872        // Test passed! The key behavior we're testing (buffer full causes force unsubscribe) has
1873        // been verified We don't need to explicitly close the client as it will be cleaned
1874        // up when dropped
1875
1876        // Just wait for the tasks to finish cleanly
1877        drop(rx); // Explicitly drop the receiver
1878        tokio::time::sleep(Duration::from_millis(50)).await;
1879
1880        // Abort the tasks to clean up
1881        jh.abort();
1882        server_thread.abort();
1883
1884        let _ = jh.await;
1885        let _ = server_thread.await;
1886    }
1887
1888    #[tokio::test]
1889    async fn test_server_error_handling() {
1890        use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
1891
1892        let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
1893
1894        // Test ExtractorNotFound error
1895        let error_response = WebSocketMessage::Response(Response::Error(
1896            WebsocketError::ExtractorNotFound(extractor_id.clone().into()),
1897        ));
1898        let error_json = serde_json::to_string(&error_response).unwrap();
1899
1900        let exp_comm = [
1901            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1902            ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
1903        ];
1904
1905        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1906
1907        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1908        let jh = client
1909            .connect()
1910            .await
1911            .expect("connect failed");
1912
1913        let result = timeout(
1914            Duration::from_millis(100),
1915            client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
1916        )
1917        .await
1918        .expect("subscription timed out");
1919
1920        // Verify that we get a ServerError
1921        assert!(result.is_err());
1922        if let Err(DeltasError::ServerError(msg)) = result {
1923            assert!(msg.contains("Subscription failed"));
1924            assert!(msg.contains("Extractor not found"));
1925        } else {
1926            panic!("Expected DeltasError::ServerError, got: {:?}", result);
1927        }
1928
1929        timeout(Duration::from_millis(100), client.close())
1930            .await
1931            .expect("close timed out")
1932            .expect("close failed");
1933        jh.await
1934            .expect("ws loop errored")
1935            .unwrap();
1936        server_thread.await.unwrap();
1937    }
1938
1939    #[test_log::test(tokio::test)]
1940    async fn test_subscription_not_found_error() {
1941        // Test scenario: Server restart causes subscription loss
1942        use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
1943
1944        let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
1945        let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
1946
1947        let error_response = WebSocketMessage::Response(Response::Error(
1948            WebsocketError::SubscriptionNotFound(subscription_id),
1949        ));
1950        let error_json = serde_json::to_string(&error_response).unwrap();
1951
1952        let exp_comm = [
1953            // 1. Client subscribes successfully
1954            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
1955            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
1956            // 2. Client tries to unsubscribe (server has "restarted" and lost subscription)
1957            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
1958            // 3. Server responds with SubscriptionNotFound (simulating server restart)
1959            ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
1960        ];
1961
1962        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
1963
1964        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
1965        let jh = client
1966            .connect()
1967            .await
1968            .expect("connect failed");
1969
1970        // Subscribe successfully
1971        let (received_sub_id, _rx) = timeout(
1972            Duration::from_millis(100),
1973            client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
1974        )
1975        .await
1976        .expect("subscription timed out")
1977        .expect("subscription failed");
1978
1979        assert_eq!(received_sub_id, subscription_id);
1980
1981        // Now try to unsubscribe - this should fail because server "restarted"
1982        let unsubscribe_result =
1983            timeout(Duration::from_millis(100), client.unsubscribe(subscription_id))
1984                .await
1985                .expect("unsubscribe timed out");
1986
1987        // The unsubscribe should handle the SubscriptionNotFound error gracefully
1988        // In this case, the client should treat it as successful since the subscription
1989        // is effectively gone (whether due to server restart or other reasons)
1990        unsubscribe_result
1991            .expect("Unsubscribe should succeed even if server says subscription not found");
1992
1993        timeout(Duration::from_millis(100), client.close())
1994            .await
1995            .expect("close timed out")
1996            .expect("close failed");
1997        jh.await
1998            .expect("ws loop errored")
1999            .unwrap();
2000        server_thread.await.unwrap();
2001    }
2002
2003    #[test_log::test(tokio::test)]
2004    async fn test_parse_error_handling() {
2005        use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2006
2007        let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2008        let error_response = WebSocketMessage::Response(Response::Error(
2009            WebsocketError::ParseError("}2sdf".to_string(), "malformed JSON".to_string()),
2010        ));
2011        let error_json = serde_json::to_string(&error_response).unwrap();
2012
2013        let exp_comm = [
2014            // subscribe first so connect can finish successfully
2015            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2016            ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2017        ];
2018
2019        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2020
2021        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2022        let jh = client
2023            .connect()
2024            .await
2025            .expect("connect failed");
2026
2027        // Subscribe successfully
2028        let _ = timeout(
2029            Duration::from_millis(100),
2030            client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
2031        )
2032        .await
2033        .expect("subscription timed out");
2034
2035        // The client should receive the parse error and close the connection
2036        let result = jh
2037            .await
2038            .expect("ws loop should complete");
2039        assert!(result.is_err());
2040        if let Err(DeltasError::ServerError(message)) = result {
2041            assert!(message.contains("Server failed to parse client message"));
2042        } else {
2043            panic!("Expected DeltasError::ServerError, got: {:?}", result);
2044        }
2045
2046        server_thread.await.unwrap();
2047    }
2048
2049    #[test_log::test(tokio::test)]
2050    async fn test_compression_error_handling() {
2051        use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2052
2053        let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2054        let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
2055        let error_response = WebSocketMessage::Response(Response::Error(
2056            WebsocketError::CompressionError(subscription_id, "Compression failed".to_string()),
2057        ));
2058        let error_json = serde_json::to_string(&error_response).unwrap();
2059
2060        let exp_comm = [
2061            // subscribe first so connect can finish successfully
2062            ExpectedComm::Receive(
2063                100,
2064                tungstenite::protocol::Message::Text(subscribe_with_compression(true)),
2065            ),
2066            ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2067        ];
2068
2069        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2070
2071        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2072        let jh = client
2073            .connect()
2074            .await
2075            .expect("connect failed");
2076
2077        // Subscribe successfully with compression disabled
2078        let _ = timeout(
2079            Duration::from_millis(100),
2080            client.subscribe(extractor_id, SubscriptionOptions::new()),
2081        )
2082        .await
2083        .expect("subscription timed out");
2084
2085        // The client should receive the parse error
2086        let result = jh
2087            .await
2088            .expect("ws loop should complete");
2089        assert!(result.is_err());
2090        if let Err(DeltasError::ServerError(message)) = result {
2091            assert!(message.contains("Server failed to compress message for subscription"));
2092        } else {
2093            panic!("Expected DeltasError::ServerError, got: {:?}", result);
2094        }
2095
2096        server_thread.await.unwrap();
2097    }
2098
2099    #[tokio::test]
2100    async fn test_subscribe_error_handling() {
2101        use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2102
2103        let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2104
2105        let error_response = WebSocketMessage::Response(Response::Error(
2106            WebsocketError::SubscribeError(extractor_id.clone().into()),
2107        ));
2108        let error_json = serde_json::to_string(&error_response).unwrap();
2109
2110        let exp_comm = [
2111            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2112            ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2113        ];
2114
2115        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2116
2117        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2118        let jh = client
2119            .connect()
2120            .await
2121            .expect("connect failed");
2122
2123        let result = timeout(
2124            Duration::from_millis(100),
2125            client.subscribe(extractor_id, SubscriptionOptions::new().with_compression(false)),
2126        )
2127        .await
2128        .expect("subscription timed out");
2129
2130        // Verify that we get a ServerError for subscribe failure
2131        assert!(result.is_err());
2132        if let Err(DeltasError::ServerError(msg)) = result {
2133            assert!(msg.contains("Subscription failed"));
2134            assert!(msg.contains("Failed to subscribe to extractor"));
2135        } else {
2136            panic!("Expected DeltasError::ServerError, got: {:?}", result);
2137        }
2138
2139        timeout(Duration::from_millis(100), client.close())
2140            .await
2141            .expect("close timed out")
2142            .expect("close failed");
2143        jh.await
2144            .expect("ws loop errored")
2145            .unwrap();
2146        server_thread.await.unwrap();
2147    }
2148
2149    #[tokio::test]
2150    async fn test_cancel_pending_subscription() {
2151        // This test verifies that pending subscriptions are properly cancelled when errors occur
2152        use tycho_common::dto::{Response, WebSocketMessage, WebsocketError};
2153
2154        let extractor_id = ExtractorIdentity::new(Chain::Ethereum, "vm:ambient");
2155
2156        let error_response = WebSocketMessage::Response(Response::Error(
2157            WebsocketError::ExtractorNotFound(extractor_id.clone().into()),
2158        ));
2159        let error_json = serde_json::to_string(&error_response).unwrap();
2160
2161        let exp_comm = [
2162            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2163            ExpectedComm::Send(tungstenite::protocol::Message::Text(error_json)),
2164        ];
2165
2166        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2167
2168        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2169        let jh = client
2170            .connect()
2171            .await
2172            .expect("connect failed");
2173
2174        // Start two subscription attempts simultaneously
2175        let client_clone = client.clone();
2176        let extractor_id_clone = extractor_id.clone();
2177
2178        let subscription1 = tokio::spawn({
2179            let client_for_spawn = client.clone();
2180            async move {
2181                client_for_spawn
2182                    .subscribe(extractor_id, SubscriptionOptions::new().with_compression(false))
2183                    .await
2184            }
2185        });
2186
2187        let subscription2 = tokio::spawn(async move {
2188            // This should fail because there's already a pending subscription
2189            client_clone
2190                .subscribe(extractor_id_clone, SubscriptionOptions::new())
2191                .await
2192        });
2193
2194        let (result1, result2) = tokio::join!(subscription1, subscription2);
2195
2196        let result1 = result1.unwrap();
2197        let result2 = result2.unwrap();
2198
2199        // One should fail due to ExtractorNotFound error from server
2200        // The other should fail due to SubscriptionAlreadyPending
2201        assert!(result1.is_err() || result2.is_err());
2202
2203        if let Err(DeltasError::SubscriptionAlreadyPending) = result2 {
2204            // This is expected for the second subscription
2205        } else if let Err(DeltasError::ServerError(_)) = result1 {
2206            // This is expected for the first subscription that gets the server error
2207        } else {
2208            panic!("Expected one SubscriptionAlreadyPending and one ServerError");
2209        }
2210
2211        timeout(Duration::from_millis(100), client.close())
2212            .await
2213            .expect("close timed out")
2214            .expect("close failed");
2215        jh.await
2216            .expect("ws loop errored")
2217            .unwrap();
2218        server_thread.await.unwrap();
2219    }
2220
2221    #[tokio::test]
2222    async fn test_force_unsubscribe_prevents_multiple_calls() {
2223        // Test that force_unsubscribe prevents sending duplicate unsubscribe commands
2224        // when called multiple times for the same subscription_id
2225
2226        let subscription_id = Uuid::from_str(SUBSCRIPTION_ID).unwrap();
2227
2228        let exp_comm = [
2229            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(subscribe())),
2230            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_confirmation())),
2231            // Expect only ONE unsubscribe message, even though force_unsubscribe is called twice
2232            ExpectedComm::Receive(100, tungstenite::protocol::Message::Text(unsubscribe())),
2233            ExpectedComm::Send(tungstenite::protocol::Message::Text(subscription_ended())),
2234        ];
2235
2236        let (addr, server_thread) = mock_tycho_ws(&exp_comm, 0).await;
2237
2238        let client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
2239        let jh = client
2240            .connect()
2241            .await
2242            .expect("connect failed");
2243
2244        let (received_sub_id, _rx) = timeout(
2245            Duration::from_millis(100),
2246            client.subscribe(
2247                ExtractorIdentity::new(Chain::Ethereum, "vm:ambient"),
2248                SubscriptionOptions::new().with_compression(false),
2249            ),
2250        )
2251        .await
2252        .expect("subscription timed out")
2253        .expect("subscription failed");
2254
2255        assert_eq!(received_sub_id, subscription_id);
2256
2257        // Access the inner state to call force_unsubscribe directly
2258        {
2259            let mut inner_guard = client.inner.lock().await;
2260            let inner = inner_guard
2261                .as_mut()
2262                .expect("client should be connected");
2263
2264            // Call force_unsubscribe twice - only the first should send an unsubscribe message
2265            WsDeltasClient::force_unsubscribe(subscription_id, inner).await;
2266            WsDeltasClient::force_unsubscribe(subscription_id, inner).await;
2267        }
2268
2269        // Give time for messages to be processed
2270        tokio::time::sleep(Duration::from_millis(50)).await;
2271
2272        // Close may fail if client disconnected after unsubscribe, which is fine
2273        let _ = timeout(Duration::from_millis(100), client.close()).await;
2274
2275        // Wait for tasks to complete
2276        let _ = jh.await;
2277        let _ = server_thread.await;
2278    }
2279}