use std::path::Path;
use std::time::{Duration, Instant};
use aion_server::control::{
IncarnationState, PidRecord, StopRefusal, TerminationSignal, incarnation, send_termination,
};
use crate::boot_narration::{BootNarrator, BootObservation};
use crate::console::PROBE_STEP_TIMEOUT;
const POLL_INTERVAL: Duration = Duration::from_millis(250);
pub struct HandoverRefusal {
pub code: u8,
pub message: String,
}
impl HandoverRefusal {
#[must_use]
pub fn refused(message: String) -> Self {
Self { code: 2, message }
}
#[must_use]
pub fn incomplete(message: String) -> Self {
Self { code: 1, message }
}
}
pub fn resolve_predecessor(home: &Path, verb: &str) -> Result<PidRecord, HandoverRefusal> {
match crate::boot_narration::read_observation(home) {
BootObservation::Live(record) => match record.state {
IncarnationState::Serving | IncarnationState::Draining => Ok(*record),
IncarnationState::Booting => Err(HandoverRefusal::refused(format!(
"server {} is still BOOTING ({}); there is nothing serving to {verb}. \
Watch it with `aion server status`; if you must replace it, stop it \
with `aion server stop` and start a new one",
record.pid,
record
.stage_line()
.unwrap_or_else(|| "no stage reported yet".to_owned())
))),
},
BootObservation::NoRecord => Err(HandoverRefusal::refused(format!(
"no server has claimed this home, so there is nothing to {verb}. Check with \
`aion server status`; start one with `aion`"
))),
BootObservation::NotLive(record) => Err(HandoverRefusal::refused(format!(
"this home's record names pid {}, which is not a live incarnation of that \
server (it died, or its pid was recycled). Nothing was signalled. Check \
with `aion server status`; start a server with `aion`",
record.pid
))),
BootObservation::Unreadable(error) => Err(HandoverRefusal::refused(format!(
"this home's pid record could not be read ({error}); nothing was signalled"
))),
}
}
pub fn release_predecessor(
home: &Path,
predecessor: &PidRecord,
patience: Duration,
) -> Result<(), HandoverRefusal> {
if predecessor.state == IncarnationState::Draining {
println!(
"server {} (version {}) is already draining; starting its successor now",
predecessor.pid, predecessor.version
);
return Ok(());
}
println!(
"stopping server {} (version {}) — waiting up to {}s for it to begin draining",
predecessor.pid,
predecessor.version,
patience.as_secs()
);
match send_termination(predecessor) {
Ok(TerminationSignal::Sent) => {}
Ok(TerminationSignal::AlreadyGone) => {
println!(
"server {} exited between the check and the signal; starting its successor",
predecessor.pid
);
return Ok(());
}
Err(StopRefusal::UnsupportedPlatform) => {
return Err(HandoverRefusal::refused(
"a handover needs POSIX signals, which this platform does not have".to_owned(),
));
}
Err(refusal) => {
return Err(HandoverRefusal::refused(format!(
"nothing was started: {refusal}"
)));
}
}
wait_until_letting_go(home, predecessor, patience)
}
fn wait_until_letting_go(
home: &Path,
predecessor: &PidRecord,
patience: Duration,
) -> Result<(), HandoverRefusal> {
let started = Instant::now();
loop {
if !matches!(
incarnation::probe(predecessor),
incarnation::IncarnationProbe::Verified { .. }
) {
println!("server {} has exited", predecessor.pid);
return Ok(());
}
match crate::boot_narration::read_observation(home) {
BootObservation::Live(record)
if record.is_same_incarnation(predecessor)
&& record.state == IncarnationState::Draining =>
{
println!("server {} is draining", predecessor.pid);
return Ok(());
}
BootObservation::Unreadable(error) => {
return Err(HandoverRefusal::incomplete(format!(
"server {} was signalled, but this home's pid record then became \
unreadable ({error}); no successor was started. Check `aion server \
status` before starting one",
predecessor.pid
)));
}
BootObservation::Live(_) | BootObservation::NotLive(_) | BootObservation::NoRecord => {}
}
if started.elapsed() >= patience {
return Err(HandoverRefusal::incomplete(format!(
"server {} was signalled but has neither begun draining nor exited \
within {}s. No successor was started — one begun now would be refused \
as a colliding sibling. Watch it with `aion server status`, or force \
it with a second `aion server stop`",
predecessor.pid,
patience.as_secs()
)));
}
std::thread::sleep(POLL_INTERVAL);
}
}
pub async fn start_successor(
home: &Path,
executable: &Path,
predecessor: Option<&PidRecord>,
) -> Result<PidRecord, HandoverRefusal> {
let log_path = home.join("server.log");
let mut child =
crate::launch::spawn_server_binary(executable, home, &log_path).map_err(|failure| {
HandoverRefusal::incomplete(match predecessor {
Some(record) => format!(
"server {} was signalled but the successor could not be started: {}",
record.pid, failure.message
),
None => format!("the server could not be started: {}", failure.message),
})
})?;
println!("started successor process {}", child.id());
watch_successor(home, predecessor, &mut child, &log_path).await
}
async fn watch_successor(
home: &Path,
predecessor: Option<&PidRecord>,
child: &mut std::process::Child,
log_path: &Path,
) -> Result<PidRecord, HandoverRefusal> {
let successor_pid = child.id();
let mut narrator = BootNarrator::new();
let mut predecessor_gone = predecessor.is_none();
loop {
match child.try_wait() {
Ok(Some(status)) => {
return Err(HandoverRefusal::incomplete(successor_death_report(
status,
predecessor,
log_path,
)));
}
Ok(None) => {}
Err(error) => {
return Err(HandoverRefusal::incomplete(format!(
"could not watch the successor process: {error}; its log is {}",
log_path.display()
)));
}
}
if let Some(record) = predecessor
&& !predecessor_gone
&& !matches!(
incarnation::probe(record),
incarnation::IncarnationProbe::Verified { .. }
)
{
predecessor_gone = true;
println!(
"server {} (version {}) has exited; the store's writer lock is free",
record.pid, record.version
);
}
if let BootObservation::Live(record) = narrator.observe(home)
&& record.pid == successor_pid
&& record.state == IncarnationState::Serving
&& let Some(http_address) = record.http_address
&& ready(http_address).await
{
match predecessor {
Some(old) => println!(
"{} (pid {}) → {} (pid {}), serving on {http_address}",
old.version, old.pid, record.version, record.pid
),
None => println!(
"{} (pid {}) is serving on {http_address}",
record.version, record.pid
),
}
return Ok(*record);
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
fn successor_death_report(
status: std::process::ExitStatus,
predecessor: Option<&PidRecord>,
log_path: &Path,
) -> String {
let tail = crate::launch::log_tail(log_path);
match predecessor {
Some(record) => format!(
"the successor exited during startup ({status}). The previous server (pid \
{}, version {}) was already signalled and is draining or gone, so THIS \
HOME NOW HAS NO SERVER. Its log ends with:\n{tail}\nfull log: {}\ndeath \
note: {}",
record.pid,
record.version,
log_path.display(),
death_note_path(log_path).display()
),
None => format!(
"the server exited during startup ({status}); its log ends with:\n{tail}\n\
full log: {}",
log_path.display()
),
}
}
fn death_note_path(log_path: &Path) -> std::path::PathBuf {
log_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("logs")
.join("aion-server.death.log")
}
async fn ready(address: std::net::SocketAddr) -> bool {
let Ok(client) = reqwest::Client::builder()
.timeout(PROBE_STEP_TIMEOUT)
.build()
else {
return false;
};
match client
.get(format!("http://{address}/health/ready"))
.send()
.await
{
Ok(response) => response.status() == reqwest::StatusCode::OK,
Err(_not_yet) => false,
}
}
#[cfg(test)]
mod tests {
use super::{HandoverRefusal, resolve_predecessor};
use aion_server::control::{IncarnationState, PidRecord};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn record(state: IncarnationState) -> PidRecord {
PidRecord {
pid: 1234,
started_at_unix_secs: 1,
binary_sha256: "0".repeat(64),
version: "0.0.0-test".to_owned(),
commit: "test".to_owned(),
state,
http_address: None,
grpc_address: None,
intended_http_address: None,
intended_grpc_address: None,
stage: Some("wal-recovery".to_owned()),
stage_detail: Some("materializing shard 3 of 8".to_owned()),
stage_seq: 3,
stage_updated_at_unix_secs: 1,
drain_timeout_seconds: 30,
}
}
fn plant(home: &std::path::Path, record: &PidRecord) -> TestResult {
let run_dir = home.join("run");
std::fs::create_dir_all(&run_dir)?;
std::fs::write(
run_dir.join("aion-server.pid"),
serde_json::to_string(record)?,
)?;
Ok(())
}
#[test]
fn an_empty_home_is_refused_naming_status() -> TestResult {
let home = tempfile::tempdir()?;
let Err(refusal) = resolve_predecessor(home.path(), "restart") else {
return Err("an empty home must refuse a handover".into());
};
assert_eq!(refusal.code, 2, "a refusal before signalling is exit 2");
assert!(
refusal.message.contains("aion server status"),
"the refusal must name the verb that answers: {}",
refusal.message
);
Ok(())
}
#[test]
fn a_dead_record_is_refused() -> TestResult {
let home = tempfile::tempdir()?;
let mut child = std::process::Command::new("true").spawn()?;
let dead_pid = child.id();
child.wait()?;
let mut dead = record(IncarnationState::Serving);
dead.pid = dead_pid;
dead.started_at_unix_secs = 0;
plant(home.path(), &dead)?;
let Err(refusal) = resolve_predecessor(home.path(), "restart") else {
return Err("a dead record must refuse a handover".into());
};
assert_eq!(refusal.code, 2);
Ok(())
}
#[test]
fn a_booting_incarnation_is_refused_with_its_stage() -> TestResult {
let home = tempfile::tempdir()?;
let me = aion_server::control::incarnation::self_identity()?;
let mut booting = record(IncarnationState::Booting);
booting.pid = me.pid;
booting.started_at_unix_secs = me.started_at_unix_secs;
plant(home.path(), &booting)?;
let Err(HandoverRefusal { code, message }) = resolve_predecessor(home.path(), "restart")
else {
return Err("a booting incarnation must refuse a handover".into());
};
assert_eq!(code, 2);
assert!(message.contains("BOOTING"), "{message}");
assert!(message.contains("wal-recovery"), "{message}");
assert!(message.contains("aion server stop"), "{message}");
Ok(())
}
}