Skip to main content

de_mls/conversation/
state_machine.rs

1//! Per-conversation state machine.
2
3use std::fmt::Display;
4
5/// Lifecycle state for a conversation session.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ConversationState {
8    /// Normal operation.
9    Working,
10    /// Members have stopped accepting new proposals; commit candidates
11    /// are buffered for deterministic selection.
12    Freezing,
13    /// Selection phase: the freeze-round candidate has been picked and
14    /// is being merged.
15    Selection,
16    /// Recovery: a steward election is in flight after a missed commit.
17    Reelection,
18}
19
20/// Authorization mode for a conversation.
21///
22/// - `Normal` is the default: only steward-list members may produce
23///   commits.
24/// - `Recovery` is set when an accepted Layer-3 Deadlock ECP relaxes the
25///   steward gate so any member may produce the next commit
26///   (RFC §Anti-Deadlock); cleared when a fresh election lands.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum OperatingMode {
29    #[default]
30    Normal,
31    Recovery,
32}
33
34impl Display for ConversationState {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            ConversationState::Working => write!(f, "Working"),
38            ConversationState::Freezing => write!(f, "Freezing"),
39            ConversationState::Selection => write!(f, "Selection"),
40            ConversationState::Reelection => write!(f, "Reelection"),
41        }
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct ConversationStateMachine {
47    state: ConversationState,
48}
49
50impl Default for ConversationStateMachine {
51    fn default() -> Self {
52        Self::new_as_member()
53    }
54}
55
56impl ConversationStateMachine {
57    /// Member starts in `Working` (creator path, or post-join).
58    pub fn new_as_member() -> Self {
59        Self {
60            state: ConversationState::Working,
61        }
62    }
63
64    pub fn current_state(&self) -> ConversationState {
65        self.state
66    }
67
68    pub fn start_working(&mut self) {
69        self.state = ConversationState::Working;
70    }
71
72    /// Transition to `Freezing`, allowed only from `Working` or
73    /// `Reelection`. Returns `true` on transition; `false` is a no-op.
74    pub fn start_freezing(&mut self) -> bool {
75        match self.state {
76            ConversationState::Working | ConversationState::Reelection => {
77                self.state = ConversationState::Freezing;
78                true
79            }
80            _ => false,
81        }
82    }
83
84    pub fn start_selection(&mut self) {
85        self.state = ConversationState::Selection;
86    }
87
88    pub fn start_reelection(&mut self) {
89        self.state = ConversationState::Reelection;
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn new_as_member_starts_working() {
99        let sm = ConversationStateMachine::new_as_member();
100        assert_eq!(sm.current_state(), ConversationState::Working);
101    }
102
103    #[test]
104    fn named_transitions_set_state() {
105        let mut sm = ConversationStateMachine::new_as_member();
106        assert!(sm.start_freezing());
107        assert_eq!(sm.current_state(), ConversationState::Freezing);
108        sm.start_selection();
109        assert_eq!(sm.current_state(), ConversationState::Selection);
110        sm.start_reelection();
111        assert_eq!(sm.current_state(), ConversationState::Reelection);
112        sm.start_working();
113        assert_eq!(sm.current_state(), ConversationState::Working);
114    }
115
116    #[test]
117    fn start_freezing_from_working_transitions() {
118        let mut sm = ConversationStateMachine::new_as_member();
119        assert!(sm.start_freezing());
120        assert_eq!(sm.current_state(), ConversationState::Freezing);
121    }
122
123    #[test]
124    fn start_freezing_from_reelection_transitions() {
125        let mut sm = ConversationStateMachine::new_as_member();
126        sm.start_reelection();
127        assert!(sm.start_freezing());
128        assert_eq!(sm.current_state(), ConversationState::Freezing);
129    }
130
131    /// `start_freezing` is a no-op outside `Working`/`Reelection`.
132    #[test]
133    fn start_freezing_noop_outside_working_reelection() {
134        for setup in [
135            |sm: &mut ConversationStateMachine| {
136                sm.start_freezing();
137            },
138            |sm: &mut ConversationStateMachine| sm.start_selection(),
139        ] {
140            let mut sm = ConversationStateMachine::new_as_member();
141            setup(&mut sm);
142            let before = sm.current_state();
143            assert!(!sm.start_freezing());
144            assert_eq!(sm.current_state(), before);
145        }
146    }
147}