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