Skip to main content

de_mls/conversation/
poll.rs

1//! Unified per-cycle polling entry point for [`crate::Conversation`].
2//!
3//! [`Conversation::poll`] drives all time-based conversation paths in one call:
4//! consensus-deadline ticks, freeze progression, and steward-inactivity freeze
5//! entry. The sub-steps are private to this module — the integrator calls
6//! `poll()` once per wakeup cycle and reacts to the returned [`PollOutcome`].
7
8use std::error::Error as StdError;
9use std::{
10    sync::Arc,
11    time::{Duration, Instant},
12};
13
14use openmls_traits::signatures::Signer;
15use openmls_traits::{OpenMlsProvider, storage::StorageProvider};
16use prost::Message;
17use tracing::{error, info, warn};
18
19use crate::{
20    ConsensusPlugin, Conversation, ConversationError, ConversationEvent, ConversationState,
21    DispatchOutcome, FreezeFinalizeResult, FreezeOutcome, PeerScoringPlugin, ScoreEvent, ScoreOp,
22    StewardListPlugin, mls_crypto::MlsService, protos::de_mls::messages::v1::AppMessage,
23};
24
25/// Summary returned by [`Conversation::poll`] after one polling pass.
26#[derive(Debug, Clone)]
27pub struct PollOutcome {
28    /// Earliest deadline still pending after this pass. Forward to an
29    /// external scheduler as the next wakeup hint.
30    pub next_wakeup_in: Option<Duration>,
31    /// `true` if this conversation should be torn down: a commit ejected the
32    /// local member. The integrator must remove the registry entry and clean
33    /// up the consensus scope before its next polling cycle.
34    pub leave_requested: bool,
35}
36
37impl<C, Sc, St> Conversation<C, Sc, St>
38where
39    C: ConsensusPlugin,
40    Sc: PeerScoringPlugin,
41    St: StewardListPlugin,
42{
43    /// Drive one polling cycle: tick consensus deadlines, advance freeze
44    /// state, and check steward inactivity.
45    ///
46    /// Best-effort: each step runs regardless of whether the previous one
47    /// failed; step errors are transient (a step that can't act this cycle
48    /// retries on the next) and are logged rather than surfaced.
49    ///
50    /// Returns [`PollOutcome::leave_requested`] when the conversation is ready
51    /// to be torn down; the integrator finalizes the leave.
52    ///
53    /// `signer` is the local member's MLS signer, threaded into the
54    /// steward commit-candidate build and any auto-vote casts that fire
55    /// this cycle.
56    pub fn poll<Pr>(&mut self, provider: &Pr, signer: &impl Signer) -> PollOutcome
57    where
58        Pr: OpenMlsProvider,
59        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
60    {
61        let mut leave_requested = false;
62
63        self.tick_deadlines(provider, signer);
64
65        match self.advance_freeze(provider, signer) {
66            Ok(DispatchOutcome::LeaveRequested) => leave_requested = true,
67            Ok(_) => {}
68            Err(e) => warn!(
69                conversation = %self.conversation_id,
70                error = %e,
71                "advance_freeze error in poll"
72            ),
73        }
74
75        if let Err(e) = self.drive_buffered_proposals(provider, signer) {
76            warn!(
77                conversation = %self.conversation_id,
78                error = %e,
79                "buffered-proposal drive error in poll"
80            );
81        }
82
83        if let Err(e) = self.start_freeze_on_inactivity(provider, signer) {
84            warn!(
85                conversation = %self.conversation_id,
86                error = %e,
87                "inactivity-freeze error in poll"
88            );
89        }
90
91        PollOutcome {
92            next_wakeup_in: self.next_wakeup_in(),
93            leave_requested,
94        }
95    }
96
97    /// Drive the freeze phase forward. While `Freezing`, emits
98    /// [`ConversationEvent::FreezeProgress`] as candidates arrive; once all
99    /// expected candidates are in or the freeze window elapses, transitions
100    /// to `Selection`, finalises the round, and dispatches the resulting
101    /// [`crate::ProcessResult`]. Returns
102    /// [`DispatchOutcome::LeaveRequested`] if the applied commit ejected the
103    /// local member — `poll()` surfaces that as `leave_requested`.
104    fn advance_freeze<Pr>(
105        &mut self,
106        provider: &Pr,
107        signer: &impl Signer,
108    ) -> Result<DispatchOutcome, ConversationError>
109    where
110        Pr: OpenMlsProvider,
111        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
112    {
113        let state = self.current_state();
114        if state != ConversationState::Freezing {
115            self.timing.last_freeze_progress = None;
116            return Ok(DispatchOutcome::Done);
117        }
118
119        // Early selection: skip remaining freeze time if all expected
120        // stewards have submitted candidates.
121        let all_candidates_in = self
122            .services
123            .steward_list
124            .current_list()
125            .is_some_and(|list| self.queues.freeze_candidate_count() >= list.len());
126
127        if !all_candidates_in && !self.is_freeze_timed_out() {
128            // Still freezing — surface candidate progress when it changes.
129            let (received, expected) = self.freeze_candidate_count();
130            if self.timing.last_freeze_progress != Some((received, expected)) {
131                self.timing.last_freeze_progress = Some((received, expected));
132                self.emit_event(ConversationEvent::FreezeProgress { received, expected });
133            }
134            return Ok(DispatchOutcome::Done);
135        }
136        self.timing.last_freeze_progress = None;
137
138        let selection_event = self.start_selection();
139        let has_proposals = self.queues.approved_proposals_count() > 0;
140        self.emit_event(ConversationEvent::PhaseChange(selection_event));
141
142        let conversation_id = self.conversation_id.clone();
143        let allow_subset = self.services.steward_list.config().allow_subset_candidates;
144        let self_member_id = Arc::clone(&self.self_member_id);
145        let mut finalize_result =
146            match self.finalize_freeze_round(provider, allow_subset, &self_member_id) {
147                Ok(result) => result,
148                Err(e) => {
149                    error!(conversation = %conversation_id, error = %e, "freeze finalize failed");
150                    FreezeFinalizeResult::default()
151                }
152            };
153        // Apply locally-observed score events. These come from dropped
154        // candidates in the phase-3 loop (RFC §Peer Scoring: direct local
155        // observation, no ECP needed). A downward threshold cross schedules
156        // a removal-init pass below.
157        let downward_cross = if !finalize_result.score_ops.is_empty() {
158            self.services.scoring.apply_ops(&finalize_result.score_ops)
159        } else {
160            false
161        };
162
163        if !finalize_result.committed_batch.is_empty() {
164            self.emit_event(ConversationEvent::CommitApplied(std::mem::take(
165                &mut finalize_result.committed_batch,
166            )));
167        }
168
169        // `check_and_initiate_score_removals` calls `initiate_proposal`. A
170        // downward threshold cross during finalize schedules a removal pass.
171        if downward_cross && let Err(e) = self.check_and_initiate_score_removals(provider, signer) {
172            error!(conversation = %conversation_id, error = %e, "score-removal check failed (freeze finalize)");
173        }
174
175        match finalize_result.outcome {
176            FreezeOutcome::Applied { result, welcome } => {
177                if let Some(mut welcome) = welcome {
178                    // Bundle ConversationSync (steward list + timing +
179                    // scores) into the welcome event so the integrator
180                    // delivers both atomically. The joiner replays the
181                    // sync payload through `handle_inbound` after MLS
182                    // attaches.
183                    //
184                    // Reconcile the list to the just-merged epoch first, so a
185                    // small group's sync carries the regenerated, joiner-
186                    // inclusive list rather than the pre-commit one. A large
187                    // group leaves the list for the post-commit election.
188                    welcome.conversation_sync_bytes = {
189                        let _ = self.reconcile_steward_list()?;
190                        self.build_conversation_sync_payload(provider, signer)?
191                            .unwrap_or_default()
192                    };
193                    // Broadcast the welcome to the group so every member can deliver it to the
194                    // joiners, then surface it locally as freshly minted.
195                    let broadcast_payload = AppMessage::from(welcome.clone()).encode_to_vec();
196                    self.emit_event(ConversationEvent::WelcomeReady {
197                        welcome,
198                        minted_locally: true,
199                    });
200                    self.broadcast(broadcast_payload);
201                }
202
203                let outcome = match self.dispatch_inbound_result(provider, result, signer) {
204                    Ok(o) => o,
205                    Err(e) => {
206                        error!(conversation = %conversation_id, error = %e, "finalize result dispatch failed");
207                        DispatchOutcome::Done
208                    }
209                };
210                Ok(outcome)
211            }
212            FreezeOutcome::NoCandidate => {
213                // `accuse_target` is `Some` only when we had approved proposals
214                // go unanswered *and* can attribute the miss to a live steward
215                // other than ourselves. Self-penalties are skipped — the
216                // node that failed to commit observes its own state directly
217                // and doesn't need to record a ScoreOp against itself.
218                let (transition_event, downward_cross) = if has_proposals {
219                    // Approved batch (and in-flight votes) survive so
220                    // the recovered steward commits the same proposals
221                    // once the next election lands.
222                    let event = self.start_reelection();
223
224                    // Local observation → direct peer-score penalty,
225                    // no ECP round-trip. Each honest member records
226                    // the same event independently; threshold-crossing
227                    // removal still goes through SCORE_BELOW_THRESHOLD
228                    // consensus in steward.rs.
229                    let accuse_target = {
230                        let mls = self.mls();
231                        let violation_epoch = mls.current_epoch()?;
232                        let members = mls.members()?;
233                        let self_member_id: &[u8] = &self.self_member_id;
234                        let eligible = self.queues.steward_eligibility(&members);
235                        self.services
236                            .steward_list
237                            .epoch_steward(violation_epoch, &eligible)
238                            .filter(|id| !id.is_empty() && *id != self_member_id)
239                            .map(|id| id.to_vec())
240                    };
241                    let cross = if let Some(steward_id) = accuse_target {
242                        self.services.scoring.apply_op(&ScoreOp {
243                            member_id: steward_id,
244                            event: ScoreEvent::CensorshipInactivity,
245                        })
246                    } else {
247                        false
248                    };
249
250                    (event, cross)
251                } else {
252                    self.queues.clear_freeze_round();
253                    let event = self.start_working();
254                    (event, false)
255                };
256
257                if downward_cross
258                    && let Err(e) = self.check_and_initiate_score_removals(provider, signer)
259                {
260                    error!(conversation = %conversation_id, error = %e, "score-removal check failed (freeze timeout)");
261                }
262
263                let entered_reelection = transition_event == ConversationState::Reelection;
264                self.emit_event(ConversationEvent::PhaseChange(transition_event));
265
266                // Layer 2 recovery: regenerate the steward list. Only the
267                // responsible proposer's call actually submits.
268                if entered_reelection
269                    && let Err(e) = self.initiate_steward_election(provider, true, signer)
270                {
271                    info!(conversation = %conversation_id, error = %e, "recovery election deferred");
272                }
273
274                Ok(DispatchOutcome::Done)
275            }
276        }
277    }
278
279    /// Drive the backup-steward proposal takeover. The epoch steward sponsors
280    /// announced joiners immediately; everyone else only buffers them. If the
281    /// epoch steward stays silent, the join would stall — so here a backup
282    /// steward drains the buffer once the recovery window passes, mirroring the
283    /// commit takeover (primary leads, backup follows after `recovery`).
284    ///
285    /// No-op outside `Working`; deferred while approved work is pending (the
286    /// commit path owns that, and the next epoch advance drains the buffer);
287    /// the epoch steward drains immediately; plain members never drain.
288    fn drive_buffered_proposals<Pr>(
289        &mut self,
290        provider: &Pr,
291        signer: &impl Signer,
292    ) -> Result<(), ConversationError>
293    where
294        Pr: OpenMlsProvider,
295        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
296    {
297        let idle = self.current_state() != ConversationState::Working
298            || self.queues.approved_proposals_count() > 0
299            || self.actionable_buffered_updates()?.is_empty();
300        if idle {
301            self.timing.buffered_propose_anchor = None;
302            return Ok(());
303        }
304
305        // The epoch steward is responsible now — propose without waiting.
306        if self.is_epoch_steward()? {
307            self.timing.buffered_propose_anchor = None;
308            return self.drain_buffered_updates(provider, signer);
309        }
310        // Only a steward can take over; a plain member just keeps its backup copy.
311        if !self.is_steward() {
312            return Ok(());
313        }
314
315        // Backup steward: give the epoch steward the recovery window first.
316        let anchor = match self.timing.buffered_propose_anchor {
317            Some(a) => a,
318            None => {
319                self.timing.buffered_propose_anchor = Some(Instant::now());
320                return Ok(());
321            }
322        };
323        let delay =
324            self.config.commit_inactivity_duration + self.config.recovery_inactivity_duration;
325        if Instant::now() < anchor + delay {
326            return Ok(());
327        }
328        self.timing.buffered_propose_anchor = None;
329        info!(
330            conversation = %self.conversation_id,
331            "backup steward proposing buffered updates: epoch steward silent past recovery window"
332        );
333        self.drain_buffered_updates(provider, signer)
334    }
335
336    /// Steward-inactivity freeze entry: once the inactivity timer fires with
337    /// approved work pending, start the freeze round and transition into
338    /// `Freezing`. Stewards build their own commit candidate too;
339    /// candidate-build failure is logged and the freeze transition proceeds
340    /// (peers' candidates still get processed). No-ops outside `Working`
341    /// (via `check_steward_inactivity`) and while an election is in flight.
342    fn start_freeze_on_inactivity<Pr>(
343        &mut self,
344        provider: &Pr,
345        signer: &impl Signer,
346    ) -> Result<(), ConversationError>
347    where
348        Pr: OpenMlsProvider,
349        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
350    {
351        let proposal_count = self.queues.approved_proposals_count();
352        // Hold the freeze while an election is in flight — committing on
353        // the known-stale list would just produce a NoCandidate.
354        if self.queues.has_election_in_flight() {
355            return Ok(());
356        }
357        // Recovery uses the shorter retry inactivity window so we don't
358        // burn another full epoch waiting for a steward to commit.
359        let in_recovery =
360            self.is_in_recovery_mode() || self.services.steward_list.next_retry_round() > 0;
361        let inactivity = if in_recovery {
362            self.config.recovery_inactivity_duration
363        } else if self.is_epoch_steward()? {
364            // The primary steward leads: it commits at the commit-inactivity
365            // deadline.
366            self.config.commit_inactivity_duration
367        } else {
368            // Backups (and non-stewards) wait an extra recovery window. In the
369            // normal case the primary's commit candidate pulls them into freeze
370            // first, so they never self-drive it; only a silent primary lets
371            // this longer deadline fire and a backup step in.
372            self.config.commit_inactivity_duration + self.config.recovery_inactivity_duration
373        };
374        let Some(event) = self.check_steward_inactivity(proposal_count, inactivity) else {
375            return Ok(());
376        };
377        let epoch = self.mls().current_epoch()?;
378        self.queues.start_freeze_round(epoch);
379
380        let self_member_id = Arc::clone(&self.self_member_id);
381        let outbound = if self.services.steward_list.is_steward(&self_member_id) {
382            match self.create_commit_candidate(provider, signer, &self_member_id) {
383                Ok(payload) => payload,
384                Err(e) => {
385                    error!(
386                        conversation = %self.conversation_id,
387                        error = %e,
388                        "commit candidate build failed"
389                    );
390                    None
391                }
392            }
393        } else {
394            None
395        };
396
397        info!(
398            conversation = %self.conversation_id,
399            approved = proposal_count,
400            "steward inactivity transition"
401        );
402
403        self.emit_event(ConversationEvent::PhaseChange(event));
404
405        if let Some(payload) = outbound {
406            self.broadcast(payload);
407        }
408
409        Ok(())
410    }
411}