#![cfg(unix)]
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
fn encode_connect(db: &str) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&(db.len() as u32).to_le_bytes());
payload.extend_from_slice(db.as_bytes());
payload.extend_from_slice(&0u32.to_le_bytes()); let mut frame = Vec::new();
frame.push(0x01); frame.push(0); frame.extend_from_slice(&(payload.len() as u32).to_le_bytes());
frame.extend_from_slice(&payload);
frame
}
fn encode_query(q: &str) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&(q.len() as u32).to_le_bytes());
payload.extend_from_slice(q.as_bytes());
let mut frame = Vec::new();
frame.push(0x03); frame.push(0);
frame.extend_from_slice(&(payload.len() as u32).to_le_bytes());
frame.extend_from_slice(&payload);
frame
}
async fn read_response(stream: &mut TcpStream) -> Vec<u8> {
let mut header = [0u8; 6];
stream.read_exact(&mut header).await.unwrap();
let payload_len = u32::from_le_bytes(header[2..6].try_into().unwrap()) as usize;
let mut payload = vec![0u8; payload_len];
if payload_len > 0 {
stream.read_exact(&mut payload).await.unwrap();
}
let mut full = Vec::new();
full.extend_from_slice(&header);
full.extend_from_slice(&payload);
full
}
fn free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
fn spawn_readonly_server(port: u16, data_dir: &std::path::Path) -> Child {
Command::new(env!("CARGO_BIN_EXE_powdb-server"))
.arg("--port")
.arg(port.to_string())
.arg("--bind")
.arg("127.0.0.1")
.arg("--data-dir")
.arg(data_dir)
.arg("--readonly")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn powdb-server --readonly")
}
async fn wait_for_bind(port: u16, within: Duration) -> TcpStream {
let start = Instant::now();
loop {
if let Ok(stream) = TcpStream::connect(("127.0.0.1", port)).await {
return stream;
}
assert!(
start.elapsed() <= within,
"server did not accept connections on port {port} within {within:?}"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn seed_quiescent_dir(dir: &std::path::Path) {
let mut engine = powdb_query::executor::Engine::new(dir).unwrap();
engine
.execute_powql("type User { required name: str, age: int }")
.unwrap();
engine
.execute_powql(r#"insert User { name := "Ada", age := 36 }"#)
.unwrap();
drop(engine);
}
fn hash_data_files(dir: &std::path::Path) -> String {
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let mut items: Vec<_> = std::fs::read_dir(dir).unwrap().flatten().collect();
items.sort_by_key(std::fs::DirEntry::path);
for item in items {
let path = item.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "LOCK" || name == "readers" {
continue;
}
if path.is_dir() {
walk(&path, out);
} else {
out.push(path);
}
}
}
fn mix(hash: &mut u64, bytes: &[u8]) {
for &b in bytes {
*hash ^= b as u64;
*hash = hash.wrapping_mul(0x100000001b3);
}
}
let mut files = Vec::new();
walk(dir, &mut files);
let mut hash: u64 = 0xcbf29ce484222325;
for path in &files {
mix(&mut hash, path.to_string_lossy().as_bytes());
mix(&mut hash, &std::fs::read(path).unwrap());
}
format!("{hash:016x}")
}
#[tokio::test]
async fn two_readonly_servers_serve_same_dir_and_reject_writes() {
let tmp = tempfile::tempdir().unwrap();
seed_quiescent_dir(tmp.path());
let before = hash_data_files(tmp.path());
let port_a = free_port();
let port_b = free_port();
let mut srv_a = spawn_readonly_server(port_a, tmp.path());
let mut srv_b = spawn_readonly_server(port_b, tmp.path());
let mut a = wait_for_bind(port_a, Duration::from_secs(20)).await;
let mut b = wait_for_bind(port_b, Duration::from_secs(20)).await;
for stream in [&mut a, &mut b] {
stream.write_all(&encode_connect("testdb")).await.unwrap();
let resp = read_response(stream).await;
assert_eq!(resp[0], 0x02, "expected CONNECT_OK");
stream
.write_all(&encode_query("count(User)"))
.await
.unwrap();
let resp = read_response(stream).await;
assert_eq!(resp[0], 0x08, "expected RESULT_SCALAR for count");
}
a.write_all(&encode_query(r#"insert User { name := "Bo", age := 20 }"#))
.await
.unwrap();
let resp = read_response(&mut a).await;
assert_eq!(
resp[0], 0x0A,
"expected RESULT_ERROR for a write in readonly mode"
);
let decoded = powdb_server::protocol::Message::decode(&resp).unwrap();
match decoded {
powdb_server::protocol::Message::Error { message } => {
assert!(
message.contains("readonly mode"),
"expected a readonly-mode message, got {message:?}"
);
}
other => panic!("expected Error, got {other:?}"),
}
a.write_all(&encode_query("count(User)")).await.unwrap();
let resp = read_response(&mut a).await;
assert_eq!(
resp[0], 0x08,
"connection must stay usable after a rejected write"
);
let _ = srv_a.kill();
let _ = srv_b.kill();
let _ = srv_a.wait();
let _ = srv_b.wait();
let after = hash_data_files(tmp.path());
assert_eq!(
before, after,
"two read-only servers + reads + a rejected write must not mutate the data files"
);
}
#[tokio::test]
async fn kill9_during_readonly_serving_never_mutates_dir() {
let tmp = tempfile::tempdir().unwrap();
seed_quiescent_dir(tmp.path());
let before = hash_data_files(tmp.path());
let port = free_port();
let mut srv = spawn_readonly_server(port, tmp.path());
let mut stream = wait_for_bind(port, Duration::from_secs(20)).await;
stream.write_all(&encode_connect("testdb")).await.unwrap();
let resp = read_response(&mut stream).await;
assert_eq!(resp[0], 0x02, "expected CONNECT_OK");
stream.write_all(&encode_query("User")).await.unwrap();
let _ = read_response(&mut stream).await;
stream
.write_all(&encode_query("count(User)"))
.await
.unwrap();
let _ = srv.kill(); let _ = srv.wait();
let after = hash_data_files(tmp.path());
assert_eq!(
before, after,
"kill -9 during read-only serving must leave the data files byte-identical"
);
}