1use std::future::Future;
11use std::marker::PhantomData;
12use std::panic::AssertUnwindSafe;
13use std::pin::Pin;
14
15use futures::FutureExt;
16use tokio::task::JoinHandle;
17
18use tokio_util::sync::CancellationToken;
19
20use crate::{
21 RuntimeComponentBundle, RuntimeComponentKind, RuntimeComponentRegistry, log_shutdown_report,
22 spawn_termination_signal_handler,
23};
24
25type RuntimeHookFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
26type RuntimeHook = Box<dyn FnOnce() -> RuntimeHookFuture + Send>;
27type RuntimeComponentRegistration = Box<dyn FnOnce(&mut RuntimeComponentRegistry) + Send>;
28type ShutdownResourceFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
29type ShutdownResourceFn<T> = Box<dyn FnOnce(T) -> ShutdownResourceFuture + Send>;
30
31fn empty_runtime_hook() -> RuntimeHook {
32 Box::new(|| Box::pin(async {}))
33}
34
35struct RuntimeSignalTask(JoinHandle<()>);
37
38impl Drop for RuntimeSignalTask {
39 fn drop(&mut self) {
40 self.0.abort();
41 }
42}
43
44impl RuntimeSignalTask {
45 async fn cancel_and_join(mut self) {
46 self.0.abort();
49 if let Err(error) = (&mut self.0).await
50 && error.is_panic()
51 {
52 tracing::error!(%error, "runtime signal callback panicked");
53 }
54 }
55}
56
57pub struct ServiceLifecycle<S> {
63 server: S,
64 shutdown_token: CancellationToken,
65}
66
67impl<S> ServiceLifecycle<S> {
68 pub fn new(server: S, shutdown_token: CancellationToken) -> Self {
70 Self {
71 server,
72 shutdown_token,
73 }
74 }
75}
76
77pub struct RuntimeServiceComponent<S> {
84 component_name: &'static str,
85 kind: RuntimeComponentKind,
86 service: S,
87 shutdown_token: CancellationToken,
88 stop_on_signal: RuntimeHook,
89}
90
91impl<S> RuntimeServiceComponent<S> {
92 pub fn new<F, Fut>(
96 component_name: &'static str,
97 kind: RuntimeComponentKind,
98 service: S,
99 shutdown_token: CancellationToken,
100 stop_on_signal: F,
101 ) -> Self
102 where
103 F: FnOnce() -> Fut + Send + 'static,
104 Fut: Future<Output = ()> + Send + 'static,
105 {
106 Self {
107 component_name,
108 kind,
109 service,
110 shutdown_token,
111 stop_on_signal: Box::new(move || Box::pin(stop_on_signal())),
112 }
113 }
114
115 fn into_parts(self) -> RuntimeServiceParts<S> {
116 RuntimeServiceParts {
117 service: self.service,
118 shutdown_token: self.shutdown_token,
119 stop_on_signal: self.stop_on_signal,
120 }
121 }
122}
123
124struct RuntimeServiceParts<S> {
125 service: S,
126 shutdown_token: CancellationToken,
127 stop_on_signal: RuntimeHook,
128}
129
130pub trait AsterRuntimeComponent<S> {
136 type Output;
138
139 fn apply(self, builder: AsterRuntimeBuilder<S>) -> Self::Output;
141}
142
143pub struct RuntimeComponentBundleRegistration<B> {
145 bundle: B,
146}
147
148pub struct RuntimeComponentWithShutdown<C, F> {
150 build: F,
151 _component: PhantomData<fn() -> C>,
152}
153
154pub struct TryRuntimeComponentWithShutdown<C, F, E> {
156 build: F,
157 _component: PhantomData<fn() -> C>,
158 _error: PhantomData<fn() -> E>,
159}
160
161pub const fn runtime_component<B>(bundle: B) -> RuntimeComponentBundleRegistration<B> {
163 RuntimeComponentBundleRegistration { bundle }
164}
165
166pub fn runtime_component_with_shutdown<C, F>(build: F) -> RuntimeComponentWithShutdown<C, F>
172where
173 F: FnOnce(CancellationToken) -> C,
174{
175 RuntimeComponentWithShutdown {
176 build,
177 _component: PhantomData,
178 }
179}
180
181pub fn try_runtime_component_with_shutdown<C, F, E>(
183 build: F,
184) -> TryRuntimeComponentWithShutdown<C, F, E>
185where
186 F: FnOnce(CancellationToken) -> Result<C, E>,
187{
188 TryRuntimeComponentWithShutdown {
189 build,
190 _component: PhantomData,
191 _error: PhantomData,
192 }
193}
194
195pub struct ShutdownResourceComponent<T> {
202 component_name: &'static str,
203 kind: RuntimeComponentKind,
204 phase_name: &'static str,
205 dependencies: &'static [&'static str],
206 resource: T,
207 shutdown: ShutdownResourceFn<T>,
208}
209
210impl<T> ShutdownResourceComponent<T> {
211 pub fn new<F, Fut>(
213 component_name: &'static str,
214 kind: RuntimeComponentKind,
215 phase_name: &'static str,
216 resource: T,
217 shutdown: F,
218 ) -> Self
219 where
220 F: FnOnce(T) -> Fut + Send + 'static,
221 Fut: Future<Output = Result<(), String>> + Send + 'static,
222 {
223 Self {
224 component_name,
225 kind,
226 phase_name,
227 dependencies: &[],
228 resource,
229 shutdown: Box::new(move |resource| Box::pin(shutdown(resource))),
230 }
231 }
232
233 #[must_use]
235 pub const fn depends_on_all(mut self, dependencies: &'static [&'static str]) -> Self {
236 self.dependencies = dependencies;
237 self
238 }
239}
240
241impl<T> RuntimeComponentBundle for ShutdownResourceComponent<T>
242where
243 T: Send + 'static,
244{
245 fn register(self, registry: &mut RuntimeComponentRegistry) {
246 let Self {
247 component_name,
248 kind,
249 phase_name,
250 dependencies,
251 resource,
252 shutdown,
253 } = self;
254 registry
255 .component(component_name)
256 .kind(kind)
257 .depends_on_all(dependencies)
258 .shutdown_once(phase_name, None, resource, shutdown);
259 }
260}
261
262pub fn shutdown_resource_component<T, F, Fut>(
264 component_name: &'static str,
265 kind: RuntimeComponentKind,
266 phase_name: &'static str,
267 resource: T,
268 shutdown: F,
269) -> RuntimeComponentBundleRegistration<ShutdownResourceComponent<T>>
270where
271 T: Send + 'static,
272 F: FnOnce(T) -> Fut + Send + 'static,
273 Fut: Future<Output = Result<(), String>> + Send + 'static,
274{
275 runtime_component(ShutdownResourceComponent::new(
276 component_name,
277 kind,
278 phase_name,
279 resource,
280 shutdown,
281 ))
282}
283
284pub fn shutdown_resource_component_after<T, F, Fut>(
286 component_name: &'static str,
287 kind: RuntimeComponentKind,
288 phase_name: &'static str,
289 dependencies: &'static [&'static str],
290 resource: T,
291 shutdown: F,
292) -> RuntimeComponentBundleRegistration<ShutdownResourceComponent<T>>
293where
294 T: Send + 'static,
295 F: FnOnce(T) -> Fut + Send + 'static,
296 Fut: Future<Output = Result<(), String>> + Send + 'static,
297{
298 runtime_component(
299 ShutdownResourceComponent::new(component_name, kind, phase_name, resource, shutdown)
300 .depends_on_all(dependencies),
301 )
302}
303
304impl<S, Service> AsterRuntimeComponent<S> for RuntimeServiceComponent<Service> {
305 type Output = AsterRuntimeBuilder<Service>;
306
307 fn apply(self, mut builder: AsterRuntimeBuilder<S>) -> Self::Output {
308 let component_name = self.component_name;
309 let kind = self.kind;
310 let assembly_error = if builder.service.is_some() {
311 Some(AsterRuntimeError::DuplicateService)
312 } else {
313 builder.assembly_error
314 };
315 builder.components.push(Box::new(move |registry| {
316 registry.component(component_name).kind(kind);
317 }));
318
319 AsterRuntimeBuilder {
320 service: Some(self.into_parts()),
321 shutdown_token: builder.shutdown_token,
322 before_shutdown: builder.before_shutdown,
323 components: builder.components,
324 assembly_error,
325 }
326 }
327}
328
329impl<S, B> AsterRuntimeComponent<S> for RuntimeComponentBundleRegistration<B>
330where
331 B: RuntimeComponentBundle + Send + 'static,
332{
333 type Output = AsterRuntimeBuilder<S>;
334
335 fn apply(self, mut builder: AsterRuntimeBuilder<S>) -> Self::Output {
336 builder
337 .components
338 .push(Box::new(move |registry| self.bundle.register(registry)));
339 builder
340 }
341}
342
343impl<S, C, F> AsterRuntimeComponent<S> for RuntimeComponentWithShutdown<C, F>
344where
345 C: AsterRuntimeComponent<S>,
346 F: FnOnce(CancellationToken) -> C,
347{
348 type Output = C::Output;
349
350 fn apply(self, builder: AsterRuntimeBuilder<S>) -> Self::Output {
351 let component = (self.build)(builder.shutdown_token.clone());
352 component.apply(builder)
353 }
354}
355
356impl<S, C, F, E> AsterRuntimeComponent<S> for TryRuntimeComponentWithShutdown<C, F, E>
357where
358 C: AsterRuntimeComponent<S>,
359 F: FnOnce(CancellationToken) -> Result<C, E>,
360{
361 type Output = Result<C::Output, E>;
362
363 fn apply(self, builder: AsterRuntimeBuilder<S>) -> Self::Output {
364 let component = (self.build)(builder.shutdown_token.clone())?;
365 Ok(component.apply(builder))
366 }
367}
368
369impl<B> RuntimeComponentBundle for RuntimeComponentBundleRegistration<B>
370where
371 B: RuntimeComponentBundle,
372{
373 fn register(self, registry: &mut RuntimeComponentRegistry) {
374 self.bundle.register(registry);
375 }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
380pub enum AsterRuntimeError {
381 #[error("runtime requires exactly one service component")]
383 MissingService,
384 #[error("runtime only supports one service component")]
386 DuplicateService,
387 #[error("runtime component graph is invalid: {source}")]
389 ComponentGraph {
390 source: crate::RuntimeComponentGraphError,
392 },
393 #[error("runtime startup aborted by a required component phase")]
395 Startup {
396 report: crate::StartupReport,
398 shutdown_report: crate::ShutdownReport,
400 },
401 #[error("runtime service panicked")]
403 Service {
404 shutdown_report: crate::ShutdownReport,
406 before_shutdown_panicked: bool,
408 },
409 #[error("runtime before-shutdown hook panicked")]
411 BeforeShutdown {
412 shutdown_report: crate::ShutdownReport,
414 },
415 #[error("runtime shutdown completed with failed component phases")]
417 Shutdown {
418 report: crate::ShutdownReport,
420 },
421}
422
423pub struct AsterRuntime<S> {
438 service: S,
439 shutdown_token: CancellationToken,
440 service_shutdown_token: CancellationToken,
441 stop_on_signal: RuntimeHook,
442 before_shutdown: RuntimeHook,
443 registry: RuntimeComponentRegistry,
444}
445
446impl AsterRuntime<()> {
447 #[must_use]
449 pub fn builder() -> AsterRuntimeBuilder<()> {
450 AsterRuntimeBuilder::new()
451 }
452}
453
454impl<S> AsterRuntime<S>
455where
456 S: Future,
457{
458 pub async fn run(mut self) -> Result<S::Output, AsterRuntimeError> {
481 let _shutdown_guard = self.shutdown_token.clone().drop_guard();
482 let _service_shutdown_guard = self.service_shutdown_token.clone().drop_guard();
483 let startup_report = self.registry.startup().await;
484 if startup_report.aborted() {
485 self.shutdown_token.cancel();
486 self.service_shutdown_token.cancel();
487 let shutdown_report = self.registry.shutdown().await;
488 log_shutdown_report(&shutdown_report);
489 return Err(AsterRuntimeError::Startup {
490 report: startup_report,
491 shutdown_report,
492 });
493 }
494
495 let signal_task = RuntimeSignalTask(spawn_termination_signal_handler(
496 self.shutdown_token.clone(),
497 self.stop_on_signal,
498 ));
499
500 #[expect(
503 clippy::redundant_async_block,
504 reason = "the owning async block drops the completed service future inside catch_unwind"
505 )]
506 let output = AssertUnwindSafe(async { self.service.await })
507 .catch_unwind()
508 .await;
509 self.shutdown_token.cancel();
512 self.service_shutdown_token.cancel();
513 signal_task.cancel_and_join().await;
514 tracing::info!("service stopped");
515
516 let before_shutdown_failed = AssertUnwindSafe(async { (self.before_shutdown)().await })
519 .catch_unwind()
520 .await
521 .is_err();
522
523 let report = self.registry.shutdown().await;
524 log_shutdown_report(&report);
525 let Ok(output) = output else {
526 return Err(AsterRuntimeError::Service {
527 shutdown_report: report,
528 before_shutdown_panicked: before_shutdown_failed,
529 });
530 };
531 if before_shutdown_failed {
532 return Err(AsterRuntimeError::BeforeShutdown {
533 shutdown_report: report,
534 });
535 }
536 if report.has_failures() {
537 return Err(AsterRuntimeError::Shutdown { report });
538 }
539
540 Ok(output)
541 }
542}
543
544pub struct AsterRuntimeBuilder<S = ()> {
546 service: Option<RuntimeServiceParts<S>>,
547 shutdown_token: CancellationToken,
548 before_shutdown: RuntimeHook,
549 components: Vec<RuntimeComponentRegistration>,
550 assembly_error: Option<AsterRuntimeError>,
551}
552
553impl AsterRuntimeBuilder<()> {
554 fn new() -> Self {
555 Self {
556 service: None,
557 shutdown_token: CancellationToken::new(),
558 before_shutdown: empty_runtime_hook(),
559 components: Vec::new(),
560 assembly_error: None,
561 }
562 }
563}
564
565impl<S> AsterRuntimeBuilder<S> {
566 pub fn shutdown_token(&self) -> &CancellationToken {
572 &self.shutdown_token
573 }
574
575 pub fn component<C>(self, component: C) -> C::Output
577 where
578 C: AsterRuntimeComponent<S>,
579 {
580 component.apply(self)
581 }
582
583 #[must_use]
585 pub fn before_shutdown<F, Fut>(mut self, before_shutdown: F) -> Self
586 where
587 F: FnOnce() -> Fut + Send + 'static,
588 Fut: Future<Output = ()> + Send + 'static,
589 {
590 self.before_shutdown = Box::new(move || Box::pin(before_shutdown()));
591 self
592 }
593
594 pub fn build(self) -> Result<AsterRuntime<S>, AsterRuntimeError> {
601 if let Some(error) = self.assembly_error {
602 return Err(error);
603 }
604
605 let Some(service) = self.service else {
606 return Err(AsterRuntimeError::MissingService);
607 };
608
609 let mut registry = RuntimeComponentRegistry::new();
610 for component in self.components {
611 component(&mut registry);
612 }
613 registry
614 .validate()
615 .map_err(|source| AsterRuntimeError::ComponentGraph { source })?;
616
617 let service_shutdown_token = service.shutdown_token;
618 let signal_shutdown = service_shutdown_token.clone();
619 Ok(AsterRuntime {
620 service: service.service,
621 shutdown_token: self.shutdown_token,
624 service_shutdown_token,
625 stop_on_signal: Box::new(move || {
626 signal_shutdown.cancel();
627 (service.stop_on_signal)()
628 }),
629 before_shutdown: self.before_shutdown,
630 registry,
631 })
632 }
633}
634
635impl<S> AsterRuntimeBuilder<S>
636where
637 S: Future,
638{
639 pub async fn run(self) -> Result<S::Output, AsterRuntimeError> {
645 self.build()?.run().await
646 }
647}
648
649impl<S> ServiceLifecycle<S>
650where
651 S: Future,
652{
653 pub async fn run<Stop, StopFut, AfterStop, AfterStopFut>(
663 self,
664 stop_on_signal: Stop,
665 after_stop: AfterStop,
666 ) -> S::Output
667 where
668 Stop: FnOnce() -> StopFut + Send + 'static,
669 StopFut: Future<Output = ()> + Send + 'static,
670 AfterStop: FnOnce() -> AfterStopFut,
671 AfterStopFut: Future<Output = ()>,
672 {
673 let _shutdown_guard = self.shutdown_token.clone().drop_guard();
674 let signal_task = RuntimeSignalTask(spawn_termination_signal_handler(
675 self.shutdown_token.clone(),
676 stop_on_signal,
677 ));
678
679 #[expect(
680 clippy::redundant_async_block,
681 reason = "the owning async block drops the completed service future inside catch_unwind"
682 )]
683 let server_result = AssertUnwindSafe(async { self.server.await })
684 .catch_unwind()
685 .await;
686 self.shutdown_token.cancel();
687 signal_task.cancel_and_join().await;
688 tracing::info!("server stopped");
689 let cleanup_result = AssertUnwindSafe(async { after_stop().await })
691 .catch_unwind()
692 .await;
693 match (server_result, cleanup_result) {
694 (Ok(output), Ok(())) => output,
695 (Err(payload), cleanup) => {
696 if cleanup.is_err() {
697 tracing::error!("service cleanup also panicked");
698 }
699 std::panic::resume_unwind(payload)
700 }
701 (Ok(_), Err(payload)) => std::panic::resume_unwind(payload),
702 }
703 }
704}
705
706#[cfg(test)]
707mod tests {
708 use std::sync::{
709 Arc,
710 atomic::{AtomicBool, Ordering},
711 };
712
713 use tokio_util::sync::CancellationToken;
714
715 use super::{AsterRuntime, AsterRuntimeError, RuntimeServiceComponent, ServiceLifecycle};
716 use crate::{RuntimeComponentBundle, RuntimeComponentKind, RuntimeComponentRegistry};
717
718 #[tokio::test]
719 async fn lifecycle_runs_after_stop_and_returns_server_result() {
720 let after_stop_ran = Arc::new(AtomicBool::new(false));
721 let observed_after_stop = Arc::clone(&after_stop_ran);
722
723 let result = ServiceLifecycle::new(
724 async { Ok::<_, &'static str>(42) },
725 CancellationToken::default(),
726 )
727 .run(
728 || async {},
729 move || {
730 let observed_after_stop = Arc::clone(&observed_after_stop);
731 async move {
732 observed_after_stop.store(true, Ordering::SeqCst);
733 }
734 },
735 )
736 .await;
737
738 assert_eq!(result, Ok(42));
739 assert!(after_stop_ran.load(Ordering::SeqCst));
740 }
741
742 struct TestShutdownComponent {
743 events: Arc<std::sync::Mutex<Vec<&'static str>>>,
744 }
745
746 impl RuntimeComponentBundle for TestShutdownComponent {
747 fn register(self, registry: &mut RuntimeComponentRegistry) {
748 registry.component_shutdown(
749 "test",
750 RuntimeComponentKind::Product,
751 "test_shutdown",
752 None,
753 move || {
754 let events = Arc::clone(&self.events);
755 async move {
756 events.lock().unwrap().push("component");
757 Ok(())
758 }
759 },
760 );
761 }
762 }
763
764 #[tokio::test]
765 async fn aster_runtime_runs_before_shutdown_and_registered_components() {
766 let events = Arc::new(std::sync::Mutex::new(Vec::new()));
767 let before_events = Arc::clone(&events);
768 let component_events = Arc::clone(&events);
769
770 let result = AsterRuntime::builder()
771 .component(RuntimeServiceComponent::new(
772 "http",
773 RuntimeComponentKind::Core,
774 async { Ok::<_, &'static str>(7) },
775 CancellationToken::default(),
776 || async {},
777 ))
778 .before_shutdown(move || {
779 let before_events = Arc::clone(&before_events);
780 async move {
781 before_events.lock().unwrap().push("before");
782 }
783 })
784 .component(crate::runtime_component(TestShutdownComponent {
785 events: component_events,
786 }))
787 .run()
788 .await
789 .expect("runtime should run");
790
791 assert_eq!(result, Ok(7));
792 assert_eq!(events.lock().unwrap().as_slice(), ["before", "component"]);
793 }
794
795 #[tokio::test]
796 async fn aster_runtime_runs_component_shutdown_when_startup_aborts() {
797 let events = Arc::new(std::sync::Mutex::new(Vec::new()));
798 let started_events = Arc::clone(&events);
799 let shutdown_events = Arc::clone(&events);
800
801 let result = AsterRuntime::builder()
802 .component(RuntimeServiceComponent::new(
803 "http",
804 RuntimeComponentKind::Core,
805 std::future::pending::<()>(),
806 CancellationToken::default(),
807 || async {},
808 ))
809 .component(crate::runtime_component(
810 move |registry: &mut RuntimeComponentRegistry| {
811 registry.component_startup(
812 "database",
813 RuntimeComponentKind::Core,
814 "database_connect",
815 crate::StartupPhaseFailurePolicy::Required,
816 move || {
817 let started_events = Arc::clone(&started_events);
818 async move {
819 started_events.lock().unwrap().push("database_started");
820 Ok(())
821 }
822 },
823 );
824 registry.component_shutdown(
825 "database",
826 RuntimeComponentKind::Core,
827 "database_close",
828 None,
829 move || {
830 let shutdown_events = Arc::clone(&shutdown_events);
831 async move {
832 shutdown_events.lock().unwrap().push("database_shutdown");
833 Ok(())
834 }
835 },
836 );
837 registry.component_startup(
838 "cache",
839 RuntimeComponentKind::Core,
840 "cache_connect",
841 crate::StartupPhaseFailurePolicy::Required,
842 || async { Err("cache unavailable".to_string()) },
843 );
844 },
845 ))
846 .run()
847 .await;
848
849 assert!(matches!(result, Err(AsterRuntimeError::Startup { .. })));
850 assert_eq!(
853 events.lock().unwrap().as_slice(),
854 ["database_started", "database_shutdown"]
855 );
856 }
857
858 #[tokio::test]
859 async fn aster_runtime_builder_shares_shutdown_token_with_components() {
860 let observed = Arc::new(AtomicBool::new(false));
861 let observed_component = Arc::clone(&observed);
862
863 let result = AsterRuntime::builder()
864 .component(crate::runtime_component_with_shutdown(|shutdown| {
865 let component_token = shutdown.clone();
866 RuntimeServiceComponent::new(
867 "http",
868 RuntimeComponentKind::Core,
869 async move {
870 component_token.cancel();
871 Ok::<_, &'static str>(())
872 },
873 shutdown,
874 || async {},
875 )
876 }))
877 .component(crate::runtime_component_with_shutdown(|shutdown| {
878 crate::runtime_component(move |registry: &mut RuntimeComponentRegistry| {
879 registry.component_shutdown(
880 "observer",
881 RuntimeComponentKind::Product,
882 "observe_shared_shutdown",
883 None,
884 move || {
885 let observed_component = Arc::clone(&observed_component);
886 let shutdown = shutdown.clone();
887 async move {
888 observed_component.store(shutdown.is_cancelled(), Ordering::SeqCst);
889 Ok(())
890 }
891 },
892 );
893 })
894 }))
895 .run()
896 .await
897 .expect("runtime should run");
898
899 assert_eq!(result, Ok(()));
900 assert!(observed.load(Ordering::SeqCst));
901 }
902
903 #[test]
904 fn aster_runtime_requires_service_component() {
905 let result = AsterRuntime::builder().build();
906 assert!(matches!(result, Err(AsterRuntimeError::MissingService)));
907 }
908
909 #[test]
910 fn aster_runtime_rejects_duplicate_service_components() {
911 let result = AsterRuntime::builder()
912 .component(RuntimeServiceComponent::new(
913 "http",
914 RuntimeComponentKind::Core,
915 async {},
916 CancellationToken::default(),
917 || async {},
918 ))
919 .component(RuntimeServiceComponent::new(
920 "worker",
921 RuntimeComponentKind::Core,
922 async {},
923 CancellationToken::default(),
924 || async {},
925 ))
926 .build();
927
928 assert!(matches!(result, Err(AsterRuntimeError::DuplicateService)));
929 }
930
931 #[test]
932 fn aster_runtime_rejects_invalid_component_graph() {
933 let result = AsterRuntime::builder()
934 .component(RuntimeServiceComponent::new(
935 "http",
936 RuntimeComponentKind::Core,
937 async {},
938 CancellationToken::default(),
939 || async {},
940 ))
941 .component(crate::runtime_component(
942 |registry: &mut RuntimeComponentRegistry| {
943 registry.component("database").depends_on("missing");
944 },
945 ))
946 .build();
947
948 assert!(matches!(
949 result,
950 Err(AsterRuntimeError::ComponentGraph {
951 source: crate::RuntimeComponentGraphError::MissingDependency {
952 component: "database",
953 dependency: "missing"
954 }
955 })
956 ));
957 }
958
959 #[test]
960 fn shutdown_resource_component_registers_dependencies_and_shutdown() {
961 let registry = RuntimeComponentRegistry::configured(|registry| {
962 crate::shutdown_resource_component_after(
963 "mail_outbox",
964 RuntimeComponentKind::Mail,
965 "mail_outbox_drain",
966 &["background_tasks"],
967 42_u8,
968 |_| async { Ok(()) },
969 )
970 .register(registry);
971 });
972
973 let descriptor = registry
974 .descriptor("mail_outbox")
975 .expect("shutdown resource component should be registered");
976 assert_eq!(descriptor.kind, RuntimeComponentKind::Mail);
977 assert_eq!(descriptor.dependencies, vec!["background_tasks"]);
978 assert_eq!(
979 descriptor
980 .shutdown
981 .first()
982 .expect("shutdown phase should be registered")
983 .phase_name,
984 "mail_outbox_drain"
985 );
986 }
987
988 #[tokio::test]
989 async fn completed_panicking_signal_listener_is_joined_and_logged() {
990 let task = tokio::spawn(async { panic!("signal callback failed") });
991 tokio::task::yield_now().await;
992 super::RuntimeSignalTask(task).cancel_and_join().await;
993 }
994}