Skip to main content

de_mls/core/conversation/
state_machine.rs

1//! Per-conversation state machine.
2//!
3//! Holds the [`ConversationState`] enum and exposes named transition methods.
4//! The app layer wraps this with a timer-driven controller — see
5//! [`crate::app::PhaseTimer`].
6
7use std::fmt::Display;
8
9/// The lifecycle state of a per-conversation session. Transitions are driven
10/// by the app layer through the named methods on [`ConversationStateMachine`];
11/// timing rules live in [`crate::app::PhaseTimer`].
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ConversationState {
14    /// Joiner waiting for a welcome.
15    PendingJoin,
16    /// Normal operation: members vote, the steward batches and commits.
17    Working,
18    /// Members have stopped accepting new proposals; commit candidates
19    /// are buffered for deterministic selection.
20    Freezing,
21    /// Selection phase: the freeze-round candidate has been picked and
22    /// is being merged.
23    Selection,
24    /// Recovery: a steward election is in flight after a missed commit.
25    Reelection,
26}
27
28/// Authorization mode for a conversation, orthogonal to [`ConversationState`].
29///
30/// `Normal` is the default: only steward-list members may produce
31/// commits. `Recovery` is set when an accepted Layer-3 Deadlock ECP
32/// relaxes the steward gate so any member may produce the next commit
33/// (RFC §Anti-Deadlock); cleared when a fresh election lands. Lives
34/// alongside `ConversationState` because it gates *who can act*, not *what
35/// phase the round is in*.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum OperatingMode {
38    #[default]
39    Normal,
40    Recovery,
41}
42
43impl Display for ConversationState {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            ConversationState::PendingJoin => write!(f, "PendingJoin"),
47            ConversationState::Working => write!(f, "Working"),
48            ConversationState::Freezing => write!(f, "Freezing"),
49            ConversationState::Selection => write!(f, "Selection"),
50            ConversationState::Reelection => write!(f, "Reelection"),
51        }
52    }
53}
54
55#[derive(Debug, Clone)]
56pub struct ConversationStateMachine {
57    state: ConversationState,
58}
59
60impl Default for ConversationStateMachine {
61    fn default() -> Self {
62        Self::new_as_member()
63    }
64}
65
66impl ConversationStateMachine {
67    /// Member starts in `Working` (creator path, or post-join).
68    pub fn new_as_member() -> Self {
69        Self {
70            state: ConversationState::Working,
71        }
72    }
73
74    /// Joiner starts in `PendingJoin` until the welcome arrives.
75    pub fn new_as_pending_join() -> Self {
76        Self {
77            state: ConversationState::PendingJoin,
78        }
79    }
80
81    pub fn current_state(&self) -> ConversationState {
82        self.state
83    }
84
85    pub fn start_working(&mut self) {
86        self.state = ConversationState::Working;
87    }
88
89    pub fn start_freezing(&mut self) {
90        self.state = ConversationState::Freezing;
91    }
92
93    /// Transition to `Freezing` only from `Working` or `Reelection`
94    /// (RFC: bypass the inactivity timer for ECP-driven freezes).
95    /// Returns `true` on actual transition; `false` is a no-op.
96    pub fn force_freezing(&mut self) -> bool {
97        match self.state {
98            ConversationState::Working | ConversationState::Reelection => {
99                self.state = ConversationState::Freezing;
100                true
101            }
102            _ => false,
103        }
104    }
105
106    pub fn start_selection(&mut self) {
107        self.state = ConversationState::Selection;
108    }
109
110    pub fn start_reelection(&mut self) {
111        self.state = ConversationState::Reelection;
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn new_as_member_starts_working() {
121        let sm = ConversationStateMachine::new_as_member();
122        assert_eq!(sm.current_state(), ConversationState::Working);
123    }
124
125    #[test]
126    fn new_as_pending_join_starts_pending() {
127        let sm = ConversationStateMachine::new_as_pending_join();
128        assert_eq!(sm.current_state(), ConversationState::PendingJoin);
129    }
130
131    #[test]
132    fn named_transitions_set_state() {
133        let mut sm = ConversationStateMachine::new_as_member();
134        sm.start_freezing();
135        assert_eq!(sm.current_state(), ConversationState::Freezing);
136        sm.start_selection();
137        assert_eq!(sm.current_state(), ConversationState::Selection);
138        sm.start_reelection();
139        assert_eq!(sm.current_state(), ConversationState::Reelection);
140        sm.start_working();
141        assert_eq!(sm.current_state(), ConversationState::Working);
142    }
143
144    #[test]
145    fn force_freezing_from_working_transitions() {
146        let mut sm = ConversationStateMachine::new_as_member();
147        assert!(sm.force_freezing());
148        assert_eq!(sm.current_state(), ConversationState::Freezing);
149    }
150
151    #[test]
152    fn force_freezing_from_reelection_transitions() {
153        let mut sm = ConversationStateMachine::new_as_member();
154        sm.start_reelection();
155        assert!(sm.force_freezing());
156        assert_eq!(sm.current_state(), ConversationState::Freezing);
157    }
158
159    /// `force_freezing` is a no-op outside `Working`/`Reelection`.
160    #[test]
161    fn force_freezing_noop_outside_working_reelection() {
162        for setup in [
163            |sm: &mut ConversationStateMachine| sm.start_freezing(),
164            |sm: &mut ConversationStateMachine| sm.start_selection(),
165        ] {
166            let mut sm = ConversationStateMachine::new_as_member();
167            setup(&mut sm);
168            let before = sm.current_state();
169            assert!(!sm.force_freezing());
170            assert_eq!(sm.current_state(), before);
171        }
172    }
173}