Skip to main content

camel_core/lifecycle/adapters/
controller_actor.rs

1//! Actor loop and supervision — the async task that processes route control commands.
2//!
3//! Extracted from a monolithic file. The command enum and handle live
4//! in [`controller_actor_commands`](super::controller_actor_commands).
5
6use std::collections::HashSet;
7use std::sync::{Arc, Mutex};
8use std::time::Instant;
9
10use camel_api::{MetricsCollector, RouteController, SupervisionConfig};
11use tokio::sync::mpsc;
12use tokio::task::JoinHandle;
13use tracing::{debug, error, info, warn};
14
15pub(crate) use super::controller_actor_commands::RouteControllerCommand;
16pub use super::controller_actor_commands::RouteControllerHandle;
17use super::route_controller::DefaultRouteController;
18use super::route_helpers::CrashNotification;
19
20pub fn spawn_controller_actor(
21    controller: DefaultRouteController,
22) -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
23    let (tx, mut rx) = mpsc::channel::<RouteControllerCommand>(256);
24    // Hold a clone of tx so the spawned task can send StartRoute back through
25    // the same channel after the 100ms restart sleep, without moving the
26    // original tx (which we still need to return as RouteControllerHandle).
27    let tx_for_spawn = tx.clone();
28    let handle = tokio::spawn(async move {
29        let mut controller = controller;
30        // Tracks routes currently restarting via spawned off-actor tasks.
31        // Related to but separate from spawn_supervision_task's
32        // currently_restarting set (that set tracks crash-recovery restarts;
33        // this one tracks command-driven restarts).
34        let restarting: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
35        while let Some(cmd) = rx.recv().await {
36            match cmd {
37                RouteControllerCommand::StartRoute { route_id, reply } => {
38                    // allow-unwrap: Mutex cannot be poisoned in normal operation
39                    if restarting
40                        .lock()
41                        .expect("restarting mutex poisoned") // allow-unwrap
42                        .contains(&route_id)
43                    {
44                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
45                            "route {} is restarting",
46                            route_id
47                        ))));
48                        continue;
49                    }
50                    let _ = reply.send(controller.start_route(&route_id).await);
51                }
52                RouteControllerCommand::StopRoute { route_id, reply } => {
53                    // allow-unwrap: Mutex cannot be poisoned in normal operation
54                    if restarting
55                        .lock()
56                        .expect("restarting mutex poisoned") // allow-unwrap
57                        .contains(&route_id)
58                    {
59                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
60                            "route {} is restarting",
61                            route_id
62                        ))));
63                        continue;
64                    }
65                    let _ = reply.send(controller.stop_route(&route_id).await);
66                }
67                RouteControllerCommand::RestartRoute { route_id, reply } => {
68                    // Reject if already restarting.
69                    // allow-unwrap: Mutex cannot be poisoned in normal operation
70                    {
71                        let mut guard = restarting.lock().expect("restarting mutex poisoned"); // allow-unwrap
72                        if guard.contains(&route_id) {
73                            let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
74                                "route {} is restarting",
75                                route_id
76                            ))));
77                            continue;
78                        }
79                        guard.insert(route_id.clone());
80                    }
81
82                    // Stop inline — actor owns the controller, so we cannot
83                    // move it into a spawned task. The 100ms sleep + start
84                    // happen off-actor by sending a StartRoute back through
85                    // the same channel.
86                    let stop_result = controller.stop_route(&route_id).await;
87                    if let Err(ref e) = stop_result {
88                        // allow-unwrap: Mutex cannot be poisoned in normal operation
89                        restarting
90                            .lock()
91                            .expect("restarting mutex poisoned") // allow-unwrap
92                            .remove(&route_id);
93                        let _ = reply.send(Err(e.clone()));
94                        continue;
95                    }
96
97                    // Spawn only the sleep + send StartRoute back through
98                    // the SAME channel. The StartRoute is processed on the
99                    // actor thread (correct ownership), and the reply moves
100                    // into the spawned task to bridge the restart caller.
101                    let tx_clone = tx_for_spawn.clone();
102                    let restarting_clone = restarting.clone();
103                    let route_id_for_start = route_id.clone();
104                    let route_id_for_cleanup = route_id.clone();
105                    tokio::spawn(async move {
106                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
107                        // Remove from `restarting` BEFORE sending StartRoute: the
108                        // actor's StartRoute arm rejects if the route is in the
109                        // restarting set, so removing first lets the actor
110                        // accept its own self-sent command.
111                        // allow-unwrap: Mutex cannot be poisoned in normal operation
112                        restarting_clone
113                            .lock()
114                            .expect("restarting mutex poisoned") // allow-unwrap
115                            .remove(&route_id_for_cleanup);
116                        if tx_clone
117                            .send(RouteControllerCommand::StartRoute {
118                                route_id: route_id_for_start,
119                                reply,
120                            })
121                            .await
122                            .is_err()
123                        {
124                            warn!(
125                                "route {} restart: StartRoute send failed (actor shutting down)",
126                                route_id_for_cleanup
127                            );
128                        }
129                    });
130                    // Actor returns to rx.recv() immediately — HoL eliminated.
131                }
132                RouteControllerCommand::SuspendRoute { route_id, reply } => {
133                    // allow-unwrap: Mutex cannot be poisoned in normal operation
134                    if restarting
135                        .lock()
136                        .expect("restarting mutex poisoned") // allow-unwrap
137                        .contains(&route_id)
138                    {
139                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
140                            "route {} is restarting",
141                            route_id
142                        ))));
143                        continue;
144                    }
145                    let _ = reply.send(controller.suspend_route(&route_id).await);
146                }
147                RouteControllerCommand::ResumeRoute { route_id, reply } => {
148                    // allow-unwrap: Mutex cannot be poisoned in normal operation
149                    if restarting
150                        .lock()
151                        .expect("restarting mutex poisoned") // allow-unwrap
152                        .contains(&route_id)
153                    {
154                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
155                            "route {} is restarting",
156                            route_id
157                        ))));
158                        continue;
159                    }
160                    let _ = reply.send(controller.resume_route(&route_id).await);
161                }
162                RouteControllerCommand::StartAllRoutes { reply } => {
163                    let _ = reply.send(controller.start_all_routes().await);
164                }
165                RouteControllerCommand::StopAllRoutes { reply } => {
166                    let _ = reply.send(controller.stop_all_routes().await);
167                }
168                RouteControllerCommand::AddRoute { definition, reply } => {
169                    let _ = reply.send(controller.add_route(definition).await);
170                }
171                RouteControllerCommand::RemoveRoute { route_id, reply } => {
172                    // allow-unwrap: Mutex cannot be poisoned in normal operation
173                    if restarting
174                        .lock()
175                        .expect("restarting mutex poisoned") // allow-unwrap
176                        .contains(&route_id)
177                    {
178                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
179                            "route {} is restarting",
180                            route_id
181                        ))));
182                        continue;
183                    }
184                    let _ = reply.send(controller.remove_route(&route_id).await);
185                }
186                RouteControllerCommand::SwapPipeline {
187                    route_id,
188                    pipeline,
189                    reply,
190                } => {
191                    let _ = reply.send(controller.swap_pipeline(&route_id, pipeline));
192                }
193                RouteControllerCommand::SwapPipelineRaw {
194                    route_id,
195                    pipeline,
196                    lifecycle,
197                    reply,
198                } => {
199                    let _ =
200                        reply.send(controller.swap_pipeline_raw(&route_id, pipeline, lifecycle));
201                }
202                RouteControllerCommand::CompileRouteDefinition { definition, reply } => {
203                    let _ = reply.send(controller.compile_route_definition(definition));
204                }
205                RouteControllerCommand::CompileRouteDefinitionWithGeneration {
206                    definition,
207                    generation,
208                    reply,
209                } => {
210                    let _ = reply.send(
211                        controller.compile_route_definition_with_generation(definition, generation),
212                    );
213                }
214                RouteControllerCommand::CompileRouteDefinitionPipeline {
215                    definition,
216                    generation,
217                    reply,
218                } => {
219                    let _ = reply
220                        .send(controller.compile_route_definition_pipeline(definition, generation));
221                }
222                RouteControllerCommand::CompileRouteDefinitionDryPipeline { definition, reply } => {
223                    let _ =
224                        reply.send(controller.compile_route_definition_dry_pipeline(definition));
225                }
226                RouteControllerCommand::PrepareRouteDefinitionWithGeneration {
227                    definition,
228                    generation,
229                    reply,
230                } => {
231                    let _ = reply.send(
232                        controller.prepare_route_definition_with_generation(definition, generation),
233                    );
234                }
235                RouteControllerCommand::InsertPreparedRoute { prepared, reply } => {
236                    let _ = reply.send(controller.insert_prepared_route(prepared));
237                }
238                RouteControllerCommand::RemoveRoutePreservingFunctions { route_id, reply } => {
239                    // allow-unwrap: Mutex cannot be poisoned in normal operation
240                    if restarting
241                        .lock()
242                        .expect("restarting mutex poisoned") // allow-unwrap
243                        .contains(&route_id)
244                    {
245                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
246                            "route {} is restarting",
247                            route_id
248                        ))));
249                        continue;
250                    }
251                    let _ = reply.send(
252                        controller
253                            .remove_route_preserving_functions(&route_id)
254                            .await,
255                    );
256                }
257                RouteControllerCommand::RouteFromUri { route_id, reply } => {
258                    let _ = reply.send(controller.route_from_uri(&route_id));
259                }
260                RouteControllerCommand::SetErrorHandler { config } => {
261                    controller.set_error_handler(config);
262                }
263                RouteControllerCommand::SetTracerConfig { config } => {
264                    controller.set_tracer_config(&config);
265                }
266                RouteControllerCommand::RouteCount { reply } => {
267                    let _ = reply.send(controller.route_count());
268                }
269                RouteControllerCommand::InFlightCount { route_id, reply } => {
270                    let _ = reply.send(controller.in_flight_count(&route_id));
271                }
272                RouteControllerCommand::RouteExists { route_id, reply } => {
273                    let _ = reply.send(controller.route_exists(&route_id));
274                }
275                RouteControllerCommand::RouteIds { reply } => {
276                    let _ = reply.send(controller.route_ids());
277                }
278                RouteControllerCommand::AutoStartupRouteIds { reply } => {
279                    let _ = reply.send(controller.auto_startup_route_ids());
280                }
281                RouteControllerCommand::ShutdownRouteIds { reply } => {
282                    let _ = reply.send(controller.shutdown_route_ids());
283                }
284                RouteControllerCommand::GetPipeline { route_id, reply } => {
285                    let _ = reply.send(controller.get_pipeline(&route_id));
286                }
287                RouteControllerCommand::StartRouteReload { route_id, reply } => {
288                    // allow-unwrap: Mutex cannot be poisoned in normal operation
289                    if restarting
290                        .lock()
291                        .expect("restarting mutex poisoned") // allow-unwrap
292                        .contains(&route_id)
293                    {
294                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
295                            "route {} is restarting",
296                            route_id
297                        ))));
298                        continue;
299                    }
300                    let _ = reply.send(controller.start_route_reload(&route_id).await);
301                }
302                RouteControllerCommand::StopRouteReload { route_id, reply } => {
303                    // allow-unwrap: Mutex cannot be poisoned in normal operation
304                    if restarting
305                        .lock()
306                        .expect("restarting mutex poisoned") // allow-unwrap
307                        .contains(&route_id)
308                    {
309                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
310                            "route {} is restarting",
311                            route_id
312                        ))));
313                        continue;
314                    }
315                    let _ = reply.send(controller.stop_route_reload(&route_id).await);
316                }
317                RouteControllerCommand::SetRuntimeHandle { runtime } => {
318                    controller.set_runtime_handle(runtime);
319                }
320                RouteControllerCommand::SetFunctionInvoker { invoker } => {
321                    controller.set_function_invoker(invoker);
322                }
323                RouteControllerCommand::RouteSourceHash { route_id, reply } => {
324                    let _ = reply.send(controller.route_source_hash(&route_id));
325                }
326                RouteControllerCommand::RouteHasLifecycle { route_id, reply } => {
327                    let _ = reply.send(controller.route_has_lifecycle(&route_id));
328                }
329                RouteControllerCommand::Shutdown => {
330                    break;
331                }
332            }
333        }
334    });
335    (RouteControllerHandle { tx }, handle)
336}
337
338pub fn spawn_supervision_task(
339    controller: RouteControllerHandle,
340    config: SupervisionConfig,
341    _metrics: Option<Arc<dyn MetricsCollector>>,
342    mut crash_rx: mpsc::Receiver<CrashNotification>,
343) -> JoinHandle<()> {
344    tokio::spawn(async move {
345        let mut attempts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
346        let mut last_restart_time: std::collections::HashMap<String, Instant> =
347            std::collections::HashMap::new();
348        let mut currently_restarting: std::collections::HashSet<String> =
349            std::collections::HashSet::new();
350
351        debug!("Supervision loop started");
352
353        while let Some(notification) = crash_rx.recv().await {
354            let route_id = notification.route_id;
355            if currently_restarting.contains(&route_id) {
356                continue;
357            }
358
359            if let Some(last_time) = last_restart_time.get(&route_id)
360                && last_time.elapsed() >= config.initial_delay
361            {
362                attempts.insert(route_id.clone(), 0);
363            }
364
365            let current_attempt = attempts.entry(route_id.clone()).or_insert(0);
366            *current_attempt += 1;
367
368            if config
369                .max_attempts
370                .is_some_and(|max| *current_attempt > max)
371            {
372                // log-policy: system-broken
373                error!(
374                    route_id = %route_id,
375                    attempts = *current_attempt,
376                    "Route exceeded max restart attempts, giving up"
377                );
378                continue;
379            }
380
381            let delay = config.next_delay(*current_attempt);
382            currently_restarting.insert(route_id.clone());
383            tokio::time::sleep(delay).await;
384
385            match controller.restart_route(route_id.clone()).await {
386                Ok(()) => {
387                    info!(route_id = %route_id, "Route restarted successfully");
388                    last_restart_time.insert(route_id.clone(), Instant::now());
389                }
390                Err(err) => {
391                    // log-policy: system-broken
392                    error!(route_id = %route_id, error = %err, "Failed to restart route");
393                }
394            }
395
396            currently_restarting.remove(&route_id);
397        }
398
399        debug!("Supervision loop ended");
400    })
401}
402
403#[cfg(test)]
404mod tests {
405    use super::{
406        RouteControllerCommand, RouteControllerHandle, spawn_controller_actor,
407        spawn_supervision_task,
408    };
409    use crate::lifecycle::adapters::route_controller::DefaultRouteController;
410    use crate::lifecycle::adapters::route_helpers::CrashNotification;
411    use crate::lifecycle::application::route_definition::RouteDefinition;
412    use crate::shared::components::domain::Registry;
413    use crate::shared::observability::domain::TracerConfig;
414    use camel_api::function::PrepareToken;
415    use camel_api::{
416        CamelError, ErrorHandlerConfig, Exchange, ExchangePatch, FunctionDefinition, FunctionDiff,
417        FunctionId, FunctionInvocationError, FunctionInvoker, FunctionInvokerSync, RuntimeCommand,
418        RuntimeCommandBus, RuntimeCommandResult, RuntimeQuery, RuntimeQueryBus, RuntimeQueryResult,
419        SupervisionConfig,
420    };
421    use std::sync::Arc;
422    use std::time::Duration;
423    use tokio::sync::mpsc;
424    use tokio::time::sleep;
425
426    fn build_actor_with_components() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
427        let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
428        {
429            let mut guard = registry.lock().expect("lock");
430            guard.register(std::sync::Arc::new(
431                camel_component_timer::TimerComponent::new(),
432            ));
433            guard.register(std::sync::Arc::new(
434                camel_component_mock::MockComponent::new(),
435            ));
436        }
437        let controller = DefaultRouteController::new(
438            Arc::clone(&registry),
439            Arc::new(camel_api::NoopPlatformService::default()),
440        );
441        spawn_controller_actor(controller)
442    }
443
444    fn build_empty_actor() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
445        let controller = DefaultRouteController::new(
446            Arc::new(std::sync::Mutex::new(Registry::new())),
447            Arc::new(camel_api::NoopPlatformService::default()),
448        );
449        spawn_controller_actor(controller)
450    }
451
452    fn route_def(route_id: &str, from_uri: &str) -> RouteDefinition {
453        RouteDefinition::new(from_uri, vec![]).with_route_id(route_id)
454    }
455
456    struct NoopRuntime;
457    struct NoopInvoker;
458
459    #[async_trait::async_trait]
460    impl RuntimeCommandBus for NoopRuntime {
461        async fn execute(&self, _cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
462            Ok(RuntimeCommandResult::Accepted)
463        }
464    }
465
466    #[async_trait::async_trait]
467    impl RuntimeQueryBus for NoopRuntime {
468        async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
469            Ok(match query {
470                RuntimeQuery::GetRouteStatus { route_id }
471                | RuntimeQuery::InFlightCount { route_id } => {
472                    RuntimeQueryResult::RouteNotFound { route_id }
473                }
474                RuntimeQuery::ListRoutes => RuntimeQueryResult::Routes {
475                    route_ids: Vec::new(),
476                },
477            })
478        }
479    }
480
481    impl FunctionInvokerSync for NoopInvoker {
482        fn stage_pending(
483            &self,
484            _def: FunctionDefinition,
485            _route_id: Option<&str>,
486            _generation: u64,
487        ) {
488        }
489        fn discard_staging(&self, _generation: u64) {}
490        fn begin_reload(&self) -> u64 {
491            1
492        }
493        fn function_refs_for_route(&self, _route_id: &str) -> Vec<(FunctionId, Option<String>)> {
494            vec![]
495        }
496        fn staged_refs_for_route(
497            &self,
498            _route_id: &str,
499            _generation: u64,
500        ) -> Vec<(FunctionId, Option<String>)> {
501            vec![]
502        }
503        fn staged_defs_for_route(
504            &self,
505            _route_id: &str,
506            _generation: u64,
507        ) -> Vec<(FunctionDefinition, Option<String>)> {
508            vec![]
509        }
510    }
511
512    #[async_trait::async_trait]
513    impl FunctionInvoker for NoopInvoker {
514        async fn register(
515            &self,
516            _def: FunctionDefinition,
517            _route_id: Option<&str>,
518        ) -> Result<(), FunctionInvocationError> {
519            Ok(())
520        }
521        async fn unregister(
522            &self,
523            _id: &FunctionId,
524            _route_id: Option<&str>,
525        ) -> Result<(), FunctionInvocationError> {
526            Ok(())
527        }
528        async fn invoke(
529            &self,
530            _id: &FunctionId,
531            _exchange: &Exchange,
532        ) -> Result<ExchangePatch, FunctionInvocationError> {
533            Ok(ExchangePatch::default())
534        }
535        async fn prepare_reload(
536            &self,
537            _diff: FunctionDiff,
538            _generation: u64,
539        ) -> Result<PrepareToken, FunctionInvocationError> {
540            Ok(PrepareToken::default())
541        }
542        async fn finalize_reload(
543            &self,
544            _diff: &FunctionDiff,
545            _generation: u64,
546        ) -> Result<(), FunctionInvocationError> {
547            Ok(())
548        }
549        async fn rollback_reload(
550            &self,
551            _token: PrepareToken,
552            _generation: u64,
553        ) -> Result<(), FunctionInvocationError> {
554            Ok(())
555        }
556        async fn commit_staged(&self) -> Result<(), FunctionInvocationError> {
557            Ok(())
558        }
559    }
560
561    #[tokio::test]
562    async fn start_route_sends_command_and_returns_reply() {
563        let (tx, mut rx) = mpsc::channel(1);
564        let handle = RouteControllerHandle { tx };
565
566        let task = tokio::spawn(async move { handle.start_route("route-a").await });
567
568        let command = rx.recv().await.expect("command should be received");
569        match command {
570            RouteControllerCommand::StartRoute { route_id, reply } => {
571                assert_eq!(route_id, "route-a");
572                let _ = reply.send(Ok(()));
573            }
574            _ => panic!("unexpected command variant"),
575        }
576
577        let result = task.await.expect("join should succeed");
578        assert!(result.is_ok());
579    }
580
581    #[tokio::test]
582    async fn start_route_returns_error_when_actor_stops() {
583        let (tx, rx) = mpsc::channel(1);
584        drop(rx);
585
586        let handle = RouteControllerHandle { tx };
587        let result = handle.start_route("route-a").await;
588
589        assert!(matches!(result, Err(CamelError::ProcessorError(_))));
590    }
591
592    #[tokio::test]
593    async fn spawn_controller_actor_processes_commands_and_shutdown() {
594        let controller = DefaultRouteController::new(
595            Arc::new(std::sync::Mutex::new(Registry::new())),
596            Arc::new(camel_api::NoopPlatformService::default()),
597        );
598        let (handle, join_handle) = spawn_controller_actor(controller);
599
600        assert_eq!(handle.route_count().await.expect("route_count"), 0);
601        assert_eq!(
602            handle.route_ids().await.expect("route_ids"),
603            Vec::<String>::new()
604        );
605
606        handle.shutdown().await.expect("shutdown send");
607        join_handle.await.expect("actor join");
608    }
609
610    #[tokio::test]
611    async fn actor_handle_introspection_and_mutation_commands() {
612        let (handle, join_handle) = build_actor_with_components();
613        let definition = route_def("h-1", "timer:tick?period=100");
614
615        handle.add_route(definition).await.expect("add route");
616        assert!(handle.route_exists("h-1").await.expect("route exists h-1"));
617        assert!(
618            !handle
619                .route_exists("no-such")
620                .await
621                .expect("route exists no-such")
622        );
623
624        let from_uri = handle.route_from_uri("h-1").await.expect("route_from_uri");
625        assert_eq!(from_uri.as_deref(), Some("timer:tick?period=100"));
626        assert_eq!(handle.route_count().await.expect("route_count"), 1);
627
628        let auto_ids = handle
629            .auto_startup_route_ids()
630            .await
631            .expect("auto_startup_route_ids");
632        assert!(auto_ids.iter().any(|id| id == "h-1"));
633
634        let shutdown_ids = handle
635            .shutdown_route_ids()
636            .await
637            .expect("shutdown_route_ids");
638        assert!(shutdown_ids.iter().any(|id| id == "h-1"));
639
640        let compiled = handle
641            .compile_route_definition(route_def("h-1", "timer:tick?period=100"))
642            .await
643            .expect("compile_route_definition");
644
645        assert!(
646            handle
647                .get_pipeline("h-1")
648                .await
649                .expect("get_pipeline")
650                .is_some()
651        );
652        handle
653            .swap_pipeline("h-1", compiled)
654            .await
655            .expect("swap_pipeline");
656
657        let _ = handle
658            .in_flight_count("h-1")
659            .await
660            .expect("in_flight_count");
661        let _ = handle.route_source_hash("h-1").await;
662
663        handle
664            .set_error_handler(ErrorHandlerConfig::dead_letter_channel("log:dlq"))
665            .await
666            .expect("set_error_handler");
667        handle
668            .set_tracer_config(TracerConfig::default())
669            .await
670            .expect("set_tracer_config");
671        handle
672            .set_runtime_handle(Arc::new(NoopRuntime))
673            .await
674            .expect("set_runtime_handle");
675
676        handle.remove_route("h-1").await.expect("remove_route");
677        assert_eq!(
678            handle
679                .route_count()
680                .await
681                .expect("route_count after remove"),
682            0
683        );
684        handle
685            .stop_all_routes()
686            .await
687            .expect("stop_all_routes on empty");
688
689        handle.shutdown().await.expect("shutdown send");
690        join_handle.await.expect("actor join");
691    }
692
693    #[tokio::test]
694    async fn actor_handle_lifecycle_start_stop_restart_suspend_resume() {
695        let (handle, join_handle) = build_actor_with_components();
696        handle
697            .add_route(route_def("lc-1", "timer:tick?period=50"))
698            .await
699            .expect("add route lc-1");
700
701        handle.start_route("lc-1").await.expect("start_route");
702        sleep(Duration::from_millis(20)).await;
703
704        handle.restart_route("lc-1").await.expect("restart_route");
705        sleep(Duration::from_millis(20)).await;
706
707        handle.suspend_route("lc-1").await.expect("suspend_route");
708        handle.resume_route("lc-1").await.expect("resume_route");
709        sleep(Duration::from_millis(20)).await;
710
711        handle.stop_route("lc-1").await.expect("stop_route");
712        handle.start_all_routes().await.expect("start_all_routes");
713        sleep(Duration::from_millis(20)).await;
714        handle.stop_all_routes().await.expect("stop_all_routes");
715
716        handle
717            .start_route_reload("lc-1")
718            .await
719            .expect("start_route_reload");
720        handle
721            .stop_route_reload("lc-1")
722            .await
723            .expect("stop_route_reload");
724
725        handle.shutdown().await.expect("shutdown send");
726        join_handle.await.expect("actor join");
727    }
728
729    #[tokio::test]
730    async fn spawn_supervision_restarts_route_on_crash() {
731        let (handle, join_handle) = build_actor_with_components();
732        handle
733            .add_route(route_def("sup-1", "timer:tick?period=100"))
734            .await
735            .expect("add route sup-1");
736        handle
737            .start_route("sup-1")
738            .await
739            .expect("start_route sup-1");
740
741        let (crash_tx, crash_rx) = mpsc::channel(8);
742        let supervision = spawn_supervision_task(
743            handle.clone(),
744            SupervisionConfig {
745                initial_delay: Duration::from_millis(10),
746                max_attempts: Some(2),
747                ..SupervisionConfig::default()
748            },
749            None,
750            crash_rx,
751        );
752
753        crash_tx
754            .send(CrashNotification {
755                route_id: "sup-1".to_string(),
756                error: "simulated".to_string(),
757            })
758            .await
759            .expect("send crash notification");
760
761        sleep(Duration::from_millis(150)).await;
762        drop(crash_tx);
763        supervision.await.expect("supervision join");
764
765        handle.shutdown().await.expect("shutdown send");
766        join_handle.await.expect("actor join");
767    }
768
769    #[tokio::test]
770    async fn supervision_skips_duplicate_and_gives_up_after_max_attempts() {
771        let (handle, join_handle) = build_actor_with_components();
772        handle
773            .add_route(route_def("sup-2", "timer:tick?period=100"))
774            .await
775            .expect("add route sup-2");
776        handle
777            .start_route("sup-2")
778            .await
779            .expect("start_route sup-2");
780
781        let (crash_tx, crash_rx) = mpsc::channel(8);
782        let supervision = spawn_supervision_task(
783            handle.clone(),
784            SupervisionConfig {
785                initial_delay: Duration::from_millis(10),
786                max_attempts: Some(1),
787                ..SupervisionConfig::default()
788            },
789            None,
790            crash_rx,
791        );
792
793        crash_tx
794            .send(CrashNotification {
795                route_id: "sup-2".to_string(),
796                error: "attempt-1".to_string(),
797            })
798            .await
799            .expect("send crash attempt-1");
800        crash_tx
801            .send(CrashNotification {
802                route_id: "sup-2".to_string(),
803                error: "attempt-2".to_string(),
804            })
805            .await
806            .expect("send crash attempt-2");
807
808        sleep(Duration::from_millis(200)).await;
809        drop(crash_tx);
810        supervision.await.expect("supervision join");
811
812        handle.shutdown().await.expect("shutdown send");
813        join_handle.await.expect("actor join");
814    }
815
816    #[tokio::test]
817    async fn try_set_runtime_handle_succeeds_on_fresh_actor() {
818        let (handle, join_handle) = build_empty_actor();
819
820        handle
821            .try_set_runtime_handle(Arc::new(NoopRuntime))
822            .expect("try_set_runtime_handle should succeed");
823
824        handle.shutdown().await.expect("shutdown send");
825        join_handle.await.expect("actor join");
826    }
827
828    #[tokio::test]
829    async fn shutdown_returns_error_when_actor_stopped() {
830        let (tx, rx) = mpsc::channel(1);
831        drop(rx);
832
833        let handle = RouteControllerHandle { tx };
834        let result = handle.shutdown().await;
835
836        assert!(matches!(result, Err(CamelError::ProcessorError(_))));
837    }
838
839    #[tokio::test]
840    async fn handle_methods_send_expected_commands_and_receive_replies() {
841        let (tx, mut rx) = mpsc::channel(16);
842        let handle = RouteControllerHandle { tx };
843
844        let stop_task = tokio::spawn({
845            let h = handle.clone();
846            async move { h.stop_route("r-1").await }
847        });
848        let cmd = rx.recv().await.expect("stop command");
849        match cmd {
850            RouteControllerCommand::StopRoute { route_id, reply } => {
851                assert_eq!(route_id, "r-1");
852                let _ = reply.send(Ok(()));
853            }
854            _ => panic!("unexpected command"),
855        }
856        assert!(stop_task.await.expect("join").is_ok());
857
858        let exists_task = tokio::spawn({
859            let h = handle.clone();
860            async move { h.route_exists("r-2").await }
861        });
862        let cmd = rx.recv().await.expect("exists command");
863        match cmd {
864            RouteControllerCommand::RouteExists { route_id, reply } => {
865                assert_eq!(route_id, "r-2");
866                let _ = reply.send(true);
867            }
868            _ => panic!("unexpected command"),
869        }
870        assert!(exists_task.await.expect("join").expect("ok"));
871
872        let hash_task = tokio::spawn({
873            let h = handle.clone();
874            async move { h.route_source_hash("r-3").await }
875        });
876        let cmd = rx.recv().await.expect("hash command");
877        match cmd {
878            RouteControllerCommand::RouteSourceHash { route_id, reply } => {
879                assert_eq!(route_id, "r-3");
880                let _ = reply.send(Some(77));
881            }
882            _ => panic!("unexpected command"),
883        }
884        assert_eq!(hash_task.await.expect("join"), Some(77));
885    }
886
887    #[tokio::test]
888    async fn handle_methods_error_on_dropped_reply_channel() {
889        let (tx, mut rx) = mpsc::channel(16);
890        let handle = RouteControllerHandle { tx };
891
892        let count_task = tokio::spawn({
893            let h = handle.clone();
894            async move { h.route_count().await }
895        });
896        let cmd = rx.recv().await.expect("route_count command");
897        match cmd {
898            RouteControllerCommand::RouteCount { reply } => drop(reply),
899            _ => panic!("unexpected command"),
900        }
901        assert!(matches!(
902            count_task.await.expect("join"),
903            Err(CamelError::ProcessorError(_))
904        ));
905
906        let stop_task = tokio::spawn({
907            let h = handle.clone();
908            async move { h.stop_route("x").await }
909        });
910        let cmd = rx.recv().await.expect("stop command");
911        match cmd {
912            RouteControllerCommand::StopRoute { reply, .. } => drop(reply),
913            _ => panic!("unexpected command"),
914        }
915        assert!(matches!(
916            stop_task.await.expect("join"),
917            Err(CamelError::ProcessorError(_))
918        ));
919
920        let maybe_hash = tokio::spawn({
921            let h = handle.clone();
922            async move { h.route_source_hash("x").await }
923        });
924        let cmd = rx.recv().await.expect("hash command");
925        match cmd {
926            RouteControllerCommand::RouteSourceHash { reply, .. } => drop(reply),
927            _ => panic!("unexpected command"),
928        }
929        assert_eq!(maybe_hash.await.expect("join"), None);
930    }
931
932    #[test]
933    fn try_set_function_invoker_returns_mailbox_full() {
934        let (tx, mut rx) = mpsc::channel(1);
935        tx.try_send(RouteControllerCommand::Shutdown)
936            .expect("fill mailbox");
937        let handle = RouteControllerHandle { tx };
938
939        let result = handle.try_set_function_invoker(Arc::new(NoopInvoker));
940        assert!(matches!(result, Err(CamelError::ProcessorError(_))));
941
942        rx.try_recv().expect("mailbox still has first message");
943    }
944
945    #[tokio::test]
946    async fn test_restart_does_not_block_other_routes() {
947        // D-L6: a route's restart (stop + 100ms sleep + start) must not block
948        // commands for other routes. Without the fix, start_c would wait
949        // behind restart's 100ms sleep in the actor mailbox.
950        let (handle, join_handle) = build_actor_with_components();
951        handle
952            .add_route(route_def("route-a", "timer:tick?period=100"))
953            .await
954            .expect("add route-a");
955        handle
956            .add_route(route_def("route-b", "timer:tick?period=100"))
957            .await
958            .expect("add route-b");
959        handle
960            .add_route(route_def("route-c", "timer:tick?period=100"))
961            .await
962            .expect("add route-c");
963
964        handle.start_route("route-a").await.expect("start route-a");
965        handle.start_route("route-b").await.expect("start route-b");
966
967        // Spawn restart on its own task so its RestartRoute command is
968        // guaranteed to land in the mailbox before start_c is enqueued.
969        let restart_handle = handle.clone();
970        let restart_fut =
971            tokio::spawn(async move { restart_handle.restart_route("route-a").await });
972
973        // Yield once so restart's send completes and RestartRoute is queued first.
974        tokio::task::yield_now().await;
975
976        // Now issue start_c — its StartRoute command is queued behind RestartRoute.
977        let start_c_t0 = std::time::Instant::now();
978        let start_result = handle.start_route("route-c").await;
979        let start_c_elapsed = start_c_t0.elapsed();
980
981        assert!(start_result.is_ok(), "route-c start should succeed");
982        assert!(
983            start_c_elapsed < Duration::from_millis(80),
984            "route-c start blocked by route-a restart (took {start_c_elapsed:?}); \
985             expected to complete before restart's 100ms sleep"
986        );
987
988        let restart_result = restart_fut.await.expect("restart join");
989        assert!(restart_result.is_ok(), "route-a restart should succeed");
990
991        handle.shutdown().await.expect("shutdown send");
992        join_handle.await.expect("actor join");
993    }
994
995    #[tokio::test]
996    async fn test_command_to_restarting_route_is_rejected() {
997        // D-L6: while a route is restarting, mutating commands for that route
998        // must be rejected.
999        let (handle, join_handle) = build_actor_with_components();
1000        handle
1001            .add_route(route_def("route-a", "timer:tick?period=100"))
1002            .await
1003            .expect("add route-a");
1004        handle.start_route("route-a").await.expect("start route-a");
1005
1006        // Spawn restart on its own task so the RestartRoute command is
1007        // guaranteed to land in the mailbox.
1008        let restart_handle = handle.clone();
1009        let restart_fut =
1010            tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1011
1012        // Give the actor time to process the Restart and enter the 100ms sleep.
1013        // After the inline stop completes, the actor returns to rx.recv() and
1014        // the spawned task is sleeping — route-a is in the restarting set.
1015        tokio::time::sleep(Duration::from_millis(20)).await;
1016
1017        // While restarting, try to stop route-a — must be rejected.
1018        let stop_result = handle.stop_route("route-a").await;
1019        assert!(
1020            stop_result.is_err(),
1021            "stop during restart should be rejected"
1022        );
1023        assert!(
1024            stop_result.unwrap_err().to_string().contains("restarting"),
1025            "error should mention restarting"
1026        );
1027
1028        // Now the restart completes.
1029        let restart_result = restart_fut.await.expect("restart join");
1030        assert!(restart_result.is_ok(), "route-a restart should succeed");
1031
1032        handle.shutdown().await.expect("shutdown send");
1033        join_handle.await.expect("actor join");
1034    }
1035}