Skip to main content

arcly_http/
app.rs

1//! Launch contract.
2//!
3//! Boot phases (executed strictly in this order — order matters):
4//!
5//! 1. Collect inventory-registered providers + routes.
6//! 2. Run each plugin's `on_init` with a *mutable* `ArclyPluginContext` —
7//!    plugins may queue providers (`provide<T>`), routes, global
8//!    interceptors, OpenAPI mutators.
9//! 3. Apply queued provider closures to the `DiContainerBuilder`.
10//! 4. **Freeze.** The container becomes a shared `Arc<FrozenDiContainer>`,
11//!    lock-free for reads and dropped when the server stops.
12//! 5. Build the OpenAPI spec, run plugin spec-mutators, serialize it once.
13//! 6. Mount macro-registered routes, then plugin-registered routes.
14//! 7. Bind the listener.
15//! 8. Run each plugin's `on_start(&container)` — background tasks spawn here.
16//! 9. Serve. Ctrl-C / SIGTERM triggers axum's graceful shutdown — accepts
17//!    stop, in-flight drain. **Only after** that completes do plugin
18//!    `on_shutdown(&container)` calls run, each wrapped in a 5-second
19//!    per-plugin timeout so a wedged plugin can never wedge the process.
20
21use std::marker::PhantomData;
22use std::sync::Arc;
23use std::time::Duration;
24
25use axum::response::{Html, IntoResponse};
26use axum::routing::get;
27
28use arcly_http_core::core::engine::{
29    DiContainerBuilder, HttpMethod, Module, ModuleDescriptor, RouteDescriptor,
30};
31use arcly_http_core::core::plugins::{
32    build_plugin_route, ArclyPlugin, ArclyPluginContext, PluginError, PluginStage,
33};
34use arcly_http_core::openapi::{build_spec_filtered, OpenApiInfo, SWAGGER_UI_HTML};
35use arcly_http_core::web::boundary::adapt;
36#[cfg(feature = "realtime")]
37use arcly_http_realtime::realtime::gateway::GatewayDescriptor;
38#[cfg(feature = "realtime")]
39use arcly_http_realtime::realtime::{ws_route, ConnectionRegistry};
40
41/// Tunables for the launch contract. Start from `Default` and adjust:
42///
43/// ```ignore
44/// App::launch_configured::<AppModule>(addr, info, plugins, LaunchConfig {
45///     request_timeout: Duration::from_secs(10),
46///     max_in_flight: 4_096,
47///     cors: Some(CorsConfig::for_origins(["https://app.example.com"])),
48///     ..Default::default()
49/// }).await
50/// ```
51#[derive(Clone, Debug)]
52#[non_exhaustive]
53pub struct LaunchConfig {
54    /// Per-plugin budget for `on_shutdown` / start-failure rollback (and the
55    /// concurrent `on_draining` notification). A plugin exceeding it is
56    /// skipped with a logged warning — it can never wedge the process.
57    /// Default `5s`.
58    pub drain_budget: Duration,
59    /// Process-wide request deadline. Routes without `#[Timeout]` are
60    /// cancelled (worker freed) and answered `504` past this. `ZERO`
61    /// disables. Default `30s`.
62    pub request_timeout: Duration,
63    /// Hard cap on concurrent in-flight requests — beyond it requests are
64    /// shed with `503` + `Retry-After` before any body is read (one atomic
65    /// counter, no locks). `0` = unlimited. Default `0`.
66    pub max_in_flight: usize,
67    /// Request body cap for every entry point. Default 8 MiB.
68    pub max_body_bytes: usize,
69    /// Ceiling on `#[CacheTTL]` store entries — the key includes the query
70    /// string, so an unbounded store is a memory-DoS vector. Default 10 000.
71    pub cache_max_entries: usize,
72    /// How often the cache sweeper reclaims expired entries. Default `30s`.
73    pub cache_sweep_interval: Duration,
74    /// After a shutdown signal, WebSocket clients get this long to finish
75    /// before the server sends Close frames — otherwise live sockets keep
76    /// the HTTP drain (and therefore plugin `on_shutdown`) waiting until
77    /// the supervisor SIGKILLs. Default `10s`.
78    pub ws_drain_deadline: Duration,
79    /// CORS policy. `None` (default) mounts no CORS layer at all.
80    pub cors: Option<arcly_http_core::web::cors::CorsConfig>,
81    /// Programmatic shutdown trigger — notifying it behaves exactly like
82    /// receiving SIGTERM (drain flag, WS deadline, plugin hooks). Wired by
83    /// `testing::TestServer::shutdown`; production should keep `None` and
84    /// use real signals.
85    pub shutdown_trigger: Option<std::sync::Arc<tokio::sync::Notify>>,
86    /// Mount `/docs` (Swagger UI) and `/openapi.json`. Default `true`;
87    /// disable in hardened deployments that publish the spec elsewhere.
88    pub expose_docs: bool,
89    /// Per-socket outbound queue depth — the slow-client memory ceiling; a
90    /// client that can't drain it is evicted. Default `256`.
91    pub ws_outbound_buffer: usize,
92    /// Hard cap on concurrent WebSocket connections across all gateways;
93    /// beyond it upgrades get `503` before any socket exists. `0` =
94    /// unlimited. Default `0`.
95    pub ws_max_connections: usize,
96    /// Server→client Ping cadence; pongs feed the idle sweeper. `ZERO`
97    /// disables. Default `20s`.
98    pub ws_ping_interval: Duration,
99    /// Adaptive latency shedding: when the EWMA of request latency exceeds
100    /// this target, a pressure-proportional slice of traffic is shed with
101    /// `503` (capped at 90% so the EWMA can recover). `ZERO` disables.
102    /// Default `ZERO` (opt-in).
103    pub adaptive_shed_target: Duration,
104    /// Reap sockets with no inbound activity for this long — dead TCP links
105    /// (NAT drops) never send Close and would linger forever. `ZERO`
106    /// disables. Default `60s`.
107    pub ws_idle_timeout: Duration,
108}
109
110impl Default for LaunchConfig {
111    fn default() -> Self {
112        Self {
113            drain_budget: Duration::from_secs(5),
114            request_timeout: Duration::from_secs(30),
115            max_in_flight: 0,
116            max_body_bytes: 8 * 1024 * 1024,
117            cache_max_entries: 10_000,
118            cache_sweep_interval: Duration::from_secs(30),
119            ws_drain_deadline: Duration::from_secs(10),
120            adaptive_shed_target: Duration::ZERO,
121            cors: None,
122            shutdown_trigger: None,
123            expose_docs: true,
124            ws_outbound_buffer: 256,
125            ws_max_connections: 0,
126            ws_ping_interval: Duration::from_secs(20),
127            ws_idle_timeout: Duration::from_secs(60),
128        }
129    }
130}
131
132impl LaunchConfig {
133    pub fn drain_budget(mut self, v: Duration) -> Self {
134        self.drain_budget = v;
135        self
136    }
137    pub fn request_timeout(mut self, v: Duration) -> Self {
138        self.request_timeout = v;
139        self
140    }
141    pub fn max_in_flight(mut self, v: usize) -> Self {
142        self.max_in_flight = v;
143        self
144    }
145    pub fn max_body_bytes(mut self, v: usize) -> Self {
146        self.max_body_bytes = v;
147        self
148    }
149    pub fn cache_max_entries(mut self, v: usize) -> Self {
150        self.cache_max_entries = v;
151        self
152    }
153    pub fn cache_sweep_interval(mut self, v: Duration) -> Self {
154        self.cache_sweep_interval = v;
155        self
156    }
157    pub fn ws_drain_deadline(mut self, v: Duration) -> Self {
158        self.ws_drain_deadline = v;
159        self
160    }
161    pub fn adaptive_shed_target(mut self, v: Duration) -> Self {
162        self.adaptive_shed_target = v;
163        self
164    }
165    pub fn cors(mut self, v: arcly_http_core::web::cors::CorsConfig) -> Self {
166        self.cors = Some(v);
167        self
168    }
169    pub fn shutdown_trigger(mut self, v: std::sync::Arc<tokio::sync::Notify>) -> Self {
170        self.shutdown_trigger = Some(v);
171        self
172    }
173    pub fn expose_docs(mut self, v: bool) -> Self {
174        self.expose_docs = v;
175        self
176    }
177    pub fn ws_outbound_buffer(mut self, v: usize) -> Self {
178        self.ws_outbound_buffer = v;
179        self
180    }
181    pub fn ws_max_connections(mut self, v: usize) -> Self {
182        self.ws_max_connections = v;
183        self
184    }
185    pub fn ws_ping_interval(mut self, v: Duration) -> Self {
186        self.ws_ping_interval = v;
187        self
188    }
189    pub fn ws_idle_timeout(mut self, v: Duration) -> Self {
190        self.ws_idle_timeout = v;
191        self
192    }
193
194    /// Apply `ARCLY_*` environment overrides on top of the coded values —
195    /// applied automatically by the launch path so operators can retune a
196    /// deployment (incident response, load tests) without a rebuild:
197    ///
198    /// | Variable | Field |
199    /// |---|---|
200    /// | `ARCLY_REQUEST_TIMEOUT_MS` | `request_timeout` (`0` disables) |
201    /// | `ARCLY_MAX_IN_FLIGHT` | `max_in_flight` (`0` = unlimited) |
202    /// | `ARCLY_MAX_BODY_BYTES` | `max_body_bytes` |
203    /// | `ARCLY_CACHE_MAX_ENTRIES` | `cache_max_entries` |
204    /// | `ARCLY_WS_DRAIN_DEADLINE_MS` | `ws_drain_deadline` |
205    /// | `ARCLY_DRAIN_BUDGET_MS` | `drain_budget` |
206    /// | `ARCLY_EXPOSE_DOCS` | `expose_docs` (`true`/`false`/`1`/`0`) |
207    ///
208    /// Unparseable values are ignored with a warning — a typo must never
209    /// change behaviour silently or stop the boot.
210    pub fn with_env_overrides(self) -> Self {
211        self.apply_overrides(|k| std::env::var(k).ok())
212    }
213
214    /// Testable core of [`with_env_overrides`](Self::with_env_overrides).
215    pub(crate) fn apply_overrides(mut self, get: impl Fn(&str) -> Option<String>) -> Self {
216        fn parse<T: std::str::FromStr>(key: &str, raw: String) -> Option<T> {
217            match raw.parse() {
218                Ok(v) => Some(v),
219                Err(_) => {
220                    tracing::warn!(key, value = raw, "ignoring unparseable ARCLY_* override");
221                    None
222                }
223            }
224        }
225        if let Some(v) =
226            get("ARCLY_REQUEST_TIMEOUT_MS").and_then(|r| parse("ARCLY_REQUEST_TIMEOUT_MS", r))
227        {
228            self.request_timeout = Duration::from_millis(v);
229        }
230        if let Some(v) = get("ARCLY_MAX_IN_FLIGHT").and_then(|r| parse("ARCLY_MAX_IN_FLIGHT", r)) {
231            self.max_in_flight = v;
232        }
233        if let Some(v) = get("ARCLY_MAX_BODY_BYTES").and_then(|r| parse("ARCLY_MAX_BODY_BYTES", r))
234        {
235            self.max_body_bytes = v;
236        }
237        if let Some(v) =
238            get("ARCLY_CACHE_MAX_ENTRIES").and_then(|r| parse("ARCLY_CACHE_MAX_ENTRIES", r))
239        {
240            self.cache_max_entries = v;
241        }
242        if let Some(v) =
243            get("ARCLY_WS_DRAIN_DEADLINE_MS").and_then(|r| parse("ARCLY_WS_DRAIN_DEADLINE_MS", r))
244        {
245            self.ws_drain_deadline = Duration::from_millis(v);
246        }
247        if let Some(v) =
248            get("ARCLY_DRAIN_BUDGET_MS").and_then(|r| parse("ARCLY_DRAIN_BUDGET_MS", r))
249        {
250            self.drain_budget = Duration::from_millis(v);
251        }
252        if let Some(v) =
253            get("ARCLY_WS_OUTBOUND_BUFFER").and_then(|r| parse("ARCLY_WS_OUTBOUND_BUFFER", r))
254        {
255            self.ws_outbound_buffer = v;
256        }
257        if let Some(v) =
258            get("ARCLY_WS_MAX_CONNECTIONS").and_then(|r| parse("ARCLY_WS_MAX_CONNECTIONS", r))
259        {
260            self.ws_max_connections = v;
261        }
262        if let Some(v) =
263            get("ARCLY_WS_PING_INTERVAL_MS").and_then(|r| parse("ARCLY_WS_PING_INTERVAL_MS", r))
264        {
265            self.ws_ping_interval = Duration::from_millis(v);
266        }
267        if let Some(v) =
268            get("ARCLY_WS_IDLE_TIMEOUT_MS").and_then(|r| parse("ARCLY_WS_IDLE_TIMEOUT_MS", r))
269        {
270            self.ws_idle_timeout = Duration::from_millis(v);
271        }
272        if let Some(v) = get("ARCLY_ADAPTIVE_SHED_TARGET_MS")
273            .and_then(|r| parse("ARCLY_ADAPTIVE_SHED_TARGET_MS", r))
274        {
275            self.adaptive_shed_target = Duration::from_millis(v);
276        }
277        if let Some(raw) = get("ARCLY_EXPOSE_DOCS") {
278            match raw.as_str() {
279                "true" | "1" => self.expose_docs = true,
280                "false" | "0" => self.expose_docs = false,
281                _ => tracing::warn!(value = raw, "ignoring unparseable ARCLY_EXPOSE_DOCS"),
282            }
283        }
284        self
285    }
286}
287
288pub struct App;
289
290impl App {
291    pub async fn launch<RootMod: Module>(addr: &str) -> std::io::Result<()> {
292        let info = OpenApiInfo::new("arcly-http service", env!("CARGO_PKG_VERSION"));
293        Self::launch_with_info::<RootMod>(addr, info).await
294    }
295
296    pub async fn launch_named<RootMod: Module>(
297        addr: &str,
298        title: &'static str,
299        version: &'static str,
300    ) -> std::io::Result<()> {
301        let info = OpenApiInfo::new(title, version);
302        Self::launch_with_info::<RootMod>(addr, info).await
303    }
304
305    pub async fn launch_with_info<RootMod: Module>(
306        addr: &str,
307        info: OpenApiInfo,
308    ) -> std::io::Result<()> {
309        Self::launch_with_plugins::<RootMod>(addr, info, Vec::new()).await
310    }
311
312    /// Full launch contract with plugins. See the module docstring for the
313    /// strict phase ordering.
314    pub async fn launch_with_plugins<RootMod: Module>(
315        addr: &str,
316        info: OpenApiInfo,
317        plugins: Vec<Box<dyn ArclyPlugin>>,
318    ) -> std::io::Result<()> {
319        Self::launch_configured::<RootMod>(addr, info, plugins, LaunchConfig::default()).await
320    }
321
322    /// `launch_with_plugins` with explicit [`LaunchConfig`] tunables.
323    pub async fn launch_configured<RootMod: Module>(
324        addr: &str,
325        info: OpenApiInfo,
326        plugins: Vec<Box<dyn ArclyPlugin>>,
327        config: LaunchConfig,
328    ) -> std::io::Result<()> {
329        let listener = tokio::net::TcpListener::bind(addr).await?;
330        Self::launch_on_listener::<RootMod>(listener, info, plugins, config).await
331    }
332
333    /// `launch_configured` over a pre-bound listener. This is how test
334    /// harnesses dodge the bind-the-port-twice race: the port is yours from
335    /// the moment you bind it, with no release-and-rebind window for a
336    /// parallel launch to steal it. Production code normally passes an
337    /// address string instead.
338    pub async fn launch_on_listener<RootMod: Module>(
339        listener: tokio::net::TcpListener,
340        info: OpenApiInfo,
341        mut plugins: Vec<Box<dyn ArclyPlugin>>,
342        config: LaunchConfig,
343    ) -> std::io::Result<()> {
344        let _root: PhantomData<RootMod> = PhantomData;
345        // ARCLY_* env vars override coded values — retune without a rebuild.
346        let config = config.with_env_overrides();
347        tracing::info!(
348            request_timeout = ?config.request_timeout,
349            max_in_flight = config.max_in_flight,
350            max_body_bytes = config.max_body_bytes,
351            expose_docs = config.expose_docs,
352            "launch config (effective)"
353        );
354
355        // ── 0. Walk the module DAG from RootMod ────────────────────
356        let reachable_modules = collect_reachable_modules(RootMod::descriptor());
357        let allowed_controllers: std::collections::HashSet<&'static str> = reachable_modules
358            .iter()
359            .flat_map(|m| m.controllers.iter().copied())
360            .collect();
361
362        // ── 1. Providers from reachable modules only ───────────────
363        let mut b = DiContainerBuilder::new();
364        for m in &reachable_modules {
365            for p in m.providers {
366                b.add_provider(p);
367            }
368        }
369
370        // ── 2. Plugin on_init — they may queue providers + routes ──
371        let mut plugin_ctx = ArclyPluginContext::new();
372        for p in plugins.iter_mut() {
373            plugin_ctx.current_plugin = p.name();
374            if let Err(e) = p.on_init(&mut plugin_ctx).await {
375                return Err(plugin_io_err(e));
376            }
377        }
378
379        // ── 3. Apply queued provider closures ──────────────────────
380        for f in plugin_ctx.pending_providers.drain(..) {
381            f(&mut b);
382        }
383
384        // ── 4. Freeze the container — `&'static`, lock-free reads ──
385        // The dynamic route table goes in first so services and plugins can
386        // `Inject<DynamicRouteTable>` and mount `/_plugins/*` routes at runtime.
387        b.register(arcly_http_core::web::dynamic::DynamicRouteTable::new());
388        // Per-app body cap rides the frozen container (lock-free probe) —
389        // a process-global knob would let concurrent apps clobber each other.
390        b.register(arcly_http_core::web::boundary::BodyLimit(
391            config.max_body_bytes,
392        ));
393        let container = b.freeze();
394
395        // ── 5. OpenAPI: build (scoped to the module DAG), then mutate ──
396        let mut spec_value = build_spec_filtered(&info, Some(&allowed_controllers));
397        for mutator in plugin_ctx.openapi_mutators.drain(..) {
398            mutator(&mut spec_value);
399        }
400        // Serialize ONCE into a refcounted `Bytes` — /openapi.json then serves
401        // a cheap clone (refcount bump) per request instead of re-serializing a
402        // multi-MB Value, and the buffer drops with the server (no leak).
403        let spec_bytes: bytes::Bytes = serde_json::to_vec(&spec_value)
404            .unwrap_or_else(|e| {
405                // Spec is built from our own serde_json::Value — failure
406                // here is a framework bug; fail at boot, not per-request.
407                panic!("Arcly: OpenAPI spec serialization failed: {e}")
408            })
409            .into();
410
411        // ── 6. Mount routes (filtered by reachable controller set) ─
412        // Plugin-registered global interceptors wrap every mounted route;
413        // leak the list so route closures can hold a `&'static` slice.
414        let globals: &'static [&'static dyn arcly_http_core::web::interceptors::Interceptor] =
415            Box::leak(std::mem::take(&mut plugin_ctx.global_interceptors).into_boxed_slice());
416        // Boundary filters run before the body is read, on every entry point.
417        let filters: &'static [&'static dyn arcly_http_core::web::boundary::BoundaryFilter] =
418            Box::leak(std::mem::take(&mut plugin_ctx.boundary_filters).into_boxed_slice());
419
420        let mut router: axum::Router<Arc<arcly_http_core::core::engine::FrozenDiContainer>> =
421            axum::Router::new();
422        let mut mounted: std::collections::HashSet<(&'static str, HttpMethod)> =
423            std::collections::HashSet::new();
424        // Framework-reserved routes, mounted below — plugins may not shadow them.
425        mounted.insert(("/openapi.json", HttpMethod::GET));
426        mounted.insert(("/docs", HttpMethod::GET));
427        for rt in inventory::iter::<&'static RouteDescriptor> {
428            // Empty `controller` = free-fn route → always mount.
429            // Non-empty = must belong to a controller in the reachable DAG.
430            if !rt.controller.is_empty() && !allowed_controllers.contains(rt.controller) {
431                continue;
432            }
433            mounted.insert((rt.path, rt.method));
434            router = router.route(&axum_path(rt.path), adapt(rt, globals, filters));
435            // NestJS semantics: `#[Get("/")]` on a `/products` controller
436            // serves BOTH `/products/` and `/products` — previously the
437            // bare form 404'd. Skipped when the bare path is taken.
438            if rt.path.len() > 1 && rt.path.ends_with('/') {
439                let trimmed: &'static str = &rt.path[..rt.path.len() - 1];
440                if mounted.insert((trimmed, rt.method)) {
441                    router = router.route(&axum_path(trimmed), adapt(rt, globals, filters));
442                }
443            }
444        }
445        let mut app = router.with_state(container.clone());
446        for r in &plugin_ctx.extra_routes {
447            // Surface collisions as a clean error naming the plugin, instead
448            // of letting axum's `route()` panic deep inside launch.
449            if !mounted.insert((r.path, r.method)) {
450                return Err(plugin_io_err(PluginError::new(
451                    r.plugin,
452                    PluginStage::Init,
453                    format!(
454                        "route `{:?} {}` is already mounted by another route or plugin",
455                        r.method, r.path
456                    ),
457                )));
458            }
459            app = app.route(
460                &axum_path(r.path),
461                build_plugin_route(container.clone(), r, globals, filters),
462            );
463        }
464
465        // Runtime-mutable plugin routes: one catch-all, dispatch via ArcSwap.
466        // Interceptors are installed into the table so mounts compose their
467        // chain once, at mount time.
468        container
469            .get::<arcly_http_core::web::dynamic::DynamicRouteTable>()
470            .set_globals(globals);
471        app = app.route(
472            "/_plugins/{*rest}",
473            arcly_http_core::web::dynamic::dynamic_dispatch_route(container.clone(), filters),
474        );
475
476        // ── 6b. Mount real-time gateways (filtered by reachable module DAG) ─
477        // Only compiled when the `realtime` subsystem feature is enabled. One
478        // process-wide connection registry, leaked once to `&'static` and shared
479        // by every gateway upgrade route — sharded, lock-free on the hot path.
480        // It is conceptually process-lifetime (stored in each `WsClient`), not a
481        // per-request value, so it does not ride the request path like the
482        // container does. Kept in scope for the WS-drain step in shutdown.
483        #[cfg(feature = "realtime")]
484        let registry: &'static ConnectionRegistry = Box::leak(Box::new(ConnectionRegistry::new()));
485        #[cfg(feature = "realtime")]
486        {
487            let allowed_gateways: std::collections::HashSet<&'static str> = reachable_modules
488                .iter()
489                .flat_map(|m| m.gateways.iter().copied())
490                .collect();
491            let ws_tuning = arcly_http_realtime::realtime::ws::WsTuning::new(
492                config.ws_outbound_buffer,
493                config.ws_max_connections,
494                config.ws_ping_interval,
495            );
496            // Idle sweeper: dead TCP links (NAT drops) never send Close — reap
497            // sockets with no inbound activity past the timeout. Pings above
498            // keep healthy-but-quiet clients alive via pongs.
499            if !config.ws_idle_timeout.is_zero() {
500                let idle = config.ws_idle_timeout;
501                tokio::spawn(async move {
502                    let mut tick = tokio::time::interval(idle / 2);
503                    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
504                    loop {
505                        tick.tick().await;
506                        let reaped = registry.sweep_idle(idle.as_secs());
507                        if !reaped.is_empty() {
508                            tracing::info!(
509                                count = reaped.len(),
510                                "reaped idle WebSocket connections"
511                            );
512                        }
513                    }
514                });
515            }
516            for gd in inventory::iter::<&'static GatewayDescriptor> {
517                if !allowed_gateways.contains(gd.name) {
518                    continue;
519                }
520                let runtime = (gd.build)(container.clone());
521                app = app.route(
522                    &axum_path(gd.path),
523                    ws_route(runtime, registry, container.clone(), ws_tuning),
524                );
525            }
526        }
527
528        // Docs surface is opt-out (`expose_docs` / ARCLY_EXPOSE_DOCS) for
529        // hardened deployments. The paths stay reserved either way so a
530        // plugin can't squat them when docs are off.
531        if config.expose_docs {
532            app = app
533                .route(
534                    "/openapi.json",
535                    get(move || {
536                        let spec_bytes = spec_bytes.clone();
537                        async move {
538                            (
539                                [(axum::http::header::CONTENT_TYPE, "application/json")],
540                                spec_bytes,
541                            )
542                                .into_response()
543                        }
544                    }),
545                )
546                .route(
547                    "/docs",
548                    get(|| async { Html(SWAGGER_UI_HTML).into_response() }),
549                );
550        }
551        let mut app = app.layer(axum::middleware::from_fn(
552            arcly_http_core::web::security::apply_security_headers,
553        ));
554
555        // ── 6c. Governance layers (outermost) ──────────────────────
556        // CORS (when configured), then the governor: request-id, global
557        // deadline, in-flight admission control. Layer order: the governor
558        // is added last → wraps everything, including CORS preflights.
559        if let Some(cors_cfg) = config.cors.clone() {
560            let cors_cfg = Arc::new(cors_cfg);
561            app = app.layer(axum::middleware::from_fn(
562                move |req: axum::extract::Request, next: axum::middleware::Next| {
563                    arcly_http_core::web::cors::apply_cors(Arc::clone(&cors_cfg), req, next)
564                },
565            ));
566        }
567        let gov = arcly_http_core::web::governor::Governor::new(
568            config.request_timeout,
569            config.max_in_flight,
570            config.adaptive_shed_target,
571        );
572        let app = app.layer(axum::middleware::from_fn(
573            move |req: axum::extract::Request, next: axum::middleware::Next| {
574                arcly_http_core::web::governor::govern(Arc::clone(&gov), req, next)
575            },
576        ));
577
578        // ── 6d. Resource governance knobs + cache sweeper ──────────
579        arcly_http_core::web::cache::set_capacity(config.cache_max_entries);
580        arcly_http_core::web::cache::spawn_sweeper(config.cache_sweep_interval);
581
582        // ── 7. Bind ────────────────────────────────────────────────
583        // (already bound — handed in by the caller)
584
585        // ── 8. Plugin on_start — bg tasks spawn here ───────────────
586        //
587        // If a plugin fails, roll back already-started plugins in reverse order
588        // before propagating the error — prevents orphaned background tasks.
589        // Each rollback `on_shutdown` gets the same 5-second per-plugin budget
590        // used by the post-serve drain loop: a wedged plugin must not hang the
591        // process or starve the remaining rollbacks.
592        let plugins_arc: Arc<Vec<Box<dyn ArclyPlugin>>> = Arc::new(plugins);
593        let mut started = 0usize;
594        #[allow(clippy::explicit_counter_loop)] // counter outlives the loop for the error message
595        for p in plugins_arc.iter() {
596            if let Err(e) = p.on_start(container.clone()).await {
597                for already in plugins_arc[..started].iter().rev() {
598                    drain_plugin(
599                        already.as_ref(),
600                        container.clone(),
601                        "rollback",
602                        config.drain_budget,
603                    )
604                    .await;
605                }
606                return Err(plugin_io_err(e));
607            }
608            started += 1;
609        }
610
611        // ── 9. Serve with two-phase graceful shutdown ──────────────
612        //
613        // Phase A: SIGTERM/Ctrl-C → axum stops accepting + drains in-flight.
614        // Phase B: AFTER axum has fully drained, run plugin `on_shutdown`s
615        //          in reverse declaration order, wrapped in a 5s timeout.
616        let plugins_for_draining = Arc::clone(&plugins_arc);
617        let draining_budget = config.drain_budget;
618        #[cfg(feature = "realtime")]
619        let ws_deadline = config.ws_drain_deadline;
620        let trigger = config.shutdown_trigger.clone();
621        // Clones moved into the graceful-shutdown closure; `container` stays
622        // owned here for the post-serve `on_shutdown` loop below.
623        let container_drain = container.clone();
624        let serve = axum::serve(listener, app).with_graceful_shutdown(async move {
625            match trigger {
626                Some(n) => {
627                    tokio::select! {
628                        _ = shutdown_signal() => {}
629                        _ = n.notified() => {}
630                    }
631                }
632                None => shutdown_signal().await,
633            }
634            // First thing: flip readiness so the LB stops routing new
635            // traffic to this pod while in-flight requests drain.
636            arcly_http_core::observability::health::set_draining(true);
637            tracing::info!("shutdown signal received — HTTP draining");
638            // Live WebSockets would hold the drain open until clients leave
639            // (i.e. forever) — give them `ws_drain_deadline`, then close.
640            #[cfg(feature = "realtime")]
641            tokio::spawn(async move {
642                tokio::time::sleep(ws_deadline).await;
643                let closed = registry.close_all();
644                if closed > 0 {
645                    tracing::warn!(closed, "WS drain deadline reached — closed live sockets");
646                }
647            });
648            // Notify plugins concurrently with the HTTP drain (spawned so the
649            // listener closes immediately): stop consuming MQ/scheduler work
650            // while in-flight HTTP requests finish. Cleanup stays in
651            // `on_shutdown`, which runs only after the drain completes.
652            tokio::spawn(async move {
653                for p in plugins_for_draining.iter() {
654                    match tokio::time::timeout(
655                        draining_budget,
656                        p.on_draining(container_drain.clone()),
657                    )
658                    .await
659                    {
660                        Ok(Ok(())) => {}
661                        Ok(Err(e)) => {
662                            tracing::error!(plugin = p.name(), error = %e, "plugin draining error")
663                        }
664                        Err(_) => tracing::warn!(
665                            plugin = p.name(),
666                            budget = ?draining_budget,
667                            "plugin on_draining exceeded budget"
668                        ),
669                    }
670                }
671            });
672        });
673        let serve_res = serve.await;
674
675        // HTTP server has now fully stopped. Safe to drain plugins.
676        tracing::info!(
677            budget = ?config.drain_budget,
678            "HTTP fully drained — running plugin on_shutdown (per-plugin budget)"
679        );
680        for p in plugins_arc.iter().rev() {
681            drain_plugin(
682                p.as_ref(),
683                container.clone(),
684                "shutdown",
685                config.drain_budget,
686            )
687            .await;
688        }
689        serve_res
690    }
691}
692
693/// Walk the module `imports` DAG breadth-first from the root, deduplicating
694/// by descriptor pointer identity. Returns descriptors in a stable, root-first
695/// traversal order.
696/// Translate arcly's NestJS-style path syntax (`/users/:id`, `/files/*rest`)
697/// into axum 0.8 matcher syntax (`/users/{id}`, `/files/{*rest}`).
698/// Route descriptors keep the user syntax (OpenAPI + metrics labels read
699/// it); only the string handed to the axum router is translated, leaked
700/// once per route at boot.
701fn axum_path(path: &str) -> String {
702    path.split('/')
703        .map(|seg| {
704            if let Some(name) = seg.strip_prefix(':') {
705                format!("{{{name}}}")
706            } else if let Some(name) = seg.strip_prefix('*') {
707                format!("{{*{name}}}")
708            } else {
709                seg.to_owned()
710            }
711        })
712        .collect::<Vec<_>>()
713        .join("/")
714}
715
716fn collect_reachable_modules(root: &'static ModuleDescriptor) -> Vec<&'static ModuleDescriptor> {
717    use std::collections::HashSet;
718    let mut visited: HashSet<*const ModuleDescriptor> = HashSet::new();
719    let mut queue: std::collections::VecDeque<&'static ModuleDescriptor> =
720        std::collections::VecDeque::new();
721    let mut order: Vec<&'static ModuleDescriptor> = Vec::new();
722    queue.push_back(root);
723    while let Some(m) = queue.pop_front() {
724        if !visited.insert(m as *const _) {
725            continue;
726        }
727        order.push(m);
728        for getter in m.imports {
729            queue.push_back(getter());
730        }
731    }
732    order
733}
734
735/// Wait for either SIGINT (Ctrl-C) or SIGTERM (process supervisor / K8s / Docker).
736///
737/// On Windows only SIGINT is available; the cfg guard keeps the unix-specific
738/// import out of non-unix builds.
739#[cfg(unix)]
740async fn shutdown_signal() {
741    use tokio::signal::unix::{signal, SignalKind};
742    match signal(SignalKind::terminate()) {
743        Ok(mut sigterm) => {
744            tokio::select! {
745                _ = tokio::signal::ctrl_c() => {}
746                _ = sigterm.recv() => {}
747            }
748        }
749        Err(e) => {
750            tracing::warn!(error = %e, "SIGTERM handler unavailable, falling back to SIGINT only");
751            let _ = tokio::signal::ctrl_c().await;
752        }
753    }
754}
755
756#[cfg(not(unix))]
757async fn shutdown_signal() {
758    let _ = tokio::signal::ctrl_c().await;
759}
760
761/// Run one plugin's `on_shutdown` under a per-plugin timeout, logging (never
762/// propagating) errors and timeouts. Used for both start-failure rollback
763/// and post-serve drain so the two paths can't drift apart.
764async fn drain_plugin(
765    p: &dyn ArclyPlugin,
766    container: Arc<arcly_http_core::core::engine::FrozenDiContainer>,
767    phase: &str,
768    budget: Duration,
769) {
770    match tokio::time::timeout(budget, p.on_shutdown(container)).await {
771        Ok(Ok(())) => {}
772        Ok(Err(e)) => {
773            tracing::error!(plugin = p.name(), phase, error = %e, "plugin shutdown error")
774        }
775        Err(_) => tracing::warn!(
776            plugin = p.name(),
777            phase,
778            budget = ?budget,
779            "plugin shutdown exceeded budget — skipped"
780        ),
781    }
782}
783
784fn plugin_io_err(e: PluginError) -> std::io::Error {
785    let kind = match e.stage {
786        PluginStage::Init => std::io::ErrorKind::InvalidInput,
787        PluginStage::Start => std::io::ErrorKind::ConnectionRefused,
788        PluginStage::Shutdown => std::io::ErrorKind::Other,
789    };
790    std::io::Error::new(kind, e)
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    #[test]
798    fn env_overrides_apply_and_ignore_garbage() {
799        let cfg = LaunchConfig::default().apply_overrides(|k| match k {
800            "ARCLY_REQUEST_TIMEOUT_MS" => Some("1500".into()),
801            "ARCLY_MAX_IN_FLIGHT" => Some("notanumber".into()), // ignored, keeps default
802            "ARCLY_MAX_BODY_BYTES" => Some("1024".into()),
803            "ARCLY_EXPOSE_DOCS" => Some("false".into()),
804            _ => None,
805        });
806        assert_eq!(cfg.request_timeout, Duration::from_millis(1500));
807        assert_eq!(cfg.max_in_flight, 0, "unparseable override is ignored");
808        assert_eq!(cfg.max_body_bytes, 1024);
809        assert!(!cfg.expose_docs);
810        // Untouched fields keep coded defaults.
811        assert_eq!(cfg.drain_budget, Duration::from_secs(5));
812    }
813}