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;