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 at BIRTH — before the store is opened, before anything can take
5//! minutes — and it carries the incarnation's whole life: the state it is in
6//! ([`IncarnationState`]), the boot stage it is working through, the
7//! addresses once they are bound, and the drain window once it is known.
8//!
9//! RULING — the record appears at birth, not at bind. A big store takes
10//! minutes of WAL recovery, and a record written only at bind left every
11//! control verb blind for that whole window: `status` said "no server has
12//! claimed this home", `stop` said "nothing to stop" (exit 0), and the
13//! launcher's port probe read the home as empty and spawned ANOTHER server,
14//! which blocked silently on the store writer lock. Four servers stacked
15//! invisibly on 2026-08-26. The birth claim is what makes that impossible:
16//! the second boot's [`claim_at_birth`](super::claim::claim_at_birth) sees a
17//! live booting sibling and REFUSES.
18//!
19//! Every mutation of the file — the birth claim's rename-into-place, each
20//! stage write, the bind-time fill, the drain flip, the stop verb's
21//! compare-and-delete, the guard's own exit-time compare-and-delete — runs
22//! under an exclusive OS file lock ([`std::fs::File::lock`]: `flock` on
23//! Unix, `LockFileEx` on Windows) on a sibling lock file
24//! (`aion-server.pid.lock`). The lock is what makes compare-and-delete
25//! atomic: without it, a successor claiming the home between a remover's
26//! read and its unlink would have ITS record deleted, leaving a live server
27//! no verb can address. The lock file itself is never renamed or removed —
28//! locking the pid file directly would be unsound, because the claim
29//! replaces that path's inode and a lock on the old inode excludes nobody.
30//!
31//! RULING — a lock failure at claim time REFUSES the boot. The lock is the
32//! instrument that keeps one home's records from destroying each other; a
33//! home where it cannot be taken (permissions on the lock file, a filesystem
34//! without advisory locking) is a home where a claim can silently delete a
35//! live server's address, and the honest answer is a refusal naming the
36//! remedy, not a boot that runs without the guarantee. The exit-time guard
37//! is deliberately more lenient (it leaves the file for stale
38//! reconciliation) because at that point refusing helps nobody — the
39//! process is exiting either way.
40//!
41//! RULING — every future field added to [`PidRecord`] is `#[serde(default)]`
42//! on read. The record is a cross-process, cross-version contract: a newer
43//! CLI must be able to read the record an older running server wrote —
44//! upgrade time is exactly when the stop verb matters most — so a missing
45//! field reads as its honest default, never as a parse refusal.
46//!
47//! RULING — records are compared by INCARNATION IDENTITY (pid + start
48//! instant), never by whole-record equality. The record now MUTATES during
49//! the incarnation's life (every stage write bumps `stage_seq`), so a
50//! whole-record compare would make the stop verb's reconciliation and the
51//! guard's exit-time delete miss their own record the moment a stage landed
52//! between the read and the compare — leaving live-looking debris behind
53//! every boot that took long enough to report progress.
54//!
55//! The record is an incarnation identity, never a bare pid: pid, start
56//! instant, and the serving binary's content hash (plus the bound addresses
57//! and build identity, so readers talk to the server that IS running rather
58//! than the one today's config would start). A stale file found at claim
59//! time is reconciled by incarnation check and reported — never silently
60//! overwritten, never trusted. See [`crate::control::incarnation`] for how a
61//! record is verified against the live process table.
62
63use std::net::SocketAddr;
64use std::path::{Path, PathBuf};
65
66use serde::{Deserialize, Serialize};
67
68use crate::error::ServerError;
69
70/// File name of the pid file inside the Aion home's `run/` directory.
71const PID_FILE_NAME: &str = "aion-server.pid";
72
73/// File name of the mutation lock beside the pid file. Held exclusively for
74/// the duration of every pid-file mutation; never renamed, never removed
75/// (unlinking a lock file reintroduces the race the lock exists to close).
76const PID_LOCK_FILE_NAME: &str = "aion-server.pid.lock";
77
78/// Take the exclusive pid-file mutation lock for `pid_path`'s home.
79///
80/// Blocks until the lock is granted. Holders are short but not instantaneous:
81/// the longest section is [`claim_at_birth`](super::claim::claim_at_birth)'s,
82/// which spans the stale-record reconciliation — including an incarnation
83/// probe that refreshes the process-table instrument, tens to hundreds of
84/// milliseconds under load — so waiting is bounded by that, not by a single
85/// syscall.
86///
87/// The lock is released when the returned [`std::fs::File`] drops: closing
88/// the descriptor releases both `flock` (Unix) and `LockFileEx` (Windows)
89/// locks, and neither release path can fail or panic.
90///
91/// # Errors
92///
93/// Returns [`ServerError::PidFile`] when the lock file cannot be created or
94/// the lock cannot be taken.
95pub(super) fn lock_pid_mutation(pid_path: &Path) -> Result<std::fs::File, ServerError> {
96    let lock_path = pid_path.with_file_name(PID_LOCK_FILE_NAME);
97    let file = std::fs::OpenOptions::new()
98        .create(true)
99        .truncate(false)
100        .write(true)
101        .open(&lock_path)
102        .map_err(|io_error| {
103            pid_file_error(format!(
104                "could not open the pid mutation lock `{}`: {io_error}",
105                lock_path.display()
106            ))
107        })?;
108    file.lock().map_err(|io_error| {
109        pid_file_error(format!(
110            "could not take the pid mutation lock `{}`: {io_error}",
111            lock_path.display()
112        ))
113    })?;
114    Ok(file)
115}
116
117/// Where a recorded incarnation is in its own life.
118///
119/// The three states are observable facts about a process, not policy: it is
120/// working through its boot ([`Booting`](Self::Booting)), it has both doors
121/// open ([`Serving`](Self::Serving)), or it has seen a termination signal and
122/// is draining ([`Draining`](Self::Draining)). Every one of them is a state a
123/// control verb must be able to address — that is the whole point of writing
124/// the record at birth.
125#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum IncarnationState {
128    /// The process has claimed the home and is working through its boot: the
129    /// store is opening, WAL recovery is replaying, the engine is recovering
130    /// resident workflows. No listener is bound yet, so the addresses are
131    /// `None` and the doors are shut.
132    Booting,
133    /// Both transport listeners are bound and the server is serving. This is
134    /// the `#[serde(default)]` value on read (module-header ruling): a record
135    /// written by a build that predates this field only ever existed AFTER
136    /// the bind, so `Serving` is what it always meant.
137    #[default]
138    Serving,
139    /// A termination signal has been observed and the drain is running (or
140    /// about to). The record stays in place — a draining server is exactly
141    /// the server `aion server status` must still describe — and a successor
142    /// boot SUCCEEDS it rather than being refused.
143    Draining,
144}
145
146impl IncarnationState {
147    /// The state's operator-facing name, uppercase, as the verbs render it.
148    #[must_use]
149    pub const fn label(self) -> &'static str {
150        match self {
151            Self::Booting => "BOOTING",
152            Self::Serving => "SERVING",
153            Self::Draining => "DRAINING",
154        }
155    }
156}
157
158/// The pid file's content: one JSON object describing the server incarnation
159/// that claimed the home.
160#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
161pub struct PidRecord {
162    /// Operating-system process id of the serving process.
163    pub pid: u32,
164    /// The process start instant in whole seconds since the Unix epoch, read
165    /// through the process-table instrument ([`sysinfo`]) at boot. Verifiers
166    /// compare this against the SAME instrument's answer for the live pid, so
167    /// the check is exact equality, not clock arithmetic: a reused pid wears
168    /// a different start instant.
169    pub started_at_unix_secs: u64,
170    /// SHA-256 of the serving binary's bytes, hashed from the executable path
171    /// at boot. This records WHICH build claimed the home; it is not a
172    /// liveness discriminator (an upgrade that swaps the binary by rename
173    /// changes the file on disk while the process keeps its old image).
174    pub binary_sha256: String,
175    /// Crate version of the serving binary (`CARGO_PKG_VERSION` at build).
176    pub version: String,
177    /// Source commit of the serving binary, from
178    /// [`crate::build_identity::BuildIdentity`].
179    pub commit: String,
180    /// Where this incarnation is in its own life. Defaulted on read
181    /// (module-header ruling) to [`IncarnationState::Serving`].
182    #[serde(default)]
183    pub state: IncarnationState,
184    /// HTTP address the server actually bound — recorded so `status` probes
185    /// the running server's own listener even when the config file has been
186    /// edited since boot. `None` while the incarnation is still
187    /// [`Booting`](IncarnationState::Booting): nothing is bound yet, and an
188    /// address recorded before the bind would be a promise, not a fact.
189    #[serde(default)]
190    pub http_address: Option<SocketAddr>,
191    /// gRPC address the server actually bound, recorded for the same reason
192    /// and `None` for the same window.
193    #[serde(default)]
194    pub grpc_address: Option<SocketAddr>,
195    /// HTTP address this incarnation's already-loaded configuration says it
196    /// WILL bind — a promise by name, recorded at birth precisely because it
197    /// is one. The bound pair above stays the fact about open doors; this
198    /// pair exists so a SECOND boot arriving mid-recovery can decide
199    /// collision against the holder's configuration instead of presuming it:
200    /// two explicit configs on one home with different doors are two servers,
201    /// and the second must boot unclaimed rather than refuse. `None` only in
202    /// a record written by a build that predates the field, where the honest
203    /// reading remains "unknowable, presume collision".
204    #[serde(default)]
205    pub intended_http_address: Option<SocketAddr>,
206    /// gRPC counterpart of `intended_http_address`, same birth, same reason.
207    #[serde(default)]
208    pub intended_grpc_address: Option<SocketAddr>,
209    /// The boot stage this incarnation is working through, as a machine
210    /// token: `config`, `store-open`, `writer-lock-wait`, `wal-recovery`,
211    /// `engine-recovery`, `binding`. `None` once the boot is done.
212    #[serde(default)]
213    pub stage: Option<String>,
214    /// One human sentence about the current stage — "materializing shard 17
215    /// of 64", "waiting 40s on the store writer lock at <path>". The token
216    /// above is what a script keys on; this is what an operator reads.
217    #[serde(default)]
218    pub stage_detail: Option<String>,
219    /// Incremented on EVERY stage write. Readers watch it to distinguish a
220    /// boot that is PROGRESSING from one that is STUCK — two facts a bare
221    /// stage token cannot tell apart, and the difference between "wait" and
222    /// "something is wrong".
223    #[serde(default)]
224    pub stage_seq: u64,
225    /// When the last stage write landed, in whole seconds since the Unix
226    /// epoch. Wall-clock, because the reader is a different process: a
227    /// monotonic instant does not cross the process boundary.
228    #[serde(default)]
229    pub stage_updated_at_unix_secs: u64,
230    /// The drain window this incarnation is actually running with, in whole
231    /// seconds — recorded for the same reason as the addresses: the running
232    /// server's window may have come from its own command line
233    /// (`--drain-timeout`) or from a config file edited since boot, and a
234    /// stop verb that waited out a re-derived value would misreport a healthy
235    /// long drain as "still draining" (or force it on a second invocation).
236    /// Defaulted on read (module-header ruling): a record written by a build
237    /// that predates this field — or by an incarnation still BOOTING, which
238    /// has not resolved its runtime config yet — reads as 0, which patience
239    /// resolution treats as "no recorded window" and falls through to config.
240    #[serde(default)]
241    pub drain_timeout_seconds: u64,
242}
243
244impl PidRecord {
245    /// This record's incarnation identity: the pid and the start instant,
246    /// which together name exactly one process on exactly one boot of the
247    /// machine.
248    ///
249    /// This — never whole-record equality — is what every compare-and-act
250    /// path uses (module-header ruling). The rest of the record mutates
251    /// while the incarnation lives.
252    #[must_use]
253    pub const fn identity(&self) -> (u32, u64) {
254        (self.pid, self.started_at_unix_secs)
255    }
256
257    /// Whether `self` and `other` name the same incarnation.
258    #[must_use]
259    pub fn is_same_incarnation(&self, other: &Self) -> bool {
260        self.identity() == other.identity()
261    }
262
263    /// How long ago this incarnation started, in whole seconds, against the
264    /// reader's own wall clock. Saturating: a record from a machine whose
265    /// clock has since moved backwards reports 0 rather than an absurdity.
266    #[must_use]
267    pub fn running_for_secs(&self) -> u64 {
268        now_unix_secs().saturating_sub(self.started_at_unix_secs)
269    }
270
271    /// How long ago the last stage write landed, in whole seconds, or `None`
272    /// when no stage has ever been written (a record from a build predating
273    /// stages, or an incarnation that never reported one).
274    #[must_use]
275    pub fn stage_age_secs(&self) -> Option<u64> {
276        if self.stage_updated_at_unix_secs == 0 {
277            return None;
278        }
279        Some(now_unix_secs().saturating_sub(self.stage_updated_at_unix_secs))
280    }
281
282    /// The stage rendered for an operator: `wal-recovery — materializing
283    /// shard 17 of 64`, or just the token when no detail was written, or
284    /// `None` when no stage has been reported at all.
285    #[must_use]
286    pub fn stage_line(&self) -> Option<String> {
287        let stage = self.stage.as_ref()?;
288        match &self.stage_detail {
289            Some(detail) => Some(format!("{stage} — {detail}")),
290            None => Some(stage.clone()),
291        }
292    }
293}
294
295/// Whole seconds since the Unix epoch, or 0 when the system clock is set
296/// before the epoch (which no reader can do arithmetic on anyway).
297///
298/// Public because the record's stage timestamps are wall-clock by necessity
299/// (they cross a process boundary), so every reader that renders an age has
300/// to read the same clock the writer did — through this one function, rather
301/// than each growing its own epoch arithmetic.
302#[must_use]
303pub fn now_unix_secs() -> u64 {
304    std::time::SystemTime::now()
305        .duration_since(std::time::UNIX_EPOCH)
306        .map_or(0, |elapsed| elapsed.as_secs())
307}
308
309/// What the birth claim found already sitting at the pid-file path,
310/// reconciled before the new record was written. Reported to the operator
311/// through the boot log; returned so tests can assert the reconciliation
312/// happened rather than trusting the log.
313///
314/// One face is not a reconciliation at all: [`Collision`](Self::Collision)
315/// is what the birth claim converts into a refusal
316/// ([`HomeAlreadyClaimed`](super::claim::HomeAlreadyClaimed)). It lives in
317/// this enum because it is one of the answers "what is already here?" has,
318/// and keeping it out would have meant a second classification pass.
319#[derive(Clone, Debug, Eq, PartialEq)]
320pub enum StaleReconciliation {
321    /// No file was present: the ordinary boot.
322    NonePresent,
323    /// A file was present, its pid is alive, the incarnation MATCHES, it is
324    /// BOOTING or SERVING, and its doors collide with the ones this boot
325    /// intends to open. Nothing is written and the boot is REFUSED: at birth
326    /// no bind has happened, so there is no bind-success argument to overrule
327    /// a live record with, and starting anyway is exactly the invisible
328    /// stacking the birth claim exists to prevent.
329    Collision(PidRecord),
330    /// A file was present but its process is gone — the previous server died
331    /// without removing its record (crash, `SIGKILL`). The record is replaced.
332    DeadIncarnation(PidRecord),
333    /// A file was present and its pid is alive, but the live process's start
334    /// instant does not match the record — the pid was reused by something
335    /// else after the recorded server died. The record is replaced; the
336    /// stranger keeps running untouched.
337    ReusedPid(PidRecord),
338    /// A file was present, its pid is alive, the incarnation MATCHES — but
339    /// it is recorded on DIFFERENT addresses from the ones this boot INTENDS
340    /// to bind, so the two servers do not collide: that server may be serving
341    /// its addresses right now, and this one is about to serve others. The
342    /// claimant boots UNCLAIMED: the first claimant keeps the record and the
343    /// control verbs, the new incarnation serves without them, and the
344    /// warning names the consequence. Neither server's record is ever
345    /// destroyed by the other's boot or exit.
346    LiveIncarnationElsewhere(PidRecord),
347    /// A file was present, its pid is alive, the incarnation MATCHES, and it
348    /// is DRAINING: the recorded server has seen its termination signal and
349    /// is on its way out. This boot SUCCEEDS it — the record is replaced with
350    /// the successor's, and the handover is logged naming both incarnations.
351    /// The drainer's own guard leaves the successor's record alone at exit
352    /// (its compare is by incarnation identity, which no longer matches).
353    SucceededDrainer(PidRecord),
354    /// A file was present but could not be parsed as a [`PidRecord`] (a
355    /// hand-written pid from the pre-verb ritual, or corruption). Replaced.
356    Unreadable {
357        /// Why the existing content did not parse.
358        reason: String,
359    },
360}
361
362/// Absolute path of the pid file under `home`.
363#[must_use]
364pub fn pid_file_path(home: &Path) -> PathBuf {
365    home.join("run").join(PID_FILE_NAME)
366}
367
368/// Read and parse the pid file under `home`.
369///
370/// Returns `Ok(None)` when the file does not exist — the ordinary "no server
371/// has claimed this home" state, distinct from every error.
372///
373/// # Errors
374///
375/// Returns [`ServerError::PidFile`] when the file exists but cannot be read,
376/// or exists and cannot be parsed as a [`PidRecord`].
377pub fn read(home: &Path) -> Result<Option<PidRecord>, ServerError> {
378    read_path(&pid_file_path(home))
379}
380
381/// [`read`] against an explicit path.
382///
383/// # Errors
384///
385/// Returns [`ServerError::PidFile`] when the file exists but cannot be read
386/// or parsed.
387pub(super) fn read_path(path: &Path) -> Result<Option<PidRecord>, ServerError> {
388    let content = match std::fs::read_to_string(path) {
389        Ok(content) => content,
390        Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
391        Err(io_error) => {
392            return Err(pid_file_error(format!(
393                "could not read pid file `{}`: {io_error}",
394                path.display()
395            )));
396        }
397    };
398    let record = serde_json::from_str::<PidRecord>(&content).map_err(|parse_error| {
399        pid_file_error(format!(
400            "pid file `{}` exists but does not parse as a pid record: {parse_error}. \
401             If it was written by hand (the pre-verb restart ritual wrote a bare pid), \
402             remove it and restart the server so the server writes its own record",
403            path.display()
404        ))
405    })?;
406    Ok(Some(record))
407}
408
409/// Write `record` to `path` atomically: staged into a sibling temp file, then
410/// renamed into place. The caller MUST already hold the mutation lock.
411///
412/// # Errors
413///
414/// Returns [`ServerError::PidFile`] when the record cannot be serialized, the
415/// staging file cannot be written, or the rename fails.
416pub(super) fn write_record_atomically(path: &Path, record: &PidRecord) -> Result<(), ServerError> {
417    let content = serde_json::to_string(record).map_err(|serialize_error| {
418        pid_file_error(format!(
419            "could not serialize the pid record: {serialize_error}"
420        ))
421    })?;
422    let temp_path = path.with_extension("pid.tmp");
423    std::fs::write(&temp_path, format!("{content}\n")).map_err(|io_error| {
424        pid_file_error(format!(
425            "could not write pid file staging `{}`: {io_error}",
426            temp_path.display()
427        ))
428    })?;
429    std::fs::rename(&temp_path, path).map_err(|io_error| {
430        pid_file_error(format!(
431            "could not move pid file into place at `{}`: {io_error}",
432            path.display()
433        ))
434    })
435}
436
437/// Remove the pid file under `home` if — and only if — it still holds the
438/// SAME INCARNATION as `record`. Used by the stop verb to reconcile the file
439/// a non-clean exit (forced, killed) left behind, after the process is proven
440/// gone.
441///
442/// The compare is on incarnation identity, never whole-record equality
443/// (module-header ruling): the caller's copy was read before the signal, and
444/// a server that reported a boot stage in between has a record that differs
445/// in `stage_seq` while naming the very same process. A whole-record compare
446/// would leave that server's record behind forever.
447///
448/// Returns whether the file was removed.
449///
450/// # Errors
451///
452/// Returns [`ServerError::PidFile`] when the file cannot be read or removed.
453pub fn remove_if_matches(home: &Path, record: &PidRecord) -> Result<bool, ServerError> {
454    let path = pid_file_path(home);
455    if !path.exists() {
456        // No pid file means nothing to remove and — since the lock file lives
457        // beside it — possibly no run directory to open a lock in either.
458        return Ok(false);
459    }
460    // Read, compare, and unlink under the mutation lock: without it a
461    // successor claiming the home between the read and the unlink would have
462    // ITS record deleted, leaving a live server no verb can address.
463    let mutation_lock = lock_pid_mutation(&path)?;
464    let removed = match read_path(&path)? {
465        Some(current) if current.is_same_incarnation(record) => {
466            std::fs::remove_file(&path).map_err(|io_error| {
467                pid_file_error(format!(
468                    "could not remove pid file `{}`: {io_error}",
469                    path.display()
470                ))
471            })?;
472            true
473        }
474        Some(_) | None => false,
475    };
476    drop(mutation_lock);
477    Ok(removed)
478}
479
480/// Build the [`ServerError`] for a pid-file failure.
481pub(super) fn pid_file_error(message: impl Into<String>) -> ServerError {
482    ServerError::PidFile {
483        message: message.into(),
484    }
485}
486
487#[cfg(test)]
488#[path = "pid_file_tests.rs"]
489mod tests;