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;