1use std::{
19 sync::{
20 Arc,
21 atomic::{AtomicBool, Ordering},
22 },
23 time::Duration,
24};
25
26use ahash::AHashMap;
27use nautilus_core::string::secret::SecretString;
28use nautilus_live::book::snapshot::SnapshotGate;
29use nautilus_network::{
30 RECONNECTED,
31 error::SendError,
32 websocket::{AuthTracker, SubscriptionState, WebSocketClient},
33};
34use serde_json::value::RawValue;
35use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use tokio_tungstenite::tungstenite::Message;
37use tokio_util::sync::CancellationToken;
38use ustr::Ustr;
39use zeroize::Zeroize;
40
41use super::{
42 client::{POLYMARKET_HEARTBEAT_PAYLOAD, POLYMARKET_HEARTBEAT_SECS, WsChannel},
43 messages::{
44 MarketInitialSubscribeRequest, MarketSubscribeRequest, MarketUnsubscribeRequest,
45 MarketWsMessage, PolymarketWsAuth, PolymarketWsMessage, UserSubscribeRequest,
46 UserWsMessage,
47 },
48};
49use crate::{common::credential::Credential, http::error::sanitize_error_text};
50
51const INITIAL_DUMP: bool = true;
52
53#[derive(Debug)]
55pub enum HandlerCommand {
56 SetClient(WebSocketClient),
58 Disconnect,
60 SubscribeMarket(Vec<String>),
62 UnsubscribeMarket(Vec<String>),
64 CycleMarketSubscription {
67 asset_ids: Vec<String>,
68 cancel: CancellationToken,
69 responder: tokio::sync::oneshot::Sender<CycleMarketOutcome>,
70 gate: SnapshotGate,
71 },
72 SubscribeUser,
74}
75
76#[derive(Clone, Debug)]
78pub enum CycleMarketOutcome {
79 Completed,
81 ConnectionChanged,
83 Cancelled,
85 NotDesired,
87 SendFailed(SendError),
89}
90
91pub(super) struct FeedHandler {
92 signal: Arc<AtomicBool>,
93 channel: WsChannel,
94 client: Option<WebSocketClient>,
95 cmd_rx: UnboundedReceiver<HandlerCommand>,
96 raw_rx: UnboundedReceiver<(u64, Message)>,
97 out_tx: UnboundedSender<PolymarketWsMessage>,
98 credential: Option<Credential>,
99 subscriptions: SubscriptionState,
100 discovery_subscribed: Arc<AtomicBool>,
101 initial_market_replay: Option<(Vec<String>, u64)>,
102 auth_tracker: AuthTracker,
103 user_subscribed: bool,
105 market_subscription_initialized: bool,
107 market_heartbeat_next: Option<(tokio::time::Instant, u64)>,
108 market_subscription_epochs: AHashMap<String, u64>,
110 message_buffer: Vec<PolymarketWsMessage>,
112 subscribe_new_markets: bool,
114}
115
116impl FeedHandler {
117 #[expect(clippy::too_many_arguments)]
118 pub(super) fn new(
119 signal: Arc<AtomicBool>,
120 channel: WsChannel,
121 client: Option<WebSocketClient>,
122 cmd_rx: UnboundedReceiver<HandlerCommand>,
123 raw_rx: UnboundedReceiver<(u64, Message)>,
124 out_tx: UnboundedSender<PolymarketWsMessage>,
125 credential: Option<Credential>,
126 subscriptions: SubscriptionState,
127 discovery_subscribed: Arc<AtomicBool>,
128 initial_market_replay: Option<(Vec<String>, u64)>,
129 auth_tracker: AuthTracker,
130 user_subscribed: bool,
131 subscribe_new_markets: bool,
132 ) -> Self {
133 Self {
134 signal,
135 channel,
136 client,
137 cmd_rx,
138 raw_rx,
139 out_tx,
140 credential,
141 subscriptions,
142 discovery_subscribed,
143 initial_market_replay,
144 auth_tracker,
145 user_subscribed,
146 market_subscription_initialized: false,
147 market_heartbeat_next: None,
148 market_subscription_epochs: AHashMap::new(),
149 message_buffer: Vec::new(),
150 subscribe_new_markets,
151 }
152 }
153
154 pub(super) fn send(&self, msg: PolymarketWsMessage) -> Result<(), String> {
155 self.out_tx
156 .send(msg)
157 .map_err(|e| format!("Failed to send message: {e}"))
158 }
159
160 pub(super) fn is_stopped(&self) -> bool {
161 self.signal.load(Ordering::Relaxed)
162 }
163
164 async fn send_subscribe_market(&mut self, asset_ids: &[String], connection_epoch: Option<u64>) {
165 if let Err(e) = self
166 .try_send_subscribe_market(asset_ids, connection_epoch)
167 .await
168 {
169 log::error!("Failed to send market subscribe: {e}");
170 }
171 }
172
173 async fn try_send_subscribe_market(
174 &mut self,
175 asset_ids: &[String],
176 connection_epoch: Option<u64>,
177 ) -> Result<(), SendError> {
178 let Some(ref client) = self.client else {
179 log::warn!("No client available for market subscribe");
180 return Err(SendError::Closed);
181 };
182
183 let connection_epoch = connection_epoch.unwrap_or_else(|| client.connection_epoch());
184
185 for id in asset_ids {
186 self.market_subscription_epochs.remove(id);
187 self.subscriptions.mark_subscribe(id);
188 }
189
190 let payload = if self.market_subscription_initialized {
191 serde_json::to_string(&MarketSubscribeRequest {
192 assets_ids: asset_ids.to_vec(),
193 operation: "subscribe",
194 initial_dump: INITIAL_DUMP,
195 custom_feature_enabled: self.subscribe_new_markets,
196 })
197 } else {
198 serde_json::to_string(&MarketInitialSubscribeRequest {
199 assets_ids: asset_ids.to_vec(),
200 msg_type: "market",
201 initial_dump: INITIAL_DUMP,
202 custom_feature_enabled: self.subscribe_new_markets,
203 })
204 };
205
206 let payload = match payload {
207 Ok(payload) => payload,
208 Err(e) => {
209 for id in asset_ids {
210 self.market_subscription_epochs.remove(id);
211 self.subscriptions.mark_failure(id);
212 }
213
214 return Err(SendError::InvalidInput(e.to_string()));
215 }
216 };
217
218 match client
219 .send_text_on_connection(payload, None, connection_epoch)
220 .await
221 {
222 Ok(()) => {
223 for id in asset_ids {
224 if self.market_subscription_pending(id) {
225 self.market_subscription_epochs
226 .insert(id.clone(), connection_epoch);
227 }
228 }
229
230 if !self.market_subscription_initialized {
231 self.market_subscription_initialized = true;
232 self.schedule_market_heartbeat(connection_epoch);
233 }
234
235 Ok(())
236 }
237 Err(e) => {
238 for id in asset_ids {
239 self.market_subscription_epochs.remove(id);
240 self.subscriptions.mark_failure(id);
241 }
242
243 Err(e)
244 }
245 }
246 }
247
248 async fn send_unsubscribe_market(&self, asset_ids: &[String]) {
249 let Some(epoch) = self.client.as_ref().map(|client| client.connection_epoch()) else {
250 log::warn!("No client available for market unsubscribe");
251 return;
252 };
253
254 match self.try_send_unsubscribe_market(asset_ids, epoch).await {
258 Ok(()) => {}
259 Err(SendError::ConnectionChanged) => {
260 log::debug!(
261 "Dropped market unsubscribe during reconnect; replay excludes the assets"
262 );
263 }
264 Err(e) => {
265 log::error!("Failed to send market unsubscribe: {e}");
266 }
267 }
268 }
269
270 async fn try_send_unsubscribe_market(
271 &self,
272 asset_ids: &[String],
273 connection_epoch: u64,
274 ) -> Result<(), SendError> {
275 let Some(ref client) = self.client else {
276 return Err(SendError::Closed);
277 };
278
279 let req = MarketUnsubscribeRequest {
280 assets_ids: asset_ids.to_vec(),
281 operation: "unsubscribe",
282 };
283
284 let payload =
285 serde_json::to_string(&req).map_err(|e| SendError::InvalidInput(e.to_string()))?;
286 client
287 .send_text_on_connection(payload, None, connection_epoch)
288 .await
289 }
290
291 async fn cycle_market_subscription(
292 &mut self,
293 asset_ids: &[String],
294 cancel: &CancellationToken,
295 gate: &SnapshotGate,
296 ) -> CycleMarketOutcome {
297 if cancel.is_cancelled() {
298 return CycleMarketOutcome::Cancelled;
299 }
300
301 let Some(epoch) = self.client.as_ref().map(|client| client.connection_epoch()) else {
302 return CycleMarketOutcome::SendFailed(SendError::Closed);
303 };
304
305 if asset_ids
306 .iter()
307 .any(|id| !self.market_subscription_desired(id))
308 {
309 return CycleMarketOutcome::NotDesired;
310 }
311
312 match self.try_send_unsubscribe_market(asset_ids, epoch).await {
315 Ok(()) | Err(SendError::WriteTimeout) => {}
316 Err(SendError::ConnectionChanged) => {
317 return CycleMarketOutcome::ConnectionChanged;
318 }
319 Err(e) => return CycleMarketOutcome::SendFailed(e),
320 }
321
322 if cancel.is_cancelled() {
323 self.restore_market_subscription(asset_ids, epoch).await;
324 return CycleMarketOutcome::Cancelled;
325 }
326
327 if asset_ids
328 .iter()
329 .any(|id| !self.market_subscription_desired(id))
330 {
331 return CycleMarketOutcome::NotDesired;
334 }
335
336 let current = self.client.as_ref().map(|client| client.connection_epoch());
337
338 if current != Some(epoch) {
339 return CycleMarketOutcome::ConnectionChanged;
341 }
342
343 match self.try_send_subscribe_market(asset_ids, Some(epoch)).await {
345 Ok(()) => {
346 gate.open();
349 CycleMarketOutcome::Completed
350 }
351 Err(SendError::ConnectionChanged) => CycleMarketOutcome::ConnectionChanged,
352 Err(e) => {
353 self.restore_market_subscription(asset_ids, epoch).await;
354 CycleMarketOutcome::SendFailed(e)
355 }
356 }
357 }
358
359 async fn restore_market_subscription(&mut self, asset_ids: &[String], epoch: u64) {
360 if let Err(e) = self.try_send_subscribe_market(asset_ids, Some(epoch)).await {
363 log::warn!("Failed to restore market subscription after cycle abort: {e}");
364 }
365 }
366
367 fn market_subscription_desired(&self, asset_id: &str) -> bool {
368 self.subscriptions
369 .is_subscribed(&Ustr::from(asset_id), &Ustr::from(""))
370 }
371
372 async fn send_subscribe_user(&self) {
373 let Some(ref client) = self.client else {
374 log::warn!("No client available for user subscribe");
375 return;
376 };
377 let Some(cred) = &self.credential else {
378 log::error!("User channel subscribe requires credential");
379 return;
380 };
381
382 let mut req = UserSubscribeRequest {
383 auth: PolymarketWsAuth {
384 api_key: SecretString::from(cred.api_key_str()),
385 secret: cred.api_secret(),
386 passphrase: SecretString::from(cred.passphrase()),
387 },
388 msg_type: "user",
389 };
390
391 drop(self.auth_tracker.begin());
393
394 let payload = serde_json::to_string(&req);
395 req.zeroize();
396
397 match payload {
398 Ok(payload) => {
399 if let Err(e) = client.send_text(payload, None).await {
404 self.auth_tracker.fail(e.to_string());
405 log::error!("Failed to send user subscribe: {e}");
406 }
407 }
408 Err(e) => {
409 self.auth_tracker.fail(format!("Serialize error: {e}"));
410 log::error!("Failed to serialize user subscribe request: {e}");
411 }
412 }
413 }
414
415 async fn resubscribe_all(&mut self, connection_epoch: u64) {
416 match self.channel {
417 WsChannel::Market => {
418 let ids = self.subscriptions.reset_after_reconnect();
419 if ids.is_empty() && !self.discovery_subscribed.load(Ordering::Relaxed) {
420 return;
421 }
422 log::info!(
423 "Restoring market subscription state after reconnect: assets={}, discovery={}",
424 ids.len(),
425 self.discovery_subscribed.load(Ordering::Relaxed),
426 );
427 self.send_subscribe_market(&ids, Some(connection_epoch))
428 .await;
429 }
430 WsChannel::User => {
431 if self.user_subscribed {
432 log::info!("Re-authenticating user channel after reconnect");
433 self.send_subscribe_user().await;
434 }
435 }
436 }
437 }
438
439 fn parse_messages(&self, text: &str) -> Vec<PolymarketWsMessage> {
440 if text == "NO NEW ASSETS" {
443 return vec![];
444 }
445
446 if text == "PONG" {
448 return vec![];
449 }
450
451 match self.channel {
452 WsChannel::Market => {
453 if let Ok(msgs) = serde_json::from_str::<Vec<&RawValue>>(text) {
454 msgs.into_iter()
455 .filter_map(|raw| match MarketWsMessage::parse(raw.get()) {
456 Ok(msg) => Some(PolymarketWsMessage::Market(msg)),
457 Err(e) => {
458 log::warn!("Failed to parse market WS batch element: {e}");
459 None
460 }
461 })
462 .collect()
463 } else {
464 match MarketWsMessage::parse(text) {
465 Ok(msg) => vec![PolymarketWsMessage::Market(msg)],
466 Err(e) => {
467 log::warn!(
468 "Failed to parse market WS message: {e}; payload={}",
469 sanitize_error_text(text)
470 );
471 vec![]
472 }
473 }
474 }
475 }
476 WsChannel::User => {
477 if let Ok(msgs) = UserWsMessage::parse_batch(text) {
478 msgs.into_iter().map(PolymarketWsMessage::User).collect()
479 } else {
480 match UserWsMessage::parse(text) {
481 Ok(msg) => vec![PolymarketWsMessage::User(msg)],
482 Err(e) => {
483 log::warn!(
484 "Failed to parse user WS message: {e}; payload={}",
485 sanitize_error_text(text)
486 );
487 vec![]
488 }
489 }
490 }
491 }
492 }
493 }
494
495 pub(super) async fn next(&mut self) -> Option<PolymarketWsMessage> {
496 if !self.message_buffer.is_empty() {
497 return Some(self.message_buffer.remove(0));
498 }
499
500 if let Some((asset_ids, connection_epoch)) = self.initial_market_replay.take() {
501 self.send_subscribe_market(&asset_ids, Some(connection_epoch))
502 .await;
503 }
504
505 loop {
506 let market_heartbeat_next = self.market_heartbeat_next;
507
508 tokio::select! {
509 connection_epoch = async move {
510 if let Some((deadline, connection_epoch)) = market_heartbeat_next {
511 tokio::time::sleep_until(deadline).await;
512 connection_epoch
513 } else {
514 std::future::pending::<u64>().await
515 }
516 } => {
517 self.send_market_heartbeat(connection_epoch).await;
518 self.schedule_market_heartbeat(connection_epoch);
519 }
520 Some(cmd) = self.cmd_rx.recv() => {
521 match cmd {
522 HandlerCommand::SetClient(client) => {
523 log::debug!("Setting WebSocket client in handler");
524 self.client = Some(client);
525 }
526 HandlerCommand::Disconnect => {
527 log::debug!("Handler received disconnect command");
528
529 if let Some(ref client) = self.client {
530 client.disconnect().await;
531 }
532 self.signal.store(true, Ordering::SeqCst);
533 return None;
534 }
535 HandlerCommand::SubscribeMarket(ids) => {
536 if self.subscribe_new_markets && ids.is_empty() {
537 self.discovery_subscribed.store(true, Ordering::Relaxed);
538 }
539 self.send_subscribe_market(&ids, None).await;
540 }
541 HandlerCommand::UnsubscribeMarket(ids) => {
542 for id in &ids {
543 self.market_subscription_epochs.remove(id);
544 self.subscriptions.mark_unsubscribe(id);
545 }
546 self.send_unsubscribe_market(&ids).await;
547 for id in &ids {
548 self.subscriptions.confirm_unsubscribe(id);
549 }
550 }
551 HandlerCommand::CycleMarketSubscription {
552 asset_ids,
553 cancel,
554 responder,
555 gate,
556 } => {
557 let outcome = self
558 .cycle_market_subscription(&asset_ids, &cancel, &gate)
559 .await;
560 let _ = responder.send(outcome);
561 }
562 HandlerCommand::SubscribeUser => {
563 self.user_subscribed = true;
564 self.send_subscribe_user().await;
565 }
566 }
567 }
568 Some((connection_epoch, raw)) = self.raw_rx.recv() => {
569 match raw {
570 Message::Text(text) => {
571 if text == RECONNECTED {
572 self.market_subscription_initialized = false;
573 self.market_heartbeat_next = None;
574 self.resubscribe_all(connection_epoch).await;
575 return Some(PolymarketWsMessage::Reconnected { shard_id: None });
576 }
577 let msgs = self.parse_messages(&text);
578 if msgs.is_empty() {
579 continue;
580 }
581
582 if self.channel == WsChannel::Market {
583 self.confirm_market_subscriptions(connection_epoch, &msgs);
584 } else {
585 self.auth_tracker.succeed();
588 }
589 let mut iter = msgs.into_iter();
592 let first = iter.next().unwrap();
593 self.message_buffer.extend(iter);
594 return Some(first);
595 }
596 Message::Ping(data) => {
597 if let Some(ref client) = self.client
598 && let Err(e) = client.send_pong(data.to_vec()).await
599 {
600 log::warn!("Failed to send pong: {e}");
601 }
602 }
603 Message::Close(_) => {
604 log::debug!("WebSocket close frame received");
605 return None;
606 }
607 _ => {}
608 }
609 }
610 else => return None,
611 }
612 }
613 }
614
615 fn schedule_market_heartbeat(&mut self, connection_epoch: u64) {
616 self.market_heartbeat_next = Some((
617 tokio::time::Instant::now() + Duration::from_secs(POLYMARKET_HEARTBEAT_SECS),
618 connection_epoch,
619 ));
620 }
621
622 async fn send_market_heartbeat(&self, connection_epoch: u64) {
623 let Some(ref client) = self.client else {
624 return;
625 };
626
627 if let Err(e) = client
628 .send_text_on_connection(
629 POLYMARKET_HEARTBEAT_PAYLOAD.to_string(),
630 None,
631 connection_epoch,
632 )
633 .await
634 {
635 log::debug!("Failed to send market heartbeat: {e}");
636 }
637 }
638
639 fn confirm_market_subscriptions(
640 &mut self,
641 connection_epoch: u64,
642 messages: &[PolymarketWsMessage],
643 ) {
644 for message in messages {
645 let asset_id = match message {
646 PolymarketWsMessage::Market(MarketWsMessage::Book(book)) => &book.asset_id,
647 PolymarketWsMessage::Market(MarketWsMessage::LastTradePrice(trade)) => {
648 &trade.asset_id
649 }
650 _ => continue,
651 };
652
653 let was_sent_on_connection = self
654 .market_subscription_epochs
655 .get(asset_id.as_str())
656 .is_some_and(|epoch| *epoch == connection_epoch);
657
658 if was_sent_on_connection && self.market_subscription_pending(asset_id.as_str()) {
659 self.subscriptions.confirm_subscribe(asset_id.as_str());
660 self.market_subscription_epochs.remove(asset_id.as_str());
661 }
662 }
663 }
664
665 fn market_subscription_pending(&self, asset_id: &str) -> bool {
666 let channel_level = Ustr::from("");
667 let asset_id = Ustr::from(asset_id);
668 self.subscriptions
669 .pending_subscribe()
670 .get(&asset_id)
671 .is_some_and(|symbols| symbols.contains(&channel_level))
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use std::time::Duration;
678
679 use futures_util::{SinkExt, StreamExt};
680 use nautilus_common::testing::wait_until_async;
681 use nautilus_network::websocket::{TransportBackend, WebSocketConfig, channel_message_handler};
682 use parking_lot::Mutex;
683 use rstest::{fixture, rstest};
684 use serde_json::{Value, json};
685
686 use super::*;
687 use crate::common::enums::PolymarketOrderSide;
688
689 const MARKET_ASSET_ID: &str =
690 "71321045679252212594626385532706912750332728571942532289631379312455583992563";
691
692 #[fixture]
693 fn market_handler() -> FeedHandler {
694 feed_handler(WsChannel::Market)
695 }
696
697 #[fixture]
698 fn user_handler() -> FeedHandler {
699 feed_handler(WsChannel::User)
700 }
701
702 fn feed_handler(channel: WsChannel) -> FeedHandler {
703 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
704 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
705 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
706
707 FeedHandler::new(
708 Arc::new(AtomicBool::new(false)),
709 channel,
710 None,
711 cmd_rx,
712 raw_rx,
713 out_tx,
714 None,
715 SubscriptionState::new(':'),
716 Arc::new(AtomicBool::new(false)),
717 None,
718 AuthTracker::new(),
719 false,
720 false,
721 )
722 }
723
724 async fn recording_server() -> (String, Arc<Mutex<Vec<String>>>) {
725 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
726 .await
727 .expect("bind recording server");
728 let addr = listener.local_addr().expect("recording server address");
729 let messages = Arc::new(Mutex::new(Vec::new()));
730 let received = Arc::clone(&messages);
731
732 tokio::spawn(async move {
733 let (stream, _) = listener.accept().await.expect("accept websocket client");
734 let mut socket = tokio_tungstenite::accept_async(stream)
735 .await
736 .expect("accept websocket handshake");
737
738 while let Some(message) = socket.next().await {
739 match message.expect("read websocket message") {
740 Message::Text(text) => received.lock().push(text.to_string()),
741 Message::Close(_) => break,
742 _ => {}
743 }
744 }
745 });
746
747 (format!("ws://{addr}"), messages)
748 }
749
750 async fn recording_client(url: String) -> WebSocketClient {
751 let config = WebSocketConfig::builder()
752 .url(url)
753 .backend(TransportBackend::Tungstenite)
754 .build()
755 .expect("valid websocket config");
756 let (message_handler, _message_rx) = channel_message_handler();
757 WebSocketClient::builder()
758 .config(config)
759 .message_handler(message_handler)
760 .connect()
761 .await
762 .expect("connect websocket client")
763 }
764
765 fn market_handler_with(
766 client: WebSocketClient,
767 ) -> (
768 FeedHandler,
769 UnboundedSender<(u64, Message)>,
770 SubscriptionState,
771 ) {
772 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
773 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
774 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
775 let subscriptions = SubscriptionState::new(':');
776 let handler = FeedHandler::new(
777 Arc::new(AtomicBool::new(false)),
778 WsChannel::Market,
779 Some(client),
780 cmd_rx,
781 raw_rx,
782 out_tx,
783 None,
784 subscriptions.clone(),
785 Arc::new(AtomicBool::new(false)),
786 None,
787 AuthTracker::new(),
788 false,
789 false,
790 );
791
792 (handler, raw_tx, subscriptions)
793 }
794
795 fn market_handler_with_cmd_tx(
796 client: WebSocketClient,
797 ) -> (
798 FeedHandler,
799 tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
800 UnboundedSender<(u64, Message)>,
801 SubscriptionState,
802 ) {
803 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
804 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
805 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
806 let subscriptions = SubscriptionState::new(':');
807
808 let handler = FeedHandler::new(
809 Arc::new(AtomicBool::new(false)),
810 WsChannel::Market,
811 Some(client),
812 cmd_rx,
813 raw_rx,
814 out_tx,
815 None,
816 subscriptions.clone(),
817 Arc::new(AtomicBool::new(false)),
818 None,
819 AuthTracker::new(),
820 false,
821 false,
822 );
823
824 (handler, cmd_tx, raw_tx, subscriptions)
825 }
826
827 #[rstest]
828 #[tokio::test]
829 async fn initial_market_replay_recovers_on_current_connection_epoch() {
830 let (url, messages) = recording_server().await;
831 let client = recording_client(url).await;
832
833 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
834 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
835 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
836 let mut handler = FeedHandler::new(
837 Arc::new(AtomicBool::new(false)),
838 WsChannel::Market,
839 Some(client),
840 cmd_rx,
841 raw_rx,
842 out_tx,
843 None,
844 SubscriptionState::new(':'),
845 Arc::new(AtomicBool::new(true)),
846 Some((vec![], 1)),
847 AuthTracker::new(),
848 false,
849 true,
850 );
851 raw_tx
852 .send((0, Message::Text(RECONNECTED.into())))
853 .expect("queue reconnect notification");
854
855 assert!(matches!(
856 handler.next().await,
857 Some(PolymarketWsMessage::Reconnected { .. }),
858 ));
859 handler
860 .client
861 .as_ref()
862 .expect("websocket client")
863 .send_text_on_connection("barrier".to_string(), None, 0)
864 .await
865 .expect("send barrier on current connection");
866
867 wait_until_async(
868 || {
869 let messages = Arc::clone(&messages);
870 async move { messages.lock().len() >= 2 }
871 },
872 Duration::from_secs(1),
873 )
874 .await;
875
876 {
877 let messages = messages.lock();
878 assert_eq!(messages.len(), 2);
879 assert_eq!(
880 serde_json::from_str::<Value>(&messages[0]).expect("valid subscribe payload"),
881 json!({
882 "assets_ids": [],
883 "type": "market",
884 "initial_dump": true,
885 "custom_feature_enabled": true,
886 }),
887 );
888 assert_eq!(messages[1], "barrier");
889 }
890
891 handler
892 .client
893 .as_ref()
894 .expect("websocket client")
895 .disconnect()
896 .await;
897 }
898
899 #[rstest]
900 #[tokio::test(start_paused = true)]
901 async fn market_text_heartbeat_follows_initial_subscription() {
902 let (url, messages) = recording_server().await;
903 let client = recording_client(url).await;
904 let connection_epoch = client.connection_epoch();
905 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
906 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
907 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
908 let mut handler = FeedHandler::new(
909 Arc::new(AtomicBool::new(false)),
910 WsChannel::Market,
911 Some(client),
912 cmd_rx,
913 raw_rx,
914 out_tx,
915 None,
916 SubscriptionState::new(':'),
917 Arc::new(AtomicBool::new(false)),
918 None,
919 AuthTracker::new(),
920 false,
921 false,
922 );
923
924 handler
925 .send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
926 .await;
927 wait_for_recorded_messages(&messages, 1).await;
928
929 let task = tokio::spawn(async move {
930 let message = handler.next().await;
931 (handler, message)
932 });
933 tokio::task::yield_now().await;
934 tokio::time::advance(Duration::from_secs(POLYMARKET_HEARTBEAT_SECS)).await;
935 wait_for_recorded_messages(&messages, 2).await;
936
937 raw_tx
938 .send((connection_epoch, Message::Text(RECONNECTED.into())))
939 .expect("queue reconnect notification");
940 let (mut handler, message) = task.await.expect("join handler task");
941 assert!(matches!(
942 message,
943 Some(PolymarketWsMessage::Reconnected { .. })
944 ));
945 wait_for_recorded_messages(&messages, 3).await;
946
947 let task = tokio::spawn(async move {
948 let message = handler.next().await;
949 (handler, message)
950 });
951 tokio::task::yield_now().await;
952 tokio::time::advance(Duration::from_secs(POLYMARKET_HEARTBEAT_SECS)).await;
953 wait_for_recorded_messages(&messages, 4).await;
954
955 cmd_tx
956 .send(HandlerCommand::Disconnect)
957 .expect("queue disconnect");
958 let (_, message) = task.await.expect("join handler task");
959 assert!(message.is_none());
960
961 let messages = messages.lock().clone();
962 let expected_subscription = json!({
963 "assets_ids": [MARKET_ASSET_ID],
964 "type": "market",
965 "initial_dump": true,
966 });
967 assert_eq!(messages.len(), 4);
968 assert_eq!(
969 serde_json::from_str::<Value>(&messages[0]).expect("valid subscribe payload"),
970 expected_subscription,
971 );
972 assert_eq!(messages[1], POLYMARKET_HEARTBEAT_PAYLOAD);
973 assert_eq!(
974 serde_json::from_str::<Value>(&messages[2]).expect("valid replay payload"),
975 expected_subscription,
976 );
977 assert_eq!(messages[3], POLYMARKET_HEARTBEAT_PAYLOAD);
978 }
979
980 #[rstest]
981 #[tokio::test]
982 async fn market_heartbeat_stays_bound_to_subscribed_connection() {
983 let (url, messages) = recording_server().await;
984 let client = recording_client(url).await;
985 let connection_epoch = client.connection_epoch();
986 let connection_epoch_atomic = client.connection_epoch_atomic();
987 let (mut handler, raw_tx, _) = market_handler_with(client);
988
989 handler
990 .send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
991 .await;
992 wait_for_recorded_messages(&messages, 1).await;
993
994 let replacement_epoch = connection_epoch + 1;
995 connection_epoch_atomic.store(replacement_epoch, Ordering::Release);
996 handler.send_market_heartbeat(connection_epoch).await;
997 raw_tx
998 .send((replacement_epoch, Message::Text(RECONNECTED.into())))
999 .expect("queue reconnect notification");
1000
1001 assert!(matches!(
1002 handler.next().await,
1003 Some(PolymarketWsMessage::Reconnected { .. }),
1004 ));
1005 handler
1006 .client
1007 .as_ref()
1008 .expect("websocket client")
1009 .send_text_on_connection("barrier".to_string(), None, replacement_epoch)
1010 .await
1011 .expect("send barrier on replacement connection");
1012 wait_until_async(
1013 || {
1014 let messages = Arc::clone(&messages);
1015 async move {
1016 messages
1017 .lock()
1018 .last()
1019 .is_some_and(|message| message == "barrier")
1020 }
1021 },
1022 Duration::from_secs(1),
1023 )
1024 .await;
1025
1026 let messages = messages.lock().clone();
1027 let expected_subscription = json!({
1028 "assets_ids": [MARKET_ASSET_ID],
1029 "type": "market",
1030 "initial_dump": true,
1031 });
1032 assert_eq!(messages.len(), 3);
1033 assert_eq!(
1034 serde_json::from_str::<Value>(&messages[0]).expect("valid subscribe payload"),
1035 expected_subscription,
1036 );
1037 assert_eq!(
1038 serde_json::from_str::<Value>(&messages[1]).expect("valid replay payload"),
1039 expected_subscription,
1040 );
1041 assert_eq!(messages[2], "barrier");
1042
1043 handler
1044 .client
1045 .as_ref()
1046 .expect("websocket client")
1047 .disconnect()
1048 .await;
1049 }
1050
1051 async fn wait_for_recorded_messages(messages: &Arc<Mutex<Vec<String>>>, expected: usize) {
1052 wait_until_async(
1053 || {
1054 let messages = Arc::clone(messages);
1055 async move { messages.lock().len() == expected }
1056 },
1057 Duration::from_secs(1),
1058 )
1059 .await;
1060 }
1061
1062 #[rstest]
1063 #[case(include_str!("../../test_data/ws_market_book_msg.json"))]
1064 #[case(include_str!("../../test_data/ws_market_last_trade_msg.json"))]
1065 #[tokio::test]
1066 async fn market_subscription_confirms_from_first_book_or_trade(#[case] payload: &str) {
1067 let (url, messages) = recording_server().await;
1068 let client = recording_client(url).await;
1069 let (mut handler, raw_tx, subscriptions) = market_handler_with(client);
1070
1071 handler
1072 .send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
1073 .await;
1074 wait_until_async(
1075 || {
1076 let messages = Arc::clone(&messages);
1077 async move { !messages.lock().is_empty() }
1078 },
1079 Duration::from_secs(1),
1080 )
1081 .await;
1082
1083 assert_eq!(
1084 subscriptions.pending_subscribe_topics(),
1085 vec![MARKET_ASSET_ID]
1086 );
1087 assert_eq!(subscriptions.len(), 0);
1088
1089 raw_tx
1090 .send((0, Message::Text(payload.into())))
1091 .expect("queue market data");
1092 assert!(matches!(
1093 handler.next().await,
1094 Some(PolymarketWsMessage::Market(_)),
1095 ));
1096
1097 assert!(subscriptions.pending_subscribe_topics().is_empty());
1098 assert_eq!(subscriptions.len(), 1);
1099
1100 handler
1101 .client
1102 .as_ref()
1103 .expect("websocket client")
1104 .disconnect()
1105 .await;
1106 }
1107
1108 #[rstest]
1109 fn unsolicited_market_data_does_not_create_subscription(mut market_handler: FeedHandler) {
1110 let messages =
1111 market_handler.parse_messages(include_str!("../../test_data/ws_market_book_msg.json"));
1112
1113 market_handler.confirm_market_subscriptions(0, &messages);
1114
1115 assert!(market_handler.subscriptions.is_empty());
1116 }
1117
1118 #[rstest]
1119 fn market_batch_confirms_trade_but_not_price_change(mut market_handler: FeedHandler) {
1120 let price_change_asset_id = "101";
1121 let trade_asset_id = "202";
1122 market_handler
1123 .subscriptions
1124 .mark_subscribe(price_change_asset_id);
1125 market_handler.subscriptions.mark_subscribe(trade_asset_id);
1126 market_handler
1127 .market_subscription_epochs
1128 .insert(price_change_asset_id.to_string(), 0);
1129 market_handler
1130 .market_subscription_epochs
1131 .insert(trade_asset_id.to_string(), 0);
1132 let messages = market_handler.parse_messages(include_str!(
1133 "../../test_data/ws_market_mixed_known_unknown.json"
1134 ));
1135
1136 market_handler.confirm_market_subscriptions(0, &messages);
1137
1138 assert_eq!(
1139 market_handler.subscriptions.pending_subscribe_topics(),
1140 vec![price_change_asset_id]
1141 );
1142 assert_eq!(market_handler.subscriptions.len(), 1);
1143 }
1144
1145 #[rstest]
1146 fn market_subscription_confirmation_requires_sent_current_epoch(
1147 mut market_handler: FeedHandler,
1148 ) {
1149 market_handler.subscriptions.mark_subscribe(MARKET_ASSET_ID);
1150 let messages =
1151 market_handler.parse_messages(include_str!("../../test_data/ws_market_book_msg.json"));
1152
1153 market_handler.confirm_market_subscriptions(0, &messages);
1154 assert_eq!(
1155 market_handler.subscriptions.pending_subscribe_topics(),
1156 vec![MARKET_ASSET_ID]
1157 );
1158 assert_eq!(market_handler.subscriptions.len(), 0);
1159
1160 market_handler
1161 .market_subscription_epochs
1162 .insert(MARKET_ASSET_ID.to_string(), 1);
1163 market_handler.confirm_market_subscriptions(0, &messages);
1164 assert_eq!(
1165 market_handler.subscriptions.pending_subscribe_topics(),
1166 vec![MARKET_ASSET_ID]
1167 );
1168 assert_eq!(market_handler.subscriptions.len(), 0);
1169
1170 market_handler.confirm_market_subscriptions(1, &messages);
1171 assert!(
1172 market_handler
1173 .subscriptions
1174 .pending_subscribe_topics()
1175 .is_empty()
1176 );
1177 assert_eq!(market_handler.subscriptions.len(), 1);
1178 }
1179
1180 #[rstest]
1181 #[tokio::test]
1182 async fn reconnect_replay_requires_current_connection_data() {
1183 let cancelled_asset_id = "cancelled-asset";
1184 let (url, _) = recording_server().await;
1185 let client = recording_client(url).await;
1186 let connection_epoch = client.connection_epoch();
1187 let (mut handler, raw_tx, subscriptions) = market_handler_with(client);
1188 subscriptions.mark_subscribe(MARKET_ASSET_ID);
1189 subscriptions.confirm_subscribe(MARKET_ASSET_ID);
1190 subscriptions.mark_subscribe(cancelled_asset_id);
1191 subscriptions.confirm_subscribe(cancelled_asset_id);
1192 subscriptions.mark_unsubscribe(cancelled_asset_id);
1193
1194 handler.resubscribe_all(connection_epoch).await;
1195
1196 assert_eq!(
1197 subscriptions.pending_subscribe_topics(),
1198 vec![MARKET_ASSET_ID]
1199 );
1200 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
1201 assert_eq!(subscriptions.len(), 0);
1202
1203 raw_tx
1204 .send((
1205 connection_epoch,
1206 Message::Text(include_str!("../../test_data/ws_market_book_msg.json").into()),
1207 ))
1208 .expect("queue market data");
1209 assert!(matches!(
1210 handler.next().await,
1211 Some(PolymarketWsMessage::Market(_)),
1212 ));
1213
1214 assert!(subscriptions.pending_subscribe_topics().is_empty());
1215 assert_eq!(subscriptions.len(), 1);
1216
1217 handler
1218 .client
1219 .as_ref()
1220 .expect("websocket client")
1221 .disconnect()
1222 .await;
1223 }
1224
1225 #[rstest]
1226 #[tokio::test]
1227 async fn failed_market_subscribe_stays_pending_for_reconnect_replay() {
1228 let (url, _) = recording_server().await;
1229 let client = recording_client(url).await;
1230 client.disconnect().await;
1231 let (mut handler, raw_tx, subscriptions) = market_handler_with(client);
1232 subscriptions.mark_subscribe(MARKET_ASSET_ID);
1233 subscriptions.confirm_subscribe(MARKET_ASSET_ID);
1234
1235 assert!(subscriptions.pending_subscribe_topics().is_empty());
1236 assert_eq!(subscriptions.len(), 1);
1237
1238 handler
1239 .send_subscribe_market(&[MARKET_ASSET_ID.to_string()], None)
1240 .await;
1241
1242 assert_eq!(
1243 subscriptions.pending_subscribe_topics(),
1244 vec![MARKET_ASSET_ID]
1245 );
1246 assert_eq!(subscriptions.len(), 0);
1247
1248 raw_tx
1249 .send((
1250 0,
1251 Message::Text(include_str!("../../test_data/ws_market_book_msg.json").into()),
1252 ))
1253 .expect("queue stale market data");
1254 assert!(matches!(
1255 handler.next().await,
1256 Some(PolymarketWsMessage::Market(_)),
1257 ));
1258 assert_eq!(
1259 subscriptions.pending_subscribe_topics(),
1260 vec![MARKET_ASSET_ID]
1261 );
1262 assert_eq!(subscriptions.len(), 0);
1263
1264 let (replay_url, messages) = recording_server().await;
1265 let replay_client = recording_client(replay_url).await;
1266 let connection_epoch = replay_client.connection_epoch();
1267 handler.client = Some(replay_client);
1268 handler.resubscribe_all(connection_epoch).await;
1269 wait_until_async(
1270 || {
1271 let messages = Arc::clone(&messages);
1272 async move { !messages.lock().is_empty() }
1273 },
1274 Duration::from_secs(1),
1275 )
1276 .await;
1277
1278 {
1279 let messages = messages.lock();
1280 assert_eq!(messages.len(), 1);
1281 assert_eq!(
1282 serde_json::from_str::<Value>(&messages[0]).expect("valid subscribe payload"),
1283 json!({
1284 "assets_ids": [MARKET_ASSET_ID],
1285 "type": "market",
1286 "initial_dump": true,
1287 }),
1288 );
1289 }
1290 assert_eq!(
1291 subscriptions.pending_subscribe_topics(),
1292 vec![MARKET_ASSET_ID]
1293 );
1294 assert_eq!(subscriptions.len(), 0);
1295
1296 handler
1297 .client
1298 .as_ref()
1299 .expect("websocket client")
1300 .disconnect()
1301 .await;
1302 }
1303
1304 #[rstest]
1305 fn test_parse_market_batch_skips_unknown_event(market_handler: FeedHandler) {
1306 let messages = market_handler.parse_messages(include_str!(
1307 "../../test_data/ws_market_mixed_known_unknown.json"
1308 ));
1309
1310 assert_eq!(messages.len(), 2);
1311
1312 let PolymarketWsMessage::Market(MarketWsMessage::PriceChange(quotes)) = &messages[0] else {
1313 panic!("Expected first message to be a price change");
1314 };
1315 assert_eq!(
1316 quotes.market,
1317 "0x1111111111111111111111111111111111111111111111111111111111111111"
1318 );
1319 assert_eq!(quotes.timestamp, "1700000000001");
1320 assert_eq!(quotes.price_changes.len(), 1);
1321
1322 let quote = "es.price_changes[0];
1323 assert_eq!(quote.asset_id, "101");
1324 assert_eq!(quote.price, "0.37");
1325 assert_eq!(quote.side, PolymarketOrderSide::Buy);
1326 assert_eq!(quote.size, "12.5");
1327 assert_eq!(quote.hash, "price-change-hash");
1328 assert_eq!(quote.best_bid.as_deref(), Some("0.36"));
1329 assert_eq!(quote.best_ask.as_deref(), Some("0.38"));
1330
1331 let PolymarketWsMessage::Market(MarketWsMessage::LastTradePrice(trade)) = &messages[1]
1332 else {
1333 panic!("Expected second message to be a last trade price");
1334 };
1335 assert_eq!(
1336 trade.market,
1337 "0x2222222222222222222222222222222222222222222222222222222222222222"
1338 );
1339 assert_eq!(trade.asset_id, "202");
1340 assert_eq!(trade.fee_rate_bps, "17");
1341 assert_eq!(trade.price, "0.63");
1342 assert_eq!(trade.side, PolymarketOrderSide::Sell);
1343 assert_eq!(trade.size, "4.25");
1344 assert_eq!(trade.timestamp, "1700000000003");
1345 assert_eq!(trade.transaction_hash.as_deref(), Some("0xtrade-hash"));
1346 }
1347
1348 #[rstest]
1349 fn test_parse_market_single_message(market_handler: FeedHandler) {
1350 let messages = market_handler.parse_messages(include_str!(
1351 "../../test_data/ws_market_last_trade_msg.json"
1352 ));
1353
1354 assert_eq!(messages.len(), 1);
1355
1356 let PolymarketWsMessage::Market(MarketWsMessage::LastTradePrice(trade)) = &messages[0]
1357 else {
1358 panic!("Expected a last trade price");
1359 };
1360 assert_eq!(
1361 trade.market,
1362 "0xdd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917"
1363 );
1364 assert_eq!(
1365 trade.asset_id,
1366 "71321045679252212594626385532706912750332728571942532289631379312455583992563"
1367 );
1368 assert_eq!(trade.fee_rate_bps, "0");
1369 assert_eq!(trade.price, "0.51");
1370 assert_eq!(trade.side, PolymarketOrderSide::Buy);
1371 assert_eq!(trade.size, "25.0");
1372 assert_eq!(trade.timestamp, "1703875202000");
1373 assert!(trade.transaction_hash.is_none());
1374 }
1375
1376 #[rstest]
1377 fn test_parse_user_batch(user_handler: FeedHandler) {
1378 let messages =
1379 user_handler.parse_messages(include_str!("../../test_data/ws_user_batch_msg.json"));
1380 let actual: Vec<UserWsMessage> = messages
1381 .into_iter()
1382 .map(|message| match message {
1383 PolymarketWsMessage::User(message) => message,
1384 other => panic!("Expected user message, received {other:?}"),
1385 })
1386 .collect();
1387 let expected: Vec<UserWsMessage> =
1388 serde_json::from_str(include_str!("../../test_data/ws_user_batch_msg.json"))
1389 .expect("user batch fixture should deserialize");
1390
1391 assert_eq!(actual, expected);
1392 }
1393
1394 fn cycle_asset_ids() -> Vec<String> {
1395 vec![MARKET_ASSET_ID.to_string()]
1396 }
1397
1398 fn assert_desired(handler: &FeedHandler, subscriptions: &SubscriptionState) {
1399 assert!(
1400 handler.market_subscription_desired(MARKET_ASSET_ID),
1401 "cycle must preserve desired ownership"
1402 );
1403 assert!(
1404 subscriptions.pending_unsubscribe_topics().is_empty(),
1405 "cycle must never mark desired assets for unsubscribe"
1406 );
1407 }
1408
1409 fn frame_kind(frame: &str) -> &str {
1412 let value: Value = serde_json::from_str(frame).expect("client frame should be JSON");
1413 if value.get("operation").and_then(Value::as_str) == Some("unsubscribe") {
1414 return "unsubscribe";
1415 }
1416
1417 if value.get("operation").and_then(Value::as_str) == Some("subscribe")
1418 || value.get("type").and_then(Value::as_str) == Some("market")
1419 {
1420 return "subscribe";
1421 }
1422
1423 panic!("unexpected client frame: {frame}");
1424 }
1425
1426 fn frame_assets(frame: &str) -> Vec<String> {
1427 let value: Value = serde_json::from_str(frame).expect("client frame should be JSON");
1428 value
1429 .get("assets_ids")
1430 .and_then(Value::as_array)
1431 .expect("frame should carry assets_ids")
1432 .iter()
1433 .map(|id| {
1434 id.as_str()
1435 .expect("asset id should be a string")
1436 .to_string()
1437 })
1438 .collect()
1439 }
1440
1441 #[rstest]
1442 #[tokio::test]
1443 async fn cycle_market_subscription_resubscribes_without_changing_desired_state() {
1444 let (url, messages) = recording_server().await;
1445 let client = recording_client(url).await;
1446 let (mut handler, _raw_tx, subscriptions) = market_handler_with(client);
1447 subscriptions.mark_subscribe(MARKET_ASSET_ID);
1448
1449 let gate = SnapshotGate::default();
1450 gate.lock().close();
1451
1452 let outcome = handler
1453 .cycle_market_subscription(&cycle_asset_ids(), &CancellationToken::new(), &gate)
1454 .await;
1455 assert!(matches!(outcome, CycleMarketOutcome::Completed));
1456 assert!(!gate.lock().is_closed());
1457
1458 wait_for_recorded_messages(&messages, 2).await;
1459 let frames = messages.lock().clone();
1460 assert_eq!(frame_kind(&frames[0]), "unsubscribe");
1461 assert_eq!(frame_kind(&frames[1]), "subscribe");
1462
1463 for frame in &frames {
1464 assert_eq!(frame_assets(frame), cycle_asset_ids());
1465 }
1466
1467 assert_desired(&handler, &subscriptions);
1468
1469 handler
1470 .client
1471 .as_ref()
1472 .expect("websocket client")
1473 .disconnect()
1474 .await;
1475 }
1476
1477 #[rstest]
1478 #[tokio::test]
1479 async fn cycle_market_subscription_precancelled_sends_nothing() {
1480 let (url, messages) = recording_server().await;
1481 let client = recording_client(url).await;
1482 let (mut handler, _raw_tx, subscriptions) = market_handler_with(client);
1483 subscriptions.mark_subscribe(MARKET_ASSET_ID);
1484
1485 let cancel = CancellationToken::new();
1486 cancel.cancel();
1487 let outcome = handler
1488 .cycle_market_subscription(&cycle_asset_ids(), &cancel, &SnapshotGate::default())
1489 .await;
1490 assert!(matches!(outcome, CycleMarketOutcome::Cancelled));
1491
1492 tokio::time::sleep(Duration::from_millis(100)).await;
1493 assert!(messages.lock().is_empty());
1494 assert_desired(&handler, &subscriptions);
1495
1496 handler
1497 .client
1498 .as_ref()
1499 .expect("websocket client")
1500 .disconnect()
1501 .await;
1502 }
1503
1504 #[rstest]
1505 #[tokio::test]
1506 async fn cycle_market_subscription_without_client_fails_closed(
1507 mut market_handler: FeedHandler,
1508 ) {
1509 let subscriptions = market_handler.subscriptions.clone();
1510 subscriptions.mark_subscribe(MARKET_ASSET_ID);
1511 let outcome = market_handler
1512 .cycle_market_subscription(
1513 &cycle_asset_ids(),
1514 &CancellationToken::new(),
1515 &SnapshotGate::default(),
1516 )
1517 .await;
1518 assert!(matches!(
1519 outcome,
1520 CycleMarketOutcome::SendFailed(SendError::Closed)
1521 ));
1522 assert_desired(&market_handler, &subscriptions);
1523 }
1524
1525 #[rstest]
1526 #[tokio::test]
1527 async fn cycle_market_subscription_rejects_undesired_asset() {
1528 let (url, messages) = recording_server().await;
1529 let client = recording_client(url).await;
1530 let (mut handler, _raw_tx, _subscriptions) = market_handler_with(client);
1531
1532 let outcome = handler
1533 .cycle_market_subscription(
1534 &cycle_asset_ids(),
1535 &CancellationToken::new(),
1536 &SnapshotGate::default(),
1537 )
1538 .await;
1539 assert!(matches!(outcome, CycleMarketOutcome::NotDesired));
1540
1541 tokio::time::sleep(Duration::from_millis(100)).await;
1542 assert!(messages.lock().is_empty());
1543
1544 handler
1545 .client
1546 .as_ref()
1547 .expect("websocket client")
1548 .disconnect()
1549 .await;
1550 }
1551
1552 #[rstest]
1553 #[tokio::test]
1554 async fn cycle_market_subscription_after_disconnect_fails_send() {
1555 let (url, messages) = recording_server().await;
1556 let client = recording_client(url).await;
1557 let (mut handler, _raw_tx, subscriptions) = market_handler_with(client);
1558 subscriptions.mark_subscribe(MARKET_ASSET_ID);
1559
1560 handler
1561 .client
1562 .as_ref()
1563 .expect("websocket client")
1564 .disconnect()
1565 .await;
1566
1567 let outcome = handler
1568 .cycle_market_subscription(
1569 &cycle_asset_ids(),
1570 &CancellationToken::new(),
1571 &SnapshotGate::default(),
1572 )
1573 .await;
1574 assert!(matches!(
1575 outcome,
1576 CycleMarketOutcome::SendFailed(SendError::Closed)
1577 ));
1578
1579 tokio::time::sleep(Duration::from_millis(100)).await;
1581 assert!(messages.lock().is_empty());
1582 assert_desired(&handler, &subscriptions);
1583 }
1584
1585 async fn snapshot_gated_server(snapshot: &'static str) -> String {
1589 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1590 .await
1591 .expect("bind snapshot-gated server");
1592 let addr = listener.local_addr().expect("snapshot-gated address");
1593
1594 tokio::spawn(async move {
1595 let (stream, _) = listener.accept().await.expect("accept websocket client");
1596 let mut socket = tokio_tungstenite::accept_async(stream)
1597 .await
1598 .expect("accept websocket handshake");
1599 let mut subscribed = false;
1600
1601 while let Some(message) = socket.next().await {
1602 match message.expect("read websocket message") {
1603 Message::Text(text) => {
1604 let Ok(value) = serde_json::from_str::<Value>(&text) else {
1605 continue;
1606 };
1607
1608 let operation = value.get("operation").and_then(Value::as_str);
1609 let is_initial =
1610 value.get("type").and_then(Value::as_str) == Some("market");
1611
1612 if operation == Some("unsubscribe") {
1613 subscribed = false;
1614 } else if (operation == Some("subscribe") || is_initial) && !subscribed {
1615 subscribed = true;
1616 socket
1617 .send(Message::Text(snapshot.into()))
1618 .await
1619 .expect("send gated snapshot");
1620 }
1621 }
1622 Message::Close(_) => break,
1623 _ => {}
1624 }
1625 }
1626 });
1627
1628 format!("ws://{addr}")
1629 }
1630
1631 async fn next_market_message(handler: &mut FeedHandler) -> Option<PolymarketWsMessage> {
1632 loop {
1633 match handler.next().await {
1634 Some(message @ PolymarketWsMessage::Market(_)) => return Some(message),
1635 Some(_) => {}
1636 None => return None,
1637 }
1638 }
1639 }
1640
1641 #[rstest]
1642 #[tokio::test]
1643 async fn cycle_recovers_snapshot_that_duplicate_subscribe_misses() {
1644 let snapshot = include_str!("../../test_data/ws_market_book_msg.json");
1645 let url = snapshot_gated_server(snapshot).await;
1646
1647 let config = WebSocketConfig::builder()
1648 .url(url)
1649 .backend(TransportBackend::Tungstenite)
1650 .build()
1651 .expect("valid websocket config");
1652 let (message_handler, mut message_rx) = channel_message_handler();
1653 let client = WebSocketClient::builder()
1654 .config(config)
1655 .message_handler(message_handler)
1656 .connect()
1657 .await
1658 .expect("connect websocket client");
1659 let epoch = client.connection_epoch_atomic();
1660 let (mut handler, cmd_tx, raw_tx, subscriptions) = market_handler_with_cmd_tx(client);
1661
1662 tokio::spawn(async move {
1663 while let Some(message) = message_rx.recv().await {
1664 let epoch = epoch.load(Ordering::SeqCst);
1665 if raw_tx.send((epoch, message)).is_err() {
1666 break;
1667 }
1668 }
1669 });
1670
1671 cmd_tx
1672 .send(HandlerCommand::SubscribeMarket(cycle_asset_ids()))
1673 .expect("send initial subscribe");
1674 tokio::time::timeout(Duration::from_secs(5), next_market_message(&mut handler))
1675 .await
1676 .expect("initial subscribe should yield a snapshot")
1677 .expect("handler should stay open");
1678
1679 cmd_tx
1680 .send(HandlerCommand::SubscribeMarket(cycle_asset_ids()))
1681 .expect("send duplicate subscribe");
1682 assert!(
1683 tokio::time::timeout(
1684 Duration::from_millis(500),
1685 next_market_message(&mut handler)
1686 )
1687 .await
1688 .is_err(),
1689 "duplicate subscribe must receive no snapshot from the venue"
1690 );
1691
1692 let (responder, response) = tokio::sync::oneshot::channel();
1693 cmd_tx
1694 .send(HandlerCommand::CycleMarketSubscription {
1695 asset_ids: cycle_asset_ids(),
1696 cancel: CancellationToken::new(),
1697 responder,
1698 gate: SnapshotGate::default(),
1699 })
1700 .expect("send cycle command");
1701
1702 tokio::pin!(response);
1704 let mut outcome = None;
1705 let mut snapshot_seen = false;
1706 tokio::time::timeout(Duration::from_secs(5), async {
1707 while outcome.is_none() || !snapshot_seen {
1708 tokio::select! {
1709 result = &mut response, if outcome.is_none() => {
1710 outcome = Some(result.expect("cycle responder should stay open"));
1711 }
1712 message = next_market_message(&mut handler), if !snapshot_seen => {
1713 message.expect("handler should stay open");
1714 snapshot_seen = true;
1715 }
1716 }
1717 }
1718 })
1719 .await
1720 .expect("cycle should respond and yield a fresh snapshot");
1721
1722 assert!(matches!(outcome, Some(CycleMarketOutcome::Completed)));
1723
1724 assert_desired(&handler, &subscriptions);
1725
1726 handler
1727 .client
1728 .as_ref()
1729 .expect("websocket client")
1730 .disconnect()
1731 .await;
1732 }
1733
1734 #[rstest]
1735 #[tokio::test]
1736 async fn cycle_followed_by_queued_unsubscribe_honors_the_unsubscribe() {
1737 let (url, messages) = recording_server().await;
1738 let client = recording_client(url).await;
1739 let (mut handler, cmd_tx, _raw_tx, subscriptions) = market_handler_with_cmd_tx(client);
1740 subscriptions.mark_subscribe(MARKET_ASSET_ID);
1741
1742 let (responder, response) = tokio::sync::oneshot::channel();
1744 cmd_tx
1745 .send(HandlerCommand::CycleMarketSubscription {
1746 asset_ids: cycle_asset_ids(),
1747 cancel: CancellationToken::new(),
1748 responder,
1749 gate: SnapshotGate::default(),
1750 })
1751 .expect("queue cycle command");
1752
1753 cmd_tx
1754 .send(HandlerCommand::UnsubscribeMarket(cycle_asset_ids()))
1755 .expect("queue unsubscribe command");
1756
1757 tokio::pin!(response);
1758
1759 let outcome = tokio::time::timeout(Duration::from_secs(5), async {
1760 loop {
1761 tokio::select! {
1762 biased;
1763 result = &mut response => return result.expect("cycle responder open"),
1764 _ = handler.next() => {}
1765 }
1766 }
1767 })
1768 .await
1769 .expect("cycle should respond");
1770
1771 assert!(matches!(outcome, CycleMarketOutcome::Completed));
1772
1773 tokio::time::timeout(Duration::from_secs(5), async {
1774 loop {
1775 if messages.lock().len() >= 3 {
1776 break;
1777 }
1778
1779 tokio::select! {
1780 _ = handler.next() => {}
1781 () = tokio::time::sleep(Duration::from_millis(10)) => {}
1782 }
1783 }
1784 })
1785 .await
1786 .expect("queued unsubscribe should reach the wire");
1787
1788 let frames = messages.lock().clone();
1789 assert_eq!(frame_kind(&frames[0]), "unsubscribe");
1790 assert_eq!(frame_kind(&frames[1]), "subscribe");
1791 assert_eq!(frame_kind(&frames[2]), "unsubscribe");
1792
1793 assert!(!handler.market_subscription_desired(MARKET_ASSET_ID));
1794
1795 handler
1796 .client
1797 .as_ref()
1798 .expect("websocket client")
1799 .disconnect()
1800 .await;
1801 }
1802}