use std::fmt::Write as _;
use std::net::TcpListener;
use std::os::unix::process::ExitStatusExt as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use aion_package::{
ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
ManifestVersion, PackageBuilder, PackageContract, WorkerContract,
};
use serde_json::json;
pub type TestError = Box<dyn std::error::Error>;
const BOOT_DEADLINE: Duration = Duration::from_secs(90);
const TAIL_LINES: usize = 50;
const OUTBOX_MODULE: &str = "aion_outbox_fixture";
const OUTBOX_BEAM: &[u8] =
include_bytes!("../../../aion-server/tests/fixtures/aion_outbox_fixture.beam");
const OUTBOX_SOURCE: &[u8] =
include_bytes!("../../../aion-server/tests/fixtures/aion_outbox_fixture.erl");
const FIXTURE_TASK_QUEUE: &str = "default";
const FAN_ACTIVITY_TYPES: [&str; 4] = ["fan:0", "fan:1", "fan:2", "fan:3"];
pub fn reserve_port() -> Result<u16, TestError> {
let listener = TcpListener::bind("127.0.0.1:0")?;
Ok(listener.local_addr()?.port())
}
pub fn write_package_archive(dir: &Path) -> Result<PathBuf, TestError> {
let beams = BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)])?;
let manifest = Manifest {
entry_module: OUTBOX_MODULE.to_owned(),
entry_function: "collect_four".to_owned(),
input_schema: json!({ "type": "object" }),
output_schema: json!({}),
timeout: Some(Duration::from_secs(30)),
activities: FAN_ACTIVITY_TYPES
.iter()
.map(|activity_type| DeclaredActivity {
activity_type: (*activity_type).to_owned(),
})
.collect(),
version: ManifestVersion::new("stamped-by-builder"),
format_version: CURRENT_FORMAT_VERSION,
additional_workflows: Vec::new(),
};
let contract = fixture_contract(&manifest)?;
let archive =
PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
.with_contract(contract)
.write_to_bytes()?;
let path = dir.join("collect_four.aion");
std::fs::write(&path, &archive)?;
Ok(path)
}
fn fixture_contract(manifest: &Manifest) -> Result<PackageContract, TestError> {
let mut actions = Vec::with_capacity(FAN_ACTIVITY_TYPES.len());
for activity_type in FAN_ACTIVITY_TYPES {
let descriptor = aion_worker::activity_descriptor::<String, String>(activity_type)?;
actions.push(ActionContract {
name: descriptor.name,
input_schema: descriptor.input_schema,
output_schema: descriptor.output_schema,
node: None,
timeout: None,
retry: None,
advisory: false,
agent: false,
body: None,
});
}
let mut contract = PackageContract::from_manifest(manifest);
contract.workers = vec![WorkerContract {
task_queue: FIXTURE_TASK_QUEUE.to_owned(),
actions,
}];
contract.unscoped_activities.clear();
Ok(contract)
}
pub fn log_redirect(path: &Path) -> Result<(Stdio, Stdio), TestError> {
let file = std::fs::File::create(path)?;
let duplicate = file.try_clone()?;
Ok((Stdio::from(file), Stdio::from(duplicate)))
}
pub fn log_tail(path: &Path) -> String {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) => return format!("<no readable log at {}: {error}>", path.display()),
};
let lines: Vec<&str> = contents.lines().collect();
if lines.is_empty() {
return format!("<{} exists but is empty>", path.display());
}
let skipped = lines.len().saturating_sub(TAIL_LINES);
let mut tail = String::new();
if skipped > 0 {
let _ = writeln!(tail, "<{skipped} earlier lines omitted>");
}
for line in &lines[skipped..] {
let _ = writeln!(tail, "{line}");
}
tail
}
pub fn describe_exit(status: ExitStatus) -> String {
if let Some(signal) = status.signal() {
return format!("killed by signal {signal}");
}
match status.code() {
Some(code) => format!("exit status {code}, no signal"),
None => format!("neither an exit code nor a signal ({status})"),
}
}
#[path = "readiness.rs"]
mod readiness;
pub fn wait_for_readiness(
child: &mut Child,
http_port: u16,
grpc_port: u16,
log_path: &Path,
) -> Result<(), TestError> {
use readiness::ReadinessFailure;
match readiness::wait_for_readiness(child, http_port, grpc_port, BOOT_DEADLINE) {
Ok(()) => Ok(()),
Err(ReadinessFailure::Exited {
status, http_ready, ..
}) => {
let phase = if http_ready { "AFTER" } else { "BEFORE" };
Err(format!(
"server exited during boot {phase} binding 127.0.0.1:{http_port} \
({exit}); last {TAIL_LINES} lines of {log}:\n{tail}",
exit = describe_exit(status),
log = log_path.display(),
tail = log_tail(log_path),
)
.into())
}
Err(failure) => Err(format!(
"{failure}; last {TAIL_LINES} lines of {log}:\n{tail}",
log = log_path.display(),
tail = log_tail(log_path),
)
.into()),
}
}
pub fn preserve_on_failure(work_dir: &Path, label: &str, error: &TestError) -> TestError {
match preserve_work_dir(work_dir, label) {
Ok((dest, copied)) => format!(
"{error}\n\npreserved this run's files in {dest} ({files})",
dest = dest.display(),
files = if copied.is_empty() {
"no files found".to_owned()
} else {
copied.join(", ")
},
)
.into(),
Err(copy_error) => format!(
"{error}\n\n<could not preserve the run's logs from {dir}: {copy_error}>",
dir = work_dir.display(),
)
.into(),
}
}
fn preserve_work_dir(work_dir: &Path, label: &str) -> Result<(PathBuf, Vec<String>), TestError> {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
let dest = Path::new(env!("CARGO_TARGET_TMPDIR"))
.join("failover-logs")
.join(format!("{label}-{pid}-{stamp}", pid = std::process::id()));
std::fs::create_dir_all(&dest)?;
let mut copied: Vec<String> = Vec::new();
for entry in std::fs::read_dir(work_dir)? {
let entry = entry?;
if entry.path().is_file() {
std::fs::copy(entry.path(), dest.join(entry.file_name()))?;
copied.push(entry.file_name().to_string_lossy().into_owned());
}
}
copied.sort();
Ok((dest, copied))
}
pub fn wait_until<F: FnMut() -> bool>(deadline: Duration, mut predicate: F) -> bool {
let started = Instant::now();
while started.elapsed() < deadline {
if predicate() {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
false
}
pub fn build_worker_binary() -> Result<PathBuf, TestError> {
let worker_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("spike")
.join("liminal-fan-worker");
let status = Command::new(env!("CARGO"))
.args(["build"])
.current_dir(&worker_dir)
.status()?;
if !status.success() {
return Err("failed to build spike/liminal-fan-worker".into());
}
let binary = worker_dir
.join("target")
.join("debug")
.join("liminal-fan-worker");
if !binary.exists() {
return Err(format!("worker binary not found at {}", binary.display()).into());
}
Ok(binary)
}
#[cfg(test)]
mod harness_diagnostics {
use super::{TestError, log_redirect, log_tail, preserve_on_failure, reserve_port};
use crate::child_test_support::Reaped;
use std::path::Path;
use std::process::Command;
fn spawn_doomed_child(log_path: &Path) -> Result<Reaped, TestError> {
let (stdout, stderr) = log_redirect(log_path)?;
Ok(Reaped::spawn(
Command::new("/bin/sh")
.args(["-c", "echo 'config parse failed'; echo 'boom' >&2; exit 3"])
.stdout(stdout)
.stderr(stderr),
)?)
}
#[test]
fn a_boot_death_reports_the_exit_status_the_bind_phase_and_the_log() -> Result<(), TestError> {
let temp = tempfile::tempdir()?;
let log_path = temp.path().join("node0.log");
let mut child = spawn_doomed_child(&log_path)?;
let http_port = reserve_port()?;
let grpc_port = reserve_port()?;
let Err(error) = super::wait_for_readiness(&mut child, http_port, grpc_port, &log_path)
else {
return Err("a child that exits 3 must not be reported as live".into());
};
let report = error.to_string();
assert!(
report.contains("exit status 3"),
"the exit status must be named: {report}"
);
assert!(
report.contains("BEFORE binding"),
"the bind phase must be named: {report}"
);
assert!(
report.contains("config parse failed") && report.contains("boom"),
"the child's stdout AND stderr must be quoted back: {report}"
);
Ok(())
}
#[test]
fn an_unreadable_or_empty_log_says_so_instead_of_quoting_nothing() -> Result<(), TestError> {
let temp = tempfile::tempdir()?;
let missing = temp.path().join("never-written.log");
assert!(
log_tail(&missing).contains("no readable log"),
"a missing log must be named as missing, not quoted as silence"
);
let empty = temp.path().join("empty.log");
std::fs::write(&empty, "")?;
assert!(
log_tail(&empty).contains("exists but is empty"),
"an empty log must be named as empty, not quoted as silence"
);
Ok(())
}
#[test]
fn a_failure_preserves_the_work_dir_beyond_the_tempdir() -> Result<(), TestError> {
let temp = tempfile::tempdir()?;
std::fs::write(temp.path().join("node0.log"), "the node's dying words\n")?;
let preserved =
preserve_on_failure(temp.path(), "harness-diagnostics", &TestError::from("red"));
let report = preserved.to_string();
assert!(
report.contains("node0.log"),
"the preserved files must be named: {report}"
);
let dir = temp.path().to_path_buf();
drop(temp);
assert!(!dir.exists(), "the tempdir must really be gone");
let dest = report
.rsplit_once("preserved this run's files in ")
.and_then(|(_, tail)| tail.split_once(" ("))
.map(|(path, _)| Path::new(path).to_path_buf())
.ok_or_else(|| TestError::from("the report must name the destination directory"))?;
assert!(
dest.join("node0.log").is_file(),
"the preserved copy must still exist at {}",
dest.display()
);
Ok(())
}
}
#[cfg(test)]
mod polling {
use super::wait_until;
use std::time::{Duration, Instant};
#[test]
fn wait_until_reports_whether_the_predicate_turned_true_before_the_deadline() {
let mut calls = 0_u32;
let became_true = wait_until(Duration::from_secs(5), || {
calls += 1;
calls >= 3
});
assert!(became_true, "a predicate that turns true is reported true");
assert!(
calls >= 3,
"the predicate is polled until it turns true, not once"
);
let started = Instant::now();
let never = wait_until(Duration::from_millis(250), || false);
assert!(
!never,
"a predicate that never turns true is reported false"
);
assert!(
started.elapsed() >= Duration::from_millis(250),
"false is reported only once the deadline has elapsed"
);
}
}