Skip to main content

brokapi_bcs_protocol/ws/
session.rs

1//! The background WebSocket session: connect, heartbeat, reconnect and restore subscriptions.
2
3use crate::error::BcsApiError;
4use crate::http::client::BcsHttpClient;
5use crate::models::messages::IncomingMessage;
6use crate::ws::client::BcsWsChannel;
7use crate::ws::models::MarketDataSubscribe;
8use crate::ws::models::WsOutgoing;
9use crate::ws::parser::parse_incoming;
10use futures_util::{SinkExt, StreamExt};
11use kanal::{AsyncReceiver, AsyncSender};
12use std::collections::HashSet;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::Duration;
16use tokio::net::TcpStream;
17use tokio::sync::{RwLock, watch};
18use tokio::time::{self, sleep};
19use tokio_tungstenite::connect_async;
20use tokio_tungstenite::tungstenite::Message;
21use tokio_tungstenite::tungstenite::client::IntoClientRequest;
22use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
23use tracing::{debug, error, info, warn};
24
25type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
26
27const WS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
28/// Default pause before the first reconnect attempt: 500 milliseconds (the same as in the T-Bank and
29/// Finam connectors).
30pub const DEFAULT_RECONNECT_MIN: Duration = Duration::from_millis(500);
31/// Default upper bound of the pause between reconnect attempts: 30 seconds.
32pub const DEFAULT_RECONNECT_MAX: Duration = Duration::from_secs(30);
33/// A connection that lived this long is healthy: the pause goes back to the minimum.
34const WS_HEALTHY_AFTER: Duration = Duration::from_secs(10);
35/// How long to wait for a pong or ping before the link is dropped (twice the heartbeat interval).
36const WS_PONG_TIMEOUT: Duration = Duration::from_secs(60);
37/// The most subscribe commands one session keeps.
38const WS_MAX_SUBSCRIPTIONS: usize = 100;
39
40/// A command on its way from the caller to the session task.
41///
42/// `seq` is set for a subscribe command: it is the number the command has in the restore list. When the
43/// session connects, it sends the whole list, and a queued subscribe whose number it has just sent is
44/// dropped, so the broker gets every subscribe once.
45struct Queued {
46    seq: Option<u64>,
47    outgoing: WsOutgoing,
48}
49
50/// Options of a WebSocket session.
51///
52/// The default is no announcement, a first pause of 500 ms and an upper bound of 30 s.
53///
54/// # Examples
55///
56/// ```
57/// use std::time::Duration;
58/// use brokapi_bcs_protocol::ws::session::WsOptions;
59///
60/// let options = WsOptions { announce_connects: true, ..WsOptions::default() };
61/// assert_eq!(options.reconnect_min, Duration::from_millis(500));
62/// assert_eq!(options.reconnect_max, Duration::from_secs(30));
63/// ```
64#[derive(Debug, Clone, Copy)]
65pub struct WsOptions {
66    /// Send [`IncomingMessage::Reconnected`] to the reader at every connect, before the subscriptions
67    /// are restored (also at the first connect).
68    ///
69    /// That is how the reader learns about a lost link in the same stream as the data. Count the
70    /// markers: from the second on, the connection was lost, and what came before is not continued by
71    /// what follows. For order events this means: ask for the state of your open orders again.
72    pub announce_connects: bool,
73    /// Pause before the first reconnect attempt. It doubles after each failed attempt up to
74    /// `reconnect_max`. Values below 10 ms are raised to 10 ms.
75    pub reconnect_min: Duration,
76    /// Upper bound of the pause. The pause returns to `reconnect_min` once a connection has lived
77    /// 10 seconds. A value below `reconnect_min` is raised to it.
78    pub reconnect_max: Duration,
79}
80
81impl Default for WsOptions {
82    fn default() -> Self {
83        Self {
84            announce_connects: false,
85            reconnect_min: DEFAULT_RECONNECT_MIN,
86            reconnect_max: DEFAULT_RECONNECT_MAX,
87        }
88    }
89}
90
91/// A background WebSocket session for one channel. It reconnects by itself.
92///
93/// What the session does for you:
94///
95/// * It connects with a bearer token and takes a fresh token at every reconnect. If the broker refuses
96///   the connection with 403 or "Unauthorized", it forces a token refresh.
97/// * It sends a ping every 30 seconds and answers the broker's pings. If no pong or ping arrives for
98///   60 seconds it drops the link and reconnects.
99/// * After a lost link it waits (500 ms, doubling up to 30 s, see [`WsOptions`]) and connects again.
100///   Market data subscriptions are sent again after each connect.
101/// * It parses every text frame and pushes the [`IncomingMessage`] with `try_send` into your channel.
102///   **If your channel is full, the frame is dropped** and counted ([`WsSession::dropped_events`]):
103///   the session never waits for a slow reader. Size the channel for bursts and read it quickly.
104/// * It stops when your channel is closed (the receiver dropped) or when the session value is dropped.
105///
106/// There is **no explicit close method**. Dropping the `WsSession` stops its background task at once,
107/// also when the session is waiting for the next reconnect or has never been able to connect.
108///
109/// The session never reports a lost link as an error value. Use [`WsSession::is_connected`] and, for
110/// an ordered signal, [`WsOptions::announce_connects`].
111///
112/// # Examples
113///
114/// ```
115/// # #[tokio::main(flavor = "current_thread")]
116/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
117/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
118/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
119/// use std::time::Duration;
120/// use brokapi_bcs_protocol::http::client::BcsHttpClient;
121/// use brokapi_bcs_protocol::models::instrument::InstrumentKey;
122/// use brokapi_bcs_protocol::models::messages::IncomingMessage;
123/// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
124/// use brokapi_bcs_protocol::ws::models::MarketDataSubscribe;
125/// use brokapi_bcs_protocol::ws::session::{WsOptions, WsSession};
126///
127/// let http = BcsHttpClient::new(cfg)?;
128/// let (tx, rx) = kanal::bounded_async::<IncomingMessage>(64);
129/// let options = WsOptions { announce_connects: true, ..WsOptions::default() };
130/// let session = WsSession::spawn_with(BcsWsChannel::MarketData, http, tx, options);
131///
132/// let sub = MarketDataSubscribe::order_book(vec![InstrumentKey::new("SBER", "TQBR")], 5);
133/// session.subscribe(sub).await?;
134///
135/// // First the connect marker, then the answers of the broker.
136/// let first = tokio::time::timeout(Duration::from_secs(5), rx.recv()).await??;
137/// assert!(matches!(first, IncomingMessage::Reconnected));
138/// assert_eq!(session.dropped_events(), 0);
139/// # Ok(())
140/// # }
141/// ```
142pub struct WsSession {
143    /// The channel of this session.
144    channel: BcsWsChannel,
145    /// Queue of outgoing commands.
146    user_tx: AsyncSender<Queued>,
147    /// Subscriptions of this session with their numbers, sent again after each reconnect.
148    subscriptions: Arc<RwLock<Vec<(u64, WsOutgoing)>>>,
149    /// The number of the next subscribe command.
150    next_seq: AtomicU64,
151    /// Set while the link is up.
152    is_connected: Arc<watch::Sender<bool>>,
153    /// Frames dropped because the downstream channel was full.
154    dropped_events: Arc<AtomicU64>,
155    /// Handle of the background task.
156    _handle: tokio::task::JoinHandle<()>,
157}
158
159impl Drop for WsSession {
160    /// Stops the background task, wherever it is: connected, waiting for a reconnect or never connected.
161    fn drop(&mut self) {
162        self._handle.abort();
163    }
164}
165
166impl WsSession {
167    /// Starts a session with the default [`WsOptions`]. It returns at once, the connection is made in
168    /// the background.
169    ///
170    /// # Panics
171    ///
172    /// Panics if there is no Tokio runtime, because the session is a Tokio task.
173    ///
174    /// # Examples
175    ///
176    /// ```
177    /// # #[tokio::main(flavor = "current_thread")]
178    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
179    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
180    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
181    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
182    /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
183    /// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
184    /// use brokapi_bcs_protocol::ws::session::WsSession;
185    ///
186    /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
187    /// let session = WsSession::spawn(BcsWsChannel::Limits, BcsHttpClient::new(cfg)?, tx);
188    /// assert_eq!(session.channel(), BcsWsChannel::Limits);
189    /// # Ok(())
190    /// # }
191    /// ```
192    pub fn spawn(
193        channel: BcsWsChannel,
194        http_client: BcsHttpClient,
195        event_tx: AsyncSender<IncomingMessage>,
196    ) -> Self {
197        Self::spawn_with(channel, http_client, event_tx, WsOptions::default())
198    }
199
200    /// Starts a session with the given options. Otherwise the same as [`WsSession::spawn`].
201    ///
202    /// # Panics
203    ///
204    /// Panics if there is no Tokio runtime.
205    ///
206    /// # Examples
207    ///
208    /// See the example on [`WsSession`].
209    pub fn spawn_with(
210        channel: BcsWsChannel,
211        http_client: BcsHttpClient,
212        event_tx: AsyncSender<IncomingMessage>,
213        options: WsOptions,
214    ) -> Self {
215        let (user_tx, user_rx) = kanal::bounded_async::<Queued>(128);
216        let user_rx = Arc::new(user_rx);
217        let subscriptions: Arc<RwLock<Vec<(u64, WsOutgoing)>>> = Arc::new(RwLock::new(Vec::new()));
218
219        let is_connected = Arc::new(watch::channel(false).0);
220        let dropped_events = Arc::new(AtomicU64::new(0));
221
222        let _handle = tokio::spawn(Self::run_loop(
223            channel,
224            http_client,
225            event_tx,
226            options,
227            subscriptions.clone(),
228            user_rx,
229            is_connected.clone(),
230            dropped_events.clone(),
231        ));
232
233        Self {
234            channel,
235            user_tx,
236            subscriptions,
237            next_seq: AtomicU64::new(0),
238            is_connected,
239            dropped_events,
240            _handle,
241        }
242    }
243
244    /// The number of parsed frames dropped because your event channel was full.
245    ///
246    /// A growing number means the reader is too slow or the channel is too small. Frames that were
247    /// dropped are gone. For order events that means states you must re-read over REST.
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// # #[tokio::main(flavor = "current_thread")]
253    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
254    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
255    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
256    /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
257    /// use brokapi_bcs_protocol::ws::client::BcsWsClient;
258    ///
259    /// let ws = BcsWsClient::new(cfg)?;
260    /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
261    /// let session = ws.connect_limits(tx);
262    /// assert_eq!(session.dropped_events(), 0);
263    /// # Ok(())
264    /// # }
265    /// ```
266    pub fn dropped_events(&self) -> u64 {
267        self.dropped_events.load(Ordering::Relaxed)
268    }
269
270    /// `true` while the link is up and the subscriptions have been sent again.
271    ///
272    /// It is `false` before the first connect, during a reconnect and after the session has ended.
273    /// Do not use it to decide that data is flowing, it only says the socket is open.
274    ///
275    /// # Examples
276    ///
277    /// ```
278    /// # #[tokio::main(flavor = "current_thread")]
279    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
280    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
281    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
282    /// use std::time::Duration;
283    /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
284    /// use brokapi_bcs_protocol::ws::client::BcsWsClient;
285    ///
286    /// let ws = BcsWsClient::new(cfg)?;
287    /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
288    /// let session = ws.connect_limits(tx);
289    /// tokio::time::timeout(Duration::from_secs(5), async {
290    ///     while !session.is_connected() {
291    ///         tokio::time::sleep(Duration::from_millis(10)).await;
292    ///     }
293    /// })
294    /// .await?;
295    /// # Ok(())
296    /// # }
297    /// ```
298    pub fn is_connected(&self) -> bool {
299        *self.is_connected.borrow()
300    }
301
302    /// Waits until the link is up and the subscriptions have been sent again ([`is_connected`](Self::is_connected)
303    /// is `true`). Returns at once when it already is.
304    ///
305    /// It has no limit of its own: it waits for a connect that may never come (the broker is unreachable). Put
306    /// your own timeout around it, as [`tokio::time::timeout`]. The session keeps trying in the background
307    /// either way. A session that has ended for good never comes up, so the wait then ends only with your timeout.
308    ///
309    /// # Examples
310    ///
311    /// ```
312    /// # #[tokio::main(flavor = "current_thread")]
313    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
314    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
315    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
316    /// use std::time::Duration;
317    /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
318    /// use brokapi_bcs_protocol::ws::client::BcsWsClient;
319    ///
320    /// let ws = BcsWsClient::new(cfg)?;
321    /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
322    /// let session = ws.connect_limits(tx);
323    /// tokio::time::timeout(Duration::from_secs(5), session.wait_connected()).await?;
324    /// assert!(session.is_connected());
325    /// # Ok(())
326    /// # }
327    /// ```
328    pub async fn wait_connected(&self) {
329        let mut up = self.is_connected.subscribe();
330        // The sender lives as long as this value does, so the wait cannot end in an error.
331        let _ = up.wait_for(|connected| *connected).await;
332    }
333
334    /// The channel this session is on.
335    pub fn channel(&self) -> BcsWsChannel {
336        self.channel
337    }
338
339    /// Subscribes on the market data channel. The command is kept and sent again after each reconnect.
340    ///
341    /// The command is put in the restore list first and then queued for sending. Something to know:
342    ///
343    /// * The session counts **commands**: at most 100 per session. The broker's limit (broker docs) is
344    ///   100 **instruments** per connection, so a command with many instruments can pass the local
345    ///   check and still be refused by the broker (as an error frame, not as a return value).
346    /// * If you subscribe before the first connect, the command waits in the restore list and goes out
347    ///   once, when the session connects. The same holds for a command given while the session is
348    ///   connecting.
349    ///
350    /// The answer of the broker (subscription confirmation, data, or an error frame) arrives in your
351    /// event channel, not as the return value.
352    ///
353    /// # Errors
354    ///
355    /// [`BcsApiError::SubscriptionError`] if the session is not on [`BcsWsChannel::MarketData`], if it
356    /// already holds 100 subscribe commands, or if the session task has ended.
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// # #[tokio::main(flavor = "current_thread")]
362    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
363    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
364    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
365    /// use brokapi_bcs_protocol::error::BcsApiError;
366    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
367    /// use brokapi_bcs_protocol::models::instrument::InstrumentKey;
368    /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
369    /// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
370    /// use brokapi_bcs_protocol::ws::models::MarketDataSubscribe;
371    /// use brokapi_bcs_protocol::ws::session::WsSession;
372    ///
373    /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
374    /// let sber = vec![InstrumentKey::new("SBER", "TQBR")];
375    ///
376    /// // A portfolio session takes no subscriptions.
377    /// let portfolio = WsSession::spawn(BcsWsChannel::Portfolio, BcsHttpClient::new(cfg.clone())?, tx.clone());
378    /// let err = portfolio.subscribe(MarketDataSubscribe::quotes(sber.clone())).await.unwrap_err();
379    /// assert!(matches!(err, BcsApiError::SubscriptionError { .. }));
380    ///
381    /// // A market data session does.
382    /// let md = WsSession::spawn(BcsWsChannel::MarketData, BcsHttpClient::new(cfg)?, tx);
383    /// md.subscribe(MarketDataSubscribe::quotes(sber)).await?;
384    /// # Ok(())
385    /// # }
386    /// ```
387    pub async fn subscribe(&self, sub: MarketDataSubscribe) -> Result<(), BcsApiError> {
388        if self.channel != BcsWsChannel::MarketData {
389            return Err(BcsApiError::SubscriptionError {
390                message: format!(
391                    "WS {:?} ignoring subscribe message for non-MarketData channel",
392                    self.channel
393                ),
394            });
395        }
396        let mut subs = self.subscriptions.write().await;
397        if subs.len() >= WS_MAX_SUBSCRIPTIONS {
398            return Err(BcsApiError::SubscriptionError {
399                message: format!(
400                    "Subscription limit reached ({} max per session)",
401                    WS_MAX_SUBSCRIPTIONS
402                ),
403            });
404        }
405
406        let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
407        subs.push((seq, WsOutgoing::Subscribe(sub.clone())));
408        drop(subs);
409
410        self.user_tx
411            .send(Queued {
412                seq: Some(seq),
413                outgoing: WsOutgoing::Subscribe(sub),
414            })
415            .await
416            .map_err(|e| BcsApiError::SubscriptionError {
417                message: format!("Failed to send subscription: {}", e),
418            })?;
419
420        Ok(())
421    }
422
423    /// Unsubscribes on the market data channel. Pass the same [`MarketDataSubscribe`] you subscribed
424    /// with: the session turns it into the unsubscribe command itself.
425    ///
426    /// The matching entry is removed from the restore list only if it is **equal** to the one you pass
427    /// (same instruments in the same order, same depth, same time frame). A different grouping of the
428    /// same instruments is sent to the broker as an unsubscribe, but the original stays in the restore
429    /// list and comes back after a reconnect. The broker docs describe no answer to an unsubscribe.
430    ///
431    /// # Errors
432    ///
433    /// [`BcsApiError::SubscriptionError`] if the session is not on [`BcsWsChannel::MarketData`] or if
434    /// the session task has ended.
435    ///
436    /// # Examples
437    ///
438    /// ```
439    /// # #[tokio::main(flavor = "current_thread")]
440    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
441    /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
442    /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
443    /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
444    /// use brokapi_bcs_protocol::models::instrument::InstrumentKey;
445    /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
446    /// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
447    /// use brokapi_bcs_protocol::ws::models::MarketDataSubscribe;
448    /// use brokapi_bcs_protocol::ws::session::WsSession;
449    ///
450    /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
451    /// let md = WsSession::spawn(BcsWsChannel::MarketData, BcsHttpClient::new(cfg)?, tx);
452    ///
453    /// let sub = MarketDataSubscribe::order_book(vec![InstrumentKey::new("SBER", "TQBR")], 5);
454    /// md.subscribe(sub.clone()).await?;
455    /// md.unsubscribe(sub).await?; // the same value as at subscribe
456    /// # Ok(())
457    /// # }
458    /// ```
459    pub async fn unsubscribe(&self, sub: MarketDataSubscribe) -> Result<(), BcsApiError> {
460        if self.channel != BcsWsChannel::MarketData {
461            return Err(BcsApiError::SubscriptionError {
462                message: format!(
463                    "WS {:?} ignoring unsubscribe message for non-MarketData channel",
464                    self.channel
465                ),
466            });
467        }
468
469        {
470            let mut subs = self.subscriptions.write().await;
471            subs.retain(|(_, s)| s != &WsOutgoing::Subscribe(sub.clone()));
472        }
473
474        self.user_tx
475            .send(Queued {
476                seq: None,
477                outgoing: WsOutgoing::Unsubscribe(sub.into_unsubscribe()),
478            })
479            .await
480            .map_err(|e| BcsApiError::SubscriptionError {
481                message: format!("Failed to send unsubscription: {}", e),
482            })
483    }
484
485    /// The main loop: connect, read and write, reconnect.
486    #[allow(clippy::too_many_arguments)]
487    async fn run_loop(
488        channel: BcsWsChannel,
489        http_client: BcsHttpClient,
490        event_tx: AsyncSender<IncomingMessage>,
491        options: WsOptions,
492        subscriptions: Arc<RwLock<Vec<(u64, WsOutgoing)>>>,
493        user_rx: Arc<AsyncReceiver<Queued>>,
494        is_connected: Arc<watch::Sender<bool>>,
495        dropped_events: Arc<AtomicU64>,
496    ) {
497        let ws_base = http_client.ws_base_url().to_string();
498        let backoff_min = options.reconnect_min.max(Duration::from_millis(10));
499        let backoff_max = options.reconnect_max.max(backoff_min);
500        let mut backoff = backoff_min;
501
502        'session: loop {
503            let url = format!("{}{}", ws_base, channel.path());
504            // 1. Свежий токен при каждом reconnect
505            let token = match http_client.get_access_token().await {
506                Ok(t) => t,
507                Err(e) => {
508                    error!("Failed to get token for {:?}: {}", channel, e);
509                    sleep(backoff).await;
510                    backoff = (backoff * 2).min(backoff_max);
511                    continue;
512                }
513            };
514
515            // 2. Подключение с обработкой 403
516            let ws_stream = match Self::connect(&url, &token).await {
517                Ok(s) => s,
518                Err(e) => {
519                    // Если 403 - принудительно обновляем токен и увеличиваем backoff
520                    if e.to_string().contains("403 Forbidden")
521                        || e.to_string().contains("Unauthorized")
522                    {
523                        error!("WS {:?} 403 Forbidden - forcing token refresh", channel);
524                        let _ = http_client.refresh_access_token().await;
525                    }
526
527                    error!("WS {:?} connect failed: {}", channel, e);
528                    sleep(backoff).await;
529                    backoff = (backoff * 2).min(backoff_max);
530                    continue;
531                }
532            };
533
534            info!("WS {:?} session established", channel);
535            let connected_at = tokio::time::Instant::now();
536            let (mut write, mut read) = ws_stream.split();
537
538            // 3. Pong watchdog state
539            // On the clock of tokio, like the heartbeat itself: a test moves both with one `jump`.
540            let mut last_pong = time::Instant::now();
541
542            // Reader is told first: the data of the restored subscriptions comes after this marker.
543            if options.announce_connects
544                && event_tx.send(IncomingMessage::Reconnected).await.is_err()
545            {
546                info!("Event channel closed for {:?}", channel);
547                break 'session;
548            }
549
550            // 4. Восстановить подписки. Номера отправленных запоминаем: такая же команда из очереди уже
551            // ушла, второй раз ее не шлем.
552            let mut restored: HashSet<u64> = HashSet::new();
553            {
554                let subs = subscriptions.read().await;
555                for (seq, outgoing) in subs.iter() {
556                    match serde_json::to_string(outgoing) {
557                        Ok(json) => {
558                            if write.send(Message::Text(json.into())).await.is_err() {
559                                error!("Failed to restore subscription for {:?}", channel);
560                                continue 'session;
561                            }
562                            restored.insert(*seq);
563                        }
564                        Err(e) => {
565                            error!("Failed to serialize subscription for {:?}: {}", channel, e);
566                        }
567                    }
568                }
569                if !subs.is_empty() {
570                    info!("Restored {} subscriptions for {:?}", subs.len(), channel);
571                }
572            }
573
574            is_connected.send_replace(true); // ← UP: подписки восстановлены, готов к работе
575
576            // 5. Единый event loop: read + write + heartbeat в одном select
577            let mut heartbeat = time::interval(WS_HEARTBEAT_INTERVAL);
578            heartbeat.tick().await; // пропустить немедленный первый тик
579
580            'inner_session: loop {
581                tokio::select! {
582                    // Входящие сообщения из WS
583                    frame = read.next() => {
584                        match frame {
585                            Some(Ok(Message::Text(text))) => {
586                                let incoming = parse_incoming(&text, channel);
587                                match event_tx.try_send(incoming) {
588                                    Ok(true) => {}
589                                    Ok(false) => {
590                                        dropped_events.fetch_add(1, Ordering::Relaxed);
591                                        warn!(
592                                            "WS {:?} downstream full — dropping parsed frame",
593                                            channel
594                                        );
595                                    }
596                                    Err(_) => {
597                                        info!("Event channel closed for {:?}", channel);
598                                        break 'session;
599                                    }
600                                }
601                            }
602                            Some(Ok(Message::Close(_))) => {
603                                info!("WS {:?} closed by server", channel);
604                                break 'inner_session;
605                            }
606                            Some(Ok(Message::Pong(_))) => {
607                                last_pong = time::Instant::now();
608                                info!("WS {:?} received pong", channel);
609                            }
610                            Some(Ok(Message::Ping(data))) => {
611                                last_pong = time::Instant::now();
612                                if write.send(Message::Pong(data)).await.is_err() {
613                                    error!("WS {:?} failed to send pong", channel);
614                                    break 'inner_session;
615                                }
616                            }
617                            Some(Ok(_)) => {
618                                warn!("WS {:?} received unknown message", channel);
619                            }
620                            Some(Err(e)) => {
621                                error!("WS {:?} read error: {}", channel, e);
622                                break 'inner_session;
623                            }
624                            None => {
625                                info!("WS {:?} stream ended", channel);
626                                break 'inner_session;
627                            }
628                        }
629                    }
630
631                    // ИСПРАВЛЕННЫЙ HEARTBEAT - настоящий Ping frame!
632                    _ = heartbeat.tick() => {
633                        let elapsed = last_pong.elapsed();
634                        if elapsed > WS_PONG_TIMEOUT {
635                            error!("Pong timeout for {:?} ({:?}), reconnect", channel, elapsed);
636                            break 'inner_session;
637                        }
638
639
640                        if write.send(Message::Ping(vec![].into())).await.is_err() {
641                            error!("Failed to send Ping frame");
642                            break 'inner_session;
643                        }
644                        debug!("WS {:?} → Ping frame sent", channel);
645                    }
646
647                    // Исходящие сообщения от юзера (подписки и т.д.)
648                    msg = user_rx.recv() => {
649                        match msg {
650                            Ok(Queued { seq, outgoing }) => {
651                                // Фильтрация: Subscribe/Unsubscribe только для MarketData
652                                if channel != BcsWsChannel::MarketData {
653                                    warn!("WS {:?} ignoring subscribe message for non-MarketData channel", channel);
654                                    continue 'inner_session;
655                                }
656                                // Эта подписка уже ушла вместе со списком при подключении.
657                                if seq.is_some_and(|n| restored.remove(&n)) {
658                                    continue 'inner_session;
659                                }
660                                match serde_json::to_string(&outgoing) {
661                                    Ok(json) => {
662                                        if write.send(Message::Text(json.into())).await.is_err() {
663                                            error!("WS {:?} write error", channel);
664                                            break 'inner_session;
665                                        }
666                                    }
667                                    Err(e) => {
668                                        error!("WS {:?} serialize error: {}", channel, e);
669                                    }
670                                }
671                            }
672                            Err(_) => break 'session,
673                        }
674                    }
675
676
677                }
678            }
679
680            is_connected.send_replace(false); // ← DOWN (при любом выходе из inner)
681            // Соединение, которое тут же рвется, не должно давить на сервер с минимальной паузой.
682            if connected_at.elapsed() >= WS_HEALTHY_AFTER {
683                backoff = backoff_min;
684            }
685            warn!(
686                "WS {:?} disconnected, reconnecting in {:?}...",
687                channel, backoff
688            );
689            sleep(backoff).await;
690            backoff = (backoff * 2).min(backoff_max);
691        }
692        is_connected.send_replace(false); // ← DOWN (выход из session loop)
693    }
694
695    async fn connect(url: &str, access_token: &str) -> Result<WsStream, BcsApiError> {
696        let mut request = url
697            .into_client_request()
698            .map_err(|e| BcsApiError::WsError {
699                code: None,
700                message: format!("Failed to create WS request: {}", e),
701            })?;
702
703        request.headers_mut().insert(
704            "Authorization",
705            format!("Bearer {}", access_token)
706                .parse()
707                .map_err(|e| BcsApiError::WsError {
708                    code: None,
709                    message: format!("Invalid auth header: {}", e),
710                })?,
711        );
712
713        let (stream, res) = connect_async(request)
714            .await
715            .map_err(|e| BcsApiError::WsError {
716                code: None,
717                message: format!("WebSocket connection failed internally: {}", e),
718            })?;
719
720        info!(
721            "WebSocket connected to {} with status <{}>",
722            url,
723            res.status()
724        );
725        Ok(stream)
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn default_pause_is_the_range_of_the_other_connectors() {
735        let o = WsOptions::default();
736        assert_eq!(
737            (o.reconnect_min, o.reconnect_max),
738            (Duration::from_millis(500), Duration::from_secs(30))
739        );
740        assert!(!o.announce_connects);
741    }
742}