use std::net::SocketAddr;
use std::path::Path;
use std::process::{ExitCode, Stdio};
use std::time::Instant;
use aion_server::config::{
CliOverrides, FIRST_RUN_CONFIG, ServerConfig, StoreBackend, StoreConfig, aion_home,
};
use crate::boot_narration::{BootNarrator, BootObservation};
use crate::console::{self, HealthProbe};
use aion_server::control::{IncarnationState, PidRecord};
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200);
const LOG_TAIL_LINES: usize = 40;
const LOG_TAIL_WINDOW_BYTES: u64 = 64 * 1024;
pub(crate) struct LaunchFailure {
pub(crate) code: u8,
pub(crate) message: String,
}
impl LaunchFailure {
pub(crate) fn refusal(message: String) -> Self {
Self { code: 2, message }
}
pub(crate) fn runtime(status: Option<std::process::ExitStatus>, message: String) -> Self {
let code = status
.and_then(|status| status.code())
.and_then(|code| u8::try_from(code).ok())
.filter(|&code| code != 0)
.unwrap_or(1);
Self { code, message }
}
}
pub async fn run(foreground: bool, no_open: bool) -> ExitCode {
if foreground {
return run_foreground(no_open).await;
}
match run_background(no_open).await {
Ok(code) => code,
Err(failure) => {
eprintln!("aion: {}", failure.message);
ExitCode::from(failure.code)
}
}
}
async fn run_foreground(no_open: bool) -> ExitCode {
crate::harness::announce_composed_harness();
if !no_open {
crate::server::spawn_browser_open(&CliOverrides::default());
}
aion_server::run(CliOverrides::default()).await
}
async fn run_background(no_open: bool) -> Result<ExitCode, LaunchFailure> {
let overrides = CliOverrides::default();
let config = ServerConfig::load(&overrides).map_err(|error| {
LaunchFailure::refusal(format!(
"could not resolve the server configuration: {error}"
))
})?;
let home = aion_home().map_err(|error| {
LaunchFailure::refusal(format!("could not resolve the Aion home: {error}"))
})?;
let (store, runtime) = config.into_parts();
let address = runtime.listen.http;
let url = console::served_url(address);
match console::probe_health(address).await {
HealthProbe::Live => {
println!("aion is already running.");
println!("console: {url}");
open_console(no_open, &url);
return Ok(ExitCode::SUCCESS);
}
HealthProbe::NotAion(answer) => {
return Err(LaunchFailure::refusal(not_aion_report(
address,
answer.as_deref(),
)));
}
HealthProbe::Down => {}
}
let working_dir = std::env::current_dir().map_err(|error| {
LaunchFailure::refusal(format!("could not resolve the current directory: {error}"))
})?;
let project_config = working_dir.join("aion.toml");
let home_config = home.path.join("config.toml");
let pre_existing = [&project_config, &home_config]
.into_iter()
.find(|path| path.exists())
.cloned();
if let Some(holder) = live_holder(&home.path) {
report_existing_server(&holder, &url);
if holder.state == IncarnationState::Serving {
open_console(no_open, &url);
}
return Ok(ExitCode::SUCCESS);
}
let log_path = home.path.join("server.log");
let mut child = spawn_server(&home.path, &log_path)?;
if let Some(code) =
await_liveness(&home.path, address, &url, &mut child, &log_path, no_open).await?
{
return Ok(code);
}
let config_line = config_report_line(pre_existing.as_deref(), &project_config, &home_config);
let pid_line = pid_report_line(&home.path, child.id());
print!(
"{}",
ready_report(&store, &config_line, &url, &log_path, &pid_line)
);
open_console(no_open, &url);
Ok(ExitCode::SUCCESS)
}
async fn await_liveness(
home: &Path,
address: SocketAddr,
url: &str,
child: &mut std::process::Child,
log_path: &Path,
no_open: bool,
) -> Result<Option<ExitCode>, LaunchFailure> {
let started = Instant::now();
let mut narrator = BootNarrator::new();
loop {
match child.try_wait() {
Ok(Some(status)) => {
if matches!(console::probe_health(address).await, HealthProbe::Live) {
println!("aion is already running (another launch won the race).");
println!("console: {url}");
open_console(no_open, url);
return Ok(Some(ExitCode::SUCCESS));
}
return Err(LaunchFailure::runtime(
Some(status),
format!(
"the server exited during startup ({status}); its log ends with:\n{}\nfull log: {}",
log_tail(log_path),
log_path.display()
),
));
}
Ok(None) => {}
Err(error) => {
return Err(LaunchFailure::runtime(
None,
format!(
"could not watch the server process: {error}; its log is {}",
log_path.display()
),
));
}
}
narrator.observe(home);
if matches!(console::probe_health(address).await, HealthProbe::Live) {
return Ok(None);
}
if !narrator.has_seen_record() && started.elapsed() >= console::LIVE_BUDGET {
return Err(LaunchFailure::runtime(
None,
format!(
"the server has not answered {url}health/live within {}s; it is still \
running as process {pid} — watch its log ({log}) or stop it with `aion server stop`. \
The log ends with:\n{tail}",
console::LIVE_BUDGET.as_secs(),
pid = child.id(),
log = log_path.display(),
tail = log_tail(log_path)
),
));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
fn live_holder(home: &Path) -> Option<PidRecord> {
match crate::boot_narration::read_observation(home) {
BootObservation::Live(record) => Some(*record),
BootObservation::NoRecord | BootObservation::NotLive(_) => None,
BootObservation::Unreadable(error) => {
eprintln!(
"aion: this home's pid record could not be read ({error}); not starting \
a server on top of one that may be running. Read {} yourself, or \
remove it once you are satisfied nothing is running",
aion_server::control::pid_file::pid_file_path(home).display()
);
None
}
}
}
fn report_existing_server(holder: &PidRecord, url: &str) {
match holder.state {
IncarnationState::Serving => {
println!(
"aion is already running: pid {} (version {}).",
holder.pid, holder.version
);
println!("console: {url}");
}
IncarnationState::Booting => {
let stage = holder
.stage_line()
.unwrap_or_else(|| "no stage reported yet".to_owned());
println!(
"aion is already starting on this home: pid {} (version {}) is BOOTING \
— {stage} (started {}s ago). Not starting a second server: two on one \
home do not share it, the second just blocks on the store's writer \
lock. Watch it with `aion server status`, or stop it with `aion server \
stop`",
holder.pid,
holder.version,
holder.running_for_secs()
);
}
IncarnationState::Draining => {
println!(
"aion is shutting down on this home: pid {} (version {}) is DRAINING \
(started {}s ago). Not starting a second server while it holds the \
store; run `aion` again once it has exited, or `aion server restart` \
to hand over deliberately",
holder.pid,
holder.version,
holder.running_for_secs()
);
}
}
}
pub(crate) fn spawn_server(
home: &Path,
log_path: &Path,
) -> Result<std::process::Child, LaunchFailure> {
let executable = std::env::current_exe().map_err(|error| {
LaunchFailure::refusal(format!("could not resolve the aion executable: {error}"))
})?;
spawn_server_binary(&executable, home, log_path)
}
pub(crate) fn spawn_server_binary(
executable: &Path,
home: &Path,
log_path: &Path,
) -> Result<std::process::Child, LaunchFailure> {
provision_home(home)?;
let mut log_options = std::fs::OpenOptions::new();
log_options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
log_options.mode(0o600);
}
let log_out = log_options.open(log_path).map_err(|error| {
LaunchFailure::refusal(format!(
"could not open the server log {}: {error}",
log_path.display()
))
})?;
let log_err = log_out.try_clone().map_err(|error| {
LaunchFailure::refusal(format!(
"could not open the server log {}: {error}",
log_path.display()
))
})?;
let mut command = std::process::Command::new(executable);
command
.arg("server")
.stdin(Stdio::null())
.stdout(log_out)
.stderr(log_err);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
command.process_group(0);
}
command.spawn().map_err(|error| {
LaunchFailure::refusal(format!("could not start the server process: {error}"))
})
}
pub(crate) fn provision_home(home: &Path) -> Result<(), LaunchFailure> {
match std::fs::symlink_metadata(home) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(LaunchFailure::refusal(format!(
"the Aion home {} is a symlink; the server refuses symlinked state roots — \
point AION_HOME at the real directory instead",
home.display()
)));
}
Ok(metadata) if !metadata.is_dir() => {
return Err(LaunchFailure::refusal(format!(
"the Aion home {} exists but is not a directory",
home.display()
)));
}
Ok(_) => {
#[cfg(unix)]
tighten_home(home)?;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(home).map_err(|error| {
LaunchFailure::refusal(format!(
"could not create the Aion home {}: {error}",
home.display()
))
})?;
#[cfg(unix)]
tighten_home(home)?;
}
Err(error) => {
return Err(LaunchFailure::refusal(format!(
"could not inspect the Aion home {}: {error}",
home.display()
)));
}
}
Ok(())
}
#[cfg(unix)]
fn tighten_home(home: &Path) -> Result<(), LaunchFailure> {
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(home)
.map_err(|error| {
LaunchFailure::refusal(format!(
"could not inspect the Aion home {}: {error}",
home.display()
))
})?
.permissions()
.mode();
if mode & 0o077 != 0 {
std::fs::set_permissions(home, std::fs::Permissions::from_mode(0o700)).map_err(
|error| {
LaunchFailure::refusal(format!(
"could not set owner-only permissions on {}: {error}",
home.display()
))
},
)?;
}
Ok(())
}
fn config_report_line(
pre_existing: Option<&Path>,
project_config: &Path,
home_config: &Path,
) -> String {
if let Some(path) = pre_existing {
return format!("config: {}", path.display());
}
match std::fs::read(home_config) {
Ok(bytes) if bytes == FIRST_RUN_CONFIG.as_bytes() => format!(
"config: created {} on first start — comments inside explain every surface it enables",
home_config.display()
),
Ok(_) => format!(
"config: {} (appeared during startup; not the stock first-run template)",
home_config.display()
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => format!(
"config: built-in defaults (no {} or {})",
project_config.display(),
home_config.display()
),
Err(error) => format!(
"config: {} (could not read it: {error})",
home_config.display()
),
}
}
fn pid_report_line(home: &Path, launched_pid: u32) -> String {
match aion_server::control::pid_file::read(home) {
Ok(Some(record)) if record.pid == launched_pid => {
format!("server process id: {launched_pid} (stop it with: aion server stop)")
}
Ok(Some(record)) => format!(
"server process id: {launched_pid} — NOTE: this home's pid file is held by \
another live server (pid {}), so `aion server stop` stops THAT server, \
not this one. This launch runs unclaimed; stop it by pid (kill \
{launched_pid}), or give each server its own AION_HOME",
record.pid
),
Ok(None) => format!(
"server process id: {launched_pid} — NOTE: no pid record appeared under \
this home, so `aion server stop` cannot address it; stop it by pid \
(kill {launched_pid}) and check the server log for the claim failure"
),
Err(error) => format!(
"server process id: {launched_pid} — NOTE: the home's pid record could \
not be read ({error}), so whether `aion server stop` addresses this \
server is unverified"
),
}
}
fn ready_report(
store: &StoreConfig,
config_line: &str,
url: &str,
log_path: &Path,
pid_line: &str,
) -> String {
let data_line = match store.backend {
StoreBackend::Haematite => match store.data_dir.as_deref() {
Some(data_dir) => format!("data: {data_dir}"),
None => "data: haematite store (directory resolved by the server)".to_owned(),
},
StoreBackend::Memory => "data: in-memory — state does not survive a stop".to_owned(),
};
format!(
"aion is up.\n{config_line}\n{data_line}\nconsole: {url}\nlog: {log}\n{pid_line}\n",
log = log_path.display()
)
}
fn open_console(no_open: bool, url: &str) {
if no_open {
return;
}
println!("opening your browser…");
if let Err(error) = console::open_browser(url) {
println!("could not open a browser ({error}); open {url} yourself");
}
}
fn not_aion_report(address: SocketAddr, answer: Option<&str>) -> String {
let answered = match answer {
Some(line) => format!("it answered `{line}`"),
None => "it accepted the connection but did not answer the liveness probe".to_owned(),
};
format!(
"port {port} at {address} is already in use by something that is not an Aion server \
({answered}). Stop that process, or point Aion elsewhere: set `[server] listen_address` \
in the config, or AION_SERVER_LISTEN_ADDRESS.",
port = address.port()
)
}
pub(crate) fn log_tail(path: &Path) -> String {
match read_tail_window(path) {
Ok(text) => {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(LOG_TAIL_LINES);
let tail = lines[start..].join("\n");
if tail.is_empty() {
"(the log is empty)".to_owned()
} else {
tail
}
}
Err(error) => format!("(could not read the log: {error})"),
}
}
fn read_tail_window(path: &Path) -> std::io::Result<String> {
use std::io::{Read as _, Seek as _};
let mut file = std::fs::File::open(path)?;
let length = file.metadata()?.len();
file.seek(std::io::SeekFrom::Start(
length.saturating_sub(LOG_TAIL_WINDOW_BYTES),
))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
type TestError = Box<dyn std::error::Error>;
#[test]
fn the_not_aion_refusal_names_port_and_answer() -> Result<(), std::net::AddrParseError> {
let address: SocketAddr = "127.0.0.1:8080".parse()?;
let quoted = not_aion_report(address, Some("HTTP/1.1 404 Not Found"));
assert!(quoted.contains("port 8080"));
assert!(quoted.contains("not an Aion server"));
assert!(quoted.contains("`HTTP/1.1 404 Not Found`"));
assert!(quoted.contains("AION_SERVER_LISTEN_ADDRESS"));
let silent = not_aion_report(address, None);
assert!(silent.contains("port 8080"));
assert!(silent.contains("did not answer the liveness probe"));
Ok(())
}
#[test]
fn the_config_line_claims_created_only_for_the_template_bytes() -> Result<(), TestError> {
let scratch = tempfile::tempdir()?;
let project_config = scratch.path().join("aion.toml");
let home_config = scratch.path().join("config.toml");
let line = config_report_line(Some(&project_config), &project_config, &home_config);
assert_eq!(line, format!("config: {}", project_config.display()));
let line = config_report_line(None, &project_config, &home_config);
assert!(line.starts_with("config: built-in defaults"));
std::fs::write(&home_config, FIRST_RUN_CONFIG)?;
let line = config_report_line(None, &project_config, &home_config);
assert!(
line.contains(&format!("created {}", home_config.display())),
"template bytes must report as created, got: {line}"
);
std::fs::write(&home_config, "# operator's own\n")?;
let line = config_report_line(None, &project_config, &home_config);
assert!(
!line.contains("created") && line.contains("appeared during startup"),
"foreign bytes must not claim creation, got: {line}"
);
Ok(())
}
#[test]
fn the_ready_report_names_every_location_per_backend() {
let log_path = Path::new("/tmp/aion-home/server.log");
let mut store = StoreConfig::default();
store.backend = StoreBackend::Haematite;
store.data_dir = Some("/data/haematite".to_owned());
let pid_line = "server process id: 42 (stop it with: aion server stop)";
let report = ready_report(
&store,
"config: X",
"http://127.0.0.1:8080/",
log_path,
pid_line,
);
assert!(report.starts_with("aion is up.\n"));
assert!(report.contains("config: X\n"));
assert!(report.contains("data: /data/haematite\n"));
assert!(report.contains("console: http://127.0.0.1:8080/\n"));
assert!(report.contains("log: /tmp/aion-home/server.log\n"));
assert!(report.contains("server process id: 42 (stop it with: aion server stop)\n"));
store.backend = StoreBackend::Memory;
let report = ready_report(&store, "config: X", "u", log_path, pid_line);
assert!(report.contains("data: in-memory — state does not survive a stop\n"));
}
#[test]
fn the_pid_line_promises_the_stop_verb_only_for_the_recorded_pid() -> Result<(), TestError> {
let home = tempfile::tempdir()?;
let line = pid_report_line(home.path(), 42);
assert!(
line.contains("no pid record appeared") && !line.contains("(stop it with:"),
"an absent record must withhold the promise, got: {line}"
);
let me = aion_server::control::incarnation::self_identity()?;
let record = aion_server::control::PidRecord {
pid: me.pid,
started_at_unix_secs: me.started_at_unix_secs,
binary_sha256: me.binary_sha256,
version: "0.0.0-test".to_owned(),
commit: "test".to_owned(),
state: aion_server::control::IncarnationState::Serving,
http_address: Some("127.0.0.1:8080".parse()?),
grpc_address: Some("127.0.0.1:50051".parse()?),
intended_http_address: None,
intended_grpc_address: None,
stage: None,
stage_detail: None,
stage_seq: 0,
stage_updated_at_unix_secs: 0,
drain_timeout_seconds: 30,
};
let run_dir = home.path().join("run");
std::fs::create_dir_all(&run_dir)?;
std::fs::write(
run_dir.join("aion-server.pid"),
serde_json::to_string(&record)?,
)?;
let line = pid_report_line(home.path(), me.pid);
assert!(
line.contains("(stop it with: aion server stop)"),
"the recorded pid earns the promise, got: {line}"
);
let line = pid_report_line(home.path(), me.pid.wrapping_add(1));
assert!(
line.contains("stops THAT server") && !line.contains("(stop it with:"),
"a foreign record must redirect the operator, got: {line}"
);
Ok(())
}
#[test]
fn a_booting_holder_is_found_by_the_pre_spawn_check() -> Result<(), TestError> {
let home = tempfile::tempdir()?;
assert!(
live_holder(home.path()).is_none(),
"an empty home holds nobody"
);
let me = aion_server::control::incarnation::self_identity()?;
let booting = PidRecord {
pid: me.pid,
started_at_unix_secs: me.started_at_unix_secs,
binary_sha256: me.binary_sha256,
version: "0.25.1".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("wal-recovery".to_owned()),
stage_detail: Some("materializing shard 17 of 64".to_owned()),
stage_seq: 17,
stage_updated_at_unix_secs: aion_server::control::pid_file::now_unix_secs(),
drain_timeout_seconds: 0,
};
let run_dir = home.path().join("run");
std::fs::create_dir_all(&run_dir)?;
std::fs::write(
run_dir.join("aion-server.pid"),
serde_json::to_string(&booting)?,
)?;
let holder = live_holder(home.path()).ok_or("a live booting record must be found")?;
assert_eq!(holder.pid, me.pid);
assert_eq!(holder.state, IncarnationState::Booting);
assert_eq!(
holder.stage_line().as_deref(),
Some("wal-recovery — materializing shard 17 of 64"),
"the report the operator gets must carry the stage"
);
Ok(())
}
#[test]
fn a_dead_record_does_not_block_the_spawn() -> Result<(), TestError> {
let home = tempfile::tempdir()?;
let mut child = std::process::Command::new("true").spawn()?;
let dead_pid = child.id();
child.wait()?;
let dead = PidRecord {
pid: dead_pid,
started_at_unix_secs: 0,
binary_sha256: "0".repeat(64),
version: "0.25.1".to_owned(),
commit: "test".to_owned(),
state: IncarnationState::Serving,
http_address: Some("127.0.0.1:8080".parse()?),
grpc_address: Some("127.0.0.1:50051".parse()?),
intended_http_address: None,
intended_grpc_address: None,
stage: None,
stage_detail: None,
stage_seq: 0,
stage_updated_at_unix_secs: 0,
drain_timeout_seconds: 30,
};
let run_dir = home.path().join("run");
std::fs::create_dir_all(&run_dir)?;
std::fs::write(
run_dir.join("aion-server.pid"),
serde_json::to_string(&dead)?,
)?;
assert!(
live_holder(home.path()).is_none(),
"debris must never be mistaken for a running server"
);
Ok(())
}
#[test]
fn the_log_tail_is_bounded_and_reports_unreadable_logs() -> Result<(), TestError> {
let scratch = tempfile::tempdir()?;
let log = scratch.path().join("server.log");
let missing = log_tail(&log);
assert!(
missing.starts_with("(could not read the log:"),
"a missing log must be reported, got: {missing}"
);
std::fs::write(&log, "")?;
assert_eq!(log_tail(&log), "(the log is empty)");
let line = "x".repeat(1024);
let mut many = String::new();
for n in 0..100 {
use std::fmt::Write as _;
let _ = writeln!(many, "{n} {line}");
}
std::fs::write(&log, many)?;
let tail = log_tail(&log);
assert_eq!(tail.lines().count(), LOG_TAIL_LINES);
assert!(tail.lines().last().is_some_and(|l| l.starts_with("99 ")));
Ok(())
}
#[cfg(unix)]
#[test]
fn provisioning_tightens_a_permissive_home_and_refuses_a_symlink() -> Result<(), TestError> {
use std::os::unix::fs::PermissionsExt as _;
let scratch = tempfile::tempdir()?;
let home = scratch.path().join("aion-home");
std::fs::create_dir(&home)?;
std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o755))?;
provision_home(&home).map_err(|failure| failure.message)?;
assert_eq!(
std::fs::metadata(&home)?.permissions().mode() & 0o777,
0o700,
"a permissive existing home must be tightened before the log is written"
);
let linked = scratch.path().join("linked-home");
std::os::unix::fs::symlink(&home, &linked)?;
let Err(failure) = provision_home(&linked) else {
return Err("a symlinked home must be refused".into());
};
assert_eq!(failure.code, 2);
assert!(failure.message.contains("symlink"));
Ok(())
}
}