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}