Skip to main content

whatsapp_rust/client/
sessions.rs

1//! E2E Session management for Client.
2
3use anyhow::Result;
4use rand::rngs::StdRng;
5use std::sync::Arc;
6use std::sync::atomic::Ordering;
7use std::time::Duration;
8use wacore::libsignal::protocol::{
9    IdentityChange, PreKeyBundle, SignalProtocolError, UsePQRatchet, process_prekey_bundle,
10};
11use wacore::libsignal::store::SessionStore;
12use wacore::types::jid::JidExt;
13use wacore_binary::Jid;
14
15use super::Client;
16use crate::types::events::{Event, OfflineSyncCompleted};
17
18impl Client {
19    /// Install a supplied pre-key bundle into the shared Signal cache.
20    ///
21    /// The caller owns batching and the final durability flush. Keeping those
22    /// outside lets multi-device establishment reuse one adapter and one flush.
23    pub(crate) async fn install_prekey_bundle_cached(
24        &self,
25        jid: &Jid,
26        bundle: &PreKeyBundle,
27        adapter: &mut crate::store::signal_adapter::SignalProtocolStoreAdapter,
28        rng: &mut StdRng,
29    ) -> Result<IdentityChange, SignalProtocolError> {
30        let signal_address = jid.to_protocol_address();
31        let session_mutex = self.session_lock_for(signal_address.as_str()).await;
32        let session_guard = session_mutex.lock().await;
33
34        let identity_change = process_prekey_bundle(
35            &signal_address,
36            &mut adapter.session_store,
37            &mut adapter.identity_store,
38            bundle,
39            rng,
40            UsePQRatchet::No,
41        )
42        .await?;
43
44        drop(session_guard);
45        if identity_change == IdentityChange::ReplacedExisting {
46            self.react_to_local_identity_change(jid);
47        }
48        Ok(identity_change)
49    }
50
51    /// WA Web: `WAWebOfflineResumeConst.OFFLINE_STANZA_TIMEOUT_MS = 60000`
52    pub(crate) const DEFAULT_OFFLINE_SYNC_TIMEOUT: Duration = Duration::from_secs(60);
53
54    pub(crate) async fn complete_offline_sync(&self, count: i32) {
55        self.offline_sync_metrics
56            .active
57            .store(false, Ordering::Release);
58        match self.offline_sync_metrics.start_time.lock() {
59            Ok(mut guard) => *guard = None,
60            Err(poison) => *poison.into_inner() = None,
61        }
62
63        // Run the finisher once (the semaphore swap is not idempotent). The
64        // guard is a dedicated flag, NOT offline_sync_completed: that one only
65        // flips after the tail commit so the tail's acks still observe it as
66        // false and join the aggregate offline-receipt drain (WA Web
67        // `sendAggregateOfflineReceipts`) instead of going out 1:1.
68        if self
69            .offline_sync_finish_started
70            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
71            .is_err()
72        {
73            return;
74        }
75
76        let Some(client) = self.self_weak.get().and_then(|w| w.upgrade()) else {
77            // Practically unreachable (the run loop owns a strong Arc), but a
78            // silent skip would leave the client batching forever with a
79            // widened semaphore — leave consistent live state behind instead.
80            log::error!(
81                "complete_offline_sync: self_weak upgrade failed; dropping the drain tail and switching to live mode"
82            );
83            self.inbound_commit_batch.force_live_dropping_entries();
84            self.publish_offline_sync_live_state(count, None);
85            return;
86        };
87
88        // The `ib` offline end marker is processed INLINE on the read loop, and
89        // the tail commit awaits the processing permit plus the durable write,
90        // Signal flush and the consumer's durability hook. Parking the read
91        // loop on that would starve IQ responses and pongs — a hook awaiting
92        // any server round-trip would deadlock, and a merely slow hook would
93        // trip the keepalive at the end of every drain. Ordering does not need
94        // the inline await: the single permit already serializes the finisher
95        // against stanza processing, so run it off-loop.
96        let generation = self.connection_generation.load(Ordering::Acquire);
97        self.runtime
98            .spawn(Box::pin(async move {
99                client.finish_offline_sync(count, generation).await;
100            }))
101            .detach();
102    }
103
104    /// Off-read-loop tail of [`complete_offline_sync`]: commit the drain tail,
105    /// then flip the completed flag, widen the semaphore and flush the
106    /// aggregate receipts. `generation` guards against a reconnect racing this
107    /// task — the new connection resets the drain state and must not have its
108    /// flag/semaphore/batcher touched by the old connection's finisher.
109    async fn finish_offline_sync(self: &Arc<Self>, count: i32, generation: u64) {
110        // Commit the drain tail and flip the batcher to live mode under the
111        // still-single processing permit (see flush_inbound_commits_under_permit
112        // for the raceless-transition argument), BEFORE widening the semaphore.
113        // Receipts flush after, so every receipt's message is durably committed
114        // first (WA Web's createSnapshot ordering).
115        let durable = self.finish_inbound_commit_drain(generation).await;
116
117        if self.connection_generation.load(Ordering::Acquire) != generation {
118            log::debug!(
119                "finish_offline_sync: connection generation changed during the tail commit; leaving the new connection's state alone"
120            );
121            return;
122        }
123
124        self.publish_offline_sync_live_state(count, Some(durable));
125    }
126
127    /// The drain→live state publication, shared by the finisher and its
128    /// upgrade-failure fallback (non-async so the codegen stays out of their
129    /// state machines).
130    ///
131    /// Readers that observe offline_sync_completed=true short-circuit without
132    /// touching the semaphore (wait_for_offline_delivery_end returns early),
133    /// so the ordering of flag flip vs. semaphore swap is not observable: any
134    /// in-flight worker keeps using its old 1-permit Arc and drains normally;
135    /// newly-spawned workers pick up the 64-permit semaphore via
136    /// read_message_semaphore(). The flag flip happens-before the receipt
137    /// drain takes the buffer lock, so late offline receipts either land in
138    /// the flush or observe the flag and send 1:1
139    /// (see try_buffer_offline_receipt).
140    ///
141    /// `durable`: `Some(true)` flushes the buffered offline receipts;
142    /// `Some(false)` drops them — the tail's durable write failed, its entries
143    /// are back in the batcher unacked, and receipting SKDM/session state that
144    /// never became durable would trade a redeliverable failure for a
145    /// crash-permanent one. In that case the batcher stays in drain mode AND
146    /// the semaphore stays at one permit: the whole-cache flush inside a
147    /// retry commit is only safe while no other stanza can be mid-decrypt
148    /// with unenqueued ratchet advances. The first durable flush completes
149    /// the deferred transition (see `complete_deferred_live_transition`) and
150    /// widens the semaphore then. `None` (upgrade-failure fallback) leaves
151    /// the buffer alone for the connection-state reset to clear.
152    fn publish_offline_sync_live_state(&self, count: i32, durable: Option<bool>) {
153        self.offline_sync_completed.store(true, Ordering::Release);
154        if durable != Some(false) {
155            self.swap_message_semaphore(64);
156        }
157        match durable {
158            Some(true) => self.flush_offline_receipts(),
159            Some(false) => {
160                log::warn!(
161                    "finish_offline_sync: tail commit not durable; dropping buffered offline receipts so the server redelivers"
162                );
163                self.clear_offline_receipt_buffer();
164            }
165            None => {}
166        }
167        self.offline_sync_notifier.notify(usize::MAX);
168        self.core.event_bus.dispatch(Event::OfflineSyncCompleted(
169            OfflineSyncCompleted::builder().count(count).build(),
170        ));
171    }
172
173    /// Wait for offline message delivery to complete (with timeout).
174    pub(crate) async fn wait_for_offline_delivery_end(&self) {
175        self.wait_for_offline_delivery_end_with_timeout(Self::DEFAULT_OFFLINE_SYNC_TIMEOUT)
176            .await;
177    }
178
179    pub(crate) async fn wait_for_offline_delivery_end_with_timeout(&self, timeout: Duration) {
180        let wait_generation = self.connection_generation.load(Ordering::Acquire);
181        let offline_fut = self.offline_sync_notifier.listen();
182        if self.offline_sync_completed.load(Ordering::Relaxed) {
183            return;
184        }
185
186        if wacore::runtime::timeout(&*self.runtime, timeout, offline_fut)
187            .await
188            .is_err()
189        {
190            // Guard: don't complete sync for a stale connection generation.
191            // A reconnect may have happened while we were waiting, making this
192            // timeout belong to the old connection.
193            if self.connection_generation.load(Ordering::Acquire) != wait_generation
194                || self.expected_disconnect.load(Ordering::Relaxed)
195            {
196                log::debug!(
197                    target: "Client/OfflineSync",
198                    "Offline sync timeout ignored: connection generation changed or disconnected",
199                );
200                return;
201            }
202
203            let processed = self
204                .offline_sync_metrics
205                .processed_messages
206                .load(Ordering::Acquire);
207            let expected = self
208                .offline_sync_metrics
209                .total_messages
210                .load(Ordering::Acquire);
211            log::warn!(
212                target: "Client/OfflineSync",
213                "Offline sync timed out after {:?} (processed {} of {} items); marking sync complete",
214                timeout,
215                processed,
216                expected,
217            );
218            self.complete_offline_sync(i32::try_from(processed).unwrap_or(i32::MAX))
219                .await;
220            // The finisher runs as a spawned task; keep this helper's contract
221            // that live state is in place when it returns (callers start
222            // session/send work right after). Ticked so a reconnect or
223            // shutdown mid-commit cannot strand this waiter, and bounded by a
224            // second `timeout` window: when the marker-triggered finisher was
225            // ALREADY running and its tail commit/hook is stuck, the
226            // complete_offline_sync above started nothing, and an unbounded
227            // wait here would defeat this helper's whole point — callers
228            // proceed and the finisher keeps running in the background.
229            let deadline = wacore::time::Instant::now() + timeout;
230            loop {
231                let listener = self.offline_sync_notifier.listen();
232                if self.offline_sync_completed.load(Ordering::Acquire)
233                    || self.connection_generation.load(Ordering::Acquire) != wait_generation
234                    || self.expected_disconnect.load(Ordering::Relaxed)
235                {
236                    return;
237                }
238                if wacore::time::Instant::now() >= deadline {
239                    log::warn!(
240                        target: "Client/OfflineSync",
241                        "Drain finisher still running {:?} after the offline sync timeout; proceeding without it",
242                        timeout,
243                    );
244                    return;
245                }
246                let _ = wacore::runtime::timeout(&*self.runtime, Duration::from_secs(1), listener)
247                    .await;
248            }
249        }
250    }
251
252    pub(crate) fn begin_history_sync_task(
253        &self,
254        payload_bytes: usize,
255    ) -> crate::sync_task::HistorySyncTaskTracker {
256        self.history_sync_activity.begin(payload_bytes)
257    }
258
259    pub async fn wait_for_startup_sync(&self, timeout: Duration) -> Result<()> {
260        use anyhow::anyhow;
261        use wacore::time::Instant;
262
263        let deadline = Instant::now() + timeout;
264
265        // Register the notified future *before* checking state to avoid missing
266        // a notify_waiters() that fires between the check and the await.
267        let offline_fut = self.offline_sync_notifier.listen();
268        if !self.offline_sync_completed.load(Ordering::Relaxed) {
269            let remaining = deadline.saturating_duration_since(Instant::now());
270            wacore::runtime::timeout(&*self.runtime, remaining, offline_fut)
271                .await
272                .map_err(|_| anyhow!("Timeout waiting for offline sync completion"))?;
273        }
274
275        loop {
276            let history_fut = self.history_sync_activity.listen();
277            if self.history_sync_activity.tasks() == 0 {
278                return Ok(());
279            }
280
281            let remaining = deadline.saturating_duration_since(Instant::now());
282            wacore::runtime::timeout(&*self.runtime, remaining, history_fut)
283                .await
284                .map_err(|_| anyhow!("Timeout waiting for history sync tasks to become idle"))?;
285        }
286    }
287
288    /// Ensure E2E sessions exist for the given device JIDs.
289    /// Waits for offline delivery, resolves LID mappings, then batches prekey fetches.
290    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.ensure", level = "debug", skip_all, fields(count = device_jids.len()), err(Debug)))]
291    pub(crate) async fn ensure_e2e_sessions(&self, device_jids: &[Jid]) -> Result<()> {
292        if device_jids.is_empty() {
293            return Ok(());
294        }
295        self.wait_for_offline_delivery_end().await;
296        let resolved_jids = self.resolve_lid_mappings(device_jids).await;
297        self.ensure_sessions_inner(resolved_jids).await
298    }
299
300    /// Like `ensure_e2e_sessions` but skips `resolve_lid_mappings`. Use when the
301    /// caller already resolved JIDs to the correct namespace (e.g., after
302    /// alternate PN/LID key normalization in retry handling).
303    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.ensure_resolved", level = "debug", skip_all, fields(count = jids.len()), err(Debug)))]
304    pub(crate) async fn ensure_e2e_sessions_resolved(&self, jids: &[Jid]) -> Result<()> {
305        if jids.is_empty() {
306            return Ok(());
307        }
308        self.wait_for_offline_delivery_end().await;
309        self.ensure_sessions_inner(jids.to_vec()).await
310    }
311}
312
313/// Whether a prekey fetch failed because the server considers the devices
314/// unregistered.
315///
316/// Batch-wide by nature: the fetch is one IQ, so a `406` answers for every jid
317/// in it rather than naming one.
318///
319/// Asked through `server_rejection` rather than by downcasting to one error
320/// type. This preflight calls `fetch_pre_keys` directly and gets a
321/// `crate::request::IqError::ServerError`; the fan-out reaches the same fetch
322/// through `SendContextResolver`, which re-wraps it as a
323/// `wacore::request::ServerErrorCode` to cross the crate boundary. A downcast
324/// to either one alone silently answers `false` for the other, and the failure
325/// mode of that is the send failing exactly as it did before.
326/// The `<error code>` the server attaches to a device it no longer knows.
327const UNREGISTERED_DEVICE_CODE: u16 = 406;
328
329fn is_device_unregistered(err: &anyhow::Error) -> bool {
330    use crate::error::ErrorChainExt;
331    err.server_rejection()
332        .is_some_and(|r| r.code == UNREGISTERED_DEVICE_CODE)
333}
334
335/// The distinct users named by `jids`, in first-seen order.
336///
337/// A prekey batch is usually several devices of the same one or two users, and
338/// every invalidation takes the registry lock and deletes rows, so visiting a
339/// user once per device would pay that repeatedly for no effect. Split out from
340/// the invalidation so the deduplication is observable on its own: through the
341/// cache it is not, since a user invalidated twice looks exactly like a user
342/// invalidated once.
343fn distinct_users(jids: &[Jid]) -> smallvec::SmallVec<[&str; 4]> {
344    let mut seen: smallvec::SmallVec<[&str; 4]> = smallvec::SmallVec::new();
345    for jid in jids {
346        if !seen.contains(&jid.user.as_str()) {
347            seen.push(jid.user.as_str());
348        }
349    }
350    seen
351}
352
353impl Client {
354    /// Refreshes the device list of every user named in `jids`, once each.
355    async fn invalidate_device_caches_for(&self, jids: &[Jid]) {
356        for user in distinct_users(jids) {
357            self.invalidate_device_cache(user).await;
358        }
359    }
360}
361
362impl Client {
363    /// Core session-check + prekey-fetch logic shared by both entry points.
364    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.ensure_inner", level = "debug", skip_all, fields(count = jids.len()), err(Debug)))]
365    async fn ensure_sessions_inner(&self, mut jids: Vec<Jid>) -> Result<()> {
366        use wacore::types::jid::JidExt;
367
368        // Warm-cache pre-filter: a cached session answers synchronously, so
369        // the common live-send case skips the probe-stream machinery below
370        // entirely. Contended or unknown entries fall through to the probe.
371        // Retain in place (reusing the input allocation) and rewrite one reusable
372        // address per jid instead of allocating a fresh ProtocolAddress for every
373        // lookup key. A plain local (not thread-local): the async probe below owns
374        // its own address per concurrent task.
375        let mut reusable_addr = wacore::types::jid::make_reusable_protocol_address();
376        jids.retain(|jid| {
377            jid.reset_protocol_address(&mut reusable_addr);
378            self.signal_cache.try_has_session(&reusable_addr) != Some(true)
379        });
380        if jids.is_empty() {
381            return Ok(());
382        }
383
384        let device_snapshot = self.persistence_manager.get_device_snapshot();
385
386        // Probe sessions concurrently: a cold-cache multi-recipient ensure would
387        // otherwise serialize the per-device DB reads (warm hits serialize on the
388        // cache mutex anyway). Order is irrelevant — misses are chunked for the fetch.
389        use futures::StreamExt;
390        const SESSION_PROBE_CONCURRENCY: usize = 16;
391        let backend = device_snapshot.backend.clone();
392        let jids_needing_sessions: Vec<Jid> = futures::stream::iter(jids)
393            .map(|jid| {
394                let backend = backend.clone();
395                async move {
396                    let signal_addr = jid.to_protocol_address();
397                    // Check cache first (includes unflushed sessions), fall back to backend.
398                    match self.signal_cache.has_session(&signal_addr, &*backend).await {
399                        Ok(true) => None,
400                        Ok(false) => Some(jid),
401                        Err(e) => {
402                            log::warn!("Failed to check session for {}: {}", jid.observe(), e);
403                            None
404                        }
405                    }
406                }
407            })
408            .buffer_unordered(SESSION_PROBE_CONCURRENCY)
409            .filter_map(|needed| async move { needed })
410            .collect()
411            .await;
412
413        if jids_needing_sessions.is_empty() {
414            return Ok(());
415        }
416
417        for batch in jids_needing_sessions.chunks(crate::session::SESSION_CHECK_BATCH_SIZE) {
418            self.fetch_and_establish_sessions(batch).await?;
419        }
420
421        Ok(())
422    }
423
424    /// Fetch prekeys and establish sessions for a batch of JIDs.
425    /// Returns the number of sessions successfully established.
426    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.fetch_establish", level = "debug", skip_all, fields(count = jids.len()), err(Debug)))]
427    async fn fetch_and_establish_sessions(&self, jids: &[Jid]) -> Result<usize, anyhow::Error> {
428        if jids.is_empty() {
429            return Ok(0);
430        }
431
432        let prekey_bundles = match self
433            .fetch_pre_keys(jids, Some(wacore::iq::prekeys::PreKeyFetchReason::Identity))
434            .await
435        {
436            Ok(bundles) => bundles,
437            // A `406` means the server no longer knows these devices, so the
438            // cached list that named them is stale. Refresh it before giving up,
439            // or the retry resolves the same absent device and collects the same
440            // 406 forever. It cannot affect the send in flight, whose device set
441            // is already resolved.
442            //
443            // The error still propagates, and deliberately so. The fetch is one
444            // IQ over a batch of up to `SESSION_CHECK_BATCH_SIZE` devices, and a
445            // 406 answers for the whole batch without naming which device it is
446            // about. Continuing would mean treating every device in that batch
447            // as having no prekeys, so a registered device that merely lacked a
448            // local session would be skipped by the fan-out and the message
449            // would go out to fewer devices than intended, with nothing to say
450            // so. A failed send is visible and now retries against a refreshed
451            // list; a silently short fan-out is neither.
452            Err(e) if is_device_unregistered(&e) => {
453                log::debug!(
454                    "Prekey fetch returned 406 for {} device(s); \
455                     refreshing their device lists before failing the send",
456                    jids.len()
457                );
458                self.invalidate_device_caches_for(jids).await;
459                return Err(e);
460            }
461            Err(e) => return Err(e),
462        };
463
464        // The server named these individually, which is the per-device signal a
465        // batch-wide failure cannot give: refresh exactly their device lists and
466        // leave the rest of the batch alone. The send continues, because the
467        // devices that did come back with a bundle are unaffected and skipping
468        // them would deliver to fewer devices for a reason that only concerns
469        // the named ones.
470        if !prekey_bundles.rejected.is_empty() {
471            let rejected: Vec<Jid> = prekey_bundles
472                .rejected
473                .iter()
474                .filter(|device| device.code == UNREGISTERED_DEVICE_CODE)
475                .map(|device| device.jid.clone())
476                .collect();
477            if !rejected.is_empty() {
478                log::debug!(
479                    "prekey fetch rejected {} of {} device(s) as unregistered; \
480                     refreshing their device lists",
481                    rejected.len(),
482                    jids.len()
483                );
484                self.invalidate_device_caches_for(&rejected).await;
485            }
486        }
487
488        let mut adapter = self.signal_adapter().await;
489        let mut rng = rand::make_rng::<StdRng>();
490
491        let mut success_count = 0;
492        let mut missing_count = 0;
493        let mut failed_count = 0;
494
495        for jid in jids {
496            if let Some(bundle) = prekey_bundles.bundles.get(jid) {
497                match self
498                    .install_prekey_bundle_cached(jid, bundle, &mut adapter, &mut rng)
499                    .await
500                {
501                    Ok(_) => {
502                        success_count += 1;
503                        log::debug!("Successfully established session with {}", jid.observe());
504                    }
505                    Err(e) => {
506                        failed_count += 1;
507                        log::warn!("Failed to establish session with {}: {}", jid.observe(), e);
508                    }
509                }
510            } else {
511                missing_count += 1;
512                if jid.device == 0 {
513                    log::warn!(
514                        "Server did not return prekeys for primary phone {}",
515                        jid.observe()
516                    );
517                } else {
518                    log::debug!("Server did not return prekeys for {}", jid.observe());
519                }
520            }
521        }
522
523        if missing_count > 0 || failed_count > 0 {
524            log::debug!(
525                "Session establishment: {} succeeded, {} missing prekeys, {} failed (of {} requested)",
526                success_count,
527                missing_count,
528                failed_count,
529                jids.len()
530            );
531        }
532
533        // Flush after all sessions established. Batch-safe: retry receipts
534        // reach here mid-drain, and post-timeout senders reach here during a
535        // deferred live transition — in both windows a raw whole-cache flush
536        // would persist rowless drain entries' ratchet advances.
537        if success_count > 0 {
538            self.flush_signal_cache_batch_safe().await?;
539        }
540
541        Ok(success_count)
542    }
543
544    /// Log primary phone (device 0) session state at login.
545    /// Migration is lazy via try_pn_to_lid_migration_decrypt on first message.
546    #[cfg_attr(
547        feature = "tracing",
548        tracing::instrument(
549            name = "wa.session.primary_phone_check",
550            level = "debug",
551            skip_all,
552            err(Debug)
553        )
554    )]
555    pub(crate) async fn establish_primary_phone_session_immediate(&self) -> Result<()> {
556        let device_snapshot = self.persistence_manager.get_device_snapshot();
557
558        let own_pn = device_snapshot
559            .pn
560            .clone()
561            .ok_or_else(|| anyhow::Error::from(crate::client::ClientError::NotLoggedIn))?;
562
563        let Some(ref own_lid) = device_snapshot.lid else {
564            log::debug!("No own LID yet, skipping primary phone session check");
565            return Ok(());
566        };
567
568        let primary_phone_lid = own_lid.with_device(0);
569        let primary_phone_pn = own_pn.with_device(0);
570
571        let lid_exists = self
572            .check_session_exists(&primary_phone_lid)
573            .await
574            .unwrap_or(false);
575        let pn_exists = self
576            .check_session_exists(&primary_phone_pn)
577            .await
578            .unwrap_or(false);
579
580        match (lid_exists, pn_exists) {
581            (true, _) => log::debug!("LID session with {} exists", primary_phone_lid.observe()),
582            (false, true) => {
583                log::debug!("PN-only session for own device 0 — will migrate on first message")
584            }
585            (false, false) => {
586                log::debug!("No session with own device 0 — will establish on first message")
587            }
588        }
589
590        Ok(())
591    }
592
593    /// Whether `message_encrypt` for `jid` would emit a pkmsg (no session, or a
594    /// session with an un-acked pre-key still pending). Reuses the send path's
595    /// pre-flight so the voip offer treats a session-present-but-unacked device as
596    /// pkmsg too, not as plain msg.
597    #[cfg(feature = "voip-runtime")]
598    pub(crate) async fn would_emit_pkmsg(&self, jid: &Jid) -> Result<bool, anyhow::Error> {
599        let device_store = self.persistence_manager.get_device_arc().await;
600        let mut adapter = self.signal_adapter_from(device_store);
601        let signal_addr = jid.to_protocol_address();
602        wacore::send::pkmsg_would_be_emitted(&mut adapter.session_store, &signal_addr).await
603    }
604
605    /// Check if a session exists for the given JID.
606    pub(crate) async fn check_session_exists(&self, jid: &Jid) -> Result<bool, anyhow::Error> {
607        let device_snapshot = self.persistence_manager.get_device_snapshot();
608        let signal_addr = jid.to_protocol_address();
609
610        device_snapshot
611            .contains_session(&signal_addr)
612            .await
613            .map_err(|e| anyhow::anyhow!("Failed to check session for {}: {}", jid.observe(), e))
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use wacore_binary::{JidExt, Server};
621
622    /// The 406 the preflight now tolerates is recognised by its server code and
623    /// nothing else: any other failure must still fail the send, or a transport
624    /// or auth error would be silently downgraded into "these devices have no
625    /// prekeys" and the message would go out to fewer devices than it should.
626    #[test]
627    fn only_a_406_counts_as_an_unregistered_device() {
628        // Both spellings, because they are both real: this preflight calls
629        // `fetch_pre_keys` directly and receives the first, while the fan-out
630        // goes through `SendContextResolver`, which re-wraps it as the second.
631        let as_iq_error = |code| {
632            anyhow::Error::new(crate::request::IqError::ServerError {
633                code,
634                text: "not-acceptable".to_string(),
635                error_type: None,
636                backoff: None,
637            })
638        };
639        let as_shared = |code| {
640            anyhow::Error::new(wacore::request::ServerErrorCode {
641                code,
642                text: "not-acceptable".to_string(),
643                error_type: None,
644                backoff: None,
645            })
646        };
647
648        assert!(
649            is_device_unregistered(&as_iq_error(406)),
650            "the error this preflight actually receives must be recognised"
651        );
652        assert!(is_device_unregistered(&as_shared(406)));
653
654        for code in [400, 401, 403, 404, 429, 500, 503] {
655            assert!(
656                !is_device_unregistered(&as_iq_error(code)),
657                "a {code} must not be treated as an unregistered device"
658            );
659            assert!(!is_device_unregistered(&as_shared(code)));
660        }
661
662        // Not every failure is a server error at all.
663        assert!(!is_device_unregistered(&anyhow::anyhow!("socket closed")));
664    }
665
666    /// A prekey batch is several devices of the same one or two users, and each
667    /// invalidation takes the registry lock and deletes rows, so the users are
668    /// visited once each rather than once per device.
669    ///
670    /// Asserted on the list rather than through the cache: an entry invalidated
671    /// twice is indistinguishable from one invalidated once, so a cache-level
672    /// test would pass no matter how many times each user was visited.
673    #[test]
674    fn a_batch_names_each_user_once_in_order() {
675        let a = Jid::pn("5511900000050");
676        let b = Jid::pn("5511900000051");
677        let jids = vec![
678            a.with_device(0),
679            a.with_device(1),
680            b.with_device(0),
681            a.with_device(2),
682            b.with_device(3),
683        ];
684
685        assert_eq!(
686            distinct_users(&jids).as_slice(),
687            [a.user.as_str(), b.user.as_str()],
688            "each user once, in the order the batch first names them"
689        );
690
691        assert!(distinct_users(&[]).is_empty());
692        assert_eq!(
693            distinct_users(std::slice::from_ref(&a.with_device(7))).as_slice(),
694            [a.user.as_str()]
695        );
696    }
697
698    #[test]
699    fn test_primary_phone_jid_creation_from_pn() {
700        let own_pn = Jid::pn("559999999999");
701        let primary_phone_jid = own_pn.with_device(0);
702
703        assert_eq!(primary_phone_jid.user, "559999999999");
704        assert_eq!(primary_phone_jid.server, Server::Pn);
705        assert_eq!(primary_phone_jid.device, 0);
706        assert_eq!(primary_phone_jid.agent, 0);
707        assert_eq!(primary_phone_jid.to_string(), "559999999999@s.whatsapp.net");
708    }
709
710    #[test]
711    fn test_primary_phone_jid_overwrites_existing_device() {
712        // Edge case: pn with device ID should still produce device 0
713        let own_pn = Jid::pn_device("559999999999", 33);
714        let primary_phone_jid = own_pn.with_device(0);
715
716        assert_eq!(primary_phone_jid.user, "559999999999");
717        assert_eq!(primary_phone_jid.server, Server::Pn);
718        assert_eq!(primary_phone_jid.device, 0);
719    }
720
721    #[test]
722    fn test_primary_phone_jid_is_not_ad() {
723        let primary_phone_jid = Jid::pn("559999999999").with_device(0);
724        assert!(!primary_phone_jid.is_ad()); // device 0 is NOT an additional device
725    }
726
727    #[test]
728    fn test_linked_device_is_ad() {
729        let linked_device_jid = Jid::pn_device("559999999999", 33);
730        assert!(linked_device_jid.is_ad()); // device > 0 IS an additional device
731    }
732
733    #[test]
734    fn test_primary_phone_jid_from_lid() {
735        let own_lid = Jid::lid("100000000000001");
736        let primary_phone_jid = own_lid.with_device(0);
737
738        assert_eq!(primary_phone_jid.user, "100000000000001");
739        assert_eq!(primary_phone_jid.server, Server::Lid);
740        assert_eq!(primary_phone_jid.device, 0);
741        assert!(!primary_phone_jid.is_ad());
742    }
743
744    #[test]
745    fn test_primary_phone_jid_roundtrip() {
746        let own_pn = Jid::pn("559999999999");
747        let primary_phone_jid = own_pn.with_device(0);
748
749        let jid_string = primary_phone_jid.to_string();
750        assert_eq!(jid_string, "559999999999@s.whatsapp.net");
751
752        let parsed: Jid = jid_string.parse().expect("JID should be parseable");
753        assert_eq!(parsed.user, "559999999999");
754        assert_eq!(parsed.server, Server::Pn);
755        assert_eq!(parsed.device, 0);
756    }
757
758    #[test]
759    fn test_with_device_preserves_identity() {
760        let pn = Jid::pn("1234567890");
761        let pn_device_0 = pn.with_device(0);
762        let pn_device_5 = pn.with_device(5);
763
764        assert_eq!(pn_device_0.user, pn_device_5.user);
765        assert_eq!(pn_device_0.server, pn_device_5.server);
766        assert_eq!(pn_device_0.device, 0);
767        assert_eq!(pn_device_5.device, 5);
768
769        let lid = Jid::lid("100000012345678");
770        let lid_device_0 = lid.with_device(0);
771        let lid_device_33 = lid.with_device(33);
772
773        assert_eq!(lid_device_0.user, lid_device_33.user);
774        assert_eq!(lid_device_0.server, lid_device_33.server);
775        assert_eq!(lid_device_0.device, 0);
776        assert_eq!(lid_device_33.device, 33);
777    }
778
779    #[test]
780    fn test_primary_phone_vs_companion_devices() {
781        let user = "559999999999";
782        let primary = Jid::pn(user).with_device(0);
783        let companion_web = Jid::pn_device(user, 33);
784        let companion_desktop = Jid::pn_device(user, 34);
785
786        // All share the same user
787        assert_eq!(primary.user, companion_web.user);
788        assert_eq!(primary.user, companion_desktop.user);
789
790        // But have different device IDs
791        assert_eq!(primary.device, 0);
792        assert_eq!(companion_web.device, 33);
793        assert_eq!(companion_desktop.device, 34);
794
795        // Primary is NOT AD, companions ARE AD
796        assert!(!primary.is_ad());
797        assert!(companion_web.is_ad());
798        assert!(companion_desktop.is_ad());
799    }
800
801    /// Session check must succeed before establishment (fail-safe behavior).
802    #[test]
803    fn test_session_check_behavior_documentation() {
804        // Ok(true) -> skip, Ok(false) -> establish, Err -> fail-safe
805        enum SessionCheckResult {
806            Exists,
807            NotExists,
808            CheckFailed,
809        }
810
811        fn should_establish_session(
812            check_result: SessionCheckResult,
813        ) -> Result<bool, &'static str> {
814            match check_result {
815                SessionCheckResult::Exists => Ok(false),   // Don't establish
816                SessionCheckResult::NotExists => Ok(true), // Do establish
817                SessionCheckResult::CheckFailed => Err("Cannot verify - fail safe"),
818            }
819        }
820
821        // Test cases
822        assert_eq!(
823            should_establish_session(SessionCheckResult::Exists),
824            Ok(false)
825        );
826        assert_eq!(
827            should_establish_session(SessionCheckResult::NotExists),
828            Ok(true)
829        );
830        assert!(should_establish_session(SessionCheckResult::CheckFailed).is_err());
831    }
832
833    /// Protocol address format: {user}[:device]@{server}.0
834    #[test]
835    fn test_protocol_address_format_for_session_lookup() {
836        use wacore::types::jid::JidExt;
837
838        let pn = Jid::pn("559999999999").with_device(0);
839        let addr = pn.to_protocol_address();
840        assert_eq!(addr.name(), "559999999999@c.us");
841        assert_eq!(u32::from(addr.device_id()), 0);
842        assert_eq!(addr.to_string(), "559999999999@c.us.0");
843
844        let companion = Jid::pn_device("559999999999", 33);
845        let companion_addr = companion.to_protocol_address();
846        assert_eq!(companion_addr.name(), "559999999999:33@c.us");
847        assert_eq!(companion_addr.to_string(), "559999999999:33@c.us.0");
848
849        let lid = Jid::lid("100000000000001").with_device(0);
850        let lid_addr = lid.to_protocol_address();
851        assert_eq!(lid_addr.name(), "100000000000001@lid");
852        assert_eq!(u32::from(lid_addr.device_id()), 0);
853        assert_eq!(lid_addr.to_string(), "100000000000001@lid.0");
854
855        let lid_device = Jid::lid_device("100000000000001", 33);
856        let lid_device_addr = lid_device.to_protocol_address();
857        assert_eq!(lid_device_addr.name(), "100000000000001:33@lid");
858        assert_eq!(lid_device_addr.to_string(), "100000000000001:33@lid.0");
859    }
860
861    #[test]
862    fn test_filter_logic_for_session_establishment() {
863        let jids = vec![
864            Jid::pn_device("111", 0),
865            Jid::pn_device("222", 0),
866            Jid::pn_device("333", 0),
867        ];
868
869        // Simulate contains_session results
870        let session_exists = |jid: &Jid| -> Result<bool, &'static str> {
871            match jid.user.as_str() {
872                "111" => Ok(true),        // Session exists
873                "222" => Ok(false),       // No session
874                "333" => Err("DB error"), // Error
875                _ => Ok(false),
876            }
877        };
878
879        // Apply filter logic (matching ensure_e2e_sessions behavior)
880        let mut jids_needing_sessions = Vec::with_capacity(jids.len());
881        for jid in &jids {
882            match session_exists(jid) {
883                Ok(true) => {}                                        // Skip - session exists
884                Ok(false) => jids_needing_sessions.push(jid.clone()), // Needs session
885                Err(e) => eprintln!("Warning: failed to check {}: {}", jid, e), // Skip on error
886            }
887        }
888
889        // Only "222" should need a session
890        assert_eq!(jids_needing_sessions.len(), 1);
891        assert_eq!(jids_needing_sessions[0].user, "222");
892    }
893
894    // PN and LID have independent Signal sessions
895
896    #[test]
897    fn test_dual_addressing_pn_and_lid_are_independent() {
898        let pn_address = Jid::pn("551199887766").with_device(0);
899        let lid_address = Jid::lid("236395184570386").with_device(0);
900
901        assert_ne!(pn_address.user, lid_address.user);
902        assert_ne!(pn_address.server, lid_address.server);
903
904        use wacore::types::jid::JidExt;
905        let pn_signal_addr = pn_address.to_protocol_address();
906        let lid_signal_addr = lid_address.to_protocol_address();
907
908        assert_ne!(pn_signal_addr.name(), lid_signal_addr.name());
909        assert_eq!(pn_signal_addr.name(), "551199887766@c.us");
910        assert_eq!(lid_signal_addr.name(), "236395184570386@lid");
911        assert_eq!(pn_address.device, 0);
912        assert_eq!(lid_address.device, 0);
913    }
914
915    #[test]
916    fn test_lid_extraction_from_own_device() {
917        let own_lid_with_device = Jid::lid_device("236395184570386", 61);
918        let primary_lid = own_lid_with_device.with_device(0);
919
920        assert_eq!(primary_lid.user, "236395184570386");
921        assert_eq!(primary_lid.device, 0);
922        assert!(!primary_lid.is_ad());
923    }
924
925    /// PN sessions established proactively, LID sessions established by primary phone.
926    #[test]
927    fn test_stale_session_scenario_documentation() {
928        fn should_establish_pn_session(pn_exists: bool) -> bool {
929            !pn_exists
930        }
931
932        fn should_establish_lid_session(_lid_exists: bool) -> bool {
933            false // Primary phone establishes LID sessions via pkmsg
934        }
935
936        // PN exists -> don't establish
937        assert!(!should_establish_pn_session(true));
938        // PN doesn't exist -> establish
939        assert!(should_establish_pn_session(false));
940        // LID never established proactively
941        assert!(!should_establish_lid_session(true));
942        assert!(!should_establish_lid_session(false));
943    }
944
945    /// Retry mechanism: error=1 (NoSession), error=4 (InvalidMessage/MAC failure)
946    #[test]
947    fn test_retry_mechanism_for_stale_sessions() {
948        const RETRY_ERROR_NO_SESSION: u8 = 1;
949        const RETRY_ERROR_INVALID_MESSAGE: u8 = 4;
950
951        fn action_for_error(error_code: u8) -> &'static str {
952            match error_code {
953                RETRY_ERROR_NO_SESSION => "Establish new session via prekey",
954                RETRY_ERROR_INVALID_MESSAGE => "Delete stale session, resend message",
955                _ => "Unknown error",
956            }
957        }
958
959        assert_eq!(
960            action_for_error(RETRY_ERROR_NO_SESSION),
961            "Establish new session via prekey"
962        );
963        assert_eq!(
964            action_for_error(RETRY_ERROR_INVALID_MESSAGE),
965            "Delete stale session, resend message"
966        );
967    }
968
969    #[test]
970    fn test_session_establishment_lookup_normalization() {
971        use std::collections::HashMap;
972        use wacore_binary::Jid;
973
974        // Represents the bundle map returned by fetch_pre_keys
975        // (keys are normalized by parsing logic as verified in wacore/src/prekeys.rs)
976        let mut prekey_bundles: HashMap<Jid, ()> = HashMap::new(); // Using () as mock bundle placeholder
977
978        let normalized_jid = Jid::lid("123456789"); // agent=0
979        prekey_bundles.insert(normalized_jid.clone(), ());
980
981        // Represents the JID from the device list (e.g. from ensure_e2e_sessions)
982        // which might have agent=1 due to some upstream source or parsing quirk
983        let mut requested_jid = Jid::lid("123456789");
984        requested_jid.agent = 1;
985
986        // The agent is inert on a LID, so it does not hide the bundle: the raw
987        // lookup finds it. Normalising the key first was the workaround this
988        // replaced, and the helper that did it is gone.
989        assert!(
990            prekey_bundles.contains_key(&requested_jid),
991            "an inert agent must not hide the bundle"
992        );
993        assert_eq!(requested_jid, normalized_jid);
994    }
995}