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) =
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
804 info!(route_id = %route_id, "Route started");
805 self.health_registry().mark_route_started(route_id);
806 Ok(())
807 }
808
809 async fn stop_route(&mut self, route_id: &str) -> Result<(), CamelError> {
810 self.stop_route_internal(route_id).await?;
811 self.health_registry().mark_route_stopped(route_id);
812 Ok(())
813 }
814
815 async fn restart_route(&mut self, route_id: &str) -> Result<(), CamelError> {
816 self.stop_route(route_id).await?;
817 tokio::time::sleep(Duration::from_millis(100)).await;
818 self.start_route(route_id).await
819 }
820
821 async fn suspend_route(&mut self, route_id: &str) -> Result<(), CamelError> {
822 // Check route exists and state.
823 let managed = self
824 .routes
825 .get_mut(route_id)
826 .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
827
828 let consumer_running = handle_is_running(&managed.consumer_handle);
829 let pipeline_running = handle_is_running(&managed.pipeline_handle);
830
831 // Can only suspend from active started state.
832 if !consumer_running || !pipeline_running {
833 return Err(CamelError::RouteError(format!(
834 "Cannot suspend route '{}' with execution lifecycle {}",
835 route_id,
836 inferred_lifecycle_label(managed)
837 )));
838 }
839
840 info!(route_id = %route_id, "Suspending route (consumer only, keeping pipeline)");
841
842 // Cancel consumer token only (keep pipeline running)
843 let managed = self
844 .routes
845 .get_mut(route_id)
846 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
847 managed.consumer_cancel_token.cancel();
848
849 // Take and join consumer handle
850 let managed = self
851 .routes
852 .get_mut(route_id)
853 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
854 let consumer_handle = managed.consumer_handle.take();
855
856 // Wait for consumer task to complete with timeout
857 let timeout_result = tokio::time::timeout(DEFAULT_SHUTDOWN_TIMEOUT, async {
858 if let Some(handle) = consumer_handle {
859 let _ = handle.await;
860 }
861 })
862 .await;
863
864 if timeout_result.is_err() {
865 warn!(route_id = %route_id, "Consumer shutdown timed out during suspend");
866 }
867
868 // Get the managed route again (can't hold across await)
869 let managed = self
870 .routes
871 .get_mut(route_id)
872 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
873
874 // Create fresh cancellation token for consumer (for resume)
875 managed.consumer_cancel_token = CancellationToken::new();
876
877 info!(route_id = %route_id, "Route suspended (pipeline still running)");
878 self.health_registry().mark_route_stopped(route_id);
879 Ok(())
880 }
881
882 async fn resume_route(&mut self, route_id: &str) -> Result<(), CamelError> {
883 // Check route exists and is Suspended-equivalent execution state.
884 let managed = self
885 .routes
886 .get(route_id)
887 .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
888
889 let consumer_running = handle_is_running(&managed.consumer_handle);
890 let pipeline_running = handle_is_running(&managed.pipeline_handle);
891 if consumer_running || !pipeline_running {
892 return Err(CamelError::RouteError(format!(
893 "Cannot resume route '{}' with execution lifecycle {} (expected Suspended)",
894 route_id,
895 inferred_lifecycle_label(managed)
896 )));
897 }
898
899 // Get the stored channel sender (must exist for a suspended route)
900 let sender = managed.channel_sender.clone().ok_or_else(|| {
901 CamelError::RouteError("Suspended route has no channel sender".into())
902 })?;
903
904 // Get from_uri and the concurrency override for creating the new
905 // consumer (the override feeds the inline-dispatcher gate below,
906 // mirroring the start path's effective-model resolution).
907 let from_uri = managed.from_uri.clone();
908 let concurrency_override = managed.concurrency.clone();
909
910 // ADR-0061 per-bind exposure gate on resume too (see start path).
911 if let Some(bind) = bind_key_from_uri(&from_uri) {
912 let owned = self.plans_for_bind(&bind.key);
913 let siblings: Vec<(&str, &RouteSecurityPlan)> =
914 owned.iter().map(|(id, plan)| (id.as_str(), plan)).collect();
915 enforce_bind_exposure_gate(
916 &bind.key,
917 bind.loopback,
918 &siblings,
919 self.bind_acks.acknowledged(&bind.key),
920 )?;
921 }
922
923 info!(route_id = %route_id, "Resuming route (spawning consumer only)");
924
925 let consumer_component_ctx = Arc::new(
926 ControllerComponentContext::new(
927 Arc::clone(&self.registry),
928 Arc::clone(&self.languages),
929 self.tracer_metrics
930 .clone()
931 .unwrap_or_else(|| Arc::new(NoOpMetrics)),
932 Arc::clone(&self.platform_service),
933 self.health_registry(),
934 Some(route_id.to_string()),
935 self.tracer_gating.levers.components_enabled(),
936 )
937 .with_in_flight(Arc::clone(&self.in_flight_total)),
938 );
939 let consumer_rt: Arc<dyn camel_component_api::RuntimeObservability> =
940 Arc::clone(&consumer_component_ctx) as Arc<_>;
941 let (mut consumer, consumer_concurrency) = consumer_management::create_route_consumer(
942 consumer_rt,
943 &self.registry,
944 &from_uri,
945 consumer_component_ctx.as_ref(),
946 )?;
947
948 // Wire security context before spawning consumer (authenticator
949 // marker guard: see start path above).
950 let managed = self
951 .routes
952 .get(route_id)
953 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
954 deliver_security_context(consumer.as_mut(), &managed.compiled);
955
956 // Get the managed route for mutation
957 let managed = self
958 .routes
959 .get_mut(route_id)
960 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
961
962 // Create child token for consumer lifecycle
963 let consumer_cancel = managed.consumer_cancel_token.child_token();
964
965 let crash_notifier = self.crash_notifier.clone();
966 let runtime_for_consumer = self.runtime.clone();
967
968 // Create ConsumerContext with the stored sender. drainclaim: same
969 // counter installation as the start path — resumed routes count.
970 let consumer_ctx =
971 ConsumerContext::new(sender, consumer_cancel.clone(), route_id.to_string())
972 .with_in_flight_counter(Arc::clone(&self.in_flight_total));
973
974 // direct-inline-dispatch Task 3.3 (bd rc-y4vk): mirror the
975 // start_route publication on the resume path. Without this the
976 // fresh resume ConsumerContext carries no capability and the
977 // resumed consumer's registry entry silently falls back to the
978 // channel path. Same gate as start (non-Concurrent only, wildcard
979 // = Sequential-equivalent), same handle captures (pipeline swap
980 // source, pipeline_cancel child scope, shared drain counter), and
981 // published before the resumed consumer spawns — a fresh ctx means
982 // a fresh OnceLock, so the set-once keep-first contract cannot
983 // interfere with the pre-suspend publication.
984 //
985 // rc-2sba publication guard (mirrors start): an aggregate-split
986 // route's `managed.pipeline` is an identity shell
987 // (`compose_pipeline(vec![])`) and must never be exposed to inline
988 // execution — the split pre/agg/post pipelines stay driven by the
989 // aggregate engine over the channel path, so no capability is
990 // published here either.
991 if !matches!(
992 concurrency_override.unwrap_or(consumer_concurrency),
993 ConcurrencyModel::Concurrent { .. }
994 ) && managed.aggregate_split.is_none()
995 {
996 let (pipeline, pipeline_cancel, drain_in_flight) = {
997 let managed = self
998 .routes
999 .get(route_id)
1000 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
1001 (
1002 Arc::clone(&managed.pipeline),
1003 managed.pipeline_cancel_token.child_token(),
1004 Arc::clone(&managed.drain_in_flight),
1005 )
1006 };
1007 let dispatcher = RouteInlineDispatcher::new(
1008 route_id.to_string(),
1009 pipeline,
1010 pipeline_cancel,
1011 drain_in_flight,
1012 Arc::clone(&self.cohort),
1013 Some(Arc::clone(&self.in_flight_total)),
1014 );
1015 consumer_ctx.set_inline_dispatcher(Arc::new(dispatcher));
1016 }
1017
1018 // Spawn consumer task
1019 let (consumer_handle, startup_rx, watcher_inputs, outer_inputs) =
1020 consumer_management::spawn_consumer_task(
1021 route_id.to_string(),
1022 consumer,
1023 consumer_ctx,
1024 crash_notifier,
1025 runtime_for_consumer,
1026 true,
1027 );
1028
1029 // rc-w1u9: await consumer startup handshake on resume too — bind
1030 // failures during resume must surface as resume errors.
1031 // For Immediate consumers the receiver is pre-resolved (rc-slvd).
1032 let resume_result = consumer_management::await_consumer_startup(startup_rx, "resume").await;
1033 if let Err(e) = resume_result {
1034 // rc-kh7c cleanup parity with the start path: the consumer task
1035 // must not run detached after a failed resume, and child tasks
1036 // spawned by consumer.start() that observe ctx.cancelled() must
1037 // stop too.
1038 consumer_handle.abort();
1039 consumer_cancel.cancel();
1040 return Err(e);
1041 }
1042
1043 // Detached failure watcher for Immediate consumers (rc-slvd).
1044 if let Some(inputs) = watcher_inputs {
1045 consumer_management::spawn_failure_watcher(inputs);
1046 }
1047
1048 // Detached outer-task watcher for Explicit consumers (rc-a7rh):
1049 // spawned only after the resume handshake resolved Ok — rollback
1050 // terminations (abort-then-cancel above) happen before this point
1051 // and are never watched.
1052 if let Some(outer) = outer_inputs {
1053 consumer_management::spawn_outer_task_watcher(outer);
1054 }
1055
1056 // Store consumer handle and update status
1057 let managed = self
1058 .routes
1059 .get_mut(route_id)
1060 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
1061 managed.consumer_handle = Some(consumer_handle);
1062
1063 info!(route_id = %route_id, "Route resumed");
1064 self.health_registry().mark_route_started(route_id);
1065 Ok(())
1066 }
1067
1068 async fn start_all_routes(&mut self) -> Result<(), CamelError> {
1069 // Only start routes where auto_startup() == true
1070 // Sort by startup_order() ascending before starting
1071 let route_ids: Vec<String> = {
1072 let pairs = self.routes.auto_startup_sorted();
1073 pairs.into_iter().map(|(id, _)| id).collect()
1074 };
1075
1076 info!("Starting {} auto-startup routes", route_ids.len());
1077
1078 // Collect errors but continue starting remaining routes
1079 let mut errors: Vec<String> = Vec::new();
1080 for route_id in route_ids {
1081 if let Err(e) = self.start_route(&route_id).await {
1082 errors.push(format!("Route '{}': {}", route_id, e));
1083 }
1084 }
1085
1086 if !errors.is_empty() {
1087 return Err(CamelError::RouteError(format!(
1088 "Failed to start routes: {}",
1089 errors.join(", ")
1090 )));
1091 }
1092
1093 info!("All auto-startup routes started");
1094 Ok(())
1095 }
1096
1097 async fn stop_all_routes(&mut self) -> Result<(), CamelError> {
1098 // Sort by startup_order descending (reverse order)
1099 let route_ids: Vec<String> = {
1100 let pairs = self.routes.shutdown_sorted();
1101 pairs.into_iter().map(|(id, _)| id).collect()
1102 };
1103
1104 info!("Stopping {} routes", route_ids.len());
1105
1106 for route_id in route_ids {
1107 let _ = self.stop_route(&route_id).await;
1108 }
1109
1110 info!("All routes stopped");
1111 Ok(())
1112 }
1113}
1114
1115// ── rc-e2r9: b′ signal emission on reply-drop ──
1116// The shared `send_reply_or_b_prime` helper lives in
1117// `super::route_helpers` (single copy — the duplicated private helpers in
1118// this file and `route_controller.rs` drifted once already; see rc-e2r9
1119// review, Important 3).
1120
1121#[cfg(test)]
1122#[path = "route_controller_trait_tests.rs"]
1123mod bind_exposure_gate;