Skip to main content

whatsapp_rust/client/
adapters.rs

1//! Signal/sender-key store adapters, per-session locks and noise socket access.
2
3use super::*;
4use anyhow::Context as _;
5
6impl Client {
7    /// Build a [`SignalProtocolStoreAdapter`] from the current device state and signal cache.
8    pub(crate) async fn signal_adapter(
9        &self,
10    ) -> crate::store::signal_adapter::SignalProtocolStoreAdapter {
11        let device_store = self.persistence_manager.get_device_arc().await;
12        self.signal_adapter_from(device_store)
13    }
14
15    /// Build a standalone [`SenderKeyAdapter`] from the current device state and
16    /// signal cache, avoiding the full five-store adapter on the SKDM path.
17    pub(crate) async fn sender_key_adapter(
18        &self,
19    ) -> crate::store::signal_adapter::SenderKeyAdapter {
20        crate::store::signal_adapter::SenderKeyAdapter::new(
21            self.persistence_manager.get_device_arc().await,
22            self.signal_cache.clone(),
23        )
24    }
25
26    /// Build a [`SignalProtocolStoreAdapter`] from a pre-fetched device arc.
27    pub(crate) fn signal_adapter_from(
28        &self,
29        device_store: Arc<RwLock<crate::store::Device>>,
30    ) -> crate::store::signal_adapter::SignalProtocolStoreAdapter {
31        crate::store::signal_adapter::SignalProtocolStoreAdapter::new(
32            device_store,
33            self.signal_cache.clone(),
34        )
35    }
36
37    /// Get the per-address session mutex from the lock cache.
38    pub(crate) async fn session_lock_for(&self, signal_addr_str: &str) -> Arc<Mutex<()>> {
39        self.session_locks
40            .get_with_by_ref(signal_addr_str, async { Arc::new(Mutex::new(())) })
41            .await
42    }
43
44    /// Acquire the per-group cold-distribution single-flight guard.
45    pub(crate) async fn group_distribution_lock(
46        &self,
47        group: &Jid,
48    ) -> async_lock::MutexGuardArc<()> {
49        self.group_distribution_locks
50            .get_with_by_ref(group, async { Arc::new(Mutex::new(())) })
51            .await
52            .lock_arc()
53            .await
54    }
55
56    /// Get the active noise socket, or error if not connected.
57    pub(crate) async fn get_noise_socket(&self) -> Result<Arc<NoiseSocket>, ClientError> {
58        self.noise_socket
59            .lock()
60            .await
61            .clone()
62            .ok_or(ClientError::NotConnected)
63    }
64
65    /// Force any pending write-behind Signal cache state to the backend,
66    /// returning once the flush completes (or fails).
67    ///
68    /// The live receive path schedules a coalesced flush (see `signal_flush.rs`)
69    /// instead of writing through, and lease-covered sends do the same. Only a
70    /// send that raises a session or sender-key counter lease flushes
71    /// synchronously. On success, the
72    /// backend normally trails the cache by about the coalescing window, but
73    /// that is not a hard wall-clock bound — the timer can slip under runtime
74    /// starvation and the flush can wait on locks or slow/failing storage (a
75    /// backend outage extends it until the retry loop succeeds). Use this to
76    /// settle durability deterministically before reading persisted state or
77    /// ahead of a non-graceful shutdown — and check the returned `Result`, as a
78    /// failure leaves state pending.
79    ///
80    /// Call from a control task, never from inside an event handler or an
81    /// [`InboundDurabilityHook`]: during an offline-sync drain those run while
82    /// the processing permit is held, and settling routes through that same
83    /// permit — re-entering it would deadlock.
84    ///
85    /// [`InboundDurabilityHook`]: crate::types::durability_hook::InboundDurabilityHook
86    pub async fn flush_pending_signal_state(&self) -> Result<(), SignalMaintenanceError> {
87        self.flush_signal_cache_batch_safe().await
88    }
89
90    /// Flush the in-memory signal cache to the database backend. Invoked by the
91    /// send path (synchronously, pre-wire), the receive-path coalescer, and the
92    /// drain/retry/teardown recovery paths — not unconditionally per message.
93    pub(crate) async fn flush_signal_cache(&self) -> Result<(), anyhow::Error> {
94        // Clone the backend before awaiting so a slow write cannot retain the
95        // device guard and stall every concurrent Device write.
96        let backend = self
97            .persistence_manager
98            .get_device_snapshot()
99            .backend
100            .clone();
101        self.signal_cache
102            .flush(&*backend)
103            .await
104            .context("Failed to flush signal cache")
105    }
106
107    /// Signal-cache flush that is safe while the offline drain is active.
108    ///
109    /// [`flush_signal_cache`](Self::flush_signal_cache) is safe only when the
110    /// caller holds the message processing permit or the batcher is known
111    /// inactive: it persists the WHOLE cache, including ratchet advances of
112    /// drain entries that may not have a durable buffered row yet. Everything
113    /// else must go through this `_batch_safe` variant.
114    ///
115    /// During the drain, decrypted messages accumulate in the commit batcher
116    /// with no durable buffered copy; flushing the cache from an unrelated
117    /// path (a retry receipt, a send, an identity change) would persist their
118    /// ratchet advances, and a crash/teardown that then drops the entries
119    /// turns each redelivery into an ackable duplicate — silent loss for hook
120    /// consumers. So in drain mode this routes through the batcher: commit
121    /// the pending entries (rows first) and flush under the processing
122    /// permit. Outside the drain it is exactly [`Self::flush_signal_cache`].
123    ///
124    /// Must NOT be called while holding the processing permit (it acquires
125    /// it); permit-holding paths commit via the batcher directly.
126    pub(crate) async fn flush_signal_cache_batch_safe(&self) -> Result<(), SignalMaintenanceError> {
127        // Under the permit the commit ALWAYS flushes the Signal cache (an empty
128        // batch still flushes — see flush_inbound_commits_under_permit), so a
129        // successful call is proof the out-of-band advance is persisted; no
130        // stale is_active() re-check needed even if the drain finisher
131        // deactivated while we waited for the permit.
132        let drain_active = self.inbound_commit_batch.is_active();
133        if drain_active && let Some(client) = self.self_weak.get().and_then(|w| w.upgrade()) {
134            return if client
135                .flush_inbound_commits_under_permit(false, None, None)
136                .await
137            {
138                Ok(())
139            } else {
140                Err(SignalMaintenanceError::DrainCommitFailed)
141            };
142        } else if drain_active {
143            // Drain active but no live client to route the permit-held flush
144            // through (practically unreachable — the run loop holds a strong
145            // Arc). Fail closed regardless of has_entries(): an empty drain can
146            // still carry dirty SKDM-only advances with no rows, and a raw
147            // flush here would persist them rowless — the exact loss this
148            // batch-safe path exists to prevent. Leaving the cache unflushed
149            // makes the server redeliver.
150            return Err(SignalMaintenanceError::DrainShuttingDown);
151        }
152        self.flush_signal_cache()
153            .await
154            .map_err(SignalMaintenanceError::Storage)
155    }
156
157    /// Pre-wire durability gate for the send path. Flushes synchronously only
158    /// when an outbound crypto advance actually demands it — a raised session
159    /// counter lease or a raised sender-key lease not yet persisted (see
160    /// `SignalStoreCache::needs_pre_wire_flush`). Otherwise the dirty state is
161    /// covered by an existing durable lease, so it only needs to land
162    /// eventually: it rides the same coalesced write-behind as the receive
163    /// path instead of paying a serialize + storage transaction per message.
164    /// A failure must abort the send — transmitting a ciphertext whose lease
165    /// could not be persisted reintroduces the counter-reuse window.
166    ///
167    /// The gate is deliberately a GLOBAL predicate, not one scoped to the
168    /// addresses this stanza encrypted for: the send path does not carry them
169    /// back up, and erring toward an extra flush is the safe direction (it can
170    /// over-flush, never under-flush). The cost is a sharp edge under a failing
171    /// backend: another session's pending lease makes this send flush, and that
172    /// flush's failure aborts this send too — the same collateral every send
173    /// took before leases existed, now limited to the window where some lease
174    /// is actually unpersisted. So "a lease-covered send needs no flush" is a
175    /// statement about what this send REQUIRES, not a promise it never flushes.
176    pub(crate) async fn persist_signal_state_pre_wire(&self) -> Result<(), anyhow::Error> {
177        if self.signal_cache.needs_pre_wire_flush().await {
178            return self
179                .flush_signal_cache_batch_safe()
180                .await
181                .map_err(Into::into);
182        }
183        self.schedule_signal_flush(self.connection_generation.load(Ordering::Acquire));
184        Ok(())
185    }
186
187    /// [`flush_signal_cache_batch_safe`](Self::flush_signal_cache_batch_safe)
188    /// with error logging instead of propagation.
189    pub(crate) async fn flush_signal_cache_batch_safe_logged(
190        &self,
191        context: &str,
192        id: Option<&str>,
193    ) {
194        if let Err(e) = self.flush_signal_cache_batch_safe().await {
195            log_signal_flush_error(context, id, &e);
196        }
197    }
198}
199
200fn log_signal_flush_error(context: &str, id: Option<&str>, e: &SignalMaintenanceError) {
201    if let Some(id) = id {
202        log::error!("Failed to flush signal cache ({context} {id}): {e:?}");
203    } else {
204        log::error!("Failed to flush signal cache ({context}): {e:?}");
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use wacore::store::in_memory::InMemoryBackend;
212    use wacore_binary::{Jid, Server};
213
214    #[tokio::test]
215    async fn signal_flush_context_preserves_the_backend_error_chain() {
216        let backend = Arc::new(InMemoryBackend::new());
217        let client = crate::test_utils::create_test_client_with_backend(backend.clone()).await;
218        let peer = Jid::new("12025550111", Server::Pn).with_device(1);
219        crate::test_utils::seed_peer_session(&client, &peer).await;
220        backend.set_fail_session_writes(true);
221
222        let error = client
223            .flush_signal_cache()
224            .await
225            .expect_err("injected backend failure must propagate");
226        let chain: Vec<String> = error.chain().map(ToString::to_string).collect();
227
228        assert_eq!(
229            chain.first().map(String::as_str),
230            Some("Failed to flush signal cache")
231        );
232        assert!(
233            chain
234                .iter()
235                .any(|cause| cause.contains("put_sessions_batch failing (test hook)")),
236            "typed backend cause missing from {chain:?}"
237        );
238    }
239
240    #[tokio::test]
241    async fn flush_pending_signal_state_settles_a_healthy_backend() {
242        let backend = Arc::new(InMemoryBackend::new());
243        let client = crate::test_utils::create_test_client_with_backend(backend.clone()).await;
244        let peer = Jid::new("12025550112", Server::Pn).with_device(1);
245        crate::test_utils::seed_peer_session(&client, &peer).await;
246
247        client
248            .flush_pending_signal_state()
249            .await
250            .expect("a healthy backend must settle");
251    }
252
253    #[tokio::test]
254    async fn flush_pending_signal_state_reports_a_backend_failure_as_storage() {
255        let backend = Arc::new(InMemoryBackend::new());
256        let client = crate::test_utils::create_test_client_with_backend(backend.clone()).await;
257        let peer = Jid::new("12025550113", Server::Pn).with_device(1);
258        crate::test_utils::seed_peer_session(&client, &peer).await;
259        backend.set_fail_session_writes(true);
260
261        let error = client
262            .flush_pending_signal_state()
263            .await
264            .expect_err("injected backend failure must propagate");
265        assert!(matches!(error, SignalMaintenanceError::Storage(_)));
266        assert!(std::error::Error::source(&error).is_some());
267    }
268}