Skip to main content

larust_core/
application.rs

1use crate::{
2    debug, dev_reload, error, error_pages, lifecycle, AppError, AppPaths, AppState, Config,
3    ErrorPages, GracefulShutdown,
4};
5use axum::http::{header, HeaderName, HeaderValue, StatusCode};
6use axum::response::{IntoResponse, Response};
7use axum::Router;
8use std::any::Any;
9use std::net::SocketAddr;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use tower_http::catch_panic::CatchPanicLayer;
13use tower_http::services::ServeDir;
14use tower_http::set_header::SetResponseHeaderLayer;
15
16/// Upper bound on how long a restart-handoff replacement gets to report
17/// readiness (see `lifecycle::handoff`) before this process gives up on
18/// that attempt and keeps serving normally. Deliberately generous
19/// compared to `GracefulShutdown::drain_timeout` - a slow build/startup
20/// shouldn't fail a restart outright the way a stuck in-flight request
21/// should eventually force an exit.
22const HANDOFF_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
23
24/// Drain timeout used automatically under `LARUST_DEV_RELOAD`, when the
25/// app itself never opted into graceful shutdown explicitly - deliberately
26/// much shorter than `GracefulShutdown::default()`'s own 30s. `dev_reload`'s
27/// `/__larust_dev` endpoint is an SSE stream that never completes by
28/// design (`Never` + `KeepAlive`, forever), so a graceful drain can never
29/// finish *naturally* for it - the only thing that ever actually closes
30/// that connection is this timeout's own hard backstop
31/// (`tokio::time::sleep(drain_timeout)` → `std::process::exit(0)`,
32/// further down in `serve()`). Since the browser's reload detection is
33/// "the SSE connection dropped and reconnected," reload latency is
34/// directly bounded by whatever this constant is set to - a
35/// production-sized timeout here would make reload noticeably *slower*
36/// than the plain hard-kill behavior this replaces, the opposite of what
37/// this feature is for.
38const DEV_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
39
40/// Warns once, at startup, if [`Config::session_driver`] resolved to
41/// anything other than the two real, supported values (`"database"`,
42/// `"file"`). Originally this field didn't exist at all - `SESSION_DRIVER`
43/// was silently never read anywhere in this codebase, reported directly as
44/// a real production confusion (a `.env` value that looked like it should
45/// matter turning out to have done nothing, with no error, no log line,
46/// nothing). Once support for `"file"` was added, that exact same silent-
47/// no-op shape became newly possible again for anyone who sets
48/// `SESSION_DRIVER` to a typo, or to a Laravel value this framework
49/// doesn't implement (`"array"`, `"redis"`, `"cookie"`, ...) - so this
50/// still warns for those, even though the field itself is real now.
51/// Checking the *resolved* `Config` value (not the raw environment, unlike
52/// this function's own predecessor) is deliberate: it's the one thing that
53/// actually determines behavior, and it's already been through the exact
54/// `env_or("SESSION_DRIVER", "database")` resolution every other config
55/// field uses, so there's no separate "did the user actually set this"
56/// check to duplicate here the way `default_log_channel_to_file_if_
57/// stdio_detached` needs for its own, different reason.
58fn warn_if_session_driver_is_unsupported(config: &Config) {
59    if !is_supported_session_driver(&config.session_driver) {
60        tracing::warn!(
61            session_driver = %config.session_driver,
62            "SESSION_DRIVER is set to an unsupported value - Larust only supports \"database\" \
63             (the default) or \"file\". Falling back to \"database\". See \
64             docs/the-basics/middleware-sessions-and-csrf.md for what each one means."
65        );
66    }
67}
68
69/// The two real values [`Config::session_driver`]/`SESSION_DRIVER` accept -
70/// pulled out as its own pure function (rather than inlined into
71/// `warn_if_session_driver_is_unsupported`'s own `if`) specifically so a
72/// test can check the actual list of supported values directly, without
73/// needing to capture a `tracing::warn!` call to prove anything about it.
74/// `larust_http::session::session_layer`'s own driver dispatch is the
75/// other, independent place this same two-value split matters - kept as a
76/// literal `match` there rather than calling this (a `larust-core` type
77/// calling back into `larust-http` would be a dependency cycle), so if a
78/// third driver is ever added, both places need the identical update.
79fn is_supported_session_driver(value: &str) -> bool {
80    matches!(value, "database" | "file")
81}
82
83/// Set only on the child `xr deploy --run`'s `start_detached` spawns
84/// (`crates/larust-cli/src/deploy.rs`) - never on a plain `cargo run`/
85/// `xr dev` boot. Bare string literal, not a shared `pub const`, matching
86/// `LARUST_DEV_RELOAD`'s own precedent just above for the identical
87/// reason: a CLI-sets/core-reads marker that only ever needs to agree by
88/// name within this one workspace, not a real cross-crate API contract.
89const STDIO_DETACHED_ENV: &str = "LARUST_STDIO_DETACHED";
90
91/// Defaults [`Config::log_channel`] to `"file"` instead of its own
92/// built-in `"stdout"` default, but *only* when both are true: the user
93/// never explicitly set `LOG_CHANNEL` at all, and this process's
94/// stdout/stderr were permanently redirected to the null device by `xr
95/// deploy --run`'s `start_detached`. Closes a real, silent failure mode,
96/// reported directly from a production app: `start_detached` sets
97/// `Stdio::null()` on the very first generation's stdout/stderr (needed so
98/// the detached child doesn't print into `xr deploy`'s own terminal), and
99/// every later zero-downtime restart-handoff replacement inherits its own
100/// stdout from its immediate predecessor (`lifecycle::handoff::
101/// spawn_replacement_and_wait_for_ready`'s own `Stdio::inherit()`) - so
102/// that null redirection propagates down an app's *entire* production
103/// lifetime, the same way [`STDIO_DETACHED_ENV`] itself does (`Command`
104/// inherits its parent's environment by default, and nothing here ever
105/// clears it). With `LOG_CHANNEL` left at its default, that combination
106/// produces zero log output, ever, for as long as that app runs - not an
107/// error, just total silence, which is far more dangerous than a
108/// merely-suboptimal default: this fixes it automatically, with no action
109/// required from whoever's operating the app, rather than only warning
110/// about it (see `docs/deployment-and-desktop-apps.md`'s own warning,
111/// which still explains the mechanism for anyone who *does* want plain
112/// `stdout` and reads why they can't have it silently here).
113///
114/// Checks the *real* environment (`std::env::var`), not `config.
115/// log_channel` itself, deliberately: by the time `Config::from_value` has
116/// run, `Config`'s own built-in default and an explicit, redundant
117/// `LOG_CHANNEL=stdout` in `.env` both resolve to the identical string -
118/// only the raw environment can tell "the user never touched this" apart
119/// from "the user explicitly chose the same value the default already
120/// was," and only the former should ever be overridden here.
121fn default_log_channel_to_file_if_stdio_detached(config: &mut Config) {
122    let user_set_log_channel = std::env::var("LOG_CHANNEL").is_ok();
123    let stdio_is_detached = std::env::var_os(STDIO_DETACHED_ENV).is_some();
124    if !user_set_log_channel && stdio_is_detached {
125        config.log_channel = "file".to_string();
126    }
127}
128
129pub struct Application {
130    config: Config,
131    paths: AppPaths,
132    state: AppState,
133    router: Router,
134    graceful_shutdown: Option<GracefulShutdown>,
135    health_route: Option<String>,
136}
137
138impl Application {
139    /// Loads config, initializes logging, flips the process-wide debug flag
140    /// (`crate::debug`) that gates descriptive error pages, and publishes
141    /// config process-wide (`crate::config::config()`, used by
142    /// `larust_support::url()`/`asset()`/`config()`).
143    ///
144    /// `config` is the app's own generated `config/app.rs`'s `pub fn
145    /// config() -> serde_json::Value` (e.g. `my_app::config::app::config`) -
146    /// a plain function item, not a closure, so every real caller can
147    /// just pass the function itself. Called *after* `.env` is loaded (see
148    /// `with_paths` below), so its own `env`/`env_bool`/`env_or` calls
149    /// (`larust_support::config_env`) see whatever `.env` set.
150    ///
151    /// This does a small amount of synchronous filesystem I/O (loading
152    /// `.env`) even when called from inside an async runtime. That's
153    /// intentional: it runs once at startup, before any other async work is
154    /// scheduled, so the blocking cost is negligible - not worth the
155    /// complexity of `spawn_blocking` for a few KB of env file.
156    pub fn new(config: fn() -> serde_json::Value) -> Result<Self, AppError> {
157        Self::with_paths(AppPaths::default(), config)
158    }
159
160    /// Creates an application rooted at `root`, independent of the process
161    /// working directory. New binaries should prefer this over `new()`.
162    pub fn at_root(
163        root: impl Into<std::path::PathBuf>,
164        config: fn() -> serde_json::Value,
165    ) -> Result<Self, AppError> {
166        Self::with_paths(AppPaths::new(root), config)
167    }
168
169    fn with_paths(paths: AppPaths, config: fn() -> serde_json::Value) -> Result<Self, AppError> {
170        dotenvy::from_path(paths.env()).ok();
171        let mut config = Config::from_value(&config())?;
172        default_log_channel_to_file_if_stdio_detached(&mut config);
173        crate::logging::init(&config, &paths);
174        warn_if_session_driver_is_unsupported(&config);
175        debug::set(config.app_debug);
176        config.clone().publish();
177        paths.clone().publish();
178        let state = AppState::new(config.clone(), paths.clone());
179
180        Ok(Self {
181            config,
182            paths,
183            state,
184            router: Router::new(),
185            graceful_shutdown: None,
186            health_route: None,
187        })
188    }
189
190    /// Sets the router that will handle incoming requests.
191    pub fn router(mut self, router: Router) -> Self {
192        self.router = router;
193        self
194    }
195
196    pub fn config(&self) -> &Config {
197        &self.config
198    }
199
200    /// Explicit application state suitable for application-owned Axum state.
201    pub fn state(&self) -> AppState {
202        self.state.clone()
203    }
204
205    pub fn paths(&self) -> &AppPaths {
206        &self.paths
207    }
208
209    /// Opts into graceful shutdown: on Ctrl+C (or, on Unix, SIGTERM),
210    /// `serve()` stops accepting new connections and waits for in-flight
211    /// ones to finish (bounded by `config.drain_timeout`) before exiting,
212    /// instead of exiting instantly. See [`GracefulShutdown`]'s own doc
213    /// comment for why this is opt-in rather than the default.
214    pub fn with_graceful_shutdown(mut self, config: GracefulShutdown) -> Self {
215        self.graceful_shutdown = Some(config);
216        self
217    }
218
219    /// Registers Laravel-style health routing. Laravel applications use
220    /// `'/up'` by default, so generated Larust applications should call
221    /// `.with_health_route("/up")` during bootstrap.
222    ///
223    /// The endpoint is deliberately opt-in so applications keep control of
224    /// their route namespace. It returns `200 OK` once Larust has completed
225    /// bootstrap; a future diagnostic registry can add dependency checks
226    /// without changing the route contract.
227    pub fn with_health_route(mut self, path: impl Into<String>) -> Self {
228        let path = path.into();
229        assert!(
230            path.starts_with('/'),
231            "health route must start with '/'; received {path:?}"
232        );
233        self.health_route = Some(path);
234        self
235    }
236
237    /// Registers the app's 404/500 pages, rendered once here (not per
238    /// request - see `ErrorPages`' own doc comment) and used by every
239    /// `AppError::NotFound`/`Internal`/`Config`/caught-panic response for
240    /// the rest of the process's life. Entirely optional - `AppError`'s own
241    /// `into_response()` falls back to Larust's built-in default pages if
242    /// this is never called, so an app that skips this still gets styled
243    /// error pages, just not the option to override them.
244    pub fn with_error_pages(self, pages: ErrorPages) -> Self {
245        error_pages::set(pages);
246        self
247    }
248
249    /// Binds to `config.app_port` on localhost and serves until the process
250    /// is terminated.
251    pub async fn serve(self) -> Result<(), AppError> {
252        let addr = SocketAddr::from(([127, 0, 0, 1], self.config.app_port));
253        tracing::info!(%addr, app = %self.config.app_name, env = %self.config.app_env, "starting server");
254
255        // Set only on the child process `xr dev` spawns itself - never on
256        // a plain `cargo run`, and never touched by any generated app
257        // code (see the fuller explanation further down, at the route-
258        // mounting site that originally introduced this check).
259        let is_dev_reload = std::env::var_os("LARUST_DEV_RELOAD").is_some();
260
261        // Auto-enables graceful shutdown (short, dev-appropriate timeout)
262        // plus the restart-admin-channel specifically under `xr dev`'s own
263        // reload flag - never for a plain production app, and never
264        // overriding an app author's own explicit `.with_graceful_shutdown
265        // (...)` call (that app just keeps today's kill-based dev
266        // behavior, a documented, acceptable edge case: someone testing
267        // their own production graceful-shutdown config locally under
268        // `xr dev` gets what they asked for, not this override). This is
269        // what lets `xr dev` perform a real zero-downtime handoff on every
270        // rebuild instead of hard-killing the previous process first.
271        let graceful_shutdown = self.graceful_shutdown.or_else(|| {
272            is_dev_reload.then_some(GracefulShutdown {
273                drain_timeout: DEV_DRAIN_TIMEOUT,
274                restart_channel: true,
275            })
276        });
277        let app_name = self.config.app_name.clone();
278
279        // A process spawned as a restart-handoff replacement (see
280        // `lifecycle::handoff`) inherits the *same* listening socket its
281        // predecessor was already using, read from its own stdin as one
282        // line of encoded text, instead of binding `addr` fresh - the
283        // whole point of the handoff being able to start serving with no
284        // gap at all. Ordinary startup (a plain `cargo run`/`xr dev`, or
285        // any generated app not using the restart-handoff feature) never
286        // sets this env var and binds fresh exactly as before this
287        // feature existed.
288        let is_handoff_replacement =
289            std::env::var_os(lifecycle::listener::INHERIT_LISTENER_ENV).is_some();
290        if is_handoff_replacement {
291            // Arms this process's own death-signal delivery (Linux) /
292            // relies on already having inherited job-object membership
293            // (Windows) - see `lifecycle::supervisor`'s own doc comment
294            // for why this now happens *here*, inside the already-exec'd
295            // replacement, rather than via a `Command::pre_exec` hook run
296            // by the parent between `fork()` and `exec()` (that design's
297            // real, confirmed bug).
298            lifecycle::supervisor::arm_pdeathsig();
299        }
300        let std_listener = if is_handoff_replacement {
301            let mut line = String::new();
302            tokio::io::AsyncBufReadExt::read_line(
303                &mut tokio::io::BufReader::new(tokio::io::stdin()),
304                &mut line,
305            )
306            .await
307            .map_err(|source| AppError::Internal(Box::new(source)))?;
308            lifecycle::listener::inherit(&line)
309                .map_err(|source| AppError::Internal(Box::new(source)))?
310        } else {
311            lifecycle::listener::bind(addr)
312                .map_err(|source| AppError::Internal(Box::new(source)))?
313        };
314        // Kept as a plain std listener, separate from the tokio-wrapped
315        // one below - the restart-handoff machinery (`lifecycle::admin`,
316        // `lifecycle::handoff`) works with std sockets directly (it needs
317        // the raw fd/socket handle, not an async wrapper around one), and
318        // needs its own independent handle to the same underlying kernel
319        // socket regardless of whether graceful shutdown/the admin
320        // channel end up being configured at all.
321        let admin_listener = std_listener
322            .try_clone()
323            .map_err(|source| AppError::Internal(Box::new(source)))?;
324        std_listener
325            .set_nonblocking(true)
326            .map_err(|source| AppError::Internal(Box::new(source)))?;
327        let listener = tokio::net::TcpListener::from_std(std_listener)
328            .map_err(|source| AppError::Internal(Box::new(source)))?;
329
330        // `.route(...)` panics on an exact-path collision with a route the
331        // app already registered - acceptable here given how unlikely a
332        // real app is to independently choose the `__larust_dev` path, but
333        // worth knowing if this route's name ever needs to change.
334        let router = if is_dev_reload {
335            self.router
336                .route("/__larust_dev", axum::routing::get(dev_reload::handler))
337        } else {
338            self.router
339        };
340
341        let router = if let Some(path) = self.health_route {
342            router.route(&path, axum::routing::get(health))
343        } else {
344            router
345        };
346
347        // Served at the URL root (`public/logo.png` → `/logo.png`), not
348        // under a `/public` prefix - matching Laravel's own convention,
349        // where `public/` *is* the webserver's docroot. A registered route
350        // wins over a same-path file for any *literal* request path:
351        // `fallback_service` is only ever consulted when axum's own router
352        // finds no match. That precedence is per byte, not per resolved
353        // path, though - axum matches on the raw, undecoded request path,
354        // while `ServeDir` percent-decodes before resolving a file, so a
355        // percent-encoded request (`/app%2Ejs`) can reach a file the
356        // registered route at `/app.js` would otherwise have handled. Not
357        // a traversal/disclosure risk (it can only ever reach content
358        // that's already sitting in `public/`), just worth knowing the
359        // "route always wins" framing isn't byte-for-byte absolute. A
360        // missing `public/` directory isn't an error here - `ServeDir`
361        // checks the filesystem per-request, not at construction, so
362        // every request just 404s until the directory exists.
363        //
364        // `.not_found_service(...)` (not a bare `ServeDir`) so a request
365        // matching *neither* a registered route *nor* a real file gets
366        // Larust's own styled 404 (default or app-overridden, see
367        // `ErrorPages`) instead of `ServeDir`'s own bare, empty-body one -
368        // this is the only path a genuinely dead URL a visitor hits ever
369        // takes; `AppError::NotFound` is otherwise only ever constructed by
370        // a handler explicitly returning it (e.g. a failed route-model-
371        // binding lookup). Scoped precisely to `ServeDir`'s own not-found
372        // response - a handler elsewhere in the app that builds its own
373        // `(StatusCode::NOT_FOUND, ...)` response directly is untouched.
374        let router = router.fallback_service(ServeDir::new(self.paths.public()).not_found_service(
375            tower::service_fn(|_req: axum::extract::Request| async {
376                Ok::<_, std::convert::Infallible>(AppError::NotFound.into_response())
377            }),
378        ));
379
380        // Applies to every response, not just `public/`'s - `nosniff` is a
381        // broadly-correct default (OWASP baseline), but it matters
382        // specifically here: `ServeDir` infers a served file's Content-Type
383        // from its *extension* alone (via `mime_guess`), never its actual
384        // bytes, so anything written into `public/` under an
385        // extension-spoofed name (e.g. an app that validates an upload's
386        // declared MIME type but not its real bytes) would otherwise be
387        // subject to the browser's own content-sniffing - this header is
388        // what keeps a browser from second-guessing the declared type.
389        let router = router.layer(SetResponseHeaderLayer::overriding(
390            header::X_CONTENT_TYPE_OPTIONS,
391            HeaderValue::from_static("nosniff"),
392        ));
393        // Safe defaults which do not depend on whether TLS is terminated by
394        // this process or a reverse proxy. Applications can set a stricter
395        // CSP/HSTS policy at their own edge, where their asset and proxy
396        // topology is known.
397        let router = router.layer(SetResponseHeaderLayer::if_not_present(
398            HeaderName::from_static("referrer-policy"),
399            HeaderValue::from_static("strict-origin-when-cross-origin"),
400        ));
401        let router = router.layer(SetResponseHeaderLayer::if_not_present(
402            HeaderName::from_static("x-frame-options"),
403            HeaderValue::from_static("DENY"),
404        ));
405
406        install_backtrace_capturing_panic_hook();
407        let router = router.layer(CatchPanicLayer::custom(handle_panic));
408
409        // Signals the predecessor process (see `lifecycle::handoff`) that
410        // this replacement is genuinely about to start accepting
411        // connections on the inherited listener - the predecessor is
412        // waiting on exactly this line before it begins its own graceful
413        // shutdown. A no-op on any ordinary boot.
414        if is_handoff_replacement {
415            lifecycle::readiness::announce_ready();
416            // From this exact point on, the predecessor exiting is the
417            // expected, successful conclusion of this handoff, not
418            // something to react to - see `lifecycle::supervisor::linux::
419            // disarm_pdeathsig`'s own doc comment for the real bug
420            // leaving this armed past this point caused (a brand-new
421            // replacement receiving its predecessor's own death signal
422            // and mistakenly starting to drain itself).
423            lifecycle::supervisor::disarm_pdeathsig();
424        }
425
426        let Some(graceful_shutdown) = graceful_shutdown else {
427            // Today's exact behavior, byte-for-byte unchanged apart from
428            // `into_make_service_with_connect_info`: a bare `axum::serve`
429            // that exits the instant the process is killed.
430            axum::serve(
431                listener,
432                router.into_make_service_with_connect_info::<SocketAddr>(),
433            )
434            .await
435            .map_err(|source| AppError::Internal(Box::new(source)))?;
436            return Ok(());
437        };
438
439        // `shutdown_tx` fires once, on Ctrl+C/SIGTERM - `with_graceful_shutdown`
440        // then stops accepting new connections and waits for in-flight ones
441        // to finish. The `drain_timeout` sleep in this same spawned task is
442        // a hard backstop: if the graceful drain hasn't finished naturally
443        // by then (a stuck connection, a hung upstream call), force the
444        // process to exit anyway rather than hang a deploy forever. If the
445        // drain finishes first, `serve()` below returns `Ok(())`, the
446        // process exits normally, and this still-sleeping task is simply
447        // dropped along with it - nothing to clean up either way.
448        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
449        let drain_timeout = graceful_shutdown.drain_timeout;
450        let restart_channel_enabled = graceful_shutdown.restart_channel;
451        // Set right before `shutdown_tx.send(())`, only on the `Handoff`
452        // arm below - checked after `axum::serve()` returns to decide
453        // whether the `std::process::exit(0)` bypass further down applies.
454        // See that bypass's own doc comment for the Windows hang this
455        // exists to sidestep: it used to key off `is_dev_reload` instead
456        // of this, which was too narrow - a production app calling
457        // `.with_graceful_shutdown(GracefulShutdown { restart_channel:
458        // true, .. })` directly (never setting `LARUST_DEV_RELOAD`, e.g.
459        // via `xr restart`) hits the *exact same* hang on a successful
460        // handoff, and `is_dev_reload` being false meant the bypass never
461        // fired for it - confirmed by reproducing this exact hang via
462        // `tests/stale_binary_path.rs`, which uses this precise
463        // configuration. "Did this process hand off a child that outlives
464        // it" is the condition that actually matters, not which caller
465        // happened to enable the restart channel.
466        let handed_off_child = Arc::new(AtomicBool::new(false));
467        tokio::spawn({
468            let handed_off_child = Arc::clone(&handed_off_child);
469            async move {
470                if restart_channel_enabled {
471                    let address = lifecycle::admin::channel_address(&app_name);
472                    tokio::select! {
473                        _ = lifecycle::wait_for_termination() => {
474                            tracing::info!(
475                                ?drain_timeout,
476                                "shutdown signal received; draining in-flight requests"
477                            );
478                        }
479                        outcome = lifecycle::admin::run_until_command(
480                            &address,
481                            &admin_listener,
482                            HANDOFF_READY_TIMEOUT,
483                        ) => {
484                            match outcome {
485                                lifecycle::admin::AdminOutcome::Handoff(child) => {
486                                    // Dropping this handle does *not* kill the
487                                    // child - `tokio::process::Command` only
488                                    // does that with `.kill_on_drop(true)`,
489                                    // which this code path never sets. It's
490                                    // already running and serving on the
491                                    // listener this process just handed off;
492                                    // nothing further needs doing with the
493                                    // handle itself - see the forced
494                                    // `std::process::exit(0)` after
495                                    // `axum::serve()` below for *why* dropping
496                                    // it here (rather than awaiting its exit)
497                                    // is not just sufficient but necessary.
498                                    tracing::info!(
499                                        pid = child.id(),
500                                        ?drain_timeout,
501                                        "restart handoff succeeded; draining in-flight requests"
502                                    );
503                                    handed_off_child.store(true, Ordering::SeqCst);
504                                }
505                                lifecycle::admin::AdminOutcome::Stop => {
506                                    tracing::info!(
507                                        ?drain_timeout,
508                                        "stop command received; draining in-flight requests"
509                                    );
510                                }
511                            }
512                        }
513                    }
514                } else {
515                    lifecycle::wait_for_termination().await;
516                    tracing::info!(
517                        ?drain_timeout,
518                        "shutdown signal received; draining in-flight requests"
519                    );
520                }
521                let _ = shutdown_tx.send(());
522                tokio::time::sleep(drain_timeout).await;
523                tracing::warn!(
524                    "drain timeout elapsed; forcing exit with any remaining connections dropped"
525                );
526                std::process::exit(0);
527            }
528        });
529
530        axum::serve(
531            listener,
532            router.into_make_service_with_connect_info::<SocketAddr>(),
533        )
534        .with_graceful_shutdown(async move {
535            let _ = shutdown_rx.await;
536        })
537        .await
538        .map_err(|source| AppError::Internal(Box::new(source)))?;
539
540        // A restart-handoff replacement's `Child` handle (see the
541        // `AdminOutcome::Handoff` arm above) is deliberately dropped, not
542        // awaited - the whole point is that the replacement outlives this
543        // process. But on Windows, that leaves an outstanding exit-watch
544        // registration on the (still-running, by design) replacement's
545        // process handle, and confirmed empirically (not from docs -
546        // reproduced directly via instrumented tracing before landing this
547        // fix): the ordinary return-from-`main()` path hangs on it. Once
548        // `axum::serve()` returns here, the `#[tokio::main]`-generated
549        // wrapper's own `Runtime` gets dropped as `serve()`'s `Ok(())`
550        // unwinds back through `main()`, and that drop blocks the whole
551        // process from ever actually exiting until every outstanding
552        // Windows blocking-pool wait completes - including that
553        // replacement's exit-watch, which by definition won't resolve
554        // until the replacement itself exits, i.e. for the rest of the
555        // dev session. The result: this process never actually terminates
556        // - it just stops serving and lingers as an invisible zombie,
557        // still holding the Windows Job Object it created to supervise
558        // *its own* handoff target (`lifecycle::supervisor`) open. If that
559        // zombie (or an earlier one in the same chain, or `xr dev` itself)
560        // is ever force-killed later, `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`
561        // cascades the kill down through every subsequent generation,
562        // taking out the *currently serving* process as collateral
563        // damage - this is what an earlier investigation session
564        // mistakenly diagnosed as "generation N vanished." Calling
565        // `std::process::exit` here - a real OS-level termination, no
566        // destructors, no waiting for anything - sidesteps that hang
567        // entirely; safe specifically because this whole method already
568        // only reaches this point after a real (not stuck) graceful
569        // drain, so there's nothing left this process still needs to do.
570        //
571        // Keyed on `handed_off_child` (set only on the `Handoff` arm
572        // above), **not** `is_dev_reload` - a real, previously-unfixed gap
573        // this comment used to describe as intentional ("a production app
574        // without the restart-admin-channel enabled never hands off a
575        // `Child` in the first place"), which conflated two different
576        // conditions: `is_dev_reload` (this process was spawned by `xr
577        // dev`) and "this process actually handed off a child that
578        // outlives it" (true whenever a `RESTART` command produces a
579        // successful handoff, via `xr dev` *or* `xr restart` against a
580        // plain production app with `GracefulShutdown { restart_channel:
581        // true, .. }` - the exact configuration `tests/
582        // stale_binary_path.rs`'s own fixture uses, confirmed to reproduce
583        // this exact hang before this fix). A `STOP` command or a plain
584        // OS shutdown signal never sets `handed_off_child`, so this stays
585        // a no-op for every path that never spawned a still-running
586        // child - the ordinary `Ok(())` return below is already correct
587        // for those. See `docs/GOTCHAS.md`.
588        if handed_off_child.load(Ordering::SeqCst) {
589            std::process::exit(0);
590        }
591
592        Ok(())
593    }
594}
595
596async fn health() -> Response {
597    // Laravel's built-in `/up` endpoint presents a deliberately small
598    // browser-friendly status page. Keep ours entirely self-contained: a
599    // health check should not depend on a CDN, font provider, database, or
600    // application template being available in order to report bootstrap
601    // success to a load balancer.
602    let html = r#"<!doctype html>
603<html lang="en">
604<head>
605  <meta charset="utf-8">
606  <meta name="viewport" content="width=device-width, initial-scale=1">
607  <title>Application</title>
608  <style>
609    :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
610    body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f3f4f6; color: #111827; }
611    main { width: min(36rem, calc(100% - 3rem)); padding: 1.5rem; }
612    article { display: flex; gap: 1.25rem; align-items: flex-start; padding: 1.5rem; border-radius: .75rem; background: #fff; box-shadow: 0 20px 45px rgb(17 24 39 / .15); }
613    .status { position: relative; flex: 0 0 auto; width: .75rem; height: .75rem; margin-top: .38rem; border-radius: 999px; background: #4ade80; }
614    .status::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: #4ade80; animation: pulse 1.5s ease-out infinite; }
615    h1 { margin: 0; font-size: 1.25rem; }
616    p { margin: .5rem 0 0; color: #6b7280; font-size: .875rem; }
617    @keyframes pulse { from { transform: scale(1); opacity: .8; } to { transform: scale(2.4); opacity: 0; } }
618    @media (prefers-color-scheme: dark) { body { background: #111827; color: #f9fafb; } article { background: #1f2937; } p { color: #9ca3af; } }
619    @media (prefers-reduced-motion: reduce) { .status::before { animation: none; } }
620  </style>
621</head>
622<body>
623  <main>
624    <article role="status" aria-live="polite">
625      <span class="status" aria-hidden="true"></span>
626      <div>
627        <h1>Application up</h1>
628        <p>HTTP request received. Response rendered in <span id="response-time">--</span>.</p>
629      </div>
630    </article>
631  </main>
632  <script>
633    const responseTime = document.getElementById('response-time');
634    responseTime.textContent = `${Math.max(0, Math.round(performance.now()))}ms`;
635  </script>
636</body>
637</html>"#
638        .to_string();
639    (
640        StatusCode::OK,
641        [
642            (header::CONTENT_TYPE, "text/html; charset=utf-8"),
643            (header::CACHE_CONTROL, "no-store"),
644        ],
645        html,
646    )
647        .into_response()
648}
649
650thread_local! {
651    /// Set by `install_backtrace_capturing_panic_hook`'s own hook, read
652    /// (and cleared) by `handle_panic` right after - see that function's
653    /// own doc comment for why a thread-local is sound here despite
654    /// running on a shared tokio worker thread: nothing else can run on
655    /// this exact OS thread between the hook firing and `handle_panic`
656    /// reading it back, since `tower_http::catch_panic`'s own
657    /// `ResponseFuture` calls `handle_panic` *synchronously*, within the
658    /// very same `poll()` that caught the panic via `catch_unwind` -
659    /// there's no `.await` point, and therefore no scheduling opportunity
660    /// for the executor to run a *different* task on this thread, anywhere
661    /// in between.
662    static LAST_PANIC_BACKTRACE: std::cell::RefCell<Option<error::PanicBacktrace>> =
663        const { std::cell::RefCell::new(None) };
664}
665
666/// Installs a custom panic hook that captures a backtrace at the exact
667/// moment a panic happens, for `handle_panic` (below) to pick up and hand
668/// to `error::render_panic` - capturing *after* `std::panic::catch_unwind`
669/// returns (inside `handle_panic` itself) would be useless, since the stack
670/// has already unwound back up to `tower_http::catch_panic`'s own catching
671/// frame by then; a `Backtrace::capture()` taken there would just show
672/// *that* frame, not the real panic site several frames further down that
673/// no longer exist on the stack at all.
674///
675/// Guarded by `Once` so installing it is safe to call on every `serve()`
676/// (including more than one `Application` in the same process, e.g. in
677/// tests) without stacking an unbounded chain of hooks each wrapping the
678/// last - `std::panic::set_hook` replaces whatever hook is currently
679/// installed, so without this guard, a *second* `serve()` call would wrap
680/// an already-wrapped hook in another layer forever.
681///
682/// Calling `std::backtrace::Backtrace::capture()` unconditionally, on
683/// *every* panic regardless of debug mode, is deliberate and cheap: per
684/// its own documentation, it only actually walks the stack when
685/// `RUST_BACKTRACE`/`RUST_LIB_BACKTRACE` is set at all - otherwise this is
686/// just one cached atomic read, the identical cost Rust's own default
687/// panic hook already pays on every panic regardless of whether anything
688/// downstream of it cares. Production mode still never *shows* whatever
689/// was captured here (`render_panic` only renders it when `debug::
690/// is_enabled()`), so this adds no new information disclosure risk - only
691/// debug mode's own already-privileged detail gets an extra section.
692fn install_backtrace_capturing_panic_hook() {
693    static INSTALLED: std::sync::Once = std::sync::Once::new();
694    INSTALLED.call_once(|| {
695        let previous = std::panic::take_hook();
696        std::panic::set_hook(Box::new(move |info| {
697            use std::backtrace::{Backtrace, BacktraceStatus};
698            let backtrace = Backtrace::capture();
699            let captured = match backtrace.status() {
700                BacktraceStatus::Captured => error::PanicBacktrace::Captured(backtrace.to_string()),
701                BacktraceStatus::Unsupported => error::PanicBacktrace::Unsupported,
702                // `Disabled`, and any future non-exhaustive variant alike -
703                // the same "degrade to the always-safe default" precedent
704                // `logging::init`'s own unrecognized-`LOG_CHANNEL` handling
705                // already uses.
706                _ => error::PanicBacktrace::Disabled,
707            };
708            LAST_PANIC_BACKTRACE.with(|cell| *cell.borrow_mut() = Some(captured));
709            // Preserves whatever the previous hook did (Rust's own default -
710            // printing to stderr - unless something else already replaced
711            // it) - this hook adds a capture, it doesn't replace existing
712            // panic-reporting behavior.
713            previous(info);
714        }));
715    });
716}
717
718/// Converts a panicking handler into a response instead of dropping the
719/// connection with nothing - before this, a panic anywhere in a handler
720/// meant that one request just failed silently, with no framework-level
721/// response at all.
722fn handle_panic(payload: Box<dyn Any + Send + 'static>) -> Response {
723    // `downcast` (consuming) rather than `downcast_ref` + `.clone()` for the
724    // `String` case - avoids cloning a payload that's about to be dropped
725    // anyway; the panic path is cold, but there's no reason to allocate
726    // twice when ownership is right there.
727    let message = match payload.downcast::<String>() {
728        Ok(s) => *s,
729        Err(payload) => match payload.downcast_ref::<&str>() {
730            Some(s) => s.to_string(),
731            None => "unknown panic payload".to_string(),
732        },
733    };
734    // Falls back to `Disabled` if the hook never ran at all (e.g. a test
735    // that calls `handle_panic` directly without ever installing it) -
736    // the same safe default the hook itself falls back to for an
737    // unrecognized `BacktraceStatus`.
738    let backtrace = LAST_PANIC_BACKTRACE
739        .with(|cell| cell.borrow_mut().take())
740        .unwrap_or(error::PanicBacktrace::Disabled);
741    error::render_panic(&message, backtrace)
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use axum::body::Body;
748    use axum::http::{Request, StatusCode};
749    use axum::routing::get;
750    use tower::ServiceExt;
751
752    /// A unit test (not an integration test under `tests/`) specifically
753    /// because `handle_panic` is private - only code inside this crate can
754    /// reach it directly. This compiles to its own isolated test binary
755    /// (there are no other `src/`-local unit tests in this crate today),
756    /// so it doesn't race `debug::set()`'s `OnceLock` against the
757    /// `tests/error_response_*.rs` integration tests, which each run in
758    /// their own separate process anyway.
759    ///
760    /// Only covers the production-mode (default, unset) branch - flipping
761    /// to debug mode here would permanently commit this test binary's
762    /// `OnceLock` for any test added later in this file. The debug-mode
763    /// rendering path (the same `debug_page` helper `AppError::Internal`
764    /// already exercises in `tests/error_response_debug_mode.rs`) was
765    /// verified live against a real running app instead: a deliberately
766    /// panicking handler correctly rendered the panic message as HTML with
767    /// `APP_DEBUG=true`, and the server kept serving subsequent requests
768    /// afterward.
769    #[tokio::test]
770    async fn panicking_handler_is_caught_and_rendered_instead_of_dropping_the_connection() {
771        async fn always_panics() -> &'static str {
772            panic!("boom");
773        }
774
775        let router = Router::new()
776            .route("/panic", get(always_panics))
777            .layer(CatchPanicLayer::custom(handle_panic));
778
779        let response = router
780            .oneshot(Request::get("/panic").body(Body::empty()).unwrap())
781            .await
782            .unwrap();
783
784        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
785        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
786            .await
787            .unwrap();
788        let body = String::from_utf8(bytes.to_vec()).unwrap();
789        // No `error_pages::set()` call anywhere in this test binary, so
790        // this exercises (and pins) the built-in default page, not an
791        // app-registered override - see `error_pages::default_internal_html`.
792        assert_eq!(body, crate::default_internal_html());
793        assert!(
794            !body.contains("boom"),
795            "panic message must not leak: {body}"
796        );
797    }
798
799    #[tokio::test]
800    async fn laravel_style_health_handler_returns_a_status_page() {
801        let response = health().await;
802        assert_eq!(response.status(), StatusCode::OK);
803        assert_eq!(
804            response.headers().get(header::CONTENT_TYPE).unwrap(),
805            "text/html; charset=utf-8"
806        );
807        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
808            .await
809            .unwrap();
810        let body = String::from_utf8(body.to_vec()).unwrap();
811        assert!(body.contains("Application up"));
812        assert!(body.contains("HTTP request received. Response rendered in"));
813        assert!(body.contains("performance.now()"));
814    }
815
816    /// Exercises the exact `.fallback_service(ServeDir::new(...)
817    /// .not_found_service(...))` pattern `serve()` wires up - against a
818    /// `tempfile::tempdir()` rather than the real, hardcoded `"public"`
819    /// path (relative to the process's CWD, not something a unit test
820    /// should depend on), so this proves the underlying tower-http
821    /// integration behaves correctly without needing to touch the real
822    /// filesystem convention. Only covers the literal, byte-identical-path
823    /// case - see the comment above `fallback_service` in `serve()` for the
824    /// percent-encoded-path caveat this doesn't (and can't easily) pin
825    /// without depending on tower-http/axum internals more closely than a
826    /// unit test here should.
827    #[tokio::test]
828    async fn fallback_service_serves_static_files_but_registered_routes_still_win() {
829        let dir = tempfile::tempdir().unwrap();
830        std::fs::write(dir.path().join("logo.png"), b"fake-image-bytes").unwrap();
831        std::fs::write(
832            dir.path().join("app.js"),
833            b"real file, should lose to the route",
834        )
835        .unwrap();
836
837        async fn app_js_route() -> &'static str {
838            "handled by a registered route"
839        }
840
841        let router = Router::new()
842            .route("/app.js", get(app_js_route))
843            .fallback_service(
844                ServeDir::new(dir.path()).not_found_service(tower::service_fn(
845                    |_req: axum::extract::Request| async {
846                        Ok::<_, std::convert::Infallible>(AppError::NotFound.into_response())
847                    },
848                )),
849            );
850
851        // A path with no registered route, but a real file on disk, is
852        // served directly from that file.
853        let logo_response = router
854            .clone()
855            .oneshot(Request::get("/logo.png").body(Body::empty()).unwrap())
856            .await
857            .unwrap();
858        assert_eq!(logo_response.status(), StatusCode::OK);
859        let logo_bytes = axum::body::to_bytes(logo_response.into_body(), usize::MAX)
860            .await
861            .unwrap();
862        assert_eq!(logo_bytes, "fake-image-bytes".as_bytes());
863
864        // A path that exists as *both* a registered route and a real file
865        // is handled by the route - `fallback_service` is only ever
866        // consulted when nothing else matched.
867        let app_js_response = router
868            .clone()
869            .oneshot(Request::get("/app.js").body(Body::empty()).unwrap())
870            .await
871            .unwrap();
872        let app_js_bytes = axum::body::to_bytes(app_js_response.into_body(), usize::MAX)
873            .await
874            .unwrap();
875        assert_eq!(app_js_bytes, "handled by a registered route".as_bytes());
876
877        // A path matching neither a route nor a file now gets Larust's own
878        // styled 404 (via `AppError::NotFound`), not `ServeDir`'s bare,
879        // empty-body one - the real gap this whole change closes.
880        let missing_response = router
881            .oneshot(
882                Request::get("/does-not-exist.css")
883                    .body(Body::empty())
884                    .unwrap(),
885            )
886            .await
887            .unwrap();
888        assert_eq!(missing_response.status(), StatusCode::NOT_FOUND);
889        let missing_bytes = axum::body::to_bytes(missing_response.into_body(), usize::MAX)
890            .await
891            .unwrap();
892        assert_eq!(
893            String::from_utf8(missing_bytes.to_vec()).unwrap(),
894            crate::default_not_found_html()
895        );
896    }
897
898    /// Guards every test below that reads or sets `LOG_CHANNEL`/
899    /// `LARUST_STDIO_DETACHED` - real process-wide state, not per-test
900    /// isolated, which `cargo test`'s default parallel execution would
901    /// otherwise race (same reasoning, same `OnceLock`-backed `std::sync::
902    /// Mutex` pattern, as `dev_reload.rs`'s own `test_lock` - see that
903    /// module's doc comment for the fuller explanation of why a bare
904    /// `std::sync::Mutex` is fine here specifically because these tests
905    /// hold the guard across no `.await` point at all).
906    fn env_lock() -> &'static std::sync::Mutex<()> {
907        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
908        LOCK.get_or_init(|| std::sync::Mutex::new(()))
909    }
910
911    #[test]
912    fn defaults_log_channel_to_file_when_stdio_is_detached_and_the_user_never_set_one() {
913        let _guard = env_lock()
914            .lock()
915            .unwrap_or_else(std::sync::PoisonError::into_inner);
916        std::env::remove_var("LOG_CHANNEL");
917        std::env::set_var(STDIO_DETACHED_ENV, "1");
918
919        let mut config = Config::from_value(&serde_json::json!({})).unwrap();
920        assert_eq!(
921            config.log_channel, "stdout",
922            "sanity check on Config's own default"
923        );
924        default_log_channel_to_file_if_stdio_detached(&mut config);
925        assert_eq!(config.log_channel, "file");
926
927        std::env::remove_var(STDIO_DETACHED_ENV);
928    }
929
930    /// The actual reason this checks the real environment instead of
931    /// trusting `config.log_channel` - see `default_log_channel_to_file_if_
932    /// stdio_detached`'s own doc comment. An explicit `LOG_CHANNEL=stdout`
933    /// happens to be the same string `Config`'s own default already
934    /// produces, but the user's explicit choice must still win.
935    #[test]
936    fn does_not_override_an_explicitly_set_log_channel_even_if_it_matches_the_default() {
937        let _guard = env_lock()
938            .lock()
939            .unwrap_or_else(std::sync::PoisonError::into_inner);
940        std::env::set_var("LOG_CHANNEL", "stdout");
941        std::env::set_var(STDIO_DETACHED_ENV, "1");
942
943        let mut config = Config::from_value(&serde_json::json!({})).unwrap();
944        default_log_channel_to_file_if_stdio_detached(&mut config);
945        assert_eq!(config.log_channel, "stdout");
946
947        std::env::remove_var("LOG_CHANNEL");
948        std::env::remove_var(STDIO_DETACHED_ENV);
949    }
950
951    #[test]
952    fn does_nothing_on_an_ordinary_boot_where_stdio_was_never_detached() {
953        let _guard = env_lock()
954            .lock()
955            .unwrap_or_else(std::sync::PoisonError::into_inner);
956        std::env::remove_var("LOG_CHANNEL");
957        std::env::remove_var(STDIO_DETACHED_ENV);
958
959        let mut config = Config::from_value(&serde_json::json!({})).unwrap();
960        default_log_channel_to_file_if_stdio_detached(&mut config);
961        assert_eq!(
962            config.log_channel, "stdout",
963            "an ordinary cargo run/xr dev boot must be completely unaffected"
964        );
965    }
966
967    #[test]
968    fn is_supported_session_driver_accepts_exactly_the_two_real_values() {
969        assert!(is_supported_session_driver("database"));
970        assert!(is_supported_session_driver("file"));
971    }
972
973    #[test]
974    fn is_supported_session_driver_rejects_a_laravel_value_this_framework_does_not_implement() {
975        // Real Laravel `SESSION_DRIVER` values this codebase deliberately
976        // doesn't support - see `larust_http::session`'s own module doc
977        // comment for why there's still no in-memory ("array") option even
978        // now that "file" is real.
979        for unsupported in ["array", "redis", "cookie", "memcached", "dynamodb"] {
980            assert!(
981                !is_supported_session_driver(unsupported),
982                "{unsupported:?} must not be treated as a supported driver"
983            );
984        }
985    }
986
987    #[test]
988    fn is_supported_session_driver_rejects_an_empty_or_typo_d_value() {
989        assert!(!is_supported_session_driver(""));
990        assert!(!is_supported_session_driver("Database")); // wrong case
991        assert!(!is_supported_session_driver("fiel")); // typo
992    }
993}