use serde::de::DeserializeOwned;
use std::io;
use std::io::BufReader;
use std::io::ErrorKind;
use std::io::prelude::*;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use crate::daemon::api::{MachineStart, MachineStatus, MachineStop, Reply, Request, Status};
use crate::daemon::instance;
use crate::machine;
use crate::{Error, Result};
const REPLY_TIMEOUT: Duration = Duration::from_secs(5);
const OPERATION_TIMEOUT: Duration = Duration::from_mins(5);
const SHUTDOWN_RETRY_TIMEOUT: Duration = Duration::from_millis(10);
pub struct Client {
dir: PathBuf,
reply_timeout: Duration,
operation_timeout: Duration,
}
impl Client {
#[must_use]
pub fn new(dir: PathBuf) -> Self {
Self {
dir,
reply_timeout: REPLY_TIMEOUT,
operation_timeout: OPERATION_TIMEOUT,
}
}
pub fn status(&self) -> Result<Status> {
self.request(&Request::Status {}, self.reply_timeout)
}
pub fn shutdown(&self, force: bool) -> Result<()> {
let timeout = if force {
self.operation_timeout
} else {
self.reply_timeout
};
self.request::<()>(&Request::Shutdown { force }, timeout)?;
wait_for_lock_release(&self.dir, timeout)
}
pub fn machine_status(&self, name: &str) -> Result<MachineStatus> {
self.request(
&Request::MachineStatus {
name: name.to_string(),
},
self.reply_timeout,
)
}
pub fn machine_start(&self, name: &str, spec: machine::Spec) -> Result<MachineStart> {
self.request(
&Request::MachineStart {
name: name.to_string(),
spec,
},
self.operation_timeout,
)
}
pub fn machine_stop(&self, name: &str) -> Result<MachineStop> {
self.request(
&Request::MachineStop {
name: name.to_string(),
},
self.operation_timeout,
)
}
pub fn machine_list(&self) -> Result<Vec<machine::Status>> {
self.request(&Request::MachineList {}, self.reply_timeout)
}
pub fn machine_resolve(&self, name: &str) -> Result<()> {
self.request(
&Request::MachineResolve {
name: name.to_string(),
},
self.reply_timeout,
)
}
fn request<T: DeserializeOwned>(&self, request: &Request, timeout: Duration) -> Result<T> {
let request_json = serde_json::to_string(request).map_err(Error::DaemonProtocol)?;
let mut stream = connect_with_timeouts(&self.dir, timeout)?;
stream
.write_all(format!("{request_json}\n").as_bytes())
.map_err(socket_error)?;
let mut response = String::new();
BufReader::new(&stream)
.read_line(&mut response)
.map_err(socket_error)?;
parse_reply(&response)
}
}
fn wait_for_lock_release(dir: &Path, timeout: Duration) -> Result<()> {
let deadline = Instant::now().checked_add(timeout);
while instance::lock_held(dir)? {
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Err(Error::DaemonStopTimeout);
}
thread::sleep(SHUTDOWN_RETRY_TIMEOUT);
}
Ok(())
}
fn connect_with_timeouts(dir: &Path, timeout: Duration) -> Result<UnixStream> {
let stream = instance::connect(&instance::socket_path(dir)).map_err(socket_error)?;
stream
.set_read_timeout(Some(timeout))
.map_err(socket_error)?;
stream
.set_write_timeout(Some(timeout))
.map_err(socket_error)?;
Ok(stream)
}
fn parse_reply<T: DeserializeOwned>(line: &str) -> Result<T> {
if line.is_empty() {
return Err(Error::DaemonNotRunning);
}
match serde_json::from_str::<Reply<T>>(line).map_err(Error::DaemonProtocol)? {
Reply::Done(value) => Ok(value),
Reply::Error { message } => Err(Error::DaemonRequest { message }),
}
}
fn socket_error(error: io::Error) -> Error {
if matches!(
error.kind(),
ErrorKind::NotFound
| ErrorKind::ConnectionRefused
| ErrorKind::ConnectionReset
| ErrorKind::BrokenPipe
) {
Error::DaemonNotRunning
} else {
Error::DaemonTransport(error)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::net::{UnixListener, UnixStream};
use std::sync::mpsc;
#[test]
fn shutdown_times_out_while_the_lock_is_held() {
let reply = serde_json::to_string(&Reply::Done(())).unwrap();
let socket_dir = fake_daemon(reply);
let _lock = instance::lock(socket_dir.path()).unwrap();
let client = Client {
reply_timeout: Duration::from_millis(100),
..Client::new(socket_dir.path().to_path_buf())
};
assert!(matches!(
client.shutdown(false),
Err(Error::DaemonStopTimeout)
));
}
#[test]
fn error_from_the_daemon() {
let socket_dir = fake_daemon(r#"{"error":{"message":"boom"}}"#.to_string());
let client = Client::new(socket_dir.path().to_path_buf());
assert!(
matches!(client.status(), Err(Error::DaemonRequest { message }) if message == "boom")
);
}
#[test]
fn daemon_not_running() {
let socket_dir = tempfile::tempdir().unwrap();
let client = Client::new(socket_dir.path().to_path_buf());
assert!(matches!(client.status(), Err(Error::DaemonNotRunning)));
}
#[test]
fn empty_reply_from_the_daemon() {
let socket_dir = fake_daemon(String::new());
let client = Client::new(socket_dir.path().to_path_buf());
assert!(matches!(client.status(), Err(Error::DaemonNotRunning)));
}
#[test]
fn invalid_reply_from_the_daemon() {
let socket_dir = fake_daemon("nope".to_string());
let client = Client::new(socket_dir.path().to_path_buf());
assert!(matches!(client.status(), Err(Error::DaemonProtocol { .. })));
}
#[test]
fn timeout_reply_from_the_daemon() {
let socket_dir = tempfile::tempdir().unwrap();
let _listener = UnixListener::bind(instance::socket_path(socket_dir.path())).unwrap();
let client = Client {
reply_timeout: Duration::from_millis(100),
..Client::new(socket_dir.path().to_path_buf())
};
assert!(matches!(
client.status(),
Err(Error::DaemonTransport { .. })
));
}
#[test]
fn timeout_request_to_a_daemon_that_stops_reading() {
let socket_dir = tempfile::tempdir().unwrap();
let listener = UnixListener::bind(instance::socket_path(socket_dir.path())).unwrap();
let (tx, rx) = mpsc::channel::<()>();
thread::spawn(move || {
let _stream = listener.incoming().next().unwrap().unwrap();
rx.recv().unwrap_err();
});
let client = Client {
operation_timeout: Duration::from_millis(100),
..Client::new(socket_dir.path().to_path_buf())
};
let name = "A".repeat(300_000);
let spec = machine::Spec::default();
let started = Instant::now();
let result = client.machine_start(&name, spec);
assert!(matches!(result, Err(Error::DaemonTransport { .. })));
assert!(started.elapsed() < Duration::from_secs(5));
drop(tx);
}
#[test]
fn fails_fast_when_the_backlog_is_full() {
let socket_dir = tempfile::tempdir().unwrap();
let socket_path = instance::socket_path(socket_dir.path());
let listener = UnixListener::bind(&socket_path).unwrap();
rustix::net::listen(&listener, 1).unwrap();
let queued: Vec<UnixStream> = (0..1024)
.map_while(|_| instance::connect(&socket_path).ok())
.collect();
assert!(!queued.is_empty());
let (tx, rx) = mpsc::channel();
let dir = socket_dir.path().to_path_buf();
thread::spawn(move || {
let client = Client {
reply_timeout: Duration::from_millis(100),
..Client::new(dir)
};
tx.send(client.status()).unwrap();
});
let result = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(result.is_err());
drop(queued);
}
fn fake_daemon(reply: String) -> tempfile::TempDir {
let socket_dir = tempfile::tempdir().unwrap();
let listener = UnixListener::bind(instance::socket_path(socket_dir.path())).unwrap();
thread::spawn(move || {
let stream = listener.incoming().next().unwrap().unwrap();
let _ = BufReader::new(&stream)
.read_line(&mut String::new())
.unwrap();
if !reply.is_empty() {
(&stream)
.write_all(format!("{reply}\n").as_bytes())
.unwrap();
}
});
socket_dir
}
}