use std::path::Path;
use std::time::{Duration, Instant};
use aion_server::control::{IncarnationProbe, IncarnationState, PidRecord, incarnation, pid_file};
const STALL_REPORT_INTERVAL: Duration = Duration::from_secs(10);
pub struct BootNarrator {
last_stage_line: Option<String>,
last_seq: Option<(u64, Instant)>,
last_stall_report: Option<Instant>,
seen_record: bool,
}
#[derive(Clone, Debug)]
pub enum BootObservation {
NoRecord,
NotLive(Box<PidRecord>),
Live(Box<PidRecord>),
Unreadable(String),
}
impl Default for BootNarrator {
fn default() -> Self {
Self::new()
}
}
impl BootNarrator {
#[must_use]
pub const fn new() -> Self {
Self {
last_stage_line: None,
last_seq: None,
last_stall_report: None,
seen_record: false,
}
}
#[must_use]
pub const fn has_seen_record(&self) -> bool {
self.seen_record
}
pub fn observe(&mut self, home: &Path) -> BootObservation {
let observation = read_observation(home);
if let BootObservation::Live(record) = &observation {
self.seen_record = true;
self.narrate_live(record);
}
observation
}
fn narrate_live(&mut self, record: &PidRecord) {
let now = Instant::now();
match self.last_seq {
Some((seq, _)) if seq == record.stage_seq => {}
_ => {
self.last_seq = Some((record.stage_seq, now));
self.last_stall_report = None;
}
}
if let Some(line) = stage_line(record)
&& self.last_stage_line.as_deref() != Some(line.as_str())
{
println!("{line}");
self.last_stage_line = Some(line);
}
let Some((_, since)) = self.last_seq else {
return;
};
let stalled_for = now.saturating_duration_since(since);
if stalled_for < STALL_REPORT_INTERVAL {
return;
}
let due = self
.last_stall_report
.is_none_or(|at| now.saturating_duration_since(at) >= STALL_REPORT_INTERVAL);
if !due {
return;
}
self.last_stall_report = Some(now);
println!(
"no progress reported for {}s — server {} is alive and still {}; waiting. \
Ctrl-C leaves it running; `aion server stop` stops it",
stalled_for.as_secs(),
record.pid,
record.state.label().to_lowercase()
);
}
}
#[must_use]
pub fn stage_line(record: &PidRecord) -> Option<String> {
match record.state {
IncarnationState::Booting => Some(match record.stage_line() {
Some(stage) => format!("server {} booting — {stage}", record.pid),
None => format!("server {} booting", record.pid),
}),
IncarnationState::Draining => Some(format!(
"server {} (version {}) is draining",
record.pid, record.version
)),
IncarnationState::Serving => None,
}
}
#[must_use]
pub fn read_observation(home: &Path) -> BootObservation {
match pid_file::read(home) {
Err(error) => BootObservation::Unreadable(error.to_string()),
Ok(None) => BootObservation::NoRecord,
Ok(Some(record)) => match incarnation::probe(&record) {
IncarnationProbe::Verified { .. } => BootObservation::Live(Box::new(record)),
IncarnationProbe::ProcessGone | IncarnationProbe::DifferentIncarnation { .. } => {
BootObservation::NotLive(Box::new(record))
}
},
}
}
#[cfg(test)]
mod tests {
use super::{BootNarrator, BootObservation, read_observation, stage_line};
use aion_server::control::{IncarnationState, PidRecord};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn booting_record(stage: &str, detail: &str, seq: u64) -> PidRecord {
PidRecord {
pid: 4242,
started_at_unix_secs: 1,
binary_sha256: "0".repeat(64),
version: "0.0.0-test".to_owned(),
commit: "test".to_owned(),
state: IncarnationState::Booting,
http_address: None,
grpc_address: None,
intended_http_address: None,
intended_grpc_address: None,
stage: Some(stage.to_owned()),
stage_detail: Some(detail.to_owned()),
stage_seq: seq,
stage_updated_at_unix_secs: 1,
drain_timeout_seconds: 0,
}
}
#[test]
fn a_booting_record_narrates_its_stage() {
let line = stage_line(&booting_record(
"wal-recovery",
"materializing shard 17 of 64",
3,
))
.unwrap_or_default();
assert!(line.contains("4242"), "the pid must be named: {line}");
assert!(line.contains("wal-recovery"), "the stage token: {line}");
assert!(line.contains("shard 17 of 64"), "the detail: {line}");
}
#[test]
fn a_serving_record_has_no_stage_line() {
let mut record = booting_record("binding", "x", 1);
record.state = IncarnationState::Serving;
assert!(stage_line(&record).is_none());
}
#[test]
fn an_empty_home_reads_as_no_record() -> TestResult {
let home = tempfile::tempdir()?;
assert!(matches!(
read_observation(home.path()),
BootObservation::NoRecord
));
let mut narrator = BootNarrator::new();
assert!(matches!(
narrator.observe(home.path()),
BootObservation::NoRecord
));
assert!(
!narrator.has_seen_record(),
"an empty home must not count as a watchable boot"
);
Ok(())
}
#[test]
fn a_dead_records_home_reads_as_not_live() -> TestResult {
let home = tempfile::tempdir()?;
let run_dir = home.path().join("run");
std::fs::create_dir_all(&run_dir)?;
let mut child = std::process::Command::new("true").spawn()?;
let dead_pid = child.id();
child.wait()?;
let mut record = booting_record("store-open", "opening", 1);
record.pid = dead_pid;
std::fs::write(
run_dir.join("aion-server.pid"),
serde_json::to_string(&record)?,
)?;
assert!(
matches!(
read_observation(home.path()),
BootObservation::NotLive(_) | BootObservation::NoRecord
),
"a reaped pid must never read as a live boot"
);
Ok(())
}
}