use std::io::Read;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
const CONTENDER_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
fn spawn_http_daemon(store_path: &std::path::Path) -> Child {
Command::new(env!("CARGO_BIN_EXE_velesdb-memory"))
.arg("--http")
.arg("--http-insecure")
.arg("--http-port")
.arg("0") .env("VELESDB_MEMORY_PATH", store_path)
.env("VELESDB_MEMORY_QUIET", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn velesdb-memory --http")
}
fn wait_for_stderr_line(
mut stderr: impl Read + Send + 'static,
needle: &'static str,
timeout: Duration,
) -> bool {
use std::io::{BufRead, BufReader};
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let reader = BufReader::new(&mut stderr);
for line in reader.lines() {
let Ok(line) = line else { break };
let found = line.contains(needle);
let _ = tx.send(line);
if found {
return;
}
}
});
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return false;
}
match rx.recv_timeout(remaining) {
Ok(line) if line.contains(needle) => return true,
Ok(_) => {} Err(_) => return false,
}
}
}
fn wait_for_exit(child: &mut Child, timeout: Duration) -> Option<std::process::ExitStatus> {
let deadline = Instant::now() + timeout;
loop {
if let Ok(Some(status)) = child.try_wait() {
return Some(status);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn drain_with_timeout<R: Read + Send + 'static>(mut reader: R, timeout: Duration) -> String {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = String::new();
let _ = reader.read_to_string(&mut buf);
let _ = tx.send(buf);
});
rx.recv_timeout(timeout).unwrap_or_default()
}
#[test]
fn second_http_daemon_on_same_store_fails_with_actionable_lock_message() {
let store_dir = tempfile::tempdir().expect("create scratch store dir");
let mut holder = spawn_http_daemon(store_dir.path());
let holder_stderr = holder.stderr.take().expect("holder stderr must be piped");
let holder_up = wait_for_stderr_line(holder_stderr, "HTTP server listening", STARTUP_TIMEOUT);
if !holder_up {
let _ = holder.kill();
let _ = holder.wait();
panic!(
"first HTTP daemon never logged its listening line within {STARTUP_TIMEOUT:?} — \
it must be up (store opened, port bound) before this test can prove anything \
about a second daemon contending on the same store"
);
}
let mut contender = spawn_http_daemon(store_dir.path());
let contender_stderr = contender
.stderr
.take()
.expect("contender stderr must be piped");
drop(contender.stdout.take());
let status = wait_for_exit(&mut contender, CONTENDER_EXIT_TIMEOUT).unwrap_or_else(|| {
let _ = contender.kill();
let _ = contender.wait();
panic!(
"a second --http daemon opening an already-locked store did not exit within \
{CONTENDER_EXIT_TIMEOUT:?} — startup must fail fast (bounded retry), not hang, \
exactly like the stdio path (#1448)"
);
});
assert!(
!status.success(),
"a second --http daemon opening an already-locked store must exit non-zero, got: {status:?}"
);
let stderr_text = drain_with_timeout(contender_stderr, Duration::from_secs(2));
let lower = stderr_text.to_lowercase();
assert!(
lower.contains("velesdb_memory_path") && lower.contains("pkill"),
"expected the same actionable lock-contention message the stdio path prints \
(naming VELESDB_MEMORY_PATH as the escape hatch and pkill as the fix), \
got: {stderr_text:?}"
);
let _ = holder.kill();
let _ = holder.wait();
}