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