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;