use std::fs::{File, OpenOptions};
use std::io::Write;
use std::panic::PanicHookInfo;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use tracing::{error, info, warn};
use crate::ServerError;
static ARMED_ONCE: AtomicBool = AtomicBool::new(false);
const NOTE_FILE_NAME: &str = "aion-server.death.log";
#[must_use]
pub fn note_path(home: &std::path::Path) -> std::path::PathBuf {
home.join("logs").join(NOTE_FILE_NAME)
}
static BREADCRUMB_SLOT: Mutex<Option<BreadcrumbWriter>> = Mutex::new(None);
struct BreadcrumbWriter {
note: Arc<NoteFile>,
armed: Arc<AtomicBool>,
}
pub fn breadcrumb(entry: &str) {
let slot = BREADCRUMB_SLOT
.lock()
.unwrap_or_else(PoisonError::into_inner);
if let Some(writer) = slot.as_ref()
&& writer.armed.load(Ordering::SeqCst)
{
writer.note.write_entry(&format!("BREADCRUMB {entry}"));
}
}
type FatalAction = Box<dyn Fn(i32) + Send + Sync>;
pub struct DeathNote {
note: Arc<NoteFile>,
armed: Arc<AtomicBool>,
#[cfg(unix)]
signals_handle: signal_hook::iterator::Handle,
disarm_reason: Option<String>,
}
impl std::fmt::Debug for DeathNote {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("DeathNote")
.field("path", &self.note.path)
.field("armed", &self.armed.load(Ordering::SeqCst))
.finish_non_exhaustive()
}
}
impl DeathNote {
pub fn arm(home: &Path) -> Result<Self, ServerError> {
Self::arm_with_fatal_action(home, None)
}
fn arm_with_fatal_action(
home: &Path,
fatal_action: Option<FatalAction>,
) -> Result<Self, ServerError> {
if ARMED_ONCE
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return Err(death_note_error(
"a death note is already armed in this process; the panic hook and \
signal registrations are process-global and must not be doubled",
));
}
let logs_dir = home.join("logs");
std::fs::create_dir_all(&logs_dir).map_err(|source| {
death_note_error(format!(
"could not create logs directory `{}`: {source}",
logs_dir.display()
))
})?;
let path = note_path(home);
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|source| {
death_note_error(format!(
"could not open death note `{}`: {source}",
path.display()
))
})?;
let note = Arc::new(NoteFile {
path,
file: Mutex::new(file),
});
let build = crate::build_identity::BuildIdentity::current();
note.write_entry(&format!(
"ARMED pid={} version={} build={}",
std::process::id(),
env!("CARGO_PKG_VERSION"),
build.line(),
));
let armed = Arc::new(AtomicBool::new(true));
*BREADCRUMB_SLOT
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(BreadcrumbWriter {
note: Arc::clone(¬e),
armed: Arc::clone(&armed),
});
install_panic_hook(Arc::clone(¬e), Arc::clone(&armed));
#[cfg(unix)]
let signals_handle = {
let action =
fatal_action.unwrap_or_else(|| terminate_with_default_action(Arc::clone(¬e)));
spawn_signal_watcher(Arc::clone(¬e), Arc::clone(&armed), action)?
};
#[cfg(not(unix))]
drop(fatal_action);
info!(path = %note.path.display(), "death note armed");
Ok(Self {
note,
armed,
#[cfg(unix)]
signals_handle,
disarm_reason: None,
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.note.path
}
pub fn disarm(mut self, reason: &str) {
self.disarm_reason = Some(reason.to_owned());
}
pub fn record_outcome(&self, record: &crate::control::outcome::OutcomeRecord) {
match crate::control::outcome::render_entry(record) {
Ok(entry) => self.note.write_entry(&entry),
Err(error) => {
error!(
%error,
"could not write the shutdown outcome record into the death note; \
`aion server stop` will report the record as absent"
);
}
}
}
}
impl Drop for DeathNote {
fn drop(&mut self) {
self.armed.store(false, Ordering::SeqCst);
#[cfg(unix)]
self.signals_handle.close();
let reason = self.disarm_reason.as_deref().unwrap_or(
"run scope exited without an explicit disarm (an error return, or an unwind — \
see any PANIC entry directly above)",
);
self.note.write_entry(&format!("DISARMED {reason}"));
*BREADCRUMB_SLOT
.lock()
.unwrap_or_else(PoisonError::into_inner) = None;
info!(path = %self.note.path.display(), reason, "death note disarmed");
}
}
struct NoteFile {
path: PathBuf,
file: Mutex<File>,
}
impl NoteFile {
fn write_entry(&self, entry: &str) {
let line = format!("{} {entry}\n", chrono::Utc::now().to_rfc3339());
let mut file = self.file.lock().unwrap_or_else(PoisonError::into_inner);
let written = file
.write_all(line.as_bytes())
.and_then(|()| file.flush())
.and_then(|()| file.sync_all());
if let Err(io_error) = written {
eprintln!(
"death note write to `{}` failed ({io_error}); the entry was: {line}",
self.path.display()
);
}
}
}
fn death_note_error(message: impl Into<String>) -> ServerError {
ServerError::DeathNote {
message: message.into(),
}
}
fn install_panic_hook(note: Arc<NoteFile>, armed: Arc<AtomicBool>) {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info: &PanicHookInfo<'_>| {
if armed.load(Ordering::SeqCst) {
note.write_entry(&panic_entry(panic_info));
}
previous(panic_info);
}));
}
fn panic_entry(panic_info: &PanicHookInfo<'_>) -> String {
let thread = std::thread::current();
let thread_name = thread.name().unwrap_or("<unnamed>").to_owned();
let location = panic_info
.location()
.map_or_else(|| "<unknown location>".to_owned(), ToString::to_string);
let payload: &str = panic_info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| {
panic_info
.payload()
.downcast_ref::<String>()
.map(String::as_str)
})
.unwrap_or("<non-string panic payload>");
let backtrace = std::backtrace::Backtrace::force_capture();
format!(
"PANIC thread={thread_name} location={location} payload={payload} \
(a panic in a spawned task can be survivable; death by panic is this entry \
followed by a DISARMED line as the unwind leaves the run scope)\n{backtrace}"
)
}
#[cfg(unix)]
fn terminate_with_default_action(note: Arc<NoteFile>) -> FatalAction {
Box::new(move |signal| {
if let Err(io_error) = signal_hook::low_level::emulate_default_handler(signal) {
note.write_entry(&format!(
"SIGNAL-DEFAULT-FAILED signal={} error={io_error}; exiting {} instead",
signal_label(signal),
128 + signal,
));
std::process::exit(128 + signal);
}
})
}
#[cfg(unix)]
fn spawn_signal_watcher(
note: Arc<NoteFile>,
armed: Arc<AtomicBool>,
fatal_action: FatalAction,
) -> Result<signal_hook::iterator::Handle, ServerError> {
use signal_hook::consts::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2};
let mut signals =
signal_hook::iterator::Signals::new([SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2])
.map_err(|source| {
death_note_error(format!("could not register the signal watcher: {source}"))
})?;
let handle = signals.handle();
std::thread::Builder::new()
.name("aion-death-note".to_owned())
.spawn(move || {
for signal in &mut signals {
if !armed.load(Ordering::SeqCst) {
continue;
}
let label = signal_label(signal);
if signal == SIGTERM || signal == SIGINT {
note.write_entry(&format!(
"SIGNAL {label} observed; the graceful drain owns the response"
));
warn!(signal = label, "death note observed a termination signal");
} else {
note.write_entry(&format!(
"SIGNAL {label} received; terminating with the signal's default action"
));
error!(
signal = label,
"death note recorded a fatal signal; applying its default action"
);
fatal_action(signal);
}
}
})
.map_err(|source| {
death_note_error(format!(
"could not spawn the signal watcher thread: {source}"
))
})?;
Ok(handle)
}
#[cfg(unix)]
fn signal_label(signal: i32) -> String {
signal_hook::low_level::signal_name(signal)
.map_or_else(|| format!("signal {signal}"), ToOwned::to_owned)
}
#[cfg(test)]
#[path = "death_note_tests.rs"]
mod tests;