Skip to main content

pjson_rs/infrastructure/websocket/
server.rs

1//! WebSocket server implementation for Axum
2
3#[cfg(feature = "http-server")]
4use super::{AdaptiveStreamController, StreamOptions, WebSocketTransport, WsMessage};
5use crate::{
6    Result as PjsResult,
7    infrastructure::bounded_channel::{self, ByteBoundedSender, byte_bounded_channel},
8    security::{RateLimitConfig, RateLimitGuard, WebSocketRateLimiter},
9};
10#[cfg(feature = "http-server")]
11use axum::{
12    extract::{
13        ConnectInfo, State, WebSocketUpgrade,
14        ws::{Message, WebSocket},
15    },
16    http::StatusCode,
17    response::{IntoResponse, Response},
18};
19use futures::StreamExt;
20use serde_json::Value;
21use std::collections::HashMap;
22use std::future::Future;
23use std::net::{IpAddr, SocketAddr};
24use std::sync::Arc;
25use std::time::Duration;
26use tokio::sync::RwLock;
27use tokio::sync::broadcast::error::RecvError;
28use tracing::{debug, error, info, warn};
29use uuid;
30
31/// Capacity of each per-connection outgoing message channel.
32///
33/// Bounds how many frames can queue for a slow client before `send_frame`
34/// drops further frames rather than growing memory without limit (see
35/// `send_frame`'s doc for why it drops instead of awaiting capacity).
36/// This is a message-count bound only; [`MAX_QUEUED_OUTGOING_BYTES`]
37/// additionally bounds cumulative queued bytes, so a connection queuing
38/// many large frames is capped well before it could reach
39/// `OUTGOING_QUEUE_CAPACITY * max_frame_size`.
40const OUTGOING_QUEUE_CAPACITY: usize = 1000;
41
42/// Cumulative byte budget for a single connection's outgoing message
43/// channel, on top of [`OUTGOING_QUEUE_CAPACITY`]'s message-count bound.
44///
45/// Without this, `OUTGOING_QUEUE_CAPACITY` alone bounds queue depth but
46/// not queued bytes: at the default 16 MiB `max_websocket_frame_size`, a
47/// fully-queued connection could hold up to `1000 * 16 MiB` ≈ 16 GiB.
48/// 16 MiB keeps worst-case per-connection queued memory a small,
49/// predictable constant regardless of individual message size.
50const MAX_QUEUED_OUTGOING_BYTES: usize = 16 * 1024 * 1024;
51
52/// How often the background sweep spawned by
53/// [`AxumWebSocketTransport::with_rate_limit_config`] checks for streaming
54/// sessions older than [`SESSION_MAX_AGE`].
55const SESSION_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
56
57/// Maximum age of a controller-tracked streaming session before the
58/// background sweep removes it (aborting its streaming task) even if the
59/// owning connection's teardown never ran, e.g. a broadcast-lagged frame
60/// receiver or a session created but never associated with a live
61/// connection.
62const SESSION_MAX_AGE: Duration = Duration::from_secs(3600);
63
64/// Pre-resolved `Origin` allow-list policy for [`AxumWebSocketTransport::upgrade_handler`].
65///
66/// Resolved once at construction (see [`AxumWebSocketTransport::with_allowed_origins`])
67/// from a `Vec<String>` with the same config semantics as
68/// [`crate::infrastructure::http::axum_adapter::build_cors_layer_from_origins`]'s
69/// `allowed_origins`, rather than re-parsing the list on every upgrade request.
70#[derive(Debug, Clone)]
71enum OriginAllowList {
72    /// `[]` — deny all cross-origin upgrades (fail-closed default).
73    DenyAll,
74    /// `["*"]` — allow any origin.
75    ///
76    /// **This is more permissive on a WebSocket endpoint than the
77    /// equivalent CORS `Any` on an HTTP endpoint.** Browsers refuse to
78    /// send credentials (cookies) on a CORS request whose response is
79    /// `Access-Control-Allow-Origin: *`, so wildcard CORS can't itself be
80    /// used to steal a credentialed session. WebSocket has no such rule —
81    /// the browser attaches ambient credentials to the handshake
82    /// regardless of what the server's `Origin` policy turns out to be.
83    /// Setting this fully re-enables the CSWSH this allow-list exists to
84    /// prevent; only use it for endpoints that perform their own
85    /// authentication and don't rely on browser ambient credentials.
86    Any,
87    /// Explicit origin list, matched by case-sensitive byte equality
88    /// against the `Origin` header value.
89    Explicit(Vec<axum::http::HeaderValue>),
90}
91
92impl OriginAllowList {
93    /// Resolve a raw `allowed_origins` list into a policy.
94    ///
95    /// Mixing `"*"` with explicit origins is treated as [`Self::DenyAll`]
96    /// (fail-closed) rather than a construction error: unlike
97    /// `build_cors_layer_from_origins`, this is called from a builder that
98    /// returns `Self`, not `Result`.
99    fn resolve(allowed_origins: &[String]) -> Self {
100        let has_wildcard = allowed_origins.iter().any(|o| o == "*");
101        let has_explicit = allowed_origins.iter().any(|o| o != "*");
102
103        match (allowed_origins.is_empty(), has_wildcard, has_explicit) {
104            (true, _, _) => OriginAllowList::DenyAll,
105            (_, true, true) => OriginAllowList::DenyAll,
106            (_, true, false) => OriginAllowList::Any,
107            (_, false, _) => OriginAllowList::Explicit(
108                allowed_origins
109                    .iter()
110                    .filter_map(|o| Self::parse_explicit_origin(o))
111                    .collect(),
112            ),
113        }
114    }
115
116    /// Parse one explicit `allowed_origins` entry, warning about entries
117    /// that can never match a real browser `Origin` header instead of
118    /// silently accepting them.
119    ///
120    /// A real `Origin` value is always a lowercase `scheme://host[:port]`
121    /// with no path. An entry like `"example.com"` (missing scheme),
122    /// `"https://example.com/"` (trailing path), or
123    /// `"HTTPS://Example.com"` (uppercase) still parses as a valid
124    /// `HeaderValue` and is kept fail-closed, but can never equal an
125    /// actual `Origin` header byte-for-byte — making that entry an
126    /// effective silent deny with no other diagnostic, unlike
127    /// `build_cors_layer_from_origins`, which hard-errors on unparseable
128    /// origins. An entry that fails to parse as a `HeaderValue` at all is
129    /// dropped (it could never match anything).
130    fn parse_explicit_origin(origin: &str) -> Option<axum::http::HeaderValue> {
131        // `"null"` is a legitimate `Origin` value per the Fetch/HTML spec —
132        // sent by sandboxed iframes, `file://` pages, and some redirected
133        // requests — not a malformed entry, so it's exempt from the
134        // shape check below.
135        let looks_like_origin = origin == "null"
136            || origin.split_once("://").is_some_and(|(scheme, rest)| {
137                !rest.contains('/')
138                    && !scheme.bytes().any(|b| b.is_ascii_uppercase())
139                    && !rest.bytes().any(|b| b.is_ascii_uppercase())
140            });
141        if !looks_like_origin {
142            warn!(
143                "WebSocket allowed_origins entry {origin:?} does not look like a real Origin \
144                 (expected lowercase `scheme://host[:port]` with no path) and will likely never \
145                 match a real request"
146            );
147        }
148
149        match origin.parse::<axum::http::HeaderValue>() {
150            Ok(value) => Some(value),
151            Err(e) => {
152                warn!(
153                    "WebSocket allowed_origins entry {origin:?} is not a valid header value \
154                     and is being dropped: {e}"
155                );
156                None
157            }
158        }
159    }
160
161    /// Whether a present `Origin` header value is allowed.
162    fn allows(&self, origin: &axum::http::HeaderValue) -> bool {
163        match self {
164            OriginAllowList::DenyAll => false,
165            OriginAllowList::Any => true,
166            OriginAllowList::Explicit(list) => list.iter().any(|o| o == origin),
167        }
168    }
169}
170
171/// Axum WebSocket transport implementation
172pub struct AxumWebSocketTransport {
173    controller: Arc<AdaptiveStreamController>,
174    /// Active connection IDs for tracking open sockets
175    active_connections: Arc<RwLock<Vec<String>>>,
176    /// Per-connection outgoing senders; keyed by connection ID
177    outgoing_channels: Arc<RwLock<HashMap<String, ByteBoundedSender<String>>>>,
178    /// Streaming session IDs created by each connection (via `StreamInit`),
179    /// so [`Self::handle_socket`]'s teardown can abort the right sessions'
180    /// streaming tasks when the connection closes.
181    connection_sessions: Arc<RwLock<HashMap<String, Vec<String>>>>,
182    /// Per-IP rate limiter applied to upgrade requests, connection establishment,
183    /// and inbound application-level messages.
184    rate_limiter: Arc<WebSocketRateLimiter>,
185    /// `Origin` allow-list applied to WebSocket upgrades, to block
186    /// cross-site WebSocket hijacking (CSWSH) from browser clients. See
187    /// [`Self::with_allowed_origins`].
188    allowed_origins: OriginAllowList,
189}
190
191impl AxumWebSocketTransport {
192    /// Create a transport with the default rate-limit configuration.
193    ///
194    /// See [`RateLimitConfig::default`] for the limits applied.
195    pub fn new() -> Self {
196        Self::with_rate_limit_config(RateLimitConfig::default())
197    }
198
199    /// Create a transport with an explicit rate-limit configuration.
200    ///
201    /// Use [`RateLimitConfig::high_traffic`] or [`RateLimitConfig::low_resource`]
202    /// for preset profiles, or construct a custom [`RateLimitConfig`].
203    ///
204    /// Spawns a background sweep that periodically aborts streaming
205    /// sessions older than `SESSION_MAX_AGE` via
206    /// [`AdaptiveStreamController::cleanup_expired_sessions`]; the sweep
207    /// holds only a [`std::sync::Weak`] reference to the controller, so it
208    /// exits once every `Arc<AdaptiveStreamController>` (including this
209    /// transport's own) is dropped, instead of keeping the controller alive
210    /// forever.
211    pub fn with_rate_limit_config(config: RateLimitConfig) -> Self {
212        let controller = Arc::new(AdaptiveStreamController::new());
213
214        let weak_controller = Arc::downgrade(&controller);
215        tokio::spawn(async move {
216            let mut interval = tokio::time::interval(SESSION_CLEANUP_INTERVAL);
217            loop {
218                interval.tick().await;
219                let Some(controller) = weak_controller.upgrade() else {
220                    break;
221                };
222                controller.cleanup_expired_sessions(SESSION_MAX_AGE).await;
223            }
224        });
225
226        Self {
227            controller,
228            active_connections: Arc::new(RwLock::new(Vec::new())),
229            outgoing_channels: Arc::new(RwLock::new(HashMap::new())),
230            connection_sessions: Arc::new(RwLock::new(HashMap::new())),
231            rate_limiter: Arc::new(WebSocketRateLimiter::new(config)),
232            allowed_origins: OriginAllowList::DenyAll,
233        }
234    }
235
236    /// Restrict WebSocket upgrades to the given `Origin` allow-list.
237    ///
238    /// Reuses the config semantics of
239    /// [`HttpServerConfig::allowed_origins`](crate::infrastructure::http::axum_adapter::HttpServerConfig::allowed_origins)'s
240    /// CORS allow-list:
241    /// - `[]` (the default) — deny all cross-origin upgrades (fail-closed)
242    /// - `["*"]` — allow any origin. **More dangerous here than the
243    ///   equivalent CORS `Any`**: browsers attach ambient credentials to a
244    ///   WebSocket handshake regardless of the server's `Origin` response,
245    ///   unlike CORS, so a wildcard here fully re-enables the CSWSH this
246    ///   allow-list exists to prevent.
247    /// - `"*"` mixed with explicit origins — treated as deny-all (fail
248    ///   closed); unlike the CORS layer this cannot be surfaced as a
249    ///   construction error, since this builder returns `Self`
250    ///
251    /// Explicit entries that can never match a real `Origin` header (no
252    /// `scheme://`, a trailing path, or uppercase letters) are kept
253    /// fail-closed but logged with `warn!`, since they'd otherwise silently
254    /// deny every browser connection with no diagnostic.
255    ///
256    /// This only governs requests that *carry* an `Origin` header. A
257    /// request without one is always allowed to upgrade regardless of this
258    /// list — see [`Self::upgrade_handler`] for why that is safe.
259    pub fn with_allowed_origins(mut self, allowed_origins: Vec<String>) -> Self {
260        self.allowed_origins = OriginAllowList::resolve(&allowed_origins);
261        self
262    }
263
264    /// Handle WebSocket upgrade for Axum.
265    ///
266    /// Extracts the peer address via [`ConnectInfo`] and rejects upgrade
267    /// requests that exceed the per-IP request budget with HTTP 429 before any
268    /// WebSocket frames are exchanged.
269    ///
270    /// Also rejects, with HTTP 403, upgrades carrying an `Origin` header not
271    /// in [`Self::with_allowed_origins`]'s allow-list — see that method and
272    /// the check's own doc comment below for the CSWSH threat model and why
273    /// a missing `Origin` header is allowed.
274    ///
275    /// Configures axum/tungstenite's transport-level `max_message_size` and
276    /// `max_frame_size` from the transport's [`RateLimitConfig::max_frame_size`],
277    /// so an oversized frame is rejected during frame assembly instead of
278    /// being fully buffered first and only rejected afterward by the
279    /// application-level `check_message` call (which remains as
280    /// defense-in-depth for messages under the transport cap but still over
281    /// policy in other ways).
282    ///
283    /// The router must be served with
284    /// `into_make_service_with_connect_info::<SocketAddr>()` so the peer
285    /// address is populated; otherwise the upgrade response is HTTP 500.
286    pub async fn upgrade_handler(
287        ws: WebSocketUpgrade,
288        ConnectInfo(addr): ConnectInfo<SocketAddr>,
289        headers: axum::http::HeaderMap,
290        State(transport): State<Arc<Self>>,
291    ) -> Response {
292        let client_ip = addr.ip();
293
294        if let Err(e) = transport.rate_limiter.check_request(client_ip) {
295            warn!("WebSocket upgrade denied for IP {}: {}", client_ip, e);
296            return (StatusCode::TOO_MANY_REQUESTS, e.to_string()).into_response();
297        }
298
299        // Browsers always attach `Origin` to a WebSocket handshake, and
300        // CSWSH depends on the browser sending that header along with
301        // ambient credentials (cookies). A missing `Origin` therefore
302        // cannot be a browser exploiting CSWSH — it's a native client, e.g.
303        // `PjsWebSocketClient` or a non-browser tool, none of which send
304        // one. Rejecting those would break every native client while
305        // gaining no CSWSH protection, so an absent header is always
306        // allowed here regardless of `allowed_origins`. Combined with the
307        // fail-closed `DenyAll` default, this means browser clients are
308        // refused by default while native clients keep working.
309        if let Some(origin) = headers.get(axum::http::header::ORIGIN)
310            && !transport.allowed_origins.allows(origin)
311        {
312            warn!(
313                "WebSocket upgrade rejected for IP {}: disallowed Origin {:?}",
314                client_ip, origin
315            );
316            return StatusCode::FORBIDDEN.into_response();
317        }
318
319        let max_frame_size = transport.rate_limiter.config().max_frame_size;
320        let ws = ws
321            .max_message_size(max_frame_size)
322            .max_frame_size(max_frame_size);
323
324        ws.on_upgrade(move |socket| transport.handle_socket(socket, client_ip))
325    }
326
327    /// Handle WebSocket connection lifecycle
328    pub async fn handle_socket(self: Arc<Self>, socket: WebSocket, client_ip: IpAddr) {
329        info!("New WebSocket connection established from {}", client_ip);
330
331        let write_timeout = self.rate_limiter.config().write_timeout;
332
333        let guard = match RateLimitGuard::new(self.rate_limiter.clone(), client_ip) {
334            Ok(g) => Arc::new(g),
335            Err(e) => {
336                warn!(
337                    "WebSocket connection rejected for IP {} (rate limit): {}",
338                    client_ip, e
339                );
340                let (mut sender, _) = socket.split();
341                let _ = super::send_with_write_timeout(
342                    &mut sender,
343                    Message::Close(Some(axum::extract::ws::CloseFrame {
344                        code: 1008, // Policy Violation
345                        reason: e.to_string().into(),
346                    })),
347                    write_timeout,
348                )
349                .await;
350                return;
351            }
352        };
353
354        let connection_id = uuid::Uuid::new_v4().to_string();
355        self.active_connections
356            .write()
357            .await
358            .push(connection_id.clone());
359
360        let frame_rx = self.controller.subscribe_frames();
361
362        // Create channel for sending outgoing messages to this connection
363        let (outgoing_tx, mut outgoing_rx) =
364            byte_bounded_channel::<String>(OUTGOING_QUEUE_CAPACITY, MAX_QUEUED_OUTGOING_BYTES);
365        self.outgoing_channels
366            .write()
367            .await
368            .insert(connection_id.clone(), outgoing_tx);
369
370        let (mut sender, mut receiver) = socket.split();
371
372        // Spawn single task to handle both sending and receiving
373        let transport_clone = self.clone();
374        let connection_id_clone = Arc::new(connection_id.clone());
375        let guard_for_task = guard.clone();
376        let websocket_task = {
377            let mut frame_rx = frame_rx;
378            tokio::spawn(async move {
379                loop {
380                    tokio::select! {
381                        // Handle frames from stream controller. Match on the full
382                        // Result so Lagged is logged-and-skipped while Closed
383                        // ends the loop instead of busy-spinning.
384                        recv_result = frame_rx.recv() => {
385                            match recv_result {
386                                Ok((_session_id, message)) => {
387                                    match serde_json::to_string(&message) {
388                                        Ok(json_str) => {
389                                            if let Err(e) = super::send_with_write_timeout(&mut sender, Message::Text(json_str.into()), write_timeout).await {
390                                                error!("Failed to send message to client: {}", e);
391                                                break;
392                                            }
393                                        }
394                                        Err(e) => {
395                                            error!("Failed to serialize message: {}", e);
396                                        }
397                                    }
398                                }
399                                Err(RecvError::Lagged(skipped)) => {
400                                    warn!("Frame broadcast lagged; skipped {} frames", skipped);
401                                }
402                                Err(RecvError::Closed) => {
403                                    debug!("Frame broadcast channel closed");
404                                    break;
405                                }
406                            }
407                        }
408                        // Handle outgoing messages from application. Already
409                        // serialized at `send_frame` time — see its doc for why.
410                        // `split` (rather than `into_inner`) keeps the byte
411                        // budget charged until the write actually completes,
412                        // not just until the item leaves the channel.
413                        Some(envelope) = outgoing_rx.recv() => {
414                            let (json_str, _budget_permit) = envelope.split();
415                            if let Err(e) = super::send_with_write_timeout(&mut sender, Message::Text(json_str.into()), write_timeout).await {
416                                error!("Failed to send outgoing message to client: {}", e);
417                                break;
418                            }
419                        }
420                        // Handle incoming messages from client
421                        Some(msg) = receiver.next() => {
422                            match msg {
423                                Ok(Message::Text(text)) => {
424                                    if let Err(e) = guard_for_task.check_message(text.len()) {
425                                        warn!(
426                                            "Inbound text frame rejected for IP {} (rate limit): {}",
427                                            client_ip, e
428                                        );
429                                        let _ = super::send_with_write_timeout(
430                                            &mut sender,
431                                            Message::Close(Some(axum::extract::ws::CloseFrame {
432                                                code: 1008,
433                                                reason: e.to_string().into(),
434                                            })),
435                                            write_timeout,
436                                        ).await;
437                                        break;
438                                    }
439                                    match serde_json::from_str::<WsMessage>(&text) {
440                                        Ok(ws_message) => {
441                                            if let Err(e) = transport_clone.handle_websocket_message(Arc::clone(&connection_id_clone), ws_message).await {
442                                                error!("Failed to handle message: {}", e);
443                                            }
444                                        }
445                                        Err(e) => {
446                                            warn!("Failed to parse WebSocket message: {}", e);
447                                        }
448                                    }
449                                }
450                                Ok(Message::Binary(data)) => {
451                                    if let Err(e) = guard_for_task.check_message(data.len()) {
452                                        warn!(
453                                            "Inbound binary frame rejected for IP {} (rate limit): {}",
454                                            client_ip, e
455                                        );
456                                        let _ = super::send_with_write_timeout(
457                                            &mut sender,
458                                            Message::Close(Some(axum::extract::ws::CloseFrame {
459                                                code: 1008,
460                                                reason: e.to_string().into(),
461                                            })),
462                                            write_timeout,
463                                        ).await;
464                                        break;
465                                    }
466                                    debug!("Received binary data: {} bytes", data.len());
467                                }
468                                Ok(Message::Ping(data)) => {
469                                    if let Err(e) = super::send_with_write_timeout(&mut sender, Message::Pong(data), write_timeout).await {
470                                        error!("Failed to send pong: {}", e);
471                                        break;
472                                    }
473                                }
474                                Ok(Message::Pong(_)) => {
475                                    debug!("Received pong from client");
476                                }
477                                Ok(Message::Close(_)) => {
478                                    info!("Client closed WebSocket connection");
479                                    break;
480                                }
481                                Err(e) => {
482                                    error!("WebSocket error: {}", e);
483                                    break;
484                                }
485                            }
486                        }
487                        else => {
488                            break;
489                        }
490                    }
491                }
492                drop(guard_for_task);
493            })
494        };
495
496        // Wait for the task to complete
497        if let Err(e) = websocket_task.await {
498            error!("WebSocket task failed: {}", e);
499        }
500
501        // Clean up outgoing channel and connection record. The rate-limit
502        // guard's connection counter is decremented when the last Arc<Guard>
503        // is dropped (here and when the spawned task ends).
504        self.outgoing_channels.write().await.remove(&connection_id);
505        let mut connections = self.active_connections.write().await;
506        connections.retain(|conn_id| *conn_id != connection_id);
507        drop(connections);
508        drop(guard);
509
510        // Abort every streaming task this connection started — otherwise a
511        // session's frame-streaming task keeps running (and its abort
512        // handle stays unreachable) after the client that requested it has
513        // disconnected.
514        if let Some(session_ids) = self
515            .connection_sessions
516            .write()
517            .await
518            .remove(&connection_id)
519        {
520            for session_id in session_ids {
521                self.controller.remove_session(&session_id).await;
522            }
523        }
524
525        info!("WebSocket connection closed for {}", client_ip);
526    }
527
528    /// Returns a shared handle to the underlying [`AdaptiveStreamController`].
529    pub fn controller(&self) -> Arc<AdaptiveStreamController> {
530        self.controller.clone()
531    }
532
533    /// Returns the number of currently active WebSocket connections.
534    ///
535    /// Useful for observability, health endpoints, and integration tests.
536    pub async fn active_connection_count(&self) -> usize {
537        self.active_connections.read().await.len()
538    }
539
540    /// Handle WebSocket message for a specific connection.
541    ///
542    /// Thin wrapper around [`WebSocketTransport::handle_message`] that
543    /// forwards the axum socket loop's already-shared `connection_id`; all
544    /// message handling, including `connection_sessions` tracking for
545    /// `StreamInit`, lives in `handle_message` itself. `connection_sessions`
546    /// entries are drained only by [`Self::handle_socket`]'s teardown,
547    /// keyed by the same connection id — nothing else in this type removes
548    /// them, so a caller that drives [`WebSocketTransport::handle_message`]
549    /// directly, bypassing `handle_socket`, leaves its sessions in that map
550    /// until the connection id happens to be reused or the process exits.
551    async fn handle_websocket_message(
552        &self,
553        connection_id: Arc<String>,
554        message: WsMessage,
555    ) -> PjsResult<()> {
556        debug!(
557            "Handling WebSocket message for connection {}: {:?}",
558            connection_id, message
559        );
560        self.handle_message(connection_id, message).await
561    }
562}
563
564impl Default for AxumWebSocketTransport {
565    fn default() -> Self {
566        Self::new()
567    }
568}
569
570impl WebSocketTransport for AxumWebSocketTransport {
571    type Connection = String; // Use connection ID instead of WebSocket
572
573    type StartStreamFuture<'a>
574        = impl Future<Output = PjsResult<String>> + Send + 'a
575    where
576        Self: 'a;
577
578    type SendFrameFuture<'a>
579        = impl Future<Output = PjsResult<()>> + Send + 'a
580    where
581        Self: 'a;
582
583    type HandleMessageFuture<'a>
584        = impl Future<Output = PjsResult<()>> + Send + 'a
585    where
586        Self: 'a;
587
588    type CloseStreamFuture<'a>
589        = impl Future<Output = PjsResult<()>> + Send + 'a
590    where
591        Self: 'a;
592
593    fn start_stream(
594        &self,
595        _connection: Arc<Self::Connection>,
596        data: Value,
597        options: StreamOptions,
598    ) -> Self::StartStreamFuture<'_> {
599        async move {
600            let session_id = self.controller.create_session(data, options).await?;
601            self.controller.start_streaming(&session_id).await?;
602            Ok(session_id)
603        }
604    }
605
606    /// The channel this queues onto is drained by the same `tokio::select!`
607    /// loop in [`Self::handle_socket`] that also awaits
608    /// `handle_websocket_message` inline. Calling `send_frame` from
609    /// within that inline handling path (directly or transitively) would
610    /// deadlock the connection: the loop can't reach `outgoing_rx.recv()`
611    /// again until the in-flight branch finishes, so a blocking send would
612    /// wait forever on a receiver that can't run. Using `try_send` here
613    /// keeps that latent hazard from becoming a real deadlock — see
614    /// `WebSocketTransport::send_frame`'s doc for the general contract.
615    ///
616    /// Always returns `Ok(())` even when the frame is dropped (channel
617    /// full, or larger than `MAX_QUEUED_OUTGOING_BYTES`) — this mirrors
618    /// the underlying channel's own fire-and-forget delivery guarantee
619    /// (an `Ok` `try_send` on a normal `mpsc` channel doesn't promise the
620    /// receiver will ever read the item either) and matches how the
621    /// broadcast-based `frame_rx` delivery path also has no per-frame
622    /// delivery acknowledgment. Both drop reasons are logged via `warn!`.
623    fn send_frame(
624        &self,
625        connection: Arc<Self::Connection>,
626        message: WsMessage,
627    ) -> Self::SendFrameFuture<'_> {
628        async move {
629            // Clone the sender and release the read lock before sending:
630            // a stalled consumer must not hold up other connections
631            // waiting on `outgoing_channels` (e.g. cleanup taking the write lock).
632            let tx = self
633                .outgoing_channels
634                .read()
635                .await
636                .get(connection.as_ref())
637                .cloned();
638            if let Some(tx) = tx {
639                // Serialized once here, rather than in the consuming loop:
640                // this is also what the byte-budget check in `try_send`
641                // measures, so the queued-bytes accounting matches the
642                // actual bytes held in memory.
643                match serde_json::to_string(&message) {
644                    Ok(json_str) => {
645                        let len = json_str.len();
646                        match tx.try_send(json_str, len) {
647                            Ok(()) => {}
648                            Err(bounded_channel::TrySendError::BudgetExceeded(_)) => {
649                                warn!(
650                                    "send_frame: dropping frame for connection {} (byte budget exceeded, {} bytes)",
651                                    connection.as_ref(),
652                                    len
653                                );
654                            }
655                            Err(bounded_channel::TrySendError::Channel(_)) => {
656                                warn!(
657                                    "send_frame: dropping frame for connection {} (channel full or closed)",
658                                    connection.as_ref()
659                                );
660                            }
661                        }
662                    }
663                    Err(e) => {
664                        warn!(
665                            "send_frame: failed to serialize frame for connection {}: {}",
666                            connection.as_ref(),
667                            e
668                        );
669                    }
670                }
671            } else {
672                warn!(
673                    "send_frame: no outgoing channel for connection {}",
674                    connection.as_ref()
675                );
676            }
677            Ok(())
678        }
679    }
680
681    /// The `StreamInit` arm records the created session under `connection`
682    /// in `connection_sessions` so [`Self::handle_socket`]'s teardown can
683    /// abort it on disconnect. Nothing else drains that map: a caller that
684    /// drives this method directly, bypassing `handle_socket` (e.g. a test,
685    /// or a future non-axum trait caller), leaves its session's entry there
686    /// indefinitely — [`WebSocketTransport::close_stream`] removes the
687    /// session from the controller but does not touch `connection_sessions`.
688    fn handle_message(
689        &self,
690        connection: Arc<Self::Connection>,
691        message: WsMessage,
692    ) -> Self::HandleMessageFuture<'_> {
693        async move {
694            match message {
695                WsMessage::StreamInit { data, options, .. } => {
696                    let session_id = self.controller.create_session(data, options).await?;
697                    // Tracked before `start_streaming` so a session that was
698                    // successfully created is still reachable for cleanup
699                    // even if `start_streaming` itself returns an error.
700                    self.connection_sessions
701                        .write()
702                        .await
703                        .entry((*connection).clone())
704                        .or_default()
705                        .push(session_id.clone());
706                    self.controller.start_streaming(&session_id).await?;
707                    info!(
708                        "Created new streaming session for connection {}",
709                        connection.as_ref()
710                    );
711                }
712                WsMessage::FrameAck {
713                    session_id,
714                    frame_id,
715                    processing_time_ms,
716                } => {
717                    debug!(
718                        "Received frame ack: session={}, frame={}, time={}ms",
719                        session_id, frame_id, processing_time_ms
720                    );
721                    self.controller
722                        .handle_frame_ack(&session_id, frame_id, processing_time_ms)
723                        .await?;
724                }
725                WsMessage::Ping { timestamp } => {
726                    debug!("Received ping with timestamp: {}", timestamp);
727                    // Pong is handled automatically in handle_socket
728                }
729                WsMessage::Error {
730                    session_id,
731                    error,
732                    code,
733                } => {
734                    // `session_id` and `error` are both arbitrary
735                    // client-supplied strings with no length validation on
736                    // this path — log only their lengths (plus the
737                    // connection id for correlation), never the values
738                    // themselves, at WARN (see #415 S1: unbounded WARN
739                    // amplification from a single rate-limited connection).
740                    warn!(
741                        "Received error from client on connection {}: session_id_len={:?}, code={}, error_len={}",
742                        connection.as_ref(),
743                        session_id.as_deref().map(str::len),
744                        code,
745                        error.len()
746                    );
747                }
748                _ => {
749                    warn!(
750                        "Unhandled message type from connection {}",
751                        connection.as_ref()
752                    );
753                }
754            }
755            Ok(())
756        }
757    }
758
759    fn close_stream(&self, session_id: &str) -> Self::CloseStreamFuture<'_> {
760        let session_id = session_id.to_string();
761        async move {
762            info!("Closing stream session: {}", session_id);
763            self.controller.remove_session(&session_id).await;
764            Ok(())
765        }
766    }
767}
768
769/// Helper function to create WebSocket router for Axum
770pub fn create_websocket_router() -> axum::Router<Arc<AxumWebSocketTransport>> {
771    use axum::routing::get;
772
773    axum::Router::new().route("/ws", get(AxumWebSocketTransport::upgrade_handler))
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use serde_json::json;
780
781    #[tokio::test]
782    async fn test_transport_creation() {
783        let transport = AxumWebSocketTransport::new();
784        assert!(Arc::strong_count(&transport.controller) >= 1);
785    }
786
787    #[tokio::test]
788    async fn test_stream_initialization() {
789        let transport = AxumWebSocketTransport::new();
790        let data = json!({
791            "critical": {"id": 1, "status": "active"},
792            "metadata": {"created": "2024-01-15T12:00:00Z"}
793        });
794
795        let session_id = transport
796            .controller
797            .create_session(data, StreamOptions::default())
798            .await
799            .unwrap();
800        assert!(!session_id.is_empty());
801
802        // Test starting stream
803        transport
804            .controller
805            .start_streaming(&session_id)
806            .await
807            .unwrap();
808    }
809
810    #[tokio::test]
811    async fn test_handle_message_stream_init_tracks_connection_session() {
812        // Regression test for #415: `WebSocketTransport::handle_message`'s
813        // `StreamInit` arm used to create a session without recording it in
814        // `connection_sessions`, so a caller driving the transport through
815        // the trait directly (bypassing `handle_socket`) got a session that
816        // was never associated with its connection for cleanup. Assert the
817        // association exists after calling `handle_message` directly.
818        let transport = AxumWebSocketTransport::new();
819        let connection = Arc::new("conn-x".to_string());
820
821        transport
822            .handle_message(
823                connection.clone(),
824                WsMessage::StreamInit {
825                    session_id: "ignored-client-supplied-id".to_string(),
826                    data: json!({"test": "value"}),
827                    options: StreamOptions::default(),
828                },
829            )
830            .await
831            .unwrap();
832
833        let sessions = transport.connection_sessions.read().await;
834        let tracked = sessions
835            .get(connection.as_ref())
836            .expect("connection_sessions must have an entry for this connection");
837        assert_eq!(tracked.len(), 1);
838        assert!(!tracked[0].is_empty());
839    }
840
841    #[tokio::test]
842    async fn test_outgoing_channel_is_bounded() {
843        // Regression test for #314: the per-connection outgoing channel
844        // used to be unbounded, so a stalled consumer let it grow without
845        // limit. It must now reject sends once `OUTGOING_QUEUE_CAPACITY`
846        // is reached instead of growing memory indefinitely.
847        let (tx, mut rx) =
848            byte_bounded_channel::<String>(OUTGOING_QUEUE_CAPACITY, MAX_QUEUED_OUTGOING_BYTES);
849
850        for _ in 0..OUTGOING_QUEUE_CAPACITY {
851            tx.try_send("ping".to_string(), 4)
852                .expect("channel should accept sends up to its capacity");
853        }
854
855        let result = tx.try_send("ping".to_string(), 4);
856        assert!(
857            matches!(
858                result,
859                Err(bounded_channel::TrySendError::Channel(
860                    tokio::sync::mpsc::error::TrySendError::Full(_)
861                ))
862            ),
863            "channel must reject sends past capacity instead of growing unbounded"
864        );
865
866        // Draining a slot frees capacity again — this is the flow-control
867        // behavior an unbounded channel could never provide.
868        rx.recv().await.expect("receiver should still be open");
869        tx.try_send("ping".to_string(), 4)
870            .expect("channel should accept a send after capacity is freed");
871    }
872
873    #[tokio::test]
874    async fn test_send_frame_drops_when_byte_budget_exceeded() {
875        // Regression test for #349: a message-count bound alone doesn't
876        // bound queued bytes. A single frame larger than
877        // `MAX_QUEUED_OUTGOING_BYTES` must be dropped even though the
878        // channel is nowhere near its message-count capacity.
879        let transport = AxumWebSocketTransport::new();
880        let connection_id = "test-connection".to_string();
881        let (tx, mut rx) =
882            byte_bounded_channel::<String>(OUTGOING_QUEUE_CAPACITY, MAX_QUEUED_OUTGOING_BYTES);
883        transport
884            .outgoing_channels
885            .write()
886            .await
887            .insert(connection_id.clone(), tx);
888
889        let connection = Arc::new(connection_id);
890        let oversized_message = WsMessage::Error {
891            session_id: None,
892            error: "x".repeat(MAX_QUEUED_OUTGOING_BYTES + 1),
893            code: 0,
894        };
895
896        transport
897            .send_frame(connection, oversized_message)
898            .await
899            .expect("send_frame returns Ok even when it drops the frame");
900
901        assert!(
902            rx.try_recv().is_err(),
903            "an over-budget frame must be dropped, not queued"
904        );
905    }
906
907    #[tokio::test]
908    async fn test_send_frame_drops_on_full_channel_without_blocking() {
909        // Regression test for S3: exercises the real `send_frame` code
910        // path (registered channel + read-lock clone) rather than an
911        // isolated mpsc channel, proving that a full outgoing channel
912        // makes `send_frame` drop-and-log via `try_send` instead of
913        // blocking. `try_send` never awaits, so it also makes the earlier
914        // deadlock hazard (this connection's own loop being both the
915        // sender and the only consumer) moot; the sender is still cloned
916        // out of the lock before sending for lock hygiene.
917        let transport = AxumWebSocketTransport::new();
918        let connection_id = "test-connection".to_string();
919        let (tx, mut rx) =
920            byte_bounded_channel::<String>(OUTGOING_QUEUE_CAPACITY, MAX_QUEUED_OUTGOING_BYTES);
921        transport
922            .outgoing_channels
923            .write()
924            .await
925            .insert(connection_id.clone(), tx);
926
927        let connection = Arc::new(connection_id);
928        for _ in 0..OUTGOING_QUEUE_CAPACITY {
929            transport
930                .send_frame(connection.clone(), WsMessage::Ping { timestamp: 0 })
931                .await
932                .expect("send_frame should accept sends up to channel capacity");
933        }
934
935        tokio::time::timeout(
936            std::time::Duration::from_secs(2),
937            transport.send_frame(connection.clone(), WsMessage::Ping { timestamp: 0 }),
938        )
939        .await
940        .expect("send_frame must not block when the outgoing channel is full")
941        .expect("send_frame must return Ok even when dropping the overflow frame");
942
943        rx.close();
944        let mut drained = 0;
945        while rx.try_recv().is_ok() {
946            drained += 1;
947        }
948        assert_eq!(
949            drained, OUTGOING_QUEUE_CAPACITY,
950            "the overflow frame must have been dropped, not queued"
951        );
952    }
953}