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