aion-server 0.30.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! Death-note tests: one sequential test drives every observable path — the
//! global panic hook and signal registrations are process-wide, so a single
//! ordered test avoids cross-test interference by construction.
#![cfg(unix)]
// `clippy::panic`: raising a real panic IS the subject under test — the hook
// records it; same file-level test allowance as the failover showcases.
#![allow(clippy::expect_used, clippy::panic)]

use std::path::Path;
use std::sync::mpsc;
use std::time::{Duration, Instant};

use super::{DeathNote, FatalAction};

fn note_content(path: &Path) -> String {
    std::fs::read_to_string(path).unwrap_or_default()
}

/// Wait for `needle` to appear in the note. Signal delivery is asynchronous
/// (kernel → watcher thread → file), so the completion EVENT is the entry
/// itself; this polls for that event under a generous deadline rather than
/// sleeping and hoping.
fn wait_for_entry(path: &Path, needle: &str) -> String {
    let deadline = Instant::now() + Duration::from_secs(10);
    loop {
        let content = note_content(path);
        if content.contains(needle) {
            return content;
        }
        assert!(
            Instant::now() < deadline,
            "timed out waiting for note entry `{needle}`; note so far:\n{content}"
        );
        std::thread::sleep(Duration::from_millis(20));
    }
}

#[test]
fn the_death_note_records_every_observable_path_once_armed() {
    let home = tempfile::tempdir().expect("create a temporary Aion home");

    // Before any note is armed, a breadcrumb is a silent no-op.
    super::breadcrumb("pre-arm breadcrumb must never reach a note");

    let (fatal_tx, fatal_rx) = mpsc::channel();
    let recorder: FatalAction = Box::new(move |signal| {
        // A send failure means the receiver is gone, which only the test's
        // own teardown can cause; nothing to propagate from a signal thread.
        let _ = fatal_tx.send(signal);
    });
    let note = DeathNote::arm_with_fatal_action(home.path(), Some(recorder))
        .expect("arm the death note under the temporary home");
    let path = note.path().to_path_buf();

    // The bracket opens with the process identity — carried by the FRAME
    // every entry wears, not by the ARMED body. One home's note is shared by
    // every server that ever ran against it, and their lives interleave, so
    // the pid has to be on each line rather than only on the bracket's first.
    let content = note_content(&path);
    let framed = format!("pid={} ARMED ", std::process::id());
    assert!(
        content.contains(&framed),
        "the note must open with a pid-framed ARMED line ({framed}), got:\n{content}"
    );

    // A second arm in the same process is refused, not doubled.
    let second = DeathNote::arm(home.path());
    assert!(
        matches!(second, Err(crate::ServerError::DeathNote { .. })),
        "a second arm must refuse with the DeathNote error"
    );

    // An armed note accepts breadcrumbs — and never saw the pre-arm one.
    super::breadcrumb("declared-action start action=probe workflow_id=wf run_id=r1");
    let content = wait_for_entry(&path, "BREADCRUMB declared-action start action=probe");
    assert!(
        !content.contains("pre-arm breadcrumb"),
        "a pre-arm breadcrumb must never be written, got:\n{content}"
    );

    // A panic is recorded with payload and location, and is survivable.
    let unwound = std::panic::catch_unwind(|| panic!("death-note test panic"));
    assert!(unwound.is_err(), "the probe panic must actually unwind");
    let content = wait_for_entry(&path, "PANIC");
    assert!(
        content.contains("death-note test panic"),
        "the PANIC entry must carry the payload, got:\n{content}"
    );
    assert!(
        content.contains("death_note_tests.rs"),
        "the PANIC entry must carry the location, got:\n{content}"
    );

    // 🔴 BEFORE the drain is watching — the whole boot window, minutes long on
    // a large store — SIGTERM ABANDONS THE BOOT. Registering this watcher masks
    // the signal's default action, so a `SIGTERM observed` entry with nobody
    // listening is a signal caught and answered by no one: `aion server stop`
    // against a booting server reported `still draining` at patience against a
    // server that was not draining, and the server came up serving afterwards
    // (measured 2026-08-26).
    signal_hook::low_level::raise(signal_hook::consts::SIGTERM).expect("raise SIGTERM");
    let delivered = fatal_rx
        .recv_timeout(Duration::from_secs(10))
        .expect("a termination signal before the drain is watching must be answered");
    assert_eq!(delivered, signal_hook::consts::SIGTERM);
    let content = wait_for_entry(&path, "SIGNAL SIGTERM received during boot");
    assert!(
        content.contains("the boot is abandoned"),
        "the entry must say what was done about it, got:\n{content}"
    );
    // And the bracket CLOSES here, with the reason. `Drop` never runs on this
    // path in production (the default action ends the process), so without
    // this line the note shows an ARMED bracket that never closed — which
    // every reader is entitled to read as the `kill -9` shape, and `aion
    // server stop` duly reported exactly that about a stop the operator had
    // just asked for.
    let content = wait_for_entry(&path, "DISARMED SIGTERM received before the doors opened");
    assert!(
        content.contains("nothing serving and nothing draining"),
        "the closing line must carry the reason, got:\n{content}"
    );

    // ONCE the run loop is watching, the same signal is an observation again:
    // an entry, and NO fatal action — the graceful drain owns the response.
    note.drain_owns_termination();
    signal_hook::low_level::raise(signal_hook::consts::SIGTERM).expect("raise SIGTERM");
    wait_for_entry(&path, "SIGNAL SIGTERM observed");
    assert!(
        fatal_rx.try_recv().is_err(),
        "once the drain is watching, SIGTERM must never reach the fatal action"
    );

    // SIGHUP takes the fatal arm: entry first, then the (injected) action.
    signal_hook::low_level::raise(signal_hook::consts::SIGHUP).expect("raise SIGHUP");
    let delivered = fatal_rx
        .recv_timeout(Duration::from_secs(10))
        .expect("the fatal action must be invoked for SIGHUP");
    assert_eq!(delivered, signal_hook::consts::SIGHUP);
    wait_for_entry(&path, "SIGNAL SIGHUP received; terminating");

    // Disarm closes the bracket with the caller's reason…
    note.disarm("test disarm: clean exit");
    let content = wait_for_entry(&path, "DISARMED test disarm: clean exit");
    let settled_len = content.len();

    // …and gates the hook off: a later panic writes nothing.
    let unwound = std::panic::catch_unwind(|| panic!("post-disarm panic must not be recorded"));
    assert!(unwound.is_err(), "the post-disarm probe panic must unwind");
    let content = note_content(&path);
    assert_eq!(
        content.len(),
        settled_len,
        "a disarmed note must not accept panic entries, got:\n{content}"
    );

    // …and breadcrumbs are gated off the same way.
    super::breadcrumb("post-disarm breadcrumb must not be recorded");
    let content = note_content(&path);
    assert_eq!(
        content.len(),
        settled_len,
        "a disarmed note must not accept breadcrumb entries, got:\n{content}"
    );
}