Skip to main content

nautilus_betfair/stream/
client.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//! Betfair Exchange Stream API client.
17//!
18//! Connects to the Betfair raw TLS stream (CRLF-delimited JSON), authenticates,
19//! and manages market/order subscriptions with automatic clk-based resubscription
20//! on reconnection.
21
22use std::{
23    fmt::Debug,
24    sync::{
25        Arc, OnceLock,
26        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
27    },
28    time::{Duration, Instant},
29};
30
31use bytes::Bytes;
32use nautilus_core::string::secret::{REDACTED, SecretString};
33use nautilus_live::{
34    book::snapshot::SnapshotGate,
35    task::{SharedTaskSlot, TaskJoinOutcome},
36};
37use nautilus_network::{
38    SocketState, SocketStateSink, WriterSender,
39    mode::ReconnectRequestOutcome,
40    socket::{
41        SocketClient, SocketConfig, SocketHeartbeat, SocketReconnectHandle, SocketReconnectReplay,
42        TcpMessageHandler, WriterCommand,
43    },
44};
45use parking_lot::{Mutex, MutexGuard};
46use tokio::sync::watch; // tokio-import-ok
47use tokio_tungstenite::tungstenite::stream::Mode;
48use zeroize::Zeroizing;
49
50use super::{
51    config::{
52        BETFAIR_STREAM_HEARTBEAT_MAX_MS, BETFAIR_STREAM_HEARTBEAT_MIN_MS, BetfairStreamConfig,
53    },
54    error::BetfairStreamError,
55    messages::{
56        Authentication, CricketSubscription, MarketDataFilter, MarketSubscription, OrderFilter,
57        OrderSubscription, RaceSubscription, Status, StreamMarketFilter, StreamMessage,
58        stream_decode,
59    },
60};
61use crate::common::{
62    consts::{
63        BETFAIR_STREAM_SERVER_HEARTBEAT_MS, STREAM_OP_MARKET_SUBSCRIPTION,
64        STREAM_OP_ORDER_SUBSCRIPTION,
65    },
66    credential::BetfairCredential,
67    enums::{ChangeType, SegmentType, StatusErrorCode},
68};
69
70pub(crate) type StreamMessageHandler = Arc<dyn Fn(StreamMessage) + Send + Sync>;
71
72#[derive(Clone, Copy, Debug)]
73pub(crate) enum HeartbeatTimeoutSource {
74    Outbound,
75    Server,
76}
77
78const AUTH_REQUEST_ID: u64 = 1;
79const STREAM_STATUS_SUCCESS: &str = "SUCCESS";
80const STREAM_DEGRADED_STATUS: i32 = 503;
81const MARKET_SUBSCRIPTION_REPLAY_KEY: u64 = 1;
82const ORDER_SUBSCRIPTION_REPLAY_KEY: u64 = 2;
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85#[repr(u8)]
86pub enum StreamLifecycleState {
87    Disconnected,
88    Idle,
89    Pending,
90    Active,
91    Degraded,
92    Rejected,
93}
94
95impl StreamLifecycleState {
96    fn from_atomic(value: &AtomicU8) -> Self {
97        match value.load(Ordering::Acquire) {
98            1 => Self::Idle,
99            2 => Self::Pending,
100            3 => Self::Active,
101            4 => Self::Degraded,
102            5 => Self::Rejected,
103            _ => Self::Disconnected,
104        }
105    }
106}
107
108#[derive(Debug)]
109struct LifecycleState {
110    value: AtomicU8,
111    changed: watch::Sender<()>,
112}
113
114impl LifecycleState {
115    fn new(state: StreamLifecycleState) -> Self {
116        Self {
117            value: AtomicU8::new(state as u8),
118            changed: watch::channel(()).0,
119        }
120    }
121
122    fn get(&self) -> StreamLifecycleState {
123        StreamLifecycleState::from_atomic(&self.value)
124    }
125
126    fn set(&self, state: StreamLifecycleState) {
127        self.value.store(state as u8, Ordering::Release);
128        self.changed.send_replace(());
129    }
130}
131
132#[derive(Debug)]
133struct ProtocolLifecycle {
134    transport_connected: AtomicBool,
135    authenticated: LifecycleState,
136    market: LifecycleState,
137    market_was_current: AtomicBool,
138    market_requires_image: AtomicBool,
139    market_image_tainted: AtomicBool,
140    order: LifecycleState,
141    order_was_current: AtomicBool,
142    order_requires_image: AtomicBool,
143    order_image_tainted: AtomicBool,
144}
145
146impl Default for ProtocolLifecycle {
147    fn default() -> Self {
148        Self {
149            transport_connected: AtomicBool::new(false),
150            authenticated: LifecycleState::new(StreamLifecycleState::Disconnected),
151            market: LifecycleState::new(StreamLifecycleState::Idle),
152            market_was_current: AtomicBool::new(false),
153            market_requires_image: AtomicBool::new(false),
154            market_image_tainted: AtomicBool::new(false),
155            order: LifecycleState::new(StreamLifecycleState::Idle),
156            order_was_current: AtomicBool::new(false),
157            order_requires_image: AtomicBool::new(false),
158            order_image_tainted: AtomicBool::new(false),
159        }
160    }
161}
162
163impl ProtocolLifecycle {
164    fn on_transport(&self, state: SocketState, market_id: u64, order_id: u64) {
165        let connected = state == SocketState::Connected;
166        self.transport_connected.store(connected, Ordering::Release);
167        self.authenticated.set(if connected {
168            StreamLifecycleState::Pending
169        } else {
170            StreamLifecycleState::Disconnected
171        });
172        self.market
173            .set(subscription_transport_state(connected, market_id));
174        self.market_was_current.store(false, Ordering::Release);
175        self.market_image_tainted.store(false, Ordering::Release);
176        self.order
177            .set(subscription_transport_state(connected, order_id));
178        self.order_was_current.store(false, Ordering::Release);
179        self.order_image_tainted.store(false, Ordering::Release);
180    }
181
182    fn on_status(&self, status: &Status, market_id: u64, order_id: u64) {
183        let Some(id) = status.id else {
184            return;
185        };
186        let next = if status.status_code.as_deref() == Some(STREAM_STATUS_SUCCESS)
187            && status.error_code.is_none()
188        {
189            StreamLifecycleState::Active
190        } else {
191            StreamLifecycleState::Rejected
192        };
193
194        if id == AUTH_REQUEST_ID {
195            self.authenticated.set(next);
196        } else if id == market_id {
197            self.market.set(if next == StreamLifecycleState::Active {
198                StreamLifecycleState::Pending
199            } else {
200                next
201            });
202        } else if id == order_id {
203            self.order.set(if next == StreamLifecycleState::Active {
204                StreamLifecycleState::Pending
205            } else {
206                next
207            });
208        }
209    }
210
211    fn on_change(
212        state: &LifecycleState,
213        was_current: &AtomicBool,
214        requires_image: &AtomicBool,
215        status: Option<i32>,
216        change_type: Option<ChangeType>,
217        segment_type: Option<SegmentType>,
218    ) {
219        let complete = change_complete(segment_type);
220        let initial = change_type == Some(ChangeType::SubImage)
221            || (change_type == Some(ChangeType::ResubDelta)
222                && !requires_image.load(Ordering::Acquire));
223
224        if status == Some(STREAM_DEGRADED_STATUS) {
225            state.set(StreamLifecycleState::Degraded);
226            return;
227        }
228
229        let current = state.get();
230
231        if status.is_none()
232            && complete
233            && (initial
234                || (current == StreamLifecycleState::Degraded
235                    && was_current.load(Ordering::Acquire)))
236        {
237            if change_type == Some(ChangeType::SubImage) {
238                requires_image.store(false, Ordering::Release);
239            }
240            was_current.store(true, Ordering::Release);
241            state.set(StreamLifecycleState::Active);
242        }
243    }
244}
245
246const fn subscription_transport_state(connected: bool, id: u64) -> StreamLifecycleState {
247    if !connected {
248        StreamLifecycleState::Disconnected
249    } else if id == 0 {
250        StreamLifecycleState::Idle
251    } else {
252        StreamLifecycleState::Pending
253    }
254}
255
256async fn wait_for_lifecycle_state(state: &LifecycleState, expected: StreamLifecycleState) {
257    let mut changed_rx = state.changed.subscribe();
258    loop {
259        if state.get() == expected {
260            return;
261        }
262        changed_rx
263            .changed()
264            .await
265            .expect("lifecycle sender lives as long as the borrowed client");
266    }
267}
268
269/// Betfair Exchange Stream API client using raw TLS (CRLF-delimited JSON).
270///
271/// On connect, authenticates immediately. On reconnection, replays authentication
272/// and any active subscriptions with the latest `clk` token for delta resumption.
273///
274/// The auth bytes are stored in a watch channel so the caller can push refreshed
275/// session tokens via [`update_auth`](Self::update_auth) after keep-alive or HTTP
276/// reconnect. The `closed` flag distinguishes permanent shutdown from transient
277/// reconnect.
278#[derive(Debug)]
279pub struct BetfairStreamClient {
280    socket: SocketClient,
281    market_sub_tx: watch::Sender<Option<MarketSubscription>>,
282    market_clk_tx: watch::Sender<Option<String>>,
283    market_initial_clk_tx: watch::Sender<Option<String>>,
284    order_sub_tx: watch::Sender<Option<OrderSubscription>>,
285    order_clk_tx: watch::Sender<Option<String>>,
286    order_initial_clk_tx: watch::Sender<Option<String>>,
287    market_active_sub_id: Arc<AtomicU64>,
288    order_active_sub_id: Arc<AtomicU64>,
289    request_id: Arc<AtomicU64>,
290    market_state_lock: Arc<Mutex<()>>,
291    order_state_lock: Arc<Mutex<()>>,
292    auth_tx: watch::Sender<StreamAuth>,
293    reconnect_auth: Arc<ReconnectAuthState>,
294    lifecycle: Arc<ProtocolLifecycle>,
295    dead_peer_enabled: Arc<AtomicBool>,
296    dead_peer_timeout_ms: Arc<AtomicU64>,
297    dead_peer_timeout_override: bool,
298    dead_peer_task: SharedTaskSlot<()>,
299    closed: AtomicBool,
300}
301
302impl BetfairStreamClient {
303    /// Connects to the Betfair stream API and authenticates.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if the connection fails or authentication cannot be sent.
308    pub async fn connect(
309        credential: &BetfairCredential,
310        session_token: impl Into<SecretString>,
311        handler: TcpMessageHandler,
312        config: BetfairStreamConfig,
313    ) -> Result<Self, BetfairStreamError> {
314        Self::connect_inner(
315            credential,
316            session_token.into(),
317            StreamHandler::Raw(handler),
318            config,
319            HeartbeatTimeoutSource::Server,
320            None,
321        )
322        .await
323    }
324
325    /// Connects to the Betfair stream API and reports transport availability changes.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if the connection fails or authentication cannot be sent.
330    pub(crate) async fn connect_with_state_sink(
331        credential: &BetfairCredential,
332        session_token: SecretString,
333        handler: StreamMessageHandler,
334        config: BetfairStreamConfig,
335        heartbeat_timeout_source: HeartbeatTimeoutSource,
336        state_sink: Option<SocketStateSink>,
337    ) -> Result<Self, BetfairStreamError> {
338        Self::connect_inner(
339            credential,
340            session_token,
341            StreamHandler::Decoded(handler),
342            config,
343            heartbeat_timeout_source,
344            state_sink,
345        )
346        .await
347    }
348
349    async fn connect_inner(
350        credential: &BetfairCredential,
351        session_token: SecretString,
352        handler: StreamHandler,
353        config: BetfairStreamConfig,
354        heartbeat_timeout_source: HeartbeatTimeoutSource,
355        state_sink: Option<SocketStateSink>,
356    ) -> Result<Self, BetfairStreamError> {
357        config
358            .validate()
359            .map_err(|e| BetfairStreamError::ProtocolError(e.to_string()))?;
360        let auth = Zeroizing::new(Authentication::with_id(
361            credential.app_key(),
362            session_token,
363            AUTH_REQUEST_ID,
364        ));
365        let auth_bytes = Zeroizing::new(serde_json::to_vec(&*auth)?);
366        let reconnect_auth = Arc::new(ReconnectAuthState::default());
367        let (auth_tx, auth_rx) = watch::channel(StreamAuth {
368            generation: 0,
369            bytes: auth_bytes.clone(),
370        });
371        let mode = if config.use_tls {
372            Mode::Tls
373        } else {
374            Mode::Plain
375        };
376
377        let (market_clk_tx, market_clk_rx) = watch::channel(None::<String>);
378        let (market_initial_clk_tx, market_initial_clk_rx) = watch::channel(None::<String>);
379        let (order_clk_tx, order_clk_rx) = watch::channel(None::<String>);
380        let (order_initial_clk_tx, order_initial_clk_rx) = watch::channel(None::<String>);
381        let (market_sub_tx, market_sub_rx) = watch::channel(None::<MarketSubscription>);
382        let (order_sub_tx, order_sub_rx) = watch::channel(None::<OrderSubscription>);
383
384        // Clone senders for the handler; struct keeps originals to reset on re-subscribe.
385        let market_sub_tx_h = market_sub_tx.clone();
386        let order_sub_tx_h = order_sub_tx.clone();
387        let (market_clk_tx_h, market_initial_clk_tx_h) =
388            (market_clk_tx.clone(), market_initial_clk_tx.clone());
389        let (order_clk_tx_h, order_initial_clk_tx_h) =
390            (order_clk_tx.clone(), order_initial_clk_tx.clone());
391
392        let market_active_sub_id = Arc::new(AtomicU64::new(0));
393        let order_active_sub_id = Arc::new(AtomicU64::new(0));
394        let request_id = Arc::new(AtomicU64::new(AUTH_REQUEST_ID + 1));
395        let request_id_h = Arc::clone(&request_id);
396        let market_state_lock = Arc::new(Mutex::new(()));
397        let order_state_lock = Arc::new(Mutex::new(()));
398        let market_state_lock_h = Arc::clone(&market_state_lock);
399        let order_state_lock_h = Arc::clone(&order_state_lock);
400        let writer_tx_h = Arc::new(OnceLock::new());
401        let writer_tx_handler = Arc::clone(&writer_tx_h);
402        let market_active_sub_id_h = Arc::clone(&market_active_sub_id);
403        let order_active_sub_id_h = Arc::clone(&order_active_sub_id);
404        let reconnect_auth_h = Arc::clone(&reconnect_auth);
405        let lifecycle = Arc::new(ProtocolLifecycle::default());
406        let lifecycle_h = Arc::clone(&lifecycle);
407        let last_inbound = Arc::new(Mutex::new(Instant::now()));
408        let last_inbound_h = Arc::clone(&last_inbound);
409        let dead_peer_enabled = Arc::new(AtomicBool::new(false));
410        let dead_peer_timeout_ms = Arc::new(AtomicU64::new(
411            config.dead_peer_timeout_secs().saturating_mul(1_000),
412        ));
413        let dead_peer_timeout_ms_h = Arc::clone(&dead_peer_timeout_ms);
414        let timeout_override = config.heartbeat_timeout_secs.is_some();
415
416        let message_handler: TcpMessageHandler = Arc::new(move |data: &[u8]| {
417            *last_inbound_h.lock() = Instant::now();
418            let Some(msg) = handler.decode(data) else {
419                return;
420            };
421
422            match &msg {
423                StreamMessage::MarketChange(mcm) => {
424                    let _state = lock_stream_state(&market_state_lock_h);
425                    let active = market_active_sub_id_h.load(Ordering::SeqCst);
426                    let current = active == 0 || mcm.id.is_none_or(|id| id == active);
427                    if !current {
428                        return;
429                    }
430
431                    if mcm.status == Some(STREAM_DEGRADED_STATUS) {
432                        if mcm.segment_type.is_some() {
433                            lifecycle_h
434                                .market_image_tainted
435                                .store(true, Ordering::Release);
436                        }
437                        ProtocolLifecycle::on_change(
438                            &lifecycle_h.market,
439                            &lifecycle_h.market_was_current,
440                            &lifecycle_h.market_requires_image,
441                            mcm.status,
442                            mcm.ct,
443                            mcm.segment_type,
444                        );
445                        return;
446                    }
447
448                    let image_start = mcm.ct == Some(ChangeType::SubImage)
449                        && matches!(mcm.segment_type, None | Some(SegmentType::SegStart));
450                    let complete = change_complete(mcm.segment_type);
451                    if image_start && mcm.status.is_none() {
452                        lifecycle_h
453                            .market_image_tainted
454                            .store(false, Ordering::Release);
455                    } else if lifecycle_h.market_image_tainted.load(Ordering::Acquire) {
456                        if complete
457                            && let Err(e) = reissue_market_subscription(
458                                &request_id_h,
459                                &market_active_sub_id_h,
460                                &lifecycle_h,
461                                &market_sub_tx_h,
462                                &market_clk_tx_h,
463                                &market_initial_clk_tx_h,
464                                writer_tx_handler.get(),
465                            )
466                        {
467                            log::error!("Failed to recover Betfair market stream: {e}");
468                        }
469                        return;
470                    }
471
472                    let lifecycle_state = lifecycle_h.market.get();
473                    if lifecycle_state == StreamLifecycleState::Degraded
474                        && mcm.ct != Some(ChangeType::SubImage)
475                    {
476                        if complete
477                            && let Err(e) = reissue_market_subscription(
478                                &request_id_h,
479                                &market_active_sub_id_h,
480                                &lifecycle_h,
481                                &market_sub_tx_h,
482                                &market_clk_tx_h,
483                                &market_initial_clk_tx_h,
484                                writer_tx_handler.get(),
485                            )
486                        {
487                            log::error!("Failed to recover Betfair market stream: {e}");
488                        }
489                        return;
490                    }
491
492                    if lifecycle_h.market_requires_image.load(Ordering::Acquire)
493                        && mcm.ct == Some(ChangeType::ResubDelta)
494                    {
495                        return;
496                    }
497
498                    ProtocolLifecycle::on_change(
499                        &lifecycle_h.market,
500                        &lifecycle_h.market_was_current,
501                        &lifecycle_h.market_requires_image,
502                        mcm.status,
503                        mcm.ct,
504                        mcm.segment_type,
505                    );
506                    update_stream_state(
507                        &mcm.clk,
508                        &mcm.initial_clk,
509                        mcm.heartbeat_ms,
510                        &market_clk_tx_h,
511                        &market_initial_clk_tx_h,
512                        timeout_override,
513                        &dead_peer_timeout_ms_h,
514                    );
515                    handler.handle(data, msg);
516                }
517                StreamMessage::OrderChange(ocm) => {
518                    let _state = lock_stream_state(&order_state_lock_h);
519                    let active = order_active_sub_id_h.load(Ordering::SeqCst);
520                    let current = active == 0 || ocm.id.is_none_or(|id| id == active);
521                    if !current {
522                        return;
523                    }
524
525                    if ocm.status == Some(STREAM_DEGRADED_STATUS) {
526                        if ocm.segment_type.is_some() {
527                            lifecycle_h
528                                .order_image_tainted
529                                .store(true, Ordering::Release);
530                        }
531                        ProtocolLifecycle::on_change(
532                            &lifecycle_h.order,
533                            &lifecycle_h.order_was_current,
534                            &lifecycle_h.order_requires_image,
535                            ocm.status,
536                            ocm.ct,
537                            ocm.segment_type,
538                        );
539                        handler.handle(data, msg);
540                        return;
541                    }
542
543                    let image_start = ocm.ct == Some(ChangeType::SubImage)
544                        && matches!(ocm.segment_type, None | Some(SegmentType::SegStart));
545                    let complete = change_complete(ocm.segment_type);
546                    if image_start && ocm.status.is_none() {
547                        lifecycle_h
548                            .order_image_tainted
549                            .store(false, Ordering::Release);
550                    } else if lifecycle_h.order_image_tainted.load(Ordering::Acquire) {
551                        if complete {
552                            reissue_order_subscription(
553                                &request_id_h,
554                                &order_active_sub_id_h,
555                                &lifecycle_h,
556                                &order_sub_tx_h,
557                                &order_clk_tx_h,
558                                &order_initial_clk_tx_h,
559                                writer_tx_handler.get(),
560                            );
561                        }
562                        return;
563                    }
564
565                    let lifecycle_state = lifecycle_h.order.get();
566                    if lifecycle_state == StreamLifecycleState::Degraded
567                        && lifecycle_h.order_requires_image.load(Ordering::Acquire)
568                        && ocm.ct != Some(ChangeType::SubImage)
569                    {
570                        if complete {
571                            reissue_order_subscription(
572                                &request_id_h,
573                                &order_active_sub_id_h,
574                                &lifecycle_h,
575                                &order_sub_tx_h,
576                                &order_clk_tx_h,
577                                &order_initial_clk_tx_h,
578                                writer_tx_handler.get(),
579                            );
580                        }
581                        return;
582                    }
583
584                    if lifecycle_h.order_requires_image.load(Ordering::Acquire)
585                        && ocm.ct == Some(ChangeType::ResubDelta)
586                    {
587                        return;
588                    }
589
590                    ProtocolLifecycle::on_change(
591                        &lifecycle_h.order,
592                        &lifecycle_h.order_was_current,
593                        &lifecycle_h.order_requires_image,
594                        ocm.status,
595                        ocm.ct,
596                        ocm.segment_type,
597                    );
598                    update_stream_state(
599                        &ocm.clk,
600                        &ocm.initial_clk,
601                        ocm.heartbeat_ms,
602                        &order_clk_tx_h,
603                        &order_initial_clk_tx_h,
604                        timeout_override,
605                        &dead_peer_timeout_ms_h,
606                    );
607                    handler.handle(data, msg);
608                }
609                StreamMessage::Status(status) => {
610                    let _market_state = lock_stream_state(&market_state_lock_h);
611                    let _order_state = lock_stream_state(&order_state_lock_h);
612                    let market_id = market_active_sub_id_h.load(Ordering::Acquire);
613                    let order_id = order_active_sub_id_h.load(Ordering::Acquire);
614                    lifecycle_h.on_status(status, market_id, order_id);
615                    // Clear rejected clocks so the next reconnect requests a full image
616                    if status.error_code == Some(StatusErrorCode::InvalidClock) {
617                        if market_id > 0 && status.id == Some(market_id) {
618                            let _ = market_clk_tx_h.send(None);
619                            let _ = market_initial_clk_tx_h.send(None);
620                            lifecycle_h
621                                .market_requires_image
622                                .store(true, Ordering::Release);
623                            lifecycle_h
624                                .market_image_tainted
625                                .store(false, Ordering::Release);
626                            log::warn!(
627                                "Betfair market stream INVALID_CLOCK: clocks cleared, \
628                                 next reconnect will request a full image",
629                            );
630                        } else if order_id > 0 && status.id == Some(order_id) {
631                            let _ = order_clk_tx_h.send(None);
632                            let _ = order_initial_clk_tx_h.send(None);
633                            lifecycle_h
634                                .order_requires_image
635                                .store(true, Ordering::Release);
636                            lifecycle_h
637                                .order_image_tainted
638                                .store(false, Ordering::Release);
639                            log::warn!(
640                                "Betfair order stream INVALID_CLOCK: clocks cleared, \
641                                 next reconnect will request a full image",
642                            );
643                        }
644                    } else if status.connection_closed {
645                        log::warn!(
646                            "Betfair stream connection closed by server: {:?} - {:?}",
647                            status.error_code,
648                            status.error_message,
649                        );
650                    } else if status.error_code.is_some() {
651                        log::warn!(
652                            "Betfair stream status error: {:?} - {:?}",
653                            status.error_code,
654                            status.error_message,
655                        );
656                    }
657                    handler.handle(data, msg);
658                }
659                StreamMessage::Connection(_) => {
660                    reconnect_auth_h.request_pending();
661                    handler.handle(data, msg);
662                }
663                _ => {
664                    handler.handle(data, msg);
665                }
666            }
667        });
668
669        let auth_reconnect = auth_rx;
670        let reconnect_auth_replay = Arc::clone(&reconnect_auth);
671        let market_state_replay = Arc::clone(&market_state_lock);
672        let order_state_replay = Arc::clone(&order_state_lock);
673        let reconnect_replay: SocketReconnectReplay = Arc::new(move || {
674            let mut replay = Vec::with_capacity(3);
675            let auth = auth_reconnect.borrow().clone();
676            reconnect_auth_replay.record_replay(auth.generation);
677
678            replay.push(Bytes::copy_from_slice(&auth.bytes));
679
680            {
681                let _state = lock_stream_state(&market_state_replay);
682
683                if let Some(mut sub) = market_sub_rx.borrow().clone() {
684                    sub.clk = market_clk_rx.borrow().clone();
685                    sub.initial_clk = market_initial_clk_rx.borrow().clone();
686                    if let Ok(sub_bytes) = serde_json::to_vec(&sub) {
687                        replay.push(Bytes::from(sub_bytes));
688                    }
689                }
690            }
691
692            {
693                let _state = lock_stream_state(&order_state_replay);
694
695                if let Some(mut sub) = order_sub_rx.borrow().clone() {
696                    sub.clk = order_clk_rx.borrow().clone();
697                    sub.initial_clk = order_initial_clk_rx.borrow().clone();
698                    if let Ok(sub_bytes) = serde_json::to_vec(&sub) {
699                        replay.push(Bytes::from(sub_bytes));
700                    }
701                }
702            }
703
704            replay
705        });
706
707        let url = format!("{}:{}", config.host, config.port);
708        let lifecycle_sink = Arc::clone(&lifecycle);
709        let market_id_sink = Arc::clone(&market_active_sub_id);
710        let order_id_sink = Arc::clone(&order_active_sub_id);
711        let last_inbound_sink = Arc::clone(&last_inbound);
712        let market_state_sink = Arc::clone(&market_state_lock);
713        let order_state_sink = Arc::clone(&order_state_lock);
714        let lifecycle_callback = move |state| {
715            let _market_state = lock_stream_state(&market_state_sink);
716            let _order_state = lock_stream_state(&order_state_sink);
717            lifecycle_sink.on_transport(
718                state,
719                market_id_sink.load(Ordering::Acquire),
720                order_id_sink.load(Ordering::Acquire),
721            );
722            *last_inbound_sink.lock() = Instant::now();
723        };
724        let state_sink = match state_sink {
725            Some(sink) => sink.with_callback(lifecycle_callback),
726            None => SocketStateSink::new(lifecycle_callback),
727        };
728        let socket_config = SocketConfig {
729            url,
730            mode,
731            suffix: b"\r\n".to_vec(),
732            message_handler: Some(message_handler),
733            heartbeat: outbound_heartbeat(config.heartbeat_secs),
734            connect_timeout_ms: None,
735            reconnect_delay_initial_ms: Some(config.reconnect_delay_initial_ms),
736            reconnect_delay_max_ms: Some(config.reconnect_delay_max_ms),
737            reconnect_backoff_factor: None,
738            reconnect_jitter_ms: None,
739            connection_max_retries: None,
740            reconnect_max_attempts: None,
741            heartbeat_timeout_secs: heartbeat_timeout(
742                heartbeat_timeout_source,
743                config.heartbeat_secs,
744                config.heartbeat_timeout_secs,
745            ),
746            writer_capacity: None,
747            certs_dir: None,
748        };
749
750        let socket = SocketClient::builder()
751            .config(socket_config)
752            .state_sink(state_sink)
753            .reconnect_replay(reconnect_replay)
754            .connect()
755            .await
756            .map_err(|e| BetfairStreamError::ConnectionFailed(e.to_string()))?;
757        writer_tx_h
758            .set(socket.writer_tx.clone())
759            .expect("Betfair stream writer must only be initialized once");
760        reconnect_auth.set_handle(socket.reconnect_handle());
761
762        socket
763            .send_bytes(auth_bytes.as_slice().to_vec())
764            .await
765            .map_err(|e| BetfairStreamError::ConnectionFailed(e.to_string()))?;
766
767        let dead_peer_task = SharedTaskSlot::new();
768
769        if matches!(heartbeat_timeout_source, HeartbeatTimeoutSource::Server) {
770            let reconnect = socket.reconnect_handle();
771            let enabled = Arc::clone(&dead_peer_enabled);
772            let last = Arc::clone(&last_inbound);
773            let timeout_ms = Arc::clone(&dead_peer_timeout_ms);
774
775            dead_peer_task
776                .spawn(async move {
777                    loop {
778                        tokio::time::sleep(Duration::from_millis(100)).await;
779
780                        if !enabled.load(Ordering::Acquire) {
781                            continue;
782                        }
783                        let timeout = Duration::from_millis(timeout_ms.load(Ordering::Acquire));
784                        if last.lock().elapsed() >= timeout {
785                            let _ = reconnect.request_reconnect();
786                        }
787                    }
788                })
789                .map_err(|e| {
790                    BetfairStreamError::ConnectionFailed(format!(
791                        "failed to start dead-peer monitor: {e}"
792                    ))
793                })?;
794        }
795
796        Ok(Self {
797            socket,
798            market_sub_tx,
799            market_clk_tx,
800            market_initial_clk_tx,
801            order_sub_tx,
802            order_clk_tx,
803            order_initial_clk_tx,
804            market_active_sub_id,
805            order_active_sub_id,
806            request_id,
807            market_state_lock,
808            order_state_lock,
809            auth_tx,
810            reconnect_auth,
811            lifecycle,
812            dead_peer_enabled,
813            dead_peer_timeout_ms,
814            dead_peer_timeout_override: timeout_override,
815            dead_peer_task,
816            closed: AtomicBool::new(false),
817        })
818    }
819
820    /// Subscribes to market data for the given filter and data fields.
821    ///
822    /// Stores the subscription for automatic replay on reconnection.
823    ///
824    /// # Errors
825    ///
826    /// Returns an error if serialization or sending fails.
827    pub async fn subscribe_markets(
828        &self,
829        market_filter: StreamMarketFilter,
830        data_filter: MarketDataFilter,
831        heartbeat_ms: Option<u64>,
832        conflate_ms: Option<u64>,
833    ) -> Result<(), BetfairStreamError> {
834        self.write_market_subscription(
835            market_filter,
836            data_filter,
837            heartbeat_ms,
838            conflate_ms,
839            &SnapshotGate::default(),
840        )
841    }
842
843    /// Queues a market subscription without waiting, so subscriptions reach the venue in call
844    /// order.
845    ///
846    /// Opens `gate` once the write is queued, under the lock that orders change messages, so no
847    /// message from the new subscription is handled while the gate is closed.
848    ///
849    /// # Errors
850    ///
851    /// Returns an error if serialization or sending fails.
852    pub(crate) fn write_market_subscription(
853        &self,
854        market_filter: StreamMarketFilter,
855        data_filter: MarketDataFilter,
856        heartbeat_ms: Option<u64>,
857        conflate_ms: Option<u64>,
858        gate: &SnapshotGate,
859    ) -> Result<(), BetfairStreamError> {
860        if self.closed.load(Ordering::SeqCst) || self.socket.is_closed() {
861            return Err(BetfairStreamError::Disconnected(
862                "stream client is closed".to_string(),
863            ));
864        }
865        let heartbeat_ms = heartbeat_ms.unwrap_or(BETFAIR_STREAM_SERVER_HEARTBEAT_MS);
866        validate_subscription_heartbeat(heartbeat_ms)?;
867        self.update_dead_peer_timeout(heartbeat_ms);
868        let _state = lock_stream_state(&self.market_state_lock);
869        let id = self.request_id.fetch_add(1, Ordering::Relaxed);
870        // Advance the active ID before clearing clocks so that any in-flight MCMs
871        // from the previous subscription are immediately rejected by the handler.
872        self.market_active_sub_id.store(id, Ordering::SeqCst);
873        self.lifecycle.market.set(StreamLifecycleState::Pending);
874        self.lifecycle
875            .market_was_current
876            .store(false, Ordering::Release);
877        self.lifecycle
878            .market_requires_image
879            .store(true, Ordering::Release);
880        self.lifecycle
881            .market_image_tainted
882            .store(false, Ordering::Release);
883        self.dead_peer_enabled.store(true, Ordering::Release);
884        let sub = MarketSubscription {
885            op: STREAM_OP_MARKET_SUBSCRIPTION.to_string(),
886            id: Some(id),
887            market_filter,
888            market_data_filter: data_filter,
889            clk: None,
890            conflate_ms,
891            heartbeat_ms: Some(heartbeat_ms),
892            initial_clk: None,
893            segmentation_enabled: Some(true),
894        };
895
896        // Reset clocks so a disconnect before the first MCM response doesn't replay
897        // stale tokens from a previous subscription with different filters.
898        let _ = self.market_clk_tx.send(None);
899        let _ = self.market_initial_clk_tx.send(None);
900        let _ = self.market_sub_tx.send(Some(sub.clone()));
901
902        let data = Bytes::from(serde_json::to_vec(&sub)?);
903        self.socket
904            .writer_tx
905            .send(WriterCommand::SendOrReplay {
906                key: MARKET_SUBSCRIPTION_REPLAY_KEY,
907                data,
908            })
909            .map_err(|e| BetfairStreamError::ConnectionFailed(e.to_string()))?;
910
911        gate.open();
912        Ok(())
913    }
914
915    /// Subscribes to order updates.
916    ///
917    /// Stores the subscription for automatic replay on reconnection.
918    ///
919    /// # Errors
920    ///
921    /// Returns an error if serialization or sending fails.
922    pub async fn subscribe_orders(
923        &self,
924        order_filter: Option<OrderFilter>,
925        heartbeat_ms: Option<u64>,
926    ) -> Result<(), BetfairStreamError> {
927        if self.closed.load(Ordering::SeqCst) || self.socket.is_closed() {
928            return Err(BetfairStreamError::Disconnected(
929                "stream client is closed".to_string(),
930            ));
931        }
932        let heartbeat_ms = heartbeat_ms.unwrap_or(BETFAIR_STREAM_SERVER_HEARTBEAT_MS);
933        validate_subscription_heartbeat(heartbeat_ms)?;
934        self.update_dead_peer_timeout(heartbeat_ms);
935        let _state = lock_stream_state(&self.order_state_lock);
936        let id = self.request_id.fetch_add(1, Ordering::Relaxed);
937        self.order_active_sub_id.store(id, Ordering::SeqCst);
938        self.lifecycle.order.set(StreamLifecycleState::Pending);
939        self.lifecycle
940            .order_was_current
941            .store(false, Ordering::Release);
942        self.lifecycle
943            .order_requires_image
944            .store(true, Ordering::Release);
945        self.lifecycle
946            .order_image_tainted
947            .store(false, Ordering::Release);
948        self.dead_peer_enabled.store(true, Ordering::Release);
949        let sub = OrderSubscription {
950            op: STREAM_OP_ORDER_SUBSCRIPTION.to_string(),
951            id: Some(id),
952            order_filter,
953            clk: None,
954            conflate_ms: None,
955            heartbeat_ms: Some(heartbeat_ms),
956            initial_clk: None,
957            segmentation_enabled: Some(true),
958        };
959
960        // Reset clocks so a disconnect before the first OCM response doesn't replay
961        // stale tokens from a previous subscription with different filters.
962        let _ = self.order_clk_tx.send(None);
963        let _ = self.order_initial_clk_tx.send(None);
964        let _ = self.order_sub_tx.send(Some(sub.clone()));
965
966        let data = Bytes::from(serde_json::to_vec(&sub)?);
967        self.socket
968            .writer_tx
969            .send(WriterCommand::SendOrReplay {
970                key: ORDER_SUBSCRIPTION_REPLAY_KEY,
971                data,
972            })
973            .map_err(|e| BetfairStreamError::ConnectionFailed(e.to_string()))?;
974        Ok(())
975    }
976
977    /// Requests a fresh image of every subscribed market.
978    ///
979    /// Reissues the retained market subscription under a new ID and without clocks, so Betfair
980    /// replaces it and sends a new `SUB_IMAGE`. Change messages from the replaced subscription are
981    /// discarded from this call onward. Opens `gate` once the write is queued, as
982    /// [`Self::write_market_subscription`] does.
983    ///
984    /// # Errors
985    ///
986    /// Returns an error if the client is closed, no market subscription is retained, or the
987    /// subscription cannot be queued.
988    pub(crate) fn request_market_image(
989        &self,
990        gate: &SnapshotGate,
991    ) -> Result<(), BetfairStreamError> {
992        if self.closed.load(Ordering::SeqCst) || self.socket.is_closed() {
993            return Err(BetfairStreamError::Disconnected(
994                "stream client is closed".to_string(),
995            ));
996        }
997
998        let _state = lock_stream_state(&self.market_state_lock);
999        reissue_market_subscription(
1000            &self.request_id,
1001            &self.market_active_sub_id,
1002            &self.lifecycle,
1003            &self.market_sub_tx,
1004            &self.market_clk_tx,
1005            &self.market_initial_clk_tx,
1006            Some(&self.socket.writer_tx),
1007        )?;
1008        gate.open();
1009        Ok(())
1010    }
1011
1012    fn update_dead_peer_timeout(&self, heartbeat_ms: u64) {
1013        if !self.dead_peer_timeout_override {
1014            self.dead_peer_timeout_ms
1015                .store(heartbeat_ms.saturating_mul(2), Ordering::Release);
1016        }
1017    }
1018
1019    /// Returns `true` if the connection is active.
1020    #[must_use]
1021    pub fn is_active(&self) -> bool {
1022        self.socket.is_active()
1023    }
1024
1025    #[must_use]
1026    pub fn authentication_state(&self) -> StreamLifecycleState {
1027        self.lifecycle.authenticated.get()
1028    }
1029
1030    #[must_use]
1031    pub fn market_subscription_state(&self) -> StreamLifecycleState {
1032        self.lifecycle.market.get()
1033    }
1034
1035    #[must_use]
1036    pub fn order_subscription_state(&self) -> StreamLifecycleState {
1037        self.lifecycle.order.get()
1038    }
1039
1040    /// Waits for the authentication lifecycle component to equal `expected`.
1041    ///
1042    /// Returns immediately if the component is already in the exact expected state;
1043    /// otherwise waits for a later transition. A transient expected state that is
1044    /// replaced before this task observes it can be missed because transitions are not
1045    /// recorded as history. This method has no internal timeout; callers wanting a
1046    /// bound should wrap it in [`tokio::time::timeout`].
1047    pub async fn wait_for_authentication_state(&self, expected: StreamLifecycleState) {
1048        wait_for_lifecycle_state(&self.lifecycle.authenticated, expected).await;
1049    }
1050
1051    /// Waits for the market subscription lifecycle component to equal `expected`.
1052    ///
1053    /// Returns immediately if the component is already in the exact expected state;
1054    /// otherwise waits for a later transition. A transient expected state that is
1055    /// replaced before this task observes it can be missed because transitions are not
1056    /// recorded as history. This method has no internal timeout; callers wanting a
1057    /// bound should wrap it in [`tokio::time::timeout`].
1058    pub async fn wait_for_market_subscription_state(&self, expected: StreamLifecycleState) {
1059        wait_for_lifecycle_state(&self.lifecycle.market, expected).await;
1060    }
1061
1062    /// Waits for the order subscription lifecycle component to equal `expected`.
1063    ///
1064    /// Returns immediately if the component is already in the exact expected state;
1065    /// otherwise waits for a later transition. A transient expected state that is
1066    /// replaced before this task observes it can be missed because transitions are not
1067    /// recorded as history. This method has no internal timeout; callers wanting a
1068    /// bound should wrap it in [`tokio::time::timeout`].
1069    pub async fn wait_for_order_subscription_state(&self, expected: StreamLifecycleState) {
1070        wait_for_lifecycle_state(&self.lifecycle.order, expected).await;
1071    }
1072
1073    #[must_use]
1074    pub fn is_authenticated(&self) -> bool {
1075        self.socket.is_active() && self.authentication_state() == StreamLifecycleState::Active
1076    }
1077
1078    #[must_use]
1079    pub fn is_market_ready(&self) -> bool {
1080        self.is_authenticated() && self.market_subscription_state() == StreamLifecycleState::Active
1081    }
1082
1083    #[must_use]
1084    pub fn is_order_ready(&self) -> bool {
1085        self.is_authenticated() && self.order_subscription_state() == StreamLifecycleState::Active
1086    }
1087
1088    /// Pushes refreshed auth bytes so the next reconnection or subscription uses
1089    /// the current session token instead of the one from initial connect.
1090    pub fn update_auth(&self, app_key: &str, session_token: impl Into<SecretString>) {
1091        update_auth_state(
1092            &self.auth_tx,
1093            &Authentication::with_id(app_key, session_token, AUTH_REQUEST_ID),
1094        );
1095    }
1096
1097    /// Requests replacement of the active stream transport.
1098    ///
1099    /// Returns `true` only when this call starts a reconnect. Duplicate requests and requests after
1100    /// close return `false`.
1101    #[must_use]
1102    pub fn request_reconnect(&self) -> bool {
1103        self.request_reconnect_outcome() == ReconnectRequestOutcome::Accepted
1104    }
1105
1106    pub(crate) fn request_reconnect_outcome(&self) -> ReconnectRequestOutcome {
1107        if self.closed.load(Ordering::SeqCst) {
1108            return ReconnectRequestOutcome::Closed;
1109        }
1110        self.reconnect_auth
1111            .request(self.auth_tx.borrow().generation)
1112    }
1113
1114    pub(crate) fn begin_shutdown(&self) {
1115        self.closed.store(true, Ordering::SeqCst);
1116        self.dead_peer_enabled.store(false, Ordering::Release);
1117        self.dead_peer_task.abort();
1118        self.socket.begin_shutdown();
1119    }
1120
1121    /// Closes the stream connection.
1122    ///
1123    /// # Errors
1124    ///
1125    /// Returns an error if the dead-peer task fails or does not stop after abort.
1126    pub async fn close(&self) -> Result<(), BetfairStreamError> {
1127        self.begin_shutdown();
1128        self.socket.close().await;
1129
1130        if let Some(outcome) = self
1131            .dead_peer_task
1132            .finish(Duration::from_secs(1), Duration::from_secs(2))
1133            .await
1134        {
1135            match outcome {
1136                TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => Ok(()),
1137                TaskJoinOutcome::Failed(e) => Err(BetfairStreamError::Disconnected(format!(
1138                    "dead-peer task failed: {e}"
1139                ))),
1140                TaskJoinOutcome::Incomplete => Err(BetfairStreamError::Timeout(
1141                    "dead-peer task did not stop after abort".to_string(),
1142                )),
1143            }
1144        } else {
1145            Ok(())
1146        }
1147    }
1148}
1149
1150impl Drop for BetfairStreamClient {
1151    fn drop(&mut self) {
1152        self.dead_peer_enabled.store(false, Ordering::Release);
1153
1154        self.dead_peer_task.abort();
1155    }
1156}
1157
1158fn lock_stream_state(lock: &Mutex<()>) -> MutexGuard<'_, ()> {
1159    lock.lock()
1160}
1161
1162/// Betfair race stream client for Total Performance Data (TPD).
1163///
1164/// Connects to `sports-data-stream-api.betfair.com` and subscribes to Race Change
1165/// Messages (RCM) with live GPS tracking data. Simpler than [`BetfairStreamClient`]:
1166/// no clk-based delta resumption, just auth + raceSubscription on (re)connect.
1167#[derive(Debug)]
1168pub struct BetfairRaceStreamClient {
1169    socket: SocketClient,
1170    auth_tx: watch::Sender<StreamAuth>,
1171    reconnect_auth: Arc<ReconnectAuthState>,
1172    closed: AtomicBool,
1173}
1174
1175impl BetfairRaceStreamClient {
1176    /// Connects to the Betfair race stream and subscribes.
1177    ///
1178    /// The `race_fatal_tx` channel receives a signal when the server returns a
1179    /// fatal status error (e.g. NOT_AUTHORIZED, no TPD entitlement). The caller
1180    /// should monitor this channel and close the client when it fires.
1181    ///
1182    /// # Errors
1183    ///
1184    /// Returns an error if the connection fails or the initial send fails.
1185    pub async fn connect(
1186        credential: &BetfairCredential,
1187        session_token: impl Into<SecretString>,
1188        handler: TcpMessageHandler,
1189        config: BetfairStreamConfig,
1190        race_fatal_tx: tokio::sync::mpsc::UnboundedSender<()>,
1191    ) -> Result<Self, BetfairStreamError> {
1192        let subscription = AuxiliaryStreamSubscription::race(race_fatal_tx)?;
1193        Self::connect_with_subscription(
1194            credential,
1195            session_token.into(),
1196            StreamHandler::Raw(handler),
1197            config,
1198            subscription,
1199            None,
1200        )
1201        .await
1202    }
1203
1204    pub(crate) async fn connect_decoded(
1205        credential: &BetfairCredential,
1206        session_token: SecretString,
1207        handler: StreamMessageHandler,
1208        config: BetfairStreamConfig,
1209        race_fatal_tx: tokio::sync::mpsc::UnboundedSender<()>,
1210        state_sink: Option<SocketStateSink>,
1211    ) -> Result<Self, BetfairStreamError> {
1212        let subscription = AuxiliaryStreamSubscription::race(race_fatal_tx)?;
1213        Self::connect_with_subscription(
1214            credential,
1215            session_token,
1216            StreamHandler::Decoded(handler),
1217            config,
1218            subscription,
1219            state_sink,
1220        )
1221        .await
1222    }
1223
1224    /// Connects to the Betfair sports data stream and subscribes to cricket.
1225    ///
1226    /// The `cricket_fatal_tx` channel receives a signal when the server returns
1227    /// a fatal status error.
1228    ///
1229    /// # Errors
1230    ///
1231    /// Returns an error if the connection fails or the initial send fails.
1232    pub async fn connect_cricket(
1233        credential: &BetfairCredential,
1234        session_token: impl Into<SecretString>,
1235        handler: TcpMessageHandler,
1236        config: BetfairStreamConfig,
1237        cricket_fatal_tx: tokio::sync::mpsc::UnboundedSender<()>,
1238    ) -> Result<Self, BetfairStreamError> {
1239        let subscription = AuxiliaryStreamSubscription::cricket(cricket_fatal_tx)?;
1240        Self::connect_with_subscription(
1241            credential,
1242            session_token.into(),
1243            StreamHandler::Raw(handler),
1244            config,
1245            subscription,
1246            None,
1247        )
1248        .await
1249    }
1250
1251    pub(crate) async fn connect_cricket_decoded(
1252        credential: &BetfairCredential,
1253        session_token: SecretString,
1254        handler: StreamMessageHandler,
1255        config: BetfairStreamConfig,
1256        cricket_fatal_tx: tokio::sync::mpsc::UnboundedSender<()>,
1257        state_sink: Option<SocketStateSink>,
1258    ) -> Result<Self, BetfairStreamError> {
1259        let subscription = AuxiliaryStreamSubscription::cricket(cricket_fatal_tx)?;
1260        Self::connect_with_subscription(
1261            credential,
1262            session_token,
1263            StreamHandler::Decoded(handler),
1264            config,
1265            subscription,
1266            state_sink,
1267        )
1268        .await
1269    }
1270
1271    async fn connect_with_subscription(
1272        credential: &BetfairCredential,
1273        session_token: SecretString,
1274        handler: StreamHandler,
1275        config: BetfairStreamConfig,
1276        subscription: AuxiliaryStreamSubscription,
1277        state_sink: Option<SocketStateSink>,
1278    ) -> Result<Self, BetfairStreamError> {
1279        let AuxiliaryStreamSubscription {
1280            bytes: sub_bytes,
1281            label,
1282            fatal_hint,
1283            fatal_tx,
1284        } = subscription;
1285
1286        let auth = Zeroizing::new(Authentication::new(credential.app_key(), session_token));
1287        let auth_bytes = Zeroizing::new(serde_json::to_vec(&*auth)?);
1288        let reconnect_auth = Arc::new(ReconnectAuthState::default());
1289        let (auth_tx, auth_rx) = watch::channel(StreamAuth {
1290            generation: 0,
1291            bytes: auth_bytes.clone(),
1292        });
1293
1294        let mode = if config.use_tls {
1295            Mode::Tls
1296        } else {
1297            Mode::Plain
1298        };
1299
1300        let reconnect_auth_h = Arc::clone(&reconnect_auth);
1301        let message_handler: TcpMessageHandler = Arc::new(move |data: &[u8]| {
1302            let Some(msg) = handler.decode(data) else {
1303                return;
1304            };
1305
1306            if let StreamMessage::Status(status) = &msg {
1307                if let Some(ref code) = status.error_code
1308                    && code.is_race_stream_fatal()
1309                {
1310                    log::error!(
1311                        "Betfair {label} stream fatal error: {:?} - {:?} ({fatal_hint})",
1312                        status.error_code,
1313                        status.error_message,
1314                    );
1315                    let _ = fatal_tx.send(());
1316                    return;
1317                }
1318
1319                if status.connection_closed {
1320                    log::warn!(
1321                        "Betfair {label} stream closed: {:?} - {:?}",
1322                        status.error_code,
1323                        status.error_message,
1324                    );
1325                } else if status.error_code.is_some() {
1326                    log::warn!(
1327                        "Betfair {label} stream status: {:?} - {:?}",
1328                        status.error_code,
1329                        status.error_message,
1330                    );
1331                }
1332            }
1333
1334            if matches!(msg, StreamMessage::Connection(_)) {
1335                reconnect_auth_h.request_pending();
1336            }
1337
1338            handler.handle(data, msg);
1339        });
1340
1341        let auth_reconnect = auth_rx;
1342        let reconnect_auth_replay = Arc::clone(&reconnect_auth);
1343        let sub_reconnect = sub_bytes.clone();
1344        let reconnect_replay: SocketReconnectReplay = Arc::new(move || {
1345            let auth = auth_reconnect.borrow().clone();
1346            reconnect_auth_replay.record_replay(auth.generation);
1347            let mut combined = Zeroizing::new(Vec::with_capacity(
1348                auth.bytes.len() + 2 + sub_reconnect.len(),
1349            ));
1350            combined.extend_from_slice(&auth.bytes);
1351            combined.extend_from_slice(b"\r\n");
1352            combined.extend_from_slice(&sub_reconnect);
1353            vec![Bytes::copy_from_slice(&combined)]
1354        });
1355
1356        let url = format!("{}:{}", config.host, config.port);
1357        let socket_config = SocketConfig {
1358            url,
1359            mode,
1360            suffix: b"\r\n".to_vec(),
1361            message_handler: Some(message_handler),
1362            heartbeat: outbound_heartbeat(config.heartbeat_secs),
1363            connect_timeout_ms: None,
1364            reconnect_delay_initial_ms: Some(config.reconnect_delay_initial_ms),
1365            reconnect_delay_max_ms: Some(config.reconnect_delay_max_ms),
1366            reconnect_backoff_factor: None,
1367            reconnect_jitter_ms: None,
1368            connection_max_retries: None,
1369            reconnect_max_attempts: None,
1370            heartbeat_timeout_secs: heartbeat_timeout(
1371                HeartbeatTimeoutSource::Outbound,
1372                config.heartbeat_secs,
1373                config.heartbeat_timeout_secs,
1374            ),
1375            writer_capacity: None,
1376            certs_dir: None,
1377        };
1378
1379        let socket = SocketClient::builder()
1380            .config(socket_config)
1381            .maybe_state_sink(state_sink)
1382            .reconnect_replay(reconnect_replay)
1383            .connect()
1384            .await
1385            .map_err(|e| BetfairStreamError::ConnectionFailed(e.to_string()))?;
1386        reconnect_auth.set_handle(socket.reconnect_handle());
1387
1388        let mut combined =
1389            Zeroizing::new(Vec::with_capacity(auth_bytes.len() + 2 + sub_bytes.len()));
1390        combined.extend_from_slice(&auth_bytes);
1391        combined.extend_from_slice(b"\r\n");
1392        combined.extend_from_slice(&sub_bytes);
1393        socket
1394            .send_bytes(combined.as_slice().to_vec())
1395            .await
1396            .map_err(|e| BetfairStreamError::ConnectionFailed(e.to_string()))?;
1397
1398        Ok(Self {
1399            socket,
1400            auth_tx,
1401            reconnect_auth,
1402            closed: AtomicBool::new(false),
1403        })
1404    }
1405
1406    /// Returns `true` if the connection is active.
1407    #[must_use]
1408    pub fn is_active(&self) -> bool {
1409        self.socket.is_active()
1410    }
1411
1412    /// Pushes refreshed auth bytes so the next reconnection uses
1413    /// the current session token instead of the one from initial connect.
1414    pub fn update_auth(&self, app_key: &str, session_token: impl Into<SecretString>) {
1415        update_auth_state(&self.auth_tx, &Authentication::new(app_key, session_token));
1416    }
1417
1418    /// Requests replacement of the active stream transport.
1419    ///
1420    /// Returns `true` only when this call starts a reconnect. Duplicate requests and requests after
1421    /// close return `false`.
1422    #[must_use]
1423    pub fn request_reconnect(&self) -> bool {
1424        self.request_reconnect_outcome() == ReconnectRequestOutcome::Accepted
1425    }
1426
1427    /// Requests transport replacement and returns the exact controller outcome.
1428    pub(crate) fn request_reconnect_outcome(&self) -> ReconnectRequestOutcome {
1429        if self.closed.load(Ordering::SeqCst) {
1430            return ReconnectRequestOutcome::Closed;
1431        }
1432        self.reconnect_auth
1433            .request(self.auth_tx.borrow().generation)
1434    }
1435
1436    pub(crate) fn begin_shutdown(&self) {
1437        self.closed.store(true, Ordering::SeqCst);
1438        self.socket.begin_shutdown();
1439    }
1440
1441    /// Closes the race stream connection.
1442    pub async fn close(&self) {
1443        self.begin_shutdown();
1444        self.socket.close().await;
1445    }
1446}
1447
1448fn update_auth_state(auth_tx: &watch::Sender<StreamAuth>, auth: &Authentication) {
1449    let Ok(bytes) = serde_json::to_vec(auth) else {
1450        return;
1451    };
1452    let bytes = Zeroizing::new(bytes);
1453    auth_tx.send_if_modified(|current| {
1454        if current.bytes == bytes {
1455            return false;
1456        }
1457        *current = StreamAuth {
1458            generation: current.generation.wrapping_add(1),
1459            bytes,
1460        };
1461        true
1462    });
1463}
1464
1465enum StreamHandler {
1466    Raw(TcpMessageHandler),
1467    Decoded(StreamMessageHandler),
1468}
1469
1470impl StreamHandler {
1471    fn decode(&self, data: &[u8]) -> Option<StreamMessage> {
1472        match stream_decode(data) {
1473            Ok(message) => Some(message),
1474            Err(e) => {
1475                match self {
1476                    Self::Raw(handler) => handler(data),
1477                    Self::Decoded(_) => log::warn!("Failed to decode stream message: {e}"),
1478                }
1479                None
1480            }
1481        }
1482    }
1483
1484    fn handle(&self, data: &[u8], message: StreamMessage) {
1485        match self {
1486            Self::Raw(handler) => handler(data),
1487            Self::Decoded(handler) => handler(message),
1488        }
1489    }
1490}
1491
1492const fn change_complete(segment_type: Option<SegmentType>) -> bool {
1493    matches!(segment_type, None | Some(SegmentType::SegEnd))
1494}
1495
1496fn reissue_market_subscription(
1497    request_id: &AtomicU64,
1498    active_id: &AtomicU64,
1499    lifecycle: &ProtocolLifecycle,
1500    sub_tx: &watch::Sender<Option<MarketSubscription>>,
1501    clk_tx: &watch::Sender<Option<String>>,
1502    initial_clk_tx: &watch::Sender<Option<String>>,
1503    writer_tx: Option<&WriterSender<WriterCommand>>,
1504) -> Result<(), BetfairStreamError> {
1505    let Some(writer_tx) = writer_tx else {
1506        return Err(BetfairStreamError::Disconnected(
1507            "writer is not initialized".to_string(),
1508        ));
1509    };
1510    let Some(mut sub) = sub_tx.borrow().clone() else {
1511        return Err(BetfairStreamError::ProtocolError(
1512            "no retained market subscription".to_string(),
1513        ));
1514    };
1515    let id = request_id.fetch_add(1, Ordering::Relaxed);
1516    sub.id = Some(id);
1517    sub.clk = None;
1518    sub.initial_clk = None;
1519    let data = Bytes::from(serde_json::to_vec(&sub)?);
1520
1521    active_id.store(id, Ordering::SeqCst);
1522    lifecycle.market.set(StreamLifecycleState::Pending);
1523    lifecycle.market_was_current.store(false, Ordering::Release);
1524    lifecycle
1525        .market_requires_image
1526        .store(true, Ordering::Release);
1527    lifecycle
1528        .market_image_tainted
1529        .store(false, Ordering::Release);
1530    let _ = clk_tx.send(None);
1531    let _ = initial_clk_tx.send(None);
1532    let _ = sub_tx.send(Some(sub));
1533
1534    writer_tx
1535        .send(WriterCommand::SendOrReplay {
1536            key: MARKET_SUBSCRIPTION_REPLAY_KEY,
1537            data,
1538        })
1539        .map_err(|e| BetfairStreamError::ConnectionFailed(e.to_string()))
1540}
1541
1542fn reissue_order_subscription(
1543    request_id: &AtomicU64,
1544    active_id: &AtomicU64,
1545    lifecycle: &ProtocolLifecycle,
1546    sub_tx: &watch::Sender<Option<OrderSubscription>>,
1547    clk_tx: &watch::Sender<Option<String>>,
1548    initial_clk_tx: &watch::Sender<Option<String>>,
1549    writer_tx: Option<&WriterSender<WriterCommand>>,
1550) {
1551    let Some(writer_tx) = writer_tx else {
1552        log::error!("Cannot recover Betfair order stream before writer initialization");
1553        return;
1554    };
1555    let Some(mut sub) = sub_tx.borrow().clone() else {
1556        log::error!("Cannot recover Betfair order stream without a retained subscription");
1557        return;
1558    };
1559    let id = request_id.fetch_add(1, Ordering::Relaxed);
1560    sub.id = Some(id);
1561    sub.clk = None;
1562    sub.initial_clk = None;
1563    let data = match serde_json::to_vec(&sub) {
1564        Ok(data) => Bytes::from(data),
1565        Err(e) => {
1566            log::error!("Failed to serialize Betfair order recovery subscription: {e}");
1567            return;
1568        }
1569    };
1570
1571    active_id.store(id, Ordering::SeqCst);
1572    lifecycle.order.set(StreamLifecycleState::Pending);
1573    lifecycle.order_was_current.store(false, Ordering::Release);
1574    lifecycle
1575        .order_requires_image
1576        .store(true, Ordering::Release);
1577    lifecycle
1578        .order_image_tainted
1579        .store(false, Ordering::Release);
1580    let _ = clk_tx.send(None);
1581    let _ = initial_clk_tx.send(None);
1582    let _ = sub_tx.send(Some(sub));
1583
1584    if let Err(e) = writer_tx.send(WriterCommand::SendOrReplay {
1585        key: ORDER_SUBSCRIPTION_REPLAY_KEY,
1586        data,
1587    }) {
1588        log::error!("Failed to queue Betfair order recovery subscription: {e}");
1589    }
1590}
1591
1592fn update_stream_state(
1593    clk: &Option<String>,
1594    initial_clk: &Option<String>,
1595    heartbeat_ms: Option<u64>,
1596    clk_tx: &watch::Sender<Option<String>>,
1597    initial_clk_tx: &watch::Sender<Option<String>>,
1598    timeout_override: bool,
1599    dead_peer_timeout_ms: &AtomicU64,
1600) {
1601    if clk.is_some() {
1602        let _ = clk_tx.send(clk.clone());
1603    }
1604
1605    if initial_clk.is_some() {
1606        let _ = initial_clk_tx.send(initial_clk.clone());
1607    }
1608    update_negotiated_heartbeat(heartbeat_ms, timeout_override, dead_peer_timeout_ms);
1609}
1610
1611fn update_negotiated_heartbeat(
1612    interval_ms: Option<u64>,
1613    timeout_override: bool,
1614    dead_peer_timeout_ms: &AtomicU64,
1615) {
1616    if !timeout_override
1617        && let Some(interval_ms) = interval_ms
1618        && (BETFAIR_STREAM_HEARTBEAT_MIN_MS..=BETFAIR_STREAM_HEARTBEAT_MAX_MS)
1619            .contains(&interval_ms)
1620    {
1621        dead_peer_timeout_ms.store(interval_ms.saturating_mul(2), Ordering::Release);
1622    }
1623}
1624
1625fn outbound_heartbeat(interval_secs: Option<u64>) -> Option<SocketHeartbeat> {
1626    interval_secs.map(|interval_secs| SocketHeartbeat {
1627        interval_secs,
1628        payload: b"{\"op\":\"heartbeat\"}".to_vec(),
1629    })
1630}
1631
1632fn heartbeat_timeout(
1633    source: HeartbeatTimeoutSource,
1634    interval_secs: Option<u64>,
1635    timeout_secs: Option<u64>,
1636) -> Option<u64> {
1637    match source {
1638        HeartbeatTimeoutSource::Outbound => {
1639            interval_secs.map(|interval| timeout_secs.unwrap_or(interval.saturating_mul(2)))
1640        }
1641        HeartbeatTimeoutSource::Server => None,
1642    }
1643}
1644
1645fn validate_subscription_heartbeat(heartbeat_ms: u64) -> Result<(), BetfairStreamError> {
1646    if !(BETFAIR_STREAM_HEARTBEAT_MIN_MS..=BETFAIR_STREAM_HEARTBEAT_MAX_MS).contains(&heartbeat_ms)
1647    {
1648        return Err(BetfairStreamError::ProtocolError(format!(
1649            "subscription heartbeat must be in range [{BETFAIR_STREAM_HEARTBEAT_MIN_MS}, \
1650             {BETFAIR_STREAM_HEARTBEAT_MAX_MS}] ms, was {heartbeat_ms} ms",
1651        )));
1652    }
1653
1654    Ok(())
1655}
1656
1657struct AuxiliaryStreamSubscription {
1658    bytes: Bytes,
1659    label: &'static str,
1660    fatal_hint: &'static str,
1661    fatal_tx: tokio::sync::mpsc::UnboundedSender<()>,
1662}
1663
1664impl AuxiliaryStreamSubscription {
1665    fn race(fatal_tx: tokio::sync::mpsc::UnboundedSender<()>) -> Result<Self, serde_json::Error> {
1666        Ok(Self {
1667            bytes: Bytes::from(serde_json::to_vec(&RaceSubscription::new(1))?),
1668            label: "race",
1669            fatal_hint: "check TPD entitlement on your Betfair app key",
1670            fatal_tx,
1671        })
1672    }
1673
1674    fn cricket(
1675        fatal_tx: tokio::sync::mpsc::UnboundedSender<()>,
1676    ) -> Result<Self, serde_json::Error> {
1677        Ok(Self {
1678            bytes: Bytes::from(serde_json::to_vec(&CricketSubscription::new(1))?),
1679            label: "cricket",
1680            fatal_hint: "check cricket data entitlement on your Betfair app key",
1681            fatal_tx,
1682        })
1683    }
1684}
1685
1686#[derive(Clone)]
1687struct StreamAuth {
1688    generation: u64,
1689    bytes: Zeroizing<Vec<u8>>,
1690}
1691
1692impl Debug for StreamAuth {
1693    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1694        f.debug_struct(stringify!(StreamAuth))
1695            .field("generation", &self.generation)
1696            .field("bytes", &REDACTED)
1697            .finish()
1698    }
1699}
1700
1701#[derive(Debug, Default)]
1702struct ReconnectAuthState {
1703    replay_generation: AtomicU64,
1704    pending_generation: AtomicU64,
1705    reconnect_handle: OnceLock<SocketReconnectHandle>,
1706}
1707
1708impl ReconnectAuthState {
1709    fn set_handle(&self, handle: SocketReconnectHandle) {
1710        let result = self.reconnect_handle.set(handle);
1711        debug_assert!(result.is_ok(), "reconnect handle is set only once");
1712    }
1713
1714    fn record_replay(&self, generation: u64) {
1715        self.replay_generation.store(generation, Ordering::SeqCst);
1716        let _ = self
1717            .pending_generation
1718            .try_update(Ordering::SeqCst, Ordering::SeqCst, |pending| {
1719                (pending != 0 && pending <= generation).then_some(0)
1720            });
1721    }
1722
1723    fn request(&self, auth_generation: u64) -> ReconnectRequestOutcome {
1724        let Some(handle) = self.reconnect_handle.get() else {
1725            return ReconnectRequestOutcome::Unsupported;
1726        };
1727
1728        let outcome = handle.request_reconnect();
1729        if outcome == ReconnectRequestOutcome::AlreadyReconnecting
1730            && auth_generation > self.replay_generation.load(Ordering::SeqCst)
1731        {
1732            self.pending_generation
1733                .fetch_max(auth_generation, Ordering::SeqCst);
1734        }
1735
1736        outcome
1737    }
1738
1739    fn request_pending(&self) {
1740        let pending_generation = self.pending_generation.load(Ordering::SeqCst);
1741        if pending_generation == 0
1742            || pending_generation <= self.replay_generation.load(Ordering::SeqCst)
1743        {
1744            return;
1745        }
1746
1747        let Some(handle) = self.reconnect_handle.get() else {
1748            return;
1749        };
1750
1751        match handle.request_reconnect() {
1752            ReconnectRequestOutcome::Accepted => {
1753                let _ = self.pending_generation.compare_exchange(
1754                    pending_generation,
1755                    0,
1756                    Ordering::SeqCst,
1757                    Ordering::SeqCst,
1758                );
1759            }
1760            ReconnectRequestOutcome::AlreadyReconnecting => {}
1761            ReconnectRequestOutcome::Disconnected
1762            | ReconnectRequestOutcome::Closed
1763            | ReconnectRequestOutcome::Unsupported => {
1764                self.pending_generation.store(0, Ordering::SeqCst);
1765            }
1766        }
1767    }
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772    use nautilus_network::SocketState;
1773    use rstest::rstest;
1774
1775    use super::*;
1776    use crate::stream::messages::{
1777        Authentication, CricketSubscription, MarketDataFilter, RaceSubscription, StreamMarketFilter,
1778    };
1779
1780    #[rstest]
1781    #[case::no_source(HeartbeatTimeoutSource::Outbound, None, None, None)]
1782    #[case::outbound_override(HeartbeatTimeoutSource::Outbound, Some(5), Some(60), Some(60))]
1783    #[case::outbound_derived(HeartbeatTimeoutSource::Outbound, Some(5), None, Some(10))]
1784    #[case::server(HeartbeatTimeoutSource::Server, None, None, None)]
1785    fn test_heartbeat_timeout(
1786        #[case] source: HeartbeatTimeoutSource,
1787        #[case] interval_secs: Option<u64>,
1788        #[case] timeout_secs: Option<u64>,
1789        #[case] expected: Option<u64>,
1790    ) {
1791        assert_eq!(
1792            heartbeat_timeout(source, interval_secs, timeout_secs),
1793            expected
1794        );
1795    }
1796
1797    #[rstest]
1798    fn test_reissue_before_writer_initialization_stays_fail_closed() {
1799        let request_id = AtomicU64::new(17);
1800        let market_active_id = AtomicU64::new(11);
1801        let order_active_id = AtomicU64::new(13);
1802        let lifecycle = ProtocolLifecycle::default();
1803        lifecycle.market.set(StreamLifecycleState::Degraded);
1804        lifecycle.order.set(StreamLifecycleState::Degraded);
1805        let (market_sub_tx, _market_sub_rx) = watch::channel(None::<MarketSubscription>);
1806        let (order_sub_tx, _order_sub_rx) = watch::channel(None::<OrderSubscription>);
1807        let (market_clk_tx, _market_clk_rx) = watch::channel(None::<String>);
1808        let (market_initial_clk_tx, _market_initial_clk_rx) = watch::channel(None::<String>);
1809        let (order_clk_tx, _order_clk_rx) = watch::channel(None::<String>);
1810        let (order_initial_clk_tx, _order_initial_clk_rx) = watch::channel(None::<String>);
1811
1812        let market_result = reissue_market_subscription(
1813            &request_id,
1814            &market_active_id,
1815            &lifecycle,
1816            &market_sub_tx,
1817            &market_clk_tx,
1818            &market_initial_clk_tx,
1819            None,
1820        );
1821        reissue_order_subscription(
1822            &request_id,
1823            &order_active_id,
1824            &lifecycle,
1825            &order_sub_tx,
1826            &order_clk_tx,
1827            &order_initial_clk_tx,
1828            None,
1829        );
1830
1831        assert!(matches!(
1832            market_result,
1833            Err(BetfairStreamError::Disconnected(_))
1834        ));
1835        assert_eq!(
1836            (
1837                request_id.load(Ordering::Acquire),
1838                market_active_id.load(Ordering::Acquire),
1839                order_active_id.load(Ordering::Acquire),
1840                lifecycle.market.get(),
1841                lifecycle.order.get(),
1842            ),
1843            (
1844                17,
1845                11,
1846                13,
1847                StreamLifecycleState::Degraded,
1848                StreamLifecycleState::Degraded,
1849            ),
1850        );
1851    }
1852
1853    #[rstest]
1854    fn test_invalid_clock_status_resets_clocks() {
1855        let (market_clk_tx, market_clk_rx) = watch::channel(Some("old-market-clk".to_string()));
1856        let (market_initial_clk_tx, market_initial_clk_rx) =
1857            watch::channel(Some("old-market-iclk".to_string()));
1858        let (order_clk_tx, order_clk_rx) = watch::channel(Some("old-order-clk".to_string()));
1859        let (order_initial_clk_tx, order_initial_clk_rx) =
1860            watch::channel(Some("old-order-iclk".to_string()));
1861
1862        let handler: TcpMessageHandler = Arc::new(move |data: &[u8]| {
1863            if let Ok(msg) = stream_decode(data)
1864                && let StreamMessage::Status(status) = &msg
1865                && status.error_code == Some(StatusErrorCode::InvalidClock)
1866            {
1867                let _ = market_clk_tx.send(None);
1868                let _ = market_initial_clk_tx.send(None);
1869                let _ = order_clk_tx.send(None);
1870                let _ = order_initial_clk_tx.send(None);
1871            }
1872        });
1873
1874        handler(
1875            br#"{"op":"status","statusCode":"503","errorCode":"INVALID_CLOCK","connectionClosed":true}"#,
1876        );
1877
1878        assert!(
1879            market_clk_rx.borrow().is_none(),
1880            "market clk must be cleared"
1881        );
1882        assert!(
1883            market_initial_clk_rx.borrow().is_none(),
1884            "market initialClk must be cleared"
1885        );
1886        assert!(order_clk_rx.borrow().is_none(), "order clk must be cleared");
1887        assert!(
1888            order_initial_clk_rx.borrow().is_none(),
1889            "order initialClk must be cleared"
1890        );
1891    }
1892
1893    #[rstest]
1894    fn test_auth_message_serialization() {
1895        let auth = Authentication::new("my-app-key".to_string(), "my-session".to_string());
1896        let json = serde_json::to_string(&auth).unwrap();
1897        assert!(json.contains("\"op\":\"authentication\""));
1898        assert!(json.contains("\"appKey\":\"my-app-key\""));
1899        assert!(json.contains("\"session\":\"my-session\""));
1900    }
1901
1902    #[rstest]
1903    #[case::exchange(true)]
1904    #[case::auxiliary(false)]
1905    fn test_update_auth_state_changes_once_per_distinct_payload(#[case] with_id: bool) {
1906        let make_auth = |session: &str| {
1907            if with_id {
1908                Authentication::with_id(
1909                    "test-app-key".to_string(),
1910                    session.to_string(),
1911                    AUTH_REQUEST_ID,
1912                )
1913            } else {
1914                Authentication::new("test-app-key".to_string(), session.to_string())
1915            }
1916        };
1917        let initial = make_auth("initial");
1918        let initial_bytes = serde_json::to_vec(&initial).unwrap();
1919        let (auth_tx, auth_rx) = watch::channel(StreamAuth {
1920            generation: 7,
1921            bytes: Zeroizing::new(initial_bytes.clone()),
1922        });
1923
1924        update_auth_state(&auth_tx, &initial);
1925        let debug = format!("{:?}", auth_rx.borrow());
1926        assert_eq!(auth_rx.borrow().generation, 7);
1927        assert_eq!(auth_rx.borrow().bytes.as_slice(), initial_bytes);
1928        assert!(debug.contains(REDACTED));
1929        assert!(!debug.contains("initial"));
1930
1931        let replacement = make_auth("replacement");
1932        let replacement_bytes = serde_json::to_vec(&replacement).unwrap();
1933        update_auth_state(&auth_tx, &replacement);
1934        assert_eq!(auth_rx.borrow().generation, 8);
1935        assert_eq!(auth_rx.borrow().bytes.as_slice(), replacement_bytes);
1936
1937        update_auth_state(&auth_tx, &replacement);
1938        assert_eq!(auth_rx.borrow().generation, 8);
1939    }
1940
1941    #[rstest]
1942    fn test_clk_is_updated_from_mcm() {
1943        let (market_clk_tx, market_clk_rx) = watch::channel(None::<String>);
1944        let (market_initial_clk_tx, market_initial_clk_rx) = watch::channel(None::<String>);
1945        let (order_clk_tx, order_clk_rx) = watch::channel(None::<String>);
1946        let (order_initial_clk_tx, order_initial_clk_rx) = watch::channel(None::<String>);
1947        let market_active_sub_id = Arc::new(AtomicU64::new(5));
1948        let order_active_sub_id = Arc::new(AtomicU64::new(6));
1949
1950        let handler: TcpMessageHandler = Arc::new(move |data: &[u8]| {
1951            if let Ok(msg) = stream_decode(data) {
1952                match &msg {
1953                    StreamMessage::MarketChange(mcm) => {
1954                        let active = market_active_sub_id.load(Ordering::SeqCst);
1955                        if active > 0 && mcm.id.is_none_or(|id| id == active) {
1956                            if mcm.clk.is_some() {
1957                                let _ = market_clk_tx.send(mcm.clk.clone());
1958                            }
1959
1960                            if mcm.initial_clk.is_some() {
1961                                let _ = market_initial_clk_tx.send(mcm.initial_clk.clone());
1962                            }
1963                        }
1964                    }
1965                    StreamMessage::OrderChange(ocm) => {
1966                        let active = order_active_sub_id.load(Ordering::SeqCst);
1967                        if active > 0 && ocm.id.is_none_or(|id| id == active) {
1968                            if ocm.clk.is_some() {
1969                                let _ = order_clk_tx.send(ocm.clk.clone());
1970                            }
1971
1972                            if ocm.initial_clk.is_some() {
1973                                let _ = order_initial_clk_tx.send(ocm.initial_clk.clone());
1974                            }
1975                        }
1976                    }
1977                    _ => {}
1978                }
1979            }
1980        });
1981
1982        // MCM/OCM with matching subscription id update clocks.
1983        handler(br#"{"op":"mcm","id":5,"pt":1000,"initialClk":"mcm-iclk","clk":"mcm-clk"}"#);
1984        handler(br#"{"op":"ocm","id":6,"pt":2000,"initialClk":"ocm-iclk","clk":"ocm-clk"}"#);
1985
1986        assert_eq!(market_clk_rx.borrow().as_deref(), Some("mcm-clk"));
1987        assert_eq!(market_initial_clk_rx.borrow().as_deref(), Some("mcm-iclk"));
1988        assert_eq!(order_clk_rx.borrow().as_deref(), Some("ocm-clk"));
1989        assert_eq!(order_initial_clk_rx.borrow().as_deref(), Some("ocm-iclk"));
1990
1991        // MCM without an id (e.g. heartbeat) is accepted for the active subscription.
1992        handler(br#"{"op":"mcm","pt":1001,"clk":"hb-clk"}"#);
1993        assert_eq!(market_clk_rx.borrow().as_deref(), Some("hb-clk"));
1994
1995        // MCM from a stale subscription (explicit wrong id) must not overwrite stored clocks.
1996        handler(br#"{"op":"mcm","id":4,"pt":1002,"clk":"stale-clk"}"#);
1997        assert_eq!(market_clk_rx.borrow().as_deref(), Some("hb-clk"));
1998    }
1999
2000    #[rstest]
2001    fn test_reconnect_callback_sends_auth_and_subscription() {
2002        let (market_clk_tx, market_clk_rx) = watch::channel(Some("mcm-clk1".to_string()));
2003        let (market_initial_clk_tx, market_initial_clk_rx) =
2004            watch::channel(Some("mcm-iclk1".to_string()));
2005        let (order_clk_tx, order_clk_rx) = watch::channel(Some("ocm-clk1".to_string()));
2006        let (order_initial_clk_tx, order_initial_clk_rx) =
2007            watch::channel(Some("ocm-iclk1".to_string()));
2008        let (market_sub_tx, market_sub_rx) = watch::channel(None::<MarketSubscription>);
2009        let (order_sub_tx, order_sub_rx) = watch::channel(None::<OrderSubscription>);
2010
2011        let auth = Authentication::new("key".to_string(), "token".to_string());
2012        let auth_bytes = Bytes::from(serde_json::to_vec(&auth).unwrap());
2013
2014        let _ = market_sub_tx.send(Some(MarketSubscription {
2015            op: STREAM_OP_MARKET_SUBSCRIPTION.to_string(),
2016            id: Some(1),
2017            market_filter: StreamMarketFilter::default(),
2018            market_data_filter: MarketDataFilter::default(),
2019            clk: None,
2020            conflate_ms: None,
2021            heartbeat_ms: Some(BETFAIR_STREAM_HEARTBEAT_MAX_MS),
2022            initial_clk: None,
2023            segmentation_enabled: Some(true),
2024        }));
2025        let _ = order_sub_tx.send(Some(OrderSubscription {
2026            op: STREAM_OP_ORDER_SUBSCRIPTION.to_string(),
2027            id: Some(2),
2028            order_filter: None,
2029            clk: None,
2030            conflate_ms: None,
2031            heartbeat_ms: Some(BETFAIR_STREAM_HEARTBEAT_MAX_MS),
2032            initial_clk: None,
2033            segmentation_enabled: Some(true),
2034        }));
2035
2036        let auth_bytes_reconnect = auth_bytes;
2037        let reconnect_replay: SocketReconnectReplay = Arc::new(move || {
2038            let mut replay = Vec::with_capacity(3);
2039            let market_sub = market_sub_rx.borrow().clone();
2040            let order_sub = order_sub_rx.borrow().clone();
2041
2042            replay.push(auth_bytes_reconnect.clone());
2043
2044            if let Some(mut sub) = market_sub {
2045                sub.clk = market_clk_rx.borrow().clone();
2046                sub.initial_clk = market_initial_clk_rx.borrow().clone();
2047                if let Ok(sub_bytes) = serde_json::to_vec(&sub) {
2048                    replay.push(Bytes::from(sub_bytes));
2049                }
2050            }
2051
2052            if let Some(mut sub) = order_sub {
2053                sub.clk = order_clk_rx.borrow().clone();
2054                sub.initial_clk = order_initial_clk_rx.borrow().clone();
2055                if let Ok(sub_bytes) = serde_json::to_vec(&sub) {
2056                    replay.push(Bytes::from(sub_bytes));
2057                }
2058            }
2059
2060            replay
2061        });
2062
2063        drop(market_clk_tx);
2064        drop(market_initial_clk_tx);
2065        drop(order_clk_tx);
2066        drop(order_initial_clk_tx);
2067
2068        let replay = reconnect_replay();
2069        let [auth_bytes, market_bytes, order_bytes] = replay.as_slice() else {
2070            panic!("expected auth, market, and order replay messages");
2071        };
2072
2073        let auth_str = std::str::from_utf8(auth_bytes).unwrap();
2074        let market_str = std::str::from_utf8(market_bytes).unwrap();
2075        let order_str = std::str::from_utf8(order_bytes).unwrap();
2076
2077        assert!(auth_str.contains("\"op\":\"authentication\""));
2078        assert!(market_str.contains("\"op\":\"marketSubscription\""));
2079        // Both clk and initialClk must be injected into each resubscription
2080        assert!(market_str.contains("\"clk\":\"mcm-clk1\""));
2081        assert!(market_str.contains("\"initialClk\":\"mcm-iclk1\""));
2082
2083        assert!(order_str.contains("\"op\":\"orderSubscription\""));
2084        assert!(order_str.contains("\"clk\":\"ocm-clk1\""));
2085        assert!(order_str.contains("\"initialClk\":\"ocm-iclk1\""));
2086    }
2087
2088    #[rstest]
2089    #[tokio::test]
2090    async fn test_auth_update_after_replay_snapshot_requests_follow_up() {
2091        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
2092
2093        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2094        let port = listener.local_addr().unwrap().port();
2095        let server = tokio::spawn(async move {
2096            let (socket, _) = listener.accept().await.unwrap();
2097            let (read_half, _write_half) = socket.into_split();
2098            let mut reader = BufReader::new(read_half);
2099            let mut line = String::new();
2100            reader.read_line(&mut line).await.unwrap();
2101            line.clear();
2102            reader.read_line(&mut line).await.unwrap();
2103
2104            let (socket, _) = listener.accept().await.unwrap();
2105            let (read_half, mut write_half) = socket.into_split();
2106            let mut reader = BufReader::new(read_half);
2107            line.clear();
2108            reader.read_line(&mut line).await.unwrap();
2109            let auth: serde_json::Value = serde_json::from_str(&line).unwrap();
2110            assert_eq!(auth["session"], "replacement-1");
2111            line.clear();
2112            reader.read_line(&mut line).await.unwrap();
2113            write_half
2114                .write_all(b"{\"op\":\"connection\",\"connectionId\":\"replacement-1\"}\r\n")
2115                .await
2116                .unwrap();
2117
2118            let (socket, _) = listener.accept().await.unwrap();
2119            let (read_half, _write_half) = socket.into_split();
2120            let mut reader = BufReader::new(read_half);
2121            line.clear();
2122            reader.read_line(&mut line).await.unwrap();
2123            let auth: serde_json::Value = serde_json::from_str(&line).unwrap();
2124            assert_eq!(auth["session"], "replacement-2");
2125            line.clear();
2126            reader.read_line(&mut line).await.unwrap();
2127            let subscription: serde_json::Value = serde_json::from_str(&line).unwrap();
2128            assert_eq!(subscription["op"], "orderSubscription");
2129        });
2130
2131        let credential = BetfairCredential::new(
2132            "testuser".to_string(),
2133            "testpass".to_string(),
2134            "test-app-key".to_string(),
2135        );
2136        let config = BetfairStreamConfig {
2137            host: "127.0.0.1".to_string(),
2138            port,
2139            heartbeat_secs: None,
2140            heartbeat_timeout_secs: Some(60),
2141            reconnect_delay_initial_ms: 200,
2142            reconnect_delay_max_ms: 1_000,
2143            use_tls: false,
2144        };
2145        let client = BetfairStreamClient::connect(
2146            &credential,
2147            "initial".to_string(),
2148            Arc::new(|_| {}),
2149            config,
2150        )
2151        .await
2152        .unwrap();
2153        client.subscribe_orders(None, Some(5_000)).await.unwrap();
2154
2155        client.update_auth("test-app-key", "replacement-1".to_string());
2156        assert!(client.request_reconnect());
2157        tokio::time::timeout(std::time::Duration::from_secs(2), async {
2158            while client
2159                .reconnect_auth
2160                .replay_generation
2161                .load(Ordering::SeqCst)
2162                < 1
2163            {
2164                tokio::task::yield_now().await;
2165            }
2166        })
2167        .await
2168        .unwrap();
2169
2170        client.update_auth("test-app-key", "replacement-2".to_string());
2171        assert!(!client.request_reconnect());
2172        assert_eq!(
2173            client
2174                .reconnect_auth
2175                .pending_generation
2176                .load(Ordering::SeqCst),
2177            2,
2178        );
2179
2180        tokio::time::timeout(std::time::Duration::from_secs(5), server)
2181            .await
2182            .unwrap()
2183            .unwrap();
2184        client.close().await.expect("close stream");
2185    }
2186
2187    #[rstest]
2188    #[case::no_subscription(false, "Protocol error: no retained market subscription")]
2189    #[case::closed(true, "Disconnected: stream client is closed")]
2190    #[tokio::test]
2191    async fn test_request_market_image_fails_without_open_subscription(
2192        #[case] subscribe_then_close: bool,
2193        #[case] expected: &str,
2194    ) {
2195        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2196        let port = listener.local_addr().unwrap().port();
2197
2198        let server = tokio::spawn(async move {
2199            let (socket, _) = listener.accept().await.unwrap();
2200            let mut lines = tokio::io::AsyncBufReadExt::lines(tokio::io::BufReader::new(socket));
2201            while let Ok(Some(_)) = lines.next_line().await {}
2202        });
2203
2204        let credential = BetfairCredential::new(
2205            "testuser".to_string(),
2206            "testpass".to_string(),
2207            "test-app-key".to_string(),
2208        );
2209
2210        let config = BetfairStreamConfig {
2211            host: "127.0.0.1".to_string(),
2212            port,
2213            heartbeat_secs: None,
2214            heartbeat_timeout_secs: Some(60),
2215            reconnect_delay_initial_ms: 200,
2216            reconnect_delay_max_ms: 1_000,
2217            use_tls: false,
2218        };
2219
2220        let client = BetfairStreamClient::connect(
2221            &credential,
2222            "session".to_string(),
2223            Arc::new(|_| {}),
2224            config,
2225        )
2226        .await
2227        .unwrap();
2228
2229        if subscribe_then_close {
2230            client
2231                .subscribe_markets(Default::default(), Default::default(), None, None)
2232                .await
2233                .unwrap();
2234            client.close().await.expect("close stream");
2235        }
2236
2237        let result = client.request_market_image(&SnapshotGate::default());
2238
2239        assert_eq!(result.unwrap_err().to_string(), expected);
2240        client.close().await.expect("close stream");
2241        server.abort();
2242    }
2243
2244    #[rstest]
2245    fn test_race_subscription_serialization() {
2246        let sub = RaceSubscription::new(42);
2247        let json = serde_json::to_string(&sub).unwrap();
2248        assert!(json.contains("\"op\":\"raceSubscription\""));
2249        assert!(json.contains("\"id\":42"));
2250    }
2251
2252    #[rstest]
2253    fn test_cricket_subscription_serialization() {
2254        let sub = CricketSubscription::new(42);
2255        let json = serde_json::to_string(&sub).unwrap();
2256        assert!(json.contains("\"op\":\"cricketSubscription\""));
2257        assert!(json.contains("\"id\":42"));
2258    }
2259
2260    #[rstest]
2261    #[case::race(false, "raceSubscription")]
2262    #[case::cricket(true, "cricketSubscription")]
2263    #[tokio::test]
2264    async fn test_auxiliary_stream_state_and_controller_reconnect(
2265        #[case] cricket: bool,
2266        #[case] subscription_op: &'static str,
2267    ) {
2268        use std::time::Duration;
2269
2270        use parking_lot::Mutex;
2271        use tokio::io::{AsyncBufReadExt, BufReader};
2272
2273        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2274        let port = listener.local_addr().unwrap().port();
2275        let (initial_tx, initial_rx) = tokio::sync::oneshot::channel();
2276        let (replacement_tx, replacement_rx) = tokio::sync::oneshot::channel();
2277        let (done_tx, done_rx) = tokio::sync::oneshot::channel();
2278
2279        let server = tokio::spawn(async move {
2280            let (socket, _) = listener.accept().await.unwrap();
2281            let (read_half, initial_write_half) = socket.into_split();
2282            let mut initial_reader = BufReader::new(read_half);
2283            let mut auth = String::new();
2284            let mut subscription = String::new();
2285            initial_reader.read_line(&mut auth).await.unwrap();
2286            initial_reader.read_line(&mut subscription).await.unwrap();
2287            let auth: serde_json::Value = serde_json::from_str(&auth).unwrap();
2288            let subscription: serde_json::Value = serde_json::from_str(&subscription).unwrap();
2289            assert_eq!(auth["session"], "test-session");
2290            assert_eq!(subscription["op"], subscription_op);
2291            initial_tx.send(()).unwrap();
2292
2293            let (socket, _) = listener.accept().await.unwrap();
2294            let (read_half, replacement_write_half) = socket.into_split();
2295            let mut replacement_reader = BufReader::new(read_half);
2296            let mut replay_auth = String::new();
2297            let mut replay_subscription = String::new();
2298            replacement_reader
2299                .read_line(&mut replay_auth)
2300                .await
2301                .unwrap();
2302            replacement_reader
2303                .read_line(&mut replay_subscription)
2304                .await
2305                .unwrap();
2306            let replay_auth: serde_json::Value = serde_json::from_str(&replay_auth).unwrap();
2307            let replay_subscription: serde_json::Value =
2308                serde_json::from_str(&replay_subscription).unwrap();
2309            assert_eq!(replay_auth, auth);
2310            assert_eq!(replay_subscription, subscription);
2311            replacement_tx.send(()).unwrap();
2312
2313            let _initial_connection = (initial_reader, initial_write_half);
2314            let _replacement_connection = (replacement_reader, replacement_write_half);
2315            let _ = done_rx.await;
2316        });
2317
2318        let states = Arc::new(Mutex::new(Vec::new()));
2319        let states_sink = Arc::clone(&states);
2320        let state_sink = SocketStateSink::new(move |state| {
2321            states_sink.lock().push(state);
2322        });
2323        let credential = BetfairCredential::new(
2324            "testuser".to_string(),
2325            "testpass".to_string(),
2326            "test-app-key".to_string(),
2327        );
2328        let config = BetfairStreamConfig {
2329            host: "127.0.0.1".to_string(),
2330            port,
2331            heartbeat_secs: Some(5),
2332            heartbeat_timeout_secs: Some(60),
2333            reconnect_delay_initial_ms: 100,
2334            reconnect_delay_max_ms: 500,
2335            use_tls: false,
2336        };
2337        let (fatal_tx, _fatal_rx) = tokio::sync::mpsc::unbounded_channel();
2338        let client = if cricket {
2339            BetfairRaceStreamClient::connect_cricket_decoded(
2340                &credential,
2341                "test-session".into(),
2342                Arc::new(|_| {}),
2343                config,
2344                fatal_tx,
2345                Some(state_sink),
2346            )
2347            .await
2348            .unwrap()
2349        } else {
2350            BetfairRaceStreamClient::connect_decoded(
2351                &credential,
2352                "test-session".into(),
2353                Arc::new(|_| {}),
2354                config,
2355                fatal_tx,
2356                Some(state_sink),
2357            )
2358            .await
2359            .unwrap()
2360        };
2361
2362        initial_rx.await.unwrap();
2363        assert_eq!(
2364            client.request_reconnect_outcome(),
2365            ReconnectRequestOutcome::Accepted,
2366        );
2367        tokio::time::timeout(Duration::from_secs(5), replacement_rx)
2368            .await
2369            .unwrap()
2370            .unwrap();
2371        tokio::time::timeout(Duration::from_secs(5), async {
2372            while states.lock().len() < 3 {
2373                tokio::task::yield_now().await;
2374            }
2375        })
2376        .await
2377        .unwrap();
2378        client.close().await;
2379
2380        assert_eq!(
2381            *states.lock(),
2382            vec![
2383                SocketState::Connected,
2384                SocketState::Disconnected,
2385                SocketState::Connected,
2386            ],
2387        );
2388        assert_eq!(
2389            client.request_reconnect_outcome(),
2390            ReconnectRequestOutcome::Closed,
2391        );
2392
2393        let _ = done_tx.send(());
2394        server.await.unwrap();
2395    }
2396
2397    #[rstest]
2398    fn test_race_stream_reconnect_replays_auth_and_subscription() {
2399        let auth = Authentication::new("key".to_string(), "token".to_string());
2400        let auth_bytes = Bytes::from(serde_json::to_vec(&auth).unwrap());
2401        let race_sub = RaceSubscription::new(1);
2402        let race_sub_bytes = Bytes::from(serde_json::to_vec(&race_sub).unwrap());
2403
2404        let auth_reconnect = auth_bytes;
2405        let sub_reconnect = race_sub_bytes;
2406        let reconnect_replay: SocketReconnectReplay = Arc::new(move || {
2407            let mut combined = Vec::with_capacity(auth_reconnect.len() + 2 + sub_reconnect.len());
2408            combined.extend_from_slice(&auth_reconnect);
2409            combined.extend_from_slice(b"\r\n");
2410            combined.extend_from_slice(&sub_reconnect);
2411            vec![Bytes::from(combined)]
2412        });
2413
2414        let replay = reconnect_replay();
2415        let [bytes] = replay.as_slice() else {
2416            panic!("expected one combined replay message");
2417        };
2418
2419        let text = std::str::from_utf8(bytes).unwrap();
2420        let (auth_part, sub_part) = text
2421            .split_once("\r\n")
2422            .expect("CRLF separator in combined message");
2423
2424        assert!(auth_part.contains("\"op\":\"authentication\""));
2425        assert!(sub_part.contains("\"op\":\"raceSubscription\""));
2426    }
2427
2428    #[rstest]
2429    fn test_race_stream_handler_fatal_status_sends_kill_signal() {
2430        let (race_fatal_tx, mut race_fatal_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
2431        let inner_handler: TcpMessageHandler = Arc::new(|_data: &[u8]| {});
2432
2433        let handler: TcpMessageHandler = Arc::new(move |data: &[u8]| {
2434            if let Ok(StreamMessage::Status(status)) = stream_decode(data)
2435                && let Some(ref code) = status.error_code
2436                && code.is_race_stream_fatal()
2437            {
2438                let _ = race_fatal_tx.send(());
2439                return;
2440            }
2441            inner_handler(data);
2442        });
2443
2444        // Fatal: NOT_AUTHORIZED
2445        handler(
2446            br#"{"op":"status","statusCode":"503","errorCode":"NOT_AUTHORIZED","connectionClosed":true}"#,
2447        );
2448        assert!(
2449            race_fatal_rx.try_recv().is_ok(),
2450            "fatal error must send kill signal"
2451        );
2452
2453        // Non-fatal: INVALID_CLOCK
2454        handler(
2455            br#"{"op":"status","statusCode":"503","errorCode":"INVALID_CLOCK","connectionClosed":true}"#,
2456        );
2457        assert!(
2458            race_fatal_rx.try_recv().is_err(),
2459            "non-fatal error must not send kill signal"
2460        );
2461    }
2462}