Skip to main content

pjson_rs/infrastructure/websocket/
client.rs

1//! WebSocket client implementation for PJS streaming
2
3#[cfg(feature = "websocket-client")]
4use super::{StreamOptions, WsMessage};
5use crate::{
6    Error as PjsError, Result as PjsResult,
7    infrastructure::bounded_channel::{ByteBoundedSender, Envelope, byte_bounded_channel},
8};
9use futures::StreamExt;
10use serde_json::Value;
11use std::{
12    collections::HashMap,
13    sync::Arc,
14    time::{Duration, Instant},
15};
16use tokio::sync::{RwLock, mpsc};
17use tokio_tungstenite::{connect_async, tungstenite::Message};
18use tracing::{debug, error, info, warn};
19use url::Url;
20
21/// Capacity of the client's outgoing message channel.
22///
23/// Bounds how many outgoing messages (stream requests, acks, pongs) can
24/// queue while `send_task` catches up. This is a message-count bound only;
25/// [`MAX_QUEUED_MESSAGE_BYTES`] additionally bounds cumulative queued
26/// bytes.
27const MESSAGE_QUEUE_CAPACITY: usize = 1000;
28
29/// Cumulative byte budget for the client's outgoing message channel, on
30/// top of [`MESSAGE_QUEUE_CAPACITY`]'s message-count bound.
31///
32/// Keeps worst-case queued memory a small, predictable constant regardless
33/// of individual message size (e.g. a large `StreamInit` payload).
34const MAX_QUEUED_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
35
36/// WebSocket client for receiving PJS streams
37pub struct PjsWebSocketClient {
38    url: Url,
39    sessions: Arc<RwLock<HashMap<String, ClientStreamSession>>>,
40    message_tx: ByteBoundedSender<String>,
41    message_rx: Arc<RwLock<Option<mpsc::Receiver<Envelope<String>>>>>,
42    write_timeout: Duration,
43}
44
45/// Client-side stream session
46#[derive(Debug)]
47struct ClientStreamSession {
48    id: String,
49    created_at: Instant,
50    received_frames: HashMap<u32, ReceivedFrame>,
51    reconstructed_data: Value,
52    is_complete: bool,
53}
54
55/// Frame received by client
56#[derive(Debug, Clone)]
57struct ReceivedFrame {
58    received_at: Instant,
59    processed_at: Option<Instant>,
60}
61
62impl PjsWebSocketClient {
63    /// Create new WebSocket client
64    pub fn new(url: impl AsRef<str>) -> PjsResult<Self> {
65        let url = Url::parse(url.as_ref()).map_err(|e| PjsError::InvalidUrl(e.to_string()))?;
66
67        let (message_tx, message_rx) =
68            byte_bounded_channel(MESSAGE_QUEUE_CAPACITY, MAX_QUEUED_MESSAGE_BYTES);
69
70        Ok(Self {
71            url,
72            sessions: Arc::new(RwLock::new(HashMap::new())),
73            message_tx,
74            message_rx: Arc::new(RwLock::new(Some(message_rx))),
75            write_timeout: super::WRITE_TIMEOUT,
76        })
77    }
78
79    /// Overrides the deadline for a single outbound WebSocket sink write,
80    /// used by the `send_task` spawned in [`Self::connect`].
81    ///
82    /// Defaults to `infrastructure::websocket::WRITE_TIMEOUT` (10s) — see
83    /// its doc for the rationale and the tradeoff it implies for large
84    /// frames sent to slow clients. Pair a shorter value with a
85    /// resource-constrained deployment where freeing a wedged send task
86    /// quickly matters more than absorbing network jitter (mirroring
87    /// `RateLimitConfig::low_resource`'s tightened `write_timeout` on the
88    /// server side); pair a longer value with a deployment that expects
89    /// large payloads over slow or high-latency uplinks and would
90    /// otherwise see legitimate writes misclassified as stalled.
91    ///
92    /// `write_timeout` is not validated: [`Duration::ZERO`] leaves at most
93    /// one poll of the underlying write before it is treated as a timeout,
94    /// and an arbitrarily large value (including [`Duration::MAX`]) is
95    /// accepted as-is and does not panic, since `tokio::time::timeout`
96    /// clamps internally.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use pjson_rs::infrastructure::websocket::PjsWebSocketClient;
102    /// use std::time::Duration;
103    ///
104    /// let client = PjsWebSocketClient::new("ws://localhost:3001/ws")
105    ///     .unwrap()
106    ///     .with_write_timeout(Duration::from_secs(3));
107    /// ```
108    #[must_use]
109    pub fn with_write_timeout(mut self, write_timeout: Duration) -> Self {
110        self.write_timeout = write_timeout;
111        self
112    }
113
114    /// Connect to WebSocket server and start message handling
115    pub async fn connect(&self) -> PjsResult<()> {
116        info!("Connecting to WebSocket server: {}", self.url);
117
118        let (ws_stream, _) = connect_async(self.url.as_str())
119            .await
120            .map_err(|e| PjsError::ConnectionFailed(e.to_string()))?;
121
122        info!("WebSocket connection established");
123
124        let (mut write, mut read) = ws_stream.split();
125
126        // Take the receiver (can only be done once)
127        let mut message_rx = self
128            .message_rx
129            .write()
130            .await
131            .take()
132            .ok_or_else(|| PjsError::ClientError("Client already connected".to_string()))?;
133
134        // Spawn task to send outgoing messages. Messages are already
135        // serialized at the point they were queued (see `request_stream`
136        // and `handle_incoming_message`), so the byte-budget accounting
137        // there matches the bytes actually held in memory here. `split`
138        // (rather than `into_inner`) keeps the byte budget charged until
139        // the write actually completes, not just until the item leaves
140        // the channel.
141        let write_timeout = self.write_timeout;
142        let send_task = tokio::spawn(async move {
143            while let Some(envelope) = message_rx.recv().await {
144                let (json_str, _budget_permit) = envelope.split();
145                if let Err(e) = super::send_with_write_timeout(
146                    &mut write,
147                    Message::Text(json_str.into()),
148                    write_timeout,
149                )
150                .await
151                {
152                    error!("Failed to send message: {}", e);
153                    break;
154                }
155            }
156        });
157
158        // Handle incoming messages
159        let sessions = self.sessions.clone();
160        let message_tx = self.message_tx.clone();
161        let receive_task = tokio::spawn(async move {
162            while let Some(msg) = read.next().await {
163                match msg {
164                    Ok(Message::Text(text)) => match serde_json::from_str::<WsMessage>(&text) {
165                        Ok(ws_message) => {
166                            if let Err(e) = Self::handle_incoming_message(
167                                sessions.clone(),
168                                message_tx.clone(),
169                                ws_message,
170                            )
171                            .await
172                            {
173                                error!("Failed to handle incoming message: {}", e);
174                            }
175                        }
176                        Err(e) => {
177                            warn!("Failed to parse incoming message: {}", e);
178                        }
179                    },
180                    Ok(Message::Binary(data)) => {
181                        debug!("Received binary data: {} bytes", data.len());
182                    }
183                    Ok(Message::Ping(_data)) => {
184                        debug!("Received ping, sending pong");
185                        // Pong is handled automatically by tungstenite
186                    }
187                    Ok(Message::Pong(_)) => {
188                        debug!("Received pong");
189                    }
190                    Ok(Message::Close(_)) => {
191                        info!("Server closed connection");
192                        break;
193                    }
194                    Ok(Message::Frame(_)) => {
195                        // Raw frame - usually handled internally by tungstenite
196                        debug!("Received raw frame");
197                    }
198                    Err(e) => {
199                        error!("WebSocket error: {}", e);
200                        break;
201                    }
202                }
203            }
204        });
205
206        // Wait for either task to complete
207        tokio::select! {
208            _ = send_task => {
209                debug!("Send task completed");
210            }
211            _ = receive_task => {
212                debug!("Receive task completed");
213            }
214        }
215
216        info!("WebSocket connection closed");
217        Ok(())
218    }
219
220    /// Request stream initialization
221    pub async fn request_stream(
222        &self,
223        data: Value,
224        options: Option<StreamOptions>,
225    ) -> PjsResult<String> {
226        let session_id = uuid::Uuid::new_v4().to_string();
227        let options = options.unwrap_or_default();
228
229        let message = WsMessage::StreamInit {
230            session_id: session_id.clone(),
231            data,
232            options,
233        };
234        let json_str = serde_json::to_string(&message).map_err(|e| {
235            PjsError::ClientError(format!("Failed to serialize stream request: {e}"))
236        })?;
237        let len = json_str.len();
238
239        self.message_tx.send(json_str, len).await.map_err(|_| {
240            PjsError::ClientError(
241                "Failed to send stream request: outgoing channel closed".to_string(),
242            )
243        })?;
244
245        // Initialize session tracking
246        let session = ClientStreamSession {
247            id: session_id.clone(),
248            created_at: Instant::now(),
249            received_frames: HashMap::new(),
250            reconstructed_data: serde_json::json!({}),
251            is_complete: false,
252        };
253
254        self.sessions
255            .write()
256            .await
257            .insert(session_id.clone(), session);
258
259        info!("Requested stream initialization: {}", session_id);
260        Ok(session_id)
261    }
262
263    /// Get current reconstructed data for session
264    pub async fn get_current_data(&self, session_id: &str) -> PjsResult<Option<Value>> {
265        let sessions = self.sessions.read().await;
266        Ok(sessions
267            .get(session_id)
268            .map(|session| session.reconstructed_data.clone()))
269    }
270
271    /// Check if stream is complete
272    pub async fn is_stream_complete(&self, session_id: &str) -> bool {
273        let sessions = self.sessions.read().await;
274        sessions
275            .get(session_id)
276            .map(|session| session.is_complete)
277            .unwrap_or(false)
278    }
279
280    /// Get stream statistics
281    pub async fn get_stream_stats(&self, session_id: &str) -> Option<StreamStats> {
282        let sessions = self.sessions.read().await;
283        sessions.get(session_id).map(|session| {
284            let total_frames = session.received_frames.len();
285            let processed_frames = session
286                .received_frames
287                .values()
288                .filter(|frame| frame.processed_at.is_some())
289                .count();
290
291            let avg_processing_time = if processed_frames > 0 {
292                let total_time: Duration = session
293                    .received_frames
294                    .values()
295                    .filter_map(|frame| {
296                        frame
297                            .processed_at
298                            .map(|processed| processed.duration_since(frame.received_at))
299                    })
300                    .sum();
301                Some(total_time / processed_frames as u32)
302            } else {
303                None
304            };
305
306            StreamStats {
307                session_id: session.id.clone(),
308                total_frames,
309                processed_frames,
310                is_complete: session.is_complete,
311                duration: session.created_at.elapsed(),
312                average_processing_time: avg_processing_time,
313            }
314        })
315    }
316
317    /// Best-effort control-message send: serializes `message` and
318    /// `try_send`s it, logging (rather than propagating) both serialization
319    /// and send failures. Used for acks/pongs sent from
320    /// `handle_incoming_message`, which runs inline inside the read loop —
321    /// awaiting a full channel there would stall draining the socket, so
322    /// dropping is preferred over blocking (see call sites for the fuller
323    /// rationale).
324    fn try_send_control_message(
325        message_tx: &ByteBoundedSender<String>,
326        message: &WsMessage,
327        kind: &str,
328    ) {
329        match serde_json::to_string(message) {
330            Ok(json_str) => {
331                let len = json_str.len();
332                if let Err(e) = message_tx.try_send(json_str, len) {
333                    warn!(
334                        "Dropping {} (channel full, byte budget exceeded, or closed): {:?}",
335                        kind, e
336                    );
337                }
338            }
339            Err(e) => warn!("Failed to serialize {}: {}", kind, e),
340        }
341    }
342
343    async fn handle_incoming_message(
344        sessions: Arc<RwLock<HashMap<String, ClientStreamSession>>>,
345        message_tx: ByteBoundedSender<String>,
346        message: WsMessage,
347    ) -> PjsResult<()> {
348        match message {
349            WsMessage::StreamFrame {
350                session_id,
351                frame_id,
352                priority: _priority,
353                payload,
354                is_complete,
355            } => {
356                debug!("Received frame {} for session {}", frame_id, session_id);
357
358                let processing_start = Instant::now();
359
360                {
361                    let mut sessions = sessions.write().await;
362                    if let Some(session) = sessions.get_mut(&session_id) {
363                        // Store received frame
364                        let frame = ReceivedFrame {
365                            received_at: processing_start,
366                            processed_at: None,
367                        };
368                        session.received_frames.insert(frame_id, frame);
369
370                        // Apply frame to reconstructed data
371                        Self::apply_frame_to_data(&mut session.reconstructed_data, &payload)?;
372
373                        if is_complete {
374                            session.is_complete = true;
375                            info!("Stream completed for session {}", session_id);
376                        }
377
378                        // Mark as processed
379                        if let Some(frame) = session.received_frames.get_mut(&frame_id) {
380                            frame.processed_at = Some(Instant::now());
381                        }
382                    }
383                }
384
385                let processing_time = processing_start.elapsed();
386
387                // Send acknowledgment
388                let ack_message = WsMessage::FrameAck {
389                    session_id,
390                    frame_id,
391                    processing_time_ms: processing_time.as_millis() as u64,
392                };
393
394                // Best-effort: dropping an ack is recoverable (the server
395                // can re-send or time out the frame); stalling the whole
396                // read loop is not. See `try_send_control_message`'s doc.
397                Self::try_send_control_message(&message_tx, &ack_message, "frame acknowledgment");
398            }
399            WsMessage::StreamComplete {
400                session_id,
401                checksum,
402            } => {
403                info!("Stream completed: {} (checksum: {})", session_id, checksum);
404
405                let mut sessions = sessions.write().await;
406                if let Some(session) = sessions.get_mut(&session_id) {
407                    session.is_complete = true;
408                }
409            }
410            WsMessage::Error {
411                session_id,
412                error,
413                code,
414            } => {
415                error!(
416                    "Received error from server: session={:?}, error={}, code={}",
417                    session_id, error, code
418                );
419            }
420            WsMessage::Ping { timestamp } => {
421                debug!("Received ping with timestamp: {}", timestamp);
422                let pong = WsMessage::Pong { timestamp };
423                // Same rationale as the ack above: best-effort, must not
424                // stall the read loop by awaiting a full channel.
425                Self::try_send_control_message(&message_tx, &pong, "pong");
426            }
427            WsMessage::Pong { timestamp } => {
428                debug!("Received pong with timestamp: {}", timestamp);
429            }
430            _ => {
431                warn!("Unhandled message type: {:?}", message);
432            }
433        }
434        Ok(())
435    }
436
437    fn apply_frame_to_data(data: &mut Value, payload: &Value) -> PjsResult<()> {
438        // Simple merge strategy - in production, this would be more sophisticated
439        match (data.as_object_mut(), payload.as_object()) {
440            (Some(data_map), Some(payload_map)) => {
441                for (key, value) in payload_map {
442                    data_map.insert(key.clone(), value.clone());
443                }
444            }
445            _ => {
446                *data = payload.clone();
447            }
448        }
449        Ok(())
450    }
451}
452
453/// Stream statistics
454#[derive(Debug, Clone)]
455pub struct StreamStats {
456    /// Identifier of the streaming session.
457    pub session_id: String,
458    /// Total number of frames received from the server.
459    pub total_frames: usize,
460    /// Number of frames the client has finished processing.
461    pub processed_frames: usize,
462    /// Whether the stream has been marked complete.
463    pub is_complete: bool,
464    /// Wall-clock duration since the session was created.
465    pub duration: Duration,
466    /// Average per-frame processing duration, if any frames have been processed.
467    pub average_processing_time: Option<Duration>,
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use serde_json::json;
474
475    #[tokio::test]
476    async fn test_client_creation() {
477        let client = PjsWebSocketClient::new("ws://localhost:3001/ws").unwrap();
478        assert_eq!(client.url.as_str(), "ws://localhost:3001/ws");
479        assert_eq!(client.write_timeout, super::super::WRITE_TIMEOUT);
480    }
481
482    #[tokio::test]
483    async fn test_with_write_timeout_overrides_default() {
484        let client = PjsWebSocketClient::new("ws://localhost:3001/ws")
485            .unwrap()
486            .with_write_timeout(Duration::from_secs(3));
487        assert_eq!(client.write_timeout, Duration::from_secs(3));
488    }
489
490    #[tokio::test]
491    async fn test_stream_session() {
492        let client = PjsWebSocketClient::new("ws://localhost:3001/ws").unwrap();
493        let data = json!({"test": "data"});
494
495        let session_id = client.request_stream(data, None).await.unwrap();
496        assert!(!session_id.is_empty());
497
498        let sessions = client.sessions.read().await;
499        assert!(sessions.contains_key(&session_id));
500    }
501
502    #[tokio::test]
503    async fn test_message_channel_is_bounded() {
504        // Regression test for #314: the client's outgoing message channel
505        // used to be unbounded, so a stalled `send_task` let it grow
506        // without limit. It must now reject sends once
507        // `MESSAGE_QUEUE_CAPACITY` is reached.
508        let client = PjsWebSocketClient::new("ws://localhost:3001/ws").unwrap();
509        let tx = client.message_tx.clone();
510        let pong = "pong".to_string();
511
512        for _ in 0..MESSAGE_QUEUE_CAPACITY {
513            tx.try_send(pong.clone(), pong.len())
514                .expect("channel should accept sends up to its capacity");
515        }
516
517        let result = tx.try_send(pong.clone(), pong.len());
518        assert!(
519            matches!(
520                result,
521                Err(
522                    crate::infrastructure::bounded_channel::TrySendError::Channel(
523                        mpsc::error::TrySendError::Full(_)
524                    )
525                )
526            ),
527            "channel must reject sends past capacity instead of growing unbounded"
528        );
529    }
530
531    #[tokio::test]
532    async fn test_message_channel_rejects_when_byte_budget_exceeded() {
533        // Regression test for #349: a message-count bound alone doesn't
534        // bound queued bytes. A single message larger than
535        // `MAX_QUEUED_MESSAGE_BYTES` must be rejected even though the
536        // channel is nowhere near its message-count capacity.
537        let client = PjsWebSocketClient::new("ws://localhost:3001/ws").unwrap();
538        let tx = client.message_tx.clone();
539        let oversized = "x".repeat(MAX_QUEUED_MESSAGE_BYTES + 1);
540        let len = oversized.len();
541
542        assert!(
543            matches!(
544                tx.try_send(oversized, len),
545                Err(crate::infrastructure::bounded_channel::TrySendError::BudgetExceeded(_))
546            ),
547            "a single over-budget message must be rejected"
548        );
549    }
550
551    #[test]
552    fn test_apply_frame_to_data() {
553        let mut data = json!({"existing": "value"});
554        let payload = json!({"new": "data", "existing": "updated"});
555
556        PjsWebSocketClient::apply_frame_to_data(&mut data, &payload).unwrap();
557
558        assert_eq!(data["existing"], "updated");
559        assert_eq!(data["new"], "data");
560    }
561}