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;