1use std::{
28 fmt::Debug,
29 sync::{
30 Arc,
31 atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
32 },
33};
34
35use futures_util::Stream;
36use nautilus_core::{
37 AtomicMap,
38 string::secret::{REDACTED, SecretString},
39};
40use nautilus_live::{
41 SocketControl, SocketControlFactory,
42 task::{TaskJoinOutcome, TaskSlot, finish_task},
43};
44use nautilus_model::instruments::{Instrument, InstrumentAny};
45use nautilus_network::{
46 ConnectionUrl,
47 http::create_standard_nautilus_headers,
48 mode::ConnectionMode,
49 websocket::{
50 PingHandler, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
51 channel_message_handler,
52 },
53};
54use parking_lot::Mutex;
55use tokio_util::sync::CancellationToken;
56use ustr::Ustr;
57
58use super::{
59 super::error::{BinanceWsError, BinanceWsResult},
60 handler::BinanceSpotWsFeedHandler,
61 messages::{BinanceSpotWsMessage, BinanceSpotWsStreamsCommand},
62 subscription::{MAX_CONNECTIONS, MAX_STREAMS_PER_CONNECTION},
63};
64use crate::common::{
65 consts::{
66 BINANCE_API_KEY_HEADER, BINANCE_RATE_LIMIT_KEY_SUBSCRIPTION, BINANCE_SPOT_SBE_WS_URL,
67 BINANCE_WS_CONNECTION_QUOTA, BINANCE_WS_SUBSCRIPTION_QUOTA,
68 },
69 credential::Ed25519Credential,
70};
71
72struct ConnectionSlot {
74 cmd_tx: tokio::sync::mpsc::UnboundedSender<BinanceSpotWsStreamsCommand>,
75 streams: Vec<String>,
76 subscriptions_state: SubscriptionState,
77 task_handle: TaskSlot<()>,
78 cancellation_token: CancellationToken,
79 connection_mode: Arc<AtomicU8>,
80 socket_control: Option<SocketControl>,
81 shutdown_errors: Vec<String>,
82}
83
84#[derive(Clone)]
91pub struct BinanceSpotWebSocketClient {
92 url: ConnectionUrl,
93 credential: Option<Arc<Ed25519Credential>>,
94 heartbeat: Option<u64>,
95 signal: Arc<AtomicBool>,
96 slots: Arc<ConnectionSlots>,
97 connect_lock: Arc<tokio::sync::Mutex<()>>,
98 out_tx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedSender<BinanceSpotWsMessage>>>>,
99 out_rx: Arc<Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<BinanceSpotWsMessage>>>>,
100 request_id_counter: Arc<AtomicU64>,
101 instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
102 transport_backend: TransportBackend,
103 proxy_url: Option<SecretString>,
104 socket_factory: Option<SocketControlFactory>,
105 socket_endpoint: Option<String>,
106}
107
108impl Debug for BinanceSpotWebSocketClient {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.debug_struct(stringify!(BinanceSpotWebSocketClient))
111 .field("url", &self.url)
112 .field("credential", &self.credential.as_ref().map(|_| REDACTED))
113 .field("heartbeat", &self.heartbeat)
114 .finish_non_exhaustive()
115 }
116}
117
118impl Default for BinanceSpotWebSocketClient {
119 fn default() -> Self {
120 Self::new(None, None, None, None, TransportBackend::default()).unwrap()
121 }
122}
123
124impl BinanceSpotWebSocketClient {
125 pub fn new(
131 url: Option<String>,
132 api_key: Option<String>,
133 api_secret: Option<String>,
134 heartbeat: Option<u64>,
135 transport_backend: TransportBackend,
136 ) -> anyhow::Result<Self> {
137 let url = url.unwrap_or(BINANCE_SPOT_SBE_WS_URL.to_string());
138
139 let credential = match (
140 api_key.map(SecretString::from),
141 api_secret.map(SecretString::from),
142 ) {
143 (Some(key), Some(secret)) => {
144 let credential = Ed25519Credential::new(key, secret).map_err(|e| {
145 anyhow::anyhow!(
146 "Binance Spot SBE market-data streams require an Ed25519 API key \
147 (HMAC keys are not supported): {e}"
148 )
149 })?;
150 Some(Arc::new(credential))
151 }
152 _ => None,
153 };
154
155 Ok(Self {
156 url: url.into(),
157 credential,
158 heartbeat,
159 signal: Arc::new(AtomicBool::new(false)),
160 slots: Arc::new(ConnectionSlots(Mutex::new(Vec::new()))),
161 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
162 out_tx: Arc::new(Mutex::new(None)),
163 out_rx: Arc::new(Mutex::new(None)),
164 request_id_counter: Arc::new(AtomicU64::new(1)),
165 instruments_cache: Arc::new(AtomicMap::new()),
166 transport_backend,
167 proxy_url: None,
168 socket_factory: None,
169 socket_endpoint: None,
170 })
171 }
172
173 #[must_use]
175 pub fn with_proxy(mut self, proxy_url: Option<String>) -> Self {
176 self.proxy_url = proxy_url.map(SecretString::from);
177 self
178 }
179
180 #[must_use]
182 pub fn with_socket_control(
183 mut self,
184 factory: SocketControlFactory,
185 endpoint: impl Into<String>,
186 ) -> Self {
187 self.socket_factory = Some(factory);
188 self.socket_endpoint = Some(endpoint.into());
189 self
190 }
191
192 #[must_use]
194 pub fn has_credentials(&self) -> bool {
195 self.credential.is_some()
196 }
197
198 #[must_use]
200 pub fn is_active(&self) -> bool {
201 let slots = self.slots.lock();
202 slots
203 .iter()
204 .any(|s| s.connection_mode.load(Ordering::Relaxed) == ConnectionMode::Active as u8)
205 }
206
207 #[must_use]
209 pub fn is_closed(&self) -> bool {
210 let slots = self.slots.lock();
211 slots.is_empty()
212 || slots
213 .iter()
214 .all(|s| s.connection_mode.load(Ordering::Relaxed) == ConnectionMode::Closed as u8)
215 }
216
217 #[must_use]
219 pub fn subscription_count(&self) -> usize {
220 let slots = self.slots.lock();
221 slots.iter().map(|s| s.subscriptions_state.len()).sum()
222 }
223
224 pub async fn connect(&mut self) -> BinanceWsResult<()> {
230 let connect_lock = Arc::clone(&self.connect_lock);
231 let _connect_guard = connect_lock.lock().await;
232
233 if !self.slots.lock().is_empty() {
234 self.close_connections().await?;
235 }
236
237 {
238 let _slots = self.slots.lock();
239 self.signal.store(false, Ordering::Release);
240 }
241
242 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel();
243 *self.out_tx.lock() = Some(out_tx);
244 *self.out_rx.lock() = Some(out_rx);
245
246 let slot = self.create_connection(0).await?;
247 let shutdown = {
248 let mut slots = self.slots.lock();
249 let shutdown = self.signal.load(Ordering::Acquire);
250 slots.push(slot);
251 shutdown
252 };
253
254 if shutdown {
255 let rollback = self.close_connections().await;
256 return Err(BinanceWsError::ClientError(match rollback {
257 Ok(()) => "Binance Spot SBE stream pool shutdown began during connect".to_string(),
258 Err(e) => format!(
259 "Binance Spot SBE stream pool shutdown began during connect; rollback failed: {e}"
260 ),
261 }));
262 }
263
264 log::debug!(
265 "Connected to Binance Spot SBE stream pool: url={}",
266 self.url.origin()
267 );
268 Ok(())
269 }
270
271 pub async fn close(&mut self) -> BinanceWsResult<()> {
277 self.begin_shutdown();
278 let connect_lock = Arc::clone(&self.connect_lock);
279 let _connect_guard = connect_lock.lock().await;
280 self.close_connections().await
281 }
282
283 pub(crate) fn begin_shutdown(&self) {
284 let slots = self.slots.lock();
285 self.signal.store(true, Ordering::Release);
286
287 for slot in slots.iter() {
288 if let Some(control) = &slot.socket_control {
289 control.deregister();
290 }
291 slot.cancellation_token.cancel();
292 let _ = slot.cmd_tx.send(BinanceSpotWsStreamsCommand::Disconnect);
293 }
294 }
295
296 async fn close_connections(&self) -> BinanceWsResult<()> {
297 self.begin_shutdown();
298
299 let mut batch = ConnectionSlotBatch::take(&self.slots);
300 let mut index = batch.slots.len();
301 while index > 0 {
302 index -= 1;
303 let slot = &mut batch.slots[index];
304 if let Some(control) = &slot.socket_control {
305 control.deregister();
306 }
307 slot.cancellation_token.cancel();
308 let _result = slot.cmd_tx.send(BinanceSpotWsStreamsCommand::Disconnect);
309 let Some(outcome) = finish_task(
310 &mut slot.task_handle,
311 std::time::Duration::from_secs(2),
312 std::time::Duration::from_secs(2),
313 )
314 .await
315 else {
316 if slot.shutdown_errors.is_empty() {
317 batch.slots.remove(index);
318 }
319 continue;
320 };
321
322 match outcome {
323 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
324 TaskJoinOutcome::Failed(e) => slot
325 .shutdown_errors
326 .push(format!("Spot SBE handler failed: {e}")),
327 TaskJoinOutcome::Incomplete => {
328 slot.shutdown_errors
329 .push("Spot SBE handler did not stop after abort".to_string());
330 }
331 }
332
333 if slot.task_handle.is_none() && slot.shutdown_errors.is_empty() {
334 batch.slots.remove(index);
335 }
336 }
337
338 *self.out_tx.lock() = None;
339 *self.out_rx.lock() = None;
340
341 let errors = batch
342 .slots
343 .iter_mut()
344 .flat_map(|slot| std::mem::take(&mut slot.shutdown_errors))
345 .collect::<Vec<_>>();
346 batch.slots.retain(|slot| slot.task_handle.is_some());
347 if !errors.is_empty() {
348 return Err(BinanceWsError::ClientError(errors.join("; ")));
349 }
350 log::debug!("Disconnected from Binance Spot SBE stream pool");
351 Ok(())
352 }
353
354 pub async fn subscribe(&self, streams: Vec<String>) -> BinanceWsResult<()> {
364 let _connect_guard = self.connect_lock.lock().await;
367
368 let new_streams: Vec<String> = {
370 let slots = self.slots.lock();
371
372 if self.signal.load(Ordering::Acquire) {
373 return Err(BinanceWsError::ClientError(
374 "Binance Spot SBE stream pool is shutting down".to_string(),
375 ));
376 }
377 streams
378 .into_iter()
379 .filter(|s| !slots.iter().any(|slot| slot.streams.contains(s)))
380 .collect()
381 };
382
383 if new_streams.is_empty() {
384 return Ok(());
385 }
386
387 loop {
389 let (remaining_capacity, slot_count) = {
390 let slots = self.slots.lock();
391 let cap: usize = slots
392 .iter()
393 .map(|s| MAX_STREAMS_PER_CONNECTION - s.streams.len())
394 .sum();
395 (cap, slots.len())
396 };
397
398 if remaining_capacity >= new_streams.len() || slot_count >= MAX_CONNECTIONS {
399 break;
400 }
401
402 let new_slot = self.create_connection(slot_count).await?;
403 let (slot_count, shutdown) = {
404 let mut slots = self.slots.lock();
405 let shutdown = self.signal.load(Ordering::Acquire);
406 slots.push(new_slot);
407 (slots.len(), shutdown)
408 };
409
410 if shutdown {
411 let client = self.clone();
412 let rollback = client.close_connections().await;
413 return Err(BinanceWsError::ClientError(match rollback {
414 Ok(()) => {
415 "Binance Spot SBE stream pool shutdown began during subscribe".to_string()
416 }
417 Err(e) => format!(
418 "Binance Spot SBE stream pool shutdown began during subscribe; rollback failed: {e}"
419 ),
420 }));
421 }
422
423 log::debug!(
424 "Pool slot {} connected: url={}",
425 slot_count - 1,
426 self.url.origin()
427 );
428 }
429
430 let mut slots = self.slots.lock();
433
434 if self.signal.load(Ordering::Acquire) {
435 return Err(BinanceWsError::ClientError(
436 "Binance Spot SBE stream pool is shutting down".to_string(),
437 ));
438 }
439 let mut slot_batches: Vec<(usize, Vec<String>)> = Vec::new();
440 let mut slot_counts: Vec<usize> = slots.iter().map(|s| s.streams.len()).collect();
441
442 for stream in &new_streams {
443 let slot_idx = slot_counts
444 .iter()
445 .position(|&count| count < MAX_STREAMS_PER_CONNECTION)
446 .ok_or_else(|| {
447 let max_total = MAX_CONNECTIONS * MAX_STREAMS_PER_CONNECTION;
448 BinanceWsError::ClientError(format!(
449 "Pool exhausted: {max_total} total subscriptions \
450 ({MAX_CONNECTIONS} connections x {MAX_STREAMS_PER_CONNECTION} streams)"
451 ))
452 })?;
453
454 slot_counts[slot_idx] += 1;
455
456 if let Some(batch) = slot_batches.iter_mut().find(|(i, _)| *i == slot_idx) {
457 batch.1.push(stream.clone());
458 } else {
459 slot_batches.push((slot_idx, vec![stream.clone()]));
460 }
461 }
462
463 for (slot_idx, batch) in &slot_batches {
465 slots[*slot_idx]
466 .cmd_tx
467 .send(BinanceSpotWsStreamsCommand::Subscribe {
468 streams: batch.clone(),
469 })
470 .map_err(|e| {
471 BinanceWsError::ClientError(format!(
472 "Handler not available for pool slot {slot_idx}: {e}"
473 ))
474 })?;
475 slots[*slot_idx].streams.extend(batch.iter().cloned());
476 }
477
478 Ok(())
479 }
480
481 pub async fn unsubscribe(&self, streams: Vec<String>) -> BinanceWsResult<()> {
487 let _connect_guard = self.connect_lock.lock().await;
488 let mut slots = self.slots.lock();
489
490 if self.signal.load(Ordering::Acquire) {
491 return Err(BinanceWsError::ClientError(
492 "Binance Spot SBE stream pool is shutting down".to_string(),
493 ));
494 }
495 let mut slot_batches: Vec<(usize, Vec<String>)> = Vec::new();
496
497 for stream in &streams {
498 if let Some(slot_idx) = slots.iter().position(|s| s.streams.contains(stream)) {
499 if let Some(batch) = slot_batches.iter_mut().find(|(i, _)| *i == slot_idx) {
500 batch.1.push(stream.clone());
501 } else {
502 slot_batches.push((slot_idx, vec![stream.clone()]));
503 }
504 }
505 }
506
507 for (slot_idx, batch) in &slot_batches {
509 slots[*slot_idx]
510 .cmd_tx
511 .send(BinanceSpotWsStreamsCommand::Unsubscribe {
512 streams: batch.clone(),
513 })
514 .map_err(|e| {
515 BinanceWsError::ClientError(format!(
516 "Handler not available for pool slot {slot_idx}: {e}"
517 ))
518 })?;
519
520 for stream in batch {
521 slots[*slot_idx].streams.retain(|s| s != stream);
522 }
523 }
524
525 Ok(())
526 }
527
528 pub fn stream(&self) -> impl Stream<Item = BinanceSpotWsMessage> + 'static {
533 let out_rx = self.out_rx.lock().take();
534 async_stream::stream! {
535 if let Some(mut rx) = out_rx {
536 while let Some(msg) = rx.recv().await {
537 yield msg;
538 }
539 }
540 }
541 }
542
543 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
545 self.instruments_cache.rcu(|m| {
546 for inst in instruments {
547 m.insert(inst.symbol().inner(), inst.clone());
548 }
549 });
550 }
551
552 pub fn replace_instruments(&self, instruments: &[InstrumentAny]) {
554 let cache = instruments
555 .iter()
556 .map(|instrument| (instrument.symbol().inner(), instrument.clone()))
557 .collect();
558 self.instruments_cache.store(cache);
559 }
560
561 pub fn cache_instrument(&self, instrument: InstrumentAny) {
563 self.instruments_cache
564 .insert(instrument.symbol().inner(), instrument);
565 }
566
567 #[must_use]
569 pub fn instruments_cache(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
570 self.instruments_cache.clone()
571 }
572
573 #[must_use]
575 pub fn get_instrument(&self, symbol: &str) -> Option<InstrumentAny> {
576 self.instruments_cache.get_cloned(&Ustr::from(symbol))
577 }
578
579 async fn create_connection(&self, slot_index: usize) -> BinanceWsResult<ConnectionSlot> {
580 let out_tx = self.out_tx.lock().clone().ok_or_else(|| {
581 BinanceWsError::ClientError("Output channel not initialized".to_string())
582 })?;
583
584 let (raw_handler, raw_rx) = channel_message_handler();
585 let ping_handler: PingHandler = Arc::new(move |_| {});
586
587 let mut headers = create_standard_nautilus_headers();
588
589 if let Some(ref cred) = self.credential {
590 headers.push((
591 BINANCE_API_KEY_HEADER.to_string(),
592 cred.api_key().to_string(),
593 ));
594 }
595
596 let config = WebSocketConfig {
597 url: self.url.expose().to_owned(),
598 headers,
599 heartbeat_interval_secs: self.heartbeat,
600 heartbeat_payload: None,
601 connect_timeout_ms: Some(5_000),
602 reconnect_delay_initial_ms: Some(500),
603 reconnect_delay_max_ms: Some(5_000),
604 reconnect_backoff_factor: Some(2.0),
605 reconnect_jitter_ms: Some(250),
606 reconnect_max_attempts: None,
607 heartbeat_timeout_secs: None,
608 idle_timeout_ms: None,
609 writer_capacity: None,
610 backend: self.transport_backend,
611 proxy_url: self
612 .proxy_url
613 .as_ref()
614 .map(|value| value.expose_secret().to_owned()),
615 max_message_size_bytes: None,
616 max_frame_size_bytes: None,
617 };
618
619 let keyed_quotas = vec![(
620 BINANCE_RATE_LIMIT_KEY_SUBSCRIPTION[0].to_string(),
621 *BINANCE_WS_SUBSCRIPTION_QUOTA,
622 )];
623
624 let socket_control = self
625 .socket_factory
626 .as_ref()
627 .zip(self.socket_endpoint.as_ref())
628 .map(|(factory, endpoint)| {
629 let endpoint = if slot_index == 0 {
630 endpoint.clone()
631 } else {
632 format!("{endpoint}-{slot_index}")
633 };
634 factory.control(endpoint)
635 });
636 let client = WebSocketClient::builder()
637 .config(config)
638 .message_handler(raw_handler)
639 .ping_handler(ping_handler)
640 .keyed_quotas(keyed_quotas)
641 .default_quota(*BINANCE_WS_CONNECTION_QUOTA)
642 .maybe_state_sink(socket_control.as_ref().map(SocketControl::sink))
643 .connect()
644 .await
645 .map_err(|e| BinanceWsError::NetworkError(e.to_string()))?;
646
647 let connection_mode = client.connection_mode_atomic();
648 let reconnect_handle = client.reconnect_handle();
649 let subscriptions_state = SubscriptionState::new('@');
650 let cancellation_token = CancellationToken::new();
651
652 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
653
654 let mut handler = BinanceSpotWsFeedHandler::new(
655 self.signal.clone(),
656 cmd_rx,
657 raw_rx,
658 out_tx.clone(),
659 subscriptions_state.clone(),
660 self.request_id_counter.clone(),
661 );
662
663 cmd_tx
664 .send(BinanceSpotWsStreamsCommand::SetClient(client))
665 .map_err(|e| BinanceWsError::ClientError(format!("Failed to set client: {e}")))?;
666
667 let signal = self.signal.clone();
668 let token = cancellation_token.clone();
669 let subs = subscriptions_state.clone();
670 let resubscribe_tx = cmd_tx.clone();
671
672 let mut task_handle = TaskSlot::new();
673 if let Err(e) = task_handle.spawn(async move {
674 loop {
675 tokio::select! {
676 () = token.cancelled() => {
677 log::debug!("Handler task cancelled");
678 break;
679 }
680 result = handler.next() => {
681 match result {
682 Some(BinanceSpotWsMessage::Reconnected) => {
683 log::info!("WebSocket reconnected, restoring subscriptions");
684 let all_topics = subs.all_topics();
685 for topic in &all_topics {
686 subs.mark_failure(topic);
687 }
688
689 let streams = subs.all_topics();
690 if !streams.is_empty()
691 && let Err(e) = resubscribe_tx.send(BinanceSpotWsStreamsCommand::Subscribe { streams }) {
692 log::error!("Failed to resubscribe after reconnect: {e}");
693 }
694
695 if out_tx.send(BinanceSpotWsMessage::Reconnected).is_err() {
696 log::debug!("Output channel closed");
697 break;
698 }
699 }
700 Some(msg) => {
701 if out_tx.send(msg).is_err() {
702 log::debug!("Output channel closed");
703 break;
704 }
705 }
706 None => {
707 if signal.load(Ordering::Relaxed) {
708 log::debug!("Handler received shutdown signal");
709 } else {
710 log::warn!("Handler loop ended unexpectedly");
711 }
712 break;
713 }
714 }
715 }
716 }
717 }
718 }) {
719 cancellation_token.cancel();
720 let shutdown_error = match finish_task(
721 &mut task_handle,
722 std::time::Duration::ZERO,
723 std::time::Duration::from_secs(2),
724 )
725 .await
726 {
727 Some(TaskJoinOutcome::Failed(error)) => {
728 Some(format!("Binance Spot WS handler task failed: {error}"))
729 }
730 Some(TaskJoinOutcome::Incomplete) => {
731 Some("Binance Spot WS handler task did not stop after abort".to_string())
732 }
733 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => None,
734 };
735 return Err(BinanceWsError::ClientError(match shutdown_error {
736 Some(shutdown_error) => format!(
737 "Failed to start Spot WS handler task: {e}; startup rollback failed: \
738 {shutdown_error}"
739 ),
740 None => format!("Failed to start Spot WS handler task: {e}"),
741 }));
742 }
743
744 if let Some(control) = &socket_control {
745 control.register(move || reconnect_handle.request_reconnect());
746 }
747
748 Ok(ConnectionSlot {
749 cmd_tx,
750 streams: Vec::new(),
751 subscriptions_state,
752 task_handle,
753 cancellation_token,
754 connection_mode,
755 socket_control,
756 shutdown_errors: Vec::new(),
757 })
758 }
759}
760
761struct ConnectionSlots(Mutex<Vec<ConnectionSlot>>);
762
763impl std::ops::Deref for ConnectionSlots {
764 type Target = Mutex<Vec<ConnectionSlot>>;
765
766 fn deref(&self) -> &Self::Target {
767 &self.0
768 }
769}
770
771impl Drop for ConnectionSlots {
772 fn drop(&mut self) {
773 for slot in self.0.get_mut().iter() {
774 slot.cancellation_token.cancel();
775 if let Some(handle) = slot.task_handle.as_ref() {
776 handle.abort();
777 }
778
779 if let Some(control) = &slot.socket_control {
780 control.deregister();
781 }
782 }
783 }
784}
785
786struct ConnectionSlotBatch<'a> {
787 owner: &'a Mutex<Vec<ConnectionSlot>>,
788 slots: Vec<ConnectionSlot>,
789}
790
791impl<'a> ConnectionSlotBatch<'a> {
792 fn take(owner: &'a Mutex<Vec<ConnectionSlot>>) -> Self {
793 let slots = std::mem::take(&mut *owner.lock());
794 Self { owner, slots }
795 }
796}
797
798impl Drop for ConnectionSlotBatch<'_> {
799 fn drop(&mut self) {
800 self.owner.lock().extend(self.slots.drain(..));
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use rstest::rstest;
807
808 use super::*;
809
810 #[rstest]
811 #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
812 #[case(
813 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
814 "wss://stream.example.com:9443"
815 )]
816 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
817 let client = BinanceSpotWebSocketClient::new(
818 Some(url.to_string()),
819 None,
820 None,
821 None,
822 TransportBackend::default(),
823 )
824 .unwrap();
825 let debug = format!("{client:?}");
826 assert!(debug.contains(&format!("url: {origin:?}")));
827 assert!(!debug.contains("SYN-"));
828 assert_eq!(client.url.expose(), url);
829 }
830
831 #[tokio::test]
832 async fn test_cancelled_close_retains_connection_slot() {
833 let mut client =
834 BinanceSpotWebSocketClient::new(None, None, None, None, TransportBackend::default())
835 .unwrap();
836 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
837 client.slots.lock().push(ConnectionSlot {
838 cmd_tx,
839 streams: Vec::new(),
840 subscriptions_state: SubscriptionState::new('@'),
841 task_handle: TaskSlot::from_handle(tokio::spawn(std::future::pending())),
842 cancellation_token: CancellationToken::new(),
843 connection_mode: Arc::new(AtomicU8::new(ConnectionMode::Active as u8)),
844 socket_control: None,
845 shutdown_errors: Vec::new(),
846 });
847
848 {
849 let close = client.close();
850 tokio::pin!(close);
851 tokio::select! {
852 result = &mut close => panic!("close completed unexpectedly: {result:?}"),
853 command = cmd_rx.recv() => assert!(command.is_some()),
854 }
855 }
856
857 let slots = client.slots.lock();
858 assert_eq!(slots.len(), 1);
859 assert!(slots[0].task_handle.is_some());
860 }
861
862 #[rstest]
863 fn test_new_rejects_hmac_secret_with_actionable_error() {
864 let secret = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [0u8; 48]);
869
870 let result = BinanceSpotWebSocketClient::new(
871 None,
872 Some("test_key".to_string()),
873 Some(secret),
874 None,
875 TransportBackend::default(),
876 );
877
878 let err = result.expect_err("HMAC secret must be rejected");
879 let msg = err.to_string();
880 assert!(
881 msg.contains("Ed25519"),
882 "error should mention Ed25519, was: {msg}"
883 );
884 assert!(
885 msg.contains("HMAC"),
886 "error should mention HMAC, was: {msg}"
887 );
888 }
889
890 #[rstest]
891 fn test_with_proxy_preserves_proxy_url() {
892 let client =
893 BinanceSpotWebSocketClient::new(None, None, None, None, TransportBackend::default())
894 .unwrap()
895 .with_proxy(Some("socks5://proxy.example:1080".to_string()));
896
897 assert_eq!(
898 client.proxy_url.as_ref().map(SecretString::expose_secret),
899 Some("socks5://proxy.example:1080")
900 );
901 }
902}