Skip to main content

aion_server/control/
guard.rs

1//! The pid-file guard: the live handle on this incarnation's claim.
2//!
3//! The guard is created by the birth claim and held for the whole run scope.
4//! Three things go through it:
5//!
6//! 1. **Stage writes** while the boot works — see [`super::stage`], whose
7//!    reporter shares this guard's record handle so the guard's own copy is
8//!    always the one on disk.
9//! 2. **The bind-time fill**: the bound addresses, the resolved drain window,
10//!    and `state = Serving`.
11//! 3. **The drain flip**: `state = Draining`, written from the signal path
12//!    before the drain itself runs, so a successor's birth claim sees a
13//!    drainer to SUCCEED rather than a server to refuse.
14//!
15//! Every one of them is a compare-and-write under the pid mutation lock: read
16//! the file, verify it still holds THIS incarnation, apply the change, write
17//! atomically. A record that has been replaced by a successor is never
18//! overwritten — the update warns and skips, exactly as the guard's `Drop`
19//! leaves a successor's file alone.
20//!
21//! `Drop` removes the file only while it still holds this INCARNATION — the
22//! identity compare, never whole-record equality, because the record mutates
23//! throughout the incarnation's life (see the [`super::pid_file`] module
24//! ruling).
25
26use std::path::PathBuf;
27use std::sync::{Arc, Mutex, PoisonError};
28
29use tracing::{debug, info, warn};
30
31use super::pid_file::{
32    PidRecord, StaleReconciliation, lock_pid_mutation, pid_file_error, read_path,
33    write_record_atomically,
34};
35use crate::error::ServerError;
36
37/// The outcome of an attempted update of this incarnation's own record.
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub enum RecordUpdate {
40    /// The file still held this incarnation and the change was written.
41    Written,
42    /// This guard holds no claim (an unclaimed boot): there is nothing on
43    /// disk that belongs to this incarnation, so nothing was written.
44    Unclaimed,
45    /// The file no longer holds this incarnation — a successor claimed the
46    /// home, or the record was removed out from under the running server.
47    /// Nothing was written: overwriting would destroy a live server's
48    /// address.
49    NotOurs,
50}
51
52/// Removes the pid file on drop — but only while the file still holds this
53/// guard's INCARNATION, so a lingering guard can never delete a successor's
54/// claim.
55#[derive(Debug)]
56pub struct PidFileGuard {
57    path: PathBuf,
58    /// This incarnation's record as last written. Shared with every
59    /// [`StageReporter`](super::stage::StageReporter) cloned off this guard,
60    /// so a stage written from a blocking store thread is immediately visible
61    /// to the run loop that holds the guard.
62    record: Arc<Mutex<PidRecord>>,
63    reconciliation: StaleReconciliation,
64    /// Whether this guard's incarnation actually holds the claim. False for a
65    /// server that booted UNCLAIMED over another live claimant's record —
66    /// such a guard owns nothing on disk, its `Drop` must remove nothing, and
67    /// its stage writes must touch nothing.
68    holds_claim: bool,
69}
70
71impl PidFileGuard {
72    /// A guard over a record this incarnation genuinely wrote.
73    pub(super) fn claimed(
74        path: PathBuf,
75        record: PidRecord,
76        reconciliation: StaleReconciliation,
77    ) -> Self {
78        Self {
79            path,
80            record: Arc::new(Mutex::new(record)),
81            reconciliation,
82            holds_claim: true,
83        }
84    }
85
86    /// A guard for a boot that runs UNCLAIMED: it owns nothing on disk.
87    pub(super) fn unclaimed(
88        path: PathBuf,
89        record: PidRecord,
90        reconciliation: StaleReconciliation,
91    ) -> Self {
92        Self {
93            path,
94            record: Arc::new(Mutex::new(record)),
95            reconciliation,
96            holds_claim: false,
97        }
98    }
99
100    /// What the birth claim found and reconciled when this guard was created.
101    #[must_use]
102    pub fn reconciliation(&self) -> &StaleReconciliation {
103        &self.reconciliation
104    }
105
106    /// A copy of this incarnation's record as last written.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`ServerError::PidFile`] when the in-process record lock is
111    /// poisoned — a panic inside a previous update, which leaves the copy's
112    /// agreement with the file unproven.
113    pub fn record(&self) -> Result<PidRecord, ServerError> {
114        self.record
115            .lock()
116            .map(|record| record.clone())
117            .map_err(|_poisoned| {
118                pid_file_error(
119                    "the in-process pid record lock is poisoned: a previous update \
120                     panicked, so this incarnation's copy of its own record cannot be \
121                     trusted",
122                )
123            })
124    }
125
126    /// Whether this guard's incarnation holds the home's claim. False for a
127    /// server that booted UNCLAIMED over another live claimant's record
128    /// ([`StaleReconciliation::LiveIncarnationElsewhere`]): the control verbs
129    /// address the recorded server, not this one.
130    #[must_use]
131    pub fn holds_claim(&self) -> bool {
132        self.holds_claim
133    }
134
135    /// Apply `change` to this incarnation's own record and write it.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`ServerError::PidFile`] when the mutation lock cannot be
140    /// taken, the file cannot be read, or the write fails. A record that no
141    /// longer belongs to this incarnation is NOT an error — it is
142    /// [`RecordUpdate::NotOurs`], reported and skipped.
143    pub fn update_own(
144        &self,
145        change: impl FnOnce(&mut PidRecord),
146    ) -> Result<RecordUpdate, ServerError> {
147        update_record(&self.path, &self.record, self.holds_claim, change)
148    }
149
150    /// A cheap, cloneable handle for reporting boot stages from anywhere —
151    /// including a blocking closure on another thread.
152    #[must_use]
153    pub fn stage_reporter(&self) -> super::stage::StageReporter {
154        super::stage::StageReporter::new(
155            self.path.clone(),
156            Arc::clone(&self.record),
157            self.holds_claim,
158        )
159    }
160}
161
162/// The one compare-and-write path: read the file under the mutation lock,
163/// verify it still holds this incarnation, apply `change`, write atomically,
164/// and refresh the in-process copy.
165///
166/// Shared by the guard and every stage reporter cloned off it, so there is
167/// exactly one implementation of "never overwrite a successor".
168pub(super) fn update_record(
169    path: &std::path::Path,
170    shared: &Mutex<PidRecord>,
171    holds_claim: bool,
172    change: impl FnOnce(&mut PidRecord),
173) -> Result<RecordUpdate, ServerError> {
174    if !holds_claim {
175        // An unclaimed boot owns nothing on disk. Debug rather than warn: the
176        // claim already warned once, loudly, about the whole consequence —
177        // repeating it per stage write would bury the boot log.
178        debug!(
179            path = %path.display(),
180            "this incarnation booted unclaimed; not writing its own pid record"
181        );
182        return Ok(RecordUpdate::Unclaimed);
183    }
184    let mut ours = shared.lock().map_err(|_poisoned| {
185        pid_file_error(
186            "the in-process pid record lock is poisoned: a previous update panicked, \
187             so this incarnation's copy of its own record cannot be trusted",
188        )
189    })?;
190    let mutation_lock = lock_pid_mutation(path)?;
191    let current = read_path(path)?;
192    let outcome = match current {
193        Some(current) if current.is_same_incarnation(&ours) => {
194            // The FILE is the base, not the in-process copy: the file is what
195            // every other process reads, and starting from it means an update
196            // can never silently revert a field this incarnation wrote
197            // through a different handle.
198            let mut next = current;
199            change(&mut next);
200            write_record_atomically(path, &next)?;
201            *ours = next;
202            RecordUpdate::Written
203        }
204        Some(_) => {
205            warn!(
206                path = %path.display(),
207                pid = ours.pid,
208                "the pid file no longer holds this incarnation's record (a successor \
209                 claimed the home); leaving it alone rather than overwriting a live \
210                 server's address"
211            );
212            RecordUpdate::NotOurs
213        }
214        None => {
215            warn!(
216                path = %path.display(),
217                pid = ours.pid,
218                "the pid file is GONE although this incarnation holds the claim — it \
219                 was removed out from under the running server; not recreating it \
220                 mid-life, so the disappearance stays visible"
221            );
222            RecordUpdate::NotOurs
223        }
224    };
225    drop(mutation_lock);
226    Ok(outcome)
227}
228
229impl Drop for PidFileGuard {
230    fn drop(&mut self) {
231        if !self.holds_claim {
232            // An unclaimed boot owns nothing on disk: the live claimant's
233            // record must survive this incarnation's exit untouched.
234            return;
235        }
236        // A poisoned in-process lock is recovered rather than propagated
237        // here, and that is a deliberate asymmetry with `update_own`: `Drop`
238        // has nowhere to return an error to, and the datum behind the lock is
239        // a plain record with no invariant a panic could have broken
240        // mid-update (the write is atomic, staged and renamed). Recovering
241        // gives the exit its identity compare; refusing would leave a live
242        // server's record behind on every panic.
243        let ours = self
244            .record
245            .lock()
246            .unwrap_or_else(PoisonError::into_inner)
247            .clone();
248        // The compare-and-delete runs under the mutation lock: by the time
249        // this guard drops, the listeners are closed and a successor can be
250        // mid-claim, and an unlocked read-then-unlink could delete the
251        // successor's freshly renamed record. If the lock cannot be taken,
252        // the file is LEFT IN PLACE rather than removed without proof of
253        // exclusivity — `aion server stop` and the next boot both reconcile
254        // a leftover record as stale, while a deleted live record is a
255        // running server no verb can address.
256        let mutation_lock = match lock_pid_mutation(&self.path) {
257            Ok(lock) => lock,
258            Err(error) => {
259                warn!(
260                    path = %self.path.display(),
261                    %error,
262                    "could not take the pid mutation lock on exit; leaving the pid \
263                     file for stale reconciliation"
264                );
265                return;
266            }
267        };
268        // Absence, unreadability, and unparseability are three different
269        // facts here as everywhere else in this module — each arm names its
270        // own, so an exit-time surprise never passes in silence.
271        let current = match std::fs::read_to_string(&self.path) {
272            Ok(content) => match serde_json::from_str::<PidRecord>(&content) {
273                Ok(record) => Some(record),
274                Err(parse_error) => {
275                    warn!(
276                        path = %self.path.display(),
277                        %parse_error,
278                        "the pid file does not parse at exit time; leaving it for \
279                         stale reconciliation rather than deleting a record this \
280                         binary cannot compare"
281                    );
282                    None
283                }
284            },
285            Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => {
286                // Absence here is NOT benign: this incarnation holds the
287                // claim, a successor's claim REPLACES the record rather than
288                // leaving the path empty, and the stop verb reconciles only
289                // files whose process is proven gone — which cannot be this
290                // one, mid-`Drop`. (A wiped home never gets here either: the
291                // lock open above needs the run directory and fails first.)
292                // What reaches this arm is the pid file alone removed out
293                // from under its live holder — an operator `rm` with the run
294                // directory intact. Warn: no other record of the
295                // disappearance exists, and the next boot's clean-slate
296                // claim would otherwise be indistinguishable from an
297                // ordinary first start.
298                warn!(
299                    path = %self.path.display(),
300                    "the pid file is GONE at exit time although this incarnation \
301                     held the claim — it was removed out from under the running \
302                     server; nothing to reconcile, recording the disappearance"
303                );
304                None
305            }
306            Err(io_error) => {
307                warn!(
308                    path = %self.path.display(),
309                    %io_error,
310                    "could not read the pid file at exit time; leaving it for \
311                     stale reconciliation"
312                );
313                None
314            }
315        };
316        match current {
317            // Identity, not whole-record equality: this incarnation's record
318            // has been rewritten many times since the claim (every stage, the
319            // bind fill, the drain flip), and the only thing that has not
320            // moved — the only thing that MUST not move — is who it names.
321            Some(record) if record.is_same_incarnation(&ours) => {
322                if let Err(io_error) = std::fs::remove_file(&self.path) {
323                    warn!(
324                        path = %self.path.display(),
325                        %io_error,
326                        "could not remove the pid file on exit; `aion server stop` \
327                         and the next boot both reconcile it as stale"
328                    );
329                } else {
330                    info!(path = %self.path.display(), "pid file removed on exit");
331                }
332            }
333            Some(_) => {
334                info!(
335                    path = %self.path.display(),
336                    "pid file now holds a different incarnation's record; leaving it"
337                );
338            }
339            None => {}
340        }
341        drop(mutation_lock);
342    }
343}
344
345#[cfg(test)]
346#[path = "guard_tests.rs"]
347mod tests;