1use std::sync::{
19 Arc,
20 atomic::{AtomicBool, AtomicU8, Ordering},
21};
22
23use nautilus_live::{
24 SocketControl,
25 task::{TaskJoinOutcome, TaskSlot, finish_task},
26};
27use nautilus_network::{
28 SocketStateSink,
29 mode::ConnectionMode,
30 websocket::{
31 AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
32 channel_epoch_message_handler, proxy::ProxyUrl,
33 },
34};
35
36use super::{
37 handler::{FeedHandler, HandlerCommand},
38 messages::PolymarketWsMessage,
39};
40use crate::common::{
41 credential::Credential,
42 urls::{clob_ws_market_url, clob_ws_user_url},
43};
44
45pub(super) const POLYMARKET_HEARTBEAT_SECS: u64 = 10;
49pub(super) const POLYMARKET_HEARTBEAT_PAYLOAD: &str = "PING";
50
51const POLYMARKET_HEARTBEAT_TIMEOUT_SECS: u64 = POLYMARKET_HEARTBEAT_SECS * 3;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum WsChannel {
59 Market,
60 User,
61}
62
63#[derive(Clone, Debug)]
67pub struct WsSubscriptionHandle {
68 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
69}
70
71impl WsSubscriptionHandle {
72 pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
74 self.cmd_tx
75 .read()
76 .await
77 .send(HandlerCommand::SubscribeMarket(asset_ids))
78 .map_err(|e| anyhow::anyhow!("Failed to send SubscribeMarket: {e}"))
79 }
80
81 pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
83 self.cmd_tx
84 .read()
85 .await
86 .send(HandlerCommand::UnsubscribeMarket(asset_ids))
87 .map_err(|e| anyhow::anyhow!("Failed to send UnsubscribeMarket: {e}"))
88 }
89
90 #[cfg(test)]
94 pub(crate) fn from_sender(sender: tokio::sync::mpsc::UnboundedSender<HandlerCommand>) -> Self {
95 Self {
96 cmd_tx: Arc::new(tokio::sync::RwLock::new(sender)),
97 }
98 }
99}
100
101#[derive(Debug)]
107pub struct PolymarketWebSocketClient {
108 channel: WsChannel,
109 url: String,
110 connection_mode: Arc<AtomicU8>,
111 signal: Arc<AtomicBool>,
112 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
113 out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>>,
114 credential: Option<Credential>,
115 subscriptions: SubscriptionState,
116 discovery_subscribed: Arc<AtomicBool>,
117 auth_tracker: AuthTracker,
118 user_subscribed: Arc<AtomicBool>,
121 task_handle: TaskSlot<()>,
122 subscribe_new_markets: bool,
123 transport_backend: TransportBackend,
124 proxy_url: Option<ProxyUrl>,
125 socket_sink: Option<SocketStateSink>,
126 socket_control: Option<SocketControl>,
127}
128
129#[derive(Clone, Debug)]
130pub(crate) struct PolymarketWebSocketShutdownHandle {
131 signal: Arc<AtomicBool>,
132}
133
134impl PolymarketWebSocketShutdownHandle {
135 pub(crate) fn begin_shutdown(&self) {
136 self.signal.store(true, Ordering::Relaxed);
137 }
138}
139
140impl PolymarketWebSocketClient {
141 #[must_use]
145 pub fn new_market(
146 base_url: Option<String>,
147 subscribe_new_markets: bool,
148 transport_backend: TransportBackend,
149 ) -> Self {
150 Self::new_market_with_proxy(base_url, subscribe_new_markets, transport_backend, None)
151 }
152
153 #[must_use]
155 pub fn new_market_with_proxy(
156 base_url: Option<String>,
157 subscribe_new_markets: bool,
158 transport_backend: TransportBackend,
159 proxy_url: Option<ProxyUrl>,
160 ) -> Self {
161 let url = base_url.unwrap_or_else(|| clob_ws_market_url().to_string());
162 Self::new_inner(
163 WsChannel::Market,
164 url,
165 None,
166 subscribe_new_markets,
167 transport_backend,
168 proxy_url,
169 )
170 }
171
172 #[must_use]
176 pub fn new_user(
177 base_url: Option<String>,
178 credential: Credential,
179 transport_backend: TransportBackend,
180 ) -> Self {
181 Self::new_user_with_proxy(base_url, credential, transport_backend, None)
182 }
183
184 #[must_use]
186 pub fn new_user_with_proxy(
187 base_url: Option<String>,
188 credential: Credential,
189 transport_backend: TransportBackend,
190 proxy_url: Option<ProxyUrl>,
191 ) -> Self {
192 let url = base_url.unwrap_or_else(|| clob_ws_user_url().to_string());
193 Self::new_inner(
194 WsChannel::User,
195 url,
196 Some(credential),
197 false,
198 transport_backend,
199 proxy_url,
200 )
201 }
202
203 fn new_inner(
204 channel: WsChannel,
205 url: String,
206 credential: Option<Credential>,
207 subscribe_new_markets: bool,
208 transport_backend: TransportBackend,
209 proxy_url: Option<ProxyUrl>,
210 ) -> Self {
211 let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
212 Self {
213 channel,
214 url,
215 connection_mode: Arc::new(AtomicU8::new(ConnectionMode::Closed.as_u8())),
216 signal: Arc::new(AtomicBool::new(false)),
217 cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
218 out_rx: None,
219 credential,
220 subscriptions: SubscriptionState::new(':'),
221 discovery_subscribed: Arc::new(AtomicBool::new(false)),
222 auth_tracker: AuthTracker::new(),
223 user_subscribed: Arc::new(AtomicBool::new(false)),
224 task_handle: TaskSlot::new(),
225 subscribe_new_markets,
226 transport_backend,
227 proxy_url,
228 socket_sink: None,
229 socket_control: None,
230 }
231 }
232
233 #[must_use]
235 pub fn with_state_sink(mut self, state_sink: SocketStateSink) -> Self {
236 self.socket_sink = Some(state_sink);
237 self
238 }
239
240 #[must_use]
242 pub(crate) fn with_socket_control(mut self, control: SocketControl) -> Self {
243 self.socket_control = Some(control);
244 self
245 }
246
247 #[cfg(test)]
248 pub(crate) fn proxy_url(&self) -> Option<&ProxyUrl> {
249 self.proxy_url.as_ref()
250 }
251
252 pub async fn connect(&mut self) -> anyhow::Result<()> {
254 let mode = ConnectionMode::from_atomic(&self.connection_mode);
255 if mode.is_active() || mode.is_reconnect() {
256 log::warn!("Polymarket WebSocket already connected or reconnecting");
257 return Ok(());
258 }
259
260 if self.task_handle.is_some() {
261 self.disconnect().await?;
262 }
263
264 let (message_handler, raw_rx) = channel_epoch_message_handler();
265 let cfg = self.websocket_config();
266
267 let client = WebSocketClient::epoch_builder()
268 .config(cfg)
269 .epoch_handler(message_handler)
270 .maybe_state_sink(
271 self.socket_control
272 .as_ref()
273 .map(SocketControl::sink)
274 .or_else(|| self.socket_sink.clone()),
275 )
276 .connect()
277 .await?;
278
279 if let Some(control) = &self.socket_control {
280 let handle = client.reconnect_handle();
281 control.register(move || handle.request_reconnect());
282 }
283 let connection_epoch = client.connection_epoch();
284
285 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
286 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<PolymarketWsMessage>();
287
288 *self.cmd_tx.write().await = cmd_tx.clone();
289 self.out_rx = Some(out_rx);
290
291 let client_mode = client.connection_mode_atomic();
292 self.connection_mode = client_mode;
293
294 log::debug!("Polymarket WebSocket connected: {}", self.url);
295
296 let initial_market_replay = match self.channel {
299 WsChannel::Market => {
300 let topics = self.subscriptions.reset_after_reconnect();
301 if !topics.is_empty() || self.discovery_subscribed.load(Ordering::Relaxed) {
302 log::debug!(
303 "Replaying market subscription state onto new session: assets={}, discovery={}",
304 topics.len(),
305 self.discovery_subscribed.load(Ordering::Relaxed),
306 );
307 Some((topics, connection_epoch))
308 } else {
309 None
310 }
311 }
312 WsChannel::User => {
313 if self.user_subscribed.load(Ordering::Relaxed) {
314 log::debug!("Replaying user subscribe onto new session");
315 cmd_tx
316 .send(HandlerCommand::SubscribeUser)
317 .map_err(|e| anyhow::anyhow!("Failed to replay SubscribeUser: {e}"))?;
318 }
319 None
320 }
321 };
322
323 let signal = Arc::clone(&self.signal);
324 let channel = self.channel;
325 let credential = self.credential.clone();
326 let subscriptions = self.subscriptions.clone();
327 let discovery_subscribed = Arc::clone(&self.discovery_subscribed);
328 let auth_tracker = self.auth_tracker.clone();
329 let user_subscribed = self.user_subscribed.load(Ordering::Relaxed);
330 let subscribe_new_markets = self.subscribe_new_markets;
331
332 if let Err(e) = self.task_handle.spawn(async move {
333 let mut handler = FeedHandler::new(
334 signal,
335 channel,
336 Some(client),
337 cmd_rx,
338 raw_rx,
339 out_tx,
340 credential,
341 subscriptions,
342 discovery_subscribed,
343 initial_market_replay,
344 auth_tracker,
345 user_subscribed,
346 subscribe_new_markets,
347 );
348
349 loop {
350 match handler.next().await {
351 Some(PolymarketWsMessage::Reconnected) => {
352 log::info!("Polymarket WebSocket reconnected");
353
354 if handler.send(PolymarketWsMessage::Reconnected).is_err() {
355 if handler.is_stopped() {
356 log::debug!("Output channel closed, stopping handler");
357 } else {
358 log::error!("Output channel closed, stopping handler");
359 }
360 break;
361 }
362 }
363 Some(msg) => {
364 if handler.send(msg).is_err() {
365 if handler.is_stopped() {
366 log::debug!("Output channel closed, stopping handler");
367 } else {
368 log::error!("Output channel closed, stopping handler");
369 }
370 break;
371 }
372 }
373 None => {
374 if handler.is_stopped() {
375 log::debug!("Stop signal received, ending handler task");
376 } else {
377 log::warn!("Polymarket WebSocket stream ended unexpectedly");
378 }
379 break;
380 }
381 }
382 }
383 log::debug!("Polymarket WebSocket handler task completed");
384 }) {
385 self.out_rx = None;
386 anyhow::bail!("Failed to start Polymarket WebSocket handler task: {e}");
387 }
388 Ok(())
389 }
390
391 fn websocket_config(&self) -> WebSocketConfig {
392 let heartbeat_payload = match self.channel {
395 WsChannel::Market => None,
396 WsChannel::User => Some(POLYMARKET_HEARTBEAT_PAYLOAD.to_string()),
397 };
398
399 WebSocketConfig {
400 url: self.url.clone(),
401 headers: vec![],
402 heartbeat_interval_secs: Some(POLYMARKET_HEARTBEAT_SECS),
403 heartbeat_payload,
404 connect_timeout_ms: Some(15_000),
405 reconnect_delay_initial_ms: Some(250),
406 reconnect_delay_max_ms: Some(5_000),
407 reconnect_backoff_factor: Some(2.0),
408 reconnect_jitter_ms: Some(200),
409 reconnect_max_attempts: None,
410 heartbeat_timeout_secs: Some(POLYMARKET_HEARTBEAT_TIMEOUT_SECS),
411 idle_timeout_ms: None,
412 backend: self.transport_backend,
413 proxy_url: self.proxy_url.as_ref().map(|url| url.expose().to_string()),
414 }
415 }
416
417 pub(crate) fn begin_shutdown(&self) {
418 self.signal.store(true, Ordering::Relaxed);
419 }
420
421 pub(crate) fn shutdown_handle(&self) -> PolymarketWebSocketShutdownHandle {
422 PolymarketWebSocketShutdownHandle {
423 signal: Arc::clone(&self.signal),
424 }
425 }
426
427 pub(crate) fn abort(&mut self) {
428 self.begin_shutdown();
429 self.connection_mode
430 .store(ConnectionMode::Closed.as_u8(), Ordering::SeqCst);
431 self.task_handle.abort();
432 self.auth_tracker.invalidate();
433
434 if let Some(control) = &self.socket_control {
435 control.deregister();
436 }
437 }
438
439 pub async fn disconnect(&mut self) -> anyhow::Result<()> {
441 log::debug!("Disconnecting Polymarket WebSocket");
442 self.signal.store(true, Ordering::Relaxed);
443
444 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
445 log::debug!("Failed to send disconnect (handler may already be shut down): {e}");
446 }
447
448 let task_result = match finish_task(
449 &mut self.task_handle,
450 std::time::Duration::from_secs(2),
451 std::time::Duration::from_secs(2),
452 )
453 .await
454 {
455 None | Some(TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted) => Ok(()),
456 Some(TaskJoinOutcome::Failed(error)) => Err(anyhow::anyhow!(
457 "Polymarket WebSocket handler failed: {error}"
458 )),
459 Some(TaskJoinOutcome::Incomplete) => Err(anyhow::anyhow!(
460 "Polymarket WebSocket handler did not stop after abort"
461 )),
462 };
463 self.auth_tracker.invalidate();
466
467 if let Some(control) = &self.socket_control {
468 control.deregister();
469 }
470 log::debug!("Polymarket WebSocket disconnected");
471 task_result
472 }
473
474 #[must_use]
476 pub fn is_active(&self) -> bool {
477 ConnectionMode::from_atomic(&self.connection_mode).is_active()
478 }
479
480 pub(crate) fn has_task(&self) -> bool {
481 self.task_handle.is_some()
482 }
483
484 #[must_use]
486 pub fn url(&self) -> &str {
487 &self.url
488 }
489
490 #[must_use]
492 pub fn subscription_count(&self) -> usize {
493 self.subscriptions.all_topics().len()
494 }
495
496 pub(crate) fn clear_reconnect_state(&self) {
501 self.subscriptions.clear();
502 self.discovery_subscribed.store(false, Ordering::Relaxed);
503 self.user_subscribed.store(false, Ordering::Relaxed);
504 self.auth_tracker.invalidate();
505 }
506
507 #[must_use]
509 pub fn is_authenticated(&self) -> bool {
510 self.auth_tracker.is_authenticated()
511 }
512
513 pub async fn subscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
522 if self.channel != WsChannel::Market {
523 anyhow::bail!(
524 "subscribe_market() requires a market-channel client (created with new_market())"
525 );
526 }
527 self.cmd_tx
528 .read()
529 .await
530 .send(HandlerCommand::SubscribeMarket(asset_ids))
531 .map_err(|e| anyhow::anyhow!("Failed to send SubscribeMarket: {e}"))
532 }
533
534 pub async fn unsubscribe_market(&self, asset_ids: Vec<String>) -> anyhow::Result<()> {
543 if self.channel != WsChannel::Market {
544 anyhow::bail!(
545 "unsubscribe_market() requires a market-channel client (created with new_market())"
546 );
547 }
548 self.cmd_tx
549 .read()
550 .await
551 .send(HandlerCommand::UnsubscribeMarket(asset_ids))
552 .map_err(|e| anyhow::anyhow!("Failed to send UnsubscribeMarket: {e}"))
553 }
554
555 pub async fn subscribe_user(&self) -> anyhow::Result<()> {
561 if self.channel != WsChannel::User {
562 anyhow::bail!(
563 "subscribe_user() requires a user-channel client (created with new_user())"
564 );
565 }
566 self.cmd_tx
567 .read()
568 .await
569 .send(HandlerCommand::SubscribeUser)
570 .map_err(|e| anyhow::anyhow!("Failed to send SubscribeUser: {e}"))?;
571 self.user_subscribed.store(true, Ordering::Relaxed);
574 Ok(())
575 }
576
577 #[must_use]
579 pub fn clone_subscription_handle(&self) -> WsSubscriptionHandle {
580 WsSubscriptionHandle {
581 cmd_tx: Arc::clone(&self.cmd_tx),
582 }
583 }
584
585 #[must_use]
591 pub fn take_message_receiver(
592 &mut self,
593 ) -> Option<tokio::sync::mpsc::UnboundedReceiver<PolymarketWsMessage>> {
594 self.out_rx.take()
595 }
596
597 pub async fn next_message(&mut self) -> Option<PolymarketWsMessage> {
602 if let Some(ref mut rx) = self.out_rx {
603 rx.recv().await
604 } else {
605 None
606 }
607 }
608}
609
610impl Drop for PolymarketWebSocketClient {
611 fn drop(&mut self) {
612 self.signal.store(true, Ordering::Relaxed);
613
614 if let Some(handle) = self.task_handle.as_ref() {
615 handle.abort();
616 }
617
618 if let Some(control) = &self.socket_control {
619 control.deregister();
620 }
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use std::net::SocketAddr;
627
628 use axum::{
629 Router,
630 extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade},
631 response::Response,
632 routing::get,
633 };
634 use nautilus_network::{
635 RECONNECTED,
636 websocket::{TransportBackend, WebSocketConfig, proxy::ProxyUrl},
637 };
638 use rstest::rstest;
639
640 use super::*;
641
642 async fn handle_upgrade(ws: WebSocketUpgrade) -> Response {
643 ws.on_upgrade(handle_socket)
644 }
645
646 async fn handle_socket(mut socket: WebSocket) {
647 let _ = socket
648 .send(AxumWsMessage::Text(RECONNECTED.to_string().into()))
649 .await;
650 }
651
652 async fn start_test_server() -> SocketAddr {
653 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
654 .await
655 .expect("bind test websocket server");
656 let addr = listener.local_addr().expect("test websocket address");
657 let router = Router::new().route("/ws", get(handle_upgrade));
658
659 tokio::spawn(async move {
660 axum::serve(listener, router)
661 .await
662 .expect("test websocket server failed");
663 });
664
665 tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
666 addr
667 }
668
669 #[tokio::test]
670 async fn cancelled_disconnect_retains_handler_task() {
671 let mut client = PolymarketWebSocketClient::new_market(
672 Some("ws://127.0.0.1:0".to_string()),
673 false,
674 TransportBackend::default(),
675 );
676 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
677 client.cmd_tx = Arc::new(tokio::sync::RwLock::new(cmd_tx));
678 client
679 .task_handle
680 .insert(tokio::spawn(std::future::pending()));
681
682 {
683 let disconnect = client.disconnect();
684 tokio::pin!(disconnect);
685 tokio::select! {
686 result = &mut disconnect => panic!("disconnect completed unexpectedly: {result:?}"),
687 command = cmd_rx.recv() => assert!(command.is_some()),
688 }
689 }
690
691 assert!(client.task_handle.is_some());
692 }
693
694 #[rstest]
695 #[tokio::test]
696 async fn connect_forwards_reconnected_message_to_receiver() {
697 let addr = start_test_server().await;
698 let mut client = PolymarketWebSocketClient::new_market(
699 Some(format!("ws://{addr}/ws")),
700 false,
701 TransportBackend::default(),
702 );
703
704 client.connect().await.expect("connect websocket client");
705
706 let message =
707 tokio::time::timeout(tokio::time::Duration::from_secs(2), client.next_message())
708 .await
709 .expect("wait for websocket message");
710
711 assert!(matches!(
712 message,
713 Some(super::super::messages::PolymarketWsMessage::Reconnected)
714 ));
715
716 client
717 .disconnect()
718 .await
719 .expect("disconnect websocket client");
720 }
721
722 #[rstest]
723 fn proxy_url_is_retained_for_market_and_user_clients() {
724 const MARKET_PROXY: &str = "http://market-user:market-proxy-secret@127.0.0.1:18086";
725 const USER_PROXY: &str = "https://user-user:user-proxy-secret@127.0.0.1:18087";
726 let market = PolymarketWebSocketClient::new_market_with_proxy(
727 Some("ws://market.example/ws".to_string()),
728 false,
729 TransportBackend::Tungstenite,
730 Some(ProxyUrl::parse(MARKET_PROXY).unwrap()),
731 );
732 let credential = crate::common::credential::Credential::new(
733 "fixture-key",
734 "Zml4dHVyZQ==",
735 "fixture-passphrase".to_string(),
736 )
737 .unwrap();
738 let user = PolymarketWebSocketClient::new_user_with_proxy(
739 Some("ws://user.example/ws".to_string()),
740 credential,
741 TransportBackend::Tungstenite,
742 Some(ProxyUrl::parse(USER_PROXY).unwrap()),
743 );
744 let market_config = market.websocket_config();
745 let user_config = user.websocket_config();
746 let market_debug = format!("{market:?}");
747 let user_debug = format!("{user:?}");
748 let assert_common = |config: &WebSocketConfig| {
749 assert_eq!(config.headers, Vec::<(String, String)>::new());
750 assert_eq!(config.heartbeat_interval_secs, Some(10));
751 assert_eq!(config.connect_timeout_ms, Some(15_000));
752 assert_eq!(config.reconnect_delay_initial_ms, Some(250));
753 assert_eq!(config.reconnect_delay_max_ms, Some(5_000));
754 assert_eq!(config.reconnect_backoff_factor, Some(2.0));
755 assert_eq!(config.reconnect_jitter_ms, Some(200));
756 assert_eq!(config.reconnect_max_attempts, None);
757 assert_eq!(config.idle_timeout_ms, None);
760 assert_eq!(config.backend, TransportBackend::Tungstenite);
761 };
762
763 assert_eq!(market.proxy_url.as_ref().unwrap().expose(), MARKET_PROXY);
764 assert_eq!(user.proxy_url.as_ref().unwrap().expose(), USER_PROXY);
765 assert_eq!(market_config.url, "ws://market.example/ws");
766 assert_eq!(user_config.url, "ws://user.example/ws");
767 assert_eq!(market_config.proxy_url.as_deref(), Some(MARKET_PROXY));
768 assert_eq!(user_config.proxy_url.as_deref(), Some(USER_PROXY));
769 assert_eq!(market_config.heartbeat_payload, None);
770 assert_eq!(user_config.heartbeat_payload.as_deref(), Some("PING"));
771 assert_common(&market_config);
772 assert_common(&user_config);
773 assert!(!market_debug.contains("market-proxy-secret"));
774 assert!(!user_debug.contains("user-proxy-secret"));
775 }
776}