use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStdout, Command, Stdio};
use std::time::{Duration, Instant};
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
fn read_line_with_timeout(mut reader: BufReader<ChildStdout>, timeout: Duration) -> Option<String> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut line = String::new();
let result = reader.read_line(&mut line).map(|n| (n, line));
let _ = tx.send(result);
});
match rx.recv_timeout(timeout) {
Ok(Ok((0, _)) | Err(_)) | Err(_) => None,
Ok(Ok((_, line))) => Some(line),
}
}
fn initialize_request(id: u64) -> String {
format!(
r#"{{"jsonrpc":"2.0","id":{id},"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"mcp_lifecycle_test","version":"0"}}}}}}"#
)
}
fn spawn_server(store_path: &std::path::Path) -> Child {
Command::new(env!("CARGO_BIN_EXE_velesdb-memory"))
.env("VELESDB_MEMORY_PATH", store_path)
.env("VELESDB_MEMORY_QUIET", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn velesdb-memory binary")
}
fn complete_initialize_handshake(child: &mut Child) {
let mut stdin = child.stdin.take().expect("child stdin must be piped");
let stdout = child.stdout.take().expect("child stdout must be piped");
let reader = BufReader::new(stdout);
writeln!(stdin, "{}", initialize_request(1)).expect("write initialize to child stdin");
stdin.flush().expect("flush initialize request");
let response = read_line_with_timeout(reader, SHUTDOWN_TIMEOUT)
.unwrap_or_else(|| panic!("no initialize response within {SHUTDOWN_TIMEOUT:?}"));
assert!(
response.contains("\"protocolVersion\""),
"expected an initialize response, got: {response}"
);
child.stdin = Some(stdin);
}
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));
}
}
#[test]
fn server_exits_when_stdin_reaches_eof() {
let store_dir = tempfile::tempdir().expect("create scratch store dir");
let mut child = spawn_server(store_dir.path());
complete_initialize_handshake(&mut child);
drop(child.stdin.take());
let status = wait_for_exit(&mut child, SHUTDOWN_TIMEOUT);
if status.is_none() {
let _ = child.kill();
let _ = child.wait();
}
let status = status.unwrap_or_else(|| {
panic!(
"server did not exit within {SHUTDOWN_TIMEOUT:?} of stdin EOF — \
it is not observing transport closure (#1448)"
)
});
assert!(
status.success(),
"server exited non-zero on stdin EOF: {status:?}"
);
}
#[test]
fn store_lock_is_released_after_stdin_eof_so_a_second_session_can_connect() {
let store_dir = tempfile::tempdir().expect("create scratch store dir");
let mut first = spawn_server(store_dir.path());
complete_initialize_handshake(&mut first);
drop(first.stdin.take());
let first_status = wait_for_exit(&mut first, SHUTDOWN_TIMEOUT);
if first_status.is_none() {
let _ = first.kill();
let _ = first.wait();
}
assert!(
first_status.is_some(),
"first server did not exit within {SHUTDOWN_TIMEOUT:?} of stdin EOF (#1448); \
a second session on the same store cannot be expected to connect"
);
let mut second = spawn_server(store_dir.path());
let mut stdin = second
.stdin
.take()
.expect("second child stdin must be piped");
let stdout = second
.stdout
.take()
.expect("second child stdout must be piped");
let reader = BufReader::new(stdout);
writeln!(stdin, "{}", initialize_request(1)).expect("write initialize to second child");
stdin
.flush()
.expect("flush initialize request to second child");
let response = read_line_with_timeout(reader, SHUTDOWN_TIMEOUT).unwrap_or_else(|| {
let _ = second.kill();
let _ = second.wait();
panic!(
"second server on the same store did not answer initialize within \
{SHUTDOWN_TIMEOUT:?} — the store lock from the first (EOF-closed) \
session was not released (#1448)"
);
});
assert!(
response.contains("\"protocolVersion\""),
"expected an initialize response from the second session, got: {response}"
);
drop(stdin);
let second_status = wait_for_exit(&mut second, SHUTDOWN_TIMEOUT);
if second_status.is_none() {
let _ = second.kill();
let _ = second.wait();
}
}
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()
}
const ORPHAN_LOCK_RELEASE_TIMEOUT: Duration = Duration::from_secs(25);
const PROBE_HANDSHAKE_TIMEOUT: Duration = SHUTDOWN_TIMEOUT;
#[test]
fn server_self_exits_when_orphaned_even_with_stdin_held_open() {
let store_dir = tempfile::tempdir().expect("create scratch store dir");
let sync_dir = tempfile::tempdir().expect("create sync dir for handoff fifo");
let fifo_path = sync_dir.path().join("orphan-handoff");
let server_bin = env!("CARGO_BIN_EXE_velesdb-memory");
let mkfifo_status = Command::new("mkfifo")
.arg(&fifo_path)
.status()
.expect("failed to run mkfifo");
assert!(
mkfifo_status.success(),
"mkfifo failed to create handoff fifo"
);
let mut child = Command::new("sh")
.arg("-c")
.arg(format!(
r#"exec 3<&0; (exec "{server_bin}" 0<&3 3<&-) & read -r _line < "{fifo}"; exit 0"#,
fifo = fifo_path.display()
))
.env("VELESDB_MEMORY_PATH", store_dir.path())
.env("VELESDB_MEMORY_QUIET", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn intermediate shell");
complete_initialize_handshake(&mut child);
std::fs::write(&fifo_path, b"go\n").expect("signal intermediate to exit via fifo");
let stdin_guard = child.stdin.take().expect("stdin must stay piped open");
let stderr = child.stderr.take().expect("stderr must be piped");
let deadline = Instant::now() + ORPHAN_LOCK_RELEASE_TIMEOUT;
let mut released = false;
while Instant::now() < deadline {
let mut probe = spawn_server(store_dir.path());
let mut probe_stdin = probe.stdin.take().expect("probe stdin must be piped");
let probe_stdout = probe.stdout.take().expect("probe stdout must be piped");
let reader = BufReader::new(probe_stdout);
let wrote = writeln!(probe_stdin, "{}", initialize_request(1)).is_ok()
&& probe_stdin.flush().is_ok();
if wrote {
if let Some(line) = read_line_with_timeout(reader, PROBE_HANDSHAKE_TIMEOUT) {
if line.contains("\"protocolVersion\"") {
released = true;
}
}
}
drop(probe_stdin);
let _ = probe.kill();
let _ = probe.wait();
if released {
break;
}
std::thread::sleep(Duration::from_millis(500));
}
drop(stdin_guard);
let stderr_text = drain_with_timeout(stderr, Duration::from_secs(2));
assert!(
released,
"store lock was never released within {ORPHAN_LOCK_RELEASE_TIMEOUT:?} of the \
server being orphaned with stdin still held open — the server does not \
detect that its parent died and self-exit (#1448)"
);
assert!(
stderr_text.to_lowercase().contains("parent"),
"expected the orphaned server to log a clear parent-death shutdown \
message on stderr, got: {stderr_text:?}"
);
}
#[test]
fn database_locked_at_startup_prints_actionable_guidance_and_exits_nonzero() {
let store_dir = tempfile::tempdir().expect("create scratch store dir");
let mut holder = spawn_server(store_dir.path());
complete_initialize_handshake(&mut holder);
let mut contender = Command::new(env!("CARGO_BIN_EXE_velesdb-memory"))
.env("VELESDB_MEMORY_PATH", store_dir.path())
.env("VELESDB_MEMORY_QUIET", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn contending velesdb-memory process");
let stderr = contender
.stderr
.take()
.expect("contender stderr must be piped");
drop(contender.stdin.take());
drop(contender.stdout.take());
let status = wait_for_exit(&mut contender, Duration::from_secs(5)).unwrap_or_else(|| {
let _ = contender.kill();
let _ = contender.wait();
panic!(
"a process opening an already-locked store did not exit within 5s \
(#1448) — startup must fail fast (bounded retry), not hang"
);
});
assert!(
!status.success(),
"a process opening an already-locked store must exit non-zero so \
client health-checks can detect the failure, got: {status:?}"
);
let stderr_text = drain_with_timeout(stderr, Duration::from_secs(2));
let lower = stderr_text.to_lowercase();
assert!(
lower.contains("velesdb_memory_path") && lower.contains("pkill"),
"expected an actionable lock-contention message on stderr (naming \
VELESDB_MEMORY_PATH as the escape hatch and pkill as the fix), \
got: {stderr_text:?}"
);
let _ = holder.kill();
let _ = holder.wait();
}