use std::path::PathBuf;
use std::process::{ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use std::{io, net::SocketAddr, time::Duration};
use tempfile::tempdir;
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream};
use tokio::process::{Child, Command};
use tokio::time::{sleep, timeout};
use tsoracle_client::{Client, ClientError};
async fn bind_unused_set(n: usize) -> Vec<SocketAddr> {
let mut listeners = Vec::with_capacity(n);
for _ in 0..n {
listeners.push(TcpListener::bind("127.0.0.1:0").await.unwrap());
}
let addrs: Vec<SocketAddr> = listeners.iter().map(|l| l.local_addr().unwrap()).collect();
drop(listeners);
addrs
}
async fn wait_until_accepting(addr: SocketAddr, budget: Duration) -> io::Result<()> {
timeout(budget, async move {
loop {
if TcpStream::connect(addr).await.is_ok() {
return;
}
sleep(Duration::from_millis(25)).await;
}
})
.await
.map_err(|_| {
io::Error::new(
io::ErrorKind::TimedOut,
format!("server at {addr} did not accept within {budget:?}"),
)
})
}
enum AwaitOutcome {
Ready,
ChildExited(ExitStatus),
Timeout(SocketAddr),
}
#[cfg(feature = "metrics")]
fn disable_metrics_exporter_for_test(cmd: &mut Command) {
cmd.arg("--no-metrics");
}
#[cfg(not(feature = "metrics"))]
fn disable_metrics_exporter_for_test(_cmd: &mut Command) {}
async fn await_listening(
child: &mut Child,
ready_idx: &[usize],
addrs: &[SocketAddr],
per_addr_budget: Duration,
) -> AwaitOutcome {
for &i in ready_idx {
let addr = addrs[i];
let waiter = wait_until_accepting(addr, per_addr_budget);
tokio::pin!(waiter);
tokio::select! {
res = &mut waiter => match res {
Ok(()) => continue,
Err(_) => return AwaitOutcome::Timeout(addr),
},
res = child.wait() => {
let status = res.expect("wait on child failed");
return AwaitOutcome::ChildExited(status);
}
}
}
AwaitOutcome::Ready
}
async fn retry_spawn<F>(
n_ports: usize,
ready_idx: &[usize],
per_addr_budget: Duration,
mut build: F,
) -> (Child, Vec<SocketAddr>)
where
F: FnMut(&[SocketAddr]) -> Command,
{
const MAX_ATTEMPTS: usize = 3;
let mut last_eaddrinuse: Option<String> = None;
for attempt in 0..MAX_ATTEMPTS {
let addrs = bind_unused_set(n_ports).await;
let mut cmd = build(&addrs);
disable_metrics_exporter_for_test(&mut cmd);
cmd.stderr(Stdio::piped()).kill_on_drop(true);
let mut child = cmd.spawn().expect("spawn tsoracle");
let stderr_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let mut stderr_pipe = child.stderr.take().expect("stderr piped");
let drain_buf = Arc::clone(&stderr_buf);
let drain = tokio::spawn(async move {
let mut chunk = [0u8; 4096];
loop {
match stderr_pipe.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => drain_buf.lock().unwrap().extend_from_slice(&chunk[..n]),
}
}
});
match await_listening(&mut child, ready_idx, &addrs, per_addr_budget).await {
AwaitOutcome::Ready => {
drop(drain);
return (child, addrs);
}
AwaitOutcome::ChildExited(status) => {
let _ = drain.await;
let stderr = String::from_utf8_lossy(&stderr_buf.lock().unwrap()).into_owned();
if stderr.contains("Address already in use") {
last_eaddrinuse = Some(format!(
"attempt {}/{MAX_ATTEMPTS}: EADDRINUSE (status={status})\
\nstderr:\n{stderr}",
attempt + 1,
));
continue;
}
panic!(
"binary exited before accepting connections: status={status}\
\nstderr:\n{stderr}"
);
}
AwaitOutcome::Timeout(addr) => {
let _ = child.kill().await;
let _ = drain.await;
let stderr = String::from_utf8_lossy(&stderr_buf.lock().unwrap()).into_owned();
panic!(
"binary did not start accepting on {addr} within {per_addr_budget:?}\
\nstderr:\n{stderr}"
);
}
}
}
panic!(
"EADDRINUSE on all {MAX_ATTEMPTS} port-allocation attempts; last error:\n{}",
last_eaddrinuse.unwrap_or_else(|| "<none>".into())
);
}
#[tokio::test]
async fn binary_serves_timestamps() {
let binary_path = env!("CARGO_BIN_EXE_tsoracle");
let state_dir = tempdir().unwrap();
let (mut child, addrs) = retry_spawn(1, &[0], Duration::from_secs(10), |addrs| {
let mut cmd = Command::new(binary_path);
cmd.arg("serve")
.arg("file")
.arg("--listen")
.arg(addrs[0].to_string())
.arg("--state-dir")
.arg(state_dir.path())
.arg("--log")
.arg("warn");
cmd
})
.await;
let listen_addr = addrs[0];
let client = Client::connect(vec![listen_addr.to_string()])
.await
.unwrap();
wait_until_responsive(&client, Duration::from_secs(5))
.await
.expect("server never became responsive after starting to accept");
let ts1 = client.get_ts().await.unwrap();
let ts2 = client.get_ts().await.unwrap();
assert!(ts2 > ts1, "ts2 {ts2:?} > ts1 {ts1:?}");
child.kill().await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn sigterm_triggers_graceful_shutdown() {
let binary_path = env!("CARGO_BIN_EXE_tsoracle");
let state_dir = tempdir().unwrap();
let (mut child, _addrs) = retry_spawn(1, &[0], Duration::from_secs(10), |addrs| {
let mut cmd = Command::new(binary_path);
cmd.arg("serve")
.arg("file")
.arg("--listen")
.arg(addrs[0].to_string())
.arg("--state-dir")
.arg(state_dir.path())
.arg("--log")
.arg("warn");
cmd
})
.await;
let pid = child.id().expect("child has a pid before exit");
let kill = Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.status()
.await
.expect("spawn kill");
assert!(kill.success(), "failed to deliver SIGTERM to pid {pid}");
let status = timeout(Duration::from_secs(10), child.wait())
.await
.expect("server did not exit within the grace period after SIGTERM")
.expect("wait on child failed");
assert!(
status.success(),
"expected graceful exit (status 0) after SIGTERM, got {status}"
);
}
async fn wait_until_responsive(client: &Client, budget: Duration) -> Result<(), ClientError> {
let deadline = Instant::now() + budget;
let mut last_err: Option<ClientError> = None;
loop {
match client.get_ts().await {
Ok(_) => return Ok(()),
Err(err) => {
if Instant::now() >= deadline {
return Err(last_err.unwrap_or(err));
}
last_err = Some(err);
sleep(Duration::from_millis(25)).await;
}
}
}
}
#[cfg(feature = "openraft")]
#[tokio::test]
async fn serve_openraft_single_node_serves_after_bootstrap() {
let binary_path = env!("CARGO_BIN_EXE_tsoracle");
let raft_dir = tempdir().unwrap();
let (mut child, addrs) = retry_spawn(3, &[0], Duration::from_secs(15), |addrs| {
let listen_addr = addrs[0];
let raft_addr = addrs[1];
let admin_addr = addrs[2];
let mut cmd = Command::new(binary_path);
cmd.arg("serve")
.arg("openraft")
.arg("--id")
.arg("1")
.arg("--listen")
.arg(listen_addr.to_string())
.arg("--raft-addr")
.arg(raft_addr.to_string())
.arg("--raft-dir")
.arg(raft_dir.path())
.arg("--bootstrap")
.arg("--members")
.arg(format!("1={raft_addr}/{listen_addr}/{admin_addr}"))
.arg("--log")
.arg("warn");
cmd
})
.await;
let listen_addr = addrs[0];
let client = Client::connect(vec![listen_addr.to_string()])
.await
.unwrap();
wait_until_responsive(&client, Duration::from_secs(15))
.await
.expect("openraft node never became responsive after starting to accept");
let ts1 = client.get_ts().await.unwrap();
let ts2 = client.get_ts().await.unwrap();
assert!(ts2 > ts1, "ts2 {ts2:?} > ts1 {ts1:?}");
child.kill().await.unwrap();
}
struct ServerCerts {
cert: PathBuf,
key: PathBuf,
ca_path: PathBuf,
ca_pem: String,
}
fn write_server_certs(dir: &std::path::Path) -> ServerCerts {
use rcgen::{
BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair,
KeyUsagePurpose,
};
let ca_key = KeyPair::generate().expect("ca keypair");
let mut ca_params =
CertificateParams::new(vec!["tsoracle-smoke-ca".to_string()]).expect("ca params");
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let ca_cert = ca_params.self_signed(&ca_key).expect("ca self-sign");
let server_key = KeyPair::generate().expect("server keypair");
let mut server_params =
CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()])
.expect("server params");
server_params
.distinguished_name
.push(DnType::CommonName, "localhost");
server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
let server_cert = server_params
.signed_by(&server_key, &ca_cert, &ca_key)
.expect("server sign");
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
let ca_path = dir.join("ca.pem");
let ca_pem = ca_cert.pem();
std::fs::write(&cert_path, server_cert.pem()).expect("write cert.pem");
std::fs::write(&key_path, server_key.serialize_pem()).expect("write key.pem");
std::fs::write(&ca_path, &ca_pem).expect("write ca.pem");
ServerCerts {
cert: cert_path,
key: key_path,
ca_path,
ca_pem,
}
}
#[tokio::test]
async fn serve_file_with_client_tls_serves_over_tls() {
let binary_path = env!("CARGO_BIN_EXE_tsoracle");
let certdir = tempdir().unwrap();
let certs = write_server_certs(certdir.path());
let state_dir = tempdir().unwrap();
let (mut child, addrs) = retry_spawn(1, &[0], Duration::from_secs(10), |addrs| {
let mut cmd = Command::new(binary_path);
cmd.arg("serve")
.arg("file")
.arg("--listen")
.arg(addrs[0].to_string())
.arg("--state-dir")
.arg(state_dir.path())
.arg("--tls-cert")
.arg(&certs.cert)
.arg("--tls-key")
.arg(&certs.key)
.arg("--log")
.arg("warn");
cmd
})
.await;
let listen_addr = addrs[0];
let tls = tonic::transport::ClientTlsConfig::new()
.ca_certificate(tonic::transport::Certificate::from_pem(&certs.ca_pem))
.domain_name("localhost");
let client = tsoracle_client::ClientBuilder::endpoints(vec![format!(
"localhost:{}",
listen_addr.port()
)])
.tls_config(tls)
.build()
.await
.expect("client build");
wait_until_responsive(&client, Duration::from_secs(5))
.await
.expect("server never became responsive after TLS handshake");
assert!(client.get_ts().await.is_ok());
child.kill().await.unwrap();
}
#[cfg(feature = "openraft")]
#[tokio::test]
async fn serve_openraft_with_peer_mtls_boots_and_serves() {
let binary_path = env!("CARGO_BIN_EXE_tsoracle");
let certdir = tempdir().unwrap();
let certs = write_server_certs(certdir.path());
let raft_dir = tempdir().unwrap();
let (mut child, addrs) = retry_spawn(3, &[0], Duration::from_secs(15), |addrs| {
let listen_addr = addrs[0];
let raft_addr = addrs[1];
let admin_addr = addrs[2];
let mut cmd = Command::new(binary_path);
cmd.arg("serve")
.arg("openraft")
.arg("--id")
.arg("1")
.arg("--listen")
.arg(listen_addr.to_string())
.arg("--raft-addr")
.arg(raft_addr.to_string())
.arg("--raft-dir")
.arg(raft_dir.path())
.arg("--bootstrap")
.arg("--members")
.arg(format!("1={raft_addr}/{listen_addr}/{admin_addr}"))
.arg("--peer-tls-cert")
.arg(&certs.cert)
.arg("--peer-tls-key")
.arg(&certs.key)
.arg("--peer-tls-ca")
.arg(&certs.ca_path)
.arg("--log")
.arg("warn");
cmd
})
.await;
let listen_addr = addrs[0];
let client = Client::connect(vec![listen_addr.to_string()])
.await
.unwrap();
wait_until_responsive(&client, Duration::from_secs(15))
.await
.expect("openraft node with peer mTLS never became responsive");
let ts1 = client.get_ts().await.unwrap();
let ts2 = client.get_ts().await.unwrap();
assert!(ts2 > ts1, "ts2 {ts2:?} > ts1 {ts1:?}");
child.kill().await.unwrap();
}
#[cfg(feature = "openraft")]
#[tokio::test]
async fn admin_members_lists_the_bootstrap_node() {
let binary_path = env!("CARGO_BIN_EXE_tsoracle");
let raft_dir = tempdir().unwrap();
let (mut server, addrs) = retry_spawn(3, &[0, 2], Duration::from_secs(15), |addrs| {
let listen_addr = addrs[0];
let raft_addr = addrs[1];
let admin_addr = addrs[2];
let mut cmd = Command::new(binary_path);
cmd.arg("serve")
.arg("openraft")
.arg("--id")
.arg("1")
.arg("--listen")
.arg(listen_addr.to_string())
.arg("--raft-addr")
.arg(raft_addr.to_string())
.arg("--raft-dir")
.arg(raft_dir.path())
.arg("--bootstrap")
.arg("--members")
.arg(format!("1={raft_addr}/{listen_addr}/{admin_addr}"))
.arg("--admin-listen")
.arg(admin_addr.to_string())
.arg("--heartbeat-ms")
.arg("50")
.arg("--election-min-ms")
.arg("150")
.arg("--election-max-ms")
.arg("300")
.arg("--log")
.arg("warn");
cmd
})
.await;
let listen_addr = addrs[0];
let admin_addr = addrs[2];
let tso_client = Client::connect(vec![listen_addr.to_string()])
.await
.unwrap();
wait_until_responsive(&tso_client, Duration::from_secs(15))
.await
.expect("openraft node never became responsive before admin query");
let output = Command::new(binary_path)
.arg("admin")
.arg("members")
.arg("--endpoint")
.arg(format!("http://{admin_addr}"))
.output()
.await
.expect("spawn tsoracle admin members");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"tsoracle admin members failed (status={})\nstdout: {stdout}\nstderr: {stderr}",
output.status
);
assert!(
stdout.contains("id=1"),
"expected stdout to contain 'id=1', got:\n{stdout}"
);
assert!(
stdout.contains("role=Voter"),
"expected the role rendered as a human-readable name, got:\n{stdout}"
);
server.kill().await.unwrap();
}
#[tokio::test]
async fn serve_paxos_errors_when_feature_compiled_out() {
#[cfg(not(feature = "paxos"))]
{
let exe = env!("CARGO_BIN_EXE_tsoracle");
let output = tokio::process::Command::new(exe)
.args([
"serve",
"paxos",
"--node-id",
"1",
"--peer-listen",
"127.0.0.1:0",
"--peers",
"1=127.0.0.1:1",
"--tso-peers",
"1=127.0.0.1:2",
"--data-dir",
"/tmp/x",
])
.output()
.await
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("does not include the paxos driver"),
"stderr: {stderr}"
);
}
}