Skip to main content

wsio_client/
session.rs

1use std::sync::{
2    Arc,
3    LazyLock,
4};
5
6use anyhow::{
7    Result,
8    bail,
9};
10use arc_swap::ArcSwap;
11use kikiutils::atomic::enum_cell::AtomicEnumCell;
12use num_enum::{
13    IntoPrimitive,
14    TryFromPrimitive,
15};
16use tokio::{
17    spawn,
18    sync::{
19        Mutex,
20        mpsc::{
21            Receiver,
22            Sender,
23            channel,
24        },
25    },
26    task::JoinHandle,
27    time::{
28        sleep,
29        timeout,
30    },
31};
32use tokio_tungstenite::tungstenite::Message;
33use tokio_util::sync::CancellationToken;
34
35use crate::{
36    WsIoClient,
37    core::{
38        channel_capacity_from_websocket_config,
39        packet::{
40            WsIoPacket,
41            WsIoPacketType,
42        },
43        traits::task::spawner::TaskSpawner,
44        utils::task::abort_locked_task,
45    },
46    runtime::WsIoClientRuntime,
47};
48
49// Enums
50#[repr(u8)]
51#[derive(Debug, Eq, IntoPrimitive, PartialEq, TryFromPrimitive)]
52enum SessionState {
53    AwaitingInit,
54    AwaitingReady,
55    Closed,
56    Closing,
57    Created,
58    Initiating,
59    Ready,
60    Readying,
61}
62
63// Structs
64#[derive(Debug)]
65pub struct WsIoClientSession {
66    cancel_token: ArcSwap<CancellationToken>,
67    init_timeout_task: Mutex<Option<JoinHandle<()>>>,
68    message_tx: Sender<Arc<Message>>,
69    ping_task: Mutex<Option<JoinHandle<()>>>,
70    ready_timeout_task: Mutex<Option<JoinHandle<()>>>,
71    runtime: Arc<WsIoClientRuntime>,
72    state: AtomicEnumCell<SessionState>,
73}
74
75impl TaskSpawner for WsIoClientSession {
76    #[inline]
77    fn cancel_token(&self) -> Arc<CancellationToken> {
78        self.cancel_token.load_full()
79    }
80}
81
82impl WsIoClientSession {
83    #[inline]
84    pub(crate) fn new(runtime: Arc<WsIoClientRuntime>) -> (Arc<Self>, Receiver<Arc<Message>>) {
85        let channel_capacity = channel_capacity_from_websocket_config(&runtime.config.websocket_config);
86        let (message_tx, message_rx) = channel(channel_capacity);
87        (
88            Arc::new(Self {
89                cancel_token: ArcSwap::new(Arc::new(CancellationToken::new())),
90                init_timeout_task: Mutex::new(None),
91                message_tx,
92                ping_task: Mutex::new(None),
93                ready_timeout_task: Mutex::new(None),
94                runtime,
95                state: AtomicEnumCell::new(SessionState::Created),
96            }),
97            message_rx,
98        )
99    }
100
101    // Private methods
102    #[inline]
103    fn handle_disconnect_packet(&self) -> Result<()> {
104        #[cfg(feature = "tracing")]
105        tracing::debug!("received server disconnect packet");
106        let runtime = self.runtime.clone();
107        spawn(async move { runtime.disconnect().await });
108        Ok(())
109    }
110
111    #[inline]
112    fn handle_event_packet(self: &Arc<Self>, event: &str, packet_data: Option<Vec<u8>>) -> Result<()> {
113        #[cfg(feature = "tracing")]
114        tracing::trace!(event, has_data = packet_data.is_some(), "received server event packet");
115        self.runtime.event_registry.dispatch_event_packet(
116            self.clone(),
117            event,
118            &self.runtime.config.packet_codec,
119            packet_data,
120            &self.runtime,
121        );
122
123        Ok(())
124    }
125
126    async fn handle_init_packet(self: &Arc<Self>, packet_data: Option<&[u8]>) -> Result<()> {
127        // Verify current state; only valid from AwaitingInit → Initiating
128        let state = self.state.get();
129        match state {
130            SessionState::AwaitingInit => self.state.try_transition(state, SessionState::Initiating)?,
131            _ => {
132                #[cfg(feature = "tracing")]
133                tracing::debug!(?state, "received init packet in invalid client session state");
134                bail!("Received init packet in invalid state: {state:?}");
135            },
136        }
137
138        #[cfg(feature = "tracing")]
139        tracing::debug!("received server init packet");
140
141        // Abort init-timeout task
142        abort_locked_task(&self.init_timeout_task).await;
143
144        // Invoke init_handler with timeout protection if configured
145        let response_data = if let Some(init_handler) = &self.runtime.config.init_handler {
146            match timeout(
147                self.runtime.config.init_handler_timeout,
148                init_handler(self.clone(), packet_data, &self.runtime.config.packet_codec),
149            )
150            .await
151            {
152                Ok(result) => result?,
153                Err(err) => {
154                    #[cfg(feature = "tracing")]
155                    tracing::warn!(error = %err, "client init handler timed out");
156                    return Err(err.into());
157                },
158            }
159        } else {
160            None
161        };
162
163        // Transition state to AwaitingReady
164        self.state
165            .try_transition(SessionState::Initiating, SessionState::AwaitingReady)?;
166
167        // Spawn ready-timeout watchdog to close session if Ready is not received in time
168        let session = self.clone();
169        *self.ready_timeout_task.lock().await = Some(spawn(async move {
170            sleep(session.runtime.config.ready_packet_timeout).await;
171            if session.state.is(SessionState::AwaitingReady) {
172                #[cfg(feature = "tracing")]
173                tracing::warn!("timed out waiting for server ready packet");
174                session.close();
175            }
176        }));
177
178        // Send init packet
179        self.send_packet(&WsIoPacket::new_init(response_data)).await
180    }
181
182    async fn handle_ready_packet(self: &Arc<Self>) -> Result<()> {
183        // Verify current state; only valid from AwaitingReady → Ready
184        let state = self.state.get();
185        match state {
186            SessionState::AwaitingReady => self.state.try_transition(state, SessionState::Ready)?,
187            _ => {
188                #[cfg(feature = "tracing")]
189                tracing::debug!(?state, "received ready packet in invalid client session state");
190                bail!("Received ready packet in invalid state: {state:?}");
191            },
192        }
193
194        #[cfg(feature = "tracing")]
195        tracing::debug!("client session is ready");
196
197        // Abort ready-timeout task
198        abort_locked_task(&self.ready_timeout_task).await;
199
200        // Wake send event message task
201        self.runtime.wake_send_event_message_task_notify.notify_waiters();
202
203        // Invoke on_session_ready_handler if configured
204        if let Some(on_session_ready_handler) = self.runtime.config.on_session_ready_handler.clone() {
205            // Run handler asynchronously in a detached task
206            self.spawn_task(on_session_ready_handler(self.clone()));
207        }
208
209        Ok(())
210    }
211
212    async fn send_message(&self, message: Arc<Message>) -> Result<()> {
213        Ok(self.message_tx.send(message).await?)
214    }
215
216    async fn send_packet(&self, packet: &WsIoPacket) -> Result<()> {
217        self.send_message(self.runtime.encode_packet_to_message(packet)?).await
218    }
219
220    // Protected methods
221    pub(crate) async fn cleanup(self: &Arc<Self>) {
222        #[cfg(feature = "tracing")]
223        tracing::debug!("cleaning up client session");
224        // Set state to Closing
225        self.state.store(SessionState::Closing);
226
227        // Abort tasks
228        abort_locked_task(&self.init_timeout_task).await;
229        abort_locked_task(&self.ping_task).await;
230        abort_locked_task(&self.ready_timeout_task).await;
231
232        // Cancel all ongoing operations via cancel token
233        self.cancel_token.load().cancel();
234
235        // Invoke on_session_close_handler with timeout protection if configured
236        if let Some(on_session_close_handler) = &self.runtime.config.on_session_close_handler
237            && let Err(_err) = timeout(
238                self.runtime.config.on_session_close_handler_timeout,
239                on_session_close_handler(self.clone()),
240            )
241            .await
242        {
243            #[cfg(feature = "tracing")]
244            tracing::warn!(error = %_err, "client session close handler timed out");
245        }
246
247        // Set state to Closed
248        self.state.store(SessionState::Closed);
249
250        #[cfg(feature = "tracing")]
251        tracing::debug!("client session closed");
252    }
253
254    #[inline]
255    pub(crate) fn close(&self) {
256        // Skip if session is already Closing or Closed, otherwise set state to Closing
257        match self.state.get() {
258            SessionState::Closed | SessionState::Closing => return,
259            _state => {
260                #[cfg(feature = "tracing")]
261                tracing::debug!(state = ?_state, "closing client session");
262                self.state.store(SessionState::Closing)
263            },
264        }
265
266        // Send websocket close frame to initiate graceful shutdown
267        let _ = self.message_tx.try_send(Arc::new(Message::Close(None)));
268    }
269
270    pub(crate) async fn emit_event_message(&self, message: Arc<Message>) -> Result<()> {
271        self.state.ensure(SessionState::Ready, |state| {
272            format!("Cannot emit event message in invalid state: {state:?}")
273        })?;
274
275        self.send_message(message).await
276    }
277
278    pub(crate) async fn handle_incoming_packet(self: &Arc<Self>, encoded_packet: &[u8]) -> Result<()> {
279        // TODO: lazy load
280        let packet = match self.runtime.config.packet_codec.decode(encoded_packet) {
281            Ok(packet) => packet,
282            Err(err) => {
283                #[cfg(feature = "tracing")]
284                tracing::debug!(error = %err, "failed to decode server packet");
285                return Err(err);
286            },
287        };
288        match packet.r#type {
289            WsIoPacketType::Disconnect => self.handle_disconnect_packet(),
290            WsIoPacketType::Event => {
291                if self.is_ready() {
292                    if let Some(event) = packet.key.as_deref() {
293                        return self.handle_event_packet(event, packet.data);
294                    } else {
295                        bail!("Event packet missing key");
296                    }
297                }
298
299                Ok(())
300            },
301            WsIoPacketType::Init => self.handle_init_packet(packet.data.as_deref()).await,
302            WsIoPacketType::Ready => self.handle_ready_packet().await,
303        }
304    }
305
306    pub(crate) async fn init(self: &Arc<Self>) {
307        self.state.store(SessionState::AwaitingInit);
308        #[cfg(feature = "tracing")]
309        tracing::debug!("client session awaiting server init packet");
310        let session = self.clone();
311
312        // Create init-timeout watchdog to close session if init not received in time
313        *self.init_timeout_task.lock().await = Some(spawn(async move {
314            sleep(session.runtime.config.init_packet_timeout).await;
315            if session.state.is(SessionState::AwaitingInit) {
316                #[cfg(feature = "tracing")]
317                tracing::warn!("timed out waiting for server init packet");
318                session.close();
319            }
320        }));
321
322        // Create ping task to send 1-byte heartbeat frame to keep the connection alive
323        let session = self.clone();
324        *self.ping_task.lock().await = Some(spawn(async move {
325            loop {
326                sleep(session.runtime.config.ping_interval).await;
327                if session.send_message(PING_MESSAGE.clone()).await.is_err() {
328                    #[cfg(feature = "tracing")]
329                    tracing::warn!("failed to send client heartbeat; closing session");
330                    session.close();
331                }
332            }
333        }));
334    }
335
336    // Public methods
337    #[inline]
338    pub fn client(&self) -> WsIoClient {
339        WsIoClient(self.runtime.clone())
340    }
341
342    #[inline]
343    pub fn is_ready(&self) -> bool {
344        self.state.is(SessionState::Ready)
345    }
346}
347
348// Constants/Statics
349static PING_MESSAGE: LazyLock<Arc<Message>> = LazyLock::new(|| Arc::new(Message::Binary(vec![0x01].into())));