use std::time::Duration;
use crate::client::ClientId;
use crate::client::message::MessageOutcome;
use crate::error::{Error, ServerError};
use crate::session::{
Bandwidth, BindKind, BoundInfo, RecoveryKind, RecoveryOutcome as WireRecovery,
ResubscribedEntry, ServerAnnouncement, SessionClosed as WireClosed, SubscriptionKey,
UnbindReason,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SubscriptionId {
client: ClientId,
key: SubscriptionKey,
}
impl SubscriptionId {
#[must_use]
#[inline]
pub const fn get(self) -> u64 {
self.key.get()
}
pub(crate) const fn new(client: ClientId, key: SubscriptionKey) -> Self {
Self { client, key }
}
pub(crate) const fn key(self) -> SubscriptionKey {
self.key
}
pub(crate) const fn client(self) -> ClientId {
self.client
}
#[cfg(feature = "test-util")]
#[must_use]
pub(crate) const fn from_raw(id: u64) -> Self {
Self {
client: ClientId::DETACHED,
key: SubscriptionKey::from_raw(id),
}
}
}
impl std::fmt::Display for SubscriptionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"client#{}/subscription#{}",
self.client.get(),
self.key.get()
)
}
}
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Continuity {
New,
Preserved,
Recovered {
requested_from: u64,
},
Replaced {
previous_session_id: Option<String>,
},
}
impl std::fmt::Debug for Continuity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::New => f.write_str("New"),
Self::Preserved => f.write_str("Preserved"),
Self::Recovered { requested_from } => f
.debug_struct("Recovered")
.field("requested_from", requested_from)
.finish(),
Self::Replaced {
previous_session_id,
} => f
.debug_struct("Replaced")
.field(
"previous_session_id",
&previous_session_id.as_ref().map(|_| crate::REDACTED),
)
.finish(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum StateValidity {
Valid,
Pending,
Invalid,
}
impl Continuity {
#[must_use]
#[inline]
pub const fn state_validity(&self) -> StateValidity {
match self {
Self::Preserved => StateValidity::Valid,
Self::Recovered { .. } => StateValidity::Pending,
Self::New | Self::Replaced { .. } => StateValidity::Invalid,
}
}
}
impl From<BindKind> for Continuity {
fn from(kind: BindKind) -> Self {
match kind {
BindKind::Created => Self::New,
BindKind::Rebound => Self::Preserved,
BindKind::Recovering {
requested_progressive,
} => Self::Recovered {
requested_from: requested_progressive,
},
BindKind::Recreated { previous } => Self::Replaced {
previous_session_id: previous.map(|id| id.as_str().to_owned()),
},
}
}
}
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Connected {
pub session_id: String,
pub continuity: Continuity,
pub keepalive: Duration,
pub request_limit_bytes: u64,
}
#[cfg(feature = "test-util")]
impl Connected {
#[must_use]
pub(crate) const fn from_parts(
session_id: String,
continuity: Continuity,
keepalive: Duration,
request_limit_bytes: u64,
) -> Self {
Self {
session_id,
continuity,
keepalive,
request_limit_bytes,
}
}
}
impl std::fmt::Debug for Connected {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Connected")
.field("session_id", &crate::REDACTED)
.field("continuity", &self.continuity)
.field("keepalive", &self.keepalive)
.field("request_limit_bytes", &self.request_limit_bytes)
.finish()
}
}
impl From<BoundInfo> for Connected {
fn from(info: BoundInfo) -> Self {
Self {
session_id: info.session_id.as_str().to_owned(),
continuity: info.kind.into(),
keepalive: info.keep_alive,
request_limit_bytes: info.request_limit_bytes,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Recovery {
pub requested_from: u64,
pub resumed_at: u64,
pub outcome: RecoveryOutcome,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RecoveryOutcome {
Exact,
Duplicated {
suppressed: u64,
},
Gap {
missing: u64,
},
}
impl Recovery {
#[must_use]
#[inline]
pub const fn is_lossless(&self) -> bool {
!matches!(self.outcome, RecoveryOutcome::Gap { .. })
}
#[cfg(feature = "test-util")]
#[must_use]
pub(crate) const fn from_parts(
requested_from: u64,
resumed_at: u64,
outcome: RecoveryOutcome,
) -> Self {
Self {
requested_from,
resumed_at,
outcome,
}
}
}
impl From<WireRecovery> for Recovery {
fn from(outcome: WireRecovery) -> Self {
Self {
requested_from: outcome.requested,
resumed_at: outcome.resumed_at,
outcome: match outcome.kind {
RecoveryKind::Exact => RecoveryOutcome::Exact,
RecoveryKind::Duplicated { count } => {
RecoveryOutcome::Duplicated { suppressed: count }
}
RecoveryKind::Gap { missing } => RecoveryOutcome::Gap { missing },
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Resubscribed {
pub id: SubscriptionId,
pub was_active: bool,
pub snapshot_restarting: bool,
}
#[cfg(feature = "test-util")]
impl Resubscribed {
#[must_use]
pub(crate) const fn from_parts(
id: SubscriptionId,
was_active: bool,
snapshot_restarting: bool,
) -> Self {
Self {
id,
was_active,
snapshot_restarting,
}
}
}
impl Resubscribed {
pub(crate) const fn from_entry(client: ClientId, entry: ResubscribedEntry) -> Self {
Self {
id: SubscriptionId::new(client, entry.key),
was_active: entry.previously_active,
snapshot_restarting: entry.snapshot_requested,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DisconnectReason {
Handover,
ConnectionFailed {
detail: String,
},
Stalled {
budget: Duration,
},
Refused(ServerError),
Refreshing(ServerError),
}
impl From<UnbindReason> for DisconnectReason {
fn from(reason: UnbindReason) -> Self {
match reason {
UnbindReason::ForcedByClient { .. }
| UnbindReason::ContentLengthReached { .. }
| UnbindReason::PollCycleExpired { .. }
| UnbindReason::Looped { .. } => Self::Handover,
UnbindReason::ConnectionFailed { detail } => Self::ConnectionFailed { detail },
UnbindReason::KeepaliveExpired { budget } => Self::Stalled { budget },
UnbindReason::Rejected { cause } => Self::Refused(cause.into()),
UnbindReason::ServerRefresh { cause } => Self::Refreshing(cause.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClosedReason {
ByClient,
ByServer(ServerError),
ReconnectExhausted {
attempts: u32,
last: Option<Box<DisconnectReason>>,
},
Internal {
reason: String,
},
}
impl ClosedReason {
#[must_use]
pub fn into_error(self) -> Error {
match self {
Self::ByClient => Error::Disconnected,
Self::ByServer(cause) => Error::Session(cause),
Self::ReconnectExhausted { attempts, last } => {
Error::ReconnectExhausted { attempts, last }
}
Self::Internal { reason } => Error::Internal { reason },
}
}
}
impl From<WireClosed> for ClosedReason {
fn from(closed: WireClosed) -> Self {
match closed {
WireClosed::ByClient { .. } => Self::ByClient,
WireClosed::ByServer { cause } => Self::ByServer(cause.into()),
WireClosed::RetriesExhausted { attempts, last } => Self::ReconnectExhausted {
attempts,
last: last.map(|reason| Box::new(DisconnectReason::from(reason))),
},
WireClosed::Internal { reason } => Self::Internal { reason },
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ServerInfo {
ServerName(String),
ClientIp(String),
BandwidthLimit {
kbps: Option<String>,
managed: bool,
},
UnreadableBandwidthLimit {
literal: String,
},
Clock {
elapsed_seconds: u64,
},
}
impl From<ServerAnnouncement> for ServerInfo {
fn from(announcement: ServerAnnouncement) -> Self {
match announcement {
ServerAnnouncement::Name(name) => Self::ServerName(name),
ServerAnnouncement::ClientIp(address) => Self::ClientIp(address),
ServerAnnouncement::Clock { elapsed_seconds } => Self::Clock { elapsed_seconds },
ServerAnnouncement::Bandwidth(bandwidth) => match bandwidth {
Bandwidth::Limited { kbps } => Self::BandwidthLimit {
kbps: Some(kbps),
managed: true,
},
Bandwidth::Unlimited => Self::BandwidthLimit {
kbps: None,
managed: true,
},
Bandwidth::Unmanaged => Self::BandwidthLimit {
kbps: None,
managed: false,
},
Bandwidth::Unrecognized { literal } => Self::UnreadableBandwidthLimit { literal },
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SessionEvent {
Connected(Box<Connected>),
Recovered(Recovery),
Resubscribed(Vec<Resubscribed>),
Disconnected {
reason: DisconnectReason,
retry_in: Option<Duration>,
},
Message(Box<MessageOutcome>),
ServerInfo(ServerInfo),
RequestRejected(ServerError),
RequestNotSent {
reason: String,
},
Unrecognized {
line: String,
},
Closed(ClosedReason),
}
#[derive(Debug)]
pub struct SessionEvents {
staged: std::collections::VecDeque<SessionEvent>,
events: tokio::sync::mpsc::Receiver<SessionEvent>,
}
impl SessionEvents {
pub(crate) const fn with_staged(
staged: std::collections::VecDeque<SessionEvent>,
events: tokio::sync::mpsc::Receiver<SessionEvent>,
) -> Self {
Self { staged, events }
}
}
impl futures_util::Stream for SessionEvents {
type Item = SessionEvent;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
if let Some(event) = self.staged.pop_front() {
return std::task::Poll::Ready(Some(event));
}
self.events.poll_recv(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{ServerCause, SessionId};
#[test]
fn test_continuity_distinguishes_the_three_adr_0005_cases() {
assert!(matches!(
Continuity::from(BindKind::Rebound),
Continuity::Preserved
));
assert!(matches!(
Continuity::from(BindKind::Recovering {
requested_progressive: 42
}),
Continuity::Recovered { .. }
));
let replaced = Continuity::from(BindKind::Recreated {
previous: Some(SessionId::new("S1")),
});
assert!(matches!(
replaced,
Continuity::Replaced { previous_session_id } if previous_session_id.as_deref() == Some("S1")
));
assert!(matches!(
Continuity::from(BindKind::Created),
Continuity::New
));
}
#[test]
fn test_only_a_clean_rebind_reports_state_as_valid() {
assert_eq!(Continuity::Preserved.state_validity(), StateValidity::Valid);
}
#[test]
fn test_a_recovery_in_flight_reports_state_as_pending() {
assert_eq!(
Continuity::Recovered { requested_from: 42 }.state_validity(),
StateValidity::Pending
);
}
#[test]
fn test_a_first_session_reports_state_as_invalid_like_a_replacement() {
assert_eq!(Continuity::New.state_validity(), StateValidity::Invalid);
assert_eq!(
Continuity::Replaced {
previous_session_id: Some("S1".to_owned())
}
.state_validity(),
StateValidity::Invalid
);
}
#[test]
fn test_a_recovery_that_reports_a_gap_resolves_the_pending_answer() {
let bind = Continuity::from(BindKind::Recovering {
requested_progressive: 10,
});
assert_eq!(bind.state_validity(), StateValidity::Pending);
let lossless = Recovery::from(WireRecovery {
requested: 10,
resumed_at: 10,
kind: RecoveryKind::Exact,
});
assert!(lossless.is_lossless());
let lossy = Recovery::from(WireRecovery {
requested: 10,
resumed_at: 14,
kind: RecoveryKind::Gap { missing: 4 },
});
assert!(!lossy.is_lossless());
}
#[test]
fn test_debug_redacts_every_session_identifier() {
let connected = Connected {
session_id: "S1234abcd".to_owned(),
continuity: Continuity::Replaced {
previous_session_id: Some("S5678efgh".to_owned()),
},
keepalive: Duration::from_secs(5),
request_limit_bytes: 50_000,
};
let rendered = format!("{connected:?}");
assert!(!rendered.contains("S1234abcd"), "{rendered}");
assert!(!rendered.contains("S5678efgh"), "{rendered}");
assert!(rendered.contains(crate::REDACTED), "{rendered}");
assert!(rendered.contains("50000"), "{rendered}");
let event = SessionEvent::Connected(Box::new(connected));
assert!(!format!("{event:?}").contains("S1234abcd"));
}
#[test]
fn test_continuity_carries_the_requested_recovery_point() {
assert!(matches!(
Continuity::from(BindKind::Recovering {
requested_progressive: 17
}),
Continuity::Recovered { requested_from: 17 }
));
}
#[test]
fn test_recovery_reports_a_gap_as_lossy() {
let exact = Recovery::from(WireRecovery {
requested: 10,
resumed_at: 10,
kind: RecoveryKind::Exact,
});
assert!(exact.is_lossless());
let duplicated = Recovery::from(WireRecovery {
requested: 10,
resumed_at: 7,
kind: RecoveryKind::Duplicated { count: 3 },
});
assert!(duplicated.is_lossless());
assert!(matches!(
duplicated.outcome,
RecoveryOutcome::Duplicated { suppressed: 3 }
));
let gap = Recovery::from(WireRecovery {
requested: 10,
resumed_at: 14,
kind: RecoveryKind::Gap { missing: 4 },
});
assert!(!gap.is_lossless());
}
#[test]
fn test_disconnect_reason_maps_a_clean_handover_apart_from_a_failure() {
assert!(matches!(
DisconnectReason::from(UnbindReason::ContentLengthReached {
expected_delay: Duration::ZERO
}),
DisconnectReason::Handover
));
assert!(matches!(
DisconnectReason::from(UnbindReason::KeepaliveExpired {
budget: Duration::from_secs(8)
}),
DisconnectReason::Stalled { .. }
));
assert!(matches!(
DisconnectReason::from(UnbindReason::ServerRefresh {
cause: ServerCause {
code: 48,
message: String::new()
}
}),
DisconnectReason::Refreshing(cause) if cause.code() == 48
));
}
#[test]
fn test_closed_reason_round_trips_into_an_error() {
assert!(matches!(
ClosedReason::ByClient.into_error(),
Error::Disconnected
));
assert!(matches!(
ClosedReason::ByServer(ServerError::new(2, "no adapter set")).into_error(),
Error::Session(cause) if cause.code() == 2
));
assert!(matches!(
ClosedReason::Internal {
reason: "boom".to_owned()
}
.into_error(),
Error::Internal { .. }
));
}
#[test]
fn test_server_info_translates_what_the_session_announced() {
assert!(matches!(
ServerInfo::from(ServerAnnouncement::Name(
"Lightstreamer HTTP Server".to_owned()
)),
ServerInfo::ServerName(name) if name.contains("Lightstreamer")
));
assert!(matches!(
ServerInfo::from(ServerAnnouncement::Clock {
elapsed_seconds: 12
}),
ServerInfo::Clock {
elapsed_seconds: 12
}
));
assert!(matches!(
ServerInfo::from(ServerAnnouncement::ClientIp("0:0:0:0:0:0:0:1".to_owned())),
ServerInfo::ClientIp(address) if address == "0:0:0:0:0:0:0:1"
));
assert!(matches!(
ServerInfo::from(ServerAnnouncement::Bandwidth(Bandwidth::Unmanaged)),
ServerInfo::BandwidthLimit {
kbps: None,
managed: false
}
));
assert!(matches!(
ServerInfo::from(ServerAnnouncement::Bandwidth(Bandwidth::Unlimited)),
ServerInfo::BandwidthLimit {
kbps: None,
managed: true
}
));
assert!(matches!(
ServerInfo::from(ServerAnnouncement::Bandwidth(Bandwidth::Unrecognized {
literal: String::new(),
})),
ServerInfo::UnreadableBandwidthLimit { literal } if literal.is_empty()
));
}
#[test]
fn test_unrecognized_lines_are_surfaced_verbatim() {
let event = SessionEvent::Unrecognized {
line: "FUTURE,1,2,3".to_owned(),
};
assert!(matches!(event, SessionEvent::Unrecognized { line } if line == "FUTURE,1,2,3"));
}
}