use crate::{
bridge::remote::remote_render::{RemoteError, RemoteStream, connect_and_handshake},
settings::WorkerSettings,
};
use indicatrix_net::{
client::{self, ClientError, ConnectionInfo},
library::{LibraryRequest, LibraryResponse},
messages,
};
use std::{
io::{Read, Write},
sync::{Mutex, PoisonError},
time::Duration,
};
const LIBRARY_READ_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub enum LibraryClientError {
Connect(RemoteError),
NoLibraryCapacity,
Client(ClientError),
WorkerError(String),
}
impl std::fmt::Display for LibraryClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Connect(e) => write!(f, "{e}"),
Self::NoLibraryCapacity => write!(
f,
"this worker does not serve a design library -- it was started without a library database"
),
Self::Client(e) => write!(f, "{e}"),
Self::WorkerError(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for LibraryClientError {}
impl From<ClientError> for LibraryClientError {
fn from(e: ClientError) -> Self {
Self::Client(e)
}
}
impl From<messages::NetError> for LibraryClientError {
fn from(e: messages::NetError) -> Self {
Self::Client(e.into())
}
}
pub fn probe(worker: &WorkerSettings) -> Result<ConnectionInfo, LibraryClientError> {
let (_stream, welcome) = connect_and_handshake(worker).map_err(LibraryClientError::Connect)?;
if !welcome.library {
return Err(LibraryClientError::NoLibraryCapacity);
}
Ok(welcome.into())
}
fn connect_checked(worker: &WorkerSettings) -> Result<RemoteStream, LibraryClientError> {
let (stream, welcome) = connect_and_handshake(worker).map_err(LibraryClientError::Connect)?;
if !welcome.library {
return Err(LibraryClientError::NoLibraryCapacity);
}
stream
.sock
.set_read_timeout(Some(LIBRARY_READ_TIMEOUT))
.map_err(|e| LibraryClientError::Connect(RemoteError::Io(e)))?;
Ok(stream)
}
pub fn request(
worker: &WorkerSettings,
req: &LibraryRequest,
) -> Result<LibraryResponse, LibraryClientError> {
let mut stream = connect_checked(worker)?;
send_and_read(&mut stream, req)
}
fn send_and_read<S: Read + Write>(
stream: &mut S,
req: &LibraryRequest,
) -> Result<LibraryResponse, LibraryClientError> {
client::send_library_request(stream, req)?;
let response: LibraryResponse = messages::read_message(stream)?;
match response {
LibraryResponse::Error(e) => Err(LibraryClientError::WorkerError(e.message)),
other => Ok(other),
}
}
const fn is_dead_connection(e: &LibraryClientError) -> bool {
matches!(e, LibraryClientError::Client(ClientError::Net(_)))
}
fn request_with_reconnect<S: Read + Write>(
held: &mut Option<S>,
req: &LibraryRequest,
mut connect: impl FnMut() -> Result<S, LibraryClientError>,
) -> Result<LibraryResponse, LibraryClientError> {
if held.is_none() {
*held = Some(connect()?);
}
let stream = held.as_mut().expect("just connected above if it was empty");
match send_and_read(stream, req) {
Ok(response) => Ok(response),
Err(e) if is_dead_connection(&e) => {
*held = None;
let mut fresh = connect()?;
let response = send_and_read(&mut fresh, req)?;
*held = Some(fresh);
Ok(response)
}
Err(e) => Err(e),
}
}
pub struct LibrarySession {
worker: WorkerSettings,
stream: Mutex<Option<RemoteStream>>,
}
impl LibrarySession {
#[must_use]
pub const fn new(worker: WorkerSettings) -> Self {
Self {
worker,
stream: Mutex::new(None),
}
}
pub fn request(&self, req: &LibraryRequest) -> Result<LibraryResponse, LibraryClientError> {
let mut guard = self.stream.lock().unwrap_or_else(PoisonError::into_inner);
request_with_reconnect(&mut guard, req, || connect_checked(&self.worker))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn library_client_error_display_is_human_readable() {
let e = LibraryClientError::NoLibraryCapacity;
assert!(e.to_string().contains("does not serve a design library"));
let e = LibraryClientError::WorkerError("bad filter".to_string());
assert_eq!(e.to_string(), "bad filter");
}
struct FakeStream {
input: std::io::Cursor<Vec<u8>>,
output: Vec<u8>,
}
impl FakeStream {
fn new(input: Vec<u8>) -> Self {
Self {
input: std::io::Cursor::new(input),
output: Vec::new(),
}
}
fn dead() -> Self {
Self::new(Vec::new())
}
}
impl Read for FakeStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.input.read(buf)
}
}
impl Write for FakeStream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.output.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn encoded_response(resp: &LibraryResponse) -> Vec<u8> {
let mut buf = Vec::new();
messages::write_message(&mut buf, resp).unwrap();
buf
}
#[test]
fn request_with_reconnect_reconnects_once_after_a_dead_connection_and_succeeds() {
let good_reply = encoded_response(&LibraryResponse::NotFound);
let mut remaining = vec![FakeStream::new(good_reply)];
let connect_calls = std::cell::Cell::new(0);
let mut held: Option<FakeStream> = Some(FakeStream::dead());
let response = request_with_reconnect(&mut held, &LibraryRequest::FilterOptions, || {
connect_calls.set(connect_calls.get() + 1);
Ok(remaining.pop().expect("only one reconnect expected"))
})
.unwrap();
assert_eq!(response, LibraryResponse::NotFound);
assert_eq!(
connect_calls.get(),
1,
"exactly one reconnect after the held connection turned out dead"
);
assert!(
held.is_some(),
"the freshly reconnected stream is kept in `held` for the next request"
);
}
#[test]
fn request_with_reconnect_connects_lazily_on_the_first_call() {
let good_reply = encoded_response(&LibraryResponse::NotFound);
let mut held: Option<FakeStream> = None;
let connect_calls = std::cell::Cell::new(0);
let response = request_with_reconnect(&mut held, &LibraryRequest::FilterOptions, || {
connect_calls.set(connect_calls.get() + 1);
Ok(FakeStream::new(good_reply.clone()))
})
.unwrap();
assert_eq!(response, LibraryResponse::NotFound);
assert_eq!(connect_calls.get(), 1);
}
#[test]
fn request_with_reconnect_does_not_reconnect_for_a_worker_error_reply() {
let error_reply = encoded_response(&LibraryResponse::Error(messages::ErrorMsg {
code: 1,
message: "bad filter".to_string(),
}));
let mut held = Some(FakeStream::new(error_reply));
let connect_calls = std::cell::Cell::new(0);
let result = request_with_reconnect(&mut held, &LibraryRequest::FilterOptions, || {
connect_calls.set(connect_calls.get() + 1);
Ok::<FakeStream, LibraryClientError>(FakeStream::dead())
});
assert!(matches!(result, Err(LibraryClientError::WorkerError(_))));
assert_eq!(
connect_calls.get(),
0,
"a worker-level error reply must never trigger a reconnect"
);
}
#[test]
fn request_with_reconnect_propagates_the_error_when_reconnecting_also_fails() {
let mut held = Some(FakeStream::dead());
let connect_calls = std::cell::Cell::new(0);
let result = request_with_reconnect(&mut held, &LibraryRequest::FilterOptions, || {
connect_calls.set(connect_calls.get() + 1);
Err::<FakeStream, _>(LibraryClientError::Connect(RemoteError::Io(
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"),
)))
});
assert!(matches!(result, Err(LibraryClientError::Connect(_))));
assert_eq!(
connect_calls.get(),
1,
"exactly one reconnect attempt, not a retry loop"
);
assert!(
held.is_none(),
"a connection already known dead is never left in `held` after a failed reconnect"
);
}
struct TimingOutStream;
impl Read for TimingOutStream {
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"timed out",
))
}
}
impl Write for TimingOutStream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn request_with_reconnect_treats_a_timed_out_read_as_a_dead_connection() {
let mut held = Some(TimingOutStream);
let connect_calls = std::cell::Cell::new(0);
let result = request_with_reconnect(&mut held, &LibraryRequest::FilterOptions, || {
connect_calls.set(connect_calls.get() + 1);
Ok::<TimingOutStream, LibraryClientError>(TimingOutStream)
});
assert!(
matches!(result, Err(LibraryClientError::Client(ClientError::Net(_)))),
"a TimedOut read must surface as a transport-level ClientError::Net"
);
assert_eq!(
connect_calls.get(),
1,
"a timed-out read on the held connection must trigger exactly one reconnect \
attempt, same as any other dead-connection error"
);
}
}