Skip to main content

whatsapp_rust/client/
builder.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use thiserror::Error;
6
7use super::Client;
8#[cfg(feature = "client-lifecycle")]
9use super::{ClientLifecycle, LifecycleRegistration};
10use crate::cache_config::CacheConfig;
11use crate::http::HttpClient;
12#[cfg(feature = "plugins")]
13use crate::plugins::{
14    ClientPlugin, PluginHost, PluginHostConfig, PluginPlan, PluginPlanError, PluginRegistration,
15    UntypedClientPlugin,
16};
17use crate::store::error::StoreError;
18use crate::store::persistence_manager::PersistenceManager;
19use crate::sync_task::MajorSyncTask;
20use crate::transport::TransportFactory;
21use crate::types::durability_hook::InboundDurabilityHook;
22use crate::types::enc_handler::EncHandler;
23use wacore::runtime::Runtime;
24
25/// Result of constructing a [`Client`].
26///
27/// Consume with [`ClientBuild::into_client`] for the standard worker or
28/// [`ClientBuild::into_parts`] when the host owns that worker itself.
29pub struct ClientBuild {
30    client: Arc<Client>,
31    sync_task_receiver: async_channel::Receiver<MajorSyncTask>,
32}
33
34impl ClientBuild {
35    pub(crate) fn new(
36        client: Arc<Client>,
37        sync_task_receiver: async_channel::Receiver<MajorSyncTask>,
38    ) -> Self {
39        Self {
40            client,
41            sync_task_receiver,
42        }
43    }
44
45    /// Transfer the client and start its default major-sync worker.
46    pub fn into_client(self) -> Arc<Client> {
47        let (client, sync_task_receiver) = self.into_parts();
48        client.start_sync_task_worker(sync_task_receiver);
49        client
50    }
51
52    /// Transfer ownership of the client and its sole sync-task receiver.
53    /// The caller must drain the receiver for history sync to keep working.
54    pub fn into_parts(self) -> (Arc<Client>, async_channel::Receiver<MajorSyncTask>) {
55        (self.client, self.sync_task_receiver)
56    }
57}
58
59/// A validated client-construction failure.
60#[derive(Debug, Error)]
61#[non_exhaustive]
62pub enum ClientBuilderError {
63    #[error("missing async runtime")]
64    MissingRuntime,
65    #[error("missing persistence manager")]
66    MissingPersistenceManager,
67    #[error("missing transport factory")]
68    MissingTransportFactory,
69    #[error("missing HTTP client")]
70    MissingHttpClient,
71    #[error("background saver interval must be greater than zero")]
72    InvalidBackgroundSaverInterval,
73    #[cfg(feature = "plugins")]
74    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
75    #[error("plugin install timeout must be greater than zero")]
76    InvalidPluginInstallTimeout,
77    #[cfg(feature = "plugins")]
78    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
79    #[error("plugin callback timeout must be greater than zero")]
80    InvalidPluginCallbackTimeout,
81    #[cfg(feature = "plugins")]
82    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
83    #[error("plugin task-drain timeout must be greater than zero")]
84    InvalidPluginTaskDrainTimeout,
85    #[error("the configured backend does not support the inbound durability hook: {0}")]
86    UnsupportedDurabilityBackend(String),
87    #[cfg(feature = "client-lifecycle")]
88    #[cfg_attr(docsrs, doc(cfg(feature = "client-lifecycle")))]
89    #[error("client lifecycle installation failed: {0}")]
90    LifecycleInstall(#[source] anyhow::Error),
91    #[cfg(feature = "plugins")]
92    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
93    #[error("plugin host installation failed: {0}")]
94    PluginInstall(#[source] anyhow::Error),
95    #[cfg(feature = "plugins")]
96    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
97    #[error("invalid plugin plan: {0}")]
98    PluginPlan(#[from] PluginPlanError),
99}
100
101/// Runtime-validated, low-level builder for [`Client`].
102///
103/// Unlike [`crate::bot::BotBuilder`], this builder deliberately does not use
104/// typestate. FFI and embedded hosts can populate dependencies dynamically and
105/// receive a typed error without encoding Rust generic state in their wrapper.
106#[must_use = "call .build() to produce the Client; the builder does nothing on its own"]
107pub struct ClientBuilder {
108    runtime: Option<Arc<dyn Runtime>>,
109    persistence_manager: Option<Arc<PersistenceManager>>,
110    transport_factory: Option<Arc<dyn TransportFactory>>,
111    http_client: Option<Arc<dyn HttpClient>>,
112    override_version: Option<(u32, u32, u32)>,
113    cache_config: CacheConfig,
114    custom_enc_handlers: HashMap<String, Arc<dyn EncHandler>>,
115    inbound_durability_hook: Option<Arc<dyn InboundDurabilityHook>>,
116    skip_history_sync: bool,
117    wanted_pre_key_count: Option<usize>,
118    resend_rate_limit: Option<(u32, u32)>,
119    task_instrument: Option<Arc<dyn wacore::stats::TaskInstrument>>,
120    alloc_meter: Option<Arc<wacore::stats::AllocMeter>>,
121    background_saver_interval: Option<Duration>,
122    #[cfg(feature = "client-lifecycle")]
123    lifecycle: Option<Arc<dyn ClientLifecycle>>,
124    #[cfg(feature = "plugins")]
125    plugins: Vec<PluginRegistration>,
126    #[cfg(feature = "plugins")]
127    plugin_host_config: PluginHostConfig,
128}
129
130impl Default for ClientBuilder {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl ClientBuilder {
137    /// Create an empty builder. All four platform dependencies are required.
138    pub fn new() -> Self {
139        Self {
140            runtime: None,
141            persistence_manager: None,
142            transport_factory: None,
143            http_client: None,
144            override_version: None,
145            cache_config: CacheConfig::default(),
146            custom_enc_handlers: HashMap::new(),
147            inbound_durability_hook: None,
148            skip_history_sync: false,
149            wanted_pre_key_count: None,
150            resend_rate_limit: None,
151            task_instrument: None,
152            alloc_meter: None,
153            background_saver_interval: None,
154            #[cfg(feature = "client-lifecycle")]
155            lifecycle: None,
156            #[cfg(feature = "plugins")]
157            plugins: Vec::new(),
158            #[cfg(feature = "plugins")]
159            plugin_host_config: PluginHostConfig::default(),
160        }
161    }
162
163    pub fn with_runtime<R>(mut self, runtime: R) -> Self
164    where
165        R: Runtime,
166    {
167        self.runtime = Some(Arc::new(runtime));
168        self
169    }
170
171    pub fn with_runtime_arc(mut self, runtime: Arc<dyn Runtime>) -> Self {
172        self.runtime = Some(runtime);
173        self
174    }
175
176    pub fn with_persistence_manager(
177        mut self,
178        persistence_manager: Arc<PersistenceManager>,
179    ) -> Self {
180        self.persistence_manager = Some(persistence_manager);
181        self
182    }
183
184    pub fn with_transport_factory<T>(mut self, transport_factory: T) -> Self
185    where
186        T: TransportFactory + 'static,
187    {
188        self.transport_factory = Some(Arc::new(transport_factory));
189        self
190    }
191
192    pub fn with_transport_factory_arc(
193        mut self,
194        transport_factory: Arc<dyn TransportFactory>,
195    ) -> Self {
196        self.transport_factory = Some(transport_factory);
197        self
198    }
199
200    pub fn with_http_client<H>(mut self, http_client: H) -> Self
201    where
202        H: HttpClient + 'static,
203    {
204        self.http_client = Some(Arc::new(http_client));
205        self
206    }
207
208    pub fn with_http_client_arc(mut self, http_client: Arc<dyn HttpClient>) -> Self {
209        self.http_client = Some(http_client);
210        self
211    }
212
213    pub fn with_version_override(mut self, version: (u32, u32, u32)) -> Self {
214        self.override_version = Some(version);
215        self
216    }
217
218    pub fn with_cache_config(mut self, cache_config: CacheConfig) -> Self {
219        self.cache_config = cache_config;
220        self
221    }
222
223    /// Register a handler for one encrypted payload type before the client starts.
224    pub fn with_enc_handler<H>(mut self, payload_type: impl Into<String>, handler: H) -> Self
225    where
226        H: EncHandler + 'static,
227    {
228        self.custom_enc_handlers
229            .insert(payload_type.into(), Arc::new(handler));
230        self
231    }
232
233    /// Register an already-shared encrypted payload handler.
234    pub fn with_enc_handler_arc(
235        mut self,
236        payload_type: impl Into<String>,
237        handler: Arc<dyn EncHandler>,
238    ) -> Self {
239        self.custom_enc_handlers
240            .insert(payload_type.into(), handler);
241        self
242    }
243
244    pub(crate) fn with_custom_enc_handlers(
245        mut self,
246        handlers: HashMap<String, Arc<dyn EncHandler>>,
247    ) -> Self {
248        self.custom_enc_handlers = handlers;
249        self
250    }
251
252    /// Install the durable-inbound hook after verifying backend support.
253    pub fn with_inbound_durability_hook<H>(mut self, hook: H) -> Self
254    where
255        H: InboundDurabilityHook + 'static,
256    {
257        self.inbound_durability_hook = Some(Arc::new(hook));
258        self
259    }
260
261    /// Install an already-shared durable-inbound hook.
262    pub fn with_inbound_durability_hook_arc(
263        mut self,
264        hook: Arc<dyn InboundDurabilityHook>,
265    ) -> Self {
266        self.inbound_durability_hook = Some(hook);
267        self
268    }
269
270    pub fn with_skip_history_sync(mut self, skip: bool) -> Self {
271        self.skip_history_sync = skip;
272        self
273    }
274
275    pub fn with_wanted_pre_key_count(mut self, count: usize) -> Self {
276        self.wanted_pre_key_count = Some(count);
277        self
278    }
279
280    pub fn with_resend_rate_limit(mut self, burst: u32, refill_per_min: u32) -> Self {
281        self.resend_rate_limit = Some((burst, refill_per_min));
282        self
283    }
284
285    /// Instrument every task spawned through the configured runtime.
286    pub fn with_task_instrument(
287        mut self,
288        instrument: Arc<dyn wacore::stats::TaskInstrument>,
289    ) -> Self {
290        self.task_instrument = Some(instrument);
291        self.alloc_meter = None;
292        self
293    }
294
295    /// Install allocation attribution as the task instrument.
296    pub fn with_alloc_meter(mut self, meter: Arc<wacore::stats::AllocMeter>) -> Self {
297        self.task_instrument = Some(meter.clone());
298        self.alloc_meter = Some(meter);
299        self
300    }
301
302    /// Run periodic device persistence for the lifetime of the client.
303    pub fn with_background_saver_interval(mut self, interval: Duration) -> Self {
304        self.background_saver_interval = Some(interval);
305        self
306    }
307
308    /// Install the aggregate lifecycle used by extensions of this client.
309    #[cfg(feature = "client-lifecycle")]
310    #[cfg_attr(docsrs, doc(cfg(feature = "client-lifecycle")))]
311    pub fn with_lifecycle<L>(mut self, lifecycle: L) -> Self
312    where
313        L: ClientLifecycle + 'static,
314    {
315        self.lifecycle = Some(Arc::new(lifecycle));
316        self
317    }
318
319    /// Install an already-shared aggregate lifecycle.
320    #[cfg(feature = "client-lifecycle")]
321    #[cfg_attr(docsrs, doc(cfg(feature = "client-lifecycle")))]
322    pub fn with_lifecycle_arc(mut self, lifecycle: Arc<dyn ClientLifecycle>) -> Self {
323        self.lifecycle = Some(lifecycle);
324        self
325    }
326
327    /// Register a native plugin for transactional installation before services start.
328    #[cfg(feature = "plugins")]
329    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
330    pub fn with_plugin<P: ClientPlugin>(mut self, plugin: P) -> Self {
331        self.plugins.push(PluginRegistration::new(plugin));
332        self
333    }
334
335    /// Register an already-shared native plugin without changing its marker type.
336    #[cfg(feature = "plugins")]
337    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
338    pub fn with_plugin_arc<P: ClientPlugin>(mut self, plugin: Arc<P>) -> Self {
339        self.plugins.push(PluginRegistration::new_arc(plugin));
340        self
341    }
342
343    /// Register a manifest-ID-keyed plugin that exposes no Rust typed API.
344    #[cfg(feature = "plugins")]
345    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
346    pub fn with_untyped_plugin<P: UntypedClientPlugin>(mut self, plugin: P) -> Self {
347        self.plugins.push(PluginRegistration::new_untyped(plugin));
348        self
349    }
350
351    /// Register an already-shared manifest-ID-keyed plugin.
352    #[cfg(feature = "plugins")]
353    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
354    pub fn with_untyped_plugin_arc<P: UntypedClientPlugin + ?Sized>(
355        mut self,
356        plugin: Arc<P>,
357    ) -> Self {
358        self.plugins
359            .push(PluginRegistration::new_untyped_arc(plugin));
360        self
361    }
362
363    /// Configure plugin lifecycle and tracked-task deadlines.
364    #[cfg(feature = "plugins")]
365    #[cfg_attr(docsrs, doc(cfg(feature = "plugins")))]
366    pub fn with_plugin_host_config(mut self, config: PluginHostConfig) -> Self {
367        self.plugin_host_config = config;
368        self
369    }
370
371    #[cfg(feature = "plugins")]
372    pub(crate) fn with_plugin_registrations(
373        mut self,
374        registrations: Vec<PluginRegistration>,
375    ) -> Self {
376        self.plugins = registrations;
377        self
378    }
379
380    /// Validate dependencies, assemble an inert client, then start its services.
381    pub async fn build(self) -> Result<ClientBuild, ClientBuilderError> {
382        self.build_boxed().await
383    }
384
385    #[inline(never)]
386    fn build_boxed(
387        self,
388    ) -> wacore::runtime::BoxFuture<'static, Result<ClientBuild, ClientBuilderError>> {
389        Box::pin(async move {
390            let runtime = self
391                .runtime
392                .as_ref()
393                .cloned()
394                .ok_or(ClientBuilderError::MissingRuntime)?;
395            let persistence_manager = self
396                .persistence_manager
397                .as_ref()
398                .cloned()
399                .ok_or(ClientBuilderError::MissingPersistenceManager)?;
400            let transport_factory = self
401                .transport_factory
402                .as_ref()
403                .cloned()
404                .ok_or(ClientBuilderError::MissingTransportFactory)?;
405            let http_client = self
406                .http_client
407                .as_ref()
408                .cloned()
409                .ok_or(ClientBuilderError::MissingHttpClient)?;
410
411            if self.background_saver_interval == Some(Duration::ZERO) {
412                return Err(ClientBuilderError::InvalidBackgroundSaverInterval);
413            }
414
415            #[cfg(feature = "plugins")]
416            if self.plugin_host_config.install_timeout() == Duration::ZERO {
417                return Err(ClientBuilderError::InvalidPluginInstallTimeout);
418            }
419            #[cfg(feature = "plugins")]
420            if self.plugin_host_config.callback_timeout() == Duration::ZERO {
421                return Err(ClientBuilderError::InvalidPluginCallbackTimeout);
422            }
423            #[cfg(feature = "plugins")]
424            if self.plugin_host_config.task_drain_timeout() == Duration::ZERO {
425                return Err(ClientBuilderError::InvalidPluginTaskDrainTimeout);
426            }
427
428            if self.inbound_durability_hook.is_some() {
429                probe_durability_backend(&persistence_manager.backend()).await?;
430            }
431
432            self.finish(runtime, persistence_manager, transport_factory, http_client)
433                .await
434        })
435    }
436
437    pub(crate) async fn build_required(
438        runtime: Arc<dyn Runtime>,
439        persistence_manager: Arc<PersistenceManager>,
440        transport_factory: Arc<dyn TransportFactory>,
441        http_client: Arc<dyn HttpClient>,
442        override_version: Option<(u32, u32, u32)>,
443        cache_config: CacheConfig,
444    ) -> ClientBuild {
445        let result = Self {
446            override_version,
447            cache_config,
448            ..Self::new()
449        }
450        .finish(runtime, persistence_manager, transport_factory, http_client)
451        .await;
452        match result {
453            Ok(build) => build,
454            Err(error) => unreachable!("default lifecycle-free build failed: {error}"),
455        }
456    }
457
458    async fn finish(
459        self,
460        runtime: Arc<dyn Runtime>,
461        persistence_manager: Arc<PersistenceManager>,
462        transport_factory: Arc<dyn TransportFactory>,
463        http_client: Arc<dyn HttpClient>,
464    ) -> Result<ClientBuild, ClientBuilderError> {
465        #[cfg(feature = "plugins")]
466        let plugin_plan = PluginPlan::prepare(self.plugins)?;
467        let runtime: Arc<dyn Runtime> = match self.task_instrument {
468            Some(instrument) => {
469                Arc::new(wacore::stats::InstrumentedRuntime::new(runtime, instrument))
470            }
471            None => runtime,
472        };
473
474        #[cfg(feature = "client-lifecycle")]
475        let lifecycle_handler = self.lifecycle;
476        #[cfg(feature = "plugins")]
477        let (lifecycle_handler, plugin_host) = {
478            let mut lifecycle_handler = lifecycle_handler;
479            let plugin_host = plugin_plan.map(|plan| {
480                let host = PluginHost::new(plan, lifecycle_handler.take(), self.plugin_host_config);
481                lifecycle_handler = Some(host.clone());
482                host
483            });
484            (lifecycle_handler, plugin_host)
485        };
486        #[cfg(feature = "client-lifecycle")]
487        let lifecycle = lifecycle_handler.map(|handler| {
488            #[cfg(feature = "plugins")]
489            if let Some(plugin_host) = &plugin_host {
490                return Arc::new(LifecycleRegistration::new_with_timeout(
491                    handler,
492                    Arc::clone(&runtime),
493                    plugin_host.lifecycle_callback_timeout(),
494                ));
495            }
496            Arc::new(LifecycleRegistration::new(handler, Arc::clone(&runtime)))
497        });
498        let assembly = Client::assemble(
499            Arc::clone(&runtime),
500            Arc::clone(&persistence_manager),
501            transport_factory,
502            http_client,
503            self.override_version,
504            self.cache_config,
505            ClientExtensions {
506                #[cfg(feature = "client-lifecycle")]
507                lifecycle,
508                #[cfg(feature = "plugins")]
509                plugin_host,
510            },
511        );
512        let client = assembly.client();
513        #[cfg(feature = "client-lifecycle")]
514        let mut construction = ClientConstructionGuard::new(Arc::clone(&client));
515
516        if !self.custom_enc_handlers.is_empty() {
517            let _ = client.custom_enc_handlers.set(self.custom_enc_handlers);
518        }
519        if let Some(hook) = self.inbound_durability_hook {
520            let _ = client.inbound_durability_hook.set(hook);
521        }
522        if self.skip_history_sync {
523            client.set_skip_history_sync(true);
524        }
525        if let Some(count) = self.wanted_pre_key_count {
526            client.set_wanted_pre_key_count(count);
527        }
528        if let Some((burst, refill_per_min)) = self.resend_rate_limit {
529            client.set_resend_rate_limit(burst, refill_per_min);
530        }
531        if let Some(meter) = self.alloc_meter {
532            let _ = client.alloc_meter.set(meter);
533        }
534        #[cfg(feature = "client-lifecycle")]
535        if let Some(lifecycle) = &client.lifecycle
536            && let Err(error) = lifecycle.install(Arc::downgrade(&client)).await
537        {
538            #[cfg(feature = "plugins")]
539            if client.plugin_host.is_some() {
540                return Err(ClientBuilderError::PluginInstall(error));
541            }
542            return Err(ClientBuilderError::LifecycleInstall(error));
543        }
544
545        let build = assembly.start();
546        if let Some(interval) = self.background_saver_interval {
547            let saver_handle = persistence_manager.run_background_saver(
548                runtime,
549                interval,
550                build.client.shutdown_signal(),
551            );
552            let _ = build.client.saver_handle.set(saver_handle);
553        }
554        #[cfg(feature = "client-lifecycle")]
555        if let Some(lifecycle) = &client.lifecycle {
556            #[cfg(feature = "plugins")]
557            let activated = if let Some(plugin_host) = &client.plugin_host {
558                lifecycle.activate_with(|| plugin_host.commit())
559            } else {
560                lifecycle.activate()
561            };
562            #[cfg(not(feature = "plugins"))]
563            let activated = lifecycle.activate();
564            if !activated {
565                client.signal_shutdown_sync();
566                client.shutdown_lifecycle().await;
567                #[cfg(feature = "plugins")]
568                if client.plugin_host.is_some() {
569                    return Err(ClientBuilderError::PluginInstall(anyhow::anyhow!(
570                        "client shutdown raced plugin publication"
571                    )));
572                }
573                return Err(ClientBuilderError::LifecycleInstall(anyhow::anyhow!(
574                    "client shutdown raced lifecycle activation"
575                )));
576            }
577        }
578        #[cfg(feature = "client-lifecycle")]
579        construction.disarm();
580        Ok(build)
581    }
582}
583
584async fn probe_durability_backend(
585    backend: &Arc<dyn crate::store::traits::Backend>,
586) -> Result<(), ClientBuilderError> {
587    use portable_atomic::{AtomicU64, Ordering};
588
589    static PROBE_SEQ: AtomicU64 = AtomicU64::new(0);
590    const PROBE_JID: &str = "0@s.whatsapp.net";
591    const PROBE_PAYLOAD: &[u8] = b"probe";
592    let probe_id = format!(
593        "__wa_durability_probe_{}_{}__",
594        std::process::id(),
595        PROBE_SEQ.fetch_add(1, Ordering::Relaxed)
596    );
597    let map_err =
598        |error: StoreError| ClientBuilderError::UnsupportedDurabilityBackend(error.to_string());
599
600    backend
601        .store_pending_inbound(PROBE_JID, PROBE_JID, &probe_id, PROBE_PAYLOAD)
602        .await
603        .map_err(map_err)?;
604    let stored = backend
605        .get_pending_inbound(PROBE_JID, PROBE_JID, &probe_id)
606        .await
607        .map_err(map_err)?;
608    backend
609        .delete_pending_inbound(PROBE_JID, PROBE_JID, &probe_id)
610        .await
611        .map_err(map_err)?;
612
613    if stored.as_deref() != Some(PROBE_PAYLOAD) {
614        return Err(ClientBuilderError::UnsupportedDurabilityBackend(
615            "pending-inbound buffer did not round-trip".to_string(),
616        ));
617    }
618    Ok(())
619}
620
621/// Owns the only path from a fully allocated client to started background
622/// services, preventing callers from publishing a partially configured client.
623pub(super) struct ClientAssembly {
624    client: Arc<Client>,
625    sync_task_receiver: async_channel::Receiver<MajorSyncTask>,
626}
627
628#[cfg(feature = "client-lifecycle")]
629struct ClientConstructionGuard {
630    client: Arc<Client>,
631    armed: bool,
632}
633
634#[cfg(feature = "client-lifecycle")]
635impl ClientConstructionGuard {
636    fn new(client: Arc<Client>) -> Self {
637        Self {
638            client,
639            armed: true,
640        }
641    }
642
643    fn disarm(&mut self) {
644        self.armed = false;
645    }
646}
647
648#[cfg(feature = "client-lifecycle")]
649impl Drop for ClientConstructionGuard {
650    fn drop(&mut self) {
651        if self.armed {
652            self.client.signal_shutdown_sync();
653        }
654    }
655}
656
657#[derive(Default)]
658pub(super) struct ClientExtensions {
659    #[cfg(feature = "client-lifecycle")]
660    pub(super) lifecycle: Option<Arc<LifecycleRegistration>>,
661    #[cfg(feature = "plugins")]
662    pub(super) plugin_host: Option<Arc<PluginHost>>,
663}
664
665impl ClientAssembly {
666    pub(super) fn new(
667        client: Arc<Client>,
668        sync_task_receiver: async_channel::Receiver<MajorSyncTask>,
669    ) -> Self {
670        Self {
671            client,
672            sync_task_receiver,
673        }
674    }
675
676    pub(super) fn start(self) -> ClientBuild {
677        self.client.start_services();
678        ClientBuild::new(self.client, self.sync_task_receiver)
679    }
680
681    fn client(&self) -> Arc<Client> {
682        Arc::clone(&self.client)
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use std::future::Future;
689    use std::pin::Pin;
690    use std::sync::atomic::{AtomicUsize, Ordering};
691    use std::time::Duration;
692
693    use super::*;
694    use crate::runtime_impl::TokioRuntime;
695    use crate::test_utils::MockHttpClient;
696    use crate::transport::mock::MockTransportFactory;
697    use wacore::runtime::AbortHandle;
698
699    #[cfg(feature = "client-lifecycle")]
700    struct FailingLifecycle {
701        spawns: Arc<AtomicUsize>,
702        installed_client: std::sync::Mutex<Option<std::sync::Weak<Client>>>,
703    }
704
705    #[cfg(feature = "client-lifecycle")]
706    struct RunDuringInstallLifecycle {
707        client: async_channel::Sender<Arc<Client>>,
708        release: async_channel::Receiver<()>,
709        run_finished: async_channel::Sender<()>,
710    }
711
712    #[cfg(feature = "client-lifecycle")]
713    struct ConnectDuringInstallLifecycle {
714        client: async_channel::Sender<Arc<Client>>,
715        release: async_channel::Receiver<()>,
716        connect_invoked: async_channel::Sender<()>,
717        connect_finished: async_channel::Sender<bool>,
718    }
719
720    #[cfg(feature = "client-lifecycle")]
721    struct BlockingTransportFactory {
722        started: async_channel::Sender<()>,
723        release: async_channel::Receiver<()>,
724    }
725
726    #[cfg(feature = "client-lifecycle")]
727    #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
728    #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
729    impl TransportFactory for BlockingTransportFactory {
730        async fn create_transport(
731            &self,
732        ) -> Result<
733            (
734                Arc<dyn crate::transport::Transport>,
735                async_channel::Receiver<crate::transport::TransportEvent>,
736            ),
737            anyhow::Error,
738        > {
739            self.started
740                .send(())
741                .await
742                .map_err(|_| anyhow::anyhow!("transport-start receiver closed"))?;
743            self.release
744                .recv()
745                .await
746                .map_err(|_| anyhow::anyhow!("transport release closed"))?;
747            Err(anyhow::anyhow!("injected transport stop"))
748        }
749    }
750
751    #[cfg(feature = "client-lifecycle")]
752    impl ClientLifecycle for RunDuringInstallLifecycle {
753        fn install<'a>(
754            &'a self,
755            client: std::sync::Weak<Client>,
756        ) -> wacore::runtime::BoxFuture<'a, anyhow::Result<()>> {
757            Box::pin(async move {
758                let client = client
759                    .upgrade()
760                    .ok_or_else(|| anyhow::anyhow!("client unavailable during install"))?;
761                let run_client = client.clone();
762                let run_finished = self.run_finished.clone();
763                client
764                    .runtime
765                    .spawn(Box::pin(async move {
766                        run_client.run().await;
767                        let _ = run_finished.send(()).await;
768                    }))
769                    .detach();
770                self.client
771                    .send(client)
772                    .await
773                    .map_err(|_| anyhow::anyhow!("test client receiver closed"))?;
774                self.release
775                    .recv()
776                    .await
777                    .map_err(|_| anyhow::anyhow!("test install release closed"))?;
778                Ok(())
779            })
780        }
781    }
782
783    #[cfg(feature = "client-lifecycle")]
784    impl ClientLifecycle for ConnectDuringInstallLifecycle {
785        fn install<'a>(
786            &'a self,
787            client: std::sync::Weak<Client>,
788        ) -> wacore::runtime::BoxFuture<'a, anyhow::Result<()>> {
789            Box::pin(async move {
790                let client = client
791                    .upgrade()
792                    .ok_or_else(|| anyhow::anyhow!("client unavailable during install"))?;
793                let connect_client = client.clone();
794                let connect_invoked = self.connect_invoked.clone();
795                let connect_finished = self.connect_finished.clone();
796                client
797                    .runtime
798                    .spawn(Box::pin(async move {
799                        let _ = connect_invoked.send(()).await;
800                        let failed = connect_client.connect().await.is_err();
801                        let _ = connect_finished.send(failed).await;
802                    }))
803                    .detach();
804                self.client
805                    .send(client)
806                    .await
807                    .map_err(|_| anyhow::anyhow!("test client receiver closed"))?;
808                self.release
809                    .recv()
810                    .await
811                    .map_err(|_| anyhow::anyhow!("test install release closed"))?;
812                Ok(())
813            })
814        }
815    }
816
817    #[cfg(feature = "client-lifecycle")]
818    impl ClientLifecycle for FailingLifecycle {
819        fn install<'a>(
820            &'a self,
821            client: std::sync::Weak<Client>,
822        ) -> wacore::runtime::BoxFuture<'a, anyhow::Result<()>> {
823            Box::pin(async move {
824                assert_eq!(self.spawns.load(Ordering::SeqCst), 0);
825                *self
826                    .installed_client
827                    .lock()
828                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(client);
829                Err(anyhow::anyhow!("injected install failure"))
830            })
831        }
832    }
833
834    struct CountingRuntime {
835        spawns: Arc<AtomicUsize>,
836    }
837
838    #[async_trait::async_trait]
839    impl Runtime for CountingRuntime {
840        fn spawn(&self, future: Pin<Box<dyn Future<Output = ()> + Send + 'static>>) -> AbortHandle {
841            self.spawns.fetch_add(1, Ordering::SeqCst);
842            TokioRuntime.spawn(future)
843        }
844
845        fn sleep(&self, duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> {
846            TokioRuntime.sleep(duration)
847        }
848
849        fn spawn_blocking(
850            &self,
851            f: Box<dyn FnOnce() + Send + 'static>,
852        ) -> Pin<Box<dyn Future<Output = ()> + Send>> {
853            TokioRuntime.spawn_blocking(f)
854        }
855
856        fn yield_now(&self) -> Option<Pin<Box<dyn Future<Output = ()> + Send>>> {
857            TokioRuntime.yield_now()
858        }
859    }
860
861    async fn complete_builder() -> ClientBuilder {
862        let persistence_manager = Arc::new(
863            PersistenceManager::new(crate::test_utils::create_test_backend().await)
864                .await
865                .expect("persistence manager"),
866        );
867        ClientBuilder::new()
868            .with_runtime(TokioRuntime)
869            .with_persistence_manager(persistence_manager)
870            .with_transport_factory(MockTransportFactory::new())
871            .with_http_client(MockHttpClient)
872    }
873
874    #[tokio::test]
875    async fn validates_required_dependencies_before_assembly() {
876        assert!(matches!(
877            ClientBuilder::new().build().await,
878            Err(ClientBuilderError::MissingRuntime)
879        ));
880
881        assert!(matches!(
882            ClientBuilder::new()
883                .with_runtime(TokioRuntime)
884                .build()
885                .await,
886            Err(ClientBuilderError::MissingPersistenceManager)
887        ));
888
889        let persistence_manager = Arc::new(
890            PersistenceManager::new(crate::test_utils::create_test_backend().await)
891                .await
892                .expect("persistence manager"),
893        );
894        assert!(matches!(
895            ClientBuilder::new()
896                .with_runtime(TokioRuntime)
897                .with_persistence_manager(Arc::clone(&persistence_manager))
898                .build()
899                .await,
900            Err(ClientBuilderError::MissingTransportFactory)
901        ));
902
903        assert!(matches!(
904            ClientBuilder::new()
905                .with_runtime(TokioRuntime)
906                .with_persistence_manager(persistence_manager)
907                .with_transport_factory(MockTransportFactory::new())
908                .build()
909                .await,
910            Err(ClientBuilderError::MissingHttpClient)
911        ));
912    }
913
914    #[tokio::test]
915    async fn assembly_is_inert_until_started() {
916        let persistence_manager = Arc::new(
917            PersistenceManager::new(crate::test_utils::create_test_backend().await)
918                .await
919                .expect("persistence manager"),
920        );
921        let spawns = Arc::new(AtomicUsize::new(0));
922        let runtime = Arc::new(CountingRuntime {
923            spawns: Arc::clone(&spawns),
924        }) as Arc<dyn Runtime>;
925
926        let assembly = Client::assemble(
927            runtime,
928            persistence_manager,
929            Arc::new(MockTransportFactory::new()),
930            Arc::new(MockHttpClient),
931            None,
932            CacheConfig::default(),
933            ClientExtensions::default(),
934        );
935        assert_eq!(spawns.load(Ordering::SeqCst), 0);
936
937        let build = assembly.start();
938        assert_eq!(spawns.load(Ordering::SeqCst), 1);
939        build.into_client().signal_shutdown_sync();
940    }
941
942    #[tokio::test]
943    #[cfg(feature = "client-lifecycle")]
944    async fn run_leaked_during_install_waits_for_complete_construction() {
945        let (client_tx, client_rx) = async_channel::bounded(1);
946        let (release_tx, release_rx) = async_channel::bounded(1);
947        let (run_finished_tx, run_finished_rx) = async_channel::bounded(1);
948        let builder = complete_builder()
949            .await
950            .with_lifecycle(RunDuringInstallLifecycle {
951                client: client_tx,
952                release: release_rx,
953                run_finished: run_finished_tx,
954            });
955        let build = tokio::spawn(async move { builder.build().await });
956        let leaked_client = client_rx
957            .recv()
958            .await
959            .expect("client leaked during install");
960        tokio::task::yield_now().await;
961        assert!(!leaked_client.is_running.load(Ordering::Acquire));
962
963        release_tx.send(()).await.expect("release installation");
964        let client = build
965            .await
966            .expect("builder task")
967            .expect("successful build")
968            .into_client();
969        tokio::time::timeout(Duration::from_secs(1), async {
970            while !client.is_running.load(Ordering::Acquire) {
971                tokio::task::yield_now().await;
972            }
973        })
974        .await
975        .expect("run released after activation");
976        client.signal_shutdown_sync();
977        tokio::time::timeout(Duration::from_secs(5), run_finished_rx.recv())
978            .await
979            .expect("run stop timeout")
980            .expect("run stopped");
981    }
982
983    #[tokio::test]
984    #[cfg(feature = "client-lifecycle")]
985    async fn connect_leaked_during_install_waits_for_complete_construction() {
986        let (client_tx, client_rx) = async_channel::bounded(1);
987        let (install_release_tx, install_release_rx) = async_channel::bounded(1);
988        let (connect_invoked_tx, connect_invoked_rx) = async_channel::bounded(1);
989        let (connect_finished_tx, connect_finished_rx) = async_channel::bounded(1);
990        let (transport_started_tx, transport_started_rx) = async_channel::bounded(1);
991        let (transport_release_tx, transport_release_rx) = async_channel::bounded(1);
992        let builder = complete_builder()
993            .await
994            .with_transport_factory(BlockingTransportFactory {
995                started: transport_started_tx,
996                release: transport_release_rx,
997            })
998            .with_lifecycle(ConnectDuringInstallLifecycle {
999                client: client_tx,
1000                release: install_release_rx,
1001                connect_invoked: connect_invoked_tx,
1002                connect_finished: connect_finished_tx,
1003            });
1004        let build = tokio::spawn(async move { builder.build().await });
1005        let leaked_client = client_rx
1006            .recv()
1007            .await
1008            .expect("client leaked during install");
1009        connect_invoked_rx
1010            .recv()
1011            .await
1012            .expect("direct connect invoked");
1013
1014        assert!(
1015            tokio::time::timeout(Duration::from_millis(100), transport_started_rx.recv())
1016                .await
1017                .is_err(),
1018            "transport started before construction activation"
1019        );
1020        assert!(!leaked_client.is_connecting.load(Ordering::Acquire));
1021
1022        install_release_tx
1023            .send(())
1024            .await
1025            .expect("release installation");
1026        let client = build
1027            .await
1028            .expect("builder task")
1029            .expect("successful build")
1030            .into_client();
1031        tokio::time::timeout(Duration::from_secs(1), transport_started_rx.recv())
1032            .await
1033            .expect("connect remained gated after activation")
1034            .expect("transport-start sender closed");
1035        transport_release_tx
1036            .send(())
1037            .await
1038            .expect("release transport");
1039        assert!(
1040            tokio::time::timeout(Duration::from_secs(1), connect_finished_rx.recv())
1041                .await
1042                .expect("direct connect did not finish")
1043                .expect("connect-finished sender closed")
1044        );
1045        client.signal_shutdown_sync();
1046    }
1047
1048    #[tokio::test]
1049    #[cfg(feature = "client-lifecycle")]
1050    async fn shutdown_during_install_rejects_leaked_connect() {
1051        let (client_tx, client_rx) = async_channel::bounded(1);
1052        let (_install_release_tx, install_release_rx) = async_channel::bounded(1);
1053        let (connect_invoked_tx, connect_invoked_rx) = async_channel::bounded(1);
1054        let (connect_finished_tx, connect_finished_rx) = async_channel::bounded(1);
1055        let (transport_started_tx, transport_started_rx) = async_channel::bounded(1);
1056        let (_transport_release_tx, transport_release_rx) = async_channel::bounded(1);
1057        let builder = complete_builder()
1058            .await
1059            .with_transport_factory(BlockingTransportFactory {
1060                started: transport_started_tx,
1061                release: transport_release_rx,
1062            })
1063            .with_lifecycle(ConnectDuringInstallLifecycle {
1064                client: client_tx,
1065                release: install_release_rx,
1066                connect_invoked: connect_invoked_tx,
1067                connect_finished: connect_finished_tx,
1068            });
1069        let build = tokio::spawn(async move { builder.build().await });
1070        let leaked_client = client_rx
1071            .recv()
1072            .await
1073            .expect("client leaked during install");
1074        connect_invoked_rx
1075            .recv()
1076            .await
1077            .expect("direct connect invoked");
1078        leaked_client.signal_shutdown_sync();
1079
1080        assert!(matches!(
1081            tokio::time::timeout(Duration::from_secs(2), build)
1082                .await
1083                .expect("lifecycle install ignored terminal shutdown")
1084                .expect("builder task"),
1085            Err(ClientBuilderError::LifecycleInstall(_))
1086        ));
1087        assert!(
1088            tokio::time::timeout(Duration::from_secs(1), connect_finished_rx.recv())
1089                .await
1090                .expect("direct connect did not observe rejection")
1091                .expect("connect-finished sender closed")
1092        );
1093        assert!(transport_started_rx.try_recv().is_err());
1094        assert!(!leaked_client.is_connecting.load(Ordering::Acquire));
1095    }
1096
1097    #[tokio::test]
1098    #[cfg(feature = "client-lifecycle")]
1099    async fn shutdown_during_install_rejects_leaked_run_and_the_build() {
1100        let (client_tx, client_rx) = async_channel::bounded(1);
1101        let (_release_tx, release_rx) = async_channel::bounded(1);
1102        let (run_finished_tx, run_finished_rx) = async_channel::bounded(1);
1103        let builder = complete_builder()
1104            .await
1105            .with_lifecycle(RunDuringInstallLifecycle {
1106                client: client_tx,
1107                release: release_rx,
1108                run_finished: run_finished_tx,
1109            });
1110        let build = tokio::spawn(async move { builder.build().await });
1111        let leaked_client = client_rx
1112            .recv()
1113            .await
1114            .expect("client leaked during install");
1115        leaked_client.signal_shutdown_sync();
1116
1117        assert!(matches!(
1118            tokio::time::timeout(Duration::from_secs(2), build)
1119                .await
1120                .expect("lifecycle install ignored terminal shutdown")
1121                .expect("builder task"),
1122            Err(ClientBuilderError::LifecycleInstall(_))
1123        ));
1124        tokio::time::timeout(Duration::from_secs(5), run_finished_rx.recv())
1125            .await
1126            .expect("rejected run stop timeout")
1127            .expect("rejected run stopped");
1128        assert!(!leaked_client.is_running.load(Ordering::Acquire));
1129    }
1130
1131    #[tokio::test]
1132    async fn low_level_builder_installs_options_and_owned_services() {
1133        let persistence_manager = Arc::new(
1134            PersistenceManager::new(crate::test_utils::create_test_backend().await)
1135                .await
1136                .expect("persistence manager"),
1137        );
1138        let spawns = Arc::new(AtomicUsize::new(0));
1139        let meter = Arc::new(wacore::stats::AllocMeter::new());
1140
1141        let build = ClientBuilder::new()
1142            .with_runtime(CountingRuntime {
1143                spawns: Arc::clone(&spawns),
1144            })
1145            .with_persistence_manager(persistence_manager)
1146            .with_transport_factory(MockTransportFactory::new())
1147            .with_http_client(MockHttpClient)
1148            .with_skip_history_sync(true)
1149            .with_wanted_pre_key_count(123)
1150            .with_alloc_meter(Arc::clone(&meter))
1151            .with_background_saver_interval(Duration::from_secs(3600))
1152            .build()
1153            .await
1154            .expect("complete builder");
1155        let client = build.into_client();
1156
1157        assert!(client.skip_history_sync_enabled());
1158        assert_eq!(client.wanted_pre_key_count(), 123);
1159        assert!(
1160            client
1161                .alloc_meter
1162                .get()
1163                .is_some_and(|installed| Arc::ptr_eq(installed, &meter))
1164        );
1165        assert!(client.saver_handle.get().is_some());
1166        assert_eq!(spawns.load(Ordering::SeqCst), 3);
1167        client.signal_shutdown_sync();
1168    }
1169
1170    #[tokio::test]
1171    async fn consuming_build_as_client_keeps_major_sync_worker_alive() {
1172        let client = complete_builder()
1173            .await
1174            .build()
1175            .await
1176            .expect("complete builder")
1177            .into_client();
1178
1179        assert!(!client.major_sync_task_sender.is_closed());
1180        client.signal_shutdown_sync();
1181    }
1182
1183    #[tokio::test]
1184    async fn rejects_zero_background_saver_interval() {
1185        let result = complete_builder()
1186            .await
1187            .with_background_saver_interval(Duration::ZERO)
1188            .build()
1189            .await;
1190
1191        assert!(matches!(
1192            result,
1193            Err(ClientBuilderError::InvalidBackgroundSaverInterval)
1194        ));
1195    }
1196
1197    #[cfg(feature = "client-lifecycle")]
1198    struct PanickingInstallLifecycle {
1199        when_polled: bool,
1200    }
1201
1202    #[cfg(feature = "client-lifecycle")]
1203    impl ClientLifecycle for PanickingInstallLifecycle {
1204        fn install(
1205            &self,
1206            _client: std::sync::Weak<Client>,
1207        ) -> wacore::runtime::BoxFuture<'_, anyhow::Result<()>> {
1208            if !self.when_polled {
1209                panic!("injected synchronous install panic");
1210            }
1211            Box::pin(async { panic!("injected asynchronous install panic") })
1212        }
1213    }
1214
1215    #[tokio::test]
1216    #[cfg(feature = "client-lifecycle")]
1217    async fn lifecycle_install_panics_are_typed_build_errors() {
1218        for when_polled in [false, true] {
1219            let result = complete_builder()
1220                .await
1221                .with_lifecycle(PanickingInstallLifecycle { when_polled })
1222                .build()
1223                .await;
1224            assert!(matches!(
1225                result,
1226                Err(ClientBuilderError::LifecycleInstall(_))
1227            ));
1228        }
1229    }
1230
1231    #[tokio::test]
1232    #[cfg(feature = "client-lifecycle")]
1233    async fn lifecycle_install_failure_publishes_nothing_and_starts_no_tasks() {
1234        let persistence_manager = Arc::new(
1235            PersistenceManager::new(crate::test_utils::create_test_backend().await)
1236                .await
1237                .expect("persistence manager"),
1238        );
1239        let spawns = Arc::new(AtomicUsize::new(0));
1240        let lifecycle = Arc::new(FailingLifecycle {
1241            spawns: Arc::clone(&spawns),
1242            installed_client: std::sync::Mutex::new(None),
1243        });
1244
1245        let result = ClientBuilder::new()
1246            .with_runtime(CountingRuntime {
1247                spawns: Arc::clone(&spawns),
1248            })
1249            .with_persistence_manager(persistence_manager)
1250            .with_transport_factory(MockTransportFactory::new())
1251            .with_http_client(MockHttpClient)
1252            .with_lifecycle_arc(lifecycle.clone())
1253            .build()
1254            .await;
1255
1256        assert!(matches!(
1257            result,
1258            Err(ClientBuilderError::LifecycleInstall(_))
1259        ));
1260        assert_eq!(spawns.load(Ordering::SeqCst), 0);
1261        assert!(
1262            lifecycle
1263                .installed_client
1264                .lock()
1265                .unwrap_or_else(|poisoned| poisoned.into_inner())
1266                .as_ref()
1267                .is_some_and(|client| client.upgrade().is_none())
1268        );
1269    }
1270}