arcature 2026.2.1

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! The health-managed serve path — lifecycle state machine + health endpoints
//! + coordinated drain (AP2.1-10).
//!
//! [`Application::serve_with_health`] is the production lifecycle entry
//! point. It is the testable analogue of [`Application::run_with_health`]:
//! it runs the same startup → state → serve → drain → shutdown sequence
//! but accepts a pre-bound listener and a caller-provided shutdown signal,
//! so integration tests drive the lifecycle deterministically (e.g. with a
//! `oneshot` channel) without sending OS signals.
//!
//! # Lifecycle
//!
//! 1. **Startup** — `startup()` connects configured subsystems. The
//!    [`Lifecycle`] starts in [`LifecycleState::Starting`].
//! 2. **State** — `state_fn(&resources, &lifecycle)` builds the `AppState`
//!    from the live handles AND receives the [`Lifecycle`] so it can register
//!    readiness checks and drain hooks (the realtime lane, jobs, a warmup
//!    query). The application may also clone the lifecycle into `AppState`.
//! 3. **Ready** — the engine marks the lifecycle
//!    [`LifecycleState::Ready`]. Readiness becomes true iff the
//!    application-registered checks pass; `/up/ready` returns 200.
//! 4. **Serve** — the engine assembles the pipeline (with the health router
//!    merged in and the lifecycle layered on) and serves until
//!    `shutdown_signal` resolves.
//! 5. **Drain** — on the signal, the engine calls `lifecycle.begin_drain()`
//!    (readiness → 503 immediately, before intake stops), then triggers
//!    axum graceful shutdown (stop new intake, drain in-flight HTTP), and
//!    after the HTTP drain runs the registered [`DrainHook`]s (Realtime/jobs
//!    close long-lived connections).
//! 6. **Shutdown** — the engine's `shutdown()` tears down subsystem
//!    resources in reverse startup order; then `lifecycle.mark_stopped()`.
//!
//! This is additive over [`serve_with_lifecycle`]: callers that want only
//! the subsystem lifecycle (no health endpoints, no drain hooks) still use
//! that path. `serve_with_health` is the production default.
//!
//! Gated by the `macros` feature (tokio `TcpListener` / `signal`) plus at
//! least one lifecycle subsystem, matching `serve_with_lifecycle`. The
//! health router and the [`Lifecycle`] type themselves are feature-free
//! (pure `std`), so an expert user on a custom runtime can build a
//! `Lifecycle`, mount [`crate::health::router`], and drive their own
//! shutdown.

use std::future::Future;

use crate::application::Result;
use crate::application::lifecycle::Lifecycle;
use crate::application::shutdown;
use crate::application::startup;
use crate::application::ty::Application;
use crate::health;
use crate::pipeline::assemble::into_service;

impl<S> Application<S>
where
    S: Clone + Send + Sync + 'static,
{
    /// Serve with the full production lifecycle: subsystem startup, health
    /// endpoints, readiness gates, graceful drain, and shutdown.
    ///
    /// This is the production default and the testable entry point. It runs
    /// the same lifecycle as [`run_with_health`](Self::run_with_health) but
    /// accepts a pre-bound `listener` and a caller-provided
    /// `shutdown_signal`, so integration tests drive the lifecycle
    /// deterministically.
    ///
    /// The `state_fn` receives `&Resources` **and** `&Lifecycle`: build the
    /// `AppState` from the live handles, register readiness checks
    /// (`lifecycle.register_readiness(...)`), and register drain hooks
    /// (`lifecycle.register_drain_hook(...)`) inside it. The application
    /// may also clone the lifecycle into `AppState`.
    ///
    /// # Errors
    ///
    /// Returns [`EngineError::Startup`](crate::EngineError::Startup) if a
    /// subsystem fails, [`EngineError::Serve`](crate::EngineError::Serve)
    /// if the server fails, or
    /// [`EngineError::Shutdown`](crate::EngineError::Shutdown) if a
    /// subsystem fails to shut down. A serve error takes precedence over a
    /// shutdown error. Drain-hook failures are logged best-effort (the hooks
    /// are best-effort; the engine resources tear down regardless).
    pub async fn serve_with_health<L, F, Sh>(
        self,
        listener: L,
        state_fn: F,
        shutdown_signal: Sh,
    ) -> Result<()>
    where
        L: crate::axum::serve::Listener,
        L::Addr: std::fmt::Debug,
        F: Fn(&crate::application::Resources, &Lifecycle) -> S + Send + Sync + 'static,
        Sh: Future<Output = ()> + Send + 'static,
    {
        // ── 1. Startup ──────────────────────────────────────────────────
        let lifecycle = Lifecycle::new();
        let (started, resolved_app) = startup::startup(self).await?;

        // ── 2. State (the app registers readiness/drain hooks here) ──────
        let state = state_fn(&started.resources, &lifecycle);

        // ── 3. Ready (readiness true iff app checks pass) ────────────────
        lifecycle.mark_ready();

        // ── 4. Serve (health merged in, lifecycle layered on) ───────────
        let routes = resolved_app.routes.with_state(state);
        let app = Application {
            routes,
            proxy: resolved_app.proxy,
            bind_address: resolved_app.bind_address,
            port: resolved_app.port,
            #[cfg(feature = "inertia")]
            inertia_config: resolved_app.inertia_config,
            #[cfg(feature = "inertia")]
            page_contracts: resolved_app.page_contracts,
            #[cfg(feature = "pages")]
            pages: resolved_app.pages,
            #[cfg(feature = "pages")]
            maintenance_guard: resolved_app.maintenance_guard,
            #[cfg(feature = "db")]
            database: None,
            #[cfg(feature = "cache")]
            cache_config: None,
            #[cfg(feature = "storage")]
            storage_config: None,
            #[cfg(feature = "mail")]
            mail_config: None,
            #[cfg(feature = "jobs")]
            jobs_registry: None,
            #[cfg(feature = "jobs")]
            worker_config: None,
            #[cfg(feature = "dx")]
            error_mapping: resolved_app.error_mapping,
            #[cfg(feature = "dev-proxy")]
            dev_proxy_endpoint: resolved_app.dev_proxy_endpoint,
        };

        // The drain signal wrapper: on the caller's signal, flip readiness
        // false FIRST (before intake stops) so a load balancer observing
        // /up/ready stops sending traffic. axum's graceful shutdown (wired
        // below) then stops accepting and drains in-flight HTTP.
        let drain_lifecycle = lifecycle.clone();
        let drain_signal = async move {
            shutdown_signal.await;
            drain_lifecycle.begin_drain();
        };

        // Assemble the app pipeline (pre-routing proxy/observe, the router,
        // post-routing maintenance/Inertia/observe, the 404 fallback) into a
        // service. Merge the health routes in front of that service as the
        // fallback's sibling: `/up/*` routes short-circuit before the app's
        // maintenance/Inertia layers (an operator draining traffic sees the
        // real readiness, not a maintenance 503 — PROGRAM.md AP2.1-10). The
        // lifecycle is layered on as an Axum `Extension` so the health
        // handlers read it without coupling to the app state type.
        let app_service = into_service(app);
        let merged = crate::axum::Router::<()>::new()
            .merge(health::router())
            .fallback_service(app_service)
            .layer(health::lifecycle_layer(lifecycle.clone()));

        let serve_result = crate::axum::serve(listener, merged.into_make_service())
            .with_graceful_shutdown(drain_signal)
            .await
            .map_err(|source| crate::EngineError::Serve { source });

        // ── 5. Drain hooks (after HTTP drain) ───────────────────────────
        // axum's graceful shutdown has drained in-flight HTTP. Now run the
        // registered drain hooks (Realtime/jobs close long-lived
        // connections), then tear down subsystem resources. The hooks are
        // best-effort; failures are logged and the engine tears down
        // regardless (a partial drain is better than leaving connections
        // open — AGENTS.md §9).
        let hook_errors = lifecycle.run_drain_hooks().await;
        for hook_err in &hook_errors {
            eprintln!("warning: drain hook failed: {hook_err}");
        }

        // ── 6. Shutdown (subsystem teardown, reverse startup order) ─────
        let shutdown_result = shutdown::shutdown(started).await;

        // ── 7. Stopped (process about to exit) ──────────────────────────
        lifecycle.mark_stopped();

        // Surface errors: a serve error takes precedence; then shutdown.
        match serve_result {
            Ok(()) => shutdown_result,
            Err(serve_err) => {
                if let Err(ref shutdown_err) = shutdown_result {
                    eprintln!("warning: shutdown also failed after serve error: {shutdown_err}");
                }
                Err(serve_err)
            }
        }
    }
}