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