1use 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
83pub 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 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 #[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 #[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 #[must_use]
317 pub fn with_socket_control(mut self, control: SocketControl) -> Self {
318 self.socket_control = Some(control);
319 self
320 }
321
322 #[must_use]
324 pub fn url(&self) -> &str {
325 self.url.expose()
326 }
327
328 #[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 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 #[must_use]
373 pub fn subscription_count(&self) -> usize {
374 self.subscriptions.len()
375 }
376
377 #[must_use]
379 pub fn instruments_cache(&self) -> Arc<DashMap<i64, InstrumentAny>> {
380 Arc::clone(&self.instruments)
381 }
382
383 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 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 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 let connection_mode_atomic = client.connection_mode_atomic();
530 let connection_epoch_atomic = client.connection_epoch_atomic();
531
532 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 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 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 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 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 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 #[must_use]
740 pub(crate) fn take_task_slot(&mut self) -> TaskSlot<()> {
741 std::mem::take(&mut self.task_handle)
742 }
743
744 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn subscribe_height(&self) -> Result<(), LighterWsError> {
1037 self.send_subscribe(LighterWsChannel::Height, None).await
1038 }
1039
1040 pub async fn unsubscribe_height(&self) -> Result<(), LighterWsError> {
1046 self.send_unsubscribe(LighterWsChannel::Height).await
1047 }
1048
1049 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 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 pub async fn unsubscribe_account(
1100 &self,
1101 channel: LighterWsChannel,
1102 ) -> Result<(), LighterWsError> {
1103 self.send_unsubscribe(channel).await
1104 }
1105
1106 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 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(®istry),
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(®istry),
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}