Skip to main content

whatsapp_rust/client/
lifecycle.rs

1//! Client construction and connection lifecycle: connect, run, reconnect, shutdown.
2
3use super::*;
4
5/// Max groups with a cached resolved-device snapshot. LRU eviction covers
6/// accounts in more groups; an evicted entry just recomputes on next send.
7const GROUP_DEVICES_MEMO_CAPACITY: u64 = 64;
8
9/// Max 1:1 chats with a cached resolved-device snapshot. Higher than the
10/// group bound because a bot's active DM set is typically much wider; each
11/// entry is only the device list plus its member set.
12const DM_DEVICES_MEMO_CAPACITY: u64 = 512;
13
14/// `authenticated_generation` when no connection has published one.
15///
16/// Not zero: that is a real generation, the one a freshly built client is on,
17/// so zero would read as authenticated before anything had authenticated.
18/// `connection_generation` only ever counts up, so this can never collide.
19pub(crate) const NO_AUTHENTICATED_GENERATION: u64 = u64::MAX;
20
21impl Drop for Client {
22    fn drop(&mut self) {
23        self.signal_shutdown_sync();
24    }
25}
26
27impl Client {
28    /// WA Web `resetDelay: 30000` — only after a connection has stayed up this
29    /// long is the reconnect backoff counter reset to its base.
30    pub(crate) const STABLE_CONNECTION_RESET_MS: i64 = 30_000;
31
32    /// Create a runtime-validated low-level client builder.
33    pub fn builder() -> ClientBuilder {
34        ClientBuilder::new()
35    }
36
37    pub fn shutdown_signal(&self) -> wacore::runtime::ShutdownSignal {
38        self.shutdown_notifier.subscribe()
39    }
40
41    /// Synchronous flag-only equivalent of the first lines of `disconnect()`.
42    /// Spawned tasks watching `is_shutting_down()` / `shutdown_notifier` exit
43    /// on their next poll. Does NOT flush, close the transport, or touch
44    /// persistence — prefer `disconnect()` whenever you can `await`. Exists
45    /// for `Drop` impls on FFI wrappers (e.g. `WasmWhatsAppClient`) that
46    /// can't run async cleanup synchronously.
47    pub fn signal_shutdown_sync(&self) {
48        self.expected_disconnect.store(true, Ordering::Relaxed);
49        self.is_running.store(false, Ordering::Relaxed);
50        self.shutdown_notifier.notify();
51        self.notify_session_state();
52        #[cfg(feature = "client-lifecycle")]
53        if let Some(lifecycle) = &self.lifecycle {
54            lifecycle.signal_shutdown_sync();
55        }
56        self.notify_connection_shutdown();
57    }
58
59    pub(crate) fn connection_shutdown_signal(&self) -> wacore::runtime::ShutdownSignal {
60        self.connection_shutdown
61            .lock()
62            .unwrap_or_else(|p| p.into_inner())
63            .subscribe()
64    }
65
66    /// Fire the per-connection shutdown. Per-connection subscribers exit;
67    /// the terminal shutdown_notifier is untouched so reconnects still work.
68    pub(crate) fn notify_connection_shutdown(&self) {
69        self.connection_shutdown
70            .lock()
71            .unwrap_or_else(|p| p.into_inner())
72            .notify();
73        // Also the session signal, because this is the one point every
74        // connection ends through — planned or fatal. `handle_stream_error`
75        // makes a client terminal by setting `enable_auto_reconnect` and
76        // `expected_disconnect` and then calling only this; work parked in
77        // `await_connection` would otherwise wait for the run loop to unwind far
78        // enough to announce it, and the invariant on `is_terminal` promises
79        // better than "eventually, if some other loop gets there".
80        //
81        // A teardown that a reconnect follows wakes the wait for nothing, which
82        // costs a state re-read and a re-park. That is the trade this notifier
83        // is built for.
84        self.notify_session_state();
85    }
86
87    /// Reset the per-connection notifier. Call at the start of each new
88    /// connection so subscribers registered afterwards see a fresh signal.
89    /// The previous notifier's subscribers have already been woken (either
90    /// by notify on disconnect, or by falling out of scope).
91    pub(crate) fn reset_connection_shutdown(&self) {
92        *self
93            .connection_shutdown
94            .lock()
95            .unwrap_or_else(|p| p.into_inner()) = wacore::runtime::ShutdownNotifier::new();
96    }
97
98    pub(crate) fn is_shutting_down(&self) -> bool {
99        self.expected_disconnect.load(Ordering::Relaxed) || !self.is_running.load(Ordering::Relaxed)
100    }
101
102    /// Whether the client is finished for good, as opposed to being between
103    /// connections.
104    ///
105    /// [`is_shutting_down`](Self::is_shutting_down) answers both at once, which
106    /// is a trap for work that outlives a connection: a planned reconnect makes
107    /// it true, so anything reading it as "stop" throws itself away instead of
108    /// waiting for the replacement.
109    ///
110    /// Built from the signals that actually mean *terminal*. The shutdown
111    /// notifier is deliberately left untouched by reconnects, and it is fired by
112    /// `disconnect`, `logout` and `signal_shutdown_sync`. The stream errors that
113    /// end a session without going through those clear `enable_auto_reconnect`
114    /// and set `expected_disconnect` together, which is what tells them apart
115    /// from an application merely turning auto-reconnect off.
116    ///
117    /// Not `is_running` on its own: that tracks whether `run()`'s supervision
118    /// loop is active, which a direct-connect client never starts, so a healthy
119    /// one would look permanently stopped the moment its application expressed a
120    /// reconnect preference.
121    ///
122    /// Every transition that can make this true fires
123    /// [`session_state_notifier`](Client::session_state_notifier), because a
124    /// waiter parked on a connection that is never coming has no other way to
125    /// learn that it should stop.
126    pub(crate) fn is_terminal(&self) -> bool {
127        if self.shutdown_signal().is_fired() {
128            return true;
129        }
130        // `enable_auto_reconnect` alone is a preference, not proof: it is public,
131        // and an application may clear it on a healthy connection to mean "do
132        // not come back after this one ends". The internal paths that really do
133        // end the session — conflict, 516, an unrecoverable connect failure —
134        // always set `expected_disconnect` alongside it, so the pair is what
135        // separates a policy from a verdict.
136        //
137        // The second half is the run loop's own exit: it stops by clearing
138        // `is_running` and breaking, without firing the notifier or touching
139        // `expected_disconnect`, so that pairing has to count too. It is read
140        // together with a dead socket, because `is_running` is also false for a
141        // direct-connect client that never started a supervision loop — and one
142        // of those with a live connection is not finished, it just never had a
143        // loop to end. By the time the run loop breaks, `cleanup_connection_state`
144        // has already cleared `is_connected`, so the real exit still reads as one.
145        !self.enable_auto_reconnect.load(Ordering::Relaxed)
146            && (self.expected_disconnect.load(Ordering::Relaxed)
147                || (!self.is_running.load(Ordering::Relaxed) && !self.is_connected()))
148    }
149
150    /// Wake everything waiting on whether this client can still do work.
151    ///
152    /// Call after any store that may have flipped [`is_terminal`](Self::is_terminal)
153    /// or completed authentication. Spurious calls are free: every waiter
154    /// re-reads the state and parks again, so this is only ever a hint that the
155    /// answer is worth asking for again.
156    pub(crate) fn notify_session_state(&self) {
157        self.session_state_notifier.notify(usize::MAX);
158    }
159
160    /// The supervision loop giving up for good.
161    ///
162    /// A named transition rather than two stores at the branch, because the
163    /// notify is not optional here and there is nowhere else to learn of it:
164    /// this is the only terminal transition that fires no notifier — a later
165    /// `run()` must stay possible, so the shutdown one is deliberately left
166    /// alone — and announces no socket, ever again. Work parked waiting for a
167    /// connection finds out here or not at all.
168    pub(crate) fn stop_supervision_loop(&self) {
169        self.is_running.store(false, Ordering::Relaxed);
170        self.notify_session_state();
171    }
172
173    /// Returns `true` when the client has completed its full startup:
174    /// transport connected, server authenticated, and critical app state synced.
175    /// This is the condition `wait_for_connected` uses to resolve.
176    fn is_fully_ready(&self) -> bool {
177        self.is_connected() && self.is_logged_in() && self.is_ready.load(Ordering::Relaxed)
178    }
179
180    /// Dispatch the Connected event and notify waiters for the originating connection.
181    pub(crate) async fn dispatch_connected(&self, expected_generation: u64) {
182        #[cfg(feature = "client-lifecycle")]
183        {
184            if let Some(lifecycle) = &self.lifecycle {
185                if !lifecycle.ready(expected_generation).await {
186                    debug!(
187                        "Skipping Connected dispatch for retired generation {expected_generation}"
188                    );
189                    return;
190                }
191
192                // Cleanup takes the same lock before retiring the generation, so the final
193                // validation and publication form one transition with its generation bump.
194                let _login_transition = self
195                    .login_transition
196                    .lock()
197                    .unwrap_or_else(|poisoned| poisoned.into_inner());
198                if self.connection_generation.load(Ordering::SeqCst) != expected_generation
199                    || self.expected_disconnect.load(Ordering::Acquire)
200                {
201                    debug!(
202                        "Skipping Connected dispatch after generation {expected_generation} retired"
203                    );
204                    return;
205                }
206                if !lifecycle.publish_ready(expected_generation, || self.publish_connected()) {
207                    debug!("Skipping Connected dispatch after lifecycle cancellation");
208                }
209                return;
210            }
211        }
212
213        #[cfg(feature = "client-lifecycle")]
214        let _login_transition = self
215            .login_transition
216            .lock()
217            .unwrap_or_else(|poisoned| poisoned.into_inner());
218        if self.connection_generation.load(Ordering::SeqCst) != expected_generation
219            || self.expected_disconnect.load(Ordering::Acquire)
220        {
221            debug!("Skipping Connected dispatch after its connection retired");
222            return;
223        }
224        self.publish_connected();
225    }
226
227    fn publish_connected(&self) {
228        self.is_ready.store(true, Ordering::Relaxed);
229        wacore::telemetry::set_connected(true);
230        self.core.event_bus.dispatch(Event::Connected(
231            crate::types::events::Connected::builder().build(),
232        ));
233        self.connected_notifier.notify(usize::MAX);
234    }
235
236    #[cfg(feature = "client-lifecycle")]
237    pub(super) async fn shutdown_lifecycle(&self) {
238        if let Some(lifecycle) = &self.lifecycle {
239            lifecycle.shutdown().await;
240        }
241    }
242
243    #[cfg(feature = "client-lifecycle")]
244    fn request_lifecycle_shutdown(&self) {
245        if let Some(lifecycle) = &self.lifecycle {
246            lifecycle.request_shutdown();
247        }
248    }
249
250    /// Create a new `Client` with default cache configuration.
251    ///
252    /// This is the standard constructor. Use [`Client::new_with_cache_config`]
253    /// if you need to customise cache TTL / capacity.
254    pub async fn new(
255        runtime: Arc<dyn Runtime>,
256        persistence_manager: Arc<PersistenceManager>,
257        transport_factory: Arc<dyn crate::transport::TransportFactory>,
258        http_client: Arc<dyn crate::http::HttpClient>,
259        override_version: Option<(u32, u32, u32)>,
260    ) -> (Arc<Self>, async_channel::Receiver<MajorSyncTask>) {
261        ClientBuilder::build_required(
262            runtime,
263            persistence_manager,
264            transport_factory,
265            http_client,
266            override_version,
267            CacheConfig::default(),
268        )
269        .await
270        .into_parts()
271    }
272
273    /// Create a new `Client` with a custom [`CacheConfig`].
274    pub async fn new_with_cache_config(
275        runtime: Arc<dyn Runtime>,
276        persistence_manager: Arc<PersistenceManager>,
277        transport_factory: Arc<dyn crate::transport::TransportFactory>,
278        http_client: Arc<dyn crate::http::HttpClient>,
279        override_version: Option<(u32, u32, u32)>,
280        cache_config: CacheConfig,
281    ) -> (Arc<Self>, async_channel::Receiver<MajorSyncTask>) {
282        ClientBuilder::build_required(
283            runtime,
284            persistence_manager,
285            transport_factory,
286            http_client,
287            override_version,
288            cache_config,
289        )
290        .await
291        .into_parts()
292    }
293
294    pub(super) fn assemble(
295        runtime: Arc<dyn Runtime>,
296        persistence_manager: Arc<PersistenceManager>,
297        transport_factory: Arc<dyn crate::transport::TransportFactory>,
298        http_client: Arc<dyn crate::http::HttpClient>,
299        override_version: Option<(u32, u32, u32)>,
300        cache_config: CacheConfig,
301        extensions: ClientExtensions,
302    ) -> ClientAssembly {
303        let ClientExtensions {
304            #[cfg(feature = "client-lifecycle")]
305            lifecycle,
306            #[cfg(feature = "plugins")]
307            plugin_host,
308        } = extensions;
309        let mut unique_id_bytes = [0u8; 2];
310        rand::make_rng::<rand::rngs::StdRng>().fill_bytes(&mut unique_id_bytes);
311
312        let device_snapshot = persistence_manager.get_device_snapshot();
313        let core = wacore::client::CoreClient::new(device_snapshot.core.clone());
314
315        let (tx, rx) = async_channel::bounded(32);
316
317        let device_topology = device_topology::DeviceTopology::new();
318        let this = Self {
319            runtime: runtime.clone(),
320            core,
321            msg_secret_buffer: crate::msg_secret_buffer::MsgSecretWriteBuffer::new(
322                persistence_manager.backend(),
323                runtime.clone(),
324            ),
325            persistence_manager: persistence_manager.clone(),
326            media_conn: Arc::new(RwLock::new(None)),
327            is_logged_in: Arc::new(AtomicBool::new(false)),
328            #[cfg(feature = "client-lifecycle")]
329            login_transition: std::sync::Mutex::new(()),
330            is_connecting: Arc::new(AtomicBool::new(false)),
331            is_running: Arc::new(AtomicBool::new(false)),
332            is_connected: Arc::new(AtomicBool::new(false)),
333            send_active_receipts: AtomicU32::new(0),
334            ik_handshake_failures: Arc::new(AtomicU32::new(0)),
335            shutdown_notifier: wacore::runtime::ShutdownNotifier::new(),
336            connection_shutdown: std::sync::Mutex::new(wacore::runtime::ShutdownNotifier::new()),
337            #[cfg(feature = "client-lifecycle")]
338            lifecycle,
339            #[cfg(feature = "plugins")]
340            plugin_host,
341            stats: Arc::new(wacore::stats::SessionStats::new()),
342
343            transport: Arc::new(Mutex::new(None)),
344            transport_events: Arc::new(Mutex::new(None)),
345            transport_factory,
346            noise_socket: Arc::new(Mutex::new(None)),
347
348            response_waiters: Arc::new(std::sync::Mutex::new(ResponseWaiterMap::default())),
349            node_waiters: std::sync::Mutex::new(Vec::new()),
350            node_waiter_count: AtomicUsize::new(0),
351            sent_node_waiters: std::sync::Mutex::new(Vec::new()),
352            sent_node_waiter_count: AtomicUsize::new(0),
353            unique_id: format!("{}.{}", unique_id_bytes[0], unique_id_bytes[1]),
354            id_counter: Arc::new(AtomicU64::new(0)),
355            unified_session: crate::unified_session::UnifiedSessionManager::new(),
356
357            signal_cache: Arc::new(crate::store::signal_cache::SignalStoreCache::new()),
358            message_processing_semaphore: std::sync::Mutex::new(Arc::new(
359                async_lock::Semaphore::new(1),
360            )),
361            message_semaphore_generation: Arc::new(AtomicU64::new(0)),
362            // Coordination caches: capacity-only eviction, no TTL/TTI.
363            // These hold live mutexes and channel senders; time-based eviction
364            // while tasks hold references would silently break serialisation.
365            // The evict_guard also blocks capacity eviction of a mutex a task is
366            // holding (strong_count > 1) — evicting it would mint a second mutex
367            // and let two writers race the same Signal session.
368            session_locks: Cache::builder()
369                .max_capacity(cache_config.session_locks_capacity.max(1))
370                .evict_guard(|m| Arc::strong_count(m) <= 1)
371                .build(),
372            chat_lanes: Cache::builder()
373                .max_capacity(cache_config.chat_lanes_capacity.max(1))
374                .evict_guard(|lane: &ChatLane| Arc::strong_count(&lane.enqueue_lock) <= 1)
375                .build(),
376            lid_pn_cache: Arc::new(LidPnCache::with_config(
377                &cache_config.lid_pn_cache,
378                cache_config.cache_stores.lid_pn_cache.clone(),
379            )),
380            ab_props: Arc::new(wacore::store::ab_props::AbPropsCache::new()),
381            group_cache: Mutex::new(None),
382
383            expected_disconnect: Arc::new(AtomicBool::new(false)),
384            intentional_reconnect: AtomicBool::new(false),
385            connection_generation: Arc::new(AtomicU64::new(0)),
386
387            recent_messages: cache_config.recent_messages.build_with_ttl(),
388
389            sender_key_device_cache: crate::sender_key_device_cache::SenderKeyDeviceCache::new(
390                &cache_config.sender_key_devices_cache,
391            ),
392
393            pending_device_sync: crate::pending_device_sync::PendingDeviceSync::new(),
394
395            pending_retries: Arc::new(std::sync::Mutex::new(HashSet::new())),
396
397            message_retry_counts: cache_config.message_retry_counts.build_with_ttl(),
398
399            session_recreate_history: cache_config.session_recreate_history.build_with_ttl(),
400
401            resend_rate_limiter: crate::resend_rate_limiter::ResendRateLimiter::new(
402                cache_config.resend_rate_limiter_capacity,
403                crate::resend_rate_limiter::DEFAULT_RESEND_BURST,
404                crate::resend_rate_limiter::DEFAULT_RESEND_REFILL_PER_MIN,
405            ),
406
407            undecryptable_dispatched: cache_config.undecryptable_dispatched.build_with_ttl(),
408
409            offline_sync_metrics: Arc::new(OfflineSyncMetrics {
410                active: AtomicBool::new(false),
411                total_messages: AtomicUsize::new(0),
412                processed_messages: AtomicUsize::new(0),
413                start_time: std::sync::Mutex::new(None),
414            }),
415            offline_batch: Arc::new(offline_resume::OfflineBatchCoordinator::new()),
416
417            enable_auto_reconnect: Arc::new(AtomicBool::new(true)),
418            auto_reconnect_errors: Arc::new(AtomicU32::new(0)),
419            connected_at_ms: Arc::new(AtomicI64::new(0)),
420            backoff_reset_suppressed: Arc::new(AtomicBool::new(false)),
421
422            needs_initial_full_sync: Arc::new(app_state::BootstrapGate::new(false)),
423
424            app_state_processor: Mutex::new(None),
425            app_state_key_requests: Arc::new(Mutex::new(HashMap::new())),
426            app_state_syncing: app_state::SyncInFlight::new(),
427            app_state_send_lock: Arc::new(Mutex::new(())),
428            initial_keys_synced_notifier: Arc::new(event_listener::Event::new()),
429            initial_app_state_keys_received: Arc::new(AtomicBool::new(false)),
430            prekey_upload_lock: Arc::new(Mutex::new(())),
431            signed_pre_key_rotation_lock: Arc::new(Mutex::new(())),
432            offline_sync_notifier: Arc::new(event_listener::Event::new()),
433            offline_sync_completed: Arc::new(AtomicBool::new(false)),
434            offline_sync_finish_started: Arc::new(AtomicBool::new(false)),
435            offline_receipt_buffer: std::sync::Mutex::new(Vec::new()),
436            inbound_commit_batch: Default::default(),
437            history_sync_activity: Arc::new(crate::sync_task::HistorySyncActivity::new()),
438            outbound_flush: Arc::new(crate::flush_scope::FlushScope::new()),
439            delivery_receipt_queue: std::sync::OnceLock::new(),
440            transport_ack_queue: std::sync::OnceLock::new(),
441            presence_subscriptions: Arc::new(Mutex::new(HashSet::new())),
442            socket_ready_notifier: Arc::new(event_listener::Event::new()),
443            is_ready: Arc::new(AtomicBool::new(false)),
444            connected_notifier: Arc::new(event_listener::Event::new()),
445            authenticated_generation: Arc::new(AtomicU64::new(NO_AUTHENTICATED_GENERATION)),
446            session_state_notifier: Arc::new(event_listener::Event::new()),
447            major_sync_task_sender: tx,
448            pairing_cancellation_tx: Arc::new(Mutex::new(None)),
449            pairing_qr_refresh_tx: Arc::new(Mutex::new(None)),
450            pair_code_state: Arc::new(Mutex::new(wacore::pair_code::PairCodeState::default())),
451            passkey_state: Arc::new(Mutex::new(crate::passkey::flow::PasskeyFlowState::default())),
452            passkey_opening: AtomicBool::new(false),
453            signal_flush_state: AtomicU64::new(0),
454            signal_flush_lifecycle: Mutex::new(()),
455            #[cfg(test)]
456            signal_flush_test_failures: AtomicU32::new(0),
457            #[cfg(test)]
458            signal_flush_test_block: AtomicBool::new(false),
459            #[cfg(test)]
460            signal_flush_test_in_attempt: AtomicU32::new(0),
461            #[cfg(test)]
462            app_state_key_share_prepare_test_failures: AtomicU32::new(0),
463            custom_enc_handlers: std::sync::OnceLock::new(),
464            inbound_durability_hook: std::sync::OnceLock::new(),
465            retry_admission: std::sync::OnceLock::new(),
466            chatstate_handlers: Arc::new(RwLock::new(Vec::new())),
467            pdo_pending_requests: cache_config.pdo_pending_requests.build_with_ttl(),
468            pdo_requested: cache_config.pdo_requested.build_with_ttl(),
469            device_registry_cache: device_topology::DeviceRegistryCache::new(
470                cache_config.device_registry_cache.build_typed_ttl(
471                    cache_config.cache_stores.device_registry_cache.clone(),
472                    "device_registry",
473                ),
474                Arc::clone(&device_topology),
475            ),
476            device_topology,
477            device_memos_enabled: cache_config.cache_stores.device_registry_cache.is_none()
478                && cache_config.cache_stores.lid_pn_cache.is_none(),
479            group_devices_memo: Cache::builder()
480                .max_capacity(GROUP_DEVICES_MEMO_CAPACITY)
481                .build(),
482            dm_devices_memo: Cache::builder()
483                .max_capacity(DM_DEVICES_MEMO_CAPACITY)
484                .build(),
485            #[cfg(test)]
486            dm_devices_memo_recomputes: AtomicU64::new(0),
487            // A live lane also protects recipient-tracker reset/update ordering.
488            group_distribution_locks: Cache::builder()
489                .max_capacity(cache_config.group_distribution_locks_capacity.max(1))
490                .evict_guard(|m| Arc::strong_count(m) <= 1)
491                .build(),
492            skdm_warm_memo: Cache::builder()
493                .max_capacity(GROUP_DEVICES_MEMO_CAPACITY)
494                .build(),
495            stanza_router: Self::create_stanza_router(),
496            synchronous_ack: false,
497            http_client,
498            override_version,
499            skip_history_sync: AtomicBool::new(false),
500            wanted_pre_key_count: AtomicUsize::new(crate::prekeys::DEFAULT_WANTED_PRE_KEY_COUNT),
501            cache_config,
502            self_weak: std::sync::OnceLock::new(),
503            saver_handle: std::sync::OnceLock::new(),
504            alloc_meter: std::sync::OnceLock::new(),
505            raw_node_forwarding: AtomicUsize::new(0),
506            #[cfg(feature = "voip-runtime")]
507            call_registry: Arc::new(wacore::voip::CallRegistry::new()),
508            #[cfg(feature = "voip-runtime")]
509            pending_call_link_joins: Arc::new(std::sync::Mutex::new(
510                voip::PendingCallLinkJoins::default(),
511            )),
512            #[cfg(feature = "voip-runtime")]
513            pending_call_link_join_lane: Arc::new(Mutex::new(())),
514            #[cfg(feature = "voip-runtime")]
515            answer_transition_locks: std::array::from_fn(|_| Arc::new(Mutex::new(()))),
516            #[cfg(feature = "voip-runtime")]
517            pending_outgoing_calls: Arc::new(std::sync::Mutex::new(HashMap::new())),
518        };
519
520        let arc = Arc::new(this);
521        // Mapping changes alter which canonical record a device lookup
522        // resolves to, so LidPnCache records into the same topology tracker.
523        arc.lid_pn_cache
524            .attach_topology(Arc::clone(&arc.device_topology));
525        let _ = arc.self_weak.set(Arc::downgrade(&arc));
526
527        ClientAssembly::new(arc, rx)
528    }
529
530    pub(super) fn start_services(self: &Arc<Self>) {
531        let warm_up_arc = self.clone();
532        self.runtime
533            .spawn(Box::pin(async move {
534                if let Err(e) = warm_up_arc.warm_up_lid_pn_cache().await {
535                    warn!("Failed to warm up LID-PN cache: {e}");
536                }
537            }))
538            .detach();
539    }
540
541    // Deliberately NOT instrumented: this span would live for the entire client
542    // lifetime, distorting duration/throughput metrics just like the removed
543    // keepalive-loop span. Identity (lid/pn) attribution comes from the
544    // per-operation spans (send/request), which record it themselves.
545    pub async fn run(self: &Arc<Self>) {
546        #[cfg(feature = "client-lifecycle")]
547        if let Some(lifecycle) = &self.lifecycle
548            && !lifecycle.wait_until_active().await
549        {
550            warn!("Client `run` rejected before construction completed.");
551            return;
552        }
553        let shutdown = self.shutdown_signal();
554        if shutdown.is_fired() {
555            warn!("Client `run` called after shutdown.");
556            return;
557        }
558        if self.is_running.swap(true, Ordering::SeqCst) {
559            warn!("Client `run` method called while already running.");
560            return;
561        }
562        if shutdown.is_fired() {
563            self.is_running.store(false, Ordering::SeqCst);
564            return;
565        }
566        // Reconnects are counted at iteration start: every pass after the
567        // first is an attempt actually being made. Counting at the branches
568        // below would also count a final pass that never reconnects (a user
569        // disconnect() flips is_running while the branch runs).
570        let mut first_connect = true;
571        while self.is_running.load(Ordering::Relaxed) {
572            if !first_connect {
573                self.stats.record_reconnect();
574            }
575            first_connect = false;
576            self.expected_disconnect.store(false, Ordering::Relaxed);
577
578            if let Err(connect_err) = self.connect().await {
579                wacore::telemetry::connect("fail");
580                let is_transient = matches!(
581                    &connect_err,
582                    ConnectError::Handshake(e) if e.is_transient()
583                );
584                if is_transient {
585                    debug!("Transient connect failure, will retry: {connect_err:#}");
586                } else {
587                    error!("Failed to connect: {connect_err:#}. Will retry...");
588                }
589            } else {
590                wacore::telemetry::connect("ok");
591                let loop_result = self.read_messages_loop().await;
592                // Consume intentional_reconnect on EVERY exit, reading it AFTER the loop
593                // ends (reconnect() sets it while the loop runs, then tears down via the
594                // shutdown signal — the Expected path). Consuming it only on some paths
595                // left it stale for the next connection, misclassifying the next genuine
596                // disconnect as intentional and swallowing its Disconnected event.
597                let intentional = self.intentional_reconnect.swap(false, Ordering::Relaxed);
598                // Some(reason) = unexpected disconnect worth a `Disconnected` event; the
599                // reason distinguishes a routine server recycle from a real failure so
600                // consumers don't have to.
601                let unexpected_disconnect = match loop_result {
602                    Ok(node_io::ReadLoopExit::Expected) => {
603                        debug!("Message loop exited gracefully (expected disconnect).");
604                        None
605                    }
606                    Ok(node_io::ReadLoopExit::ServerRecycle(reason)) => {
607                        if self.expected_disconnect.load(Ordering::Relaxed) || intentional {
608                            debug!("Message loop exited during expected disconnect.");
609                            None
610                        } else {
611                            // read_messages_loop already logged this at info; a clean
612                            // recycle stays quiet here too.
613                            Some(reason)
614                        }
615                    }
616                    Err(e) => {
617                        if self.expected_disconnect.load(Ordering::Relaxed) || intentional {
618                            debug!("Message loop exited during expected disconnect.");
619                            None
620                        } else {
621                            // read_messages_loop already logged the cause at warn; keep
622                            // this at debug to avoid double-reporting.
623                            debug!("Message loop exited, will reconnect if enabled: {e:#}");
624                            Some(e.into_reason())
625                        }
626                    }
627                };
628
629                self.cleanup_connection_state().await;
630
631                // Dispatch after cleanup so handlers see cleared connection state.
632                if let Some(reason) = unexpected_disconnect {
633                    self.core.event_bus.dispatch(Event::Disconnected(
634                        crate::types::events::Disconnected::builder()
635                            .reason(reason)
636                            .build(),
637                    ));
638                }
639            }
640
641            if !self.enable_auto_reconnect.load(Ordering::Relaxed) {
642                info!("Auto-reconnect disabled, shutting down.");
643                self.stop_supervision_loop();
644                break;
645            }
646
647            // If this was an expected disconnect (e.g., 515 after pairing), reconnect immediately
648            if self.expected_disconnect.load(Ordering::Relaxed) {
649                self.auto_reconnect_errors.store(0, Ordering::Relaxed);
650                // Consume the auth timestamp so a later failed connect can't
651                // read this cycle's stale value as a "stable" connection.
652                self.connected_at_ms.store(0, Ordering::Relaxed);
653                info!("Expected disconnect (e.g., 515), reconnecting immediately...");
654                continue;
655            }
656
657            // Reset the backoff only after a stable connection, unless an
658            // explicit penalty (429 / manual reconnect) must survive — WA Web
659            // `resetDelay` + `cancelReset`.
660            let connected_at = self.connected_at_ms.swap(0, Ordering::Relaxed);
661            let penalty = self.backoff_reset_suppressed.load(Ordering::Relaxed);
662            if should_reset_backoff(connected_at, wacore::time::now_millis(), penalty) {
663                self.auto_reconnect_errors.store(0, Ordering::Relaxed);
664            }
665
666            let error_count = self.auto_reconnect_errors.fetch_add(1, Ordering::SeqCst);
667            // WA Web: Fibonacci backoff with 10% jitter, max 900s.
668            // algo: { type: "fibonacci", first: 1000, second: 1000 }
669            // jitter: 0.1, max: 9e5
670            let delay = fibonacci_backoff(error_count);
671            info!(
672                "Will attempt to reconnect in {:?} (attempt {})",
673                delay,
674                error_count + 1
675            );
676            // Race the wait against the terminal shutdown: the loop only tests
677            // `is_running` at the top, so a bare sleep would hold a shutdown
678            // unobserved for as long as the backoff runs — up to the 900s cap,
679            // which a 429 reaches in a couple of stream errors. Falling through
680            // is the whole fix; the loop condition handles the exit.
681            //
682            // Fresh listener per iteration (event_listener is edge-triggered);
683            // `shutdown` itself is subscribed once above and holds the notifier
684            // alive. Deliberately NOT `connection_shutdown_signal()`: that one
685            // fires on every disconnect the loop is here to reconnect from, so
686            // watching it would collapse the backoff instead of interrupting it.
687            let shutdown_fired = wacore::runtime::wait_for_shutdown(&shutdown);
688            futures::select! {
689                _ = self.runtime.sleep(delay).fuse() => {}
690                _ = shutdown_fired.fuse() => {
691                    debug!("Shutdown signalled during reconnect backoff, exiting run loop.");
692                }
693            }
694        }
695        #[cfg(feature = "client-lifecycle")]
696        self.shutdown_lifecycle().await;
697        info!("Client run loop has shut down.");
698    }
699
700    /// Boxed barrier: see [`crate::bot::Bot::run`]. Coroutines are LocalCopy
701    /// across crates, so consumers awaiting the connect graph directly would
702    /// re-codegen it; the box makes them poll through a vtable instead.
703    pub async fn connect(self: &Arc<Self>) -> Result<(), ConnectError> {
704        #[cfg(feature = "client-lifecycle")]
705        if let Some(lifecycle) = &self.lifecycle
706            && !lifecycle.wait_until_active().await
707        {
708            return Err(ConnectError::NotActivated);
709        }
710        self.connect_boxed().await
711    }
712
713    #[inline(never)]
714    fn connect_boxed(self: &Arc<Self>) -> wacore::runtime::BoxFuture<'_, Result<(), ConnectError>> {
715        Box::pin(self.connect_graph())
716    }
717
718    // err(level = "warn", ...): run()'s caller already classifies failures here itself
719    // (debug! for a transient HandshakeError worth a quiet retry, error! otherwise — see
720    // run()'s connect_err handling) — the default ERROR level on this span ignored that
721    // and turned every transient handshake retry into its own GlitchTip issue. A genuine
722    // failure still surfaces via that caller's error! call, independent of this span's level.
723    #[cfg_attr(
724        feature = "tracing",
725        tracing::instrument(
726            name = "wa.conn.connect",
727            level = "info",
728            skip_all,
729            fields(lid = tracing::field::Empty, pn = tracing::field::Empty),
730            err(level = "warn", Debug)
731        )
732    )]
733    async fn connect_graph(self: &Arc<Self>) -> Result<(), ConnectError> {
734        #[cfg(feature = "tracing")]
735        self.record_identity_on_span(&tracing::Span::current());
736
737        if self.is_connecting.swap(true, Ordering::SeqCst) {
738            return Err(ConnectError::AlreadyConnected);
739        }
740
741        let _guard = scopeguard::guard((), |_| {
742            self.is_connecting.store(false, Ordering::Relaxed);
743        });
744
745        if self.is_connected() {
746            return Err(ConnectError::AlreadyConnected);
747        }
748        let _t = wacore::telemetry::timer(wacore::telemetry::CONNECT_DURATION);
749
750        // Reset login state for new connection attempt. This ensures that
751        // handle_success will properly process the <success> stanza even if
752        // a previous connection's post-login task bailed out early.
753        self.is_logged_in.store(false, Ordering::Relaxed);
754        self.is_ready.store(false, Ordering::Relaxed);
755        self.is_connected.store(false, Ordering::Relaxed);
756        self.offline_sync_completed.store(false, Ordering::Relaxed);
757        self.offline_sync_finish_started
758            .store(false, Ordering::Relaxed);
759        self.clear_offline_receipt_buffer();
760        // Uncommitted batch entries were never acked; the server redelivers
761        // them on this fresh connection. The cache decision is coupled to the
762        // drop: entries present here mean their cache-only ratchet advances
763        // have no rows (e.g. a stanza that outlived the teardown settle and
764        // enqueued late), and flushing those later would make each redelivery
765        // an ackable duplicate — so the cache falls with them. With nothing
766        // dropped, anything resident is state a failed teardown flush
767        // deliberately retained (committed/acked, never redelivered) for the
768        // next successful flush to persist.
769        if self.inbound_commit_batch.reset() {
770            log::warn!(
771                "connect: dropping unflushed Signal state along with late uncommitted drain entries"
772            );
773            self.signal_cache.clear().await;
774        }
775        self.offline_batch.reset();
776        self.outbound_flush.reopen();
777
778        // WA Web: both MQTT and DGW transports use a 20s connect timeout.
779        // Without this, a dead network blocks on the OS TCP SYN timeout (~60-75s).
780        // Version fetch is also wrapped so a hung HTTP request doesn't block connect().
781        let version_future = rt_timeout(
782            &*self.runtime,
783            TRANSPORT_CONNECT_TIMEOUT,
784            crate::version::resolve_and_update_version(
785                &self.persistence_manager,
786                &self.http_client,
787                self.override_version,
788            ),
789        );
790        let transport_future = rt_timeout(
791            &*self.runtime,
792            TRANSPORT_CONNECT_TIMEOUT,
793            self.transport_factory.create_transport(),
794        );
795
796        debug!("Connecting WebSocket and fetching latest client version in parallel...");
797        let (version_result, transport_result) = futures::join!(version_future, transport_future);
798
799        version_result
800            .map_err(|_| ConnectError::Timeout {
801                stage: ConnectStage::VersionFetch,
802                timeout: TRANSPORT_CONNECT_TIMEOUT,
803            })?
804            .map_err(ConnectError::Version)?;
805        let (transport, mut transport_events) = transport_result
806            .map_err(|_| ConnectError::Timeout {
807                stage: ConnectStage::Transport,
808                timeout: TRANSPORT_CONNECT_TIMEOUT,
809            })?
810            .map_err(ConnectError::Transport)?;
811        debug!("Version fetch and transport connection established.");
812
813        let noise_socket = match handshake::do_handshake(
814            self.runtime.clone(),
815            &self.persistence_manager,
816            &self.ik_handshake_failures,
817            transport.clone(),
818            &mut transport_events,
819            Some(self.stats.clone()),
820        )
821        .await
822        {
823            Ok(socket) => socket,
824            Err(e) => {
825                transport.disconnect().await;
826                return Err(e.into());
827            }
828        };
829
830        // Fresh per-connection shutdown so subscribers registered during this
831        // connection see a clean signal; the previous notifier was already
832        // fired on the prior cleanup_connection_state.
833        self.reset_connection_shutdown();
834
835        // Invalidated before the socket is published, not after `<success>`
836        // lands. `handle_success` sets `is_logged_in` one step before it
837        // increments the generation, and the value left behind by the *previous*
838        // connection equals the generation still in place during that step — so
839        // without this the window reads as authenticated on a generation the
840        // next instruction retires. Nothing is authenticated until this
841        // connection says so itself.
842        self.authenticated_generation
843            .store(NO_AUTHENTICATED_GENERATION, Ordering::SeqCst);
844
845        *self.transport.lock().await = Some(transport);
846        *self.transport_events.lock().await = Some(transport_events);
847        *self.noise_socket.lock().await = Some(noise_socket);
848        self.is_connected.store(true, Ordering::Release);
849
850        // Notify waiters that socket is ready (before login)
851        self.socket_ready_notifier.notify(usize::MAX);
852
853        let client_clone = self.clone();
854        self.runtime
855            .spawn(Box::pin(async move { client_clone.keepalive_loop().await }))
856            .detach();
857
858        Ok(())
859    }
860
861    /// Deregister this companion device and disconnect.
862    /// Does NOT wipe stored keys. Delete the storage backend to fully clear credentials.
863    ///
864    /// Infallible on purpose: the deregistration IQ is best-effort (it cannot be
865    /// sent at all while offline), and the local teardown runs either way, so a
866    /// caller has nothing to branch on. A failed IQ is logged at warn.
867    #[cfg_attr(
868        feature = "tracing",
869        tracing::instrument(name = "wa.conn.logout", level = "info", skip_all)
870    )]
871    pub async fn logout(self: &Arc<Self>) {
872        use wacore::iq::devices::RemoveCompanionDeviceSpec;
873
874        self.enable_auto_reconnect.store(false, Ordering::Relaxed);
875
876        if self.is_connected()
877            && let Ok(jid) = self.require_pn()
878            && let Err(e) = self.execute(RemoveCompanionDeviceSpec::new(&jid)).await
879        {
880            warn!("Failed to send logout IQ: {e}");
881        }
882
883        self.core.event_bus.dispatch(Event::LoggedOut(
884            crate::types::events::LoggedOut::builder()
885                .on_connect(false)
886                .reason(ConnectFailureReason::LoggedOut)
887                .build(),
888        ));
889
890        self.disconnect().await;
891    }
892
893    #[cfg_attr(
894        feature = "tracing",
895        tracing::instrument(name = "wa.conn.disconnect", level = "info", skip_all)
896    )]
897    pub async fn disconnect(self: &Arc<Self>) {
898        info!("Disconnecting client intentionally.");
899        wacore::telemetry::set_connected(false);
900        self.expected_disconnect.store(true, Ordering::Relaxed);
901        self.is_running.store(false, Ordering::Relaxed);
902        self.shutdown_notifier.notify();
903        self.notify_session_state();
904        #[cfg(feature = "client-lifecycle")]
905        self.request_lifecycle_shutdown();
906
907        // Drain buffered offline receipts into the flush window before
908        // closing it, so a disconnect mid-offline-sync still acks the
909        // already-processed backlog (issue #571 semantics). close() only stops
910        // outbound task spawns, not buffering, so a message still in flight can
911        // re-buffer after this drain; those entries are dropped by the
912        // connection-state reset (clear_offline_receipt_buffer) and the server
913        // redelivers their messages on the next connect, where they are
914        // re-acked fresh.
915        //
916        // Commit any accumulated drain batch first so its acks land in this
917        // receipt drain. Bounded like the outbound flush below: on timeout the
918        // entries simply stay unacked and the server redelivers them — and the
919        // buffered receipts stay unsent too, because their SKDM/session state
920        // may not be durable yet (receipting an SKDM whose sender key only
921        // lives in the cache would lose it to a crash with no redelivery).
922        if self
923            .flush_inbound_commits_bounded(Duration::from_secs(5))
924            .await
925        {
926            self.flush_offline_receipts();
927        }
928        // Prevent late receipt producers from escaping the drain window.
929        self.outbound_flush.close();
930        self.outbound_flush
931            .flush(&*self.runtime, Duration::from_secs(5))
932            .await;
933        self.notify_connection_shutdown();
934
935        if let Err(e) = self.persistence_manager.flush().await {
936            log::error!("Failed to flush device state during disconnect: {e}");
937        }
938
939        // Close after flush; cleanup may also win this race on the run loop.
940        if let Some(transport) = self.transport.lock().await.as_ref() {
941            transport.disconnect().await;
942        }
943        self.cleanup_connection_state().await;
944
945        // The write-behind secret drain is detached; a clean exit right after
946        // a capture must not lose the only copy. Sealing first degrades any
947        // straggler capture (a lane worker still draining its backlog) to an
948        // inline write, so nothing can land on the detached drain after the
949        // final flush below and then be acked.
950        self.msg_secret_buffer.seal();
951        self.msg_secret_buffer.flush().await;
952        #[cfg(feature = "client-lifecycle")]
953        self.shutdown_lifecycle().await;
954    }
955
956    /// Backoff step used by [`reconnect()`](Self::reconnect) to create an offline window.
957    ///
958    /// `fibonacci_backoff(RECONNECT_BACKOFF_STEP)` determines the delay before
959    /// the run loop re-connects.  This must be longer than the mock server's
960    /// chatstate TTL (`CHATSTATE_TTL_SECS=3`) so TTL-expiry tests pass.
961    ///
962    /// Sequence: fib(0)=1s, fib(1)=1s, fib(2)=2s, fib(3)=3s, **fib(4)=5s**.
963    pub const RECONNECT_BACKOFF_STEP: u32 = 4;
964
965    /// Drop the current connection and trigger the auto-reconnect loop.
966    ///
967    /// Unlike [`disconnect`](Self::disconnect), this does **not** stop the run loop. The client
968    /// will reconnect automatically using the same persisted identity/store,
969    /// just as it would after a network interruption. Use
970    /// [`wait_for_connected`](Self::wait_for_connected) to wait for the new connection to be ready.
971    ///
972    /// This is useful for:
973    /// - Handling network changes (e.g., Wi-Fi → cellular)
974    /// - Forcing a fresh server session
975    /// - Testing offline message delivery
976    #[cfg_attr(
977        feature = "tracing",
978        tracing::instrument(name = "wa.conn.reconnect", level = "info", skip_all)
979    )]
980    pub async fn reconnect(self: &Arc<Self>) {
981        info!("Reconnecting: dropping transport for auto-reconnect.");
982        #[cfg(feature = "client-lifecycle")]
983        if let Some(lifecycle) = &self.lifecycle {
984            lifecycle.cancel_active_scope();
985        }
986        wacore::telemetry::reconnect();
987        self.intentional_reconnect.store(true, Ordering::Relaxed);
988        self.auto_reconnect_errors
989            .store(Self::RECONNECT_BACKOFF_STEP, Ordering::Relaxed);
990        // Deliberate step: the stability reset must not erase it.
991        self.backoff_reset_suppressed.store(true, Ordering::Relaxed);
992
993        // Same durable-before-receipts gate as disconnect().
994        if self
995            .flush_inbound_commits_bounded(Duration::from_secs(2))
996            .await
997        {
998            self.flush_offline_receipts();
999        }
1000        self.outbound_flush.close();
1001        self.outbound_flush
1002            .flush(&*self.runtime, Duration::from_secs(2))
1003            .await;
1004        self.notify_connection_shutdown();
1005
1006        if let Some(transport) = self.transport.lock().await.as_ref() {
1007            transport.disconnect().await;
1008        }
1009    }
1010
1011    /// Drop the current connection and reconnect immediately with no delay.
1012    ///
1013    /// Unlike [`reconnect`](Self::reconnect), which introduces a deliberate offline window,
1014    /// this method sets the `expected_disconnect` flag so the run loop
1015    /// skips the backoff delay and reconnects as fast as possible.
1016    #[cfg_attr(
1017        feature = "tracing",
1018        tracing::instrument(name = "wa.conn.reconnect_immediately", level = "info", skip_all)
1019    )]
1020    pub async fn reconnect_immediately(self: &Arc<Self>) {
1021        info!("Reconnecting immediately (expected disconnect).");
1022        #[cfg(feature = "client-lifecycle")]
1023        if let Some(lifecycle) = &self.lifecycle {
1024            lifecycle.cancel_active_scope();
1025        }
1026        self.expected_disconnect.store(true, Ordering::Relaxed);
1027
1028        // Same durable-before-receipts gate as disconnect().
1029        if self
1030            .flush_inbound_commits_bounded(Duration::from_secs(2))
1031            .await
1032        {
1033            self.flush_offline_receipts();
1034        }
1035        self.outbound_flush.close();
1036        self.outbound_flush
1037            .flush(&*self.runtime, Duration::from_secs(2))
1038            .await;
1039        self.notify_connection_shutdown();
1040
1041        if let Some(transport) = self.transport.lock().await.as_ref() {
1042            transport.disconnect().await;
1043        }
1044    }
1045
1046    #[cfg_attr(
1047        feature = "tracing",
1048        tracing::instrument(name = "wa.conn.cleanup", level = "debug", skip_all)
1049    )]
1050    #[cfg(not(feature = "client-lifecycle"))]
1051    pub(crate) async fn cleanup_connection_state(self: &Arc<Self>) {
1052        self.cleanup_connection_state_inner().await;
1053        self.clear_connection_scoped_pair_code().await;
1054    }
1055
1056    /// A pair-code flow belongs to the connection that carried it: the pairing
1057    /// ref and any in-flight `companion_hello` die with the socket, and the
1058    /// server routes no `primary_hello` to a session it has dropped. Left
1059    /// standing, the outstanding-code guard would reject the very request that
1060    /// reconnecting exists to make.
1061    ///
1062    /// Runs after the inner teardown, so the generation is already retired and
1063    /// the transport already closed: a request that claims the slot from here
1064    /// on is one the next connection will carry.
1065    async fn clear_connection_scoped_pair_code(self: &Arc<Self>) {
1066        *self.pair_code_state.lock().await = wacore::pair_code::PairCodeState::Idle;
1067    }
1068
1069    #[cfg_attr(
1070        feature = "tracing",
1071        tracing::instrument(name = "wa.conn.cleanup", level = "debug", skip_all)
1072    )]
1073    #[cfg(feature = "client-lifecycle")]
1074    pub(crate) async fn cleanup_connection_state(self: &Arc<Self>) {
1075        if self.lifecycle.is_none() {
1076            self.cleanup_connection_state_inner().await;
1077            self.clear_connection_scoped_pair_code().await;
1078            return;
1079        }
1080
1081        // Scope closure must survive a caller dropping its cleanup waiter.
1082        let (completed, completion) = futures::channel::oneshot::channel();
1083        let client = Arc::clone(self);
1084        self.runtime
1085            .spawn(Box::pin(async move {
1086                let result = std::panic::AssertUnwindSafe(client.cleanup_connection_state_inner())
1087                    .catch_unwind()
1088                    .await;
1089                let _ = completed.send(result);
1090            }))
1091            .detach();
1092        match completion.await {
1093            Ok(Ok(())) => {}
1094            Ok(Err(panic)) => std::panic::resume_unwind(panic),
1095            Err(_) => error!("Detached connection cleanup stopped before completion"),
1096        }
1097        self.clear_connection_scoped_pair_code().await;
1098    }
1099
1100    async fn cleanup_connection_state_inner(&self) {
1101        #[cfg(feature = "client-lifecycle")]
1102        let login_transition = self
1103            .login_transition
1104            .lock()
1105            .unwrap_or_else(|poisoned| poisoned.into_inner());
1106        // Bump the generation FIRST: it is the "this connection is over"
1107        // signal every per-connection loop already polls. Chat-lane workers
1108        // stop draining their queues (their remaining stanzas were never
1109        // acked and redeliver), stale finishers/timers stand down, and —
1110        // combined with the post-permit generation re-check in
1111        // process_classified_message — no decrypt can START after the
1112        // permit-held cache settle below, so no rowless ratchet advances can
1113        // dirty the cache behind teardown's back.
1114        #[cfg(feature = "client-lifecycle")]
1115        let closed_generation = self.connection_generation.fetch_add(1, Ordering::SeqCst);
1116        #[cfg(not(feature = "client-lifecycle"))]
1117        self.connection_generation.fetch_add(1, Ordering::SeqCst);
1118        #[cfg(feature = "client-lifecycle")]
1119        let scope_close = self.lifecycle.as_ref().map(|lifecycle| {
1120            let lifecycle = Arc::clone(lifecycle);
1121            scopeguard::guard((lifecycle, closed_generation), |(lifecycle, generation)| {
1122                if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1123                    lifecycle.close_scope(generation);
1124                }))
1125                .is_err()
1126                {
1127                    error!("Client lifecycle scope closure panicked");
1128                }
1129            })
1130        });
1131        #[cfg(feature = "client-lifecycle")]
1132        if let Some(lifecycle) = &self.lifecycle {
1133            lifecycle.cancel_scope(closed_generation);
1134        }
1135        self.notify_connection_shutdown();
1136        // The coalesced-flush scheduler needs no explicit reset: its state is
1137        // generation-scoped, so the bump above already hands ownership to the
1138        // next connection's first request and retires any stale worker.
1139        // Note: node_waiters are intentionally NOT cleared here — they are
1140        // cross-connection (callers may register a waiter before an action that
1141        // completes on a subsequent connection, e.g. after 515 reconnect).
1142        // sent_node_waiters ARE cleared because they match pre-encryption
1143        // outgoing stanzas, which are transport-scoped.
1144        self.clear_sent_node_waiters();
1145        self.is_logged_in.store(false, Ordering::Relaxed);
1146        #[cfg(feature = "client-lifecycle")]
1147        drop(login_transition);
1148        self.is_ready.store(false, Ordering::Relaxed);
1149        // Publish the disconnected state BEFORE draining VoIP calls (it used to be cleared only after
1150        // the socket teardown below): a concurrent accept()/call() setup that finishes its async work
1151        // in this window must see `!is_connected()` and bail instead of registering/connecting a call
1152        // after this sweep.
1153        self.is_connected.store(false, Ordering::Release);
1154        // Tear down every in-flight VoIP call: the relay socket and signaling are connection-scoped,
1155        // so a call can't survive a disconnect/reconnect. Aborts each media task and clears the map.
1156        #[cfg(feature = "voip-runtime")]
1157        {
1158            self.call_registry.abort_all();
1159            // Dormant outgoing calls (relay never arrived) live in pending_outgoing_calls, not the
1160            // registry, so abort_all misses them. Drain them and notify `ended` so any waiter wakes.
1161            crate::voip::facade::drain_pending_outgoing_on_disconnect(self);
1162        }
1163        // Close the socket as part of cleanup so this path is authoritative
1164        // even when reached via the run loop's graceful-exit flow (not just
1165        // `Client::disconnect()`). Transport impls make `disconnect()`
1166        // idempotent, so the redundant call from `Client::disconnect()` is
1167        // safe.
1168        if let Some(transport) = self.transport.lock().await.take() {
1169            transport.disconnect().await;
1170        }
1171        *self.transport_events.lock().await = None;
1172        *self.noise_socket.lock().await = None;
1173        // Authoritative point for the gauge: every disconnect (intentional or a
1174        // run-loop drop/reconnect) funnels through here, so disconnect()'s early
1175        // set is just a prompt redundant signal. (`is_connected` was already cleared above, before
1176        // the VoIP drain, so no task can observe is_connected==true with a cleared socket.)
1177        wacore::telemetry::set_connected(false);
1178        // Presence doesn't survive reconnects: demote presence-driven active
1179        // receipts (1 -> 0), leaving a forced value (2) untouched.
1180        let _ =
1181            self.send_active_receipts
1182                .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
1183        // Drop per-chat lanes so workers exit via channel close. Reliable
1184        // (awaited) clear: a skipped invalidation would leave a stale ChatLane
1185        // whose worker exits on the generation check after reconnect.
1186        self.chat_lanes.clear().await;
1187        // Clear pending retries so stale keys from detached scopeguard
1188        // cleanup don't suppress the first retry after reconnect.
1189        self.pending_retries
1190            .lock()
1191            .unwrap_or_else(|p| p.into_inner())
1192            .clear();
1193        // Commit any accumulated drain batch and settle the Signal cache in
1194        // ONE permit-held section (see teardown_inbound_commits_bounded):
1195        // persisting ratchet advances while dropping their uncommitted batch
1196        // entries — or while an old lane worker is mid-decrypt — would turn
1197        // redeliveries into ackable duplicates with no buffered copy.
1198        // Acks/events from this commit are best-effort (the socket is gone);
1199        // the durable hook commit is what matters. Reached on every teardown
1200        // path, including the run loop's unexpected read-loop exit, which
1201        // never goes through disconnect().
1202        //
1203        // Hold the coalesced-flush barrier across the whole settle: a stale flush
1204        // worker that already passed its generation check must not interleave a
1205        // backend write between our commit and the next connection's drain, or it
1206        // could persist that drain's rowless advances. The worker re-checks the
1207        // generation (bumped above) once it gets the gate, so it stands down.
1208        let flush_gate = self.signal_flush_lifecycle.lock().await;
1209        if let Some(client) = self.self_weak.get().and_then(|w| w.upgrade()) {
1210            client
1211                .teardown_inbound_commits_bounded(Duration::from_secs(5))
1212                .await;
1213        } else {
1214            // Same class of bug as the complete_offline_sync twin: a silent
1215            // skip here is the acked-before-committed loss — make it loud,
1216            // and drop the dirty cache with the entries it covers.
1217            log::error!(
1218                "cleanup_connection_state: self_weak upgrade failed; dropping uncommitted drain entries and their unflushed Signal state"
1219            );
1220            self.signal_cache.clear().await;
1221        }
1222        // Reset semaphore to 1 permit for next offline sync.
1223        self.swap_message_semaphore(1);
1224        // Reset dead-socket timestamps so stale values from the previous
1225        // connection don't trigger an immediate reconnect on the next one.
1226        self.stats.reset_connection_activity();
1227        self.pending_device_sync.clear().await;
1228        // Reset offline sync state for next connection
1229        self.offline_sync_completed.store(false, Ordering::Relaxed);
1230        self.offline_sync_finish_started
1231            .store(false, Ordering::Relaxed);
1232        self.clear_offline_receipt_buffer();
1233        // Same rule as receipts: uncommitted entries drop here and the server
1234        // redelivers them on the next connect. The cache falls with dropped
1235        // entries (rowless advances — including a timed-out settle's restored
1236        // batch); with nothing dropped it survives for the next flush.
1237        if self.inbound_commit_batch.reset() {
1238            log::warn!(
1239                "cleanup_connection_state: dropping unflushed Signal state along with late uncommitted drain entries"
1240            );
1241            self.signal_cache.clear().await;
1242        }
1243        // Cache is settled and any dropped entries cleared; a worker may run again.
1244        drop(flush_gate);
1245        self.offline_batch.reset();
1246        self.offline_sync_metrics
1247            .active
1248            .store(false, Ordering::Release);
1249        self.offline_sync_metrics
1250            .total_messages
1251            .store(0, Ordering::Release);
1252        self.offline_sync_metrics
1253            .processed_messages
1254            .store(0, Ordering::Release);
1255        match self.offline_sync_metrics.start_time.lock() {
1256            Ok(mut guard) => *guard = None,
1257            Err(poison) => *poison.into_inner() = None,
1258        }
1259        self.history_sync_activity.reset();
1260        // Drain all pending IQ waiters so they fail fast with InternalChannelClosed
1261        // instead of hanging until the 75s timeout.
1262        // Scoped so the sync guard is dropped before the awaits below (a
1263        // std::sync::MutexGuard held across an await would make this future !Send).
1264        let waiter_count = {
1265            let mut waiters_map = self.response_waiters_guard();
1266            let count = waiters_map.len();
1267            // Release the backing storage while preserving the generation
1268            // sequence; an old request guard may drop after reconnect and must
1269            // not match a new waiter that reused the same explicit ID.
1270            waiters_map.clear();
1271            count
1272        };
1273        if waiter_count > 0 {
1274            debug!(
1275                "Dropping {} orphaned IQ response waiter(s) on disconnect",
1276                waiter_count
1277            );
1278        }
1279
1280        // Clear app state tracking maps to prevent unbounded growth across reconnections.
1281        // Replace with new collections to release backing storage.
1282        *self.app_state_key_requests.lock().await = HashMap::new();
1283        self.app_state_syncing.clear();
1284
1285        // Drop stale media connection (auth tokens become invalid on reconnect)
1286        *self.media_conn.write().await = None;
1287
1288        // Clear app state key cache — keys will be re-fetched from DB on demand
1289        if let Some(proc) = self.app_state_processor.lock().await.as_ref() {
1290            proc.clear_key_cache().await;
1291        }
1292        #[cfg(feature = "client-lifecycle")]
1293        drop(scope_close);
1294    }
1295
1296    /// Waits for the noise socket to be established.
1297    ///
1298    /// Returns `Ok(())` when the socket is ready, or `Err` on timeout.
1299    /// This is useful for code that needs to send messages before login,
1300    /// such as requesting a pair code during initial pairing.
1301    ///
1302    /// If the socket is already connected, returns immediately.
1303    pub async fn wait_for_socket(&self, timeout: Duration) -> Result<(), ConnectError> {
1304        // Fast path: already connected
1305        if self.is_connected() {
1306            return Ok(());
1307        }
1308
1309        // Register waiter and re-check to avoid race condition:
1310        // If socket becomes ready between checks, the notified future captures it.
1311        let notified = self.socket_ready_notifier.listen();
1312        if self.is_connected() {
1313            return Ok(());
1314        }
1315
1316        rt_timeout(&*self.runtime, timeout, notified)
1317            .await
1318            .map_err(|_| ConnectError::Timeout {
1319                stage: ConnectStage::Socket,
1320                timeout,
1321            })
1322    }
1323
1324    /// Waits for the client to establish a connection and complete login.
1325    ///
1326    /// Returns `Ok(())` when connected, or `Err` on timeout.
1327    /// This is useful for code that needs to run after connection is established
1328    /// and authentication is complete.
1329    ///
1330    /// If the client is already connected and logged in, returns immediately.
1331    pub async fn wait_for_connected(&self, timeout: Duration) -> Result<(), ConnectError> {
1332        // Fast path: fully ready (connected + logged in + critical sync done).
1333        if self.is_fully_ready() {
1334            return Ok(());
1335        }
1336
1337        // Register waiter and re-check to avoid TOCTOU race:
1338        // dispatch_connected() could fire between the check above and notified() registration.
1339        let notified = self.connected_notifier.listen();
1340        if self.is_fully_ready() {
1341            return Ok(());
1342        }
1343
1344        rt_timeout(&*self.runtime, timeout, notified)
1345            .await
1346            .map_err(|_| ConnectError::Timeout {
1347                stage: ConnectStage::Ready,
1348                timeout,
1349            })
1350    }
1351
1352    pub fn is_connected(&self) -> bool {
1353        self.is_connected.load(Ordering::Acquire)
1354    }
1355
1356    /// Force the connected flag for tests that exercise connected-only operations.
1357    #[cfg(test)]
1358    pub(crate) fn set_connected_for_test(&self, connected: bool) {
1359        self.is_connected.store(connected, Ordering::Release);
1360    }
1361
1362    pub fn is_logged_in(&self) -> bool {
1363        self.is_logged_in.load(Ordering::Relaxed)
1364    }
1365
1366    /// Whether an IQ sent right now could actually be answered.
1367    ///
1368    /// Three separate conditions that were once asked as one, each of which
1369    /// alone admits a request that cannot come back: a socket, so there is
1370    /// somewhere to send it; authentication, because `<success>` both makes the
1371    /// server willing to answer and fixes the generation the answer is admitted
1372    /// under; and a supervision loop, because `send_and_wait_iq` refuses without
1373    /// one — a direct-connect client has no reader, so its every request would
1374    /// time out.
1375    ///
1376    /// Authentication is read as *the generation is final*, not as
1377    /// `is_logged_in` alone: that flag is set by the duplicate-`<success>` guard
1378    /// one step before the increment, and a caller that binds a scope in between
1379    /// binds a generation the next instruction retires.
1380    pub(crate) fn can_reach_server(&self) -> bool {
1381        self.is_connected()
1382            && self.is_logged_in()
1383            && self.authenticated_generation.load(Ordering::SeqCst)
1384                == self.connection_generation.load(Ordering::SeqCst)
1385            && self.is_running.load(Ordering::Relaxed)
1386            // A socket already marked for retirement will answer, and then the
1387            // answer will be thrown away. `reconnect_immediately` sets this
1388            // before its bounded flushes and closes the transport only after,
1389            // so the window is wide enough to admit a whole sync that the
1390            // replacement generation then retires — attempt charged, work lost.
1391            && !self.expected_disconnect.load(Ordering::Relaxed)
1392    }
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397    use super::*;
1398    use std::time::Duration;
1399
1400    #[tokio::test]
1401    async fn wait_for_socket_resolves_immediately_once_connected() {
1402        let client = crate::test_utils::create_test_client().await;
1403        client.set_connected_for_test(true);
1404
1405        client
1406            .wait_for_socket(Duration::from_millis(50))
1407            .await
1408            .expect("an already connected client must not wait");
1409    }
1410
1411    #[tokio::test]
1412    async fn wait_for_socket_times_out_at_the_socket_stage() {
1413        let client = crate::test_utils::create_test_client().await;
1414
1415        let timeout = Duration::from_millis(50);
1416        let error = client
1417            .wait_for_socket(timeout)
1418            .await
1419            .expect_err("a disconnected client must time out");
1420        assert!(matches!(
1421            error,
1422            ConnectError::Timeout {
1423                stage: ConnectStage::Socket,
1424                timeout: waited,
1425            } if waited == timeout
1426        ));
1427    }
1428
1429    #[tokio::test]
1430    async fn wait_for_connected_resolves_immediately_once_fully_ready() {
1431        let client = crate::test_utils::create_test_client().await;
1432        client.set_connected_for_test(true);
1433        client.is_logged_in.store(true, Ordering::Relaxed);
1434        client.is_ready.store(true, Ordering::Relaxed);
1435
1436        client
1437            .wait_for_connected(Duration::from_millis(50))
1438            .await
1439            .expect("a fully ready client must not wait");
1440    }
1441
1442    #[tokio::test]
1443    async fn wait_for_connected_times_out_at_the_ready_stage() {
1444        let client = crate::test_utils::create_test_client().await;
1445        // Connected but never logged in: readiness, not the socket, is missing.
1446        client.set_connected_for_test(true);
1447
1448        let timeout = Duration::from_millis(50);
1449        let error = client
1450            .wait_for_connected(timeout)
1451            .await
1452            .expect_err("a client that never logged in must time out");
1453        assert!(matches!(
1454            error,
1455            ConnectError::Timeout {
1456                stage: ConnectStage::Ready,
1457                timeout: waited,
1458            } if waited == timeout
1459        ));
1460    }
1461
1462    #[tokio::test]
1463    async fn logout_tears_down_an_offline_client_without_sending_the_iq() {
1464        let client = crate::test_utils::create_test_client().await;
1465
1466        tokio::time::timeout(Duration::from_secs(5), client.logout())
1467            .await
1468            .expect("logout must not block on an offline client");
1469
1470        assert!(!client.enable_auto_reconnect.load(Ordering::Relaxed));
1471        assert!(!client.is_connected());
1472    }
1473
1474    #[tokio::test]
1475    async fn logout_still_tears_down_when_the_deregistration_iq_fails() {
1476        let client = crate::test_utils::create_test_client().await;
1477        // Flagged connected with no socket behind it, so the IQ cannot be sent.
1478        client.set_connected_for_test(true);
1479
1480        tokio::time::timeout(Duration::from_secs(5), client.logout())
1481            .await
1482            .expect("a failed deregistration IQ must not block logout");
1483
1484        assert!(!client.enable_auto_reconnect.load(Ordering::Relaxed));
1485        assert!(!client.is_connected());
1486    }
1487
1488    #[tokio::test]
1489    async fn connect_rejects_an_already_connected_client() {
1490        let client = crate::test_utils::create_test_client().await;
1491        client.set_connected_for_test(true);
1492
1493        let error = client
1494            .connect()
1495            .await
1496            .expect_err("connecting twice must be refused");
1497        assert!(matches!(error, ConnectError::AlreadyConnected));
1498    }
1499
1500    /// Far enough up the Fibonacci sequence that the next backoff is the 900s
1501    /// cap. The cap is what makes these tests decisive: an uninterruptible
1502    /// wait parks the loop for 15 minutes, so a prompt return can only come
1503    /// from the wait racing the shutdown signal.
1504    const CAPPED_BACKOFF_ATTEMPTS: u32 = 40;
1505
1506    /// Starts `run()` and returns once the loop has actually reached its
1507    /// reconnect backoff. The attempt counter is bumped immediately before the
1508    /// wait, so observing the bump is proof the loop is parked there — no
1509    /// timed guess involved.
1510    async fn run_until_parked_in_backoff(client: &Arc<Client>) -> tokio::task::JoinHandle<()> {
1511        client
1512            .auto_reconnect_errors
1513            .store(CAPPED_BACKOFF_ATTEMPTS, Ordering::Relaxed);
1514
1515        let runner = client.clone();
1516        let run = tokio::spawn(async move { runner.run().await });
1517
1518        crate::test_utils::poll_until("the run loop to reach its reconnect backoff", || {
1519            client.auto_reconnect_errors.load(Ordering::Relaxed) > CAPPED_BACKOFF_ATTEMPTS
1520        })
1521        .await;
1522
1523        run
1524    }
1525
1526    /// A shutdown that lands *during* the reconnect backoff must be observed
1527    /// then, not when the sleep happens to expire. `disconnect()` returns
1528    /// promptly either way; what the consumer awaits is the run future, and
1529    /// with an uninterruptible wait that future outlives the shutdown by up to
1530    /// the 900s cap — long enough that a supervisor awaiting `Bot::run` reads
1531    /// it as a hang.
1532    #[tokio::test]
1533    async fn disconnect_interrupts_the_reconnect_backoff() {
1534        let client = crate::test_utils::create_test_client().await;
1535        let run = run_until_parked_in_backoff(&client).await;
1536
1537        client.disconnect().await;
1538
1539        tokio::time::timeout(Duration::from_secs(10), run)
1540            .await
1541            .expect("run() must return when disconnect() fires, not after the 900s backoff")
1542            .expect("the run task must not panic");
1543    }
1544
1545    /// `signal_shutdown_sync()` is the flag-only path taken by `Drop` impls on
1546    /// FFI wrappers, and its contract is the same: watchers exit on their next
1547    /// poll. The run loop is a watcher, so the parked backoff must wake here
1548    /// too — it is the path a `Drop` cannot follow up with an `await`.
1549    #[tokio::test]
1550    async fn signal_shutdown_sync_interrupts_the_reconnect_backoff() {
1551        let client = crate::test_utils::create_test_client().await;
1552        let run = run_until_parked_in_backoff(&client).await;
1553
1554        client.signal_shutdown_sync();
1555
1556        tokio::time::timeout(Duration::from_secs(10), run)
1557            .await
1558            .expect("run() must return when signal_shutdown_sync() fires")
1559            .expect("the run task must not panic");
1560    }
1561
1562    /// The counterpart guard: the *per-connection* shutdown fires on every
1563    /// disconnect the loop is supposed to reconnect from, so the backoff must
1564    /// not watch it. Subscribing to the wrong signal would pass the two tests
1565    /// above while silently turning every backoff into a no-op and hammering
1566    /// the server — this pins the normal path down.
1567    #[tokio::test]
1568    async fn a_connection_level_shutdown_does_not_cut_the_backoff_short() {
1569        let client = crate::test_utils::create_test_client().await;
1570        let run = run_until_parked_in_backoff(&client).await;
1571
1572        client.notify_connection_shutdown();
1573
1574        // Still parked: the loop must not have come back around to bump the
1575        // counter for another attempt.
1576        tokio::time::sleep(Duration::from_millis(200)).await;
1577        assert_eq!(
1578            client.auto_reconnect_errors.load(Ordering::Relaxed),
1579            CAPPED_BACKOFF_ATTEMPTS + 1,
1580            "a per-connection shutdown must not release the reconnect backoff"
1581        );
1582
1583        client.disconnect().await;
1584        tokio::time::timeout(Duration::from_secs(10), run)
1585            .await
1586            .expect("run() must still return on a terminal shutdown")
1587            .expect("the run task must not panic");
1588    }
1589}