use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use super::guard::PidFileGuard;
use super::incarnation;
use super::pid_file::{
IncarnationState, PidRecord, StaleReconciliation, lock_pid_mutation, pid_file_error,
pid_file_path, write_record_atomically,
};
use crate::error::ServerError;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct IntendedAddresses {
pub http: SocketAddr,
pub grpc: SocketAddr,
}
#[derive(Clone, Debug, thiserror::Error)]
#[error("{}", render_refusal(.home, .holder))]
pub struct HomeAlreadyClaimed {
pub home: PathBuf,
pub holder: PidRecord,
}
fn render_refusal(home: &Path, holder: &PidRecord) -> String {
let stage = match holder.stage_line() {
Some(stage) => format!(", stage: {stage}"),
None => String::new(),
};
format!(
"refusing to start: the Aion home `{home}` is already held by a LIVE server — \
pid {pid}, version {version}, state {state}{stage}, started {age}s ago. Two \
servers on one home do not share it: the second blocks inside the store's \
writer lock, silently, until the first exits. Watch that server with `aion \
server status`; stop it with `aion server stop`; or give this server its own \
home by setting AION_HOME",
home = home.display(),
pid = holder.pid,
version = holder.version,
state = holder.state.label(),
age = holder.running_for_secs(),
)
}
pub fn claim_at_birth(
home: &Path,
record: &PidRecord,
intended: IntendedAddresses,
) -> Result<PidFileGuard, ServerError> {
let path = pid_file_path(home);
let run_dir = path.parent().ok_or_else(|| {
pid_file_error(format!(
"pid file path `{}` has no parent directory",
path.display()
))
})?;
std::fs::create_dir_all(run_dir).map_err(|io_error| {
pid_file_error(format!(
"could not create run directory `{}`: {io_error}",
run_dir.display()
))
})?;
let mutation_lock = lock_pid_mutation(&path)?;
let reconciliation = reconcile_existing(&path, intended);
match &reconciliation {
StaleReconciliation::LiveIncarnationElsewhere(existing) => {
warn!(
path = %path.display(),
recorded_pid = existing.pid,
recorded_state = existing.state.label(),
recorded_http_address = ?existing.http_address,
this_http_address = %intended.http,
"a live server already holds this home's pid file on different \
addresses; this incarnation boots UNCLAIMED — `aion server \
stop`/`status` will address the recorded server, not this one, and \
this boot reports no stages. Give each server its own AION_HOME to \
make both addressable"
);
drop(mutation_lock);
return Ok(PidFileGuard::unclaimed(
path,
record.clone(),
reconciliation,
));
}
StaleReconciliation::Collision(existing) => {
let refusal = HomeAlreadyClaimed {
home: home.to_path_buf(),
holder: existing.clone(),
};
warn!(
path = %path.display(),
holder_pid = existing.pid,
holder_state = existing.state.label(),
holder_stage = ?existing.stage,
"refusing to boot: this home is held by a live server on colliding \
addresses"
);
drop(mutation_lock);
return Err(ServerError::HomeAlreadyClaimed {
refusal: Box::new(refusal),
});
}
other => report_reconciliation(&path, other),
}
write_record_atomically(&path, record)?;
drop(mutation_lock);
info!(
path = %path.display(),
pid = record.pid,
started_at_unix_secs = record.started_at_unix_secs,
state = record.state.label(),
"pid file written at birth; this incarnation has claimed the home"
);
Ok(PidFileGuard::claimed(path, record.clone(), reconciliation))
}
fn reconcile_existing(path: &Path, intended: IntendedAddresses) -> StaleReconciliation {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => {
return StaleReconciliation::NonePresent;
}
Err(io_error) => {
return StaleReconciliation::Unreadable {
reason: format!("could not read the existing file: {io_error}"),
};
}
};
let record = match serde_json::from_str::<PidRecord>(&content) {
Ok(record) => record,
Err(parse_error) => {
return StaleReconciliation::Unreadable {
reason: format!("existing content does not parse as a pid record: {parse_error}"),
};
}
};
match incarnation::probe(&record) {
incarnation::IncarnationProbe::ProcessGone => StaleReconciliation::DeadIncarnation(record),
incarnation::IncarnationProbe::DifferentIncarnation { .. } => {
StaleReconciliation::ReusedPid(record)
}
incarnation::IncarnationProbe::Verified { .. } => match record.state {
IncarnationState::Draining => StaleReconciliation::SucceededDrainer(record),
IncarnationState::Booting | IncarnationState::Serving => {
if collides(&record, intended) {
StaleReconciliation::Collision(record)
} else {
StaleReconciliation::LiveIncarnationElsewhere(record)
}
}
},
}
}
fn collides(holder: &PidRecord, intended: IntendedAddresses) -> bool {
let holder_doors = match (holder.http_address, holder.grpc_address) {
(None, None) => (holder.intended_http_address, holder.intended_grpc_address),
bound => bound,
};
match holder_doors {
(None, None) => true,
(http, grpc) => {
http.is_some_and(|address| address == intended.http || address == intended.grpc)
|| grpc.is_some_and(|address| address == intended.http || address == intended.grpc)
}
}
}
fn report_reconciliation(path: &Path, reconciliation: &StaleReconciliation) {
match reconciliation {
StaleReconciliation::NonePresent
| StaleReconciliation::LiveIncarnationElsewhere(_)
| StaleReconciliation::Collision(_) => {}
StaleReconciliation::DeadIncarnation(record) => {
warn!(
path = %path.display(),
stale_pid = record.pid,
stale_started_at_unix_secs = record.started_at_unix_secs,
stale_version = %record.version,
stale_state = record.state.label(),
"stale pid file: the recorded server (pid gone) died without removing \
its record; replacing it with this incarnation's"
);
}
StaleReconciliation::ReusedPid(record) => {
warn!(
path = %path.display(),
stale_pid = record.pid,
stale_started_at_unix_secs = record.started_at_unix_secs,
"stale pid file: the recorded pid is alive but belongs to a different \
process (pid reused after the recorded server died); replacing the \
record and leaving that process untouched"
);
}
StaleReconciliation::SucceededDrainer(record) => {
info!(
path = %path.display(),
draining_pid = record.pid,
draining_version = %record.version,
"succeeding a DRAINING server on this home: it has seen its \
termination signal and is on its way out, so this incarnation takes \
the record. Its own guard leaves this claim alone at exit (the \
compare is by incarnation identity). This boot waits out the store \
writer lock the drainer still holds — that wait is the handover, not \
a fault"
);
}
StaleReconciliation::Unreadable { reason } => {
warn!(
path = %path.display(),
%reason,
"pid file present but unreadable as a record (hand-written or \
corrupt); replacing it with this incarnation's"
);
}
}
}
#[cfg(test)]
#[path = "claim_tests.rs"]
mod tests;