offline_protocol/protocol/session.rs
1//! Session confirmation, welcome lifecycle, and pending session reconciliation.
2
3use super::{
4 classify_transport_send_error, internal_prefixes, key_package_resend_wait_secs,
5 lock_shared_state, send_failure_token, GatewayCarrier, OfflineProtocol, PresenceRescueThrottle,
6 PruneAllowance, RestorableRecord, SessionState, WelcomeDeliveryState, WelcomeLifecycleRecord,
7 CONFIRMATION_PROBE_INTERVAL_SECS, CONFIRMATION_RETRY_INTERVAL_SECS, MAX_REKEY_TRACKED_PEERS,
8 RECONCILIATION_THROTTLE_MS, REKEY_INTERVAL_SECS, SEND_FAIL_REASON_CONFIRM_TIMEOUT,
9 WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS, WELCOME_LIFECYCLE_TTL_SECS,
10 WELCOME_MESH_CONFIRM_TIMEOUT_SECS, WELCOME_NO_CARRIER_RETRY_SECS,
11 WELCOME_PRESENCE_RESCUE_BASE_SECS, WELCOME_PRESENCE_RESCUE_MAX_SECS, WELCOME_RETRY_BATCH_SIZE,
12 WELCOME_RETRY_JITTER_RATIO, WELCOME_UNREACHABLE_RETRY_CAP_SECS, WELCOME_WATCHLIST_MAX_AGE_SECS,
13};
14use crate::events::SecurityWarningCode;
15use crate::mls_observability::MlsOperationContext;
16use crate::protocol::reachability::{Claim, FactSource};
17use crate::{Error, EstablishmentState, Event, Result, SessionStateError};
18use chrono::{Duration as ChronoDuration, Utc};
19use offline_protocol_core::time::{Duration as StdDuration, Instant};
20use offline_protocol_core::{Message, MessageId, MessagePriority};
21use offline_protocol_mls::{MlsManager, WelcomeMessage};
22use offline_protocol_sealed::SessionCorroboration;
23use offline_protocol_transport::TransportType;
24use std::collections::HashSet;
25use std::hash::{Hash, Hasher};
26use std::sync::{Arc, RwLock};
27use tracing::{debug, info, warn};
28
29/// The confirmation source for a probe or acknowledgement whose
30/// corroboration matched: group-aware proof, like a successful decrypt.
31pub(super) const SESSION_CORROBORATED: &str = "session_corroborated";
32
33/// What a confirmation probe or acknowledgement says about the session the
34/// two sides hold.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub(super) enum Corroboration {
37 /// No corroboration in the frame: an older peer, or a leaf. The plaintext
38 /// rules apply as they always did.
39 Absent,
40 /// The same group at the same epoch.
41 Agrees,
42 /// The same epoch and a different group: a split session.
43 Split,
44 /// A different epoch, which the desync path owns, or no session or MLS
45 /// on this side to compare against. Evidence of nothing.
46 Inconclusive,
47}
48
49impl OfflineProtocol {
50 // ========================================================================
51 // SESSION STATE
52 // ========================================================================
53
54 /// Returns true when persisted state marks this peer session as confirmed.
55 ///
56 /// Uses the in-memory `confirmed_sessions` cache as a fast path to avoid
57 /// hitting persistent storage on every message send. The cache is populated
58 /// when sessions are confirmed and cleared when sessions are invalidated.
59 pub(super) fn is_session_confirmed(&mut self, peer_id: &str) -> Result<bool> {
60 // Fast path: check in-memory cache only — no I/O. The cache is kept in
61 // sync by confirm_session_state() and all invalidation paths (blocking,
62 // stale cleanup, etc.). We deliberately skip the has_mls_session() guard
63 // here because it calls load_group() which hits MlsStorage (10-100ms on
64 // mobile Keychain/Keystore). If the session was wiped externally, the
65 // encrypt call will fail and we handle it there.
66 if self.confirmed_sessions.contains(peer_id) {
67 return Ok(true);
68 }
69
70 let persisted = self
71 .load_session_state_entry(peer_id)?
72 .unwrap_or(SessionState::Pending);
73 if matches!(persisted, SessionState::Confirmed) {
74 if !self.has_mls_session(peer_id)? {
75 warn!(
76 peer_id = %peer_id,
77 "Persisted confirmed state has no matching MLS session; clearing stale state"
78 );
79 self.confirmed_sessions.remove(peer_id);
80 self.clear_confirmation_recovery_tracking(peer_id);
81 self.welcome_lifecycles.remove(peer_id);
82 // The MLS session is gone, so any both-create owner gate for this
83 // peer is stale; clear it (memory + storage) or it would block
84 // `welcome_received` confirmation on the next re-pairing.
85 self.clear_both_create_awaiting_decrypt(peer_id);
86 if let Err(err) = self.clear_session_state_entry(peer_id) {
87 warn!(
88 peer_id = %peer_id,
89 error = %err,
90 "Failed to clear stale persisted session state"
91 );
92 }
93 if let Err(err) = self.clear_welcome_lifecycle_entry(peer_id) {
94 warn!(
95 peer_id = %peer_id,
96 error = %err,
97 "Failed to clear stale persisted welcome lifecycle"
98 );
99 }
100 return Ok(false);
101 }
102 self.confirmed_sessions.insert(peer_id.to_string());
103 return Ok(true);
104 }
105 Ok(false)
106 }
107
108 pub(super) fn has_mls_session(&self, peer_id: &str) -> Result<bool> {
109 let Some(mls) = self.mls_manager.clone() else {
110 return Ok(false);
111 };
112
113 let manager = mls
114 .read()
115 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
116 manager.has_session(peer_id).map_err(Error::Mls)
117 }
118
119 /// Returns the current establishment state for a peer (for API and error reporting).
120 pub(super) fn establishment_state(&self, peer_id: &str) -> Result<EstablishmentState> {
121 if let Some(mls) = &self.mls_manager {
122 let has_session = {
123 let manager = mls
124 .read()
125 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
126 manager.has_session(peer_id).map_err(Error::Mls)?
127 };
128 if has_session {
129 let state = self
130 .load_session_state_entry(peer_id)?
131 .unwrap_or(SessionState::Pending);
132 return Ok(if matches!(state, SessionState::Confirmed) {
133 EstablishmentState::SessionConfirmed
134 } else {
135 EstablishmentState::SessionPending
136 });
137 }
138 }
139
140 let now_ms = Utc::now().timestamp_millis() as u64;
141 if let Some(pkg) = self.pending_key_packages.get(peer_id) {
142 if now_ms < pkg.local_expires_at_ms {
143 return Ok(EstablishmentState::HaveKeyPackage);
144 }
145 }
146 if self.load_peer_key_package_from_storage(peer_id).is_some() {
147 return Ok(EstablishmentState::HaveKeyPackage);
148 }
149 Ok(EstablishmentState::NoKeyPackage)
150 }
151
152 /// Monotonic state transition helper: Pending -> Confirmed only.
153 pub(super) fn confirm_session_state(
154 &mut self,
155 peer_id: &str,
156 source_event: &str,
157 ) -> Result<bool> {
158 if !self.can_confirm_from_source(peer_id, source_event) {
159 warn!(
160 event = "session_confirmation_blocked",
161 session_or_group_id = %peer_id,
162 source_event = %source_event,
163 "session_confirmation_blocked"
164 );
165 return Ok(false);
166 }
167
168 let previous = self.ensure_session_state_entry(peer_id, source_event)?;
169
170 if matches!(previous, SessionState::Confirmed) {
171 self.confirmed_sessions.insert(peer_id.to_string());
172 self.clear_confirmation_recovery_tracking(peer_id);
173 self.clear_both_create_awaiting_decrypt(peer_id);
174 info!(
175 event = "session_state_transition",
176 session_or_group_id = %peer_id,
177 previous_state = "Confirmed",
178 new_state = "Confirmed",
179 source_event = %source_event,
180 "session_state_transition"
181 );
182 return Ok(false);
183 }
184
185 // Persist first, then publish in-memory view.
186 if let Err(err) = self.persist_session_state(peer_id, SessionState::Confirmed, source_event)
187 {
188 self.schedule_confirmation_retry(peer_id, source_event);
189 return Err(err);
190 }
191
192 self.confirmed_sessions.insert(peer_id.to_string());
193 self.clear_confirmation_recovery_tracking(peer_id);
194 // Group-aware proof has arrived (decrypt), so we no longer need to gate
195 // confirmation on decrypt for this both-create peer.
196 self.clear_both_create_awaiting_decrypt(peer_id);
197 // The peer has proved the session, so the outbound Welcome — kept
198 // non-terminal so a lost fragment keeps being retried — is delivered.
199 // Mark it Sent so process_welcome_retry_queue stops re-sending it. Mesh
200 // has no transport-level delivery ack, so this session proof is the mesh
201 // equivalent of on_transport_send_confirmed.
202 self.mark_welcome_confirmed(peer_id, source_event);
203 if source_event != "welcome_received" && source_event != "decrypt_success" {
204 self.maybe_emit_local_session_established(
205 peer_id,
206 Self::session_ready_context_for_source(source_event),
207 );
208 }
209 info!(
210 event = "session_state_transition",
211 session_or_group_id = %peer_id,
212 previous_state = previous.as_str(),
213 new_state = "Confirmed",
214 source_event = %source_event,
215 "session_state_transition"
216 );
217
218 // Replication starts here rather than at the four callers that reach
219 // this transition, because the four callers are exactly the shape of
220 // bug that keeps happening: one of them forgets, and the feature is
221 // silently absent on whichever confirmation path that caller owned.
222 // This is also the only signal that covers all four — the session
223 // lifecycle event does not fire on the probe and ack paths.
224 #[cfg(feature = "data")]
225 self.kick_data_sync(peer_id, source_event);
226
227 Ok(true)
228 }
229
230 /// Marks a non-terminal outbound welcome lifecycle as `Sent` once the peer
231 /// has proved the session (probe / ack / welcome / decrypt). The mesh
232 /// equivalent of [`Self::on_transport_send_confirmed`], which only fires for
233 /// Internet. No-op when there is no welcome lifecycle for `peer_id` (e.g. a
234 /// confirmation keyed by a group id rather than a 1:1 peer) or it is already
235 /// terminal, so it is safe to call from every confirmation path.
236 fn mark_welcome_confirmed(&mut self, peer_id: &str, source_event: &str) {
237 match self.welcome_lifecycles.get(peer_id).map(|r| r.state) {
238 // In flight / retried — fall through and mark Sent below.
239 Some(WelcomeDeliveryState::SendAttempted) | Some(WelcomeDeliveryState::Failed) => {}
240 // Parked, never actually sent (no carrier at send time) for a peer
241 // that converged over some other path. `Created -> Sent` is not a
242 // legal transition and there is nothing in flight to confirm, so drop
243 // the now-moot lifecycle (memory + storage) rather than leaving it to
244 // linger until the session is deleted.
245 Some(WelcomeDeliveryState::Created) => {
246 self.welcome_lifecycles.remove(peer_id);
247 if let Err(err) = self.clear_welcome_lifecycle_entry(peer_id) {
248 warn!(
249 peer_id = %peer_id,
250 error = %err,
251 "Failed to clear parked welcome lifecycle on confirmation"
252 );
253 }
254 return;
255 }
256 // Already terminal (Sent / Expired) or no lifecycle — nothing to do.
257 _ => return,
258 }
259 if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
260 record.next_retry_at = None;
261 record.last_reason_code = None;
262 record.last_transport_error = None;
263 }
264 // transition_welcome_state persists the record and logs the transition.
265 if self
266 .transition_welcome_state(peer_id, WelcomeDeliveryState::Sent, source_event)
267 .is_err()
268 {
269 return;
270 }
271 if let Some(snapshot) = self.welcome_lifecycles.get(peer_id).cloned() {
272 if let Ok(state) = lock_shared_state(&self.shared_state) {
273 state.emit_event(Event::welcome_send_succeeded(
274 peer_id.to_string(),
275 snapshot.welcome_message.id.as_str().to_string(),
276 snapshot.group_id,
277 snapshot.attempt,
278 ));
279 }
280 }
281 }
282
283 /// Ensures a session has an explicit persisted state entry.
284 pub(super) fn ensure_session_state_entry(
285 &self,
286 peer_id: &str,
287 source_event: &str,
288 ) -> Result<SessionState> {
289 let existing = self.load_session_state_entry(peer_id)?;
290 if let Some(state) = existing {
291 return Ok(state);
292 }
293
294 self.persist_session_state(peer_id, SessionState::Pending, source_event)?;
295 info!(
296 event = "session_state_transition",
297 session_or_group_id = %peer_id,
298 previous_state = "Absent",
299 new_state = "Pending",
300 source_event = %source_event,
301 "session_state_transition"
302 );
303 Ok(SessionState::Pending)
304 }
305
306 /// Reconstructs runtime confirmation cache from persisted session states.
307 /// `allowance` is the launch's shared advisory pool — this walk's deletes
308 /// are advisory (a dropped record re-bootstraps to `Pending`, which is the
309 /// safe answer), and they add up against the same allowance as the four
310 /// category walks. See `storage::MAX_RESTORE_PRUNE_DELETES`.
311 pub(super) fn restore_session_states_from_manager(
312 &mut self,
313 mls: Arc<RwLock<MlsManager>>,
314 allowance: &mut PruneAllowance,
315 ) -> Result<()> {
316 self.confirmed_sessions.clear();
317
318 let sessions = {
319 let manager = mls
320 .read()
321 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
322 manager.list_sessions()?
323 };
324
325 // The session list itself is deliberately not truncated the way a
326 // container-listed walk is: dropping its tail would silently leave
327 // confirmed peers out of `confirmed_sessions`. What needed bounding was
328 // the *deletes* a bad store makes this walk issue — one device barrier
329 // per peer, with nothing counting them until now.
330 let mut budget = allowance.refusing();
331 for peer_id in sessions {
332 // Before anything that can `continue`: an MLS session with this
333 // peer exists, which is the capability fact, and it holds whether
334 // or not the peer's `session_states` record turns out to be
335 // readable. Deriving it from the record instead would reintroduce
336 // exactly the deletable dependency `encryption_capable_peers`
337 // exists to escape.
338 self.mark_encryption_capable(&peer_id);
339
340 // Restore-path read: a record whose bytes will not decode is
341 // dropped and re-bootstrapped as `Pending` rather than failing
342 // initialization forever. The send path deliberately keeps the
343 // strict loader — see `load_restorable_state_record`.
344 let state = match self.load_session_state_for_restore(&peer_id, Some(&mut budget)) {
345 RestorableRecord::Present(state) => state,
346 RestorableRecord::Absent => self.bootstrap_missing_session_state(&peer_id),
347 // A record is still on disk and could not be read *this*
348 // session. Re-bootstrapping would persist `Pending` straight
349 // over one that may say `Confirmed`, and propagating would fail
350 // `initialize_mls` for as long as that single record stays
351 // unreadable — on an install that then cannot send at all.
352 // Skipping is what leaves the record recoverable: the peer is
353 // simply absent from `confirmed_sessions`, and the send path's
354 // own strict loader (`is_session_confirmed`) still fails closed
355 // for it — and re-reads the record, so a transient failure
356 // heals the moment the store answers again.
357 //
358 // Three consequences worth naming rather than rediscovering,
359 // all of them following from the peer being absent from
360 // `confirmed_sessions` for the run:
361 //
362 // 1. The Welcome machinery gates on
363 // `!confirmed_sessions.contains(peer)` *plus* a live
364 // `welcome_lifecycles` entry (`welcome_pending_peers`,
365 // `resend_unconfirmed_sent_welcome`, the retry ladder). So a
366 // peer whose lifecycle restored as `Sent` while its session
367 // state did not can draw a redundant Welcome re-send.
368 // 2. `flush_restored_confirmed_pending_messages` will not flush
369 // this peer's queued pre-session messages this session. They
370 // stay queued — in memory and on disk — and flush once a
371 // launch can read the record, or once a live confirmation
372 // re-adds the peer.
373 // 3. Tier-2 crypto recovery re-seals a resend only for a peer
374 // in `confirmed_sessions` (`reseal_resend_content`), so a
375 // resend to this peer replays its original ciphertext for
376 // the run rather than re-sealing to the current epoch.
377 //
378 // All three are deferrals on an already-degraded store, and all
379 // three are strictly better than the alternative this replaced
380 // — propagating, which failed `initialize_mls` on every launch
381 // for as long as the one record stayed unreadable, on an
382 // install that could then send nothing at all.
383 RestorableRecord::Unavailable => {
384 warn!(
385 session_or_group_id = %peer_id,
386 "Session state could not be read this session; leaving the record in \
387 place and treating the session as unconfirmed"
388 );
389 continue;
390 }
391 };
392 if matches!(state, SessionState::Confirmed) {
393 self.confirmed_sessions.insert(peer_id.clone());
394 }
395 info!(
396 event = "session_state_restored",
397 session_or_group_id = %peer_id,
398 previous_state = "Pending",
399 new_state = %state.as_str(),
400 source_event = "initialize_mls",
401 "session_state_restored"
402 );
403 }
404
405 if budget.exhausted {
406 warn!(
407 deleted = budget.spent,
408 budget = super::storage::MAX_RESTORE_PRUNE_DELETES,
409 "Session state restore hit its share of the launch delete budget; the \
410 unreadable records left behind are re-bootstrapped as Pending anyway and \
411 dropped on a later launch"
412 );
413 }
414
415 Ok(())
416 }
417
418 /// The state a session with no readable record starts from.
419 ///
420 /// Infallible: the migration write is best effort, like every other
421 /// persistence call on the restore path. It used to propagate, so one
422 /// `StoreFailed` — a full disk, a container the OS has locked — failed
423 /// `initialize_mls` outright and, with `require_encryption` on by default,
424 /// left the install unable to send. `Pending` is the safe answer in memory
425 /// either way, and a launch that can write re-derives the record.
426 pub(super) fn bootstrap_missing_session_state(&self, peer_id: &str) -> SessionState {
427 // Legacy session records without explicit state are treated as Pending.
428 // Recovery is driven by probe/ack reconciliation, never by implicit inference.
429 let restored_state = SessionState::Pending;
430 if let Err(e) = self.persist_session_state(
431 peer_id,
432 restored_state,
433 "initialize_mls_missing_state_migration",
434 ) {
435 warn!(
436 session_or_group_id = %peer_id,
437 error = %e,
438 "Failed to persist the bootstrapped session state; the session is Pending for \
439 this run and the record is re-derived on the next launch"
440 );
441 }
442 info!(
443 event = "session_state_transition",
444 session_or_group_id = %peer_id,
445 previous_state = "Absent",
446 new_state = %restored_state.as_str(),
447 source_event = "initialize_mls_missing_state_migration",
448 "session_state_transition"
449 );
450
451 restored_state
452 }
453
454 // ========================================================================
455 // CONFIRMATION RECOVERY
456 // ========================================================================
457
458 pub(super) fn schedule_confirmation_retry(&mut self, peer_id: &str, source_event: &str) {
459 self.confirmation_retry_due_at
460 .insert(peer_id.to_string(), Utc::now());
461 warn!(
462 event = "session_confirmation_retry_scheduled",
463 session_or_group_id = %peer_id,
464 source_event = %source_event,
465 "session_confirmation_retry_scheduled"
466 );
467 }
468
469 pub(super) fn clear_confirmation_recovery_tracking(&mut self, peer_id: &str) {
470 self.confirmation_retry_due_at.remove(peer_id);
471 self.confirmation_probe_due_at.remove(peer_id);
472 self.confirmation_probe_unreachable_parks.remove(peer_id);
473 // Withdrawn with the tracking: once the map forgets it, no later
474 // probe can supersede it, and it would keep its full retry ladder.
475 if let Some(probe) = self.confirmation_probe_outstanding.remove(peer_id) {
476 self.forget_outbound_message(&probe);
477 }
478 self.confirmation_probe_superseded.remove(peer_id);
479 }
480
481 /// Applies a relay verdict on a probe that has since been superseded
482 /// (`confirmation_probe_superseded`) the way the verdict handlers apply
483 /// one on a queued probe: the reachability fact, then the probe back-off.
484 /// Consumed, so one probe's verdict escalates once, as it did while the
485 /// probe was still queued. A no-op for any other id; returns whether
486 /// the verdict was applied.
487 pub(super) fn note_superseded_probe_verdict(
488 &mut self,
489 message_id: &MessageId,
490 carrier: Option<TransportType>,
491 ) -> bool {
492 let Some(peer) = self
493 .confirmation_probe_superseded
494 .iter()
495 .find(|(_, probe)| *probe == message_id)
496 .map(|(peer, _)| peer.clone())
497 else {
498 return false;
499 };
500 self.confirmation_probe_superseded.remove(&peer);
501 if let Some(carrier) = carrier {
502 self.reachability.record(
503 &peer,
504 carrier,
505 Claim::Unreachable,
506 FactSource::GatewayVerdict,
507 Instant::now(),
508 );
509 }
510 self.note_confirmation_probe_unreachable(&peer);
511 true
512 }
513
514 /// Fold a relay "recipient unreachable" verdict into the session-confirmation
515 /// probe schedule: escalate this peer's next probe on the same 15s -> 600s
516 /// ladder the welcome lifecycle uses (see
517 /// [`Self::apply_recipient_unreachable_failure`]). Without this, a peer that
518 /// established an MLS session then vanished before confirming is re-probed
519 /// every `CONFIRMATION_PROBE_INTERVAL_SECS` (5s) indefinitely, because
520 /// `send_session_confirmation_probe` re-arms it on every probe and
521 /// `on_transport_send_failed` otherwise never consults this scheduler. A
522 /// handful of such peers pushes aggregate relay traffic past the
523 /// per-connection rate limit, which disconnects the socket on a loop.
524 ///
525 /// No-op unless the peer is currently a probe target (a `confirmation_probe`
526 /// entry exists, so a probe was just attempted), so ordinary DM failures to
527 /// confirmed peers do not touch the probe schedule. The counter is reset on
528 /// a reachability edge in [`Self::on_peer_presence`] so a returning peer is
529 /// re-probed immediately and its session still converges.
530 pub(super) fn note_confirmation_probe_unreachable(&mut self, peer_id: &str) {
531 if !self.confirmation_probe_due_at.contains_key(peer_id) {
532 return;
533 }
534 let parks = {
535 let counter = self
536 .confirmation_probe_unreachable_parks
537 .entry(peer_id.to_string())
538 .or_insert(0);
539 *counter = counter.saturating_add(1);
540 *counter
541 };
542 // parks >= 1; shift clamped so 15 << 6 = 960 is the largest pre-cap
543 // value (no overflow), then capped — identical to the welcome ladder.
544 let backoff_secs = (WELCOME_NO_CARRIER_RETRY_SECS << (parks - 1).min(6))
545 .min(WELCOME_UNREACHABLE_RETRY_CAP_SECS);
546 self.confirmation_probe_due_at.insert(
547 peer_id.to_string(),
548 Utc::now() + ChronoDuration::seconds(backoff_secs),
549 );
550 }
551
552 pub(super) fn collect_pending_session_peers(&mut self) -> Result<Vec<String>> {
553 let Some(mls) = self.mls_manager.clone() else {
554 return Ok(Vec::new());
555 };
556
557 let sessions = {
558 let manager = mls
559 .read()
560 .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
561 manager.list_sessions()?
562 };
563
564 let mut pending = Vec::new();
565 for peer_id in sessions {
566 if !self.is_session_confirmed(&peer_id)? {
567 pending.push(peer_id);
568 }
569 }
570
571 Ok(pending)
572 }
573
574 pub(super) fn send_session_confirmation_probe(&mut self, peer_id: &str, source_event: &str) {
575 let Some(challenge) = self.session_challenge_body(peer_id) else {
576 debug!(
577 session_or_group_id = %peer_id,
578 source_event = %source_event,
579 "No corroboration to probe with; not probing"
580 );
581 // Next interval rather than the next scan: a tag that keeps
582 // failing would otherwise cost a group load and an export on
583 // every reconciliation pass.
584 let next = Utc::now() + ChronoDuration::seconds(CONFIRMATION_PROBE_INTERVAL_SECS);
585 let due_at = self
586 .confirmation_probe_due_at
587 .entry(peer_id.to_string())
588 .or_insert(next);
589 *due_at = (*due_at).max(next);
590 return;
591 };
592 // Superseded, not failed: the new probe asks the same question, so
593 // the old one's retry ladder would only stack onto it. Here rather
594 // than in the scan because the Welcome fast path sends one too, and
595 // a probe it replaced would keep its full ladder. Silent, and no
596 // delivery failure is recorded against the carrier. Before the send,
597 // not after: if the send errors the peer has no probe in flight
598 // until the next scan, but a new probe can never evict a message at
599 // outbox capacity to make room beside the one it replaces. The
600 // superseded id is kept so a relay verdict that arrives for it late
601 // still backs the schedule off (`note_superseded_probe_verdict`).
602 if let Some(previous) = self.confirmation_probe_outstanding.remove(peer_id) {
603 self.forget_outbound_message(&previous);
604 self.confirmation_probe_superseded
605 .insert(peer_id.to_string(), previous);
606 }
607 // The sender owns the cadence, so a fast-path probe is not superseded
608 // by the next scan before its ACK can arrive, and a relay verdict for
609 // it can back the schedule off (`note_confirmation_probe_unreachable`
610 // only escalates a peer that has a due time). Stamped before the send
611 // so a verdict delivered during it is not overwritten.
612 //
613 // A later due time is kept: the scan only sends once the due time has
614 // passed, but the fast path can fire while the schedule is backed off,
615 // and resetting it would undo the ladder a relay verdict built.
616 let next = Utc::now() + ChronoDuration::seconds(CONFIRMATION_PROBE_INTERVAL_SECS);
617 let due_at = self
618 .confirmation_probe_due_at
619 .entry(peer_id.to_string())
620 .or_insert(next);
621 *due_at = (*due_at).max(next);
622 let probe = format!("{}{}", internal_prefixes::SESSION_CONFIRM_PROBE, challenge);
623 match self.send_internal_message(peer_id, probe, MessagePriority::High) {
624 Ok(message_id) => {
625 self.confirmation_probe_outstanding
626 .insert(peer_id.to_string(), message_id);
627 info!(
628 event = "session_confirmation_probe_sent",
629 session_or_group_id = %peer_id,
630 source_event = %source_event,
631 "session_confirmation_probe_sent"
632 );
633 }
634 Err(err) => {
635 // The send can fail after the probe entered the outbox, and
636 // then no id comes back to track. Any probe still queued for
637 // this peer is such an orphan, since the tracked one was
638 // withdrawn above. (A full ACK tracker no longer lands here:
639 // it holds the probe and returns its id.)
640 let orphans: Vec<_> = self
641 .outbox
642 .values()
643 .filter(|entry| {
644 entry.message.recipient.as_str() == peer_id
645 && Self::is_confirmation_probe(&entry.message)
646 })
647 .map(|entry| entry.message.id.clone())
648 .collect();
649 for orphan in orphans {
650 self.forget_outbound_message(&orphan);
651 }
652 warn!(
653 event = "session_confirmation_probe_failed",
654 session_or_group_id = %peer_id,
655 source_event = %source_event,
656 error = %err,
657 "session_confirmation_probe_failed"
658 );
659 }
660 }
661 }
662
663 /// Throttled wrapper around session reconciliation. Skips the expensive
664 /// `list_sessions()` storage I/O when there is no pending work or when the
665 /// last scan ran recently (within `RECONCILIATION_THROTTLE_MS`).
666 ///
667 /// This is the entry-point called from `process()` and `receive_message()`
668 /// on every tick/poll. Without throttling, the storage call holds the
669 /// protocol Mutex and blocks `sendMessage()` on mobile platforms.
670 pub(super) fn run_throttled_reconciliation(&mut self, source_event: &str) {
671 // Fast path: nothing pending → skip entirely (no storage I/O)
672 let has_pending_work = !self.pending_encrypted_messages.is_empty()
673 || !self.confirmation_probe_due_at.is_empty()
674 || !self.confirmation_retry_due_at.is_empty()
675 || !self.corroboration_reask_at.is_empty();
676
677 if !has_pending_work {
678 return;
679 }
680
681 // Throttle: only run the full scan every RECONCILIATION_THROTTLE_MS
682 let now = Instant::now();
683 let throttle = StdDuration::from_millis(RECONCILIATION_THROTTLE_MS);
684 if let Some(last) = self.last_reconciliation_at {
685 if now.duration_since(last) < throttle {
686 return;
687 }
688 }
689
690 self.last_reconciliation_at = Some(now);
691 self.retry_pending_session_confirmations();
692 self.kick_pending_session_reconciliation(source_event);
693 self.send_due_corroboration_reasks();
694
695 // A message queued for a session is the standing reason to want one,
696 // so a lost key package is re-armed here too, not only on a discovery
697 // that a quiet pair may never produce.
698 let waiting: Vec<String> = self.pending_encrypted_messages.keys().cloned().collect();
699 for peer_id in waiting {
700 self.rearm_key_package_for_peer(&peer_id);
701 }
702 }
703
704 pub(super) fn kick_pending_session_reconciliation(&mut self, source_event: &str) {
705 let now = Utc::now();
706 let pending_peers = match self.collect_pending_session_peers() {
707 Ok(peers) => peers,
708 Err(err) => {
709 warn!(
710 event = "session_confirmation_probe_scan_failed",
711 source_event = %source_event,
712 error = %err,
713 "session_confirmation_probe_scan_failed"
714 );
715 return;
716 }
717 };
718
719 let pending_set: HashSet<String> = pending_peers.iter().cloned().collect();
720 self.confirmation_probe_due_at
721 .retain(|peer, _| pending_set.contains(peer));
722 self.confirmation_probe_unreachable_parks
723 .retain(|peer, _| pending_set.contains(peer));
724 self.confirmation_probe_superseded
725 .retain(|peer, _| pending_set.contains(peer));
726 // Not a plain `retain`: a probe whose peer left the pending set
727 // without passing through `clear_confirmation_recovery_tracking`
728 // would be forgotten while still queued.
729 let settled: Vec<String> = self
730 .confirmation_probe_outstanding
731 .keys()
732 .filter(|peer| !pending_set.contains(*peer))
733 .cloned()
734 .collect();
735 for peer in settled {
736 if let Some(probe) = self.confirmation_probe_outstanding.remove(&peer) {
737 self.forget_outbound_message(&probe);
738 }
739 }
740
741 for peer_id in pending_peers {
742 let due_at = self
743 .confirmation_probe_due_at
744 .get(&peer_id)
745 .copied()
746 .unwrap_or(now);
747 if due_at > now {
748 continue;
749 }
750 self.send_session_confirmation_probe(&peer_id, source_event);
751 }
752 }
753
754 /// Triggers a rate-limited re-key of the 1:1 session with `peer_id` after an
755 /// epoch-desync decrypt failure.
756 ///
757 /// The self-healing primitive is a two-part move, matching the unblock
758 /// `session_reset` flow: tear down our *own* stale session **and** advertise
759 /// a `session_reset` key package. The peer, on receiving it, drops its stale
760 /// session, auto-establishes a fresh one from our enclosed key package, and
761 /// Welcomes us back — which we, now session-less, simply *join*.
762 ///
763 /// Tearing down our session alone (without the reset key package) would
764 /// deadlock: the peer's original key package was consumed at first
765 /// establishment, so nothing could rebuild the channel, and the peer (still
766 /// believing its session is healthy) would never re-advertise one. Sending
767 /// the reset key package is what breaks that deadlock.
768 ///
769 /// Deleting the local session is also what makes convergence **symmetric and
770 /// single-round regardless of user-id ordering**. Because we keep no local
771 /// session, the peer's returning Welcome is joined via the fresh-session
772 /// path rather than the greater-id-adopts tiebreaker in
773 /// `handle_welcome_message` — so the lexicographically-smaller detector is no
774 /// longer stranded (the failure mode when the stale session was kept). Both
775 /// orderings are covered end-to-end by
776 /// `test_desync_dm_heals_end_to_end_when_detector_id_is_greater` and
777 /// `test_desync_dm_heals_end_to_end_when_detector_id_is_smaller`.
778 ///
779 /// Rate-limited via `rekey_due_at` to at most one re-key per
780 /// [`REKEY_INTERVAL_SECS`] per peer, so a peer replaying stale-epoch
781 /// ciphertext (or an injected wrong-epoch frame) cannot drive a re-key storm.
782 /// The interval is stamped before the send so a send error still counts
783 /// against the limit, and it is **never reset early** — not even by a
784 /// successful decrypt on the healed session. That is deliberate: a genuine
785 /// re-fork and a replayed old-epoch frame are indistinguishable at this layer
786 /// (both surface as `WrongEpoch`), so clearing the floor on heal would let an
787 /// attacker who lands one legit decrypt between replays defeat the limit and
788 /// force ~one teardown per inbound message. The floor lapses naturally after
789 /// [`REKEY_INTERVAL_SECS`]; during that window Tier 1 (un-ACK + sender
790 /// retries) keeps delivery honest, so the only cost of not resetting is that a
791 /// genuine second desync within the window heals up to one interval later —
792 /// an acceptable trade for a rare event against a bounded-churn guarantee.
793 ///
794 /// **SECURITY — this trigger is unauthenticated, and cannot be made
795 /// otherwise.** `peer_id` is the *wire-claimed* sender. Three facts compose:
796 /// `__MLS_ENC__` is a data-plane prefix, deliberately exempt from the
797 /// Ed25519 + derivation control-message gate (see `DATA_PLANE_PREFIXES`);
798 /// OpenMLS
799 /// validates the framing header — group id, then epoch — *before* any AEAD,
800 /// sender-data decryption or signature check, so `WrongEpoch` /
801 /// `NoPastEpochData` is produced with the sender still entirely unverified;
802 /// and a 1:1 slot id is `session:<a>:<b>` over two public user ids. So the
803 /// classification that lands here is reachable by **anyone who can inject a
804 /// frame** — no key material, no captured ciphertext, no session, no replay
805 /// needed. `test_forged_frame_reaches_session_desync_without_any_key_material`
806 /// builds such a frame from scratch to keep this statement honest. It is
807 /// inherent to MLS framing, not an OpenMLS defect, so no upgrade changes it.
808 ///
809 /// The `SenderIdentityMismatch` gate cannot help: it compares the MLS
810 /// credential to the claimed sender, and that credential only exists once
811 /// `process_message` **succeeds**. Every pre-authentication verdict is
812 /// structurally outside its reach.
813 ///
814 /// Because the trigger cannot be authenticated, the mitigation is that
815 /// acting on it is **harmless**, not that it is trusted:
816 /// - `SessionManager::decrypt_message` requires the envelope to name the
817 /// slot shared with the claimed sender, so one derivable session id
818 /// cannot be aimed at arbitrary peers, and `rekey_due_at` cannot be grown
819 /// with attacker-chosen keys.
820 /// - [`REKEY_INTERVAL_SECS`] bounds this to one re-key per peer per window.
821 /// - The heal destroys nothing: queued plaintext survives a reset (it seals
822 /// at flush time against the rebuilt session), and Tier 2 re-seals
823 /// in-flight resends.
824 /// - Each re-key emits `SecurityWarningCode::SessionRekeyTriggered`, so a
825 /// sustained rate — the signature of injected frames rather than a real
826 /// fork — is visible to the app.
827 ///
828 /// **Residual:** an injector can still force bounded re-key churn on a pair
829 /// (delayed delivery, never lost). Closing that needs a signed
830 /// epoch-corroboration exchange before teardown; a liveness-only probe does
831 /// not work, since a healthy peer answers and we would tear down anyway.
832 /// Also see `docs/state-machines/session-lifecycle.md` ("Desync and heal")
833 /// and `docs/security/threat-model.md` (residual risk R2).
834 pub(super) fn schedule_session_rekey(&mut self, peer_id: &str) {
835 if !self.claim_rekey_slot(peer_id) {
836 return;
837 }
838
839 // A re-key is remotely triggerable and, until now, entirely silent to
840 // the app. A genuine fork produces these occasionally; a sustained rate
841 // for one peer is the signature of injected frames, which an operator
842 // cannot otherwise distinguish.
843 //
844 // Emitted on this path only. The warning names an epoch desync because
845 // that is what reached it, and an application asking for a re-key on
846 // its own schedule has not seen one: reusing the code there would put a
847 // fixed token in front of two different facts and leave an operator
848 // reading their own maintenance as the attack signature this exists to
849 // surface.
850 self.emit_security_warning(
851 peer_id,
852 SecurityWarningCode::SessionRekeyTriggered,
853 "1:1 session torn down and re-advertised after an epoch desync",
854 );
855 if self
856 .drive_session_rekey(peer_id, "after epoch desync")
857 .is_err()
858 {
859 // The advertisement did not leave, and this path discards the
860 // session anyway. What reached it is a fork: every later frame on
861 // that session decrypts to nothing, so keeping it buys a pair that
862 // stays silently broken rather than one that rebuilds when the
863 // peer is next heard from. The application-driven entry makes the
864 // opposite choice, and for the opposite reason: the session it was
865 // asked to rotate is healthy.
866 self.discard_local_session(peer_id);
867 }
868 }
869
870 /// Takes this peer's re-key slot, or reports that the floor has not lapsed.
871 ///
872 /// The floor is what bounds a peer replaying stale-epoch ciphertext to one
873 /// teardown per window rather than one per frame, and it is shared with the
874 /// application-driven entry deliberately: a re-key costs the same teardown
875 /// and re-exchange whoever asked for it, and a caller looping on it would
876 /// otherwise be able to do to a pair exactly what the bound exists to stop
877 /// an attacker doing.
878 pub(super) fn claim_rekey_slot(&mut self, peer_id: &str) -> bool {
879 let now = Utc::now();
880 if let Some(due_at) = self.rekey_due_at.get(peer_id) {
881 if *due_at > now {
882 return false;
883 }
884 }
885 // Bounded like every other map keyed by a wire-claimed id: the desync
886 // that reaches the caller is classified before MLS authenticates
887 // anything.
888 if !self.rekey_due_at.contains_key(peer_id)
889 && self.rekey_due_at.len() >= MAX_REKEY_TRACKED_PEERS
890 {
891 self.rekey_due_at.clear();
892 }
893 self.rekey_due_at.insert(
894 peer_id.to_string(),
895 now + ChronoDuration::seconds(REKEY_INTERVAL_SECS),
896 );
897 true
898 }
899
900 /// Advertises a reset key package and tears the local session down.
901 ///
902 /// This is the whole of what a re-key does on the wire, shared by the
903 /// desync path and by [`OfflineProtocol::rekey_session`] so the two cannot
904 /// drift into meaning different things to a peer. `reason` appears in the
905 /// log line only.
906 ///
907 /// # Why the advertisement goes first
908 ///
909 /// An `Err` here means **nothing was sent and nothing was torn down**.
910 /// [`TransportManager::send`](crate::TransportManager::send) reports an
911 /// error only once no transport has accepted the frame, so a failure is a
912 /// peer that has been told nothing, and a caller rotating a *healthy*
913 /// session must be able to leave it alone: discarding it on the strength
914 /// of a frame that never left strands the pair, with this side holding no
915 /// session, the peer holding one it will never be told to drop, and
916 /// nothing in either that re-opens the exchange.
917 ///
918 /// The order is invisible to a peer. Both steps run under one `&mut self`,
919 /// so no inbound frame can be processed between them, and what a peer sees
920 /// on success is what this path has always sent. The property the old
921 /// order was written for still holds for the same reason: by the time the
922 /// peer's returning Welcome is processed there is no local session left,
923 /// so it is *joined* rather than gated by the greater-id-adopts tiebreaker
924 /// in `handle_welcome_message`, and both sides rebuild in a single round.
925 pub(super) fn drive_session_rekey(&mut self, peer_id: &str, reason: &str) -> Result<()> {
926 if let Err(err) = self.send_key_package_to(peer_id, true) {
927 warn!(
928 event = "session_rekey_send_failed",
929 peer_id = %peer_id,
930 reason = %reason,
931 error = %err,
932 "session_rekey_send_failed"
933 );
934 return Err(err);
935 }
936 self.discard_local_session(peer_id);
937 info!(
938 event = "session_rekey_triggered",
939 peer_id = %peer_id,
940 reason = %reason,
941 "Triggered 1:1 session re-key"
942 );
943 Ok(())
944 }
945
946 /// Drops this side's 1:1 session with `peer_id`, best effort.
947 ///
948 /// Mirrors the unblock `session_reset` flow. A missing session is a no-op,
949 /// and a delete that fails is logged rather than raised: the peer has
950 /// already been sent the reset by every caller that reaches here, and the
951 /// rebuild plus the auto key-package exchange re-arm establishment either
952 /// way.
953 fn discard_local_session(&mut self, peer_id: &str) {
954 if self.has_mls_session(peer_id).unwrap_or(false) {
955 if let Err(err) = self.manual_mls_delete_session(peer_id) {
956 debug!(
957 peer_id = %peer_id,
958 error = %err,
959 "rekey: failed to delete local stale session"
960 );
961 }
962 }
963 }
964
965 /// Gives back a re-key slot claimed for work that did not happen.
966 ///
967 /// Only the application-driven entry and the split heal may call this,
968 /// and only when the advertisement never left: no teardown happened, so
969 /// nothing was spent and a caller back in range a second later should not
970 /// be made to wait out a window that bounds teardowns. Both drop nothing
971 /// unless the push left. The desync path must **not**, since it discards
972 /// the session whether or not the reset was sent, and that is exactly the
973 /// churn the floor exists to bound.
974 pub(super) fn release_rekey_slot(&mut self, peer_id: &str) {
975 self.rekey_due_at.remove(peer_id);
976 }
977
978 pub(super) fn retry_pending_session_confirmations(&mut self) {
979 let now = Utc::now();
980 let due_peers: Vec<String> = self
981 .confirmation_retry_due_at
982 .iter()
983 .filter_map(|(peer_id, due_at)| {
984 if *due_at <= now {
985 Some(peer_id.clone())
986 } else {
987 None
988 }
989 })
990 .collect();
991
992 for peer_id in due_peers {
993 match self.has_mls_session(&peer_id) {
994 Ok(true) => {}
995 Ok(false) => {
996 self.confirmation_retry_due_at.remove(&peer_id);
997 continue;
998 }
999 Err(err) => {
1000 warn!(
1001 event = "session_confirmation_retry_scan_failed",
1002 session_or_group_id = %peer_id,
1003 error = %err,
1004 "session_confirmation_retry_scan_failed"
1005 );
1006 self.confirmation_retry_due_at.insert(
1007 peer_id,
1008 now + ChronoDuration::seconds(CONFIRMATION_RETRY_INTERVAL_SECS),
1009 );
1010 continue;
1011 }
1012 }
1013
1014 if !self.can_confirm_from_source(&peer_id, "confirmation_retry") {
1015 debug!(
1016 peer_id = %peer_id,
1017 "Skipping confirmation retry until welcome send is at least attempted"
1018 );
1019 continue;
1020 }
1021
1022 match self.confirm_session_state(&peer_id, "confirmation_retry") {
1023 Ok(_) => {
1024 let _ = self.flush_pending_messages(&peer_id);
1025 self.process_pending_decryption(&peer_id);
1026 }
1027 Err(err) => {
1028 warn!(
1029 event = "session_confirmation_retry_failed",
1030 session_or_group_id = %peer_id,
1031 error = %err,
1032 "session_confirmation_retry_failed"
1033 );
1034 self.confirmation_retry_due_at.insert(
1035 peer_id,
1036 now + ChronoDuration::seconds(CONFIRMATION_RETRY_INTERVAL_SECS),
1037 );
1038 }
1039 }
1040 }
1041 }
1042
1043 /// Marks `peer_id` as a both-create owner gate — we kept our own group and
1044 /// must observe a group-aware decrypt before confirming — and persists it so
1045 /// an owner restart mid-convergence cannot let a stale plaintext probe/ack
1046 /// confirm prematurely. Only writes storage on a genuine insert.
1047 pub(super) fn mark_both_create_awaiting_decrypt(&mut self, peer_id: &str) {
1048 if self
1049 .both_create_awaiting_decrypt
1050 .insert(peer_id.to_string())
1051 {
1052 self.persist_both_create_awaiting_decrypt(peer_id);
1053 }
1054 }
1055
1056 /// Clears the both-create owner gate for `peer_id` (it has converged) from
1057 /// both memory and storage. Only writes storage on a genuine removal.
1058 pub(super) fn clear_both_create_awaiting_decrypt(&mut self, peer_id: &str) {
1059 if self.both_create_awaiting_decrypt.remove(peer_id) {
1060 self.delete_both_create_awaiting_decrypt(peer_id);
1061 }
1062 }
1063
1064 pub(super) fn can_confirm_from_source(&self, peer_id: &str, source_event: &str) -> bool {
1065 // Both-create owner: we kept our own group and are waiting for the peer to
1066 // prove it adopted *our* group. Only a successful decrypt is group-aware
1067 // proof of that; a plaintext probe/ack proves only that the peer holds
1068 // some session (possibly its own, pre-adoption group), so it must not
1069 // confirm us or we would stop retransmitting and strand the peer.
1070 if self.both_create_awaiting_decrypt.contains(peer_id)
1071 && source_event != "decrypt_success"
1072 && source_event != SESSION_CORROBORATED
1073 {
1074 return false;
1075 }
1076
1077 // A successful group decrypt is definitive, group-aware proof that the
1078 // peer adopted our 1:1 group. `decrypt_success` is raised only inside
1079 // `DecryptResult::Success` (see message_dispatch.rs): ciphertext on the
1080 // single stored `session:<a>:<b>` group actually decrypted, which is
1081 // possible only if the peer is a member. It must therefore confirm the
1082 // session regardless of our *local* Welcome's delivery state. Gating it on
1083 // a still-active local Welcome (the lifecycle match below) strands a
1084 // both-create owner whose own Welcome timed out to `Failed`/`Expired` on a
1085 // lossy or asymmetric BLE link (the Android↔iOS case): the owner decrypts
1086 // the peer's messages but never confirms, so every outbound send fails
1087 // `SessionNotReady` — it can receive but never reply. The both-create gate
1088 // above is preserved, so a both-create owner is still restricted to *only*
1089 // decrypt_success (a plaintext probe/ack can never confirm it).
1090 //
1091 // A corroborated probe or acknowledgement is the same proof without a
1092 // message to carry it: its tag is computable only by a member of this
1093 // very group at this epoch (`SessionCorroboration`).
1094 if source_event == "decrypt_success" || source_event == SESSION_CORROBORATED {
1095 return true;
1096 }
1097
1098 if !matches!(
1099 source_event,
1100 "confirmation_ack_received" | "confirmation_probe_received" | "confirmation_retry"
1101 ) {
1102 return true;
1103 }
1104
1105 match self.welcome_lifecycles.get(peer_id) {
1106 Some(record) => matches!(
1107 record.state,
1108 WelcomeDeliveryState::Sent | WelcomeDeliveryState::SendAttempted
1109 ),
1110 None => matches!(
1111 source_event,
1112 // Compatibility path for sessions created before welcome lifecycle
1113 // persistence existed. A plaintext probe/ack/retry can confirm here
1114 // (it is the only evidence such a legacy session has); a group-aware
1115 // decrypt already returned true above and never reaches this arm.
1116 "confirmation_ack_received" | "confirmation_probe_received" | "confirmation_retry"
1117 ),
1118 }
1119 }
1120
1121 /// Whether this device gives way to `remote_id` when the two hold
1122 /// competing groups for their slot: the greater address adopts.
1123 ///
1124 /// Addresses compare as addresses, hash bytes, the order
1125 /// `GroupId::for_session` canonicalizes slots with, because the bech32
1126 /// charset is not ASCII-monotonic and comparing the rendered strings would
1127 /// give a different answer than every other address comparison. Any total
1128 /// order converges as long as both peers use the same one; ids that are
1129 /// not both addresses keep string order.
1130 pub(super) fn gives_way_to(&self, remote_id: &str) -> bool {
1131 match (
1132 self.local_id.parse::<offline_protocol_core::Address>(),
1133 remote_id.parse::<offline_protocol_core::Address>(),
1134 ) {
1135 (Ok(local), Ok(remote)) => local > remote,
1136 _ => self.local_id.as_str() > remote_id,
1137 }
1138 }
1139
1140 /// This side's corroboration of its session with `peer_id` over `nonce`,
1141 /// as a frame body, or `None` without a session.
1142 fn corroboration_body(&self, peer_id: &str, nonce: &[u8]) -> Option<String> {
1143 let mls = self.mls_manager.as_ref()?;
1144 let manager = mls.read().ok()?;
1145 let (epoch, tag) = manager.session_corroboration(peer_id, nonce).ok()??;
1146 SessionCorroboration {
1147 epoch,
1148 nonce: nonce.to_vec(),
1149 tag,
1150 }
1151 .to_body()
1152 }
1153
1154 /// When this side's session with `peer_id` was established, which names
1155 /// that session: a new one is stamped on every create and join.
1156 fn session_incarnation(&self, peer_id: &str) -> Option<u64> {
1157 self.mls_manager
1158 .as_ref()
1159 .and_then(|mls| mls.read().ok())
1160 .and_then(|manager| manager.session_established_at_ms(peer_id).ok())
1161 .flatten()
1162 }
1163
1164 /// The body of a confirmation probe to `peer_id`: a challenge. `None`
1165 /// when this side cannot compute one (no session, a store read or export
1166 /// that failed), and the probe is then not sent. Never an empty body: the
1167 /// peer reads that as an older device and confirms on the plaintext
1168 /// rules, which is how a split used to confirm.
1169 ///
1170 /// One challenge per peer and session, carried by every probe until it
1171 /// is answered: an unconfirmed session is probed every few seconds, and a
1172 /// fresh nonce per probe would retire each challenge before an answer
1173 /// over a slow path (the mesh, a relay) could come back, so a split
1174 /// there would never heal.
1175 pub(super) fn session_challenge_body(&mut self, peer_id: &str) -> Option<String> {
1176 let incarnation = self.session_incarnation(peer_id);
1177 let nonce = match self.corroboration_challenges.get(peer_id) {
1178 Some((nonce, issued_under)) if *issued_under == incarnation => *nonce,
1179 _ => *uuid::Uuid::new_v4().as_bytes(),
1180 };
1181 let body = self.corroboration_body(peer_id, &nonce)?;
1182 if !self.corroboration_challenges.contains_key(peer_id)
1183 && self.corroboration_challenges.len() >= MAX_REKEY_TRACKED_PEERS
1184 {
1185 self.corroboration_challenges.clear();
1186 }
1187 self.corroboration_challenges
1188 .insert(peer_id.to_string(), (nonce, incarnation));
1189 Some(body)
1190 }
1191
1192 /// The body of an acknowledgement answering `probe`: this side's tag over
1193 /// the prober's own nonce, so the prober can tell an answer to its
1194 /// challenge from any other frame. Empty for a probe with no
1195 /// corroboration, from an older peer that reads no body. `None` when the
1196 /// tag cannot be computed, and the acknowledgement is then not sent: an
1197 /// empty body would be read as an older device and confirmed on the
1198 /// plaintext rules.
1199 pub(super) fn session_answer_body(
1200 &self,
1201 peer_id: &str,
1202 probe: Option<&SessionCorroboration>,
1203 ) -> Option<String> {
1204 match probe {
1205 None => Some(String::new()),
1206 Some(probe) => self.corroboration_body(peer_id, &probe.nonce),
1207 }
1208 }
1209
1210 /// Whether `nonce` is the challenge this side has out to `peer_id`, issued
1211 /// under the session it holds now. Spent by the asking: the first answer
1212 /// retires it, and the next probe carries a new one.
1213 pub(super) fn answers_live_challenge(&mut self, peer_id: &str, nonce: &[u8]) -> bool {
1214 let incarnation = self.session_incarnation(peer_id);
1215 match self.corroboration_challenges.get(peer_id) {
1216 Some((issued, issued_under)) if issued.as_slice() == nonce => {
1217 let live = *issued_under == incarnation;
1218 self.corroboration_challenges.remove(peer_id);
1219 live
1220 }
1221 _ => false,
1222 }
1223 }
1224
1225 /// What a probe or acknowledgement's corroboration from `peer_id` says
1226 /// about the session the two sides hold.
1227 pub(super) fn corroborate_session(
1228 &self,
1229 peer_id: &str,
1230 theirs: Option<&SessionCorroboration>,
1231 ) -> Corroboration {
1232 let Some(theirs) = theirs else {
1233 return Corroboration::Absent;
1234 };
1235 let ours = self
1236 .mls_manager
1237 .as_ref()
1238 .and_then(|mls| mls.read().ok())
1239 .and_then(|manager| manager.session_corroboration(peer_id, &theirs.nonce).ok())
1240 .flatten();
1241 match ours {
1242 Some((epoch, tag)) if epoch == theirs.epoch && tag == theirs.tag => {
1243 Corroboration::Agrees
1244 }
1245 Some((epoch, _)) if epoch == theirs.epoch => Corroboration::Split,
1246 _ => Corroboration::Inconclusive,
1247 }
1248 }
1249
1250 /// Asks `peer_id` to corroborate a session that just failed an ordinary
1251 /// decrypt, or that a probe showed split, at most once per re-key
1252 /// interval.
1253 ///
1254 /// Invariant: a split between two sides that have both confirmed is
1255 /// found by the first frame that fails between them. Confirmation probes
1256 /// stop once a session confirms, so without this a split that formed
1257 /// another way (an older peer's plaintext confirmation, a restored
1258 /// store) would fail every frame for the life of the pair.
1259 ///
1260 /// A failure only asks; it never tears anything down, which is what ADR
1261 /// 0006 requires of a class anyone corrupting a frame can reach. A
1262 /// healthy session answers with a matching tag and nothing happens.
1263 ///
1264 /// Sent apart from the confirmation probes, because a confirmed session
1265 /// has none: one tracked as a confirmation probe was withdrawn from the
1266 /// outbox by the next reconciliation scan, so a first send that did not
1267 /// leave was never retried. A confirmation probe whose own send fails
1268 /// still clears every probe queued for the peer, this one included; only
1269 /// an unconfirmed session reaches that, and its next probe carries the
1270 /// same challenge.
1271 pub(super) fn request_session_corroboration(&mut self, peer_id: &str) {
1272 let now = Utc::now();
1273 if self
1274 .corroboration_due_at
1275 .get(peer_id)
1276 .is_some_and(|due_at| *due_at > now)
1277 {
1278 return;
1279 }
1280 // Built first, so a tag that could not be computed this time does not
1281 // spend the window: the next failure asks again.
1282 let Some(challenge) = self.session_challenge_body(peer_id) else {
1283 return;
1284 };
1285 if !self.corroboration_due_at.contains_key(peer_id)
1286 && self.corroboration_due_at.len() >= MAX_REKEY_TRACKED_PEERS
1287 {
1288 self.corroboration_due_at.clear();
1289 }
1290 self.corroboration_due_at.insert(
1291 peer_id.to_string(),
1292 now + ChronoDuration::seconds(REKEY_INTERVAL_SECS),
1293 );
1294 let probe = format!("{}{}", internal_prefixes::SESSION_CONFIRM_PROBE, challenge);
1295 if let Err(err) = self.send_internal_message(peer_id, probe, MessagePriority::High) {
1296 debug!(peer_id = %peer_id, error = %err, "Corroboration probe deferred");
1297 }
1298 }
1299
1300 /// Asks again each peer whose split heal the re-key floor held back, once
1301 /// the floor has opened. The ask's own limit is lifted first: it was set
1302 /// by the ask that drew the held answer, and would otherwise swallow this
1303 /// one.
1304 pub(super) fn send_due_corroboration_reasks(&mut self) {
1305 let now = Utc::now();
1306 let due: Vec<String> = self
1307 .corroboration_reask_at
1308 .iter()
1309 .filter(|(_, at)| **at <= now)
1310 .map(|(peer, _)| peer.clone())
1311 .collect();
1312 for peer_id in due {
1313 self.corroboration_reask_at.remove(&peer_id);
1314 self.corroboration_due_at.remove(&peer_id);
1315 self.request_session_corroboration(&peer_id);
1316 }
1317 }
1318
1319 /// Acts on a corroboration from `peer_id` that shows a split.
1320 ///
1321 /// Invariant: a split is ended by exactly one side, the one that
1322 /// [`gives_way_to`](Self::gives_way_to) the other, and only on an
1323 /// acknowledgement answering a challenge it issued under the session it
1324 /// holds now (`answered_our_challenge`). Anything else, a probe, or an
1325 /// acknowledgement of an older challenge, may be a frame sealed before a
1326 /// heal and delivered late, an outbox retry carrying an earlier group's
1327 /// tag, or a replay: acting on those tore down healthy sessions. Such a
1328 /// frame only makes the greater side ask for itself, so a real split is
1329 /// still healed one round trip later.
1330 ///
1331 /// The heal gives up this side's group: a key package saying this side
1332 /// holds no session goes out first, then the local group is dropped. The
1333 /// peer answers as for any `no_session` package. When its own Welcome
1334 /// names the package, which it does after a both-create race because
1335 /// that package was never consumed, it sends that Welcome again and
1336 /// keeps its group, and this side joins it. A Welcome merely delayed
1337 /// rather than lost is then joined too, where a reset would have made the
1338 /// peer rebuild on the same package and left the delayed Welcome to split
1339 /// the pair again. Otherwise the peer rebuilds from the package. It
1340 /// shares the re-key floor, and a push that could not be sent drops
1341 /// nothing and gives the slot back: the peer was told nothing, and the
1342 /// next challenge asks again.
1343 pub(super) fn heal_split_session(&mut self, peer_id: &str, answered_our_challenge: bool) {
1344 let gives_way = self.gives_way_to(peer_id);
1345 if !(gives_way && answered_our_challenge) {
1346 if gives_way {
1347 self.request_session_corroboration(peer_id);
1348 }
1349 return;
1350 }
1351 if !self.claim_rekey_slot(peer_id) {
1352 // The answer spent the challenge, and a quiet confirmed pair has no
1353 // failing frame to ask again. Asking now would do nothing (the ask
1354 // that drew this answer has just set its own limit) and an answer
1355 // would meet the same floor, so ask once it opens.
1356 let opens = self
1357 .rekey_due_at
1358 .get(peer_id)
1359 .copied()
1360 .unwrap_or_else(|| Utc::now() + ChronoDuration::seconds(REKEY_INTERVAL_SECS));
1361 if !self.corroboration_reask_at.contains_key(peer_id)
1362 && self.corroboration_reask_at.len() >= MAX_REKEY_TRACKED_PEERS
1363 {
1364 self.corroboration_reask_at.clear();
1365 }
1366 self.corroboration_reask_at
1367 .insert(peer_id.to_string(), opens);
1368 return;
1369 }
1370 if let Err(err) = self.push_key_package(peer_id, false, true) {
1371 debug!(peer_id = %peer_id, error = %err, "Split heal deferred");
1372 self.release_rekey_slot(peer_id);
1373 return;
1374 }
1375 // Here and not on every mismatching frame: an unconfirmed split side
1376 // is probed every few seconds, and the event reaches the application
1377 // and telemetry. Once per heal that went out, bounded by the re-key
1378 // floor.
1379 self.emit_event(Event::convergence_diag(
1380 "session_split_detected".to_string(),
1381 peer_id.to_string(),
1382 "heals=true".to_string(),
1383 ));
1384 info!(peer_id = %peer_id, "Gave up our group of a split session to join the peer's");
1385 self.discard_local_session(peer_id);
1386 }
1387
1388 pub(super) fn abort_pending_session_for_peer(
1389 &mut self,
1390 peer_id: &str,
1391 reason: crate::events::WelcomeReasonCode,
1392 ) {
1393 self.drop_pending_queue_for_peer(peer_id, reason.welcome_failure_reason());
1394 if let Ok(state) = lock_shared_state(&self.shared_state) {
1395 state.emit_event(Event::secure_session_failed(
1396 peer_id.to_string(),
1397 format!("Welcome delivery failed: {}", reason.as_str()),
1398 ));
1399 }
1400 }
1401
1402 pub(super) fn maybe_emit_local_session_established(
1403 &self,
1404 peer_id: &str,
1405 context: MlsOperationContext,
1406 ) {
1407 let Some(record) = self.welcome_lifecycles.get(peer_id) else {
1408 return;
1409 };
1410 if !matches!(record.state, WelcomeDeliveryState::Sent) {
1411 return;
1412 }
1413 self.emit_mls_session_ready(peer_id, &record.group_id, context);
1414 if let Ok(state) = lock_shared_state(&self.shared_state) {
1415 state.emit_event(Event::secure_session_established(
1416 peer_id.to_string(),
1417 record.group_id.clone(),
1418 record.group_id.starts_with("session:"),
1419 true,
1420 ));
1421 }
1422 }
1423
1424 // ========================================================================
1425 // WELCOME LIFECYCLE
1426 // ========================================================================
1427
1428 /// `user_send` is true when a user's own send created the session: their
1429 /// message waits on this Welcome, so its first send must alert (see
1430 /// [`Self::try_send_welcome`]).
1431 pub(super) fn send_welcome_message(
1432 &mut self,
1433 recipient: &str,
1434 welcome: &WelcomeMessage,
1435 user_send: bool,
1436 ) -> Result<bool> {
1437 let serialized =
1438 serde_json::to_string(welcome).map_err(|e| Error::Serialization(e.to_string()))?;
1439 let content = format!("{}{}", internal_prefixes::WELCOME, serialized);
1440 let mut message =
1441 self.create_message(recipient, content, Some(MessagePriority::High), None)?;
1442 self.sign_control_message(&mut message)?;
1443 let group_id = welcome.group_id.as_str().to_string();
1444
1445 self.upsert_welcome_lifecycle(recipient, &group_id, message, "welcome_created")?;
1446 self.try_send_welcome(recipient, "welcome_initial_send", user_send)
1447 }
1448
1449 /// Parks a Welcome that currently has no transport carrier: refresh the
1450 /// carrier-relative TTL and schedule a slow re-check, WITHOUT consuming a
1451 /// retry attempt, transitioning state, or emitting a `WelcomeSendAttempted`
1452 /// event. A no-carrier send is a guaranteed failure, so re-attempting it at
1453 /// the data-plane retry rate only churns storage I/O and app events while a
1454 /// device is offline. The lifecycle stays in its current non-terminal state
1455 /// (Created on the first send, Failed on a later retry) so the retry queue
1456 /// re-polls it on [`WELCOME_NO_CARRIER_RETRY_SECS`]; `on_neighbor_discovered`
1457 /// re-arms it immediately when a carrier surfaces the peer. Returns
1458 /// `Ok(false)` (not sent).
1459 ///
1460 /// **Invariant: the refreshed TTL always outlives the retry being
1461 /// scheduled.** [`Self::try_send_welcome`] checks `expires_at` *before*
1462 /// running a due retry, so a record whose TTL lapsed while it waited gets
1463 /// expired terminally (`welcome_send_expired` + `secure_session_failed`) by
1464 /// the very timer meant to recover it. That is not hypothetical at the far
1465 /// end of the unreachable ladder in
1466 /// [`Self::apply_recipient_unreachable_failure`]: it reaches 480s at six
1467 /// consecutive parks and [`WELCOME_UNREACHABLE_RETRY_CAP_SECS`] (600s)
1468 /// beyond that, both above the 300s [`WELCOME_LIFECYCLE_TTL_SECS`]. A full
1469 /// extra interval of slack keeps the probe's own outcome — a verdict
1470 /// re-park or a confirm timeout — inside the window too. No-op for the 15s
1471 /// no-carrier park, whose interval is far below the TTL.
1472 fn park_welcome_no_carrier(
1473 &mut self,
1474 peer_id: &str,
1475 reason: crate::events::WelcomeReasonCode,
1476 transport_error: Option<&'static str>,
1477 retry_in_secs: i64,
1478 ) -> Result<bool> {
1479 let snapshot = {
1480 let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
1481 return Ok(false);
1482 };
1483 let now = Utc::now();
1484 record.next_retry_at = Some(now + ChronoDuration::seconds(retry_in_secs));
1485 record.expires_at = now
1486 + ChronoDuration::seconds(
1487 WELCOME_LIFECYCLE_TTL_SECS.max(retry_in_secs.saturating_mul(2)),
1488 );
1489 record.last_reason_code = Some(reason);
1490 record.last_transport_error = transport_error.map(str::to_string);
1491 record.clone()
1492 };
1493 self.persist_welcome_lifecycle_entry(&snapshot)?;
1494 debug!(
1495 peer_id = %peer_id,
1496 state = snapshot.state.as_str(),
1497 retry_in_secs,
1498 "Welcome parked: no transport carrier, re-checking on slow interval"
1499 );
1500 Ok(false)
1501 }
1502
1503 /// The Welcome is the frame that wakes an offline peer for a first
1504 /// message queued behind it, so the relay push is decided per send rather
1505 /// than baked into the stored frame: it alerts when `user_send` (the
1506 /// message is being queued right now) or when user content is already
1507 /// waiting for the peer, and is otherwise
1508 /// [`crate::constants::NO_ALERT_KEY`]-marked. Metadata is outside the
1509 /// control signature, so changing it here leaves the frame verifiable.
1510 pub(super) fn try_send_welcome(
1511 &mut self,
1512 peer_id: &str,
1513 source_event: &str,
1514 user_send: bool,
1515 ) -> Result<bool> {
1516 let now = Utc::now();
1517 let mut record = self
1518 .welcome_lifecycles
1519 .get(peer_id)
1520 .cloned()
1521 .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
1522
1523 if matches!(record.state, WelcomeDeliveryState::Sent) {
1524 return Ok(true);
1525 }
1526 if matches!(record.state, WelcomeDeliveryState::Expired) {
1527 return Ok(false);
1528 }
1529
1530 // Only honor the TTL when a carrier actually exists: a Welcome that has
1531 // never had a deliverable transport must not age out.
1532 let carrier_available = !self.transport_manager.get_available_transports().is_empty();
1533 if carrier_available && record.expires_at <= now {
1534 self.transition_welcome_state(peer_id, WelcomeDeliveryState::Expired, source_event)?;
1535 if let Ok(state) = lock_shared_state(&self.shared_state) {
1536 state.emit_event(Event::welcome_send_expired(
1537 peer_id.to_string(),
1538 record.welcome_message.id.as_str().to_string(),
1539 record.attempt,
1540 crate::events::WelcomeReasonCode::RetryExhausted,
1541 ));
1542 }
1543 self.abort_pending_session_for_peer(
1544 peer_id,
1545 crate::events::WelcomeReasonCode::RetryExhausted,
1546 );
1547 return Ok(false);
1548 }
1549
1550 // No carrier at all: a send is guaranteed to fail, so do NOT burn the
1551 // speculative attempt increment, the SendAttempted/Failed transitions
1552 // (two persisted state changes + two info logs), and a
1553 // `WelcomeSendAttempted` event on it every retry tick. Park the
1554 // lifecycle on a slow carrier-relative interval instead and re-check
1555 // later; `on_neighbor_discovered` re-arms it immediately when a carrier
1556 // surfaces the peer. This keeps an offline device quiet rather than
1557 // spinning storage I/O + app events at the data-plane retry rate.
1558 if !carrier_available {
1559 return self.park_welcome_no_carrier(
1560 peer_id,
1561 crate::events::WelcomeReasonCode::TransportUnavailable,
1562 None,
1563 WELCOME_NO_CARRIER_RETRY_SECS,
1564 );
1565 }
1566
1567 record.attempt = record.attempt.saturating_add(1);
1568 self.welcome_lifecycles
1569 .insert(peer_id.to_string(), record.clone());
1570 self.persist_welcome_lifecycle_entry(&record)?;
1571 self.transition_welcome_state(peer_id, WelcomeDeliveryState::SendAttempted, source_event)?;
1572
1573 if let Ok(state) = lock_shared_state(&self.shared_state) {
1574 state.emit_event(Event::welcome_send_attempted(
1575 peer_id.to_string(),
1576 record.welcome_message.id.as_str().to_string(),
1577 record.group_id.clone(),
1578 record.attempt,
1579 ));
1580 }
1581
1582 // A Welcome for a peer no carrier is known to reach is handed to
1583 // neighbours to carry, as the key package that produced it was: a
1584 // session between two devices that only reach each other through a
1585 // third is built from exactly these two frames crossing it. Carried
1586 // or sent, it is equally unproved, so it takes the same in-flight
1587 // path below and the peer's probe or decrypt is still what settles
1588 // it. A carried one waits the mesh confirmation timeout, since a
1589 // neighbour is a mesh link whatever the selector last picked.
1590 let mut welcome = record.welcome_message.clone();
1591 if user_send || self.has_user_content_waiting_for(peer_id) {
1592 welcome.metadata.remove(crate::constants::NO_ALERT_KEY);
1593 } else {
1594 welcome
1595 .metadata
1596 .insert(crate::constants::NO_ALERT_KEY.to_string(), "1".to_string());
1597 }
1598 let sent = self
1599 .send_or_carry_handshake(&welcome)
1600 .map(|handed_to_mesh| {
1601 if handed_to_mesh > 0 {
1602 None
1603 } else {
1604 self.transport_manager.current_transport()
1605 }
1606 });
1607
1608 match sent {
1609 Ok(transport_used) => {
1610 let mut updated =
1611 self.welcome_lifecycles
1612 .get(peer_id)
1613 .cloned()
1614 .ok_or_else(|| {
1615 Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
1616 })?;
1617
1618 // No transport's send() proves the peer reassembled and joined
1619 // the group, so keep the lifecycle NON-TERMINAL until an explicit
1620 // confirmation arrives and let the retry queue re-send on timeout.
1621 //
1622 // - Internet send() only enqueues for platform polling; the
1623 // confirmation is on_transport_send_confirmed.
1624 // - A BLE / WiFi-Direct GATT WRITE_TYPE_NO_RESPONSE returning Ok
1625 // only means the local stack accepted the bytes. A single lost
1626 // fragment of the multi-fragment Welcome would otherwise leave
1627 // the sender believing the session exists while the peer holds
1628 // undecryptable ciphertext, with NO retry — permanently
1629 // bricking the pair. The mesh confirmation is the peer proving
1630 // the session (its probe / ack / welcome / decrypt), which marks
1631 // the welcome Sent via confirm_session_state.
1632 let confirm_timeout_secs =
1633 if matches!(transport_used, Some(TransportType::Internet)) {
1634 WELCOME_INTERNET_CONFIRM_TIMEOUT_SECS
1635 } else {
1636 WELCOME_MESH_CONFIRM_TIMEOUT_SECS
1637 };
1638 updated.next_retry_at =
1639 Some(Utc::now() + ChronoDuration::seconds(confirm_timeout_secs));
1640 updated.last_reason_code = None;
1641 updated.last_transport_error = None;
1642 self.welcome_lifecycles
1643 .insert(peer_id.to_string(), updated.clone());
1644 self.persist_welcome_lifecycle_entry(&updated)?;
1645
1646 // Seed the confirmation-probe scheduler for this pending peer so
1647 // the SENDER actively reconciles instead of waiting passively.
1648 //
1649 // The Welcome is now in flight but unconfirmed. The sender's only
1650 // built-in convergence path would otherwise be the receiver's
1651 // single proactive encrypted confirm on first Welcome receipt — a
1652 // single point of failure with no retry (a lost fragment, a
1653 // not-yet-ready encryptor, or the retransmit/owner_keep path on
1654 // the receiver all strand it). By marking a probe due now,
1655 // `run_throttled_reconciliation` (whose `has_pending_work` gate is
1656 // otherwise false on the sender, since it has no pending encrypted
1657 // messages) starts emitting `SESSION_CONFIRM_PROBE`s. The peer —
1658 // which holds the session — replies with `SESSION_CONFIRM_ACK`,
1659 // confirming us and marking this Welcome `Sent`. The entry is
1660 // dropped once confirmed (`clear_confirmation_recovery_tracking`)
1661 // or once the peer is no longer pending (`kick`'s retain), so this
1662 // adds no steady-state work after convergence.
1663 self.confirmation_probe_due_at
1664 .insert(peer_id.to_string(), Utc::now());
1665
1666 Ok(false)
1667 }
1668 Err(err) => {
1669 let no_carrier = Self::is_no_carrier_error(&err);
1670 let reason = Self::map_welcome_reason_code(&err);
1671 // Classified, not rendered: the transport layer interpolates
1672 // the peer into `PeerNotReachable`, and this value is both
1673 // persisted with the lifecycle record and emitted on
1674 // `WelcomeSendFailed.transport_error`, where the scrubber
1675 // hashes `peer_id` beside it. The full error is logged above.
1676 self.apply_welcome_send_failure(
1677 peer_id,
1678 reason,
1679 Some(send_failure_token(&err)),
1680 no_carrier,
1681 source_event,
1682 )
1683 }
1684 }
1685 }
1686
1687 /// Re-arms a stalled or expired outbound Welcome when a peer becomes
1688 /// reachable again, so a session that never converged while the peer was
1689 /// offline gets a fresh delivery attempt over the now-available carrier.
1690 ///
1691 /// No-op when the peer's session is already confirmed, when there is no
1692 /// Welcome lifecycle, or when the Welcome is already `Sent` or still
1693 /// in-flight (`SendAttempted`). An `Expired` lifecycle is rebuilt from its
1694 /// retained Welcome message — the MLS group is not torn down on expiry, so
1695 /// the stored Welcome is still valid to re-send — while a `Created`/`Failed`
1696 /// lifecycle has its budget and TTL reset and is retried immediately.
1697 pub(super) fn rearm_welcome_for_peer(&mut self, peer_id: &str, source_event: &str) {
1698 if self.confirmed_sessions.contains(peer_id) {
1699 return;
1700 }
1701 self.resend_welcome(peer_id, source_event, false);
1702 }
1703
1704 /// Sends the retained Welcome to `peer_id` again, unless it is in flight
1705 /// or delivered. When `peer_never_got_it`, one in flight or delivered is
1706 /// sent again too: the peer has said it holds no session, so neither
1707 /// attempt reached it.
1708 pub(super) fn resend_welcome(
1709 &mut self,
1710 peer_id: &str,
1711 source_event: &str,
1712 peer_never_got_it: bool,
1713 ) {
1714 let Some(record) = self.welcome_lifecycles.get(peer_id).cloned() else {
1715 return;
1716 };
1717 match record.state {
1718 WelcomeDeliveryState::SendAttempted | WelcomeDeliveryState::Sent
1719 if !peer_never_got_it => {}
1720 WelcomeDeliveryState::SendAttempted => {
1721 // Settled as lost, the one legal way back to a send.
1722 if self
1723 .transition_welcome_state(peer_id, WelcomeDeliveryState::Failed, source_event)
1724 .is_ok()
1725 {
1726 self.resend_welcome(peer_id, source_event, false);
1727 }
1728 }
1729 WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed => {
1730 if let Some(entry) = self.welcome_lifecycles.get_mut(peer_id) {
1731 entry.attempt = 0;
1732 // A reachability edge resets the unreachable-park
1733 // escalation: the next relay verdict starts the interval
1734 // ladder over from its base.
1735 entry.unreachable_parks = 0;
1736 entry.next_retry_at = Some(Utc::now());
1737 entry.last_reason_code = None;
1738 entry.last_transport_error = None;
1739 entry.expires_at =
1740 Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
1741 }
1742 if let Err(err) = self.try_send_welcome(peer_id, source_event, false) {
1743 warn!(
1744 peer_id = %peer_id,
1745 error = %err,
1746 "Failed to re-arm welcome on peer reachability"
1747 );
1748 }
1749 }
1750 WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired => {
1751 // Expired is terminal in the lifecycle state machine, and Sent
1752 // is settled, so rebuild from the retained Welcome message
1753 // (upsert permits overwrite from either) before re-sending.
1754 let welcome_message = record.welcome_message.clone();
1755 let group_id = record.group_id.clone();
1756 if let Err(err) =
1757 self.upsert_welcome_lifecycle(peer_id, &group_id, welcome_message, source_event)
1758 {
1759 warn!(
1760 peer_id = %peer_id,
1761 error = %err,
1762 "Failed to rebuild expired welcome on peer reachability"
1763 );
1764 return;
1765 }
1766 if let Err(err) = self.try_send_welcome(peer_id, source_event, false) {
1767 warn!(
1768 peer_id = %peer_id,
1769 error = %err,
1770 "Failed to re-arm expired welcome on peer reachability"
1771 );
1772 }
1773 }
1774 }
1775 }
1776
1777 /// Pushes our key package to `peer_id` again when an earlier push produced
1778 /// no session.
1779 ///
1780 /// Invariant: a key package we pushed stands in for an exchange only until
1781 /// the interval lapses; past it, no session with the peer means the push
1782 /// is treated as lost. Without this the push is fire-once: a carrier can
1783 /// drop that one frame without reporting it (a superseded peer stream
1784 /// drops its late frames, `docs/spec/stream-framing.md`), the peer is already marked
1785 /// as sent to, and when both sides' pushes are lost neither ever sends
1786 /// again, so the pair never forms a session.
1787 ///
1788 /// The re-push is the same package (ADR 0012): the pool hands a peer its
1789 /// own live package until a Welcome consumes it, so no key material is
1790 /// minted. A session that exists but is unconfirmed is left to the Welcome
1791 /// lifecycle and the confirmation probes, which own that half.
1792 ///
1793 /// Only a peer already pushed to qualifies; the first push belongs to
1794 /// discovery, except for a peer only the mesh reaches, which discovery
1795 /// never names (see [`Self::push_key_package_across_mesh`]), and a peer
1796 /// only a relay reaches, whose published package is fetched instead (see
1797 /// [`Self::request_nostr_key_package_for`]). The stamp is taken before the send, so a send that fails
1798 /// still waits out the interval instead of being retried on every tick.
1799 /// A send that fails does not count as a repeat, though: the backoff is
1800 /// for a peer that was asked and did not answer, and a peer whose frame
1801 /// never left was not asked. Counting it would walk an unreachable peer
1802 /// up to the cap while it is away, and nothing resets the count when it
1803 /// comes back over a carrier with no neighbour-lost event.
1804 ///
1805 /// A blocked peer is never pushed to. Blocking is bidirectional, and the
1806 /// tick reaches this for any peer with a queued message, which a block
1807 /// leaves in place; without the check the tick would advertise our
1808 /// presence and key package to the blocked peer every window.
1809 pub(super) fn rearm_key_package_for_peer(&mut self, peer_id: &str) {
1810 if !self.config.encryption.enabled
1811 || !self.config.encryption.auto_key_exchange
1812 || self.confirmed_sessions.contains(peer_id)
1813 || self.is_user_blocked(peer_id)
1814 {
1815 return;
1816 }
1817 let Some((sent_at, repeats)) = self.key_package_sent_to.get_mut(peer_id) else {
1818 self.push_key_package_across_mesh(peer_id);
1819 // A peer only a relay reaches is asked about instead, on the
1820 // transport's own interval: a query no relay answered (none held
1821 // a record yet, or none was live) is not asked again by anything
1822 // else while the message waits.
1823 if !self.pending_key_packages.contains_key(peer_id) {
1824 self.request_nostr_key_package_for(peer_id);
1825 }
1826 return;
1827 };
1828 if sent_at.elapsed() < StdDuration::from_secs(key_package_resend_wait_secs(*repeats)) {
1829 return;
1830 }
1831 // Stamped whatever the storage check below says: it is storage I/O,
1832 // and discovery runs on every inbound body, so an unconfirmed session
1833 // (or a failing read) must cost one check per wait, not one per
1834 // frame. A check that finds a session, and a push that leaves, each
1835 // double the wait, so a peer that never answers settles at one push
1836 // per cap.
1837 *sent_at = Instant::now();
1838 let backed_off = match self.has_mls_session(peer_id) {
1839 Ok(false) => {
1840 debug!(peer_id = %peer_id, "Key package produced no session, pushing it again");
1841 match self.send_key_package_to(peer_id, false) {
1842 Ok(()) => true,
1843 Err(e) => {
1844 debug!(peer_id = %peer_id, error = %e, "Key package re-push deferred");
1845 false
1846 }
1847 }
1848 }
1849 Ok(true) | Err(_) => true,
1850 };
1851 if backed_off {
1852 if let Some((_, repeats)) = self.key_package_sent_to.get_mut(peer_id) {
1853 *repeats = repeats.saturating_add(1);
1854 }
1855 }
1856 }
1857
1858 /// Makes the first key-package push to a peer that only the mesh can
1859 /// reach, for whom a message is waiting.
1860 ///
1861 /// Invariant: a message queued for a session is enough to start that
1862 /// session over any path that can carry a frame to the peer. The first
1863 /// push is otherwise discovery's, and discovery names only the device a
1864 /// frame arrived from: on a peer two hops away every frame arrives
1865 /// attributed to the device in the middle, so the pair is never
1866 /// discovered and, before this, never started a session. A message to a
1867 /// device the sender had never been next to waited out its seven days.
1868 ///
1869 /// Three things keep it narrow:
1870 ///
1871 /// - **Only with a message waiting.** The caller is the reconciliation
1872 /// tick, for peers holding a queued message, so the package goes to
1873 /// addresses the application asked to reach, never to whoever is heard.
1874 /// - **Only when nothing else reaches the peer.** A direct link means
1875 /// discovery has already pushed, and an infrastructure carrier has its
1876 /// own exchange (for Nostr, fetching the peer's published package; see
1877 /// [`Self::request_nostr_key_package_for`]); both are left alone.
1878 /// - **Only once by this route.** A push that a neighbour took is recorded
1879 /// like any other, so every repeat after it is the re-push ladder's, on
1880 /// its doubling wait. A push nobody took is tried again on the next
1881 /// tick, and costs nothing new: the pool hands the peer the same
1882 /// package until a Welcome consumes it (ADR 0012).
1883 fn push_key_package_across_mesh(&mut self, peer_id: &str) {
1884 if self.can_reach_recipient(peer_id) || self.transport_manager.mesh_neighbors().is_empty() {
1885 return;
1886 }
1887 if !matches!(self.has_mls_session(peer_id), Ok(false)) {
1888 return;
1889 }
1890 debug!(peer_id = %peer_id, "Message waiting for a peer only the mesh reaches, pushing our key package");
1891 if let Err(e) = self.send_key_package_to(peer_id, false) {
1892 debug!(peer_id = %peer_id, error = %e, "Key package push across the mesh deferred");
1893 }
1894 }
1895
1896 /// Ingests an authoritative per-peer presence signal (the internet relay's
1897 /// `PresenceStatusWithLastSeen` answer, bridged by the platform layer).
1898 ///
1899 /// `online` drives the same reachability machinery as a transport-level
1900 /// discovery — outbox flush, welcome re-arm, auto key exchange — plus a
1901 /// rescue for the store-less-relay hole: a welcome whose wire send was
1902 /// confirmed (`Sent`) while the peer was actually offline was dropped by
1903 /// the relay (it forwards or pushes, never stores), and
1904 /// `Self::rearm_welcome_for_peer` deliberately never touches `Sent`.
1905 /// The peer being provably online is the safe moment to rebuild and
1906 /// re-send it: if the original did land, the receiver dedups by message
1907 /// id and the confirmation probe resolves the lifecycle.
1908 ///
1909 /// `offline` parks retry-pending welcomes pending a reachability edge,
1910 /// without burning budget. In-flight records (`SendAttempted`) are left
1911 /// to their confirm deadline — a `DeliveryError`-driven failure or the
1912 /// confirm timeout resolves them, after which the next offline tick parks.
1913 ///
1914 /// The welcome re-arm/re-send on the online edge is throttled per peer
1915 /// with exponential backoff (`WELCOME_PRESENCE_RESCUE_BASE_SECS` ..
1916 /// `WELCOME_PRESENCE_RESCUE_MAX_SECS`): presence answers arrive on the
1917 /// platform's ~20s watch tick, and a peer that is online but can never
1918 /// prove the session (stale key package after a reinstall, incompatible
1919 /// version) must not be re-sent the multi-frame MLS welcome on every
1920 /// tick forever. A throttled online tick still flushes queued data-plane
1921 /// traffic for the peer.
1922 ///
1923 /// Emits `presence_updated` naming the carrier that answered — this
1924 /// function is the gateway-sourced half of the unified stream and must
1925 /// only be fed a gateway's own observation (bridged via
1926 /// `internet_peer_presence` or `reticulum_peer_presence`); peer-sent
1927 /// `__PRESENCE__` self-reports are handled in `message_dispatch` and emit
1928 /// `source: Peer`. Self, blocked, or empty peer ids are dropped entirely:
1929 /// an app waiting on `presence_updated` for a blocked peer will never see
1930 /// it.
1931 pub fn on_peer_presence(&mut self, peer_id: &str, online: bool, last_seen_ms: Option<i64>) {
1932 self.on_peer_presence_via(peer_id, online, last_seen_ms, GatewayCarrier::Internet);
1933 }
1934
1935 /// [`Self::on_peer_presence`], for whichever gateway answered.
1936 ///
1937 /// The carrier is threaded rather than assumed because every effect below
1938 /// is carrier-scoped and silently wrong under the wrong one: the fact is
1939 /// recorded against the carrier that made the claim, the un-park flush is
1940 /// pinned to the carrier that just proved it can reach the peer, and the
1941 /// event names the carrier so an app filtering on relay-observed presence
1942 /// still can. A Reticulum gateway's answer forcing a send over the
1943 /// Internet transport would be a reachability proof about one carrier
1944 /// spent on another.
1945 pub fn on_peer_presence_via(
1946 &mut self,
1947 peer_id: &str,
1948 online: bool,
1949 last_seen_ms: Option<i64>,
1950 carrier: GatewayCarrier,
1951 ) {
1952 if peer_id.is_empty() || peer_id == self.local_id || self.is_user_blocked(peer_id) {
1953 return;
1954 }
1955 // A presence answer is a claim about this recipient on the carrier
1956 // that answered, so it is recorded as one. It supersedes an earlier
1957 // verdict for that carrier: both answer the same question and this is
1958 // the later answer. Presence facts decay faster than verdicts, since
1959 // this is a third party's report about someone else's connection.
1960 self.reachability.record(
1961 peer_id,
1962 carrier.transport(),
1963 if online {
1964 Claim::Reachable
1965 } else {
1966 Claim::Unreachable
1967 },
1968 FactSource::GatewayPresence,
1969 offline_protocol_core::time::Instant::now(),
1970 );
1971 if online {
1972 // Carrier-scoped reachability proof: un-park and re-drive the
1973 // peer's DMs over that carrier before anything else
1974 // (see `flush_outbox_for_peer_via` — this also cancels
1975 // unanswerable mesh-probe ACKs that would otherwise hold the
1976 // messages hostage past this edge). The rescue branch's inner
1977 // flush then finds successfully re-driven entries awaiting
1978 // fresh ACKs and leaves them alone; entries whose forced send
1979 // *failed* are picked up again, so the inner flush keeps the
1980 // carrier override — DORS must not re-route them into the
1981 // mesh void with the park counter already cleared.
1982 self.flush_outbox_for_peer_via(peer_id, Some(carrier.transport()));
1983 if self.welcome_rescue_permitted(peer_id) {
1984 self.on_neighbor_discovered_via(peer_id, Some(carrier.transport()));
1985 self.resend_unconfirmed_sent_welcome(peer_id, "peer_presence_online");
1986 self.note_welcome_rescue_attempt(peer_id);
1987 }
1988 // A reachability edge resets the session-confirmation probe's
1989 // unreachable backoff: clear the escalation counter and mark a probe
1990 // due now, so a peer that went unreachable while unconfirmed is
1991 // re-probed immediately and its session converges rather than
1992 // waiting out the (up to 600s) backoff.
1993 if self.confirmation_probe_due_at.contains_key(peer_id) {
1994 self.confirmation_probe_unreachable_parks.remove(peer_id);
1995 self.confirmation_probe_due_at
1996 .insert(peer_id.to_string(), Utc::now());
1997 }
1998 } else {
1999 self.park_welcome_peer_unreachable(peer_id);
2000 }
2001
2002 let status = if online {
2003 crate::events::PresenceStatus::Online
2004 } else {
2005 crate::events::PresenceStatus::Offline
2006 };
2007 self.emit_event(Event::presence_updated_from(
2008 peer_id.to_string(),
2009 status,
2010 Utc::now().timestamp_millis(),
2011 last_seen_ms,
2012 carrier.presence_source(),
2013 ));
2014 }
2015
2016 /// Peers with a welcome lifecycle whose session has not been proven —
2017 /// the internet presence watch set. Includes `Sent` records (wire-
2018 /// confirmed but never session-confirmed): only a presence signal can
2019 /// rescue those over a store-less relay.
2020 /// Lifecycles older than `WELCOME_WATCHLIST_MAX_AGE_SECS` are excluded:
2021 /// a permanently-dead peer must not occupy watch-rotation slots (and keep
2022 /// its parked record alive via `expires_at` pushes) forever.
2023 pub fn welcome_pending_peers(&self) -> Vec<String> {
2024 let watch_cutoff = Utc::now() - ChronoDuration::seconds(WELCOME_WATCHLIST_MAX_AGE_SECS);
2025 self.welcome_lifecycles
2026 .iter()
2027 .filter(|(peer_id, record)| {
2028 !self.confirmed_sessions.contains(*peer_id) && record.created_at > watch_cutoff
2029 })
2030 .map(|(peer_id, _)| peer_id.clone())
2031 .collect()
2032 }
2033
2034 /// Rebuilds and re-sends a `Sent`-but-session-unconfirmed welcome (see
2035 /// [`Self::on_peer_presence`]). No-op for any other state.
2036 fn resend_unconfirmed_sent_welcome(&mut self, peer_id: &str, source_event: &str) {
2037 if self.confirmed_sessions.contains(peer_id) {
2038 return;
2039 }
2040 let Some(record) = self.welcome_lifecycles.get(peer_id) else {
2041 return;
2042 };
2043 if !matches!(record.state, WelcomeDeliveryState::Sent) {
2044 return;
2045 }
2046 let welcome_message = record.welcome_message.clone();
2047 let group_id = record.group_id.clone();
2048 if let Err(err) =
2049 self.upsert_welcome_lifecycle(peer_id, &group_id, welcome_message, source_event)
2050 {
2051 warn!(
2052 peer_id = %peer_id,
2053 error = %err,
2054 "Failed to rebuild unconfirmed sent welcome on presence"
2055 );
2056 return;
2057 }
2058 if let Err(err) = self.try_send_welcome(peer_id, source_event, false) {
2059 warn!(
2060 peer_id = %peer_id,
2061 error = %err,
2062 "Failed to re-send unconfirmed welcome on presence"
2063 );
2064 }
2065 }
2066
2067 /// True when a presence-online rescue for this peer is currently allowed
2068 /// by the per-peer backoff. Trivially true (and the throttle entry is
2069 /// dropped) once the peer has no unconfirmed welcome — there is nothing
2070 /// left to throttle.
2071 fn welcome_rescue_permitted(&mut self, peer_id: &str) -> bool {
2072 let pending = self.welcome_lifecycles.contains_key(peer_id)
2073 && !self.confirmed_sessions.contains(peer_id);
2074 if !pending {
2075 self.welcome_presence_rescue.remove(peer_id);
2076 return true;
2077 }
2078 self.welcome_presence_rescue
2079 .get(peer_id)
2080 .is_none_or(|throttle| throttle.next_allowed_at <= Utc::now())
2081 }
2082
2083 /// Records that a presence-online tick ran the rescue actions for a peer
2084 /// that still has an unconfirmed welcome, doubling the wait before the
2085 /// next one (base 40s, capped at 10 minutes). The counter is per
2086 /// convergence attempt: the entry is dropped (via
2087 /// [`Self::welcome_rescue_permitted`]) as soon as the session confirms
2088 /// or the lifecycle goes away.
2089 fn note_welcome_rescue_attempt(&mut self, peer_id: &str) {
2090 let pending = self.welcome_lifecycles.contains_key(peer_id)
2091 && !self.confirmed_sessions.contains(peer_id);
2092 if !pending {
2093 self.welcome_presence_rescue.remove(peer_id);
2094 return;
2095 }
2096 let throttle = self
2097 .welcome_presence_rescue
2098 .entry(peer_id.to_string())
2099 .or_insert(PresenceRescueThrottle {
2100 next_allowed_at: Utc::now(),
2101 rescues: 0,
2102 });
2103 // Shift is clamped to 8, so 40 << 8 = 10240 max before the cap —
2104 // no overflow risk.
2105 let backoff_secs = (WELCOME_PRESENCE_RESCUE_BASE_SECS << throttle.rescues.min(8))
2106 .min(WELCOME_PRESENCE_RESCUE_MAX_SECS);
2107 throttle.next_allowed_at = Utc::now() + ChronoDuration::seconds(backoff_secs);
2108 throttle.rescues = throttle.rescues.saturating_add(1);
2109 }
2110
2111 /// Parks a welcome pending a peer-reachability edge: reason
2112 /// `PeerUnreachable`, **no timed retry**. The carrier (relay socket) is
2113 /// healthy — only this peer is unreachable on it — so the *data-plane*
2114 /// retry this cancels would re-send the welcome into another
2115 /// `DeliveryError` while burning the real-delivery budget that a
2116 /// carrier-backed failure already charged. Recovery is edge-driven
2117 /// instead: `on_peer_presence(online)` and `on_neighbor_discovered` re-arm
2118 /// via `rearm_welcome_for_peer`, and the peer stays on the presence
2119 /// watchlist (`welcome_pending_peers`) so the platform keeps polling for
2120 /// that edge. The TTL is pushed like a no-carrier park: an unreachable
2121 /// peer must not age the welcome out.
2122 ///
2123 /// Its caller is responsible for not applying this to a record already
2124 /// holding an escalating unreachable probe — see
2125 /// [`Self::park_welcome_peer_unreachable`], which is what distinguishes
2126 /// the budget-burning data-plane retry cancelled here from the
2127 /// budget-refunded probe that must survive.
2128 fn park_welcome_awaiting_peer(&mut self, peer_id: &str) -> Result<()> {
2129 let snapshot = {
2130 let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
2131 return Ok(());
2132 };
2133 record.next_retry_at = None;
2134 record.expires_at = Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
2135 record.last_reason_code = Some(crate::events::WelcomeReasonCode::PeerUnreachable);
2136 record.clone()
2137 };
2138 self.persist_welcome_lifecycle_entry(&snapshot)?;
2139 debug!(
2140 peer_id = %peer_id,
2141 state = snapshot.state.as_str(),
2142 "Welcome parked: peer unreachable on carrier, awaiting reachability edge"
2143 );
2144 Ok(())
2145 }
2146
2147 /// Parks a retry-pending welcome for a peer the relay reports offline.
2148 /// Only `Created`/`Failed` records park: `SendAttempted` keeps its
2149 /// confirm deadline, and `Sent`/`Expired` are handled on the online edge
2150 /// (or corrected by an attributed `DeliveryError`). Skipped entirely when
2151 /// a mesh carrier is also available — relay presence is only
2152 /// authoritative for the internet path, and the data-plane retry track
2153 /// may still deliver over BLE / WiFi-Direct.
2154 ///
2155 /// Never cancels an escalating unreachable probe: a record that already
2156 /// holds one was parked by [`Self::apply_recipient_unreachable_failure`],
2157 /// which already set the same reason code and pushed the same TTL — so this
2158 /// adds nothing and would only downgrade the probe to edge-only. That
2159 /// matters because on an internet-only device this runs once per presence
2160 /// rotation for as long as the peer is down, which would otherwise
2161 /// re-strand the welcome after every single verdict. The distinction is
2162 /// budget: the data-plane retry this *does* cancel was charged an attempt
2163 /// by a carrier-backed failure, while the probe refunds its attempt on
2164 /// every verdict.
2165 ///
2166 /// `last_reason_code` — not `unreachable_parks` alone — is what encodes
2167 /// that distinction. The counter is sticky (only `rearm_welcome_for_peer`
2168 /// clears it), so gating on it by itself would keep shielding the record
2169 /// after the probe track has stopped protecting it: past
2170 /// [`WELCOME_WATCHLIST_MAX_AGE_SECS`] a confirm timeout no longer re-parks
2171 /// (see [`Self::welcome_probe_repark_permitted`]) and instead charges an
2172 /// attempt and arms a plain data-plane retry — precisely the budget-burner
2173 /// this park exists to cancel, and left running it expires the welcome at
2174 /// `max_retries`. Note `park_welcome_awaiting_peer` also stamps
2175 /// `PeerUnreachable` but leaves `next_retry_at = None`, so the
2176 /// `next_retry_at.is_some()` conjunct below is load-bearing, not
2177 /// redundant: the reason code *and* a scheduled retry together are what
2178 /// identify a live probe. A carrier-backed failure stamps
2179 /// `Timeout`/`SendFailed` and a successful send clears the code entirely.
2180 fn park_welcome_peer_unreachable(&mut self, peer_id: &str) {
2181 if self.confirmed_sessions.contains(peer_id) {
2182 return;
2183 }
2184 let Some(record) = self.welcome_lifecycles.get(peer_id) else {
2185 return;
2186 };
2187 if !matches!(
2188 record.state,
2189 WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed
2190 ) {
2191 return;
2192 }
2193 if record.last_reason_code == Some(crate::events::WelcomeReasonCode::PeerUnreachable)
2194 && record.unreachable_parks > 0
2195 && record.next_retry_at.is_some()
2196 {
2197 return;
2198 }
2199 // Local mesh carriers only (BLE / WiFi-Direct): Nostr and Reticulum
2200 // are internet-dependent, so their availability must not veto the
2201 // park — mirrors the carrier guard in
2202 // `apply_recipient_unreachable_failure`.
2203 if self
2204 .transport_manager
2205 .get_available_transports()
2206 .keys()
2207 .any(|transport| matches!(transport, TransportType::BLE | TransportType::WiFiDirect))
2208 {
2209 return;
2210 }
2211 let _ = self.park_welcome_awaiting_peer(peer_id);
2212 }
2213
2214 /// True when an unconfirmed welcome send should resolve as another
2215 /// unreachable verdict ([`Self::apply_recipient_unreachable_failure`])
2216 /// rather than as a carrier-backed failure that ages the record.
2217 ///
2218 /// A live `unreachable_parks` counter means the relay declared the peer
2219 /// offline and no reachability edge has fired since (every edge clears it
2220 /// via `rearm_welcome_for_peer`), so the send that just went unconfirmed
2221 /// was the escalating probe, not a delivery attempt. Without this the
2222 /// probe destroys the very welcome it exists to recover: the relay
2223 /// accepts the frame whenever its push fallback succeeds, which returns
2224 /// *no* `DeliveryError` at all, so the probe resolves at the 10s confirm
2225 /// timeout instead. Treated as carrier-backed that charges the attempt
2226 /// and arms a plain data-plane retry, and since nothing in that ladder
2227 /// pushes `expires_at`, the record walks into `should_expire` within one
2228 /// TTL window — terminal `welcome_send_expired` + `secure_session_failed`
2229 /// for a peer that is merely offline. Re-parking instead refunds the
2230 /// attempt, escalates the interval and pushes the TTL, mirroring the DM
2231 /// path's [`Self::try_repark_exhausted_dm`]: settlement stays reserved
2232 /// for delivery or the record genuinely ageing out below.
2233 ///
2234 /// Only the *presence-offline* answer cancelled that retry before, which
2235 /// is not a defense at all for the case this whole probe exists to serve
2236 /// — a headless consumer that never polls presence.
2237 ///
2238 /// Bounded by [`WELCOME_WATCHLIST_MAX_AGE_SECS`] measured from
2239 /// `created_at`, the same threshold that stops watching a peer as
2240 /// permanently dead: past it the counter no longer shields the record and
2241 /// normal ageing expires it. This is the welcome's twin of the DM probe's
2242 /// absolute bound (`first_sent_at` vs the outbox absolute lifetime) —
2243 /// without it a peer that never returns keeps a welcome probing at the
2244 /// 600s cap forever.
2245 ///
2246 /// Deliberately narrow to `Timeout`: a `SendFailed`/`TransportUnavailable`
2247 /// failure is evidence about the *carrier*, not about this peer, and
2248 /// keeps ageing the record normally.
2249 fn welcome_probe_repark_permitted(&self, peer_id: &str) -> bool {
2250 let Some(record) = self.welcome_lifecycles.get(peer_id) else {
2251 return false;
2252 };
2253 record.unreachable_parks > 0
2254 && record.created_at
2255 > Utc::now() - ChronoDuration::seconds(WELCOME_WATCHLIST_MAX_AGE_SECS)
2256 }
2257
2258 /// Applies the relay's authoritative "recipient offline" verdict
2259 /// (a `recipient_unreachable`-tagged transport failure) to a welcome
2260 /// lifecycle. Also reached *without* a relay verdict when a probe's
2261 /// confirm timeout resolves while the park counter is still live — the
2262 /// same conclusion inferred rather than relayed, see
2263 /// [`Self::welcome_probe_repark_permitted`].
2264 ///
2265 /// The bridge wire-confirms on socket-write success, so by the time the
2266 /// relay's `DeliveryError` arrives the record is normally already `Sent`
2267 /// — a *false* Sent: the store-less relay dropped the content. Correct
2268 /// it back to `Failed` (emitting `welcome_send_failed` so the app's
2269 /// earlier `welcome_send_succeeded` is superseded), refund the attempt
2270 /// (the peer never saw the frame), and park pending a reachability edge.
2271 /// `SendAttempted` (the failure raced ahead of the wire confirm) gets
2272 /// the same treatment; `Created`/`Failed` records just park. Terminal
2273 /// `Expired` records are left to the online edge, which rebuilds them.
2274 ///
2275 /// The record always keeps a timed retry whose interval escalates per
2276 /// consecutive park. Relay presence is authoritative only for the
2277 /// internet path — with a mesh carrier live the peer may still be
2278 /// reachable over BLE / WiFi-Direct (DORS can pick internet even with
2279 /// mesh present) — but an internet-only device needs the timed track just
2280 /// as much: parking it edge-only left the welcome dependent solely on a
2281 /// presence-online answer, i.e. on the platform's presence-polling
2282 /// cadence, which is the same dead end that stalled parked DMs (see
2283 /// [`Self::park_unreachable_dm`]). The escalation (15s → 600s cap) is
2284 /// what bounds the retry: DORS may keep routing to the internet path,
2285 /// where each round trips another budget-refunded `DeliveryError`, and
2286 /// without escalation that would be an unbounded resend loop into the
2287 /// relay. Every verdict refunds the attempt and pushes the TTL — past the
2288 /// scheduled probe, see the invariant on [`Self::park_welcome_no_carrier`]
2289 /// — so the probes never age the welcome out or burn its real-delivery
2290 /// budget, and the counter resets on any reachability edge
2291 /// (`rearm_welcome_for_peer`).
2292 pub(super) fn apply_recipient_unreachable_failure(
2293 &mut self,
2294 peer_id: &str,
2295 transport_error: Option<&'static str>,
2296 ) -> Result<()> {
2297 // A late relay verdict for a session that has since been proven (the
2298 // welcome was rescued over another path and the peer confirmed) must
2299 // not corrupt the converged lifecycle: without this guard the stale
2300 // DeliveryError flips the record back to Failed, refunds an attempt,
2301 // persists the stale state, and emits welcome_send_failed AFTER the
2302 // app already saw secure_session_established.
2303 if self.confirmed_sessions.contains(peer_id) {
2304 return Ok(());
2305 }
2306 let Some(state) = self.welcome_lifecycles.get(peer_id).map(|r| r.state) else {
2307 return Ok(());
2308 };
2309 match state {
2310 WelcomeDeliveryState::Sent | WelcomeDeliveryState::SendAttempted => {
2311 self.transition_welcome_state(
2312 peer_id,
2313 WelcomeDeliveryState::Failed,
2314 "recipient_unreachable",
2315 )?;
2316 if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
2317 record.attempt = record.attempt.saturating_sub(1);
2318 record.last_transport_error = transport_error.map(str::to_string);
2319 }
2320 }
2321 WelcomeDeliveryState::Created | WelcomeDeliveryState::Failed => {
2322 if let Some(record) = self.welcome_lifecycles.get_mut(peer_id) {
2323 record.last_transport_error = transport_error.map(str::to_string);
2324 }
2325 }
2326 WelcomeDeliveryState::Expired => return Ok(()),
2327 }
2328 // Escalating timed retry on every carrier (see the doc comment): the
2329 // ladder, not a carrier guard, is what bounds resends into the relay.
2330 let parks = {
2331 let Some(record) = self.welcome_lifecycles.get_mut(peer_id) else {
2332 return Ok(());
2333 };
2334 record.unreachable_parks = record.unreachable_parks.saturating_add(1);
2335 record.unreachable_parks
2336 };
2337 // parks >= 1 here; shift clamped so 15 << 6 = 960 is the largest
2338 // pre-cap value — no overflow risk.
2339 let retry_in_secs = (WELCOME_NO_CARRIER_RETRY_SECS << (parks - 1).min(6))
2340 .min(WELCOME_UNREACHABLE_RETRY_CAP_SECS);
2341 self.park_welcome_no_carrier(
2342 peer_id,
2343 crate::events::WelcomeReasonCode::PeerUnreachable,
2344 transport_error,
2345 retry_in_secs,
2346 )?;
2347
2348 let Some(snapshot) = self.welcome_lifecycles.get(peer_id).cloned() else {
2349 return Ok(());
2350 };
2351 if let Ok(shared) = lock_shared_state(&self.shared_state) {
2352 shared.emit_event(Event::welcome_send_failed(
2353 peer_id.to_string(),
2354 snapshot.welcome_message.id.as_str().to_string(),
2355 snapshot.group_id.clone(),
2356 snapshot.attempt,
2357 crate::events::WelcomeReasonCode::PeerUnreachable,
2358 snapshot
2359 .last_transport_error
2360 .as_deref()
2361 .map(classify_transport_send_error),
2362 // Retryable, and now always with a scheduled time: the park
2363 // keeps an escalating timed probe on every carrier. The
2364 // reachability edges (presence online / peer discovery) still
2365 // re-arm it sooner when they fire.
2366 true,
2367 snapshot.next_retry_at.map(|at| at.timestamp_millis()),
2368 ));
2369 }
2370 Ok(())
2371 }
2372
2373 /// Records a Welcome send failure and decides whether to retry or expire.
2374 ///
2375 /// `no_carrier` is `true` when the failure was simply that no transport
2376 /// carrier exists yet (vs. a present carrier that failed mid-send). A
2377 /// no-carrier failure must NOT age the Welcome: it neither counts toward
2378 /// the retry budget nor lets the TTL expire it, because the peer is merely
2379 /// unreachable for now and will be retried once a carrier appears. The
2380 /// speculative attempt increment from [`Self::try_send_welcome`] is rolled
2381 /// back so a long offline period leaves the real-delivery budget intact.
2382 pub(super) fn apply_welcome_send_failure(
2383 &mut self,
2384 peer_id: &str,
2385 reason: crate::events::WelcomeReasonCode,
2386 transport_error: Option<&'static str>,
2387 no_carrier: bool,
2388 source_event: &str,
2389 ) -> Result<bool> {
2390 let mut updated = self
2391 .welcome_lifecycles
2392 .get(peer_id)
2393 .cloned()
2394 .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
2395
2396 if matches!(
2397 updated.state,
2398 WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
2399 ) {
2400 return Ok(matches!(updated.state, WelcomeDeliveryState::Sent));
2401 }
2402
2403 // A confirm timeout on a welcome that still holds a live
2404 // unreachable-park counter is a *probe* resolving, not a delivery
2405 // failing — re-park it as another unreachable verdict instead of
2406 // ageing it (see [`Self::welcome_probe_repark_permitted`]).
2407 if !no_carrier
2408 && matches!(reason, crate::events::WelcomeReasonCode::Timeout)
2409 && self.welcome_probe_repark_permitted(peer_id)
2410 {
2411 self.apply_recipient_unreachable_failure(peer_id, transport_error)?;
2412 return Ok(false);
2413 }
2414
2415 let max_attempts = self.config.reliability.retry.max_retries.max(1);
2416 // A no-carrier failure never expires the Welcome: neither the retry
2417 // budget nor the TTL applies while the peer is simply unreachable. Only
2418 // a carrier-backed failure (SendFailed / Timeout) ages it toward expiry.
2419 let should_expire =
2420 !no_carrier && (updated.attempt >= max_attempts || updated.expires_at <= Utc::now());
2421 if should_expire {
2422 let terminal_reason = crate::events::WelcomeReasonCode::RetryExhausted;
2423 {
2424 let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
2425 Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
2426 })?;
2427 record.last_reason_code = Some(terminal_reason);
2428 record.last_transport_error = transport_error.map(str::to_string);
2429 record.next_retry_at = None;
2430 }
2431
2432 if !matches!(updated.state, WelcomeDeliveryState::Failed) {
2433 self.transition_welcome_state(peer_id, WelcomeDeliveryState::Failed, source_event)?;
2434 }
2435 self.transition_welcome_state(
2436 peer_id,
2437 WelcomeDeliveryState::Expired,
2438 "welcome_retry_exhausted",
2439 )?;
2440
2441 let expired_snapshot =
2442 self.welcome_lifecycles
2443 .get(peer_id)
2444 .cloned()
2445 .ok_or_else(|| {
2446 Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
2447 })?;
2448 if let Ok(state) = lock_shared_state(&self.shared_state) {
2449 state.emit_event(Event::welcome_send_failed(
2450 peer_id.to_string(),
2451 expired_snapshot.welcome_message.id.as_str().to_string(),
2452 expired_snapshot.group_id.clone(),
2453 expired_snapshot.attempt,
2454 terminal_reason,
2455 expired_snapshot
2456 .last_transport_error
2457 .as_deref()
2458 .map(classify_transport_send_error),
2459 false,
2460 None,
2461 ));
2462 state.emit_event(Event::welcome_send_expired(
2463 peer_id.to_string(),
2464 expired_snapshot.welcome_message.id.as_str().to_string(),
2465 expired_snapshot.attempt,
2466 terminal_reason,
2467 ));
2468 }
2469 self.abort_pending_session_for_peer(peer_id, terminal_reason);
2470 return Ok(false);
2471 }
2472
2473 // A no-carrier failure (the carrier raced away after the pre-send check,
2474 // or an async transport-failed callback with no carrier) re-checks on the
2475 // slow no-carrier interval rather than the data-plane backoff: there is
2476 // nothing to deliver over until a carrier returns, and `try_send_welcome`
2477 // parks subsequent no-carrier ticks cheaply anyway.
2478 let retry_at = if no_carrier {
2479 Utc::now() + ChronoDuration::seconds(WELCOME_NO_CARRIER_RETRY_SECS)
2480 } else {
2481 let delay_ms = self.compute_welcome_retry_delay_ms(peer_id, updated.attempt);
2482 Utc::now() + ChronoDuration::milliseconds(delay_ms as i64)
2483 };
2484
2485 {
2486 let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
2487 Error::Other(format!("Missing welcome lifecycle for {}", peer_id))
2488 })?;
2489 // Roll back the speculative attempt increment from try_send_welcome
2490 // for a no-carrier failure so the retry budget stays reserved for
2491 // real delivery attempts over an available carrier, and push the TTL
2492 // forward so a never-deliverable Welcome does not age out — the TTL
2493 // clock is carrier-relative, not creation-relative.
2494 if no_carrier {
2495 record.attempt = record.attempt.saturating_sub(1);
2496 record.expires_at =
2497 Utc::now() + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS);
2498 }
2499 record.last_reason_code = Some(reason);
2500 record.last_transport_error = transport_error.map(str::to_string);
2501 record.next_retry_at = Some(retry_at);
2502 }
2503
2504 if !matches!(updated.state, WelcomeDeliveryState::Failed) {
2505 self.transition_welcome_state(peer_id, WelcomeDeliveryState::Failed, source_event)?;
2506 } else if let Some(record) = self.welcome_lifecycles.get(peer_id).cloned() {
2507 self.persist_welcome_lifecycle_entry(&record)?;
2508 }
2509
2510 updated = self
2511 .welcome_lifecycles
2512 .get(peer_id)
2513 .cloned()
2514 .ok_or_else(|| Error::Other(format!("Missing welcome lifecycle for {}", peer_id)))?;
2515 if let Ok(state) = lock_shared_state(&self.shared_state) {
2516 state.emit_event(Event::welcome_send_failed(
2517 peer_id.to_string(),
2518 updated.welcome_message.id.as_str().to_string(),
2519 updated.group_id,
2520 updated.attempt,
2521 reason,
2522 updated
2523 .last_transport_error
2524 .as_deref()
2525 .map(classify_transport_send_error),
2526 true,
2527 Some(retry_at.timestamp_millis()),
2528 ));
2529 }
2530 Ok(false)
2531 }
2532
2533 pub(super) fn compute_welcome_retry_delay_ms(&self, peer_id: &str, attempt: u32) -> u64 {
2534 let config = &self.config.reliability.retry;
2535 let capped_attempt = attempt.saturating_sub(1);
2536 let base_ms = if capped_attempt == 0 {
2537 config.initial_delay_ms
2538 } else {
2539 let multiplier = config.backoff_multiplier.powi(capped_attempt as i32);
2540 (config.initial_delay_ms as f64 * multiplier as f64) as u64
2541 }
2542 .min(config.max_delay_ms);
2543
2544 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2545 peer_id.hash(&mut hasher);
2546 attempt.hash(&mut hasher);
2547 Utc::now().timestamp_millis().hash(&mut hasher);
2548 let bucket = (hasher.finish() % 10_000) as f64 / 10_000.0;
2549 let jitter_factor = 1.0 + ((bucket * 2.0 - 1.0) * WELCOME_RETRY_JITTER_RATIO);
2550 let jittered = (base_ms as f64 * jitter_factor).round() as i64;
2551 jittered.max(1) as u64
2552 }
2553
2554 pub(super) fn can_transition_welcome_state(
2555 current: WelcomeDeliveryState,
2556 next: WelcomeDeliveryState,
2557 ) -> bool {
2558 matches!(
2559 (current, next),
2560 (
2561 WelcomeDeliveryState::Created,
2562 WelcomeDeliveryState::SendAttempted
2563 ) | (WelcomeDeliveryState::Created, WelcomeDeliveryState::Expired)
2564 | (
2565 WelcomeDeliveryState::SendAttempted,
2566 WelcomeDeliveryState::Sent
2567 )
2568 | (
2569 WelcomeDeliveryState::SendAttempted,
2570 WelcomeDeliveryState::Failed
2571 )
2572 | (
2573 WelcomeDeliveryState::Failed,
2574 WelcomeDeliveryState::SendAttempted
2575 )
2576 | (WelcomeDeliveryState::Failed, WelcomeDeliveryState::Sent)
2577 | (WelcomeDeliveryState::Failed, WelcomeDeliveryState::Expired)
2578 // Sent is normally a sink, but the relay's DeliveryError is
2579 // authoritative proof that a wire-confirmed frame was dropped
2580 // (store-less relay, recipient offline). That correction is
2581 // the one legal way back out of Sent — see
2582 // `on_transport_send_failed`.
2583 | (WelcomeDeliveryState::Sent, WelcomeDeliveryState::Failed)
2584 )
2585 }
2586
2587 pub(super) fn transition_welcome_state(
2588 &mut self,
2589 peer_id: &str,
2590 next_state: WelcomeDeliveryState,
2591 source_event: &str,
2592 ) -> Result<()> {
2593 let (previous_state, record_snapshot) = {
2594 let record = self.welcome_lifecycles.get_mut(peer_id).ok_or_else(|| {
2595 Error::Other(format!(
2596 "Missing welcome lifecycle for transition: {}",
2597 peer_id
2598 ))
2599 })?;
2600
2601 if record.state == next_state {
2602 return Ok(());
2603 }
2604
2605 if !Self::can_transition_welcome_state(record.state, next_state) {
2606 return Err(Error::Other(format!(
2607 "Illegal welcome lifecycle transition for {}: {} -> {}",
2608 peer_id,
2609 record.state.as_str(),
2610 next_state.as_str()
2611 )));
2612 }
2613
2614 let previous = record.state;
2615 record.state = next_state;
2616 if matches!(
2617 next_state,
2618 WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
2619 ) {
2620 record.next_retry_at = None;
2621 }
2622 (previous, record.clone())
2623 };
2624
2625 self.persist_welcome_lifecycle_entry(&record_snapshot)?;
2626 info!(
2627 event = "welcome_lifecycle_transition",
2628 session_or_group_id = %peer_id,
2629 previous_state = previous_state.as_str(),
2630 new_state = next_state.as_str(),
2631 source_event = %source_event,
2632 attempt = record_snapshot.attempt,
2633 "welcome_lifecycle_transition"
2634 );
2635 Ok(())
2636 }
2637
2638 pub(super) fn upsert_welcome_lifecycle(
2639 &mut self,
2640 peer_id: &str,
2641 group_id: &str,
2642 welcome_message: Message,
2643 source_event: &str,
2644 ) -> Result<()> {
2645 if let Some(existing) = self.welcome_lifecycles.get(peer_id) {
2646 if !matches!(
2647 existing.state,
2648 WelcomeDeliveryState::Sent | WelcomeDeliveryState::Expired
2649 ) {
2650 return Err(Error::Other(format!(
2651 "Refusing to overwrite active welcome lifecycle for {} in state {}",
2652 peer_id,
2653 existing.state.as_str()
2654 )));
2655 }
2656 }
2657
2658 let now = Utc::now();
2659 let record = WelcomeLifecycleRecord {
2660 peer_id: peer_id.to_string(),
2661 group_id: group_id.to_string(),
2662 state: WelcomeDeliveryState::Created,
2663 attempt: 0,
2664 unreachable_parks: 0,
2665 welcome_message,
2666 next_retry_at: None,
2667 last_reason_code: None,
2668 last_transport_error: None,
2669 created_at: now,
2670 expires_at: now + ChronoDuration::seconds(WELCOME_LIFECYCLE_TTL_SECS),
2671 };
2672 self.welcome_lifecycles
2673 .insert(peer_id.to_string(), record.clone());
2674 self.persist_welcome_lifecycle_entry(&record)?;
2675 info!(
2676 event = "welcome_lifecycle_transition",
2677 session_or_group_id = %peer_id,
2678 previous_state = "Absent",
2679 new_state = WelcomeDeliveryState::Created.as_str(),
2680 source_event = %source_event,
2681 attempt = 0,
2682 "welcome_lifecycle_transition"
2683 );
2684 Ok(())
2685 }
2686
2687 pub(super) fn map_welcome_reason_code(error: &Error) -> crate::events::WelcomeReasonCode {
2688 SessionStateError::classify(error).to_welcome_reason_code()
2689 }
2690
2691 /// True when a send failed because no transport carrier exists yet, as
2692 /// opposed to a present-but-unhealthy carrier (`SendFailed`) or a
2693 /// sent-but-unconfirmed timeout. A no-carrier failure means the peer is
2694 /// simply unreachable right now, so the Welcome must be kept alive and
2695 /// retried rather than counted against its budget — see
2696 /// [`Self::apply_welcome_send_failure`].
2697 pub(super) fn is_no_carrier_error(error: &Error) -> bool {
2698 matches!(
2699 error,
2700 Error::Transport(offline_protocol_transport::Error::TransportNotAvailable(_))
2701 )
2702 }
2703
2704 pub(super) fn session_ready_context_for_source(source_event: &str) -> MlsOperationContext {
2705 match source_event {
2706 "confirmation_ack_received"
2707 | "confirmation_probe_received"
2708 | "decrypt_success"
2709 | SESSION_CORROBORATED => MlsOperationContext::Receive,
2710 "welcome_received" => MlsOperationContext::Welcome,
2711 _ => MlsOperationContext::Send,
2712 }
2713 }
2714
2715 pub(super) fn find_welcome_peer_by_message_id(&self, message_id: &str) -> Option<String> {
2716 self.welcome_lifecycles
2717 .iter()
2718 .find_map(|(peer_id, record)| {
2719 if record.welcome_message.id.as_str() == message_id {
2720 return Some(peer_id.clone());
2721 }
2722 None
2723 })
2724 }
2725
2726 pub(super) fn process_welcome_retry_queue(&mut self) -> Result<()> {
2727 let now = Utc::now();
2728 let timed_out_attempts: Vec<String> = self
2729 .welcome_lifecycles
2730 .iter()
2731 .filter_map(|(peer_id, record)| {
2732 // Skip peers whose session is already confirmed: the welcome is
2733 // delivered, mark_welcome_confirmed should have marked it Sent,
2734 // and re-sending would be wasted bandwidth (defensive against any
2735 // keying mismatch that left the lifecycle non-terminal).
2736 if self.confirmed_sessions.contains(peer_id) {
2737 return None;
2738 }
2739 if matches!(record.state, WelcomeDeliveryState::SendAttempted)
2740 && record.next_retry_at.is_some_and(|retry_at| retry_at <= now)
2741 {
2742 return Some(peer_id.clone());
2743 }
2744 None
2745 })
2746 .take(WELCOME_RETRY_BATCH_SIZE)
2747 .collect();
2748
2749 for peer_id in timed_out_attempts {
2750 let _ = self.apply_welcome_send_failure(
2751 &peer_id,
2752 crate::events::WelcomeReasonCode::Timeout,
2753 Some(SEND_FAIL_REASON_CONFIRM_TIMEOUT),
2754 // A confirm timeout means the Welcome was sent over a carrier;
2755 // this is not a no-carrier failure, so it ages normally.
2756 false,
2757 "welcome_confirm_timeout",
2758 )?;
2759 }
2760
2761 let due_peers: Vec<String> = self
2762 .welcome_lifecycles
2763 .iter()
2764 .filter_map(|(peer_id, record)| {
2765 if self.confirmed_sessions.contains(peer_id) {
2766 return None;
2767 }
2768 // Failed = a normal retry-due Welcome. Created with a due
2769 // next_retry_at = a no-carrier-parked Welcome (see
2770 // `park_welcome_no_carrier`): a fresh Created entry has
2771 // next_retry_at == None, so only parked ones match here.
2772 if matches!(
2773 record.state,
2774 WelcomeDeliveryState::Failed | WelcomeDeliveryState::Created
2775 ) && record.next_retry_at.is_some_and(|retry_at| retry_at <= now)
2776 {
2777 return Some(peer_id.clone());
2778 }
2779 None
2780 })
2781 .take(WELCOME_RETRY_BATCH_SIZE)
2782 .collect();
2783
2784 for peer_id in due_peers {
2785 if let Err(err) = self.try_send_welcome(&peer_id, "welcome_retry", false) {
2786 warn!(
2787 peer_id = %peer_id,
2788 error = %err,
2789 "Failed to process welcome retry"
2790 );
2791 }
2792 }
2793
2794 Ok(())
2795 }
2796
2797 pub(super) fn has_terminal_welcome_failure(&self, peer_id: &str) -> bool {
2798 self.welcome_lifecycles
2799 .get(peer_id)
2800 .is_some_and(|record| matches!(record.state, WelcomeDeliveryState::Expired))
2801 }
2802
2803 /// Whether a message the user sent is waiting for `peer_id`: queued
2804 /// behind session establishment, or on the outbox unmarked (an entry
2805 /// restored from before the marker existed counts, the safe direction).
2806 fn has_user_content_waiting_for(&self, peer_id: &str) -> bool {
2807 self.pending_encrypted_messages
2808 .get(peer_id)
2809 .is_some_and(|queued| !queued.is_empty())
2810 || self.outbox.values().any(|entry| {
2811 entry.message.recipient.as_str() == peer_id
2812 && !entry
2813 .message
2814 .metadata
2815 .contains_key(crate::constants::NO_ALERT_KEY)
2816 })
2817 }
2818}