Skip to main content

aster_forge_runtime/
lifecycle.rs

1//! Service lifecycle runner.
2//!
3//! This module contains the shared entrypoint mechanics for Aster services.
4//! Product crates still build their HTTP server, application state, background
5//! workers, and business hooks, while Forge owns the repeated runtime flow:
6//! register components, run startup phases, wait for termination, stop the main
7//! service, run product before-shutdown hooks, run component shutdown phases,
8//! and return the service output.
9
10use std::future::Future;
11use std::marker::PhantomData;
12use std::pin::Pin;
13
14use tokio_util::sync::CancellationToken;
15
16use crate::{
17    RuntimeComponentBundle, RuntimeComponentKind, RuntimeComponentRegistry, log_shutdown_report,
18    spawn_termination_signal_handler,
19};
20
21type RuntimeHookFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
22type RuntimeHook = Box<dyn FnOnce() -> RuntimeHookFuture + Send>;
23type RuntimeComponentRegistration = Box<dyn FnOnce(&mut RuntimeComponentRegistry) + Send>;
24type ShutdownResourceFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
25type ShutdownResourceFn<T> = Box<dyn FnOnce(T) -> ShutdownResourceFuture + Send>;
26
27fn empty_runtime_hook() -> RuntimeHook {
28    Box::new(|| Box::pin(async {}))
29}
30
31/// Runs one service future with shared Aster shutdown mechanics.
32///
33/// This lower-level helper exists for tests and uncommon one-off runners. New
34/// product entrypoints should prefer [`AsterRuntime::builder`] and register the
35/// main service through [`RuntimeServiceComponent`].
36pub struct ServiceLifecycle<S> {
37    server: S,
38    shutdown_token: CancellationToken,
39}
40
41impl<S> ServiceLifecycle<S> {
42    /// Creates a lifecycle runner for `server` and `shutdown_token`.
43    pub fn new(server: S, shutdown_token: CancellationToken) -> Self {
44        Self {
45            server,
46            shutdown_token,
47        }
48    }
49}
50
51/// Component that provides the main runtime service future.
52///
53/// Exactly one runtime service component must be registered. HTTP products
54/// usually expose a small `http_component(...)` constructor that builds this
55/// value from an Actix `Server`, its `ServerHandle`, and the shared shutdown
56/// token.
57pub struct RuntimeServiceComponent<S> {
58    component_name: &'static str,
59    kind: RuntimeComponentKind,
60    service: S,
61    shutdown_token: CancellationToken,
62    stop_on_signal: RuntimeHook,
63}
64
65impl<S> RuntimeServiceComponent<S> {
66    /// Creates a service component with a product-provided stop hook.
67    pub fn new<F, Fut>(
68        component_name: &'static str,
69        kind: RuntimeComponentKind,
70        service: S,
71        shutdown_token: CancellationToken,
72        stop_on_signal: F,
73    ) -> Self
74    where
75        F: FnOnce() -> Fut + Send + 'static,
76        Fut: Future<Output = ()> + Send + 'static,
77    {
78        Self {
79            component_name,
80            kind,
81            service,
82            shutdown_token,
83            stop_on_signal: Box::new(move || Box::pin(stop_on_signal())),
84        }
85    }
86
87    fn into_parts(self) -> RuntimeServiceParts<S> {
88        RuntimeServiceParts {
89            service: self.service,
90            shutdown_token: self.shutdown_token,
91            stop_on_signal: self.stop_on_signal,
92        }
93    }
94}
95
96struct RuntimeServiceParts<S> {
97    service: S,
98    shutdown_token: CancellationToken,
99    stop_on_signal: RuntimeHook,
100}
101
102/// Runtime component adapter used by [`AsterRuntimeBuilder::component`].
103///
104/// Service components can change the builder's service future type. Registry
105/// components keep the current builder type and only add lifecycle descriptors
106/// and hooks.
107pub trait AsterRuntimeComponent<S> {
108    /// Builder type returned after this component is applied.
109    type Output;
110
111    /// Applies the component to the runtime builder.
112    fn apply(self, builder: AsterRuntimeBuilder<S>) -> Self::Output;
113}
114
115/// Wrapper for components that only register runtime descriptors and hooks.
116pub struct RuntimeComponentBundleRegistration<B> {
117    bundle: B,
118}
119
120/// Component factory that receives the runtime's shared shutdown token.
121pub struct RuntimeComponentWithShutdown<C, F> {
122    build: F,
123    _component: PhantomData<fn() -> C>,
124}
125
126/// Fallible component factory that receives the runtime's shared shutdown token.
127pub struct TryRuntimeComponentWithShutdown<C, F, E> {
128    build: F,
129    _component: PhantomData<fn() -> C>,
130    _error: PhantomData<fn() -> E>,
131}
132
133/// Adapts a [`RuntimeComponentBundle`] for [`AsterRuntimeBuilder::component`].
134pub const fn runtime_component<B>(bundle: B) -> RuntimeComponentBundleRegistration<B> {
135    RuntimeComponentBundleRegistration { bundle }
136}
137
138/// Builds one runtime component from the runtime's shared shutdown token.
139///
140/// Use this when a product component needs the same token that `AsterRuntime`
141/// cancels on termination signals, for example an HTTP server, config reload
142/// subscription, or background worker group.
143pub fn runtime_component_with_shutdown<C, F>(build: F) -> RuntimeComponentWithShutdown<C, F>
144where
145    F: FnOnce(CancellationToken) -> C,
146{
147    RuntimeComponentWithShutdown {
148        build,
149        _component: PhantomData,
150    }
151}
152
153/// Fallible variant of [`runtime_component_with_shutdown`].
154pub fn try_runtime_component_with_shutdown<C, F, E>(
155    build: F,
156) -> TryRuntimeComponentWithShutdown<C, F, E>
157where
158    F: FnOnce(CancellationToken) -> Result<C, E>,
159{
160    TryRuntimeComponentWithShutdown {
161        build,
162        _component: PhantomData,
163        _error: PhantomData,
164    }
165}
166
167/// Runtime component for one shutdown-only owned resource.
168///
169/// This adapter is useful for product-owned resources whose lifecycle is
170/// otherwise simple: declare a component, optional dependencies, and a shutdown
171/// phase that consumes the resource exactly once. Product crates still own the
172/// resource type and the shutdown closure; Forge owns the component boilerplate.
173pub struct ShutdownResourceComponent<T> {
174    component_name: &'static str,
175    kind: RuntimeComponentKind,
176    phase_name: &'static str,
177    dependencies: &'static [&'static str],
178    resource: T,
179    shutdown: ShutdownResourceFn<T>,
180}
181
182impl<T> ShutdownResourceComponent<T> {
183    /// Creates a shutdown-only resource component.
184    pub fn new<F, Fut>(
185        component_name: &'static str,
186        kind: RuntimeComponentKind,
187        phase_name: &'static str,
188        resource: T,
189        shutdown: F,
190    ) -> Self
191    where
192        F: FnOnce(T) -> Fut + Send + 'static,
193        Fut: Future<Output = Result<(), String>> + Send + 'static,
194    {
195        Self {
196            component_name,
197            kind,
198            phase_name,
199            dependencies: &[],
200            resource,
201            shutdown: Box::new(move |resource| Box::pin(shutdown(resource))),
202        }
203    }
204
205    /// Declares components that must shut down before this resource.
206    pub const fn depends_on_all(mut self, dependencies: &'static [&'static str]) -> Self {
207        self.dependencies = dependencies;
208        self
209    }
210}
211
212impl<T> RuntimeComponentBundle for ShutdownResourceComponent<T>
213where
214    T: Send + 'static,
215{
216    fn register(self, registry: &mut RuntimeComponentRegistry) {
217        let Self {
218            component_name,
219            kind,
220            phase_name,
221            dependencies,
222            resource,
223            shutdown,
224        } = self;
225        registry
226            .component(component_name)
227            .kind(kind)
228            .depends_on_all(dependencies)
229            .shutdown_once(phase_name, None, resource, shutdown);
230    }
231}
232
233/// Creates a shutdown-only resource component registration.
234pub fn shutdown_resource_component<T, F, Fut>(
235    component_name: &'static str,
236    kind: RuntimeComponentKind,
237    phase_name: &'static str,
238    resource: T,
239    shutdown: F,
240) -> RuntimeComponentBundleRegistration<ShutdownResourceComponent<T>>
241where
242    T: Send + 'static,
243    F: FnOnce(T) -> Fut + Send + 'static,
244    Fut: Future<Output = Result<(), String>> + Send + 'static,
245{
246    runtime_component(ShutdownResourceComponent::new(
247        component_name,
248        kind,
249        phase_name,
250        resource,
251        shutdown,
252    ))
253}
254
255/// Creates a shutdown-only resource component registration with dependencies.
256pub fn shutdown_resource_component_after<T, F, Fut>(
257    component_name: &'static str,
258    kind: RuntimeComponentKind,
259    phase_name: &'static str,
260    dependencies: &'static [&'static str],
261    resource: T,
262    shutdown: F,
263) -> RuntimeComponentBundleRegistration<ShutdownResourceComponent<T>>
264where
265    T: Send + 'static,
266    F: FnOnce(T) -> Fut + Send + 'static,
267    Fut: Future<Output = Result<(), String>> + Send + 'static,
268{
269    runtime_component(
270        ShutdownResourceComponent::new(component_name, kind, phase_name, resource, shutdown)
271            .depends_on_all(dependencies),
272    )
273}
274
275impl<S, Service> AsterRuntimeComponent<S> for RuntimeServiceComponent<Service> {
276    type Output = AsterRuntimeBuilder<Service>;
277
278    fn apply(self, mut builder: AsterRuntimeBuilder<S>) -> Self::Output {
279        let component_name = self.component_name;
280        let kind = self.kind;
281        let assembly_error = if builder.service.is_some() {
282            Some(AsterRuntimeError::DuplicateService)
283        } else {
284            builder.assembly_error
285        };
286        builder.components.push(Box::new(move |registry| {
287            registry.component(component_name).kind(kind);
288        }));
289
290        AsterRuntimeBuilder {
291            service: Some(self.into_parts()),
292            shutdown_token: builder.shutdown_token,
293            before_shutdown: builder.before_shutdown,
294            components: builder.components,
295            assembly_error,
296        }
297    }
298}
299
300impl<S, B> AsterRuntimeComponent<S> for RuntimeComponentBundleRegistration<B>
301where
302    B: RuntimeComponentBundle + Send + 'static,
303{
304    type Output = AsterRuntimeBuilder<S>;
305
306    fn apply(self, mut builder: AsterRuntimeBuilder<S>) -> Self::Output {
307        builder
308            .components
309            .push(Box::new(move |registry| self.bundle.register(registry)));
310        builder
311    }
312}
313
314impl<S, C, F> AsterRuntimeComponent<S> for RuntimeComponentWithShutdown<C, F>
315where
316    C: AsterRuntimeComponent<S>,
317    F: FnOnce(CancellationToken) -> C,
318{
319    type Output = C::Output;
320
321    fn apply(self, builder: AsterRuntimeBuilder<S>) -> Self::Output {
322        let component = (self.build)(builder.shutdown_token.clone());
323        component.apply(builder)
324    }
325}
326
327impl<S, C, F, E> AsterRuntimeComponent<S> for TryRuntimeComponentWithShutdown<C, F, E>
328where
329    C: AsterRuntimeComponent<S>,
330    F: FnOnce(CancellationToken) -> Result<C, E>,
331{
332    type Output = Result<C::Output, E>;
333
334    fn apply(self, builder: AsterRuntimeBuilder<S>) -> Self::Output {
335        let component = (self.build)(builder.shutdown_token.clone())?;
336        Ok(component.apply(builder))
337    }
338}
339
340impl<B> RuntimeComponentBundle for RuntimeComponentBundleRegistration<B>
341where
342    B: RuntimeComponentBundle,
343{
344    fn register(self, registry: &mut RuntimeComponentRegistry) {
345        self.bundle.register(registry);
346    }
347}
348
349/// Error returned when runtime assembly or required startup phases fail.
350#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
351pub enum AsterRuntimeError {
352    /// No service component was registered.
353    #[error("runtime requires exactly one service component")]
354    MissingService,
355    /// More than one service component was registered.
356    #[error("runtime only supports one service component")]
357    DuplicateService,
358    /// Runtime component dependencies cannot be resolved.
359    #[error("runtime component graph is invalid: {source}")]
360    ComponentGraph {
361        /// Underlying component graph validation error.
362        source: crate::RuntimeComponentGraphError,
363    },
364    /// A required startup phase aborted runtime startup.
365    #[error("runtime startup aborted by a required component phase")]
366    Startup {
367        /// Startup report that contains the failing phase.
368        report: crate::StartupReport,
369    },
370}
371
372/// Product-facing runtime runner built from service and component registrations.
373///
374/// Product entrypoints should register the main service as a component, then
375/// register database, task, mail, product, or shutdown bundles in the same
376/// chain:
377///
378/// ```ignore
379/// AsterRuntime::builder()
380///     .component(http_component(...))
381///     .component(database_component(...))
382///     .component(task_component(...))
383///     .run()
384///     .await?;
385/// ```
386pub struct AsterRuntime<S> {
387    service: S,
388    shutdown_token: CancellationToken,
389    stop_on_signal: RuntimeHook,
390    before_shutdown: RuntimeHook,
391    registry: RuntimeComponentRegistry,
392}
393
394impl AsterRuntime<()> {
395    /// Creates a runtime builder.
396    pub fn builder() -> AsterRuntimeBuilder<()> {
397        AsterRuntimeBuilder::new()
398    }
399}
400
401impl<S> AsterRuntime<S>
402where
403    S: Future,
404{
405    /// Runs startup, the main service, before-shutdown hooks, and component shutdown.
406    ///
407    /// When a required startup phase aborts, component shutdown phases still run
408    /// best-effort before the startup error is returned, so components that already
409    /// completed startup (connection pools, spawned workers, buffered writers) get
410    /// a chance to release their resources.
411    pub async fn run(mut self) -> Result<S::Output, AsterRuntimeError> {
412        let startup_report = self.registry.startup().await;
413        if startup_report.aborted() {
414            let shutdown_report = self.registry.shutdown().await;
415            log_shutdown_report(&shutdown_report);
416            return Err(AsterRuntimeError::Startup {
417                report: startup_report,
418            });
419        }
420
421        let _signal_task =
422            spawn_termination_signal_handler(self.shutdown_token, self.stop_on_signal);
423
424        let output = self.service.await;
425        tracing::info!("service stopped");
426
427        (self.before_shutdown)().await;
428
429        let report = self.registry.shutdown().await;
430        log_shutdown_report(&report);
431
432        Ok(output)
433    }
434}
435
436/// Builder for [`AsterRuntime`].
437pub struct AsterRuntimeBuilder<S = ()> {
438    service: Option<RuntimeServiceParts<S>>,
439    shutdown_token: CancellationToken,
440    before_shutdown: RuntimeHook,
441    components: Vec<RuntimeComponentRegistration>,
442    assembly_error: Option<AsterRuntimeError>,
443}
444
445impl AsterRuntimeBuilder<()> {
446    fn new() -> Self {
447        Self {
448            service: None,
449            shutdown_token: CancellationToken::new(),
450            before_shutdown: empty_runtime_hook(),
451            components: Vec::new(),
452            assembly_error: None,
453        }
454    }
455}
456
457impl<S> AsterRuntimeBuilder<S> {
458    /// Returns the runtime-owned shutdown token.
459    ///
460    /// This accessor is primarily for shared component crates that implement
461    /// [`AsterRuntimeComponent`] and need to spawn work using the same token the runtime cancels
462    /// when the process receives a termination signal.
463    pub fn shutdown_token(&self) -> &CancellationToken {
464        &self.shutdown_token
465    }
466
467    /// Adds one runtime component.
468    pub fn component<C>(self, component: C) -> C::Output
469    where
470        C: AsterRuntimeComponent<S>,
471    {
472        component.apply(self)
473    }
474
475    /// Registers a product hook that runs after the service future stops and before components.
476    pub fn before_shutdown<F, Fut>(mut self, before_shutdown: F) -> Self
477    where
478        F: FnOnce() -> Fut + Send + 'static,
479        Fut: Future<Output = ()> + Send + 'static,
480    {
481        self.before_shutdown = Box::new(move || Box::pin(before_shutdown()));
482        self
483    }
484
485    /// Builds the runtime runner.
486    pub fn build(self) -> Result<AsterRuntime<S>, AsterRuntimeError> {
487        if let Some(error) = self.assembly_error {
488            return Err(error);
489        }
490
491        let Some(service) = self.service else {
492            return Err(AsterRuntimeError::MissingService);
493        };
494
495        let mut registry = RuntimeComponentRegistry::new();
496        for component in self.components {
497            component(&mut registry);
498        }
499        registry
500            .validate()
501            .map_err(|source| AsterRuntimeError::ComponentGraph { source })?;
502
503        Ok(AsterRuntime {
504            service: service.service,
505            shutdown_token: service.shutdown_token,
506            stop_on_signal: service.stop_on_signal,
507            before_shutdown: self.before_shutdown,
508            registry,
509        })
510    }
511}
512
513impl<S> AsterRuntimeBuilder<S>
514where
515    S: Future,
516{
517    /// Builds and runs the runtime.
518    pub async fn run(self) -> Result<S::Output, AsterRuntimeError> {
519        self.build()?.run().await
520    }
521}
522
523impl<S> ServiceLifecycle<S>
524where
525    S: Future,
526{
527    /// Runs the service future and product cleanup hooks.
528    pub async fn run<Stop, StopFut, AfterStop, AfterStopFut>(
529        self,
530        stop_on_signal: Stop,
531        after_stop: AfterStop,
532    ) -> S::Output
533    where
534        Stop: FnOnce() -> StopFut + Send + 'static,
535        StopFut: Future<Output = ()> + Send + 'static,
536        AfterStop: FnOnce() -> AfterStopFut,
537        AfterStopFut: Future<Output = ()>,
538    {
539        let _signal_task = spawn_termination_signal_handler(self.shutdown_token, stop_on_signal);
540
541        let server_result = self.server.await;
542        tracing::info!("server stopped");
543        after_stop().await;
544        server_result
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use std::sync::{
551        Arc,
552        atomic::{AtomicBool, Ordering},
553    };
554
555    use super::{AsterRuntime, AsterRuntimeError, RuntimeServiceComponent, ServiceLifecycle};
556    use crate::{RuntimeComponentBundle, RuntimeComponentKind, RuntimeComponentRegistry};
557
558    #[tokio::test]
559    async fn lifecycle_runs_after_stop_and_returns_server_result() {
560        let after_stop_ran = Arc::new(AtomicBool::new(false));
561        let observed_after_stop = Arc::clone(&after_stop_ran);
562
563        let result = ServiceLifecycle::new(async { Ok::<_, &'static str>(42) }, Default::default())
564            .run(
565                || async {},
566                move || {
567                    let observed_after_stop = Arc::clone(&observed_after_stop);
568                    async move {
569                        observed_after_stop.store(true, Ordering::SeqCst);
570                    }
571                },
572            )
573            .await;
574
575        assert_eq!(result, Ok(42));
576        assert!(after_stop_ran.load(Ordering::SeqCst));
577    }
578
579    struct TestShutdownComponent {
580        events: Arc<std::sync::Mutex<Vec<&'static str>>>,
581    }
582
583    impl RuntimeComponentBundle for TestShutdownComponent {
584        fn register(self, registry: &mut RuntimeComponentRegistry) {
585            registry.component_shutdown(
586                "test",
587                RuntimeComponentKind::Product,
588                "test_shutdown",
589                None,
590                move || {
591                    let events = Arc::clone(&self.events);
592                    async move {
593                        events.lock().unwrap().push("component");
594                        Ok(())
595                    }
596                },
597            );
598        }
599    }
600
601    #[tokio::test]
602    async fn aster_runtime_runs_before_shutdown_and_registered_components() {
603        let events = Arc::new(std::sync::Mutex::new(Vec::new()));
604        let before_events = Arc::clone(&events);
605        let component_events = Arc::clone(&events);
606
607        let result = AsterRuntime::builder()
608            .component(RuntimeServiceComponent::new(
609                "http",
610                RuntimeComponentKind::Core,
611                async { Ok::<_, &'static str>(7) },
612                Default::default(),
613                || async {},
614            ))
615            .before_shutdown(move || {
616                let before_events = Arc::clone(&before_events);
617                async move {
618                    before_events.lock().unwrap().push("before");
619                }
620            })
621            .component(crate::runtime_component(TestShutdownComponent {
622                events: component_events,
623            }))
624            .run()
625            .await
626            .expect("runtime should run");
627
628        assert_eq!(result, Ok(7));
629        assert_eq!(events.lock().unwrap().as_slice(), ["before", "component"]);
630    }
631
632    #[tokio::test]
633    async fn aster_runtime_runs_component_shutdown_when_startup_aborts() {
634        let events = Arc::new(std::sync::Mutex::new(Vec::new()));
635        let started_events = Arc::clone(&events);
636        let shutdown_events = Arc::clone(&events);
637
638        let result = AsterRuntime::builder()
639            .component(RuntimeServiceComponent::new(
640                "http",
641                RuntimeComponentKind::Core,
642                std::future::pending::<()>(),
643                Default::default(),
644                || async {},
645            ))
646            .component(crate::runtime_component(
647                move |registry: &mut RuntimeComponentRegistry| {
648                    registry.component_startup(
649                        "database",
650                        RuntimeComponentKind::Core,
651                        "database_connect",
652                        crate::StartupPhaseFailurePolicy::Required,
653                        move || {
654                            let started_events = Arc::clone(&started_events);
655                            async move {
656                                started_events.lock().unwrap().push("database_started");
657                                Ok(())
658                            }
659                        },
660                    );
661                    registry.component_shutdown(
662                        "database",
663                        RuntimeComponentKind::Core,
664                        "database_close",
665                        None,
666                        move || {
667                            let shutdown_events = Arc::clone(&shutdown_events);
668                            async move {
669                                shutdown_events.lock().unwrap().push("database_shutdown");
670                                Ok(())
671                            }
672                        },
673                    );
674                    registry.component_startup(
675                        "cache",
676                        RuntimeComponentKind::Core,
677                        "cache_connect",
678                        crate::StartupPhaseFailurePolicy::Required,
679                        || async { Err("cache unavailable".to_string()) },
680                    );
681                },
682            ))
683            .run()
684            .await;
685
686        assert!(matches!(result, Err(AsterRuntimeError::Startup { .. })));
687        // The component that completed startup must still receive its shutdown phase,
688        // and the never-started service future must stay untouched.
689        assert_eq!(
690            events.lock().unwrap().as_slice(),
691            ["database_started", "database_shutdown"]
692        );
693    }
694
695    #[tokio::test]
696    async fn aster_runtime_builder_shares_shutdown_token_with_components() {
697        let observed = Arc::new(AtomicBool::new(false));
698        let observed_component = Arc::clone(&observed);
699
700        let result = AsterRuntime::builder()
701            .component(crate::runtime_component_with_shutdown(|shutdown| {
702                let component_token = shutdown.clone();
703                RuntimeServiceComponent::new(
704                    "http",
705                    RuntimeComponentKind::Core,
706                    async move {
707                        component_token.cancel();
708                        Ok::<_, &'static str>(())
709                    },
710                    shutdown,
711                    || async {},
712                )
713            }))
714            .component(crate::runtime_component_with_shutdown(|shutdown| {
715                crate::runtime_component(move |registry: &mut RuntimeComponentRegistry| {
716                    registry.component_shutdown(
717                        "observer",
718                        RuntimeComponentKind::Product,
719                        "observe_shared_shutdown",
720                        None,
721                        move || {
722                            let observed_component = Arc::clone(&observed_component);
723                            let shutdown = shutdown.clone();
724                            async move {
725                                observed_component.store(shutdown.is_cancelled(), Ordering::SeqCst);
726                                Ok(())
727                            }
728                        },
729                    );
730                })
731            }))
732            .run()
733            .await
734            .expect("runtime should run");
735
736        assert_eq!(result, Ok(()));
737        assert!(observed.load(Ordering::SeqCst));
738    }
739
740    #[test]
741    fn aster_runtime_requires_service_component() {
742        let result = AsterRuntime::builder().build();
743        assert!(matches!(result, Err(AsterRuntimeError::MissingService)));
744    }
745
746    #[test]
747    fn aster_runtime_rejects_duplicate_service_components() {
748        let result = AsterRuntime::builder()
749            .component(RuntimeServiceComponent::new(
750                "http",
751                RuntimeComponentKind::Core,
752                async {},
753                Default::default(),
754                || async {},
755            ))
756            .component(RuntimeServiceComponent::new(
757                "worker",
758                RuntimeComponentKind::Core,
759                async {},
760                Default::default(),
761                || async {},
762            ))
763            .build();
764
765        assert!(matches!(result, Err(AsterRuntimeError::DuplicateService)));
766    }
767
768    #[test]
769    fn aster_runtime_rejects_invalid_component_graph() {
770        let result = AsterRuntime::builder()
771            .component(RuntimeServiceComponent::new(
772                "http",
773                RuntimeComponentKind::Core,
774                async {},
775                Default::default(),
776                || async {},
777            ))
778            .component(crate::runtime_component(
779                |registry: &mut RuntimeComponentRegistry| {
780                    registry.component("database").depends_on("missing");
781                },
782            ))
783            .build();
784
785        assert!(matches!(
786            result,
787            Err(AsterRuntimeError::ComponentGraph {
788                source: crate::RuntimeComponentGraphError::MissingDependency {
789                    component: "database",
790                    dependency: "missing"
791                }
792            })
793        ));
794    }
795
796    #[test]
797    fn shutdown_resource_component_registers_dependencies_and_shutdown() {
798        let registry = RuntimeComponentRegistry::configured(|registry| {
799            crate::shutdown_resource_component_after(
800                "mail_outbox",
801                RuntimeComponentKind::Mail,
802                "mail_outbox_drain",
803                &["background_tasks"],
804                42_u8,
805                |_| async { Ok(()) },
806            )
807            .register(registry);
808        });
809
810        let descriptor = registry
811            .descriptor("mail_outbox")
812            .expect("shutdown resource component should be registered");
813        assert_eq!(descriptor.kind, RuntimeComponentKind::Mail);
814        assert_eq!(descriptor.dependencies, vec!["background_tasks"]);
815        assert_eq!(
816            descriptor
817                .shutdown
818                .first()
819                .expect("shutdown phase should be registered")
820                .phase_name,
821            "mail_outbox_drain"
822        );
823    }
824}