use std::collections::HashMap;
#[cfg(unix)]
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, Mutex as StdMutex};
use std::task::{Context, Poll};
use std::time::Duration;
use futures::{Sink, SinkExt, Stream, StreamExt};
use tokio::net::TcpStream;
#[cfg(unix)]
use tokio::net::UnixStream;
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use tokio_tungstenite::tungstenite::{self, Message};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use plana::jsonrpc::deferred::{
DeferredOpOutcome, DeferredOpRef, DeferredOpSettledParams, OpsResultParams, OPS_RESULT_METHOD,
OPS_SETTLED_METHOD,
};
use plana::jsonrpc::{
error_codes, Id, JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest,
JSONRPC_VERSION,
};
use crate::close_codes::HEARTBEAT_METHOD;
use crate::{ConnectionState, DeferredOpError, RpcError};
#[derive(Debug, Clone)]
pub struct RpcClientConfig {
pub connect_timeout: Duration,
pub call_timeout: Duration,
pub heartbeat_interval: Duration,
pub heartbeat_timeout: Duration,
pub heartbeat: bool,
pub reconnect_initial: Duration,
pub reconnect_factor: f64,
pub reconnect_max: Duration,
pub max_reconnect_attempts: Option<u32>,
pub op_poll_initial: Duration,
pub op_poll_factor: f64,
pub op_poll_max: Duration,
}
impl Default for RpcClientConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(5),
call_timeout: Duration::from_secs(30),
heartbeat_interval: Duration::from_secs(15),
heartbeat_timeout: Duration::from_secs(10),
heartbeat: true,
reconnect_initial: Duration::from_millis(500),
reconnect_factor: 1.5,
reconnect_max: Duration::from_secs(30),
max_reconnect_attempts: None,
op_poll_initial: Duration::from_millis(500),
op_poll_factor: 1.5,
op_poll_max: Duration::from_secs(5),
}
}
}
type PendingResult = Result<serde_json::Value, PendingFail>;
#[derive(Debug)]
enum PendingFail {
Closed,
Rpc(JsonRpcError),
}
enum Cmd {
SendFrame {
frame: String,
ack: oneshot::Sender<Result<(), RpcError>>,
},
ForceReconnect,
}
struct Inner {
config: RpcClientConfig,
cmd_tx: mpsc::Sender<Cmd>,
state: watch::Sender<ConnectionState>,
notifications: broadcast::Sender<serde_json::Value>,
pending: StdMutex<HashMap<Id, oneshot::Sender<PendingResult>>>,
}
pub struct RpcClientBuilder {
url: String,
config: RpcClientConfig,
#[cfg(feature = "tls")]
danger_accept_invalid_certs: bool,
}
impl RpcClientBuilder {
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = url.into();
self
}
#[cfg(feature = "tls")]
pub fn install_ring_tls_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
#[cfg(feature = "tls")]
pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
self.danger_accept_invalid_certs = accept;
self
}
pub fn config(mut self, config: RpcClientConfig) -> Self {
self.config = config;
self
}
pub fn build(self) -> RpcClient {
assert!(!self.url.is_empty(), "rpc client url must be set");
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>(64);
let (state_tx, _state_rx) = watch::channel(ConnectionState::Connecting);
let (notify_tx, _notify_rx) = broadcast::channel::<serde_json::Value>(64);
let inner = Arc::new(Inner {
config: self.config,
cmd_tx: cmd_tx.clone(),
state: state_tx,
notifications: notify_tx,
pending: StdMutex::new(HashMap::new()),
});
let plan = DialPlan {
endpoint: Endpoint::parse(&self.url),
#[cfg(feature = "tls")]
danger_accept_invalid_certs: self.danger_accept_invalid_certs,
};
let supervisor = supervise(plan, inner.clone(), cmd_rx);
tokio::spawn(supervisor);
RpcClient { inner }
}
}
#[derive(Clone)]
pub struct RpcClient {
inner: Arc<Inner>,
}
impl std::fmt::Debug for RpcClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RpcClient")
.field("state", &self.state())
.finish_non_exhaustive()
}
}
impl RpcClient {
pub fn builder() -> RpcClientBuilder {
RpcClientBuilder {
url: String::new(),
config: RpcClientConfig::default(),
#[cfg(feature = "tls")]
danger_accept_invalid_certs: false,
}
}
pub fn state(&self) -> ConnectionState {
*self.inner.state.borrow()
}
pub fn subscribe_state(&self) -> watch::Receiver<ConnectionState> {
self.inner.state.subscribe()
}
pub fn subscribe(&self) -> broadcast::Receiver<serde_json::Value> {
self.inner.notifications.subscribe()
}
pub async fn call(
&self,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, RpcError> {
let id = Id::new_uuid();
let request = JsonRpcRequest {
jsonrpc: JSONRPC_VERSION.to_string(),
id: Some(id.clone()),
method: method.to_string(),
params: Some(params),
};
let frame =
serde_json::to_string(&request).map_err(|e| RpcError::Transport(e.to_string()))?;
self.send_and_wait(frame, id).await
}
pub async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), RpcError> {
let notification = JsonRpcNotification {
jsonrpc: JSONRPC_VERSION.to_string(),
method: method.to_string(),
params: Some(params),
};
let frame =
serde_json::to_string(¬ification).map_err(|e| RpcError::Transport(e.to_string()))?;
self.admit(frame).await
}
pub fn force_reconnect(&self) {
let _ = self.inner.cmd_tx.try_send(Cmd::ForceReconnect);
}
pub async fn await_op(
&self,
op_id: &DeferredOpRef,
deadline: Duration,
) -> Result<DeferredOpOutcome, DeferredOpError> {
let cadence = PollCadence::from_config(&self.inner.config);
let end = tokio::time::Instant::now()
.checked_add(deadline)
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(86_400));
let mut notifications = self.subscribe();
let mut delay = cadence.initial;
loop {
let remaining = end.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(DeferredOpError::Deadline(deadline));
}
match tokio::time::timeout(remaining, self.collect_op(op_id)).await {
Err(_) => return Err(DeferredOpError::Deadline(deadline)),
Ok(Ok(outcome)) if outcome.is_settled() => return Ok(outcome),
Ok(Ok(_pending)) => {}
Ok(Err(err)) => match err {
DeferredOpError::Unknown | DeferredOpError::Expired => return Err(err),
DeferredOpError::Rpc(RpcError::Closed)
if self.state() == ConnectionState::Failed =>
{
return Err(err);
}
DeferredOpError::Rpc(RpcError::Closed)
| DeferredOpError::Rpc(RpcError::Timeout(_))
| DeferredOpError::Rpc(RpcError::Transport(_)) => {}
other => return Err(other),
},
}
let remaining = end.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(DeferredOpError::Deadline(deadline));
}
let wait = delay.min(remaining);
let _ = tokio::time::timeout(wait, async {
loop {
match notifications.recv().await {
Ok(value) if announces_settlement(&value, op_id) => break,
Ok(_) => continue,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => {
tokio::time::sleep(wait).await;
break;
}
}
}
})
.await;
delay = cadence.grow(delay);
}
}
async fn collect_op(
&self,
op_id: &DeferredOpRef,
) -> Result<DeferredOpOutcome, DeferredOpError> {
let params = serde_json::to_value(OpsResultParams {
op_id: op_id.clone(),
})
.map_err(|e| DeferredOpError::Rpc(RpcError::Transport(e.to_string())))?;
match self.call(OPS_RESULT_METHOD, params).await {
Ok(value) => serde_json::from_value(value).map_err(|e| {
DeferredOpError::Protocol(format!("ops.result answered an unreadable outcome: {e}"))
}),
Err(RpcError::Rpc { code, .. }) if code == error_codes::OPS_UNKNOWN => {
Err(DeferredOpError::Unknown)
}
Err(RpcError::Rpc { code, .. }) if code == error_codes::OPS_EXPIRED => {
Err(DeferredOpError::Expired)
}
Err(err) => Err(DeferredOpError::Rpc(err)),
}
}
async fn send_and_wait(&self, frame: String, id: Id) -> Result<serde_json::Value, RpcError> {
let (tx, rx) = oneshot::channel::<PendingResult>();
self.inner.pending.lock().unwrap().insert(id.clone(), tx);
let _guard = PendingGuard {
inner: &self.inner,
id: id.clone(),
};
self.admit(frame).await?;
let timeout = self.inner.config.call_timeout;
match tokio::time::timeout(timeout, rx).await {
Err(_) => Err(RpcError::Timeout(timeout)),
Ok(Ok(result)) => result.map_err(|fail| match fail {
PendingFail::Closed => RpcError::Closed,
PendingFail::Rpc(err) => err.into(),
}),
Ok(Err(_cancelled)) => Err(RpcError::Closed),
}
}
async fn admit(&self, frame: String) -> Result<(), RpcError> {
let (ack_tx, ack_rx) = oneshot::channel();
self.inner
.cmd_tx
.send(Cmd::SendFrame { frame, ack: ack_tx })
.await
.map_err(|_| RpcError::Closed)?;
match ack_rx.await {
Ok(result) => result,
Err(_) => Err(RpcError::Closed),
}
}
}
enum Outcome {
Lost,
Forced,
Shutdown,
}
enum Endpoint {
Tcp(String),
#[cfg(unix)]
Unix(PathBuf),
Unusable(String),
}
impl Endpoint {
fn parse(url: &str) -> Self {
let Some((scheme, rest)) = url.split_once("://") else {
return Self::Unusable(format!("not a transport url (no scheme): {url:?}"));
};
match scheme.to_ascii_lowercase().as_str() {
"ws" => Self::Tcp(url.to_owned()),
"wss" => wss_endpoint(url),
"ipc" if rest.is_empty() => {
Self::Unusable(format!("ipc:// url has no socket path: {url:?}"))
}
"ipc" => ipc_endpoint(rest),
other => Self::Unusable(format!(
"unsupported transport scheme {other:?} (expected ws, wss, or ipc)"
)),
}
}
}
#[cfg(feature = "tls")]
fn wss_endpoint(url: &str) -> Endpoint {
Endpoint::Tcp(url.to_owned())
}
#[cfg(not(feature = "tls"))]
fn wss_endpoint(url: &str) -> Endpoint {
Endpoint::Unusable(format!(
"wss:// requires the `tls` feature of plana-rpc-client: {url:?}"
))
}
#[cfg(unix)]
fn ipc_endpoint(path: &str) -> Endpoint {
Endpoint::Unix(PathBuf::from(path))
}
#[cfg(not(unix))]
fn ipc_endpoint(path: &str) -> Endpoint {
let _ = path;
Endpoint::Unusable("ipc:// requires a unix platform".to_owned())
}
struct DialPlan {
endpoint: Endpoint,
#[cfg(feature = "tls")]
danger_accept_invalid_certs: bool,
}
enum ClientSocket {
Tcp(Box<WebSocketStream<MaybeTlsStream<TcpStream>>>),
#[cfg(unix)]
Unix(Box<WebSocketStream<UnixStream>>),
}
impl Stream for ClientSocket {
type Item = tungstenite::Result<Message>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match self.get_mut() {
ClientSocket::Tcp(socket) => socket.poll_next_unpin(cx),
#[cfg(unix)]
ClientSocket::Unix(socket) => socket.poll_next_unpin(cx),
}
}
}
impl Sink<Message> for ClientSocket {
type Error = tungstenite::Error;
fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
match self.get_mut() {
ClientSocket::Tcp(socket) => socket.poll_ready_unpin(cx),
#[cfg(unix)]
ClientSocket::Unix(socket) => socket.poll_ready_unpin(cx),
}
}
fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
match self.get_mut() {
ClientSocket::Tcp(socket) => socket.start_send_unpin(item),
#[cfg(unix)]
ClientSocket::Unix(socket) => socket.start_send_unpin(item),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
match self.get_mut() {
ClientSocket::Tcp(socket) => socket.poll_flush_unpin(cx),
#[cfg(unix)]
ClientSocket::Unix(socket) => socket.poll_flush_unpin(cx),
}
}
fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
match self.get_mut() {
ClientSocket::Tcp(socket) => socket.poll_close_unpin(cx),
#[cfg(unix)]
ClientSocket::Unix(socket) => socket.poll_close_unpin(cx),
}
}
}
async fn dial(plan: &DialPlan) -> Result<ClientSocket, tungstenite::Error> {
match &plan.endpoint {
Endpoint::Tcp(url) => {
#[cfg(feature = "tls")]
{
let connector = crate::tls::connector(plan.danger_accept_invalid_certs);
let (socket, _response) = tokio_tungstenite::connect_async_tls_with_config(
url.clone(),
None,
false,
connector,
)
.await?;
Ok(ClientSocket::Tcp(Box::new(socket)))
}
#[cfg(not(feature = "tls"))]
{
let (socket, _response) = tokio_tungstenite::connect_async(url.clone()).await?;
Ok(ClientSocket::Tcp(Box::new(socket)))
}
}
#[cfg(unix)]
Endpoint::Unix(path) => {
let stream = UnixStream::connect(path).await?;
let (socket, _response) =
tokio_tungstenite::client_async_with_config("ws://localhost/", stream, None)
.await?;
Ok(ClientSocket::Unix(Box::new(socket)))
}
Endpoint::Unusable(reason) => {
Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, reason.clone()).into())
}
}
}
async fn supervise(plan: DialPlan, inner: Arc<Inner>, mut cmd_rx: mpsc::Receiver<Cmd>) {
let config = inner.config.clone();
let mut backoff = config.reconnect_initial;
let mut attempts: u32 = 0;
loop {
set_state(&inner, ConnectionState::Connecting);
let dialed = tokio::time::timeout(config.connect_timeout, dial(&plan)).await;
let socket = match dialed {
Ok(Ok(socket)) => Some(socket),
Ok(Err(err)) => {
tracing::debug!(error = %err, "rpc client dial failed");
None
}
Err(_) => {
tracing::debug!("rpc client dial timed out");
None
}
};
let outcome = match socket {
Some(socket) => {
set_state(&inner, ConnectionState::Connected);
backoff = config.reconnect_initial;
attempts = 0;
connected_phase(socket, &inner, &mut cmd_rx).await
}
None => Outcome::Lost,
};
match outcome {
Outcome::Shutdown => {
reject_all_pending(&inner);
return;
}
Outcome::Lost | Outcome::Forced => {
reject_all_pending(&inner);
set_state(&inner, ConnectionState::Reconnecting);
}
}
attempts += 1;
if let Some(max) = config.max_reconnect_attempts {
if attempts >= max {
tracing::warn!(attempts, "rpc client reconnect budget exhausted");
set_state(&inner, ConnectionState::Failed);
while let Some(cmd) = cmd_rx.recv().await {
if let Cmd::SendFrame { ack, .. } = cmd {
let _ = ack.send(Err(RpcError::Closed));
}
}
return;
}
}
let deadline = tokio::time::Instant::now() + backoff;
loop {
tokio::select! {
cmd = cmd_rx.recv() => match cmd {
None => {
reject_all_pending(&inner);
return;
}
Some(Cmd::SendFrame { ack, .. }) => {
let _ = ack.send(Err(RpcError::Closed));
}
Some(Cmd::ForceReconnect) => break,
},
_ = tokio::time::sleep_until(deadline) => break,
}
}
backoff = scale_backoff(backoff, config.reconnect_factor, config.reconnect_max);
}
}
async fn connected_phase(
socket: ClientSocket,
inner: &Arc<Inner>,
cmd_rx: &mut mpsc::Receiver<Cmd>,
) -> Outcome {
let config = &inner.config;
let (sink, mut stream) = futures::StreamExt::split(socket);
let (out_tx, out_rx) = mpsc::channel::<String>(64);
let writer = tokio::spawn(write_pump(sink, out_rx));
let mut heartbeat = tokio::time::interval(config.heartbeat_interval);
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut watchdog = tokio::time::interval(config.heartbeat_timeout);
watchdog.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut last_inbound = std::time::Instant::now();
let outcome = loop {
tokio::select! {
cmd = cmd_rx.recv() => match cmd {
None => break Outcome::Shutdown,
Some(Cmd::SendFrame { frame, ack }) => {
let admitted = out_tx
.send(frame)
.await
.map_err(|_| RpcError::Closed);
let failed = admitted.is_err();
let _ = ack.send(admitted);
if failed {
break Outcome::Lost;
}
}
Some(Cmd::ForceReconnect) => break Outcome::Forced,
},
msg = stream.next() => match msg {
Some(Ok(Message::Text(text))) => {
last_inbound = std::time::Instant::now();
handle_inbound(inner, &text);
}
Some(Ok(Message::Close(_))) => break Outcome::Lost,
Some(Ok(_)) => last_inbound = std::time::Instant::now(),
Some(Err(_)) | None => break Outcome::Lost,
},
_ = heartbeat.tick(), if config.heartbeat => {
let frame = JsonRpcNotification {
jsonrpc: JSONRPC_VERSION.to_string(),
method: HEARTBEAT_METHOD.to_string(),
params: None,
};
if let Ok(text) = serde_json::to_string(&frame) {
if out_tx.send(text).await.is_err() {
break Outcome::Lost;
}
}
},
_ = watchdog.tick(), if config.heartbeat => {
if last_inbound.elapsed() >= config.heartbeat_timeout {
tracing::debug!("rpc client heartbeat watchdog fired");
break Outcome::Lost;
}
}
}
};
drop(out_tx);
writer.abort();
outcome
}
async fn write_pump<S>(mut sink: S, mut out_rx: mpsc::Receiver<String>)
where
S: SinkExt<Message> + Unpin,
S::Error: std::fmt::Debug,
{
while let Some(text) = out_rx.recv().await {
if sink.send(Message::Text(text.into())).await.is_err() {
break;
}
}
}
fn handle_inbound(inner: &Arc<Inner>, text: &str) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
return;
};
let Ok(message) = serde_json::from_value::<JsonRpcMessage>(value) else {
tracing::warn!("rpc client received an unclassifiable frame");
return;
};
match message {
JsonRpcMessage::Response(response) => {
let completer = inner.pending.lock().unwrap().remove(&response.id);
if let Some(tx) = completer {
let result = match response.error {
Some(err) => Err(PendingFail::Rpc(err)),
None => Ok(response.result.unwrap_or(serde_json::Value::Null)),
};
let _ = tx.send(result);
} else {
tracing::debug!(id = ?response.id, "unsolicited response ignored");
}
}
JsonRpcMessage::Notification(notification) => {
if notification.method == crate::close_codes::HEARTBEAT_ACK_METHOD {
return;
}
let _ = inner
.notifications
.send(serde_json::to_value(¬ification).unwrap_or_default());
}
JsonRpcMessage::Request(_) => {
tracing::warn!("server sent a request frame; not supported by this client");
}
}
}
fn reject_all_pending(inner: &Arc<Inner>) {
let mut pending = inner.pending.lock().unwrap();
for (_, tx) in pending.drain() {
let _ = tx.send(Err(PendingFail::Closed));
}
}
fn set_state(inner: &Arc<Inner>, state: ConnectionState) {
inner.state.send_replace(state);
}
fn scale_backoff(current: Duration, factor: f64, max: Duration) -> Duration {
let next = current.mul_f64(factor);
if next > max {
max
} else {
next
}
}
struct PollCadence {
initial: Duration,
factor: f64,
max: Duration,
}
impl PollCadence {
const FLOOR: Duration = Duration::from_millis(10);
const CEILING: Duration = Duration::from_secs(60 * 60);
const DEFAULT_FACTOR: f64 = 1.5;
const MAX_FACTOR: f64 = 100.0;
fn from_config(config: &RpcClientConfig) -> Self {
let initial = config.op_poll_initial.clamp(Self::FLOOR, Self::CEILING);
Self {
initial,
factor: if config.op_poll_factor.is_finite() && config.op_poll_factor >= 1.0 {
config.op_poll_factor.min(Self::MAX_FACTOR)
} else {
Self::DEFAULT_FACTOR
},
max: config.op_poll_max.clamp(initial, Self::CEILING),
}
}
fn grow(&self, current: Duration) -> Duration {
scale_backoff(current, self.factor, self.max).max(Self::FLOOR)
}
}
struct PendingGuard<'a> {
inner: &'a Arc<Inner>,
id: Id,
}
impl Drop for PendingGuard<'_> {
fn drop(&mut self) {
self.inner
.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&self.id);
}
}
fn announces_settlement(value: &serde_json::Value, op_id: &DeferredOpRef) -> bool {
let Ok(notification) = serde_json::from_value::<JsonRpcNotification>(value.clone()) else {
return false;
};
if notification.method != OPS_SETTLED_METHOD {
return false;
}
notification
.params
.and_then(|params| serde_json::from_value::<DeferredOpSettledParams>(params).ok())
.is_some_and(|params| params.op_id == *op_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn a_dropped_call_releases_its_pending_slot() {
let (cmd_tx, mut cmd_rx) = mpsc::channel::<Cmd>(8);
let (state, _state_rx) = watch::channel(ConnectionState::Connected);
let (notifications, _notify_rx) = broadcast::channel::<serde_json::Value>(4);
tokio::spawn(async move {
while let Some(Cmd::SendFrame { ack, .. }) = cmd_rx.recv().await {
let _ = ack.send(Ok(()));
}
});
let inner = Arc::new(Inner {
config: RpcClientConfig::default(),
cmd_tx,
state,
notifications,
pending: StdMutex::new(HashMap::new()),
});
let client = RpcClient {
inner: inner.clone(),
};
for _ in 0..2 {
let cut = tokio::time::timeout(
Duration::from_millis(40),
client.call("never.answers", serde_json::json!({})),
)
.await;
assert!(cut.is_err(), "the call must still be in flight when cut");
assert!(
inner.pending.lock().unwrap().is_empty(),
"a cancelled call must not leave a pending entry behind"
);
}
}
}