mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
769 lines
25 KiB
Rust
769 lines
25 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
pub const MAX_RECONNECT_ATTEMPTS: u32 = 8;
|
|
pub const RECONNECT_BACKOFF_BASE_MS: u64 = 100;
|
|
pub const RECONNECT_BACKOFF_CAP_MS: u64 = 5_000;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum LinuxCaptureFault {
|
|
StreamError(i32),
|
|
PortalSessionLost,
|
|
NodeRemoved,
|
|
PermissionRevoked,
|
|
BufferUnderrun,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum LinuxCaptureState {
|
|
Connecting {
|
|
since_ns: u64,
|
|
},
|
|
Active {
|
|
since_ns: u64,
|
|
},
|
|
Reconnecting {
|
|
since_ns: u64,
|
|
attempts: u32,
|
|
last_fault: LinuxCaptureFault,
|
|
},
|
|
Failed {
|
|
since_ns: u64,
|
|
final_fault: LinuxCaptureFault,
|
|
total_attempts: u32,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum LinuxCaptureEvent {
|
|
Connected,
|
|
Faulted(LinuxCaptureFault),
|
|
ReconnectAttempted,
|
|
Reset,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum LinuxCaptureAction {
|
|
None,
|
|
EnterActive,
|
|
ScheduleReconnect { attempt: u32, backoff_ms: u64 },
|
|
ReportFailure,
|
|
RestartFromFailed,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum LinuxCaptureFsmError {
|
|
InvalidInputState,
|
|
InvalidOutputState,
|
|
InvariantViolated,
|
|
}
|
|
|
|
pub fn reconnect_backoff_ms(attempts: u32) -> u64 {
|
|
assert!(attempts >= 1, "backoff attempts must be >= 1");
|
|
assert!(
|
|
attempts <= MAX_RECONNECT_ATTEMPTS,
|
|
"backoff attempts must be <= MAX_RECONNECT_ATTEMPTS"
|
|
);
|
|
let shift = attempts - 1;
|
|
if shift >= 64 {
|
|
return RECONNECT_BACKOFF_CAP_MS;
|
|
}
|
|
let raw = RECONNECT_BACKOFF_BASE_MS.saturating_mul(1u64 << shift);
|
|
let capped = if raw > RECONNECT_BACKOFF_CAP_MS {
|
|
RECONNECT_BACKOFF_CAP_MS
|
|
} else {
|
|
raw
|
|
};
|
|
assert!(
|
|
capped >= RECONNECT_BACKOFF_BASE_MS,
|
|
"backoff produced sub-base value"
|
|
);
|
|
assert!(capped <= RECONNECT_BACKOFF_CAP_MS, "backoff exceeded cap");
|
|
capped
|
|
}
|
|
|
|
pub fn transition_linux_capture_state(
|
|
state: LinuxCaptureState,
|
|
event: LinuxCaptureEvent,
|
|
now_ns: u64,
|
|
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
|
|
assert_state_invariants(&state).map_err(|_| LinuxCaptureFsmError::InvalidInputState)?;
|
|
let (next, action) = dispatch(state, event, now_ns)?;
|
|
assert_state_invariants(&next).map_err(|_| LinuxCaptureFsmError::InvalidOutputState)?;
|
|
Ok((next, action))
|
|
}
|
|
|
|
fn dispatch(
|
|
state: LinuxCaptureState,
|
|
event: LinuxCaptureEvent,
|
|
now_ns: u64,
|
|
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
|
|
match state {
|
|
LinuxCaptureState::Connecting { .. } => from_connecting(state, event, now_ns),
|
|
LinuxCaptureState::Active { .. } => from_active(state, event, now_ns),
|
|
LinuxCaptureState::Reconnecting { .. } => from_reconnecting(state, event, now_ns),
|
|
LinuxCaptureState::Failed { .. } => from_failed(state, event, now_ns),
|
|
}
|
|
}
|
|
|
|
fn from_connecting(
|
|
state: LinuxCaptureState,
|
|
event: LinuxCaptureEvent,
|
|
now_ns: u64,
|
|
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
|
|
debug_assert!(matches!(state, LinuxCaptureState::Connecting { .. }));
|
|
match event {
|
|
LinuxCaptureEvent::Connected => Ok((
|
|
LinuxCaptureState::Active { since_ns: now_ns },
|
|
LinuxCaptureAction::EnterActive,
|
|
)),
|
|
LinuxCaptureEvent::Faulted(fault) => {
|
|
let next = LinuxCaptureState::Reconnecting {
|
|
since_ns: now_ns,
|
|
attempts: 1,
|
|
last_fault: fault,
|
|
};
|
|
let backoff = reconnect_backoff_ms(1);
|
|
Ok((
|
|
next,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: 1,
|
|
backoff_ms: backoff,
|
|
},
|
|
))
|
|
}
|
|
LinuxCaptureEvent::ReconnectAttempted => Ok((state, LinuxCaptureAction::None)),
|
|
LinuxCaptureEvent::Reset => Ok((state, LinuxCaptureAction::None)),
|
|
}
|
|
}
|
|
|
|
fn from_active(
|
|
state: LinuxCaptureState,
|
|
event: LinuxCaptureEvent,
|
|
now_ns: u64,
|
|
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
|
|
debug_assert!(matches!(state, LinuxCaptureState::Active { .. }));
|
|
match event {
|
|
LinuxCaptureEvent::Connected => Ok((state, LinuxCaptureAction::None)),
|
|
LinuxCaptureEvent::Faulted(fault) => {
|
|
let next = LinuxCaptureState::Reconnecting {
|
|
since_ns: now_ns,
|
|
attempts: 1,
|
|
last_fault: fault,
|
|
};
|
|
let backoff = reconnect_backoff_ms(1);
|
|
Ok((
|
|
next,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: 1,
|
|
backoff_ms: backoff,
|
|
},
|
|
))
|
|
}
|
|
LinuxCaptureEvent::ReconnectAttempted => Ok((state, LinuxCaptureAction::None)),
|
|
LinuxCaptureEvent::Reset => Ok((state, LinuxCaptureAction::None)),
|
|
}
|
|
}
|
|
|
|
fn from_reconnecting(
|
|
state: LinuxCaptureState,
|
|
event: LinuxCaptureEvent,
|
|
now_ns: u64,
|
|
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
|
|
let (since_ns, attempts, last_fault) = match state {
|
|
LinuxCaptureState::Reconnecting {
|
|
since_ns,
|
|
attempts,
|
|
last_fault,
|
|
} => (since_ns, attempts, last_fault),
|
|
_ => return Err(LinuxCaptureFsmError::InvariantViolated),
|
|
};
|
|
assert!(
|
|
(1..=MAX_RECONNECT_ATTEMPTS).contains(&attempts),
|
|
"attempts out of range"
|
|
);
|
|
match event {
|
|
LinuxCaptureEvent::Connected => Ok((
|
|
LinuxCaptureState::Active { since_ns: now_ns },
|
|
LinuxCaptureAction::EnterActive,
|
|
)),
|
|
LinuxCaptureEvent::Faulted(new_fault) => {
|
|
handle_fault_while_reconnecting(since_ns, attempts, new_fault, now_ns)
|
|
}
|
|
LinuxCaptureEvent::ReconnectAttempted => {
|
|
handle_reconnect_attempt(since_ns, attempts, last_fault)
|
|
}
|
|
LinuxCaptureEvent::Reset => Ok((state, LinuxCaptureAction::None)),
|
|
}
|
|
}
|
|
|
|
fn handle_fault_while_reconnecting(
|
|
since_ns: u64,
|
|
attempts: u32,
|
|
new_fault: LinuxCaptureFault,
|
|
now_ns: u64,
|
|
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
|
|
assert!(attempts >= 1, "attempts must be >= 1");
|
|
assert!(
|
|
attempts <= MAX_RECONNECT_ATTEMPTS,
|
|
"attempts must be <= MAX_RECONNECT_ATTEMPTS"
|
|
);
|
|
if attempts >= MAX_RECONNECT_ATTEMPTS {
|
|
let next = LinuxCaptureState::Failed {
|
|
since_ns: now_ns,
|
|
final_fault: new_fault,
|
|
total_attempts: attempts,
|
|
};
|
|
return Ok((next, LinuxCaptureAction::ReportFailure));
|
|
}
|
|
let bumped = attempts + 1;
|
|
let next = LinuxCaptureState::Reconnecting {
|
|
since_ns,
|
|
attempts: bumped,
|
|
last_fault: new_fault,
|
|
};
|
|
let backoff = reconnect_backoff_ms(bumped);
|
|
Ok((
|
|
next,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: bumped,
|
|
backoff_ms: backoff,
|
|
},
|
|
))
|
|
}
|
|
|
|
fn handle_reconnect_attempt(
|
|
since_ns: u64,
|
|
attempts: u32,
|
|
last_fault: LinuxCaptureFault,
|
|
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
|
|
assert!(attempts >= 1, "attempts must be >= 1");
|
|
assert!(
|
|
attempts <= MAX_RECONNECT_ATTEMPTS,
|
|
"attempts must be <= MAX_RECONNECT_ATTEMPTS"
|
|
);
|
|
let capped = if attempts >= MAX_RECONNECT_ATTEMPTS {
|
|
MAX_RECONNECT_ATTEMPTS
|
|
} else {
|
|
attempts + 1
|
|
};
|
|
let next = LinuxCaptureState::Reconnecting {
|
|
since_ns,
|
|
attempts: capped,
|
|
last_fault,
|
|
};
|
|
let backoff = reconnect_backoff_ms(capped);
|
|
Ok((
|
|
next,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: capped,
|
|
backoff_ms: backoff,
|
|
},
|
|
))
|
|
}
|
|
|
|
fn from_failed(
|
|
state: LinuxCaptureState,
|
|
event: LinuxCaptureEvent,
|
|
now_ns: u64,
|
|
) -> Result<(LinuxCaptureState, LinuxCaptureAction), LinuxCaptureFsmError> {
|
|
debug_assert!(matches!(state, LinuxCaptureState::Failed { .. }));
|
|
match event {
|
|
LinuxCaptureEvent::Connected => Ok((state, LinuxCaptureAction::None)),
|
|
LinuxCaptureEvent::Faulted(_) => Ok((state, LinuxCaptureAction::None)),
|
|
LinuxCaptureEvent::ReconnectAttempted => Ok((state, LinuxCaptureAction::None)),
|
|
LinuxCaptureEvent::Reset => Ok((
|
|
LinuxCaptureState::Connecting { since_ns: now_ns },
|
|
LinuxCaptureAction::RestartFromFailed,
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn assert_state_invariants(state: &LinuxCaptureState) -> Result<(), LinuxCaptureFsmError> {
|
|
match state {
|
|
LinuxCaptureState::Connecting { .. } => Ok(()),
|
|
LinuxCaptureState::Active { .. } => Ok(()),
|
|
LinuxCaptureState::Reconnecting { attempts, .. } => {
|
|
if *attempts < 1 {
|
|
return Err(LinuxCaptureFsmError::InvariantViolated);
|
|
}
|
|
if *attempts > MAX_RECONNECT_ATTEMPTS {
|
|
return Err(LinuxCaptureFsmError::InvariantViolated);
|
|
}
|
|
Ok(())
|
|
}
|
|
LinuxCaptureState::Failed { total_attempts, .. } => {
|
|
if *total_attempts > MAX_RECONNECT_ATTEMPTS {
|
|
return Err(LinuxCaptureFsmError::InvariantViolated);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type LinuxCaptureListener = Box<dyn FnMut(LinuxCaptureEvent, &LinuxCaptureState) + Send>;
|
|
|
|
pub struct LinuxCaptureStateMachine {
|
|
state: LinuxCaptureState,
|
|
listener: Option<LinuxCaptureListener>,
|
|
}
|
|
|
|
impl LinuxCaptureStateMachine {
|
|
pub fn new(now_ns: u64) -> Self {
|
|
let initial = LinuxCaptureState::Connecting { since_ns: now_ns };
|
|
assert!(matches!(initial, LinuxCaptureState::Connecting { .. }));
|
|
Self {
|
|
state: initial,
|
|
listener: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_listener<F>(now_ns: u64, listener: F) -> Self
|
|
where
|
|
F: FnMut(LinuxCaptureEvent, &LinuxCaptureState) + Send + 'static,
|
|
{
|
|
let initial = LinuxCaptureState::Connecting { since_ns: now_ns };
|
|
assert!(matches!(initial, LinuxCaptureState::Connecting { .. }));
|
|
Self {
|
|
state: initial,
|
|
listener: Some(Box::new(listener)),
|
|
}
|
|
}
|
|
|
|
pub fn state(&self) -> &LinuxCaptureState {
|
|
&self.state
|
|
}
|
|
|
|
pub fn dispatch(
|
|
&mut self,
|
|
event: LinuxCaptureEvent,
|
|
now_ns: u64,
|
|
) -> Result<LinuxCaptureAction, LinuxCaptureFsmError> {
|
|
assert_state_invariants(&self.state)?;
|
|
let (next, action) = transition_linux_capture_state(self.state, event, now_ns)?;
|
|
self.state = next;
|
|
if let Some(listener) = self.listener.as_mut() {
|
|
listener(event, &self.state);
|
|
}
|
|
assert_state_invariants(&self.state)?;
|
|
Ok(action)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const T0: u64 = 1_000;
|
|
const T1: u64 = 2_000;
|
|
const T2: u64 = 3_000;
|
|
|
|
fn connecting(t: u64) -> LinuxCaptureState {
|
|
LinuxCaptureState::Connecting { since_ns: t }
|
|
}
|
|
|
|
fn active(t: u64) -> LinuxCaptureState {
|
|
LinuxCaptureState::Active { since_ns: t }
|
|
}
|
|
|
|
fn reconnecting(t: u64, attempts: u32, fault: LinuxCaptureFault) -> LinuxCaptureState {
|
|
LinuxCaptureState::Reconnecting {
|
|
since_ns: t,
|
|
attempts,
|
|
last_fault: fault,
|
|
}
|
|
}
|
|
|
|
fn failed(t: u64, fault: LinuxCaptureFault, total: u32) -> LinuxCaptureState {
|
|
LinuxCaptureState::Failed {
|
|
since_ns: t,
|
|
final_fault: fault,
|
|
total_attempts: total,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn connecting_plus_connected_goes_active() {
|
|
let (next, action) =
|
|
transition_linux_capture_state(connecting(T0), LinuxCaptureEvent::Connected, T1)
|
|
.unwrap();
|
|
assert_eq!(next, active(T1));
|
|
assert_eq!(action, LinuxCaptureAction::EnterActive);
|
|
}
|
|
|
|
#[test]
|
|
fn connecting_plus_faulted_goes_reconnecting() {
|
|
let fault = LinuxCaptureFault::StreamError(-13);
|
|
let (next, action) =
|
|
transition_linux_capture_state(connecting(T0), LinuxCaptureEvent::Faulted(fault), T1)
|
|
.unwrap();
|
|
assert_eq!(next, reconnecting(T1, 1, fault));
|
|
assert_eq!(
|
|
action,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: 1,
|
|
backoff_ms: 100
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_plus_faulted_goes_reconnecting() {
|
|
let fault = LinuxCaptureFault::PortalSessionLost;
|
|
let (next, action) =
|
|
transition_linux_capture_state(active(T0), LinuxCaptureEvent::Faulted(fault), T1)
|
|
.unwrap();
|
|
assert_eq!(next, reconnecting(T1, 1, fault));
|
|
assert_eq!(
|
|
action,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: 1,
|
|
backoff_ms: 100
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_plus_reconnect_attempted_is_noop() {
|
|
let (next, action) =
|
|
transition_linux_capture_state(active(T0), LinuxCaptureEvent::ReconnectAttempted, T1)
|
|
.unwrap();
|
|
assert_eq!(next, active(T0));
|
|
assert_eq!(action, LinuxCaptureAction::None);
|
|
}
|
|
|
|
#[test]
|
|
fn active_plus_connected_is_noop() {
|
|
let (next, action) =
|
|
transition_linux_capture_state(active(T0), LinuxCaptureEvent::Connected, T1).unwrap();
|
|
assert_eq!(next, active(T0));
|
|
assert_eq!(action, LinuxCaptureAction::None);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnecting_plus_connected_goes_active() {
|
|
let fault = LinuxCaptureFault::NodeRemoved;
|
|
let (next, action) = transition_linux_capture_state(
|
|
reconnecting(T0, 3, fault),
|
|
LinuxCaptureEvent::Connected,
|
|
T1,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(next, active(T1));
|
|
assert_eq!(action, LinuxCaptureAction::EnterActive);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnecting_plus_reconnect_attempted_increments() {
|
|
let fault = LinuxCaptureFault::NodeRemoved;
|
|
let (next, action) = transition_linux_capture_state(
|
|
reconnecting(T0, 2, fault),
|
|
LinuxCaptureEvent::ReconnectAttempted,
|
|
T1,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(next, reconnecting(T0, 3, fault));
|
|
assert_eq!(
|
|
action,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: 3,
|
|
backoff_ms: 400
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnecting_plus_reconnect_attempted_caps_at_max() {
|
|
let fault = LinuxCaptureFault::BufferUnderrun;
|
|
let (next, action) = transition_linux_capture_state(
|
|
reconnecting(T0, MAX_RECONNECT_ATTEMPTS, fault),
|
|
LinuxCaptureEvent::ReconnectAttempted,
|
|
T1,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(next, reconnecting(T0, MAX_RECONNECT_ATTEMPTS, fault));
|
|
assert_eq!(
|
|
action,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: MAX_RECONNECT_ATTEMPTS,
|
|
backoff_ms: RECONNECT_BACKOFF_CAP_MS,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnecting_plus_faulted_below_max_increments() {
|
|
let old = LinuxCaptureFault::NodeRemoved;
|
|
let new_fault = LinuxCaptureFault::StreamError(-7);
|
|
let (next, action) = transition_linux_capture_state(
|
|
reconnecting(T0, 2, old),
|
|
LinuxCaptureEvent::Faulted(new_fault),
|
|
T1,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(next, reconnecting(T0, 3, new_fault));
|
|
assert_eq!(
|
|
action,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: 3,
|
|
backoff_ms: 400
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnecting_plus_faulted_at_max_goes_failed() {
|
|
let old = LinuxCaptureFault::NodeRemoved;
|
|
let new_fault = LinuxCaptureFault::PermissionRevoked;
|
|
let (next, action) = transition_linux_capture_state(
|
|
reconnecting(T0, MAX_RECONNECT_ATTEMPTS, old),
|
|
LinuxCaptureEvent::Faulted(new_fault),
|
|
T2,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(next, failed(T2, new_fault, MAX_RECONNECT_ATTEMPTS));
|
|
assert_eq!(action, LinuxCaptureAction::ReportFailure);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_plus_reset_goes_connecting() {
|
|
let fault = LinuxCaptureFault::StreamError(-99);
|
|
let (next, action) = transition_linux_capture_state(
|
|
failed(T0, fault, MAX_RECONNECT_ATTEMPTS),
|
|
LinuxCaptureEvent::Reset,
|
|
T1,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(next, connecting(T1));
|
|
assert_eq!(action, LinuxCaptureAction::RestartFromFailed);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_plus_connected_stays_failed() {
|
|
let fault = LinuxCaptureFault::StreamError(-99);
|
|
let prior = failed(T0, fault, MAX_RECONNECT_ATTEMPTS);
|
|
let (next, action) =
|
|
transition_linux_capture_state(prior, LinuxCaptureEvent::Connected, T1).unwrap();
|
|
assert_eq!(next, prior);
|
|
assert_eq!(action, LinuxCaptureAction::None);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_plus_faulted_stays_failed() {
|
|
let fault = LinuxCaptureFault::StreamError(-99);
|
|
let prior = failed(T0, fault, MAX_RECONNECT_ATTEMPTS);
|
|
let (next, action) = transition_linux_capture_state(
|
|
prior,
|
|
LinuxCaptureEvent::Faulted(LinuxCaptureFault::NodeRemoved),
|
|
T1,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(next, prior);
|
|
assert_eq!(action, LinuxCaptureAction::None);
|
|
}
|
|
|
|
#[test]
|
|
fn connecting_plus_reset_is_noop() {
|
|
let (next, action) =
|
|
transition_linux_capture_state(connecting(T0), LinuxCaptureEvent::Reset, T1).unwrap();
|
|
assert_eq!(next, connecting(T0));
|
|
assert_eq!(action, LinuxCaptureAction::None);
|
|
}
|
|
|
|
#[test]
|
|
fn backoff_increases_exponentially_and_caps() {
|
|
assert_eq!(reconnect_backoff_ms(1), 100);
|
|
assert_eq!(reconnect_backoff_ms(2), 200);
|
|
assert_eq!(reconnect_backoff_ms(3), 400);
|
|
assert_eq!(reconnect_backoff_ms(4), 800);
|
|
assert_eq!(reconnect_backoff_ms(5), 1_600);
|
|
assert_eq!(reconnect_backoff_ms(6), 3_200);
|
|
assert_eq!(reconnect_backoff_ms(7), RECONNECT_BACKOFF_CAP_MS);
|
|
assert_eq!(reconnect_backoff_ms(8), RECONNECT_BACKOFF_CAP_MS);
|
|
}
|
|
|
|
#[test]
|
|
fn backoff_is_monotonic_until_cap() {
|
|
let mut prev = 0u64;
|
|
for n in 1..=MAX_RECONNECT_ATTEMPTS {
|
|
let b = reconnect_backoff_ms(n);
|
|
assert!(b >= prev, "backoff must be non-decreasing");
|
|
assert!(b <= RECONNECT_BACKOFF_CAP_MS, "backoff must respect cap");
|
|
prev = b;
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn max_reconnect_attempts_reached_via_faults_goes_failed() {
|
|
let mut state = active(T0);
|
|
let mut now = T0;
|
|
for expected_attempts in 1..=MAX_RECONNECT_ATTEMPTS {
|
|
now += 1;
|
|
let fault = LinuxCaptureFault::StreamError(expected_attempts as i32);
|
|
let (next, _action) =
|
|
transition_linux_capture_state(state, LinuxCaptureEvent::Faulted(fault), now)
|
|
.unwrap();
|
|
match next {
|
|
LinuxCaptureState::Reconnecting { attempts, .. } => {
|
|
assert_eq!(attempts, expected_attempts);
|
|
}
|
|
_ => panic!("expected Reconnecting, got {:?}", next),
|
|
}
|
|
state = next;
|
|
}
|
|
now += 1;
|
|
let final_fault = LinuxCaptureFault::PermissionRevoked;
|
|
let (next, action) =
|
|
transition_linux_capture_state(state, LinuxCaptureEvent::Faulted(final_fault), now)
|
|
.unwrap();
|
|
assert!(matches!(next, LinuxCaptureState::Failed { .. }));
|
|
assert_eq!(action, LinuxCaptureAction::ReportFailure);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_input_state_rejected() {
|
|
let bad = LinuxCaptureState::Reconnecting {
|
|
since_ns: T0,
|
|
attempts: 0,
|
|
last_fault: LinuxCaptureFault::NodeRemoved,
|
|
};
|
|
let err =
|
|
transition_linux_capture_state(bad, LinuxCaptureEvent::Connected, T1).unwrap_err();
|
|
assert_eq!(err, LinuxCaptureFsmError::InvalidInputState);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_input_state_attempts_too_high_rejected() {
|
|
let bad = LinuxCaptureState::Reconnecting {
|
|
since_ns: T0,
|
|
attempts: MAX_RECONNECT_ATTEMPTS + 1,
|
|
last_fault: LinuxCaptureFault::NodeRemoved,
|
|
};
|
|
let err =
|
|
transition_linux_capture_state(bad, LinuxCaptureEvent::Connected, T1).unwrap_err();
|
|
assert_eq!(err, LinuxCaptureFsmError::InvalidInputState);
|
|
}
|
|
|
|
#[test]
|
|
fn determinism_same_input_same_output() {
|
|
let state = reconnecting(T0, 3, LinuxCaptureFault::NodeRemoved);
|
|
let ev = LinuxCaptureEvent::Faulted(LinuxCaptureFault::StreamError(-5));
|
|
let a = transition_linux_capture_state(state, ev, T2).unwrap();
|
|
let b = transition_linux_capture_state(state, ev, T2).unwrap();
|
|
let c = transition_linux_capture_state(state, ev, T2).unwrap();
|
|
assert_eq!(a, b);
|
|
assert_eq!(b, c);
|
|
}
|
|
|
|
struct Lcg(u64);
|
|
|
|
impl Lcg {
|
|
fn next(&mut self) -> u64 {
|
|
self.0 = self
|
|
.0
|
|
.wrapping_mul(6364136223846793005)
|
|
.wrapping_add(1442695040888963407);
|
|
self.0
|
|
}
|
|
|
|
fn next_u32(&mut self, bound: u32) -> u32 {
|
|
assert!(bound > 0);
|
|
(self.next() % bound as u64) as u32
|
|
}
|
|
}
|
|
|
|
fn random_event(rng: &mut Lcg) -> LinuxCaptureEvent {
|
|
let kind = rng.next_u32(4);
|
|
match kind {
|
|
0 => LinuxCaptureEvent::Connected,
|
|
1 => LinuxCaptureEvent::Faulted(random_fault(rng)),
|
|
2 => LinuxCaptureEvent::ReconnectAttempted,
|
|
3 => LinuxCaptureEvent::Reset,
|
|
_ => unreachable!("rng bounded to 4"),
|
|
}
|
|
}
|
|
|
|
fn random_fault(rng: &mut Lcg) -> LinuxCaptureFault {
|
|
let kind = rng.next_u32(5);
|
|
match kind {
|
|
0 => LinuxCaptureFault::StreamError(-(rng.next_u32(128) as i32)),
|
|
1 => LinuxCaptureFault::PortalSessionLost,
|
|
2 => LinuxCaptureFault::NodeRemoved,
|
|
3 => LinuxCaptureFault::PermissionRevoked,
|
|
4 => LinuxCaptureFault::BufferUnderrun,
|
|
_ => unreachable!("rng bounded to 5"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invariants_hold_across_random_transitions() {
|
|
let mut rng = Lcg(0x9E37_79B9_7F4A_7C15);
|
|
let mut state = connecting(T0);
|
|
let mut now = T0;
|
|
for _ in 0..1_000 {
|
|
now = now.wrapping_add(1);
|
|
let ev = random_event(&mut rng);
|
|
let result = transition_linux_capture_state(state, ev, now);
|
|
let next = result.expect("invariant must hold for any valid input");
|
|
state = next.0;
|
|
assert!(assert_state_invariants(&state).is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn state_machine_dispatches_through_recovery() {
|
|
use std::sync::{Arc, Mutex};
|
|
let log: Arc<Mutex<Vec<(LinuxCaptureEvent, LinuxCaptureState)>>> =
|
|
Arc::new(Mutex::new(Vec::with_capacity(8)));
|
|
let log_clone = log.clone();
|
|
let mut fsm = LinuxCaptureStateMachine::with_listener(T0, move |ev, st| {
|
|
log_clone.lock().unwrap().push((ev, *st));
|
|
});
|
|
assert!(matches!(fsm.state(), LinuxCaptureState::Connecting { .. }));
|
|
let a = fsm.dispatch(LinuxCaptureEvent::Connected, T1).unwrap();
|
|
assert_eq!(a, LinuxCaptureAction::EnterActive);
|
|
assert!(matches!(fsm.state(), LinuxCaptureState::Active { .. }));
|
|
let b = fsm
|
|
.dispatch(
|
|
LinuxCaptureEvent::Faulted(LinuxCaptureFault::PortalSessionLost),
|
|
T2,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
b,
|
|
LinuxCaptureAction::ScheduleReconnect {
|
|
attempt: 1,
|
|
backoff_ms: 100
|
|
}
|
|
);
|
|
assert!(matches!(
|
|
fsm.state(),
|
|
LinuxCaptureState::Reconnecting { .. }
|
|
));
|
|
let c = fsm.dispatch(LinuxCaptureEvent::Connected, T2 + 1).unwrap();
|
|
assert_eq!(c, LinuxCaptureAction::EnterActive);
|
|
assert!(matches!(fsm.state(), LinuxCaptureState::Active { .. }));
|
|
let events = log.lock().unwrap();
|
|
assert_eq!(events.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn state_machine_dispatches_failure_then_reset() {
|
|
let mut fsm = LinuxCaptureStateMachine::new(T0);
|
|
fsm.dispatch(LinuxCaptureEvent::Connected, T0 + 1).unwrap();
|
|
for i in 0..=MAX_RECONNECT_ATTEMPTS {
|
|
let fault = LinuxCaptureFault::StreamError(-(i as i32) - 1);
|
|
fsm.dispatch(LinuxCaptureEvent::Faulted(fault), T0 + 10 + i as u64)
|
|
.unwrap();
|
|
}
|
|
match fsm.state() {
|
|
LinuxCaptureState::Failed { total_attempts, .. } => {
|
|
assert_eq!(*total_attempts, MAX_RECONNECT_ATTEMPTS);
|
|
}
|
|
other => panic!("expected Failed, got {:?}", other),
|
|
}
|
|
let action = fsm.dispatch(LinuxCaptureEvent::Reset, T0 + 1_000).unwrap();
|
|
assert_eq!(action, LinuxCaptureAction::RestartFromFailed);
|
|
assert!(matches!(fsm.state(), LinuxCaptureState::Connecting { .. }));
|
|
}
|
|
}
|