Skip to main content

camel_component_ws/
lib.rs

1//! WebSocket component for rust-camel — Axum-based WebSocket server and Tokio-tungstenite client for bidirectional messaging.
2//!
3//! Main types: `WsComponent`, `WsBundle`, `WsConfig`, `WsServerConfig`, `WsClientConfig`, `WsEndpointConfig`.
4//! Main modules: `bundle`, `config`, `health`.
5
6pub mod bundle;
7pub mod config;
8pub mod health;
9pub(crate) mod tls_reload;
10
11pub use bundle::WsBundle;
12pub use config::{WsClientConfig, WsConfig, WsEndpointConfig, WsServerConfig};
13pub use health::WsHealthCheck;
14
15use std::collections::HashMap;
16use std::sync::{Arc, Mutex, OnceLock};
17
18use async_trait::async_trait;
19use axum::body::Body;
20use axum::extract::ws::{CloseCode, CloseFrame, Message as WsMessage, WebSocket, WebSocketUpgrade};
21use axum::extract::{FromRequest, Request, State};
22use axum::http::{StatusCode, header};
23use axum::response::IntoResponse;
24use axum::{Router, serve};
25use camel_api::security_policy::{AccessMode, AuthPrincipal, RouteSecurityPlan};
26use camel_auth::{AuthenticatedPrincipal, ProviderRegistry};
27use camel_component_api::tls_source::ServerTlsSource;
28use camel_component_api::{
29    Body as CamelBody, BoxProcessor, CamelError, Component, ComponentMetadata, ConcurrencyModel,
30    Consumer, ConsumerContext, ConsumerStartupMode, Endpoint, Exchange, ExchangeEnvelope,
31    Message as CamelMessage, NetworkRetryPolicy, ProducerContext, RuntimeObservability,
32    retry_async,
33};
34use dashmap::DashMap;
35use futures::{SinkExt, StreamExt};
36use std::future::Future;
37use std::pin::Pin;
38use std::task::{Context, Poll};
39use tokio::sync::{OnceCell, RwLock, mpsc};
40use tokio::task::JoinHandle;
41use tokio_tungstenite::tungstenite;
42use tokio_tungstenite::tungstenite::client::IntoClientRequest;
43use tokio_tungstenite::tungstenite::protocol::Message as ClientWsMessage;
44use tower::Service;
45
46#[derive(Clone)]
47pub struct WsPathConfig {
48    pub max_connections: u32,
49    pub max_message_size: u32,
50    pub heartbeat_interval: std::time::Duration,
51    pub idle_timeout: std::time::Duration,
52    pub allow_origin: String,
53}
54
55impl Default for WsPathConfig {
56    fn default() -> Self {
57        let cfg = WsEndpointConfig::default();
58        Self {
59            max_connections: cfg.max_connections,
60            max_message_size: cfg.max_message_size,
61            heartbeat_interval: cfg.heartbeat_interval,
62            idle_timeout: cfg.idle_timeout,
63            allow_origin: cfg.allow_origin,
64        }
65    }
66}
67
68#[derive(Clone)]
69pub struct WsTlsConfig {
70    pub cert_path: String,
71    pub key_path: String,
72}
73
74type DispatchTable = Arc<RwLock<HashMap<String, mpsc::Sender<ExchangeEnvelope>>>>;
75
76struct ServerHandle {
77    state: WsAppState,
78    is_tls: bool,
79    _task: JoinHandle<()>,
80    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
81    tls_source: Option<ServerTlsSource>,
82    /// Present for the TLS (wss) path so callers can await
83    /// `axum_server::Handle::listening()` to detect bind success or
84    /// failure. `None` for the plain-ws path, which binds synchronously
85    /// inside `spawn_server`.
86    listening_handle: Option<axum_server::Handle>,
87}
88
89struct ServerRegistryInner {
90    cell: Arc<OnceCell<ServerHandle>>,
91    ref_count: usize,
92}
93
94pub struct ServerRegistry {
95    inner: Mutex<HashMap<u16, ServerRegistryInner>>,
96}
97
98impl ServerRegistry {
99    pub fn global() -> &'static Self {
100        static REG: OnceLock<ServerRegistry> = OnceLock::new();
101        REG.get_or_init(|| Self {
102            inner: Mutex::new(HashMap::new()),
103        })
104    }
105
106    pub async fn get_or_spawn(
107        &'static self,
108        host: &str,
109        port: u16,
110        tls_config: Option<WsTlsConfig>,
111        runtime: Arc<dyn RuntimeObservability>,
112        route_id: String,
113    ) -> Result<(WsAppState, Option<axum_server::Handle>), CamelError> {
114        let wants_tls = tls_config.is_some();
115        let host_owned = host.to_string();
116
117        let (cell, _is_new) = {
118            let mut guard = self.inner.lock().map_err(|_| {
119                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
120            })?;
121            let entry = guard.entry(port).or_insert_with(|| ServerRegistryInner {
122                cell: Arc::new(OnceCell::new()),
123                ref_count: 0,
124            });
125            entry.ref_count += 1;
126            (entry.cell.clone(), entry.ref_count == 1)
127        };
128
129        let handle = cell
130            .get_or_try_init(|| async {
131                let handle = spawn_server(
132                    &host_owned,
133                    port,
134                    tls_config,
135                    runtime.clone(),
136                    route_id.clone(),
137                )
138                .await?;
139                // Register reload handler (exactly-once: inside OnceCell init closure).
140                if let (Some(tls_cfg), Some(source)) =
141                    (handle.tls_config.as_ref(), handle.tls_source.as_ref())
142                {
143                    let handler = Arc::new(crate::tls_reload::WsReloadHandler::new(
144                        tls_cfg.clone(),
145                        source.clone(),
146                        port,
147                    ));
148                    camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
149                }
150                Ok::<ServerHandle, CamelError>(handle)
151            })
152            .await;
153
154        let handle = match handle {
155            Ok(h) => h,
156            Err(e) => {
157                // Decrement ref_count on spawn failure so the entry is
158                // cleaned up when it reaches zero.
159                let mut guard = self.inner.lock().map_err(|_| {
160                    CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
161                })?;
162                if let Some(entry) = guard.get_mut(&port) {
163                    entry.ref_count -= 1;
164                    if entry.ref_count == 0 {
165                        guard.remove(&port);
166                    }
167                }
168                return Err(e);
169            }
170        };
171
172        if wants_tls != handle.is_tls {
173            // Decrement ref count since we're rejecting this caller
174            let mut guard = self.inner.lock().map_err(|_| {
175                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
176            })?;
177            if let Some(entry) = guard.get_mut(&port) {
178                entry.ref_count -= 1;
179                if entry.ref_count == 0 {
180                    guard.remove(&port);
181                }
182            }
183            return Err(CamelError::EndpointCreationFailed(format!(
184                "Server on port {port} already running with different TLS mode"
185            )));
186        }
187
188        Ok((handle.state.clone(), handle.listening_handle.clone()))
189    }
190
191    /// Release a reference to the server on the given port.
192    /// WebSocket servers are process-lifetime: the server stays alive
193    /// for potential restart. Path deregistration happens separately.
194    pub(crate) fn release(&self, port: u16) {
195        tracing::debug!(port, "WebSocket consumer released (server kept alive)");
196    }
197
198    /// Reset the global registry — **test-only**.
199    #[cfg(test)]
200    pub fn reset() {
201        let mut guard = Self::global().inner.lock().expect("ServerRegistry lock");
202        for entry in guard.values() {
203            if let Some(handle) = entry.cell.get() {
204                handle._task.abort();
205            }
206        }
207        guard.clear();
208    }
209}
210
211async fn spawn_server(
212    host: &str,
213    port: u16,
214    tls_config: Option<WsTlsConfig>,
215    runtime: Arc<dyn RuntimeObservability>,
216    route_id: String,
217) -> Result<ServerHandle, CamelError> {
218    let host_owned = host.to_string();
219    let addr = format!("{host}:{port}");
220    let dispatch: DispatchTable = Arc::new(RwLock::new(HashMap::new()));
221    let path_configs = Arc::new(DashMap::new());
222    let path_policies = Arc::new(DashMap::new());
223    let server_error = new_atomic_false();
224    let state = WsAppState {
225        dispatch: Arc::clone(&dispatch),
226        path_configs: Arc::clone(&path_configs),
227        path_policies: Arc::clone(&path_policies),
228        server_error: Arc::clone(&server_error),
229        runtime: Arc::clone(&runtime),
230        route_id: route_id.clone(),
231    };
232    let app = Router::new()
233        .fallback(dispatch_handler)
234        .with_state(state.clone());
235
236    let (task, is_tls, retained_tls_cfg, retained_source, listening_handle) =
237        if let Some(ref tls) = tls_config {
238            let rustls = load_tls_config(&tls.cert_path, &tls.key_path)?;
239            let parsed_addr = addr.parse().map_err(|e| {
240                CamelError::EndpointCreationFailed(format!("Invalid listen address {addr}: {e}"))
241            })?;
242            let tls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(rustls));
243            let tls_source = ServerTlsSource {
244                cert_path: std::path::PathBuf::from(&tls.cert_path),
245                key_path: std::path::PathBuf::from(&tls.key_path),
246                client_ca_path: None,
247            };
248            // Clone for handle retention — the original moves into the spawned task
249            let retained = tls_cfg.clone();
250            // axum_server defers the TCP bind into the spawned serve() future, so
251            // surface bind success/failure via Handle::listening(). The clone here
252            // moves into the task; the original is retained for the caller.
253            let listen_handle = axum_server::Handle::new();
254            let listen_handle_for_task = listen_handle.clone();
255            let error_flag = Arc::clone(&server_error);
256            let rt = Arc::clone(&runtime);
257            let rid = route_id.clone();
258            let task = tokio::spawn(async move {
259                if let Err(e) = axum_server::bind_rustls(parsed_addr, tls_cfg)
260                    .handle(listen_handle_for_task)
261                    .serve(app.into_make_service())
262                    .await
263                {
264                    rt.health()
265                        .force_unhealthy_for_route(&rid, "g:ws:bind-tls", &e.to_string());
266                    // log-policy: outside-contract
267                    tracing::error!(
268                        host = host_owned,
269                        port = port,
270                        error = %e,
271                        "WebSocket server terminated with error"
272                    );
273                    error_flag.store(true, Ordering::Relaxed);
274                }
275            });
276            (
277                task,
278                true,
279                Some(retained),
280                Some(tls_source),
281                Some(listen_handle),
282            )
283        } else {
284            let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
285                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
286            })?;
287            let error_flag = Arc::clone(&server_error);
288            let rt = Arc::clone(&runtime);
289            let rid = route_id.clone();
290            let task = tokio::spawn(async move {
291                if let Err(e) = serve(listener, app).await {
292                    rt.health()
293                        .force_unhealthy_for_route(&rid, "g:ws:bind-plain", &e.to_string());
294                    // log-policy: outside-contract
295                    tracing::error!(
296                        host = host_owned,
297                        port = port,
298                        error = %e,
299                        "WebSocket server terminated with error"
300                    );
301                    error_flag.store(true, Ordering::Relaxed);
302                }
303            });
304            (task, false, None, None, None)
305        };
306
307    tracing::info!(host, port, is_tls, "WebSocket server started");
308
309    Ok(ServerHandle {
310        state,
311        is_tls,
312        _task: task,
313        tls_config: retained_tls_cfg,
314        tls_source: retained_source,
315        listening_handle,
316    })
317}
318
319#[derive(Clone)]
320pub struct WsAppState {
321    pub dispatch: DispatchTable,
322    pub path_configs: Arc<DashMap<String, WsPathConfig>>,
323    pub path_policies: Arc<DashMap<String, camel_component_api::SecurityContext>>,
324    pub server_error: Arc<AtomicBool>,
325    /// Observable runtime for ADR-0012 (e) metric and (g) health calls.
326    pub runtime: Arc<dyn RuntimeObservability>,
327    /// Route id of the consumer that created this server.
328    pub route_id: String,
329}
330
331pub struct WsConnectionRegistry {
332    connections: DashMap<String, mpsc::Sender<WsMessage>>,
333}
334
335static GLOBAL_CONNECTION_REGISTRIES: OnceLock<
336    DashMap<(String, u16, String), Arc<WsConnectionRegistry>>,
337> = OnceLock::new();
338
339fn global_registries() -> &'static DashMap<(String, u16, String), Arc<WsConnectionRegistry>> {
340    GLOBAL_CONNECTION_REGISTRIES.get_or_init(DashMap::new)
341}
342
343impl Default for WsConnectionRegistry {
344    fn default() -> Self {
345        Self::new()
346    }
347}
348
349impl WsConnectionRegistry {
350    pub fn new() -> Self {
351        Self {
352            connections: DashMap::new(),
353        }
354    }
355
356    pub fn insert(&self, key: String, tx: mpsc::Sender<WsMessage>) {
357        self.connections.insert(key, tx);
358    }
359
360    pub fn remove(&self, key: &str) {
361        self.connections.remove(key);
362    }
363
364    pub fn len(&self) -> usize {
365        self.connections.len()
366    }
367
368    pub fn is_empty(&self) -> bool {
369        self.connections.is_empty()
370    }
371
372    pub fn snapshot_senders(&self) -> Vec<mpsc::Sender<WsMessage>> {
373        self.connections.iter().map(|e| e.value().clone()).collect()
374    }
375
376    pub fn get_senders_for_keys(&self, keys: &[String]) -> Vec<mpsc::Sender<WsMessage>> {
377        keys.iter()
378            .filter_map(|k| self.connections.get(k).map(|e| e.value().clone()))
379            .collect()
380    }
381}
382
383/// Kernel authentication state for one ws path (`unify-transport-auth`,
384/// Task 2.8).
385///
386/// Construction-order (Task 2.1 lesson): the compiled plan and the
387/// provider registry ride the route's [`SecurityContext`], which the
388/// consumer publishes into the shared server state at `start()` — in the
389/// same step that makes the path dispatchable — so no handshake can reach
390/// an upgrade with half-captured state and nothing patches the entry
391/// afterwards. `None` unless both pieces are present: a plan without
392/// providers can never mint a principal, and a registry without a plan
393/// has nothing to enforce. A context without both is Public pass-through
394/// (no extraction, no evaluation) — DSL routes always carry a compiled
395/// plan (mandatory at `add_route`) and programmatic routes without one
396/// fail closed at the controller's strict dispatch check
397/// (`finish-auth-flip` Task 1.1).
398struct WsKernelAuth {
399    plan: RouteSecurityPlan,
400    providers: Arc<ProviderRegistry>,
401}
402
403impl WsKernelAuth {
404    fn from_security_context(ctx: &camel_component_api::SecurityContext) -> Option<Self> {
405        Some(Self {
406            plan: ctx.plan.clone()?,
407            providers: ctx.providers.clone()?,
408        })
409    }
410}
411
412/// Map an authentication failure to the upgrade-rejection response — the
413/// ws denial idiom is refusing the HTTP upgrade, so the handshake never
414/// completes and no close frame is needed.
415fn ws_upgrade_auth_error(e: &CamelError) -> axum::response::Response {
416    let (status, body) = match e {
417        CamelError::Unauthenticated(_) => (StatusCode::UNAUTHORIZED, "Unauthorized"),
418        CamelError::AuthProviderUnavailable(_) => {
419            (StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable")
420        }
421        _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"),
422    };
423    (status, body).into_response()
424}
425
426/// ADR-0051 defense-in-depth: when the accepted credential came from a
427/// query param, log the redacted URI in the upgrade debug record. ws
428/// forbids `QueryParam` at compile time since Task 1.8; the redaction
429/// stays so a future re-introduction can never leak the token value.
430fn debug_log_query_credential(
431    uri: &axum::http::Uri,
432    sources: &[camel_auth::CredentialSource],
433    accepted: &camel_auth::CredentialSource,
434) {
435    if !matches!(accepted, camel_auth::CredentialSource::QueryParam { .. }) {
436        return;
437    }
438    let mut sensitive: Vec<&str> = vec!["access_token", "token"];
439    sensitive.extend(sources.iter().filter_map(|source| match source {
440        camel_auth::CredentialSource::QueryParam { param } => Some(param.as_str()),
441        _ => None,
442    }));
443    let redacted = camel_auth::redact_query_params(uri, &sensitive);
444    tracing::debug!(path = %redacted, "WS upgrade with query token (redacted)");
445}
446
447pub async fn dispatch_handler(
448    State(state): State<WsAppState>,
449    req: Request<Body>,
450) -> impl IntoResponse {
451    let path = req.uri().path().to_string();
452    let origin = req
453        .headers()
454        .get(header::ORIGIN)
455        .and_then(|value| value.to_str().ok())
456        .map(str::to_string);
457    let remote_addr = req
458        .extensions()
459        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
460        .map(|ci| ci.0.to_string())
461        .unwrap_or_default();
462    let table = state.dispatch.read().await;
463    if !table.contains_key(&path) {
464        return (
465            StatusCode::NOT_FOUND,
466            "no ws endpoint registered for this path",
467        )
468            .into_response();
469    }
470    drop(table);
471
472    let path_config = state
473        .path_configs
474        .get(&path)
475        .map(|entry| entry.value().clone())
476        .unwrap_or_default();
477    if !is_origin_allowed(&path_config.allow_origin, origin.as_deref()) {
478        return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
479    }
480
481    let mut carrier_opt: Option<AuthenticatedPrincipal> = None;
482    if let Some(sec_ctx) = state.path_policies.get(&path)
483        && let Some(kernel) = WsKernelAuth::from_security_context(&sec_ctx)
484    {
485        // Kernel path (Task 2.8): `Public` skips credential extraction
486        // entirely (pass-through); any other mode extracts per the
487        // plan's sources and mints the sealed principal through the
488        // kernel. No local policy evaluation — authorization belongs
489        // to the pipeline's policy layer and Task 2.9's dispatch
490        // check, which read the typed carrier.
491        if matches!(kernel.plan.access_mode, AccessMode::Public) {
492            tracing::debug!(path = %path, "WS upgrade: public plan, skipping credential extraction");
493        } else {
494            match camel_auth::extract_token_multi(
495                req.headers(),
496                req.uri(),
497                &kernel.plan.credential_sources,
498            ) {
499                Some(extracted) => {
500                    debug_log_query_credential(
501                        req.uri(),
502                        &kernel.plan.credential_sources,
503                        &extracted.source,
504                    );
505                    match camel_auth::kernel_authenticate(
506                        &kernel.plan,
507                        &kernel.providers,
508                        &extracted,
509                    )
510                    .await
511                    {
512                        Ok(carrier) => {
513                            tracing::debug!(
514                                path = %path,
515                                subject = %carrier.principal().subject,
516                                provider = %carrier.provider_id(),
517                                "WS upgrade authorized via kernel"
518                            );
519                            // The sealed carrier rides the connection
520                            // into every message exchange.
521                            carrier_opt = Some(carrier);
522                        }
523                        Err(e) => {
524                            state
525                                .runtime
526                                .metrics()
527                                .increment_errors(&state.route_id, "e:ws:authn");
528                            tracing::warn!(path = %path, error = %e, "WS upgrade kernel authentication failed");
529                            return ws_upgrade_auth_error(&e);
530                        }
531                    }
532                }
533                None => {
534                    state
535                        .runtime
536                        .metrics()
537                        .increment_errors(&state.route_id, "e:ws:authn");
538                    tracing::warn!(path = %path, "WS upgrade rejected: no credential found in any source");
539                    return (
540                        StatusCode::UNAUTHORIZED,
541                        [("WWW-Authenticate", "Bearer".to_string())],
542                        "Unauthorized",
543                    )
544                        .into_response();
545                }
546            }
547        }
548    }
549
550    let upgrade_headers: HashMap<String, String> = req
551        .headers()
552        .iter()
553        .filter_map(|(k, v)| Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string())))
554        .collect();
555
556    let ws: WebSocketUpgrade = match WebSocketUpgrade::from_request(req, &()).await {
557        Ok(ws) => ws,
558        Err(_) => {
559            return (StatusCode::BAD_REQUEST, "not a websocket request").into_response();
560        }
561    };
562
563    ws.on_upgrade(move |socket| {
564        ws_handler(
565            socket,
566            state,
567            path,
568            remote_addr,
569            upgrade_headers,
570            carrier_opt,
571        )
572    })
573    .into_response()
574}
575
576#[allow(unused_variables)]
577async fn ws_handler(
578    socket: WebSocket,
579    state: WsAppState,
580    path: String,
581    remote_addr: String,
582    upgrade_headers: HashMap<String, String>,
583    carrier: Option<AuthenticatedPrincipal>,
584) {
585    let connection_key = uuid::Uuid::new_v4().to_string();
586    let path_config = state
587        .path_configs
588        .get(&path)
589        .map(|entry| entry.value().clone())
590        .unwrap_or_default();
591
592    let env_tx = {
593        let table = state.dispatch.read().await;
594        table.get(&path).cloned()
595    };
596    let Some(env_tx) = env_tx else {
597        return;
598    };
599
600    let (mut sink, mut stream) = socket.split();
601    let (out_tx, mut out_rx) = mpsc::channel::<WsMessage>(32);
602
603    let registry = global_registries();
604    let mut registry_key = None;
605    for entry in registry.iter() {
606        if entry.key().2 == path {
607            entry.value().insert(connection_key.clone(), out_tx.clone());
608            registry_key = Some(entry.key().clone());
609            break;
610        }
611    }
612
613    // Clone for writer closure and subsequent tracing (WS-009)
614    let conn_key_for_writer = connection_key.clone();
615    let path_for_writer = path.clone();
616
617    let writer = tokio::spawn(async move {
618        while let Some(msg) = out_rx.recv().await {
619            if let Err(e) = sink.send(msg).await {
620                tracing::warn!(
621                    connection_key = conn_key_for_writer,
622                    path = path_for_writer,
623                    error = %e,
624                    "WebSocket writer send error — closing connection"
625                );
626                break;
627            }
628        }
629    });
630
631    tracing::info!(
632        connection_key = connection_key,
633        path = path,
634        remote_addr = remote_addr,
635        "WebSocket connection opened"
636    );
637
638    let mut over_limit = false;
639    if let Some(key) = &registry_key
640        && let Some(entry) = registry.get(key)
641        && entry.len() > path_config.max_connections as usize
642    {
643        over_limit = true;
644    }
645    if over_limit {
646        try_send_with_backpressure(
647            &out_tx,
648            WsMessage::Close(Some(CloseFrame {
649                code: CloseCode::from(1013u16),
650                reason: "max connections exceeded".into(),
651            })),
652            "max-connections-close",
653        );
654        if let Some(key) = registry_key.clone()
655            && let Some(entry) = registry.get(&key)
656        {
657            entry.remove(&connection_key);
658        }
659        drop(out_tx);
660        let _ = writer.await;
661        return;
662    }
663
664    let heartbeat_task = if path_config.heartbeat_interval > std::time::Duration::ZERO {
665        let ping_tx = out_tx.clone();
666        let interval = path_config.heartbeat_interval;
667        Some(tokio::spawn(async move {
668            let mut ticker = tokio::time::interval(interval);
669            loop {
670                ticker.tick().await;
671                let _ = try_send_with_backpressure(
672                    &ping_tx,
673                    WsMessage::Ping(Vec::new().into()),
674                    "heartbeat-ping",
675                );
676            }
677        }))
678    } else {
679        None
680    };
681
682    loop {
683        let next_msg = if path_config.idle_timeout > std::time::Duration::ZERO {
684            match tokio::time::timeout(path_config.idle_timeout, stream.next()).await {
685                Ok(msg) => msg,
686                Err(_) => {
687                    try_send_with_backpressure(
688                        &out_tx,
689                        WsMessage::Close(Some(CloseFrame {
690                            code: CloseCode::from(1000u16),
691                            reason: "idle timeout".into(),
692                        })),
693                        "idle-timeout-close",
694                    );
695                    break;
696                }
697            }
698        } else {
699            stream.next().await
700        };
701
702        let Some(msg) = next_msg else {
703            break;
704        };
705
706        match msg {
707            Ok(WsMessage::Ping(data)) => {
708                tracing::debug!(
709                    connection_key = connection_key,
710                    path = path,
711                    "WebSocket ping received — sending pong"
712                );
713                let _ = try_send_with_backpressure(
714                    &out_tx,
715                    WsMessage::Pong(data),
716                    "ping-pong-response",
717                );
718            }
719            Ok(WsMessage::Pong(_)) => {
720                tracing::debug!(
721                    connection_key = connection_key,
722                    path = path,
723                    "WebSocket pong received"
724                );
725            }
726            Ok(WsMessage::Text(text)) => {
727                if text.len() > path_config.max_message_size as usize {
728                    try_send_with_backpressure(
729                        &out_tx,
730                        WsMessage::Close(Some(CloseFrame {
731                            code: CloseCode::from(1009u16),
732                            reason: "message too large".into(),
733                        })),
734                        "max-message-size-close-text",
735                    );
736                    break;
737                }
738
739                let mut message = CamelMessage::new(CamelBody::Text(text.to_string()));
740                message.set_header(
741                    "CamelWsConnectionKey",
742                    serde_json::Value::String(connection_key.clone()),
743                );
744                message.set_header("CamelWsPath", serde_json::Value::String(path.clone()));
745                message.set_header(
746                    "CamelWsRemoteAddress",
747                    serde_json::Value::String(remote_addr.clone()),
748                );
749
750                #[allow(unused_mut)]
751                let mut exchange = Exchange::new(message);
752                // Kernel-minted typed carrier (Task 2.8): a FRESH exchange is
753                // built per inbound message, so every one must carry the
754                // connection's authenticated principal — Task 2.9's dispatch
755                // enforcement reads it off each exchange, not just the first.
756                if let Some(ref carrier) = carrier {
757                    camel_auth::install_carrier(&mut exchange, carrier);
758                }
759                #[cfg(feature = "otel")]
760                {
761                    camel_otel::extract_into_exchange(&mut exchange, &upgrade_headers);
762                }
763                if env_tx
764                    .send(ExchangeEnvelope {
765                        exchange,
766                        reply_tx: None,
767                    })
768                    .await
769                    .is_err()
770                {
771                    break;
772                }
773            }
774            Ok(WsMessage::Binary(data)) => {
775                if data.len() > path_config.max_message_size as usize {
776                    try_send_with_backpressure(
777                        &out_tx,
778                        WsMessage::Close(Some(CloseFrame {
779                            code: CloseCode::from(1009u16),
780                            reason: "message too large".into(),
781                        })),
782                        "max-message-size-close-binary",
783                    );
784                    break;
785                }
786
787                let mut message = CamelMessage::new(CamelBody::Bytes(data));
788                message.set_header(
789                    "CamelWsConnectionKey",
790                    serde_json::Value::String(connection_key.clone()),
791                );
792                message.set_header("CamelWsPath", serde_json::Value::String(path.clone()));
793                message.set_header(
794                    "CamelWsRemoteAddress",
795                    serde_json::Value::String(remote_addr.clone()),
796                );
797
798                #[allow(unused_mut)]
799                let mut exchange = Exchange::new(message);
800                // Kernel-minted typed carrier (Task 2.8): same mandate as the
801                // text site — every binary message exchange carries it too.
802                if let Some(ref carrier) = carrier {
803                    camel_auth::install_carrier(&mut exchange, carrier);
804                }
805                #[cfg(feature = "otel")]
806                {
807                    camel_otel::extract_into_exchange(&mut exchange, &upgrade_headers);
808                }
809                if env_tx
810                    .send(ExchangeEnvelope {
811                        exchange,
812                        reply_tx: None,
813                    })
814                    .await
815                    .is_err()
816                {
817                    break;
818                }
819            }
820            Ok(WsMessage::Close(cf)) => {
821                let reason = cf
822                    .as_ref()
823                    .map(|f| f.reason.to_string())
824                    .unwrap_or_default();
825                tracing::info!(
826                    connection_key = connection_key,
827                    path = path,
828                    reason = reason,
829                    "WebSocket connection closed by peer"
830                );
831                break;
832            }
833            Err(e) => {
834                tracing::warn!(
835                    connection_key = connection_key,
836                    path = path,
837                    error = %e,
838                    "WebSocket receive error"
839                );
840                break;
841            }
842        }
843    }
844
845    if let Some(task) = heartbeat_task {
846        task.abort();
847    }
848
849    if let Some(key) = registry_key
850        && let Some(entry) = registry.get(&key)
851    {
852        entry.remove(&connection_key);
853    }
854    drop(out_tx);
855    let _ = writer.await;
856
857    tracing::info!(
858        connection_key = connection_key,
859        path = path,
860        "WebSocket connection closed"
861    );
862}
863
864pub struct WsComponent {
865    pub(crate) config: WsConfig,
866}
867
868impl WsComponent {
869    pub fn new() -> Self {
870        Self {
871            config: WsConfig::default(),
872        }
873    }
874
875    pub fn with_config(config: WsConfig) -> Self {
876        Self { config }
877    }
878}
879
880impl Default for WsComponent {
881    fn default() -> Self {
882        Self::new()
883    }
884}
885
886impl Component for WsComponent {
887    fn scheme(&self) -> &str {
888        "ws"
889    }
890
891    fn metadata(&self) -> ComponentMetadata {
892        WsEndpointConfig::metadata()
893    }
894
895    fn create_endpoint(
896        &self,
897        uri: &str,
898        ctx: &dyn camel_component_api::ComponentContext,
899    ) -> Result<Box<dyn Endpoint>, CamelError> {
900        self.config.validate()?;
901        let mut cfg = WsEndpointConfig::from_uri(uri)?;
902        if let Some(v) = self.config.max_connections {
903            cfg.max_connections = v;
904        }
905        if let Some(v) = self.config.max_message_size {
906            cfg.max_message_size = v;
907        }
908        if let Some(v) = self.config.heartbeat_interval_ms {
909            cfg.heartbeat_interval = std::time::Duration::from_millis(v);
910        }
911        if let Some(v) = self.config.idle_timeout_ms {
912            cfg.idle_timeout = std::time::Duration::from_millis(v);
913        }
914        if let Some(v) = self.config.connect_timeout_ms {
915            cfg.connect_timeout = std::time::Duration::from_millis(v);
916        }
917        if let Some(v) = self.config.response_timeout_ms {
918            cfg.response_timeout = std::time::Duration::from_millis(v);
919        }
920        if let Some(v) = self.config.send_timeout_ms {
921            cfg.send_timeout = std::time::Duration::from_millis(v);
922        }
923        if let Some(v) = self.config.binary_payload {
924            cfg.binary_payload = v;
925        }
926        if let Some(ref v) = self.config.subprotocols {
927            cfg.subprotocols = v.clone();
928        }
929        let health_check = WsHealthCheck::new(cfg.host.clone(), cfg.port);
930        ctx.register_current_route_health_check(std::sync::Arc::new(health_check));
931        Ok(Box::new(WsEndpoint {
932            uri: uri.to_string(),
933            cfg,
934        }))
935    }
936}
937
938pub struct WssComponent {
939    pub(crate) config: WsConfig,
940}
941
942impl WssComponent {
943    pub fn new() -> Self {
944        Self {
945            config: WsConfig::default(),
946        }
947    }
948
949    pub fn with_config(config: WsConfig) -> Self {
950        Self { config }
951    }
952}
953
954impl Default for WssComponent {
955    fn default() -> Self {
956        Self::new()
957    }
958}
959
960impl Component for WssComponent {
961    fn scheme(&self) -> &str {
962        "wss"
963    }
964
965    fn metadata(&self) -> ComponentMetadata {
966        WsEndpointConfig::metadata()
967    }
968
969    fn create_endpoint(
970        &self,
971        uri: &str,
972        ctx: &dyn camel_component_api::ComponentContext,
973    ) -> Result<Box<dyn Endpoint>, CamelError> {
974        self.config.validate()?;
975        let mut cfg = WsEndpointConfig::from_uri(uri)?;
976        if let Some(v) = self.config.max_connections {
977            cfg.max_connections = v;
978        }
979        if let Some(v) = self.config.max_message_size {
980            cfg.max_message_size = v;
981        }
982        if let Some(v) = self.config.heartbeat_interval_ms {
983            cfg.heartbeat_interval = std::time::Duration::from_millis(v);
984        }
985        if let Some(v) = self.config.idle_timeout_ms {
986            cfg.idle_timeout = std::time::Duration::from_millis(v);
987        }
988        if let Some(v) = self.config.connect_timeout_ms {
989            cfg.connect_timeout = std::time::Duration::from_millis(v);
990        }
991        if let Some(v) = self.config.response_timeout_ms {
992            cfg.response_timeout = std::time::Duration::from_millis(v);
993        }
994        if let Some(v) = self.config.send_timeout_ms {
995            cfg.send_timeout = std::time::Duration::from_millis(v);
996        }
997        if let Some(v) = self.config.binary_payload {
998            cfg.binary_payload = v;
999        }
1000        if let Some(ref v) = self.config.subprotocols {
1001            cfg.subprotocols = v.clone();
1002        }
1003        let health_check = WsHealthCheck::new(cfg.host.clone(), cfg.port);
1004        ctx.register_current_route_health_check(std::sync::Arc::new(health_check));
1005        Ok(Box::new(WsEndpoint {
1006            uri: uri.to_string(),
1007            cfg,
1008        }))
1009    }
1010}
1011
1012struct WsEndpoint {
1013    uri: String,
1014    cfg: WsEndpointConfig,
1015}
1016
1017impl Endpoint for WsEndpoint {
1018    fn uri(&self) -> &str {
1019        &self.uri
1020    }
1021
1022    fn create_consumer(
1023        &self,
1024        rt: Arc<dyn camel_component_api::RuntimeObservability>,
1025    ) -> Result<Box<dyn Consumer>, CamelError> {
1026        Ok(Box::new(WsConsumer::new(self.cfg.server_config(), rt)))
1027    }
1028
1029    fn create_producer(
1030        &self,
1031        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1032        _ctx: &ProducerContext,
1033    ) -> Result<BoxProcessor, CamelError> {
1034        Ok(BoxProcessor::new(WsProducer::new(self.cfg.client_config())))
1035    }
1036}
1037
1038pub struct WsConsumer {
1039    cfg: WsServerConfig,
1040    registry: Arc<WsConnectionRegistry>,
1041    server_state: Option<WsAppState>,
1042    registry_key: Option<(String, u16, String)>,
1043    forward_task: Option<JoinHandle<Result<(), CamelError>>>,
1044    security_ctx: Option<camel_component_api::SecurityContext>,
1045    /// Runtime observability handle for ADR-0012 metrics and health calls.
1046    runtime: Arc<dyn camel_component_api::RuntimeObservability>,
1047}
1048
1049impl WsConsumer {
1050    pub fn new(
1051        cfg: WsServerConfig,
1052        runtime: Arc<dyn camel_component_api::RuntimeObservability>,
1053    ) -> Self {
1054        Self {
1055            cfg,
1056            registry: Arc::new(WsConnectionRegistry::new()),
1057            server_state: None,
1058            registry_key: None,
1059            forward_task: None,
1060            security_ctx: None,
1061            runtime,
1062        }
1063    }
1064}
1065
1066#[async_trait]
1067impl Consumer for WsConsumer {
1068    async fn start(&mut self, ctx: ConsumerContext) -> Result<(), CamelError> {
1069        // Reject double-start (WS-006)
1070        if self.server_state.is_some() {
1071            return Err(CamelError::EndpointCreationFailed(
1072                "WebSocket consumer already started".into(),
1073            ));
1074        }
1075
1076        tracing::info!(
1077            host = self.cfg.inner.host,
1078            port = self.cfg.inner.port,
1079            path = self.cfg.inner.path,
1080            scheme = self.cfg.inner.scheme,
1081            "WebSocket consumer starting"
1082        );
1083
1084        let tls_config = if self.cfg.inner.scheme == "wss" {
1085            let cert_path = self.cfg.inner.tls_cert.clone().ok_or_else(|| {
1086                CamelError::EndpointCreationFailed("TLS cert path is required for wss".into())
1087            })?;
1088            let key_path = self.cfg.inner.tls_key.clone().ok_or_else(|| {
1089                CamelError::EndpointCreationFailed("TLS key path is required for wss".into())
1090            })?;
1091            Some(WsTlsConfig {
1092                cert_path,
1093                key_path,
1094            })
1095        } else {
1096            None
1097        };
1098
1099        let (state, listening_handle) = ServerRegistry::global()
1100            .get_or_spawn(
1101                &self.cfg.inner.host,
1102                self.cfg.inner.port,
1103                tls_config,
1104                self.runtime.clone(),
1105                ctx.route_id().to_string(),
1106            )
1107            .await?;
1108
1109        // Readiness gating:
1110        // - Plain ws: `spawn_server` binds the TCP listener synchronously
1111        //   before returning, so `get_or_spawn` returning `Ok` means the
1112        //   bind already succeeded. Signal readiness immediately.
1113        // - wss: `spawn_server` defers the bind into a spawned serve()
1114        //   task. The `axum_server::Handle` lets us await the actual bind
1115        //   result. `listening()` returns `Some(addr)` once bound, or
1116        //   `None` if the bind failed — propagate that failure here so
1117        //   the route never marks itself ready on a dead listener.
1118        match listening_handle {
1119            Some(handle) => match handle.listening().await {
1120                Some(_addr) => ctx.mark_ready(),
1121                None => {
1122                    return Err(CamelError::EndpointCreationFailed(
1123                        "TLS listener bind failed".to_string(),
1124                    ));
1125                }
1126            },
1127            None => ctx.mark_ready(),
1128        }
1129
1130        let (env_tx, mut env_rx) = mpsc::channel::<ExchangeEnvelope>(64);
1131        {
1132            let mut table = state.dispatch.write().await;
1133            table.insert(self.cfg.inner.path.clone(), env_tx);
1134        }
1135
1136        state.path_configs.insert(
1137            self.cfg.inner.path.clone(),
1138            WsPathConfig {
1139                max_connections: self.cfg.inner.max_connections,
1140                max_message_size: self.cfg.inner.max_message_size,
1141                heartbeat_interval: self.cfg.inner.heartbeat_interval,
1142                idle_timeout: self.cfg.inner.idle_timeout,
1143                allow_origin: self.cfg.inner.allow_origin.clone(),
1144            },
1145        );
1146
1147        if let Some(ref sec_ctx) = self.security_ctx {
1148            let path = self.cfg.inner.path.clone();
1149            state.path_policies.insert(path, sec_ctx.clone());
1150        }
1151
1152        let registry_key = (
1153            self.cfg.inner.canonical_host(),
1154            self.cfg.inner.port,
1155            self.cfg.inner.path.clone(),
1156        );
1157        global_registries().insert(registry_key.clone(), Arc::clone(&self.registry));
1158
1159        let sender = ctx.sender();
1160        let forward_task: JoinHandle<Result<(), CamelError>> = tokio::spawn(async move {
1161            while let Some(envelope) = env_rx.recv().await {
1162                if sender.send(envelope).await.is_err() {
1163                    break;
1164                }
1165            }
1166            Ok(())
1167        });
1168
1169        self.server_state = Some(state);
1170        self.registry_key = Some(registry_key);
1171        self.forward_task = Some(forward_task);
1172        Ok(())
1173    }
1174
1175    async fn stop(&mut self) -> Result<(), CamelError> {
1176        tracing::info!(
1177            host = self.cfg.inner.host,
1178            port = self.cfg.inner.port,
1179            path = self.cfg.inner.path,
1180            "WebSocket consumer stopping"
1181        );
1182
1183        let close_msg = WsMessage::Close(Some(axum::extract::ws::CloseFrame {
1184            code: axum::extract::ws::CloseCode::from(1001u16),
1185            reason: "consumer stopping".into(),
1186        }));
1187        for tx in self.registry.snapshot_senders() {
1188            let _ = try_send_with_backpressure(&tx, close_msg.clone(), "consumer-stop-close");
1189        }
1190
1191        let mut had_server_error = false;
1192
1193        if let Some(state) = self.server_state.take() {
1194            had_server_error = state.server_error.load(Ordering::Relaxed);
1195            state.path_policies.remove(&self.cfg.inner.path);
1196            let mut table = state.dispatch.write().await;
1197            table.remove(&self.cfg.inner.path);
1198            state.path_configs.remove(&self.cfg.inner.path);
1199        }
1200
1201        if let Some(key) = self.registry_key.take() {
1202            global_registries().remove(&key);
1203            ServerRegistry::global().release(key.1);
1204        }
1205
1206        if let Some(task) = self.forward_task.take() {
1207            task.abort();
1208        }
1209
1210        tracing::info!(
1211            host = self.cfg.inner.host,
1212            port = self.cfg.inner.port,
1213            path = self.cfg.inner.path,
1214            "WebSocket consumer stopped"
1215        );
1216
1217        if had_server_error {
1218            tracing::warn!(
1219                host = self.cfg.inner.host,
1220                port = self.cfg.inner.port,
1221                path = self.cfg.inner.path,
1222                "WebSocket server had errors during its lifetime"
1223            );
1224            return Err(CamelError::ProcessorError(
1225                "WebSocket server terminated with errors during its lifetime".into(),
1226            ));
1227        }
1228
1229        Ok(())
1230    }
1231
1232    fn concurrency_model(&self) -> ConcurrencyModel {
1233        ConcurrencyModel::Concurrent {
1234            max: Some(self.cfg.inner.max_connections as usize),
1235        }
1236    }
1237
1238    fn startup_mode(&self) -> ConsumerStartupMode {
1239        ConsumerStartupMode::Explicit
1240    }
1241
1242    fn background_task_handle(&mut self) -> Option<JoinHandle<Result<(), CamelError>>> {
1243        self.forward_task.take()
1244    }
1245
1246    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
1247        // Construction-order (Task 2.1 lesson, applied at Task 2.8): the
1248        // context must already carry the compiled plan and the provider
1249        // registry here — `start()` publishes it into the shared server
1250        // state in the same step that makes the path dispatchable, so no
1251        // handshake can observe a half-patched policy.
1252        self.security_ctx = Some(ctx);
1253    }
1254}
1255
1256use std::sync::atomic::{AtomicBool, Ordering};
1257
1258fn new_atomic_false() -> Arc<AtomicBool> {
1259    Arc::new(AtomicBool::new(false))
1260}
1261
1262/// Classify a WebSocket error as retryable (transient network failure).
1263///
1264/// Retryable: connection refused, timeout, connection failed.
1265/// Permanent: anything else (protocol errors, auth failures, etc.).
1266#[inline]
1267fn is_retryable_ws_error(err: &CamelError) -> bool {
1268    let s = err.to_string();
1269    s.contains("connection refused") || s.contains("timeout") || s.contains("connection failed")
1270}
1271
1272#[derive(Clone)]
1273pub struct WsProducer {
1274    cfg: WsClientConfig,
1275    /// Shared flag set by the async future when server-send hits backpressure,
1276    /// so that the next `poll_ready` call can return an error. (WS-003)
1277    backpressure_flag: Arc<AtomicBool>,
1278}
1279
1280impl WsProducer {
1281    pub fn new(cfg: WsClientConfig) -> Self {
1282        Self {
1283            cfg,
1284            backpressure_flag: Arc::new(AtomicBool::new(false)),
1285        }
1286    }
1287}
1288
1289impl Service<Exchange> for WsProducer {
1290    type Response = Exchange;
1291    type Error = CamelError;
1292    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1293
1294    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
1295        // Return error if last server-send hit backpressure (WS-003)
1296        if self.backpressure_flag.swap(false, Ordering::Relaxed) {
1297            return Poll::Ready(Err(CamelError::ProcessorError(
1298                "WebSocket producer backpressure: previous send was dropped due to full channel"
1299                    .into(),
1300            )));
1301        }
1302        Poll::Ready(Ok(()))
1303    }
1304
1305    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1306        let cfg = self.cfg.clone();
1307        let backpressure_flag = Arc::clone(&self.backpressure_flag);
1308
1309        Box::pin(async move {
1310            let canonical_host = cfg.inner.canonical_host();
1311            let key = (
1312                canonical_host.clone(),
1313                cfg.inner.port,
1314                cfg.inner.path.clone(),
1315            );
1316
1317            let send_to_all = exchange
1318                .input
1319                .header("CamelWsSendToAll")
1320                .and_then(|v| v.as_bool())
1321                .or_else(|| exchange.input.header("sendToAll").and_then(|v| v.as_bool()))
1322                .unwrap_or(false);
1323
1324            let conn_keys_header = exchange
1325                .input
1326                .header("CamelWsConnectionKey")
1327                .and_then(|v| v.as_str())
1328                .map(str::to_string);
1329
1330            let local_exists = global_registries().contains_key(&key);
1331            let server_send_mode = send_to_all || conn_keys_header.is_some() || local_exists;
1332
1333            let message_type = exchange
1334                .input
1335                .header("CamelWsMessageType")
1336                .and_then(|v| v.as_str())
1337                .unwrap_or("text")
1338                .to_ascii_lowercase();
1339
1340            if server_send_mode {
1341                let registry = global_registries().get(&key).map(|e| Arc::clone(e.value()));
1342                let Some(registry) = registry else {
1343                    return Err(CamelError::ProcessorError(format!(
1344                        "WebSocket local consumer not found for {}:{}{}",
1345                        canonical_host, cfg.inner.port, cfg.inner.path
1346                    )));
1347                };
1348
1349                let out_msg = body_to_axum_ws_message(
1350                    std::mem::take(&mut exchange.input.body),
1351                    &message_type,
1352                )
1353                .await?;
1354
1355                let targets = if send_to_all {
1356                    registry.snapshot_senders()
1357                } else if let Some(keys) = conn_keys_header {
1358                    let parsed: Vec<String> = keys
1359                        .split(',')
1360                        .map(str::trim)
1361                        .filter(|k| !k.is_empty())
1362                        .map(|k| k.to_string())
1363                        .collect();
1364                    registry.get_senders_for_keys(&parsed)
1365                } else {
1366                    registry.snapshot_senders()
1367                };
1368
1369                let mut dropped = 0usize;
1370                for tx in &targets {
1371                    if !try_send_with_backpressure(tx, out_msg.clone(), "producer-send") {
1372                        dropped += 1;
1373                    }
1374                }
1375
1376                if dropped > 0 {
1377                    tracing::warn!(
1378                        host = canonical_host,
1379                        port = cfg.inner.port,
1380                        path = cfg.inner.path,
1381                        dropped,
1382                        total = targets.len(),
1383                        "WebSocket producer dropped messages due to backpressure"
1384                    );
1385                    exchange.input.set_header(
1386                        "CamelWsDeliveryDropped",
1387                        serde_json::Value::Number(dropped.into()),
1388                    );
1389                    // Signal backpressure for next poll_ready call (WS-003)
1390                    backpressure_flag.store(true, Ordering::Relaxed);
1391                    if dropped == targets.len() {
1392                        return Err(CamelError::ProcessorError(format!(
1393                            "WebSocket producer: all {dropped} message(s) dropped due to backpressure"
1394                        )));
1395                    }
1396                }
1397
1398                tracing::debug!(
1399                    host = canonical_host,
1400                    port = cfg.inner.port,
1401                    path = cfg.inner.path,
1402                    targets = targets.len(),
1403                    "WebSocket producer server-send complete"
1404                );
1405
1406                return Ok(exchange);
1407            }
1408
1409            let url = format!(
1410                "{}://{}:{}{}",
1411                cfg.inner.scheme, cfg.inner.host, cfg.inner.port, cfg.inner.path
1412            );
1413
1414            tracing::debug!(url = url, "WebSocket producer connecting");
1415
1416            #[allow(unused_mut)]
1417            let mut request = url
1418                .clone()
1419                .into_client_request()
1420                .map_err(|e| CamelError::ProcessorError(format!("WebSocket request error: {e}")))?;
1421
1422            #[cfg(feature = "otel")]
1423            {
1424                let mut otel_headers = HashMap::new();
1425                camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
1426                for (k, v) in otel_headers {
1427                    if let (Ok(name), Ok(val)) = (
1428                        http::header::HeaderName::from_bytes(k.as_bytes()),
1429                        http::header::HeaderValue::from_str(&v),
1430                    ) {
1431                        request.headers_mut().insert(name, val);
1432                    }
1433                }
1434            }
1435
1436            // Add Sec-WebSocket-Protocol header if subprotocols configured (WS-007)
1437            if !cfg.inner.subprotocols.is_empty() {
1438                let proto_value = cfg.inner.subprotocols.join(", ");
1439                if let (Ok(name), Ok(val)) = (
1440                    http::header::HeaderName::from_bytes(b"Sec-WebSocket-Protocol"),
1441                    http::header::HeaderValue::from_str(&proto_value),
1442                ) {
1443                    request.headers_mut().insert(name, val);
1444                }
1445            }
1446
1447            // Determine message type: respect binary_payload config (WS-018)
1448            let effective_message_type = if cfg.inner.binary_payload {
1449                "binary"
1450            } else {
1451                &message_type
1452            };
1453
1454            let reconnect_policy = cfg.inner.reconnect_policy.clone();
1455            let mut ws_stream =
1456                connect_ws_with_retry(request, &url, cfg.inner.connect_timeout, &reconnect_policy)
1457                    .await?;
1458
1459            // Close/reconnect path: rate-limited bail. On close frame, sleep
1460            // delay_for(0) and return Err to signal the outer route to re-invoke
1461            // the producer. The attempts counter below bounds how many times
1462            // we'll signal reconnect before terminating. Independent counter —
1463            // OLD code shared a counter with the connect loop above; this is a
1464            // behavior change (cleaner separation of concerns).
1465            let attempts = 0u32;
1466
1467            let out_msg = body_to_client_ws_message(
1468                std::mem::take(&mut exchange.input.body),
1469                effective_message_type,
1470            )
1471            .await?;
1472
1473            send_with_timeout(ws_stream.send(out_msg), cfg.inner.send_timeout).await?;
1474
1475            let incoming = tokio::time::timeout(cfg.inner.response_timeout, async {
1476                loop {
1477                    match ws_stream.next().await {
1478                        Some(Ok(ClientWsMessage::Ping(_))) | Some(Ok(ClientWsMessage::Pong(_))) => {
1479                            continue;
1480                        }
1481                        other => break other,
1482                    }
1483                }
1484            })
1485            .await
1486            .map_err(|_| CamelError::ProcessorError("WebSocket response timeout".into()))?;
1487
1488            match incoming {
1489                Some(Ok(ClientWsMessage::Text(text))) => {
1490                    tracing::debug!(url = url, "WebSocket producer received text response");
1491                    exchange.input.body = CamelBody::Text(text.to_string());
1492                }
1493                Some(Ok(ClientWsMessage::Binary(data))) => {
1494                    tracing::debug!(url = url, "WebSocket producer received binary response");
1495                    exchange.input.body = CamelBody::Bytes(data);
1496                }
1497                Some(Ok(ClientWsMessage::Close(frame))) => {
1498                    let normal = frame
1499                        .as_ref()
1500                        .map(|f| {
1501                            f.code == tungstenite::protocol::frame::coding::CloseCode::Normal
1502                                || f.code == tungstenite::protocol::frame::coding::CloseCode::Away
1503                        })
1504                        .unwrap_or(true);
1505
1506                    if normal {
1507                        tracing::debug!(url = url, "WebSocket producer received normal close");
1508                        exchange.input.body = CamelBody::Empty;
1509                    } else if reconnect_policy.should_retry(attempts + 1) {
1510                        let delay = reconnect_policy.delay_for(0); // fresh delay on close
1511                        tracing::warn!(
1512                            url = url,
1513                            attempt = attempts + 1,
1514                            delay_ms = delay.as_millis(),
1515                            "WebSocket closed by peer — reconnecting"
1516                        );
1517                        tokio::time::sleep(delay).await;
1518                        return Err(CamelError::ProcessorError(format!(
1519                            "WebSocket reconnect required after close: code {}",
1520                            frame.map(|f| u16::from(f.code)).unwrap_or_default()
1521                        )));
1522                    } else {
1523                        let code = frame.map(|f| u16::from(f.code)).unwrap_or_default();
1524                        return Err(CamelError::ProcessorError(format!(
1525                            "WebSocket peer closed: code {code}"
1526                        )));
1527                    }
1528                }
1529                Some(Ok(_)) | None => {
1530                    exchange.input.body = CamelBody::Empty;
1531                }
1532                Some(Err(e)) => {
1533                    return Err(CamelError::ProcessorError(format!(
1534                        "WebSocket receive failed: {e}"
1535                    )));
1536                }
1537            }
1538
1539            let _ = ws_stream.close(None).await;
1540            tracing::debug!(url = url, "WebSocket producer connection closed");
1541            Ok(exchange)
1542        })
1543    }
1544}
1545
1546async fn body_to_axum_ws_message(
1547    body: CamelBody,
1548    message_type: &str,
1549) -> Result<WsMessage, CamelError> {
1550    match message_type {
1551        "binary" => Ok(WsMessage::Binary(body.into_bytes(10 * 1024 * 1024).await?)),
1552        _ => Ok(WsMessage::Text(body_to_text(body).await?.into())),
1553    }
1554}
1555
1556async fn body_to_client_ws_message(
1557    body: CamelBody,
1558    message_type: &str,
1559) -> Result<ClientWsMessage, CamelError> {
1560    match message_type {
1561        "binary" => Ok(ClientWsMessage::Binary(
1562            body.into_bytes(10 * 1024 * 1024).await?,
1563        )),
1564        _ => Ok(ClientWsMessage::Text(body_to_text(body).await?.into())),
1565    }
1566}
1567
1568async fn body_to_text(body: CamelBody) -> Result<String, CamelError> {
1569    Ok(match body {
1570        CamelBody::Text(s) => s,
1571        CamelBody::Xml(s) => s,
1572        CamelBody::Json(v) => v.to_string(),
1573        CamelBody::Bytes(b) => String::from_utf8_lossy(&b).to_string(),
1574        CamelBody::Stream(stream) => {
1575            let bytes = CamelBody::Stream(stream)
1576                .into_bytes(10 * 1024 * 1024)
1577                .await?;
1578            String::from_utf8_lossy(&bytes).to_string()
1579        }
1580        // Empty and future variants render as an empty string.
1581        _ => String::new(),
1582    })
1583}
1584
1585fn is_origin_allowed(allowed_origin: &str, request_origin: Option<&str>) -> bool {
1586    if allowed_origin == "*" {
1587        return true;
1588    }
1589    request_origin.is_some_and(|origin| origin == allowed_origin)
1590}
1591
1592fn try_send_with_backpressure(tx: &mpsc::Sender<WsMessage>, msg: WsMessage, context: &str) -> bool {
1593    match tx.try_send(msg) {
1594        Ok(()) => true,
1595        Err(error) => {
1596            tracing::warn!(%context, %error, "dropping websocket outbound message due to backpressure");
1597            false
1598        }
1599    }
1600}
1601
1602async fn send_with_timeout(
1603    send_future: impl std::future::Future<Output = Result<(), tungstenite::Error>>,
1604    timeout: std::time::Duration,
1605) -> Result<(), CamelError> {
1606    match tokio::time::timeout(timeout, send_future).await {
1607        Ok(result) => {
1608            result.map_err(|e| CamelError::ProcessorError(format!("WebSocket send failed: {e}")))
1609        }
1610        Err(_) => Err(CamelError::ProcessorError(format!(
1611            "WebSocket send timeout after {timeout:?}"
1612        ))),
1613    }
1614}
1615
1616fn load_tls_config(
1617    cert_path: &str,
1618    key_path: &str,
1619) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1620    use std::fs::File;
1621    use std::io::BufReader;
1622
1623    let cert_file = File::open(cert_path)
1624        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1625    let key_file = File::open(key_path)
1626        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1627
1628    let certs = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1629        .collect::<Result<Vec<_>, _>>()
1630        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1631
1632    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1633        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1634        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1635
1636    tokio_rustls::rustls::ServerConfig::builder()
1637        .with_no_client_auth()
1638        .with_single_cert(certs, key)
1639        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1640}
1641
1642fn map_connect_error(err: tungstenite::Error, url: &str) -> CamelError {
1643    match err {
1644        tungstenite::Error::Io(ioe) if ioe.kind() == std::io::ErrorKind::ConnectionRefused => {
1645            CamelError::ProcessorError(format!("WebSocket connection refused: {ioe}"))
1646        }
1647        tungstenite::Error::Tls(_) => {
1648            CamelError::ProcessorError("WebSocket TLS handshake failed: handshake error".into())
1649        }
1650        other => {
1651            let msg = other.to_string();
1652            if msg.to_lowercase().contains("connection refused") {
1653                CamelError::ProcessorError(format!("WebSocket connection refused: {msg}"))
1654            } else if msg.to_lowercase().contains("tls") {
1655                CamelError::ProcessorError(format!("WebSocket TLS handshake failed: {msg}"))
1656            } else {
1657                CamelError::ProcessorError(format!("WebSocket connection failed ({url}): {msg}"))
1658            }
1659        }
1660    }
1661}
1662
1663/// Connect to a WebSocket server with retry logic using the configured
1664/// [`NetworkRetryPolicy`]. Extracted for testability so the regression test
1665/// (rc-1nm) can drive the real production connect path rather than a
1666/// synthetic fake.
1667async fn connect_ws_with_retry<R>(
1668    request: R,
1669    url: &str,
1670    connect_timeout: std::time::Duration,
1671    reconnect_policy: &NetworkRetryPolicy,
1672) -> Result<
1673    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
1674    CamelError,
1675>
1676where
1677    R: IntoClientRequest + Unpin + Clone,
1678{
1679    let url_owned = url.to_string();
1680    retry_async(
1681        reconnect_policy,
1682        Some("ws-producer"),
1683        || {
1684            let r = request.clone();
1685            let url = url_owned.clone();
1686            async move {
1687                match tokio::time::timeout(connect_timeout, tokio_tungstenite::connect_async(r))
1688                    .await
1689                {
1690                    Ok(Ok((stream, _))) => Ok(stream),
1691                    Ok(Err(e)) => Err(map_connect_error(e, &url)),
1692                    Err(_) => Err(CamelError::ProcessorError(format!(
1693                        "WebSocket connect timeout ({connect_timeout:?}) to {url}"
1694                    ))),
1695                }
1696            }
1697        },
1698        is_retryable_ws_error,
1699    )
1700    .await
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705    use camel_component_api::test_support::PanicRuntimeObservability;
1706    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1707        std::sync::Arc::new(PanicRuntimeObservability)
1708    }
1709    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1710        std::sync::Arc::new(PanicRuntimeObservability)
1711    }
1712
1713    /// Serialize tests that touch the global `ServerRegistry::global()`.
1714    ///
1715    /// `ServerRegistry::reset()` aborts ALL server tasks globally, so any
1716    /// test with a running server must hold this lock for its duration to
1717    /// prevent a concurrent `reset()` from killing its server. Tests that
1718    /// call `reset()` must also hold it.
1719    static REGISTRY_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1720
1721    use super::*;
1722    use camel_component_api::NoOpComponentContext;
1723    use std::time::Duration;
1724
1725    use tokio::sync::mpsc;
1726    use tokio_tungstenite::connect_async;
1727    use tokio_tungstenite::tungstenite::Message as ClientMessage;
1728    use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
1729    use tokio_util::sync::CancellationToken;
1730    use tower::ServiceExt;
1731
1732    fn free_port() -> u16 {
1733        std::net::TcpListener::bind("127.0.0.1:0")
1734            .unwrap()
1735            .local_addr()
1736            .unwrap()
1737            .port()
1738    }
1739
1740    #[test]
1741    fn ws_component_scheme_is_ws() {
1742        assert_eq!(WsComponent::new().scheme(), "ws");
1743    }
1744
1745    #[test]
1746    fn wss_component_scheme_is_wss() {
1747        assert_eq!(WssComponent::new().scheme(), "wss");
1748    }
1749
1750    #[test]
1751    fn endpoint_config_defaults_match_spec() {
1752        let cfg = WsEndpointConfig::default();
1753        assert_eq!(cfg.scheme, "ws");
1754        assert_eq!(cfg.host, "0.0.0.0");
1755        assert_eq!(cfg.port, 8080);
1756        assert_eq!(cfg.path, "/");
1757        assert_eq!(cfg.max_connections, 100);
1758        assert_eq!(cfg.max_message_size, 65536);
1759        assert!(!cfg.send_to_all);
1760        assert_eq!(cfg.heartbeat_interval, Duration::ZERO);
1761        assert_eq!(cfg.idle_timeout, Duration::ZERO);
1762        assert_eq!(cfg.connect_timeout, Duration::from_secs(10));
1763        assert_eq!(cfg.response_timeout, Duration::from_secs(30));
1764        assert_eq!(cfg.allow_origin, "*");
1765        assert_eq!(cfg.tls_cert, None);
1766        assert_eq!(cfg.tls_key, None);
1767        assert!(cfg.reconnect);
1768        assert_eq!(cfg.reconnect_max_attempts, 5);
1769        assert_eq!(cfg.reconnect_delay_ms, 1000);
1770        assert_eq!(cfg.send_timeout, Duration::from_secs(30));
1771        assert!(!cfg.binary_payload);
1772        assert!(cfg.subprotocols.is_empty());
1773    }
1774
1775    #[test]
1776    fn endpoint_config_parses_uri_params() {
1777        let uri = "ws://localhost:9001/chat?maxConnections=42&maxMessageSize=1024&sendToAll=true&heartbeatIntervalMs=1500&idleTimeoutMs=2500&connectTimeoutMs=3500&responseTimeoutMs=4500&allowOrigin=https://example.com&tlsCert=/tmp/cert.pem&tlsKey=/tmp/key.pem";
1778        let cfg = WsEndpointConfig::from_uri(uri).unwrap();
1779
1780        assert_eq!(cfg.scheme, "ws");
1781        assert_eq!(cfg.host, "localhost");
1782        assert_eq!(cfg.port, 9001);
1783        assert_eq!(cfg.path, "/chat");
1784        assert_eq!(cfg.max_connections, 42);
1785        assert_eq!(cfg.max_message_size, 1024);
1786        assert!(cfg.send_to_all);
1787        assert_eq!(cfg.heartbeat_interval, Duration::from_millis(1500));
1788        assert_eq!(cfg.idle_timeout, Duration::from_millis(2500));
1789        assert_eq!(cfg.connect_timeout, Duration::from_millis(3500));
1790        assert_eq!(cfg.response_timeout, Duration::from_millis(4500));
1791        assert_eq!(cfg.allow_origin, "https://example.com");
1792        assert_eq!(cfg.tls_cert.as_deref(), Some("/tmp/cert.pem"));
1793        assert_eq!(cfg.tls_key.as_deref(), Some("/tmp/key.pem"));
1794        assert!(cfg.reconnect);
1795        assert_eq!(cfg.reconnect_max_attempts, 5);
1796        assert_eq!(cfg.reconnect_delay_ms, 1000);
1797    }
1798
1799    #[test]
1800    fn endpoint_config_parses_reconnect_uri_params() {
1801        let uri =
1802            "ws://localhost:9001/chat?reconnect=false&reconnectMaxAttempts=2&reconnectDelayMs=25";
1803        let cfg = WsEndpointConfig::from_uri(uri).unwrap();
1804        assert!(!cfg.reconnect);
1805        assert_eq!(cfg.reconnect_max_attempts, 2);
1806        assert_eq!(cfg.reconnect_delay_ms, 25);
1807    }
1808
1809    #[test]
1810    fn endpoint_config_override_chain_uri_overrides_defaults() {
1811        let cfg = WsEndpointConfig::from_uri("ws://127.0.0.1:8089/echo?maxConnections=7").unwrap();
1812        assert_eq!(cfg.max_connections, 7);
1813        assert_eq!(cfg.max_message_size, 65536);
1814        assert!(!cfg.send_to_all);
1815        assert_eq!(cfg.response_timeout, Duration::from_secs(30));
1816    }
1817
1818    #[test]
1819    fn endpoint_trait_creates_consumer_and_producer() {
1820        let ctx = NoOpComponentContext;
1821        let endpoint = WsComponent::new()
1822            .create_endpoint("ws://127.0.0.1:9010/trait", &ctx)
1823            .unwrap();
1824
1825        endpoint.create_consumer(rt()).unwrap();
1826        endpoint
1827            .create_producer(rt(), &ProducerContext::default())
1828            .unwrap();
1829    }
1830
1831    #[test]
1832    fn ws_consumer_concurrency_model_uses_max_connections() {
1833        let cfg = WsEndpointConfig::from_uri("ws://127.0.0.1:9011/cm?maxConnections=321").unwrap();
1834        let consumer = WsConsumer::new(cfg.server_config(), test_rt());
1835        assert_eq!(
1836            consumer.concurrency_model(),
1837            ConcurrencyModel::Concurrent { max: Some(321) }
1838        );
1839    }
1840
1841    #[tokio::test]
1842    async fn connection_registry_add_remove_broadcast_and_targeted_send() {
1843        let registry = WsConnectionRegistry::new();
1844        let (tx1, mut rx1) = mpsc::channel(8);
1845        let (tx2, mut rx2) = mpsc::channel(8);
1846
1847        registry.insert("k1".into(), tx1);
1848        registry.insert("k2".into(), tx2);
1849        assert_eq!(registry.len(), 2);
1850
1851        for tx in registry.snapshot_senders() {
1852            tx.send(WsMessage::Text("broadcast".into())).await.unwrap();
1853        }
1854
1855        assert_eq!(rx1.recv().await, Some(WsMessage::Text("broadcast".into())));
1856        assert_eq!(rx2.recv().await, Some(WsMessage::Text("broadcast".into())));
1857
1858        let target = registry.get_senders_for_keys(&["k1".to_string()]);
1859        assert_eq!(target.len(), 1);
1860        target[0]
1861            .send(WsMessage::Text("targeted".into()))
1862            .await
1863            .unwrap();
1864
1865        assert_eq!(rx1.recv().await, Some(WsMessage::Text("targeted".into())));
1866        assert!(
1867            tokio::time::timeout(Duration::from_millis(50), rx2.recv())
1868                .await
1869                .is_err()
1870        );
1871
1872        registry.remove("k1");
1873        assert_eq!(registry.len(), 1);
1874    }
1875
1876    #[test]
1877    fn host_canonicalization_maps_local_hosts_to_loopback() {
1878        let c1 = WsEndpointConfig::from_uri("ws://0.0.0.0:9100/a")
1879            .unwrap()
1880            .canonical_host();
1881        let c2 = WsEndpointConfig::from_uri("ws://localhost:9101/b")
1882            .unwrap()
1883            .canonical_host();
1884        let c3 = WsEndpointConfig::from_uri("ws://127.0.0.1:9102/c")
1885            .unwrap()
1886            .canonical_host();
1887
1888        assert_eq!(c1, "127.0.0.1");
1889        assert_eq!(c2, "127.0.0.1");
1890        assert_eq!(c3, "127.0.0.1");
1891    }
1892
1893    #[tokio::test]
1894    async fn echo_flow_round_trips_message_through_consumer_and_producer() {
1895        let _guard = REGISTRY_TEST_LOCK.lock().await;
1896        let port = free_port();
1897        let uri = format!("ws://127.0.0.1:{port}/echo");
1898        let component_ctx = NoOpComponentContext;
1899        let endpoint = WsComponent::new()
1900            .create_endpoint(&uri, &component_ctx)
1901            .unwrap();
1902
1903        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1904        let producer = endpoint
1905            .create_producer(rt(), &ProducerContext::default())
1906            .unwrap();
1907
1908        let (route_tx, mut route_rx) = mpsc::channel(16);
1909        let ctx = ConsumerContext::new(
1910            route_tx,
1911            CancellationToken::new(),
1912            "ws-test-route".to_string(),
1913        );
1914        consumer.start(ctx).await.unwrap();
1915
1916        let route_task = tokio::spawn(async move {
1917            if let Some(envelope) = route_rx.recv().await {
1918                let payload = envelope
1919                    .exchange
1920                    .input
1921                    .body
1922                    .as_text()
1923                    .unwrap_or_default()
1924                    .to_string();
1925                let key = envelope
1926                    .exchange
1927                    .input
1928                    .header("CamelWsConnectionKey")
1929                    .and_then(|v| v.as_str())
1930                    .unwrap()
1931                    .to_string();
1932
1933                let mut response = Exchange::new(CamelMessage::new(CamelBody::Text(payload)));
1934                response
1935                    .input
1936                    .set_header("CamelWsConnectionKey", serde_json::Value::String(key));
1937                producer.oneshot(response).await.unwrap();
1938            }
1939        });
1940
1941        let url = format!("ws://127.0.0.1:{port}/echo");
1942        let (mut client, _) = loop {
1943            match connect_async(&url).await {
1944                Ok(ok) => break ok,
1945                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
1946            }
1947        };
1948
1949        client
1950            .send(ClientMessage::Text("hello-ws".into()))
1951            .await
1952            .unwrap();
1953
1954        let incoming = tokio::time::timeout(Duration::from_secs(2), async {
1955            loop {
1956                match client.next().await {
1957                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
1958                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
1959                    Some(Ok(_)) => continue,
1960                    Some(Err(e)) => panic!("ws receive failed: {e}"),
1961                    None => panic!("websocket closed before echo"),
1962                }
1963            }
1964        })
1965        .await
1966        .unwrap();
1967
1968        assert_eq!(incoming, "hello-ws");
1969
1970        consumer.stop().await.unwrap();
1971        route_task.await.unwrap();
1972    }
1973
1974    #[tokio::test]
1975    async fn consumer_stop_sends_close_1001() {
1976        let _guard = REGISTRY_TEST_LOCK.lock().await;
1977        let port = free_port();
1978        let uri = format!("ws://127.0.0.1:{port}/shutdown");
1979        let component_ctx = NoOpComponentContext;
1980        let endpoint = WsComponent::new()
1981            .create_endpoint(&uri, &component_ctx)
1982            .unwrap();
1983
1984        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1985        let (route_tx, _route_rx) = mpsc::channel(16);
1986        let ctx = ConsumerContext::new(
1987            route_tx,
1988            CancellationToken::new(),
1989            "ws-test-route".to_string(),
1990        );
1991        consumer.start(ctx).await.unwrap();
1992
1993        let url = format!("ws://127.0.0.1:{port}/shutdown");
1994        let (mut client, _) = loop {
1995            match connect_async(&url).await {
1996                Ok(ok) => break ok,
1997                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
1998            }
1999        };
2000
2001        client
2002            .send(ClientMessage::Text("keepalive".into()))
2003            .await
2004            .unwrap();
2005
2006        consumer.stop().await.unwrap();
2007
2008        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
2009            loop {
2010                match client.next().await {
2011                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
2012                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2013                    Some(Ok(_)) => continue,
2014                    Some(Err(e)) => panic!("ws receive failed: {e}"),
2015                    None => panic!("websocket closed without close frame"),
2016                }
2017            }
2018        })
2019        .await
2020        .unwrap();
2021
2022        assert_eq!(close_code, Some(CloseCode::Away));
2023    }
2024
2025    #[test]
2026    fn wildcard_origin_allows_anything() {
2027        assert!(is_origin_allowed("*", None));
2028        assert!(is_origin_allowed("*", Some("https://example.com")));
2029    }
2030
2031    #[test]
2032    fn exact_origin_requires_match() {
2033        assert!(is_origin_allowed(
2034            "https://example.com",
2035            Some("https://example.com")
2036        ));
2037        assert!(!is_origin_allowed(
2038            "https://example.com",
2039            Some("https://other.com")
2040        ));
2041        assert!(!is_origin_allowed("https://example.com", None));
2042    }
2043
2044    #[test]
2045    fn endpoint_config_rejects_invalid_scheme() {
2046        let result = WsEndpointConfig::from_uri("http://localhost:9000/path");
2047        assert!(result.is_err());
2048        let msg = result.unwrap_err().to_string();
2049        assert!(
2050            msg.contains("Invalid WebSocket scheme"),
2051            "expected scheme error, got: {msg}"
2052        );
2053    }
2054
2055    #[tokio::test]
2056    async fn wss_consumer_start_fails_without_tls_cert() {
2057        let _guard = REGISTRY_TEST_LOCK.lock().await;
2058        let port = free_port();
2059        let component_ctx = NoOpComponentContext;
2060        let endpoint = WssComponent::new()
2061            .create_endpoint(&format!("wss://127.0.0.1:{port}/secure"), &component_ctx)
2062            .unwrap();
2063        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2064        let (tx, _rx) = mpsc::channel(16);
2065        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "ws-test-route".to_string());
2066        let result = consumer.start(ctx).await;
2067        assert!(result.is_err());
2068        let msg = result.unwrap_err().to_string();
2069        assert!(
2070            msg.contains("TLS cert path is required"),
2071            "expected TLS cert error, got: {msg}"
2072        );
2073    }
2074
2075    #[tokio::test]
2076    async fn wss_consumer_start_fails_with_nonexistent_cert() {
2077        let _guard = REGISTRY_TEST_LOCK.lock().await;
2078        // Ensure clean global state (process-lifetime servers may leak across tests).
2079        ServerRegistry::reset();
2080
2081        let port = free_port();
2082        let component_ctx = NoOpComponentContext;
2083        let endpoint = WssComponent::new()
2084            .create_endpoint(&format!(
2085                "wss://127.0.0.1:{port}/secure?tlsCert=/nonexistent/cert.pem&tlsKey=/nonexistent/key.pem"
2086            ), &component_ctx)
2087            .unwrap();
2088        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2089        let (tx, _rx) = mpsc::channel(16);
2090        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "ws-test-route".to_string());
2091        let result = consumer.start(ctx).await;
2092        assert!(result.is_err());
2093        let msg = result.unwrap_err().to_string();
2094        assert!(
2095            msg.contains("TLS cert file error"),
2096            "expected cert file error, got: {msg}"
2097        );
2098    }
2099
2100    #[tokio::test]
2101    async fn server_registry_returns_same_state_for_same_port() {
2102        let _guard = REGISTRY_TEST_LOCK.lock().await;
2103        let port = free_port();
2104        let (state1, _) = ServerRegistry::global()
2105            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2106            .await
2107            .unwrap();
2108        let (state2, _) = ServerRegistry::global()
2109            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2110            .await
2111            .unwrap();
2112        assert!(
2113            Arc::ptr_eq(&state1.dispatch, &state2.dispatch),
2114            "expected same dispatch table for same port"
2115        );
2116    }
2117
2118    #[tokio::test]
2119    async fn dispatch_handler_returns_404_for_unregistered_path() {
2120        let _guard = REGISTRY_TEST_LOCK.lock().await;
2121        let port = free_port();
2122        let (state, _) = ServerRegistry::global()
2123            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2124            .await
2125            .unwrap();
2126        let app = Router::new().fallback(dispatch_handler).with_state(state);
2127        let response = tokio::time::timeout(
2128            Duration::from_secs(2),
2129            tower::ServiceExt::oneshot(
2130                app,
2131                axum::http::Request::builder()
2132                    .uri("/nonexistent")
2133                    .body(Body::empty())
2134                    .unwrap(),
2135            ),
2136        )
2137        .await
2138        .unwrap()
2139        .unwrap();
2140        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2141    }
2142
2143    #[tokio::test]
2144    async fn client_mode_producer_connects_and_echoes() {
2145        let app = Router::new().route(
2146            "/echo",
2147            axum::routing::get(|ws: WebSocketUpgrade| async move {
2148                ws.on_upgrade(|mut socket: WebSocket| async move {
2149                    while let Some(Ok(msg)) = socket.recv().await {
2150                        match msg {
2151                            WsMessage::Text(text) => {
2152                                let _ = socket.send(WsMessage::Text(text)).await;
2153                            }
2154                            WsMessage::Binary(data) => {
2155                                let _ = socket.send(WsMessage::Binary(data)).await;
2156                            }
2157                            WsMessage::Close(_) => break,
2158                            _ => {}
2159                        }
2160                    }
2161                })
2162            }),
2163        );
2164        // Bind to port 0 directly to avoid TOCTOU race with free_port() + re-bind
2165        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2166        let port = listener.local_addr().unwrap().port();
2167        let server_task = tokio::spawn(async move {
2168            let _ = serve(listener, app).await;
2169        });
2170
2171        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/echo")).unwrap();
2172        let producer = WsProducer::new(cfg.client_config());
2173
2174        let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("hello-client".into())));
2175        tokio::time::sleep(Duration::from_millis(25)).await;
2176        let result =
2177            match tokio::time::timeout(Duration::from_secs(3), producer.oneshot(exchange)).await {
2178                Ok(Ok(r)) => r,
2179                Ok(Err(_)) => panic!("producer call failed"),
2180                Err(_) => panic!("producer call timed out"),
2181            };
2182
2183        assert_eq!(result.input.body.as_text().unwrap(), "hello-client");
2184
2185        server_task.abort();
2186    }
2187
2188    #[tokio::test]
2189    async fn max_connections_rejects_with_close_1013() {
2190        let _guard = REGISTRY_TEST_LOCK.lock().await;
2191        let port = free_port();
2192        let uri = format!("ws://127.0.0.1:{port}/limited?maxConnections=1");
2193        let component_ctx = NoOpComponentContext;
2194        let endpoint = WsComponent::new()
2195            .create_endpoint(&uri, &component_ctx)
2196            .unwrap();
2197        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2198        let (route_tx, _route_rx) = mpsc::channel(16);
2199        let ctx = ConsumerContext::new(
2200            route_tx,
2201            CancellationToken::new(),
2202            "ws-test-route".to_string(),
2203        );
2204        consumer.start(ctx).await.unwrap();
2205
2206        let url = format!("ws://127.0.0.1:{port}/limited");
2207        let (_client1, _) = loop {
2208            match connect_async(&url).await {
2209                Ok(ok) => break ok,
2210                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2211            }
2212        };
2213
2214        tokio::time::sleep(Duration::from_millis(100)).await;
2215
2216        let (mut client2, _) = connect_async(&url).await.unwrap();
2217
2218        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
2219            loop {
2220                match client2.next().await {
2221                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
2222                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2223                    Some(Ok(ClientMessage::Text(_))) => continue,
2224                    Some(Ok(_)) => continue,
2225                    Some(Err(e)) => panic!("client2 ws receive failed: {e}"),
2226                    None => panic!("client2 closed without close frame"),
2227                }
2228            }
2229        })
2230        .await
2231        .unwrap();
2232
2233        assert_eq!(
2234            close_code,
2235            Some(CloseCode::from(1013u16)),
2236            "expected 1013 (Try Again Later) for max connections"
2237        );
2238
2239        consumer.stop().await.unwrap();
2240    }
2241
2242    #[tokio::test]
2243    async fn max_message_size_rejects_with_close_1009() {
2244        let _guard = REGISTRY_TEST_LOCK.lock().await;
2245        let port = free_port();
2246        let uri = format!("ws://127.0.0.1:{port}/sizelimit?maxMessageSize=10");
2247        let component_ctx = NoOpComponentContext;
2248        let endpoint = WsComponent::new()
2249            .create_endpoint(&uri, &component_ctx)
2250            .unwrap();
2251        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2252        let (route_tx, _route_rx) = mpsc::channel(16);
2253        let ctx = ConsumerContext::new(
2254            route_tx,
2255            CancellationToken::new(),
2256            "ws-test-route".to_string(),
2257        );
2258        consumer.start(ctx).await.unwrap();
2259
2260        let url = format!("ws://127.0.0.1:{port}/sizelimit");
2261        let (mut client, _) = loop {
2262            match connect_async(&url).await {
2263                Ok(ok) => break ok,
2264                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2265            }
2266        };
2267
2268        let oversized = "x".repeat(100);
2269        client
2270            .send(ClientMessage::Text(oversized.into()))
2271            .await
2272            .unwrap();
2273
2274        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
2275            loop {
2276                match client.next().await {
2277                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
2278                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2279                    Some(Ok(_)) => continue,
2280                    Some(Err(e)) => panic!("ws receive failed: {e}"),
2281                    None => panic!("websocket closed without close frame"),
2282                }
2283            }
2284        })
2285        .await
2286        .unwrap();
2287
2288        assert_eq!(
2289            close_code,
2290            Some(CloseCode::from(1009u16)),
2291            "expected 1009 (Message Too Big) for oversized message"
2292        );
2293
2294        consumer.stop().await.unwrap();
2295    }
2296
2297    #[tokio::test]
2298    async fn origin_rejection_returns_403() {
2299        let _guard = REGISTRY_TEST_LOCK.lock().await;
2300        let port = free_port();
2301        let uri = format!("ws://127.0.0.1:{port}/origintest?allowOrigin=https://allowed.com");
2302        let component_ctx = NoOpComponentContext;
2303        let endpoint = WsComponent::new()
2304            .create_endpoint(&uri, &component_ctx)
2305            .unwrap();
2306        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2307        let (route_tx, _route_rx) = mpsc::channel(16);
2308        let ctx = ConsumerContext::new(
2309            route_tx,
2310            CancellationToken::new(),
2311            "ws-test-route".to_string(),
2312        );
2313        consumer.start(ctx).await.unwrap();
2314
2315        let (state, _) = ServerRegistry::global()
2316            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2317            .await
2318            .unwrap();
2319        let app = Router::new().fallback(dispatch_handler).with_state(state);
2320
2321        let response = tokio::time::timeout(
2322            Duration::from_secs(2),
2323            tower::ServiceExt::oneshot(
2324                app,
2325                axum::http::Request::builder()
2326                    .uri("/origintest")
2327                    .header("origin", "https://evil.com")
2328                    .header("upgrade", "websocket")
2329                    .header("connection", "Upgrade")
2330                    .header("sec-websocket-version", "13")
2331                    .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
2332                    .body(Body::empty())
2333                    .unwrap(),
2334            ),
2335        )
2336        .await
2337        .unwrap()
2338        .unwrap();
2339
2340        assert_eq!(
2341            response.status(),
2342            StatusCode::FORBIDDEN,
2343            "expected 403 for disallowed origin"
2344        );
2345
2346        consumer.stop().await.unwrap();
2347    }
2348
2349    #[tokio::test]
2350    async fn broadcast_sends_to_all_connected_clients() {
2351        let _guard = REGISTRY_TEST_LOCK.lock().await;
2352        let port = free_port();
2353        let uri = format!("ws://127.0.0.1:{port}/bc");
2354        let component_ctx = NoOpComponentContext;
2355        let endpoint = WsComponent::new()
2356            .create_endpoint(&uri, &component_ctx)
2357            .unwrap();
2358        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2359        let producer = endpoint
2360            .create_producer(rt(), &ProducerContext::default())
2361            .unwrap();
2362
2363        let (route_tx, _route_rx) = mpsc::channel(16);
2364        let ctx = ConsumerContext::new(
2365            route_tx,
2366            CancellationToken::new(),
2367            "ws-test-route".to_string(),
2368        );
2369        consumer.start(ctx).await.unwrap();
2370
2371        let url = format!("ws://127.0.0.1:{port}/bc");
2372
2373        let (mut client1, _) = loop {
2374            match connect_async(&url).await {
2375                Ok(ok) => break ok,
2376                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2377            }
2378        };
2379
2380        let (mut client2, _) = connect_async(&url).await.unwrap();
2381
2382        tokio::time::sleep(Duration::from_millis(100)).await;
2383
2384        let mut response =
2385            Exchange::new(CamelMessage::new(CamelBody::Text("broadcast-msg".into())));
2386        response
2387            .input
2388            .set_header("CamelWsSendToAll", serde_json::Value::Bool(true));
2389        producer.oneshot(response).await.unwrap();
2390
2391        let recv1 = tokio::time::timeout(Duration::from_secs(2), async {
2392            loop {
2393                match client1.next().await {
2394                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
2395                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2396                    _ => panic!("client1 unexpected message or close"),
2397                }
2398            }
2399        })
2400        .await
2401        .unwrap();
2402
2403        let recv2 = tokio::time::timeout(Duration::from_secs(2), async {
2404            loop {
2405                match client2.next().await {
2406                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
2407                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2408                    _ => panic!("client2 unexpected message or close"),
2409                }
2410            }
2411        })
2412        .await
2413        .unwrap();
2414
2415        assert_eq!(recv1, "broadcast-msg");
2416        assert_eq!(recv2, "broadcast-msg");
2417
2418        consumer.stop().await.unwrap();
2419    }
2420
2421    #[tokio::test]
2422    async fn concurrent_get_or_spawn_returns_same_state() {
2423        let _guard = REGISTRY_TEST_LOCK.lock().await;
2424        let port = free_port();
2425        let results: Arc<std::sync::Mutex<Vec<WsAppState>>> =
2426            Arc::new(std::sync::Mutex::new(Vec::new()));
2427
2428        let mut handles = Vec::new();
2429        for _ in 0..4 {
2430            let results = results.clone();
2431            handles.push(tokio::spawn(async move {
2432                let (state, _) = ServerRegistry::global()
2433                    .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2434                    .await
2435                    .unwrap();
2436                results.lock().unwrap().push(state);
2437            }));
2438        }
2439
2440        for h in handles {
2441            h.await.unwrap();
2442        }
2443
2444        let states = results.lock().unwrap();
2445        assert_eq!(states.len(), 4);
2446        for i in 1..states.len() {
2447            assert!(
2448                Arc::ptr_eq(&states[0].dispatch, &states[i].dispatch),
2449                "all concurrent callers should get the same dispatch table"
2450            );
2451        }
2452    }
2453
2454    #[tokio::test]
2455    async fn body_conversion_helpers_cover_text_and_binary_paths() {
2456        let text_msg = body_to_axum_ws_message(CamelBody::Text("abc".into()), "text")
2457            .await
2458            .unwrap();
2459        assert!(matches!(text_msg, WsMessage::Text(_)));
2460
2461        let bin_msg = body_to_axum_ws_message(CamelBody::Bytes(vec![1, 2, 3].into()), "binary")
2462            .await
2463            .unwrap();
2464        assert!(matches!(bin_msg, WsMessage::Binary(_)));
2465
2466        let client_text =
2467            body_to_client_ws_message(CamelBody::Json(serde_json::json!({"k":"v"})), "text")
2468                .await
2469                .unwrap();
2470        assert!(matches!(client_text, ClientWsMessage::Text(_)));
2471
2472        let client_bin = body_to_client_ws_message(CamelBody::Bytes(vec![7, 8].into()), "binary")
2473            .await
2474            .unwrap();
2475        assert!(matches!(client_bin, ClientWsMessage::Binary(_)));
2476    }
2477
2478    #[tokio::test]
2479    async fn body_to_text_handles_empty_text_json_and_bytes() {
2480        assert_eq!(body_to_text(CamelBody::Empty).await.unwrap(), "");
2481        assert_eq!(
2482            body_to_text(CamelBody::Text("hello".into())).await.unwrap(),
2483            "hello"
2484        );
2485        assert_eq!(
2486            body_to_text(CamelBody::Json(serde_json::json!({"n":1})))
2487                .await
2488                .unwrap(),
2489            "{\"n\":1}"
2490        );
2491        assert_eq!(
2492            body_to_text(CamelBody::Bytes(b"hi".to_vec().into()))
2493                .await
2494                .unwrap(),
2495            "hi"
2496        );
2497    }
2498
2499    #[test]
2500    fn try_send_with_backpressure_returns_false_when_channel_full() {
2501        let (tx, _rx) = mpsc::channel::<WsMessage>(1);
2502        assert!(try_send_with_backpressure(
2503            &tx,
2504            WsMessage::Text("first".into()),
2505            "test"
2506        ));
2507        assert!(!try_send_with_backpressure(
2508            &tx,
2509            WsMessage::Text("second".into()),
2510            "test"
2511        ));
2512    }
2513
2514    // WS-017: send_with_timeout fires when the underlying send future exceeds the budget.
2515    #[tokio::test(start_paused = true)]
2516    async fn send_with_timeout_fires_on_elapsed() {
2517        // Advance the mock clock past the deadline before polling so the pending future
2518        // is observed as already-elapsed on the first poll.
2519        tokio::time::advance(Duration::from_millis(200)).await;
2520        let result = send_with_timeout(
2521            std::future::pending::<Result<(), tungstenite::Error>>(),
2522            Duration::from_millis(100),
2523        )
2524        .await;
2525        let err = result.expect_err("send_with_timeout must return Err on elapsed");
2526        assert!(
2527            err.to_string().contains("timeout"),
2528            "expected timeout error, got: {err}"
2529        );
2530    }
2531
2532    // WS-017: send_with_timeout returns Ok when the underlying future completes within the budget.
2533    #[tokio::test]
2534    async fn send_with_timeout_succeeds_when_fast() {
2535        let result = send_with_timeout(
2536            async { Ok::<(), tungstenite::Error>(()) },
2537            Duration::from_secs(30),
2538        )
2539        .await;
2540        assert!(result.is_ok(), "expected Ok, got: {result:?}");
2541    }
2542
2543    #[test]
2544    fn map_connect_error_formats_connection_refused_and_generic_errors() {
2545        let refused = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
2546        let err = map_connect_error(tungstenite::Error::Io(refused), "ws://localhost:1/x");
2547        assert!(err.to_string().contains("WebSocket connection refused"));
2548
2549        let generic = map_connect_error(
2550            tungstenite::Error::Protocol(
2551                tokio_tungstenite::tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
2552            ),
2553            "ws://localhost:2/y",
2554        );
2555        assert!(
2556            generic
2557                .to_string()
2558                .contains("WebSocket connection failed (ws://localhost:2/y)")
2559        );
2560    }
2561
2562    // === Phase B Finding Tests ===
2563
2564    // WS-015: maxConnections=0 must be rejected
2565    #[test]
2566    fn from_uri_rejects_max_connections_zero() {
2567        let result = WsEndpointConfig::from_uri("ws://localhost:9200/test?maxConnections=0");
2568        assert!(result.is_err());
2569        let msg = result.unwrap_err().to_string();
2570        assert!(
2571            msg.contains("maxConnections must be >= 1"),
2572            "expected maxConnections validation error, got: {msg}"
2573        );
2574    }
2575
2576    // WS-019: maxMessageSize=0 must be rejected
2577    #[test]
2578    fn from_uri_rejects_max_message_size_zero() {
2579        let result = WsEndpointConfig::from_uri("ws://localhost:9201/test?maxMessageSize=0");
2580        assert!(result.is_err());
2581        let msg = result.unwrap_err().to_string();
2582        assert!(
2583            msg.contains("maxMessageSize must be > 0"),
2584            "expected maxMessageSize validation error, got: {msg}"
2585        );
2586    }
2587
2588    // WS-020: allowOrigin="" must be rejected
2589    #[test]
2590    fn from_uri_rejects_empty_allow_origin() {
2591        let result = WsEndpointConfig::from_uri("ws://localhost:9202/test?allowOrigin=");
2592        assert!(result.is_err());
2593        let msg = result.unwrap_err().to_string();
2594        assert!(
2595            msg.contains("allowOrigin must not be empty"),
2596            "expected allowOrigin validation error, got: {msg}"
2597        );
2598    }
2599
2600    // WS-006: Double-start must be rejected
2601    #[tokio::test]
2602    async fn consumer_double_start_returns_error() {
2603        let _guard = REGISTRY_TEST_LOCK.lock().await;
2604        let port = free_port();
2605        let uri = format!("ws://127.0.0.1:{port}/doublestart");
2606        let component_ctx = NoOpComponentContext;
2607        let endpoint = WsComponent::new()
2608            .create_endpoint(&uri, &component_ctx)
2609            .unwrap();
2610
2611        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2612        let (route_tx, _route_rx) = mpsc::channel(16);
2613        let ctx = ConsumerContext::new(
2614            route_tx,
2615            CancellationToken::new(),
2616            "ws-test-route".to_string(),
2617        );
2618
2619        // First start should succeed
2620        consumer.start(ctx).await.unwrap();
2621
2622        // Second start should fail
2623        let (route_tx2, _route_rx2) = mpsc::channel(16);
2624        let ctx2 = ConsumerContext::new(
2625            route_tx2,
2626            CancellationToken::new(),
2627            "ws-test-route-2".to_string(),
2628        );
2629        let result = consumer.start(ctx2).await;
2630        assert!(result.is_err());
2631        let msg = result.unwrap_err().to_string();
2632        assert!(
2633            msg.contains("already started"),
2634            "expected double-start error, got: {msg}"
2635        );
2636
2637        consumer.stop().await.unwrap();
2638    }
2639
2640    // WS-005: Registry cleanup on stop + port reuse
2641    #[tokio::test]
2642    async fn registry_cleanup_on_consumer_stop() {
2643        let _guard = REGISTRY_TEST_LOCK.lock().await;
2644        let port = free_port();
2645        let uri = format!("ws://127.0.0.1:{port}/cleanup");
2646        let component_ctx = NoOpComponentContext;
2647        let endpoint = WsComponent::new()
2648            .create_endpoint(&uri, &component_ctx)
2649            .unwrap();
2650
2651        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2652        let (route_tx, _route_rx) = mpsc::channel(16);
2653        let ctx = ConsumerContext::new(
2654            route_tx,
2655            CancellationToken::new(),
2656            "ws-test-route".to_string(),
2657        );
2658        consumer.start(ctx).await.unwrap();
2659
2660        // Verify registry entry exists
2661        let registries = global_registries();
2662        let key = ("127.0.0.1".to_string(), port, "/cleanup".to_string());
2663        assert!(
2664            registries.contains_key(&key),
2665            "registry should have entry after start"
2666        );
2667
2668        // Stop consumer
2669        consumer.stop().await.unwrap();
2670
2671        // Verify registry entry is removed
2672        assert!(
2673            !registries.contains_key(&key),
2674            "registry should be cleaned up after stop"
2675        );
2676
2677        // Server is process-lifetime: release() is a no-op, so the
2678        // ServerRegistry entry stays. The port cannot be re-bound until
2679        // ServerRegistry::reset() is called.
2680        let server_reg = ServerRegistry::global();
2681        let guard = server_reg.inner.lock().unwrap();
2682        assert!(
2683            guard.contains_key(&port),
2684            "ServerRegistry must keep port entry after consumer stop (process-lifetime server)"
2685        );
2686    }
2687
2688    // WS-003 + WS-004: poll_ready backpressure and server-send error handling
2689    #[tokio::test]
2690    async fn producer_server_send_returns_error_when_all_dropped() {
2691        let _guard = REGISTRY_TEST_LOCK.lock().await;
2692        let port = free_port();
2693        let uri = format!("ws://127.0.0.1:{port}/backpressure");
2694        let component_ctx = NoOpComponentContext;
2695        let endpoint = WsComponent::new()
2696            .create_endpoint(&uri, &component_ctx)
2697            .unwrap();
2698
2699        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2700        let producer = endpoint
2701            .create_producer(rt(), &ProducerContext::default())
2702            .unwrap();
2703
2704        let (route_tx, _route_rx) = mpsc::channel(1); // Tiny channel to force backpressure
2705        let ctx = ConsumerContext::new(
2706            route_tx,
2707            CancellationToken::new(),
2708            "ws-test-route".to_string(),
2709        );
2710        consumer.start(ctx).await.unwrap();
2711
2712        // Connect a client so the registry has an entry
2713        let url = format!("ws://127.0.0.1:{port}/backpressure");
2714        let (mut client, _) = loop {
2715            match connect_async(&url).await {
2716                Ok(ok) => break ok,
2717                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2718            }
2719        };
2720
2721        // Don't consume messages — let the channel fill up
2722        tokio::time::sleep(Duration::from_millis(50)).await;
2723
2724        // Flood the channel to trigger backpressure
2725        let mut all_dropped = false;
2726        for _ in 0..100 {
2727            let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("flood".into())));
2728            match producer.clone().oneshot(exchange).await {
2729                Ok(_) => {}
2730                Err(e) => {
2731                    if e.to_string().contains("backpressure") {
2732                        all_dropped = true;
2733                        break;
2734                    }
2735                }
2736            }
2737        }
2738
2739        // The producer should eventually return a backpressure error
2740        assert!(
2741            all_dropped,
2742            "producer should return error when all messages are dropped due to backpressure"
2743        );
2744
2745        // Clean up
2746        let _ = client.close(None).await;
2747        consumer.stop().await.unwrap();
2748    }
2749
2750    // WS-012: Ping/pong round-trip in server mode
2751    #[tokio::test]
2752    async fn server_responds_to_client_ping_with_pong() {
2753        let _guard = REGISTRY_TEST_LOCK.lock().await;
2754        let port = free_port();
2755        let uri = format!("ws://127.0.0.1:{port}/pingpong");
2756        let component_ctx = NoOpComponentContext;
2757        let endpoint = WsComponent::new()
2758            .create_endpoint(&uri, &component_ctx)
2759            .unwrap();
2760
2761        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2762        let (route_tx, _route_rx) = mpsc::channel(16);
2763        let ctx = ConsumerContext::new(
2764            route_tx,
2765            CancellationToken::new(),
2766            "ws-test-route".to_string(),
2767        );
2768        consumer.start(ctx).await.unwrap();
2769
2770        let url = format!("ws://127.0.0.1:{port}/pingpong");
2771        let (mut client, _) = loop {
2772            match connect_async(&url).await {
2773                Ok(ok) => break ok,
2774                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2775            }
2776        };
2777
2778        // Send a ping
2779        client
2780            .send(ClientMessage::Ping(vec![1, 2, 3].into()))
2781            .await
2782            .unwrap();
2783
2784        // Expect a pong with the same payload
2785        let pong = tokio::time::timeout(Duration::from_secs(2), async {
2786            loop {
2787                match client.next().await {
2788                    Some(Ok(ClientMessage::Pong(data))) => break data,
2789                    Some(Ok(ClientMessage::Ping(_))) => continue,
2790                    Some(Ok(_)) => continue,
2791                    Some(Err(e)) => panic!("ws receive failed: {e}"),
2792                    None => panic!("websocket closed before pong"),
2793                }
2794            }
2795        })
2796        .await
2797        .unwrap();
2798
2799        assert_eq!(pong, vec![1, 2, 3], "pong should echo ping payload");
2800
2801        consumer.stop().await.unwrap();
2802    }
2803
2804    // WS-008: Client-side retry on transient connect failures
2805    #[tokio::test]
2806    async fn producer_retries_on_connection_refused() {
2807        // Use a port that nothing is listening on
2808        let port = free_port();
2809        // Ensure nothing is on this port
2810        let cfg = WsEndpointConfig::from_uri(&format!(
2811            "ws://127.0.0.1:{port}/retry?reconnect=true&reconnectMaxAttempts=2&reconnectDelayMs=50"
2812        ))
2813        .unwrap();
2814        let producer = WsProducer::new(cfg.client_config());
2815
2816        let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("hello".into())));
2817
2818        // Should fail after retries (nothing listening)
2819        let result = tokio::time::timeout(Duration::from_secs(5), producer.oneshot(exchange)).await;
2820        assert!(
2821            result.is_ok(),
2822            "producer should complete (with error) within timeout"
2823        );
2824        let result = result.unwrap();
2825        assert!(
2826            result.is_err(),
2827            "producer should fail when nothing is listening"
2828        );
2829        let msg = result.unwrap_err().to_string();
2830        assert!(
2831            msg.contains("connection refused"),
2832            "expected connection refused error, got: {msg}"
2833        );
2834    }
2835
2836    // WS-001: Server bind error is visible (fake server-start error test)
2837    #[tokio::test]
2838    async fn server_bind_error_is_reported() {
2839        let _guard = REGISTRY_TEST_LOCK.lock().await;
2840        // Bind a port manually to cause a conflict
2841        let _listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2842        let port = _listener.local_addr().unwrap().port();
2843
2844        // Try to start a consumer on the same port — should succeed since axum binds lazily
2845        // The actual bind error happens when the server task runs
2846        let uri = format!("ws://127.0.0.1:{port}/binderror");
2847        let component_ctx = NoOpComponentContext;
2848        let endpoint = WsComponent::new()
2849            .create_endpoint(&uri, &component_ctx)
2850            .unwrap();
2851
2852        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2853        let (route_tx, _route_rx) = mpsc::channel(16);
2854        let ctx = ConsumerContext::new(
2855            route_tx,
2856            CancellationToken::new(),
2857            "ws-test-route".to_string(),
2858        );
2859
2860        // Start should succeed (server spawns, but bind may fail)
2861        let start_result = consumer.start(ctx).await;
2862        // The server may or may not have bound yet — this test verifies no panic
2863        // The actual error is logged by the server task
2864        let _ = start_result;
2865
2866        consumer.stop().await.unwrap();
2867    }
2868
2869    #[test]
2870    fn ws_app_state_server_error_starts_false() {
2871        let state = WsAppState {
2872            dispatch: Arc::new(RwLock::new(HashMap::new())),
2873            path_configs: Arc::new(DashMap::new()),
2874            path_policies: Arc::new(DashMap::new()),
2875            server_error: new_atomic_false(),
2876            runtime: test_rt(),
2877            route_id: "test-route".into(),
2878        };
2879        assert!(
2880            !state.server_error.load(Ordering::Relaxed),
2881            "server_error should start as false"
2882        );
2883    }
2884
2885    #[test]
2886    fn ws_app_state_server_error_can_be_set() {
2887        let state = WsAppState {
2888            dispatch: Arc::new(RwLock::new(HashMap::new())),
2889            path_configs: Arc::new(DashMap::new()),
2890            path_policies: Arc::new(DashMap::new()),
2891            server_error: new_atomic_false(),
2892            runtime: test_rt(),
2893            route_id: "test-route".into(),
2894        };
2895        assert!(!state.server_error.load(Ordering::Relaxed));
2896        state.server_error.store(true, Ordering::Relaxed);
2897        assert!(state.server_error.load(Ordering::Relaxed));
2898    }
2899
2900    #[tokio::test]
2901    async fn consumer_stop_returns_error_when_server_had_errors() {
2902        let _guard = REGISTRY_TEST_LOCK.lock().await;
2903        let port = free_port();
2904        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/errorflag")).unwrap();
2905        let mut consumer = WsConsumer::new(cfg.server_config(), test_rt());
2906        let (route_tx, _route_rx) = mpsc::channel(16);
2907        let ctx = ConsumerContext::new(
2908            route_tx,
2909            CancellationToken::new(),
2910            "ws-test-route".to_string(),
2911        );
2912        consumer.start(ctx).await.unwrap();
2913
2914        // Simulate server error by setting the flag directly
2915        if let Some(ref state) = consumer.server_state {
2916            state.server_error.store(true, Ordering::Relaxed);
2917        }
2918
2919        let result = consumer.stop().await;
2920        assert!(
2921            result.is_err(),
2922            "stop should return error when server had errors"
2923        );
2924        let msg = result.unwrap_err().to_string();
2925        assert!(
2926            msg.contains("terminated with errors"),
2927            "expected server error message, got: {msg}"
2928        );
2929    }
2930
2931    #[tokio::test]
2932    async fn consumer_stop_succeeds_when_server_healthy() {
2933        let _guard = REGISTRY_TEST_LOCK.lock().await;
2934        let port = free_port();
2935        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/healthy")).unwrap();
2936        let mut consumer = WsConsumer::new(cfg.server_config(), test_rt());
2937        let (route_tx, _route_rx) = mpsc::channel(16);
2938        let ctx = ConsumerContext::new(
2939            route_tx,
2940            CancellationToken::new(),
2941            "ws-test-route".to_string(),
2942        );
2943        consumer.start(ctx).await.unwrap();
2944
2945        let result = consumer.stop().await;
2946        assert!(
2947            result.is_ok(),
2948            "stop should succeed when server is healthy: {:?}",
2949            result
2950        );
2951    }
2952
2953    // === H-10 Finding Tests ===
2954
2955    // WS-007: subprotocol negotiation support
2956    #[test]
2957    fn endpoint_config_parses_subprotocols() {
2958        let cfg = WsEndpointConfig::from_uri(
2959            "ws://localhost:9001/chat?subprotocols=graphql-ws,graphql-transport-ws",
2960        )
2961        .unwrap();
2962        assert_eq!(cfg.subprotocols, vec!["graphql-ws", "graphql-transport-ws"]);
2963    }
2964
2965    #[test]
2966    fn endpoint_config_default_subprotocols_empty() {
2967        let cfg = WsEndpointConfig::default();
2968        assert!(cfg.subprotocols.is_empty());
2969    }
2970
2971    // WS-017: sendTimeoutMs URI option
2972    #[test]
2973    fn endpoint_config_parses_send_timeout() {
2974        let cfg =
2975            WsEndpointConfig::from_uri("ws://localhost:9001/chat?sendTimeoutMs=5000").unwrap();
2976        assert_eq!(cfg.send_timeout, Duration::from_millis(5000));
2977    }
2978
2979    #[test]
2980    fn endpoint_config_default_send_timeout() {
2981        let cfg = WsEndpointConfig::default();
2982        assert_eq!(cfg.send_timeout, Duration::from_secs(30));
2983    }
2984
2985    #[test]
2986    fn endpoint_config_rejects_invalid_send_timeout() {
2987        let err =
2988            WsEndpointConfig::from_uri("ws://localhost:9001/chat?sendTimeoutMs=abc").unwrap_err();
2989        assert!(err.to_string().contains("sendTimeoutMs"));
2990    }
2991
2992    // WS-018: binaryPayload URI option
2993    #[test]
2994    fn endpoint_config_parses_binary_payload() {
2995        let cfg =
2996            WsEndpointConfig::from_uri("ws://localhost:9001/chat?binaryPayload=true").unwrap();
2997        assert!(cfg.binary_payload);
2998    }
2999
3000    #[test]
3001    fn endpoint_config_default_binary_payload_false() {
3002        let cfg = WsEndpointConfig::default();
3003        assert!(!cfg.binary_payload);
3004    }
3005
3006    #[test]
3007    fn endpoint_config_rejects_invalid_binary_payload() {
3008        let err =
3009            WsEndpointConfig::from_uri("ws://localhost:9001/chat?binaryPayload=yes").unwrap_err();
3010        assert!(err.to_string().contains("binaryPayload"));
3011    }
3012
3013    /// Regression: max_attempts=N → exactly N invocations (caught OpenSearch off-by-one 1f5c4c2a).
3014    /// Replicates the exact retry loop from the WebSocket producer connect (lib.rs:~1228-1275):
3015    ///   attempts starts at 0, should_retry(attempts+1), delay_for(attempts), attempts += 1
3016    #[tokio::test]
3017    async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
3018        use camel_component_api::NetworkRetryPolicy;
3019        use std::sync::Arc;
3020        use std::sync::atomic::{AtomicU32, Ordering};
3021
3022        let policy = NetworkRetryPolicy {
3023            max_attempts: 3,
3024            initial_delay: Duration::from_millis(1),
3025            max_delay: Duration::from_millis(1),
3026            multiplier: 1.0,
3027            ..NetworkRetryPolicy::default()
3028        };
3029
3030        let calls = Arc::new(AtomicU32::new(0));
3031        let calls_clone = Arc::clone(&calls);
3032        let mut attempts: u32 = 0;
3033
3034        let _result: Result<(), ()> = loop {
3035            calls_clone.fetch_add(1, Ordering::SeqCst);
3036            let op_result: Result<(), ()> = Err(());
3037            match op_result {
3038                Ok(_) => unreachable!(),
3039                Err(_) if policy.should_retry(attempts + 1) => {
3040                    let delay = policy.delay_for(attempts);
3041                    tokio::time::sleep(delay).await;
3042                    attempts += 1;
3043                    continue;
3044                }
3045                Err(_) => break Err(()),
3046            }
3047        };
3048
3049        assert_eq!(
3050            calls.load(Ordering::SeqCst),
3051            3,
3052            "max_attempts=3 must yield exactly 3 invocations"
3053        );
3054    }
3055
3056    /// Edge case: max_attempts=1 → exactly 1 invocation (initial attempt only, no retry).
3057    /// Locks the edge that originally broke OpenSearch.
3058    #[tokio::test]
3059    async fn retry_loop_with_max_attempts_1_invokes_operation_once() {
3060        use camel_component_api::NetworkRetryPolicy;
3061        use std::sync::Arc;
3062        use std::sync::atomic::{AtomicU32, Ordering};
3063
3064        let policy = NetworkRetryPolicy {
3065            max_attempts: 1,
3066            initial_delay: Duration::from_millis(1),
3067            max_delay: Duration::from_millis(1),
3068            multiplier: 1.0,
3069            ..NetworkRetryPolicy::default()
3070        };
3071
3072        let calls = Arc::new(AtomicU32::new(0));
3073        let calls_clone = Arc::clone(&calls);
3074        let mut attempts: u32 = 0;
3075
3076        let _result: Result<(), ()> = loop {
3077            calls_clone.fetch_add(1, Ordering::SeqCst);
3078            let op_result: Result<(), ()> = Err(());
3079            match op_result {
3080                Ok(_) => unreachable!(),
3081                Err(_) if policy.should_retry(attempts + 1) => {
3082                    let delay = policy.delay_for(attempts);
3083                    tokio::time::sleep(delay).await;
3084                    attempts += 1;
3085                    continue;
3086                }
3087                Err(_) => break Err(()),
3088            }
3089        };
3090
3091        assert_eq!(
3092            calls.load(Ordering::SeqCst),
3093            1,
3094            "max_attempts=1 must yield exactly 1 invocation"
3095        );
3096    }
3097
3098    // ── rc-1nm regression: WS producer retry emits component=ws-producer ──
3099
3100    use std::fmt::Write as _;
3101    use std::sync::{Arc, Mutex};
3102    use tracing::Subscriber;
3103    use tracing_subscriber::Layer;
3104    use tracing_subscriber::layer::SubscriberExt;
3105
3106    struct CollectingLayer {
3107        events: Arc<Mutex<Vec<String>>>,
3108    }
3109
3110    impl<S: Subscriber> Layer<S> for CollectingLayer {
3111        fn on_event(
3112            &self,
3113            event: &tracing::Event<'_>,
3114            _ctx: tracing_subscriber::layer::Context<'_, S>,
3115        ) {
3116            let mut buf = String::new();
3117            let mut visitor = CollectingVisitor { fields: &mut buf };
3118            event.record(&mut visitor);
3119            if let Ok(mut events) = self.events.lock() {
3120                events.push(buf);
3121            }
3122        }
3123    }
3124
3125    struct CollectingVisitor<'a> {
3126        fields: &'a mut String,
3127    }
3128
3129    impl CollectingVisitor<'_> {
3130        fn record_field(&mut self, name: &str, value: &str) {
3131            write!(self.fields, " {name}={value}").ok();
3132        }
3133    }
3134
3135    impl tracing::field::Visit for CollectingVisitor<'_> {
3136        fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
3137            self.record_field(field.name(), value);
3138        }
3139        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
3140            self.record_field(field.name(), &format!("{value:?}"));
3141        }
3142        fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
3143            self.record_field(field.name(), &value.to_string());
3144        }
3145    }
3146
3147    /// Regression for rc-1nm: the WS producer retry path must emit
3148    /// `component=ws-producer` in retry log events so operators can
3149    /// identify which component is retrying.
3150    ///
3151    /// Drives `retry_async` directly with `Some("ws-producer")` and a
3152    /// deterministic retryable error. An earlier version exercised the
3153    /// production `connect_ws_with_retry` helper against `ws://127.0.0.1:1`,
3154    /// but that was flaky under heavy workspace load: the thread-local
3155    /// tracing subscriber (`set_default`) very occasionally missed the
3156    /// event logged from within the async connect path (the warn! is
3157    /// always emitted — `map_connect_error` always yields a retryable
3158    /// string for `ws://` — so the miss was purely a capture race).
3159    /// Driving `retry_async` synchronously with a synthetic op removes the
3160    /// network I/O and reactor scheduling, so the warn! is always emitted
3161    /// and captured on the test thread.
3162    #[tokio::test]
3163    async fn ws_producer_retry_log_emits_component_ws_producer() {
3164        let events = Arc::new(Mutex::new(Vec::new()));
3165        let layer = CollectingLayer {
3166            events: events.clone(),
3167        };
3168        let subscriber = tracing_subscriber::registry().with(layer);
3169        let _guard = tracing::subscriber::set_default(subscriber);
3170
3171        let policy = NetworkRetryPolicy {
3172            max_attempts: 2,
3173            initial_delay: Duration::from_millis(1),
3174            max_delay: Duration::from_millis(5),
3175            ..NetworkRetryPolicy::default()
3176        };
3177
3178        // Deterministic retryable failure (string recognised by
3179        // is_retryable_ws_error) — no network I/O, so the retry warn! is
3180        // emitted and captured synchronously on this thread.
3181        let result: Result<(), CamelError> = retry_async(
3182            &policy,
3183            Some("ws-producer"),
3184            || async {
3185                Err(CamelError::ProcessorError(
3186                    "WebSocket connection refused: simulated".to_string(),
3187                ))
3188            },
3189            is_retryable_ws_error,
3190        )
3191        .await;
3192
3193        assert!(result.is_err(), "expected exhausted-retries error");
3194        let captured = events.lock().unwrap();
3195        assert!(
3196            !captured.is_empty(),
3197            "expected at least one retry log event, got none"
3198        );
3199        let first = &captured[0];
3200        assert!(
3201            first.contains("component=ws-producer"),
3202            "rc-1nm regression: expected 'component=ws-producer' in WS retry log, got: {first}"
3203        );
3204    }
3205
3206    // ── TLS cert hot-reload: release/unregister integration tests ─────────
3207    //
3208    // These verify the WSS path: `get_or_spawn` registers a `WsReloadHandler`
3209    // in `TlsReloadRegistry::global()`; `release` unregisters it when the
3210    // last reference drops. The host-agnostic `matches` impl keys on
3211    // (scheme="wss", port) — see `WsReloadHandler::matches`.
3212
3213    #[tokio::test]
3214    async fn wss_release_unregisters_tls_reload_handler() {
3215        use camel_component_api::test_support::tls;
3216        use camel_component_api::tls_source::TlsReloadRegistry;
3217
3218        let _guard = REGISTRY_TEST_LOCK.lock().await;
3219        let _ = rustls::crypto::ring::default_provider().install_default();
3220
3221        let (cert_pem, key_pem) = {
3222            let (_ca, c, k) = tls::gen_server_cert();
3223            (c, k)
3224        };
3225        let cert_path = tls::write_pem_tmp("ws-release-cert.pem", &cert_pem);
3226        let key_path = tls::write_pem_tmp("ws-release-key.pem", &key_pem);
3227
3228        let port = free_port();
3229        let tls_cfg = WsTlsConfig {
3230            cert_path: cert_path.to_str().expect("cert path").to_string(),
3231            key_path: key_path.to_str().expect("key path").to_string(),
3232        };
3233
3234        // Spawn a single WSS server.
3235        let (_state, _) = ServerRegistry::global()
3236            .get_or_spawn(
3237                "127.0.0.1",
3238                port,
3239                Some(tls_cfg),
3240                test_rt(),
3241                "ws-release-test".into(),
3242            )
3243            .await
3244            .expect("WSS server should spawn");
3245
3246        // Handler is registered (host-agnostic — match passes empty host).
3247        let handler = TlsReloadRegistry::global().find("wss", "", port);
3248        assert!(
3249            handler.is_some(),
3250            "WSS server must register a reload handler for wss://*:{port}"
3251        );
3252        // Exercise it to verify the registered handler is functional.
3253        handler
3254            .unwrap()
3255            .reload()
3256            .await
3257            .expect("registered WSS handler reload() must succeed");
3258
3259        // Release the (only) reference. release() is a no-op
3260        // (process-lifetime server), so the handler STAYS registered.
3261        ServerRegistry::global().release(port);
3262
3263        assert!(
3264            TlsReloadRegistry::global().find("wss", "", port).is_some(),
3265            "WSS server release is a no-op; reload handler must remain registered"
3266        );
3267    }
3268
3269    #[tokio::test]
3270    async fn wss_multiple_refs_release_does_not_unregister() {
3271        use camel_component_api::test_support::tls;
3272        use camel_component_api::tls_source::TlsReloadRegistry;
3273
3274        let _guard = REGISTRY_TEST_LOCK.lock().await;
3275        let _ = rustls::crypto::ring::default_provider().install_default();
3276
3277        let (cert_pem, key_pem) = {
3278            let (_ca, c, k) = tls::gen_server_cert();
3279            (c, k)
3280        };
3281        let cert_path = tls::write_pem_tmp("ws-multiref-cert.pem", &cert_pem);
3282        let key_path = tls::write_pem_tmp("ws-multiref-key.pem", &key_pem);
3283
3284        let port = free_port();
3285        let tls_cfg = WsTlsConfig {
3286            cert_path: cert_path.to_str().expect("cert path").to_string(),
3287            key_path: key_path.to_str().expect("key path").to_string(),
3288        };
3289
3290        // Acquire TWO references to the same port.
3291        let (_s1, _) = ServerRegistry::global()
3292            .get_or_spawn(
3293                "127.0.0.1",
3294                port,
3295                Some(tls_cfg.clone()),
3296                test_rt(),
3297                "ws-multiref-r1".into(),
3298            )
3299            .await
3300            .expect("WSS server should spawn (ref 1)");
3301        let (_s2, _) = ServerRegistry::global()
3302            .get_or_spawn(
3303                "127.0.0.1",
3304                port,
3305                Some(tls_cfg),
3306                test_rt(),
3307                "ws-multiref-r2".into(),
3308            )
3309            .await
3310            .expect("WSS server should spawn (ref 2)");
3311
3312        // Handler is registered.
3313        assert!(
3314            TlsReloadRegistry::global().find("wss", "", port).is_some(),
3315            "WSS server with refs must have a registered reload handler"
3316        );
3317
3318        // Release the FIRST reference — ref count is still 1, handler must remain.
3319        ServerRegistry::global().release(port);
3320        assert!(
3321            TlsReloadRegistry::global().find("wss", "", port).is_some(),
3322            "handler must remain registered while ref count > 0"
3323        );
3324
3325        // Release the LAST reference. release() is a no-op regardless of
3326        // ref count, so the handler STAYS registered.
3327        ServerRegistry::global().release(port);
3328        assert!(
3329            TlsReloadRegistry::global().find("wss", "", port).is_some(),
3330            "handler must remain registered — release() is a no-op (process-lifetime server)"
3331        );
3332    }
3333
3334    #[tokio::test]
3335    async fn ws_plaintext_does_not_register_tls_reload_handler() {
3336        use camel_component_api::tls_source::TlsReloadRegistry;
3337
3338        let _guard = REGISTRY_TEST_LOCK.lock().await;
3339        // Ensure clean global state (process-lifetime servers may leak across tests).
3340        ServerRegistry::reset();
3341
3342        let port = free_port();
3343        let (_state, _) = ServerRegistry::global()
3344            .get_or_spawn(
3345                "127.0.0.1",
3346                port,
3347                None,
3348                test_rt(),
3349                "ws-plaintext-no-reload-test".into(),
3350            )
3351            .await
3352            .expect("plaintext WS server should spawn");
3353
3354        // No handler for either wss or ws — plaintext has nothing to reload.
3355        assert!(
3356            TlsReloadRegistry::global().find("wss", "", port).is_none(),
3357            "plaintext WS server must not register a wss handler"
3358        );
3359
3360        // Cleanup.
3361        ServerRegistry::global().release(port);
3362    }
3363
3364    // WSS readiness: a failed TLS listener bind must NOT signal readiness.
3365    #[tokio::test]
3366    async fn test_wss_bind_failure_does_not_mark_ready() {
3367        use camel_component_api::StartupSignal;
3368        use camel_component_api::test_support::{NoopRuntimeObservability, tls};
3369
3370        let _guard = REGISTRY_TEST_LOCK.lock().await;
3371        let _ = rustls::crypto::ring::default_provider().install_default();
3372        // Clean global state (process-lifetime servers may leak across tests).
3373        ServerRegistry::reset();
3374
3375        // Generate valid TLS material so we get past cert loading and reach
3376        // the actual listener bind.
3377        let (cert_pem, key_pem) = {
3378            let (_ca, c, k) = tls::gen_server_cert();
3379            (c, k)
3380        };
3381        let cert_path = tls::write_pem_tmp("ws-bindfail-cert.pem", &cert_pem);
3382        let key_path = tls::write_pem_tmp("ws-bindfail-key.pem", &key_pem);
3383        let cert_str = cert_path.to_str().expect("cert path");
3384        let key_str = key_path.to_str().expect("key path");
3385
3386        // Pre-bind the port so the WSS listener bind fails with EADDRINUSE.
3387        let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3388        let port = blocker.local_addr().unwrap().port();
3389
3390        let uri = format!("wss://127.0.0.1:{port}/secure?tlsCert={cert_str}&tlsKey={key_str}");
3391        let component_ctx = NoOpComponentContext;
3392        let endpoint = WssComponent::new()
3393            .create_endpoint(&uri, &component_ctx)
3394            .unwrap();
3395        // NoopRuntimeObservability: the bind-failure path calls
3396        // `health().force_unhealthy_for_route`, which must not panic.
3397        let rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability> =
3398            std::sync::Arc::new(NoopRuntimeObservability);
3399        let mut consumer = endpoint.create_consumer(rt).unwrap();
3400
3401        // Install our own startup pair so we can observe whether mark_ready
3402        // was called.
3403        let (signal, receiver) = StartupSignal::pair();
3404        let (route_tx, _route_rx) = mpsc::channel(16);
3405        let ctx = ConsumerContext::new(
3406            route_tx,
3407            CancellationToken::new(),
3408            "ws-bindfail-route".to_string(),
3409        )
3410        .with_startup(signal);
3411
3412        let result = consumer.start(ctx).await;
3413        assert!(
3414            result.is_err(),
3415            "start() must return Err when the WSS listener bind fails: {result:?}"
3416        );
3417
3418        // ctx was dropped when start() returned, so the startup signal sender
3419        // is gone. await_ready resolves immediately: Err means the consumer
3420        // never signalled readiness (good); Ok would mean mark_ready was
3421        // called before the bind failure surfaced (bug).
3422        let ready_result: Result<(), _> = receiver.await_ready().await;
3423        assert!(
3424            ready_result.is_err(),
3425            "mark_ready() must not be called when the WSS listener bind fails"
3426        );
3427
3428        drop(blocker);
3429        let _ = consumer.stop().await;
3430    }
3431
3432    #[test]
3433    fn ws_upgrade_error_provider_unavailable_is_503() {
3434        let err =
3435            CamelError::AuthProviderUnavailable("totally arbitrary detail with no marker".into());
3436        let resp = ws_upgrade_auth_error(&err).into_response();
3437        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
3438    }
3439
3440    #[test]
3441    fn ws_upgrade_error_generic_processor_error_is_500() {
3442        let err = CamelError::ProcessorError("auth provider unavailable".into());
3443        let resp = ws_upgrade_auth_error(&err).into_response();
3444        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
3445    }
3446
3447    #[test]
3448    fn ws_upgrade_error_unauthenticated_is_401() {
3449        let err = CamelError::Unauthenticated("bad".into());
3450        let resp = ws_upgrade_auth_error(&err).into_response();
3451        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
3452    }
3453}