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 BOOT_DEADLINE: Duration = Duration::from_secs(10);
const OUTPUT_DEADLINE: Duration = Duration::from_secs(10);
struct ServerProcess {
child: Child,
}
impl Drop for ServerProcess {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
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 wait_until_listening(address: SocketAddr) -> Result<(), Box<dyn Error>> {
let deadline = Instant::now() + BOOT_DEADLINE;
while Instant::now() < deadline {
if TcpStream::connect_timeout(&address, Duration::from_millis(250)).is_ok() {
return Ok(());
}
thread::sleep(Duration::from_millis(50));
}
Err(format!("server never bound {address} within {BOOT_DEADLINE:?}").into())
}
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)
}
fn await_line(receiver: &Receiver<String>, needle: &str) -> Result<Vec<String>, Vec<String>> {
let deadline = Instant::now() + OUTPUT_DEADLINE;
let mut seen = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(seen);
}
match receiver.recv_timeout(remaining) {
Ok(line) => {
let matched = line.contains(needle);
seen.push(line);
if matched {
return Ok(seen);
}
}
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return Err(seen),
}
}
}
#[test]
fn spawned_server_reports_startup_on_stderr() -> Result<(), Box<dyn Error>> {
let directory = tempfile::tempdir()?;
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-server"))
.arg("--config")
.arg(&config_path)
.env_remove("RUST_LOG")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?,
};
let stderr = stderr_lines(&mut server.child)?;
wait_until_listening(listen_address)?;
match await_line(&stderr, STARTUP_EVENT) {
Ok(_) => Ok(()),
Err(seen) => Err(format!(
"server bound {listen_address} but never wrote {STARTUP_EVENT:?} to stderr \
within {OUTPUT_DEADLINE:?}; stderr carried {} line(s): {seen:#?}",
seen.len()
)
.into()),
}
}