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::console::{self, HealthProbe};
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;
struct LaunchFailure {
code: u8,
message: String,
}
impl LaunchFailure {
fn refusal(message: String) -> Self {
Self { code: 2, message }
}
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();
let log_path = home.path.join("server.log");
let mut child = spawn_server(&home.path, &log_path)?;
let started = Instant::now();
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(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()
),
));
}
}
if matches!(console::probe_health(address).await, HealthProbe::Live) {
break;
}
if 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 `kill {pid}`. \
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;
}
let config_line = config_report_line(pre_existing.as_deref(), &project_config, &home_config);
print!(
"{}",
ready_report(&store, &config_line, &url, &log_path, child.id())
);
open_console(no_open, &url);
Ok(ExitCode::SUCCESS)
}
fn spawn_server(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 executable = std::env::current_exe().map_err(|error| {
LaunchFailure::refusal(format!("could not resolve the aion executable: {error}"))
})?;
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}"))
})
}
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 ready_report(
store: &StoreConfig,
config_line: &str,
url: &str,
log_path: &Path,
pid: u32,
) -> 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\
server process id: {pid} (stop it with: kill {pid})\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()
)
}
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 report = ready_report(&store, "config: X", "http://127.0.0.1:8080/", log_path, 42);
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: kill 42)\n"));
store.backend = StoreBackend::Memory;
let report = ready_report(&store, "config: X", "u", log_path, 1);
assert!(report.contains("data: in-memory — state does not survive a stop\n"));
}
#[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(())
}
}