use std::collections::VecDeque;
use std::fmt;
use std::time::Duration;
use tokio::net::TcpStream;
use tokio_util::codec::Framed;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::{actor::codec::WhatsMinerCodec, permission::Permission};
#[derive(PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub(crate) struct Salt(String);
impl Salt {
pub(crate) fn new(salt: impl Into<String>) -> Self {
Self(salt.into())
}
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Salt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[REDACTED]")
}
}
impl fmt::Display for Salt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[REDACTED]")
}
}
pub(crate) struct Session {
pub(crate) transport: Framed<TcpStream, WhatsMinerCodec>,
pub(crate) salt: Salt,
pub(crate) permissions: Permission,
}
impl fmt::Debug for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Session")
.field("salt", &self.salt)
.field("permissions", &self.permissions)
.finish_non_exhaustive()
}
}
pub(crate) enum ActorState {
Created,
Connecting,
Connected(Session),
Downloading(Session),
Uploading(Session),
Reconnecting {
attempt: usize,
reason: ReconnectReason,
},
Disconnecting(Session),
Closed {
reason: CloseReason,
},
}
impl fmt::Debug for ActorState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Created => write!(f, "Created"),
Self::Connecting => write!(f, "Connecting"),
Self::Connected(s) => f.debug_tuple("Connected").field(s).finish(),
Self::Downloading(s) => f.debug_tuple("Downloading").field(s).finish(),
Self::Uploading(s) => f.debug_tuple("Uploading").field(s).finish(),
Self::Reconnecting { attempt, reason } => f
.debug_struct("Reconnecting")
.field("attempt", attempt)
.field("reason", reason)
.finish(),
Self::Disconnecting(s) => f.debug_tuple("Disconnecting").field(s).finish(),
Self::Closed { reason } => f.debug_struct("Closed").field("reason", reason).finish(),
}
}
}
impl ActorState {
pub(crate) fn status(&self) -> ActorStatus {
match self {
Self::Created => ActorStatus::Created,
Self::Connecting => ActorStatus::Connecting,
Self::Connected(session) => ActorStatus::Connected {
permissions: session.permissions,
},
Self::Downloading(session) => ActorStatus::Downloading {
permissions: session.permissions,
},
Self::Uploading(session) => ActorStatus::Uploading {
permissions: session.permissions,
},
Self::Reconnecting { attempt, reason } => ActorStatus::Reconnecting {
attempt: *attempt,
reason: reason.clone(),
},
Self::Disconnecting(session) => ActorStatus::Disconnecting {
permissions: session.permissions,
},
Self::Closed { reason } => ActorStatus::Closed {
reason: reason.clone(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActorStatus {
Created,
Connecting,
Connected {
permissions: Permission,
},
Downloading {
permissions: Permission,
},
Uploading {
permissions: Permission,
},
Reconnecting {
attempt: usize,
reason: ReconnectReason,
},
Disconnecting {
permissions: Permission,
},
Closed {
reason: CloseReason,
},
}
impl ActorStatus {
pub fn permissions(&self) -> Option<Permission> {
match self {
Self::Connected { permissions }
| Self::Downloading { permissions }
| Self::Uploading { permissions }
| Self::Disconnecting { permissions } => Some(*permissions),
_ => None,
}
}
pub fn is_connected(&self) -> bool {
matches!(self, Self::Connected { .. })
}
pub fn is_closed(&self) -> bool {
matches!(self, Self::Closed { .. })
}
}
impl fmt::Display for ActorStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Created => write!(f, "created"),
Self::Connecting => write!(f, "connecting"),
Self::Connected { permissions } => write!(f, "connected ({permissions:?})"),
Self::Downloading { .. } => write!(f, "downloading"),
Self::Uploading { .. } => write!(f, "uploading"),
Self::Reconnecting { attempt, reason } => {
write!(f, "reconnecting (attempt {attempt}: {reason})")
}
Self::Disconnecting { .. } => write!(f, "disconnecting"),
Self::Closed { reason } => write!(f, "closed ({reason})"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReconnectReason {
FirmwareUpdate,
Io(String),
ProtocolError(String),
Timeout,
ConnectionClosed,
}
impl From<&crate::error::Error> for ReconnectReason {
fn from(value: &crate::error::Error) -> Self {
match value {
crate::error::Error::Timeout => ReconnectReason::Timeout,
crate::error::Error::ConnectionClosed => ReconnectReason::ConnectionClosed,
crate::error::Error::Io(error) => ReconnectReason::Io(error.to_string()),
_ => ReconnectReason::ProtocolError(value.to_string()),
}
}
}
impl fmt::Display for ReconnectReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FirmwareUpdate => write!(f, "firmware update"),
Self::Io(err) => write!(f, "I/O error: {err}"),
Self::ProtocolError(err) => write!(f, "protocol error: {err}"),
Self::Timeout => write!(f, "timeout"),
Self::ConnectionClosed => write!(f, "connection closed"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloseReason {
Shutdown,
ReconnectFailed { attempts: usize },
Disconnected,
}
impl fmt::Display for CloseReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Shutdown => write!(f, "shutdown requested"),
Self::ReconnectFailed { attempts } => {
write!(f, "reconnection failed after {attempts} attempts")
}
Self::Disconnected => write!(f, "disconnected"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ReconnectPolicy {
#[default]
None,
Fixed(Duration),
FixedWithLimit(Duration, usize),
Exponent(Duration, f64),
ExponentWithLimit(Duration, f64, usize),
}
impl ReconnectPolicy {
pub fn delay_for_attempt(&self, attempt: usize) -> Option<Duration> {
if attempt == 0 {
return None;
}
let (initial, factor) = match *self {
Self::None => return None,
Self::Fixed(delay) => return Some(delay),
Self::FixedWithLimit(delay, limit) => return (attempt <= limit).then_some(delay),
Self::Exponent(initial, factor) => (initial, factor),
Self::ExponentWithLimit(initial, factor, limit) => {
if attempt > limit {
return None;
}
(initial, factor)
}
};
if !factor.is_finite() || factor < 0.0 {
return None;
}
if attempt == 1 || initial.is_zero() {
return Some(initial);
}
let seconds = initial.as_secs_f64() * factor.powf((attempt - 1) as f64);
Some(Duration::try_from_secs_f64(seconds).unwrap_or(Duration::MAX))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LifecycleIntent {
Connect,
Shutdown,
}
#[derive(Debug, Default)]
pub(crate) struct PendingPlan {
intents: VecDeque<LifecycleIntent>,
}
impl PendingPlan {
pub(crate) const CAPACITY: usize = 4;
pub(crate) fn push(&mut self, intent: LifecycleIntent, current_state: &ActorState) {
if self.intents.back() == Some(&intent) {
return;
}
if intent == LifecycleIntent::Connect
&& self.intents.back() != Some(&LifecycleIntent::Shutdown)
&& matches!(
current_state,
ActorState::Connecting
| ActorState::Connected(_)
| ActorState::Downloading(_)
| ActorState::Uploading(_)
| ActorState::Reconnecting { .. }
)
{
return;
}
if intent == LifecycleIntent::Shutdown
&& self.intents.back() == Some(&LifecycleIntent::Connect)
&& matches!(
current_state,
ActorState::Created | ActorState::Closed { .. }
)
{
self.intents.pop_back();
return;
}
assert!(
self.intents.len() < Self::CAPACITY,
"lifecycle plan capacity exceeded"
);
self.intents.push_back(intent);
}
pub(crate) fn drain_from_receiver(
&mut self,
rx: &mut tokio::sync::mpsc::Receiver<LifecycleIntent>,
current_state: &ActorState,
) {
for _ in 0..Self::CAPACITY.saturating_sub(self.intents.len()) {
match rx.try_recv() {
Ok(intent) => self.push(intent, current_state),
Err(_) => break,
}
}
}
pub(crate) fn pop(&mut self) -> Option<LifecycleIntent> {
self.intents.pop_front()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn salt_redacted_and_as_str() {
let salt = Salt::new("secret_salt_123");
assert_eq!(salt.as_str(), "secret_salt_123");
assert_eq!(format!("{salt:?}"), "[REDACTED]");
assert_eq!(format!("{salt}"), "[REDACTED]");
}
#[test]
fn reconnect_delays_preserve_growth_and_retry_limit() {
let policy = ReconnectPolicy::ExponentWithLimit(Duration::from_millis(10), 2.0, 3);
assert_eq!(policy.delay_for_attempt(1), Some(Duration::from_millis(10)));
assert_eq!(policy.delay_for_attempt(3), Some(Duration::from_millis(40)));
assert_eq!(policy.delay_for_attempt(4), None);
let unbounded = ReconnectPolicy::Exponent(Duration::from_secs(1), 2.0);
assert_eq!(unbounded.delay_for_attempt(usize::MAX), Some(Duration::MAX));
assert_eq!(
ReconnectPolicy::Exponent(Duration::ZERO, 2.0).delay_for_attempt(usize::MAX),
Some(Duration::ZERO)
);
assert_eq!(
ReconnectPolicy::Exponent(Duration::from_secs(1), f64::NAN).delay_for_attempt(2),
None
);
}
#[test]
fn pending_plan_reduction_and_cancellation() {
let mut plan = PendingPlan::default();
let created = ActorState::Created;
plan.push(LifecycleIntent::Connect, &created);
assert_eq!(plan.intents.len(), 1);
plan.push(LifecycleIntent::Shutdown, &created);
assert_eq!(plan.intents.len(), 0);
plan.push(LifecycleIntent::Connect, &created);
plan.push(LifecycleIntent::Connect, &created);
assert_eq!(plan.intents.len(), 1);
assert_eq!(plan.pop(), Some(LifecycleIntent::Connect));
let connecting = ActorState::Connecting;
plan.push(LifecycleIntent::Connect, &connecting);
assert_eq!(plan.pop(), None);
let reconnecting = ActorState::Reconnecting {
attempt: 1,
reason: ReconnectReason::Timeout,
};
plan.push(LifecycleIntent::Connect, &reconnecting);
assert_eq!(plan.pop(), None);
plan.push(LifecycleIntent::Shutdown, &connecting);
plan.push(LifecycleIntent::Connect, &connecting);
assert_eq!(plan.pop(), Some(LifecycleIntent::Shutdown));
assert_eq!(plan.pop(), Some(LifecycleIntent::Connect));
}
}