Skip to main content

runledger_runtime/
supervisor.rs

1use std::borrow::Borrow;
2use std::future::Future;
3use std::sync::Arc;
4use std::time::Duration;
5
6use tokio::runtime::Handle;
7use tracing::warn;
8
9use crate::catalog::JobCatalog;
10use crate::config::JobsConfig;
11use crate::observer::{JobLifecycleObserver, JobLifecycleObservers};
12use crate::registry::JobRegistry;
13use crate::scheduler::run_scheduler_loop;
14use crate::shutdown::{ShutdownHandle, ShutdownSignal};
15use crate::task_group::TaskGroup;
16use crate::{Result, RuntimeError};
17
18const WORKER_TASK: &str = "worker";
19const SCHEDULER_TASK: &str = "scheduler";
20const REAPER_TASK: &str = "reaper";
21
22/// Supervises the Runledger runtime loops spawned for a worker process.
23///
24/// A supervisor owns the worker, scheduler, and reaper task handles selected by
25/// [`SupervisorBuilder`]. Use [`Self::run_until_shutdown`] for a typical worker
26/// process that should exit on either an external shutdown signal or an internal
27/// runtime task failure.
28///
29/// Dropping a supervisor requests shutdown and detaches the task handles. Call
30/// [`Self::shutdown`] or [`Self::join`] when the owning process needs to observe
31/// panics or unexpected task exits.
32#[must_use]
33pub struct Supervisor {
34    shutdown: ShutdownSignal,
35    tasks: TaskGroup,
36}
37
38/// Builds a [`Supervisor`] with configurable runtime loops.
39///
40/// Worker, scheduler, and reaper loops are enabled by default. Call
41/// [`Self::with_registry`] or [`Self::with_catalog`] before [`Self::build`] when
42/// worker or reaper loops remain enabled.
43#[must_use]
44pub struct SupervisorBuilder<'a> {
45    pool: &'a runledger_postgres::DbPool,
46    runtime: Handle,
47    registry: Option<JobRegistry>,
48    registry_source: Option<RegistrySource>,
49    mixed_registry_sources: bool,
50    config: JobsConfig,
51    observers: Vec<Arc<dyn JobLifecycleObserver>>,
52    worker_enabled: bool,
53    scheduler_enabled: bool,
54    reaper_enabled: bool,
55}
56
57/// Cloneable handle for requesting supervisor shutdown from another task.
58#[derive(Clone)]
59pub struct SupervisorShutdown {
60    handle: ShutdownHandle,
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64enum RegistrySource {
65    Registry,
66    Catalog,
67}
68
69impl Supervisor {
70    /// Returns a builder for a supervisor over a shared pool and runtime
71    /// configuration.
72    ///
73    /// This validates that the caller is inside the Tokio runtime that will own
74    /// spawned supervisor tasks.
75    pub fn builder(
76        pool: &runledger_postgres::DbPool,
77        config: JobsConfig,
78    ) -> std::result::Result<SupervisorBuilder<'_>, RuntimeError> {
79        let runtime =
80            Handle::try_current().map_err(|source| RuntimeError::MissingTokioRuntime { source })?;
81
82        Ok(SupervisorBuilder {
83            pool,
84            runtime,
85            registry: None,
86            registry_source: None,
87            mixed_registry_sources: false,
88            config,
89            observers: Vec::new(),
90            worker_enabled: true,
91            scheduler_enabled: true,
92            reaper_enabled: true,
93        })
94    }
95
96    /// Returns a cloneable shutdown handle that can request shutdown without
97    /// owning the supervisor task joins.
98    #[must_use]
99    pub fn shutdown_handle(&self) -> SupervisorShutdown {
100        SupervisorShutdown {
101            handle: self.shutdown.handle(),
102        }
103    }
104
105    /// Requests graceful shutdown of all supervised loops.
106    pub fn request_shutdown(&self) {
107        self.shutdown.request();
108    }
109
110    /// Returns whether shutdown has been requested through this supervisor or a
111    /// clone of its shutdown handle.
112    #[must_use]
113    pub fn is_shutdown_requested(&self) -> bool {
114        self.shutdown.is_requested()
115    }
116
117    /// Waits for all supervised loops to exit.
118    ///
119    /// With the default long-running loops, this method waits until shutdown is
120    /// requested through a [`SupervisorShutdown`] handle or until a task exits.
121    /// If a loop exits before shutdown was requested, the remaining loops are
122    /// asked to shut down and the first observed error is returned. Additional
123    /// task failures observed while draining are logged. This method does not
124    /// impose a deadline; use [`Self::shutdown_with_timeout`] when the caller
125    /// owns shutdown and needs a bounded wait.
126    pub async fn join(mut self) -> Result<()> {
127        let shutdown = self.shutdown.clone();
128        self.tasks.join(&shutdown).await
129    }
130
131    /// Requests graceful shutdown and waits for all supervised loops to exit.
132    ///
133    /// If a loop exits before shutdown was requested, the remaining loops are
134    /// asked to shut down and the pre-existing task exit is reported, even when
135    /// that exit is only observed after shutdown begins. This method does not
136    /// impose a deadline. Use [`Self::shutdown_with_timeout`] when the owning
137    /// process needs a shutdown budget; externally timing out this consuming
138    /// future can detach still-running task handles.
139    pub async fn shutdown(mut self) -> Result<()> {
140        let shutdown = self.shutdown.clone();
141        self.tasks.shutdown(&shutdown).await
142    }
143
144    /// Waits until `shutdown` resolves or a supervised task fails, then exits.
145    ///
146    /// If `shutdown` resolves first, graceful shutdown is requested and the
147    /// supervisor waits up to `timeout` for all loops to exit. If a loop panics
148    /// or exits unexpectedly before `shutdown` resolves, shutdown is requested
149    /// for the remaining loops and the original task error is returned after
150    /// those loops drain or a timeout is reported. If shutdown is requested
151    /// through a [`SupervisorShutdown`] handle and every loop exits cleanly before
152    /// `shutdown` resolves, this returns successfully.
153    ///
154    /// This is the preferred method for worker binaries because it observes
155    /// internal task failures during normal operation while still applying a
156    /// bounded shutdown budget to cooperative process termination.
157    ///
158    /// If `timeout` is too large to represent as a runtime deadline, this returns
159    /// [`RuntimeError::ShutdownTimeoutTooLarge`] immediately. A zero timeout
160    /// requests shutdown, aborts tasks without waiting for cooperative exits, and
161    /// reports [`RuntimeError::ShutdownTimeout`].
162    ///
163    /// If the initial timeout validation fails before `shutdown` resolves, the
164    /// supervisor is still dropped, so shutdown is requested, but task handles
165    /// are not aborted or drained. If a deadline overflow is detected after
166    /// shutdown begins, remaining tasks are aborted and drained before returning.
167    pub async fn run_until_shutdown<F>(mut self, shutdown: F, timeout: Duration) -> Result<()>
168    where
169        F: Future<Output = ()>,
170    {
171        let shutdown_signal = self.shutdown.clone();
172        self.tasks
173            .run_until_shutdown(shutdown, timeout, &shutdown_signal)
174            .await
175    }
176
177    /// Requests graceful shutdown and waits up to `timeout` for all supervised
178    /// loops to exit.
179    ///
180    /// If a loop had already exited before this method begins shutdown, that
181    /// failure is returned after the remaining loops have had the same shutdown
182    /// budget to exit cooperatively. If the timeout expires, remaining tasks are
183    /// aborted and drained with a bounded cleanup attempt before a timeout error
184    /// is returned. Abort cleanup can make total wall-clock time exceed `timeout`
185    /// by up to one second, or `timeout`, whichever is smaller. A zero timeout
186    /// requests shutdown, immediately aborts tasks that did not already finish,
187    /// and reports [`RuntimeError::ShutdownTimeout`].
188    ///
189    /// If `timeout` is too large to represent as a runtime deadline, this returns
190    /// [`RuntimeError::ShutdownTimeoutTooLarge`] immediately. The supervisor is
191    /// still dropped, so shutdown is requested, but task handles are not aborted
192    /// or drained.
193    pub async fn shutdown_with_timeout(mut self, timeout: Duration) -> Result<()> {
194        let shutdown = self.shutdown.clone();
195        self.tasks.shutdown_with_timeout(timeout, &shutdown).await
196    }
197}
198
199impl Drop for Supervisor {
200    fn drop(&mut self) {
201        if !self.tasks.is_empty() {
202            warn!(
203                task_count = self.tasks.len(),
204                "dropping jobs runtime supervisor before joining tasks; tasks may continue detached after shutdown is requested and later panics will not be observed"
205            );
206        }
207        // Drop cannot await task handles, so this only nudges loops to exit.
208        self.request_shutdown();
209    }
210}
211
212impl<'a> SupervisorBuilder<'a> {
213    /// Registers the handlers used by worker execution and reaper terminal hooks.
214    ///
215    /// A registry is required when worker or reaper loops are enabled. Scheduler-only
216    /// supervisors can be built without one.
217    #[must_use = "builder methods return an updated builder value"]
218    pub fn with_registry(mut self, registry: JobRegistry) -> Self {
219        self.mixed_registry_sources |= self.registry_source == Some(RegistrySource::Catalog);
220        self.registry_source = Some(RegistrySource::Registry);
221        self.registry = Some(registry);
222        self
223    }
224
225    /// Registers handlers from a [`JobCatalog`].
226    ///
227    /// This does not sync database job definitions. Call
228    /// [`JobCatalog::sync_definitions`] before starting the supervisor or
229    /// creating schedules. Pass `&catalog` when the caller will continue using
230    /// the catalog for schedule, enqueue, or workflow helpers after building the
231    /// supervisor.
232    ///
233    /// # Registry Source
234    ///
235    /// Calling this and [`Self::with_registry`] on the same builder is rejected
236    /// by [`Self::build`]. Choose one registration source per builder.
237    #[must_use = "builder methods return an updated builder value"]
238    pub fn with_catalog(mut self, catalog: impl Borrow<JobCatalog>) -> Self {
239        self.mixed_registry_sources |= self.registry_source == Some(RegistrySource::Registry);
240        self.registry_source = Some(RegistrySource::Catalog);
241        self.registry = Some(catalog.borrow().to_registry());
242        self
243    }
244
245    /// Disables worker job claiming and execution for this supervisor.
246    #[must_use = "builder methods return an updated builder value"]
247    pub fn disable_worker(mut self) -> Self {
248        self.worker_enabled = false;
249        self
250    }
251
252    /// Disables cron schedule materialization for this supervisor.
253    #[must_use = "builder methods return an updated builder value"]
254    pub fn disable_scheduler(mut self) -> Self {
255        self.scheduler_enabled = false;
256        self
257    }
258
259    /// Disables expired-lease reaping for this supervisor.
260    #[must_use = "builder methods return an updated builder value"]
261    pub fn disable_reaper(mut self) -> Self {
262        self.reaper_enabled = false;
263        self
264    }
265
266    /// Registers a best-effort observer for committed job lifecycle events.
267    ///
268    /// Observer callbacks run outside Runledger storage transactions. A callback
269    /// timeout or panic is logged and does not change durable job state.
270    #[must_use = "builder methods return an updated builder value"]
271    pub fn with_job_lifecycle_observer(
272        mut self,
273        observer: impl JobLifecycleObserver + 'static,
274    ) -> Self {
275        self.observers.push(Arc::new(observer));
276        self
277    }
278
279    /// Starts the enabled runtime loops and returns the owning supervisor.
280    ///
281    /// Returns an error when worker or reaper loops are enabled without a job
282    /// registry.
283    pub fn build(self) -> std::result::Result<Supervisor, RuntimeError> {
284        let Self {
285            pool,
286            runtime,
287            registry,
288            registry_source: _,
289            mixed_registry_sources,
290            config,
291            observers,
292            worker_enabled,
293            scheduler_enabled,
294            reaper_enabled,
295        } = self;
296
297        config
298            .validate()
299            .map_err(|source| RuntimeError::InvalidJobsConfig { source })?;
300
301        if mixed_registry_sources {
302            return Err(RuntimeError::MixedRegistrySources);
303        }
304
305        let registry = match registry {
306            Some(registry) => registry,
307            None if worker_enabled || reaper_enabled => {
308                return Err(RuntimeError::MissingRegistry {
309                    worker_enabled,
310                    reaper_enabled,
311                });
312            }
313            None => JobRegistry::new(),
314        };
315
316        let (shutdown, shutdown_rx) = ShutdownSignal::channel();
317        let mut tasks = TaskGroup::new();
318        let observers = JobLifecycleObservers::from_arc_observers(observers);
319
320        if worker_enabled {
321            tasks.spawn_on(&runtime, WORKER_TASK, {
322                let pool = pool.clone();
323                let registry = registry.clone();
324                let config = config.clone();
325                let shutdown_rx = shutdown_rx.clone();
326                let observers = observers.clone();
327                async move {
328                    crate::worker::run_worker_loop_with_observer(
329                        pool,
330                        registry,
331                        config,
332                        shutdown_rx,
333                        observers,
334                    )
335                    .await
336                }
337            });
338        }
339
340        if scheduler_enabled {
341            tasks.spawn_on(&runtime, SCHEDULER_TASK, {
342                let pool = pool.clone();
343                let config = config.clone();
344                let shutdown_rx = shutdown_rx.clone();
345                async move { run_scheduler_loop(pool, config, shutdown_rx).await }
346            });
347        }
348
349        if reaper_enabled {
350            let pool = pool.clone();
351            let registry = registry.clone();
352            let config = config.clone();
353            let shutdown_rx = shutdown_rx.clone();
354            let observers = observers.clone();
355            tasks.spawn_on(&runtime, REAPER_TASK, async move {
356                crate::reaper::run_reaper_loop_with_observer(
357                    pool,
358                    registry,
359                    config,
360                    shutdown_rx,
361                    observers,
362                )
363                .await
364            });
365        }
366
367        Ok(Supervisor { shutdown, tasks })
368    }
369}
370
371impl SupervisorShutdown {
372    /// Requests graceful shutdown of all loops watched by the supervisor.
373    pub fn request_shutdown(&self) {
374        self.handle.request();
375    }
376
377    /// Returns whether shutdown has been requested.
378    #[must_use]
379    pub fn is_shutdown_requested(&self) -> bool {
380        self.handle.is_requested()
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use std::time::Duration;
387
388    use sqlx::postgres::PgPoolOptions;
389    use tokio::time::timeout;
390
391    use super::*;
392
393    const UNUSED_LAZY_POOL_URL: &str = "postgres://postgres:postgres@127.0.0.1:65535/runledger";
394
395    fn lazy_pool() -> runledger_postgres::DbPool {
396        PgPoolOptions::new()
397            // The disable-only tests never acquire this pool; this URL is only
398            // a valid PgPool value for supervisor wiring assertions.
399            .connect_lazy(UNUSED_LAZY_POOL_URL)
400            .expect("construct lazy pool")
401    }
402
403    fn test_config() -> JobsConfig {
404        JobsConfig {
405            worker_id: "supervisor-test-worker".to_string(),
406            poll_interval: Duration::from_millis(25),
407            claim_batch_size: 4,
408            lease_ttl_seconds: 10,
409            max_global_concurrency: 4,
410            reaper_interval: Duration::from_millis(50),
411            schedule_poll_interval: Duration::from_millis(50),
412            reaper_retry_delay_ms: 1_000,
413        }
414    }
415
416    fn empty_builder(pool: &runledger_postgres::DbPool) -> SupervisorBuilder<'_> {
417        Supervisor::builder(pool, test_config()).expect("supervisor builder has runtime")
418    }
419
420    fn missing_registry_flags(builder: SupervisorBuilder<'_>) -> (bool, bool) {
421        match builder.build() {
422            Err(RuntimeError::MissingRegistry {
423                worker_enabled,
424                reaper_enabled,
425            }) => (worker_enabled, reaper_enabled),
426            Ok(_) => panic!("missing registry should be a build error"),
427            Err(other) => panic!("expected missing registry error, got {other:?}"),
428        }
429    }
430
431    fn task_names(supervisor: &Supervisor) -> Vec<&'static str> {
432        supervisor.tasks.names_for_tests()
433    }
434
435    async fn abort_supervisor_tasks(mut supervisor: Supervisor) {
436        supervisor.tasks.abort_all_for_tests().await;
437    }
438
439    #[tokio::test]
440    async fn builder_defaults_enable_all_loops() {
441        let pool = lazy_pool();
442        let builder = empty_builder(&pool);
443
444        assert!(builder.worker_enabled);
445        assert!(builder.scheduler_enabled);
446        assert!(builder.reaper_enabled);
447        assert!(builder.registry.is_none());
448        assert_eq!(builder.registry_source, None);
449        assert!(!builder.mixed_registry_sources);
450    }
451
452    #[tokio::test]
453    async fn builder_accepts_registry_for_worker_and_reaper_loops() {
454        let pool = lazy_pool();
455        let builder = empty_builder(&pool).with_registry(JobRegistry::new());
456
457        assert!(builder.registry.is_some());
458        assert_eq!(builder.registry_source, Some(RegistrySource::Registry));
459        assert!(!builder.mixed_registry_sources);
460    }
461
462    #[tokio::test]
463    async fn builder_rejects_mixed_registry_sources() {
464        let pool = lazy_pool();
465        let registry_then_catalog = empty_builder(&pool)
466            .with_registry(JobRegistry::new())
467            .with_catalog(JobCatalog::new())
468            .disable_worker()
469            .disable_reaper()
470            .build();
471        let Err(registry_then_catalog) = registry_then_catalog else {
472            panic!("mixed registry sources should be rejected");
473        };
474        assert!(matches!(
475            registry_then_catalog,
476            RuntimeError::MixedRegistrySources
477        ));
478
479        let catalog_then_registry = empty_builder(&pool)
480            .with_catalog(JobCatalog::new())
481            .with_registry(JobRegistry::new())
482            .disable_worker()
483            .disable_reaper()
484            .build();
485        let Err(catalog_then_registry) = catalog_then_registry else {
486            panic!("mixed registry sources should be rejected");
487        };
488        assert!(matches!(
489            catalog_then_registry,
490            RuntimeError::MixedRegistrySources
491        ));
492    }
493
494    #[tokio::test]
495    async fn builder_requires_registry_when_worker_or_reaper_is_enabled() {
496        let pool = lazy_pool();
497
498        assert_eq!(missing_registry_flags(empty_builder(&pool)), (true, true));
499        assert_eq!(
500            missing_registry_flags(empty_builder(&pool).disable_scheduler().disable_reaper()),
501            (true, false)
502        );
503        assert_eq!(
504            missing_registry_flags(empty_builder(&pool).disable_worker().disable_scheduler()),
505            (false, true)
506        );
507    }
508
509    #[tokio::test]
510    async fn builder_rejects_invalid_direct_config_values_before_spawning_loops() {
511        let cases = [
512            {
513                let mut config = test_config();
514                config.max_global_concurrency = 0;
515                (
516                    config,
517                    crate::config::JobsConfigValidationError::InvalidMaxGlobalConcurrency,
518                )
519            },
520            {
521                let mut config = test_config();
522                config.claim_batch_size = 0;
523                (
524                    config,
525                    crate::config::JobsConfigValidationError::InvalidClaimBatchSize { actual: 0 },
526                )
527            },
528            {
529                let mut config = test_config();
530                config.lease_ttl_seconds = 0;
531                (
532                    config,
533                    crate::config::JobsConfigValidationError::InvalidLeaseTtlSeconds { actual: 0 },
534                )
535            },
536        ];
537
538        for (config, expected) in cases {
539            let pool = lazy_pool();
540            let result = Supervisor::builder(&pool, config)
541                .expect("supervisor builder has runtime")
542                .disable_worker()
543                .disable_scheduler()
544                .disable_reaper()
545                .build();
546            let Err(error) = result else {
547                panic!("invalid direct config should be rejected");
548            };
549
550            match error {
551                RuntimeError::InvalidJobsConfig { source } => {
552                    assert_eq!(source, expected);
553                }
554                other => panic!("expected invalid jobs config error, got {other:?}"),
555            }
556        }
557    }
558
559    #[test]
560    fn builder_requires_tokio_runtime_before_cloning_pool() {
561        let runtime = tokio::runtime::Runtime::new().expect("construct Tokio runtime");
562        let pool = runtime.block_on(async { lazy_pool() });
563        let error = match Supervisor::builder(&pool, test_config()) {
564            Err(error) => error,
565            Ok(builder) => {
566                drop(builder);
567                runtime.block_on(async {
568                    pool.close().await;
569                });
570                std::mem::forget(pool);
571                panic!("missing Tokio runtime should be a builder error");
572            }
573        };
574
575        // The builder was intentionally called outside a runtime to exercise
576        // the pre-clone runtime check. Close and drop the pool inside the
577        // temporary runtime so sqlx's own drop precondition does not contaminate
578        // this assertion.
579        runtime.block_on(async {
580            pool.close().await;
581        });
582        std::mem::forget(pool);
583        match error {
584            RuntimeError::MissingTokioRuntime { .. } => {}
585            other => panic!("expected missing Tokio runtime error, got {other:?}"),
586        }
587    }
588
589    #[tokio::test]
590    async fn builder_can_disable_each_loop() {
591        let pool = lazy_pool();
592        let builder = empty_builder(&pool)
593            .disable_worker()
594            .disable_scheduler()
595            .disable_reaper();
596
597        assert!(!builder.worker_enabled);
598        assert!(!builder.scheduler_enabled);
599        assert!(!builder.reaper_enabled);
600    }
601
602    #[tokio::test]
603    async fn builder_spawns_only_enabled_tasks() {
604        let pool = lazy_pool();
605
606        let all_disabled = empty_builder(&pool)
607            .disable_worker()
608            .disable_scheduler()
609            .disable_reaper()
610            .build()
611            .expect("all-disabled supervisor should build");
612        assert_eq!(task_names(&all_disabled), Vec::<&'static str>::new());
613        abort_supervisor_tasks(all_disabled).await;
614
615        let scheduler_only = empty_builder(&pool)
616            .disable_worker()
617            .disable_reaper()
618            .build()
619            .expect("scheduler-only supervisor should not require registry");
620        assert_eq!(task_names(&scheduler_only), vec![SCHEDULER_TASK]);
621        abort_supervisor_tasks(scheduler_only).await;
622
623        let worker_only = empty_builder(&pool)
624            .with_registry(JobRegistry::new())
625            .disable_scheduler()
626            .disable_reaper()
627            .build()
628            .expect("worker-only supervisor should build with registry");
629        assert_eq!(task_names(&worker_only), vec![WORKER_TASK]);
630        abort_supervisor_tasks(worker_only).await;
631
632        let reaper_only = empty_builder(&pool)
633            .with_registry(JobRegistry::new())
634            .disable_worker()
635            .disable_scheduler()
636            .build()
637            .expect("reaper-only supervisor should build with registry");
638        assert_eq!(task_names(&reaper_only), vec![REAPER_TASK]);
639        abort_supervisor_tasks(reaper_only).await;
640
641        let all_enabled = empty_builder(&pool)
642            .with_registry(JobRegistry::new())
643            .build()
644            .expect("all-enabled supervisor should build with registry");
645        assert_eq!(
646            task_names(&all_enabled),
647            vec![WORKER_TASK, SCHEDULER_TASK, REAPER_TASK]
648        );
649        abort_supervisor_tasks(all_enabled).await;
650    }
651
652    #[tokio::test]
653    async fn all_disabled_supervisor_join_and_shutdown_succeed() {
654        Supervisor::builder(&lazy_pool(), test_config())
655            .expect("supervisor builder has runtime")
656            .disable_worker()
657            .disable_scheduler()
658            .disable_reaper()
659            .build()
660            .expect("all-disabled supervisor should build")
661            .join()
662            .await
663            .expect("all-disabled supervisor should join");
664
665        Supervisor::builder(&lazy_pool(), test_config())
666            .expect("supervisor builder has runtime")
667            .disable_worker()
668            .disable_scheduler()
669            .disable_reaper()
670            .build()
671            .expect("all-disabled supervisor should build")
672            .shutdown()
673            .await
674            .expect("all-disabled supervisor should shut down");
675    }
676
677    #[tokio::test]
678    async fn shutdown_handle_can_request_shutdown_before_join() {
679        let supervisor = Supervisor::builder(&lazy_pool(), test_config())
680            .expect("supervisor builder has runtime")
681            .disable_worker()
682            .disable_scheduler()
683            .disable_reaper()
684            .build()
685            .expect("all-disabled supervisor should build");
686        let shutdown = supervisor.shutdown_handle();
687        let cloned_shutdown = shutdown.clone();
688
689        cloned_shutdown.request_shutdown();
690
691        assert!(shutdown.is_shutdown_requested());
692        assert!(supervisor.is_shutdown_requested());
693        supervisor
694            .join()
695            .await
696            .expect("supervisor should join after shutdown handle request");
697    }
698
699    #[tokio::test]
700    async fn run_until_shutdown_with_no_tasks_waits_for_signal() {
701        let supervisor = Supervisor::builder(&lazy_pool(), test_config())
702            .expect("supervisor builder has runtime")
703            .disable_worker()
704            .disable_scheduler()
705            .disable_reaper()
706            .build()
707            .expect("all-disabled supervisor should build");
708        let (signal_tx, signal_rx) = tokio::sync::oneshot::channel();
709        let mut run = tokio::spawn(supervisor.run_until_shutdown(
710            async move {
711                signal_rx.await.expect("shutdown signal should be sent");
712            },
713            Duration::from_secs(1),
714        ));
715
716        assert!(
717            timeout(Duration::from_millis(50), &mut run).await.is_err(),
718            "all-disabled supervisor should wait for the shutdown signal"
719        );
720
721        signal_tx.send(()).expect("signal receiver should be alive");
722        run.await
723            .expect("run-until-shutdown task should join")
724            .expect("all-disabled supervisor should complete after signal");
725    }
726}