aion-server 0.29.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! The pid-file guard: the live handle on this incarnation's claim.
//!
//! The guard is created by the birth claim and held for the whole run scope.
//! Three things go through it:
//!
//! 1. **Stage writes** while the boot works — see [`super::stage`], whose
//!    reporter shares this guard's record handle so the guard's own copy is
//!    always the one on disk.
//! 2. **The bind-time fill**: the bound addresses, the resolved drain window,
//!    and `state = Serving`.
//! 3. **The drain flip**: `state = Draining`, written from the signal path
//!    before the drain itself runs, so a successor's birth claim sees a
//!    drainer to SUCCEED rather than a server to refuse.
//!
//! Every one of them is a compare-and-write under the pid mutation lock: read
//! the file, verify it still holds THIS incarnation, apply the change, write
//! atomically. A record that has been replaced by a successor is never
//! overwritten — the update warns and skips, exactly as the guard's `Drop`
//! leaves a successor's file alone.
//!
//! `Drop` removes the file only while it still holds this INCARNATION — the
//! identity compare, never whole-record equality, because the record mutates
//! throughout the incarnation's life (see the [`super::pid_file`] module
//! ruling).

use std::path::PathBuf;
use std::sync::{Arc, Mutex, PoisonError};

use tracing::{debug, info, warn};

use super::pid_file::{
    PidRecord, StaleReconciliation, lock_pid_mutation, pid_file_error, read_path,
    write_record_atomically,
};
use crate::error::ServerError;

/// The outcome of an attempted update of this incarnation's own record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RecordUpdate {
    /// The file still held this incarnation and the change was written.
    Written,
    /// This guard holds no claim (an unclaimed boot): there is nothing on
    /// disk that belongs to this incarnation, so nothing was written.
    Unclaimed,
    /// The file no longer holds this incarnation — a successor claimed the
    /// home, or the record was removed out from under the running server.
    /// Nothing was written: overwriting would destroy a live server's
    /// address.
    NotOurs,
}

/// Removes the pid file on drop — but only while the file still holds this
/// guard's INCARNATION, so a lingering guard can never delete a successor's
/// claim.
#[derive(Debug)]
pub struct PidFileGuard {
    path: PathBuf,
    /// This incarnation's record as last written. Shared with every
    /// [`StageReporter`](super::stage::StageReporter) cloned off this guard,
    /// so a stage written from a blocking store thread is immediately visible
    /// to the run loop that holds the guard.
    record: Arc<Mutex<PidRecord>>,
    reconciliation: StaleReconciliation,
    /// Whether this guard's incarnation actually holds the claim. False for a
    /// server that booted UNCLAIMED over another live claimant's record —
    /// such a guard owns nothing on disk, its `Drop` must remove nothing, and
    /// its stage writes must touch nothing.
    holds_claim: bool,
}

impl PidFileGuard {
    /// A guard over a record this incarnation genuinely wrote.
    pub(super) fn claimed(
        path: PathBuf,
        record: PidRecord,
        reconciliation: StaleReconciliation,
    ) -> Self {
        Self {
            path,
            record: Arc::new(Mutex::new(record)),
            reconciliation,
            holds_claim: true,
        }
    }

    /// A guard for a boot that runs UNCLAIMED: it owns nothing on disk.
    pub(super) fn unclaimed(
        path: PathBuf,
        record: PidRecord,
        reconciliation: StaleReconciliation,
    ) -> Self {
        Self {
            path,
            record: Arc::new(Mutex::new(record)),
            reconciliation,
            holds_claim: false,
        }
    }

    /// What the birth claim found and reconciled when this guard was created.
    #[must_use]
    pub fn reconciliation(&self) -> &StaleReconciliation {
        &self.reconciliation
    }

    /// A copy of this incarnation's record as last written.
    ///
    /// # Errors
    ///
    /// Returns [`ServerError::PidFile`] when the in-process record lock is
    /// poisoned — a panic inside a previous update, which leaves the copy's
    /// agreement with the file unproven.
    pub fn record(&self) -> Result<PidRecord, ServerError> {
        self.record
            .lock()
            .map(|record| record.clone())
            .map_err(|_poisoned| {
                pid_file_error(
                    "the in-process pid record lock is poisoned: a previous update \
                     panicked, so this incarnation's copy of its own record cannot be \
                     trusted",
                )
            })
    }

    /// Whether this guard's incarnation holds the home's claim. False for a
    /// server that booted UNCLAIMED over another live claimant's record
    /// ([`StaleReconciliation::LiveIncarnationElsewhere`]): the control verbs
    /// address the recorded server, not this one.
    #[must_use]
    pub fn holds_claim(&self) -> bool {
        self.holds_claim
    }

    /// Apply `change` to this incarnation's own record and write it.
    ///
    /// # Errors
    ///
    /// Returns [`ServerError::PidFile`] when the mutation lock cannot be
    /// taken, the file cannot be read, or the write fails. A record that no
    /// longer belongs to this incarnation is NOT an error — it is
    /// [`RecordUpdate::NotOurs`], reported and skipped.
    pub fn update_own(
        &self,
        change: impl FnOnce(&mut PidRecord),
    ) -> Result<RecordUpdate, ServerError> {
        update_record(&self.path, &self.record, self.holds_claim, change)
    }

    /// A cheap, cloneable handle for reporting boot stages from anywhere —
    /// including a blocking closure on another thread.
    #[must_use]
    pub fn stage_reporter(&self) -> super::stage::StageReporter {
        super::stage::StageReporter::new(
            self.path.clone(),
            Arc::clone(&self.record),
            self.holds_claim,
        )
    }
}

/// The one compare-and-write path: read the file under the mutation lock,
/// verify it still holds this incarnation, apply `change`, write atomically,
/// and refresh the in-process copy.
///
/// Shared by the guard and every stage reporter cloned off it, so there is
/// exactly one implementation of "never overwrite a successor".
pub(super) fn update_record(
    path: &std::path::Path,
    shared: &Mutex<PidRecord>,
    holds_claim: bool,
    change: impl FnOnce(&mut PidRecord),
) -> Result<RecordUpdate, ServerError> {
    if !holds_claim {
        // An unclaimed boot owns nothing on disk. Debug rather than warn: the
        // claim already warned once, loudly, about the whole consequence —
        // repeating it per stage write would bury the boot log.
        debug!(
            path = %path.display(),
            "this incarnation booted unclaimed; not writing its own pid record"
        );
        return Ok(RecordUpdate::Unclaimed);
    }
    let mut ours = shared.lock().map_err(|_poisoned| {
        pid_file_error(
            "the in-process pid record lock is poisoned: a previous update panicked, \
             so this incarnation's copy of its own record cannot be trusted",
        )
    })?;
    let mutation_lock = lock_pid_mutation(path)?;
    let current = read_path(path)?;
    let outcome = match current {
        Some(current) if current.is_same_incarnation(&ours) => {
            // The FILE is the base, not the in-process copy: the file is what
            // every other process reads, and starting from it means an update
            // can never silently revert a field this incarnation wrote
            // through a different handle.
            let mut next = current;
            change(&mut next);
            write_record_atomically(path, &next)?;
            *ours = next;
            RecordUpdate::Written
        }
        Some(_) => {
            warn!(
                path = %path.display(),
                pid = ours.pid,
                "the pid file no longer holds this incarnation's record (a successor \
                 claimed the home); leaving it alone rather than overwriting a live \
                 server's address"
            );
            RecordUpdate::NotOurs
        }
        None => {
            warn!(
                path = %path.display(),
                pid = ours.pid,
                "the pid file is GONE although this incarnation holds the claim — it \
                 was removed out from under the running server; not recreating it \
                 mid-life, so the disappearance stays visible"
            );
            RecordUpdate::NotOurs
        }
    };
    drop(mutation_lock);
    Ok(outcome)
}

impl Drop for PidFileGuard {
    fn drop(&mut self) {
        if !self.holds_claim {
            // An unclaimed boot owns nothing on disk: the live claimant's
            // record must survive this incarnation's exit untouched.
            return;
        }
        // A poisoned in-process lock is recovered rather than propagated
        // here, and that is a deliberate asymmetry with `update_own`: `Drop`
        // has nowhere to return an error to, and the datum behind the lock is
        // a plain record with no invariant a panic could have broken
        // mid-update (the write is atomic, staged and renamed). Recovering
        // gives the exit its identity compare; refusing would leave a live
        // server's record behind on every panic.
        let ours = self
            .record
            .lock()
            .unwrap_or_else(PoisonError::into_inner)
            .clone();
        // The compare-and-delete runs under the mutation lock: by the time
        // this guard drops, the listeners are closed and a successor can be
        // mid-claim, and an unlocked read-then-unlink could delete the
        // successor's freshly renamed record. If the lock cannot be taken,
        // the file is LEFT IN PLACE rather than removed without proof of
        // exclusivity — `aion server stop` and the next boot both reconcile
        // a leftover record as stale, while a deleted live record is a
        // running server no verb can address.
        let mutation_lock = match lock_pid_mutation(&self.path) {
            Ok(lock) => lock,
            Err(error) => {
                warn!(
                    path = %self.path.display(),
                    %error,
                    "could not take the pid mutation lock on exit; leaving the pid \
                     file for stale reconciliation"
                );
                return;
            }
        };
        // Absence, unreadability, and unparseability are three different
        // facts here as everywhere else in this module — each arm names its
        // own, so an exit-time surprise never passes in silence.
        let current = match std::fs::read_to_string(&self.path) {
            Ok(content) => match serde_json::from_str::<PidRecord>(&content) {
                Ok(record) => Some(record),
                Err(parse_error) => {
                    warn!(
                        path = %self.path.display(),
                        %parse_error,
                        "the pid file does not parse at exit time; leaving it for \
                         stale reconciliation rather than deleting a record this \
                         binary cannot compare"
                    );
                    None
                }
            },
            Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => {
                // Absence here is NOT benign: this incarnation holds the
                // claim, a successor's claim REPLACES the record rather than
                // leaving the path empty, and the stop verb reconciles only
                // files whose process is proven gone — which cannot be this
                // one, mid-`Drop`. (A wiped home never gets here either: the
                // lock open above needs the run directory and fails first.)
                // What reaches this arm is the pid file alone removed out
                // from under its live holder — an operator `rm` with the run
                // directory intact. Warn: no other record of the
                // disappearance exists, and the next boot's clean-slate
                // claim would otherwise be indistinguishable from an
                // ordinary first start.
                warn!(
                    path = %self.path.display(),
                    "the pid file is GONE at exit time although this incarnation \
                     held the claim — it was removed out from under the running \
                     server; nothing to reconcile, recording the disappearance"
                );
                None
            }
            Err(io_error) => {
                warn!(
                    path = %self.path.display(),
                    %io_error,
                    "could not read the pid file at exit time; leaving it for \
                     stale reconciliation"
                );
                None
            }
        };
        match current {
            // Identity, not whole-record equality: this incarnation's record
            // has been rewritten many times since the claim (every stage, the
            // bind fill, the drain flip), and the only thing that has not
            // moved — the only thing that MUST not move — is who it names.
            Some(record) if record.is_same_incarnation(&ours) => {
                if let Err(io_error) = std::fs::remove_file(&self.path) {
                    warn!(
                        path = %self.path.display(),
                        %io_error,
                        "could not remove the pid file on exit; `aion server stop` \
                         and the next boot both reconcile it as stale"
                    );
                } else {
                    info!(path = %self.path.display(), "pid file removed on exit");
                }
            }
            Some(_) => {
                info!(
                    path = %self.path.display(),
                    "pid file now holds a different incarnation's record; leaving it"
                );
            }
            None => {}
        }
        drop(mutation_lock);
    }
}

#[cfg(test)]
#[path = "guard_tests.rs"]
mod tests;