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::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
35/// Keeps the internal signal listener owned by its service run, including cancellation paths.
36struct 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        // Once the service has stopped, its signal callback has no remaining stop work to own.
47        // Join cancellation before component cleanup can release the callback's dependencies.
48        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
57/// Runs one service future with shared Aster shutdown mechanics.
58///
59/// This lower-level helper exists for tests and uncommon one-off runners. New
60/// product entrypoints should prefer [`AsterRuntime::builder`] and register the
61/// main service through [`RuntimeServiceComponent`].
62pub struct ServiceLifecycle<S> {
63    server: S,
64    shutdown_token: CancellationToken,
65}
66
67impl<S> ServiceLifecycle<S> {
68    /// Creates a lifecycle runner for `server` and `shutdown_token`.
69    pub fn new(server: S, shutdown_token: CancellationToken) -> Self {
70        Self {
71            server,
72            shutdown_token,
73        }
74    }
75}
76
77/// Component that provides the main runtime service future.
78///
79/// Exactly one runtime service component must be registered. HTTP products
80/// usually expose a small `http_component(...)` constructor that builds this
81/// value from an Actix `Server`, its `ServerHandle`, and the shared shutdown
82/// token.
83pub 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    /// Creates a service component with a product-provided stop hook.
93    /// The hook only stops the service; the runtime cancels it after the service future completes.
94    /// Put resource cleanup in component shutdown phases so it runs after listener join.
95    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
130/// Runtime component adapter used by [`AsterRuntimeBuilder::component`].
131///
132/// Service components can change the builder's service future type. Registry
133/// components keep the current builder type and only add lifecycle descriptors
134/// and hooks.
135pub trait AsterRuntimeComponent<S> {
136    /// Builder type returned after this component is applied.
137    type Output;
138
139    /// Applies the component to the runtime builder.
140    fn apply(self, builder: AsterRuntimeBuilder<S>) -> Self::Output;
141}
142
143/// Wrapper for components that only register runtime descriptors and hooks.
144pub struct RuntimeComponentBundleRegistration<B> {
145    bundle: B,
146}
147
148/// Component factory that receives the runtime's shared shutdown token.
149pub struct RuntimeComponentWithShutdown<C, F> {
150    build: F,
151    _component: PhantomData<fn() -> C>,
152}
153
154/// Fallible component factory that receives the runtime's shared shutdown token.
155pub struct TryRuntimeComponentWithShutdown<C, F, E> {
156    build: F,
157    _component: PhantomData<fn() -> C>,
158    _error: PhantomData<fn() -> E>,
159}
160
161/// Adapts a [`RuntimeComponentBundle`] for [`AsterRuntimeBuilder::component`].
162pub const fn runtime_component<B>(bundle: B) -> RuntimeComponentBundleRegistration<B> {
163    RuntimeComponentBundleRegistration { bundle }
164}
165
166/// Builds one runtime component from the runtime's shared shutdown token.
167///
168/// Use this when a product component needs the same token that `AsterRuntime`
169/// cancels on termination signals, for example an HTTP server, config reload
170/// subscription, or background worker group.
171pub 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
181/// Fallible variant of [`runtime_component_with_shutdown`].
182pub 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
195/// Runtime component for one shutdown-only owned resource.
196///
197/// This adapter is useful for product-owned resources whose lifecycle is
198/// otherwise simple: declare a component, optional dependencies, and a shutdown
199/// phase that consumes the resource exactly once. Product crates still own the
200/// resource type and the shutdown closure; Forge owns the component boilerplate.
201pub 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    /// Creates a shutdown-only resource component.
212    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    /// Declares components that must shut down before this resource.
234    #[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
262/// Creates a shutdown-only resource component registration.
263pub 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
284/// Creates a shutdown-only resource component registration with dependencies.
285pub 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/// Error returned when runtime assembly or startup/shutdown lifecycle work fails.
379#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
380pub enum AsterRuntimeError {
381    /// No service component was registered.
382    #[error("runtime requires exactly one service component")]
383    MissingService,
384    /// More than one service component was registered.
385    #[error("runtime only supports one service component")]
386    DuplicateService,
387    /// Runtime component dependencies cannot be resolved.
388    #[error("runtime component graph is invalid: {source}")]
389    ComponentGraph {
390        /// Underlying component graph validation error.
391        source: crate::RuntimeComponentGraphError,
392    },
393    /// A required startup phase aborted runtime startup.
394    #[error("runtime startup aborted by a required component phase")]
395    Startup {
396        /// Startup report that contains the failing phase.
397        report: crate::StartupReport,
398        /// Cleanup report, including any secondary drain or dependency-close failures.
399        shutdown_report: crate::ShutdownReport,
400    },
401    /// The main service panicked. Product hooks and component cleanup still ran.
402    #[error("runtime service panicked")]
403    Service {
404        /// Cleanup report, including secondary drain or dependency-close failures.
405        shutdown_report: crate::ShutdownReport,
406        /// Whether the product before-shutdown hook also panicked.
407        before_shutdown_panicked: bool,
408    },
409    /// The product before-shutdown hook panicked. Component cleanup still completed.
410    #[error("runtime before-shutdown hook panicked")]
411    BeforeShutdown {
412        /// Cleanup report, including secondary drain or dependency-close failures.
413        shutdown_report: crate::ShutdownReport,
414    },
415    /// One or more shutdown phases failed or timed out. All remaining phases still ran.
416    #[error("runtime shutdown completed with failed component phases")]
417    Shutdown {
418        /// Complete ordered report, including failures and dependency cleanup results.
419        report: crate::ShutdownReport,
420    },
421}
422
423/// Product-facing runtime runner built from service and component registrations.
424///
425/// Product entrypoints should register the main service as a component, then
426/// register database, task, mail, product, or shutdown bundles in the same
427/// chain:
428///
429/// ```ignore
430/// AsterRuntime::builder()
431///     .component(http_component(...))
432///     .component(database_component(...))
433///     .component(task_component(...))
434///     .run()
435///     .await?;
436/// ```
437pub 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    /// Creates a runtime builder.
448    #[must_use]
449    pub fn builder() -> AsterRuntimeBuilder<()> {
450        AsterRuntimeBuilder::new()
451    }
452}
453
454impl<S> AsterRuntime<S>
455where
456    S: Future,
457{
458    /// Runs startup, the main service, before-shutdown hooks, and component shutdown.
459    /// The owned signal listener is cancelled and joined after the service stops, before cleanup.
460    ///
461    /// When a required startup phase aborts, component shutdown phases still run
462    /// best-effort before the startup error is returned, so components that already
463    /// completed startup (connection pools, spawned workers, buffered writers) get
464    /// a chance to release their resources.
465    ///
466    /// # Errors
467    ///
468    /// Returns a startup report error when a required startup phase aborts, or a shutdown
469    /// report error after all cleanup phases run if any phase fails or times out. A panicking
470    /// before-shutdown hook also returns an error after component cleanup. Best-effort product
471    /// hooks should handle their own errors and return success explicitly.
472    /// Service panics become [`AsterRuntimeError::Service`] after cleanup, preserving secondary
473    /// hook and shutdown failures without exposing the panic payload. Ordinary service outputs
474    /// (including product `Result::Err` values) are returned unchanged when cleanup succeeds.
475    /// This also covers destruction of the service future after it finishes.
476    ///
477    /// The shared and service cancellation tokens are cancelled before cleanup on every exit.
478    /// Dropping a polled run future also cancels these tokens, but cannot await cleanup; callers
479    /// must let `run` complete to guarantee asynchronous drain and worker joins.
480    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        // Own the await inside the boundary so destruction of a completed service future is
501        // covered as well as its poll. Destruction after catch_unwind would bypass cleanup.
502        #[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        // Workers may wait only for cancellation. Wake them before any hook tries to join them,
510        // including when the service ends without a process signal or unwinds unexpectedly.
511        self.shutdown_token.cancel();
512        self.service_shutdown_token.cancel();
513        signal_task.cancel_and_join().await;
514        tracing::info!("service stopped");
515
516        // The product hook precedes audit drain. Isolate both construction and polling so a
517        // panic cannot skip writer join or database close, and retain secondary cleanup errors.
518        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
544/// Builder for [`AsterRuntime`].
545pub 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    /// Returns the runtime-owned shutdown token.
567    ///
568    /// This accessor is primarily for shared component crates that implement
569    /// [`AsterRuntimeComponent`] and need to spawn work using the same token the runtime cancels
570    /// on termination signals, service completion, startup abort, or cancellation of a polled run.
571    pub fn shutdown_token(&self) -> &CancellationToken {
572        &self.shutdown_token
573    }
574
575    /// Adds one runtime component.
576    pub fn component<C>(self, component: C) -> C::Output
577    where
578        C: AsterRuntimeComponent<S>,
579    {
580        component.apply(self)
581    }
582
583    /// Registers a product hook that runs after the service future stops and before components.
584    #[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    /// Builds the runtime runner.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error when component assembly previously failed, no service was registered, or
599    /// the component dependency graph is invalid.
600    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            // Component factories receive this token even when the service was constructed
622            // with a separate token. Keep both domains notified rather than replacing the root.
623            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    /// Builds and runs the runtime.
640    ///
641    /// # Errors
642    ///
643    /// Returns assembly or startup/shutdown lifecycle errors.
644    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    /// Runs the service future and product cleanup hooks.
654    /// Cancels and joins its signal listener before invoking `after_stop`; dropping this run
655    /// future also aborts the listener so it cannot retain resources of an abandoned service.
656    /// Cancellation is signalled before cleanup, including service panic and run cancellation.
657    ///
658    /// # Panics
659    /// Service panics are resumed with their original payload after `after_stop`. If only cleanup
660    /// panics, its panic is resumed; if both panic, the original service panic takes precedence.
661    /// Destruction of the completed service future is part of this boundary.
662    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        // Catch construction as well as polling, and retain the primary panic on double failure.
690        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        // The component that completed startup must still receive its shutdown phase,
851        // and the never-started service future must stay untouched.
852        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}