Skip to main content

camel_component_jms/
component.rs

1use std::collections::HashMap;
2use std::future::Future;
3use std::path::PathBuf;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::task::{Context, Poll};
8use std::time::{Duration, Instant};
9
10use camel_bridge::{
11    download::ensure_binary,
12    health::wait_for_health,
13    process::{BridgeProcess, BridgeProcessConfig, BrokerType},
14};
15use camel_component_api::{
16    BoxProcessor, CamelError, Component, Consumer, Endpoint, Exchange, NetworkRetryPolicy,
17    ProducerContext,
18};
19use dashmap::DashMap;
20use tokio::sync::{Mutex, watch};
21use tonic::transport::Channel;
22use tower::Service;
23use tracing::{info, warn};
24
25use crate::config::{BrokerConfig, JmsEndpointConfig, JmsPoolConfig};
26use crate::consumer::JmsConsumer;
27use crate::health::JmsHealthCheck;
28use crate::producer::JmsProducer;
29use crate::proto::{HealthRequest, bridge_service_client::BridgeServiceClient};
30
31// ── Transport error classification ───────────────────────────────────────────
32
33/// Shared constant prefix for all bridge transport errors.
34///
35/// Both error-producing sites (producer.rs, consumer.rs) and the detection
36/// helper (`is_bridge_transport_error`) reference this constant so that a
37/// format-string drift cannot silently break retry logic.
38pub const BRIDGE_TRANSPORT_ERROR_PREFIX: &str = "JMS gRPC ";
39const MAX_RESTART_ATTEMPTS: u32 = 10;
40
41// ── BridgeState ──────────────────────────────────────────────────────────────
42
43#[derive(Debug, Clone)]
44pub enum BridgeState {
45    Starting,
46    Ready { channel: Channel },
47    Degraded(String),
48    Restarting { attempt: u32, next_at: Instant },
49    Stopped,
50}
51
52// ── BridgeSlot ───────────────────────────────────────────────────────────────
53
54pub struct BridgeSlot {
55    pub name: String,
56    pub broker_url: String,
57    pub broker_type: BrokerType,
58    pub credentials: Option<(String, String)>,
59    pub state_rx: watch::Receiver<BridgeState>,
60    pub(crate) state_tx: watch::Sender<BridgeState>,
61    /// BridgeProcess::stop(mut self) takes ownership — Mutex<Option<>> is required.
62    pub process: Arc<tokio::sync::Mutex<Option<BridgeProcess>>>,
63    /// JoinHandle of the health monitor task for this slot.
64    /// Stored so that shutdown can await the monitor and observe panics.
65    pub(crate) health_monitor_handle: Arc<tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>>,
66}
67
68impl std::fmt::Debug for BridgeSlot {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("BridgeSlot")
71            .field("name", &self.name)
72            .field("broker_url", &self.broker_url)
73            .field("broker_type", &self.broker_type)
74            .finish()
75    }
76}
77
78// ── JmsBridgePool ────────────────────────────────────────────────────────────
79
80pub struct JmsBridgePool {
81    pub(crate) slots: DashMap<String, Arc<BridgeSlot>>,
82    pub(crate) config: HashMap<String, BrokerConfig>,
83    pub(crate) bridge_start_timeout_ms: u64,
84    pub(crate) reconnect: NetworkRetryPolicy,
85    pub(crate) health_check_interval_ms: u64,
86    pub(crate) bridge_version: String,
87    pub(crate) bridge_cache_dir: PathBuf,
88    /// Maximum number of concurrently active (Starting/Ready) bridges.
89    pub(crate) max_bridges: usize,
90    /// Serializes bridge admission (check + insert) to prevent race on max_bridges.
91    bridge_create_lock: Mutex<()>,
92    pub(crate) shutting_down: Arc<AtomicBool>,
93}
94
95impl JmsBridgePool {
96    pub fn from_config(pool_config: JmsPoolConfig) -> Result<Self, CamelError> {
97        pool_config.validate()?;
98        // Backward compat: broker_reconnect_interval_ms overrides
99        // reconnect.initial_delay when explicitly set (non-default).
100        let mut reconnect = pool_config.reconnect;
101        if pool_config.broker_reconnect_interval_ms
102            != crate::config::default_broker_reconnect_interval_ms()
103        {
104            reconnect.initial_delay =
105                Duration::from_millis(pool_config.broker_reconnect_interval_ms);
106        }
107        Ok(Self {
108            slots: DashMap::new(),
109            config: pool_config.brokers,
110            bridge_start_timeout_ms: pool_config.bridge_start_timeout_ms,
111            reconnect,
112            health_check_interval_ms: pool_config.health_check_interval_ms,
113            bridge_version: crate::BRIDGE_VERSION.to_string(),
114            bridge_cache_dir: pool_config.bridge_cache_dir,
115            max_bridges: pool_config.max_bridges,
116            bridge_create_lock: Mutex::new(()),
117            shutting_down: Arc::new(AtomicBool::new(false)),
118        })
119    }
120
121    /// Resolve broker name from the URI `broker=` param.
122    ///
123    /// - If `Some(name)` → validate it exists in config and return it.
124    /// - If `None` and exactly one broker is configured → use it implicitly.
125    /// - If `None` and multiple brokers are configured → error asking for `?broker=`.
126    /// - If `None` and no brokers are configured → error asking to declare brokers.
127    pub fn resolve_broker_name(&self, name: Option<&str>) -> Result<String, CamelError> {
128        match name {
129            Some(n) => {
130                if self.config.contains_key(n) {
131                    Ok(n.to_string())
132                } else {
133                    Err(CamelError::ProcessorError(format!(
134                        "Unknown JMS broker '{n}' — declare it in [components.jms.brokers] in Camel.toml",
135                    )))
136                }
137            }
138            None => match self.config.len() {
139                0 => Err(CamelError::ProcessorError(
140                    "No JMS brokers configured — declare at least one in [components.jms.brokers] in Camel.toml".to_string(),
141                )),
142                1 => Ok(self.config.keys().next().unwrap().clone()), // allow-unwrap
143                _ => Err(CamelError::ProcessorError(format!(
144                    "Multiple JMS brokers configured ({}); specify one with ?broker=<name> in the URI",
145                    self.config.keys().cloned().collect::<Vec<_>>().join(", ")
146                ))),
147            },
148        }
149    }
150
151    /// Resolve broker type: activemq/artemis schemes hard-override config type; jms uses config.
152    pub fn resolve_broker_type(&self, scheme: &str, broker_name: &str) -> BrokerType {
153        let config_type = self
154            .config
155            .get(broker_name)
156            .map(|c| c.broker_type.clone())
157            .unwrap_or(BrokerType::Generic);
158
159        match scheme {
160            "activemq" => {
161                if config_type != BrokerType::ActiveMq && config_type != BrokerType::Generic {
162                    warn!(
163                        "Scheme 'activemq' overrides configured broker_type '{:?}' for broker '{}'",
164                        config_type, broker_name
165                    );
166                }
167                BrokerType::ActiveMq
168            }
169            "artemis" => {
170                if config_type != BrokerType::Artemis && config_type != BrokerType::Generic {
171                    warn!(
172                        "Scheme 'artemis' overrides configured broker_type '{:?}' for broker '{}'",
173                        config_type, broker_name
174                    );
175                }
176                BrokerType::Artemis
177            }
178            _ => config_type,
179        }
180    }
181
182    /// Get or create a BridgeSlot for the given broker name.
183    /// If the slot doesn't exist, starts the bridge process and spawns the health monitor.
184    pub async fn get_or_create_slot(
185        &self,
186        broker_name: &str,
187    ) -> Result<Arc<BridgeSlot>, CamelError> {
188        if let Some(slot) = self.slots.get(broker_name) {
189            return Ok(Arc::clone(&*slot));
190        }
191
192        // Serialize admission: check + insert in single critical section to prevent
193        // concurrent creators from both seeing count below limit and exceeding max_bridges.
194        let _guard = self.bridge_create_lock.lock().await;
195
196        // Re-check after acquiring lock (another caller may have inserted while we waited).
197        if let Some(slot) = self.slots.get(broker_name) {
198            return Ok(Arc::clone(&*slot));
199        }
200
201        // Enforce max_bridges: count ALL slots in the map under the admission lock.
202        // We count total slots (not just Starting/Ready) because any inserted slot
203        // represents an allocated bridge — even Degraded/Restarting slots hold resources
204        // and the bridge process may still be running. Counting only active states would
205        // allow a race: slot A transitions Starting→Degraded between two callers' checks,
206        // letting both pass the limit.
207        let total_count = self.slots.len();
208        if total_count >= self.max_bridges {
209            return Err(CamelError::Config(format!(
210                "JMS bridge limit reached: {total_count} bridge(s) >= max_bridges ({})",
211                self.max_bridges
212            )));
213        }
214
215        let broker_config = self.config.get(broker_name).ok_or_else(|| {
216            CamelError::ProcessorError(format!("Unknown JMS broker '{}'", broker_name))
217        })?;
218
219        // Clone all required broker data before touching DashMap::entry().
220        let broker_url = broker_config.broker_url.clone();
221        let broker_type = broker_config.broker_type.clone();
222        let credentials = match (&broker_config.username, &broker_config.password) {
223            (Some(u), Some(p)) => Some((u.clone(), p.clone())),
224            _ => None,
225        };
226
227        let slot = match self.slots.entry(broker_name.to_string()) {
228            dashmap::Entry::Occupied(existing) => {
229                return Ok(Arc::clone(existing.get()));
230            }
231            dashmap::Entry::Vacant(entry) => {
232                let (state_tx, state_rx) = watch::channel(BridgeState::Starting);
233                let slot = Arc::new(BridgeSlot {
234                    name: broker_name.to_string(),
235                    broker_url: broker_url.clone(),
236                    broker_type: broker_type.clone(),
237                    credentials: credentials.clone(),
238                    state_rx,
239                    state_tx,
240                    process: Arc::new(tokio::sync::Mutex::new(None)),
241                    health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
242                });
243                entry.insert(Arc::clone(&slot));
244                slot
245            }
246        };
247
248        let start_result = Self::start_bridge_process(
249            &self.bridge_version,
250            &self.bridge_cache_dir,
251            self.bridge_start_timeout_ms,
252            &broker_url,
253            &broker_type,
254            &credentials,
255        )
256        .await;
257
258        match start_result {
259            Ok((process, channel)) => {
260                {
261                    let mut guard = slot.process.lock().await;
262                    *guard = Some(process);
263                }
264                let _ = slot.state_tx.send(BridgeState::Ready { channel });
265            }
266            Err(e) => {
267                let _ = slot
268                    .state_tx
269                    .send(BridgeState::Degraded(format!("Initial start failed: {e}")));
270            }
271        }
272
273        self.spawn_health_monitor(Arc::clone(&slot)).await;
274
275        Ok(slot)
276    }
277
278    /// Signal a slot to restart (called by producers on transport errors).
279    pub fn restart_slot(&self, broker_name: &str) {
280        if let Some(slot) = self.slots.get(broker_name) {
281            let _ = slot.state_tx.send(BridgeState::Restarting {
282                attempt: 0,
283                next_at: Instant::now(),
284            });
285        }
286    }
287
288    /// Recreate the tonic channel for an existing running bridge process.
289    ///
290    /// Useful when a channel becomes stale after transport-level failures while
291    /// the underlying bridge process is still alive.
292    pub async fn refresh_slot_channel(&self, broker_name: &str) -> Result<(), CamelError> {
293        let slot = self
294            .slots
295            .get(broker_name)
296            .map(|s| Arc::clone(&*s))
297            .ok_or_else(|| {
298                CamelError::ProcessorError(format!("Unknown JMS broker '{}'", broker_name))
299            })?;
300
301        // Lock is held during connect() (TLS handshake + retries) — widened from
302        // the old grpc_port() read. Bounded: localhost TLS (~10ms typical), per-slot
303        // lock (only blocks same-broker operations). Acceptable trade-off vs exposing
304        // pub(crate) TLS material to component crates.
305        let channel = {
306            let guard = slot.process.lock().await;
307            let process = guard.as_ref().ok_or_else(|| {
308                CamelError::ProcessorError(format!(
309                    "JMS broker '{}' has no running bridge process",
310                    broker_name
311                ))
312            })?;
313            process.connect().await.map_err(|e| {
314                CamelError::ProcessorError(format!(
315                    "JMS broker '{}' channel refresh failed: {}",
316                    broker_name, e
317                ))
318            })?
319        };
320
321        let _ = slot.state_tx.send(BridgeState::Ready { channel });
322        Ok(())
323    }
324
325    pub fn begin_shutdown(&self) {
326        self.shutting_down.store(true, Ordering::SeqCst);
327    }
328
329    /// Shutdown all slots: stop all bridge processes and await health monitors.
330    pub async fn shutdown(&self) -> Result<(), CamelError> {
331        self.begin_shutdown();
332        let names: Vec<String> = self.slots.iter().map(|e| e.key().clone()).collect();
333        let mut errors: Vec<String> = Vec::new();
334
335        for name in names {
336            if let Some((_, slot)) = self.slots.remove(&name) {
337                // Signal the health monitor to stop.
338                let _ = slot.state_tx.send(BridgeState::Stopped);
339
340                // Stop the bridge process.
341                let process = {
342                    let mut guard = slot.process.lock().await;
343                    guard.take()
344                };
345                if let Some(p) = process
346                    && let Err(e) = p.stop().await
347                {
348                    errors.push(format!("broker '{}': process stop failed: {e}", slot.name));
349                }
350
351                // Await the health monitor task with timeout; abort if it doesn't stop.
352                let monitor_handle = {
353                    let mut guard = slot.health_monitor_handle.lock().await;
354                    guard.take()
355                };
356                if let Some(mut h) = monitor_handle
357                    && tokio::time::timeout(Duration::from_secs(5), &mut h)
358                        .await
359                        .is_err()
360                {
361                    h.abort();
362                    let _ = h.await;
363                    warn!(
364                        "health monitor for '{}' did not stop in 5s; aborted",
365                        slot.name
366                    );
367                }
368            }
369        }
370
371        if errors.is_empty() {
372            Ok(())
373        } else {
374            Err(CamelError::ProcessorError(format!(
375                "JMS pool shutdown completed with {} error(s): {}",
376                errors.len(),
377                errors.join("; ")
378            )))
379        }
380    }
381
382    async fn spawn_health_monitor(&self, slot: Arc<BridgeSlot>) {
383        let health_interval = self.health_check_interval_ms;
384        let bridge_version = self.bridge_version.clone();
385        let bridge_cache_dir = self.bridge_cache_dir.clone();
386        let start_timeout_ms = self.bridge_start_timeout_ms;
387        let handle_ref = Arc::clone(&slot.health_monitor_handle);
388        let shutting_down = Arc::clone(&self.shutting_down);
389        let broker_name = slot.name.clone();
390
391        let handle = tokio::spawn(async move {
392            loop {
393                let state = slot.state_rx.borrow().clone();
394                match state {
395                    BridgeState::Stopped => {
396                        info!("Health monitor for '{}' exiting (Stopped)", slot.name);
397                        break;
398                    }
399                    BridgeState::Ready { ref channel } => {
400                        tokio::time::sleep(Duration::from_millis(health_interval)).await;
401                        let mut client = BridgeServiceClient::new(channel.clone());
402                        let health_timeout = Duration::from_secs(3);
403                        match tokio::time::timeout(health_timeout, client.health(HealthRequest {}))
404                            .await
405                        {
406                            Ok(Ok(_)) => {}
407                            Ok(Err(e)) => {
408                                warn!(
409                                    "Health check failed for broker '{}': {e}. Marking Degraded.",
410                                    slot.name
411                                );
412                                let _ = slot.state_tx.send(BridgeState::Degraded(e.to_string()));
413                            }
414                            Err(_) => {
415                                let msg = format!(
416                                    "health RPC timed out after {}ms",
417                                    health_timeout.as_millis()
418                                );
419                                warn!(
420                                    "Health check timed out for broker '{}': {}. Marking Degraded.",
421                                    slot.name, msg
422                                );
423                                let _ = slot.state_tx.send(BridgeState::Degraded(msg));
424                            }
425                        }
426                    }
427                    BridgeState::Degraded(_) | BridgeState::Starting => {
428                        if matches!(*slot.state_rx.borrow(), BridgeState::Stopped) {
429                            break;
430                        }
431                        if shutting_down.load(Ordering::SeqCst) {
432                            tracing::info!(
433                                "Pool shutting down — not restarting bridge for broker '{}'",
434                                broker_name
435                            );
436                            break;
437                        }
438                        let _ = slot.state_tx.send(BridgeState::Restarting {
439                            attempt: 0,
440                            next_at: Instant::now(),
441                        });
442                    }
443                    BridgeState::Restarting { attempt, next_at } => {
444                        if shutting_down.load(Ordering::SeqCst) {
445                            tracing::info!(
446                                "Pool shutting down — aborting restart for broker '{}'",
447                                broker_name
448                            );
449                            break;
450                        }
451
452                        let now = Instant::now();
453                        if now < next_at {
454                            tokio::time::sleep(next_at - now).await;
455                        }
456
457                        info!(
458                            "Restarting bridge for broker '{}' (attempt {})",
459                            slot.name,
460                            attempt + 1
461                        );
462
463                        if attempt >= MAX_RESTART_ATTEMPTS {
464                            // log-policy: system-broken
465                            tracing::error!(
466                                "Max restart attempts ({}) reached for broker '{}' — staying degraded",
467                                attempt,
468                                broker_name
469                            );
470                            let _ = slot.state_tx.send(BridgeState::Degraded(format!(
471                                "max restart attempts ({}) exceeded",
472                                attempt
473                            )));
474                            break;
475                        }
476
477                        let old_process = {
478                            let mut guard = slot.process.lock().await;
479                            guard.take()
480                        };
481                        if let Some(p) = old_process {
482                            let _ = p.stop().await;
483                        }
484
485                        let start_result = Self::start_bridge_process(
486                            &bridge_version,
487                            &bridge_cache_dir,
488                            start_timeout_ms,
489                            &slot.broker_url,
490                            &slot.broker_type,
491                            &slot.credentials,
492                        )
493                        .await;
494
495                        match start_result {
496                            Ok((process, channel)) => {
497                                // Guard: don't resurrect a stopped slot (shutdown may have run
498                                // while this async bridge start was in-flight).
499                                if matches!(*slot.state_rx.borrow(), BridgeState::Stopped) {
500                                    let _ = process.stop().await;
501                                    break;
502                                }
503                                {
504                                    let mut guard = slot.process.lock().await;
505                                    *guard = Some(process);
506                                }
507                                let _ = slot.state_tx.send(BridgeState::Ready { channel });
508                                info!("Broker '{}' bridge restarted successfully", slot.name);
509                            }
510                            Err(e) => {
511                                // Guard: don't schedule retries after shutdown.
512                                if matches!(*slot.state_rx.borrow(), BridgeState::Stopped) {
513                                    break;
514                                }
515                                let delay_secs = std::cmp::min(5 * 2u64.pow(attempt), 120);
516                                let next = Instant::now() + Duration::from_secs(delay_secs);
517                                warn!(
518                                    "Failed to restart bridge for '{}' (attempt {}): {e}. Retry in {delay_secs}s",
519                                    slot.name,
520                                    attempt + 1
521                                );
522                                let _ = slot.state_tx.send(BridgeState::Restarting {
523                                    attempt: attempt + 1,
524                                    next_at: next,
525                                });
526                            }
527                        }
528                    }
529                }
530            }
531        });
532
533        // Store the handle so shutdown can await the monitor.
534        let mut guard = handle_ref.lock().await;
535        *guard = Some(handle);
536    }
537
538    async fn start_bridge_process(
539        bridge_version: &str,
540        bridge_cache_dir: &std::path::Path,
541        start_timeout_ms: u64,
542        broker_url: &str,
543        broker_type: &BrokerType,
544        credentials: &Option<(String, String)>,
545    ) -> Result<(BridgeProcess, Channel), CamelError> {
546        info!(
547            "Starting JMS bridge process for {}...",
548            redact_url(broker_url)
549        );
550        let binary_path = ensure_binary(bridge_version, bridge_cache_dir)
551            .await
552            .map_err(|e| {
553                CamelError::ProcessorError(format!("JMS bridge binary unavailable: {e}"))
554            })?;
555
556        let process_config = BridgeProcessConfig::jms(
557            binary_path,
558            broker_url.to_string(),
559            broker_type.clone(),
560            credentials.as_ref().map(|(u, _)| u.clone()),
561            credentials
562                .as_ref()
563                .map(|(_, p)| camel_bridge::process::Redacted::new(p.clone())),
564            start_timeout_ms,
565        );
566
567        let total_timeout = Duration::from_millis(start_timeout_ms);
568        let result = tokio::time::timeout(total_timeout, async {
569            let (process, channel) = BridgeProcess::start_and_connect(&process_config)
570                .await
571                .map_err(|e| CamelError::ProcessorError(format!("JMS bridge start failed: {e}")))?;
572
573            wait_for_health(&channel, Duration::from_secs(10), |ch| {
574                let mut client = BridgeServiceClient::new(ch);
575                async move {
576                    let resp = client.health(HealthRequest {}).await?;
577                    Ok(resp.into_inner().healthy)
578                }
579            })
580            .await
581            .map_err(|e| {
582                CamelError::ProcessorError(format!("JMS bridge health check failed: {e}"))
583            })?;
584
585            Ok::<(BridgeProcess, Channel), CamelError>((process, channel))
586        })
587        .await
588        .map_err(|_| {
589            CamelError::ProcessorError(format!(
590                "JMS bridge start timed out after {}ms",
591                start_timeout_ms
592            ))
593        })??;
594
595        Ok(result)
596    }
597}
598
599// ── Drop impl: cleanup on pool drop without explicit shutdown ─────────────────
600
601impl Drop for JmsBridgePool {
602    fn drop(&mut self) {
603        self.shutting_down.store(true, Ordering::SeqCst);
604
605        // Clone bridge slots before we lose access to self.slots.
606        let slots: Vec<(String, Arc<BridgeSlot>)> = self
607            .slots
608            .iter()
609            .map(|e| (e.key().clone(), e.value().clone()))
610            .collect();
611
612        if slots.is_empty() {
613            return;
614        }
615
616        match tokio::runtime::Handle::try_current() {
617            Ok(handle) => {
618                // Spawn the per-bridge cleanup (same sequence as shutdown()).
619                // Dropping the JoinHandle detaches the task — it runs to completion.
620                drop(handle.spawn(async move {
621                    for (_name, slot) in slots {
622                        // Signal the health monitor to stop.
623                        let _ = slot.state_tx.send(BridgeState::Stopped);
624
625                        // Stop the bridge process.
626                        let process = {
627                            let mut guard = slot.process.lock().await;
628                            guard.take()
629                        };
630                        if let Some(p) = process {
631                            let _ = p.stop().await;
632                        }
633
634                        // Await the health monitor task with timeout; abort if needed.
635                        let monitor_handle = {
636                            let mut guard = slot.health_monitor_handle.lock().await;
637                            guard.take()
638                        };
639                        if let Some(mut h) = monitor_handle
640                            && tokio::time::timeout(Duration::from_secs(5), &mut h)
641                                .await
642                                .is_err()
643                        {
644                            h.abort();
645                            let _ = h.await;
646                            warn!(
647                                "health monitor for '{}' did not stop in 5s; aborted",
648                                slot.name
649                            );
650                        }
651                    }
652                }));
653            }
654            Err(_) => {
655                warn!("JmsBridgePool dropped outside tokio runtime; bridges not cleaned up");
656            }
657        }
658    }
659}
660
661// ── JmsComponent ─────────────────────────────────────────────────────────────
662
663#[derive(Clone)]
664pub struct JmsComponent {
665    scheme: String,
666    pool: Arc<JmsBridgePool>,
667}
668
669impl JmsComponent {
670    pub fn with_scheme(scheme: impl Into<String>, pool: Arc<JmsBridgePool>) -> Self {
671        Self {
672            scheme: scheme.into(),
673            pool,
674        }
675    }
676
677    pub fn scheme(&self) -> &str {
678        &self.scheme
679    }
680
681    /// Test helper: send a message directly without going through a route.
682    #[cfg(test)]
683    pub async fn send_for_test(
684        &self,
685        destination: &str,
686        body: &[u8],
687        content_type: &str,
688    ) -> Result<String, CamelError> {
689        let broker_name = self.pool.resolve_broker_name(None)?;
690        let slot = self.pool.get_or_create_slot(&broker_name).await?;
691        let channel = match &*slot.state_rx.borrow() {
692            BridgeState::Ready { channel } => channel.clone(),
693            other => {
694                return Err(CamelError::ProcessorError(format!(
695                    "Bridge not ready: {:?}",
696                    other
697                )));
698            }
699        };
700        let mut client = BridgeServiceClient::new(channel);
701        let r = client
702            .send(crate::proto::SendRequest {
703                destination: destination.to_string(),
704                body: body.to_vec(),
705                headers: Default::default(),
706                content_type: content_type.to_string(),
707            })
708            .await
709            .map_err(|e| CamelError::ProcessorError(format!("test send error: {e}")))?;
710        Ok(r.into_inner().message_id)
711    }
712}
713
714impl Component for JmsComponent {
715    fn scheme(&self) -> &str {
716        &self.scheme
717    }
718
719    fn create_endpoint(
720        &self,
721        uri: &str,
722        ctx: &dyn camel_component_api::ComponentContext,
723    ) -> Result<Box<dyn Endpoint>, CamelError> {
724        let endpoint_config = JmsEndpointConfig::from_uri(uri)?;
725        let broker_name = self
726            .pool
727            .resolve_broker_name(endpoint_config.broker_name.as_deref())?;
728        let resolved_broker_type = self.pool.resolve_broker_type(&self.scheme, &broker_name);
729
730        let health_check = JmsHealthCheck::new(Arc::clone(&self.pool), broker_name.clone());
731        ctx.register_current_route_health_check(Arc::new(health_check));
732
733        Ok(Box::new(JmsEndpoint {
734            pool: Arc::clone(&self.pool),
735            uri: uri.to_string(),
736            broker_name,
737            resolved_broker_type,
738            endpoint_config,
739        }))
740    }
741}
742
743// ── JmsEndpoint ──────────────────────────────────────────────────────────────
744
745struct JmsEndpoint {
746    pool: Arc<JmsBridgePool>,
747    uri: String,
748    broker_name: String,
749    resolved_broker_type: BrokerType,
750    endpoint_config: JmsEndpointConfig,
751}
752
753impl Endpoint for JmsEndpoint {
754    fn uri(&self) -> &str {
755        &self.uri
756    }
757
758    fn create_producer(
759        &self,
760        rt: Arc<dyn camel_component_api::RuntimeObservability>,
761        _ctx: &ProducerContext,
762    ) -> Result<BoxProcessor, CamelError> {
763        Ok(BoxProcessor::new(LazyJmsProducer {
764            pool: Arc::clone(&self.pool),
765            broker_name: self.broker_name.clone(),
766            endpoint_config: self.endpoint_config.clone(),
767            resolved_broker_type: self.resolved_broker_type.clone(),
768            runtime: rt,
769        }))
770    }
771
772    fn create_consumer(
773        &self,
774        rt: Arc<dyn camel_component_api::RuntimeObservability>,
775    ) -> Result<Box<dyn Consumer>, CamelError> {
776        Ok(Box::new(JmsConsumer::new(
777            Arc::clone(&self.pool),
778            self.broker_name.clone(),
779            self.endpoint_config.clone(),
780            self.pool.reconnect.clone(),
781            rt,
782        )))
783    }
784}
785
786#[derive(Clone)]
787struct LazyJmsProducer {
788    pool: Arc<JmsBridgePool>,
789    broker_name: String,
790    endpoint_config: JmsEndpointConfig,
791    #[allow(dead_code)]
792    resolved_broker_type: BrokerType,
793    /// Phase B will use this for `rt.metrics().increment_errors(...)` and
794    /// `rt.health().force_unhealthy_for_route(...)` calls per ADR-0012.
795    #[allow(dead_code)]
796    runtime: Arc<dyn camel_component_api::RuntimeObservability>,
797}
798
799impl Service<Exchange> for LazyJmsProducer {
800    type Response = Exchange;
801    type Error = CamelError;
802    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
803
804    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
805        // Check existing slot state if one is already present.
806        // If no slot exists yet, return Ready — call() will handle async bridge start.
807        if let Some(slot) = self.pool.slots.get(&self.broker_name) {
808            match &*slot.state_rx.borrow() {
809                BridgeState::Ready { .. } => return Poll::Ready(Ok(())),
810                BridgeState::Starting | BridgeState::Restarting { .. } => {
811                    // Register a waker so the executor is notified when bridge state
812                    // changes. Without this, Poll::Pending would stall callers that use
813                    // strict Tower semantics (poll_ready loop before call()).
814                    // Guard with try_current: fall back to wake_by_ref when no Tokio
815                    // runtime is active (e.g. unit tests).
816                    let waker = cx.waker().clone();
817                    let mut rx = slot.state_rx.clone();
818                    if let Ok(handle) = tokio::runtime::Handle::try_current() {
819                        handle.spawn(async move {
820                            let _ = rx.changed().await;
821                            waker.wake();
822                        });
823                    } else {
824                        waker.wake_by_ref();
825                    }
826                    return Poll::Pending;
827                }
828                BridgeState::Degraded(reason) => {
829                    return Poll::Ready(Err(CamelError::ProcessorError(format!(
830                        "JMS broker '{}' is degraded: {}",
831                        self.broker_name, reason
832                    ))));
833                }
834                BridgeState::Stopped => {
835                    return Poll::Ready(Err(CamelError::ProcessorError(format!(
836                        "JMS broker '{}' is stopped",
837                        self.broker_name
838                    ))));
839                }
840            }
841        }
842        Poll::Ready(Ok(()))
843    }
844
845    fn call(&mut self, exchange: Exchange) -> Self::Future {
846        let pool = Arc::clone(&self.pool);
847        let broker_name = self.broker_name.clone();
848        let endpoint_config = self.endpoint_config.clone();
849
850        Box::pin(async move {
851            let slot = pool.get_or_create_slot(&broker_name).await?;
852            let mut rx = slot.state_rx.clone();
853
854            loop {
855                let state = rx.borrow().clone();
856                match state {
857                    BridgeState::Ready { channel } => {
858                        let mut producer = JmsProducer::new(channel, endpoint_config.clone());
859                        match producer.call(exchange).await {
860                            Ok(done) => return Ok(done),
861                            Err(first_err) if is_bridge_transport_error(&first_err) => {
862                                warn!(
863                                    broker = %broker_name,
864                                    error = %first_err,
865                                    "JMS send transport error; refreshing channel (no automatic resend)"
866                                );
867
868                                if let Err(refresh_err) =
869                                    pool.refresh_slot_channel(&broker_name).await
870                                {
871                                    warn!(
872                                        broker = %broker_name,
873                                        error = %refresh_err,
874                                        "JMS channel refresh failed; requesting bridge restart"
875                                    );
876                                    pool.restart_slot(&broker_name);
877                                }
878
879                                // Do NOT automatically resend — the first send may have reached
880                                // the broker even though the ack failed. Resending non-idempotent
881                                // writes causes duplicates. Return the original error so the caller
882                                // can decide whether to retry.
883                                return Err(first_err);
884                            }
885                            Err(other_err) => return Err(other_err),
886                        }
887                    }
888                    BridgeState::Degraded(reason) => {
889                        return Err(CamelError::ProcessorError(format!(
890                            "JMS broker '{}' is degraded: {}",
891                            broker_name, reason
892                        )));
893                    }
894                    BridgeState::Stopped => {
895                        return Err(CamelError::ProcessorError(format!(
896                            "JMS broker '{}' is stopped",
897                            broker_name
898                        )));
899                    }
900                    BridgeState::Starting | BridgeState::Restarting { .. } => {
901                        if rx.changed().await.is_err() {
902                            return Err(CamelError::ProcessorError(format!(
903                                "JMS broker '{}' state channel closed",
904                                broker_name
905                            )));
906                        }
907                    }
908                }
909            }
910        })
911    }
912}
913
914// ── Helpers ──────────────────────────────────────────────────────────────────
915
916/// Redact userinfo (username:password@) from a broker URL for safe logging.
917/// Handles URLs like `tcp://user:pass@host:61616` → `tcp://***@host:61616`.
918fn redact_url(url: &str) -> String {
919    // Find the scheme separator (://)
920    if let Some(pos) = url.find("://") {
921        let scheme = &url[..pos + 3]; // includes "://"
922        let rest = &url[pos + 3..];
923        // Find @ in the remainder — everything before @ is userinfo
924        if let Some(at_pos) = rest.find('@') {
925            return format!("{}***@{}", scheme, &rest[at_pos + 1..]);
926        }
927    }
928    url.to_string()
929}
930
931pub fn is_bridge_transport_error(err: &CamelError) -> bool {
932    // Typed variant matching: only ProcessorError messages that start with
933    // the well-known transport prefix are classified as transport errors.
934    // This rejects Config errors, business errors, and other CamelError variants
935    // without relying on the Display wrapper formatting.
936    match err {
937        CamelError::ProcessorError(msg) => msg.starts_with(BRIDGE_TRANSPORT_ERROR_PREFIX),
938        _ => false,
939    }
940}
941
942// ── Unit tests ───────────────────────────────────────────────────────────────
943
944#[cfg(test)]
945mod tests {
946    use camel_component_api::test_support::PanicRuntimeObservability;
947    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
948        std::sync::Arc::new(PanicRuntimeObservability)
949    }
950    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
951        std::sync::Arc::new(PanicRuntimeObservability)
952    }
953
954    use super::*;
955    use crate::config::{BrokerConfig, JmsPoolConfig};
956    use std::collections::HashMap;
957
958    #[test]
959    fn from_config_accepts_empty_brokers() {
960        let pool_config = JmsPoolConfig::default();
961        let result = JmsBridgePool::from_config(pool_config);
962        assert!(result.is_ok());
963    }
964
965    #[test]
966    fn resolve_broker_name_with_explicit_name() {
967        let pool = JmsBridgePool::from_config(JmsPoolConfig::single_broker(
968            "tcp://localhost:61616",
969            BrokerType::ActiveMq,
970        ))
971        .unwrap();
972        assert_eq!(
973            pool.resolve_broker_name(Some("default")).unwrap(),
974            "default"
975        );
976    }
977
978    #[test]
979    fn resolve_broker_name_default() {
980        let pool = JmsBridgePool::from_config(JmsPoolConfig::single_broker(
981            "tcp://localhost:61616",
982            BrokerType::ActiveMq,
983        ))
984        .unwrap();
985        assert_eq!(pool.resolve_broker_name(None).unwrap(), "default");
986    }
987
988    #[test]
989    fn resolve_broker_name_unknown_returns_error() {
990        let pool = JmsBridgePool::from_config(JmsPoolConfig::single_broker(
991            "tcp://localhost:61616",
992            BrokerType::ActiveMq,
993        ))
994        .unwrap();
995        let err = pool.resolve_broker_name(Some("unknown")).unwrap_err();
996        assert!(
997            err.to_string().contains("Unknown JMS broker 'unknown'"),
998            "got: {}",
999            err
1000        );
1001    }
1002
1003    #[test]
1004    fn resolve_broker_type_scheme_overrides() {
1005        let pool = JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1006            "tcp://localhost:61616",
1007            BrokerType::Generic,
1008        ))
1009        .unwrap();
1010        assert_eq!(
1011            pool.resolve_broker_type("activemq", "default"),
1012            BrokerType::ActiveMq
1013        );
1014        assert_eq!(
1015            pool.resolve_broker_type("artemis", "default"),
1016            BrokerType::Artemis
1017        );
1018        assert_eq!(
1019            pool.resolve_broker_type("jms", "default"),
1020            BrokerType::Generic
1021        );
1022    }
1023
1024    #[test]
1025    fn resolve_broker_type_activemq_scheme_overrides_artemis_config() {
1026        let pool = JmsBridgePool::from_config(JmsPoolConfig {
1027            brokers: HashMap::from([(
1028                "main".to_string(),
1029                BrokerConfig {
1030                    broker_url: "tcp://localhost:61616".to_string(),
1031                    broker_type: BrokerType::Artemis,
1032                    username: None,
1033                    password: None,
1034                },
1035            )]),
1036            ..JmsPoolConfig::default()
1037        })
1038        .unwrap();
1039        assert_eq!(
1040            pool.resolve_broker_type("activemq", "main"),
1041            BrokerType::ActiveMq
1042        );
1043        assert_eq!(pool.resolve_broker_type("jms", "main"), BrokerType::Artemis);
1044    }
1045
1046    #[test]
1047    fn create_endpoint_resolves_broker() {
1048        let pool = Arc::new(
1049            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1050                "tcp://localhost:61616",
1051                BrokerType::ActiveMq,
1052            ))
1053            .unwrap(),
1054        );
1055        let component = JmsComponent::with_scheme("jms", pool);
1056        let endpoint = component.create_endpoint(
1057            "jms:queue:orders",
1058            &camel_component_api::NoOpComponentContext,
1059        );
1060        assert!(endpoint.is_ok(), "got: {:?}", endpoint.err());
1061    }
1062
1063    #[test]
1064    fn create_endpoint_rejects_wrong_scheme() {
1065        let pool = Arc::new(
1066            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1067                "tcp://localhost:61616",
1068                BrokerType::ActiveMq,
1069            ))
1070            .unwrap(),
1071        );
1072        let component = JmsComponent::with_scheme("jms", pool);
1073        let err = component
1074            .create_endpoint("kafka:orders", &camel_component_api::NoOpComponentContext)
1075            .err()
1076            .unwrap();
1077        assert!(
1078            err.to_string()
1079                .contains("expected scheme 'jms', 'activemq', or 'artemis'"),
1080            "got: {}",
1081            err
1082        );
1083    }
1084
1085    #[test]
1086    fn create_endpoint_with_explicit_broker_param() {
1087        let pool = Arc::new(
1088            JmsBridgePool::from_config(JmsPoolConfig {
1089                brokers: HashMap::from([
1090                    (
1091                        "primary".to_string(),
1092                        BrokerConfig {
1093                            broker_url: "tcp://primary:61616".to_string(),
1094                            broker_type: BrokerType::ActiveMq,
1095                            username: None,
1096                            password: None,
1097                        },
1098                    ),
1099                    (
1100                        "secondary".to_string(),
1101                        BrokerConfig {
1102                            broker_url: "tcp://secondary:61616".to_string(),
1103                            broker_type: BrokerType::Artemis,
1104                            username: None,
1105                            password: None,
1106                        },
1107                    ),
1108                ]),
1109                ..JmsPoolConfig::default()
1110            })
1111            .unwrap(),
1112        );
1113        let component = JmsComponent::with_scheme("jms", Arc::clone(&pool));
1114        let endpoint = component.create_endpoint(
1115            "jms:queue:orders?broker=secondary",
1116            &camel_component_api::NoOpComponentContext,
1117        );
1118        assert!(endpoint.is_ok(), "got: {:?}", endpoint.err());
1119    }
1120
1121    #[tokio::test]
1122    async fn concurrent_get_or_create_slot_no_deadlock() {
1123        use tokio::time::timeout;
1124
1125        struct EnvGuard {
1126            key: &'static str,
1127            prev: Option<std::ffi::OsString>,
1128        }
1129        impl Drop for EnvGuard {
1130            fn drop(&mut self) {
1131                if let Some(v) = &self.prev {
1132                    // SAFETY: restoring process env in test scope.
1133                    unsafe { std::env::set_var(self.key, v) };
1134                } else {
1135                    // SAFETY: restoring process env in test scope.
1136                    unsafe { std::env::remove_var(self.key) };
1137                }
1138            }
1139        }
1140
1141        let env_key = "CAMEL_JMS_BRIDGE_BINARY_PATH";
1142        let _guard = EnvGuard {
1143            key: env_key,
1144            prev: std::env::var_os(env_key),
1145        };
1146        // SAFETY: test-scoped env mutation.
1147        unsafe { std::env::set_var(env_key, "/bin/false") };
1148
1149        let pool = Arc::new(
1150            JmsBridgePool::from_config(JmsPoolConfig {
1151                brokers: HashMap::from([(
1152                    "test".to_string(),
1153                    BrokerConfig {
1154                        broker_url: "tcp://localhost:61616".to_string(),
1155                        broker_type: BrokerType::ActiveMq,
1156                        username: None,
1157                        password: None,
1158                    },
1159                )]),
1160                bridge_start_timeout_ms: 100,
1161                ..JmsPoolConfig::default()
1162            })
1163            .unwrap(),
1164        );
1165
1166        let handles: Vec<_> = (0..5)
1167            .map(|_| {
1168                let pool = Arc::clone(&pool);
1169                tokio::spawn(async move {
1170                    let _ = pool.get_or_create_slot("test").await;
1171                })
1172            })
1173            .collect();
1174
1175        let result = timeout(Duration::from_secs(5), async {
1176            for h in handles {
1177                let _ = h.await;
1178            }
1179        })
1180        .await;
1181
1182        assert!(result.is_ok(), "Concurrent get_or_create_slot deadlocked!");
1183    }
1184
1185    #[tokio::test]
1186    async fn lazy_producer_reports_degraded_when_bridge_start_fails() {
1187        use tower::Service;
1188
1189        struct EnvGuard {
1190            key: &'static str,
1191            prev: Option<std::ffi::OsString>,
1192        }
1193        impl Drop for EnvGuard {
1194            fn drop(&mut self) {
1195                if let Some(v) = &self.prev {
1196                    // SAFETY: restoring process env in test scope.
1197                    unsafe { std::env::set_var(self.key, v) };
1198                } else {
1199                    // SAFETY: restoring process env in test scope.
1200                    unsafe { std::env::remove_var(self.key) };
1201                }
1202            }
1203        }
1204
1205        let env_key = "CAMEL_JMS_BRIDGE_BINARY_PATH";
1206        let _guard = EnvGuard {
1207            key: env_key,
1208            prev: std::env::var_os(env_key),
1209        };
1210        // SAFETY: test-scoped env mutation.
1211        unsafe { std::env::set_var(env_key, "/bin/false") };
1212
1213        let pool = Arc::new(
1214            JmsBridgePool::from_config(JmsPoolConfig {
1215                brokers: HashMap::from([(
1216                    "default".to_string(),
1217                    BrokerConfig {
1218                        broker_url: "tcp://localhost:61616".to_string(),
1219                        broker_type: BrokerType::ActiveMq,
1220                        username: None,
1221                        password: None,
1222                    },
1223                )]),
1224                bridge_start_timeout_ms: 100,
1225                ..JmsPoolConfig::default()
1226            })
1227            .unwrap(),
1228        );
1229
1230        let component = JmsComponent::with_scheme("jms", pool);
1231        let endpoint = component
1232            .create_endpoint(
1233                "jms:queue:orders",
1234                &camel_component_api::NoOpComponentContext,
1235            )
1236            .unwrap();
1237        let mut producer = endpoint
1238            .create_producer(rt(), &camel_component_api::ProducerContext::default())
1239            .unwrap();
1240
1241        let mut exchange = Exchange::default();
1242        exchange.input.body = camel_component_api::Body::Text("hello".to_string());
1243
1244        let err = producer.call(exchange).await.unwrap_err();
1245        assert!(err.to_string().contains("is degraded"), "got: {}", err);
1246    }
1247
1248    /// A send transport error should trigger a channel refresh attempt first.
1249    /// If refresh cannot be performed (e.g. no running bridge process metadata),
1250    /// the producer requests a bridge restart as fallback.
1251    #[tokio::test]
1252    async fn lazy_producer_requests_restart_when_refresh_unavailable() {
1253        use tokio::sync::watch;
1254        use tonic::transport::Endpoint as TonicEndpoint;
1255        use tower::Service;
1256
1257        // Build a lazy channel to a port where nothing is listening.
1258        // connect_lazy() succeeds immediately; the error manifests on the actual RPC call.
1259        let dead_channel = TonicEndpoint::from_static("http://127.0.0.1:1").connect_lazy();
1260
1261        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
1262            channel: dead_channel.clone(),
1263        });
1264
1265        let pool = Arc::new(
1266            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1267                "tcp://localhost:61616",
1268                BrokerType::ActiveMq,
1269            ))
1270            .unwrap(),
1271        );
1272
1273        // Manually insert a slot with the dead-channel in Ready state.
1274        let slot = Arc::new(BridgeSlot {
1275            name: "default".to_string(),
1276            broker_url: "tcp://localhost:61616".to_string(),
1277            broker_type: BrokerType::ActiveMq,
1278            credentials: None,
1279            state_rx: state_rx.clone(),
1280            state_tx: state_tx.clone(),
1281            process: Arc::new(tokio::sync::Mutex::new(None)),
1282            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1283        });
1284        pool.slots.insert("default".to_string(), Arc::clone(&slot));
1285
1286        let endpoint_config =
1287            crate::config::JmsEndpointConfig::from_uri("jms:queue:test-retry").unwrap();
1288
1289        let mut producer = LazyJmsProducer {
1290            pool: Arc::clone(&pool),
1291            broker_name: "default".to_string(),
1292            endpoint_config,
1293            resolved_broker_type: BrokerType::ActiveMq,
1294            runtime: test_rt(),
1295        };
1296
1297        let mut exchange = Exchange::default();
1298        exchange.input.body = camel_component_api::Body::Text("hello".to_string());
1299
1300        // The send will fail because the channel points to a dead port.
1301        let result = producer.call(exchange).await;
1302        assert!(result.is_err(), "expected send to fail");
1303
1304        // Refresh cannot run in this setup (slot has no BridgeProcess), so the
1305        // fallback path requests a restart.
1306        let state_after = state_rx.borrow().clone();
1307        assert!(
1308            matches!(state_after, BridgeState::Restarting { .. }),
1309            "slot must enter Restarting when refresh is unavailable; got: {:?}",
1310            state_after
1311        );
1312    }
1313
1314    // ── JMS-007: Transport error classification ──────────────────────────────
1315
1316    #[test]
1317    fn transport_error_detects_send_error() {
1318        let err = CamelError::ProcessorError(format!(
1319            "{}send error: connection refused",
1320            BRIDGE_TRANSPORT_ERROR_PREFIX
1321        ));
1322        assert!(
1323            is_bridge_transport_error(&err),
1324            "send error must be classified as transport"
1325        );
1326    }
1327
1328    #[test]
1329    fn transport_error_detects_subscribe_error() {
1330        let err = CamelError::ProcessorError(format!(
1331            "{}subscribe error: stream reset",
1332            BRIDGE_TRANSPORT_ERROR_PREFIX
1333        ));
1334        assert!(
1335            is_bridge_transport_error(&err),
1336            "subscribe error must be classified as transport"
1337        );
1338    }
1339
1340    #[test]
1341    fn transport_error_rejects_business_errors() {
1342        let err = CamelError::ProcessorError("JMS broker 'main' is degraded: timeout".to_string());
1343        assert!(
1344            !is_bridge_transport_error(&err),
1345            "degraded state error must NOT be transport"
1346        );
1347    }
1348
1349    #[test]
1350    fn transport_error_rejects_config_errors() {
1351        let err = CamelError::Config("bridge_start_timeout_ms must be > 0".to_string());
1352        assert!(
1353            !is_bridge_transport_error(&err),
1354            "config error must NOT be transport"
1355        );
1356    }
1357
1358    #[test]
1359    fn transport_error_prefix_is_used_by_producer_and_consumer() {
1360        // Verify the constant prefix matches what producer.rs and consumer.rs emit.
1361        // If this test fails, the constant has drifted from the error format strings.
1362        assert!(
1363            BRIDGE_TRANSPORT_ERROR_PREFIX.starts_with("JMS gRPC "),
1364            "prefix must start with 'JMS gRPC '"
1365        );
1366    }
1367
1368    // ── JMS-006: max_bridges enforcement ─────────────────────────────────────
1369
1370    #[tokio::test]
1371    async fn pool_enforces_max_bridges_limit() {
1372        use tokio::sync::watch;
1373
1374        let pool = Arc::new(
1375            JmsBridgePool::from_config(JmsPoolConfig {
1376                brokers: HashMap::from([
1377                    (
1378                        "b1".to_string(),
1379                        BrokerConfig {
1380                            broker_url: "tcp://b1:61616".to_string(),
1381                            broker_type: BrokerType::ActiveMq,
1382                            username: None,
1383                            password: None,
1384                        },
1385                    ),
1386                    (
1387                        "b2".to_string(),
1388                        BrokerConfig {
1389                            broker_url: "tcp://b2:61616".to_string(),
1390                            broker_type: BrokerType::ActiveMq,
1391                            username: None,
1392                            password: None,
1393                        },
1394                    ),
1395                    (
1396                        "b3".to_string(),
1397                        BrokerConfig {
1398                            broker_url: "tcp://b3:61616".to_string(),
1399                            broker_type: BrokerType::ActiveMq,
1400                            username: None,
1401                            password: None,
1402                        },
1403                    ),
1404                ]),
1405                max_bridges: 2,
1406                ..JmsPoolConfig::default()
1407            })
1408            .unwrap(),
1409        );
1410
1411        // Manually insert two slots to simulate existing bridges.
1412        // max_bridges counts ALL slots in the map (not just active states).
1413        for name in &["b1", "b2"] {
1414            let (state_tx, state_rx) = watch::channel(BridgeState::Starting);
1415            let slot = Arc::new(BridgeSlot {
1416                name: name.to_string(),
1417                broker_url: format!("tcp://{name}:61616"),
1418                broker_type: BrokerType::ActiveMq,
1419                credentials: None,
1420                state_rx,
1421                state_tx,
1422                process: Arc::new(tokio::sync::Mutex::new(None)),
1423                health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1424            });
1425            pool.slots.insert(name.to_string(), slot);
1426        }
1427
1428        // Attempting to create a third slot should fail.
1429        let err = pool.get_or_create_slot("b3").await.unwrap_err();
1430        assert!(
1431            err.to_string().contains("max_bridges"),
1432            "expected max_bridges error, got: {}",
1433            err
1434        );
1435    }
1436
1437    #[tokio::test]
1438    async fn pool_allows_slot_when_below_max_bridges() {
1439        use tokio::sync::watch;
1440
1441        let pool = Arc::new(
1442            JmsBridgePool::from_config(JmsPoolConfig {
1443                brokers: HashMap::from([(
1444                    "b1".to_string(),
1445                    BrokerConfig {
1446                        broker_url: "tcp://b1:61616".to_string(),
1447                        broker_type: BrokerType::ActiveMq,
1448                        username: None,
1449                        password: None,
1450                    },
1451                )]),
1452                max_bridges: 2,
1453                bridge_start_timeout_ms: 100,
1454                ..JmsPoolConfig::default()
1455            })
1456            .unwrap(),
1457        );
1458
1459        // Insert one slot in Degraded state (not counted as active).
1460        let (state_tx, state_rx) = watch::channel(BridgeState::Degraded("test".to_string()));
1461        let slot = Arc::new(BridgeSlot {
1462            name: "b1".to_string(),
1463            broker_url: "tcp://b1:61616".to_string(),
1464            broker_type: BrokerType::ActiveMq,
1465            credentials: None,
1466            state_rx,
1467            state_tx,
1468            process: Arc::new(tokio::sync::Mutex::new(None)),
1469            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1470        });
1471        pool.slots.insert("b1".to_string(), slot);
1472
1473        // b1 is Degraded (not active), so creating b1's slot returns existing.
1474        // The max_bridges check only applies to new slots.
1475        let result = pool.get_or_create_slot("b1").await;
1476        assert!(result.is_ok(), "existing slot must be returned");
1477    }
1478
1479    // ── JMS-003: poll_ready reflects bridge state ────────────────────────────
1480
1481    #[tokio::test]
1482    async fn poll_ready_returns_pending_when_starting() {
1483        use tokio::sync::watch;
1484        use tower::Service;
1485
1486        let pool = Arc::new(
1487            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1488                "tcp://localhost:61616",
1489                BrokerType::ActiveMq,
1490            ))
1491            .unwrap(),
1492        );
1493
1494        let (state_tx, state_rx) = watch::channel(BridgeState::Starting);
1495        let slot = Arc::new(BridgeSlot {
1496            name: "default".to_string(),
1497            broker_url: "tcp://localhost:61616".to_string(),
1498            broker_type: BrokerType::ActiveMq,
1499            credentials: None,
1500            state_rx,
1501            state_tx,
1502            process: Arc::new(tokio::sync::Mutex::new(None)),
1503            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1504        });
1505        pool.slots.insert("default".to_string(), slot);
1506
1507        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1508        let mut producer = LazyJmsProducer {
1509            pool: Arc::clone(&pool),
1510            broker_name: "default".to_string(),
1511            endpoint_config,
1512            resolved_broker_type: BrokerType::ActiveMq,
1513            runtime: test_rt(),
1514        };
1515
1516        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1517        assert!(
1518            matches!(result, Poll::Pending),
1519            "poll_ready must be Pending when Starting; got: {:?}",
1520            result
1521        );
1522    }
1523
1524    #[tokio::test]
1525    async fn poll_ready_returns_error_when_degraded() {
1526        use tokio::sync::watch;
1527        use tower::Service;
1528
1529        let pool = Arc::new(
1530            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1531                "tcp://localhost:61616",
1532                BrokerType::ActiveMq,
1533            ))
1534            .unwrap(),
1535        );
1536
1537        let (state_tx, state_rx) =
1538            watch::channel(BridgeState::Degraded("health check failed".to_string()));
1539        let slot = Arc::new(BridgeSlot {
1540            name: "default".to_string(),
1541            broker_url: "tcp://localhost:61616".to_string(),
1542            broker_type: BrokerType::ActiveMq,
1543            credentials: None,
1544            state_rx,
1545            state_tx,
1546            process: Arc::new(tokio::sync::Mutex::new(None)),
1547            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1548        });
1549        pool.slots.insert("default".to_string(), slot);
1550
1551        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1552        let mut producer = LazyJmsProducer {
1553            pool: Arc::clone(&pool),
1554            broker_name: "default".to_string(),
1555            endpoint_config,
1556            resolved_broker_type: BrokerType::ActiveMq,
1557            runtime: test_rt(),
1558        };
1559
1560        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1561        assert!(
1562            matches!(result, Poll::Ready(Err(_))),
1563            "poll_ready must be Err when Degraded; got: {:?}",
1564            result
1565        );
1566        let err_msg = match result {
1567            Poll::Ready(Err(e)) => e.to_string(),
1568            _ => unreachable!(),
1569        };
1570        assert!(
1571            err_msg.contains("degraded"),
1572            "error must mention degraded: {}",
1573            err_msg
1574        );
1575    }
1576
1577    #[tokio::test]
1578    async fn poll_ready_returns_error_when_stopped() {
1579        use tokio::sync::watch;
1580        use tower::Service;
1581
1582        let pool = Arc::new(
1583            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1584                "tcp://localhost:61616",
1585                BrokerType::ActiveMq,
1586            ))
1587            .unwrap(),
1588        );
1589
1590        let (state_tx, state_rx) = watch::channel(BridgeState::Stopped);
1591        let slot = Arc::new(BridgeSlot {
1592            name: "default".to_string(),
1593            broker_url: "tcp://localhost:61616".to_string(),
1594            broker_type: BrokerType::ActiveMq,
1595            credentials: None,
1596            state_rx,
1597            state_tx,
1598            process: Arc::new(tokio::sync::Mutex::new(None)),
1599            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1600        });
1601        pool.slots.insert("default".to_string(), slot);
1602
1603        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1604        let mut producer = LazyJmsProducer {
1605            pool: Arc::clone(&pool),
1606            broker_name: "default".to_string(),
1607            endpoint_config,
1608            resolved_broker_type: BrokerType::ActiveMq,
1609            runtime: test_rt(),
1610        };
1611
1612        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1613        assert!(
1614            matches!(result, Poll::Ready(Err(_))),
1615            "poll_ready must be Err when Stopped; got: {:?}",
1616            result
1617        );
1618    }
1619
1620    #[tokio::test]
1621    async fn poll_ready_returns_ready_when_slot_ready() {
1622        use tokio::sync::watch;
1623        use tonic::transport::Endpoint as TonicEndpoint;
1624        use tower::Service;
1625
1626        let pool = Arc::new(
1627            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1628                "tcp://localhost:61616",
1629                BrokerType::ActiveMq,
1630            ))
1631            .unwrap(),
1632        );
1633
1634        let lazy_channel = TonicEndpoint::from_static("http://127.0.0.1:1").connect_lazy();
1635        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
1636            channel: lazy_channel,
1637        });
1638        let slot = Arc::new(BridgeSlot {
1639            name: "default".to_string(),
1640            broker_url: "tcp://localhost:61616".to_string(),
1641            broker_type: BrokerType::ActiveMq,
1642            credentials: None,
1643            state_rx,
1644            state_tx,
1645            process: Arc::new(tokio::sync::Mutex::new(None)),
1646            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1647        });
1648        pool.slots.insert("default".to_string(), slot);
1649
1650        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1651        let mut producer = LazyJmsProducer {
1652            pool: Arc::clone(&pool),
1653            broker_name: "default".to_string(),
1654            endpoint_config,
1655            resolved_broker_type: BrokerType::ActiveMq,
1656            runtime: test_rt(),
1657        };
1658
1659        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1660        assert!(
1661            matches!(result, Poll::Ready(Ok(()))),
1662            "poll_ready must be Ready(Ok) when bridge is Ready; got: {:?}",
1663            result
1664        );
1665    }
1666
1667    #[tokio::test]
1668    async fn poll_ready_returns_ready_when_no_slot_exists() {
1669        use tower::Service;
1670
1671        let pool = Arc::new(
1672            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1673                "tcp://localhost:61616",
1674                BrokerType::ActiveMq,
1675            ))
1676            .unwrap(),
1677        );
1678
1679        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1680        let mut producer = LazyJmsProducer {
1681            pool: Arc::clone(&pool),
1682            broker_name: "default".to_string(),
1683            endpoint_config,
1684            resolved_broker_type: BrokerType::ActiveMq,
1685            runtime: test_rt(),
1686        };
1687
1688        // No slot exists yet — poll_ready should return Ready so call() can start the bridge.
1689        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1690        assert!(
1691            matches!(result, Poll::Ready(Ok(()))),
1692            "poll_ready must be Ready(Ok) when no slot exists; got: {:?}",
1693            result
1694        );
1695    }
1696
1697    // ── JMS-001: Health monitor lifecycle ────────────────────────────────────
1698
1699    #[tokio::test]
1700    async fn pool_shutdown_awaits_health_monitor() {
1701        use tokio::sync::watch;
1702
1703        let pool = Arc::new(
1704            JmsBridgePool::from_config(JmsPoolConfig {
1705                brokers: HashMap::from([(
1706                    "default".to_string(),
1707                    BrokerConfig {
1708                        broker_url: "tcp://localhost:61616".to_string(),
1709                        broker_type: BrokerType::ActiveMq,
1710                        username: None,
1711                        password: None,
1712                    },
1713                )]),
1714                health_check_interval_ms: 100,
1715                ..JmsPoolConfig::default()
1716            })
1717            .unwrap(),
1718        );
1719
1720        // Create a slot manually with a spawned health monitor task.
1721        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
1722            channel: tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy(),
1723        });
1724        let monitor_handle_ref: Arc<tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>> =
1725            Arc::new(tokio::sync::Mutex::new(None));
1726
1727        // Spawn a simple monitor that exits on Stopped.
1728        let state_rx_clone = state_rx.clone();
1729        let handle = tokio::spawn(async move {
1730            loop {
1731                if matches!(*state_rx_clone.borrow(), BridgeState::Stopped) {
1732                    break;
1733                }
1734                tokio::time::sleep(Duration::from_millis(50)).await;
1735            }
1736        });
1737        *monitor_handle_ref.lock().await = Some(handle);
1738
1739        let slot = Arc::new(BridgeSlot {
1740            name: "default".to_string(),
1741            broker_url: "tcp://localhost:61616".to_string(),
1742            broker_type: BrokerType::ActiveMq,
1743            credentials: None,
1744            state_rx,
1745            state_tx,
1746            process: Arc::new(tokio::sync::Mutex::new(None)),
1747            health_monitor_handle: monitor_handle_ref,
1748        });
1749        pool.slots.insert("default".to_string(), slot);
1750
1751        // Shutdown should complete without hanging — the monitor exits on Stopped.
1752        let result = pool.shutdown().await;
1753        // May report errors from bridge process (none in this test), but must not hang.
1754        let _ = result;
1755    }
1756
1757    #[tokio::test]
1758    async fn health_monitor_handle_stored_after_spawn() {
1759        use tokio::sync::watch;
1760
1761        let pool = Arc::new(
1762            JmsBridgePool::from_config(JmsPoolConfig {
1763                brokers: HashMap::from([(
1764                    "default".to_string(),
1765                    BrokerConfig {
1766                        broker_url: "tcp://localhost:61616".to_string(),
1767                        broker_type: BrokerType::ActiveMq,
1768                        username: None,
1769                        password: None,
1770                    },
1771                )]),
1772                health_check_interval_ms: 100,
1773                bridge_start_timeout_ms: 100,
1774                ..JmsPoolConfig::default()
1775            })
1776            .unwrap(),
1777        );
1778
1779        let (state_tx, state_rx) = watch::channel(BridgeState::Stopped);
1780        let slot = Arc::new(BridgeSlot {
1781            name: "default".to_string(),
1782            broker_url: "tcp://localhost:61616".to_string(),
1783            broker_type: BrokerType::ActiveMq,
1784            credentials: None,
1785            state_rx,
1786            state_tx,
1787            process: Arc::new(tokio::sync::Mutex::new(None)),
1788            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1789        });
1790        pool.slots.insert("default".to_string(), Arc::clone(&slot));
1791
1792        pool.spawn_health_monitor(Arc::clone(&slot)).await;
1793
1794        tokio::time::sleep(Duration::from_millis(50)).await;
1795        let guard = slot.health_monitor_handle.lock().await;
1796        assert!(
1797            guard.is_some(),
1798            "health monitor handle must be stored after spawn_health_monitor"
1799        );
1800    }
1801
1802    // ── JMS-008: URL redaction for safe logging ──────────────────────────────
1803
1804    #[test]
1805    fn redact_url_strips_userinfo_with_password() {
1806        assert_eq!(
1807            redact_url("tcp://admin:s3cret@broker:61616"),
1808            "tcp://***@broker:61616"
1809        );
1810    }
1811
1812    #[test]
1813    fn redact_url_strips_userinfo_without_password() {
1814        assert_eq!(
1815            redact_url("tcp://admin@broker:61616"),
1816            "tcp://***@broker:61616"
1817        );
1818    }
1819
1820    #[test]
1821    fn redact_url_passes_clean_url_unchanged() {
1822        assert_eq!(redact_url("tcp://localhost:61616"), "tcp://localhost:61616");
1823    }
1824
1825    #[test]
1826    fn redact_url_handles_ssl_scheme() {
1827        assert_eq!(
1828            redact_url("ssl://user:pass@secure-broker:61617"),
1829            "ssl://***@secure-broker:61617"
1830        );
1831    }
1832
1833    // ── JMS-009: max_bridges race condition under concurrency ────────────────
1834
1835    #[tokio::test]
1836    async fn concurrent_slot_creation_respects_max_bridges() {
1837        let pool = Arc::new(
1838            JmsBridgePool::from_config(JmsPoolConfig {
1839                brokers: HashMap::from([
1840                    (
1841                        "b1".to_string(),
1842                        BrokerConfig {
1843                            broker_url: "tcp://b1:61616".to_string(),
1844                            broker_type: BrokerType::ActiveMq,
1845                            username: None,
1846                            password: None,
1847                        },
1848                    ),
1849                    (
1850                        "b2".to_string(),
1851                        BrokerConfig {
1852                            broker_url: "tcp://b2:61616".to_string(),
1853                            broker_type: BrokerType::ActiveMq,
1854                            username: None,
1855                            password: None,
1856                        },
1857                    ),
1858                    (
1859                        "b3".to_string(),
1860                        BrokerConfig {
1861                            broker_url: "tcp://b3:61616".to_string(),
1862                            broker_type: BrokerType::ActiveMq,
1863                            username: None,
1864                            password: None,
1865                        },
1866                    ),
1867                ]),
1868                max_bridges: 2,
1869                bridge_start_timeout_ms: 100,
1870                ..JmsPoolConfig::default()
1871            })
1872            .unwrap(),
1873        );
1874
1875        let (state_tx, state_rx) = watch::channel(BridgeState::Starting);
1876        for name in &["b1", "b2"] {
1877            let slot = Arc::new(BridgeSlot {
1878                name: name.to_string(),
1879                broker_url: format!("tcp://{name}:61616"),
1880                broker_type: BrokerType::ActiveMq,
1881                credentials: None,
1882                state_rx: state_rx.clone(),
1883                state_tx: state_tx.clone(),
1884                process: Arc::new(tokio::sync::Mutex::new(None)),
1885                health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1886            });
1887            pool.slots.insert(name.to_string(), slot);
1888        }
1889
1890        assert_eq!(pool.slots.len(), 2);
1891
1892        let guard = pool.bridge_create_lock.lock().await;
1893        let total_count = pool.slots.len();
1894        assert!(total_count >= pool.max_bridges);
1895        let result = if total_count >= pool.max_bridges {
1896            Err(CamelError::Config(format!(
1897                "JMS bridge limit reached: {total_count} bridge(s) >= max_bridges ({})",
1898                pool.max_bridges
1899            )))
1900        } else {
1901            Ok(())
1902        };
1903        drop(guard);
1904
1905        assert!(result.is_err(), "3rd broker should be rejected");
1906        let err_msg = result.unwrap_err().to_string();
1907        assert!(
1908            err_msg.contains("max_bridges"),
1909            "error must mention max_bridges, got: {err_msg}"
1910        );
1911    }
1912
1913    // ── JMS-010: Transport error does NOT auto-resend ────────────────────────
1914
1915    #[tokio::test]
1916    async fn transport_error_refreshes_channel_but_does_not_resend() {
1917        use tokio::sync::watch;
1918        use tonic::transport::Endpoint as TonicEndpoint;
1919        use tower::Service;
1920
1921        let dead_channel = TonicEndpoint::from_static("http://127.0.0.1:1").connect_lazy();
1922
1923        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
1924            channel: dead_channel.clone(),
1925        });
1926
1927        let pool = Arc::new(
1928            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1929                "tcp://localhost:61616",
1930                BrokerType::ActiveMq,
1931            ))
1932            .unwrap(),
1933        );
1934
1935        let slot = Arc::new(BridgeSlot {
1936            name: "default".to_string(),
1937            broker_url: "tcp://localhost:61616".to_string(),
1938            broker_type: BrokerType::ActiveMq,
1939            credentials: None,
1940            state_rx: state_rx.clone(),
1941            state_tx: state_tx.clone(),
1942            process: Arc::new(tokio::sync::Mutex::new(None)),
1943            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1944        });
1945        pool.slots.insert("default".to_string(), Arc::clone(&slot));
1946
1947        let endpoint_config =
1948            crate::config::JmsEndpointConfig::from_uri("jms:queue:test-no-resend").unwrap();
1949
1950        let mut producer = LazyJmsProducer {
1951            pool: Arc::clone(&pool),
1952            broker_name: "default".to_string(),
1953            endpoint_config,
1954            resolved_broker_type: BrokerType::ActiveMq,
1955            runtime: test_rt(),
1956        };
1957
1958        let mut exchange = Exchange::default();
1959        exchange.input.body = camel_component_api::Body::Text("hello".to_string());
1960
1961        let result = producer.call(exchange).await;
1962        assert!(result.is_err(), "expected send to fail");
1963
1964        let state_after = state_rx.borrow().clone();
1965        assert!(
1966            matches!(state_after, BridgeState::Restarting { .. }),
1967            "slot must enter Restarting; got: {:?}",
1968            state_after
1969        );
1970
1971        let err_msg = result.unwrap_err().to_string();
1972        assert!(
1973            err_msg.contains(BRIDGE_TRANSPORT_ERROR_PREFIX),
1974            "error must be original transport error, got: {}",
1975            err_msg
1976        );
1977    }
1978
1979    // ── D-L12: Drop impl cleans up slots without explicit shutdown ───────────
1980
1981    #[tokio::test]
1982    async fn test_jms_bridge_pool_drop_cleans_up_slots() {
1983        use std::sync::atomic::{AtomicBool, Ordering};
1984        use tokio::sync::watch;
1985
1986        let pool = Arc::new(
1987            JmsBridgePool::from_config(JmsPoolConfig {
1988                brokers: HashMap::from([(
1989                    "default".to_string(),
1990                    BrokerConfig {
1991                        broker_url: "tcp://localhost:61616".to_string(),
1992                        broker_type: BrokerType::ActiveMq,
1993                        username: None,
1994                        password: None,
1995                    },
1996                )]),
1997                health_check_interval_ms: 50,
1998                ..JmsPoolConfig::default()
1999            })
2000            .unwrap(),
2001        );
2002
2003        // Create a slot with a health monitor that exits on BridgeState::Stopped.
2004        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
2005            channel: tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy(),
2006        });
2007
2008        let monitor_exited = Arc::new(AtomicBool::new(false));
2009        let exited = monitor_exited.clone();
2010        let rx = state_rx.clone();
2011        let handle = tokio::spawn(async move {
2012            loop {
2013                if matches!(*rx.borrow(), BridgeState::Stopped) {
2014                    break;
2015                }
2016                tokio::time::sleep(Duration::from_millis(10)).await;
2017            }
2018            exited.store(true, Ordering::SeqCst);
2019        });
2020
2021        let monitor_handle_ref: Arc<tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>> =
2022            Arc::new(tokio::sync::Mutex::new(None));
2023        *monitor_handle_ref.lock().await = Some(handle);
2024
2025        let slot = Arc::new(BridgeSlot {
2026            name: "default".to_string(),
2027            broker_url: "tcp://localhost:61616".to_string(),
2028            broker_type: BrokerType::ActiveMq,
2029            credentials: None,
2030            state_rx,
2031            state_tx,
2032            process: Arc::new(tokio::sync::Mutex::new(None)),
2033            health_monitor_handle: monitor_handle_ref,
2034        });
2035        pool.slots.insert("default".to_string(), slot);
2036
2037        // Drop the pool WITHOUT calling shutdown() — the Drop impl must fire.
2038        drop(pool);
2039
2040        // Give the spawned cleanup task time to send Stopped and await the monitor.
2041        tokio::time::sleep(Duration::from_millis(200)).await;
2042
2043        assert!(
2044            monitor_exited.load(Ordering::SeqCst),
2045            "health monitor should have exited after pool drop (Stopped signal sent by Drop)"
2046        );
2047    }
2048}