1use std::{
2 future::Future,
3 pin::Pin,
4 sync::{
5 atomic::{AtomicU64, AtomicUsize, Ordering},
6 Arc, Mutex,
7 },
8 time::Duration,
9};
10
11use async_trait::async_trait;
12use saluki_common::collections::FastHashMap;
13use saluki_common::sync::shutdown::ShutdownHandle;
14use saluki_error::GenericError;
15use snafu::{OptionExt as _, Snafu};
16use tokio::{pin, runtime::Handle, select, sync::mpsc};
17use tracing::{debug, error, warn};
18
19use super::{
20 dedicated::{spawn_dedicated_runtime, RuntimeConfiguration, RuntimeMode},
21 restart::{RestartAction, RestartMode, RestartState, RestartStrategy, RestartType},
22 worker_state::WorkerState,
23};
24use crate::runtime::{
25 process::{Process, ProcessExt as _},
26 state::DataspaceRegistry,
27};
28
29const UNNAMED_CHILD: &str = "unnamed";
33
34pub type SupervisorFuture = Pin<Box<dyn Future<Output = Result<(), GenericError>> + Send>>;
36
37pub(super) type WorkerFuture = Pin<Box<dyn Future<Output = Result<(), WorkerError>> + Send>>;
43
44#[derive(Debug)]
49pub(super) enum WorkerError {
50 Initialization {
56 child_name: Option<String>,
57 source: InitializationError,
58 },
59
60 Runtime(GenericError),
62
63 ShutdownTimedOut {
70 aborted: usize,
72 },
73}
74
75impl From<SupervisorError> for WorkerError {
76 fn from(err: SupervisorError) -> Self {
77 match err {
78 SupervisorError::FailedToInitialize { child_name, source } => WorkerError::Initialization {
81 child_name: Some(child_name),
82 source,
83 },
84 SupervisorError::ShutdownTimedOut { aborted } => WorkerError::ShutdownTimedOut { aborted },
86 other => WorkerError::Runtime(other.into()),
88 }
89 }
90}
91
92#[derive(Debug, Snafu)]
94pub enum ProcessError {
95 #[snafu(display("Child process was aborted by the supervisor."))]
97 Aborted,
98
99 #[snafu(display("Child process panicked."))]
101 Panicked,
102
103 #[snafu(display("Child process terminated with an error: {}", source))]
105 Terminated {
106 source: GenericError,
108 },
109}
110
111#[derive(Debug, Snafu)]
117#[snafu(context(suffix(false)))]
118pub enum InitializationError {
119 #[snafu(display("Process failed to initialize: {}", source))]
121 Failed {
122 source: GenericError,
124 },
125}
126
127impl From<GenericError> for InitializationError {
128 fn from(source: GenericError) -> Self {
129 Self::Failed { source }
130 }
131}
132
133#[derive(Clone, Copy, Debug)]
135pub enum ShutdownStrategy {
136 Graceful(Duration),
138
139 Brutal,
141}
142
143#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
150pub enum AutoShutdown {
151 #[default]
153 Never,
154
155 AnySignificant,
157
158 AllSignificant,
160}
161
162#[async_trait]
164pub trait Supervisable: Send + Sync {
165 fn name(&self) -> &str;
167
168 fn shutdown_strategy(&self) -> ShutdownStrategy {
170 ShutdownStrategy::Graceful(Duration::from_secs(5))
171 }
172
173 fn wants_shutdown_signal(&self) -> bool {
186 true
187 }
188
189 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError>;
203}
204
205#[derive(Debug, Snafu)]
207#[snafu(context(suffix(false)))]
208pub enum SupervisorError {
209 #[snafu(display("Invalid name for supervisor or worker: '{}'", name))]
211 InvalidName {
212 name: String,
214 },
215
216 #[snafu(display("Child process '{}' failed to initialize: {}", child_name, source))]
221 FailedToInitialize {
222 child_name: String,
224
225 source: InitializationError,
227 },
228
229 #[snafu(display("Supervisor has exceeded restart limits and was forced to shutdown."))]
231 Shutdown,
232
233 #[snafu(display("Supervisor shut down after a significant child terminated."))]
238 SignificantChildExited,
239
240 #[snafu(display(
249 "Shutdown completed uncleanly: {} worker(s) were forcefully aborted after exceeding their shutdown timeout.",
250 aborted
251 ))]
252 ShutdownTimedOut {
253 aborted: usize,
255 },
256}
257
258pub struct ChildSpecification<S = WorkerSpec> {
278 spec_inner: S,
279}
280
281pub struct WorkerSpec {
283 worker: Arc<dyn Supervisable>,
284 options: ChildOptions,
285}
286
287pub struct SupervisorSpec {
289 supervisor: Supervisor,
290 options: ChildOptions,
291}
292
293impl ChildSpecification<WorkerSpec> {
299 pub(crate) fn worker<T: Supervisable + 'static>(worker: T) -> Self {
301 Self {
302 spec_inner: WorkerSpec {
303 worker: Arc::new(worker),
304 options: ChildOptions::default(),
305 },
306 }
307 }
308
309 pub(crate) fn one_shot_worker<T: Supervisable + 'static>(worker: T) -> Self {
314 Self::worker(worker).with_restart_type(RestartType::Temporary)
315 }
316
317 #[must_use]
323 pub(crate) fn with_restart_type(mut self, restart_type: RestartType) -> Self {
324 self.spec_inner.options.restart = Some(restart_type);
325 self
326 }
327
328 #[must_use]
334 pub(crate) fn with_significant(mut self, significant: bool) -> Self {
335 self.spec_inner.options.significant = significant;
336 self
337 }
338
339 #[must_use]
348 pub(crate) fn with_runtime(mut self, handle: Handle) -> Self {
349 self.spec_inner.options.runtime = Some(handle);
350 self
351 }
352
353 #[must_use]
360 pub(crate) fn with_shutdown_strategy(mut self, strategy: ShutdownStrategy) -> Self {
361 self.spec_inner.options.shutdown = ChildShutdown::Explicit(strategy);
362 self
363 }
364
365 #[must_use]
376 pub(crate) fn with_budget_bounded_shutdown(mut self) -> Self {
377 self.spec_inner.options.shutdown = ChildShutdown::BudgetBounded;
378 self
379 }
380}
381
382impl ChildSpecification<SupervisorSpec> {
391 #[must_use]
397 pub(crate) fn with_restart_type(mut self, restart_type: RestartType) -> Self {
398 self.spec_inner.options.restart = Some(restart_type);
399 self
400 }
401
402 #[must_use]
407 pub(crate) fn with_significant(mut self, significant: bool) -> Self {
408 self.spec_inner.options.significant = significant;
409 self
410 }
411}
412
413impl<T> From<T> for ChildSpecification<WorkerSpec>
414where
415 T: Supervisable + 'static,
416{
417 fn from(worker: T) -> Self {
418 Self::worker(worker)
419 }
420}
421
422impl From<Supervisor> for ChildSpecification<SupervisorSpec> {
423 fn from(supervisor: Supervisor) -> Self {
424 Self {
425 spec_inner: SupervisorSpec {
426 supervisor,
427 options: ChildOptions::default(),
428 },
429 }
430 }
431}
432
433mod sealed {
434 pub trait Sealed {}
435}
436
437impl sealed::Sealed for WorkerSpec {}
438impl sealed::Sealed for SupervisorSpec {}
439
440pub trait ChildState: sealed::Sealed + Sized {
447 #[doc(hidden)]
452 fn into_child_parts(spec: ChildSpecification<Self>, default_restart: RestartType) -> LoweredChild;
453}
454
455pub struct LoweredChild {
460 spec: SupervisedChild,
461 config: ChildConfig,
462}
463
464impl ChildState for WorkerSpec {
465 fn into_child_parts(spec: ChildSpecification<Self>, default_restart: RestartType) -> LoweredChild {
466 let WorkerSpec { worker, options } = spec.spec_inner;
467 LoweredChild {
468 spec: SupervisedChild::Worker(worker),
469 config: options.resolve(default_restart),
470 }
471 }
472}
473
474impl ChildState for SupervisorSpec {
475 fn into_child_parts(spec: ChildSpecification<Self>, default_restart: RestartType) -> LoweredChild {
476 let SupervisorSpec { supervisor, options } = spec.spec_inner;
477 LoweredChild {
478 spec: SupervisedChild::Supervisor(supervisor),
479 config: options.resolve(default_restart),
480 }
481 }
482}
483
484pub(super) enum SupervisedChild {
486 Worker(Arc<dyn Supervisable>),
487 Supervisor(Supervisor),
488}
489
490impl SupervisedChild {
491 pub(super) fn is_supervisor(&self) -> bool {
493 matches!(self, Self::Supervisor(_))
494 }
495
496 fn process_type(&self) -> &'static str {
497 match self {
498 Self::Worker(_) => "worker",
499 Self::Supervisor(_) => "supervisor",
500 }
501 }
502
503 fn name(&self) -> &str {
504 match self {
505 Self::Worker(worker) => worker.name(),
506 Self::Supervisor(supervisor) => &supervisor.supervisor_id,
507 }
508 }
509
510 pub(super) fn wants_shutdown_signal(&self) -> bool {
516 match self {
517 Self::Worker(worker) => worker.wants_shutdown_signal(),
518 Self::Supervisor(_) => true,
519 }
520 }
521
522 pub(super) fn shutdown_strategy(&self) -> ShutdownStrategy {
523 match self {
524 Self::Worker(worker) => worker.shutdown_strategy(),
525
526 Self::Supervisor(_) => ShutdownStrategy::Graceful(Duration::MAX),
529 }
530 }
531
532 pub(super) fn create_process(&self, parent_process: &Process) -> Process {
539 let name = self.name();
540 let process = match self {
541 Self::Worker(_) => Process::worker(name, parent_process),
542 Self::Supervisor(_) => Process::supervisor(name, Some(parent_process)),
543 };
544
545 process.unwrap_or_else(|| {
546 warn!(
547 parent_process = parent_process.name(),
548 child_name = name,
549 "Child process name is not usable as a process name; falling back to '{}'.",
550 UNNAMED_CHILD
551 );
552
553 match self {
554 Self::Worker(_) => Process::worker(UNNAMED_CHILD, parent_process),
555 Self::Supervisor(_) => Process::supervisor(UNNAMED_CHILD, Some(parent_process)),
556 }
557 .expect("placeholder child name is always a valid process name")
558 })
559 }
560
561 pub(super) fn create_worker_future(
562 &self, process: Process, process_shutdown: ShutdownHandle,
563 ) -> Result<WorkerFuture, SupervisorError> {
564 match self {
565 Self::Worker(worker) => {
566 let worker = Arc::clone(worker);
567 Ok(Box::pin(async move {
568 let run_future =
569 worker
570 .initialize(process_shutdown)
571 .await
572 .map_err(|source| WorkerError::Initialization {
573 child_name: None,
574 source,
575 })?;
576 run_future.await.map_err(WorkerError::Runtime)
577 }))
578 }
579 Self::Supervisor(sup) => {
580 match sup.runtime_mode() {
581 RuntimeMode::Ambient => {
582 Ok(sup.as_nested_process(process, process_shutdown))
584 }
585 RuntimeMode::Dedicated(config) => {
586 let child_name = sup.supervisor_id.to_string();
589 let dataspace = process.dataspace().clone();
590 let handle =
591 spawn_dedicated_runtime(sup.inner_clone(), config.clone(), process_shutdown, dataspace)
592 .map_err(|e| SupervisorError::FailedToInitialize {
593 child_name,
594 source: e.into(),
595 })?;
596
597 Ok(Box::pin(async move { handle.await.map_err(WorkerError::from) }))
598 }
599 }
600 }
601 }
602 }
603}
604
605impl Clone for SupervisedChild {
606 fn clone(&self) -> Self {
607 match self {
608 Self::Worker(worker) => Self::Worker(Arc::clone(worker)),
609 Self::Supervisor(supervisor) => Self::Supervisor(supervisor.inner_clone()),
610 }
611 }
612}
613
614#[derive(Clone, Copy, Debug, Default)]
616pub(super) enum ChildShutdown {
617 #[default]
619 Worker,
620
621 Explicit(ShutdownStrategy),
623
624 BudgetBounded,
629}
630
631#[derive(Clone, Debug, Default)]
638pub(super) struct ChildOptions {
639 restart: Option<RestartType>,
640 significant: bool,
641
642 runtime: Option<Handle>,
644
645 shutdown: ChildShutdown,
646}
647
648impl ChildOptions {
649 fn resolve(self, default_restart: RestartType) -> ChildConfig {
651 ChildConfig {
652 restart: self.restart.unwrap_or(default_restart),
653 significant: self.significant,
654 runtime: self.runtime,
655 shutdown: self.shutdown,
656 }
657 }
658}
659
660#[derive(Clone, Debug)]
663pub(super) struct ChildConfig {
664 restart: RestartType,
665 significant: bool,
666 runtime: Option<Handle>,
667 shutdown: ChildShutdown,
668}
669
670impl ChildConfig {
671 pub(super) fn runtime(&self) -> Option<&Handle> {
673 self.runtime.as_ref()
674 }
675
676 pub(super) fn shutdown(&self) -> ChildShutdown {
678 self.shutdown
679 }
680}
681
682#[derive(Clone)]
684struct ChildEntry {
685 spec: SupervisedChild,
686 config: ChildConfig,
687 dynamic: bool,
690}
691
692#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
697pub struct ChildId(u64);
698
699impl ChildId {
700 pub const fn as_u64(self) -> u64 {
702 self.0
703 }
704}
705
706struct PendingSpawn {
708 id: u64,
709 spec: SupervisedChild,
710 config: ChildConfig,
711}
712
713const SPAWN_DRAIN_BATCH: usize = 64;
719
720#[derive(Clone)]
736pub struct SupervisorHandle {
737 name: Arc<str>,
738 current_tx: Arc<Mutex<Option<mpsc::UnboundedSender<PendingSpawn>>>>,
741 id_counter: Arc<AtomicU64>,
742 active: Arc<AtomicUsize>,
743}
744
745impl SupervisorHandle {
746 pub fn name(&self) -> &str {
748 &self.name
749 }
750
751 pub fn spawn<S, T>(&self, child: T) -> ChildId
766 where
767 S: ChildState,
768 T: Into<ChildSpecification<S>>,
769 {
770 let LoweredChild { spec, config } = S::into_child_parts(child.into(), RestartType::Temporary);
771
772 let id = self.id_counter.fetch_add(1, Ordering::Relaxed);
775 let pending = PendingSpawn { id, spec, config };
776
777 let tx = self.current_tx.lock().unwrap().clone();
784 match tx {
785 Some(tx) => {
788 if let Err(e) = tx.send(pending) {
789 debug!(
790 supervisor_id = %self.name,
791 child_name = e.0.spec.name(),
792 "Supervisor is shutting down; dynamic child will not be started."
793 );
794 }
795 }
796 None => warn!(
799 supervisor_id = %self.name,
800 child_name = pending.spec.name(),
801 "Supervisor is not running; dynamic child will not be started."
802 ),
803 }
804
805 ChildId(id)
806 }
807
808 pub fn is_running(&self) -> bool {
810 self.current_tx.lock().unwrap().is_some()
811 }
812
813 pub fn active_children(&self) -> usize {
818 self.active.load(Ordering::Relaxed)
819 }
820}
821
822pub struct Supervisor {
850 supervisor_id: Arc<str>,
851 child_specs: Vec<ChildEntry>,
852 restart_strategy: RestartStrategy,
853 auto_shutdown: AutoShutdown,
854 shutdown_budget: Option<Duration>,
855 runtime_mode: RuntimeMode,
856 current_tx: Arc<Mutex<Option<mpsc::UnboundedSender<PendingSpawn>>>>,
860 id_counter: Arc<AtomicU64>,
861 active: Arc<AtomicUsize>,
863}
864
865impl Supervisor {
866 pub fn new<S: AsRef<str>>(supervisor_id: S) -> Result<Self, SupervisorError> {
868 if supervisor_id.as_ref().is_empty() {
872 return Err(SupervisorError::InvalidName {
873 name: supervisor_id.as_ref().to_string(),
874 });
875 }
876
877 Ok(Self {
878 supervisor_id: supervisor_id.as_ref().into(),
879 child_specs: Vec::new(),
880 restart_strategy: RestartStrategy::default(),
881 auto_shutdown: AutoShutdown::default(),
882 shutdown_budget: None,
883 runtime_mode: RuntimeMode::default(),
884 current_tx: Arc::new(Mutex::new(None)),
885 id_counter: Arc::new(AtomicU64::new(0)),
886 active: Arc::new(AtomicUsize::new(0)),
887 })
888 }
889
890 pub fn id(&self) -> &str {
892 &self.supervisor_id
893 }
894
895 pub fn with_restart_strategy(mut self, strategy: RestartStrategy) -> Self {
897 self.restart_strategy = strategy;
898 self
899 }
900
901 pub fn with_auto_shutdown(mut self, auto_shutdown: AutoShutdown) -> Self {
906 self.auto_shutdown = auto_shutdown;
907 self
908 }
909
910 #[must_use]
933 pub fn with_shutdown_budget(mut self, budget: Duration) -> Self {
934 self.shutdown_budget = Some(budget);
935 self
936 }
937
938 pub fn handle(&self) -> SupervisorHandle {
944 SupervisorHandle {
945 name: Arc::clone(&self.supervisor_id),
946 current_tx: Arc::clone(&self.current_tx),
947 id_counter: Arc::clone(&self.id_counter),
948 active: Arc::clone(&self.active),
949 }
950 }
951
952 pub fn with_dedicated_runtime(mut self, config: RuntimeConfiguration) -> Self {
962 self.runtime_mode = RuntimeMode::Dedicated(config);
963 self
964 }
965
966 pub(crate) fn runtime_mode(&self) -> &RuntimeMode {
968 &self.runtime_mode
969 }
970
971 pub fn add_worker<S, T>(&mut self, child: T)
981 where
982 S: ChildState,
983 T: Into<ChildSpecification<S>>,
984 {
985 let LoweredChild { spec, config } = S::into_child_parts(child.into(), RestartType::Permanent);
986 self.push_child(ChildEntry {
987 spec,
988 config,
989 dynamic: false,
990 });
991 }
992
993 fn warn_if_significance_is_inert(&self, config: &ChildConfig, child_name: &str) {
1007 if !config.significant {
1008 return;
1009 }
1010
1011 if config.restart == RestartType::Permanent {
1012 warn!(
1013 supervisor_id = %self.supervisor_id,
1014 child_name,
1015 "Child is marked significant but is permanent, so it is always restarted and the flag has no effect."
1016 );
1017 }
1018
1019 if self.auto_shutdown == AutoShutdown::Never {
1020 warn!(
1021 supervisor_id = %self.supervisor_id,
1022 child_name,
1023 "Child is marked significant but the supervisor's auto-shutdown policy is `Never`, so the flag has \
1024 no effect."
1025 );
1026 }
1027 }
1028
1029 fn push_child(&mut self, entry: ChildEntry) {
1030 debug!(
1031 supervisor_id = %self.supervisor_id,
1032 "Adding new static child process #{}. ({}, {}, {:?})",
1033 self.child_specs.len(),
1034 entry.spec.process_type(),
1035 entry.spec.name(),
1036 entry.config,
1037 );
1038
1039 debug_assert!(
1044 !(entry.config.significant && entry.config.restart == RestartType::Permanent),
1045 "child '{}' was marked significant but is permanent, so it is always restarted and its termination can \
1046 never drive auto-shutdown",
1047 entry.spec.name()
1048 );
1049
1050 self.child_specs.push(entry);
1051 }
1052
1053 fn spawn_static_children(
1054 &self, children: &mut FastHashMap<u64, ChildEntry>, worker_state: &mut WorkerState,
1055 ) -> Result<(), SupervisorError> {
1056 debug!(supervisor_id = %self.supervisor_id, "Spawning all static child processes.");
1057 for entry in &self.child_specs {
1058 self.warn_if_significance_is_inert(&entry.config, entry.spec.name());
1059
1060 let id = self.id_counter.fetch_add(1, Ordering::Relaxed);
1061 worker_state.add_worker(id, &entry.spec, &entry.config)?;
1062 children.insert(id, entry.clone());
1063 }
1064
1065 Ok(())
1066 }
1067
1068 fn respawn_children_one_for_all(
1076 &self, children: &mut FastHashMap<u64, ChildEntry>, worker_state: &mut WorkerState,
1077 ) -> Result<(), SupervisorError> {
1078 debug!(supervisor_id = %self.supervisor_id, "Restarting all eligible static child processes.");
1079 for entry in &self.child_specs {
1080 if entry.config.restart == RestartType::Temporary {
1083 continue;
1084 }
1085 let id = self.id_counter.fetch_add(1, Ordering::Relaxed);
1086 worker_state.add_worker(id, &entry.spec, &entry.config)?;
1087 children.insert(id, entry.clone());
1088 }
1089
1090 Ok(())
1091 }
1092
1093 fn spawn_dynamic_child(
1095 &self, spawn: PendingSpawn, worker_state: &mut WorkerState, children: &mut FastHashMap<u64, ChildEntry>,
1096 significant_remaining: &mut usize,
1097 ) {
1098 let PendingSpawn { id, spec, config } = spawn;
1099 let entry = ChildEntry {
1100 spec,
1101 config,
1102 dynamic: true,
1103 };
1104 self.warn_if_significance_is_inert(&entry.config, entry.spec.name());
1105
1106 match worker_state.add_worker(id, &entry.spec, &entry.config) {
1107 Ok(()) => {
1108 if entry.config.significant {
1109 *significant_remaining += 1;
1110 }
1111 self.active.fetch_add(1, Ordering::Relaxed);
1112 children.insert(id, entry);
1113 }
1114 Err(e) => {
1115 error!(
1119 supervisor_id = %self.supervisor_id,
1120 child_name = entry.spec.name(),
1121 error = %e,
1122 "Failed to start dynamic child."
1123 );
1124 }
1125 }
1126 }
1127
1128 async fn run_inner(&self, process: Process, process_shutdown: ShutdownHandle) -> Result<(), SupervisorError> {
1129 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
1132 *self.current_tx.lock().unwrap() = Some(cmd_tx);
1133
1134 let result = self.supervise(process, process_shutdown, cmd_rx).await;
1135
1136 *self.current_tx.lock().unwrap() = None;
1139 self.active.store(0, Ordering::Relaxed);
1140 result
1141 }
1142
1143 async fn supervise(
1144 &self, process: Process, process_shutdown: ShutdownHandle, mut cmd_rx: mpsc::UnboundedReceiver<PendingSpawn>,
1145 ) -> Result<(), SupervisorError> {
1146 let mut restart_state = RestartState::new(self.restart_strategy);
1147 let mut worker_state = WorkerState::new(process, self.handle(), self.shutdown_budget);
1148
1149 let mut children: FastHashMap<u64, ChildEntry> = FastHashMap::default();
1152
1153 self.spawn_static_children(&mut children, &mut worker_state)?;
1156
1157 let mut significant_remaining = children.values().filter(|entry| entry.config.significant).count();
1159
1160 let mut spawn_batch = Vec::with_capacity(SPAWN_DRAIN_BATCH);
1162
1163 pin!(process_shutdown);
1165
1166 let outcome = loop {
1167 select! {
1168 biased;
1171
1172 _ = &mut process_shutdown => break Ok(()),
1176
1177 (child_id, worker_result) = worker_state.wait_for_next_worker() => {
1181 let (child_name, config, dynamic) = {
1183 let entry = children.get(&child_id).expect("completed worker must be present in the roster");
1184 (entry.spec.name().to_string(), entry.config.clone(), entry.dynamic)
1185 };
1186
1187 if let Err(WorkerError::Initialization { child_name: inner, source }) = worker_result {
1189 let full_name = match inner {
1192 Some(inner) => format!("{}/{}", child_name, inner),
1193 None => child_name.clone(),
1194 };
1195
1196 error!(supervisor_id = %self.supervisor_id, worker_name = full_name, "Child process failed to initialize: {}", source);
1197 break Err(SupervisorError::FailedToInitialize { child_name: full_name, source });
1198 }
1199
1200 let abnormal = worker_result.is_err();
1203 let worker_result = worker_result.map_err(|e| match e {
1204 WorkerError::Runtime(e) => ProcessError::Terminated { source: e },
1205 WorkerError::Initialization { .. } => unreachable!("handled above"),
1206 WorkerError::ShutdownTimedOut { aborted } => ProcessError::Terminated {
1211 source: SupervisorError::ShutdownTimedOut { aborted }.into(),
1212 },
1213 });
1214
1215 if !config.restart.should_restart(abnormal) {
1216 if abnormal {
1225 warn!(supervisor_id = %self.supervisor_id, worker_name = %child_name, restart = ?config.restart, ?worker_result, "Child process exited with an error and is not eligible for restart.");
1226 } else {
1227 debug!(supervisor_id = %self.supervisor_id, worker_name = %child_name, restart = ?config.restart, "Child process exited and is not eligible for restart.");
1228 }
1229 children.remove(&child_id);
1230 if dynamic {
1231 self.active.fetch_sub(1, Ordering::Relaxed);
1232 }
1233
1234 if config.significant {
1238 significant_remaining = significant_remaining.saturating_sub(1);
1239 let auto_shutdown = match self.auto_shutdown {
1240 AutoShutdown::Never => false,
1241 AutoShutdown::AnySignificant => true,
1242 AutoShutdown::AllSignificant => significant_remaining == 0,
1243 };
1244 if auto_shutdown {
1245 warn!(supervisor_id = %self.supervisor_id, worker_name = %child_name, ?worker_result, "Significant child terminated; shutting down supervisor.");
1246 break Err(SupervisorError::SignificantChildExited);
1247 }
1248 }
1249 } else {
1250 match restart_state.evaluate_restart() {
1251 RestartAction::Restart(mode) => match mode {
1252 RestartMode::OneForOne => {
1253 warn!(supervisor_id = %self.supervisor_id, worker_name = %child_name, ?worker_result, "Child process terminated, restarting.");
1254 let spec = children.get(&child_id).expect("present for restart").spec.clone();
1255 if let Err(e) = worker_state.add_worker(child_id, &spec, &config) {
1256 break Err(e);
1257 }
1258 }
1259 RestartMode::OneForAll => {
1260 warn!(supervisor_id = %self.supervisor_id, worker_name = %child_name, ?worker_result, "Child process terminated, restarting all processes.");
1261 let _ = worker_state.shutdown_workers().await;
1265 children.clear();
1269 self.active.store(0, Ordering::Relaxed);
1270 let respawn = self.respawn_children_one_for_all(&mut children, &mut worker_state);
1271 if let Err(e) = respawn {
1272 break Err(e);
1273 }
1274 significant_remaining =
1275 children.values().filter(|entry| entry.config.significant).count();
1276 }
1277 },
1278 RestartAction::Shutdown => {
1279 error!(supervisor_id = %self.supervisor_id, worker_name = %child_name, ?worker_result, "Supervisor shutting down due to restart limits.");
1280 break Err(SupervisorError::Shutdown);
1281 }
1282 }
1283 }
1284 }
1285
1286 _ = cmd_rx.recv_many(&mut spawn_batch, SPAWN_DRAIN_BATCH) => {
1290 for spawn in spawn_batch.drain(..) {
1291 self.spawn_dynamic_child(spawn, &mut worker_state, &mut children, &mut significant_remaining);
1292 }
1293 }
1294 }
1295 };
1296
1297 cmd_rx.close();
1302 let mut discarded = 0;
1303 while cmd_rx.try_recv().is_ok() {
1304 discarded += 1;
1305 }
1306 if discarded > 0 {
1307 debug!(
1308 supervisor_id = %self.supervisor_id,
1309 discarded,
1310 "Discarded queued dynamic children during shutdown."
1311 );
1312 }
1313 let aborted = worker_state.shutdown_workers().await;
1314
1315 match outcome {
1320 Ok(()) if aborted > 0 => {
1321 warn!(supervisor_id = %self.supervisor_id, aborted, "Shutdown completed uncleanly; workers were forcefully aborted.");
1322 Err(SupervisorError::ShutdownTimedOut { aborted })
1323 }
1324 outcome => outcome,
1325 }
1326 }
1327
1328 fn as_nested_process(&self, process: Process, process_shutdown: ShutdownHandle) -> WorkerFuture {
1329 debug!(supervisor_id = %self.supervisor_id, "Nested supervisor starting.");
1332
1333 let sup = self.inner_clone();
1335
1336 Box::pin(async move {
1337 sup.run_inner(process, process_shutdown)
1338 .await
1339 .map_err(WorkerError::from)
1340 })
1341 }
1342
1343 pub async fn run(&mut self) -> Result<(), SupervisorError> {
1349 let process_shutdown = ShutdownHandle::noop();
1352 let process = Process::supervisor(&self.supervisor_id, None).context(InvalidName {
1353 name: self.supervisor_id.to_string(),
1354 })?;
1355
1356 debug!(supervisor_id = %self.supervisor_id, "Supervisor starting.");
1357 self.run_inner(process.clone(), process_shutdown)
1358 .into_process_future(process)
1359 .await
1360 }
1361
1362 pub async fn run_with_shutdown<F: Future + Send + 'static>(&mut self, shutdown: F) -> Result<(), SupervisorError> {
1371 let (shutdown_coordinator, shutdown_handle) = ShutdownHandle::paired();
1375 let run = self.run_with_shutdown_inner(shutdown_handle, None);
1376 pin!(run, shutdown);
1377
1378 let mut shutdown_coordinator = Some(shutdown_coordinator);
1379 loop {
1380 select! {
1381 result = &mut run => return result,
1382 _ = &mut shutdown, if shutdown_coordinator.is_some() => {
1383 shutdown_coordinator.take().expect("coordinator present per select guard").shutdown();
1384 }
1385 }
1386 }
1387 }
1388
1389 pub(crate) async fn run_with_shutdown_inner(
1401 &mut self, process_shutdown: ShutdownHandle, dataspace: Option<DataspaceRegistry>,
1402 ) -> Result<(), SupervisorError> {
1403 let process =
1404 Process::supervisor_with_dataspace(&self.supervisor_id, None, dataspace).context(InvalidName {
1405 name: self.supervisor_id.to_string(),
1406 })?;
1407
1408 debug!(supervisor_id = %self.supervisor_id, "Supervisor starting.");
1409 self.run_inner(process.clone(), process_shutdown)
1410 .into_process_future(process)
1411 .await
1412 }
1413
1414 fn inner_clone(&self) -> Self {
1415 Self {
1419 supervisor_id: Arc::clone(&self.supervisor_id),
1420 child_specs: self.child_specs.clone(),
1421 restart_strategy: self.restart_strategy,
1422 auto_shutdown: self.auto_shutdown,
1423 shutdown_budget: self.shutdown_budget,
1424 runtime_mode: self.runtime_mode.clone(),
1425 current_tx: Arc::clone(&self.current_tx),
1426 id_counter: Arc::clone(&self.id_counter),
1427 active: Arc::clone(&self.active),
1428 }
1429 }
1430}
1431
1432#[cfg(test)]
1433mod tests {
1434 use std::{
1435 future::pending,
1436 sync::atomic::{AtomicBool, AtomicUsize, Ordering},
1437 };
1438
1439 use async_trait::async_trait;
1440 use saluki_common::sync::shutdown::ShutdownCoordinator;
1441 use saluki_metrics::test::TestRecorder;
1442 use tokio::{
1443 sync::oneshot,
1444 task::JoinHandle,
1445 time::{sleep, timeout},
1446 };
1447
1448 use super::*;
1449 use crate::runtime::{self, FnWorker};
1450 use crate::test_support::wait_until;
1451
1452 #[derive(Clone)]
1454 enum InitBehavior {
1455 Instant,
1457
1458 Slow(Duration),
1460
1461 Fail(&'static str),
1463 }
1464
1465 #[derive(Clone)]
1467 enum RunBehavior {
1468 UntilShutdown,
1470
1471 FailAfter(Duration, &'static str),
1473
1474 CompleteAfter(Duration),
1476
1477 SlowShutdown(Duration),
1479
1480 IgnoreShutdown,
1482
1483 PanicAfter(Duration),
1485 }
1486
1487 struct MockWorker {
1489 name: &'static str,
1490 init_behavior: InitBehavior,
1491 run_behavior: RunBehavior,
1492 start_count: Arc<AtomicUsize>,
1493 finish_count: Arc<AtomicUsize>,
1494 brutal_shutdown: bool,
1495 graceful_timeout: Duration,
1496 }
1497
1498 impl MockWorker {
1499 fn long_running(name: &'static str) -> Self {
1501 Self {
1502 name,
1503 init_behavior: InitBehavior::Instant,
1504 run_behavior: RunBehavior::UntilShutdown,
1505 start_count: Arc::new(AtomicUsize::new(0)),
1506 finish_count: Arc::new(AtomicUsize::new(0)),
1507 brutal_shutdown: false,
1508 graceful_timeout: Duration::from_millis(500),
1509 }
1510 }
1511
1512 fn failing(name: &'static str, delay: Duration) -> Self {
1514 Self {
1515 name,
1516 init_behavior: InitBehavior::Instant,
1517 run_behavior: RunBehavior::FailAfter(delay, "worker failed"),
1518 start_count: Arc::new(AtomicUsize::new(0)),
1519 finish_count: Arc::new(AtomicUsize::new(0)),
1520 brutal_shutdown: false,
1521 graceful_timeout: Duration::from_millis(500),
1522 }
1523 }
1524
1525 fn completing(name: &'static str, delay: Duration) -> Self {
1527 Self {
1528 name,
1529 init_behavior: InitBehavior::Instant,
1530 run_behavior: RunBehavior::CompleteAfter(delay),
1531 start_count: Arc::new(AtomicUsize::new(0)),
1532 finish_count: Arc::new(AtomicUsize::new(0)),
1533 brutal_shutdown: false,
1534 graceful_timeout: Duration::from_millis(500),
1535 }
1536 }
1537
1538 fn slow_shutdown(name: &'static str, delay: Duration) -> Self {
1540 Self {
1541 name,
1542 init_behavior: InitBehavior::Instant,
1543 run_behavior: RunBehavior::SlowShutdown(delay),
1544 start_count: Arc::new(AtomicUsize::new(0)),
1545 finish_count: Arc::new(AtomicUsize::new(0)),
1546 brutal_shutdown: false,
1547 graceful_timeout: Duration::from_millis(500),
1548 }
1549 }
1550
1551 fn ignore_shutdown(name: &'static str) -> Self {
1553 Self {
1554 name,
1555 init_behavior: InitBehavior::Instant,
1556 run_behavior: RunBehavior::IgnoreShutdown,
1557 start_count: Arc::new(AtomicUsize::new(0)),
1558 finish_count: Arc::new(AtomicUsize::new(0)),
1559 brutal_shutdown: false,
1560 graceful_timeout: Duration::from_millis(500),
1561 }
1562 }
1563
1564 fn panicking(name: &'static str, delay: Duration) -> Self {
1566 Self {
1567 name,
1568 init_behavior: InitBehavior::Instant,
1569 run_behavior: RunBehavior::PanicAfter(delay),
1570 start_count: Arc::new(AtomicUsize::new(0)),
1571 finish_count: Arc::new(AtomicUsize::new(0)),
1572 brutal_shutdown: false,
1573 graceful_timeout: Duration::from_millis(500),
1574 }
1575 }
1576
1577 fn init_failure(name: &'static str) -> Self {
1579 Self {
1580 name,
1581 init_behavior: InitBehavior::Fail("init failed"),
1582 run_behavior: RunBehavior::UntilShutdown,
1583 start_count: Arc::new(AtomicUsize::new(0)),
1584 finish_count: Arc::new(AtomicUsize::new(0)),
1585 brutal_shutdown: false,
1586 graceful_timeout: Duration::from_millis(500),
1587 }
1588 }
1589
1590 fn slow_init(name: &'static str, init_delay: Duration) -> Self {
1592 Self {
1593 name,
1594 init_behavior: InitBehavior::Slow(init_delay),
1595 run_behavior: RunBehavior::UntilShutdown,
1596 start_count: Arc::new(AtomicUsize::new(0)),
1597 finish_count: Arc::new(AtomicUsize::new(0)),
1598 brutal_shutdown: false,
1599 graceful_timeout: Duration::from_millis(500),
1600 }
1601 }
1602
1603 fn start_count(&self) -> Arc<AtomicUsize> {
1609 Arc::clone(&self.start_count)
1610 }
1611
1612 fn finish_count(&self) -> Arc<AtomicUsize> {
1620 Arc::clone(&self.finish_count)
1621 }
1622
1623 fn with_brutal_shutdown(mut self) -> Self {
1625 self.brutal_shutdown = true;
1626 self
1627 }
1628
1629 fn with_graceful_timeout(mut self, timeout: Duration) -> Self {
1631 self.graceful_timeout = timeout;
1632 self
1633 }
1634 }
1635
1636 #[async_trait]
1637 impl Supervisable for MockWorker {
1638 fn name(&self) -> &str {
1639 self.name
1640 }
1641
1642 fn shutdown_strategy(&self) -> ShutdownStrategy {
1643 if self.brutal_shutdown {
1644 ShutdownStrategy::Brutal
1645 } else {
1646 ShutdownStrategy::Graceful(self.graceful_timeout)
1647 }
1648 }
1649
1650 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
1651 match &self.init_behavior {
1652 InitBehavior::Instant => {}
1653 InitBehavior::Slow(delay) => {
1654 sleep(*delay).await;
1655 }
1656 InitBehavior::Fail(msg) => {
1657 return Err(InitializationError::Failed {
1658 source: GenericError::msg(*msg),
1659 });
1660 }
1661 }
1662
1663 let start_count = Arc::clone(&self.start_count);
1664 let finish_count = Arc::clone(&self.finish_count);
1665 let run_behavior = self.run_behavior.clone();
1666
1667 Ok(Box::pin(async move {
1668 start_count.fetch_add(1, Ordering::SeqCst);
1669
1670 match run_behavior {
1671 RunBehavior::UntilShutdown => {
1672 process_shutdown.await;
1673 Ok(())
1674 }
1675 RunBehavior::FailAfter(delay, msg) => {
1676 select! {
1677 _ = sleep(delay) => {
1678 finish_count.fetch_add(1, Ordering::SeqCst);
1681 Err(GenericError::msg(msg))
1682 }
1683 _ = process_shutdown => {
1684 Ok(())
1685 }
1686 }
1687 }
1688 RunBehavior::CompleteAfter(delay) => {
1689 select! {
1690 _ = sleep(delay) => {
1691 finish_count.fetch_add(1, Ordering::SeqCst);
1693 Ok(())
1694 }
1695 _ = process_shutdown => Ok(()),
1696 }
1697 }
1698 RunBehavior::SlowShutdown(delay) => {
1699 process_shutdown.await;
1700 sleep(delay).await;
1701 finish_count.fetch_add(1, Ordering::SeqCst);
1703 Ok(())
1704 }
1705 RunBehavior::IgnoreShutdown => {
1706 let _hold = process_shutdown;
1708 pending().await
1709 }
1710 RunBehavior::PanicAfter(delay) => {
1711 select! {
1712 _ = sleep(delay) => panic!("worker panicked"),
1713 _ = process_shutdown => Ok(()),
1714 }
1715 }
1716 }
1717 }))
1718 }
1719 }
1720
1721 async fn run_supervisor_with_trigger(
1727 supervisor: Supervisor,
1728 ) -> (oneshot::Sender<()>, JoinHandle<Result<(), SupervisorError>>) {
1729 let sup_handle = supervisor.handle();
1731 let mut supervisor = supervisor;
1732
1733 let (tx, rx) = oneshot::channel();
1734 let handle = tokio::spawn(async move { supervisor.run_with_shutdown(rx).await });
1735
1736 wait_until("supervisor is running", || sup_handle.is_running()).await;
1737 (tx, handle)
1738 }
1739
1740 async fn join_supervisor(handle: JoinHandle<Result<(), SupervisorError>>) -> Result<(), SupervisorError> {
1745 timeout(Duration::from_secs(2), handle)
1746 .await
1747 .expect("supervisor should exit promptly")
1748 .expect("supervisor task should not panic")
1749 }
1750
1751 #[tokio::test]
1754 async fn standalone_supervisor_shuts_down_cleanly() {
1755 let mut sup = Supervisor::new("test-sup").unwrap();
1756 sup.add_worker(MockWorker::long_running("worker1"));
1757 sup.add_worker(MockWorker::long_running("worker2"));
1758
1759 let (tx, handle) = run_supervisor_with_trigger(sup).await;
1760 tx.send(()).unwrap();
1761
1762 let result = join_supervisor(handle).await;
1763 assert!(result.is_ok());
1764 }
1765
1766 #[tokio::test]
1767 async fn nested_supervisor_shuts_down_cleanly() {
1768 let mut child_sup = Supervisor::new("child-sup").unwrap();
1769 child_sup.add_worker(MockWorker::long_running("inner-worker"));
1770
1771 let mut parent_sup = Supervisor::new("parent-sup").unwrap();
1772 parent_sup.add_worker(MockWorker::long_running("outer-worker"));
1773 parent_sup.add_worker(child_sup);
1774
1775 let (tx, handle) = run_supervisor_with_trigger(parent_sup).await;
1776 tx.send(()).unwrap();
1777
1778 let result = join_supervisor(handle).await;
1779 assert!(result.is_ok());
1780 }
1781
1782 #[tokio::test]
1783 async fn empty_supervisor_idles_until_shutdown() {
1784 let sup = Supervisor::new("empty-sup").unwrap();
1787
1788 let (tx, handle) = run_supervisor_with_trigger(sup).await;
1789 assert!(!handle.is_finished(), "an empty supervisor must idle rather than exit");
1790
1791 tx.send(()).unwrap();
1792 let result = join_supervisor(handle).await;
1793 assert!(result.is_ok());
1794 }
1795
1796 #[tokio::test]
1799 async fn one_for_one_restarts_only_failed_child() {
1800 let failing = MockWorker::failing("failing-worker", Duration::from_millis(50));
1801 let failing_count = failing.start_count();
1802
1803 let stable = MockWorker::long_running("stable-worker");
1804 let stable_count = stable.start_count();
1805
1806 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
1807 RestartStrategy::one_to_one().with_intensity_and_period(20, Duration::from_secs(10)),
1808 );
1809 sup.add_worker(stable);
1810 sup.add_worker(failing);
1811
1812 let (tx, handle) = run_supervisor_with_trigger(sup).await;
1813
1814 wait_until("the failing worker has been restarted", || {
1816 failing_count.load(Ordering::SeqCst) >= 2
1817 })
1818 .await;
1819 let _ = tx.send(());
1820
1821 let result = join_supervisor(handle).await;
1822 assert!(result.is_ok());
1823
1824 assert!(
1826 failing_count.load(Ordering::SeqCst) >= 2,
1827 "failing worker should have been restarted"
1828 );
1829 assert_eq!(
1831 stable_count.load(Ordering::SeqCst),
1832 1,
1833 "stable worker should not have been restarted"
1834 );
1835 }
1836
1837 #[tokio::test]
1838 async fn one_for_all_restarts_all_children() {
1839 let failing = MockWorker::failing("failing-worker", Duration::from_millis(50));
1840 let failing_count = failing.start_count();
1841
1842 let stable = MockWorker::long_running("stable-worker");
1843 let stable_count = stable.start_count();
1844
1845 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
1846 RestartStrategy::one_for_all().with_intensity_and_period(20, Duration::from_secs(10)),
1847 );
1848 sup.add_worker(stable);
1849 sup.add_worker(failing);
1850
1851 let (tx, handle) = run_supervisor_with_trigger(sup).await;
1852
1853 wait_until("both workers have been restarted", || {
1855 failing_count.load(Ordering::SeqCst) >= 2 && stable_count.load(Ordering::SeqCst) >= 2
1856 })
1857 .await;
1858 let _ = tx.send(());
1859
1860 let result = join_supervisor(handle).await;
1861 assert!(result.is_ok());
1862
1863 assert!(
1865 failing_count.load(Ordering::SeqCst) >= 2,
1866 "failing worker should have been restarted"
1867 );
1868 assert!(
1869 stable_count.load(Ordering::SeqCst) >= 2,
1870 "stable worker should also have been restarted"
1871 );
1872 }
1873
1874 #[tokio::test]
1875 async fn one_for_all_does_not_restart_temporary_children() {
1876 let failing = MockWorker::failing("failing-worker", Duration::from_millis(50));
1879 let failing_count = failing.start_count();
1880
1881 let temp = MockWorker::long_running("temp-worker");
1882 let temp_count = temp.start_count();
1883
1884 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
1885 RestartStrategy::one_for_all().with_intensity_and_period(20, Duration::from_secs(10)),
1886 );
1887 sup.add_worker(ChildSpecification::worker(temp).with_restart_type(RestartType::Temporary));
1888 sup.add_worker(failing);
1889
1890 let (tx, handle) = run_supervisor_with_trigger(sup).await;
1891
1892 wait_until("the permanent worker has been restarted", || {
1894 failing_count.load(Ordering::SeqCst) >= 2
1895 })
1896 .await;
1897 let _ = tx.send(());
1898
1899 let result = join_supervisor(handle).await;
1900 assert!(result.is_ok());
1901 assert!(
1902 failing_count.load(Ordering::SeqCst) >= 2,
1903 "permanent worker should have been restarted by one-for-all"
1904 );
1905 assert_eq!(
1906 temp_count.load(Ordering::SeqCst),
1907 1,
1908 "temporary child must not be restarted by a one-for-all group restart"
1909 );
1910 }
1911
1912 #[tokio::test]
1913 async fn one_for_all_restarts_transient_children() {
1914 let transient = MockWorker::completing("transient-worker", Duration::from_millis(30));
1917 let transient_count = transient.start_count();
1918
1919 let failing = MockWorker::failing("failing-worker", Duration::from_millis(80));
1921
1922 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
1923 RestartStrategy::one_for_all().with_intensity_and_period(20, Duration::from_secs(10)),
1924 );
1925 sup.add_worker(ChildSpecification::worker(transient).with_restart_type(RestartType::Transient));
1926 sup.add_worker(failing);
1927
1928 let (tx, handle) = run_supervisor_with_trigger(sup).await;
1929
1930 wait_until("the transient worker has been restarted by the group", || {
1931 transient_count.load(Ordering::SeqCst) >= 2
1932 })
1933 .await;
1934 let _ = tx.send(());
1935
1936 let result = join_supervisor(handle).await;
1937 assert!(result.is_ok());
1938 assert!(
1939 transient_count.load(Ordering::SeqCst) >= 2,
1940 "transient child must be restarted by a one-for-all group restart, even after a clean exit"
1941 );
1942 }
1943
1944 #[tokio::test]
1945 async fn transient_abnormal_exit_triggers_one_for_all() {
1946 let transient = MockWorker::failing("transient-worker", Duration::from_millis(50));
1949 let transient_count = transient.start_count();
1950
1951 let stable = MockWorker::long_running("stable-worker");
1952 let stable_count = stable.start_count();
1953
1954 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
1955 RestartStrategy::one_for_all().with_intensity_and_period(20, Duration::from_secs(10)),
1956 );
1957 sup.add_worker(ChildSpecification::worker(transient).with_restart_type(RestartType::Transient));
1958 sup.add_worker(stable);
1959
1960 let (tx, handle) = run_supervisor_with_trigger(sup).await;
1961
1962 wait_until("the abnormal exit has restarted both workers", || {
1963 transient_count.load(Ordering::SeqCst) >= 2 && stable_count.load(Ordering::SeqCst) >= 2
1964 })
1965 .await;
1966 let _ = tx.send(());
1967
1968 let result = join_supervisor(handle).await;
1969 assert!(result.is_ok());
1970 assert!(
1971 transient_count.load(Ordering::SeqCst) >= 2,
1972 "transient worker must be restarted after its own abnormal exit"
1973 );
1974 assert!(
1975 stable_count.load(Ordering::SeqCst) >= 2,
1976 "the transient's abnormal exit must trigger a one-for-all that also restarts the sibling"
1977 );
1978 }
1979
1980 #[tokio::test]
1981 async fn restart_limit_exceeded_shuts_down_supervisor() {
1982 let mut sup = Supervisor::new("test-sup")
1983 .unwrap()
1984 .with_restart_strategy(RestartStrategy::one_to_one().with_intensity_and_period(1, Duration::from_secs(10)));
1985 sup.add_worker(MockWorker::failing("fast-fail", Duration::ZERO));
1987
1988 let (tx, rx) = oneshot::channel::<()>();
1989 let handle = tokio::spawn(async move { sup.run_with_shutdown(rx).await });
1990
1991 let result = join_supervisor(handle).await;
1992 drop(tx);
1993
1994 assert!(matches!(result, Err(SupervisorError::Shutdown)));
1995 }
1996
1997 #[tokio::test]
2000 async fn temporary_child_is_not_restarted() {
2001 let temp = MockWorker::failing("temp-worker", Duration::from_millis(50));
2003 let temp_started = temp.start_count();
2004 let temp_failed = temp.finish_count();
2005
2006 let stable = MockWorker::long_running("stable-worker");
2007
2008 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
2009 RestartStrategy::one_to_one().with_intensity_and_period(20, Duration::from_secs(10)),
2010 );
2011 sup.add_worker(stable);
2012 sup.add_worker(ChildSpecification::worker(temp).with_restart_type(RestartType::Temporary));
2013
2014 let (tx, handle) = run_supervisor_with_trigger(sup).await;
2015
2016 wait_until("the temporary worker has failed once", || {
2022 temp_failed.load(Ordering::SeqCst) == 1
2023 })
2024 .await;
2025 let _ = tx.send(());
2026
2027 let result = join_supervisor(handle).await;
2028 assert!(result.is_ok());
2029 assert_eq!(
2030 temp_started.load(Ordering::SeqCst),
2031 1,
2032 "temporary worker must not be restarted after it fails"
2033 );
2034 }
2035
2036 #[tokio::test]
2037 async fn transient_child_is_not_restarted_on_clean_exit() {
2038 let transient = MockWorker::completing("transient-worker", Duration::from_millis(50));
2039 let transient_started = transient.start_count();
2040 let transient_finished = transient.finish_count();
2041
2042 let stable = MockWorker::long_running("stable-worker");
2043
2044 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
2045 RestartStrategy::one_to_one().with_intensity_and_period(20, Duration::from_secs(10)),
2046 );
2047 sup.add_worker(stable);
2048 sup.add_worker(ChildSpecification::worker(transient).with_restart_type(RestartType::Transient));
2049
2050 let (tx, handle) = run_supervisor_with_trigger(sup).await;
2051
2052 wait_until("the transient worker has completed once", || {
2057 transient_finished.load(Ordering::SeqCst) == 1
2058 })
2059 .await;
2060 let _ = tx.send(());
2061
2062 let result = join_supervisor(handle).await;
2063 assert!(result.is_ok());
2064 assert_eq!(
2065 transient_started.load(Ordering::SeqCst),
2066 1,
2067 "transient worker must not be restarted after a clean exit"
2068 );
2069 }
2070
2071 #[tokio::test]
2072 async fn transient_child_is_restarted_on_failure() {
2073 let transient = MockWorker::failing("transient-worker", Duration::from_millis(50));
2074 let transient_count = transient.start_count();
2075
2076 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
2077 RestartStrategy::one_to_one().with_intensity_and_period(20, Duration::from_secs(10)),
2078 );
2079 sup.add_worker(ChildSpecification::worker(transient).with_restart_type(RestartType::Transient));
2080
2081 let (tx, handle) = run_supervisor_with_trigger(sup).await;
2082
2083 wait_until("the transient worker has been restarted", || {
2084 transient_count.load(Ordering::SeqCst) >= 2
2085 })
2086 .await;
2087 let _ = tx.send(());
2088
2089 let result = join_supervisor(handle).await;
2090 assert!(result.is_ok());
2091 assert!(
2092 transient_count.load(Ordering::SeqCst) >= 2,
2093 "transient worker must be restarted after an abnormal exit"
2094 );
2095 }
2096
2097 #[tokio::test]
2098 async fn permanent_child_is_restarted_on_clean_exit() {
2099 let permanent = MockWorker::completing("permanent-worker", Duration::from_millis(50));
2102 let permanent_count = permanent.start_count();
2103
2104 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
2105 RestartStrategy::one_to_one().with_intensity_and_period(20, Duration::from_secs(10)),
2106 );
2107 sup.add_worker(permanent);
2109
2110 let (tx, handle) = run_supervisor_with_trigger(sup).await;
2111
2112 wait_until("the permanent worker has been restarted", || {
2113 permanent_count.load(Ordering::SeqCst) >= 2
2114 })
2115 .await;
2116 let _ = tx.send(());
2117
2118 let result = join_supervisor(handle).await;
2119 assert!(result.is_ok());
2120 assert!(
2121 permanent_count.load(Ordering::SeqCst) >= 2,
2122 "permanent worker must be restarted even after a clean exit"
2123 );
2124 }
2125
2126 #[tokio::test]
2127 async fn temporary_failures_do_not_consume_restart_intensity() {
2128 let mut sup = Supervisor::new("test-sup")
2132 .unwrap()
2133 .with_restart_strategy(RestartStrategy::one_to_one().with_intensity_and_period(1, Duration::from_secs(10)));
2134
2135 let workers = [
2136 MockWorker::failing("temp-0", Duration::from_millis(20)),
2137 MockWorker::failing("temp-1", Duration::from_millis(20)),
2138 MockWorker::failing("temp-2", Duration::from_millis(20)),
2139 MockWorker::failing("temp-3", Duration::from_millis(20)),
2140 MockWorker::failing("temp-4", Duration::from_millis(20)),
2141 ];
2142 let started: Vec<_> = workers.iter().map(|w| w.start_count()).collect();
2143 let failed: Vec<_> = workers.iter().map(|w| w.finish_count()).collect();
2144 for worker in workers {
2145 sup.add_worker(ChildSpecification::worker(worker).with_restart_type(RestartType::Temporary));
2146 }
2147 sup.add_worker(MockWorker::long_running("stable-worker"));
2149
2150 let (tx, handle) = run_supervisor_with_trigger(sup).await;
2151 wait_until("every temporary worker has failed once", || {
2155 failed.iter().all(|c| c.load(Ordering::SeqCst) == 1)
2156 })
2157 .await;
2158 let _ = tx.send(());
2159
2160 let result = join_supervisor(handle).await;
2161 assert!(
2162 result.is_ok(),
2163 "supervisor must not trip its restart limit on temporary exits"
2164 );
2165 for count in started {
2166 assert_eq!(
2167 count.load(Ordering::SeqCst),
2168 1,
2169 "each temporary worker runs exactly once"
2170 );
2171 }
2172 }
2173
2174 #[tokio::test]
2175 async fn transient_clean_exits_do_not_consume_restart_intensity() {
2176 let mut sup = Supervisor::new("test-sup")
2180 .unwrap()
2181 .with_restart_strategy(RestartStrategy::one_to_one().with_intensity_and_period(1, Duration::from_secs(10)));
2182
2183 let workers = [
2184 MockWorker::completing("transient-0", Duration::from_millis(20)),
2185 MockWorker::completing("transient-1", Duration::from_millis(20)),
2186 MockWorker::completing("transient-2", Duration::from_millis(20)),
2187 MockWorker::completing("transient-3", Duration::from_millis(20)),
2188 MockWorker::completing("transient-4", Duration::from_millis(20)),
2189 ];
2190 let started: Vec<_> = workers.iter().map(|w| w.start_count()).collect();
2191 let finished: Vec<_> = workers.iter().map(|w| w.finish_count()).collect();
2192 for worker in workers {
2193 sup.add_worker(ChildSpecification::worker(worker).with_restart_type(RestartType::Transient));
2194 }
2195 sup.add_worker(MockWorker::long_running("stable-worker"));
2197
2198 let (tx, handle) = run_supervisor_with_trigger(sup).await;
2199 wait_until("every transient worker has completed once", || {
2203 finished.iter().all(|c| c.load(Ordering::SeqCst) == 1)
2204 })
2205 .await;
2206 let _ = tx.send(());
2207
2208 let result = join_supervisor(handle).await;
2209 assert!(
2210 result.is_ok(),
2211 "supervisor must not trip its restart limit on clean transient exits"
2212 );
2213 for count in started {
2214 assert_eq!(
2215 count.load(Ordering::SeqCst),
2216 1,
2217 "each transient worker runs exactly once"
2218 );
2219 }
2220 }
2221
2222 #[tokio::test]
2223 async fn supervisor_idles_when_all_temporary_children_exit() {
2224 let temp_a = MockWorker::completing("temp-a", Duration::from_millis(10));
2228 let a_finished = temp_a.finish_count();
2229 let temp_b = MockWorker::completing("temp-b", Duration::from_millis(10));
2230 let b_finished = temp_b.finish_count();
2231
2232 let mut sup = Supervisor::new("test-sup").unwrap();
2233 let handle = sup.handle();
2234 sup.add_worker(ChildSpecification::worker(temp_a).with_restart_type(RestartType::Temporary));
2235 sup.add_worker(ChildSpecification::worker(temp_b).with_restart_type(RestartType::Temporary));
2236
2237 let (tx, run) = run_supervisor_with_trigger(sup).await;
2238
2239 wait_until("both temporary children have completed", || {
2243 a_finished.load(Ordering::SeqCst) == 1 && b_finished.load(Ordering::SeqCst) == 1
2244 })
2245 .await;
2246
2247 let dynamic = MockWorker::long_running("late-comer");
2250 let dynamic_count = dynamic.start_count();
2251 handle.spawn(dynamic);
2252 wait_until("the late dynamic child has started", || {
2253 dynamic_count.load(Ordering::SeqCst) == 1
2254 })
2255 .await;
2256 assert!(
2257 handle.is_running(),
2258 "supervisor must keep running after all temporary children exit"
2259 );
2260
2261 tx.send(()).unwrap();
2262 let result = join_supervisor(run).await;
2263 assert!(result.is_ok());
2264 }
2265
2266 #[tokio::test]
2269 async fn significant_child_drives_auto_shutdown() {
2270 let mut sup = Supervisor::new("test-sup")
2273 .unwrap()
2274 .with_auto_shutdown(AutoShutdown::AnySignificant);
2275 sup.add_worker(MockWorker::long_running("stable"));
2276 sup.add_worker(
2277 ChildSpecification::worker(MockWorker::completing("significant", Duration::from_millis(50)))
2278 .with_restart_type(RestartType::Temporary)
2279 .with_significant(true),
2280 );
2281
2282 let (_tx, rx) = oneshot::channel::<()>();
2284 let result = timeout(Duration::from_secs(2), sup.run_with_shutdown(rx))
2285 .await
2286 .unwrap();
2287 assert!(matches!(result, Err(SupervisorError::SignificantChildExited)));
2288 }
2289
2290 #[tokio::test]
2291 async fn significant_child_added_before_the_auto_shutdown_policy_still_drives_it() {
2292 let mut sup = Supervisor::new("test-sup").unwrap();
2297 sup.add_worker(MockWorker::long_running("stable"));
2298 sup.add_worker(
2299 runtime::supervisable(MockWorker::completing("significant", Duration::from_millis(50)))
2300 .temporary()
2301 .with_significant(true)
2302 .build(),
2303 );
2304 let mut sup = sup.with_auto_shutdown(AutoShutdown::AnySignificant);
2305
2306 let (_tx, rx) = oneshot::channel::<()>();
2307 let result = timeout(Duration::from_secs(2), sup.run_with_shutdown(rx))
2308 .await
2309 .unwrap();
2310 assert!(
2311 matches!(result, Err(SupervisorError::SignificantChildExited)),
2312 "the policy set after registration should still have applied, got {result:?}"
2313 );
2314 }
2315
2316 #[tokio::test]
2317 async fn non_significant_exit_does_not_auto_shutdown() {
2318 let plain = MockWorker::completing("plain", Duration::from_millis(10));
2320 let plain_finished = plain.finish_count();
2321
2322 let mut sup = Supervisor::new("test-sup")
2323 .unwrap()
2324 .with_auto_shutdown(AutoShutdown::AnySignificant);
2325 let handle = sup.handle();
2326 sup.add_worker(MockWorker::long_running("stable"));
2327 sup.add_worker(ChildSpecification::worker(plain).with_restart_type(RestartType::Temporary));
2328
2329 let (tx, run) = run_supervisor_with_trigger(sup).await;
2330
2331 wait_until("the non-significant child has completed", || {
2335 plain_finished.load(Ordering::SeqCst) == 1
2336 })
2337 .await;
2338
2339 let dynamic = MockWorker::long_running("late-comer");
2343 let dynamic_count = dynamic.start_count();
2344 handle.spawn(dynamic);
2345 wait_until("the late dynamic child has started", || {
2346 dynamic_count.load(Ordering::SeqCst) == 1
2347 })
2348 .await;
2349 assert!(
2350 handle.is_running(),
2351 "a non-significant child exiting must not trigger auto-shutdown"
2352 );
2353
2354 tx.send(()).unwrap();
2355 let result = join_supervisor(run).await;
2356 assert!(result.is_ok());
2357 }
2358
2359 #[tokio::test]
2360 async fn all_significant_waits_for_last() {
2361 let mut sup = Supervisor::new("test-sup")
2363 .unwrap()
2364 .with_auto_shutdown(AutoShutdown::AllSignificant);
2365 sup.add_worker(
2366 ChildSpecification::worker(MockWorker::completing("sig-a", Duration::from_millis(50)))
2367 .with_restart_type(RestartType::Temporary)
2368 .with_significant(true),
2369 );
2370 sup.add_worker(
2371 ChildSpecification::worker(MockWorker::completing("sig-b", Duration::from_millis(250)))
2372 .with_restart_type(RestartType::Temporary)
2373 .with_significant(true),
2374 );
2375
2376 let (_tx, rx) = oneshot::channel::<()>();
2377 let start = std::time::Instant::now();
2378 let result = timeout(Duration::from_secs(2), sup.run_with_shutdown(rx))
2379 .await
2380 .unwrap();
2381 let elapsed = start.elapsed();
2382
2383 assert!(matches!(result, Err(SupervisorError::SignificantChildExited)));
2384 assert!(
2386 elapsed >= Duration::from_millis(200),
2387 "auto-shutdown must wait for all significant children (took {elapsed:?})"
2388 );
2389 }
2390
2391 #[tokio::test]
2394 async fn init_failure_propagates_with_child_name() {
2395 let mut sup = Supervisor::new("test-sup").unwrap();
2396 sup.add_worker(MockWorker::long_running("good-worker"));
2397 sup.add_worker(MockWorker::init_failure("bad-worker"));
2398
2399 let (_tx, rx) = oneshot::channel::<()>();
2400 let result = timeout(Duration::from_secs(2), sup.run_with_shutdown(rx))
2401 .await
2402 .unwrap();
2403
2404 match result {
2405 Err(SupervisorError::FailedToInitialize { child_name, .. }) => {
2406 assert_eq!(child_name, "bad-worker");
2407 }
2408 other => panic!("expected FailedToInitialize, got: {:?}", other),
2409 }
2410 }
2411
2412 #[tokio::test]
2413 async fn init_failure_does_not_trigger_restart() {
2414 let init_fail = MockWorker::init_failure("bad-worker");
2415 let start_count = init_fail.start_count();
2416
2417 let mut sup = Supervisor::new("test-sup").unwrap().with_restart_strategy(
2418 RestartStrategy::one_to_one().with_intensity_and_period(10, Duration::from_secs(10)),
2419 );
2420 sup.add_worker(init_fail);
2421
2422 let (_tx, rx) = oneshot::channel::<()>();
2423 let result = timeout(Duration::from_secs(2), sup.run_with_shutdown(rx))
2424 .await
2425 .unwrap();
2426
2427 assert!(matches!(result, Err(SupervisorError::FailedToInitialize { .. })));
2428 assert_eq!(start_count.load(Ordering::SeqCst), 0);
2430 }
2431
2432 #[tokio::test]
2435 async fn shutdown_completes_promptly_in_steady_state() {
2436 let mut sup = Supervisor::new("test-sup").unwrap();
2437 sup.add_worker(MockWorker::long_running("worker1"));
2438 sup.add_worker(MockWorker::long_running("worker2"));
2439
2440 let (tx, handle) = run_supervisor_with_trigger(sup).await;
2441 tx.send(()).unwrap();
2442
2443 let result = timeout(Duration::from_secs(1), handle).await;
2445 assert!(result.is_ok(), "shutdown should complete promptly");
2446 }
2447
2448 #[tokio::test]
2449 async fn shutdown_during_slow_init_completes_promptly() {
2450 let mut sup = Supervisor::new("test-sup").unwrap();
2451 sup.add_worker(MockWorker::slow_init("slow-worker", Duration::from_secs(30)));
2453
2454 let (tx, rx) = oneshot::channel();
2455 let handle = tokio::spawn(async move { sup.run_with_shutdown(rx).await });
2456
2457 sleep(Duration::from_millis(20)).await;
2459 tx.send(()).unwrap();
2460
2461 let result = timeout(Duration::from_secs(2), handle).await;
2464 assert!(result.is_ok(), "shutdown during slow init should complete promptly");
2465 }
2466
2467 #[tokio::test]
2470 async fn dynamic_children_spawn_after_start() {
2471 let sup = Supervisor::new("dyn-sup").unwrap();
2472 let handle = sup.handle();
2473 let (tx, run) = run_supervisor_with_trigger(sup).await;
2474 wait_until("supervisor is running", || handle.is_running()).await;
2475
2476 let c1 = MockWorker::long_running("c1");
2477 let c2 = MockWorker::long_running("c2");
2478 let c1_count = c1.start_count();
2479 let c2_count = c2.start_count();
2480 handle.spawn(c1);
2481 handle.spawn(c2);
2482
2483 wait_until("both dynamic children have started", || {
2484 c1_count.load(Ordering::SeqCst) == 1 && c2_count.load(Ordering::SeqCst) == 1
2485 })
2486 .await;
2487 assert_eq!(handle.active_children(), 2);
2488
2489 tx.send(()).unwrap();
2490 let result = join_supervisor(run).await;
2491 assert!(result.is_ok());
2492 assert_eq!(
2493 handle.active_children(),
2494 0,
2495 "all dynamic children must be drained on shutdown"
2496 );
2497 }
2498
2499 #[tokio::test]
2500 async fn temporary_dynamic_child_failure_is_isolated() {
2501 let sup = Supervisor::new("dyn-sup").unwrap();
2504 let handle = sup.handle();
2505 let (tx, run) = run_supervisor_with_trigger(sup).await;
2506 wait_until("supervisor is running", || handle.is_running()).await;
2507
2508 let failing = MockWorker::failing("boom", Duration::from_millis(20));
2509 let failing_count = failing.start_count();
2510 handle.spawn(failing);
2511 wait_until("the failing dynamic child has run once", || {
2512 failing_count.load(Ordering::SeqCst) == 1
2513 })
2514 .await;
2515 wait_until("all dynamic children have drained", || handle.active_children() == 0).await;
2516
2517 sleep(Duration::from_millis(50)).await;
2518 assert!(
2519 handle.is_running(),
2520 "supervisor stays up after an isolated child failure"
2521 );
2522 assert_eq!(
2523 failing_count.load(Ordering::SeqCst),
2524 1,
2525 "a temporary child is never restarted"
2526 );
2527
2528 handle.spawn(MockWorker::long_running("c2"));
2530 wait_until("one dynamic child is running", || handle.active_children() == 1).await;
2531
2532 tx.send(()).unwrap();
2533 let result = join_supervisor(run).await;
2534 assert!(result.is_ok());
2535 }
2536
2537 #[tokio::test]
2538 async fn temporary_dynamic_child_panic_is_isolated() {
2539 let sup = Supervisor::new("dyn-sup").unwrap();
2541 let handle = sup.handle();
2542 let (tx, run) = run_supervisor_with_trigger(sup).await;
2543 wait_until("supervisor is running", || handle.is_running()).await;
2544
2545 handle.spawn(MockWorker::panicking("boom", Duration::from_millis(20)));
2546 wait_until("all dynamic children have drained", || handle.active_children() == 0).await;
2547
2548 sleep(Duration::from_millis(50)).await;
2549 assert!(handle.is_running(), "supervisor stays up after an isolated child panic");
2550
2551 tx.send(()).unwrap();
2552 let result = join_supervisor(run).await;
2553 assert!(result.is_ok());
2554 }
2555
2556 #[tokio::test]
2557 async fn significant_dynamic_child_failure_shuts_down_supervisor() {
2558 let sup = Supervisor::new("dyn-sup")
2561 .unwrap()
2562 .with_auto_shutdown(AutoShutdown::AnySignificant);
2563 let handle = sup.handle();
2564 let (_tx, run) = run_supervisor_with_trigger(sup).await;
2565 wait_until("supervisor is running", || handle.is_running()).await;
2566
2567 handle.spawn(
2568 ChildSpecification::worker(MockWorker::failing("boom", Duration::from_millis(20))).with_significant(true),
2569 );
2570
2571 let result = join_supervisor(run).await;
2572 assert!(matches!(result, Err(SupervisorError::SignificantChildExited)));
2573 }
2574
2575 #[tokio::test]
2576 async fn dynamic_spawn_outside_a_run_is_accepted_and_dropped() {
2577 let sup = Supervisor::new("dyn-sup").unwrap();
2582 let handle = sup.handle();
2583
2584 assert!(!handle.is_running());
2585 let before = MockWorker::long_running("before-start");
2586 let before_count = before.start_count();
2587 handle.spawn(before);
2588
2589 let (tx, run) = run_supervisor_with_trigger(sup).await;
2591 wait_until("supervisor is running", || handle.is_running()).await;
2592 let worker = MockWorker::long_running("after-start");
2593 let started = worker.start_count();
2594 handle.spawn(worker);
2595 wait_until("the dynamic child has started", || started.load(Ordering::SeqCst) == 1).await;
2596 assert_eq!(
2597 before_count.load(Ordering::SeqCst),
2598 0,
2599 "a child spawned before the run must not be started by it"
2600 );
2601
2602 tx.send(()).unwrap();
2603 let result = join_supervisor(run).await;
2604 assert!(result.is_ok());
2605
2606 wait_until("the supervisor has stopped", || !handle.is_running()).await;
2608 let after = MockWorker::long_running("after-shutdown");
2609 let after_count = after.start_count();
2610 handle.spawn(after);
2611
2612 sleep(Duration::from_millis(50)).await;
2613 assert_eq!(
2614 after_count.load(Ordering::SeqCst),
2615 0,
2616 "a child spawned after shutdown must never start"
2617 );
2618 }
2619
2620 #[tokio::test]
2621 async fn dynamic_spawn_allocates_an_id_eagerly() {
2622 let sup = Supervisor::new("dyn-sup").unwrap();
2626 let handle = sup.handle();
2627 let (tx, run) = run_supervisor_with_trigger(sup).await;
2628 wait_until("supervisor is running", || handle.is_running()).await;
2629
2630 let worker = MockWorker::long_running("c");
2631 let started = worker.start_count();
2632 let id = handle.spawn(worker);
2633 assert_eq!(id.as_u64(), 0);
2634 wait_until("the dynamic child has started", || started.load(Ordering::SeqCst) == 1).await;
2635
2636 tx.send(()).unwrap();
2637 let result = join_supervisor(run).await;
2638 assert!(result.is_ok());
2639 }
2640
2641 #[tokio::test]
2642 async fn dynamic_child_with_an_unusable_name_runs_under_a_placeholder() {
2643 let recorder = TestRecorder::default();
2648 let _guard = metrics::set_default_local_recorder(&recorder);
2649
2650 let sup = Supervisor::new("dyn-sup").unwrap();
2651 let handle = sup.handle();
2652 let (tx, run) = run_supervisor_with_trigger(sup).await;
2653 wait_until("supervisor is running", || handle.is_running()).await;
2654
2655 let worker = MockWorker::long_running("");
2656 let started = worker.start_count();
2657 handle.spawn(worker);
2658 wait_until("the unnamed dynamic child has started", || {
2659 started.load(Ordering::SeqCst) == 1
2660 })
2661 .await;
2662
2663 assert!(handle.is_running());
2665 handle.spawn(MockWorker::long_running("ok"));
2666 wait_until("both dynamic children are running", || handle.active_children() == 2).await;
2667
2668 tx.send(()).unwrap();
2669 let result = join_supervisor(run).await;
2670 assert!(result.is_ok());
2671
2672 let polls = recorder.counter(("runtime_task_poll_count", &[("task_name", "dyn_sup.unnamed")]));
2673 assert!(
2674 polls.is_some_and(|polls| polls > 0),
2675 "the child should have run under the placeholder name, got {polls:?}"
2676 );
2677 }
2678
2679 #[tokio::test]
2680 async fn dynamic_spawns_are_not_capped() {
2681 const CHILDREN: usize = 2048;
2685
2686 let sup = Supervisor::new("dyn-sup").unwrap();
2687 let handle = sup.handle();
2688 let (tx, run) = run_supervisor_with_trigger(sup).await;
2689 wait_until("supervisor is running", || handle.is_running()).await;
2690
2691 for _ in 0..CHILDREN {
2692 handle.spawn(MockWorker::long_running("burst").with_graceful_timeout(Duration::from_secs(30)));
2695 }
2696
2697 wait_until("every child in the burst has started", || {
2698 handle.active_children() == CHILDREN
2699 })
2700 .await;
2701
2702 tx.send(()).unwrap();
2703 let result = timeout(Duration::from_secs(30), run)
2704 .await
2705 .expect("supervisor should stop")
2706 .expect("supervisor task should not panic");
2707 assert!(result.is_ok(), "the burst should have drained cleanly: {result:?}");
2708 }
2709
2710 #[tokio::test]
2711 async fn dynamically_spawned_supervisor_runs_and_drains() {
2712 let child_worker = MockWorker::long_running("nested-child");
2715 let child_started = child_worker.start_count();
2716 let mut nested = Supervisor::new("nested-sup").unwrap();
2717 nested.add_worker(child_worker);
2718
2719 let sup = Supervisor::new("dyn-sup").unwrap();
2720 let handle = sup.handle();
2721 let (tx, run) = run_supervisor_with_trigger(sup).await;
2722 wait_until("supervisor is running", || handle.is_running()).await;
2723
2724 handle.spawn(nested);
2725 wait_until("the nested supervisor's own child has started", || {
2726 child_started.load(Ordering::SeqCst) == 1
2727 })
2728 .await;
2729
2730 tx.send(()).unwrap();
2731 let result = join_supervisor(run).await;
2732 assert!(
2733 result.is_ok(),
2734 "the nested subtree should have drained cleanly: {result:?}"
2735 );
2736 }
2737
2738 fn failing_subtree(name: &'static str) -> (Supervisor, Arc<AtomicUsize>) {
2745 let worker = MockWorker::failing("nested-child", Duration::from_millis(5));
2746 let started = worker.start_count();
2747 let mut nested = Supervisor::new(name)
2748 .unwrap()
2749 .with_restart_strategy(RestartStrategy::new(RestartMode::OneForOne, 0, Duration::from_secs(30)));
2750 nested.add_worker(worker);
2751
2752 (nested, started)
2753 }
2754
2755 #[tokio::test]
2756 async fn dynamic_nested_supervisor_defaults_to_temporary() {
2757 let (nested, started) = failing_subtree("nested-temp");
2761
2762 let sup = Supervisor::new("dyn-temp-sup").unwrap();
2763 let handle = sup.handle();
2764 let (tx, run) = run_supervisor_with_trigger(sup).await;
2765
2766 handle.spawn(nested);
2767 wait_until("the subtree has started once", || started.load(Ordering::SeqCst) == 1).await;
2768
2769 sleep(Duration::from_millis(200)).await;
2771 assert_eq!(
2772 started.load(Ordering::SeqCst),
2773 1,
2774 "a temporary subtree must not be restarted"
2775 );
2776
2777 tx.send(()).unwrap();
2778 assert!(join_supervisor(run).await.is_ok());
2779 }
2780
2781 #[tokio::test]
2782 async fn dynamic_nested_supervisor_can_be_made_permanent() {
2783 let (nested, started) = failing_subtree("nested-perm");
2785
2786 let sup = Supervisor::new("dyn-perm-sup")
2787 .unwrap()
2788 .with_restart_strategy(RestartStrategy::new(
2789 RestartMode::OneForOne,
2790 100,
2791 Duration::from_secs(30),
2792 ));
2793 let handle = sup.handle();
2794 let (tx, run) = run_supervisor_with_trigger(sup).await;
2795
2796 handle.nested_supervisor(nested).spawn();
2797 wait_until("the subtree has been restarted", || started.load(Ordering::SeqCst) >= 2).await;
2798
2799 tx.send(()).unwrap();
2800 assert!(join_supervisor(run).await.is_ok());
2801 }
2802
2803 #[tokio::test]
2804 async fn dynamic_nested_supervisor_can_be_significant() {
2805 let (nested, _started) = failing_subtree("nested-sig");
2808
2809 let sup = Supervisor::new("dyn-sig-sup")
2810 .unwrap()
2811 .with_auto_shutdown(AutoShutdown::AnySignificant);
2812 let handle = sup.handle();
2813 let (tx, run) = run_supervisor_with_trigger(sup).await;
2814
2815 handle
2816 .nested_supervisor(nested)
2817 .temporary()
2818 .with_significant(true)
2819 .spawn();
2820
2821 let result = timeout(Duration::from_secs(5), run)
2822 .await
2823 .expect("supervisor should stop once the significant subtree terminates")
2824 .expect("supervisor task should not panic");
2825 assert!(
2826 matches!(result, Err(SupervisorError::SignificantChildExited)),
2827 "the significant subtree's termination should have stopped the parent, got {result:?}"
2828 );
2829
2830 let _ = tx.send(());
2832 }
2833
2834 #[tokio::test]
2835 async fn budget_of_duration_max_does_not_leave_a_budget_bounded_child_unbounded() {
2836 let mut sup = Supervisor::new("test-sup").unwrap().with_shutdown_budget(Duration::MAX);
2841 sup.add_worker(
2842 ChildSpecification::one_shot_worker(
2843 MockWorker::ignore_shutdown("stuck").with_graceful_timeout(Duration::from_millis(100)),
2844 )
2845 .with_budget_bounded_shutdown(),
2846 );
2847
2848 let (tx, run) = run_supervisor_with_trigger(sup).await;
2849 tx.send(()).unwrap();
2850
2851 let started = tokio::time::Instant::now();
2852 let result = join_supervisor(run).await;
2853 let elapsed = started.elapsed();
2854
2855 assert!(
2856 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
2857 "the worker's own deadline should have aborted it, got {result:?}"
2858 );
2859 assert!(
2860 elapsed < Duration::from_secs(1),
2861 "the child should have been bounded by its own 100ms deadline; took {elapsed:?}"
2862 );
2863 }
2864
2865 #[tokio::test]
2866 async fn budget_bounded_child_falls_back_to_its_own_deadline_without_a_budget() {
2867 let mut sup = Supervisor::new("test-sup").unwrap();
2871 sup.add_worker(
2872 ChildSpecification::one_shot_worker(
2873 MockWorker::ignore_shutdown("stuck").with_graceful_timeout(Duration::from_millis(100)),
2874 )
2875 .with_budget_bounded_shutdown(),
2876 );
2877
2878 let (tx, run) = run_supervisor_with_trigger(sup).await;
2879 tx.send(()).unwrap();
2880
2881 let started = tokio::time::Instant::now();
2882 let result = join_supervisor(run).await;
2883 let elapsed = started.elapsed();
2884
2885 assert!(
2886 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
2887 "the worker's own deadline should have aborted it, got {result:?}"
2888 );
2889 assert!(
2890 elapsed < Duration::from_secs(1),
2891 "the child should have been bounded by its own 100ms deadline; took {elapsed:?}"
2892 );
2893 }
2894
2895 #[tokio::test]
2896 async fn concurrent_shutdown_drains_many_children_quickly() {
2897 const CHILDREN: usize = 500;
2898 const SHUTDOWN_DELAY: Duration = Duration::from_millis(50);
2899
2900 let sup = Supervisor::new("dyn-sup").unwrap();
2901 let handle = sup.handle();
2902 let (tx, run) = run_supervisor_with_trigger(sup).await;
2903 wait_until("supervisor is running", || handle.is_running()).await;
2904
2905 for _ in 0..CHILDREN {
2906 handle.spawn(MockWorker::slow_shutdown("conn", SHUTDOWN_DELAY));
2907 }
2908 wait_until("all dynamic children are running", || {
2909 handle.active_children() == CHILDREN
2910 })
2911 .await;
2912
2913 let start = std::time::Instant::now();
2916 tx.send(()).unwrap();
2917 let result = timeout(Duration::from_secs(5), run).await.unwrap().unwrap();
2918 let elapsed = start.elapsed();
2919
2920 assert!(result.is_ok());
2921 assert_eq!(handle.active_children(), 0, "active count must return to zero");
2922 assert!(
2923 elapsed < Duration::from_secs(2),
2924 "shutdown must be concurrent (took {elapsed:?})"
2925 );
2926 }
2927
2928 #[tokio::test]
2929 async fn concurrent_shutdown_aborts_unresponsive_children() {
2930 let sup = Supervisor::new("dyn-sup").unwrap();
2931 let handle = sup.handle();
2932 let (tx, run) = run_supervisor_with_trigger(sup).await;
2933 wait_until("supervisor is running", || handle.is_running()).await;
2934
2935 handle.spawn(MockWorker::ignore_shutdown("stuck"));
2936 wait_until("one dynamic child is running", || handle.active_children() == 1).await;
2937
2938 let start = std::time::Instant::now();
2941 tx.send(()).unwrap();
2942 let result = join_supervisor(run).await;
2943 let elapsed = start.elapsed();
2944
2945 assert!(
2947 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
2948 "aborting a stuck child must surface as an unclean shutdown, got {result:?}"
2949 );
2950 assert_eq!(handle.active_children(), 0);
2951 assert!(
2952 elapsed < Duration::from_secs(1),
2953 "stuck child must be aborted at the deadline (took {elapsed:?})"
2954 );
2955 }
2956
2957 #[tokio::test]
2958 async fn concurrent_shutdown_honors_per_child_deadline() {
2959 let sup = Supervisor::new("dyn-sup").unwrap();
2964 let handle = sup.handle();
2965 let (tx, run) = run_supervisor_with_trigger(sup).await;
2966 wait_until("supervisor is running", || handle.is_running()).await;
2967
2968 handle.spawn(MockWorker::long_running("responsive").with_graceful_timeout(Duration::MAX));
2970 handle.spawn(MockWorker::ignore_shutdown("stuck").with_graceful_timeout(Duration::from_millis(200)));
2972 wait_until("both dynamic children are running", || handle.active_children() == 2).await;
2973
2974 let start = std::time::Instant::now();
2975 tx.send(()).unwrap();
2976 let result = join_supervisor(run).await;
2977 let elapsed = start.elapsed();
2978
2979 assert!(
2981 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
2982 "aborting the stuck child must surface as an unclean shutdown with a count of 1, got {result:?}"
2983 );
2984 assert_eq!(handle.active_children(), 0);
2985 assert!(
2986 elapsed < Duration::from_secs(1),
2987 "stuck child must be aborted at its own deadline despite an infinite-timeout sibling (took {elapsed:?})"
2988 );
2989 }
2990
2991 #[tokio::test]
2992 async fn unresponsive_child_is_aborted_at_its_deadline() {
2993 let mut sup = Supervisor::new("test-sup").unwrap();
2996 sup.add_worker(MockWorker::ignore_shutdown("stuck"));
2997
2998 let (tx, handle) = run_supervisor_with_trigger(sup).await;
2999
3000 let start = std::time::Instant::now();
3001 tx.send(()).unwrap();
3002 let result = join_supervisor(handle).await;
3003 let elapsed = start.elapsed();
3004
3005 assert!(
3006 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
3007 "aborting a stuck child must surface as an unclean shutdown, got {result:?}"
3008 );
3009 assert!(
3010 elapsed < Duration::from_secs(1),
3011 "unresponsive child must be aborted at its deadline (took {elapsed:?})"
3012 );
3013 }
3014
3015 #[tokio::test]
3016 async fn brutal_shutdown_aborts_child_immediately() {
3017 let mut sup = Supervisor::new("test-sup").unwrap();
3020 sup.add_worker(MockWorker::ignore_shutdown("brutal-stuck").with_brutal_shutdown());
3021
3022 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3023
3024 let start = std::time::Instant::now();
3025 tx.send(()).unwrap();
3026 let result = join_supervisor(handle).await;
3027 let elapsed = start.elapsed();
3028
3029 assert!(result.is_ok());
3032 assert!(
3033 elapsed < Duration::from_millis(200),
3034 "brutal-shutdown child must be aborted immediately, not after a graceful wait (took {elapsed:?})"
3035 );
3036 }
3037
3038 #[tokio::test]
3039 async fn shutdown_timeout_aborts_aggregate_to_root() {
3040 let mut child_sup = Supervisor::new("child-sup").unwrap();
3044 child_sup
3045 .add_worker(MockWorker::ignore_shutdown("child-stuck").with_graceful_timeout(Duration::from_millis(200)));
3046
3047 let mut parent_sup = Supervisor::new("parent-sup").unwrap();
3048 parent_sup
3049 .add_worker(MockWorker::ignore_shutdown("parent-stuck").with_graceful_timeout(Duration::from_millis(200)));
3050 parent_sup.add_worker(MockWorker::long_running("parent-clean"));
3051 parent_sup.add_worker(child_sup);
3052
3053 let (tx, handle) = run_supervisor_with_trigger(parent_sup).await;
3054 tx.send(()).unwrap();
3055
3056 let result = join_supervisor(handle).await;
3057 assert!(
3058 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 2 })),
3059 "forced aborts must aggregate across the tree (1 direct + 1 nested), got {result:?}"
3060 );
3061 }
3062
3063 #[tokio::test]
3066 async fn restart_intensity_zero_shuts_down_on_first_failure() {
3067 let worker = MockWorker::failing("boom", Duration::from_millis(20));
3071 let start_count = worker.start_count();
3072
3073 let mut sup = Supervisor::new("test-sup")
3074 .unwrap()
3075 .with_restart_strategy(RestartStrategy::new(RestartMode::OneForOne, 0, Duration::from_secs(5)));
3076 sup.add_worker(worker);
3077
3078 let (_tx, rx) = oneshot::channel::<()>();
3079 let result = timeout(Duration::from_secs(2), sup.run_with_shutdown(rx))
3080 .await
3081 .unwrap();
3082
3083 assert!(matches!(result, Err(SupervisorError::Shutdown)));
3084 assert_eq!(
3085 start_count.load(Ordering::SeqCst),
3086 1,
3087 "with intensity zero the worker must run exactly once and never be restarted"
3088 );
3089 }
3090
3091 #[tokio::test]
3092 async fn one_for_all_restart_loses_dynamic_children() {
3093 let failing = MockWorker::failing("failing-static", Duration::from_millis(50));
3098 let failing_count = failing.start_count();
3099
3100 let sup = Supervisor::new("dyn-sup").unwrap().with_restart_strategy(
3101 RestartStrategy::one_for_all().with_intensity_and_period(20, Duration::from_secs(10)),
3102 );
3103 let handle = sup.handle();
3104 let mut sup = sup;
3105 sup.add_worker(failing);
3106
3107 let (tx, run) = run_supervisor_with_trigger(sup).await;
3108
3109 let dynamic = MockWorker::long_running("dynamic");
3111 let dynamic_count = dynamic.start_count();
3112 handle.spawn(dynamic);
3113 wait_until("the dynamic child is running", || handle.active_children() == 1).await;
3114
3115 wait_until("the static worker has been restarted", || {
3117 failing_count.load(Ordering::SeqCst) >= 2
3118 })
3119 .await;
3120
3121 wait_until("the dynamic child has been discarded", || handle.active_children() == 0).await;
3124 assert_eq!(
3125 dynamic_count.load(Ordering::SeqCst),
3126 1,
3127 "a dynamic child must be lost -- not restored -- across a one-for-all restart"
3128 );
3129
3130 tx.send(()).unwrap();
3131 let result = join_supervisor(run).await;
3132 assert!(result.is_ok());
3133 }
3134
3135 #[tokio::test]
3138 async fn dedicated_single_threaded_runtime_runs_nested_worker_and_shuts_down_cleanly() {
3139 let worker = MockWorker::long_running("dedicated-worker");
3143 let worker_count = worker.start_count();
3144
3145 let mut child_sup = Supervisor::new("child-sup")
3146 .unwrap()
3147 .with_dedicated_runtime(RuntimeConfiguration::single_threaded());
3148 child_sup.add_worker(worker);
3149
3150 let mut parent_sup = Supervisor::new("parent-sup").unwrap();
3151 parent_sup.add_worker(child_sup);
3152
3153 let (tx, handle) = run_supervisor_with_trigger(parent_sup).await;
3154
3155 wait_until("the dedicated worker has started", || {
3157 worker_count.load(Ordering::SeqCst) == 1
3158 })
3159 .await;
3160
3161 tx.send(()).unwrap();
3162 let result = join_supervisor(handle).await;
3163 assert!(
3164 result.is_ok(),
3165 "dedicated-runtime supervisor should shut down cleanly, got {result:?}"
3166 );
3167 }
3168
3169 #[tokio::test]
3170 async fn dedicated_multi_threaded_runtime_runs_nested_worker() {
3171 let worker = MockWorker::long_running("dedicated-worker");
3173 let worker_count = worker.start_count();
3174
3175 let mut child_sup = Supervisor::new("child-sup")
3176 .unwrap()
3177 .with_dedicated_runtime(RuntimeConfiguration::multi_threaded(2));
3178 child_sup.add_worker(worker);
3179
3180 let mut parent_sup = Supervisor::new("parent-sup").unwrap();
3181 parent_sup.add_worker(child_sup);
3182
3183 let (tx, handle) = run_supervisor_with_trigger(parent_sup).await;
3184 wait_until("the dedicated worker has started", || {
3185 worker_count.load(Ordering::SeqCst) == 1
3186 })
3187 .await;
3188
3189 tx.send(()).unwrap();
3190 let result = join_supervisor(handle).await;
3191 assert!(
3192 result.is_ok(),
3193 "multi-threaded dedicated-runtime supervisor should shut down cleanly, got {result:?}"
3194 );
3195 }
3196
3197 #[tokio::test]
3198 async fn dedicated_runtime_forced_abort_aggregates_to_root() {
3199 let stuck = MockWorker::ignore_shutdown("stuck").with_graceful_timeout(Duration::from_millis(200));
3203 let stuck_count = stuck.start_count();
3204
3205 let mut child_sup = Supervisor::new("child-sup")
3206 .unwrap()
3207 .with_dedicated_runtime(RuntimeConfiguration::single_threaded());
3208 child_sup.add_worker(stuck);
3209
3210 let mut parent_sup = Supervisor::new("parent-sup").unwrap();
3211 parent_sup.add_worker(child_sup);
3212
3213 let (tx, handle) = run_supervisor_with_trigger(parent_sup).await;
3214
3215 wait_until("the stuck worker has started", || {
3218 stuck_count.load(Ordering::SeqCst) == 1
3219 })
3220 .await;
3221
3222 tx.send(()).unwrap();
3223 let result = join_supervisor(handle).await;
3224 assert!(
3225 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
3226 "a stuck worker in a dedicated runtime must surface as an unclean shutdown aggregated to the root, got {result:?}"
3227 );
3228 }
3229
3230 #[tokio::test]
3233 async fn child_with_runtime_override_runs_on_that_runtime() {
3234 let child_runtime = tokio::runtime::Builder::new_multi_thread()
3237 .worker_threads(1)
3238 .thread_name("child-rt-test")
3239 .enable_all()
3240 .build()
3241 .expect("should build child runtime");
3242
3243 let (thread_tx, thread_rx) = oneshot::channel();
3244 let worker = FnWorker::new("placed", async move {
3245 let thread_name = std::thread::current().name().unwrap_or_default().to_string();
3246 let _ = thread_tx.send(thread_name);
3247 pending::<()>().await;
3248 });
3249
3250 let mut sup = Supervisor::new("test-sup").unwrap();
3253 sup.add_worker(
3254 ChildSpecification::worker(worker)
3255 .with_restart_type(RestartType::Temporary)
3256 .with_runtime(child_runtime.handle().clone())
3257 .with_shutdown_strategy(ShutdownStrategy::Brutal),
3258 );
3259
3260 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3261
3262 let thread_name = timeout(Duration::from_secs(2), thread_rx)
3263 .await
3264 .expect("child should report its thread promptly")
3265 .expect("child should not be dropped before reporting");
3266 assert!(
3267 thread_name.starts_with("child-rt-test"),
3268 "child must run on the runtime given to `with_runtime`, but ran on thread {thread_name:?}"
3269 );
3270
3271 tx.send(()).unwrap();
3272 assert!(join_supervisor(handle).await.is_ok());
3273
3274 child_runtime.shutdown_background();
3276 }
3277
3278 #[tokio::test]
3279 async fn child_shutdown_strategy_override_takes_precedence_over_worker() {
3280 let worker = MockWorker::ignore_shutdown("stuck").with_graceful_timeout(Duration::from_secs(30));
3285
3286 let mut sup = Supervisor::new("test-sup").unwrap();
3287 sup.add_worker(
3288 ChildSpecification::worker(worker)
3289 .with_restart_type(RestartType::Temporary)
3290 .with_shutdown_strategy(ShutdownStrategy::Graceful(Duration::from_millis(50))),
3291 );
3292
3293 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3294 tx.send(()).unwrap();
3295
3296 let result = join_supervisor(handle).await;
3297 assert!(
3298 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
3299 "the overridden 50ms deadline should have aborted the stuck child, got {result:?}"
3300 );
3301 }
3302
3303 struct DrainWaiter {
3309 coordinator: Mutex<Option<ShutdownCoordinator>>,
3310 finished: Arc<AtomicBool>,
3311 }
3312
3313 #[async_trait]
3314 impl Supervisable for DrainWaiter {
3315 fn name(&self) -> &str {
3316 "waiter"
3317 }
3318
3319 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
3320 let coordinator = self
3321 .coordinator
3322 .lock()
3323 .expect("drain waiter mutex poisoned")
3324 .take()
3325 .expect("drain waiter runs once");
3326 let finished = Arc::clone(&self.finished);
3327
3328 Ok(Box::pin(async move {
3329 process_shutdown.await;
3330 coordinator.shutdown_and_wait().await;
3331 finished.store(true, Ordering::SeqCst);
3332 Ok(())
3333 }))
3334 }
3335 }
3336
3337 fn build_drain_pair(
3343 stuck_strategy: ShutdownStrategy, waiter_strategy: ShutdownStrategy,
3344 ) -> (Supervisor, Arc<AtomicBool>) {
3345 let mut coordinator = ShutdownCoordinator::default();
3346 let held_handle = coordinator.register();
3347
3348 let stuck = FnWorker::new("stuck", async move {
3349 let _held = held_handle;
3351 pending::<()>().await;
3352 });
3353
3354 let waiter_finished = Arc::new(AtomicBool::new(false));
3355 let waiter = DrainWaiter {
3356 coordinator: Mutex::new(Some(coordinator)),
3357 finished: Arc::clone(&waiter_finished),
3358 };
3359
3360 let mut sup = Supervisor::new("test-sup").unwrap();
3361 sup.add_worker(
3362 ChildSpecification::worker(stuck)
3363 .with_restart_type(RestartType::Temporary)
3364 .with_shutdown_strategy(stuck_strategy),
3365 );
3366 sup.add_worker(
3367 ChildSpecification::worker(waiter)
3368 .with_restart_type(RestartType::Temporary)
3369 .with_shutdown_strategy(waiter_strategy),
3370 );
3371
3372 (sup, waiter_finished)
3373 }
3374
3375 #[tokio::test]
3376 async fn shorter_child_deadline_releases_a_waiting_sibling() {
3377 let (sup, waiter_finished) = build_drain_pair(
3381 ShutdownStrategy::Graceful(Duration::from_millis(100)),
3382 ShutdownStrategy::Graceful(Duration::from_secs(1)),
3383 );
3384
3385 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3386 tx.send(()).unwrap();
3387
3388 let result = join_supervisor(handle).await;
3389 assert!(
3390 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
3391 "only the stuck child should have been aborted, got {result:?}"
3392 );
3393 assert!(
3394 waiter_finished.load(Ordering::SeqCst),
3395 "the waiter should have been released by the stuck child's abort and run to completion"
3396 );
3397 }
3398
3399 #[tokio::test]
3400 async fn equal_child_deadlines_abort_the_waiter_too() {
3401 let (sup, waiter_finished) = build_drain_pair(
3406 ShutdownStrategy::Graceful(Duration::from_millis(100)),
3407 ShutdownStrategy::Graceful(Duration::from_millis(100)),
3408 );
3409
3410 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3411 tx.send(()).unwrap();
3412
3413 let result = join_supervisor(handle).await;
3414 assert!(
3415 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 2 })),
3416 "both children should have been aborted together, got {result:?}"
3417 );
3418 assert!(
3419 !waiter_finished.load(Ordering::SeqCst),
3420 "the waiter should have been aborted mid-wait, not completed"
3421 );
3422 }
3423
3424 #[tokio::test]
3427 async fn budget_bounds_children_that_have_no_deadline_of_their_own() {
3428 let mut sup = Supervisor::new("test-sup")
3433 .unwrap()
3434 .with_shutdown_budget(Duration::from_millis(100));
3435
3436 for name in ["stuck_one", "stuck_two"] {
3437 sup.add_worker(
3438 ChildSpecification::worker(FnWorker::new(name, pending::<()>()))
3439 .with_restart_type(RestartType::Temporary)
3440 .with_shutdown_strategy(ShutdownStrategy::Graceful(Duration::MAX)),
3441 );
3442 }
3443
3444 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3445 tx.send(()).unwrap();
3446
3447 let result = join_supervisor(handle).await;
3448 assert!(
3449 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 2 })),
3450 "the budget should have aborted both deadline-less children, got {result:?}"
3451 );
3452 }
3453
3454 #[tokio::test]
3455 async fn budget_does_not_delay_children_that_stop_on_their_own() {
3456 let mut sup = Supervisor::new("test-sup")
3459 .unwrap()
3460 .with_shutdown_budget(Duration::from_secs(30));
3461 sup.add_worker(
3462 ChildSpecification::one_shot_worker(MockWorker::long_running("prompt"))
3463 .with_shutdown_strategy(ShutdownStrategy::Graceful(Duration::MAX)),
3464 );
3465
3466 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3467 let started = tokio::time::Instant::now();
3468 tx.send(()).unwrap();
3469
3470 assert!(join_supervisor(handle).await.is_ok());
3471 let elapsed = started.elapsed();
3472 assert!(
3473 elapsed < Duration::from_millis(500),
3474 "shutdown should finish as soon as the child does, not burn the budget; took {elapsed:?}"
3475 );
3476 }
3477
3478 #[tokio::test]
3479 async fn child_deadline_shorter_than_budget_still_wins() {
3480 let mut sup = Supervisor::new("test-sup")
3483 .unwrap()
3484 .with_shutdown_budget(Duration::from_secs(30));
3485 sup.add_worker(
3486 ChildSpecification::worker(FnWorker::new("stuck", pending::<()>()))
3487 .with_restart_type(RestartType::Temporary)
3488 .with_shutdown_strategy(ShutdownStrategy::Graceful(Duration::from_millis(100))),
3489 );
3490
3491 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3492 tx.send(()).unwrap();
3493
3494 let result = join_supervisor(handle).await;
3496 assert!(
3497 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
3498 "the child's own 100ms deadline should have won over the budget, got {result:?}"
3499 );
3500 }
3501
3502 #[tokio::test]
3503 async fn every_worker_records_poll_metrics() {
3504 let recorder = TestRecorder::default();
3507 let _guard = metrics::set_default_local_recorder(&recorder);
3508
3509 let mut sup = Supervisor::new("metrics_sup").unwrap();
3511 sup.add_worker(ChildSpecification::one_shot_worker(MockWorker::long_running("timed")));
3512
3513 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3514 tx.send(()).unwrap();
3515 assert!(join_supervisor(handle).await.is_ok());
3516
3517 let polls = recorder.counter(("runtime_task_poll_count", &[("task_name", "metrics_sup.timed")]));
3518 assert!(
3519 polls.is_some_and(|polls| polls > 0),
3520 "a supervised worker should have recorded poll metrics, got {polls:?}"
3521 );
3522 }
3523
3524 #[tokio::test]
3525 async fn budget_bounds_the_whole_drain_rather_than_each_child() {
3526 let mut sup = Supervisor::new("test-sup")
3530 .unwrap()
3531 .with_shutdown_budget(Duration::from_millis(150));
3532
3533 for name in ["stuck_one", "stuck_two", "stuck_three"] {
3534 sup.add_worker(
3535 ChildSpecification::one_shot_worker(FnWorker::new(name, pending::<()>()))
3536 .with_shutdown_strategy(ShutdownStrategy::Graceful(Duration::from_secs(10))),
3537 );
3538 }
3539
3540 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3541 tx.send(()).unwrap();
3542
3543 let result = join_supervisor(handle).await;
3544 assert!(
3545 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 3 })),
3546 "the budget should have bounded the whole drain, got {result:?}"
3547 );
3548 }
3549
3550 #[tokio::test]
3551 async fn budget_of_duration_max_is_treated_as_no_budget() {
3552 let mut sup = Supervisor::new("test-sup").unwrap().with_shutdown_budget(Duration::MAX);
3555 sup.add_worker(ChildSpecification::one_shot_worker(MockWorker::long_running("prompt")));
3556
3557 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3558 tx.send(()).unwrap();
3559 assert!(join_supervisor(handle).await.is_ok());
3560 }
3561
3562 #[tokio::test]
3563 async fn near_max_child_timeout_does_not_panic() {
3564 let mut sup = Supervisor::new("test-sup").unwrap();
3567 sup.add_worker(
3568 ChildSpecification::one_shot_worker(MockWorker::long_running("prompt"))
3569 .with_shutdown_strategy(ShutdownStrategy::Graceful(Duration::MAX - Duration::from_nanos(1))),
3570 );
3571
3572 let (tx, handle) = run_supervisor_with_trigger(sup).await;
3573 tx.send(()).unwrap();
3574 assert!(join_supervisor(handle).await.is_ok());
3575 }
3576
3577 #[tokio::test]
3578 async fn budget_does_not_cut_off_a_nested_supervisor_mid_drain() {
3579 let slow = MockWorker::slow_shutdown("slow", Duration::from_millis(300));
3583 let drained = slow.finish_count();
3584
3585 let mut nested = Supervisor::new("nested").unwrap();
3586 nested.add_worker(
3587 ChildSpecification::one_shot_worker(slow)
3588 .with_shutdown_strategy(ShutdownStrategy::Graceful(Duration::from_secs(10))),
3589 );
3590
3591 let mut parent = Supervisor::new("parent")
3592 .unwrap()
3593 .with_shutdown_budget(Duration::from_millis(50));
3594 parent.add_worker(nested);
3595
3596 let (tx, handle) = run_supervisor_with_trigger(parent).await;
3597 tx.send(()).unwrap();
3598
3599 let result = join_supervisor(handle).await;
3600 assert!(
3601 drained.load(Ordering::SeqCst) == 1,
3602 "the nested subtree should have drained rather than being cut off by the parent's budget: {result:?}"
3603 );
3604 assert!(
3605 result.is_ok(),
3606 "the nested drain finished in time, so shutdown was clean: {result:?}"
3607 );
3608 }
3609}