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