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}