Skip to main content

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 death note's path under `home` — the ONE derivation, shared with the
65/// reader ([`crate::control::outcome::read_fate`]) so the writer and the
66/// reader cannot drift apart: a drifted reader would report "the exit left no
67/// recorded account" over a perfectly good note, silently.
68#[must_use]
69pub fn note_path(home: &std::path::Path) -> std::path::PathBuf {
70    home.join("logs").join(NOTE_FILE_NAME)
71}
72
73/// The armed note's breadcrumb route. Work sites reach the note through this
74/// process-global slot because the note's other recorders (panic hook, signal
75/// watcher) are process-global by nature and [`ARMED_ONCE`] already enforces
76/// a single armed note per process. Set by [`DeathNote::arm`], cleared on
77/// disarm; [`breadcrumb`] is a silent no-op in between.
78static BREADCRUMB_SLOT: Mutex<Option<BreadcrumbWriter>> = Mutex::new(None);
79
80/// The pieces [`breadcrumb`] needs: the shared note file and the armed flag
81/// that gates every recorder off after disarm.
82struct BreadcrumbWriter {
83    note: Arc<NoteFile>,
84    armed: Arc<AtomicBool>,
85}
86
87/// Append a `BREADCRUMB` entry to the armed death note, naming work now in
88/// flight so a corpse's last breadcrumb identifies the site that never
89/// finished. A silent no-op when no note is armed (tests, tools, the CLI).
90///
91/// Every note entry is written through to disk (`sync_all`), so call this at
92/// work-start cadence — one entry per declared action body — never inside a
93/// hot loop.
94pub fn breadcrumb(entry: &str) {
95    let slot = BREADCRUMB_SLOT
96        .lock()
97        .unwrap_or_else(PoisonError::into_inner);
98    if let Some(writer) = slot.as_ref()
99        && writer.armed.load(Ordering::SeqCst)
100    {
101        writer.note.write_entry(&format!("BREADCRUMB {entry}"));
102    }
103}
104
105/// What the signal watcher does after noting a signal whose default action
106/// terminates the process. Production re-applies the default action (the
107/// process dies exactly as it would have, but on the record); tests inject a
108/// recorder so the suite is not killed by its own probe.
109type FatalAction = Box<dyn Fn(i32) + Send + Sync>;
110
111/// The armed death note. Constructed by [`DeathNote::arm`] early in the
112/// server run loop; its `Drop` writes the `DISARMED` line, so any ordinary
113/// exit from the run scope — clean shutdown, error return, or a panic
114/// unwinding through it — closes the bracket on the record.
115pub struct DeathNote {
116    note: Arc<NoteFile>,
117    armed: Arc<AtomicBool>,
118    #[cfg(unix)]
119    signals_handle: signal_hook::iterator::Handle,
120    disarm_reason: Option<String>,
121}
122
123impl std::fmt::Debug for DeathNote {
124    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        formatter
126            .debug_struct("DeathNote")
127            .field("path", &self.note.path)
128            .field("armed", &self.armed.load(Ordering::SeqCst))
129            .finish_non_exhaustive()
130    }
131}
132
133impl DeathNote {
134    /// Arm the death note under the resolved Aion home: create
135    /// `<home>/logs/`, open the note file append-only, write the `ARMED`
136    /// line, install the panic hook, and start the signal watcher.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`ServerError::DeathNote`] when a note is already armed in
141    /// this process, when the logs directory or note file cannot be created,
142    /// or when the signal watcher cannot be installed. Arming failures are
143    /// fatal to boot by design: a server that cannot record its own death is
144    /// exactly the server this module exists for.
145    pub fn arm(home: &Path) -> Result<Self, ServerError> {
146        Self::arm_with_fatal_action(home, None)
147    }
148
149    /// [`DeathNote::arm`] with an injectable fatal action. `None` selects the
150    /// production action (re-apply the signal's default, terminating the
151    /// process); tests inject a recorder so the probe signal does not kill
152    /// the test binary. The seam sits on the production path — the injected
153    /// closure replaces only the final kill, never the note write.
154    fn arm_with_fatal_action(
155        home: &Path,
156        fatal_action: Option<FatalAction>,
157    ) -> Result<Self, ServerError> {
158        if ARMED_ONCE
159            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
160            .is_err()
161        {
162            return Err(death_note_error(
163                "a death note is already armed in this process; the panic hook and \
164                 signal registrations are process-global and must not be doubled",
165            ));
166        }
167        let logs_dir = home.join("logs");
168        std::fs::create_dir_all(&logs_dir).map_err(|source| {
169            death_note_error(format!(
170                "could not create logs directory `{}`: {source}",
171                logs_dir.display()
172            ))
173        })?;
174        let path = note_path(home);
175        let file = OpenOptions::new()
176            .create(true)
177            .append(true)
178            .open(&path)
179            .map_err(|source| {
180                death_note_error(format!(
181                    "could not open death note `{}`: {source}",
182                    path.display()
183                ))
184            })?;
185        let note = Arc::new(NoteFile {
186            path,
187            file: Mutex::new(file),
188        });
189        let build = crate::build_identity::BuildIdentity::current();
190        note.write_entry(&format!(
191            "ARMED pid={} version={} build={}",
192            std::process::id(),
193            env!("CARGO_PKG_VERSION"),
194            build.line(),
195        ));
196        let armed = Arc::new(AtomicBool::new(true));
197        *BREADCRUMB_SLOT
198            .lock()
199            .unwrap_or_else(PoisonError::into_inner) = Some(BreadcrumbWriter {
200            note: Arc::clone(&note),
201            armed: Arc::clone(&armed),
202        });
203        install_panic_hook(Arc::clone(&note), Arc::clone(&armed));
204        #[cfg(unix)]
205        let signals_handle = {
206            let action =
207                fatal_action.unwrap_or_else(|| terminate_with_default_action(Arc::clone(&note)));
208            spawn_signal_watcher(Arc::clone(&note), Arc::clone(&armed), action)?
209        };
210        #[cfg(not(unix))]
211        drop(fatal_action);
212        info!(path = %note.path.display(), "death note armed");
213        Ok(Self {
214            note,
215            armed,
216            #[cfg(unix)]
217            signals_handle,
218            disarm_reason: None,
219        })
220    }
221
222    /// Path of the note file, for the startup banner and operator docs.
223    #[must_use]
224    pub fn path(&self) -> &Path {
225        &self.note.path
226    }
227
228    /// Close the bracket explicitly with the run loop's own account of how it
229    /// ended. Consumes the note; the `DISARMED` line is written by `Drop`.
230    pub fn disarm(mut self, reason: &str) {
231        self.disarm_reason = Some(reason.to_owned());
232    }
233
234    /// Append the shutdown outcome record as an `OUTCOME` entry, written
235    /// BEFORE disarm so `aion server stop` can read the drain's full result
236    /// after the process is gone ([`crate::control::outcome`] is the
237    /// reader). The death note already owns the "what ended this process"
238    /// seam; extending it with one entry kind keeps a single file and a
239    /// single writer — no parallel outcome channel.
240    ///
241    /// A record that cannot be serialized is reported and dropped: the
242    /// shutdown must not fail over its own receipt, and the reader treats
243    /// the record's absence as a state it names honestly.
244    pub fn record_outcome(&self, record: &crate::control::outcome::OutcomeRecord) {
245        match crate::control::outcome::render_entry(record) {
246            Ok(entry) => self.note.write_entry(&entry),
247            Err(error) => {
248                error!(
249                    %error,
250                    "could not write the shutdown outcome record into the death note; \
251                     `aion server stop` will report the record as absent"
252                );
253            }
254        }
255    }
256}
257
258impl Drop for DeathNote {
259    fn drop(&mut self) {
260        self.armed.store(false, Ordering::SeqCst);
261        #[cfg(unix)]
262        self.signals_handle.close();
263        let reason = self.disarm_reason.as_deref().unwrap_or(
264            "run scope exited without an explicit disarm (an error return, or an unwind — \
265             see any PANIC entry directly above)",
266        );
267        self.note.write_entry(&format!("DISARMED {reason}"));
268        *BREADCRUMB_SLOT
269            .lock()
270            .unwrap_or_else(PoisonError::into_inner) = None;
271        info!(path = %self.note.path.display(), reason, "death note disarmed");
272    }
273}
274
275/// The note file plus its path, shared by the run scope, the panic hook, and
276/// the signal watcher thread.
277struct NoteFile {
278    path: PathBuf,
279    file: Mutex<File>,
280}
281
282impl NoteFile {
283    /// Append one timestamped entry and force it to disk. A poisoned lock is
284    /// recovered and written through — the writer that poisoned it was a
285    /// panicking thread, which is precisely when this file must still accept
286    /// entries. A write or sync failure falls back to stderr so the entry is
287    /// never silently lost while the process can still say anything at all.
288    fn write_entry(&self, entry: &str) {
289        let line = format!("{} {entry}\n", chrono::Utc::now().to_rfc3339());
290        let mut file = self.file.lock().unwrap_or_else(PoisonError::into_inner);
291        let written = file
292            .write_all(line.as_bytes())
293            .and_then(|()| file.flush())
294            .and_then(|()| file.sync_all());
295        if let Err(io_error) = written {
296            eprintln!(
297                "death note write to `{}` failed ({io_error}); the entry was: {line}",
298                self.path.display()
299            );
300        }
301    }
302}
303
304/// Build the [`ServerError`] for an arming failure.
305fn death_note_error(message: impl Into<String>) -> ServerError {
306    ServerError::DeathNote {
307        message: message.into(),
308    }
309}
310
311/// Install the panic hook, chained IN FRONT of the previously installed hook
312/// so default stderr reporting (and anything a test harness installed) still
313/// runs after the note is on disk.
314fn install_panic_hook(note: Arc<NoteFile>, armed: Arc<AtomicBool>) {
315    let previous = std::panic::take_hook();
316    std::panic::set_hook(Box::new(move |panic_info: &PanicHookInfo<'_>| {
317        if armed.load(Ordering::SeqCst) {
318            note.write_entry(&panic_entry(panic_info));
319        }
320        previous(panic_info);
321    }));
322}
323
324/// Render a panic into one note entry: thread, location, payload, and the
325/// full backtrace (force-captured — by the time this runs, cost is moot).
326fn panic_entry(panic_info: &PanicHookInfo<'_>) -> String {
327    let thread = std::thread::current();
328    let thread_name = thread.name().unwrap_or("<unnamed>").to_owned();
329    let location = panic_info
330        .location()
331        .map_or_else(|| "<unknown location>".to_owned(), ToString::to_string);
332    let payload: &str = panic_info
333        .payload()
334        .downcast_ref::<&str>()
335        .copied()
336        .or_else(|| {
337            panic_info
338                .payload()
339                .downcast_ref::<String>()
340                .map(String::as_str)
341        })
342        .unwrap_or("<non-string panic payload>");
343    let backtrace = std::backtrace::Backtrace::force_capture();
344    format!(
345        "PANIC thread={thread_name} location={location} payload={payload} \
346         (a panic in a spawned task can be survivable; death by panic is this entry \
347         followed by a DISARMED line as the unwind leaves the run scope)\n{backtrace}"
348    )
349}
350
351/// The production fatal action: re-apply the signal's default disposition so
352/// the process terminates exactly as it would have without the watcher. If
353/// the default action somehow fails to terminate, that failure is itself
354/// noted and the process exits loudly with the conventional `128 + signal`
355/// code rather than continuing in an undefined disposition.
356#[cfg(unix)]
357fn terminate_with_default_action(note: Arc<NoteFile>) -> FatalAction {
358    Box::new(move |signal| {
359        if let Err(io_error) = signal_hook::low_level::emulate_default_handler(signal) {
360            note.write_entry(&format!(
361                "SIGNAL-DEFAULT-FAILED signal={} error={io_error}; exiting {} instead",
362                signal_label(signal),
363                128 + signal,
364            ));
365            std::process::exit(128 + signal);
366        }
367    })
368}
369
370/// Spawn the signal watcher thread over the catchable termination set.
371///
372/// `SIGTERM`/`SIGINT` entries are observations (the run loop's graceful drain
373/// owns the response); `SIGHUP`/`SIGQUIT`/`SIGUSR1`/`SIGUSR2` had a default
374/// disposition of silent process death, so those are noted, synced, and then
375/// handed to `fatal_action`. Registration goes through `signal-hook`'s
376/// registry, which chains with tokio's own listeners rather than replacing
377/// them.
378#[cfg(unix)]
379fn spawn_signal_watcher(
380    note: Arc<NoteFile>,
381    armed: Arc<AtomicBool>,
382    fatal_action: FatalAction,
383) -> Result<signal_hook::iterator::Handle, ServerError> {
384    use signal_hook::consts::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2};
385
386    let mut signals =
387        signal_hook::iterator::Signals::new([SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2])
388            .map_err(|source| {
389            death_note_error(format!("could not register the signal watcher: {source}"))
390        })?;
391    let handle = signals.handle();
392    std::thread::Builder::new()
393        .name("aion-death-note".to_owned())
394        .spawn(move || {
395            for signal in &mut signals {
396                if !armed.load(Ordering::SeqCst) {
397                    continue;
398                }
399                let label = signal_label(signal);
400                if signal == SIGTERM || signal == SIGINT {
401                    note.write_entry(&format!(
402                        "SIGNAL {label} observed; the graceful drain owns the response"
403                    ));
404                    warn!(signal = label, "death note observed a termination signal");
405                } else {
406                    note.write_entry(&format!(
407                        "SIGNAL {label} received; terminating with the signal's default action"
408                    ));
409                    error!(
410                        signal = label,
411                        "death note recorded a fatal signal; applying its default action"
412                    );
413                    fatal_action(signal);
414                }
415            }
416        })
417        .map_err(|source| {
418            death_note_error(format!(
419                "could not spawn the signal watcher thread: {source}"
420            ))
421        })?;
422    Ok(handle)
423}
424
425/// Human-readable signal name (`SIGHUP`), falling back to the raw number for
426/// anything the platform table does not name.
427#[cfg(unix)]
428fn signal_label(signal: i32) -> String {
429    signal_hook::low_level::signal_name(signal)
430        .map_or_else(|| format!("signal {signal}"), ToOwned::to_owned)
431}
432
433#[cfg(test)]
434#[path = "death_note_tests.rs"]
435mod tests;