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 route_id = ctx.route_id().to_string();
1161        let runtime = Arc::clone(&self.runtime);
1162        let forward_task: JoinHandle<Result<(), CamelError>> = tokio::spawn(async move {
1163            while let Some(envelope) = env_rx.recv().await {
1164                if sender.send(envelope).await.is_err() {
1165                    // (category b′ per ADR-0012: locally terminal message
1166                    // dispatch — the pipeline receiver is gone and the loop
1167                    // exits, so this is the only signal.)
1168                    runtime
1169                        .metrics()
1170                        .increment_errors(&route_id, "b-prime:ws:message-dispatch");
1171                    break;
1172                }
1173            }
1174            Ok(())
1175        });
1176
1177        self.server_state = Some(state);
1178        self.registry_key = Some(registry_key);
1179        self.forward_task = Some(forward_task);
1180        Ok(())
1181    }
1182
1183    async fn stop(&mut self) -> Result<(), CamelError> {
1184        tracing::info!(
1185            host = self.cfg.inner.host,
1186            port = self.cfg.inner.port,
1187            path = self.cfg.inner.path,
1188            "WebSocket consumer stopping"
1189        );
1190
1191        let close_msg = WsMessage::Close(Some(axum::extract::ws::CloseFrame {
1192            code: axum::extract::ws::CloseCode::from(1001u16),
1193            reason: "consumer stopping".into(),
1194        }));
1195        for tx in self.registry.snapshot_senders() {
1196            let _ = try_send_with_backpressure(&tx, close_msg.clone(), "consumer-stop-close");
1197        }
1198
1199        let mut had_server_error = false;
1200
1201        if let Some(state) = self.server_state.take() {
1202            had_server_error = state.server_error.load(Ordering::Relaxed);
1203            state.path_policies.remove(&self.cfg.inner.path);
1204            let mut table = state.dispatch.write().await;
1205            table.remove(&self.cfg.inner.path);
1206            state.path_configs.remove(&self.cfg.inner.path);
1207        }
1208
1209        if let Some(key) = self.registry_key.take() {
1210            global_registries().remove(&key);
1211            ServerRegistry::global().release(key.1);
1212        }
1213
1214        if let Some(task) = self.forward_task.take() {
1215            task.abort();
1216        }
1217
1218        tracing::info!(
1219            host = self.cfg.inner.host,
1220            port = self.cfg.inner.port,
1221            path = self.cfg.inner.path,
1222            "WebSocket consumer stopped"
1223        );
1224
1225        if had_server_error {
1226            tracing::warn!(
1227                host = self.cfg.inner.host,
1228                port = self.cfg.inner.port,
1229                path = self.cfg.inner.path,
1230                "WebSocket server had errors during its lifetime"
1231            );
1232            return Err(CamelError::ProcessorError(
1233                "WebSocket server terminated with errors during its lifetime".into(),
1234            ));
1235        }
1236
1237        Ok(())
1238    }
1239
1240    fn concurrency_model(&self) -> ConcurrencyModel {
1241        ConcurrencyModel::Concurrent {
1242            max: Some(self.cfg.inner.max_connections as usize),
1243        }
1244    }
1245
1246    fn startup_mode(&self) -> ConsumerStartupMode {
1247        ConsumerStartupMode::Explicit
1248    }
1249
1250    fn background_task_handle(&mut self) -> Option<JoinHandle<Result<(), CamelError>>> {
1251        self.forward_task.take()
1252    }
1253
1254    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
1255        // Construction-order (Task 2.1 lesson, applied at Task 2.8): the
1256        // context must already carry the compiled plan and the provider
1257        // registry here — `start()` publishes it into the shared server
1258        // state in the same step that makes the path dispatchable, so no
1259        // handshake can observe a half-patched policy.
1260        self.security_ctx = Some(ctx);
1261    }
1262}
1263
1264use std::sync::atomic::{AtomicBool, Ordering};
1265
1266fn new_atomic_false() -> Arc<AtomicBool> {
1267    Arc::new(AtomicBool::new(false))
1268}
1269
1270/// Classify a WebSocket error as retryable (transient network failure).
1271///
1272/// Retryable: connection refused, timeout, connection failed.
1273/// Permanent: anything else (protocol errors, auth failures, etc.).
1274#[inline]
1275fn is_retryable_ws_error(err: &CamelError) -> bool {
1276    let s = err.to_string();
1277    s.contains("connection refused") || s.contains("timeout") || s.contains("connection failed")
1278}
1279
1280#[derive(Clone)]
1281pub struct WsProducer {
1282    cfg: WsClientConfig,
1283    /// Shared flag set by the async future when server-send hits backpressure,
1284    /// so that the next `poll_ready` call can return an error. (WS-003)
1285    backpressure_flag: Arc<AtomicBool>,
1286}
1287
1288impl WsProducer {
1289    pub fn new(cfg: WsClientConfig) -> Self {
1290        Self {
1291            cfg,
1292            backpressure_flag: Arc::new(AtomicBool::new(false)),
1293        }
1294    }
1295}
1296
1297impl Service<Exchange> for WsProducer {
1298    type Response = Exchange;
1299    type Error = CamelError;
1300    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1301
1302    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
1303        // Return error if last server-send hit backpressure (WS-003)
1304        if self.backpressure_flag.swap(false, Ordering::Relaxed) {
1305            return Poll::Ready(Err(CamelError::ProcessorError(
1306                "WebSocket producer backpressure: previous send was dropped due to full channel"
1307                    .into(),
1308            )));
1309        }
1310        Poll::Ready(Ok(()))
1311    }
1312
1313    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1314        let cfg = self.cfg.clone();
1315        let backpressure_flag = Arc::clone(&self.backpressure_flag);
1316
1317        Box::pin(async move {
1318            let canonical_host = cfg.inner.canonical_host();
1319            let key = (
1320                canonical_host.clone(),
1321                cfg.inner.port,
1322                cfg.inner.path.clone(),
1323            );
1324
1325            let send_to_all = exchange
1326                .input
1327                .header("CamelWsSendToAll")
1328                .and_then(|v| v.as_bool())
1329                .or_else(|| exchange.input.header("sendToAll").and_then(|v| v.as_bool()))
1330                .unwrap_or(false);
1331
1332            let conn_keys_header = exchange
1333                .input
1334                .header("CamelWsConnectionKey")
1335                .and_then(|v| v.as_str())
1336                .map(str::to_string);
1337
1338            let local_exists = global_registries().contains_key(&key);
1339            let server_send_mode = send_to_all || conn_keys_header.is_some() || local_exists;
1340
1341            let message_type = exchange
1342                .input
1343                .header("CamelWsMessageType")
1344                .and_then(|v| v.as_str())
1345                .unwrap_or("text")
1346                .to_ascii_lowercase();
1347
1348            if server_send_mode {
1349                let registry = global_registries().get(&key).map(|e| Arc::clone(e.value()));
1350                let Some(registry) = registry else {
1351                    return Err(CamelError::ProcessorError(format!(
1352                        "WebSocket local consumer not found for {}:{}{}",
1353                        canonical_host, cfg.inner.port, cfg.inner.path
1354                    )));
1355                };
1356
1357                let out_msg = body_to_axum_ws_message(
1358                    std::mem::take(&mut exchange.input.body),
1359                    &message_type,
1360                )
1361                .await?;
1362
1363                let targets = if send_to_all {
1364                    registry.snapshot_senders()
1365                } else if let Some(keys) = conn_keys_header {
1366                    let parsed: Vec<String> = keys
1367                        .split(',')
1368                        .map(str::trim)
1369                        .filter(|k| !k.is_empty())
1370                        .map(|k| k.to_string())
1371                        .collect();
1372                    registry.get_senders_for_keys(&parsed)
1373                } else {
1374                    registry.snapshot_senders()
1375                };
1376
1377                let mut dropped = 0usize;
1378                for tx in &targets {
1379                    if !try_send_with_backpressure(tx, out_msg.clone(), "producer-send") {
1380                        dropped += 1;
1381                    }
1382                }
1383
1384                if dropped > 0 {
1385                    tracing::warn!(
1386                        host = canonical_host,
1387                        port = cfg.inner.port,
1388                        path = cfg.inner.path,
1389                        dropped,
1390                        total = targets.len(),
1391                        "WebSocket producer dropped messages due to backpressure"
1392                    );
1393                    exchange.input.set_header(
1394                        "CamelWsDeliveryDropped",
1395                        serde_json::Value::Number(dropped.into()),
1396                    );
1397                    // Signal backpressure for next poll_ready call (WS-003)
1398                    backpressure_flag.store(true, Ordering::Relaxed);
1399                    if dropped == targets.len() {
1400                        return Err(CamelError::ProcessorError(format!(
1401                            "WebSocket producer: all {dropped} message(s) dropped due to backpressure"
1402                        )));
1403                    }
1404                }
1405
1406                tracing::debug!(
1407                    host = canonical_host,
1408                    port = cfg.inner.port,
1409                    path = cfg.inner.path,
1410                    targets = targets.len(),
1411                    "WebSocket producer server-send complete"
1412                );
1413
1414                return Ok(exchange);
1415            }
1416
1417            let url = format!(
1418                "{}://{}:{}{}",
1419                cfg.inner.scheme, cfg.inner.host, cfg.inner.port, cfg.inner.path
1420            );
1421
1422            tracing::debug!(url = url, "WebSocket producer connecting");
1423
1424            #[allow(unused_mut)]
1425            let mut request = url
1426                .clone()
1427                .into_client_request()
1428                .map_err(|e| CamelError::ProcessorError(format!("WebSocket request error: {e}")))?;
1429
1430            #[cfg(feature = "otel")]
1431            {
1432                let mut otel_headers = HashMap::new();
1433                camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
1434                for (k, v) in otel_headers {
1435                    if let (Ok(name), Ok(val)) = (
1436                        http::header::HeaderName::from_bytes(k.as_bytes()),
1437                        http::header::HeaderValue::from_str(&v),
1438                    ) {
1439                        request.headers_mut().insert(name, val);
1440                    }
1441                }
1442            }
1443
1444            // Add Sec-WebSocket-Protocol header if subprotocols configured (WS-007)
1445            if !cfg.inner.subprotocols.is_empty() {
1446                let proto_value = cfg.inner.subprotocols.join(", ");
1447                if let (Ok(name), Ok(val)) = (
1448                    http::header::HeaderName::from_bytes(b"Sec-WebSocket-Protocol"),
1449                    http::header::HeaderValue::from_str(&proto_value),
1450                ) {
1451                    request.headers_mut().insert(name, val);
1452                }
1453            }
1454
1455            // Determine message type: respect binary_payload config (WS-018)
1456            let effective_message_type = if cfg.inner.binary_payload {
1457                "binary"
1458            } else {
1459                &message_type
1460            };
1461
1462            let reconnect_policy = cfg.inner.reconnect_policy.clone();
1463            let mut ws_stream =
1464                connect_ws_with_retry(request, &url, cfg.inner.connect_timeout, &reconnect_policy)
1465                    .await?;
1466
1467            // Close/reconnect path: rate-limited bail. On close frame, sleep
1468            // delay_for(0) and return Err to signal the outer route to re-invoke
1469            // the producer. The attempts counter below bounds how many times
1470            // we'll signal reconnect before terminating. Independent counter —
1471            // OLD code shared a counter with the connect loop above; this is a
1472            // behavior change (cleaner separation of concerns).
1473            let attempts = 0u32;
1474
1475            let out_msg = body_to_client_ws_message(
1476                std::mem::take(&mut exchange.input.body),
1477                effective_message_type,
1478            )
1479            .await?;
1480
1481            send_with_timeout(ws_stream.send(out_msg), cfg.inner.send_timeout).await?;
1482
1483            let incoming = tokio::time::timeout(cfg.inner.response_timeout, async {
1484                loop {
1485                    match ws_stream.next().await {
1486                        Some(Ok(ClientWsMessage::Ping(_))) | Some(Ok(ClientWsMessage::Pong(_))) => {
1487                            continue;
1488                        }
1489                        other => break other,
1490                    }
1491                }
1492            })
1493            .await
1494            .map_err(|_| CamelError::ProcessorError("WebSocket response timeout".into()))?;
1495
1496            match incoming {
1497                Some(Ok(ClientWsMessage::Text(text))) => {
1498                    tracing::debug!(url = url, "WebSocket producer received text response");
1499                    exchange.input.body = CamelBody::Text(text.to_string());
1500                }
1501                Some(Ok(ClientWsMessage::Binary(data))) => {
1502                    tracing::debug!(url = url, "WebSocket producer received binary response");
1503                    exchange.input.body = CamelBody::Bytes(data);
1504                }
1505                Some(Ok(ClientWsMessage::Close(frame))) => {
1506                    let normal = frame
1507                        .as_ref()
1508                        .map(|f| {
1509                            f.code == tungstenite::protocol::frame::coding::CloseCode::Normal
1510                                || f.code == tungstenite::protocol::frame::coding::CloseCode::Away
1511                        })
1512                        .unwrap_or(true);
1513
1514                    if normal {
1515                        tracing::debug!(url = url, "WebSocket producer received normal close");
1516                        exchange.input.body = CamelBody::Empty;
1517                    } else if reconnect_policy.should_retry(attempts + 1) {
1518                        let delay = reconnect_policy.delay_for(0); // fresh delay on close
1519                        tracing::warn!(
1520                            url = url,
1521                            attempt = attempts + 1,
1522                            delay_ms = delay.as_millis(),
1523                            "WebSocket closed by peer — reconnecting"
1524                        );
1525                        tokio::time::sleep(delay).await;
1526                        return Err(CamelError::ProcessorError(format!(
1527                            "WebSocket reconnect required after close: code {}",
1528                            frame.map(|f| u16::from(f.code)).unwrap_or_default()
1529                        )));
1530                    } else {
1531                        let code = frame.map(|f| u16::from(f.code)).unwrap_or_default();
1532                        return Err(CamelError::ProcessorError(format!(
1533                            "WebSocket peer closed: code {code}"
1534                        )));
1535                    }
1536                }
1537                Some(Ok(_)) | None => {
1538                    exchange.input.body = CamelBody::Empty;
1539                }
1540                Some(Err(e)) => {
1541                    return Err(CamelError::ProcessorError(format!(
1542                        "WebSocket receive failed: {e}"
1543                    )));
1544                }
1545            }
1546
1547            let _ = ws_stream.close(None).await;
1548            tracing::debug!(url = url, "WebSocket producer connection closed");
1549            Ok(exchange)
1550        })
1551    }
1552}
1553
1554async fn body_to_axum_ws_message(
1555    body: CamelBody,
1556    message_type: &str,
1557) -> Result<WsMessage, CamelError> {
1558    match message_type {
1559        "binary" => Ok(WsMessage::Binary(body.into_bytes(10 * 1024 * 1024).await?)),
1560        _ => Ok(WsMessage::Text(body_to_text(body).await?.into())),
1561    }
1562}
1563
1564async fn body_to_client_ws_message(
1565    body: CamelBody,
1566    message_type: &str,
1567) -> Result<ClientWsMessage, CamelError> {
1568    match message_type {
1569        "binary" => Ok(ClientWsMessage::Binary(
1570            body.into_bytes(10 * 1024 * 1024).await?,
1571        )),
1572        _ => Ok(ClientWsMessage::Text(body_to_text(body).await?.into())),
1573    }
1574}
1575
1576async fn body_to_text(body: CamelBody) -> Result<String, CamelError> {
1577    Ok(match body {
1578        CamelBody::Text(s) => s,
1579        CamelBody::Xml(s) => s,
1580        CamelBody::Json(v) => v.to_string(),
1581        CamelBody::Bytes(b) => String::from_utf8_lossy(&b).to_string(),
1582        CamelBody::Stream(stream) => {
1583            let bytes = CamelBody::Stream(stream)
1584                .into_bytes(10 * 1024 * 1024)
1585                .await?;
1586            String::from_utf8_lossy(&bytes).to_string()
1587        }
1588        // Empty and future variants render as an empty string.
1589        _ => String::new(),
1590    })
1591}
1592
1593fn is_origin_allowed(allowed_origin: &str, request_origin: Option<&str>) -> bool {
1594    if allowed_origin == "*" {
1595        return true;
1596    }
1597    request_origin.is_some_and(|origin| origin == allowed_origin)
1598}
1599
1600fn try_send_with_backpressure(tx: &mpsc::Sender<WsMessage>, msg: WsMessage, context: &str) -> bool {
1601    match tx.try_send(msg) {
1602        Ok(()) => true,
1603        Err(error) => {
1604            tracing::warn!(%context, %error, "dropping websocket outbound message due to backpressure");
1605            false
1606        }
1607    }
1608}
1609
1610async fn send_with_timeout(
1611    send_future: impl std::future::Future<Output = Result<(), tungstenite::Error>>,
1612    timeout: std::time::Duration,
1613) -> Result<(), CamelError> {
1614    match tokio::time::timeout(timeout, send_future).await {
1615        Ok(result) => {
1616            result.map_err(|e| CamelError::ProcessorError(format!("WebSocket send failed: {e}")))
1617        }
1618        Err(_) => Err(CamelError::ProcessorError(format!(
1619            "WebSocket send timeout after {timeout:?}"
1620        ))),
1621    }
1622}
1623
1624fn load_tls_config(
1625    cert_path: &str,
1626    key_path: &str,
1627) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1628    use std::fs::File;
1629    use std::io::BufReader;
1630
1631    let cert_file = File::open(cert_path)
1632        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1633    let key_file = File::open(key_path)
1634        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1635
1636    let certs = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1637        .collect::<Result<Vec<_>, _>>()
1638        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1639
1640    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1641        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1642        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1643
1644    tokio_rustls::rustls::ServerConfig::builder()
1645        .with_no_client_auth()
1646        .with_single_cert(certs, key)
1647        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1648}
1649
1650fn map_connect_error(err: tungstenite::Error, url: &str) -> CamelError {
1651    match err {
1652        tungstenite::Error::Io(ioe) if ioe.kind() == std::io::ErrorKind::ConnectionRefused => {
1653            CamelError::ProcessorError(format!("WebSocket connection refused: {ioe}"))
1654        }
1655        tungstenite::Error::Tls(_) => {
1656            CamelError::ProcessorError("WebSocket TLS handshake failed: handshake error".into())
1657        }
1658        other => {
1659            let msg = other.to_string();
1660            if msg.to_lowercase().contains("connection refused") {
1661                CamelError::ProcessorError(format!("WebSocket connection refused: {msg}"))
1662            } else if msg.to_lowercase().contains("tls") {
1663                CamelError::ProcessorError(format!("WebSocket TLS handshake failed: {msg}"))
1664            } else {
1665                CamelError::ProcessorError(format!("WebSocket connection failed ({url}): {msg}"))
1666            }
1667        }
1668    }
1669}
1670
1671/// Connect to a WebSocket server with retry logic using the configured
1672/// [`NetworkRetryPolicy`]. Extracted for testability so the regression test
1673/// (rc-1nm) can drive the real production connect path rather than a
1674/// synthetic fake.
1675async fn connect_ws_with_retry<R>(
1676    request: R,
1677    url: &str,
1678    connect_timeout: std::time::Duration,
1679    reconnect_policy: &NetworkRetryPolicy,
1680) -> Result<
1681    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
1682    CamelError,
1683>
1684where
1685    R: IntoClientRequest + Unpin + Clone,
1686{
1687    let url_owned = url.to_string();
1688    retry_async(
1689        reconnect_policy,
1690        "ws",
1691        "connect",
1692        || {
1693            let r = request.clone();
1694            let url = url_owned.clone();
1695            async move {
1696                match tokio::time::timeout(connect_timeout, tokio_tungstenite::connect_async(r))
1697                    .await
1698                {
1699                    Ok(Ok((stream, _))) => Ok(stream),
1700                    Ok(Err(e)) => Err(map_connect_error(e, &url)),
1701                    Err(_) => Err(CamelError::ProcessorError(format!(
1702                        "WebSocket connect timeout ({connect_timeout:?}) to {url}"
1703                    ))),
1704                }
1705            }
1706        },
1707        is_retryable_ws_error,
1708        None,
1709    )
1710    .await
1711}
1712
1713#[cfg(test)]
1714mod tests {
1715    use camel_component_api::test_support::PanicRuntimeObservability;
1716    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1717        std::sync::Arc::new(PanicRuntimeObservability)
1718    }
1719    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1720        std::sync::Arc::new(PanicRuntimeObservability)
1721    }
1722
1723    /// Serialize tests that touch the global `ServerRegistry::global()`.
1724    ///
1725    /// `ServerRegistry::reset()` aborts ALL server tasks globally, so any
1726    /// test with a running server must hold this lock for its duration to
1727    /// prevent a concurrent `reset()` from killing its server. Tests that
1728    /// call `reset()` must also hold it.
1729    static REGISTRY_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1730
1731    use super::*;
1732    use camel_component_api::NoOpComponentContext;
1733    use std::time::Duration;
1734
1735    use tokio::sync::mpsc;
1736    use tokio_tungstenite::connect_async;
1737    use tokio_tungstenite::tungstenite::Message as ClientMessage;
1738    use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
1739    use tokio_util::sync::CancellationToken;
1740    use tower::ServiceExt;
1741
1742    fn free_port() -> u16 {
1743        std::net::TcpListener::bind("127.0.0.1:0")
1744            .unwrap()
1745            .local_addr()
1746            .unwrap()
1747            .port()
1748    }
1749
1750    #[test]
1751    fn ws_component_scheme_is_ws() {
1752        assert_eq!(WsComponent::new().scheme(), "ws");
1753    }
1754
1755    #[test]
1756    fn wss_component_scheme_is_wss() {
1757        assert_eq!(WssComponent::new().scheme(), "wss");
1758    }
1759
1760    #[test]
1761    fn endpoint_config_defaults_match_spec() {
1762        let cfg = WsEndpointConfig::default();
1763        assert_eq!(cfg.scheme, "ws");
1764        assert_eq!(cfg.host, "0.0.0.0");
1765        assert_eq!(cfg.port, 8080);
1766        assert_eq!(cfg.path, "/");
1767        assert_eq!(cfg.max_connections, 100);
1768        assert_eq!(cfg.max_message_size, 65536);
1769        assert!(!cfg.send_to_all);
1770        assert_eq!(cfg.heartbeat_interval, Duration::ZERO);
1771        assert_eq!(cfg.idle_timeout, Duration::ZERO);
1772        assert_eq!(cfg.connect_timeout, Duration::from_secs(10));
1773        assert_eq!(cfg.response_timeout, Duration::from_secs(30));
1774        assert_eq!(cfg.allow_origin, "*");
1775        assert_eq!(cfg.tls_cert, None);
1776        assert_eq!(cfg.tls_key, None);
1777        assert!(cfg.reconnect);
1778        assert_eq!(cfg.reconnect_max_attempts, 5);
1779        assert_eq!(cfg.reconnect_delay_ms, 1000);
1780        assert_eq!(cfg.send_timeout, Duration::from_secs(30));
1781        assert!(!cfg.binary_payload);
1782        assert!(cfg.subprotocols.is_empty());
1783    }
1784
1785    #[test]
1786    fn endpoint_config_parses_uri_params() {
1787        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";
1788        let cfg = WsEndpointConfig::from_uri(uri).unwrap();
1789
1790        assert_eq!(cfg.scheme, "ws");
1791        assert_eq!(cfg.host, "localhost");
1792        assert_eq!(cfg.port, 9001);
1793        assert_eq!(cfg.path, "/chat");
1794        assert_eq!(cfg.max_connections, 42);
1795        assert_eq!(cfg.max_message_size, 1024);
1796        assert!(cfg.send_to_all);
1797        assert_eq!(cfg.heartbeat_interval, Duration::from_millis(1500));
1798        assert_eq!(cfg.idle_timeout, Duration::from_millis(2500));
1799        assert_eq!(cfg.connect_timeout, Duration::from_millis(3500));
1800        assert_eq!(cfg.response_timeout, Duration::from_millis(4500));
1801        assert_eq!(cfg.allow_origin, "https://example.com");
1802        assert_eq!(cfg.tls_cert.as_deref(), Some("/tmp/cert.pem"));
1803        assert_eq!(cfg.tls_key.as_deref(), Some("/tmp/key.pem"));
1804        assert!(cfg.reconnect);
1805        assert_eq!(cfg.reconnect_max_attempts, 5);
1806        assert_eq!(cfg.reconnect_delay_ms, 1000);
1807    }
1808
1809    #[test]
1810    fn endpoint_config_parses_reconnect_uri_params() {
1811        let uri =
1812            "ws://localhost:9001/chat?reconnect=false&reconnectMaxAttempts=2&reconnectDelayMs=25";
1813        let cfg = WsEndpointConfig::from_uri(uri).unwrap();
1814        assert!(!cfg.reconnect);
1815        assert_eq!(cfg.reconnect_max_attempts, 2);
1816        assert_eq!(cfg.reconnect_delay_ms, 25);
1817    }
1818
1819    #[test]
1820    fn endpoint_config_override_chain_uri_overrides_defaults() {
1821        let cfg = WsEndpointConfig::from_uri("ws://127.0.0.1:8089/echo?maxConnections=7").unwrap();
1822        assert_eq!(cfg.max_connections, 7);
1823        assert_eq!(cfg.max_message_size, 65536);
1824        assert!(!cfg.send_to_all);
1825        assert_eq!(cfg.response_timeout, Duration::from_secs(30));
1826    }
1827
1828    #[test]
1829    fn endpoint_trait_creates_consumer_and_producer() {
1830        let ctx = NoOpComponentContext;
1831        let endpoint = WsComponent::new()
1832            .create_endpoint("ws://127.0.0.1:9010/trait", &ctx)
1833            .unwrap();
1834
1835        endpoint.create_consumer(rt()).unwrap();
1836        endpoint
1837            .create_producer(rt(), &ProducerContext::default())
1838            .unwrap();
1839    }
1840
1841    #[test]
1842    fn ws_consumer_concurrency_model_uses_max_connections() {
1843        let cfg = WsEndpointConfig::from_uri("ws://127.0.0.1:9011/cm?maxConnections=321").unwrap();
1844        let consumer = WsConsumer::new(cfg.server_config(), test_rt());
1845        assert_eq!(
1846            consumer.concurrency_model(),
1847            ConcurrencyModel::Concurrent { max: Some(321) }
1848        );
1849    }
1850
1851    #[tokio::test]
1852    async fn connection_registry_add_remove_broadcast_and_targeted_send() {
1853        let registry = WsConnectionRegistry::new();
1854        let (tx1, mut rx1) = mpsc::channel(8);
1855        let (tx2, mut rx2) = mpsc::channel(8);
1856
1857        registry.insert("k1".into(), tx1);
1858        registry.insert("k2".into(), tx2);
1859        assert_eq!(registry.len(), 2);
1860
1861        for tx in registry.snapshot_senders() {
1862            tx.send(WsMessage::Text("broadcast".into())).await.unwrap();
1863        }
1864
1865        assert_eq!(rx1.recv().await, Some(WsMessage::Text("broadcast".into())));
1866        assert_eq!(rx2.recv().await, Some(WsMessage::Text("broadcast".into())));
1867
1868        let target = registry.get_senders_for_keys(&["k1".to_string()]);
1869        assert_eq!(target.len(), 1);
1870        target[0]
1871            .send(WsMessage::Text("targeted".into()))
1872            .await
1873            .unwrap();
1874
1875        assert_eq!(rx1.recv().await, Some(WsMessage::Text("targeted".into())));
1876        assert!(
1877            tokio::time::timeout(Duration::from_millis(50), rx2.recv())
1878                .await
1879                .is_err()
1880        );
1881
1882        registry.remove("k1");
1883        assert_eq!(registry.len(), 1);
1884    }
1885
1886    #[test]
1887    fn host_canonicalization_maps_local_hosts_to_loopback() {
1888        let c1 = WsEndpointConfig::from_uri("ws://0.0.0.0:9100/a")
1889            .unwrap()
1890            .canonical_host();
1891        let c2 = WsEndpointConfig::from_uri("ws://localhost:9101/b")
1892            .unwrap()
1893            .canonical_host();
1894        let c3 = WsEndpointConfig::from_uri("ws://127.0.0.1:9102/c")
1895            .unwrap()
1896            .canonical_host();
1897
1898        assert_eq!(c1, "127.0.0.1");
1899        assert_eq!(c2, "127.0.0.1");
1900        assert_eq!(c3, "127.0.0.1");
1901    }
1902
1903    #[tokio::test]
1904    async fn echo_flow_round_trips_message_through_consumer_and_producer() {
1905        let _guard = REGISTRY_TEST_LOCK.lock().await;
1906        let port = free_port();
1907        let uri = format!("ws://127.0.0.1:{port}/echo");
1908        let component_ctx = NoOpComponentContext;
1909        let endpoint = WsComponent::new()
1910            .create_endpoint(&uri, &component_ctx)
1911            .unwrap();
1912
1913        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1914        let producer = endpoint
1915            .create_producer(rt(), &ProducerContext::default())
1916            .unwrap();
1917
1918        let (route_tx, mut route_rx) = mpsc::channel(16);
1919        let ctx = ConsumerContext::new(
1920            route_tx,
1921            CancellationToken::new(),
1922            "ws-test-route".to_string(),
1923        );
1924        consumer.start(ctx).await.unwrap();
1925
1926        let route_task = tokio::spawn(async move {
1927            if let Some(envelope) = route_rx.recv().await {
1928                let payload = envelope
1929                    .exchange
1930                    .input
1931                    .body
1932                    .as_text()
1933                    .unwrap_or_default()
1934                    .to_string();
1935                let key = envelope
1936                    .exchange
1937                    .input
1938                    .header("CamelWsConnectionKey")
1939                    .and_then(|v| v.as_str())
1940                    .unwrap()
1941                    .to_string();
1942
1943                let mut response = Exchange::new(CamelMessage::new(CamelBody::Text(payload)));
1944                response
1945                    .input
1946                    .set_header("CamelWsConnectionKey", serde_json::Value::String(key));
1947                producer.oneshot(response).await.unwrap();
1948            }
1949        });
1950
1951        let url = format!("ws://127.0.0.1:{port}/echo");
1952        let (mut client, _) = loop {
1953            match connect_async(&url).await {
1954                Ok(ok) => break ok,
1955                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
1956            }
1957        };
1958
1959        client
1960            .send(ClientMessage::Text("hello-ws".into()))
1961            .await
1962            .unwrap();
1963
1964        let incoming = tokio::time::timeout(Duration::from_secs(2), async {
1965            loop {
1966                match client.next().await {
1967                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
1968                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
1969                    Some(Ok(_)) => continue,
1970                    Some(Err(e)) => panic!("ws receive failed: {e}"),
1971                    None => panic!("websocket closed before echo"),
1972                }
1973            }
1974        })
1975        .await
1976        .unwrap();
1977
1978        assert_eq!(incoming, "hello-ws");
1979
1980        consumer.stop().await.unwrap();
1981        route_task.await.unwrap();
1982    }
1983
1984    #[tokio::test]
1985    async fn consumer_stop_sends_close_1001() {
1986        let _guard = REGISTRY_TEST_LOCK.lock().await;
1987        let port = free_port();
1988        let uri = format!("ws://127.0.0.1:{port}/shutdown");
1989        let component_ctx = NoOpComponentContext;
1990        let endpoint = WsComponent::new()
1991            .create_endpoint(&uri, &component_ctx)
1992            .unwrap();
1993
1994        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1995        let (route_tx, _route_rx) = mpsc::channel(16);
1996        let ctx = ConsumerContext::new(
1997            route_tx,
1998            CancellationToken::new(),
1999            "ws-test-route".to_string(),
2000        );
2001        consumer.start(ctx).await.unwrap();
2002
2003        let url = format!("ws://127.0.0.1:{port}/shutdown");
2004        let (mut client, _) = loop {
2005            match connect_async(&url).await {
2006                Ok(ok) => break ok,
2007                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2008            }
2009        };
2010
2011        client
2012            .send(ClientMessage::Text("keepalive".into()))
2013            .await
2014            .unwrap();
2015
2016        consumer.stop().await.unwrap();
2017
2018        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
2019            loop {
2020                match client.next().await {
2021                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
2022                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2023                    Some(Ok(_)) => continue,
2024                    Some(Err(e)) => panic!("ws receive failed: {e}"),
2025                    None => panic!("websocket closed without close frame"),
2026                }
2027            }
2028        })
2029        .await
2030        .unwrap();
2031
2032        assert_eq!(close_code, Some(CloseCode::Away));
2033    }
2034
2035    #[test]
2036    fn wildcard_origin_allows_anything() {
2037        assert!(is_origin_allowed("*", None));
2038        assert!(is_origin_allowed("*", Some("https://example.com")));
2039    }
2040
2041    #[test]
2042    fn exact_origin_requires_match() {
2043        assert!(is_origin_allowed(
2044            "https://example.com",
2045            Some("https://example.com")
2046        ));
2047        assert!(!is_origin_allowed(
2048            "https://example.com",
2049            Some("https://other.com")
2050        ));
2051        assert!(!is_origin_allowed("https://example.com", None));
2052    }
2053
2054    #[test]
2055    fn endpoint_config_rejects_invalid_scheme() {
2056        let result = WsEndpointConfig::from_uri("http://localhost:9000/path");
2057        assert!(result.is_err());
2058        let msg = result.unwrap_err().to_string();
2059        assert!(
2060            msg.contains("Invalid WebSocket scheme"),
2061            "expected scheme error, got: {msg}"
2062        );
2063    }
2064
2065    #[tokio::test]
2066    async fn wss_consumer_start_fails_without_tls_cert() {
2067        let _guard = REGISTRY_TEST_LOCK.lock().await;
2068        let port = free_port();
2069        let component_ctx = NoOpComponentContext;
2070        let endpoint = WssComponent::new()
2071            .create_endpoint(&format!("wss://127.0.0.1:{port}/secure"), &component_ctx)
2072            .unwrap();
2073        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2074        let (tx, _rx) = mpsc::channel(16);
2075        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "ws-test-route".to_string());
2076        let result = consumer.start(ctx).await;
2077        assert!(result.is_err());
2078        let msg = result.unwrap_err().to_string();
2079        assert!(
2080            msg.contains("TLS cert path is required"),
2081            "expected TLS cert error, got: {msg}"
2082        );
2083    }
2084
2085    #[tokio::test]
2086    async fn wss_consumer_start_fails_with_nonexistent_cert() {
2087        let _guard = REGISTRY_TEST_LOCK.lock().await;
2088        // Ensure clean global state (process-lifetime servers may leak across tests).
2089        ServerRegistry::reset();
2090
2091        let port = free_port();
2092        let component_ctx = NoOpComponentContext;
2093        let endpoint = WssComponent::new()
2094            .create_endpoint(&format!(
2095                "wss://127.0.0.1:{port}/secure?tlsCert=/nonexistent/cert.pem&tlsKey=/nonexistent/key.pem"
2096            ), &component_ctx)
2097            .unwrap();
2098        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2099        let (tx, _rx) = mpsc::channel(16);
2100        let ctx = ConsumerContext::new(tx, CancellationToken::new(), "ws-test-route".to_string());
2101        let result = consumer.start(ctx).await;
2102        assert!(result.is_err());
2103        let msg = result.unwrap_err().to_string();
2104        assert!(
2105            msg.contains("TLS cert file error"),
2106            "expected cert file error, got: {msg}"
2107        );
2108    }
2109
2110    #[tokio::test]
2111    async fn server_registry_returns_same_state_for_same_port() {
2112        let _guard = REGISTRY_TEST_LOCK.lock().await;
2113        let port = free_port();
2114        let (state1, _) = ServerRegistry::global()
2115            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2116            .await
2117            .unwrap();
2118        let (state2, _) = ServerRegistry::global()
2119            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2120            .await
2121            .unwrap();
2122        assert!(
2123            Arc::ptr_eq(&state1.dispatch, &state2.dispatch),
2124            "expected same dispatch table for same port"
2125        );
2126    }
2127
2128    #[tokio::test]
2129    async fn dispatch_handler_returns_404_for_unregistered_path() {
2130        let _guard = REGISTRY_TEST_LOCK.lock().await;
2131        let port = free_port();
2132        let (state, _) = ServerRegistry::global()
2133            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2134            .await
2135            .unwrap();
2136        let app = Router::new().fallback(dispatch_handler).with_state(state);
2137        let response = tokio::time::timeout(
2138            Duration::from_secs(2),
2139            tower::ServiceExt::oneshot(
2140                app,
2141                axum::http::Request::builder()
2142                    .uri("/nonexistent")
2143                    .body(Body::empty())
2144                    .unwrap(),
2145            ),
2146        )
2147        .await
2148        .unwrap()
2149        .unwrap();
2150        assert_eq!(response.status(), StatusCode::NOT_FOUND);
2151    }
2152
2153    #[tokio::test]
2154    async fn client_mode_producer_connects_and_echoes() {
2155        let app = Router::new().route(
2156            "/echo",
2157            axum::routing::get(|ws: WebSocketUpgrade| async move {
2158                ws.on_upgrade(|mut socket: WebSocket| async move {
2159                    while let Some(Ok(msg)) = socket.recv().await {
2160                        match msg {
2161                            WsMessage::Text(text) => {
2162                                let _ = socket.send(WsMessage::Text(text)).await;
2163                            }
2164                            WsMessage::Binary(data) => {
2165                                let _ = socket.send(WsMessage::Binary(data)).await;
2166                            }
2167                            WsMessage::Close(_) => break,
2168                            _ => {}
2169                        }
2170                    }
2171                })
2172            }),
2173        );
2174        // Bind to port 0 directly to avoid TOCTOU race with free_port() + re-bind
2175        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2176        let port = listener.local_addr().unwrap().port();
2177        let server_task = tokio::spawn(async move {
2178            let _ = serve(listener, app).await;
2179        });
2180
2181        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/echo")).unwrap();
2182        let producer = WsProducer::new(cfg.client_config());
2183
2184        let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("hello-client".into())));
2185        tokio::time::sleep(Duration::from_millis(25)).await;
2186        let result =
2187            match tokio::time::timeout(Duration::from_secs(3), producer.oneshot(exchange)).await {
2188                Ok(Ok(r)) => r,
2189                Ok(Err(_)) => panic!("producer call failed"),
2190                Err(_) => panic!("producer call timed out"),
2191            };
2192
2193        assert_eq!(result.input.body.as_text().unwrap(), "hello-client");
2194
2195        server_task.abort();
2196    }
2197
2198    #[tokio::test]
2199    async fn max_connections_rejects_with_close_1013() {
2200        let _guard = REGISTRY_TEST_LOCK.lock().await;
2201        let port = free_port();
2202        let uri = format!("ws://127.0.0.1:{port}/limited?maxConnections=1");
2203        let component_ctx = NoOpComponentContext;
2204        let endpoint = WsComponent::new()
2205            .create_endpoint(&uri, &component_ctx)
2206            .unwrap();
2207        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2208        let (route_tx, _route_rx) = mpsc::channel(16);
2209        let ctx = ConsumerContext::new(
2210            route_tx,
2211            CancellationToken::new(),
2212            "ws-test-route".to_string(),
2213        );
2214        consumer.start(ctx).await.unwrap();
2215
2216        let url = format!("ws://127.0.0.1:{port}/limited");
2217        let (_client1, _) = loop {
2218            match connect_async(&url).await {
2219                Ok(ok) => break ok,
2220                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2221            }
2222        };
2223
2224        tokio::time::sleep(Duration::from_millis(100)).await;
2225
2226        let (mut client2, _) = connect_async(&url).await.unwrap();
2227
2228        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
2229            loop {
2230                match client2.next().await {
2231                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
2232                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2233                    Some(Ok(ClientMessage::Text(_))) => continue,
2234                    Some(Ok(_)) => continue,
2235                    Some(Err(e)) => panic!("client2 ws receive failed: {e}"),
2236                    None => panic!("client2 closed without close frame"),
2237                }
2238            }
2239        })
2240        .await
2241        .unwrap();
2242
2243        assert_eq!(
2244            close_code,
2245            Some(CloseCode::from(1013u16)),
2246            "expected 1013 (Try Again Later) for max connections"
2247        );
2248
2249        consumer.stop().await.unwrap();
2250    }
2251
2252    #[tokio::test]
2253    async fn max_message_size_rejects_with_close_1009() {
2254        let _guard = REGISTRY_TEST_LOCK.lock().await;
2255        let port = free_port();
2256        let uri = format!("ws://127.0.0.1:{port}/sizelimit?maxMessageSize=10");
2257        let component_ctx = NoOpComponentContext;
2258        let endpoint = WsComponent::new()
2259            .create_endpoint(&uri, &component_ctx)
2260            .unwrap();
2261        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2262        let (route_tx, _route_rx) = mpsc::channel(16);
2263        let ctx = ConsumerContext::new(
2264            route_tx,
2265            CancellationToken::new(),
2266            "ws-test-route".to_string(),
2267        );
2268        consumer.start(ctx).await.unwrap();
2269
2270        let url = format!("ws://127.0.0.1:{port}/sizelimit");
2271        let (mut client, _) = loop {
2272            match connect_async(&url).await {
2273                Ok(ok) => break ok,
2274                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2275            }
2276        };
2277
2278        let oversized = "x".repeat(100);
2279        client
2280            .send(ClientMessage::Text(oversized.into()))
2281            .await
2282            .unwrap();
2283
2284        let close_code = tokio::time::timeout(Duration::from_secs(2), async {
2285            loop {
2286                match client.next().await {
2287                    Some(Ok(ClientMessage::Close(frame))) => break frame.map(|f| f.code),
2288                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2289                    Some(Ok(_)) => continue,
2290                    Some(Err(e)) => panic!("ws receive failed: {e}"),
2291                    None => panic!("websocket closed without close frame"),
2292                }
2293            }
2294        })
2295        .await
2296        .unwrap();
2297
2298        assert_eq!(
2299            close_code,
2300            Some(CloseCode::from(1009u16)),
2301            "expected 1009 (Message Too Big) for oversized message"
2302        );
2303
2304        consumer.stop().await.unwrap();
2305    }
2306
2307    #[tokio::test]
2308    async fn origin_rejection_returns_403() {
2309        let _guard = REGISTRY_TEST_LOCK.lock().await;
2310        let port = free_port();
2311        let uri = format!("ws://127.0.0.1:{port}/origintest?allowOrigin=https://allowed.com");
2312        let component_ctx = NoOpComponentContext;
2313        let endpoint = WsComponent::new()
2314            .create_endpoint(&uri, &component_ctx)
2315            .unwrap();
2316        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2317        let (route_tx, _route_rx) = mpsc::channel(16);
2318        let ctx = ConsumerContext::new(
2319            route_tx,
2320            CancellationToken::new(),
2321            "ws-test-route".to_string(),
2322        );
2323        consumer.start(ctx).await.unwrap();
2324
2325        let (state, _) = ServerRegistry::global()
2326            .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2327            .await
2328            .unwrap();
2329        let app = Router::new().fallback(dispatch_handler).with_state(state);
2330
2331        let response = tokio::time::timeout(
2332            Duration::from_secs(2),
2333            tower::ServiceExt::oneshot(
2334                app,
2335                axum::http::Request::builder()
2336                    .uri("/origintest")
2337                    .header("origin", "https://evil.com")
2338                    .header("upgrade", "websocket")
2339                    .header("connection", "Upgrade")
2340                    .header("sec-websocket-version", "13")
2341                    .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
2342                    .body(Body::empty())
2343                    .unwrap(),
2344            ),
2345        )
2346        .await
2347        .unwrap()
2348        .unwrap();
2349
2350        assert_eq!(
2351            response.status(),
2352            StatusCode::FORBIDDEN,
2353            "expected 403 for disallowed origin"
2354        );
2355
2356        consumer.stop().await.unwrap();
2357    }
2358
2359    #[tokio::test]
2360    async fn broadcast_sends_to_all_connected_clients() {
2361        let _guard = REGISTRY_TEST_LOCK.lock().await;
2362        let port = free_port();
2363        let uri = format!("ws://127.0.0.1:{port}/bc");
2364        let component_ctx = NoOpComponentContext;
2365        let endpoint = WsComponent::new()
2366            .create_endpoint(&uri, &component_ctx)
2367            .unwrap();
2368        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2369        let producer = endpoint
2370            .create_producer(rt(), &ProducerContext::default())
2371            .unwrap();
2372
2373        let (route_tx, _route_rx) = mpsc::channel(16);
2374        let ctx = ConsumerContext::new(
2375            route_tx,
2376            CancellationToken::new(),
2377            "ws-test-route".to_string(),
2378        );
2379        consumer.start(ctx).await.unwrap();
2380
2381        let url = format!("ws://127.0.0.1:{port}/bc");
2382
2383        let (mut client1, _) = loop {
2384            match connect_async(&url).await {
2385                Ok(ok) => break ok,
2386                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2387            }
2388        };
2389
2390        let (mut client2, _) = connect_async(&url).await.unwrap();
2391
2392        tokio::time::sleep(Duration::from_millis(100)).await;
2393
2394        let mut response =
2395            Exchange::new(CamelMessage::new(CamelBody::Text("broadcast-msg".into())));
2396        response
2397            .input
2398            .set_header("CamelWsSendToAll", serde_json::Value::Bool(true));
2399        producer.oneshot(response).await.unwrap();
2400
2401        let recv1 = tokio::time::timeout(Duration::from_secs(2), async {
2402            loop {
2403                match client1.next().await {
2404                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
2405                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2406                    _ => panic!("client1 unexpected message or close"),
2407                }
2408            }
2409        })
2410        .await
2411        .unwrap();
2412
2413        let recv2 = tokio::time::timeout(Duration::from_secs(2), async {
2414            loop {
2415                match client2.next().await {
2416                    Some(Ok(ClientMessage::Text(txt))) => break txt.to_string(),
2417                    Some(Ok(ClientMessage::Ping(_))) | Some(Ok(ClientMessage::Pong(_))) => continue,
2418                    _ => panic!("client2 unexpected message or close"),
2419                }
2420            }
2421        })
2422        .await
2423        .unwrap();
2424
2425        assert_eq!(recv1, "broadcast-msg");
2426        assert_eq!(recv2, "broadcast-msg");
2427
2428        consumer.stop().await.unwrap();
2429    }
2430
2431    #[tokio::test]
2432    async fn concurrent_get_or_spawn_returns_same_state() {
2433        let _guard = REGISTRY_TEST_LOCK.lock().await;
2434        let port = free_port();
2435        let results: Arc<std::sync::Mutex<Vec<WsAppState>>> =
2436            Arc::new(std::sync::Mutex::new(Vec::new()));
2437
2438        let mut handles = Vec::new();
2439        for _ in 0..4 {
2440            let results = results.clone();
2441            handles.push(tokio::spawn(async move {
2442                let (state, _) = ServerRegistry::global()
2443                    .get_or_spawn("127.0.0.1", port, None, test_rt(), "test-route".into())
2444                    .await
2445                    .unwrap();
2446                results.lock().unwrap().push(state);
2447            }));
2448        }
2449
2450        for h in handles {
2451            h.await.unwrap();
2452        }
2453
2454        let states = results.lock().unwrap();
2455        assert_eq!(states.len(), 4);
2456        for i in 1..states.len() {
2457            assert!(
2458                Arc::ptr_eq(&states[0].dispatch, &states[i].dispatch),
2459                "all concurrent callers should get the same dispatch table"
2460            );
2461        }
2462    }
2463
2464    #[tokio::test]
2465    async fn body_conversion_helpers_cover_text_and_binary_paths() {
2466        let text_msg = body_to_axum_ws_message(CamelBody::Text("abc".into()), "text")
2467            .await
2468            .unwrap();
2469        assert!(matches!(text_msg, WsMessage::Text(_)));
2470
2471        let bin_msg = body_to_axum_ws_message(CamelBody::Bytes(vec![1, 2, 3].into()), "binary")
2472            .await
2473            .unwrap();
2474        assert!(matches!(bin_msg, WsMessage::Binary(_)));
2475
2476        let client_text =
2477            body_to_client_ws_message(CamelBody::Json(serde_json::json!({"k":"v"})), "text")
2478                .await
2479                .unwrap();
2480        assert!(matches!(client_text, ClientWsMessage::Text(_)));
2481
2482        let client_bin = body_to_client_ws_message(CamelBody::Bytes(vec![7, 8].into()), "binary")
2483            .await
2484            .unwrap();
2485        assert!(matches!(client_bin, ClientWsMessage::Binary(_)));
2486    }
2487
2488    #[tokio::test]
2489    async fn body_to_text_handles_empty_text_json_and_bytes() {
2490        assert_eq!(body_to_text(CamelBody::Empty).await.unwrap(), "");
2491        assert_eq!(
2492            body_to_text(CamelBody::Text("hello".into())).await.unwrap(),
2493            "hello"
2494        );
2495        assert_eq!(
2496            body_to_text(CamelBody::Json(serde_json::json!({"n":1})))
2497                .await
2498                .unwrap(),
2499            "{\"n\":1}"
2500        );
2501        assert_eq!(
2502            body_to_text(CamelBody::Bytes(b"hi".to_vec().into()))
2503                .await
2504                .unwrap(),
2505            "hi"
2506        );
2507    }
2508
2509    #[test]
2510    fn try_send_with_backpressure_returns_false_when_channel_full() {
2511        let (tx, _rx) = mpsc::channel::<WsMessage>(1);
2512        assert!(try_send_with_backpressure(
2513            &tx,
2514            WsMessage::Text("first".into()),
2515            "test"
2516        ));
2517        assert!(!try_send_with_backpressure(
2518            &tx,
2519            WsMessage::Text("second".into()),
2520            "test"
2521        ));
2522    }
2523
2524    // WS-017: send_with_timeout fires when the underlying send future exceeds the budget.
2525    #[tokio::test(start_paused = true)]
2526    async fn send_with_timeout_fires_on_elapsed() {
2527        // Advance the mock clock past the deadline before polling so the pending future
2528        // is observed as already-elapsed on the first poll.
2529        tokio::time::advance(Duration::from_millis(200)).await;
2530        let result = send_with_timeout(
2531            std::future::pending::<Result<(), tungstenite::Error>>(),
2532            Duration::from_millis(100),
2533        )
2534        .await;
2535        let err = result.expect_err("send_with_timeout must return Err on elapsed");
2536        assert!(
2537            err.to_string().contains("timeout"),
2538            "expected timeout error, got: {err}"
2539        );
2540    }
2541
2542    // WS-017: send_with_timeout returns Ok when the underlying future completes within the budget.
2543    #[tokio::test]
2544    async fn send_with_timeout_succeeds_when_fast() {
2545        let result = send_with_timeout(
2546            async { Ok::<(), tungstenite::Error>(()) },
2547            Duration::from_secs(30),
2548        )
2549        .await;
2550        assert!(result.is_ok(), "expected Ok, got: {result:?}");
2551    }
2552
2553    #[test]
2554    fn map_connect_error_formats_connection_refused_and_generic_errors() {
2555        let refused = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
2556        let err = map_connect_error(tungstenite::Error::Io(refused), "ws://localhost:1/x");
2557        assert!(err.to_string().contains("WebSocket connection refused"));
2558
2559        let generic = map_connect_error(
2560            tungstenite::Error::Protocol(
2561                tokio_tungstenite::tungstenite::error::ProtocolError::ResetWithoutClosingHandshake,
2562            ),
2563            "ws://localhost:2/y",
2564        );
2565        assert!(
2566            generic
2567                .to_string()
2568                .contains("WebSocket connection failed (ws://localhost:2/y)")
2569        );
2570    }
2571
2572    // === Phase B Finding Tests ===
2573
2574    // WS-015: maxConnections=0 must be rejected
2575    #[test]
2576    fn from_uri_rejects_max_connections_zero() {
2577        let result = WsEndpointConfig::from_uri("ws://localhost:9200/test?maxConnections=0");
2578        assert!(result.is_err());
2579        let msg = result.unwrap_err().to_string();
2580        assert!(
2581            msg.contains("maxConnections must be >= 1"),
2582            "expected maxConnections validation error, got: {msg}"
2583        );
2584    }
2585
2586    // WS-019: maxMessageSize=0 must be rejected
2587    #[test]
2588    fn from_uri_rejects_max_message_size_zero() {
2589        let result = WsEndpointConfig::from_uri("ws://localhost:9201/test?maxMessageSize=0");
2590        assert!(result.is_err());
2591        let msg = result.unwrap_err().to_string();
2592        assert!(
2593            msg.contains("maxMessageSize must be > 0"),
2594            "expected maxMessageSize validation error, got: {msg}"
2595        );
2596    }
2597
2598    // WS-020: allowOrigin="" must be rejected
2599    #[test]
2600    fn from_uri_rejects_empty_allow_origin() {
2601        let result = WsEndpointConfig::from_uri("ws://localhost:9202/test?allowOrigin=");
2602        assert!(result.is_err());
2603        let msg = result.unwrap_err().to_string();
2604        assert!(
2605            msg.contains("allowOrigin must not be empty"),
2606            "expected allowOrigin validation error, got: {msg}"
2607        );
2608    }
2609
2610    // WS-006: Double-start must be rejected
2611    #[tokio::test]
2612    async fn consumer_double_start_returns_error() {
2613        let _guard = REGISTRY_TEST_LOCK.lock().await;
2614        let port = free_port();
2615        let uri = format!("ws://127.0.0.1:{port}/doublestart");
2616        let component_ctx = NoOpComponentContext;
2617        let endpoint = WsComponent::new()
2618            .create_endpoint(&uri, &component_ctx)
2619            .unwrap();
2620
2621        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2622        let (route_tx, _route_rx) = mpsc::channel(16);
2623        let ctx = ConsumerContext::new(
2624            route_tx,
2625            CancellationToken::new(),
2626            "ws-test-route".to_string(),
2627        );
2628
2629        // First start should succeed
2630        consumer.start(ctx).await.unwrap();
2631
2632        // Second start should fail
2633        let (route_tx2, _route_rx2) = mpsc::channel(16);
2634        let ctx2 = ConsumerContext::new(
2635            route_tx2,
2636            CancellationToken::new(),
2637            "ws-test-route-2".to_string(),
2638        );
2639        let result = consumer.start(ctx2).await;
2640        assert!(result.is_err());
2641        let msg = result.unwrap_err().to_string();
2642        assert!(
2643            msg.contains("already started"),
2644            "expected double-start error, got: {msg}"
2645        );
2646
2647        consumer.stop().await.unwrap();
2648    }
2649
2650    // WS-005: Registry cleanup on stop + port reuse
2651    #[tokio::test]
2652    async fn registry_cleanup_on_consumer_stop() {
2653        let _guard = REGISTRY_TEST_LOCK.lock().await;
2654        let port = free_port();
2655        let uri = format!("ws://127.0.0.1:{port}/cleanup");
2656        let component_ctx = NoOpComponentContext;
2657        let endpoint = WsComponent::new()
2658            .create_endpoint(&uri, &component_ctx)
2659            .unwrap();
2660
2661        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2662        let (route_tx, _route_rx) = mpsc::channel(16);
2663        let ctx = ConsumerContext::new(
2664            route_tx,
2665            CancellationToken::new(),
2666            "ws-test-route".to_string(),
2667        );
2668        consumer.start(ctx).await.unwrap();
2669
2670        // Verify registry entry exists
2671        let registries = global_registries();
2672        let key = ("127.0.0.1".to_string(), port, "/cleanup".to_string());
2673        assert!(
2674            registries.contains_key(&key),
2675            "registry should have entry after start"
2676        );
2677
2678        // Stop consumer
2679        consumer.stop().await.unwrap();
2680
2681        // Verify registry entry is removed
2682        assert!(
2683            !registries.contains_key(&key),
2684            "registry should be cleaned up after stop"
2685        );
2686
2687        // Server is process-lifetime: release() is a no-op, so the
2688        // ServerRegistry entry stays. The port cannot be re-bound until
2689        // ServerRegistry::reset() is called.
2690        let server_reg = ServerRegistry::global();
2691        let guard = server_reg.inner.lock().unwrap();
2692        assert!(
2693            guard.contains_key(&port),
2694            "ServerRegistry must keep port entry after consumer stop (process-lifetime server)"
2695        );
2696    }
2697
2698    // WS-003 + WS-004: poll_ready backpressure and server-send error handling
2699    #[tokio::test]
2700    async fn producer_server_send_returns_error_when_all_dropped() {
2701        let _guard = REGISTRY_TEST_LOCK.lock().await;
2702        let port = free_port();
2703        let uri = format!("ws://127.0.0.1:{port}/backpressure");
2704        let component_ctx = NoOpComponentContext;
2705        let endpoint = WsComponent::new()
2706            .create_endpoint(&uri, &component_ctx)
2707            .unwrap();
2708
2709        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2710        let producer = endpoint
2711            .create_producer(rt(), &ProducerContext::default())
2712            .unwrap();
2713
2714        let (route_tx, _route_rx) = mpsc::channel(1); // Tiny channel to force backpressure
2715        let ctx = ConsumerContext::new(
2716            route_tx,
2717            CancellationToken::new(),
2718            "ws-test-route".to_string(),
2719        );
2720        consumer.start(ctx).await.unwrap();
2721
2722        // Connect a client so the registry has an entry
2723        let url = format!("ws://127.0.0.1:{port}/backpressure");
2724        let (mut client, _) = loop {
2725            match connect_async(&url).await {
2726                Ok(ok) => break ok,
2727                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2728            }
2729        };
2730
2731        // Don't consume messages — let the channel fill up
2732        tokio::time::sleep(Duration::from_millis(50)).await;
2733
2734        // Flood the channel to trigger backpressure
2735        let mut all_dropped = false;
2736        for _ in 0..100 {
2737            let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("flood".into())));
2738            match producer.clone().oneshot(exchange).await {
2739                Ok(_) => {}
2740                Err(e) => {
2741                    if e.to_string().contains("backpressure") {
2742                        all_dropped = true;
2743                        break;
2744                    }
2745                }
2746            }
2747        }
2748
2749        // The producer should eventually return a backpressure error
2750        assert!(
2751            all_dropped,
2752            "producer should return error when all messages are dropped due to backpressure"
2753        );
2754
2755        // Clean up
2756        let _ = client.close(None).await;
2757        consumer.stop().await.unwrap();
2758    }
2759
2760    // WS-012: Ping/pong round-trip in server mode
2761    #[tokio::test]
2762    async fn server_responds_to_client_ping_with_pong() {
2763        let _guard = REGISTRY_TEST_LOCK.lock().await;
2764        let port = free_port();
2765        let uri = format!("ws://127.0.0.1:{port}/pingpong");
2766        let component_ctx = NoOpComponentContext;
2767        let endpoint = WsComponent::new()
2768            .create_endpoint(&uri, &component_ctx)
2769            .unwrap();
2770
2771        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2772        let (route_tx, _route_rx) = mpsc::channel(16);
2773        let ctx = ConsumerContext::new(
2774            route_tx,
2775            CancellationToken::new(),
2776            "ws-test-route".to_string(),
2777        );
2778        consumer.start(ctx).await.unwrap();
2779
2780        let url = format!("ws://127.0.0.1:{port}/pingpong");
2781        let (mut client, _) = loop {
2782            match connect_async(&url).await {
2783                Ok(ok) => break ok,
2784                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
2785            }
2786        };
2787
2788        // Send a ping
2789        client
2790            .send(ClientMessage::Ping(vec![1, 2, 3].into()))
2791            .await
2792            .unwrap();
2793
2794        // Expect a pong with the same payload
2795        let pong = tokio::time::timeout(Duration::from_secs(2), async {
2796            loop {
2797                match client.next().await {
2798                    Some(Ok(ClientMessage::Pong(data))) => break data,
2799                    Some(Ok(ClientMessage::Ping(_))) => continue,
2800                    Some(Ok(_)) => continue,
2801                    Some(Err(e)) => panic!("ws receive failed: {e}"),
2802                    None => panic!("websocket closed before pong"),
2803                }
2804            }
2805        })
2806        .await
2807        .unwrap();
2808
2809        assert_eq!(pong, vec![1, 2, 3], "pong should echo ping payload");
2810
2811        consumer.stop().await.unwrap();
2812    }
2813
2814    // WS-008: Client-side retry on transient connect failures
2815    #[tokio::test]
2816    async fn producer_retries_on_connection_refused() {
2817        // Use a port that nothing is listening on
2818        let port = free_port();
2819        // Ensure nothing is on this port
2820        let cfg = WsEndpointConfig::from_uri(&format!(
2821            "ws://127.0.0.1:{port}/retry?reconnect=true&reconnectMaxAttempts=2&reconnectDelayMs=50"
2822        ))
2823        .unwrap();
2824        let producer = WsProducer::new(cfg.client_config());
2825
2826        let exchange = Exchange::new(CamelMessage::new(CamelBody::Text("hello".into())));
2827
2828        // Should fail after retries (nothing listening)
2829        let result = tokio::time::timeout(Duration::from_secs(5), producer.oneshot(exchange)).await;
2830        assert!(
2831            result.is_ok(),
2832            "producer should complete (with error) within timeout"
2833        );
2834        let result = result.unwrap();
2835        assert!(
2836            result.is_err(),
2837            "producer should fail when nothing is listening"
2838        );
2839        let msg = result.unwrap_err().to_string();
2840        assert!(
2841            msg.contains("connection refused"),
2842            "expected connection refused error, got: {msg}"
2843        );
2844    }
2845
2846    // WS-001: Server bind error is visible (fake server-start error test)
2847    #[tokio::test]
2848    async fn server_bind_error_is_reported() {
2849        let _guard = REGISTRY_TEST_LOCK.lock().await;
2850        // Bind a port manually to cause a conflict
2851        let _listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2852        let port = _listener.local_addr().unwrap().port();
2853
2854        // Try to start a consumer on the same port — should succeed since axum binds lazily
2855        // The actual bind error happens when the server task runs
2856        let uri = format!("ws://127.0.0.1:{port}/binderror");
2857        let component_ctx = NoOpComponentContext;
2858        let endpoint = WsComponent::new()
2859            .create_endpoint(&uri, &component_ctx)
2860            .unwrap();
2861
2862        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2863        let (route_tx, _route_rx) = mpsc::channel(16);
2864        let ctx = ConsumerContext::new(
2865            route_tx,
2866            CancellationToken::new(),
2867            "ws-test-route".to_string(),
2868        );
2869
2870        // Start should succeed (server spawns, but bind may fail)
2871        let start_result = consumer.start(ctx).await;
2872        // The server may or may not have bound yet — this test verifies no panic
2873        // The actual error is logged by the server task
2874        let _ = start_result;
2875
2876        consumer.stop().await.unwrap();
2877    }
2878
2879    #[test]
2880    fn ws_app_state_server_error_starts_false() {
2881        let state = WsAppState {
2882            dispatch: Arc::new(RwLock::new(HashMap::new())),
2883            path_configs: Arc::new(DashMap::new()),
2884            path_policies: Arc::new(DashMap::new()),
2885            server_error: new_atomic_false(),
2886            runtime: test_rt(),
2887            route_id: "test-route".into(),
2888        };
2889        assert!(
2890            !state.server_error.load(Ordering::Relaxed),
2891            "server_error should start as false"
2892        );
2893    }
2894
2895    #[test]
2896    fn ws_app_state_server_error_can_be_set() {
2897        let state = WsAppState {
2898            dispatch: Arc::new(RwLock::new(HashMap::new())),
2899            path_configs: Arc::new(DashMap::new()),
2900            path_policies: Arc::new(DashMap::new()),
2901            server_error: new_atomic_false(),
2902            runtime: test_rt(),
2903            route_id: "test-route".into(),
2904        };
2905        assert!(!state.server_error.load(Ordering::Relaxed));
2906        state.server_error.store(true, Ordering::Relaxed);
2907        assert!(state.server_error.load(Ordering::Relaxed));
2908    }
2909
2910    #[tokio::test]
2911    async fn consumer_stop_returns_error_when_server_had_errors() {
2912        let _guard = REGISTRY_TEST_LOCK.lock().await;
2913        let port = free_port();
2914        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/errorflag")).unwrap();
2915        let mut consumer = WsConsumer::new(cfg.server_config(), test_rt());
2916        let (route_tx, _route_rx) = mpsc::channel(16);
2917        let ctx = ConsumerContext::new(
2918            route_tx,
2919            CancellationToken::new(),
2920            "ws-test-route".to_string(),
2921        );
2922        consumer.start(ctx).await.unwrap();
2923
2924        // Simulate server error by setting the flag directly
2925        if let Some(ref state) = consumer.server_state {
2926            state.server_error.store(true, Ordering::Relaxed);
2927        }
2928
2929        let result = consumer.stop().await;
2930        assert!(
2931            result.is_err(),
2932            "stop should return error when server had errors"
2933        );
2934        let msg = result.unwrap_err().to_string();
2935        assert!(
2936            msg.contains("terminated with errors"),
2937            "expected server error message, got: {msg}"
2938        );
2939    }
2940
2941    #[tokio::test]
2942    async fn consumer_stop_succeeds_when_server_healthy() {
2943        let _guard = REGISTRY_TEST_LOCK.lock().await;
2944        let port = free_port();
2945        let cfg = WsEndpointConfig::from_uri(&format!("ws://127.0.0.1:{port}/healthy")).unwrap();
2946        let mut consumer = WsConsumer::new(cfg.server_config(), test_rt());
2947        let (route_tx, _route_rx) = mpsc::channel(16);
2948        let ctx = ConsumerContext::new(
2949            route_tx,
2950            CancellationToken::new(),
2951            "ws-test-route".to_string(),
2952        );
2953        consumer.start(ctx).await.unwrap();
2954
2955        let result = consumer.stop().await;
2956        assert!(
2957            result.is_ok(),
2958            "stop should succeed when server is healthy: {:?}",
2959            result
2960        );
2961    }
2962
2963    // === H-10 Finding Tests ===
2964
2965    // WS-007: subprotocol negotiation support
2966    #[test]
2967    fn endpoint_config_parses_subprotocols() {
2968        let cfg = WsEndpointConfig::from_uri(
2969            "ws://localhost:9001/chat?subprotocols=graphql-ws,graphql-transport-ws",
2970        )
2971        .unwrap();
2972        assert_eq!(cfg.subprotocols, vec!["graphql-ws", "graphql-transport-ws"]);
2973    }
2974
2975    #[test]
2976    fn endpoint_config_default_subprotocols_empty() {
2977        let cfg = WsEndpointConfig::default();
2978        assert!(cfg.subprotocols.is_empty());
2979    }
2980
2981    // WS-017: sendTimeoutMs URI option
2982    #[test]
2983    fn endpoint_config_parses_send_timeout() {
2984        let cfg =
2985            WsEndpointConfig::from_uri("ws://localhost:9001/chat?sendTimeoutMs=5000").unwrap();
2986        assert_eq!(cfg.send_timeout, Duration::from_millis(5000));
2987    }
2988
2989    #[test]
2990    fn endpoint_config_default_send_timeout() {
2991        let cfg = WsEndpointConfig::default();
2992        assert_eq!(cfg.send_timeout, Duration::from_secs(30));
2993    }
2994
2995    #[test]
2996    fn endpoint_config_rejects_invalid_send_timeout() {
2997        let err =
2998            WsEndpointConfig::from_uri("ws://localhost:9001/chat?sendTimeoutMs=abc").unwrap_err();
2999        assert!(err.to_string().contains("sendTimeoutMs"));
3000    }
3001
3002    // WS-018: binaryPayload URI option
3003    #[test]
3004    fn endpoint_config_parses_binary_payload() {
3005        let cfg =
3006            WsEndpointConfig::from_uri("ws://localhost:9001/chat?binaryPayload=true").unwrap();
3007        assert!(cfg.binary_payload);
3008    }
3009
3010    #[test]
3011    fn endpoint_config_default_binary_payload_false() {
3012        let cfg = WsEndpointConfig::default();
3013        assert!(!cfg.binary_payload);
3014    }
3015
3016    #[test]
3017    fn endpoint_config_rejects_invalid_binary_payload() {
3018        let err =
3019            WsEndpointConfig::from_uri("ws://localhost:9001/chat?binaryPayload=yes").unwrap_err();
3020        assert!(err.to_string().contains("binaryPayload"));
3021    }
3022
3023    /// Regression: max_attempts=N → exactly N invocations (caught OpenSearch off-by-one 1f5c4c2a).
3024    /// Replicates the exact retry loop from the WebSocket producer connect (lib.rs:~1228-1275):
3025    ///   attempts starts at 0, should_retry(attempts+1), delay_for(attempts), attempts += 1
3026    #[tokio::test]
3027    async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
3028        use camel_component_api::NetworkRetryPolicy;
3029        use std::sync::Arc;
3030        use std::sync::atomic::{AtomicU32, Ordering};
3031
3032        let policy = NetworkRetryPolicy {
3033            max_attempts: 3,
3034            initial_delay: Duration::from_millis(1),
3035            max_delay: Duration::from_millis(1),
3036            multiplier: 1.0,
3037            ..NetworkRetryPolicy::default()
3038        };
3039
3040        let calls = Arc::new(AtomicU32::new(0));
3041        let calls_clone = Arc::clone(&calls);
3042        let mut attempts: u32 = 0;
3043
3044        let _result: Result<(), ()> = loop {
3045            calls_clone.fetch_add(1, Ordering::SeqCst);
3046            let op_result: Result<(), ()> = Err(());
3047            match op_result {
3048                Ok(_) => unreachable!(),
3049                Err(_) if policy.should_retry(attempts + 1) => {
3050                    let delay = policy.delay_for(attempts);
3051                    tokio::time::sleep(delay).await;
3052                    attempts += 1;
3053                    continue;
3054                }
3055                Err(_) => break Err(()),
3056            }
3057        };
3058
3059        assert_eq!(
3060            calls.load(Ordering::SeqCst),
3061            3,
3062            "max_attempts=3 must yield exactly 3 invocations"
3063        );
3064    }
3065
3066    /// Edge case: max_attempts=1 → exactly 1 invocation (initial attempt only, no retry).
3067    /// Locks the edge that originally broke OpenSearch.
3068    #[tokio::test]
3069    async fn retry_loop_with_max_attempts_1_invokes_operation_once() {
3070        use camel_component_api::NetworkRetryPolicy;
3071        use std::sync::Arc;
3072        use std::sync::atomic::{AtomicU32, Ordering};
3073
3074        let policy = NetworkRetryPolicy {
3075            max_attempts: 1,
3076            initial_delay: Duration::from_millis(1),
3077            max_delay: Duration::from_millis(1),
3078            multiplier: 1.0,
3079            ..NetworkRetryPolicy::default()
3080        };
3081
3082        let calls = Arc::new(AtomicU32::new(0));
3083        let calls_clone = Arc::clone(&calls);
3084        let mut attempts: u32 = 0;
3085
3086        let _result: Result<(), ()> = loop {
3087            calls_clone.fetch_add(1, Ordering::SeqCst);
3088            let op_result: Result<(), ()> = Err(());
3089            match op_result {
3090                Ok(_) => unreachable!(),
3091                Err(_) if policy.should_retry(attempts + 1) => {
3092                    let delay = policy.delay_for(attempts);
3093                    tokio::time::sleep(delay).await;
3094                    attempts += 1;
3095                    continue;
3096                }
3097                Err(_) => break Err(()),
3098            }
3099        };
3100
3101        assert_eq!(
3102            calls.load(Ordering::SeqCst),
3103            1,
3104            "max_attempts=1 must yield exactly 1 invocation"
3105        );
3106    }
3107
3108    // ── rc-1nm regression: WS producer retry emits component=ws-producer ──
3109
3110    use std::fmt::Write as _;
3111    use std::sync::{Arc, Mutex};
3112    use tracing::Subscriber;
3113    use tracing_subscriber::Layer;
3114    use tracing_subscriber::layer::SubscriberExt;
3115
3116    struct CollectingLayer {
3117        events: Arc<Mutex<Vec<String>>>,
3118    }
3119
3120    impl<S: Subscriber> Layer<S> for CollectingLayer {
3121        fn on_event(
3122            &self,
3123            event: &tracing::Event<'_>,
3124            _ctx: tracing_subscriber::layer::Context<'_, S>,
3125        ) {
3126            let mut buf = String::new();
3127            let mut visitor = CollectingVisitor { fields: &mut buf };
3128            event.record(&mut visitor);
3129            if let Ok(mut events) = self.events.lock() {
3130                events.push(buf);
3131            }
3132        }
3133    }
3134
3135    struct CollectingVisitor<'a> {
3136        fields: &'a mut String,
3137    }
3138
3139    impl CollectingVisitor<'_> {
3140        fn record_field(&mut self, name: &str, value: &str) {
3141            write!(self.fields, " {name}={value}").ok();
3142        }
3143    }
3144
3145    impl tracing::field::Visit for CollectingVisitor<'_> {
3146        fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
3147            self.record_field(field.name(), value);
3148        }
3149        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
3150            self.record_field(field.name(), &format!("{value:?}"));
3151        }
3152        fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
3153            self.record_field(field.name(), &value.to_string());
3154        }
3155    }
3156
3157    /// Regression for rc-1nm: the WS producer retry path must emit
3158    /// `scheme=ws` / `operation=connect` in retry log events so operators
3159    /// can identify which component is retrying.
3160    ///
3161    /// Drives `retry_async` directly with `("ws", "connect")` and a
3162    /// deterministic retryable error. An earlier version exercised the
3163    /// production `connect_ws_with_retry` helper against `ws://127.0.0.1:1`,
3164    /// but that was flaky under heavy workspace load: the thread-local
3165    /// tracing subscriber (`set_default`) very occasionally missed the
3166    /// event logged from within the async connect path (the warn! is
3167    /// always emitted — `map_connect_error` always yields a retryable
3168    /// string for `ws://` — so the miss was purely a capture race).
3169    /// Driving `retry_async` synchronously with a synthetic op removes the
3170    /// network I/O and reactor scheduling, so the warn! is always emitted
3171    /// and captured on the test thread.
3172    #[tokio::test]
3173    async fn ws_producer_retry_log_emits_component_ws_producer() {
3174        let events = Arc::new(Mutex::new(Vec::new()));
3175        let layer = CollectingLayer {
3176            events: events.clone(),
3177        };
3178        let subscriber = tracing_subscriber::registry().with(layer);
3179        let _guard = tracing::subscriber::set_default(subscriber);
3180
3181        let policy = NetworkRetryPolicy {
3182            max_attempts: 2,
3183            initial_delay: Duration::from_millis(1),
3184            max_delay: Duration::from_millis(5),
3185            ..NetworkRetryPolicy::default()
3186        };
3187
3188        // Deterministic retryable failure (string recognised by
3189        // is_retryable_ws_error) — no network I/O, so the retry warn! is
3190        // emitted and captured synchronously on this thread.
3191        let result: Result<(), CamelError> = retry_async(
3192            &policy,
3193            "ws",
3194            "connect",
3195            || async {
3196                Err(CamelError::ProcessorError(
3197                    "WebSocket connection refused: simulated".to_string(),
3198                ))
3199            },
3200            is_retryable_ws_error,
3201            None,
3202        )
3203        .await;
3204
3205        assert!(result.is_err(), "expected exhausted-retries error");
3206        let captured = events.lock().unwrap();
3207        assert!(
3208            !captured.is_empty(),
3209            "expected at least one retry log event, got none"
3210        );
3211        let first = &captured[0];
3212        assert!(
3213            first.contains("scheme=ws"),
3214            "rc-1nm regression: expected 'scheme=ws' in WS retry log, got: {first}"
3215        );
3216        assert!(
3217            first.contains("operation=connect"),
3218            "rc-1nm regression: expected 'operation=connect' in WS retry log, got: {first}"
3219        );
3220    }
3221
3222    // ── TLS cert hot-reload: release/unregister integration tests ─────────
3223    //
3224    // These verify the WSS path: `get_or_spawn` registers a `WsReloadHandler`
3225    // in `TlsReloadRegistry::global()`; `release` unregisters it when the
3226    // last reference drops. The host-agnostic `matches` impl keys on
3227    // (scheme="wss", port) — see `WsReloadHandler::matches`.
3228
3229    #[tokio::test]
3230    async fn wss_release_unregisters_tls_reload_handler() {
3231        use camel_component_api::test_support::tls;
3232        use camel_component_api::tls_source::TlsReloadRegistry;
3233
3234        let _guard = REGISTRY_TEST_LOCK.lock().await;
3235        let _ = rustls::crypto::ring::default_provider().install_default();
3236
3237        let (cert_pem, key_pem) = {
3238            let (_ca, c, k) = tls::gen_server_cert();
3239            (c, k)
3240        };
3241        let cert_path = tls::write_pem_tmp("ws-release-cert.pem", &cert_pem);
3242        let key_path = tls::write_pem_tmp("ws-release-key.pem", &key_pem);
3243
3244        let port = free_port();
3245        let tls_cfg = WsTlsConfig {
3246            cert_path: cert_path.to_str().expect("cert path").to_string(),
3247            key_path: key_path.to_str().expect("key path").to_string(),
3248        };
3249
3250        // Spawn a single WSS server.
3251        let (_state, _) = ServerRegistry::global()
3252            .get_or_spawn(
3253                "127.0.0.1",
3254                port,
3255                Some(tls_cfg),
3256                test_rt(),
3257                "ws-release-test".into(),
3258            )
3259            .await
3260            .expect("WSS server should spawn");
3261
3262        // Handler is registered (host-agnostic — match passes empty host).
3263        let handler = TlsReloadRegistry::global().find("wss", "", port);
3264        assert!(
3265            handler.is_some(),
3266            "WSS server must register a reload handler for wss://*:{port}"
3267        );
3268        // Exercise it to verify the registered handler is functional.
3269        handler
3270            .unwrap()
3271            .reload()
3272            .await
3273            .expect("registered WSS handler reload() must succeed");
3274
3275        // Release the (only) reference. release() is a no-op
3276        // (process-lifetime server), so the handler STAYS registered.
3277        ServerRegistry::global().release(port);
3278
3279        assert!(
3280            TlsReloadRegistry::global().find("wss", "", port).is_some(),
3281            "WSS server release is a no-op; reload handler must remain registered"
3282        );
3283    }
3284
3285    #[tokio::test]
3286    async fn wss_multiple_refs_release_does_not_unregister() {
3287        use camel_component_api::test_support::tls;
3288        use camel_component_api::tls_source::TlsReloadRegistry;
3289
3290        let _guard = REGISTRY_TEST_LOCK.lock().await;
3291        let _ = rustls::crypto::ring::default_provider().install_default();
3292
3293        let (cert_pem, key_pem) = {
3294            let (_ca, c, k) = tls::gen_server_cert();
3295            (c, k)
3296        };
3297        let cert_path = tls::write_pem_tmp("ws-multiref-cert.pem", &cert_pem);
3298        let key_path = tls::write_pem_tmp("ws-multiref-key.pem", &key_pem);
3299
3300        let port = free_port();
3301        let tls_cfg = WsTlsConfig {
3302            cert_path: cert_path.to_str().expect("cert path").to_string(),
3303            key_path: key_path.to_str().expect("key path").to_string(),
3304        };
3305
3306        // Acquire TWO references to the same port.
3307        let (_s1, _) = ServerRegistry::global()
3308            .get_or_spawn(
3309                "127.0.0.1",
3310                port,
3311                Some(tls_cfg.clone()),
3312                test_rt(),
3313                "ws-multiref-r1".into(),
3314            )
3315            .await
3316            .expect("WSS server should spawn (ref 1)");
3317        let (_s2, _) = ServerRegistry::global()
3318            .get_or_spawn(
3319                "127.0.0.1",
3320                port,
3321                Some(tls_cfg),
3322                test_rt(),
3323                "ws-multiref-r2".into(),
3324            )
3325            .await
3326            .expect("WSS server should spawn (ref 2)");
3327
3328        // Handler is registered.
3329        assert!(
3330            TlsReloadRegistry::global().find("wss", "", port).is_some(),
3331            "WSS server with refs must have a registered reload handler"
3332        );
3333
3334        // Release the FIRST reference — ref count is still 1, handler must remain.
3335        ServerRegistry::global().release(port);
3336        assert!(
3337            TlsReloadRegistry::global().find("wss", "", port).is_some(),
3338            "handler must remain registered while ref count > 0"
3339        );
3340
3341        // Release the LAST reference. release() is a no-op regardless of
3342        // ref count, so the handler STAYS registered.
3343        ServerRegistry::global().release(port);
3344        assert!(
3345            TlsReloadRegistry::global().find("wss", "", port).is_some(),
3346            "handler must remain registered — release() is a no-op (process-lifetime server)"
3347        );
3348    }
3349
3350    #[tokio::test]
3351    async fn ws_plaintext_does_not_register_tls_reload_handler() {
3352        use camel_component_api::tls_source::TlsReloadRegistry;
3353
3354        let _guard = REGISTRY_TEST_LOCK.lock().await;
3355        // Ensure clean global state (process-lifetime servers may leak across tests).
3356        ServerRegistry::reset();
3357
3358        let port = free_port();
3359        let (_state, _) = ServerRegistry::global()
3360            .get_or_spawn(
3361                "127.0.0.1",
3362                port,
3363                None,
3364                test_rt(),
3365                "ws-plaintext-no-reload-test".into(),
3366            )
3367            .await
3368            .expect("plaintext WS server should spawn");
3369
3370        // No handler for either wss or ws — plaintext has nothing to reload.
3371        assert!(
3372            TlsReloadRegistry::global().find("wss", "", port).is_none(),
3373            "plaintext WS server must not register a wss handler"
3374        );
3375
3376        // Cleanup.
3377        ServerRegistry::global().release(port);
3378    }
3379
3380    // WSS readiness: a failed TLS listener bind must NOT signal readiness.
3381    #[tokio::test]
3382    async fn test_wss_bind_failure_does_not_mark_ready() {
3383        use camel_component_api::StartupSignal;
3384        use camel_component_api::test_support::{NoopRuntimeObservability, tls};
3385
3386        let _guard = REGISTRY_TEST_LOCK.lock().await;
3387        let _ = rustls::crypto::ring::default_provider().install_default();
3388        // Clean global state (process-lifetime servers may leak across tests).
3389        ServerRegistry::reset();
3390
3391        // Generate valid TLS material so we get past cert loading and reach
3392        // the actual listener bind.
3393        let (cert_pem, key_pem) = {
3394            let (_ca, c, k) = tls::gen_server_cert();
3395            (c, k)
3396        };
3397        let cert_path = tls::write_pem_tmp("ws-bindfail-cert.pem", &cert_pem);
3398        let key_path = tls::write_pem_tmp("ws-bindfail-key.pem", &key_pem);
3399        let cert_str = cert_path.to_str().expect("cert path");
3400        let key_str = key_path.to_str().expect("key path");
3401
3402        // Pre-bind the port so the WSS listener bind fails with EADDRINUSE.
3403        let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3404        let port = blocker.local_addr().unwrap().port();
3405
3406        let uri = format!("wss://127.0.0.1:{port}/secure?tlsCert={cert_str}&tlsKey={key_str}");
3407        let component_ctx = NoOpComponentContext;
3408        let endpoint = WssComponent::new()
3409            .create_endpoint(&uri, &component_ctx)
3410            .unwrap();
3411        // NoopRuntimeObservability: the bind-failure path calls
3412        // `health().force_unhealthy_for_route`, which must not panic.
3413        let rt: std::sync::Arc<dyn camel_component_api::RuntimeObservability> =
3414            std::sync::Arc::new(NoopRuntimeObservability);
3415        let mut consumer = endpoint.create_consumer(rt).unwrap();
3416
3417        // Install our own startup pair so we can observe whether mark_ready
3418        // was called.
3419        let (signal, receiver) = StartupSignal::pair();
3420        let (route_tx, _route_rx) = mpsc::channel(16);
3421        let ctx = ConsumerContext::new(
3422            route_tx,
3423            CancellationToken::new(),
3424            "ws-bindfail-route".to_string(),
3425        )
3426        .with_startup(signal);
3427
3428        let result = consumer.start(ctx).await;
3429        assert!(
3430            result.is_err(),
3431            "start() must return Err when the WSS listener bind fails: {result:?}"
3432        );
3433
3434        // ctx was dropped when start() returned, so the startup signal sender
3435        // is gone. await_ready resolves immediately: Err means the consumer
3436        // never signalled readiness (good); Ok would mean mark_ready was
3437        // called before the bind failure surfaced (bug).
3438        let ready_result: Result<(), _> = receiver.await_ready().await;
3439        assert!(
3440            ready_result.is_err(),
3441            "mark_ready() must not be called when the WSS listener bind fails"
3442        );
3443
3444        drop(blocker);
3445        let _ = consumer.stop().await;
3446    }
3447
3448    #[test]
3449    fn ws_upgrade_error_provider_unavailable_is_503() {
3450        let err =
3451            CamelError::AuthProviderUnavailable("totally arbitrary detail with no marker".into());
3452        let resp = ws_upgrade_auth_error(&err).into_response();
3453        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
3454    }
3455
3456    #[test]
3457    fn ws_upgrade_error_generic_processor_error_is_500() {
3458        let err = CamelError::ProcessorError("auth provider unavailable".into());
3459        let resp = ws_upgrade_auth_error(&err).into_response();
3460        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
3461    }
3462
3463    #[test]
3464    fn ws_upgrade_error_unauthenticated_is_401() {
3465        let err = CamelError::Unauthenticated("bad".into());
3466        let resp = ws_upgrade_auth_error(&err).into_response();
3467        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
3468    }
3469
3470    // -------------------------------------------------------------------
3471    // Recording metrics collector for testing increment_errors calls
3472    // (pattern: camel-direct tests::RecordingMetrics)
3473    // -------------------------------------------------------------------
3474
3475    struct RecordingMetrics {
3476        errors: Arc<Mutex<Vec<(String, String)>>>,
3477    }
3478
3479    impl camel_api::MetricsCollector for RecordingMetrics {
3480        fn record_exchange_duration(&self, _: &str, _: Duration) {}
3481        fn increment_errors(&self, route_id: &str, error_type: &str) {
3482            self.errors
3483                .lock()
3484                .expect("recording collector lock")
3485                .push((route_id.to_string(), error_type.to_string()));
3486        }
3487        fn increment_exchanges(&self, _: &str) {}
3488        fn set_queue_depth(&self, _: &str, _: usize) {}
3489        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
3490    }
3491
3492    struct RecordingRuntime {
3493        metrics_collector: Arc<RecordingMetrics>,
3494    }
3495
3496    impl RecordingRuntime {
3497        fn new(errors: Arc<Mutex<Vec<(String, String)>>>) -> Self {
3498            Self {
3499                metrics_collector: Arc::new(RecordingMetrics { errors }),
3500            }
3501        }
3502    }
3503
3504    impl camel_component_api::RuntimeObservability for RecordingRuntime {
3505        fn metrics(&self) -> Arc<dyn camel_api::MetricsCollector> {
3506            Arc::clone(&self.metrics_collector) as Arc<dyn camel_api::MetricsCollector>
3507        }
3508        fn health(&self) -> Arc<dyn camel_component_api::HealthCheckRegistry> {
3509            panic!("RecordingRuntime::health not used in this test")
3510        }
3511    }
3512
3513    #[tokio::test]
3514    async fn ws_message_dispatch_failure_counts_b_prime() {
3515        let _guard = REGISTRY_TEST_LOCK.lock().await;
3516        let errors: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
3517        let port = free_port();
3518        let uri = format!("ws://127.0.0.1:{port}/dispatch");
3519        let endpoint = WsComponent::new()
3520            .create_endpoint(&uri, &NoOpComponentContext)
3521            .unwrap();
3522
3523        // Recording runtime through the same seam the other tests fill with
3524        // the Panic helper (`test_rt()`).
3525        let mut consumer = endpoint
3526            .create_consumer(Arc::new(RecordingRuntime::new(Arc::clone(&errors))))
3527            .unwrap();
3528
3529        // Pipeline receiver dropped up front: every forward-task dispatch
3530        // send fails with `ChannelClosed`.
3531        let (route_tx, route_rx) = mpsc::channel(16);
3532        drop(route_rx);
3533        let ctx = ConsumerContext::new(
3534            route_tx,
3535            CancellationToken::new(),
3536            "ws-test-route".to_string(),
3537        );
3538        consumer.start(ctx).await.unwrap();
3539
3540        let url = format!("ws://127.0.0.1:{port}/dispatch");
3541        let (mut client, _) = loop {
3542            match connect_async(&url).await {
3543                Ok(ok) => break ok,
3544                Err(_) => tokio::time::sleep(Duration::from_millis(25)).await,
3545            }
3546        };
3547        client
3548            .send(ClientMessage::Text("lost dispatch".into()))
3549            .await
3550            .unwrap();
3551
3552        // Forward tick: let the dispatch reach the forward task and fail
3553        // the pipeline send.
3554        tokio::time::timeout(Duration::from_secs(2), async {
3555            loop {
3556                tokio::time::sleep(Duration::from_millis(10)).await;
3557                if !errors.lock().expect("recording collector lock").is_empty() {
3558                    break;
3559                }
3560            }
3561        })
3562        .await
3563        .expect("forward task never recorded the message-dispatch failure");
3564
3565        let recorded = errors.lock().expect("recording collector lock").clone();
3566        assert_eq!(
3567            recorded,
3568            vec![(
3569                "ws-test-route".to_string(),
3570                "b-prime:ws:message-dispatch".to_string()
3571            )]
3572        );
3573
3574        consumer.stop().await.unwrap();
3575    }
3576}