Skip to main content

aion_server/control/
claim.rs

1//! The birth claim: taking the home's pid file BEFORE the store opens.
2//!
3//! The claim runs at stage zero of the boot — after the death note is armed,
4//! before configuration is consumed, before the store is opened, before
5//! anything that can take minutes. From that instant the home has a record,
6//! so `aion server status` can describe the boot, `aion server stop` can stop
7//! it, and a second `aion server` on the same home is REFUSED instead of
8//! stacking silently behind the store's writer lock.
9//!
10//! What the claim does with what it finds is the whole design:
11//!
12//! | found | verdict |
13//! |---|---|
14//! | nothing | claim |
15//! | a dead incarnation's record | claim, reporting the debris |
16//! | a reused pid | claim, leaving the stranger alone |
17//! | unparseable content | claim, reporting it |
18//! | a LIVE incarnation whose addresses collide | **refuse the boot** |
19//! | a LIVE incarnation on other addresses | boot UNCLAIMED (legal multi-server home) |
20//! | a LIVE incarnation that is DRAINING | **succeed it** |
21//!
22//! Collision is decided against the addresses this boot INTENDS to bind
23//! (resolved config), because at birth nothing is bound yet. A booting
24//! sibling with no addresses recorded is ALWAYS a collision: it has not
25//! chosen its doors yet, both boots read the same config, and the only
26//! honest reading of two boots racing on one home is that they are the same
27//! server twice.
28
29use std::net::SocketAddr;
30use std::path::{Path, PathBuf};
31
32use tracing::{info, warn};
33
34use super::guard::PidFileGuard;
35use super::incarnation;
36use super::pid_file::{
37    IncarnationState, PidRecord, StaleReconciliation, lock_pid_mutation, pid_file_error,
38    pid_file_path, write_record_atomically,
39};
40use crate::error::ServerError;
41
42/// The addresses a boot intends to bind, resolved from its own configuration
43/// before any listener exists. The birth claim compares a live holder's
44/// RECORDED addresses against these to decide whether the two servers
45/// collide.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct IntendedAddresses {
48    /// The HTTP/ops-console address this boot will bind.
49    pub http: SocketAddr,
50    /// The gRPC address this boot will bind.
51    pub grpc: SocketAddr,
52}
53
54/// The refusal: this Aion home is already held by a LIVE server incarnation
55/// whose doors collide with the ones this boot intends to open.
56///
57/// This is the stacking killer. Before it existed, a second `aion server` on
58/// a home whose first server was still replaying WAL saw an empty-looking
59/// home (no record until bind, no listener to probe), started anyway, and
60/// blocked without a word inside `Database::open` on the store's writer lock.
61/// Four servers stacked that way on 2026-08-26 and nothing in the estate
62/// could see them.
63#[derive(Clone, Debug, thiserror::Error)]
64#[error("{}", render_refusal(.home, .holder))]
65pub struct HomeAlreadyClaimed {
66    /// The Aion home both servers want.
67    pub home: PathBuf,
68    /// The live incarnation that already holds it — carried whole so a
69    /// caller can render whatever the operator needs without re-reading a
70    /// file that may have moved on.
71    pub holder: PidRecord,
72}
73
74/// The refusal's operator-facing sentence: who holds the home, what it is
75/// doing right now, and the three ways out.
76fn render_refusal(home: &Path, holder: &PidRecord) -> String {
77    let stage = match holder.stage_line() {
78        Some(stage) => format!(", stage: {stage}"),
79        None => String::new(),
80    };
81    format!(
82        "refusing to start: the Aion home `{home}` is already held by a LIVE server — \
83         pid {pid}, version {version}, state {state}{stage}, started {age}s ago. Two \
84         servers on one home do not share it: the second blocks inside the store's \
85         writer lock, silently, until the first exits. Watch that server with `aion \
86         server status`; stop it with `aion server stop`; or give this server its own \
87         home by setting AION_HOME",
88        home = home.display(),
89        pid = holder.pid,
90        version = holder.version,
91        state = holder.state.label(),
92        age = holder.running_for_secs(),
93    )
94}
95
96/// Claim the home's pid file at BIRTH, before the store is opened.
97///
98/// `record` is this incarnation's identity with `state = Booting` and no
99/// addresses; `intended` is what this boot is about to bind, used only to
100/// decide collision against a live holder.
101///
102/// # Errors
103///
104/// Returns [`ServerError::HomeAlreadyClaimed`] when a live incarnation
105/// already holds the home on colliding addresses — the boot must not
106/// continue. Returns [`ServerError::PidFile`] when the `run/` directory or
107/// the file cannot be created, written, or renamed into place, or when the
108/// mutation lock cannot be taken. A stale file that cannot be REASONED about
109/// (unreadable) is reconciled and reported, never an error: refusing to boot
110/// over a corrupt leftover would turn a crash's debris into an outage.
111pub fn claim_at_birth(
112    home: &Path,
113    record: &PidRecord,
114    intended: IntendedAddresses,
115) -> Result<PidFileGuard, ServerError> {
116    let path = pid_file_path(home);
117    let run_dir = path.parent().ok_or_else(|| {
118        pid_file_error(format!(
119            "pid file path `{}` has no parent directory",
120            path.display()
121        ))
122    })?;
123    std::fs::create_dir_all(run_dir).map_err(|io_error| {
124        pid_file_error(format!(
125            "could not create run directory `{}`: {io_error}",
126            run_dir.display()
127        ))
128    })?;
129    // The read (reconcile) and the rename are one critical section: two
130    // servers booting on one home reach this lock at the same instant, and
131    // the serialization is exactly what makes the second one's reconcile see
132    // the first one's record. Without it both would find an empty home.
133    let mutation_lock = lock_pid_mutation(&path)?;
134    let reconciliation = reconcile_existing(&path, intended);
135    match &reconciliation {
136        StaleReconciliation::LiveIncarnationElsewhere(existing) => {
137            // The two servers want different doors, so neither is in the
138            // other's way. Overwriting the record here would leave a running
139            // server no verb can address, and refusing would break every
140            // legitimate multi-server home (concurrent test servers, fleet
141            // boxes on one default home). So this boot RUNS UNCLAIMED: the
142            // first claimant keeps the record and the verbs, this incarnation
143            // serves without either, and the warning names the consequence.
144            warn!(
145                path = %path.display(),
146                recorded_pid = existing.pid,
147                recorded_state = existing.state.label(),
148                recorded_http_address = ?existing.http_address,
149                this_http_address = %intended.http,
150                "a live server already holds this home's pid file on different \
151                 addresses; this incarnation boots UNCLAIMED — `aion server \
152                 stop`/`status` will address the recorded server, not this one, and \
153                 this boot reports no stages. Give each server its own AION_HOME to \
154                 make both addressable"
155            );
156            drop(mutation_lock);
157            return Ok(PidFileGuard::unclaimed(
158                path,
159                record.clone(),
160                reconciliation,
161            ));
162        }
163        StaleReconciliation::Collision(existing) => {
164            let refusal = HomeAlreadyClaimed {
165                home: home.to_path_buf(),
166                holder: existing.clone(),
167            };
168            warn!(
169                path = %path.display(),
170                holder_pid = existing.pid,
171                holder_state = existing.state.label(),
172                holder_stage = ?existing.stage,
173                "refusing to boot: this home is held by a live server on colliding \
174                 addresses"
175            );
176            drop(mutation_lock);
177            return Err(ServerError::HomeAlreadyClaimed {
178                refusal: Box::new(refusal),
179            });
180        }
181        other => report_reconciliation(&path, other),
182    }
183    write_record_atomically(&path, record)?;
184    drop(mutation_lock);
185    info!(
186        path = %path.display(),
187        pid = record.pid,
188        started_at_unix_secs = record.started_at_unix_secs,
189        state = record.state.label(),
190        "pid file written at birth; this incarnation has claimed the home"
191    );
192    Ok(PidFileGuard::claimed(path, record.clone(), reconciliation))
193}
194
195/// Classify what is already at `path` without touching it.
196///
197/// The one face the caller must never see outside this module's own decision
198/// table is [`StaleReconciliation::Collision`]: it is not a reconciliation at
199/// all but a refusal, and [`claim_at_birth`] converts it into one.
200fn reconcile_existing(path: &Path, intended: IntendedAddresses) -> StaleReconciliation {
201    let content = match std::fs::read_to_string(path) {
202        Ok(content) => content,
203        Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => {
204            return StaleReconciliation::NonePresent;
205        }
206        Err(io_error) => {
207            return StaleReconciliation::Unreadable {
208                reason: format!("could not read the existing file: {io_error}"),
209            };
210        }
211    };
212    let record = match serde_json::from_str::<PidRecord>(&content) {
213        Ok(record) => record,
214        Err(parse_error) => {
215            return StaleReconciliation::Unreadable {
216                reason: format!("existing content does not parse as a pid record: {parse_error}"),
217            };
218        }
219    };
220    match incarnation::probe(&record) {
221        incarnation::IncarnationProbe::ProcessGone => StaleReconciliation::DeadIncarnation(record),
222        incarnation::IncarnationProbe::DifferentIncarnation { .. } => {
223            StaleReconciliation::ReusedPid(record)
224        }
225        incarnation::IncarnationProbe::Verified { .. } => match record.state {
226            // A drainer is on its way out and has already stopped serving new
227            // work: succeeding it is the whole point of `aion server restart`.
228            // The successor waits out the store writer lock the drainer still
229            // holds — by design, and the stage report says so.
230            IncarnationState::Draining => StaleReconciliation::SucceededDrainer(record),
231            IncarnationState::Booting | IncarnationState::Serving => {
232                if collides(&record, intended) {
233                    StaleReconciliation::Collision(record)
234                } else {
235                    StaleReconciliation::LiveIncarnationElsewhere(record)
236                }
237            }
238        },
239    }
240}
241
242/// Whether a live holder's recorded doors collide with the ones this boot
243/// intends to open.
244///
245/// The holder's BOUND addresses are the strongest fact and win when present.
246/// A holder still booting has bound nothing — but its record carries the
247/// addresses its configuration said it WILL bind, written at birth, and
248/// comparing intention against intention is exactly as decisive: two
249/// explicit configs naming different doors are two servers, and refusing the
250/// second would break every legitimate multi-server home the moment their
251/// boots overlap (measured: battery run 0b066959, two e2e servers with
252/// distinct stores and ports, the second refused mid-`store-open`). A record
253/// with NEITHER pair was written by a build that predates the intended
254/// addresses; nothing is comparable, and the only honest reading left is a
255/// collision — two boots on one home, indistinguishable from the same
256/// server twice.
257fn collides(holder: &PidRecord, intended: IntendedAddresses) -> bool {
258    let holder_doors = match (holder.http_address, holder.grpc_address) {
259        (None, None) => (holder.intended_http_address, holder.intended_grpc_address),
260        bound => bound,
261    };
262    match holder_doors {
263        (None, None) => true,
264        (http, grpc) => {
265            http.is_some_and(|address| address == intended.http || address == intended.grpc)
266                || grpc.is_some_and(|address| address == intended.http || address == intended.grpc)
267        }
268    }
269}
270
271/// Say what the reconciliation found, at the severity it deserves.
272///
273/// The two faces that return before reaching here — the unclaimed boot and
274/// the refusal — report themselves inside [`claim_at_birth`], where each has
275/// a consequence to name that this reporter does not know about.
276fn report_reconciliation(path: &Path, reconciliation: &StaleReconciliation) {
277    match reconciliation {
278        StaleReconciliation::NonePresent
279        | StaleReconciliation::LiveIncarnationElsewhere(_)
280        | StaleReconciliation::Collision(_) => {}
281        StaleReconciliation::DeadIncarnation(record) => {
282            warn!(
283                path = %path.display(),
284                stale_pid = record.pid,
285                stale_started_at_unix_secs = record.started_at_unix_secs,
286                stale_version = %record.version,
287                stale_state = record.state.label(),
288                "stale pid file: the recorded server (pid gone) died without removing \
289                 its record; replacing it with this incarnation's"
290            );
291        }
292        StaleReconciliation::ReusedPid(record) => {
293            warn!(
294                path = %path.display(),
295                stale_pid = record.pid,
296                stale_started_at_unix_secs = record.started_at_unix_secs,
297                "stale pid file: the recorded pid is alive but belongs to a different \
298                 process (pid reused after the recorded server died); replacing the \
299                 record and leaving that process untouched"
300            );
301        }
302        StaleReconciliation::SucceededDrainer(record) => {
303            info!(
304                path = %path.display(),
305                draining_pid = record.pid,
306                draining_version = %record.version,
307                "succeeding a DRAINING server on this home: it has seen its \
308                 termination signal and is on its way out, so this incarnation takes \
309                 the record. Its own guard leaves this claim alone at exit (the \
310                 compare is by incarnation identity). This boot waits out the store \
311                 writer lock the drainer still holds — that wait is the handover, not \
312                 a fault"
313            );
314        }
315        StaleReconciliation::Unreadable { reason } => {
316            warn!(
317                path = %path.display(),
318                %reason,
319                "pid file present but unreadable as a record (hand-written or \
320                 corrupt); replacing it with this incarnation's"
321            );
322        }
323    }
324}
325
326#[cfg(test)]
327#[path = "claim_tests.rs"]
328mod tests;