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