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