#![cfg(unix)]
#[path = "test_support/child.rs"]
mod child_test_support;
#[path = "common/readiness.rs"]
mod readiness;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
use child_test_support::Reaped;
type TestError = Box<dyn std::error::Error>;
type TestResult = Result<(), TestError>;
const BOOT_DEADLINE: Duration = Duration::from_secs(60);
const VERB_DEADLINE: Duration = Duration::from_secs(60);
fn reserve_port() -> Result<u16, TestError> {
let listener = TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}
struct LiveServer {
child: Reaped,
http_port: u16,
grpc_port: u16,
home: PathBuf,
config: PathBuf,
server_log: PathBuf,
workspace: tempfile::TempDir,
}
impl LiveServer {
fn boot() -> Result<Self, TestError> {
Self::boot_with_drain_seconds(30)
}
fn boot_with_drain_seconds(drain_timeout_seconds: u64) -> Result<Self, TestError> {
let workspace = tempfile::tempdir()?;
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(workspace.path(), std::fs::Permissions::from_mode(0o700))?;
}
let http_port = reserve_port()?;
let grpc_port = reserve_port()?;
let config = workspace.path().join("server-config.toml");
std::fs::write(
&config,
format!(
r#"workflow_packages = []
[server]
listen_address = "127.0.0.1:{http_port}"
grpc_address = "127.0.0.1:{grpc_port}"
[store]
backend = "memory"
[runtime]
query_timeout_ms = 10000
[drain]
timeout_seconds = {drain_timeout_seconds}
[namespaces]
default = "default"
[websocket]
event_broadcast_capacity = 1024
cluster_broadcast_capacity = 64
[observability]
max_batch_events = 64
max_batch_hold_ms = 0
[deploy]
enabled = true
max_archive_bytes = 16777216
max_inflated_bytes = 67108864
"#
),
)?;
let home = workspace.path().join("aion-home");
let server_log = workspace.path().join("server.log");
let log_handle = std::fs::File::create(&server_log)?;
let mut child = Reaped::spawn(
Command::new(env!("CARGO_BIN_EXE_aion"))
.args([
"server",
"--config",
config.to_str().ok_or("non-utf8 config path")?,
])
.env("AION_HOME", &home)
.current_dir(workspace.path())
.stdout(Stdio::from(log_handle.try_clone()?))
.stderr(Stdio::from(log_handle)),
)?;
wait_for_boot(&mut child, http_port, grpc_port, &server_log)?;
Ok(Self {
child,
http_port,
grpc_port,
home,
config,
server_log,
workspace,
})
}
fn verb(&self, args: &[&str]) -> Result<Output, TestError> {
let mut child = Reaped::spawn(
Command::new(env!("CARGO_BIN_EXE_aion"))
.args(args)
.env("AION_HOME", &self.home)
.stdout(Stdio::piped())
.stderr(Stdio::piped()),
)?;
let started = Instant::now();
loop {
if child.try_wait()?.is_some() {
return Ok(child.wait_with_output()?);
}
if started.elapsed() > VERB_DEADLINE {
child.kill_and_wait()?;
return Err(
format!("`aion {args:?}` did not finish within {VERB_DEADLINE:?}").into(),
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn aion(&self, args: &[&str]) -> Result<Output, TestError> {
let endpoint = format!("127.0.0.1:{}", self.grpc_port);
let mut child = Reaped::spawn(
Command::new(env!("CARGO_BIN_EXE_aion"))
.args(["--endpoint", &endpoint])
.args(args)
.current_dir(self.workspace.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped()),
)?;
let started = Instant::now();
loop {
if child.try_wait()?.is_some() {
return Ok(child.wait_with_output()?);
}
if started.elapsed() > VERB_DEADLINE {
child.kill_and_wait()?;
return Err(
format!("`aion {args:?}` did not finish within {VERB_DEADLINE:?}").into(),
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn config_arg(&self) -> Result<&str, TestError> {
self.config
.to_str()
.ok_or_else(|| "non-utf8 config path".into())
}
fn pid_file(&self) -> PathBuf {
self.home.join("run").join("aion-server.pid")
}
fn wait_reaped(&mut self) -> Result<(), TestError> {
let started = Instant::now();
loop {
if self.child.try_wait()?.is_some() {
return Ok(());
}
if started.elapsed() > VERB_DEADLINE {
return Err("server child did not exit".into());
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
fn wait_for_boot(
child: &mut Child,
http_port: u16,
grpc_port: u16,
server_log: &Path,
) -> Result<(), TestError> {
match readiness::wait_for_readiness(child, http_port, grpc_port, BOOT_DEADLINE) {
Ok(()) => Ok(()),
Err(failure) => {
if matches!(failure, readiness::ReadinessFailure::TimedOut { .. }) {
let _ = child.kill();
let _ = child.wait();
}
Err(format!("{failure}{}", dying_words(server_log)).into())
}
}
}
fn dying_words(log: &Path) -> String {
match std::fs::read_to_string(log) {
Err(error) => format!(
"; the server log at {} was unreadable: {error}",
log.display()
),
Ok(text) if text.trim().is_empty() => {
format!("; the server log at {} is empty", log.display())
}
Ok(text) => {
let tail: Vec<&str> = text.lines().rev().take(15).collect();
let tail: Vec<&str> = tail.into_iter().rev().collect();
format!("; server log tail:\n{}", tail.join("\n"))
}
}
}
fn unique_sleeper(base_secs: u64) -> (String, String) {
let pid = std::process::id();
let duration = format!("{base_secs}.{pid:07}");
let pattern = format!("sleep {}$", duration.replace('.', "[.]"));
(duration, pattern)
}
fn process_table_holds(pattern: &str) -> Result<bool, TestError> {
let survey = Command::new("pgrep").args(["-f", pattern]).output()?;
match survey.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
other => Err(format!(
"pgrep -f {pattern} failed as an instrument (exit {other:?}); stderr: {}",
text(&survey.stderr)
)
.into()),
}
}
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[test]
fn a_live_server_is_stopped_and_the_drain_outcome_is_rendered() -> TestResult {
let mut server = LiveServer::boot()?;
assert!(
server.pid_file().exists(),
"the pid record must exist once the server has bound"
);
let config = server.config_arg()?.to_owned();
let output = server.verb(&["server", "stop", "--config", &config])?;
let stdout = text(&output.stdout);
let stderr = text(&output.stderr);
assert!(
output.status.success(),
"stop must succeed against a live server; stdout:\n{stdout}\nstderr:\n{stderr}{}",
dying_words(&server.server_log)
);
assert!(
stdout.contains("stopped: pid"),
"the report must name the stopped incarnation: {stdout}"
);
assert!(
stdout.contains("drain outcome: Clean"),
"an idle server's drain must render as Clean, from the outcome record: {stdout}"
);
server.wait_reaped()?;
assert!(
!server.pid_file().exists(),
"the exiting server (or the verb's reconciliation) must remove the pid record"
);
Ok(())
}
#[test]
fn a_kill_9_death_is_reported_honestly_never_fabricated() -> TestResult {
let mut server = LiveServer::boot()?;
let pid = server.child.id();
let killed = Command::new("kill")
.args(["-9", &pid.to_string()])
.status()?;
assert!(killed.success(), "the SIGKILL must be delivered");
server.wait_reaped()?;
let config = server.config_arg()?.to_owned();
let output = server.verb(&["server", "stop", "--config", &config])?;
let stdout = text(&output.stdout);
assert!(
output.status.success(),
"reconciling an already-dead server is a success, not an error: {stdout}"
);
assert!(
stdout.contains("already gone"),
"the report must say nothing was signalled: {stdout}"
);
assert!(
stdout.contains("never closed"),
"the kill -9 shape must be named (bracket never closed): {stdout}"
);
assert!(
!stdout.contains("drain outcome:"),
"no drain outcome may be fabricated for a SIGKILL death: {stdout}"
);
assert!(
!server.pid_file().exists(),
"the stale record must be reconciled away"
);
let second = server.verb(&["server", "stop", "--config", &config])?;
assert_eq!(
second.status.code(),
Some(0),
"a home with no record is the idempotent success, exactly like already-gone; \
stdout:\n{}\nstderr:\n{}",
text(&second.stdout),
text(&second.stderr)
);
assert!(
text(&second.stdout).contains("nothing to stop"),
"the success still names the no-record fact: {}",
text(&second.stdout)
);
Ok(())
}
#[test]
fn a_parked_drain_is_visible_in_the_stop_report() -> TestResult {
let mut server = LiveServer::boot_with_drain_seconds(2)?;
let fixture = server.workspace.path().join("stop_linger.awl");
let (unique_duration, marker) = unique_sleeper(600_000);
std::fs::write(
&fixture,
format!(
r#"//! One long-lingering declared command, for the parked-drain specimen.
workflow stop_linger
input name: String
outcome done: type Done, route success
type Done {{ text: String }}
type RunOutcome {{ exit_code: Int, stdout: String, stderr: String }}
worker stop_linger_box
action linger(name: String) -> RunOutcome
run "sleep {unique_duration}"
step linger
linger(name: name) -> lingered
route done(text: lingered.stdout)
"#
),
)?;
let deploy = server.aion(&["deploy", "stop_linger.awl"])?;
assert!(
deploy.status.success(),
"precondition: the linger document deploys; stdout:\n{}\nstderr:\n{}",
text(&deploy.stdout),
text(&deploy.stderr)
);
let start = server.aion(&["start", "stop_linger", "--input", r#"{"name":"parked"}"#])?;
assert!(
start.status.success(),
"precondition: the linger workflow starts; stdout:\n{}\nstderr:\n{}",
text(&start.stdout),
text(&start.stderr)
);
std::thread::sleep(Duration::from_secs(3));
assert!(
process_table_holds(&marker)?,
"positive control failed: pgrep -f {marker} does not see the sleeper the \
server just dispatched{}",
dying_words(&server.server_log)
);
let config = server.config_arg()?.to_owned();
let output = server.verb(&["server", "stop", "--config", &config, "--patience", "30"])?;
let stdout = text(&output.stdout);
assert!(
output.status.success(),
"a parked drain is a successful stop; stdout:\n{stdout}\nstderr:\n{}{}",
text(&output.stderr),
dying_words(&server.server_log)
);
assert!(
stdout.contains("drain outcome: Parked"),
"the parked face must be visible in the report, distinct from Clean: {stdout}{}",
dying_words(&server.server_log)
);
assert!(
stdout.contains("declared command still executing at drain end:"),
"the report must NAME the declared command the drain parked — an unnamed \
park is the census gap this specimen exists to close: {stdout}{}",
dying_words(&server.server_log)
);
server.wait_reaped()?;
let orphan_deadline = Instant::now() + Duration::from_secs(10);
loop {
if !process_table_holds(&marker)? {
break; }
if Instant::now() >= orphan_deadline {
return Err(format!(
"the parked declared command outlived the server: pgrep -f {marker} \
still matches"
)
.into());
}
std::thread::sleep(Duration::from_millis(200));
}
Ok(())
}
#[test]
fn a_drain_outliving_patience_reports_still_draining_with_exit_one() -> TestResult {
let mut server = LiveServer::boot_with_drain_seconds(600)?;
let fixture = server.workspace.path().join("stop_slow.awl");
let (unique_duration, marker) = unique_sleeper(20);
std::fs::write(
&fixture,
format!(
r#"//! One slow declared command, for the still-draining specimen.
workflow stop_slow
input name: String
outcome done: type Done, route success
type Done {{ text: String }}
type RunOutcome {{ exit_code: Int, stdout: String, stderr: String }}
worker stop_slow_box
action linger(name: String) -> RunOutcome
run "sleep {unique_duration}"
step linger
linger(name: name) -> lingered
route done(text: lingered.stdout)
"#
),
)?;
let deploy = server.aion(&["deploy", "stop_slow.awl"])?;
assert!(
deploy.status.success(),
"precondition: the slow document deploys; stdout:\n{}\nstderr:\n{}",
text(&deploy.stdout),
text(&deploy.stderr)
);
let start = server.aion(&["start", "stop_slow", "--input", r#"{"name":"slow"}"#])?;
assert!(
start.status.success(),
"precondition: the slow workflow starts; stdout:\n{}\nstderr:\n{}",
text(&start.stdout),
text(&start.stderr)
);
std::thread::sleep(Duration::from_secs(3));
assert!(
process_table_holds(&marker)?,
"positive control failed: pgrep -f {marker} does not see the sleeper the \
server just dispatched{}",
dying_words(&server.server_log)
);
let config = server.config_arg()?.to_owned();
let output = server.verb(&["server", "stop", "--config", &config, "--patience", "1"])?;
let stdout = text(&output.stdout);
assert_eq!(
output.status.code(),
Some(1),
"a drain outliving the patience is exit 1 — not success, not a verb failure; \
stdout:\n{stdout}\nstderr:\n{}{}",
text(&output.stderr),
dying_words(&server.server_log)
);
assert!(
stdout.contains("still draining"),
"the report must say the server is still draining: {stdout}"
);
assert!(
stdout.contains("(--patience)"),
"the report must name the flag as the patience source: {stdout}"
);
server.wait_reaped()?;
let orphan_deadline = Instant::now() + Duration::from_secs(10);
loop {
if !process_table_holds(&marker)? {
break;
}
if Instant::now() >= orphan_deadline {
return Err(format!(
"the slow declared command outlived the server: pgrep -f {marker} \
still matches"
)
.into());
}
std::thread::sleep(Duration::from_millis(200));
}
Ok(())
}
#[test]
fn status_against_a_running_server_states_every_layer() -> TestResult {
let server = LiveServer::boot()?;
let config = server.config_arg()?.to_owned();
let output = server.verb(&["server", "status", "--config", &config])?;
let stdout = text(&output.stdout);
assert!(output.status.success(), "status must compose: {stdout}");
assert!(
stdout.contains("store: in-memory"),
"a memory-backend server must be reported as in-memory, never as its \
resolved data directory: {stdout}"
);
assert!(
stdout.contains("pid file: pid"),
"the record layer must render: {stdout}"
);
assert!(
stdout.contains("RUNNING, incarnation verified"),
"the incarnation must verify against the live process: {stdout}"
);
assert!(
stdout.contains(&format!("build: {}", env!("CARGO_PKG_VERSION"))),
"the build layer must carry the version read from the BODY: {stdout}"
);
assert!(
stdout.contains("ready: YES"),
"the readiness layer must answer: {stdout}"
);
assert!(
stdout.contains(&format!("127.0.0.1:{}", server.http_port)),
"status must probe the address the record says was bound: {stdout}"
);
Ok(())
}
#[test]
fn status_against_a_stopped_server_states_the_absences() -> TestResult {
let mut server = LiveServer::boot()?;
let config = server.config_arg()?.to_owned();
let stop = server.verb(&["server", "stop", "--config", &config])?;
assert!(stop.status.success(), "precondition: the stop succeeds");
server.wait_reaped()?;
let output = server.verb(&["server", "status", "--config", &config])?;
let stdout = text(&output.stdout);
assert_eq!(
output.status.code(),
Some(1),
"no running server is exit 1 — the absence is still REPORTED in full, but \
a script asking `is the server up?` must get a no: {stdout}"
);
assert!(
stdout.contains("pid file: none"),
"the record absence must be stated: {stdout}"
);
assert!(
stdout.contains("CONFIGURED address"),
"with no record, the probe source must be named as the config: {stdout}"
);
assert!(
stdout.contains("build: unreachable"),
"the dead listener must be its own state: {stdout}"
);
Ok(())
}
#[test]
fn status_degrades_but_answers_when_the_configuration_is_unreadable() -> TestResult {
let home = tempfile::tempdir()?;
let bad_config = home.path().join("broken.toml");
std::fs::write(&bad_config, "this = is not [ valid toml")?;
let output = Command::new(env!("CARGO_BIN_EXE_aion"))
.args([
"server",
"status",
"--config",
bad_config.to_str().ok_or("config path is not utf-8")?,
])
.env("AION_HOME", home.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
let stdout = text(&output.stdout);
let stderr = text(&output.stderr);
assert_eq!(
output.status.code(),
Some(1),
"an unclaimed home with a broken config is 'not running' (1), never a \
verb failure (2): stdout={stdout} stderr={stderr}"
);
assert!(
stderr.contains("configuration: could not load"),
"the config failure must be stated as its own layer: {stderr}"
);
assert!(
stdout.contains("store: unknown — the configuration could not be read"),
"the store line must state its unavailability, not vanish: {stdout}"
);
assert!(
stdout.contains("no server has claimed this home"),
"the record layer must still answer: {stdout}"
);
assert!(
stdout.contains("the listener layers cannot be probed"),
"an unprobeable listener is a stated fact, not a silent skip: {stdout}"
);
Ok(())
}
#[test]
fn stop_reaches_the_goal_state_when_the_configuration_is_unreadable() -> TestResult {
let home = tempfile::tempdir()?;
let bad_config = home.path().join("broken.toml");
std::fs::write(&bad_config, "this = is not [ valid toml")?;
let output = Command::new(env!("CARGO_BIN_EXE_aion"))
.args([
"server",
"stop",
"--config",
bad_config.to_str().ok_or("config path is not utf-8")?,
])
.env("AION_HOME", home.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
let stdout = text(&output.stdout);
let stderr = text(&output.stderr);
assert_eq!(
output.status.code(),
Some(0),
"an unclaimed home is the goal state (0) even with a broken config — \
the patience is only needed to wait on a running server: \
stdout={stdout} stderr={stderr}"
);
assert!(
stdout.contains("nothing to stop"),
"the goal-state line must still be printed: {stdout}"
);
assert!(
stderr.contains("the wait patience could not be resolved from configuration"),
"the resolution failure must be stated as its own layer's fact, never \
swallowed: {stderr}"
);
assert_eq!(
stderr.matches("could not be resolved").count(),
1,
"the failure must be framed exactly once — a sentence inside a \
sentence means the carried account arrived pre-framed: {stderr}"
);
Ok(())
}
#[test]
fn the_control_verbs_refuse_an_endpoint_at_the_binary() -> TestResult {
for (verb, action) in [("stop", "stop"), ("status", "report status")] {
let home = tempfile::tempdir()?;
let output = Command::new(env!("CARGO_BIN_EXE_aion"))
.args(["--endpoint", "10.0.0.9:50051", "server", verb])
.env("AION_HOME", home.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
let stdout = text(&output.stdout);
let stderr = text(&output.stderr);
assert_eq!(
output.status.code(),
Some(2),
"`aion --endpoint … server {verb}` must refuse (2), never act \
locally: stdout={stdout} stderr={stderr}"
);
assert!(
stderr.contains(&format!("refusing to {action}")),
"the refusal must open with its own rendered verb phrase: {stderr}"
);
assert!(
stderr.contains(&format!("`aion server {verb}` is local")),
"the refusal must say THIS verb is local: {stderr}"
);
assert!(
!stdout.contains("nothing to stop") && !stdout.contains("pid file"),
"a refusal must precede the verb's own output: {stdout}"
);
}
Ok(())
}