Skip to main content

camel_core/lifecycle/adapters/
route_controller_trait.rs

1//! `RouteController` trait implementation for `DefaultRouteController`.
2//!
3//! Extracted from `route_controller.rs` to reduce file size. All lifecycle methods
4//! (start, stop, suspend, resume, etc.) live here.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11use tower::Service;
12use tracing::{error, info, warn};
13
14use camel_api::security_policy::RouteSecurityPlan;
15use camel_api::{CamelError, NoOpMetrics, StepLifecycle, StepShutdownReason};
16use camel_component_api::{ConcurrencyModel, ConsumerContext, consumer::ExchangeEnvelope};
17
18use crate::lifecycle::adapters::consumer_management;
19use crate::lifecycle::adapters::controller_component_context::ControllerComponentContext;
20use crate::lifecycle::adapters::route_compiler::CANCEL_TOKEN;
21use crate::lifecycle::adapters::route_controller::DefaultRouteController;
22#[cfg(test)]
23use crate::lifecycle::adapters::route_helpers::emit_start_route_event;
24use crate::lifecycle::adapters::route_helpers::{
25    DrainGuard, handle_is_running, inferred_lifecycle_label, ready_with_backoff,
26};
27use crate::lifecycle::adapters::route_registry::DEFAULT_SHUTDOWN_TIMEOUT;
28
29/// Operator acknowledgements for public exposure per bind address and the
30/// per-bind exposure gate (ADR-0061).
31///
32/// Canonical home is `camel_auth::bind_gate` (moved in Task 2.6 so
33/// transports — which may not reference `camel_core::`, see
34/// `xtask lint-component-deps` — enforce the same gate; MCP's registry is
35/// the first). Re-exported here so controller call sites, the CLI, and the
36/// gate tests keep their historical import paths.
37pub use camel_auth::bind_gate::{BindExposureAcks, enforce_bind_exposure_gate};
38
39/// Canonical gate key + loopback classification for a listener `from` URI.
40/// Only listener schemes (http/https/ws/wss/grpc) bind sockets, so only they gate;
41/// `mcp:` binds live in `McpServerConfig` (gated at the McpServerRegistry level,
42/// Task 2.6) and everything else (timer, direct) never binds.
43pub(super) struct BindKey {
44    pub(super) key: String,
45    pub(super) loopback: bool,
46}
47
48pub(super) fn bind_key_from_uri(uri: &str) -> Option<BindKey> {
49    let scheme = uri.split(':').next()?;
50    if !matches!(scheme, "http" | "https" | "ws" | "wss" | "grpc") {
51        return None;
52    }
53    let authority = uri.split("://").nth(1)?;
54    let authority = authority.split('/').next()?;
55    if authority.is_empty() {
56        return None;
57    }
58    if let Ok(addr) = authority.parse::<std::net::SocketAddr>() {
59        return Some(BindKey {
60            key: addr.to_string(),
61            loopback: addr.ip().is_loopback(),
62        });
63    }
64    // Hostname authority: loopback only for `localhost` (deterministic,
65    // fail-closed for every other hostname — no DNS). The host is the
66    // authority minus its port; bracketed IPv6 authorities are stripped
67    // to the bare host before the check.
68    let host = authority
69        .rsplit_once(':')
70        .map(|(h, _)| h)
71        .unwrap_or(authority)
72        .trim_matches(['[', ']']);
73    let loopback = host.eq_ignore_ascii_case("localhost");
74    Some(BindKey {
75        key: authority.to_string(),
76        loopback,
77    })
78}
79
80/// ADR-0061 Task 2.9 strict-mode dispatch check (the flip deferred from
81/// Task 2.2): every transport mints the typed carrier at its request
82/// boundary (grpc 2.1, mcp 2.6, ws 2.8, http 2.9), so a non-Public plan
83/// REQUIRES the carrier on the Exchange — absent or wrong-provider is
84/// denied BEFORE the pipeline runs; the transport renders the denial in
85/// its own idiom via `reply_tx`. Returns `true` when the dispatch was
86/// denied (caller must `continue`).
87fn strict_dispatch_denies(
88    dispatch_plan: &Option<camel_api::security_policy::RouteSecurityPlan>,
89    exchange: &camel_api::Exchange,
90    reply_tx: &mut Option<tokio::sync::oneshot::Sender<Result<camel_api::Exchange, CamelError>>>,
91    route_id: &str,
92) -> bool {
93    if let Some(plan) = dispatch_plan.as_ref()
94        && let Err(denial) = camel_auth::enforce_dispatch(plan, exchange)
95    {
96        if let Some(tx) = reply_tx.take() {
97            let _ = tx.send(Err(denial));
98        } else {
99            // log-policy: handler-owned
100            warn!(
101                route_id = %route_id,
102                error = %denial,
103                "dispatch denied: no kernel carrier on Exchange"
104            );
105        }
106        return true;
107    }
108    false
109}
110
111#[cfg(test)]
112mod bind_key_tests {
113    use super::bind_key_from_uri;
114
115    #[test]
116    fn https_and_wss_listeners_gate() {
117        assert_eq!(
118            bind_key_from_uri("https://0.0.0.0:8443/api").map(|b| b.key),
119            Some("0.0.0.0:8443".to_string())
120        );
121        assert_eq!(
122            bind_key_from_uri("wss://0.0.0.0:9000").map(|b| b.key),
123            Some("0.0.0.0:9000".to_string())
124        );
125    }
126
127    #[test]
128    fn non_listener_schemes_skip() {
129        assert!(bind_key_from_uri("timer:tick?period=1s").is_none());
130        assert!(bind_key_from_uri("mcp:server/tool/x").is_none());
131    }
132
133    #[test]
134    fn bracketed_ipv6_hostname_check_uses_bare_host() {
135        let b = bind_key_from_uri("ws://[::1]:8080/path").expect("parses"); // allow-unwrap
136        assert!(b.loopback, "[::1] is loopback");
137    }
138
139    #[test]
140    fn localhost_authority_with_port_is_loopback() {
141        // Regression: rsplit(':') once compared the PORT segment ("8080"),
142        // never matching "localhost"; the host must exclude the port.
143        let b = bind_key_from_uri("http://localhost:8080/api").expect("parses"); // allow-unwrap
144        assert!(b.loopback, "localhost is loopback");
145        let b = bind_key_from_uri("http://myhost.example:8080").expect("parses"); // allow-unwrap
146        assert!(!b.loopback, "other hostnames stay non-loopback");
147    }
148}
149
150/// Best-effort, reverse-order shutdown of already-started `StepLifecycle`
151/// handles when `start_route` must abort. Used both mid-start-loop (the
152/// `[0..idx)` already-started prefix) and for any post-start failure path
153/// (e.g. `create_route_consumer`, the aggregate spawn branch, the consumer
154/// startup handshake) so the ADR-0022 SPI holds: if `start_route` returns
155/// `Err`, no started handle is left running.
156///
157/// Mirrors `StepLifecycle::shutdown`'s best-effort contract — each error is
158/// logged and swallowed so one failing shutdown cannot block rollback of the
159/// remaining handles.
160async fn rollback_started(route_id: &str, handles: &[Arc<dyn StepLifecycle>]) {
161    for handle in handles.iter().rev() {
162        if let Err(e) = handle.shutdown(StepShutdownReason::RouteStop).await {
163            warn!(
164                route_id = %route_id,
165                step = handle.name(),
166                error = %e,
167                "best-effort step shutdown during start rollback failed"
168            );
169        }
170    }
171}
172
173#[async_trait::async_trait]
174impl camel_api::RouteController for DefaultRouteController {
175    async fn start_route(&mut self, route_id: &str) -> Result<(), CamelError> {
176        // Check if route exists and can be started.
177        {
178            let managed = self
179                .routes
180                .get_mut(route_id)
181                .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
182
183            let consumer_running = handle_is_running(&managed.consumer_handle);
184            let pipeline_running = handle_is_running(&managed.pipeline_handle);
185            if consumer_running && pipeline_running {
186                return Ok(());
187            }
188            if !consumer_running && pipeline_running {
189                return Err(CamelError::RouteError(format!(
190                    "Route '{}' is suspended; use resume_route() to resume, or stop_route() then start_route() for full restart",
191                    route_id
192                )));
193            }
194            if consumer_running && !pipeline_running {
195                return Err(CamelError::RouteError(format!(
196                    "Route '{}' has inconsistent execution state; stop_route() then retry start_route()",
197                    route_id
198                )));
199            }
200        }
201
202        info!(route_id = %route_id, "Starting route");
203
204        // Get the resolved route info
205        let (from_uri, pipeline, concurrency, dispatch_plan) = {
206            let managed = self
207                .routes
208                .get(route_id)
209                .expect("invariant: route must exist after prior existence check"); // allow-unwrap
210            (
211                managed.from_uri.clone(),
212                Arc::clone(&managed.pipeline),
213                managed.concurrency.clone(),
214                managed.compiled.security_plan.clone(),
215            )
216        };
217
218        // ADR-0061 per-bind exposure gate: refuse to start Public routes on
219        // non-loopback binds without operator acknowledgement. Runs before
220        // any lifecycle step starts, so nothing needs rolling back. All
221        // sibling plans on the same bind are aggregated so the error/warn
222        // names every Public route on the bind.
223        if let Some(bind) = bind_key_from_uri(&from_uri) {
224            let owned = self.plans_for_bind(&bind.key);
225            let siblings: Vec<(&str, &RouteSecurityPlan)> =
226                owned.iter().map(|(id, plan)| (id.as_str(), plan)).collect();
227            enforce_bind_exposure_gate(
228                &bind.key,
229                bind.loopback,
230                &siblings,
231                self.bind_acks.acknowledged(&bind.key),
232            )?;
233        }
234
235        // ADR-0022: await each stateful step's `start()` before spawning the
236        // pipeline or consumer. On the Nth failure, roll back the already-
237        // started steps in reverse order (best-effort) and return the original
238        // start error WITHOUT spawning anything. Handles come from the compiled
239        // pipeline assembly, already collected in route order at compile time.
240        let lifecycle_handles: Vec<Arc<dyn StepLifecycle>> = pipeline.load().lifecycle.clone();
241        for (idx, handle) in lifecycle_handles.iter().enumerate() {
242            if let Err(start_err) = handle.start().await {
243                warn!(
244                    route_id = %route_id,
245                    step = handle.name(),
246                    "step start failed; rolling back already-started steps"
247                );
248                // Only [0..idx) have started; the Nth handle itself never did.
249                rollback_started(route_id, &lifecycle_handles[0..idx]).await;
250                return Err(start_err);
251            }
252        }
253
254        // Clone crash notifier for consumer task
255        let crash_notifier = self.crash_notifier.clone();
256        let runtime_for_consumer = self.runtime.clone();
257
258        let consumer_component_ctx = Arc::new(ControllerComponentContext::new(
259            Arc::clone(&self.registry),
260            Arc::clone(&self.languages),
261            self.tracer_metrics
262                .clone()
263                .unwrap_or_else(|| Arc::new(NoOpMetrics)),
264            Arc::clone(&self.platform_service),
265            self.health_registry(),
266            Some(route_id.to_string()),
267        ));
268        let consumer_rt: Arc<dyn camel_component_api::RuntimeObservability> =
269            Arc::clone(&consumer_component_ctx) as Arc<_>;
270        let (mut consumer, consumer_concurrency) = match consumer_management::create_route_consumer(
271            consumer_rt,
272            &self.registry,
273            &from_uri,
274            consumer_component_ctx.as_ref(),
275        ) {
276            Ok(v) => v,
277            // ADR-0022 SPI: every started handle must be rolled back
278            // before start_route returns Err, so no stateful step is left
279            // running. This is the first post-start fallible step.
280            Err(e) => {
281                rollback_started(route_id, &lifecycle_handles).await;
282                return Err(e);
283            }
284        };
285
286        // Resolve effective concurrency: route override > consumer default
287        let effective_concurrency = concurrency.unwrap_or(consumer_concurrency);
288
289        // Get the managed route for mutation
290        let managed = self
291            .routes
292            .get_mut(route_id)
293            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
294
295        // Wire security context before spawning consumer
296        if let (Some(sp_config), Some(authenticator)) = (
297            managed.compiled.security_policy.as_ref(),
298            managed.compiled.security_authenticator.as_ref(),
299        ) {
300            use camel_component_api::SecurityContext;
301            let mut sec_ctx =
302                SecurityContext::from_arc(Arc::clone(&sp_config.policy), Arc::clone(authenticator))
303                    .with_credential_sources(sp_config.credential_sources.clone());
304            // Inject the route's named providers so Phase-2 transports (grpc
305            // 2.1, mcp 2.6, ws 2.8, http 2.9) can resolve them from the
306            // SecurityContext instead of holding their own authenticator.
307            if let Some(registry) = &managed.compiled.provider_registry {
308                sec_ctx = sec_ctx.with_providers(Arc::clone(registry));
309            }
310            // Thread the compiled plan (Task 1.8) so transports drive
311            // per-route dispatch enforcement from it.
312            if let Some(plan) = &managed.compiled.security_plan {
313                sec_ctx = sec_ctx.with_plan(plan.clone());
314            }
315            consumer.set_security_context(sec_ctx);
316        }
317
318        // Create channel for consumer to send exchanges
319        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(256);
320        // Create child tokens for independent lifecycle control
321        let consumer_cancel = managed.consumer_cancel_token.child_token();
322        let pipeline_cancel = managed.pipeline_cancel_token.child_token();
323        let drain_in_flight = Arc::clone(&managed.drain_in_flight);
324        // Clone sender for storage (to reuse on resume)
325        let tx_for_storage = tx.clone();
326        let consumer_ctx = ConsumerContext::new(tx, consumer_cancel.clone(), route_id.to_string());
327
328        // --- Aggregator v2: check for aggregate route with timeout ---
329        let split_clone = managed.aggregate_split.clone();
330        if let Some(split) = split_clone {
331            let result = self
332                .start_aggregate_route(
333                    route_id,
334                    split,
335                    consumer,
336                    consumer_ctx,
337                    rx,
338                    crash_notifier,
339                    runtime_for_consumer,
340                    tx_for_storage,
341                    pipeline_cancel,
342                    drain_in_flight,
343                )
344                .await;
345            // ADR-0022 SPI: roll back already-started handles if the aggregate
346            // spawn/startup path returns Err.
347            if result.is_err() {
348                // rc-kh7c: cancel consumer's cancel token to stop child tasks
349                // spawned by consumer.start() that observe ctx.cancelled().
350                if let Some(managed) = self.routes.get_mut(route_id) {
351                    managed.consumer_cancel_token.cancel();
352                }
353                rollback_started(route_id, &lifecycle_handles).await;
354            }
355            return result;
356        }
357        // --- End aggregator v2 branch ---
358
359        // Clone for the startup-failure cleanup path (rc-kh7c): pipeline_cancel
360        // is moved into the spawn closure below; this clone stays in scope so
361        // the error handler can cancel it to force immediate pipeline exit.
362        let pipeline_cancel_for_cleanup = pipeline_cancel.clone();
363
364        // Spawn pipeline task with its own cancellation token
365        let pipeline_handle = match effective_concurrency {
366            ConcurrencyModel::Concurrent { max } => {
367                // Owned for the spawned 'static task (route_id is a borrow).
368                let route_id = route_id.to_string();
369                let sem = max.map(|n| Arc::new(tokio::sync::Semaphore::new(n)));
370                tokio::spawn(async move {
371                    loop {
372                        // B2 (ADR-0044): acquire permit BEFORE dequeue.
373                        // Cancel-aware: route stop is not blocked waiting for a permit.
374                        let permit = match &sem {
375                            Some(s) => {
376                                let acquired = tokio::select! {
377                                    p = Arc::clone(s).acquire_owned() => p.expect("semaphore closed"), // allow-unwrap
378                                    _ = pipeline_cancel.cancelled() => return,
379                                };
380                                Some(acquired)
381                            }
382                            None => None,
383                        };
384
385                        let envelope = tokio::select! {
386                            envelope = rx.recv() => match envelope {
387                                Some(e) => e,
388                                None => return,
389                            },
390                            _ = pipeline_cancel.cancelled() => return,
391                        };
392                        let ExchangeEnvelope {
393                            exchange,
394                            mut reply_tx,
395                        } = envelope;
396                        // ADR-0061 Task 2.9 strict-mode dispatch check (the
397                        // flip deferred from Task 2.2): every transport now
398                        // mints the typed carrier at its request boundary
399                        // (grpc 2.1, mcp 2.6, ws 2.8, http 2.9), so a
400                        // non-Public plan REQUIRES the carrier on the
401                        // Exchange — absent or wrong-provider is denied
402                        // BEFORE the pipeline runs; the transport renders
403                        // the denial in its own idiom via reply_tx.
404                        if strict_dispatch_denies(
405                            &dispatch_plan,
406                            &exchange,
407                            &mut reply_tx,
408                            route_id.as_str(),
409                        ) {
410                            continue;
411                        }
412                        let pipe_ref = Arc::clone(&pipeline);
413                        let cancel = pipeline_cancel.clone();
414                        let drain_clone = Arc::clone(&drain_in_flight);
415                        tokio::spawn(async move {
416                            // Permit owned by this task — released on completion (RAII).
417                            let _permit = permit;
418                            let _drain_guard = DrainGuard::new(drain_clone);
419
420                            // Load current pipeline from ArcSwap
421                            let mut pipe = pipe_ref.load().processor.clone_inner();
422
423                            // Wait for service ready with circuit breaker backoff
424                            if let Err(e) = ready_with_backoff(&mut pipe, &cancel).await {
425                                if let Some(tx) = reply_tx {
426                                    let _ = tx.send(Err(e));
427                                }
428                                return;
429                            }
430
431                            // B1: scope CANCEL_TOKEN so run_steps can check
432                            // cancellation between steps.
433                            let result = CANCEL_TOKEN
434                                .scope(cancel, async move { pipe.call(exchange).await })
435                                .await;
436                            if let Some(tx) = reply_tx {
437                                let _ = tx.send(result);
438                            } else if let Err(ref e) = result {
439                                // log-policy: system-broken
440                                error!("Pipeline error: {e}");
441                            }
442                        });
443                    }
444                })
445            }
446            // Forward-compat: an unknown future variant is treated as
447            // Sequential — the safe, simplest pipeline topology. A consumer
448            // that needs Concurrent semantics for a future variant must
449            // override the route's `?concurrent=` setting explicitly so the
450            // operator (not the wildcard) chooses the topology.
451            _ => {
452                // Owned for the spawned 'static task (route_id is a borrow).
453                let route_id = route_id.to_string();
454                tokio::spawn(async move {
455                    loop {
456                        // Use select! to exit promptly on cancellation even when idle
457                        let envelope = tokio::select! {
458                            envelope = rx.recv() => match envelope {
459                                Some(e) => e,
460                                None => return, // Channel closed
461                            },
462                            _ = pipeline_cancel.cancelled() => {
463                                // Cancellation requested - exit gracefully
464                                return;
465                            }
466                        };
467                        let ExchangeEnvelope {
468                            exchange,
469                            mut reply_tx,
470                        } = envelope;
471
472                        // ADR-0061 Task 2.9 strict-mode dispatch check — see
473                        // the Concurrent branch above for the full contract.
474                        if strict_dispatch_denies(
475                            &dispatch_plan,
476                            &exchange,
477                            &mut reply_tx,
478                            route_id.as_str(),
479                        ) {
480                            continue;
481                        }
482
483                        // Load current pipeline from ArcSwap (picks up hot-reloaded pipelines)
484                        let mut pipeline = pipeline.load().processor.clone_inner();
485
486                        if let Err(e) = ready_with_backoff(&mut pipeline, &pipeline_cancel).await {
487                            if let Some(tx) = reply_tx {
488                                let _ = tx.send(Err(e));
489                            }
490                            return;
491                        }
492
493                        // B1: scope CANCEL_TOKEN so run_steps can check cancellation
494                        // between steps. Per-start task-local — child token expires
495                        // when this pipeline task exits; the next start re-scopes a
496                        // fresh one (avoids the lifecycle bug where a compiled-in
497                        // child token stays cancelled after stop→restart).
498                        let cancel = pipeline_cancel.clone();
499                        let _drain_guard = DrainGuard::new(Arc::clone(&drain_in_flight));
500                        let result = CANCEL_TOKEN
501                            .scope(cancel, async move { pipeline.call(exchange).await })
502                            .await;
503                        if let Some(tx) = reply_tx {
504                            let _ = tx.send(result);
505                        } else if let Err(ref e) = result {
506                            // log-policy: system-broken
507                            error!("Pipeline error: {e}");
508                        }
509                    }
510                })
511            }
512        };
513        #[cfg(test)]
514        emit_start_route_event("pipeline_spawned");
515
516        // Start consumer after pipeline task is spawned to minimize the chance of
517        // fire-and-forget events being produced before the pipeline loop is active.
518        let (consumer_handle, startup_rx) = consumer_management::spawn_consumer_task(
519            route_id.to_string(),
520            consumer,
521            consumer_ctx,
522            crash_notifier,
523            runtime_for_consumer,
524            false,
525        );
526        #[cfg(test)]
527        emit_start_route_event("consumer_spawned");
528
529        // rc-w1u9: await consumer startup handshake before returning. For
530        // Immediate consumers this is a no-op (pre-resolved receiver); for
531        // Explicit consumers (HTTP, WebSocket) it propagates bind failures as
532        // proper startup errors instead of silent background logs.
533        match consumer_management::await_consumer_startup(startup_rx, "startup").await {
534            Ok(()) => {}
535            Err(e) => {
536                // rc-kh7c: abort the orphaned consumer task and cancel the
537                // pipeline so neither runs detached after start_route returns
538                // Err. Dropping a JoinHandle detaches the task (Tokio
539                // contract); abort() forces termination. The pipeline task
540                // would eventually self-clean via rx-drop, but explicit
541                // cancellation makes it immediate.
542                consumer_handle.abort();
543                pipeline_cancel_for_cleanup.cancel();
544                // Cancel the consumer's cancel token so child tasks spawned
545                // by consumer.start() that observe ctx.cancelled() also stop.
546                consumer_cancel.cancel();
547                rollback_started(route_id, &lifecycle_handles).await;
548                return Err(e);
549            }
550        }
551
552        // Store handles and update status
553        let managed = self
554            .routes
555            .get_mut(route_id)
556            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
557        managed.consumer_handle = Some(consumer_handle);
558        managed.pipeline_handle = Some(pipeline_handle);
559        managed.channel_sender = Some(tx_for_storage);
560
561        info!(route_id = %route_id, "Route started");
562        self.health_registry().mark_route_started(route_id);
563        Ok(())
564    }
565
566    async fn stop_route(&mut self, route_id: &str) -> Result<(), CamelError> {
567        self.stop_route_internal(route_id).await?;
568        self.health_registry().mark_route_stopped(route_id);
569        Ok(())
570    }
571
572    async fn restart_route(&mut self, route_id: &str) -> Result<(), CamelError> {
573        self.stop_route(route_id).await?;
574        tokio::time::sleep(Duration::from_millis(100)).await;
575        self.start_route(route_id).await
576    }
577
578    async fn suspend_route(&mut self, route_id: &str) -> Result<(), CamelError> {
579        // Check route exists and state.
580        let managed = self
581            .routes
582            .get_mut(route_id)
583            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
584
585        let consumer_running = handle_is_running(&managed.consumer_handle);
586        let pipeline_running = handle_is_running(&managed.pipeline_handle);
587
588        // Can only suspend from active started state.
589        if !consumer_running || !pipeline_running {
590            return Err(CamelError::RouteError(format!(
591                "Cannot suspend route '{}' with execution lifecycle {}",
592                route_id,
593                inferred_lifecycle_label(managed)
594            )));
595        }
596
597        info!(route_id = %route_id, "Suspending route (consumer only, keeping pipeline)");
598
599        // Cancel consumer token only (keep pipeline running)
600        let managed = self
601            .routes
602            .get_mut(route_id)
603            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
604        managed.consumer_cancel_token.cancel();
605
606        // Take and join consumer handle
607        let managed = self
608            .routes
609            .get_mut(route_id)
610            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
611        let consumer_handle = managed.consumer_handle.take();
612
613        // Wait for consumer task to complete with timeout
614        let timeout_result = tokio::time::timeout(DEFAULT_SHUTDOWN_TIMEOUT, async {
615            if let Some(handle) = consumer_handle {
616                let _ = handle.await;
617            }
618        })
619        .await;
620
621        if timeout_result.is_err() {
622            warn!(route_id = %route_id, "Consumer shutdown timed out during suspend");
623        }
624
625        // Get the managed route again (can't hold across await)
626        let managed = self
627            .routes
628            .get_mut(route_id)
629            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
630
631        // Create fresh cancellation token for consumer (for resume)
632        managed.consumer_cancel_token = CancellationToken::new();
633
634        info!(route_id = %route_id, "Route suspended (pipeline still running)");
635        self.health_registry().mark_route_stopped(route_id);
636        Ok(())
637    }
638
639    async fn resume_route(&mut self, route_id: &str) -> Result<(), CamelError> {
640        // Check route exists and is Suspended-equivalent execution state.
641        let managed = self
642            .routes
643            .get(route_id)
644            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
645
646        let consumer_running = handle_is_running(&managed.consumer_handle);
647        let pipeline_running = handle_is_running(&managed.pipeline_handle);
648        if consumer_running || !pipeline_running {
649            return Err(CamelError::RouteError(format!(
650                "Cannot resume route '{}' with execution lifecycle {} (expected Suspended)",
651                route_id,
652                inferred_lifecycle_label(managed)
653            )));
654        }
655
656        // Get the stored channel sender (must exist for a suspended route)
657        let sender = managed.channel_sender.clone().ok_or_else(|| {
658            CamelError::RouteError("Suspended route has no channel sender".into())
659        })?;
660
661        // Get from_uri and concurrency for creating new consumer
662        let from_uri = managed.from_uri.clone();
663
664        // ADR-0061 per-bind exposure gate on resume too (see start path).
665        if let Some(bind) = bind_key_from_uri(&from_uri) {
666            let owned = self.plans_for_bind(&bind.key);
667            let siblings: Vec<(&str, &RouteSecurityPlan)> =
668                owned.iter().map(|(id, plan)| (id.as_str(), plan)).collect();
669            enforce_bind_exposure_gate(
670                &bind.key,
671                bind.loopback,
672                &siblings,
673                self.bind_acks.acknowledged(&bind.key),
674            )?;
675        }
676
677        info!(route_id = %route_id, "Resuming route (spawning consumer only)");
678
679        let consumer_component_ctx = Arc::new(ControllerComponentContext::new(
680            Arc::clone(&self.registry),
681            Arc::clone(&self.languages),
682            self.tracer_metrics
683                .clone()
684                .unwrap_or_else(|| Arc::new(NoOpMetrics)),
685            Arc::clone(&self.platform_service),
686            self.health_registry(),
687            Some(route_id.to_string()),
688        ));
689        let consumer_rt: Arc<dyn camel_component_api::RuntimeObservability> =
690            Arc::clone(&consumer_component_ctx) as Arc<_>;
691        let (mut consumer, _) = consumer_management::create_route_consumer(
692            consumer_rt,
693            &self.registry,
694            &from_uri,
695            consumer_component_ctx.as_ref(),
696        )?;
697
698        // Wire security context before spawning consumer
699        let managed = self
700            .routes
701            .get(route_id)
702            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
703        if let (Some(sp_config), Some(authenticator)) = (
704            managed.compiled.security_policy.as_ref(),
705            managed.compiled.security_authenticator.as_ref(),
706        ) {
707            use camel_component_api::SecurityContext;
708            let mut sec_ctx =
709                SecurityContext::from_arc(Arc::clone(&sp_config.policy), Arc::clone(authenticator))
710                    .with_credential_sources(sp_config.credential_sources.clone());
711            // Inject the route's named providers (see start path above).
712            if let Some(registry) = &managed.compiled.provider_registry {
713                sec_ctx = sec_ctx.with_providers(Arc::clone(registry));
714            }
715            // Thread the compiled plan (Task 1.8; see start path above).
716            if let Some(plan) = &managed.compiled.security_plan {
717                sec_ctx = sec_ctx.with_plan(plan.clone());
718            }
719            consumer.set_security_context(sec_ctx);
720        }
721
722        // Get the managed route for mutation
723        let managed = self
724            .routes
725            .get_mut(route_id)
726            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
727
728        // Create child token for consumer lifecycle
729        let consumer_cancel = managed.consumer_cancel_token.child_token();
730
731        let crash_notifier = self.crash_notifier.clone();
732        let runtime_for_consumer = self.runtime.clone();
733
734        // Create ConsumerContext with the stored sender
735        let consumer_ctx =
736            ConsumerContext::new(sender, consumer_cancel.clone(), route_id.to_string());
737
738        // Spawn consumer task
739        let (consumer_handle, startup_rx) = consumer_management::spawn_consumer_task(
740            route_id.to_string(),
741            consumer,
742            consumer_ctx,
743            crash_notifier,
744            runtime_for_consumer,
745            true,
746        );
747
748        // rc-w1u9: await consumer startup handshake on resume too — bind
749        // failures during resume must surface as resume errors.
750        consumer_management::await_consumer_startup(startup_rx, "resume").await?;
751
752        // Store consumer handle and update status
753        let managed = self
754            .routes
755            .get_mut(route_id)
756            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
757        managed.consumer_handle = Some(consumer_handle);
758
759        info!(route_id = %route_id, "Route resumed");
760        self.health_registry().mark_route_started(route_id);
761        Ok(())
762    }
763
764    async fn start_all_routes(&mut self) -> Result<(), CamelError> {
765        // Only start routes where auto_startup() == true
766        // Sort by startup_order() ascending before starting
767        let route_ids: Vec<String> = {
768            let pairs = self.routes.auto_startup_sorted();
769            pairs.into_iter().map(|(id, _)| id).collect()
770        };
771
772        info!("Starting {} auto-startup routes", route_ids.len());
773
774        // Collect errors but continue starting remaining routes
775        let mut errors: Vec<String> = Vec::new();
776        for route_id in route_ids {
777            if let Err(e) = self.start_route(&route_id).await {
778                errors.push(format!("Route '{}': {}", route_id, e));
779            }
780        }
781
782        if !errors.is_empty() {
783            return Err(CamelError::RouteError(format!(
784                "Failed to start routes: {}",
785                errors.join(", ")
786            )));
787        }
788
789        info!("All auto-startup routes started");
790        Ok(())
791    }
792
793    async fn stop_all_routes(&mut self) -> Result<(), CamelError> {
794        // Sort by startup_order descending (reverse order)
795        let route_ids: Vec<String> = {
796            let pairs = self.routes.shutdown_sorted();
797            pairs.into_iter().map(|(id, _)| id).collect()
798        };
799
800        info!("Stopping {} routes", route_ids.len());
801
802        for route_id in route_ids {
803            let _ = self.stop_route(&route_id).await;
804        }
805
806        info!("All routes stopped");
807        Ok(())
808    }
809}
810
811#[cfg(test)]
812#[path = "route_controller_trait_tests.rs"]
813mod bind_exposure_gate;