Skip to main content

nautilus_polymarket/websocket/
handler.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! WebSocket message handler for the Polymarket CLOB API.
17
18use 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}; // tokio-import-ok
36use 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/// Commands sent from the outer client to the inner message handler.
54#[derive(Debug)]
55pub enum HandlerCommand {
56    /// Set the WebSocketClient for the handler to use.
57    SetClient(WebSocketClient),
58    /// Disconnect the WebSocket connection.
59    Disconnect,
60    /// Add asset IDs to the market-channel subscription set and send a subscribe message.
61    SubscribeMarket(Vec<String>),
62    /// Remove asset IDs from the subscription set and send an unsubscribe message.
63    UnsubscribeMarket(Vec<String>),
64    /// Cycle asset IDs (unsubscribe then subscribe) without changing desired
65    /// subscription state, forcing the venue to emit fresh snapshots.
66    CycleMarketSubscription {
67        asset_ids: Vec<String>,
68        cancel: CancellationToken,
69        responder: tokio::sync::oneshot::Sender<CycleMarketOutcome>,
70        gate: SnapshotGate,
71    },
72    /// Send the authenticated subscribe message on the user channel.
73    SubscribeUser,
74}
75
76/// Outcome of a recovery subscription cycle.
77#[derive(Clone, Debug)]
78pub enum CycleMarketOutcome {
79    /// Both legs wrote on one connection; a fresh snapshot should follow.
80    Completed,
81    /// The connection changed mid-cycle; reconnect replay owns restoration.
82    ConnectionChanged,
83    /// The attempt was abandoned; a written unsubscribe leg was restored.
84    Cancelled,
85    /// A genuine unsubscribe won; nothing was sent or restored.
86    NotDesired,
87    /// A wire write failed; a written unsubscribe leg was restored.
88    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    // True once SubscribeUser has been explicitly requested by the caller
104    user_subscribed: bool,
105    // True once the current market-channel session has sent its initial subscribe payload.
106    market_subscription_initialized: bool,
107    market_heartbeat_next: Option<(tokio::time::Instant, u64)>,
108    // Assets awaiting first authoritative data, keyed by the connection that wrote the subscribe.
109    market_subscription_epochs: AHashMap<String, u64>,
110    // Overflow buffer for batched frames, drained before reading the next raw message
111    message_buffer: Vec<PolymarketWsMessage>,
112    // Whether to include `custom_feature_enabled: true` in the initial subscribe
113    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        // Epoch-bound: a reconnect drops the send instead of buffering it onto
255        // the new connection, where the replayed subscription set no longer
256        // includes these assets.
257        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        // Leg 1: venue-side unsubscribe. Desired state is untouched: the pool
313        // retains ownership throughout the cycle.
314        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            // A genuine unsubscribe won after the first leg: the venue state
332            // already matches desire, so no restore is needed.
333            return CycleMarketOutcome::NotDesired;
334        }
335
336        let current = self.client.as_ref().map(|client| client.connection_epoch());
337
338        if current != Some(epoch) {
339            // Reconnect replay owns restoration on the new connection.
340            return CycleMarketOutcome::ConnectionChanged;
341        }
342
343        // Leg 2: fresh subscribe on the same connection.
344        match self.try_send_subscribe_market(asset_ids, Some(epoch)).await {
345            Ok(()) => {
346                // Open before returning so the gate precedes every later raw
347                // read on this task; a queued fresh snapshot cannot miss it.
348                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        // Best effort: on failure the next recovery attempt or reconnect
361        // replay owns restoration from the retained desired state.
362        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        // Begin auth tracking; discard receiver, state is queried via is_authenticated()
392        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                // auth_tracker.succeed() is NOT called here; sending the request only
400                // confirms delivery to the server, not that the credentials were accepted.
401                // succeed() is called in next() when the server actually sends user-channel
402                // data, which is the real confirmation that authentication worked.
403                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        // When `subscribe_new_markets` is enabled, Polymarket's WSS periodically
441        // sends the plain-text string "NO NEW ASSETS" as a heartbeat/ack.
442        if text == "NO NEW ASSETS" {
443            return vec![];
444        }
445
446        // Reply to the application-level `PING` heartbeat, which is not JSON
447        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                                // Receiving any user-channel data confirms the server accepted the
586                                // credentials; mark auth as successful on the first delivery.
587                                self.auth_tracker.succeed();
588                            }
589                            // Buffer msgs[1..] so they are returned in order on subsequent
590                            // next() calls; returning first directly preserves 0,1,2,...,n order
591                            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 = &quotes.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    /// Classifies client frames: `subscribe` covers both the initial
1410    /// `type: market` frame and later `operation` frames.
1411    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        // Leg 1 never wrote, so desired state is retained for retry/replay
1580        tokio::time::sleep(Duration::from_millis(100)).await;
1581        assert!(messages.lock().is_empty());
1582        assert_desired(&handler, &subscriptions);
1583    }
1584
1585    /// Scripted venue: a subscribe only yields a snapshot when the venue-side
1586    /// state flips from unsubscribed. This reproduces the live finding that a
1587    /// duplicate mid-connection subscribe receives no snapshot.
1588    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        // The cycle runs inside next(), so pump the loop while awaiting the outcome
1703        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        // Commands serialize: the cycle completes first, then the unsubscribe wins
1743        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}