Skip to main content

reifydb_runtime/fatal/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4//! Process-wide fatal handling: either the database is healthy or it does not run at all.
5//!
6//! Anything unexpected - a panic on any thread, a violated invariant, an error that has no handler - renders one
7//! ticket-grade report and aborts. The report is the only artifact a maintainer gets, so it carries the error id,
8//! the origin, the thread, the build, and the real stack.
9//!
10//! The stack is why this is a panic hook rather than a `catch_unwind` wrapper: a hook runs *before* unwinding, so
11//! `Backtrace::force_capture` there sees the panicking frames. Capturing in a `catch_unwind` arm sees only the
12//! landing pad, because the frames it should have shown are already gone.
13//!
14//! Arming is configuration and defaults to on. Disarming exists for tests that panic on purpose.
15
16pub mod report;
17
18use std::{
19	any::Any,
20	backtrace::Backtrace,
21	env,
22	io::{self, Write},
23	panic::{self, PanicHookInfo},
24	process,
25	sync::{
26		Once,
27		atomic::{AtomicBool, Ordering},
28	},
29};
30
31use tracing::error;
32
33use crate::fatal::report::{FatalKind, FatalReport, Origin};
34
35static ARMED: AtomicBool = AtomicBool::new(true);
36static INSTALLED: Once = Once::new();
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct FatalConfig {
40	pub armed: bool,
41}
42
43impl Default for FatalConfig {
44	fn default() -> Self {
45		Self {
46			armed: env_armed().unwrap_or(true),
47		}
48	}
49}
50
51impl FatalConfig {
52	pub fn armed(armed: bool) -> Self {
53		Self {
54			armed,
55		}
56	}
57
58	pub fn disarmed() -> Self {
59		Self::armed(false)
60	}
61}
62
63fn env_armed() -> Option<bool> {
64	match env::var("REIFYDB_FATAL").ok()?.as_str() {
65		"0" | "off" | "false" => Some(false),
66		"1" | "on" | "true" => Some(true),
67		_ => None,
68	}
69}
70
71pub fn install(config: FatalConfig) {
72	ARMED.store(config.armed, Ordering::Release);
73	INSTALLED.call_once(|| {
74		panic::set_hook(Box::new(on_panic));
75	});
76}
77
78pub fn arm() {
79	ARMED.store(true, Ordering::Release);
80}
81
82pub fn disarm() {
83	ARMED.store(false, Ordering::Release);
84}
85
86pub fn is_armed() -> bool {
87	ARMED.load(Ordering::Acquire)
88}
89
90pub fn emit(report: &FatalReport) {
91	let rendered = report.render();
92	error!(
93		fatal.id = %report.error_id(),
94		fatal.kind = report.kind.as_str(),
95		fatal.thread = %report.thread_name,
96		"{}",
97		report.reason
98	);
99	let mut err = io::stderr().lock();
100	let _ = writeln!(err, "{}", rendered);
101	let _ = err.flush();
102}
103
104pub fn fatal(report: FatalReport) -> ! {
105	emit(&report);
106	process::abort()
107}
108
109fn on_panic(info: &PanicHookInfo<'_>) {
110	let backtrace = Backtrace::force_capture();
111	let mut report = FatalReport::new(FatalKind::Panic, panic_message(info));
112	if let Some(location) = info.location() {
113		report = report.origin(Origin::new(location.file(), location.line(), location.column()));
114	}
115	if !is_armed() {
116		emit(&report.backtrace(backtrace.to_string()));
117		return;
118	}
119	fatal(report.backtrace(backtrace.to_string()))
120}
121
122pub fn panic_message(info: &PanicHookInfo<'_>) -> String {
123	let payload = info.payload();
124	if let Some(message) = payload.downcast_ref::<&'static str>() {
125		(*message).to_string()
126	} else if let Some(message) = payload.downcast_ref::<String>() {
127		message.clone()
128	} else {
129		"<non-string panic payload>".to_string()
130	}
131}
132
133pub fn describe_payload(payload: &Box<dyn Any + Send>) -> String {
134	if let Some(message) = payload.downcast_ref::<&'static str>() {
135		(*message).to_string()
136	} else if let Some(message) = payload.downcast_ref::<String>() {
137		message.clone()
138	} else {
139		"<non-string panic payload>".to_string()
140	}
141}
142
143#[macro_export]
144macro_rules! fatal {
145	($reason:expr) => {
146		$crate::fatal::fatal(
147			$crate::fatal::report::FatalReport::new($crate::fatal::report::FatalKind::Invariant, $reason)
148				.origin($crate::fatal::report::Origin::new(file!(), line!(), column!()))
149				.backtrace(std::backtrace::Backtrace::force_capture().to_string()),
150		)
151	};
152	($fmt:expr, $($arg:tt)*) => {
153		$crate::fatal::fatal(
154			$crate::fatal::report::FatalReport::new($crate::fatal::report::FatalKind::Invariant, format!($fmt, $($arg)*))
155				.origin($crate::fatal::report::Origin::new(file!(), line!(), column!()))
156				.backtrace(std::backtrace::Backtrace::force_capture().to_string()),
157		)
158	};
159}
160
161#[macro_export]
162macro_rules! fatal_on_err {
163	($expr:expr) => {
164		match $expr {
165			Ok(value) => value,
166			Err(err) => $crate::fatal::fatal(
167				$crate::fatal::report::FatalReport::new(
168					$crate::fatal::report::FatalKind::Error,
169					format!("{:?}", err),
170				)
171				.origin($crate::fatal::report::Origin::new(file!(), line!(), column!()))
172				.backtrace(std::backtrace::Backtrace::force_capture().to_string()),
173			),
174		}
175	};
176	($expr:expr, $component:expr) => {
177		match $expr {
178			Ok(value) => value,
179			Err(err) => $crate::fatal::fatal(
180				$crate::fatal::report::FatalReport::new(
181					$crate::fatal::report::FatalKind::Error,
182					format!("{:?}", err),
183				)
184				.component($component)
185				.origin($crate::fatal::report::Origin::new(file!(), line!(), column!()))
186				.backtrace(std::backtrace::Backtrace::force_capture().to_string()),
187			),
188		}
189	};
190}
191
192#[cfg(test)]
193mod tests {
194	use super::*;
195
196	#[test]
197	fn the_default_config_is_armed_because_a_forgotten_flag_must_not_reopen_the_hole() {
198		// A default of off would leave a deployment that never sets the flag swallowing exactly like before.
199		assert!(FatalConfig::default().armed);
200	}
201
202	#[test]
203	fn the_env_override_only_accepts_known_spellings() {
204		// An unparsed value must fall through to the armed default rather than silently disarming.
205		assert_eq!(FatalConfig::disarmed().armed, false);
206		assert!(FatalConfig::armed(true).armed);
207	}
208
209	#[test]
210	fn a_string_panic_payload_survives_into_the_reason() {
211		// Most sites today drop the payload with `|_|`, which is what makes their logs useless.
212		let payload: Box<dyn Any + Send> = Box::new("boom".to_string());
213
214		assert_eq!(describe_payload(&payload), "boom");
215	}
216
217	#[test]
218	fn a_static_str_panic_payload_survives_into_the_reason() {
219		// `panic!("literal")` produces &'static str, not String, and downcasting only one of the two loses half
220		// the panics.
221		let payload: Box<dyn Any + Send> = Box::new("boom");
222
223		assert_eq!(describe_payload(&payload), "boom");
224	}
225
226	#[test]
227	fn an_opaque_panic_payload_is_named_rather_than_rendered_empty() {
228		// panic_any with a custom type lands here, and an empty reason reads as "no reason" instead of
229		// "unprintable".
230		let payload: Box<dyn Any + Send> = Box::new(42u32);
231
232		assert_eq!(describe_payload(&payload), "<non-string panic payload>");
233	}
234}