arcly_http_realtime/realtime/ws.rs
1//! WebSocket boundary: upgrade, per-socket read/write pumps, event dispatch.
2//!
3//! This is the *only* module that touches `axum::extract::ws` — the analogue of
4//! [`arcly_http_core::web::boundary`] for the real-time layer. Everything above it speaks
5//! arcly types ([`WsClient`], [`WsMessage`], [`GatewayRuntime`]).
6//!
7//! ## Per-connection model (no hot-path locks)
8//!
9//! ```text
10//! ┌─────────────────── handle_socket task ───────────────────┐
11//! socket ──>│ reader: stream.next() ─> dispatch(event) ─> handler fut │
12//! │ writer: rx.recv() ─> sink.send(frame) │
13//! └───────────────────────────────────────────────────────────┘
14//! ```
15//! The reader and writer run as independent halves of the split socket. Inbound
16//! frames are parsed and routed through the gateway's `dispatch` table (an
17//! immutable `&HashMap` — lock-free read). Outbound frames are produced by any
18//! task via the registry's sharded channels and drained by this socket's writer.
19
20use std::sync::Arc;
21
22use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
23use axum::http::HeaderMap;
24use axum::routing::{get, MethodRouter};
25use futures::{SinkExt, StreamExt};
26use tokio::sync::{mpsc, oneshot};
27
28use crate::realtime::connection::{ConnectionRegistry, WsClient, WsMessage};
29use crate::realtime::gateway::GatewayRuntime;
30use arcly_http_core::core::engine::FrozenDiContainer;
31use arcly_http_core::web::context::Claims;
32
33/// Per-gateway runtime tuning, sourced from `LaunchConfig` at mount time.
34#[derive(Clone, Copy, Debug)]
35#[non_exhaustive]
36pub struct WsTuning {
37 /// Outbound queue depth per socket — the slow-client memory ceiling.
38 pub outbound_buffer: usize,
39 /// Hard cap on concurrent sockets across all gateways (`0` = unlimited);
40 /// beyond it upgrades are refused with `503` before any socket exists.
41 pub max_connections: usize,
42 /// Server→client Ping cadence (`ZERO` disables). Pings provoke pongs,
43 /// which feed the idle sweeper's `last_seen`.
44 pub ping_interval: std::time::Duration,
45}
46
47impl WsTuning {
48 /// Construct WS tuning from the launch config. A constructor (rather than a
49 /// struct literal) so the `arcly-http` facade can build it across the crate
50 /// boundary despite `#[non_exhaustive]`.
51 #[doc(hidden)]
52 pub fn new(
53 outbound_buffer: usize,
54 max_connections: usize,
55 ping_interval: std::time::Duration,
56 ) -> Self {
57 Self {
58 outbound_buffer,
59 max_connections,
60 ping_interval,
61 }
62 }
63}
64
65/// Build the axum `MethodRouter` that upgrades HTTP→WebSocket for one gateway.
66///
67/// If a `JwtService` has been provided in the DI container, the
68/// `Authorization: Bearer <token>` header is decoded during the WebSocket
69/// handshake and the resulting claims are threaded through to every `WsClient`
70/// so gateway handlers can call `client.claims()` for auth decisions.
71pub fn ws_route(
72 runtime: &'static GatewayRuntime,
73 registry: &'static ConnectionRegistry,
74 container: std::sync::Arc<FrozenDiContainer>,
75 tuning: WsTuning,
76) -> MethodRouter {
77 let handler = move |ws: WebSocketUpgrade, headers: HeaderMap| {
78 let container = container.clone();
79 async move {
80 // Admission control happens BEFORE the upgrade — past the cap no
81 // socket, queue, or registry entry is ever created.
82 if tuning.max_connections > 0 && registry.connection_count() >= tuning.max_connections {
83 metrics::counter!("ws_upgrades_refused_total").increment(1);
84 return axum::http::Response::builder()
85 .status(503)
86 .header("retry-after", "5")
87 .body(axum::body::Body::from("websocket capacity reached"))
88 .expect("static refusal");
89 }
90 // The SAME unified extraction as the HTTP boundary (pipeline):
91 // trace + tenant + credentials in one pass. The handshake
92 // authenticates once; gateway handlers see claims AND the resolved
93 // tenant, and the connection inherits the caller's trace identity.
94 let provenance =
95 arcly_http_core::pipeline::Provenance::from_headers(&headers, &container).await;
96 tracing::debug!(
97 trace_id = %arcly_http_core::observability::lean_telemetry::hex_encode(&provenance.trace.trace_id),
98 tenant = provenance.tenant.as_deref().map(|t| t.id.as_str()).unwrap_or(""),
99 "WS handshake provenance"
100 );
101 ws.on_upgrade(move |socket| {
102 handle_socket(
103 socket,
104 runtime,
105 registry,
106 provenance.claims,
107 provenance.tenant,
108 tuning,
109 )
110 })
111 }
112 };
113 get(handler)
114}
115
116/// Drive one upgraded socket to completion: register, pump, dispatch, drain.
117async fn handle_socket(
118 socket: WebSocket,
119 runtime: &'static GatewayRuntime,
120 registry: &'static ConnectionRegistry,
121 claims: Option<Arc<Claims>>,
122 tenant: Option<Arc<arcly_http_core::web::tenant::TenantConfig>>,
123 tuning: WsTuning,
124) {
125 let (mut sink, mut stream) = socket.split();
126
127 // Outbound queue: any task enqueues, this socket's writer drains.
128 // **Bounded** — the depth is the per-socket memory ceiling; a client
129 // that can't drain it gets evicted by the registry, never buffered
130 // without limit.
131 let (tx, mut rx) = mpsc::channel::<WsMessage>(tuning.outbound_buffer.max(1));
132 let id = registry.register(tx, claims.clone());
133 let client = WsClient::__new(id, registry, claims, tenant);
134
135 // One-shot signal: when the writer exits for *any* reason (peer error,
136 // server-initiated Close, or channel closed), the reader is unblocked so it
137 // stops polling the stream and runs on_disconnect + unregister. Without this,
138 // a server-initiated close would leave the reader blocked on stream.next()
139 // indefinitely if the peer never sends a Close echo.
140 let (close_tx, mut close_rx) = oneshot::channel::<()>();
141
142 // Writer half — owns the sink; exits when the queue closes or the peer
143 // dies. A periodic Ping (when configured) keeps NATs/proxies open and
144 // provokes pongs that feed the idle sweeper's `last_seen`.
145 let ping_every = tuning.ping_interval;
146 let writer = tokio::spawn(async move {
147 let mut ping = (!ping_every.is_zero()).then(|| {
148 let mut t = tokio::time::interval(ping_every);
149 t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
150 t
151 });
152 loop {
153 let msg = if let Some(t) = ping.as_mut() {
154 tokio::select! {
155 m = rx.recv() => m,
156 _ = t.tick() => {
157 if sink.send(Message::Ping(bytes::Bytes::new())).await.is_err() {
158 break;
159 }
160 continue;
161 }
162 }
163 } else {
164 rx.recv().await
165 };
166 let Some(msg) = msg else { break };
167 let frame = match msg {
168 WsMessage::Text(arc) => Message::Text(arc.as_ref().into()),
169 WsMessage::Ping => Message::Ping(bytes::Bytes::new()),
170 WsMessage::Close => {
171 // Send close frame then exit immediately — do NOT loop back
172 // to rx.recv(), which would keep the sink open indefinitely.
173 let _ = sink.send(Message::Close(None)).await;
174 break;
175 }
176 };
177 if sink.send(frame).await.is_err() {
178 break;
179 }
180 }
181 // Dropping close_tx signals the reader regardless of why we exited.
182 drop(close_tx);
183 });
184
185 (runtime.on_connect)(client.clone()).await;
186
187 // Reader half — routes inbound frames to subscribed handlers.
188 // Also watches for the writer-exit signal so a server-initiated close
189 // (WsMessage::Close enqueued by a handler) terminates the reader promptly.
190 loop {
191 tokio::select! {
192 biased;
193 // Writer exited (server-initiated close or peer write error).
194 _ = &mut close_rx => break,
195 frame = stream.next() => match frame {
196 None => break,
197 Some(Err(_)) => break,
198 Some(Ok(frame)) => {
199 // Any inbound frame (including pongs from our pings)
200 // proves the link is alive for the idle sweeper.
201 registry.touch(id);
202 match frame {
203 Message::Text(text) => dispatch_event(runtime, &client, &text).await,
204 Message::Binary(_) => { /* binary multiplexing not enabled */ }
205 Message::Close(_) => break,
206 Message::Ping(_) | Message::Pong(_) => { /* axum auto-replies to pings */ }
207 }
208 }
209 }
210 }
211 }
212
213 (runtime.on_disconnect)(client.clone()).await;
214 registry.unregister(id);
215 writer.abort();
216}
217
218/// Parse one `{ "event": ..., "data": ... }` envelope and invoke its handler.
219/// Unknown events and malformed frames are ignored (a hostile client can't
220/// crash the dispatcher).
221async fn dispatch_event(runtime: &'static GatewayRuntime, client: &WsClient, raw: &str) {
222 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
223 return;
224 };
225 let Some(event) = value.get("event").and_then(|e| e.as_str()) else {
226 return;
227 };
228 let Some(handler) = runtime.handler(event) else {
229 return;
230 };
231
232 let data = value
233 .get("data")
234 .cloned()
235 .unwrap_or(serde_json::Value::Null);
236 let data_str: Arc<str> = Arc::from(serde_json::to_string(&data).unwrap_or_default());
237
238 metrics::counter!("ws_messages_in_total").increment(1);
239 // Handler errors stay at the transport edge — gateways own their own
240 // error-to-client signalling — but they are counted and logged so a
241 // misbehaving event handler is visible on dashboards.
242 if let Err(e) = handler(client.clone(), data_str).await {
243 metrics::counter!("ws_handler_errors_total").increment(1);
244 tracing::debug!(conn = client.id(), event, error = %e, "gateway handler error");
245 }
246}