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