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    #[allow(clippy::await_holding_lock)]
1123    async fn concurrent_get_or_create_slot_no_deadlock() {
1124        use tokio::time::timeout;
1125
1126        // Serialize against other tests mutating CAMEL_JMS_BRIDGE_BINARY_PATH (rc-alwn).
1127        let _env_lock_guard = crate::BRIDGE_ENV_LOCK.lock().unwrap();
1128
1129        struct EnvGuard {
1130            key: &'static str,
1131            prev: Option<std::ffi::OsString>,
1132        }
1133        impl Drop for EnvGuard {
1134            fn drop(&mut self) {
1135                if let Some(v) = &self.prev {
1136                    // SAFETY: restoring process env in test scope.
1137                    unsafe { std::env::set_var(self.key, v) };
1138                } else {
1139                    // SAFETY: restoring process env in test scope.
1140                    unsafe { std::env::remove_var(self.key) };
1141                }
1142            }
1143        }
1144
1145        let env_key = "CAMEL_JMS_BRIDGE_BINARY_PATH";
1146        let _guard = EnvGuard {
1147            key: env_key,
1148            prev: std::env::var_os(env_key),
1149        };
1150        // SAFETY: test-scoped env mutation.
1151        unsafe { std::env::set_var(env_key, "/bin/false") };
1152
1153        let pool = Arc::new(
1154            JmsBridgePool::from_config(JmsPoolConfig {
1155                brokers: HashMap::from([(
1156                    "test".to_string(),
1157                    BrokerConfig {
1158                        broker_url: "tcp://localhost:61616".to_string(),
1159                        broker_type: BrokerType::ActiveMq,
1160                        username: None,
1161                        password: None,
1162                    },
1163                )]),
1164                bridge_start_timeout_ms: 100,
1165                ..JmsPoolConfig::default()
1166            })
1167            .unwrap(),
1168        );
1169
1170        let handles: Vec<_> = (0..5)
1171            .map(|_| {
1172                let pool = Arc::clone(&pool);
1173                tokio::spawn(async move {
1174                    let _ = pool.get_or_create_slot("test").await;
1175                })
1176            })
1177            .collect();
1178
1179        let result = timeout(Duration::from_secs(5), async {
1180            for h in handles {
1181                let _ = h.await;
1182            }
1183        })
1184        .await;
1185
1186        assert!(result.is_ok(), "Concurrent get_or_create_slot deadlocked!");
1187    }
1188
1189    #[tokio::test]
1190    #[allow(clippy::await_holding_lock)]
1191    async fn lazy_producer_reports_degraded_when_bridge_start_fails() {
1192        use tower::Service;
1193
1194        // Serialize against other tests mutating CAMEL_JMS_BRIDGE_BINARY_PATH (rc-alwn).
1195        let _env_lock_guard = crate::BRIDGE_ENV_LOCK.lock().unwrap();
1196
1197        struct EnvGuard {
1198            key: &'static str,
1199            prev: Option<std::ffi::OsString>,
1200        }
1201        impl Drop for EnvGuard {
1202            fn drop(&mut self) {
1203                if let Some(v) = &self.prev {
1204                    // SAFETY: restoring process env in test scope.
1205                    unsafe { std::env::set_var(self.key, v) };
1206                } else {
1207                    // SAFETY: restoring process env in test scope.
1208                    unsafe { std::env::remove_var(self.key) };
1209                }
1210            }
1211        }
1212
1213        let env_key = "CAMEL_JMS_BRIDGE_BINARY_PATH";
1214        let _guard = EnvGuard {
1215            key: env_key,
1216            prev: std::env::var_os(env_key),
1217        };
1218        // SAFETY: test-scoped env mutation.
1219        unsafe { std::env::set_var(env_key, "/bin/false") };
1220
1221        let pool = Arc::new(
1222            JmsBridgePool::from_config(JmsPoolConfig {
1223                brokers: HashMap::from([(
1224                    "default".to_string(),
1225                    BrokerConfig {
1226                        broker_url: "tcp://localhost:61616".to_string(),
1227                        broker_type: BrokerType::ActiveMq,
1228                        username: None,
1229                        password: None,
1230                    },
1231                )]),
1232                bridge_start_timeout_ms: 100,
1233                ..JmsPoolConfig::default()
1234            })
1235            .unwrap(),
1236        );
1237
1238        let component = JmsComponent::with_scheme("jms", pool);
1239        let endpoint = component
1240            .create_endpoint(
1241                "jms:queue:orders",
1242                &camel_component_api::NoOpComponentContext,
1243            )
1244            .unwrap();
1245        let mut producer = endpoint
1246            .create_producer(rt(), &camel_component_api::ProducerContext::default())
1247            .unwrap();
1248
1249        let mut exchange = Exchange::default();
1250        exchange.input.body = camel_component_api::Body::Text("hello".to_string());
1251
1252        let err = producer.call(exchange).await.unwrap_err();
1253        assert!(err.to_string().contains("is degraded"), "got: {}", err);
1254    }
1255
1256    /// A send transport error should trigger a channel refresh attempt first.
1257    /// If refresh cannot be performed (e.g. no running bridge process metadata),
1258    /// the producer requests a bridge restart as fallback.
1259    #[tokio::test]
1260    async fn lazy_producer_requests_restart_when_refresh_unavailable() {
1261        use tokio::sync::watch;
1262        use tonic::transport::Endpoint as TonicEndpoint;
1263        use tower::Service;
1264
1265        // Build a lazy channel to a port where nothing is listening.
1266        // connect_lazy() succeeds immediately; the error manifests on the actual RPC call.
1267        let dead_channel = TonicEndpoint::from_static("http://127.0.0.1:1").connect_lazy();
1268
1269        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
1270            channel: dead_channel.clone(),
1271        });
1272
1273        let pool = Arc::new(
1274            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1275                "tcp://localhost:61616",
1276                BrokerType::ActiveMq,
1277            ))
1278            .unwrap(),
1279        );
1280
1281        // Manually insert a slot with the dead-channel in Ready state.
1282        let slot = Arc::new(BridgeSlot {
1283            name: "default".to_string(),
1284            broker_url: "tcp://localhost:61616".to_string(),
1285            broker_type: BrokerType::ActiveMq,
1286            credentials: None,
1287            state_rx: state_rx.clone(),
1288            state_tx: state_tx.clone(),
1289            process: Arc::new(tokio::sync::Mutex::new(None)),
1290            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1291        });
1292        pool.slots.insert("default".to_string(), Arc::clone(&slot));
1293
1294        let endpoint_config =
1295            crate::config::JmsEndpointConfig::from_uri("jms:queue:test-retry").unwrap();
1296
1297        let mut producer = LazyJmsProducer {
1298            pool: Arc::clone(&pool),
1299            broker_name: "default".to_string(),
1300            endpoint_config,
1301            resolved_broker_type: BrokerType::ActiveMq,
1302            runtime: test_rt(),
1303        };
1304
1305        let mut exchange = Exchange::default();
1306        exchange.input.body = camel_component_api::Body::Text("hello".to_string());
1307
1308        // The send will fail because the channel points to a dead port.
1309        let result = producer.call(exchange).await;
1310        assert!(result.is_err(), "expected send to fail");
1311
1312        // Refresh cannot run in this setup (slot has no BridgeProcess), so the
1313        // fallback path requests a restart.
1314        let state_after = state_rx.borrow().clone();
1315        assert!(
1316            matches!(state_after, BridgeState::Restarting { .. }),
1317            "slot must enter Restarting when refresh is unavailable; got: {:?}",
1318            state_after
1319        );
1320    }
1321
1322    // ── JMS-007: Transport error classification ──────────────────────────────
1323
1324    #[test]
1325    fn transport_error_detects_send_error() {
1326        let err = CamelError::ProcessorError(format!(
1327            "{}send error: connection refused",
1328            BRIDGE_TRANSPORT_ERROR_PREFIX
1329        ));
1330        assert!(
1331            is_bridge_transport_error(&err),
1332            "send error must be classified as transport"
1333        );
1334    }
1335
1336    #[test]
1337    fn transport_error_detects_subscribe_error() {
1338        let err = CamelError::ProcessorError(format!(
1339            "{}subscribe error: stream reset",
1340            BRIDGE_TRANSPORT_ERROR_PREFIX
1341        ));
1342        assert!(
1343            is_bridge_transport_error(&err),
1344            "subscribe error must be classified as transport"
1345        );
1346    }
1347
1348    #[test]
1349    fn transport_error_rejects_business_errors() {
1350        let err = CamelError::ProcessorError("JMS broker 'main' is degraded: timeout".to_string());
1351        assert!(
1352            !is_bridge_transport_error(&err),
1353            "degraded state error must NOT be transport"
1354        );
1355    }
1356
1357    #[test]
1358    fn transport_error_rejects_config_errors() {
1359        let err = CamelError::Config("bridge_start_timeout_ms must be > 0".to_string());
1360        assert!(
1361            !is_bridge_transport_error(&err),
1362            "config error must NOT be transport"
1363        );
1364    }
1365
1366    #[test]
1367    fn transport_error_prefix_is_used_by_producer_and_consumer() {
1368        // Verify the constant prefix matches what producer.rs and consumer.rs emit.
1369        // If this test fails, the constant has drifted from the error format strings.
1370        assert!(
1371            BRIDGE_TRANSPORT_ERROR_PREFIX.starts_with("JMS gRPC "),
1372            "prefix must start with 'JMS gRPC '"
1373        );
1374    }
1375
1376    // ── JMS-006: max_bridges enforcement ─────────────────────────────────────
1377
1378    #[tokio::test]
1379    async fn pool_enforces_max_bridges_limit() {
1380        use tokio::sync::watch;
1381
1382        let pool = Arc::new(
1383            JmsBridgePool::from_config(JmsPoolConfig {
1384                brokers: HashMap::from([
1385                    (
1386                        "b1".to_string(),
1387                        BrokerConfig {
1388                            broker_url: "tcp://b1:61616".to_string(),
1389                            broker_type: BrokerType::ActiveMq,
1390                            username: None,
1391                            password: None,
1392                        },
1393                    ),
1394                    (
1395                        "b2".to_string(),
1396                        BrokerConfig {
1397                            broker_url: "tcp://b2:61616".to_string(),
1398                            broker_type: BrokerType::ActiveMq,
1399                            username: None,
1400                            password: None,
1401                        },
1402                    ),
1403                    (
1404                        "b3".to_string(),
1405                        BrokerConfig {
1406                            broker_url: "tcp://b3:61616".to_string(),
1407                            broker_type: BrokerType::ActiveMq,
1408                            username: None,
1409                            password: None,
1410                        },
1411                    ),
1412                ]),
1413                max_bridges: 2,
1414                ..JmsPoolConfig::default()
1415            })
1416            .unwrap(),
1417        );
1418
1419        // Manually insert two slots to simulate existing bridges.
1420        // max_bridges counts ALL slots in the map (not just active states).
1421        for name in &["b1", "b2"] {
1422            let (state_tx, state_rx) = watch::channel(BridgeState::Starting);
1423            let slot = Arc::new(BridgeSlot {
1424                name: name.to_string(),
1425                broker_url: format!("tcp://{name}:61616"),
1426                broker_type: BrokerType::ActiveMq,
1427                credentials: None,
1428                state_rx,
1429                state_tx,
1430                process: Arc::new(tokio::sync::Mutex::new(None)),
1431                health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1432            });
1433            pool.slots.insert(name.to_string(), slot);
1434        }
1435
1436        // Attempting to create a third slot should fail.
1437        let err = pool.get_or_create_slot("b3").await.unwrap_err();
1438        assert!(
1439            err.to_string().contains("max_bridges"),
1440            "expected max_bridges error, got: {}",
1441            err
1442        );
1443    }
1444
1445    #[tokio::test]
1446    async fn pool_allows_slot_when_below_max_bridges() {
1447        use tokio::sync::watch;
1448
1449        let pool = Arc::new(
1450            JmsBridgePool::from_config(JmsPoolConfig {
1451                brokers: HashMap::from([(
1452                    "b1".to_string(),
1453                    BrokerConfig {
1454                        broker_url: "tcp://b1:61616".to_string(),
1455                        broker_type: BrokerType::ActiveMq,
1456                        username: None,
1457                        password: None,
1458                    },
1459                )]),
1460                max_bridges: 2,
1461                bridge_start_timeout_ms: 100,
1462                ..JmsPoolConfig::default()
1463            })
1464            .unwrap(),
1465        );
1466
1467        // Insert one slot in Degraded state (not counted as active).
1468        let (state_tx, state_rx) = watch::channel(BridgeState::Degraded("test".to_string()));
1469        let slot = Arc::new(BridgeSlot {
1470            name: "b1".to_string(),
1471            broker_url: "tcp://b1:61616".to_string(),
1472            broker_type: BrokerType::ActiveMq,
1473            credentials: None,
1474            state_rx,
1475            state_tx,
1476            process: Arc::new(tokio::sync::Mutex::new(None)),
1477            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1478        });
1479        pool.slots.insert("b1".to_string(), slot);
1480
1481        // b1 is Degraded (not active), so creating b1's slot returns existing.
1482        // The max_bridges check only applies to new slots.
1483        let result = pool.get_or_create_slot("b1").await;
1484        assert!(result.is_ok(), "existing slot must be returned");
1485    }
1486
1487    // ── JMS-003: poll_ready reflects bridge state ────────────────────────────
1488
1489    #[tokio::test]
1490    async fn poll_ready_returns_pending_when_starting() {
1491        use tokio::sync::watch;
1492        use tower::Service;
1493
1494        let pool = Arc::new(
1495            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1496                "tcp://localhost:61616",
1497                BrokerType::ActiveMq,
1498            ))
1499            .unwrap(),
1500        );
1501
1502        let (state_tx, state_rx) = watch::channel(BridgeState::Starting);
1503        let slot = Arc::new(BridgeSlot {
1504            name: "default".to_string(),
1505            broker_url: "tcp://localhost:61616".to_string(),
1506            broker_type: BrokerType::ActiveMq,
1507            credentials: None,
1508            state_rx,
1509            state_tx,
1510            process: Arc::new(tokio::sync::Mutex::new(None)),
1511            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1512        });
1513        pool.slots.insert("default".to_string(), slot);
1514
1515        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1516        let mut producer = LazyJmsProducer {
1517            pool: Arc::clone(&pool),
1518            broker_name: "default".to_string(),
1519            endpoint_config,
1520            resolved_broker_type: BrokerType::ActiveMq,
1521            runtime: test_rt(),
1522        };
1523
1524        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1525        assert!(
1526            matches!(result, Poll::Pending),
1527            "poll_ready must be Pending when Starting; got: {:?}",
1528            result
1529        );
1530    }
1531
1532    #[tokio::test]
1533    async fn poll_ready_returns_error_when_degraded() {
1534        use tokio::sync::watch;
1535        use tower::Service;
1536
1537        let pool = Arc::new(
1538            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1539                "tcp://localhost:61616",
1540                BrokerType::ActiveMq,
1541            ))
1542            .unwrap(),
1543        );
1544
1545        let (state_tx, state_rx) =
1546            watch::channel(BridgeState::Degraded("health check failed".to_string()));
1547        let slot = Arc::new(BridgeSlot {
1548            name: "default".to_string(),
1549            broker_url: "tcp://localhost:61616".to_string(),
1550            broker_type: BrokerType::ActiveMq,
1551            credentials: None,
1552            state_rx,
1553            state_tx,
1554            process: Arc::new(tokio::sync::Mutex::new(None)),
1555            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1556        });
1557        pool.slots.insert("default".to_string(), slot);
1558
1559        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1560        let mut producer = LazyJmsProducer {
1561            pool: Arc::clone(&pool),
1562            broker_name: "default".to_string(),
1563            endpoint_config,
1564            resolved_broker_type: BrokerType::ActiveMq,
1565            runtime: test_rt(),
1566        };
1567
1568        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1569        assert!(
1570            matches!(result, Poll::Ready(Err(_))),
1571            "poll_ready must be Err when Degraded; got: {:?}",
1572            result
1573        );
1574        let err_msg = match result {
1575            Poll::Ready(Err(e)) => e.to_string(),
1576            _ => unreachable!(),
1577        };
1578        assert!(
1579            err_msg.contains("degraded"),
1580            "error must mention degraded: {}",
1581            err_msg
1582        );
1583    }
1584
1585    #[tokio::test]
1586    async fn poll_ready_returns_error_when_stopped() {
1587        use tokio::sync::watch;
1588        use tower::Service;
1589
1590        let pool = Arc::new(
1591            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1592                "tcp://localhost:61616",
1593                BrokerType::ActiveMq,
1594            ))
1595            .unwrap(),
1596        );
1597
1598        let (state_tx, state_rx) = watch::channel(BridgeState::Stopped);
1599        let slot = Arc::new(BridgeSlot {
1600            name: "default".to_string(),
1601            broker_url: "tcp://localhost:61616".to_string(),
1602            broker_type: BrokerType::ActiveMq,
1603            credentials: None,
1604            state_rx,
1605            state_tx,
1606            process: Arc::new(tokio::sync::Mutex::new(None)),
1607            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1608        });
1609        pool.slots.insert("default".to_string(), slot);
1610
1611        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1612        let mut producer = LazyJmsProducer {
1613            pool: Arc::clone(&pool),
1614            broker_name: "default".to_string(),
1615            endpoint_config,
1616            resolved_broker_type: BrokerType::ActiveMq,
1617            runtime: test_rt(),
1618        };
1619
1620        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1621        assert!(
1622            matches!(result, Poll::Ready(Err(_))),
1623            "poll_ready must be Err when Stopped; got: {:?}",
1624            result
1625        );
1626    }
1627
1628    #[tokio::test]
1629    async fn poll_ready_returns_ready_when_slot_ready() {
1630        use tokio::sync::watch;
1631        use tonic::transport::Endpoint as TonicEndpoint;
1632        use tower::Service;
1633
1634        let pool = Arc::new(
1635            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1636                "tcp://localhost:61616",
1637                BrokerType::ActiveMq,
1638            ))
1639            .unwrap(),
1640        );
1641
1642        let lazy_channel = TonicEndpoint::from_static("http://127.0.0.1:1").connect_lazy();
1643        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
1644            channel: lazy_channel,
1645        });
1646        let slot = Arc::new(BridgeSlot {
1647            name: "default".to_string(),
1648            broker_url: "tcp://localhost:61616".to_string(),
1649            broker_type: BrokerType::ActiveMq,
1650            credentials: None,
1651            state_rx,
1652            state_tx,
1653            process: Arc::new(tokio::sync::Mutex::new(None)),
1654            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1655        });
1656        pool.slots.insert("default".to_string(), slot);
1657
1658        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1659        let mut producer = LazyJmsProducer {
1660            pool: Arc::clone(&pool),
1661            broker_name: "default".to_string(),
1662            endpoint_config,
1663            resolved_broker_type: BrokerType::ActiveMq,
1664            runtime: test_rt(),
1665        };
1666
1667        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1668        assert!(
1669            matches!(result, Poll::Ready(Ok(()))),
1670            "poll_ready must be Ready(Ok) when bridge is Ready; got: {:?}",
1671            result
1672        );
1673    }
1674
1675    #[tokio::test]
1676    async fn poll_ready_returns_ready_when_no_slot_exists() {
1677        use tower::Service;
1678
1679        let pool = Arc::new(
1680            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1681                "tcp://localhost:61616",
1682                BrokerType::ActiveMq,
1683            ))
1684            .unwrap(),
1685        );
1686
1687        let endpoint_config = crate::config::JmsEndpointConfig::from_uri("jms:queue:test").unwrap();
1688        let mut producer = LazyJmsProducer {
1689            pool: Arc::clone(&pool),
1690            broker_name: "default".to_string(),
1691            endpoint_config,
1692            resolved_broker_type: BrokerType::ActiveMq,
1693            runtime: test_rt(),
1694        };
1695
1696        // No slot exists yet — poll_ready should return Ready so call() can start the bridge.
1697        let result = producer.poll_ready(&mut Context::from_waker(futures::task::noop_waker_ref()));
1698        assert!(
1699            matches!(result, Poll::Ready(Ok(()))),
1700            "poll_ready must be Ready(Ok) when no slot exists; got: {:?}",
1701            result
1702        );
1703    }
1704
1705    // ── JMS-001: Health monitor lifecycle ────────────────────────────────────
1706
1707    #[tokio::test]
1708    async fn pool_shutdown_awaits_health_monitor() {
1709        use tokio::sync::watch;
1710
1711        let pool = Arc::new(
1712            JmsBridgePool::from_config(JmsPoolConfig {
1713                brokers: HashMap::from([(
1714                    "default".to_string(),
1715                    BrokerConfig {
1716                        broker_url: "tcp://localhost:61616".to_string(),
1717                        broker_type: BrokerType::ActiveMq,
1718                        username: None,
1719                        password: None,
1720                    },
1721                )]),
1722                health_check_interval_ms: 100,
1723                ..JmsPoolConfig::default()
1724            })
1725            .unwrap(),
1726        );
1727
1728        // Create a slot manually with a spawned health monitor task.
1729        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
1730            channel: tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy(),
1731        });
1732        let monitor_handle_ref: Arc<tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>> =
1733            Arc::new(tokio::sync::Mutex::new(None));
1734
1735        // Spawn a simple monitor that exits on Stopped.
1736        let state_rx_clone = state_rx.clone();
1737        let handle = tokio::spawn(async move {
1738            loop {
1739                if matches!(*state_rx_clone.borrow(), BridgeState::Stopped) {
1740                    break;
1741                }
1742                tokio::time::sleep(Duration::from_millis(50)).await;
1743            }
1744        });
1745        *monitor_handle_ref.lock().await = Some(handle);
1746
1747        let slot = Arc::new(BridgeSlot {
1748            name: "default".to_string(),
1749            broker_url: "tcp://localhost:61616".to_string(),
1750            broker_type: BrokerType::ActiveMq,
1751            credentials: None,
1752            state_rx,
1753            state_tx,
1754            process: Arc::new(tokio::sync::Mutex::new(None)),
1755            health_monitor_handle: monitor_handle_ref,
1756        });
1757        pool.slots.insert("default".to_string(), slot);
1758
1759        // Shutdown should complete without hanging — the monitor exits on Stopped.
1760        let result = pool.shutdown().await;
1761        // May report errors from bridge process (none in this test), but must not hang.
1762        let _ = result;
1763    }
1764
1765    #[tokio::test]
1766    async fn health_monitor_handle_stored_after_spawn() {
1767        use tokio::sync::watch;
1768
1769        let pool = Arc::new(
1770            JmsBridgePool::from_config(JmsPoolConfig {
1771                brokers: HashMap::from([(
1772                    "default".to_string(),
1773                    BrokerConfig {
1774                        broker_url: "tcp://localhost:61616".to_string(),
1775                        broker_type: BrokerType::ActiveMq,
1776                        username: None,
1777                        password: None,
1778                    },
1779                )]),
1780                health_check_interval_ms: 100,
1781                bridge_start_timeout_ms: 100,
1782                ..JmsPoolConfig::default()
1783            })
1784            .unwrap(),
1785        );
1786
1787        let (state_tx, state_rx) = watch::channel(BridgeState::Stopped);
1788        let slot = Arc::new(BridgeSlot {
1789            name: "default".to_string(),
1790            broker_url: "tcp://localhost:61616".to_string(),
1791            broker_type: BrokerType::ActiveMq,
1792            credentials: None,
1793            state_rx,
1794            state_tx,
1795            process: Arc::new(tokio::sync::Mutex::new(None)),
1796            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1797        });
1798        pool.slots.insert("default".to_string(), Arc::clone(&slot));
1799
1800        pool.spawn_health_monitor(Arc::clone(&slot)).await;
1801
1802        tokio::time::sleep(Duration::from_millis(50)).await;
1803        let guard = slot.health_monitor_handle.lock().await;
1804        assert!(
1805            guard.is_some(),
1806            "health monitor handle must be stored after spawn_health_monitor"
1807        );
1808    }
1809
1810    // ── JMS-008: URL redaction for safe logging ──────────────────────────────
1811
1812    #[test]
1813    fn redact_url_strips_userinfo_with_password() {
1814        assert_eq!(
1815            redact_url("tcp://admin:s3cret@broker:61616"),
1816            "tcp://***@broker:61616"
1817        );
1818    }
1819
1820    #[test]
1821    fn redact_url_strips_userinfo_without_password() {
1822        assert_eq!(
1823            redact_url("tcp://admin@broker:61616"),
1824            "tcp://***@broker:61616"
1825        );
1826    }
1827
1828    #[test]
1829    fn redact_url_passes_clean_url_unchanged() {
1830        assert_eq!(redact_url("tcp://localhost:61616"), "tcp://localhost:61616");
1831    }
1832
1833    #[test]
1834    fn redact_url_handles_ssl_scheme() {
1835        assert_eq!(
1836            redact_url("ssl://user:pass@secure-broker:61617"),
1837            "ssl://***@secure-broker:61617"
1838        );
1839    }
1840
1841    // ── JMS-009: max_bridges race condition under concurrency ────────────────
1842
1843    #[tokio::test]
1844    async fn concurrent_slot_creation_respects_max_bridges() {
1845        let pool = Arc::new(
1846            JmsBridgePool::from_config(JmsPoolConfig {
1847                brokers: HashMap::from([
1848                    (
1849                        "b1".to_string(),
1850                        BrokerConfig {
1851                            broker_url: "tcp://b1:61616".to_string(),
1852                            broker_type: BrokerType::ActiveMq,
1853                            username: None,
1854                            password: None,
1855                        },
1856                    ),
1857                    (
1858                        "b2".to_string(),
1859                        BrokerConfig {
1860                            broker_url: "tcp://b2:61616".to_string(),
1861                            broker_type: BrokerType::ActiveMq,
1862                            username: None,
1863                            password: None,
1864                        },
1865                    ),
1866                    (
1867                        "b3".to_string(),
1868                        BrokerConfig {
1869                            broker_url: "tcp://b3:61616".to_string(),
1870                            broker_type: BrokerType::ActiveMq,
1871                            username: None,
1872                            password: None,
1873                        },
1874                    ),
1875                ]),
1876                max_bridges: 2,
1877                bridge_start_timeout_ms: 100,
1878                ..JmsPoolConfig::default()
1879            })
1880            .unwrap(),
1881        );
1882
1883        let (state_tx, state_rx) = watch::channel(BridgeState::Starting);
1884        for name in &["b1", "b2"] {
1885            let slot = Arc::new(BridgeSlot {
1886                name: name.to_string(),
1887                broker_url: format!("tcp://{name}:61616"),
1888                broker_type: BrokerType::ActiveMq,
1889                credentials: None,
1890                state_rx: state_rx.clone(),
1891                state_tx: state_tx.clone(),
1892                process: Arc::new(tokio::sync::Mutex::new(None)),
1893                health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1894            });
1895            pool.slots.insert(name.to_string(), slot);
1896        }
1897
1898        assert_eq!(pool.slots.len(), 2);
1899
1900        let guard = pool.bridge_create_lock.lock().await;
1901        let total_count = pool.slots.len();
1902        assert!(total_count >= pool.max_bridges);
1903        let result = if total_count >= pool.max_bridges {
1904            Err(CamelError::Config(format!(
1905                "JMS bridge limit reached: {total_count} bridge(s) >= max_bridges ({})",
1906                pool.max_bridges
1907            )))
1908        } else {
1909            Ok(())
1910        };
1911        drop(guard);
1912
1913        assert!(result.is_err(), "3rd broker should be rejected");
1914        let err_msg = result.unwrap_err().to_string();
1915        assert!(
1916            err_msg.contains("max_bridges"),
1917            "error must mention max_bridges, got: {err_msg}"
1918        );
1919    }
1920
1921    // ── JMS-010: Transport error does NOT auto-resend ────────────────────────
1922
1923    #[tokio::test]
1924    async fn transport_error_refreshes_channel_but_does_not_resend() {
1925        use tokio::sync::watch;
1926        use tonic::transport::Endpoint as TonicEndpoint;
1927        use tower::Service;
1928
1929        let dead_channel = TonicEndpoint::from_static("http://127.0.0.1:1").connect_lazy();
1930
1931        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
1932            channel: dead_channel.clone(),
1933        });
1934
1935        let pool = Arc::new(
1936            JmsBridgePool::from_config(JmsPoolConfig::single_broker(
1937                "tcp://localhost:61616",
1938                BrokerType::ActiveMq,
1939            ))
1940            .unwrap(),
1941        );
1942
1943        let slot = Arc::new(BridgeSlot {
1944            name: "default".to_string(),
1945            broker_url: "tcp://localhost:61616".to_string(),
1946            broker_type: BrokerType::ActiveMq,
1947            credentials: None,
1948            state_rx: state_rx.clone(),
1949            state_tx: state_tx.clone(),
1950            process: Arc::new(tokio::sync::Mutex::new(None)),
1951            health_monitor_handle: Arc::new(tokio::sync::Mutex::new(None)),
1952        });
1953        pool.slots.insert("default".to_string(), Arc::clone(&slot));
1954
1955        let endpoint_config =
1956            crate::config::JmsEndpointConfig::from_uri("jms:queue:test-no-resend").unwrap();
1957
1958        let mut producer = LazyJmsProducer {
1959            pool: Arc::clone(&pool),
1960            broker_name: "default".to_string(),
1961            endpoint_config,
1962            resolved_broker_type: BrokerType::ActiveMq,
1963            runtime: test_rt(),
1964        };
1965
1966        let mut exchange = Exchange::default();
1967        exchange.input.body = camel_component_api::Body::Text("hello".to_string());
1968
1969        let result = producer.call(exchange).await;
1970        assert!(result.is_err(), "expected send to fail");
1971
1972        let state_after = state_rx.borrow().clone();
1973        assert!(
1974            matches!(state_after, BridgeState::Restarting { .. }),
1975            "slot must enter Restarting; got: {:?}",
1976            state_after
1977        );
1978
1979        let err_msg = result.unwrap_err().to_string();
1980        assert!(
1981            err_msg.contains(BRIDGE_TRANSPORT_ERROR_PREFIX),
1982            "error must be original transport error, got: {}",
1983            err_msg
1984        );
1985    }
1986
1987    // ── D-L12: Drop impl cleans up slots without explicit shutdown ───────────
1988
1989    #[tokio::test]
1990    async fn test_jms_bridge_pool_drop_cleans_up_slots() {
1991        use std::sync::atomic::{AtomicBool, Ordering};
1992        use tokio::sync::watch;
1993
1994        let pool = Arc::new(
1995            JmsBridgePool::from_config(JmsPoolConfig {
1996                brokers: HashMap::from([(
1997                    "default".to_string(),
1998                    BrokerConfig {
1999                        broker_url: "tcp://localhost:61616".to_string(),
2000                        broker_type: BrokerType::ActiveMq,
2001                        username: None,
2002                        password: None,
2003                    },
2004                )]),
2005                health_check_interval_ms: 50,
2006                ..JmsPoolConfig::default()
2007            })
2008            .unwrap(),
2009        );
2010
2011        // Create a slot with a health monitor that exits on BridgeState::Stopped.
2012        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
2013            channel: tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy(),
2014        });
2015
2016        let monitor_exited = Arc::new(AtomicBool::new(false));
2017        let exited = monitor_exited.clone();
2018        let rx = state_rx.clone();
2019        let handle = tokio::spawn(async move {
2020            loop {
2021                if matches!(*rx.borrow(), BridgeState::Stopped) {
2022                    break;
2023                }
2024                tokio::time::sleep(Duration::from_millis(10)).await;
2025            }
2026            exited.store(true, Ordering::SeqCst);
2027        });
2028
2029        let monitor_handle_ref: Arc<tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>> =
2030            Arc::new(tokio::sync::Mutex::new(None));
2031        *monitor_handle_ref.lock().await = Some(handle);
2032
2033        let slot = Arc::new(BridgeSlot {
2034            name: "default".to_string(),
2035            broker_url: "tcp://localhost:61616".to_string(),
2036            broker_type: BrokerType::ActiveMq,
2037            credentials: None,
2038            state_rx,
2039            state_tx,
2040            process: Arc::new(tokio::sync::Mutex::new(None)),
2041            health_monitor_handle: monitor_handle_ref,
2042        });
2043        pool.slots.insert("default".to_string(), slot);
2044
2045        // Drop the pool WITHOUT calling shutdown() — the Drop impl must fire.
2046        drop(pool);
2047
2048        // Give the spawned cleanup task time to send Stopped and await the monitor.
2049        tokio::time::sleep(Duration::from_millis(200)).await;
2050
2051        assert!(
2052            monitor_exited.load(Ordering::SeqCst),
2053            "health monitor should have exited after pool drop (Stopped signal sent by Drop)"
2054        );
2055    }
2056}