#[path = "../../liminal-server/src/test_persistence.rs"]
mod test_persistence;
use std::error::Error;
use std::io::{BufRead, BufReader};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::thread;
use std::time::{Duration, Instant};
const STARTUP_EVENT: &str = "liminal server started";
const HANG_GUARD: Duration = Duration::from_secs(300);
const CONNECT_GUARD: Duration = Duration::from_secs(5);
struct ServerProcess {
child: Child,
}
impl Drop for ServerProcess {
fn drop(&mut self) {
if let Err(error) = self.child.kill() {
match self.child.try_wait() {
Ok(Some(status)) => eprintln!(
"server cleanup kill failed because the child already exited ({status}): {error}"
),
Ok(None) => {
eprintln!("server cleanup kill failed with the child still running: {error}");
}
Err(status_error) => eprintln!(
"server cleanup kill failed: {error}; child status failed: {status_error}"
),
}
}
if let Err(error) = self.child.wait() {
eprintln!("server cleanup wait failed: {error}");
}
}
}
fn reserved_address() -> Result<SocketAddr, Box<dyn Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
drop(listener);
Ok(address)
}
fn accepts_connection(address: SocketAddr) -> bool {
TcpStream::connect_timeout(&address, CONNECT_GUARD).is_ok()
}
fn stderr_lines(child: &mut Child) -> Result<Receiver<String>, Box<dyn Error>> {
let stderr = child
.stderr
.take()
.ok_or("spawned server had no piped stderr")?;
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
for line in BufReader::new(stderr).lines() {
let Ok(line) = line else { return };
if sender.send(line).is_err() {
return;
}
}
});
Ok(receiver)
}
enum Unheard {
Exited,
Hung,
}
fn await_line(
receiver: &Receiver<String>,
needle: &str,
) -> Result<Vec<String>, (Unheard, Vec<String>)> {
let guard = Instant::now() + HANG_GUARD;
let mut seen = Vec::new();
loop {
let remaining = guard.saturating_duration_since(Instant::now());
match receiver.recv_timeout(remaining) {
Ok(line) => {
let matched = line.contains(needle);
seen.push(line);
if matched {
return Ok(seen);
}
}
Err(RecvTimeoutError::Disconnected) => return Err((Unheard::Exited, seen)),
Err(RecvTimeoutError::Timeout) => return Err((Unheard::Hung, seen)),
}
}
}
#[test]
fn spawned_server_reports_startup_on_stderr() -> Result<(), Box<dyn Error>> {
let directory = test_persistence::directory()?;
let listen_address = reserved_address()?;
let health_listen_address = reserved_address()?;
let config_path = directory.path().join("server.toml");
std::fs::write(
&config_path,
format!(
"listen_address = \"{listen_address}\"\n\
health_listen_address = \"{health_listen_address}\"\n\
drain_timeout_ms = 5000\n\
channels = []\n\
routing_rules = []\n"
),
)?;
let mut server = ServerProcess {
child: Command::new(env!("CARGO_BIN_EXE_liminal"))
.arg("--config")
.arg(&config_path)
.env("LIMINAL_PERSISTENCE_PATH", directory.path())
.env_remove("RUST_LOG")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?,
};
let stderr = stderr_lines(&mut server.child)?;
match await_line(&stderr, STARTUP_EVENT) {
Ok(_) if accepts_connection(listen_address) => Ok(()),
Ok(seen) => Err(format!(
"server wrote {STARTUP_EVENT:?} but {listen_address} refused a connection; \
stderr carried {} line(s): {seen:#?}",
seen.len()
)
.into()),
Err((Unheard::Exited, seen)) => {
let status = server.child.wait()?;
Err(format!(
"server exited ({status}) without writing {STARTUP_EVENT:?}; \
stderr carried {} line(s): {seen:#?}",
seen.len()
)
.into())
}
Err((Unheard::Hung, seen)) => {
let bound = accepts_connection(listen_address);
Err(format!(
"after {HANG_GUARD:?} the server {} and never wrote {STARTUP_EVENT:?}; \
stderr carried {} line(s): {seen:#?}",
if bound {
format!("bound {listen_address}")
} else {
format!("had not bound {listen_address}")
},
seen.len()
)
.into())
}
}
}
#[test]
fn spawned_server_storage_refusal_is_named_and_exits_nonzero() -> Result<(), Box<dyn Error>> {
spawned_server_refusal(true)
}
#[test]
fn spawned_server_invalid_persistence_path_is_named_and_exits_nonzero() -> Result<(), Box<dyn Error>>
{
spawned_server_refusal(false)
}
fn spawned_server_refusal(store_failure: bool) -> Result<(), Box<dyn Error>> {
let directory = tempfile::tempdir()?;
let persistence = directory.path().join("not-a-directory");
let blocked = if store_failure {
std::fs::create_dir(&persistence)?;
persistence.join("durability")
} else {
persistence.clone()
};
std::fs::write(&blocked, b"occupied")?;
let encoded_path = toml::Value::String(
persistence
.to_str()
.ok_or("persistence path is not UTF-8")?
.to_owned(),
);
let listen_address = reserved_address()?;
let health_listen_address = reserved_address()?;
let config = directory.path().join("refused.toml");
std::fs::write(
&config,
format!(
"listen_address = \"{listen_address}\"\nhealth_listen_address = \"{health_listen_address}\"\ndrain_timeout_ms = 5000\npersistence_path = {encoded_path}\nchannels = []\nrouting_rules = []\n"
),
)?;
let output = Command::new(env!("CARGO_BIN_EXE_liminal"))
.arg("--config")
.arg(config)
.env_remove("RUST_LOG")
.stdin(Stdio::null())
.output()?;
let stderr = String::from_utf8(output.stderr)?;
assert_eq!(output.status.code(), Some(1), "{stderr}");
let named_error = if store_failure {
"failed to open durable store at"
} else {
"path must be an existing directory"
};
assert!(stderr.contains(named_error), "{stderr}");
assert!(
stderr.contains(blocked.to_str().ok_or("refused path is not UTF-8")?),
"{stderr}"
);
assert_eq!(std::fs::read(blocked)?, b"occupied");
Ok(())
}