Skip to main content

nautilus_lighter/websocket/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Outer WebSocket client orchestrating connection lifecycle and subscriptions.
17
18use std::{
19    fmt::Debug,
20    num::NonZeroU32,
21    sync::{
22        Arc,
23        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
24    },
25    time::Duration,
26};
27
28use arc_swap::ArcSwap;
29use dashmap::{DashMap, mapref::entry::Entry};
30#[cfg(test)]
31use nautilus_common::live::get_runtime;
32use nautilus_core::string::secret::{SecretString, redact_option};
33#[cfg(test)]
34use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
35use nautilus_live::{
36    SocketControl,
37    task::{SharedTaskSlot, TaskJoinOutcome, TaskSlot, finish_task},
38};
39use nautilus_model::{
40    identifiers::{AccountId, InstrumentId},
41    instruments::InstrumentAny,
42};
43use nautilus_network::{
44    ConnectionUrl, SocketStateSink,
45    http::create_standard_nautilus_headers,
46    mode::ConnectionMode,
47    websocket::{
48        InitialConnectRetryPolicy, SubscriptionState, TransportBackend, WebSocketClient,
49        WebSocketConfig, channel_epoch_message_handler,
50    },
51};
52use parking_lot::Mutex;
53use tokio_util::sync::CancellationToken;
54
55use crate::{
56    common::{
57        consts::{
58            DISCONNECT_TIMEOUT, HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, RECONNECT_BASE_BACKOFF,
59            RECONNECT_MAX_BACKOFF,
60        },
61        enums::{LighterCandleResolution, LighterEnvironment},
62        rate_limit::ws_message_rate_limiter,
63        symbol::MarketRegistry,
64        urls::lighter_ws_url,
65    },
66    websocket::{
67        error::LighterWsError,
68        handler::{FeedHandler, HandlerCommand},
69        messages::{LighterMarketSelection, LighterWsChannel, NautilusWsMessage},
70    },
71};
72
73const RECONNECT_JITTER_MS: u64 = 200;
74const RECONNECT_BACKOFF_FACTOR: f64 = 2.0;
75
76#[derive(Clone)]
77struct SubscriptionArgs {
78    channel: LighterWsChannel,
79    auth: Option<SecretString>,
80    generation: u64,
81}
82
83/// Outer Lighter WebSocket client.
84///
85/// Orchestrates the connection lifecycle and subscription bookkeeping for the
86/// Lighter streaming API. The inner feed handler runs on a dedicated tokio
87/// task and exclusively owns the underlying [`WebSocketClient`]; this outer
88/// type communicates with it through a command channel and consumes events
89/// over an unbounded mpsc.
90///
91/// Authenticated channels store their auth token in `subscription_args` and
92/// replay it verbatim on reconnect. That stored token stays valid because the
93/// execution client rotates it on a 6h cadence (inside the venue's 7h TTL) by
94/// re-issuing `subscribe_account`, so a reconnect never replays a connect-time,
95/// potentially-expired token.
96pub struct LighterWebSocketClient {
97    url: ConnectionUrl,
98    connection_mode: Arc<ArcSwap<AtomicU8>>,
99    connection_epoch: Arc<ArcSwap<AtomicU64>>,
100    connection_lock: Arc<tokio::sync::Mutex<()>>,
101    connection_generation: Arc<AtomicU64>,
102    initial_connect_cancellation: Arc<ArcSwap<CancellationToken>>,
103    signal: Arc<AtomicBool>,
104    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
105    out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
106    subscriptions: SubscriptionState,
107    subscription_args: Arc<DashMap<String, SubscriptionArgs>>,
108    next_subscription_generation: Arc<AtomicU64>,
109    instruments: Arc<DashMap<i64, InstrumentAny>>,
110    cache_lock: Arc<Mutex<()>>,
111    registry: Arc<MarketRegistry>,
112    task_handle: TaskSlot<()>,
113    transport_backend: TransportBackend,
114    ws_timeout_secs: u64,
115    book_snapshot_timeout: Duration,
116    proxy_url: Option<SecretString>,
117    socket_sink: Option<SocketStateSink>,
118    socket_control: Option<SocketControl>,
119}
120
121#[derive(Debug)]
122pub(crate) struct RetainedTaskSlot(SharedTaskSlot<()>);
123
124impl RetainedTaskSlot {
125    pub(crate) fn new() -> Self {
126        Self(SharedTaskSlot::new())
127    }
128
129    pub(crate) fn is_empty(&self) -> bool {
130        self.0.is_empty()
131    }
132
133    pub(crate) async fn finish(&self) -> Result<(), LighterWsError> {
134        let Some(outcome) = self.0.finish(DISCONNECT_TIMEOUT, DISCONNECT_TIMEOUT).await else {
135            return Ok(());
136        };
137
138        match outcome {
139            TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => Ok(()),
140            TaskJoinOutcome::Failed(error) => Err(LighterWsError::Client(format!(
141                "retained WebSocket handler task failed: {error}"
142            ))),
143            TaskJoinOutcome::Incomplete => Err(LighterWsError::Client(
144                "retained WebSocket handler task did not stop after abort".to_string(),
145            )),
146        }
147    }
148}
149
150pub(crate) struct TaskRetentionGuard {
151    client: Option<LighterWebSocketClient>,
152    retained: Arc<RetainedTaskSlot>,
153}
154
155impl TaskRetentionGuard {
156    pub(crate) fn new(client: LighterWebSocketClient, retained: Arc<RetainedTaskSlot>) -> Self {
157        Self {
158            client: Some(client),
159            retained,
160        }
161    }
162
163    pub(crate) fn client_mut(&mut self) -> &mut LighterWebSocketClient {
164        self.client.as_mut().expect("retention guard is armed")
165    }
166
167    pub(crate) fn disarm(mut self) -> LighterWebSocketClient {
168        self.client.take().expect("retention guard is armed")
169    }
170}
171
172impl Drop for TaskRetentionGuard {
173    fn drop(&mut self) {
174        let Some(client) = self.client.as_mut() else {
175            return;
176        };
177        client.begin_shutdown();
178        let slot = client.take_task_slot();
179        if slot.is_some() && self.retained.0.try_insert_slot(slot).is_err() {
180            log::error!("Lighter retained WebSocket task slot was already occupied");
181        }
182    }
183}
184
185impl Debug for LighterWebSocketClient {
186    /// Custom `Debug` that redacts the auth token in `subscription_args`.
187    ///
188    /// Authenticated channel subscriptions store the venue bearer token
189    /// alongside the channel for reconnect replay; deriving `Debug` would
190    /// otherwise print the token verbatim.
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        let subscription_topics: Vec<String> = self
193            .subscription_args
194            .iter()
195            .map(|entry| {
196                let args = entry.value();
197                format!(
198                    "topic={} channel={:?} authed={} generation={}",
199                    entry.key(),
200                    args.channel,
201                    args.auth.is_some(),
202                    args.generation,
203                )
204            })
205            .collect();
206
207        f.debug_struct(stringify!(LighterWebSocketClient))
208            .field("url", &self.url)
209            .field("is_active", &self.is_active())
210            .field("subscription_count", &self.subscriptions.len())
211            .field("subscription_args", &subscription_topics)
212            .field("instruments_len", &self.instruments.len())
213            .field("transport_backend", &self.transport_backend)
214            .field("ws_timeout_secs", &self.ws_timeout_secs)
215            .field("book_snapshot_timeout", &self.book_snapshot_timeout)
216            .field("proxy_url", &redact_option(self.proxy_url.as_ref()))
217            .finish_non_exhaustive()
218    }
219}
220
221impl Clone for LighterWebSocketClient {
222    fn clone(&self) -> Self {
223        Self {
224            url: self.url.clone(),
225            connection_mode: Arc::clone(&self.connection_mode),
226            connection_epoch: Arc::clone(&self.connection_epoch),
227            connection_lock: Arc::clone(&self.connection_lock),
228            connection_generation: Arc::clone(&self.connection_generation),
229            initial_connect_cancellation: Arc::clone(&self.initial_connect_cancellation),
230            signal: Arc::clone(&self.signal),
231            cmd_tx: Arc::clone(&self.cmd_tx),
232            out_rx: None,
233            subscriptions: self.subscriptions.clone(),
234            subscription_args: Arc::clone(&self.subscription_args),
235            next_subscription_generation: Arc::clone(&self.next_subscription_generation),
236            instruments: Arc::clone(&self.instruments),
237            cache_lock: Arc::clone(&self.cache_lock),
238            registry: Arc::clone(&self.registry),
239            task_handle: TaskSlot::new(),
240            transport_backend: self.transport_backend,
241            ws_timeout_secs: self.ws_timeout_secs,
242            book_snapshot_timeout: self.book_snapshot_timeout,
243            proxy_url: self.proxy_url.clone(),
244            socket_sink: self.socket_sink.clone(),
245            socket_control: self.socket_control.clone(),
246        }
247    }
248}
249
250impl LighterWebSocketClient {
251    fn initial_connect_retry_policy() -> InitialConnectRetryPolicy {
252        InitialConnectRetryPolicy {
253            max_attempts: NonZeroU32::new(5).expect("initial connect attempts must be non-zero"),
254            delay_initial: Duration::from_millis(500),
255            delay_max: Duration::from_secs(5),
256            backoff_factor: 2.0,
257            jitter_ms: 250,
258        }
259    }
260
261    /// Creates a new client without connecting.
262    ///
263    /// `url` overrides the resolved environment URL when supplied.
264    #[must_use]
265    pub fn new(
266        url: Option<String>,
267        environment: LighterEnvironment,
268        registry: Arc<MarketRegistry>,
269        transport_backend: TransportBackend,
270        ws_timeout_secs: u64,
271        book_snapshot_timeout: Duration,
272        proxy_url: Option<String>,
273    ) -> Self {
274        let url = url.unwrap_or_else(|| lighter_ws_url(environment).to_string());
275        let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
276            ConnectionMode::Closed as u8,
277        ))));
278        let connection_epoch = Arc::new(ArcSwap::new(Arc::new(AtomicU64::new(0))));
279
280        let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
281
282        Self {
283            url: url.into(),
284            connection_mode,
285            connection_epoch,
286            connection_lock: Arc::new(tokio::sync::Mutex::new(())),
287            connection_generation: Arc::new(AtomicU64::new(0)),
288            initial_connect_cancellation: Arc::new(ArcSwap::from_pointee(CancellationToken::new())),
289            signal: Arc::new(AtomicBool::new(false)),
290            cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
291            out_rx: None,
292            subscriptions: SubscriptionState::new(':'),
293            subscription_args: Arc::new(DashMap::new()),
294            next_subscription_generation: Arc::new(AtomicU64::new(1)),
295            instruments: Arc::new(DashMap::new()),
296            cache_lock: Arc::new(Mutex::new(())),
297            registry,
298            task_handle: TaskSlot::new(),
299            transport_backend,
300            ws_timeout_secs,
301            book_snapshot_timeout,
302            proxy_url: proxy_url.map(SecretString::from),
303            socket_sink: None,
304            socket_control: None,
305        }
306    }
307
308    /// Configures socket state reporting for the underlying transport.
309    #[must_use]
310    pub fn with_state_sink(mut self, state_sink: SocketStateSink) -> Self {
311        self.socket_sink = Some(state_sink);
312        self
313    }
314
315    /// Configures socket state reporting and reconnect control.
316    #[must_use]
317    pub fn with_socket_control(mut self, control: SocketControl) -> Self {
318        self.socket_control = Some(control);
319        self
320    }
321
322    /// Returns the resolved WebSocket URL.
323    #[must_use]
324    pub fn url(&self) -> &str {
325        self.url.expose()
326    }
327
328    /// Returns `true` when the underlying connection is active.
329    #[must_use]
330    pub fn is_active(&self) -> bool {
331        self.connection_mode.load().load(Ordering::Relaxed) == ConnectionMode::Active as u8
332    }
333
334    #[must_use]
335    pub(crate) fn connection_epoch(&self) -> u64 {
336        self.connection_epoch.load().load(Ordering::Acquire)
337    }
338
339    pub(crate) fn connection_epoch_atomic(&self) -> Arc<AtomicU64> {
340        self.connection_epoch.load_full()
341    }
342
343    /// Waits until the underlying connection reports active, or returns an
344    /// error after the configured WebSocket timeout.
345    ///
346    /// Polls [`Self::is_active`] every 10ms. Mirrors the documented
347    /// `wait_until_active` contract for adapter WebSocket clients in
348    /// `docs/developer_guide/adapters.md`.
349    ///
350    /// # Errors
351    ///
352    /// Returns an error if the connection does not reach the active state within the configured
353    /// timeout.
354    pub async fn wait_until_active(&self) -> Result<(), LighterWsError> {
355        let timeout_secs = self.ws_timeout_secs;
356        let timeout = Duration::from_secs(timeout_secs);
357
358        tokio::time::timeout(timeout, async {
359            while !self.is_active() {
360                tokio::time::sleep(Duration::from_millis(10)).await;
361            }
362        })
363        .await
364        .map_err(|_| {
365            LighterWsError::Client(format!(
366                "WebSocket connection timeout after {timeout_secs} seconds"
367            ))
368        })
369    }
370
371    /// Returns the count of confirmed subscriptions.
372    #[must_use]
373    pub fn subscription_count(&self) -> usize {
374        self.subscriptions.len()
375    }
376
377    /// Returns a clone of the shared instrument cache.
378    #[must_use]
379    pub fn instruments_cache(&self) -> Arc<DashMap<i64, InstrumentAny>> {
380        Arc::clone(&self.instruments)
381    }
382
383    /// Caches a batch of instruments along with their venue `market_index`,
384    /// replaying them to the handler if a connection is already established.
385    pub fn cache_instruments(&self, instruments: Vec<(i64, InstrumentAny)>) {
386        let _guard = self.cache_lock.lock();
387        self.instruments.clear();
388        for (market_index, instrument) in &instruments {
389            self.instruments.insert(*market_index, instrument.clone());
390        }
391        log::debug!(
392            "Lighter instrument cache initialized with {} instruments",
393            instruments.len()
394        );
395
396        if let Ok(cmd_tx) = self.cmd_tx.try_read() {
397            let _ = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments));
398        }
399    }
400
401    /// Caches a single instrument and pushes it to the handler if connected.
402    pub fn cache_instrument(&self, market_index: i64, instrument: InstrumentAny) {
403        let _guard = self.cache_lock.lock();
404        self.instruments.insert(market_index, instrument.clone());
405
406        if let Ok(cmd_tx) = self.cmd_tx.try_read() {
407            let _ = cmd_tx.send(HandlerCommand::UpdateInstrument {
408                market_index,
409                instrument,
410            });
411        }
412    }
413
414    /// Establishes the WebSocket connection and spawns the feed-handler task.
415    /// Classified transient failures retry within the configured WebSocket timeout.
416    ///
417    /// A fresh connection clears held subscriptions; callers subscribe again for the new session.
418    /// Transport reconnects, including `SocketControl` requests, preserve and replay them.
419    ///
420    /// # Errors
421    ///
422    /// Returns an error if the connection fails permanently, exhausts its timeout, is cancelled,
423    /// or the handler cannot be initialized.
424    pub async fn connect(&mut self) -> anyhow::Result<()> {
425        self.connect_with_cancellation(CancellationToken::new())
426            .await
427    }
428
429    pub(crate) async fn connect_with_cancellation(
430        &mut self,
431        cancellation_token: CancellationToken,
432    ) -> anyhow::Result<()> {
433        let generation = self.connection_generation.load(Ordering::Acquire);
434        let _guard = self.connection_lock.lock().await;
435
436        anyhow::ensure!(
437            generation == self.connection_generation.load(Ordering::Acquire),
438            "Lighter WebSocket initial connection cancelled",
439        );
440
441        if self.is_active() {
442            log::warn!("Lighter WebSocket already connected");
443            return Ok(());
444        }
445
446        if let Some(outcome) = finish_task(
447            &mut self.task_handle,
448            DISCONNECT_TIMEOUT,
449            DISCONNECT_TIMEOUT,
450        )
451        .await
452        {
453            match outcome {
454                TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
455                TaskJoinOutcome::Failed(error) => {
456                    anyhow::bail!("Lighter WebSocket handler failed: {error}");
457                }
458                TaskJoinOutcome::Incomplete => {
459                    anyhow::bail!("Lighter WebSocket handler did not stop after abort");
460                }
461            }
462        }
463
464        self.signal.store(false, Ordering::Release);
465        self.initial_connect_cancellation
466            .store(Arc::new(cancellation_token.clone()));
467
468        let (message_handler, raw_rx) = channel_epoch_message_handler();
469        let headers = create_standard_nautilus_headers();
470
471        let cfg = WebSocketConfig {
472            url: self.url.expose().to_owned(),
473            headers,
474            heartbeat_interval_secs: Some(HEARTBEAT_INTERVAL.as_secs()),
475            heartbeat_payload: None,
476            connect_timeout_ms: Some(self.ws_timeout_secs.saturating_mul(1_000).max(1)),
477            reconnect_delay_initial_ms: Some(RECONNECT_BASE_BACKOFF.as_millis() as u64),
478            reconnect_delay_max_ms: Some(RECONNECT_MAX_BACKOFF.as_millis() as u64),
479            reconnect_backoff_factor: Some(RECONNECT_BACKOFF_FACTOR),
480            reconnect_jitter_ms: Some(RECONNECT_JITTER_MS),
481            reconnect_max_attempts: None,
482            heartbeat_timeout_secs: Some(HEARTBEAT_TIMEOUT.as_secs()),
483            idle_timeout_ms: None,
484            writer_capacity: None,
485            backend: self.transport_backend,
486            proxy_url: self
487                .proxy_url
488                .as_ref()
489                .map(|value| value.expose_secret().to_owned()),
490            max_message_size_bytes: None,
491            max_frame_size_bytes: None,
492        };
493
494        let connect = WebSocketClient::epoch_builder()
495            .config(cfg)
496            .epoch_handler(message_handler)
497            .rate_limiter(ws_message_rate_limiter(self.url.expose()))
498            .initial_connect_retry_policy(Self::initial_connect_retry_policy())
499            .cancellation_token(cancellation_token.clone())
500            .maybe_state_sink(
501                self.socket_control
502                    .as_ref()
503                    .map(SocketControl::sink)
504                    .or_else(|| self.socket_sink.clone()),
505            )
506            .connect();
507        let client =
508            match tokio::time::timeout(Duration::from_secs(self.ws_timeout_secs), connect).await {
509                Ok(result) => result?,
510                Err(_) => anyhow::bail!(
511                    "Lighter WebSocket initial connection timeout after {} seconds",
512                    self.ws_timeout_secs,
513                ),
514            };
515
516        if cancellation_token.is_cancelled()
517            || generation != self.connection_generation.load(Ordering::Acquire)
518        {
519            client.disconnect().await;
520
521            anyhow::bail!("Lighter WebSocket initial connection cancelled");
522        }
523
524        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
525        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
526
527        // Capture the connection-mode atomic before moving `client` into the
528        // SetClient command below.
529        let connection_mode_atomic = client.connection_mode_atomic();
530        let connection_epoch_atomic = client.connection_epoch_atomic();
531
532        // Queue SetClient (and the instrument cache replay) onto the new
533        // command channel BEFORE publishing it to clones or marking the
534        // connection active. Otherwise a clone observing `is_active()` could
535        // race in and send a Subscribe before SetClient lands, and the
536        // handler would drop the subscription because `inner == None`.
537        let reconnect_handle = client.reconnect_handle();
538        if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
539            anyhow::bail!("Failed to send SetClient command: {e}");
540        }
541
542        if let Some(control) = &self.socket_control {
543            control.register(move || reconnect_handle.request_reconnect());
544        }
545
546        self.out_rx = Some(out_rx);
547        {
548            let mut sender = self.cmd_tx.write().await;
549            // Only connect takes the sender write lock, so skipped cache sends join this snapshot
550            let cache_guard = self.cache_lock.lock();
551            let initial_instruments: Vec<(i64, InstrumentAny)> = self
552                .instruments
553                .iter()
554                .map(|entry| (*entry.key(), entry.value().clone()))
555                .collect();
556
557            if !initial_instruments.is_empty()
558                && let Err(e) =
559                    cmd_tx.send(HandlerCommand::InitializeInstruments(initial_instruments))
560            {
561                log::error!("Failed to send InitializeInstruments: {e}");
562            }
563
564            self.subscriptions.clear();
565            self.subscription_args.clear();
566            *sender = cmd_tx.clone();
567            self.connection_epoch.store(connection_epoch_atomic);
568            self.connection_mode.store(connection_mode_atomic);
569            // Release the sender first so cache writes cannot miss both the snapshot and dispatch
570            drop(sender);
571            drop(cache_guard);
572        }
573
574        log::debug!("Lighter WebSocket connected: {}", self.url.origin());
575
576        let signal = Arc::clone(&self.signal);
577        let subscriptions = self.subscriptions.clone();
578        let subscription_args = Arc::clone(&self.subscription_args);
579        let cmd_tx_for_reconnect = cmd_tx.clone();
580        let settlement_currency = self.registry.settlement_currency();
581        let book_snapshot_timeout = self.book_snapshot_timeout;
582
583        if let Err(e) = self.task_handle.spawn(async move {
584            let mut handler = FeedHandler::new_with_settlement_currency(
585                Arc::clone(&signal),
586                cmd_rx,
587                raw_rx,
588                out_tx,
589                subscriptions,
590                settlement_currency,
591                book_snapshot_timeout,
592            );
593
594            handler.set_command_sender(cmd_tx_for_reconnect.clone());
595
596            let restore_subscriptions = || {
597                if subscription_args.is_empty() {
598                    log::debug!("No active Lighter subscriptions to restore after reconnect");
599                    return;
600                }
601                log::debug!(
602                    "Restoring {} Lighter subscriptions after reconnect",
603                    subscription_args.len(),
604                );
605
606                // Replay first; the execution client replaces account tokens after reconnect
607                for entry in subscription_args.iter() {
608                    let args = entry.value().clone();
609                    if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
610                        channel: args.channel,
611                        auth: args.auth,
612                        response_tx: None,
613                    }) {
614                        log::error!("Failed to resend Lighter subscribe command: {e}");
615                    }
616                }
617            };
618
619            loop {
620                match handler.next().await {
621                    Some(NautilusWsMessage::Reconnected { connection_epoch }) => {
622                        log::debug!("Lighter WebSocket reconnected");
623                        restore_subscriptions();
624
625                        if handler
626                            .send(NautilusWsMessage::Reconnected { connection_epoch })
627                            .is_err()
628                        {
629                            if handler.is_stopped() {
630                                log::debug!("Failed to forward Reconnected (receiver dropped)");
631                            } else {
632                                log::error!("Failed to forward Reconnected (receiver dropped)");
633                            }
634                            break;
635                        }
636                    }
637                    Some(msg) => {
638                        if handler.send(msg).is_err() {
639                            if handler.is_stopped() {
640                                log::debug!("Failed to send Lighter message (receiver dropped)");
641                            } else {
642                                log::error!("Failed to send Lighter message (receiver dropped)");
643                            }
644                            break;
645                        }
646                    }
647                    None => {
648                        if handler.is_stopped() {
649                            log::debug!("Lighter handler stop signal observed, exiting loop");
650                            break;
651                        }
652                        log::warn!("Lighter WebSocket stream ended unexpectedly");
653                        break;
654                    }
655                }
656            }
657            log::debug!("Lighter handler task completed");
658        }) {
659            self.out_rx = None;
660            anyhow::bail!("Failed to start Lighter WebSocket handler task: {e}");
661        }
662        Ok(())
663    }
664
665    /// Disconnects gracefully: signals shutdown, drains the handler, then
666    /// awaits the task handle with a timeout.
667    ///
668    /// # Errors
669    ///
670    /// Returns an error if the handler task fails or does not stop after being aborted. Shutdown
671    /// still clears the connection mode and deregisters the socket control.
672    pub async fn disconnect(&mut self) -> Result<(), LighterWsError> {
673        self.connection_generation.fetch_add(1, Ordering::AcqRel);
674        self.initial_connect_cancellation.load().cancel();
675
676        let _guard = self.connection_lock.lock().await;
677        self.initial_connect_cancellation.load().cancel();
678
679        log::debug!("Disconnecting Lighter WebSocket");
680
681        if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
682            log::debug!("Failed to send Lighter disconnect command: {e}");
683        }
684        self.signal.store(true, Ordering::Release);
685
686        let task_result = match finish_task(
687            &mut self.task_handle,
688            DISCONNECT_TIMEOUT,
689            DISCONNECT_TIMEOUT,
690        )
691        .await
692        {
693            None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
694            Some(TaskJoinOutcome::Failed(error)) => Err(LighterWsError::Client(format!(
695                "WebSocket handler task failed: {error}"
696            ))),
697            Some(TaskJoinOutcome::Incomplete) => Err(LighterWsError::Client(
698                "WebSocket handler task did not stop after abort".to_string(),
699            )),
700        };
701
702        self.connection_mode
703            .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
704
705        if let Some(control) = &self.socket_control {
706            control.deregister();
707        }
708        task_result
709    }
710
711    pub(crate) async fn disconnect_with_task_retention(
712        self,
713        retained: Arc<RetainedTaskSlot>,
714    ) -> Result<(), LighterWsError> {
715        let mut guard = TaskRetentionGuard::new(self, retained);
716
717        guard.client_mut().disconnect().await
718    }
719
720    pub(crate) fn begin_shutdown(&self) {
721        self.initial_connect_cancellation.load().cancel();
722        self.signal.store(true, Ordering::Release);
723    }
724
725    /// Receives the next message from the handler, or `None` if the receiver
726    /// has been taken or the handler has shut down.
727    pub async fn next_event(&mut self) -> Option<NautilusWsMessage> {
728        if let Some(rx) = self.out_rx.as_mut() {
729            rx.recv().await
730        } else {
731            None
732        }
733    }
734
735    /// Takes the feed-handler task slot, leaving an empty slot behind.
736    ///
737    /// Used by callers that connect on a cloned client and want to await the
738    /// inner handler task on a different instance during disconnect.
739    #[must_use]
740    pub(crate) fn take_task_slot(&mut self) -> TaskSlot<()> {
741        std::mem::take(&mut self.task_handle)
742    }
743
744    /// Installs a feed-handler task slot previously obtained from [`Self::take_task_slot`].
745    pub(crate) fn set_task_slot(&mut self, slot: TaskSlot<()>) {
746        assert!(self.task_handle.is_none(), "task slot is already occupied");
747        self.task_handle = slot;
748    }
749
750    /// Subscribe to L2 order-book updates for an instrument.
751    ///
752    /// # Errors
753    ///
754    /// Returns an error if the instrument is not registered, the command
755    /// cannot be queued, or the venue rejects the subscription.
756    pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> Result<(), LighterWsError> {
757        let market_index = self.market_index_for(&instrument_id)?;
758
759        let session = self
760            .send_cmd_session(HandlerCommand::SetBookDeltasSub {
761                market_index,
762                subscribed: true,
763            })
764            .await?;
765
766        if let Err(e) = self
767            .subscribe_order_book_stream(market_index, &session)
768            .await
769        {
770            let _ = self
771                .send_cmd_on_session(
772                    HandlerCommand::SetBookDeltasSub {
773                        market_index,
774                        subscribed: false,
775                    },
776                    &session,
777                )
778                .await;
779            return Err(e);
780        }
781
782        Ok(())
783    }
784
785    /// Unsubscribe from L2 order-book updates.
786    ///
787    /// # Errors
788    ///
789    /// Returns an error if the instrument is not registered or the command
790    /// cannot be queued.
791    pub async fn unsubscribe_book(
792        &self,
793        instrument_id: InstrumentId,
794    ) -> Result<(), LighterWsError> {
795        let market_index = self.market_index_for(&instrument_id)?;
796        self.send_cmd(HandlerCommand::SetBookDeltasSub {
797            market_index,
798            subscribed: false,
799        })
800        .await?;
801        self.unsubscribe_order_book_stream(market_index).await
802    }
803
804    /// Subscribe to depth-10 snapshots derived from the same `order_book`
805    /// stream as [`Self::subscribe_book`].
806    ///
807    /// # Errors
808    ///
809    /// Returns an error if the instrument is not registered, the command
810    /// cannot be queued, or the venue rejects the subscription.
811    pub async fn subscribe_book_depth(
812        &self,
813        instrument_id: InstrumentId,
814    ) -> Result<(), LighterWsError> {
815        let market_index = self.market_index_for(&instrument_id)?;
816
817        let session = self
818            .send_cmd_session(HandlerCommand::SetDepthSub {
819                market_index,
820                subscribed: true,
821            })
822            .await?;
823
824        if let Err(e) = self
825            .subscribe_order_book_stream(market_index, &session)
826            .await
827        {
828            let _ = self
829                .send_cmd_on_session(
830                    HandlerCommand::SetDepthSub {
831                        market_index,
832                        subscribed: false,
833                    },
834                    &session,
835                )
836                .await;
837            return Err(e);
838        }
839
840        Ok(())
841    }
842
843    /// Unsubscribe from depth-10 snapshots.
844    ///
845    /// Clears the depth-10 emission flag without tearing down the underlying
846    /// `order_book` stream so any active deltas subscriber keeps receiving
847    /// updates.
848    ///
849    /// # Errors
850    ///
851    /// Returns an error if the instrument is not registered or the command
852    /// cannot be queued.
853    pub async fn unsubscribe_book_depth(
854        &self,
855        instrument_id: InstrumentId,
856    ) -> Result<(), LighterWsError> {
857        let market_index = self.market_index_for(&instrument_id)?;
858        self.send_cmd(HandlerCommand::SetDepthSub {
859            market_index,
860            subscribed: false,
861        })
862        .await?;
863        self.unsubscribe_order_book_stream(market_index).await
864    }
865
866    /// Subscribe to ticker (best bid/offer) updates.
867    ///
868    /// # Errors
869    ///
870    /// Returns an error if the instrument is not registered, the command
871    /// cannot be queued, or the venue rejects the subscription.
872    pub async fn subscribe_quotes(
873        &self,
874        instrument_id: InstrumentId,
875    ) -> Result<(), LighterWsError> {
876        let market_index = self.market_index_for(&instrument_id)?;
877        self.send_subscribe(LighterWsChannel::Ticker(market_index), None)
878            .await
879    }
880
881    /// Unsubscribe from ticker updates.
882    ///
883    /// # Errors
884    ///
885    /// Returns an error if the instrument is not registered or the command
886    /// cannot be queued.
887    pub async fn unsubscribe_quotes(
888        &self,
889        instrument_id: InstrumentId,
890    ) -> Result<(), LighterWsError> {
891        let market_index = self.market_index_for(&instrument_id)?;
892        self.send_unsubscribe(LighterWsChannel::Ticker(market_index))
893            .await
894    }
895
896    /// Subscribe to trade updates.
897    ///
898    /// # Errors
899    ///
900    /// Returns an error if the instrument is not registered, the command
901    /// cannot be queued, or the venue rejects the subscription.
902    pub async fn subscribe_trades(
903        &self,
904        instrument_id: InstrumentId,
905    ) -> Result<(), LighterWsError> {
906        let market_index = self.market_index_for(&instrument_id)?;
907        self.send_subscribe(LighterWsChannel::Trade(market_index), None)
908            .await
909    }
910
911    /// Unsubscribe from trade updates.
912    ///
913    /// # Errors
914    ///
915    /// Returns an error if the instrument is not registered or the command
916    /// cannot be queued.
917    pub async fn unsubscribe_trades(
918        &self,
919        instrument_id: InstrumentId,
920    ) -> Result<(), LighterWsError> {
921        let market_index = self.market_index_for(&instrument_id)?;
922        self.send_unsubscribe(LighterWsChannel::Trade(market_index))
923            .await
924    }
925
926    /// Subscribe to the `candle/{market_id}/{resolution}` stream for an
927    /// instrument and resolution.
928    ///
929    /// # Errors
930    ///
931    /// Returns an error if the instrument is not registered, the resolution
932    /// is not offered on the WebSocket stream, the command cannot be queued,
933    /// or the venue rejects the subscription.
934    pub async fn subscribe_candles(
935        &self,
936        instrument_id: InstrumentId,
937        resolution: LighterCandleResolution,
938    ) -> Result<(), LighterWsError> {
939        if !resolution.is_ws_streamable() {
940            return Err(LighterWsError::Client(format!(
941                "resolution {resolution:?} is not offered on the Lighter candle WebSocket stream",
942            )));
943        }
944        let market_index = self.market_index_for(&instrument_id)?;
945        self.send_subscribe(
946            LighterWsChannel::Candle {
947                market_index,
948                resolution,
949            },
950            None,
951        )
952        .await
953    }
954
955    /// Unsubscribe from a candle stream.
956    ///
957    /// # Errors
958    ///
959    /// Returns an error if the instrument is not registered or the command
960    /// cannot be queued.
961    pub async fn unsubscribe_candles(
962        &self,
963        instrument_id: InstrumentId,
964        resolution: LighterCandleResolution,
965    ) -> Result<(), LighterWsError> {
966        let market_index = self.market_index_for(&instrument_id)?;
967        self.send_unsubscribe(LighterWsChannel::Candle {
968            market_index,
969            resolution,
970        })
971        .await
972    }
973
974    /// Subscribe to a market-stats stream covering all markets or a single
975    /// market index.
976    ///
977    /// # Errors
978    ///
979    /// Returns an error if the command cannot be queued or the venue rejects
980    /// the subscription.
981    pub async fn subscribe_market_stats(
982        &self,
983        selection: LighterMarketSelection,
984    ) -> Result<(), LighterWsError> {
985        self.send_subscribe(LighterWsChannel::MarketStats(selection), None)
986            .await
987    }
988
989    /// Unsubscribe from a market-stats stream.
990    ///
991    /// # Errors
992    ///
993    /// Returns an error if the command cannot be queued.
994    pub async fn unsubscribe_market_stats(
995        &self,
996        selection: LighterMarketSelection,
997    ) -> Result<(), LighterWsError> {
998        self.send_unsubscribe(LighterWsChannel::MarketStats(selection))
999            .await
1000    }
1001
1002    /// Subscribe to a spot market-stats stream covering all spot markets or a
1003    /// single spot market index.
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns an error if the command cannot be queued or the venue rejects
1008    /// the subscription.
1009    pub async fn subscribe_spot_market_stats(
1010        &self,
1011        selection: LighterMarketSelection,
1012    ) -> Result<(), LighterWsError> {
1013        self.send_subscribe(LighterWsChannel::SpotMarketStats(selection), None)
1014            .await
1015    }
1016
1017    /// Unsubscribe from a spot market-stats stream.
1018    ///
1019    /// # Errors
1020    ///
1021    /// Returns an error if the command cannot be queued.
1022    pub async fn unsubscribe_spot_market_stats(
1023        &self,
1024        selection: LighterMarketSelection,
1025    ) -> Result<(), LighterWsError> {
1026        self.send_unsubscribe(LighterWsChannel::SpotMarketStats(selection))
1027            .await
1028    }
1029
1030    /// Subscribe to the chain-height stream.
1031    ///
1032    /// # Errors
1033    ///
1034    /// Returns an error if the command cannot be queued or the venue rejects
1035    /// the subscription.
1036    pub async fn subscribe_height(&self) -> Result<(), LighterWsError> {
1037        self.send_subscribe(LighterWsChannel::Height, None).await
1038    }
1039
1040    /// Unsubscribe from the chain-height stream.
1041    ///
1042    /// # Errors
1043    ///
1044    /// Returns an error if the command cannot be queued.
1045    pub async fn unsubscribe_height(&self) -> Result<(), LighterWsError> {
1046        self.send_unsubscribe(LighterWsChannel::Height).await
1047    }
1048
1049    /// Provides the execution context the feed handler stamps onto reports
1050    /// parsed from `account_*` frames.
1051    ///
1052    /// Without this context account frames fall back to
1053    /// [`NautilusWsMessage::Raw`]; once it is set the handler emits typed
1054    /// [`crate::websocket::messages::ExecutionReport`] and
1055    /// [`crate::websocket::messages::NautilusWsMessage::AccountState`]
1056    /// messages stamped with `account_id`. The `account_index` is used by
1057    /// the fill parser to determine which side of each account-trade frame
1058    /// the configured account took.
1059    ///
1060    /// # Errors
1061    ///
1062    /// Returns an error if the command cannot be queued.
1063    pub async fn set_execution_context(
1064        &self,
1065        account_id: AccountId,
1066        account_index: i64,
1067    ) -> Result<(), LighterWsError> {
1068        self.send_cmd(HandlerCommand::SetExecutionContext {
1069            account_id,
1070            account_index,
1071        })
1072        .await
1073    }
1074
1075    /// Subscribe to a private account channel using a venue auth token.
1076    ///
1077    /// The auth token must be a valid Lighter L2 auth signature; see the
1078    /// `signing` module for token construction. Re-issuing this with a fresh
1079    /// token (as the execution client's auth-token rotation does) overwrites
1080    /// the stored reconnect-replay token, keeping it within the venue TTL.
1081    ///
1082    /// # Errors
1083    ///
1084    /// Returns an error if the command cannot be queued or the venue rejects
1085    /// the subscription.
1086    pub async fn subscribe_account(
1087        &self,
1088        channel: LighterWsChannel,
1089        auth_token: SecretString,
1090    ) -> Result<(), LighterWsError> {
1091        self.send_subscribe(channel, Some(auth_token)).await
1092    }
1093
1094    /// Unsubscribe from a private account channel.
1095    ///
1096    /// # Errors
1097    ///
1098    /// Returns an error if the command cannot be queued.
1099    pub async fn unsubscribe_account(
1100        &self,
1101        channel: LighterWsChannel,
1102    ) -> Result<(), LighterWsError> {
1103        self.send_unsubscribe(channel).await
1104    }
1105
1106    /// Dispatch a signed L2 transaction over the WebSocket.
1107    ///
1108    /// `tx_type` is the venue's [`crate::common::enums::LighterTxType`]
1109    /// discriminant; `tx_info` is the JSON body produced by the matching
1110    /// [`crate::signing::tx::TxInfoJson`] renderer. The venue confirms
1111    /// acceptance via the `account_*` streams.
1112    ///
1113    /// # Errors
1114    ///
1115    /// Returns an error if the command cannot be queued or the handler cannot
1116    /// report whether it handed the frame to the network writer.
1117    pub async fn send_tx(
1118        &self,
1119        tx_type: u8,
1120        tx_info: Box<serde_json::value::RawValue>,
1121    ) -> Result<(), LighterWsError> {
1122        self.send_tx_on_connection(tx_type, tx_info, self.connection_epoch())
1123            .await
1124    }
1125
1126    pub(crate) async fn send_tx_on_connection(
1127        &self,
1128        tx_type: u8,
1129        tx_info: Box<serde_json::value::RawValue>,
1130        connection_epoch: u64,
1131    ) -> Result<(), LighterWsError> {
1132        let (response_tx, response_rx) = tokio::sync::oneshot::channel();
1133        self.send_cmd(HandlerCommand::SendTx {
1134            tx_type,
1135            tx_info,
1136            connection_epoch,
1137            response_tx,
1138        })
1139        .await?;
1140
1141        response_rx.await.map_err(|e| {
1142            LighterWsError::SendTxOutcomeUnknown(format!(
1143                "handler dropped sendTx result after accepting the command: {e}",
1144            ))
1145        })?
1146    }
1147
1148    pub(crate) async fn send_tx_batch_on_connection(
1149        &self,
1150        data: super::messages::LighterWsSendTxBatch,
1151        connection_epoch: u64,
1152    ) -> Result<(), LighterWsError> {
1153        let (response_tx, response_rx) = tokio::sync::oneshot::channel();
1154        self.send_cmd(HandlerCommand::SendTxBatch {
1155            data,
1156            connection_epoch,
1157            response_tx,
1158        })
1159        .await?;
1160
1161        response_rx.await.map_err(|e| {
1162            LighterWsError::SendTxOutcomeUnknown(format!(
1163                "handler dropped sendTxBatch result after accepting the command: {e}",
1164            ))
1165        })?
1166    }
1167
1168    async fn send_subscribe(
1169        &self,
1170        channel: LighterWsChannel,
1171        auth: Option<SecretString>,
1172    ) -> Result<(), LighterWsError> {
1173        self.send_subscribe_on_session(channel, auth, None).await
1174    }
1175
1176    async fn send_subscribe_on_session(
1177        &self,
1178        channel: LighterWsChannel,
1179        auth: Option<SecretString>,
1180        expected_session: Option<&tokio::sync::mpsc::UnboundedSender<HandlerCommand>>,
1181    ) -> Result<(), LighterWsError> {
1182        let topic = channel.topic_key();
1183        let sender = self.cmd_tx.read().await;
1184
1185        if expected_session.is_some_and(|session| !sender.same_channel(session)) {
1186            return Err(LighterWsError::Client(format!(
1187                "WebSocket session changed before subscription for {topic}",
1188            )));
1189        }
1190
1191        if matches!(channel, LighterWsChannel::OrderBook(_))
1192            && !self.subscriptions.add_reference(&topic)
1193        {
1194            return Ok(());
1195        }
1196
1197        let generation = self
1198            .next_subscription_generation
1199            .fetch_add(1, Ordering::Relaxed);
1200        let previous = self.subscription_args.insert(
1201            topic.clone(),
1202            SubscriptionArgs {
1203                channel: channel.clone(),
1204                auth: auth.clone(),
1205                generation,
1206            },
1207        );
1208        let (response_tx, response_rx) = tokio::sync::oneshot::channel();
1209
1210        if let Err(e) = send_command(
1211            &sender,
1212            HandlerCommand::Subscribe {
1213                channel: channel.clone(),
1214                auth,
1215                response_tx: Some(response_tx),
1216            },
1217        ) {
1218            if matches!(channel, LighterWsChannel::OrderBook(_)) {
1219                self.remove_subscription_args(&channel, generation);
1220            } else {
1221                self.restore_subscription_args(&topic, generation, previous);
1222            }
1223
1224            return Err(e);
1225        }
1226
1227        let session = sender.clone();
1228        drop(sender);
1229
1230        let result = match response_rx.await {
1231            Ok(result) => result.map_err(LighterWsError::Client),
1232            Err(e) => Err(LighterWsError::Client(format!(
1233                "handler dropped subscription result for {topic}: {e}",
1234            ))),
1235        };
1236
1237        let sender = self.cmd_tx.read().await;
1238
1239        if result.is_err() && sender.same_channel(&session) {
1240            self.remove_subscription_args(&channel, generation);
1241        }
1242
1243        result
1244    }
1245
1246    fn restore_subscription_args(
1247        &self,
1248        topic: &str,
1249        generation: u64,
1250        previous: Option<SubscriptionArgs>,
1251    ) {
1252        let Entry::Occupied(mut entry) = self.subscription_args.entry(topic.to_string()) else {
1253            return;
1254        };
1255
1256        if entry.get().generation != generation {
1257            return;
1258        }
1259
1260        if let Some(previous) = previous {
1261            entry.insert(previous);
1262        } else {
1263            entry.remove();
1264        }
1265    }
1266
1267    fn remove_subscription_args(&self, channel: &LighterWsChannel, generation: u64) {
1268        let topic = channel.topic_key();
1269        // Remove the failed caller before deciding whether another book consumer needs replay.
1270        if matches!(channel, LighterWsChannel::OrderBook(_))
1271            && !self.subscriptions.remove_reference(&topic)
1272        {
1273            return;
1274        }
1275
1276        let Entry::Occupied(entry) = self.subscription_args.entry(topic) else {
1277            return;
1278        };
1279
1280        if entry.get().generation == generation {
1281            entry.remove();
1282        }
1283    }
1284
1285    async fn send_unsubscribe(&self, channel: LighterWsChannel) -> Result<(), LighterWsError> {
1286        let topic = channel.topic_key();
1287        self.send_cmd(HandlerCommand::Unsubscribe { channel })
1288            .await?;
1289        self.subscription_args.remove(&topic);
1290        Ok(())
1291    }
1292
1293    async fn subscribe_order_book_stream(
1294        &self,
1295        market_index: i64,
1296        session: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1297    ) -> Result<(), LighterWsError> {
1298        self.send_subscribe_on_session(
1299            LighterWsChannel::OrderBook(market_index),
1300            None,
1301            Some(session),
1302        )
1303        .await
1304    }
1305
1306    async fn unsubscribe_order_book_stream(&self, market_index: i64) -> Result<(), LighterWsError> {
1307        let channel = LighterWsChannel::OrderBook(market_index);
1308        let topic = channel.topic_key();
1309
1310        if !self.subscriptions.remove_reference(topic.as_str()) {
1311            return Ok(());
1312        }
1313
1314        if let Err(e) = self.send_unsubscribe(channel).await {
1315            self.subscriptions.add_reference(topic.as_str());
1316            return Err(e);
1317        }
1318
1319        Ok(())
1320    }
1321
1322    async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), LighterWsError> {
1323        let sender = self.cmd_tx.read().await;
1324        send_command(&sender, cmd)
1325    }
1326
1327    async fn send_cmd_session(
1328        &self,
1329        cmd: HandlerCommand,
1330    ) -> Result<tokio::sync::mpsc::UnboundedSender<HandlerCommand>, LighterWsError> {
1331        let sender = self.cmd_tx.read().await;
1332        send_command(&sender, cmd)?;
1333        Ok(sender.clone())
1334    }
1335
1336    async fn send_cmd_on_session(
1337        &self,
1338        cmd: HandlerCommand,
1339        session: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1340    ) -> Result<(), LighterWsError> {
1341        let sender = self.cmd_tx.read().await;
1342        if sender.same_channel(session) {
1343            send_command(&sender, cmd)?;
1344        }
1345
1346        Ok(())
1347    }
1348
1349    fn market_index_for(&self, instrument_id: &InstrumentId) -> Result<i64, LighterWsError> {
1350        self.registry.market_index(instrument_id).ok_or_else(|| {
1351            LighterWsError::Client(format!(
1352                "no Lighter market_index registered for instrument: {instrument_id}"
1353            ))
1354        })
1355    }
1356}
1357
1358impl Drop for LighterWebSocketClient {
1359    fn drop(&mut self) {
1360        if self.task_handle.is_none() {
1361            return;
1362        }
1363
1364        self.connection_generation.fetch_add(1, Ordering::AcqRel);
1365        self.initial_connect_cancellation.load().cancel();
1366        self.signal.store(true, Ordering::Release);
1367
1368        if let Some(handle) = self.task_handle.as_ref() {
1369            handle.abort();
1370        }
1371
1372        if let Some(control) = &self.socket_control {
1373            control.deregister();
1374        }
1375    }
1376}
1377
1378fn send_command(
1379    sender: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1380    cmd: HandlerCommand,
1381) -> Result<(), LighterWsError> {
1382    sender
1383        .send(cmd)
1384        .map_err(|e| LighterWsError::Client(format!("handler unavailable: {e}")))
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use futures_util::{SinkExt, StreamExt};
1390    use nautilus_common::testing::wait_until_async;
1391    use nautilus_core::{UnixNanos, string::secret::REDACTED};
1392    use nautilus_model::{
1393        data::TradeTick,
1394        enums::AggressorSide,
1395        identifiers::{Symbol, TradeId},
1396        instruments::CryptoPerpetual,
1397        types::{Currency, Price, Quantity},
1398    };
1399    use nautilus_testkit::connection::race_command_with_publication;
1400    use rstest::rstest;
1401
1402    use super::*;
1403    use crate::common::{
1404        consts::LIGHTER_VENUE,
1405        enums::{LighterProductType, LighterTxType},
1406    };
1407
1408    impl LighterWebSocketClient {
1409        pub(crate) async fn drop_next_send_tx_result_for_test(&self) {
1410            let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1411            *self.cmd_tx.write().await = cmd_tx;
1412
1413            get_runtime().spawn(async move {
1414                if let Some(
1415                    HandlerCommand::SendTx { response_tx, .. }
1416                    | HandlerCommand::SendTxBatch { response_tx, .. },
1417                ) = cmd_rx.recv().await
1418                {
1419                    cmd_rx.close();
1420                    drop(response_tx);
1421                }
1422            });
1423        }
1424    }
1425
1426    fn registry_with(
1427        market_index: i64,
1428        symbol: &str,
1429        product: LighterProductType,
1430    ) -> Arc<MarketRegistry> {
1431        let registry = Arc::new(MarketRegistry::new());
1432        registry.insert(market_index, symbol, product);
1433        registry
1434    }
1435
1436    #[tokio::test]
1437    async fn command_during_sender_publication_reaches_initialized_handler() {
1438        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1439        let addr = listener.local_addr().unwrap();
1440        let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
1441
1442        let server = tokio::spawn(async move {
1443            let (socket, _) = listener.accept().await.unwrap();
1444            let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
1445            while let Some(Ok(message)) = socket.next().await {
1446                if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
1447                    let payload: serde_json::Value = serde_json::from_str(text.as_str()).unwrap();
1448                    let _ = received_tx.send(payload);
1449                    socket
1450                        .send(tokio_tungstenite::tungstenite::Message::Text(
1451                            include_str!("../../test_data/ws_trade_update.json").into(),
1452                        ))
1453                        .await
1454                        .unwrap();
1455                }
1456            }
1457        });
1458
1459        let url = format!("ws://{addr}");
1460
1461        let mut ws_client = LighterWebSocketClient::new(
1462            Some(url),
1463            LighterEnvironment::Testnet,
1464            registry_with(0, "ETH", LighterProductType::Perp),
1465            TransportBackend::default(),
1466            30,
1467            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1468            None,
1469        );
1470        ws_client
1471            .connection_epoch
1472            .store(Arc::new(AtomicU64::new(99)));
1473        let observer = ws_client.clone();
1474        let published = ws_client.clone();
1475        let instrument_id = InstrumentId::from("ETH-USD-PERP.LIGHTER");
1476
1477        let command = HandlerCommand::Subscribe {
1478            channel: LighterWsChannel::Trade(0),
1479            auth: None,
1480            response_tx: None,
1481        };
1482
1483        race_command_with_publication(
1484            Arc::clone(&ws_client.cmd_tx),
1485            ws_client.connect(),
1486            move || {
1487                assert!(published.is_active());
1488                assert_eq!(published.connection_epoch(), 0);
1489                command
1490            },
1491            || {
1492                observer.cache_instrument(0, stub_instrument(instrument_id));
1493                observer.is_active()
1494            },
1495        )
1496        .await
1497        .expect("connect failed");
1498
1499        let received = tokio::time::timeout(Duration::from_secs(2), received_rx.recv()).await;
1500        let event = tokio::time::timeout(Duration::from_secs(2), ws_client.next_event()).await;
1501        ws_client.disconnect().await.expect("disconnect failed");
1502        server.abort();
1503
1504        let payload = received
1505            .expect("startup command must reach server")
1506            .unwrap();
1507        assert_eq!(
1508            payload,
1509            serde_json::json!({
1510                "type": "subscribe",
1511                "channel": "trade/0"
1512            })
1513        );
1514
1515        let NautilusWsMessage::Trades(ticks) = event
1516            .expect("startup cache must reach handler")
1517            .expect("trade event")
1518        else {
1519            panic!("expected parsed trades");
1520        };
1521
1522        assert_eq!(ticks.len(), 1);
1523        assert_eq!(
1524            ticks[0],
1525            TradeTick::new(
1526                instrument_id,
1527                Price::from("2181.83"),
1528                Quantity::from("0.1336"),
1529                AggressorSide::Sell,
1530                TradeId::from("16164557907"),
1531                UnixNanos::from(1_773_854_156_654_000_000),
1532                ticks[0].ts_init,
1533            )
1534        );
1535    }
1536
1537    #[rstest]
1538    #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
1539    #[case(
1540        "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
1541        "wss://stream.example.com:9443"
1542    )]
1543    fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
1544        let client = LighterWebSocketClient::new(
1545            Some(url.to_string()),
1546            LighterEnvironment::Testnet,
1547            Arc::new(MarketRegistry::new()),
1548            TransportBackend::default(),
1549            30,
1550            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1551            None,
1552        );
1553        let debug = format!("{client:?}");
1554        assert!(debug.contains(&format!("url: {origin:?}")));
1555        assert!(!debug.contains("SYN-"));
1556        assert_eq!(client.url.expose(), url);
1557    }
1558
1559    #[rstest]
1560    #[case::only_caller(1, false, false)]
1561    #[case::other_consumer(2, false, true)]
1562    #[case::other_consumer_leaves(2, true, false)]
1563    fn book_failed_initial_waiter_preserves_other_consumer_replay(
1564        #[case] references: usize,
1565        #[case] other_leaves: bool,
1566        #[case] retained: bool,
1567    ) {
1568        let client = LighterWebSocketClient::new(
1569            Some("wss://example/test".into()),
1570            LighterEnvironment::Testnet,
1571            registry_with(0, "ETH", LighterProductType::Perp),
1572            TransportBackend::default(),
1573            30,
1574            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1575            None,
1576        );
1577        let topic = "order_book:0";
1578        for _ in 0..references {
1579            client.subscriptions.add_reference(topic);
1580        }
1581
1582        client.subscription_args.insert(
1583            topic.into(),
1584            SubscriptionArgs {
1585                channel: LighterWsChannel::OrderBook(0),
1586                auth: None,
1587                generation: 7,
1588            },
1589        );
1590
1591        if other_leaves {
1592            assert!(!client.subscriptions.remove_reference(topic));
1593        }
1594
1595        client.remove_subscription_args(&LighterWsChannel::OrderBook(0), 7);
1596
1597        assert_eq!(client.subscription_args.contains_key(topic), retained);
1598        assert_eq!(
1599            client.subscriptions.get_reference_count(topic),
1600            references - 1 - usize::from(other_leaves)
1601        );
1602
1603        if retained {
1604            assert_eq!(client.subscription_args.get(topic).unwrap().generation, 7);
1605        }
1606    }
1607
1608    #[rstest]
1609    fn debug_redacts_retained_auth_and_proxy_url() {
1610        let client = LighterWebSocketClient::new(
1611            Some("wss://example/test".to_string()),
1612            LighterEnvironment::Testnet,
1613            Arc::new(MarketRegistry::new()),
1614            TransportBackend::default(),
1615            30,
1616            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1617            Some("http://user:proxy-secret@localhost".to_string()),
1618        );
1619        client.subscription_args.insert(
1620            "account_all:7".to_string(),
1621            SubscriptionArgs {
1622                channel: LighterWsChannel::AccountAll(7),
1623                auth: Some(SecretString::from("auth-token")),
1624                generation: 1,
1625            },
1626        );
1627
1628        let debug = format!("{client:?}");
1629
1630        assert!(debug.contains(REDACTED));
1631        assert!(!debug.contains("proxy-secret"));
1632        assert!(!debug.contains("auth-token"));
1633    }
1634
1635    #[rstest]
1636    fn market_index_for_returns_registered_index() {
1637        let registry = registry_with(7, "ETH", LighterProductType::Perp);
1638        let client = LighterWebSocketClient::new(
1639            Some("wss://example/test".to_string()),
1640            LighterEnvironment::Testnet,
1641            Arc::clone(&registry),
1642            TransportBackend::default(),
1643            30,
1644            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1645            None,
1646        );
1647        let id = registry.instrument_id(7).expect("registered");
1648        assert_eq!(client.market_index_for(&id).unwrap(), 7);
1649    }
1650
1651    #[rstest]
1652    fn market_index_for_unregistered_returns_error() {
1653        let registry = Arc::new(MarketRegistry::new());
1654        let client = LighterWebSocketClient::new(
1655            Some("wss://example/test".to_string()),
1656            LighterEnvironment::Testnet,
1657            registry,
1658            TransportBackend::default(),
1659            30,
1660            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1661            None,
1662        );
1663        let id = InstrumentId::new(Symbol::from_str_unchecked("UNKNOWN-PERP"), *LIGHTER_VENUE);
1664        assert!(client.market_index_for(&id).is_err());
1665    }
1666
1667    #[rstest]
1668    fn cache_instrument_populates_lookup() {
1669        let registry = registry_with(0, "ETH", LighterProductType::Perp);
1670        let client = LighterWebSocketClient::new(
1671            Some("wss://example/test".to_string()),
1672            LighterEnvironment::Testnet,
1673            Arc::clone(&registry),
1674            TransportBackend::default(),
1675            30,
1676            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1677            None,
1678        );
1679        let id = registry.instrument_id(0).expect("registered");
1680        let instrument = stub_instrument(id);
1681        client.cache_instrument(0, instrument);
1682        assert!(client.instruments_cache().contains_key(&0));
1683    }
1684
1685    #[tokio::test]
1686    async fn wait_until_active_uses_configured_timeout() {
1687        let client = LighterWebSocketClient::new(
1688            Some("wss://example/test".to_string()),
1689            LighterEnvironment::Testnet,
1690            Arc::new(MarketRegistry::new()),
1691            TransportBackend::default(),
1692            0,
1693            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1694            None,
1695        );
1696
1697        let error = client
1698            .wait_until_active()
1699            .await
1700            .expect_err("inactive client should time out");
1701
1702        assert!(error.to_string().contains("timeout after 0 seconds"));
1703    }
1704
1705    #[rstest]
1706    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1707    async fn disconnect_awaits_handler_after_timeout_abort() {
1708        struct NotifyOnDrop {
1709            tx: Option<tokio::sync::oneshot::Sender<()>>,
1710        }
1711
1712        impl Drop for NotifyOnDrop {
1713            fn drop(&mut self) {
1714                if let Some(tx) = self.tx.take() {
1715                    let _ = tx.send(());
1716                }
1717            }
1718        }
1719
1720        let mut client = LighterWebSocketClient::new(
1721            Some("wss://example/test".to_string()),
1722            LighterEnvironment::Testnet,
1723            Arc::new(MarketRegistry::new()),
1724            TransportBackend::default(),
1725            30,
1726            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1727            None,
1728        );
1729        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1730        let (drop_tx, mut drop_rx) = tokio::sync::oneshot::channel();
1731        let (release_tx, release_rx) = std::sync::mpsc::channel();
1732
1733        client.task_handle.insert(get_runtime().spawn(async move {
1734            let _notify = NotifyOnDrop { tx: Some(drop_tx) };
1735            let _ = started_tx.send(());
1736            let _ = release_rx.recv();
1737            std::future::pending::<()>().await;
1738        }));
1739
1740        tokio::time::timeout(Duration::from_secs(2), started_rx)
1741            .await
1742            .expect("handler startup deadline")
1743            .expect("handler task started");
1744
1745        let disconnect = client.disconnect();
1746        tokio::pin!(disconnect);
1747        assert!(
1748            tokio::time::timeout(
1749                DISCONNECT_TIMEOUT + Duration::from_millis(250),
1750                &mut disconnect,
1751            )
1752            .await
1753            .is_err(),
1754            "disconnect must await the blocked handler after aborting it",
1755        );
1756        assert_eq!(
1757            drop_rx.try_recv(),
1758            Err(tokio::sync::oneshot::error::TryRecvError::Empty)
1759        );
1760        release_tx.send(()).expect("release blocked handler");
1761        tokio::time::timeout(Duration::from_secs(2), disconnect)
1762            .await
1763            .expect("handler must finish after release")
1764            .expect("disconnect");
1765
1766        assert_eq!(drop_rx.try_recv(), Ok(()));
1767    }
1768
1769    #[tokio::test]
1770    async fn drop_clone_does_not_cancel_handler() {
1771        let mut client = LighterWebSocketClient::new(
1772            Some("wss://example/test".to_string()),
1773            LighterEnvironment::Testnet,
1774            Arc::new(MarketRegistry::new()),
1775            TransportBackend::default(),
1776            30,
1777            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1778            None,
1779        );
1780        client
1781            .task_handle
1782            .insert(get_runtime().spawn(std::future::pending()));
1783        let clone = client.clone();
1784
1785        drop(clone);
1786
1787        assert!(!client.signal.load(Ordering::Acquire));
1788        assert!(
1789            !client
1790                .task_handle
1791                .as_ref()
1792                .expect("handler task")
1793                .is_finished()
1794        );
1795    }
1796
1797    #[tokio::test]
1798    async fn cancelled_moved_disconnect_retains_handler_task() {
1799        let mut client = LighterWebSocketClient::new(
1800            Some("wss://example/test".to_string()),
1801            LighterEnvironment::Testnet,
1802            Arc::new(MarketRegistry::new()),
1803            TransportBackend::default(),
1804            30,
1805            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1806            None,
1807        );
1808        client
1809            .task_handle
1810            .insert(get_runtime().spawn(std::future::pending()));
1811        let signal = Arc::clone(&client.signal);
1812        let retained = Arc::new(RetainedTaskSlot::new());
1813        let disconnect_future = client.disconnect_with_task_retention(Arc::clone(&retained));
1814
1815        let disconnect = get_runtime().spawn(disconnect_future);
1816
1817        wait_until_async(
1818            || async { signal.load(Ordering::Acquire) },
1819            Duration::from_secs(2),
1820        )
1821        .await;
1822        disconnect.abort();
1823        let _ = disconnect.await;
1824
1825        assert!(!retained.is_empty());
1826        retained.0.abort();
1827        retained.finish().await.expect("retained handler shutdown");
1828        assert!(retained.is_empty());
1829    }
1830
1831    #[tokio::test]
1832    async fn send_tx_reports_unknown_outcome_when_handler_result_is_dropped() {
1833        let client = LighterWebSocketClient::new(
1834            Some("wss://example/test".to_string()),
1835            LighterEnvironment::Testnet,
1836            Arc::new(MarketRegistry::new()),
1837            TransportBackend::default(),
1838            30,
1839            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1840            None,
1841        );
1842        client.drop_next_send_tx_result_for_test().await;
1843        let tx_info = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
1844
1845        let error = client
1846            .send_tx(LighterTxType::CreateOrder as u8, tx_info)
1847            .await
1848            .expect_err("dropped handler result must be ambiguous");
1849
1850        assert!(matches!(error, LighterWsError::SendTxOutcomeUnknown(_)));
1851    }
1852
1853    #[tokio::test]
1854    async fn subscription_registration_waits_for_session_publication() {
1855        let client = LighterWebSocketClient::new(
1856            Some("wss://example/test".to_string()),
1857            LighterEnvironment::Testnet,
1858            Arc::new(MarketRegistry::new()),
1859            TransportBackend::default(),
1860            30,
1861            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1862            None,
1863        );
1864        let mut publication = client.cmd_tx.write().await;
1865        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1866        let mut subscribe =
1867            Box::pin(client.subscribe_market_stats(LighterMarketSelection::Market(0)));
1868
1869        assert!(futures_util::poll!(subscribe.as_mut()).is_pending());
1870        assert!(client.subscription_args.is_empty());
1871
1872        client.subscriptions.clear();
1873        client.subscription_args.clear();
1874        *publication = cmd_tx;
1875        drop(publication);
1876
1877        assert!(futures_util::poll!(subscribe.as_mut()).is_pending());
1878        let command = cmd_rx.try_recv().expect("new-session subscribe command");
1879
1880        let HandlerCommand::Subscribe {
1881            channel,
1882            auth,
1883            response_tx: Some(response_tx),
1884        } = command
1885        else {
1886            panic!("expected subscribe command with venue result sender");
1887        };
1888
1889        response_tx
1890            .send(Ok(()))
1891            .expect("subscription result receiver");
1892        subscribe.await.expect("new-session subscription");
1893
1894        assert_eq!(
1895            channel,
1896            LighterWsChannel::MarketStats(LighterMarketSelection::Market(0))
1897        );
1898        assert!(auth.is_none());
1899        assert_eq!(client.subscription_args.len(), 1);
1900        let args = client
1901            .subscription_args
1902            .get("market_stats:0")
1903            .expect("replay arguments");
1904        assert_eq!(args.channel, channel);
1905        assert!(args.auth.is_none());
1906        assert_eq!(args.generation, 1);
1907    }
1908
1909    #[rstest]
1910    #[case::deltas(false)]
1911    #[case::depth(true)]
1912    #[tokio::test]
1913    async fn book_registration_rejects_previous_session(#[case] depth: bool) {
1914        let client = LighterWebSocketClient::new(
1915            Some("wss://example/test".to_string()),
1916            LighterEnvironment::Testnet,
1917            Arc::new(MarketRegistry::new()),
1918            TransportBackend::default(),
1919            30,
1920            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
1921            None,
1922        );
1923        let market_index = 7;
1924        let generation = 42;
1925        let channel = LighterWsChannel::OrderBook(market_index);
1926        let topic = channel.topic_key();
1927
1928        let consumer = |subscribed| {
1929            if depth {
1930                HandlerCommand::SetDepthSub {
1931                    market_index,
1932                    subscribed,
1933                }
1934            } else {
1935                HandlerCommand::SetBookDeltasSub {
1936                    market_index,
1937                    subscribed,
1938                }
1939            }
1940        };
1941
1942        let (previous_tx, mut previous_rx) = tokio::sync::mpsc::unbounded_channel();
1943        *client.cmd_tx.write().await = previous_tx;
1944        let session = client
1945            .send_cmd_session(consumer(true))
1946            .await
1947            .expect("previous-session consumer registration");
1948        let (current_tx, mut current_rx) = tokio::sync::mpsc::unbounded_channel();
1949        *client.cmd_tx.write().await = current_tx;
1950        client.subscriptions.add_reference(&topic);
1951        client.subscriptions.mark_subscribe(&topic);
1952        client.subscriptions.confirm_subscribe(&topic);
1953        client
1954            .next_subscription_generation
1955            .store(generation + 1, Ordering::Relaxed);
1956        client.subscription_args.insert(
1957            topic.clone(),
1958            SubscriptionArgs {
1959                channel: channel.clone(),
1960                auth: None,
1961                generation,
1962            },
1963        );
1964
1965        let error = client
1966            .subscribe_order_book_stream(market_index, &session)
1967            .await
1968            .expect_err("previous-session book registration must fail");
1969        client
1970            .send_cmd_on_session(consumer(false), &session)
1971            .await
1972            .expect("skip previous-session rollback");
1973
1974        let LighterWsError::Client(message) = error else {
1975            panic!("expected session mismatch client error");
1976        };
1977
1978        assert_eq!(
1979            message,
1980            format!("WebSocket session changed before subscription for {topic}")
1981        );
1982        assert_eq!(client.subscriptions.get_reference_count(&topic), 1);
1983        assert_eq!(client.subscription_count(), 1);
1984        assert_eq!(client.subscription_args.len(), 1);
1985        assert_eq!(
1986            client.next_subscription_generation.load(Ordering::Relaxed),
1987            generation + 1
1988        );
1989        let args = client
1990            .subscription_args
1991            .get(&topic)
1992            .expect("current-session replay args");
1993        assert_eq!(args.channel, channel);
1994        assert!(args.auth.is_none());
1995        assert_eq!(args.generation, generation);
1996        let command = previous_rx
1997            .try_recv()
1998            .expect("previous-session consumer flag");
1999
2000        match (depth, command) {
2001            (
2002                true,
2003                HandlerCommand::SetDepthSub {
2004                    market_index: actual,
2005                    subscribed,
2006                },
2007            )
2008            | (
2009                false,
2010                HandlerCommand::SetBookDeltasSub {
2011                    market_index: actual,
2012                    subscribed,
2013                },
2014            ) => {
2015                assert_eq!(actual, market_index);
2016                assert!(subscribed);
2017            }
2018            (_, command) => panic!("unexpected previous-session command: {command:?}"),
2019        }
2020
2021        assert!(matches!(
2022            previous_rx.try_recv(),
2023            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
2024        ));
2025        assert!(matches!(
2026            current_rx.try_recv(),
2027            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
2028        ));
2029    }
2030
2031    #[tokio::test]
2032    async fn subscribe_waits_for_venue_result_and_removes_failed_generation() {
2033        let client = LighterWebSocketClient::new(
2034            Some("wss://example/test".to_string()),
2035            LighterEnvironment::Testnet,
2036            Arc::new(MarketRegistry::new()),
2037            TransportBackend::default(),
2038            30,
2039            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
2040            None,
2041        );
2042        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2043        *client.cmd_tx.write().await = cmd_tx;
2044
2045        let subscribe_client = client.clone();
2046        let subscribe = get_runtime().spawn(async move {
2047            subscribe_client
2048                .subscribe_market_stats(LighterMarketSelection::Market(0))
2049                .await
2050        });
2051
2052        let command = cmd_rx.recv().await.expect("subscribe command");
2053        let HandlerCommand::Subscribe {
2054            response_tx: Some(response_tx),
2055            ..
2056        } = command
2057        else {
2058            panic!("expected subscribe command with venue result sender");
2059        };
2060
2061        assert!(!subscribe.is_finished());
2062        assert!(client.subscription_args.contains_key("market_stats:0"));
2063
2064        response_tx
2065            .send(Err("venue rejected subscription".to_string()))
2066            .expect("subscription result receiver");
2067        let error = subscribe
2068            .await
2069            .expect("subscribe task")
2070            .expect_err("failed venue open must fail the caller");
2071
2072        assert!(error.to_string().contains("venue rejected subscription"));
2073        assert!(!client.subscription_args.contains_key("market_stats:0"));
2074    }
2075
2076    #[tokio::test]
2077    async fn failed_older_subscribe_does_not_remove_newer_generation() {
2078        let client = LighterWebSocketClient::new(
2079            Some("wss://example/test".to_string()),
2080            LighterEnvironment::Testnet,
2081            Arc::new(MarketRegistry::new()),
2082            TransportBackend::default(),
2083            30,
2084            Duration::from_secs(DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS),
2085            None,
2086        );
2087        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2088        *client.cmd_tx.write().await = cmd_tx;
2089
2090        let older_client = client.clone();
2091        let older = get_runtime().spawn(async move {
2092            older_client
2093                .subscribe_market_stats(LighterMarketSelection::Market(0))
2094                .await
2095        });
2096        let HandlerCommand::Subscribe {
2097            response_tx: Some(older_response),
2098            ..
2099        } = cmd_rx.recv().await.expect("older subscribe command")
2100        else {
2101            panic!("expected older subscribe command with venue result sender");
2102        };
2103
2104        let newer_client = client.clone();
2105        let newer = get_runtime().spawn(async move {
2106            newer_client
2107                .subscribe_market_stats(LighterMarketSelection::Market(0))
2108                .await
2109        });
2110        let HandlerCommand::Subscribe {
2111            response_tx: Some(newer_response),
2112            ..
2113        } = cmd_rx.recv().await.expect("newer subscribe command")
2114        else {
2115            panic!("expected newer subscribe command with venue result sender");
2116        };
2117
2118        older_response
2119            .send(Err("older generation failed".to_string()))
2120            .expect("older result receiver");
2121        older
2122            .await
2123            .expect("older subscribe task")
2124            .expect_err("older generation must fail");
2125        assert!(client.subscription_args.contains_key("market_stats:0"));
2126
2127        newer_response
2128            .send(Err("newer generation failed".to_string()))
2129            .expect("newer result receiver");
2130        newer
2131            .await
2132            .expect("newer subscribe task")
2133            .expect_err("newer generation must fail");
2134        assert!(!client.subscription_args.contains_key("market_stats:0"));
2135    }
2136
2137    fn stub_instrument(id: InstrumentId) -> InstrumentAny {
2138        InstrumentAny::CryptoPerpetual(
2139            CryptoPerpetual::builder()
2140                .instrument_id(id)
2141                .raw_symbol(id.symbol)
2142                .base_currency(Currency::from("ETH"))
2143                .quote_currency(Currency::from("USDC"))
2144                .settlement_currency(Currency::from("USDC"))
2145                .is_inverse(false)
2146                .price_precision(2)
2147                .size_precision(4)
2148                .price_increment(Price::from("0.01"))
2149                .size_increment(Quantity::from("0.0001"))
2150                .ts_event(UnixNanos::default())
2151                .ts_init(UnixNanos::default())
2152                .build()
2153                .unwrap(),
2154        )
2155    }
2156}