Skip to main content

aion_server/control/
pid_file.rs

1//! The server's pid file: `<AION_HOME>/run/aion-server.pid`.
2//!
3//! The file is the stop/status verbs' address for the running server. It is
4//! written only after BOTH transport listeners have bound successfully — a
5//! boot that loses the port race must never overwrite the live server's
6//! record — and removed on clean exit by [`PidFileGuard`], which deletes the
7//! file only while it still holds this incarnation's record, so a guard
8//! outliving its server can never destroy a successor's claim.
9//!
10//! Every mutation of the file — the claim's rename-into-place, the stop
11//! verb's compare-and-delete, the guard's own exit-time compare-and-delete —
12//! runs under an exclusive OS file lock ([`std::fs::File::lock`]: `flock` on
13//! Unix, `LockFileEx` on Windows) on a sibling lock file
14//! (`aion-server.pid.lock`). The lock is what makes compare-and-delete
15//! atomic: without it, a successor claiming the home between a remover's
16//! read and its unlink would have ITS record deleted, leaving a live server
17//! no verb can address. The lock file itself is never renamed or removed —
18//! locking the pid file directly would be unsound, because the claim
19//! replaces that path's inode and a lock on the old inode excludes nobody.
20//!
21//! RULING — a lock failure at claim time REFUSES the boot. The lock is the
22//! instrument that keeps one home's records from destroying each other; a
23//! home where it cannot be taken (permissions on the lock file, a filesystem
24//! without advisory locking) is a home where a claim can silently delete a
25//! live server's address, and the honest answer is a refusal naming the
26//! remedy, not a boot that runs without the guarantee. The exit-time guard
27//! is deliberately more lenient (it leaves the file for stale
28//! reconciliation) because at that point refusing helps nobody — the
29//! process is exiting either way.
30//!
31//! RULING — every future field added to [`PidRecord`] is `#[serde(default)]`
32//! on read. The record is a cross-process, cross-version contract: a newer
33//! CLI must be able to read the record an older running server wrote —
34//! upgrade time is exactly when the stop verb matters most — so a missing
35//! field reads as its honest default, never as a parse refusal.
36//!
37//! The record is an incarnation identity, never a bare pid: pid, start
38//! instant, and the serving binary's content hash (plus the bound addresses
39//! and build identity, so readers talk to the server that IS running rather
40//! than the one today's config would start). A stale file found at claim
41//! time is reconciled by incarnation check and reported — never silently
42//! overwritten, never trusted. See [`crate::control::incarnation`] for how a
43//! record is verified against the live process table.
44
45use std::net::SocketAddr;
46use std::path::{Path, PathBuf};
47
48use serde::{Deserialize, Serialize};
49use tracing::{info, warn};
50
51use crate::error::ServerError;
52
53/// File name of the pid file inside the Aion home's `run/` directory.
54const PID_FILE_NAME: &str = "aion-server.pid";
55
56/// File name of the mutation lock beside the pid file. Held exclusively for
57/// the duration of every pid-file mutation; never renamed, never removed
58/// (unlinking a lock file reintroduces the race the lock exists to close).
59const PID_LOCK_FILE_NAME: &str = "aion-server.pid.lock";
60
61/// Take the exclusive pid-file mutation lock for `pid_path`'s home.
62///
63/// Blocks until the lock is granted. Holders are short but not instantaneous:
64/// the longest section is [`claim`]'s, which spans the stale-record
65/// reconciliation — including an incarnation probe that refreshes the
66/// process-table instrument, tens to hundreds of milliseconds under load —
67/// so waiting is bounded by that, not by a single syscall.
68///
69/// The lock is released when the returned [`std::fs::File`] drops: closing
70/// the descriptor releases both `flock` (Unix) and `LockFileEx` (Windows)
71/// locks, and neither release path can fail or panic.
72///
73/// # Errors
74///
75/// Returns [`ServerError::PidFile`] when the lock file cannot be created or
76/// the lock cannot be taken.
77fn lock_pid_mutation(pid_path: &Path) -> Result<std::fs::File, ServerError> {
78    let lock_path = pid_path.with_file_name(PID_LOCK_FILE_NAME);
79    let file = std::fs::OpenOptions::new()
80        .create(true)
81        .truncate(false)
82        .write(true)
83        .open(&lock_path)
84        .map_err(|io_error| {
85            pid_file_error(format!(
86                "could not open the pid mutation lock `{}`: {io_error}",
87                lock_path.display()
88            ))
89        })?;
90    file.lock().map_err(|io_error| {
91        pid_file_error(format!(
92            "could not take the pid mutation lock `{}`: {io_error}",
93            lock_path.display()
94        ))
95    })?;
96    Ok(file)
97}
98
99/// The pid file's content: one JSON object describing the server incarnation
100/// that claimed the home.
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct PidRecord {
103    /// Operating-system process id of the serving process.
104    pub pid: u32,
105    /// The process start instant in whole seconds since the Unix epoch, read
106    /// through the process-table instrument ([`sysinfo`]) at boot. Verifiers
107    /// compare this against the SAME instrument's answer for the live pid, so
108    /// the check is exact equality, not clock arithmetic: a reused pid wears
109    /// a different start instant.
110    pub started_at_unix_secs: u64,
111    /// SHA-256 of the serving binary's bytes, hashed from the executable path
112    /// at boot. This records WHICH build claimed the home; it is not a
113    /// liveness discriminator (an upgrade that swaps the binary by rename
114    /// changes the file on disk while the process keeps its old image).
115    pub binary_sha256: String,
116    /// Crate version of the serving binary (`CARGO_PKG_VERSION` at build).
117    pub version: String,
118    /// Source commit of the serving binary, from
119    /// [`crate::build_identity::BuildIdentity`].
120    pub commit: String,
121    /// HTTP address the server actually bound — recorded so `status` probes
122    /// the running server's own listener even when the config file has been
123    /// edited since boot.
124    pub http_address: SocketAddr,
125    /// gRPC address the server actually bound, recorded for the same reason.
126    pub grpc_address: SocketAddr,
127    /// The drain window this incarnation is actually running with, in whole
128    /// seconds — recorded for the same reason as the addresses: the running
129    /// server's window may have come from its own command line
130    /// (`--drain-timeout`) or from a config file edited since boot, and a
131    /// stop verb that waited out a re-derived value would misreport a healthy
132    /// long drain as "still draining" (or force it on a second invocation).
133    /// Defaulted on read (module-header ruling): a record written by a build
134    /// that predates this field reads as 0, which patience resolution treats
135    /// as "no recorded window" and falls through to config.
136    #[serde(default)]
137    pub drain_timeout_seconds: u64,
138}
139
140/// What [`claim`] found already sitting at the pid-file path, reconciled
141/// before the new record was written. Reported to the operator through the
142/// boot log; returned so tests can assert the reconciliation happened rather
143/// than trusting the log.
144#[derive(Clone, Debug, Eq, PartialEq)]
145pub enum StaleReconciliation {
146    /// No file was present: the ordinary boot.
147    NonePresent,
148    /// A file was present but its process is gone — the previous server died
149    /// without removing its record (crash, `SIGKILL`). The record is replaced.
150    DeadIncarnation(PidRecord),
151    /// A file was present and its pid is alive, but the live process's start
152    /// instant does not match the record — the pid was reused by something
153    /// else after the recorded server died. The record is replaced; the
154    /// stranger keeps running untouched.
155    ReusedPid(PidRecord),
156    /// A file was present, its pid is alive, the incarnation MATCHES, and
157    /// the recorded addresses equal the ones this boot just bound: the
158    /// successful binds prove the recorded server is not serving them, so
159    /// the record is presumed left over from an incompletely observed exit
160    /// and replaced, with the contradiction reported in full.
161    LiveIncarnation(PidRecord),
162    /// A file was present, its pid is alive, the incarnation MATCHES — but
163    /// it is recorded on DIFFERENT addresses from the ones this boot bound,
164    /// so the bind-success argument proves nothing: that server may be
165    /// serving its addresses right now. The claimant boots UNCLAIMED: the
166    /// first claimant keeps the record and the control verbs, the new
167    /// incarnation serves without them, and the warning names the
168    /// consequence. Neither server's record is ever destroyed by the
169    /// other's boot or exit. This face is distinct from
170    /// [`Self::LiveIncarnation`] precisely so callers and tests can tell
171    /// "claimed over debris" from "booted unclaimed" — they are opposites.
172    LiveIncarnationElsewhere(PidRecord),
173    /// A file was present but could not be parsed as a [`PidRecord`] (a
174    /// hand-written pid from the pre-verb ritual, or corruption). Replaced.
175    Unreadable {
176        /// Why the existing content did not parse.
177        reason: String,
178    },
179}
180
181/// Absolute path of the pid file under `home`.
182#[must_use]
183pub fn pid_file_path(home: &Path) -> PathBuf {
184    home.join("run").join(PID_FILE_NAME)
185}
186
187/// Read and parse the pid file under `home`.
188///
189/// Returns `Ok(None)` when the file does not exist — the ordinary "no server
190/// has claimed this home" state, distinct from every error.
191///
192/// # Errors
193///
194/// Returns [`ServerError::PidFile`] when the file exists but cannot be read,
195/// or exists and cannot be parsed as a [`PidRecord`].
196pub fn read(home: &Path) -> Result<Option<PidRecord>, ServerError> {
197    let path = pid_file_path(home);
198    let content = match std::fs::read_to_string(&path) {
199        Ok(content) => content,
200        Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
201        Err(io_error) => {
202            return Err(pid_file_error(format!(
203                "could not read pid file `{}`: {io_error}",
204                path.display()
205            )));
206        }
207    };
208    let record = serde_json::from_str::<PidRecord>(&content).map_err(|parse_error| {
209        pid_file_error(format!(
210            "pid file `{}` exists but does not parse as a pid record: {parse_error}. \
211             If it was written by hand (the pre-verb restart ritual wrote a bare pid), \
212             remove it and restart the server so the server writes its own record",
213            path.display()
214        ))
215    })?;
216    Ok(Some(record))
217}
218
219/// Claim the pid file for `record`: reconcile whatever is already there,
220/// write the new record atomically (temp file + rename), and return the
221/// guard whose `Drop` removes the file on clean exit.
222///
223/// Call only after every listener has bound: successful binds are the proof
224/// that no live server is serving this home's addresses.
225///
226/// # Errors
227///
228/// Returns [`ServerError::PidFile`] when the `run/` directory or the file
229/// cannot be created, written, or renamed into place. A stale file that
230/// cannot be REASONED about (unreadable) is reconciled and reported, never an
231/// error: refusing to boot over a corrupt leftover would turn a crash's
232/// debris into an outage.
233pub fn claim(home: &Path, record: &PidRecord) -> Result<PidFileGuard, ServerError> {
234    let path = pid_file_path(home);
235    let run_dir = path.parent().ok_or_else(|| {
236        pid_file_error(format!(
237            "pid file path `{}` has no parent directory",
238            path.display()
239        ))
240    })?;
241    std::fs::create_dir_all(run_dir).map_err(|io_error| {
242        pid_file_error(format!(
243            "could not create run directory `{}`: {io_error}",
244            run_dir.display()
245        ))
246    })?;
247    // The read (reconcile) and the rename are one critical section: a
248    // remover running between them could compare against the record this
249    // claim is about to replace and unlink the replacement.
250    let mutation_lock = lock_pid_mutation(&path)?;
251    let mut reconciliation = reconcile_existing(&path);
252    if let StaleReconciliation::LiveIncarnation(existing) = &reconciliation
253        && (existing.http_address, existing.grpc_address)
254            != (record.http_address, record.grpc_address)
255    {
256        reconciliation = StaleReconciliation::LiveIncarnationElsewhere(existing.clone());
257    }
258    if let StaleReconciliation::LiveIncarnationElsewhere(existing) = &reconciliation {
259        // The bind-success argument only covers the recorded addresses:
260        // this boot bound ITS addresses, which says nothing about whether
261        // the live recorded server is still serving DIFFERENT ones.
262        // Overwriting here would leave a running server no verb can address,
263        // and refusing to boot would break every legitimate multi-server
264        // home (concurrent test servers, fleet boxes on one default home).
265        // So this boot RUNS UNCLAIMED: the first claimant keeps the record
266        // and the verbs, this incarnation serves without either, and the
267        // warning names the consequence.
268        warn!(
269            path = %path.display(),
270            recorded_pid = existing.pid,
271            recorded_http_address = %existing.http_address,
272            this_http_address = %record.http_address,
273            "a live server already holds this home's pid file on different \
274             addresses; this incarnation boots UNCLAIMED — `aion server \
275             stop`/`status` will address the recorded server, not this one. \
276             Give each server its own AION_HOME to make both addressable"
277        );
278        drop(mutation_lock);
279        return Ok(PidFileGuard {
280            path,
281            record: record.clone(),
282            reconciliation,
283            holds_claim: false,
284        });
285    }
286    report_reconciliation(&path, &reconciliation);
287    let content = serde_json::to_string(record).map_err(|serialize_error| {
288        pid_file_error(format!(
289            "could not serialize the pid record: {serialize_error}"
290        ))
291    })?;
292    let temp_path = path.with_extension("pid.tmp");
293    std::fs::write(&temp_path, format!("{content}\n")).map_err(|io_error| {
294        pid_file_error(format!(
295            "could not write pid file staging `{}`: {io_error}",
296            temp_path.display()
297        ))
298    })?;
299    std::fs::rename(&temp_path, &path).map_err(|io_error| {
300        pid_file_error(format!(
301            "could not move pid file into place at `{}`: {io_error}",
302            path.display()
303        ))
304    })?;
305    drop(mutation_lock);
306    info!(
307        path = %path.display(),
308        pid = record.pid,
309        started_at_unix_secs = record.started_at_unix_secs,
310        "pid file written; this incarnation has claimed the home"
311    );
312    Ok(PidFileGuard {
313        path,
314        record: record.clone(),
315        reconciliation,
316        holds_claim: true,
317    })
318}
319
320/// Remove the pid file under `home` if — and only if — it still holds
321/// exactly `record`. Used by the stop verb to reconcile the file a
322/// non-clean exit (forced, killed) left behind, after the process is proven
323/// gone.
324///
325/// Returns whether the file was removed.
326///
327/// # Errors
328///
329/// Returns [`ServerError::PidFile`] when the file cannot be read or removed.
330pub fn remove_if_matches(home: &Path, record: &PidRecord) -> Result<bool, ServerError> {
331    let path = pid_file_path(home);
332    if !path.exists() {
333        // No pid file means nothing to remove and — since the lock file lives
334        // beside it — possibly no run directory to open a lock in either.
335        return Ok(false);
336    }
337    // Read, compare, and unlink under the mutation lock: without it a
338    // successor claiming the home between the read and the unlink would have
339    // ITS record deleted, leaving a live server no verb can address.
340    let mutation_lock = lock_pid_mutation(&path)?;
341    let removed = match read(home)? {
342        Some(current) if current == *record => {
343            std::fs::remove_file(&path).map_err(|io_error| {
344                pid_file_error(format!(
345                    "could not remove pid file `{}`: {io_error}",
346                    path.display()
347                ))
348            })?;
349            true
350        }
351        Some(_) | None => false,
352    };
353    drop(mutation_lock);
354    Ok(removed)
355}
356
357/// Classify what is already at `path` without touching it.
358fn reconcile_existing(path: &Path) -> StaleReconciliation {
359    let content = match std::fs::read_to_string(path) {
360        Ok(content) => content,
361        Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => {
362            return StaleReconciliation::NonePresent;
363        }
364        Err(io_error) => {
365            return StaleReconciliation::Unreadable {
366                reason: format!("could not read the existing file: {io_error}"),
367            };
368        }
369    };
370    let record = match serde_json::from_str::<PidRecord>(&content) {
371        Ok(record) => record,
372        Err(parse_error) => {
373            return StaleReconciliation::Unreadable {
374                reason: format!("existing content does not parse as a pid record: {parse_error}"),
375            };
376        }
377    };
378    match super::incarnation::probe(&record) {
379        super::incarnation::IncarnationProbe::ProcessGone => {
380            StaleReconciliation::DeadIncarnation(record)
381        }
382        super::incarnation::IncarnationProbe::DifferentIncarnation { .. } => {
383            StaleReconciliation::ReusedPid(record)
384        }
385        super::incarnation::IncarnationProbe::Verified { .. } => {
386            StaleReconciliation::LiveIncarnation(record)
387        }
388    }
389}
390
391/// Say what the reconciliation found, at the severity it deserves.
392fn report_reconciliation(path: &Path, reconciliation: &StaleReconciliation) {
393    match reconciliation {
394        // NonePresent has nothing to report; LiveIncarnationElsewhere is
395        // reported by the claim's own unclaimed-boot warning — the claim
396        // returns before reaching this reporter for that face.
397        StaleReconciliation::NonePresent | StaleReconciliation::LiveIncarnationElsewhere(_) => {}
398        StaleReconciliation::DeadIncarnation(record) => {
399            warn!(
400                path = %path.display(),
401                stale_pid = record.pid,
402                stale_started_at_unix_secs = record.started_at_unix_secs,
403                stale_version = %record.version,
404                "stale pid file: the recorded server (pid gone) died without removing \
405                 its record; replacing it with this incarnation's"
406            );
407        }
408        StaleReconciliation::ReusedPid(record) => {
409            warn!(
410                path = %path.display(),
411                stale_pid = record.pid,
412                stale_started_at_unix_secs = record.started_at_unix_secs,
413                "stale pid file: the recorded pid is alive but belongs to a different \
414                 process (pid reused after the recorded server died); replacing the \
415                 record and leaving that process untouched"
416            );
417        }
418        StaleReconciliation::LiveIncarnation(record) => {
419            warn!(
420                path = %path.display(),
421                recorded_pid = record.pid,
422                recorded_http_address = %record.http_address,
423                "pid file names a LIVE matching incarnation recorded on THESE SAME \
424                 addresses, yet this boot bound them — a contradiction (the recorded \
425                 server cannot be serving them). Replacing the record as debris of an \
426                 incompletely observed exit"
427            );
428        }
429        StaleReconciliation::Unreadable { reason } => {
430            warn!(
431                path = %path.display(),
432                %reason,
433                "pid file present but unreadable as a record (hand-written or \
434                 corrupt); replacing it with this incarnation's"
435            );
436        }
437    }
438}
439
440/// Removes the pid file on drop — but only while the file still holds the
441/// record this guard wrote, so a lingering guard can never delete a
442/// successor incarnation's claim.
443#[derive(Debug)]
444pub struct PidFileGuard {
445    path: PathBuf,
446    record: PidRecord,
447    reconciliation: StaleReconciliation,
448    /// Whether this guard's incarnation actually holds the claim. False for
449    /// a server that booted UNCLAIMED over another live claimant's record —
450    /// such a guard owns nothing on disk and its `Drop` must remove nothing.
451    holds_claim: bool,
452}
453
454impl PidFileGuard {
455    /// What [`claim`] found and reconciled when this guard was created.
456    #[must_use]
457    pub fn reconciliation(&self) -> &StaleReconciliation {
458        &self.reconciliation
459    }
460
461    /// The record this guard wrote.
462    #[must_use]
463    pub fn record(&self) -> &PidRecord {
464        &self.record
465    }
466
467    /// Whether this guard's incarnation holds the home's claim. False for a
468    /// server that booted UNCLAIMED over another live claimant's record
469    /// ([`StaleReconciliation::LiveIncarnationElsewhere`]): the control verbs
470    /// address the recorded server, not this one.
471    #[must_use]
472    pub fn holds_claim(&self) -> bool {
473        self.holds_claim
474    }
475}
476
477impl Drop for PidFileGuard {
478    fn drop(&mut self) {
479        if !self.holds_claim {
480            // An unclaimed boot owns nothing on disk: the live claimant's
481            // record must survive this incarnation's exit untouched.
482            return;
483        }
484        // The compare-and-delete runs under the mutation lock: by the time
485        // this guard drops, the listeners are closed and a successor can be
486        // mid-claim, and an unlocked read-then-unlink could delete the
487        // successor's freshly renamed record. If the lock cannot be taken,
488        // the file is LEFT IN PLACE rather than removed without proof of
489        // exclusivity — `aion server stop` and the next boot both reconcile
490        // a leftover record as stale, while a deleted live record is a
491        // running server no verb can address.
492        let mutation_lock = match lock_pid_mutation(&self.path) {
493            Ok(lock) => lock,
494            Err(error) => {
495                warn!(
496                    path = %self.path.display(),
497                    %error,
498                    "could not take the pid mutation lock on exit; leaving the pid \
499                     file for stale reconciliation"
500                );
501                return;
502            }
503        };
504        // Absence, unreadability, and unparseability are three different
505        // facts here as everywhere else in this module — each arm names its
506        // own, so an exit-time surprise never passes in silence.
507        let current = match std::fs::read_to_string(&self.path) {
508            Ok(content) => match serde_json::from_str::<PidRecord>(&content) {
509                Ok(record) => Some(record),
510                Err(parse_error) => {
511                    warn!(
512                        path = %self.path.display(),
513                        %parse_error,
514                        "the pid file does not parse at exit time; leaving it for \
515                         stale reconciliation rather than deleting a record this \
516                         binary cannot compare"
517                    );
518                    None
519                }
520            },
521            Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => {
522                // Absence here is NOT benign: this incarnation holds the
523                // claim, a successor's claim REPLACES the record rather than
524                // leaving the path empty, and the stop verb reconciles only
525                // files whose process is proven gone — which cannot be this
526                // one, mid-`Drop`. (A wiped home never gets here either: the
527                // lock open above needs the run directory and fails first.)
528                // What reaches this arm is the pid file alone removed out
529                // from under its live holder — an operator `rm` with the run
530                // directory intact. Warn: no other record of the
531                // disappearance exists, and the next boot's clean-slate
532                // claim would otherwise be indistinguishable from an
533                // ordinary first start.
534                warn!(
535                    path = %self.path.display(),
536                    "the pid file is GONE at exit time although this incarnation \
537                     held the claim — it was removed out from under the running \
538                     server; nothing to reconcile, recording the disappearance"
539                );
540                None
541            }
542            Err(io_error) => {
543                warn!(
544                    path = %self.path.display(),
545                    %io_error,
546                    "could not read the pid file at exit time; leaving it for \
547                     stale reconciliation"
548                );
549                None
550            }
551        };
552        match current {
553            Some(record) if record == self.record => {
554                if let Err(io_error) = std::fs::remove_file(&self.path) {
555                    warn!(
556                        path = %self.path.display(),
557                        %io_error,
558                        "could not remove the pid file on exit; `aion server stop` \
559                         and the next boot both reconcile it as stale"
560                    );
561                } else {
562                    info!(path = %self.path.display(), "pid file removed on exit");
563                }
564            }
565            Some(_) => {
566                info!(
567                    path = %self.path.display(),
568                    "pid file now holds a different incarnation's record; leaving it"
569                );
570            }
571            None => {}
572        }
573        drop(mutation_lock);
574    }
575}
576
577/// Build the [`ServerError`] for a pid-file failure.
578fn pid_file_error(message: impl Into<String>) -> ServerError {
579    ServerError::PidFile {
580        message: message.into(),
581    }
582}
583
584#[cfg(test)]
585#[path = "pid_file_tests.rs"]
586mod tests;