Skip to main content

pjson_rs/infrastructure/websocket/
mod.rs

1//! WebSocket transport layer for real-time PJS streaming
2//!
3//! Provides WebSocket-based streaming with progressive JSON delivery
4//! and backpressure handling for optimal client performance.
5
6use crate::{
7    Error as PjsError, Result as PjsResult, StreamFrame, domain::Priority, security::RateLimitGuard,
8};
9use futures::{Sink, SinkExt};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use sha2::{Digest, Sha256};
13use std::{
14    collections::HashMap,
15    future::Future,
16    sync::Arc,
17    time::{Duration, Instant},
18};
19use tokio::sync::{RwLock, broadcast};
20use tracing::{debug, error, info, warn};
21use uuid::Uuid;
22
23#[cfg(feature = "websocket-client")]
24pub mod client;
25pub mod security;
26#[cfg(feature = "http-server")]
27pub mod server;
28
29#[cfg(feature = "websocket-client")]
30pub use client::{PjsWebSocketClient, StreamStats};
31pub use security::SecureWebSocketHandler;
32#[cfg(feature = "http-server")]
33pub use server::{AxumWebSocketTransport, create_websocket_router};
34
35/// Default deadline for a single outbound WebSocket sink write.
36///
37/// Writing to the sink blocks until the peer's TCP receive buffer drains;
38/// a peer that stops reading (dead connection, slow-loris) would otherwise
39/// wedge the connection's task — and, on the server, the
40/// `Arc<RateLimitGuard>` it holds — until the OS eventually times out the
41/// socket. No existing timeout in `config::security::NetworkLimits` fits
42/// this: `connection_timeout_secs` bounds establishing a connection, not a
43/// steady-state write. 10s is long enough to absorb ordinary network
44/// jitter while still freeing a stuck connection promptly.
45///
46/// This bounds a single `feed`+`flush`, not a minimum throughput: a
47/// legitimate large frame (see `MAX_QUEUED_OUTGOING_BYTES` in `server.rs`,
48/// up to 16 MiB) sent to a genuinely slow-but-honest client needs roughly
49/// 13 Mbps to flush inside 10s, or it gets disconnected same as a stalled
50/// peer would. This is an accepted, documented tradeoff rather than a
51/// per-byte deadline: distinguishing "slow but making progress" from
52/// "stalled" would need tracking partial-write progress, which
53/// `Sink::send`'s `feed`+`flush` doesn't expose, and misclassifying a
54/// stalled peer as "still making progress" is the failure mode this
55/// timeout exists to close. Operators serving large frames to
56/// bandwidth-constrained clients should raise the value passed to
57/// [`send_with_write_timeout`] accordingly (the server threads its value
58/// through `RateLimitConfig::write_timeout`).
59///
60/// Shares its 10s default with `RateLimitConfig::write_timeout`, but the
61/// two are independent constants gated behind different features
62/// (`http-server` vs. none) and can't easily share a single definition —
63/// an intentional change to one's default should be mirrored in the other
64/// unless a divergence is deliberate.
65pub(crate) const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
66
67/// Writes `message` to `sink`, aborting the write if it does not complete
68/// within `timeout` (see [`WRITE_TIMEOUT`] for the production default and
69/// the rationale/tradeoffs of a fixed per-write deadline).
70///
71/// Used at every outbound WebSocket write site (server and client). A
72/// timeout is treated the same as a genuine send error — both mean the
73/// caller should stop and close the connection. Taking `timeout` as a
74/// parameter (rather than hardcoding [`WRITE_TIMEOUT`]) lets callers
75/// configure it (see `RateLimitConfig::write_timeout` on the server side,
76/// and `PjsWebSocketClient::with_write_timeout` on the client side) and
77/// lets tests exercise a real stall deterministically with a short
78/// deadline instead of waiting out the production value.
79pub(crate) async fn send_with_write_timeout<S, M>(
80    sink: &mut S,
81    message: M,
82    timeout: Duration,
83) -> Result<(), String>
84where
85    S: Sink<M> + Unpin,
86    S::Error: std::fmt::Display,
87{
88    match tokio::time::timeout(timeout, sink.send(message)).await {
89        Ok(Ok(())) => Ok(()),
90        Ok(Err(e)) => Err(format!("send failed: {e}")),
91        Err(_elapsed) => Err(format!("write stalled for {timeout:?}")),
92    }
93}
94
95#[cfg(test)]
96mod write_timeout_tests {
97    use super::*;
98
99    #[tokio::test(start_paused = true)]
100    async fn test_send_with_write_timeout_times_out_on_stalled_sink() {
101        let handle = tokio::spawn(async {
102            let mut sink = futures::sink::unfold((), |_, _item: &str| {
103                futures::future::pending::<Result<(), std::io::Error>>()
104            });
105            send_with_write_timeout(&mut sink, "hello", Duration::from_millis(200)).await
106        });
107
108        tokio::time::advance(Duration::from_millis(201)).await;
109
110        let result = handle.await.expect("task panicked");
111        assert!(
112            result.is_err(),
113            "a write that never completes must time out, not hang forever"
114        );
115    }
116
117    #[tokio::test]
118    async fn test_send_with_write_timeout_succeeds_on_ready_sink() {
119        let mut sink = futures::sink::drain();
120        send_with_write_timeout(&mut sink, "hello", WRITE_TIMEOUT)
121            .await
122            .expect("a sink that accepts immediately must not be treated as stalled");
123    }
124}
125
126/// WebSocket message types for PJS streaming
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(tag = "type", content = "data")]
129pub enum WsMessage {
130    /// Stream initialization request
131    StreamInit {
132        /// Identifier of the WebSocket session.
133        session_id: String,
134        /// Source JSON payload to be streamed.
135        data: Value,
136        /// Per-stream options controlling framing and compression.
137        options: StreamOptions,
138    },
139    /// Stream frame with priority data
140    StreamFrame {
141        /// Identifier of the WebSocket session.
142        session_id: String,
143        /// Monotonic frame index within the session.
144        frame_id: u32,
145        /// Priority assigned to this frame.
146        priority: u8,
147        /// Payload carried by the frame.
148        payload: Value,
149        /// Whether this frame completes the stream.
150        is_complete: bool,
151    },
152    /// Client acknowledgment of frame
153    FrameAck {
154        /// Identifier of the WebSocket session.
155        session_id: String,
156        /// Index of the frame being acknowledged.
157        frame_id: u32,
158        /// Time the client took to process the frame, in milliseconds.
159        processing_time_ms: u64,
160    },
161    /// Stream completion signal
162    StreamComplete {
163        /// Identifier of the WebSocket session.
164        session_id: String,
165        /// SHA-256 checksum of the concatenated frame payloads.
166        checksum: String,
167    },
168    /// Error message
169    Error {
170        /// Identifier of the WebSocket session, if known.
171        session_id: Option<String>,
172        /// Human-readable error description.
173        error: String,
174        /// Numeric error code.
175        code: u16,
176    },
177    /// Heartbeat/ping message
178    Ping {
179        /// Wall-clock timestamp at which the ping was sent.
180        timestamp: u64,
181    },
182    /// Heartbeat/pong response
183    Pong {
184        /// Wall-clock timestamp at which the pong was sent.
185        timestamp: u64,
186    },
187}
188
189/// Stream configuration options
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct StreamOptions {
192    /// Maximum frame size in bytes
193    pub max_frame_size: usize,
194    /// Client processing capability (frames per second)
195    pub client_fps: Option<u32>,
196    /// Enable compression
197    pub compression: bool,
198    /// Custom priority mapping
199    pub priority_mapping: Option<HashMap<String, u8>>,
200}
201
202impl Default for StreamOptions {
203    fn default() -> Self {
204        Self {
205            max_frame_size: 64 * 1024, // 64KB
206            client_fps: None,          // Auto-detect
207            compression: true,
208            priority_mapping: None,
209        }
210    }
211}
212
213/// WebSocket streaming session state.
214///
215/// This type is intentionally distinct from the domain-layer
216/// [`crate::domain::aggregates::StreamSession`] aggregate. The WebSocket
217/// transport maintains an ephemeral, transport-local session model with raw
218/// `String` identifiers and an in-memory `HashMap` keyed off
219/// [`AdaptiveStreamController`]; it does **not** share state with the
220/// `StreamRepositoryGat`-backed domain session created by
221/// `POST /pjs/sessions`. Sessions created over WebSocket cannot be addressed
222/// over HTTP and vice versa, and dictionary training, auth, and rate-limit
223/// middleware applied to the HTTP router do not apply to this controller.
224///
225/// See issue #239 for the full rationale and the deliberate split between
226/// the two session models.
227#[derive(Debug)]
228pub struct WebSocketStreamSession {
229    /// Transport-local session identifier.
230    pub id: String,
231    /// Instant the session was created.
232    pub created_at: Instant,
233    /// Streaming options negotiated for this session.
234    pub options: StreamOptions,
235    /// Pre-computed delivery plan as an ordered list of frames.
236    pub plan: Vec<StreamFrame>,
237    /// Index of the next frame to send.
238    pub current_frame: u32,
239    /// Frame indices acknowledged by the client so far.
240    pub acknowledged_frames: Vec<u32>,
241    /// Adaptive streaming metrics derived from client acks.
242    pub client_metrics: ClientMetrics,
243    /// Rate-limit guard scoped to this session, if installed.
244    pub rate_limit_guard: Option<RateLimitGuard>,
245    /// Handle to abort the per-session frame-streaming task on teardown.
246    ///
247    /// Not `pub`: only ever set and read within this module, which keeps
248    /// this session-lifecycle detail out of the public struct-literal
249    /// surface.
250    stream_task: Option<tokio::task::AbortHandle>,
251}
252
253/// Client performance metrics for adaptive streaming
254#[derive(Debug, Default)]
255pub struct ClientMetrics {
256    /// Exponential moving average of client frame-processing time, in milliseconds.
257    pub average_processing_time_ms: f64,
258    /// Number of frames the client has acknowledged.
259    pub frames_acknowledged: u32,
260    /// Instant of the most recent acknowledgement, if any.
261    pub last_ack_time: Option<Instant>,
262    /// Estimated downlink bandwidth, in kilobits per second, if measured.
263    pub estimated_bandwidth_kbps: Option<f64>,
264    /// Round-trip time of the WebSocket connection, in milliseconds, if measured.
265    pub connection_rtt_ms: Option<u64>,
266}
267
268impl ClientMetrics {
269    /// Fold a new processing-time observation into the moving average.
270    pub fn update_processing_time(&mut self, processing_time_ms: u64) {
271        let new_time = processing_time_ms as f64;
272        if self.frames_acknowledged == 0 {
273            self.average_processing_time_ms = new_time;
274        } else {
275            // Exponential moving average
276            let alpha = 0.3;
277            self.average_processing_time_ms =
278                alpha * new_time + (1.0 - alpha) * self.average_processing_time_ms;
279        }
280        self.frames_acknowledged += 1;
281        self.last_ack_time = Some(Instant::now());
282    }
283
284    /// Returns `true` when average client processing time exceeds the slow-client threshold.
285    pub fn is_client_slow(&self) -> bool {
286        self.average_processing_time_ms > 100.0 // > 100ms per frame
287    }
288
289    /// Recommended delay between frames given the current processing-time average.
290    ///
291    /// Clamped to `MAX_ADAPTIVE_FRAME_DELAY`: `average_processing_time_ms` is derived
292    /// from client-supplied `processing_time_ms` in [`Self::update_processing_time`],
293    /// which is unvalidated wire input (see `handle_frame_ack`). Without a ceiling, a
294    /// single malicious `FrameAck` could drive the `tokio::time::sleep` in
295    /// `AdaptiveStreamController::stream_frames` to an arbitrarily long duration and
296    /// stall that session's stream.
297    pub fn recommended_frame_delay(&self) -> Duration {
298        if self.is_client_slow() {
299            Duration::from_millis((self.average_processing_time_ms * 0.5) as u64)
300                .min(MAX_ADAPTIVE_FRAME_DELAY)
301        } else {
302            Duration::from_millis(10) // Fast clients get minimal delay
303        }
304    }
305}
306
307/// Upper bound on the per-frame delay returned by [`ClientMetrics::recommended_frame_delay`].
308const MAX_ADAPTIVE_FRAME_DELAY: Duration = Duration::from_secs(1);
309
310/// WebSocket transport trait for different implementations (GAT-based)
311pub trait WebSocketTransport: Send + Sync {
312    /// Concrete connection type the implementor uses for I/O.
313    type Connection: Send + Sync;
314
315    /// Future type for starting stream
316    type StartStreamFuture<'a>: Future<Output = PjsResult<String>> + Send + 'a
317    where
318        Self: 'a;
319
320    /// Future type for sending frame
321    type SendFrameFuture<'a>: Future<Output = PjsResult<()>> + Send + 'a
322    where
323        Self: 'a;
324
325    /// Future type for handling message
326    type HandleMessageFuture<'a>: Future<Output = PjsResult<()>> + Send + 'a
327    where
328        Self: 'a;
329
330    /// Future type for closing stream
331    type CloseStreamFuture<'a>: Future<Output = PjsResult<()>> + Send + 'a
332    where
333        Self: 'a;
334
335    /// Start streaming session
336    fn start_stream(
337        &self,
338        connection: Arc<Self::Connection>,
339        data: Value,
340        options: StreamOptions,
341    ) -> Self::StartStreamFuture<'_>;
342
343    /// Send frame to client
344    ///
345    /// Implementors typically queue `message` onto a per-connection channel
346    /// consumed by that same connection's I/O loop. Do not call this method
347    /// from within that connection's own message-handling path (e.g. from
348    /// [`WebSocketTransport::handle_message`]): if the implementor applies
349    /// backpressure by awaiting channel capacity rather than dropping,
350    /// calling from the same loop that drains the channel can deadlock the
351    /// connection.
352    fn send_frame(
353        &self,
354        connection: Arc<Self::Connection>,
355        message: WsMessage,
356    ) -> Self::SendFrameFuture<'_>;
357
358    /// Handle incoming message
359    fn handle_message(
360        &self,
361        connection: Arc<Self::Connection>,
362        message: WsMessage,
363    ) -> Self::HandleMessageFuture<'_>;
364
365    /// Close streaming session
366    fn close_stream(&self, session_id: &str) -> Self::CloseStreamFuture<'_>;
367}
368
369/// Adaptive streaming controller
370pub struct AdaptiveStreamController {
371    sessions: Arc<RwLock<HashMap<String, WebSocketStreamSession>>>,
372    frame_tx: broadcast::Sender<(String, WsMessage)>,
373}
374
375impl AdaptiveStreamController {
376    /// Create an empty controller with no active sessions.
377    pub fn new() -> Self {
378        let (frame_tx, _) = broadcast::channel(1000);
379
380        Self {
381            sessions: Arc::new(RwLock::new(HashMap::new())),
382            frame_tx,
383        }
384    }
385
386    /// Create new streaming session
387    pub async fn create_session(&self, data: Value, options: StreamOptions) -> PjsResult<String> {
388        let session_id = Uuid::new_v4().to_string();
389        let plan = vec![StreamFrame {
390            data: data.clone(),
391            priority: Priority::HIGH,
392            metadata: std::collections::HashMap::new(),
393        }]; // Simplified for now
394
395        let session = WebSocketStreamSession {
396            id: session_id.clone(),
397            created_at: Instant::now(),
398            options,
399            plan,
400            current_frame: 0,
401            acknowledged_frames: Vec::new(),
402            client_metrics: ClientMetrics::default(),
403            rate_limit_guard: None, // Will be set when connection is established
404            stream_task: None,      // Set when streaming starts
405        };
406
407        self.sessions
408            .write()
409            .await
410            .insert(session_id.clone(), session);
411
412        info!("Created streaming session: {}", session_id);
413        Ok(session_id)
414    }
415
416    /// Start streaming frames for session
417    pub async fn start_streaming(&self, session_id: &str) -> PjsResult<()> {
418        let mut sessions = self.sessions.write().await;
419        let session = sessions
420            .get_mut(session_id)
421            .ok_or_else(|| PjsError::InvalidSession(session_id.to_string()))?;
422
423        // Start streaming task
424        let session_id = session_id.to_string();
425        let frame_tx = self.frame_tx.clone();
426        let plan = session.plan.clone();
427
428        let task_session_id = session_id.clone();
429        let sessions_for_task = self.sessions.clone();
430        let handle = tokio::spawn(async move {
431            if let Err(e) =
432                Self::stream_frames(task_session_id, plan, frame_tx, sessions_for_task).await
433            {
434                error!("Error streaming frames: {}", e);
435            }
436        });
437
438        // Keep an abort handle so the task can be cancelled on session teardown,
439        // and supervise the join handle to surface panics that would otherwise
440        // be silently swallowed by the runtime. Abort any previous task first —
441        // a repeated start_streaming call for the same session would otherwise
442        // overwrite the handle and leak the earlier task.
443        if let Some(previous) = session.stream_task.replace(handle.abort_handle()) {
444            previous.abort();
445        }
446        tokio::spawn(async move {
447            match handle.await {
448                Ok(()) => {}
449                Err(join_err) if join_err.is_panic() => {
450                    error!(
451                        "Streaming task panicked for session {}: {}",
452                        session_id, join_err
453                    );
454                }
455                Err(_) => {} // task was aborted — expected on session teardown
456            }
457        });
458
459        Ok(())
460    }
461
462    async fn stream_frames(
463        session_id: String,
464        plan: Vec<StreamFrame>, // Simplified for now
465        frame_tx: broadcast::Sender<(String, WsMessage)>,
466        sessions: Arc<RwLock<HashMap<String, WebSocketStreamSession>>>,
467    ) -> Result<(), PjsError> {
468        let mut frames_data = Vec::new();
469
470        for (frame_id, frame) in plan.iter().enumerate() {
471            // Collect frame payload for checksum calculation
472            let payload_bytes =
473                serde_json::to_vec(&frame.data).map_err(|e| PjsError::Other(e.to_string()))?;
474            frames_data.push(payload_bytes);
475
476            let ws_message = WsMessage::StreamFrame {
477                session_id: session_id.clone(),
478                frame_id: frame_id as u32,
479                priority: frame.priority.value(),
480                payload: frame.data.clone(),
481                is_complete: frame_id == (plan.len() - 1),
482            };
483
484            if let Err(e) = frame_tx.send((session_id.clone(), ws_message)) {
485                error!("Failed to send frame {}: {}", frame_id, e);
486                break;
487            }
488
489            let delay = sessions
490                .read()
491                .await
492                .get(&session_id)
493                .map(|session| session.client_metrics.recommended_frame_delay())
494                .unwrap_or(Duration::from_millis(10));
495            tokio::time::sleep(delay).await;
496        }
497
498        // Send completion message with calculated checksum
499        let complete_message = WsMessage::StreamComplete {
500            session_id: session_id.clone(),
501            checksum: calculate_stream_checksum(&frames_data),
502        };
503
504        let _ = frame_tx.send((session_id, complete_message));
505        Ok(())
506    }
507
508    /// Handle frame acknowledgment
509    pub async fn handle_frame_ack(
510        &self,
511        session_id: &str,
512        frame_id: u32,
513        processing_time_ms: u64,
514    ) -> PjsResult<()> {
515        let mut sessions = self.sessions.write().await;
516        let session = sessions
517            .get_mut(session_id)
518            .ok_or_else(|| PjsError::InvalidSession(session_id.to_string()))?;
519
520        session.acknowledged_frames.push(frame_id);
521        session
522            .client_metrics
523            .update_processing_time(processing_time_ms);
524
525        debug!(
526            "Frame {} acknowledged for session {} (processing: {}ms, avg: {:.1}ms)",
527            frame_id,
528            session_id,
529            processing_time_ms,
530            session.client_metrics.average_processing_time_ms
531        );
532
533        if session.client_metrics.is_client_slow() {
534            warn!(
535                "Client {} is processing slowly (avg: {:.1}ms)",
536                session_id, session.client_metrics.average_processing_time_ms
537            );
538        }
539
540        Ok(())
541    }
542
543    /// Get subscriber for frame events
544    pub fn subscribe_frames(&self) -> broadcast::Receiver<(String, WsMessage)> {
545        self.frame_tx.subscribe()
546    }
547
548    /// Set rate limit guard for a session
549    pub async fn set_rate_limit_guard(
550        &self,
551        session_id: &str,
552        guard: RateLimitGuard,
553    ) -> PjsResult<()> {
554        let mut sessions = self.sessions.write().await;
555        let session = sessions
556            .get_mut(session_id)
557            .ok_or_else(|| PjsError::InvalidSession(session_id.to_string()))?;
558
559        session.rate_limit_guard = Some(guard);
560        Ok(())
561    }
562
563    /// Validate message against rate limits
564    pub async fn validate_message(&self, session_id: &str, frame_size: usize) -> PjsResult<()> {
565        let sessions = self.sessions.read().await;
566        let session = sessions
567            .get(session_id)
568            .ok_or_else(|| PjsError::InvalidSession(session_id.to_string()))?;
569
570        if let Some(guard) = &session.rate_limit_guard {
571            guard
572                .check_message(frame_size)
573                .map_err(|e| PjsError::SecurityError(format!("Rate limit violation: {}", e)))?;
574        }
575
576        Ok(())
577    }
578
579    /// Remove a single session by id.
580    ///
581    /// Returns `true` if the session existed and was removed, `false` if the id
582    /// was not present. Callers may safely invoke this multiple times — the
583    /// second call is a no-op.
584    ///
585    /// # Examples
586    ///
587    /// ```no_run
588    /// # use pjson_rs::infrastructure::websocket::{AdaptiveStreamController, StreamOptions};
589    /// # use serde_json::json;
590    /// # #[tokio::main] async fn main() {
591    /// let controller = AdaptiveStreamController::new();
592    /// let id = controller.create_session(json!({}), StreamOptions::default()).await.unwrap();
593    /// assert!(controller.remove_session(&id).await);
594    /// // Idempotent — second call is a no-op:
595    /// assert!(!controller.remove_session(&id).await);
596    /// # }
597    /// ```
598    pub async fn remove_session(&self, session_id: &str) -> bool {
599        let mut sessions = self.sessions.write().await;
600        let removed = sessions.remove(session_id);
601        match &removed {
602            Some(session) => {
603                if let Some(abort_handle) = &session.stream_task {
604                    abort_handle.abort();
605                }
606                info!("Removed streaming session: {}", session_id);
607            }
608            None => debug!("remove_session called on unknown id: {}", session_id),
609        }
610        removed.is_some()
611    }
612
613    /// Clean up expired sessions
614    pub async fn cleanup_expired_sessions(&self, max_age: Duration) {
615        let mut sessions = self.sessions.write().await;
616        let now = Instant::now();
617
618        sessions.retain(|id, session| {
619            let expired = now.duration_since(session.created_at) > max_age;
620            if expired {
621                if let Some(abort_handle) = &session.stream_task {
622                    abort_handle.abort();
623                }
624                info!("Cleaning up expired session: {}", id);
625            }
626            !expired
627        });
628    }
629}
630
631impl Default for AdaptiveStreamController {
632    fn default() -> Self {
633        Self::new()
634    }
635}
636
637/// Calculate SHA-256 checksum for stream completion verification
638fn calculate_stream_checksum(frames_data: &[Vec<u8>]) -> String {
639    let mut hasher = Sha256::new();
640
641    // Hash each frame's data
642    for frame_data in frames_data {
643        hasher.update(frame_data);
644    }
645
646    // Hash frame count to ensure integrity
647    hasher.update((frames_data.len() as u64).to_le_bytes());
648
649    let result = hasher.finalize();
650    let hex: String = result.iter().map(|byte| format!("{byte:02x}")).collect();
651    format!("sha256:{hex}")
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use serde_json::json;
658
659    #[tokio::test]
660    async fn test_create_session() {
661        let controller = AdaptiveStreamController::new();
662        let data = json!({
663            "critical": {"id": 1, "status": "active"},
664            "details": {"name": "test", "description": "test data"}
665        });
666
667        let session_id = controller
668            .create_session(data, StreamOptions::default())
669            .await
670            .unwrap();
671
672        assert!(!session_id.is_empty());
673
674        let sessions = controller.sessions.read().await;
675        assert!(sessions.contains_key(&session_id));
676    }
677
678    #[tokio::test]
679    async fn test_frame_acknowledgment() {
680        let controller = AdaptiveStreamController::new();
681        let data = json!({"test": "data"});
682
683        let session_id = controller
684            .create_session(data, StreamOptions::default())
685            .await
686            .unwrap();
687
688        controller
689            .handle_frame_ack(&session_id, 0, 50)
690            .await
691            .unwrap();
692
693        let sessions = controller.sessions.read().await;
694        let session = sessions.get(&session_id).unwrap();
695        assert_eq!(session.acknowledged_frames, vec![0]);
696        assert_eq!(session.client_metrics.average_processing_time_ms, 50.0);
697    }
698
699    /// Proves `remove_session` actually stops the streaming task rather
700    /// than merely dropping the session's bookkeeping entry: a long plan
701    /// is aborted before it can send every frame or the completion
702    /// message, instead of running to completion in the background.
703    #[tokio::test]
704    async fn test_remove_session_aborts_streaming_task_before_completion() {
705        let controller = AdaptiveStreamController::new();
706        let session_id = controller
707            .create_session(json!({"test": "data"}), StreamOptions::default())
708            .await
709            .unwrap();
710
711        // Long enough (10ms/frame) that the task is still far from done
712        // when we abort it immediately below.
713        {
714            let mut sessions = controller.sessions.write().await;
715            let session = sessions.get_mut(&session_id).unwrap();
716            session.plan = (0..200)
717                .map(|_| StreamFrame {
718                    data: json!({}),
719                    priority: Priority::HIGH,
720                    metadata: std::collections::HashMap::new(),
721                })
722                .collect();
723        }
724
725        let mut frames_rx = controller.subscribe_frames();
726
727        controller.start_streaming(&session_id).await.unwrap();
728        assert!(controller.remove_session(&session_id).await);
729
730        // Drain whatever the task managed to emit before the abort took
731        // effect, for a window far shorter than the full 200-frame plan
732        // (~2s) would take to complete on its own.
733        let mut saw_complete = false;
734        let mut frame_count = 0;
735        let drain_deadline = tokio::time::Instant::now() + Duration::from_millis(300);
736        while tokio::time::Instant::now() < drain_deadline {
737            match tokio::time::timeout(Duration::from_millis(20), frames_rx.recv()).await {
738                Ok(Ok((_, WsMessage::StreamComplete { .. }))) => {
739                    saw_complete = true;
740                    break;
741                }
742                Ok(Ok(_)) => frame_count += 1,
743                Ok(Err(_)) => break, // channel closed
744                Err(_) => {}         // no message in this slice; keep polling
745            }
746        }
747
748        assert!(
749            !saw_complete,
750            "streaming task must not run to completion after remove_session aborts it"
751        );
752        assert!(
753            frame_count < 200,
754            "streaming task must stop well short of the full plan once aborted, sent {frame_count} frames"
755        );
756    }
757
758    #[test]
759    fn test_client_metrics() {
760        let mut metrics = ClientMetrics::default();
761
762        metrics.update_processing_time(100);
763        assert_eq!(metrics.average_processing_time_ms, 100.0);
764
765        metrics.update_processing_time(200);
766        // Should be exponential moving average: 0.3 * 200 + 0.7 * 100 = 130
767        assert!((metrics.average_processing_time_ms - 130.0).abs() < 0.1);
768
769        assert!(metrics.is_client_slow());
770    }
771
772    /// Regression test for a malicious `FrameAck` (see `handle_frame_ack`) reporting an
773    /// astronomical `processing_time_ms`: without the clamp in `recommended_frame_delay`,
774    /// this would drive its output — and thus the per-frame `sleep` in `stream_frames` —
775    /// to roughly 46 days for a single ack.
776    #[test]
777    fn test_recommended_frame_delay_clamps_extreme_processing_time() {
778        let mut metrics = ClientMetrics::default();
779        metrics.update_processing_time(100_000_000_000);
780
781        assert_eq!(
782            metrics.recommended_frame_delay(),
783            MAX_ADAPTIVE_FRAME_DELAY,
784            "delay must be clamped to MAX_ADAPTIVE_FRAME_DELAY, not scale unbounded with client-supplied input"
785        );
786    }
787
788    /// Exercises the actual per-frame delay read path in `stream_frames`, not just the
789    /// pure `recommended_frame_delay` function: a session whose `client_metrics` were
790    /// poisoned by a malicious ack must still complete its stream promptly instead of
791    /// stalling on an unbounded `tokio::time::sleep`.
792    #[tokio::test]
793    async fn test_stream_frames_completes_promptly_under_malicious_client_metrics() {
794        let controller = AdaptiveStreamController::new();
795        let session_id = controller
796            .create_session(json!({"test": "data"}), StreamOptions::default())
797            .await
798            .unwrap();
799
800        {
801            let mut sessions = controller.sessions.write().await;
802            sessions
803                .get_mut(&session_id)
804                .unwrap()
805                .client_metrics
806                .update_processing_time(100_000_000_000);
807        }
808
809        let mut frames_rx = controller.subscribe_frames();
810        controller.start_streaming(&session_id).await.unwrap();
811
812        let result = tokio::time::timeout(MAX_ADAPTIVE_FRAME_DELAY * 2, async {
813            loop {
814                match frames_rx
815                    .recv()
816                    .await
817                    .expect("channel must not close early")
818                {
819                    (sid, WsMessage::StreamComplete { .. }) if sid == session_id => break,
820                    _ => continue,
821                }
822            }
823        })
824        .await;
825
826        assert!(
827            result.is_ok(),
828            "stream must complete within 2x MAX_ADAPTIVE_FRAME_DELAY, not stall on malicious client metrics"
829        );
830    }
831
832    #[test]
833    fn test_checksum_calculation() {
834        // Test empty frames
835        let empty_frames: Vec<Vec<u8>> = vec![];
836        let checksum = calculate_stream_checksum(&empty_frames);
837        assert!(checksum.starts_with("sha256:"));
838
839        // Test single frame
840        let single_frame = vec![vec![1, 2, 3, 4]];
841        let checksum1 = calculate_stream_checksum(&single_frame);
842        assert!(checksum1.starts_with("sha256:"));
843
844        // Test multiple frames
845        let multi_frames = vec![vec![1, 2], vec![3, 4], vec![5, 6]];
846        let checksum2 = calculate_stream_checksum(&multi_frames);
847        assert!(checksum2.starts_with("sha256:"));
848
849        // Same data should produce same checksum
850        let same_frames = vec![vec![1, 2], vec![3, 4], vec![5, 6]];
851        let checksum3 = calculate_stream_checksum(&same_frames);
852        assert_eq!(checksum2, checksum3);
853
854        // Different data should produce different checksum
855        let diff_frames = vec![vec![1, 2], vec![3, 4], vec![5, 7]]; // Last byte different
856        let checksum4 = calculate_stream_checksum(&diff_frames);
857        assert_ne!(checksum2, checksum4);
858
859        // Different order should produce different checksum
860        let reordered_frames = vec![vec![3, 4], vec![1, 2], vec![5, 6]];
861        let checksum5 = calculate_stream_checksum(&reordered_frames);
862        assert_ne!(checksum2, checksum5);
863    }
864}