1use crate::{debug, error_pages};
2use axum::http::header::CONTENT_TYPE;
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
16pub enum AppError {
17 #[error("configuration error: {0}")]
18 Config(#[source] Box<dyn std::error::Error + Send + Sync>),
19
20 #[error("not found")]
21 NotFound,
22
23 #[error("internal server error: {0}")]
24 Internal(#[source] Box<dyn std::error::Error + Send + Sync>),
25
26 #[error("{message}")]
30 Http { status: StatusCode, message: String },
31}
32
33impl IntoResponse for AppError {
34 fn into_response(self) -> Response {
35 let message = self.to_string();
43 if matches!(self, AppError::Config(_) | AppError::Internal(_)) {
44 tracing::error!(error = %message, "unhandled application error");
45 }
46
47 match self {
48 AppError::NotFound => {
49 if debug::is_enabled() {
50 debug_page(
51 StatusCode::NOT_FOUND,
52 "Not Found",
53 &["No route matched this request.".to_string()],
54 None,
55 )
56 } else {
57 html_response(StatusCode::NOT_FOUND, error_pages::not_found_html())
58 }
59 }
60 AppError::Http { status, message } => (status, message).into_response(),
61 AppError::Config(source) | AppError::Internal(source) => {
62 internal_response(&message, source.as_ref())
63 }
64 }
65 }
66}
67
68const MAX_SOURCE_CHAIN_DEPTH: u8 = 20;
76
77fn internal_response(top_message: &str, source: &(dyn std::error::Error + 'static)) -> Response {
78 if debug::is_enabled() {
79 let mut messages = vec![top_message.to_string()];
85 let mut cause = source.source();
86 let mut depth = 0;
87 while let Some(err) = cause {
88 depth += 1;
89 if depth > MAX_SOURCE_CHAIN_DEPTH {
90 messages.push("… source chain truncated …".to_string());
91 break;
92 }
93 messages.push(err.to_string());
94 cause = err.source();
95 }
96 debug_page(
97 StatusCode::INTERNAL_SERVER_ERROR,
98 "Internal Server Error",
99 &messages,
100 None,
101 )
102 } else {
103 html_response(
104 StatusCode::INTERNAL_SERVER_ERROR,
105 error_pages::internal_html(),
106 )
107 }
108}
109
110pub(crate) enum PanicBacktrace {
118 Disabled,
125 Unsupported,
128 Captured(String),
133}
134
135pub(crate) fn render_panic(message: &str, backtrace: PanicBacktrace) -> Response {
142 tracing::error!(error = %message, "panic in request handler");
143 if debug::is_enabled() {
144 debug_page(
145 StatusCode::INTERNAL_SERVER_ERROR,
146 "Internal Server Error (panic)",
147 &[message.to_string()],
148 Some(backtrace),
149 )
150 } else {
151 html_response(
152 StatusCode::INTERNAL_SERVER_ERROR,
153 error_pages::internal_html(),
154 )
155 }
156}
157
158fn html_response(status: StatusCode, html: String) -> Response {
163 (status, [(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
164}
165
166struct Frame {
172 text: String,
173 is_runtime_noise: bool,
174}
175
176fn is_frame_header_line(line: &str) -> bool {
183 let trimmed = line.trim_start();
184 let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
185 digit_count > 0 && trimmed[digit_count..].starts_with(':')
186}
187
188const RUNTIME_NOISE_PREFIXES: &[&str] = &[
201 "std::",
202 "core::",
203 "alloc::",
204 "__rust_",
205 "rust_begin_unwind",
206 "backtrace::",
207 "backtrace_rs::",
208 "tokio::",
209 "<unknown>",
210];
211
212fn classify_as_runtime_noise(frame_text: &str) -> bool {
213 let first_line = frame_text.lines().next().unwrap_or("");
214 let symbol = first_line
215 .split_once(':')
216 .map(|(_, rest)| rest.trim())
217 .unwrap_or(first_line);
218 RUNTIME_NOISE_PREFIXES
219 .iter()
220 .any(|prefix| symbol.starts_with(prefix))
221}
222
223fn parse_backtrace_frames(text: &str) -> Vec<Frame> {
228 let mut frames: Vec<Frame> = Vec::new();
229 for line in text.lines() {
230 if is_frame_header_line(line) {
231 frames.push(Frame {
232 text: line.to_string(),
233 is_runtime_noise: false,
234 });
235 } else if let Some(last) = frames.last_mut() {
236 last.text.push('\n');
237 last.text.push_str(line);
238 }
239 }
243 for frame in &mut frames {
244 frame.is_runtime_noise = classify_as_runtime_noise(&frame.text);
245 }
246 frames
247}
248
249fn render_backtrace_frames_html(frames: &[Frame]) -> String {
257 let mut html = String::new();
258 let mut i = 0;
259 while i < frames.len() {
260 if frames[i].is_runtime_noise {
261 let start = i;
262 while i < frames.len() && frames[i].is_runtime_noise {
263 i += 1;
264 }
265 let hidden = &frames[start..i];
266 let joined = hidden
267 .iter()
268 .map(|f| f.text.as_str())
269 .collect::<Vec<_>>()
270 .join("\n");
271 html.push_str(&format!(
272 "<details class=\"bt-noise\"><summary>{} runtime frame{} hidden</summary><pre>{}</pre></details>",
273 hidden.len(),
274 if hidden.len() == 1 { "" } else { "s" },
275 escape_html(&joined),
276 ));
277 } else {
278 html.push_str(&format!(
279 "<pre class=\"bt-frame\">{}</pre>",
280 escape_html(&frames[i].text)
281 ));
282 i += 1;
283 }
284 }
285 html
286}
287
288fn render_backtrace_section(backtrace: &PanicBacktrace) -> String {
292 match backtrace {
293 PanicBacktrace::Captured(text) => {
294 let frames = parse_backtrace_frames(text);
295 format!(
296 r#"<div class="section">
297 <h2>Stack trace</h2>
298 <div class="backtrace">{}</div>
299</div>"#,
300 render_backtrace_frames_html(&frames)
301 )
302 }
303 PanicBacktrace::Disabled => r#"<div class="section">
304 <h2>Stack trace</h2>
305 <p class="hint">No backtrace was captured. Set <code>RUST_BACKTRACE=1</code> (or run <code>xr dev --debug</code>, which sets it automatically) to see one here next time.</p>
306</div>"#
307 .to_string(),
308 PanicBacktrace::Unsupported => r#"<div class="section">
309 <h2>Stack trace</h2>
310 <p class="hint">Backtraces aren't supported on this platform/build.</p>
311</div>"#
312 .to_string(),
313 }
314}
315
316fn debug_page(
334 status: StatusCode,
335 title: &str,
336 messages: &[String],
337 backtrace: Option<PanicBacktrace>,
338) -> Response {
339 let (first, rest) = messages.split_first().expect("messages is never empty");
340 let causes_html: String = rest
341 .iter()
342 .map(|message| {
343 format!(
344 "<div class=\"cause\"><span class=\"cause-label\">Caused by</span><pre>{}</pre></div>",
345 escape_html(message)
346 )
347 })
348 .collect();
349 let backtrace_html = backtrace
350 .as_ref()
351 .map(render_backtrace_section)
352 .unwrap_or_default();
353
354 let html = format!(
355 r##"<!doctype html>
356<html lang="en">
357<head>
358<meta charset="utf-8">
359<meta name="viewport" content="width=device-width, initial-scale=1">
360<title>{status} {title}</title>
361<style>
362{style}
363</style>
364</head>
365<body>
366<div class="page">
367 <header>
368 <div class="brand">
369 <svg viewBox="0 0 48 48" role="img" aria-hidden="true"><path fill="#ff735f" d="M12 0h24c6.63 0 12 5.37 12 12v24c0 6.63-5.37 12-12 12H0V12C0 5.37 5.37 0 12 0Z"/><path fill="#fff" d="M13.25 30.59a1 1 0 0 1-.76-1.64l4.7-5.59-4.69-5.42a1 1 0 1 1 1.51-1.31l5.25 6.07a1 1 0 0 1 0 1.3l-5.25 6.24a1 1 0 0 1-.76.35Z"/><path fill="#fff" d="M32.75 34.73h-12a1 1 0 1 1 0-2h12a1 1 0 1 1 0 2Z"/></svg>
370 <span>larust</span>
371 </div>
372 {theme_switch}
373 </header>
374 <main>
375 <div class="badges">
376 <span class="badge badge-status">{status}</span>
377 <span class="badge badge-title">{title}</span>
378 </div>
379 <div class="cause cause-primary"><pre>{first}</pre></div>
380 {causes_html}
381 {backtrace_html}
382 </main>
383 <footer>Shown because <code>APP_DEBUG=true</code>. Never enable this outside local development.</footer>
384</div>
385{theme_script}
386</body>
387</html>"##,
388 status = status.as_u16(),
389 title = escape_html(title),
390 first = escape_html(first),
391 style = DEBUG_PAGE_STYLE,
392 theme_switch = THEME_SWITCH_HTML,
393 theme_script = THEME_SWITCH_SCRIPT,
394 );
395
396 (status, [(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
397}
398
399const DEBUG_PAGE_STYLE: &str = r#"
408:root {
409 color-scheme: dark;
410 --bg: #171513; --panel: #211e1b; --panel-2: #272522; --border: #3a352e;
411 --ink: #f8f3eb; --muted: #a99d90; --accent: #ff735f; --accent-soft: #3a2420;
412 font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
413}
414@media (prefers-color-scheme: light) {
415 :root:not([data-theme="dark"]) {
416 --bg: #f4f0e8; --panel: #fffdf9; --panel-2: #f4f0e8; --border: #e4ddd2;
417 --ink: #202124; --muted: #6b6d73; --accent: #cf3628; --accent-soft: #fbe6e3;
418 }
419}
420:root[data-theme="light"] {
421 --bg: #f4f0e8; --panel: #fffdf9; --panel-2: #f4f0e8; --border: #e4ddd2;
422 --ink: #202124; --muted: #6b6d73; --accent: #cf3628; --accent-soft: #fbe6e3;
423}
424* { box-sizing: border-box; }
425body { margin: 0; background: var(--bg); color: var(--ink); }
426.page { max-width: 64rem; margin: 0 auto; padding: 1.5rem clamp(1rem, 4vw, 2.5rem) 3rem; }
427header { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0 1.5rem; }
428.brand { display: inline-flex; align-items: center; gap: .6rem; font-size: 1.05rem; font-weight: 800; letter-spacing: -.03em; }
429.brand svg { width: 1.75rem; height: 1.75rem; flex: none; }
430.badges { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; }
431.badge { display: inline-flex; align-items: center; padding: .3rem .7rem; border-radius: 999px; font-size: .78rem; font-weight: 700; }
432.badge-status { background: var(--accent-soft); color: var(--accent); }
433.badge-title { background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); }
434.cause { margin-bottom: .9rem; background: var(--panel); border: 1px solid var(--border); border-radius: .8rem; padding: 1rem 1.1rem; }
435.cause-primary { border-color: var(--accent); }
436.cause-label { display: block; margin-bottom: .4rem; color: var(--muted); font-size: .7rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
437.cause pre, .section pre { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; font: .85rem/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
438.section { margin-top: 1.5rem; }
439.section h2 { font-size: .85rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 0 0 .7rem; }
440.hint { color: var(--muted); font-size: .9rem; }
441.backtrace { display: flex; flex-direction: column; gap: .4rem; }
442.bt-frame { background: var(--panel); border: 1px solid var(--border); border-radius: .6rem; padding: .6rem .8rem; }
443.bt-noise { background: var(--panel-2); border: 1px dashed var(--border); border-radius: .6rem; }
444.bt-noise summary { cursor: pointer; padding: .5rem .8rem; color: var(--muted); font-size: .82rem; font-weight: 650; }
445.bt-noise[open] summary { border-bottom: 1px dashed var(--border); }
446.bt-noise pre { padding: .6rem .8rem; }
447footer { margin-top: 2rem; color: var(--muted); font-size: .82rem; }
448footer code { background: var(--panel-2); border-radius: .3rem; padding: .1rem .35rem; }
449.theme-switch { position: relative; }
450.theme-switch button.toggle { display: inline-flex; align-items: center; justify-content: center; width: 2.2rem; height: 2.2rem; border-radius: .6rem; border: 1px solid var(--border); background: var(--panel); color: var(--muted); cursor: pointer; }
451.theme-switch button.toggle svg { width: 1.1rem; height: 1.1rem; }
452.theme-menu { position: absolute; right: 0; top: calc(100% + .4rem); min-width: 8rem; background: var(--panel); border: 1px solid var(--border); border-radius: .6rem; padding: .3rem; box-shadow: 0 10px 30px rgba(0,0,0,.25); z-index: 10; }
453.theme-menu button { display: block; width: 100%; text-align: left; padding: .45rem .6rem; border: 0; background: none; color: var(--ink); font: inherit; font-size: .85rem; border-radius: .4rem; cursor: pointer; }
454.theme-menu button:hover { background: var(--panel-2); }
455"#;
456
457const THEME_SWITCH_HTML: &str = r#"<div class="theme-switch">
458 <button type="button" class="toggle" id="larust-theme-toggle" aria-haspopup="true" aria-expanded="false" aria-label="Change color theme">
459 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
460 </button>
461 <div class="theme-menu" id="larust-theme-menu" hidden>
462 <button type="button" data-theme-choice="light">Light</button>
463 <button type="button" data-theme-choice="dark">Dark</button>
464 <button type="button" data-theme-choice="system">System</button>
465 </div>
466 </div>"#;
467
468const THEME_SWITCH_SCRIPT: &str = r#"<script>
474(function () {
475 var root = document.documentElement;
476 var STORAGE_KEY = 'larust-debug-theme';
477 function apply(theme) {
478 if (theme === 'dark') { root.setAttribute('data-theme', 'dark'); }
479 else if (theme === 'light') { root.setAttribute('data-theme', 'light'); }
480 else { root.removeAttribute('data-theme'); }
481 }
482 var saved = null;
483 try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
484 apply(saved || 'system');
485
486 var button = document.getElementById('larust-theme-toggle');
487 var menu = document.getElementById('larust-theme-menu');
488 button.addEventListener('click', function () {
489 var opening = menu.hidden;
490 menu.hidden = !opening;
491 button.setAttribute('aria-expanded', String(opening));
492 });
493 Array.prototype.forEach.call(menu.querySelectorAll('[data-theme-choice]'), function (option) {
494 option.addEventListener('click', function () {
495 var theme = option.getAttribute('data-theme-choice');
496 apply(theme);
497 try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
498 menu.hidden = true;
499 button.setAttribute('aria-expanded', 'false');
500 });
501 });
502 document.addEventListener('click', function (event) {
503 if (!menu.hidden && !event.target.closest('.theme-switch')) {
504 menu.hidden = true;
505 button.setAttribute('aria-expanded', 'false');
506 }
507 });
508})();
509</script>"#;
510
511fn escape_html(s: &str) -> String {
512 let mut out = String::with_capacity(s.len());
513 for c in s.chars() {
514 match c {
515 '&' => out.push_str("&"),
516 '<' => out.push_str("<"),
517 '>' => out.push_str(">"),
518 '"' => out.push_str("""),
519 '\'' => out.push_str("'"),
520 _ => out.push(c),
521 }
522 }
523 out
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn is_frame_header_line_matches_a_real_backtrace_frame_header() {
532 assert!(is_frame_header_line(" 0: rust_begin_unwind"));
533 assert!(is_frame_header_line(" 12: core::panicking::panic_fmt"));
534 assert!(!is_frame_header_line(
535 " at /rustc/abc/library/std/src/panicking.rs:665:5"
536 ));
537 assert!(!is_frame_header_line(""));
538 }
539
540 #[test]
541 fn classify_as_runtime_noise_flags_known_stdlib_and_runtime_prefixes() {
542 assert!(classify_as_runtime_noise(
543 " 0: rust_begin_unwind\n at /rustc/abc/library/std/src/panicking.rs:665:5"
544 ));
545 assert!(classify_as_runtime_noise(
546 " 1: core::panicking::panic_fmt"
547 ));
548 assert!(classify_as_runtime_noise(
549 " 2: tokio::runtime::task::core::Core<T,S>::poll"
550 ));
551 assert!(!classify_as_runtime_noise(" 3: demo::handlers::broken"));
552 }
553
554 #[test]
555 fn parse_backtrace_frames_groups_a_multi_line_frame_together() {
556 let text = " 0: rust_begin_unwind\n at /rustc/abc/library/std/src/panicking.rs:665:5\n 1: demo::handlers::broken\n at ./src/handlers.rs:10:5";
557 let frames = parse_backtrace_frames(text);
558 assert_eq!(frames.len(), 2);
559 assert!(frames[0].text.contains("rust_begin_unwind"));
560 assert!(frames[0].text.contains("panicking.rs:665:5"));
561 assert!(frames[1].text.contains("demo::handlers::broken"));
562 }
563
564 #[test]
572 fn parse_backtrace_frames_handles_a_real_captured_backtrace() {
573 let backtrace = std::backtrace::Backtrace::force_capture();
574 let frames = parse_backtrace_frames(&backtrace.to_string());
575 assert!(
576 !frames.is_empty(),
577 "a force-captured backtrace must parse into at least one frame"
578 );
579 assert!(
585 frames.iter().any(|f| f.is_runtime_noise),
586 "a real backtrace must contain at least one recognizable runtime frame"
587 );
588 }
589
590 #[test]
591 fn render_backtrace_frames_html_collapses_consecutive_noisy_frames_into_one_region() {
592 let frames = vec![
593 Frame {
594 text: " 0: rust_begin_unwind".to_string(),
595 is_runtime_noise: true,
596 },
597 Frame {
598 text: " 1: core::panicking::panic_fmt".to_string(),
599 is_runtime_noise: true,
600 },
601 Frame {
602 text: " 2: demo::handlers::broken".to_string(),
603 is_runtime_noise: false,
604 },
605 Frame {
606 text: " 3: tokio::runtime::task::core::Core<T,S>::poll".to_string(),
607 is_runtime_noise: true,
608 },
609 ];
610 let html = render_backtrace_frames_html(&frames);
611 assert_eq!(
612 html.matches("<details class=\"bt-noise\">").count(),
613 2,
614 "two separate noisy runs (leading and trailing) must become two separate collapsed regions, not one"
615 );
616 assert!(html.contains("2 runtime frames hidden"));
617 assert!(html.contains("1 runtime frame hidden"));
618 assert!(html.contains("demo::handlers::broken"));
619 }
620
621 async fn body_text(response: Response) -> String {
635 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
636 .await
637 .unwrap();
638 String::from_utf8(bytes.to_vec()).unwrap()
639 }
640
641 #[tokio::test]
642 async fn debug_page_renders_each_cause_as_its_own_card() {
643 let response = debug_page(
644 StatusCode::INTERNAL_SERVER_ERROR,
645 "Internal Server Error",
646 &["top-level failure".to_string(), "root cause".to_string()],
647 None,
648 );
649 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
650 let body = body_text(response).await;
651 assert!(body.contains("top-level failure"));
652 assert!(body.contains("root cause"));
653 assert!(body.contains("Caused by"));
654 assert!(body.contains("larust-theme-toggle"));
657 }
658
659 #[tokio::test]
660 async fn debug_page_shows_a_hint_when_backtraces_are_disabled() {
661 let response = debug_page(
662 StatusCode::INTERNAL_SERVER_ERROR,
663 "Internal Server Error (panic)",
664 &["boom".to_string()],
665 Some(PanicBacktrace::Disabled),
666 );
667 let body = body_text(response).await;
668 assert!(body.contains("RUST_BACKTRACE=1"));
669 assert!(body.contains("xr dev --debug"));
670 assert!(!body.contains("Caused by"));
673 }
674
675 #[tokio::test]
676 async fn debug_page_shows_a_hint_when_backtraces_are_unsupported() {
677 let response = debug_page(
678 StatusCode::INTERNAL_SERVER_ERROR,
679 "Internal Server Error (panic)",
680 &["boom".to_string()],
681 Some(PanicBacktrace::Unsupported),
682 );
683 let body = body_text(response).await;
684 assert!(body.contains("aren't supported on this platform"));
685 }
686
687 #[tokio::test]
688 async fn debug_page_renders_a_captured_backtrace_with_noisy_frames_collapsed() {
689 let backtrace = concat!(
690 " 0: rust_begin_unwind\n",
691 " at /rustc/abc/library/std/src/panicking.rs:665:5\n",
692 " 1: demo::handlers::broken\n",
693 " at ./src/handlers.rs:10:5\n",
694 " 2: tokio::runtime::task::core::Core<T,S>::poll\n",
695 );
696 let response = debug_page(
697 StatusCode::INTERNAL_SERVER_ERROR,
698 "Internal Server Error (panic)",
699 &["boom".to_string()],
700 Some(PanicBacktrace::Captured(backtrace.to_string())),
701 );
702 let body = body_text(response).await;
703 assert!(body.contains("Stack trace"));
704 assert!(body.contains("demo::handlers::broken"));
705 assert!(body.contains("bt-noise"));
706 assert!(!body.contains("RUST_BACKTRACE=1"));
707 }
708}