use std::process::{ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
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::task::JoinHandle;
use tokio::time::{sleep, timeout};
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_with_stderr<F>(
n_ports: usize,
ready_idx: &[usize],
per_addr_budget: Duration,
mut build: F,
) -> (Child, Vec<SocketAddr>, JoinHandle<()>, Arc<Mutex<Vec<u8>>>)
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.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().expect("spawn tsoracle (heartbeat_subprocess)");
let stdout_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let mut stdout_pipe = child.stdout.take().expect("stdout piped");
let drain_buf = Arc::clone(&stdout_buf);
let drain = tokio::spawn(async move {
let mut chunk = [0u8; 4096];
loop {
match stdout_pipe.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => drain_buf.lock().unwrap().extend_from_slice(&chunk[..n]),
}
}
});
let stderr_diag_buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
let mut stderr_pipe = child.stderr.take().expect("stderr piped");
let stderr_drain_buf = Arc::clone(&stderr_diag_buf);
let stderr_drain = tokio::spawn(async move {
let mut chunk = [0u8; 4096];
loop {
match stderr_pipe.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => stderr_drain_buf
.lock()
.unwrap()
.extend_from_slice(&chunk[..n]),
}
}
});
match await_listening(&mut child, ready_idx, &addrs, per_addr_budget).await {
AwaitOutcome::Ready => {
drop(stderr_drain);
return (child, addrs, drain, stdout_buf);
}
AwaitOutcome::ChildExited(status) => {
let _ = drain.await;
let _ = stderr_drain.await;
let stdout = String::from_utf8_lossy(&stdout_buf.lock().unwrap()).into_owned();
let stderr = String::from_utf8_lossy(&stderr_diag_buf.lock().unwrap()).into_owned();
if stdout.contains("Address already in use")
|| stderr.contains("Address already in use")
{
last_eaddrinuse = Some(format!(
"attempt {}/{MAX_ATTEMPTS}: EADDRINUSE (status={status})\
\nstdout:\n{stdout}\nstderr:\n{stderr}",
attempt + 1,
));
continue;
}
panic!(
"heartbeat_subprocess: binary exited before accepting connections: \
status={status}\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
AwaitOutcome::Timeout(addr) => {
let _ = child.kill().await;
let _ = drain.await;
let _ = stderr_drain.await;
let stdout = String::from_utf8_lossy(&stdout_buf.lock().unwrap()).into_owned();
let stderr = String::from_utf8_lossy(&stderr_diag_buf.lock().unwrap()).into_owned();
panic!(
"heartbeat_subprocess: binary did not start accepting on {addr} \
within {per_addr_budget:?}\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
}
}
panic!(
"heartbeat_subprocess: EADDRINUSE on all {MAX_ATTEMPTS} port-allocation attempts; \
last error:\n{}",
last_eaddrinuse.unwrap_or_else(|| "<none>".into())
);
}
async fn collect_log_output(drain: JoinHandle<()>, stdout_buf: Arc<Mutex<Vec<u8>>>) -> String {
let _ = drain.await;
String::from_utf8_lossy(&stdout_buf.lock().unwrap()).into_owned()
}
#[tokio::test]
async fn binary_emits_heartbeat_lines_at_configured_interval() {
let binary_path = env!("CARGO_BIN_EXE_tsoracle");
let state_dir = tempdir().unwrap();
let (mut child, _addrs, drain, stdout_buf) =
retry_spawn_with_stderr(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("info")
.arg("--heartbeat-interval")
.arg("100ms");
cmd
})
.await;
tokio::time::sleep(Duration::from_millis(400)).await;
child.start_kill().unwrap();
let _ = child.wait().await;
let stdout = collect_log_output(drain, stdout_buf).await;
let count = stdout
.lines()
.filter(|l| l.contains("tsoracle::heartbeat"))
.count();
assert!(
count >= 2,
"expected >= 2 heartbeat lines, got {count}.\nstdout:\n{stdout}"
);
}
#[tokio::test]
async fn binary_with_zero_heartbeat_interval_emits_no_heartbeat_lines() {
let binary_path = env!("CARGO_BIN_EXE_tsoracle");
let state_dir = tempdir().unwrap();
let (mut child, _addrs, drain, stdout_buf) =
retry_spawn_with_stderr(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("info")
.arg("--heartbeat-interval")
.arg("0s");
cmd
})
.await;
tokio::time::sleep(Duration::from_millis(400)).await;
child.start_kill().unwrap();
let _ = child.wait().await;
let stdout = collect_log_output(drain, stdout_buf).await;
let count = stdout
.lines()
.filter(|l| l.contains("tsoracle::heartbeat"))
.count();
assert_eq!(
count, 0,
"expected zero heartbeat lines, got {count}.\nstdout:\n{stdout}"
);
}