1use std::error::Error as StdError;
9use std::{
10 collections::HashMap,
11 sync::{Arc, Mutex},
12 time::{Duration, Instant},
13};
14
15use openmls_traits::storage::StorageProvider;
16use openmls_traits::{OpenMlsProvider, signatures::Signer};
17use prost::Message;
18use tracing::info;
19
20use hashgraph_like_consensus::events::ConsensusEventBus;
21
22use crate::{
23 BufferedCommitCandidate, ConsensusPlugin, ConsensusServiceFor, ConversationConfig,
24 ConversationError, ConversationEvent, ConversationQueues, ConversationState,
25 ConversationStateMachine, FreezeBufferOutcome, FreezeFinalizeResult, OperatingMode, Outbound,
26 PeerScoringPlugin, PhaseTimer, ProcessResult, ProposalKind, StewardListPlugin,
27 compute_commit_hash, decode_inbound_payload, finalize_freeze_round, member_set,
28 mls_crypto::{
29 CommitCandidate as MlsCommitCandidate, KeyPackageBytes, MlsCommitInput, MlsService,
30 OpenMlsService,
31 },
32 protos::de_mls::messages::v1::{
33 AppMessage, CommitCandidate, conversation_update_request::Payload,
34 },
35 replay_early_candidates,
36};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum LeaveOutcome {
41 LeaveInitiated,
44}
45
46pub(crate) type ConsensusReceiver<C> = <<C as ConsensusPlugin>::EventBus as ConsensusEventBus<
50 <C as ConsensusPlugin>::Scope,
51>>::Receiver;
52
53#[derive(Debug, Clone, Copy)]
58pub struct AutoVoteEntry {
59 pub fire_at: Instant,
60 pub vote: bool,
61}
62
63pub(crate) struct ConversationServices<C: ConsensusPlugin, Sc, St> {
68 pub(crate) mls: OpenMlsService,
72 pub(crate) scoring: Sc,
74 pub(crate) steward_list: St,
76 pub(crate) consensus: ConsensusServiceFor<C>,
80 pub(crate) consensus_rx: ConsensusReceiver<C>,
85}
86
87pub(crate) struct Timing {
89 pub(crate) phase_timer: PhaseTimer,
91 pub(crate) pending_auto_votes: HashMap<u32, AutoVoteEntry>,
96 pub(crate) pending_consensus_timeouts: HashMap<u32, Instant>,
101 pub(crate) last_freeze_progress: Option<(usize, usize)>,
107 pub(crate) buffered_propose_anchor: Option<Instant>,
114}
115
116impl Timing {
117 fn new() -> Self {
120 Self {
121 phase_timer: PhaseTimer::new(),
122 pending_auto_votes: HashMap::new(),
123 pending_consensus_timeouts: HashMap::new(),
124 last_freeze_progress: None,
125 buffered_propose_anchor: None,
126 }
127 }
128}
129
130pub struct Conversation<C: ConsensusPlugin, Sc: PeerScoringPlugin, St: StewardListPlugin> {
131 pub(crate) conversation_id: String,
135 pub(crate) queues: ConversationQueues,
136 pub(crate) services: ConversationServices<C, Sc, St>,
138 pub(crate) state_machine: ConversationStateMachine,
139 pub(crate) config: ConversationConfig,
142 operating_mode: OperatingMode,
144 pub(crate) timing: Timing,
146 pub(crate) self_member_id: Arc<[u8]>,
149 pub(crate) app_id: Arc<[u8]>,
153 pending_events: Mutex<Vec<ConversationEvent>>,
157 pending_outbound: Mutex<Vec<Outbound>>,
162}
163
164impl<C, Sc, St> Conversation<C, Sc, St>
165where
166 C: ConsensusPlugin,
167 Sc: PeerScoringPlugin,
168 St: StewardListPlugin,
169{
170 pub(crate) fn new(
174 conversation_id: String,
175 queues: ConversationQueues,
176 services: ConversationServices<C, Sc, St>,
177 state_machine: ConversationStateMachine,
178 config: ConversationConfig,
179 self_member_id: Arc<[u8]>,
180 app_id: Arc<[u8]>,
181 ) -> Self {
182 Self {
183 conversation_id,
184 queues,
185 services,
186 state_machine,
187 config,
188 operating_mode: OperatingMode::Normal,
189 timing: Timing::new(),
190 self_member_id,
191 app_id,
192 pending_events: Mutex::new(Vec::new()),
193 pending_outbound: Mutex::new(Vec::new()),
194 }
195 }
196
197 pub fn is_in_recovery_mode(&self) -> bool {
200 self.operating_mode == OperatingMode::Recovery
201 }
202
203 pub fn enter_recovery_mode(&mut self) {
204 self.operating_mode = OperatingMode::Recovery;
205 }
206
207 pub fn exit_recovery_mode(&mut self) {
208 self.operating_mode = OperatingMode::Normal;
209 }
210
211 pub fn current_state(&self) -> ConversationState {
214 self.state_machine.current_state()
215 }
216
217 pub(crate) fn mls(&self) -> &OpenMlsService {
221 &self.services.mls
222 }
223
224 pub(crate) fn mls_mut(&mut self) -> &mut OpenMlsService {
227 &mut self.services.mls
228 }
229
230 pub(crate) fn create_commit_candidate<Pr>(
238 &mut self,
239 provider: &Pr,
240 signer: &impl Signer,
241 self_member_id: &[u8],
242 ) -> Result<Option<Vec<u8>>, ConversationError>
243 where
244 Pr: OpenMlsProvider,
245 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
246 {
247 if !self.services.steward_list.is_steward(self_member_id) && !self.is_in_recovery_mode() {
248 return Err(ConversationError::NotASteward);
249 }
250
251 if self.queues.approved_proposals().is_empty() {
252 return Err(ConversationError::NoProposals);
253 }
254
255 if self.queues.has_approved_removal(self_member_id) {
259 info!(
260 conversation = self.queues.name(),
261 "commit candidate skipped: approved batch contains self-remove"
262 );
263 return Ok(None);
264 }
265
266 let non_mls_ids: Vec<u32> = self
269 .queues
270 .approved_proposals()
271 .iter()
272 .filter(|(_, req)| ProposalKind::of(req).is_governance())
273 .map(|(&id, _)| id)
274 .collect();
275
276 if !non_mls_ids.is_empty() {
277 return Err(ConversationError::UnexpectedNonMlsProposals {
278 proposal_ids: non_mls_ids,
279 });
280 }
281
282 let mls = &mut self.services.mls;
285
286 let current_members = mls.members()?;
290 let current_members_set = member_set(¤t_members);
291 let is_member = |id: &[u8]| current_members_set.contains(id);
292
293 let urgent_target = self.queues.urgent_commit_target().map(|t| t.to_vec());
296
297 let k_max = mls.commit_batch_max();
300 let approved = self.queues.approved_proposals();
301 let mut updates = Vec::with_capacity(approved.len().min(k_max));
302 let mut joiner_identities = Vec::new();
305 for (_pid, proposal) in approved.iter().take(k_max) {
306 match proposal.payload.as_ref() {
307 Some(Payload::MemberInvite(im)) => {
308 if urgent_target.is_some() {
309 continue;
310 }
311 if is_member(&im.member_id) {
312 continue;
313 }
314 updates.push(MlsCommitInput::Add(KeyPackageBytes::new(
315 im.key_package_bytes.clone(),
316 im.member_id.clone(),
317 )));
318 joiner_identities.push(im.member_id.clone());
319 }
320 Some(Payload::RemoveMember(rm)) => {
321 if let Some(target) = urgent_target.as_deref()
322 && rm.member_id != target
323 {
324 continue;
325 }
326 if !is_member(&rm.member_id) {
327 continue;
328 }
329 updates.push(MlsCommitInput::Remove(rm.member_id.clone()));
330 }
331 _ => return Err(ConversationError::InvalidConversationUpdateRequest),
332 }
333 }
334
335 if updates.is_empty() {
336 return Ok(None);
337 }
338
339 let MlsCommitCandidate {
340 proposals: mls_proposals,
341 commit,
342 welcome,
343 } = mls.create_commit_candidate(provider, signer, &updates)?;
344
345 let candidate = CommitCandidate {
346 conversation_id: self.queues.name_bytes().to_vec(),
347 mls_proposals,
348 commit_message: commit,
349 steward_member_id: self_member_id.to_vec(),
350 };
351
352 let commit_hash = compute_commit_hash(&candidate.commit_message);
355 let epoch = mls.current_epoch()?;
356 let max_candidates = mls.members()?.len();
357 let outcome = self.queues.add_freeze_candidate(
358 BufferedCommitCandidate {
359 candidate_msg: candidate.clone(),
360 commit_hash,
361 is_local_candidate: true,
362 welcome_bytes: welcome,
363 joiner_identities,
364 },
365 epoch,
366 max_candidates,
367 );
368 if !matches!(outcome, FreezeBufferOutcome::Buffered) {
371 tracing::debug!(
372 conversation = self.queues.name(),
373 epoch,
374 ?outcome,
375 "local commit candidate not buffered",
376 );
377 }
378
379 info!(
380 conversation = self.queues.name(),
381 epoch,
382 proposals = updates.len(),
383 "commit candidate created"
384 );
385
386 let candidate_msg: AppMessage = candidate.into();
387 Ok(Some(candidate_msg.encode_to_vec()))
388 }
389
390 pub(crate) fn finalize_freeze_round<Pr>(
392 &mut self,
393 provider: &Pr,
394 allow_subset_candidates: bool,
395 self_member_id: &[u8],
396 ) -> Result<FreezeFinalizeResult, ConversationError>
397 where
398 Pr: OpenMlsProvider,
399 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
400 {
401 let in_recovery = self.operating_mode == OperatingMode::Recovery;
402 let mls = &mut self.services.mls;
403 finalize_freeze_round(
404 provider,
405 &mut self.queues,
406 mls,
407 &self.services.steward_list,
408 in_recovery,
409 allow_subset_candidates,
410 self_member_id,
411 )
412 }
413
414 pub(crate) fn replay_early_candidates(&mut self) -> Result<(), ConversationError> {
418 replay_early_candidates(&mut self.queues, &mut self.services.mls)
419 }
420
421 pub(crate) fn decode_inbound<Pr>(
423 &mut self,
424 provider: &Pr,
425 payload: &[u8],
426 ) -> Result<ProcessResult, ConversationError>
427 where
428 Pr: OpenMlsProvider,
429 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
430 {
431 decode_inbound_payload(provider, &mut self.queues, &mut self.services.mls, payload)
432 }
433
434 pub(crate) fn emit_event(&self, event: ConversationEvent) {
441 match self.pending_events.lock() {
442 Ok(mut buf) => buf.push(event),
443 Err(_) => {
444 tracing::error!(?event, "event buffer mutex poisoned; event dropped")
445 }
446 }
447 }
448
449 pub fn drain_events(&self) -> Vec<ConversationEvent> {
453 match self.pending_events.lock() {
454 Ok(mut buf) => std::mem::take(&mut *buf),
455 Err(_) => {
456 tracing::error!("event buffer mutex poisoned; UI fanout will miss events");
457 Vec::new()
458 }
459 }
460 }
461
462 pub fn next_wakeup_in(&self) -> Option<Duration> {
469 let now = Instant::now();
470 let earliest = self
471 .timing
472 .pending_consensus_timeouts
473 .values()
474 .copied()
475 .chain(self.timing.pending_auto_votes.values().map(|e| e.fire_at))
476 .chain(self.phase_deadline())
477 .min()?;
478 Some(earliest.saturating_duration_since(now))
479 }
480
481 fn phase_deadline(&self) -> Option<Instant> {
487 let anchor = self.timing.phase_timer.started_at()?;
488 let cfg = &self.config;
489 match self.current_state() {
490 ConversationState::Freezing => Some(anchor + cfg.freeze_duration),
491 ConversationState::Working => {
492 if self.queues.approved_proposals_count() == 0 {
493 return None;
494 }
495 let dur = if self.is_in_recovery_mode() {
496 cfg.recovery_inactivity_duration
497 } else {
498 cfg.commit_inactivity_duration
499 };
500 Some(anchor + dur)
501 }
502 _ => None,
503 }
504 }
505
506 pub(crate) fn broadcast(&self, payload: Vec<u8>) {
512 let out = Outbound {
513 conversation_id: self.conversation_id.clone(),
514 sender: self.app_id.to_vec(),
515 payload,
516 };
517 match self.pending_outbound.lock() {
518 Ok(mut buf) => buf.push(out),
519 Err(_) => {
520 tracing::error!("outbound buffer mutex poisoned; item dropped")
521 }
522 }
523 }
524
525 pub fn drain_outbound(&self) -> Vec<Outbound> {
530 match self.pending_outbound.lock() {
531 Ok(mut buf) => std::mem::take(&mut *buf),
532 Err(_) => {
533 tracing::error!("outbound buffer mutex poisoned; integrator will miss outbound");
534 Vec::new()
535 }
536 }
537 }
538
539 pub(crate) fn register_auto_vote(&mut self, proposal_id: u32, delay: Duration, vote: bool) {
545 self.timing.pending_auto_votes.insert(
546 proposal_id,
547 AutoVoteEntry {
548 fire_at: Instant::now() + delay,
549 vote,
550 },
551 );
552 }
553
554 pub(crate) fn cancel_auto_vote(&mut self, proposal_id: u32) {
558 self.timing.pending_auto_votes.remove(&proposal_id);
559 }
560
561 pub(crate) fn cancel_all_auto_votes(&mut self) {
565 self.timing.pending_auto_votes.clear();
566 }
567
568 pub fn leave<Pr>(
573 &mut self,
574 provider: &Pr,
575 signer: &impl Signer,
576 ) -> Result<LeaveOutcome, ConversationError>
577 where
578 Pr: OpenMlsProvider,
579 <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
580 {
581 self.initiate_self_leave(provider, signer)?;
582 Ok(LeaveOutcome::LeaveInitiated)
583 }
584
585 pub(crate) fn register_consensus_timeout(&mut self, proposal_id: u32, delay: Duration) {
588 self.timing
589 .pending_consensus_timeouts
590 .insert(proposal_id, Instant::now() + delay);
591 }
592
593 pub(crate) fn unregister_consensus_timeout(&mut self, proposal_id: u32) {
598 self.timing.pending_consensus_timeouts.remove(&proposal_id);
599 }
600
601 pub(crate) fn start_working(&mut self) -> ConversationState {
604 self.state_machine.start_working();
605 self.timing.phase_timer.clear();
606 info!(state = "Working", "state transition");
607 ConversationState::Working
608 }
609
610 pub(crate) fn start_freezing(&mut self) -> Option<ConversationState> {
614 if self.state_machine.start_freezing() {
615 self.timing.phase_timer.start();
616 info!(state = "Freezing", "state transition");
617 Some(ConversationState::Freezing)
618 } else {
619 None
620 }
621 }
622
623 pub(crate) fn start_selection(&mut self) -> ConversationState {
624 self.state_machine.start_selection();
625 info!(state = "Selection", "state transition");
626 ConversationState::Selection
627 }
628
629 pub(crate) fn start_reelection(&mut self) -> ConversationState {
630 self.state_machine.start_reelection();
631 self.timing.phase_timer.clear();
632 info!(state = "Reelection", "state transition");
633 ConversationState::Reelection
634 }
635
636 pub(crate) fn is_freeze_timed_out(&self) -> bool {
638 self.current_state() == ConversationState::Freezing
639 && self
640 .timing
641 .phase_timer
642 .elapsed_since_anchor(self.config.freeze_duration)
643 }
644
645 pub(crate) fn check_steward_inactivity(
651 &mut self,
652 approved_proposals_count: usize,
653 inactivity_duration: Duration,
654 ) -> Option<ConversationState> {
655 if self.current_state() != ConversationState::Working || approved_proposals_count == 0 {
656 return None;
657 }
658 if self.timing.phase_timer.started_at().is_none() {
659 self.timing.phase_timer.start();
660 info!(
661 approved = approved_proposals_count,
662 inactivity_ms = inactivity_duration.as_millis() as u64,
663 "inactivity timer started"
664 );
665 return None;
666 }
667 if !self
668 .timing
669 .phase_timer
670 .elapsed_since_anchor(inactivity_duration)
671 {
672 return None;
673 }
674 info!(
675 inactivity_ms = inactivity_duration.as_millis() as u64,
676 approved = approved_proposals_count,
677 "inactivity window elapsed, entering freeze"
678 );
679 self.start_freezing()
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use std::time::Instant;
686
687 use openmls_basic_credential::SignatureKeyPair;
688
689 use super::*;
690 use crate::ConversationQueues;
691 use crate::defaults::DefaultConsensusPlugin;
692 use crate::test_fixtures::{
693 StubScoring, StubStewardList, TestMls, TestProvider, make_creator_mls,
694 make_test_consensus_service,
695 };
696
697 type TestConversation = Conversation<DefaultConsensusPlugin, StubScoring, StubStewardList>;
698
699 fn make_conversation_with_steward(
704 steward_list: StubStewardList,
705 ) -> (TestConversation, TestProvider, SignatureKeyPair) {
706 let (mls, provider, signer) = make_creator_mls(b"test-member-id");
707 (build_conversation(mls, steward_list), provider, signer)
708 }
709
710 fn make_conversation_working() -> TestConversation {
711 let (mls, _provider, _signer) = make_creator_mls(b"test-member-id");
712 build_conversation(mls, StubStewardList::member())
713 }
714
715 fn build_conversation(mls: TestMls, steward_list: StubStewardList) -> TestConversation {
716 let (consensus, consensus_rx) = make_test_consensus_service();
717 Conversation::new(
718 "g".to_string(),
719 ConversationQueues::new("g"),
720 ConversationServices {
721 mls,
722 scoring: StubScoring,
723 steward_list,
724 consensus,
725 consensus_rx,
726 },
727 ConversationStateMachine::new_as_member(),
728 ConversationConfig::default(),
729 Arc::from(&b"test-member-id"[..]),
730 Arc::from(&[0u8; 16][..]),
731 )
732 }
733
734 #[test]
737 fn check_steward_inactivity_first_tick_anchors_and_returns_none() {
738 let mut conversation = make_conversation_working();
739 assert_eq!(conversation.current_state(), ConversationState::Working);
740 assert!(
741 conversation.timing.phase_timer.started_at().is_none(),
742 "fresh conversation has no anchor"
743 );
744
745 let result =
746 conversation.check_steward_inactivity(1, Duration::from_secs(10));
747
748 assert_eq!(result, None, "first tick auto-anchors and returns None");
749 assert!(
750 conversation.timing.phase_timer.started_at().is_some(),
751 "anchor must be set after first tick"
752 );
753 assert_eq!(
754 conversation.current_state(),
755 ConversationState::Working,
756 "state must stay Working until inactivity actually elapses"
757 );
758
759 let result =
760 conversation.check_steward_inactivity(1, Duration::from_secs(10));
761 assert_eq!(
762 result, None,
763 "second tick before timeout still returns None"
764 );
765 }
766
767 #[test]
769 fn check_steward_inactivity_noop_without_approved_work() {
770 let mut conversation = make_conversation_working();
771 let result = conversation.check_steward_inactivity(0, Duration::from_secs(10));
772 assert_eq!(result, None);
773 assert!(
774 conversation.timing.phase_timer.started_at().is_none(),
775 "no approved work must not start the timer"
776 );
777 }
778
779 #[test]
785 fn emit_event_then_drain_returns_insertion_order_and_clears_buffer() {
786 let conversation = make_conversation_working();
787 conversation.emit_event(ConversationEvent::PhaseChange(ConversationState::Working));
788 conversation.emit_event(ConversationEvent::Leaving);
789
790 let drained = conversation.drain_events();
791 assert_eq!(drained.len(), 2);
792 assert!(matches!(
793 drained[0],
794 ConversationEvent::PhaseChange(ConversationState::Working)
795 ));
796 assert!(matches!(drained[1], ConversationEvent::Leaving));
797
798 assert!(conversation.drain_events().is_empty());
800 }
801
802 #[test]
807 fn register_auto_vote_replaces_existing_entry() {
808 let mut conversation = make_conversation_working();
809 conversation.register_auto_vote(7, Duration::from_secs(10), true);
810 let first_fire = conversation.timing.pending_auto_votes[&7].fire_at;
811
812 std::thread::sleep(Duration::from_millis(2));
815 conversation.register_auto_vote(7, Duration::from_secs(20), false);
816 assert_eq!(conversation.timing.pending_auto_votes.len(), 1);
817 let entry = conversation.timing.pending_auto_votes[&7];
818 assert!(!entry.vote);
819 assert!(entry.fire_at > first_fire);
820 }
821
822 #[test]
826 fn cancel_auto_vote_removes_only_the_targeted_proposal() {
827 let mut conversation = make_conversation_working();
828 conversation.register_auto_vote(1, Duration::from_secs(5), true);
829 conversation.register_auto_vote(2, Duration::from_secs(5), false);
830 conversation.register_auto_vote(3, Duration::from_secs(5), true);
831
832 conversation.cancel_auto_vote(2);
833 assert!(conversation.timing.pending_auto_votes.contains_key(&1));
834 assert!(!conversation.timing.pending_auto_votes.contains_key(&2));
835 assert!(conversation.timing.pending_auto_votes.contains_key(&3));
836
837 conversation.cancel_all_auto_votes();
838 assert!(conversation.timing.pending_auto_votes.is_empty());
839 }
840
841 #[test]
846 fn register_then_unregister_consensus_timeout() {
847 let mut conversation = make_conversation_working();
848 let before = Instant::now();
849 conversation.register_consensus_timeout(42, Duration::from_secs(30));
850 let fire_at = conversation.timing.pending_consensus_timeouts[&42];
851 assert!(fire_at > before + Duration::from_secs(29));
852 assert!(fire_at < Instant::now() + Duration::from_secs(31));
853
854 conversation.unregister_consensus_timeout(42);
855 assert!(
856 !conversation
857 .timing
858 .pending_consensus_timeouts
859 .contains_key(&42)
860 );
861
862 conversation.unregister_consensus_timeout(999);
864 }
865
866 #[test]
869 fn create_commit_candidate_errors_for_non_steward_outside_recovery() {
870 let (mut conversation, provider, signer) =
871 make_conversation_with_steward(StubStewardList::member());
872 let err = conversation
873 .create_commit_candidate(&provider, &signer, b"me")
874 .expect_err("non-steward should be rejected");
875 assert!(matches!(err, ConversationError::NotASteward));
876 }
877
878 #[test]
879 fn create_commit_candidate_errors_when_no_approved_proposals() {
880 let (mut conversation, provider, signer) =
881 make_conversation_with_steward(StubStewardList::steward());
882 let err = conversation
883 .create_commit_candidate(&provider, &signer, b"me")
884 .expect_err("empty approved queue should be rejected");
885 assert!(matches!(err, ConversationError::NoProposals));
886 }
887
888 #[test]
893 fn create_commit_candidate_errors_on_emergency_in_approved_queue() {
894 use crate::protos::de_mls::messages::v1::ViolationEvidence;
895
896 let (mut conversation, provider, signer) =
897 make_conversation_with_steward(StubStewardList::steward());
898 let emergency = ViolationEvidence::broken_commit(vec![0xAA], 0, Vec::<u8>::new())
899 .with_creator(vec![0x01])
900 .into_update_request()
901 .unwrap();
902 conversation.queues.insert_approved_proposal(50, emergency);
903
904 let err = conversation
905 .create_commit_candidate(&provider, &signer, b"me")
906 .expect_err("emergency in approved queue should be rejected");
907 let ConversationError::UnexpectedNonMlsProposals { proposal_ids } = err else {
908 panic!("expected UnexpectedNonMlsProposals, got {err:?}");
909 };
910 assert_eq!(proposal_ids, vec![50]);
911 }
912}