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