//! Deterministic channel-join pending-state reducer. //! //! This module implements SDD-121 Phase A for the `chanora_state` crate: //! Rust-owned authoritative current-channel membership, non-authoritative //! pending join state, and reconnect/snapshot reconciliation. use std::time::Instant; /// Channel identifier at the reducer seam. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ChannelId(pub u64); /// Per-connection epoch used to reject stale outcomes and deltas. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ConnectionEpoch(pub u64); /// Monotonic join/leave generation within reducer state. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct JoinGeneration(pub u64); /// Protocol request identifier unique within one connection epoch. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct JoinRequestId(pub u64); /// Server-confirmed voice-channel membership. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AuthoritativeMembership { /// Last server-authoritative current channel, or none if not in voice. pub current_channel: Option, /// Epoch that produced the confirmed membership value. pub confirmed_epoch: ConnectionEpoch, } /// Active non-authoritative join intent awaiting server confirmation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct JoinPending { /// Requested target channel. pub target_channel: ChannelId, /// Previous confirmed channel to preserve on failure/timeout. pub previous_confirmed_channel: Option, /// Protocol request id after command-send acceptance. pub request_id: Option, /// Reducer generation for this intent. pub generation: JoinGeneration, /// Deterministic start time supplied by the caller. pub started_at: Instant, /// Connection epoch in which the intent was created. pub connection_epoch: ConnectionEpoch, } /// Reducer-owned channel-join state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChannelJoinState { /// Server-authoritative membership. pub authoritative: AuthoritativeMembership, /// Active pending join, if any. pub pending: Option, /// Current snapshot/reconnect synchronization state. pub sync_state: ChannelJoinSyncState, /// Next generation to allocate. pub next_generation: JoinGeneration, /// Last sanitized join error for projection consumers. pub last_join_error: Option, } impl ChannelJoinState { /// Create channel-join reducer state for a connection epoch. pub fn new(epoch: ConnectionEpoch) -> Self { Self { authoritative: AuthoritativeMembership { current_channel: None, confirmed_epoch: epoch, }, pending: None, sync_state: ChannelJoinSyncState::Ready, next_generation: JoinGeneration(1), last_join_error: None, } } fn allocate_generation(&mut self) -> JoinGeneration { let generation = self.next_generation; self.next_generation = JoinGeneration(self.next_generation.0.saturating_add(1)); generation } fn bump_generation(&mut self) { self.next_generation = JoinGeneration(self.next_generation.0.saturating_add(1)); } fn current_epoch(&self) -> ConnectionEpoch { match self.sync_state { ChannelJoinSyncState::Ready => self.authoritative.confirmed_epoch, ChannelJoinSyncState::Synchronizing { epoch, .. } => epoch, } } } /// State-sync readiness for channel actions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelJoinSyncState { /// Reducer is ready to accept channel actions. Ready, /// Reducer is waiting for a snapshot boundary. Synchronizing { /// Reason for synchronization. reason: SyncReason, /// Epoch being synchronized. epoch: ConnectionEpoch, }, } /// Reason channel-join state is synchronizing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncReason { /// Initial snapshot is in progress. InitialSnapshot, /// Reconnect snapshot is in progress. Reconnect, } /// Correlation key for protocol outcomes and timeout callbacks. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct JoinOutcomeKey { /// Connection epoch of the pending join. pub connection_epoch: ConnectionEpoch, /// Generation of the pending join. pub generation: JoinGeneration, /// Protocol request id, once accepted for send. pub request_id: Option, } /// Reducer projection consumed by core/bridge mapping. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChannelJoinProjection { /// Last confirmed current channel. pub current_channel: Option, /// Non-authoritative pending target channel. pub pending_target: Option, /// Start time of the active pending join. pub pending_since: Option, /// Generation of the active pending join. pub pending_generation: Option, /// Whether a new join can be initiated. pub can_join: bool, /// Whether leave is possible for current/pending state. pub can_leave: bool, /// Current sync state. pub sync_state: ChannelJoinSyncState, /// Last sanitized join error. pub last_join_error: Option, } /// Source of authoritative membership input. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthoritativeSource { /// Live protocol delta. LiveDelta, /// Snapshot input. Snapshot, } /// Events accepted by the channel-join reducer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelJoinEvent { /// User requested a channel join. UserJoinRequested { /// Requested target channel. target_channel: ChannelId, /// Caller-supplied deterministic request time. now: Instant, }, /// Protocol command send accepted and produced a request id. JoinCommandAccepted { /// Pending generation accepted by the command path. generation: JoinGeneration, /// Protocol request id for future outcomes. request_id: JoinRequestId, }, /// Protocol command failed before a request id existed. JoinCommandRejectedBeforeSend { /// Pending generation rejected by the command path. generation: JoinGeneration, /// Sanitized failure kind. error: JoinFailureKind, }, /// Protocol command reported success; not authoritative membership. ProtocolJoinSucceeded { /// Outcome correlation key. key: JoinOutcomeKey, }, /// Protocol command reported failure. ProtocolJoinFailed { /// Outcome correlation key. key: JoinOutcomeKey, /// Sanitized failure kind. failure: JoinFailureKind, }, /// Join command timed out. JoinTimeout { /// Outcome correlation key. key: JoinOutcomeKey, /// Caller-supplied deterministic timeout time. now: Instant, }, /// Server-authoritative self membership changed. AuthoritativeSelfMove { /// Server-authoritative channel value. channel: Option, /// Epoch of the live delta or snapshot source. epoch: ConnectionEpoch, /// Authoritative input source. source: AuthoritativeSource, }, /// User requested leave. UserLeaveRequested { /// Caller-supplied deterministic leave time. now: Instant, }, /// Reconnect started. ReconnectStarted { /// New connection epoch. new_epoch: ConnectionEpoch, /// Caller-supplied deterministic reconnect time. now: Instant, }, /// Fresh snapshot is ready. SnapshotReady { /// Snapshot current channel value. current_channel: Option, /// Snapshot epoch. epoch: ConnectionEpoch, /// Caller-supplied deterministic snapshot time. now: Instant, }, } /// Side-effect actions returned by the reducer. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChannelJoinAction { /// Send a protocol join command. SendJoinCommand { /// Requested target channel. target_channel: ChannelId, /// Generation associated with the command. generation: JoinGeneration, /// Connection epoch associated with the command. epoch: ConnectionEpoch, }, /// Start a deterministic join timeout. StartJoinTimeout { /// Outcome key the timeout will report. key: JoinOutcomeKey, /// Timeout start time. started_at: Instant, }, /// Cancel a deterministic join timeout. CancelJoinTimeout { /// Outcome key to cancel. key: JoinOutcomeKey, }, /// Send a protocol leave command. SendLeaveCommand { /// Current authoritative channel at leave request time. current_channel: Option, /// Generation associated with the leave command. generation: JoinGeneration, /// Connection epoch associated with the leave command. epoch: ConnectionEpoch, }, /// Publish the latest projection. PublishProjection(ChannelJoinProjection), /// Emit a sanitized diagnostic. EmitDiagnostic { /// Sanitized diagnostic key. key: JoinDiagnosticKey, /// Optional stable error code. code: Option, }, } /// Sanitized diagnostic event key. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JoinDiagnosticKey { /// Join was requested. JoinRequested, /// Join command was accepted for send. JoinCommandSent, /// Duplicate target was coalesced. JoinDuplicateCoalesced, /// Join was confirmed authoritatively. JoinConfirmed, /// Join failed. JoinFailed, /// Join timed out. JoinTimeout, /// Stale outcome ignored. JoinStaleOutcomeIgnored, /// Pending join superseded by leave. JoinPendingSupersededByLeave, /// Reconnect synchronization started. JoinReconnectSynchronizing, /// Snapshot reconciled membership. JoinSnapshotReconciled, } /// Status for a reducer transition. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JoinReduceStatus { /// Event was accepted. Accepted, /// Duplicate same-target join was coalesced. CoalescedSameTarget, /// Intent was rejected. Rejected(JoinIntentRejected), /// Stale outcome was ignored. StaleOutcomeIgnored, /// Join was authoritatively confirmed. Confirmed, /// Join failed. Failed(JoinFailureKind), /// Join timed out. TimedOut, /// Join was superseded by leave. SupersededByLeave, /// State was reconciled by snapshot. ReconciledBySnapshot, } /// User intent rejection reason. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JoinIntentRejected { /// A different target is already pending. JoinAlreadyPendingDifferentTarget, /// Joins cannot start while synchronizing. CannotJoinWhileSynchronizing, } /// Coarse failure kind without raw protocol payloads. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JoinFailureKind { /// Server denied the join. Denied, /// Network failure. Network, /// Protocol failure. Protocol, /// Timeout. Timeout, /// Unknown sanitized failure. Unknown, } /// Stable sanitized error code consumed by bridge/UI mapping. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JoinErrorCode { /// Duplicate same target was coalesced. DuplicateSameTargetCoalesced, /// Different target requested while pending. JoinAlreadyPendingDifferentTarget, /// Join was denied. JoinDenied, /// Protocol failure. JoinProtocolFailure, /// Network failure. JoinNetworkFailure, /// Join timed out. JoinTimeout, /// Join superseded by leave. JoinSupersededByLeave, /// Stale outcome ignored. JoinStaleOutcomeIgnored, /// Snapshot/live delta reconciled to a different channel. JoinReconciledDifferentChannel, /// Command rejected before send. JoinCommandRejectedBeforeSend, /// Cannot start while synchronizing. JoinCannotStartWhileSynchronizing, } /// Complete reducer result. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChannelJoinReduction { /// Transition status. pub status: JoinReduceStatus, /// Projection after reduction. pub projection: ChannelJoinProjection, /// Side effects for the owning runtime. pub actions: Vec, } /// Reduce one channel-join event into state and side-effect actions. pub fn reduce(state: &mut ChannelJoinState, event: ChannelJoinEvent) -> ChannelJoinReduction { let mut actions = Vec::new(); let status = match event { ChannelJoinEvent::UserJoinRequested { target_channel, now, } => { if matches!(state.sync_state, ChannelJoinSyncState::Synchronizing { .. }) { state.last_join_error = Some(JoinErrorCode::JoinCannotStartWhileSynchronizing); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinFailed, code: state.last_join_error, }); JoinReduceStatus::Rejected(JoinIntentRejected::CannotJoinWhileSynchronizing) } else if let Some(pending) = state.pending { if pending.target_channel == target_channel { state.last_join_error = Some(JoinErrorCode::DuplicateSameTargetCoalesced); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinDuplicateCoalesced, code: state.last_join_error, }); JoinReduceStatus::CoalescedSameTarget } else { state.last_join_error = Some(JoinErrorCode::JoinAlreadyPendingDifferentTarget); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinFailed, code: state.last_join_error, }); JoinReduceStatus::Rejected( JoinIntentRejected::JoinAlreadyPendingDifferentTarget, ) } } else { let generation = state.allocate_generation(); let epoch = state.current_epoch(); state.pending = Some(JoinPending { target_channel, previous_confirmed_channel: state.authoritative.current_channel, request_id: None, generation, started_at: now, connection_epoch: epoch, }); state.last_join_error = None; let key = JoinOutcomeKey { connection_epoch: epoch, generation, request_id: None, }; actions.push(ChannelJoinAction::SendJoinCommand { target_channel, generation, epoch, }); actions.push(ChannelJoinAction::StartJoinTimeout { key, started_at: now, }); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinRequested, code: None, }); JoinReduceStatus::Accepted } } ChannelJoinEvent::JoinCommandAccepted { generation, request_id, } => { if let Some(pending) = state.pending.as_mut() { if pending.generation == generation { let old_key = pending_key(pending); pending.request_id = Some(request_id); let new_key = pending_key(pending); actions.push(ChannelJoinAction::CancelJoinTimeout { key: old_key }); actions.push(ChannelJoinAction::StartJoinTimeout { key: new_key, started_at: pending.started_at, }); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinCommandSent, code: None, }); JoinReduceStatus::Accepted } else { stale(state, &mut actions) } } else { stale(state, &mut actions) } } ChannelJoinEvent::JoinCommandRejectedBeforeSend { generation, error } => { if let Some(pending) = state.pending { if pending.generation == generation { let key = pending_key(&pending); state.authoritative.current_channel = pending.previous_confirmed_channel; state.pending = None; state.last_join_error = Some(JoinErrorCode::JoinCommandRejectedBeforeSend); actions.push(ChannelJoinAction::CancelJoinTimeout { key }); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinFailed, code: state.last_join_error, }); JoinReduceStatus::Failed(error) } else { stale(state, &mut actions) } } else { stale(state, &mut actions) } } ChannelJoinEvent::ProtocolJoinSucceeded { key } => { if key_matches(state.pending, key) { JoinReduceStatus::Accepted } else { stale(state, &mut actions) } } ChannelJoinEvent::ProtocolJoinFailed { key, failure } => { if key_matches(state.pending, key) { let pending = state.pending.expect("key match requires pending"); state.authoritative.current_channel = pending.previous_confirmed_channel; state.pending = None; state.last_join_error = Some(failure_error_code(failure)); actions.push(ChannelJoinAction::CancelJoinTimeout { key }); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinFailed, code: state.last_join_error, }); JoinReduceStatus::Failed(failure) } else { stale(state, &mut actions) } } ChannelJoinEvent::JoinTimeout { key, now: _ } => { if key_matches(state.pending, key) { let pending = state.pending.expect("key match requires pending"); state.authoritative.current_channel = pending.previous_confirmed_channel; state.pending = None; state.last_join_error = Some(JoinErrorCode::JoinTimeout); actions.push(ChannelJoinAction::CancelJoinTimeout { key }); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinTimeout, code: state.last_join_error, }); JoinReduceStatus::TimedOut } else { stale(state, &mut actions) } } ChannelJoinEvent::AuthoritativeSelfMove { channel, epoch, source: _, } => { if epoch != state.current_epoch() { stale(state, &mut actions) } else { state.authoritative.current_channel = channel; state.authoritative.confirmed_epoch = epoch; if let Some(pending) = state.pending { let key = pending_key(&pending); state.pending = None; actions.push(ChannelJoinAction::CancelJoinTimeout { key }); if channel == Some(pending.target_channel) { state.last_join_error = None; actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinConfirmed, code: None, }); JoinReduceStatus::Confirmed } else { state.last_join_error = Some(JoinErrorCode::JoinReconciledDifferentChannel); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinSnapshotReconciled, code: state.last_join_error, }); JoinReduceStatus::ReconciledBySnapshot } } else { JoinReduceStatus::Accepted } } } ChannelJoinEvent::UserLeaveRequested { now: _ } => { let generation = state.allocate_generation(); let epoch = state.current_epoch(); if let Some(pending) = state.pending { let key = pending_key(&pending); state.pending = None; state.last_join_error = Some(JoinErrorCode::JoinSupersededByLeave); actions.push(ChannelJoinAction::CancelJoinTimeout { key }); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinPendingSupersededByLeave, code: state.last_join_error, }); if state.authoritative.current_channel.is_some() { actions.push(ChannelJoinAction::SendLeaveCommand { current_channel: state.authoritative.current_channel, generation, epoch, }); } JoinReduceStatus::SupersededByLeave } else { if state.authoritative.current_channel.is_some() { actions.push(ChannelJoinAction::SendLeaveCommand { current_channel: state.authoritative.current_channel, generation, epoch, }); } JoinReduceStatus::Accepted } } ChannelJoinEvent::ReconnectStarted { new_epoch, now: _ } => { if let Some(pending) = state.pending { actions.push(ChannelJoinAction::CancelJoinTimeout { key: pending_key(&pending), }); } state.pending = None; state.sync_state = ChannelJoinSyncState::Synchronizing { reason: SyncReason::Reconnect, epoch: new_epoch, }; state.authoritative.confirmed_epoch = new_epoch; state.bump_generation(); state.last_join_error = None; actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinReconnectSynchronizing, code: None, }); JoinReduceStatus::Accepted } ChannelJoinEvent::SnapshotReady { current_channel, epoch, now: _, } => { if epoch != state.current_epoch() { stale(state, &mut actions) } else { let pending = state.pending; if let Some(pending) = pending { actions.push(ChannelJoinAction::CancelJoinTimeout { key: pending_key(&pending), }); } state.authoritative.current_channel = current_channel; state.authoritative.confirmed_epoch = epoch; state.pending = None; state.sync_state = ChannelJoinSyncState::Ready; state.bump_generation(); state.last_join_error = match pending { Some(pending) if current_channel != Some(pending.target_channel) => { Some(JoinErrorCode::JoinReconciledDifferentChannel) } _ => None, }; actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinSnapshotReconciled, code: state.last_join_error, }); if pending.is_some() && state.last_join_error.is_none() { JoinReduceStatus::Confirmed } else { JoinReduceStatus::ReconciledBySnapshot } } } }; finish(status, state, actions) } /// Build the channel-join projection for the current reducer state. pub fn project(state: &ChannelJoinState) -> ChannelJoinProjection { let pending = state.pending; let synchronizing = matches!(state.sync_state, ChannelJoinSyncState::Synchronizing { .. }); ChannelJoinProjection { current_channel: state.authoritative.current_channel, pending_target: pending.map(|pending| pending.target_channel), pending_since: pending.map(|pending| pending.started_at), pending_generation: pending.map(|pending| pending.generation), can_join: !synchronizing && pending.is_none(), can_leave: !synchronizing && (state.authoritative.current_channel.is_some() || pending.is_some()), sync_state: state.sync_state, last_join_error: state.last_join_error, } } fn finish( status: JoinReduceStatus, state: &ChannelJoinState, mut actions: Vec, ) -> ChannelJoinReduction { let projection = project(state); actions.push(ChannelJoinAction::PublishProjection(projection.clone())); ChannelJoinReduction { status, projection, actions, } } fn pending_key(pending: &JoinPending) -> JoinOutcomeKey { JoinOutcomeKey { connection_epoch: pending.connection_epoch, generation: pending.generation, request_id: pending.request_id, } } fn key_matches(pending: Option, key: JoinOutcomeKey) -> bool { pending.is_some_and(|pending| pending_key(&pending) == key) } fn stale(state: &mut ChannelJoinState, actions: &mut Vec) -> JoinReduceStatus { state.last_join_error = Some(JoinErrorCode::JoinStaleOutcomeIgnored); actions.push(ChannelJoinAction::EmitDiagnostic { key: JoinDiagnosticKey::JoinStaleOutcomeIgnored, code: state.last_join_error, }); JoinReduceStatus::StaleOutcomeIgnored } fn failure_error_code(failure: JoinFailureKind) -> JoinErrorCode { match failure { JoinFailureKind::Denied => JoinErrorCode::JoinDenied, JoinFailureKind::Network => JoinErrorCode::JoinNetworkFailure, JoinFailureKind::Protocol => JoinErrorCode::JoinProtocolFailure, JoinFailureKind::Timeout => JoinErrorCode::JoinTimeout, JoinFailureKind::Unknown => JoinErrorCode::JoinProtocolFailure, } } #[cfg(test)] mod tests { use super::*; fn epoch(value: u64) -> ConnectionEpoch { ConnectionEpoch(value) } fn channel(value: u64) -> ChannelId { ChannelId(value) } fn request(value: u64) -> JoinRequestId { JoinRequestId(value) } fn now(offset: u64) -> Instant { Instant::now() + std::time::Duration::from_millis(offset) } fn accepted_pending(state: &mut ChannelJoinState, target: ChannelId) -> JoinOutcomeKey { let start = now(1); let reduction = reduce( state, ChannelJoinEvent::UserJoinRequested { target_channel: target, now: start, }, ); let generation = reduction .projection .pending_generation .expect("pending generation"); let request_id = request(7); reduce( state, ChannelJoinEvent::JoinCommandAccepted { generation, request_id, }, ); JoinOutcomeKey { connection_epoch: epoch(1), generation, request_id: Some(request_id), } } #[test] fn user_join_creates_pending_and_send_start_timeout_actions() { let mut state = ChannelJoinState::new(epoch(1)); let start = now(1); let reduction = reduce( &mut state, ChannelJoinEvent::UserJoinRequested { target_channel: channel(10), now: start, }, ); assert_eq!(reduction.status, JoinReduceStatus::Accepted); assert_eq!(reduction.projection.pending_target, Some(channel(10))); assert!(matches!( reduction.actions[0], ChannelJoinAction::SendJoinCommand { target_channel: ChannelId(10), generation: JoinGeneration(1), epoch: ConnectionEpoch(1) } )); assert!( matches!(reduction.actions[1], ChannelJoinAction::StartJoinTimeout { key: JoinOutcomeKey { connection_epoch: ConnectionEpoch(1), generation: JoinGeneration(1), request_id: None }, started_at } if started_at == start) ); } #[test] fn duplicate_same_target_coalesces() { let mut state = ChannelJoinState::new(epoch(1)); reduce( &mut state, ChannelJoinEvent::UserJoinRequested { target_channel: channel(10), now: now(1), }, ); let reduction = reduce( &mut state, ChannelJoinEvent::UserJoinRequested { target_channel: channel(10), now: now(2), }, ); assert_eq!(reduction.status, JoinReduceStatus::CoalescedSameTarget); assert_eq!( reduction.projection.pending_generation, Some(JoinGeneration(1)) ); assert!(!reduction.actions.iter().any(|action| matches!( action, ChannelJoinAction::SendJoinCommand { .. } | ChannelJoinAction::StartJoinTimeout { .. } ))); } #[test] fn different_target_while_pending_rejects_and_serializes() { let mut state = ChannelJoinState::new(epoch(1)); reduce( &mut state, ChannelJoinEvent::UserJoinRequested { target_channel: channel(10), now: now(1), }, ); let reduction = reduce( &mut state, ChannelJoinEvent::UserJoinRequested { target_channel: channel(11), now: now(2), }, ); assert_eq!( reduction.status, JoinReduceStatus::Rejected(JoinIntentRejected::JoinAlreadyPendingDifferentTarget) ); assert_eq!(reduction.projection.pending_target, Some(channel(10))); assert_eq!( reduction.projection.last_join_error, Some(JoinErrorCode::JoinAlreadyPendingDifferentTarget) ); assert!(!reduction .actions .iter() .any(|action| matches!(action, ChannelJoinAction::SendJoinCommand { .. }))); } #[test] fn authoritative_current_channel_not_changed_until_self_move_or_snapshot() { let mut state = ChannelJoinState::new(epoch(1)); state.authoritative.current_channel = Some(channel(1)); let key = accepted_pending(&mut state, channel(2)); let success = reduce(&mut state, ChannelJoinEvent::ProtocolJoinSucceeded { key }); assert_eq!(success.projection.current_channel, Some(channel(1))); assert_eq!(success.projection.pending_target, Some(channel(2))); let confirmed = reduce( &mut state, ChannelJoinEvent::AuthoritativeSelfMove { channel: Some(channel(2)), epoch: epoch(1), source: AuthoritativeSource::LiveDelta, }, ); assert_eq!(confirmed.status, JoinReduceStatus::Confirmed); assert_eq!(confirmed.projection.current_channel, Some(channel(2))); assert_eq!(confirmed.projection.pending_target, None); } #[test] fn failure_and_timeout_clear_pending_and_preserve_previous_authoritative_channel() { let mut failed = ChannelJoinState::new(epoch(1)); failed.authoritative.current_channel = Some(channel(1)); let fail_key = accepted_pending(&mut failed, channel(2)); let fail_reduction = reduce( &mut failed, ChannelJoinEvent::ProtocolJoinFailed { key: fail_key, failure: JoinFailureKind::Denied, }, ); assert_eq!( fail_reduction.status, JoinReduceStatus::Failed(JoinFailureKind::Denied) ); assert_eq!(fail_reduction.projection.current_channel, Some(channel(1))); assert_eq!(fail_reduction.projection.pending_target, None); let mut timed_out = ChannelJoinState::new(epoch(1)); timed_out.authoritative.current_channel = Some(channel(1)); let timeout_key = accepted_pending(&mut timed_out, channel(2)); let timeout_reduction = reduce( &mut timed_out, ChannelJoinEvent::JoinTimeout { key: timeout_key, now: now(9), }, ); assert_eq!(timeout_reduction.status, JoinReduceStatus::TimedOut); assert_eq!( timeout_reduction.projection.current_channel, Some(channel(1)) ); assert_eq!(timeout_reduction.projection.pending_target, None); } #[test] fn stale_outcomes_ignored_by_epoch_generation_or_request_mismatch() { let mut state = ChannelJoinState::new(epoch(1)); state.authoritative.current_channel = Some(channel(1)); let key = accepted_pending(&mut state, channel(2)); for stale_key in [ JoinOutcomeKey { connection_epoch: epoch(2), ..key }, JoinOutcomeKey { generation: JoinGeneration(99), ..key }, JoinOutcomeKey { request_id: Some(request(99)), ..key }, ] { let reduction = reduce( &mut state, ChannelJoinEvent::ProtocolJoinFailed { key: stale_key, failure: JoinFailureKind::Network, }, ); assert_eq!(reduction.status, JoinReduceStatus::StaleOutcomeIgnored); assert_eq!(reduction.projection.current_channel, Some(channel(1))); assert_eq!(reduction.projection.pending_target, Some(channel(2))); } } #[test] fn leave_supersedes_pending_and_emits_leave_action_if_current_exists() { let mut state = ChannelJoinState::new(epoch(1)); state.authoritative.current_channel = Some(channel(1)); let key = accepted_pending(&mut state, channel(2)); let reduction = reduce( &mut state, ChannelJoinEvent::UserLeaveRequested { now: now(3) }, ); assert_eq!(reduction.status, JoinReduceStatus::SupersededByLeave); assert_eq!(reduction.projection.current_channel, Some(channel(1))); assert_eq!(reduction.projection.pending_target, None); assert!(reduction.actions.iter().any(|action| matches!( action, ChannelJoinAction::SendLeaveCommand { current_channel: Some(ChannelId(1)), generation: JoinGeneration(2), epoch: ConnectionEpoch(1) } ))); let stale_success = reduce(&mut state, ChannelJoinEvent::ProtocolJoinSucceeded { key }); assert_eq!(stale_success.status, JoinReduceStatus::StaleOutcomeIgnored); assert_eq!(stale_success.projection.current_channel, Some(channel(1))); } #[test] fn reconnect_clears_stales_pending_and_updates_sync_epoch() { let mut state = ChannelJoinState::new(epoch(1)); state.authoritative.current_channel = Some(channel(1)); let key = accepted_pending(&mut state, channel(2)); let reconnect = reduce( &mut state, ChannelJoinEvent::ReconnectStarted { new_epoch: epoch(2), now: now(4), }, ); assert_eq!(reconnect.projection.pending_target, None); assert_eq!( reconnect.projection.sync_state, ChannelJoinSyncState::Synchronizing { reason: SyncReason::Reconnect, epoch: epoch(2) } ); assert!(!reconnect.projection.can_join); let stale_failure = reduce( &mut state, ChannelJoinEvent::ProtocolJoinFailed { key, failure: JoinFailureKind::Network, }, ); assert_eq!(stale_failure.status, JoinReduceStatus::StaleOutcomeIgnored); } #[test] fn snapshot_replaces_authoritative_membership_and_clears_or_reconciles_pending() { let mut confirmed = ChannelJoinState::new(epoch(1)); accepted_pending(&mut confirmed, channel(2)); let confirm = reduce( &mut confirmed, ChannelJoinEvent::SnapshotReady { current_channel: Some(channel(2)), epoch: epoch(1), now: now(5), }, ); assert_eq!(confirm.status, JoinReduceStatus::Confirmed); assert_eq!(confirm.projection.current_channel, Some(channel(2))); assert_eq!(confirm.projection.pending_target, None); let mut reconciled = ChannelJoinState::new(epoch(1)); accepted_pending(&mut reconciled, channel(2)); let reconcile = reduce( &mut reconciled, ChannelJoinEvent::SnapshotReady { current_channel: Some(channel(3)), epoch: epoch(1), now: now(6), }, ); assert_eq!(reconcile.status, JoinReduceStatus::ReconciledBySnapshot); assert_eq!(reconcile.projection.current_channel, Some(channel(3))); assert_eq!(reconcile.projection.pending_target, None); assert_eq!( reconcile.projection.last_join_error, Some(JoinErrorCode::JoinReconciledDifferentChannel) ); } }