#![cfg_attr(test, allow(clippy::duplicate_mod))]
use std::{fmt, time::Duration};
pub(crate) mod bare;
pub(crate) mod protocol;
pub(crate) mod request;
pub(crate) mod supervised;
pub(crate) const MAX_STREAMS_PER_CONNECTION: usize = 1024;
pub(crate) const DEFAULT_MAX_CONNECTION_AGE: Duration = Duration::from_secs(23 * 3600 + 50 * 60);
pub(crate) const EVENT_BUFFER: usize = 1024;
pub(crate) trait StreamSet: Sized + Send + Sync + 'static {
type Name: Clone + Eq + std::hash::Hash + fmt::Display + fmt::Debug + Send + Sync + 'static;
type Path: Copy + Eq + std::hash::Hash + fmt::Display + fmt::Debug + Send + Sync + 'static;
type Update: Send + 'static;
type Error: SocketError<Path = Self::Path>;
type Event: Send + 'static;
const PATHS: &'static [Self::Path];
const MAX_NAMES_PER_REQUEST: usize;
const MIN_REQUEST_INTERVAL: Duration;
fn path(name: &Self::Name) -> Self::Path;
fn url(base: &str, path: Self::Path) -> String;
fn decode(text: &str) -> Result<Self::Update, Self::Error>;
fn update(update: Self::Update) -> Self::Event;
fn disconnected(path: Self::Path, outage: polyoxide_ws::Outage<Self::Error>) -> Self::Event;
fn reconnected(path: Self::Path) -> Self::Event;
}
pub(crate) enum Shape<'a> {
Connect(&'a polyoxide_ws::WsError),
ConnectTimeout,
Closed,
Refused,
NoAnswer,
Response,
TooManyStreams,
#[cfg_attr(not(feature = "usdm-ws"), allow(dead_code))]
WrongPath,
Frame,
Stopped,
}
pub(crate) trait SocketError:
From<polyoxide_ws::WsError>
+ polyoxide_venue::Classify
+ fmt::Display
+ fmt::Debug
+ Send
+ Sync
+ 'static
{
type Path;
type Cause: Clone + fmt::Debug + Send + Sync + 'static;
fn refused(code: i64, msg: String) -> Self;
fn no_answer(id: u64, timeout: Duration) -> Self;
fn response(id: u64, raw: String) -> Self;
fn too_many_streams(path: Self::Path, limit: usize) -> Self;
fn stopped(cause: Option<Self::Cause>) -> Self;
fn cause(&self) -> Self::Cause;
fn shape(&self) -> Shape<'_>;
}
#[cfg(test)]
#[path = "../../usdm/ws/scripted.rs"]
mod scripted;
#[cfg(test)]
mod tests {
use polyoxide_venue::{Class, Classify};
use polyoxide_ws::{Outage, WsError};
use tokio::time::Instant;
use super::{
bare::Bare,
scripted::{Script, ScriptedServer},
*,
};
struct Probe;
#[derive(Debug, thiserror::Error)]
enum ProbeError {
#[error("transport: {0}")]
Connect(#[source] WsError),
#[error("refused: {code} {msg}")]
Refused { code: i64, msg: String },
#[error("no answer to {id} within {timeout:?}")]
NoAnswer { id: u64, timeout: Duration },
#[error("malformed answer to {id}: {raw}")]
Response { id: u64, raw: String },
#[error("{path} carries at most {limit}")]
TooMany { path: &'static str, limit: usize },
#[error("stopped")]
Stopped(Option<String>),
}
impl From<WsError> for ProbeError {
fn from(err: WsError) -> Self {
Self::Connect(err)
}
}
impl Classify for ProbeError {
fn class(&self) -> Class {
Class::Decode
}
}
impl SocketError for ProbeError {
type Path = &'static str;
type Cause = String;
fn refused(code: i64, msg: String) -> Self {
Self::Refused { code, msg }
}
fn no_answer(id: u64, timeout: Duration) -> Self {
Self::NoAnswer { id, timeout }
}
fn response(id: u64, raw: String) -> Self {
Self::Response { id, raw }
}
fn too_many_streams(path: &'static str, limit: usize) -> Self {
Self::TooMany { path, limit }
}
fn stopped(cause: Option<String>) -> Self {
Self::Stopped(cause)
}
fn cause(&self) -> String {
match self {
Self::Stopped(cause) => cause.clone().unwrap_or_default(),
other => other.to_string(),
}
}
fn shape(&self) -> Shape<'_> {
match self {
Self::Connect(err) => Shape::Connect(err),
Self::Refused { .. } => Shape::Refused,
Self::NoAnswer { .. } => Shape::NoAnswer,
Self::Response { .. } => Shape::Response,
Self::TooMany { .. } => Shape::TooManyStreams,
Self::Stopped(_) => Shape::Stopped,
}
}
}
impl StreamSet for Probe {
type Name = String;
type Path = &'static str;
type Update = String;
type Error = ProbeError;
type Event = String;
const PATHS: &'static [&'static str] = &["probe"];
const MAX_NAMES_PER_REQUEST: usize = 3;
const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(250);
fn path(_name: &String) -> &'static str {
"probe"
}
fn url(base: &str, path: &'static str) -> String {
format!("{}/{path}", base.trim_end_matches('/'))
}
fn decode(text: &str) -> Result<String, ProbeError> {
Ok(text.to_owned())
}
fn update(update: String) -> String {
update
}
fn disconnected(path: &'static str, outage: Outage<ProbeError>) -> String {
let _ = outage;
format!("{path} down")
}
fn reconnected(path: &'static str) -> String {
format!("{path} back")
}
}
fn names(count: usize) -> Vec<String> {
(0..count).map(|i| format!("n{i}@probe")).collect()
}
fn sizes(server: &ScriptedServer) -> Vec<usize> {
server
.requests()
.iter()
.map(|request| request["params"].as_array().unwrap().len())
.collect()
}
#[tokio::test]
async fn requests_follow_the_stream_sets_batch_size_and_pace() {
let server = ScriptedServer::start(vec![Script::default()]).await;
let mut ws = Bare::<Probe>::open(
&server.url,
"probe",
Duration::from_secs(5),
Duration::from_secs(5),
)
.await
.unwrap();
let wanted = names(7);
let start = Instant::now();
ws.subscribe(&wanted).await.unwrap();
assert_eq!(sizes(&server), [3, 3, 1]);
assert!(
start.elapsed() >= Probe::MIN_REQUEST_INTERVAL * 2,
"three requests in {:?}",
start.elapsed()
);
assert_eq!(server.paths(), ["/probe"]);
assert_eq!(ws.streams(), wanted.as_slice());
}
#[tokio::test]
async fn unsubscribe_batches_follow_the_stream_set_too() {
let server = ScriptedServer::start(vec![Script::default()]).await;
let mut ws = Bare::<Probe>::open(
&server.url,
"probe",
Duration::from_secs(5),
Duration::from_secs(5),
)
.await
.unwrap();
let wanted = names(4);
ws.subscribe(&wanted).await.unwrap();
ws.unsubscribe(&wanted).await.unwrap();
assert_eq!(sizes(&server), [3, 1, 3, 1]);
assert!(ws.streams().is_empty());
assert_eq!(ws.path(), "probe");
}
}