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