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}