aion_server/death_note.rs
1//! The server's death note: a durable record of what is killing the process
2//! on paths that never reach the graceful-shutdown log.
3//!
4//! Two production servers died on 2026-08-16 (pids 52844 and 2681) with
5//! identical faces: the main log ends mid-flight on routine lines — no drain,
6//! no panic output, no OS crash report. This module exists so the third such
7//! death is a diagnosis instead of a mystery. It arms three recorders around
8//! the run loop, writing to `<AION_HOME>/logs/aion-server.death.log`:
9//!
10//! - an **armed/disarmed bracket**: one line when the server boots, one line
11//! when the run scope exits by ordinary control flow (clean shutdown or an
12//! error return, written by [`DeathNote`]'s `Drop`). A note that is armed
13//! but never disarmed, with no other entry, means the process was destroyed
14//! without the run loop seeing it — the `SIGKILL` / raw-`_exit` class no
15//! in-process handler can observe.
16//! - a **panic hook**, chained in front of the previously installed hook,
17//! that records every panic's thread, location, payload, and backtrace. A
18//! panic in a spawned task can be survivable; the entry says so. Death by
19//! panic reads as a `PANIC` entry followed by the drop-written `DISARMED`
20//! line as the unwind leaves the run scope.
21//! - a **termination-signal watcher** for the catchable signals whose default
22//! action kills the process silently (`SIGHUP`, `SIGQUIT`, `SIGUSR1`,
23//! `SIGUSR2`): the note records the signal, then re-applies the signal's
24//! default action so process behaviour is unchanged — the death is noted,
25//! not prevented. `SIGTERM` and `SIGINT` are recorded as observations only:
26//! the graceful drain in [`crate::run`] owns those two, and both watchers
27//! coexist because tokio and this module register through the same
28//! `signal-hook-registry` chain.
29//! - **breadcrumbs**: work sites call [`breadcrumb`] as they begin a unit of
30//! in-flight work (today: every declared action body the server executes),
31//! so a corpse's LAST breadcrumb names what was running when the process
32//! died. The third 2026-08-16 death (pid 25680, ~16:27Z) happened
33//! mid-declared-action with only an ordinary INFO log line to say so; this
34//! makes that fact a note entry the corpse reader sees first.
35//!
36//! NOT covered, stated plainly: `SIGKILL`/`SIGSTOP` (uncatchable by kernel
37//! contract); the fatal-fault signals (`SIGSEGV`/`SIGBUS`/`SIGILL`/`SIGFPE`)
38//! and raw `abort()`, whose handlers must run in async-signal context and
39//! therefore require `unsafe` this workspace denies (the OS crash reporter
40//! remains the observer for that class — both 2026-08-16 corpses left no
41//! crash report, which is itself evidence against that class); and a direct
42//! `exit()` that bypasses the run scope. With the note armed, each of those
43//! reads as ARMED-without-DISARMED plus the absence of every entry above — a
44//! narrow, named remainder instead of an anonymous death.
45
46use std::fs::{File, OpenOptions};
47use std::io::Write;
48use std::panic::PanicHookInfo;
49use std::path::{Path, PathBuf};
50use std::sync::atomic::{AtomicBool, Ordering};
51use std::sync::{Arc, Mutex, PoisonError};
52
53use tracing::{error, info, warn};
54
55use crate::ServerError;
56
57/// One death note per process: the panic hook and signal registrations are
58/// process-global, so a second armed instance would double-write every entry.
59static ARMED_ONCE: AtomicBool = AtomicBool::new(false);
60
61/// File name of the death note inside the Aion home's `logs/` directory.
62const NOTE_FILE_NAME: &str = "aion-server.death.log";
63
64/// The armed note's breadcrumb route. Work sites reach the note through this
65/// process-global slot because the note's other recorders (panic hook, signal
66/// watcher) are process-global by nature and [`ARMED_ONCE`] already enforces
67/// a single armed note per process. Set by [`DeathNote::arm`], cleared on
68/// disarm; [`breadcrumb`] is a silent no-op in between.
69static BREADCRUMB_SLOT: Mutex<Option<BreadcrumbWriter>> = Mutex::new(None);
70
71/// The pieces [`breadcrumb`] needs: the shared note file and the armed flag
72/// that gates every recorder off after disarm.
73struct BreadcrumbWriter {
74 note: Arc<NoteFile>,
75 armed: Arc<AtomicBool>,
76}
77
78/// Append a `BREADCRUMB` entry to the armed death note, naming work now in
79/// flight so a corpse's last breadcrumb identifies the site that never
80/// finished. A silent no-op when no note is armed (tests, tools, the CLI).
81///
82/// Every note entry is written through to disk (`sync_all`), so call this at
83/// work-start cadence — one entry per declared action body — never inside a
84/// hot loop.
85pub fn breadcrumb(entry: &str) {
86 let slot = BREADCRUMB_SLOT
87 .lock()
88 .unwrap_or_else(PoisonError::into_inner);
89 if let Some(writer) = slot.as_ref() {
90 if writer.armed.load(Ordering::SeqCst) {
91 writer.note.write_entry(&format!("BREADCRUMB {entry}"));
92 }
93 }
94}
95
96/// What the signal watcher does after noting a signal whose default action
97/// terminates the process. Production re-applies the default action (the
98/// process dies exactly as it would have, but on the record); tests inject a
99/// recorder so the suite is not killed by its own probe.
100type FatalAction = Box<dyn Fn(i32) + Send + Sync>;
101
102/// The armed death note. Constructed by [`DeathNote::arm`] early in the
103/// server run loop; its `Drop` writes the `DISARMED` line, so any ordinary
104/// exit from the run scope — clean shutdown, error return, or a panic
105/// unwinding through it — closes the bracket on the record.
106pub struct DeathNote {
107 note: Arc<NoteFile>,
108 armed: Arc<AtomicBool>,
109 #[cfg(unix)]
110 signals_handle: signal_hook::iterator::Handle,
111 disarm_reason: Option<String>,
112}
113
114impl std::fmt::Debug for DeathNote {
115 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 formatter
117 .debug_struct("DeathNote")
118 .field("path", &self.note.path)
119 .field("armed", &self.armed.load(Ordering::SeqCst))
120 .finish_non_exhaustive()
121 }
122}
123
124impl DeathNote {
125 /// Arm the death note under the resolved Aion home: create
126 /// `<home>/logs/`, open the note file append-only, write the `ARMED`
127 /// line, install the panic hook, and start the signal watcher.
128 ///
129 /// # Errors
130 ///
131 /// Returns [`ServerError::DeathNote`] when a note is already armed in
132 /// this process, when the logs directory or note file cannot be created,
133 /// or when the signal watcher cannot be installed. Arming failures are
134 /// fatal to boot by design: a server that cannot record its own death is
135 /// exactly the server this module exists for.
136 pub fn arm(home: &Path) -> Result<Self, ServerError> {
137 Self::arm_with_fatal_action(home, None)
138 }
139
140 /// [`DeathNote::arm`] with an injectable fatal action. `None` selects the
141 /// production action (re-apply the signal's default, terminating the
142 /// process); tests inject a recorder so the probe signal does not kill
143 /// the test binary. The seam sits on the production path — the injected
144 /// closure replaces only the final kill, never the note write.
145 fn arm_with_fatal_action(
146 home: &Path,
147 fatal_action: Option<FatalAction>,
148 ) -> Result<Self, ServerError> {
149 if ARMED_ONCE
150 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
151 .is_err()
152 {
153 return Err(death_note_error(
154 "a death note is already armed in this process; the panic hook and \
155 signal registrations are process-global and must not be doubled",
156 ));
157 }
158 let logs_dir = home.join("logs");
159 std::fs::create_dir_all(&logs_dir).map_err(|source| {
160 death_note_error(format!(
161 "could not create logs directory `{}`: {source}",
162 logs_dir.display()
163 ))
164 })?;
165 let path = logs_dir.join(NOTE_FILE_NAME);
166 let file = OpenOptions::new()
167 .create(true)
168 .append(true)
169 .open(&path)
170 .map_err(|source| {
171 death_note_error(format!(
172 "could not open death note `{}`: {source}",
173 path.display()
174 ))
175 })?;
176 let note = Arc::new(NoteFile {
177 path,
178 file: Mutex::new(file),
179 });
180 let build = crate::build_identity::BuildIdentity::current();
181 note.write_entry(&format!(
182 "ARMED pid={} version={} build={}",
183 std::process::id(),
184 env!("CARGO_PKG_VERSION"),
185 build.line(),
186 ));
187 let armed = Arc::new(AtomicBool::new(true));
188 *BREADCRUMB_SLOT
189 .lock()
190 .unwrap_or_else(PoisonError::into_inner) = Some(BreadcrumbWriter {
191 note: Arc::clone(¬e),
192 armed: Arc::clone(&armed),
193 });
194 install_panic_hook(Arc::clone(¬e), Arc::clone(&armed));
195 #[cfg(unix)]
196 let signals_handle = {
197 let action =
198 fatal_action.unwrap_or_else(|| terminate_with_default_action(Arc::clone(¬e)));
199 spawn_signal_watcher(Arc::clone(¬e), Arc::clone(&armed), action)?
200 };
201 #[cfg(not(unix))]
202 drop(fatal_action);
203 info!(path = %note.path.display(), "death note armed");
204 Ok(Self {
205 note,
206 armed,
207 #[cfg(unix)]
208 signals_handle,
209 disarm_reason: None,
210 })
211 }
212
213 /// Path of the note file, for the startup banner and operator docs.
214 #[must_use]
215 pub fn path(&self) -> &Path {
216 &self.note.path
217 }
218
219 /// Close the bracket explicitly with the run loop's own account of how it
220 /// ended. Consumes the note; the `DISARMED` line is written by `Drop`.
221 pub fn disarm(mut self, reason: &str) {
222 self.disarm_reason = Some(reason.to_owned());
223 }
224}
225
226impl Drop for DeathNote {
227 fn drop(&mut self) {
228 self.armed.store(false, Ordering::SeqCst);
229 #[cfg(unix)]
230 self.signals_handle.close();
231 let reason = self.disarm_reason.as_deref().unwrap_or(
232 "run scope exited without an explicit disarm (an error return, or an unwind — \
233 see any PANIC entry directly above)",
234 );
235 self.note.write_entry(&format!("DISARMED {reason}"));
236 *BREADCRUMB_SLOT
237 .lock()
238 .unwrap_or_else(PoisonError::into_inner) = None;
239 info!(path = %self.note.path.display(), reason, "death note disarmed");
240 }
241}
242
243/// The note file plus its path, shared by the run scope, the panic hook, and
244/// the signal watcher thread.
245struct NoteFile {
246 path: PathBuf,
247 file: Mutex<File>,
248}
249
250impl NoteFile {
251 /// Append one timestamped entry and force it to disk. A poisoned lock is
252 /// recovered and written through — the writer that poisoned it was a
253 /// panicking thread, which is precisely when this file must still accept
254 /// entries. A write or sync failure falls back to stderr so the entry is
255 /// never silently lost while the process can still say anything at all.
256 fn write_entry(&self, entry: &str) {
257 let line = format!("{} {entry}\n", chrono::Utc::now().to_rfc3339());
258 let mut file = self.file.lock().unwrap_or_else(PoisonError::into_inner);
259 let written = file
260 .write_all(line.as_bytes())
261 .and_then(|()| file.flush())
262 .and_then(|()| file.sync_all());
263 if let Err(io_error) = written {
264 eprintln!(
265 "death note write to `{}` failed ({io_error}); the entry was: {line}",
266 self.path.display()
267 );
268 }
269 }
270}
271
272/// Build the [`ServerError`] for an arming failure.
273fn death_note_error(message: impl Into<String>) -> ServerError {
274 ServerError::DeathNote {
275 message: message.into(),
276 }
277}
278
279/// Install the panic hook, chained IN FRONT of the previously installed hook
280/// so default stderr reporting (and anything a test harness installed) still
281/// runs after the note is on disk.
282fn install_panic_hook(note: Arc<NoteFile>, armed: Arc<AtomicBool>) {
283 let previous = std::panic::take_hook();
284 std::panic::set_hook(Box::new(move |panic_info: &PanicHookInfo<'_>| {
285 if armed.load(Ordering::SeqCst) {
286 note.write_entry(&panic_entry(panic_info));
287 }
288 previous(panic_info);
289 }));
290}
291
292/// Render a panic into one note entry: thread, location, payload, and the
293/// full backtrace (force-captured — by the time this runs, cost is moot).
294fn panic_entry(panic_info: &PanicHookInfo<'_>) -> String {
295 let thread = std::thread::current();
296 let thread_name = thread.name().unwrap_or("<unnamed>").to_owned();
297 let location = panic_info
298 .location()
299 .map_or_else(|| "<unknown location>".to_owned(), ToString::to_string);
300 let payload: &str = panic_info
301 .payload()
302 .downcast_ref::<&str>()
303 .copied()
304 .or_else(|| {
305 panic_info
306 .payload()
307 .downcast_ref::<String>()
308 .map(String::as_str)
309 })
310 .unwrap_or("<non-string panic payload>");
311 let backtrace = std::backtrace::Backtrace::force_capture();
312 format!(
313 "PANIC thread={thread_name} location={location} payload={payload} \
314 (a panic in a spawned task can be survivable; death by panic is this entry \
315 followed by a DISARMED line as the unwind leaves the run scope)\n{backtrace}"
316 )
317}
318
319/// The production fatal action: re-apply the signal's default disposition so
320/// the process terminates exactly as it would have without the watcher. If
321/// the default action somehow fails to terminate, that failure is itself
322/// noted and the process exits loudly with the conventional `128 + signal`
323/// code rather than continuing in an undefined disposition.
324#[cfg(unix)]
325fn terminate_with_default_action(note: Arc<NoteFile>) -> FatalAction {
326 Box::new(move |signal| {
327 if let Err(io_error) = signal_hook::low_level::emulate_default_handler(signal) {
328 note.write_entry(&format!(
329 "SIGNAL-DEFAULT-FAILED signal={} error={io_error}; exiting {} instead",
330 signal_label(signal),
331 128 + signal,
332 ));
333 std::process::exit(128 + signal);
334 }
335 })
336}
337
338/// Spawn the signal watcher thread over the catchable termination set.
339///
340/// `SIGTERM`/`SIGINT` entries are observations (the run loop's graceful drain
341/// owns the response); `SIGHUP`/`SIGQUIT`/`SIGUSR1`/`SIGUSR2` had a default
342/// disposition of silent process death, so those are noted, synced, and then
343/// handed to `fatal_action`. Registration goes through `signal-hook`'s
344/// registry, which chains with tokio's own listeners rather than replacing
345/// them.
346#[cfg(unix)]
347fn spawn_signal_watcher(
348 note: Arc<NoteFile>,
349 armed: Arc<AtomicBool>,
350 fatal_action: FatalAction,
351) -> Result<signal_hook::iterator::Handle, ServerError> {
352 use signal_hook::consts::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2};
353
354 let mut signals =
355 signal_hook::iterator::Signals::new([SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2])
356 .map_err(|source| {
357 death_note_error(format!("could not register the signal watcher: {source}"))
358 })?;
359 let handle = signals.handle();
360 std::thread::Builder::new()
361 .name("aion-death-note".to_owned())
362 .spawn(move || {
363 for signal in &mut signals {
364 if !armed.load(Ordering::SeqCst) {
365 continue;
366 }
367 let label = signal_label(signal);
368 if signal == SIGTERM || signal == SIGINT {
369 note.write_entry(&format!(
370 "SIGNAL {label} observed; the graceful drain owns the response"
371 ));
372 warn!(signal = label, "death note observed a termination signal");
373 } else {
374 note.write_entry(&format!(
375 "SIGNAL {label} received; terminating with the signal's default action"
376 ));
377 error!(
378 signal = label,
379 "death note recorded a fatal signal; applying its default action"
380 );
381 fatal_action(signal);
382 }
383 }
384 })
385 .map_err(|source| {
386 death_note_error(format!(
387 "could not spawn the signal watcher thread: {source}"
388 ))
389 })?;
390 Ok(handle)
391}
392
393/// Human-readable signal name (`SIGHUP`), falling back to the raw number for
394/// anything the platform table does not name.
395#[cfg(unix)]
396fn signal_label(signal: i32) -> String {
397 signal_hook::low_level::signal_name(signal)
398 .map_or_else(|| format!("signal {signal}"), ToOwned::to_owned)
399}
400
401#[cfg(test)]
402#[path = "death_note_tests.rs"]
403mod tests;