Skip to main content

whatsapp_rust/client/
node_io.rs

1//! Inbound node I/O: read loop, frame decryption, node routing, acks and stream errors.
2
3use super::*;
4use crate::client::{PhashWaiter, ResponseWaiter};
5use wacore::net::DisconnectReason;
6
7/// Non-error exits of [`Client::read_messages_loop`] — `ServerRecycle` keeps the
8/// routine reconnect path out of `Err`, so severity consumers (logs, the span's
9/// `err(...)` capture, error trackers) only fire for genuine failures.
10pub(crate) enum ReadLoopExit {
11    /// Shutdown signal or an expected disconnect.
12    Expected,
13    /// Server ended the stream cleanly (the routine WhatsApp reconnect path).
14    ServerRecycle(DisconnectReason),
15}
16
17/// Genuine failures of [`Client::read_messages_loop`] — everything here is worth
18/// reporting loudly, unlike [`ReadLoopExit`].
19#[derive(Debug, thiserror::Error)]
20pub(crate) enum ReadLoopError {
21    #[error("cannot start message loop: {0}")]
22    NotStarted(&'static str),
23    #[error("transport disconnected: {0}")]
24    Transport(DisconnectReason),
25    #[error("transport event channel closed")]
26    ChannelClosed,
27}
28
29impl ReadLoopError {
30    /// The disconnect reason to surface on the `Disconnected` event; failures
31    /// that carry none map to `Unknown` (conservative, matches `is_clean_shutdown`).
32    pub(crate) fn into_reason(self) -> DisconnectReason {
33        match self {
34            Self::Transport(reason) => reason,
35            Self::NotStarted(_) | Self::ChannelClosed => DisconnectReason::Unknown,
36        }
37    }
38}
39
40/// Borrows instead of taking `ValueRef::to_jid`'s owned `Jid`: this runs once
41/// per inbound stanza.
42#[inline]
43fn from_jid_matches(
44    node: &wacore_binary::NodeRef<'_>,
45    pred: impl Fn(&wacore_binary::jid::JidRef<'_>) -> bool,
46) -> bool {
47    match node.get_attr("from") {
48        Some(wacore_binary::node::ValueRef::Jid(jid)) => pred(jid),
49        Some(wacore_binary::node::ValueRef::String(s)) => {
50            wacore_binary::jid::parse_jid_ref(s.as_ref()).is_some_and(|jid| pred(&jid))
51        }
52        None => false,
53    }
54}
55
56/// The wire shape the server uses for E2EE status updates, carrying the same
57/// payload as `<message from="status@broadcast">`.
58fn is_status_broadcast_stanza(node: &wacore_binary::NodeRef<'_>) -> bool {
59    from_jid_matches(node, |jid| jid.is_status_broadcast())
60}
61
62impl Client {
63    /// Read the current semaphore generation and Arc atomically under the mutex.
64    pub(crate) fn read_message_semaphore(&self) -> (u64, Arc<async_lock::Semaphore>) {
65        let guard = match self.message_processing_semaphore.lock() {
66            Ok(g) => g,
67            Err(poisoned) => poisoned.into_inner(),
68        };
69        (
70            self.message_semaphore_generation.load(Ordering::SeqCst),
71            guard.clone(),
72        )
73    }
74
75    /// Replace the message processing semaphore and bump the generation counter.
76    ///
77    /// Both operations happen under the same mutex hold so readers always see
78    /// a consistent (generation, Arc) pair. Must be called from a non-async
79    /// context or inside a scoped block (MutexGuard is !Send).
80    pub(crate) fn swap_message_semaphore(&self, permits: usize) {
81        let mut guard = match self.message_processing_semaphore.lock() {
82            Ok(g) => g,
83            Err(poisoned) => poisoned.into_inner(),
84        };
85        *guard = Arc::new(async_lock::Semaphore::new(permits));
86        self.message_semaphore_generation
87            .fetch_add(1, Ordering::SeqCst);
88    }
89
90    /// Acquire one permit from the CURRENT message-processing semaphore.
91    ///
92    /// The semaphore can be swapped while a waiter sleeps (offline online
93    /// transition); a permit from the stale semaphore would be a no-op guard,
94    /// so re-acquire until generation and semaphore agree. Shared by stanza
95    /// processing and the commit batcher: both must serialize on the same
96    /// instance for the drain-flush safety argument to hold.
97    pub(crate) async fn acquire_message_processing_permit(&self) -> async_lock::SemaphoreGuardArc {
98        // A holder stalling while the drain semaphore is at 1 permit freezes
99        // every lane and sender with no other signal — surface long waits
100        // instead of hanging silently. The slow path keeps ONE acquire future
101        // alive across warn ticks so the waiter never loses its queue position.
102        const PERMIT_WAIT_WARN: Duration = Duration::from_secs(10);
103        loop {
104            let (generation, semaphore) = self.read_message_semaphore();
105            let permit = match semaphore.try_acquire_arc() {
106                Some(permit) => permit,
107                None => {
108                    let acquire = semaphore.acquire_arc();
109                    futures::pin_mut!(acquire);
110                    let sleep = self.runtime.sleep(PERMIT_WAIT_WARN);
111                    futures::pin_mut!(sleep);
112                    match futures::future::select(&mut acquire, sleep).await {
113                        futures::future::Either::Left((permit, _)) => permit,
114                        futures::future::Either::Right(((), _)) => {
115                            warn!(
116                                "Message-processing permit not acquired after {PERMIT_WAIT_WARN:?} (drain_active={}); a stanza worker or drain flush may be stalled",
117                                self.inbound_commit_batch.is_active()
118                            );
119                            acquire.await
120                        }
121                    }
122                }
123            };
124            if generation == self.message_semaphore_generation.load(Ordering::SeqCst) {
125                return permit;
126            }
127            // Generation changed while waiting: drop the stale permit and
128            // retry with the new semaphore.
129            drop(permit);
130        }
131    }
132
133    // err(...) stays at the default ERROR on purpose: with the routine server
134    // recycle moved to Ok(ServerRecycle), an Err from this loop now always means
135    // something genuinely wrong — so the automatic capture only ever reports
136    // real failures, not WhatsApp's periodic stream recycling.
137    #[cfg_attr(
138        feature = "tracing",
139        tracing::instrument(
140            name = "wa.conn.read_loop",
141            level = "debug",
142            skip_all,
143            fields(lid = tracing::field::Empty, pn = tracing::field::Empty),
144            err(Debug)
145        )
146    )]
147    pub(crate) async fn read_messages_loop(
148        self: &Arc<Self>,
149    ) -> Result<ReadLoopExit, ReadLoopError> {
150        #[cfg(feature = "tracing")]
151        self.record_identity_on_span(&tracing::Span::current());
152
153        debug!("Starting message processing loop...");
154
155        let mut rx_guard = self.transport_events.lock().await;
156        let transport_events = rx_guard
157            .take()
158            .ok_or(ReadLoopError::NotStarted("not connected"))?;
159        drop(rx_guard);
160
161        // The noise socket is installed before this loop starts (connect_internal)
162        // and replaced only across reconnects, which tear this loop down first —
163        // so resolve it once instead of locking the mutex per frame.
164        let noise_socket = self
165            .get_noise_socket()
166            .await
167            .map_err(|_| ReadLoopError::NotStarted("no noise socket"))?;
168
169        // Frame decoder to parse incoming data
170        let mut frame_decoder = wacore::framing::FrameDecoder::new();
171        let shutdown = self.connection_shutdown_signal();
172        // Subscribe once: a fresh wait_for_shutdown() inside the select allocated an
173        // event_listener on every frame. The signal is one-shot, so a single pinned
174        // listener still catches an in-loop firing.
175        let shutdown_fut = wacore::runtime::wait_for_shutdown(&shutdown).fuse();
176        futures::pin_mut!(shutdown_fut);
177
178        loop {
179            futures::select_biased! {
180                    _ = shutdown_fut => {
181                        debug!("Shutdown signaled in message loop. Exiting message loop.");
182                        return Ok(ReadLoopExit::Expected);
183                    },
184                    event_result = transport_events.recv().fuse() => {
185                        match event_result {
186                            Ok(crate::transport::TransportEvent::DataReceived(data)) => {
187                                // Update dead-socket timer (WA Web: deadSocketTimer reset)
188                                self.stats.mark_recv_activity();
189                                let wire_bytes = data.len();
190
191                                // Dropped before any await below: the payload is
192                                // a view into the websocket's shared read buffer,
193                                // so holding it while a node is processed keeps
194                                // that allocation alive alongside the decoder's
195                                // copy of the same bytes.
196                                frame_decoder.feed(&data);
197                                drop(data);
198
199                                // Process all complete frames.
200                                // Frame decryption must be sequential (noise protocol counter),
201                                // but we spawn node processing concurrently after decryption.
202                                let mut frames_in_batch: u32 = 0;
203
204                                while let Some(encrypted_frame) = frame_decoder.decode_frame() {
205                                    // Decrypt the frame synchronously (required for noise counter ordering)
206                                    if let Some(node) = self.decrypt_frame(&noise_socket, encrypted_frame) {
207                                        if self.processes_inline(node.get()) {
208                                            self.process_decrypted_node(node).await;
209                                        } else {
210                                            let client = self.clone();
211                                            self.runtime.spawn_detached(Box::pin(async move {
212                                                client.process_decrypted_node(node).await;
213                                            }));
214                                        }
215                                    }
216
217                                    // Check if we should exit after processing (e.g., after 515 stream error)
218                                    if self.expected_disconnect.load(Ordering::Relaxed) {
219                                        debug!("Expected disconnect signaled during frame processing. Exiting message loop.");
220                                        // The batch (this frame included — its counter
221                                        // increment is below) must not vanish from the
222                                        // wire counters on this exit path.
223                                        self.stats.record_recv_batch(wire_bytes, frames_in_batch + 1);
224                                        return Ok(ReadLoopExit::Expected);
225                                    }
226
227                                    // Cooperative yield — frequency and behavior are runtime-defined.
228                                    frames_in_batch += 1;
229                                    if frames_in_batch.is_multiple_of(self.runtime.yield_frequency())
230                                        && let Some(yield_fut) = self.runtime.yield_now()
231                                    {
232                                        yield_fut.await;
233                                    }
234                                }
235
236                                // Count the batch and refresh the timestamp after
237                                // processing so the keepalive loop sees the batch
238                                // completion time, not just the arrival time. Prevents
239                                // stale reads when a large batch (e.g. offline sync)
240                                // takes seconds to drain.
241                                self.stats.record_recv_batch(wire_bytes, frames_in_batch);
242                            },
243                            Ok(crate::transport::TransportEvent::Disconnected(reason)) => {
244                                if !self.expected_disconnect.load(Ordering::Relaxed) {
245                                    // A routine server recycle (clean EOF / normal close) is not
246                                    // an error — quiet log, Ok exit. A real transport error stays
247                                    // WARN + Err so it's never hidden behind reconnect noise.
248                                    if reason.is_clean_shutdown() {
249                                        info!("Connection closed by server ({reason}); reconnecting.");
250                                        return Ok(ReadLoopExit::ServerRecycle(reason));
251                                    }
252                                    warn!("Transport disconnected: {reason}; reconnecting.");
253                                    return Err(ReadLoopError::Transport(reason));
254                                } else {
255                                    debug!("Transport disconnected as expected: {reason}");
256                                    return Ok(ReadLoopExit::Expected);
257                                }
258                            }
259                            // Event channel closed (no DisconnectReason available) — the
260                            // transport task ended without reporting why. No reason means we
261                            // can't prove it was a clean recycle, so it stays loud (WARN),
262                            // matching the conservative `Unknown` rule in is_clean_shutdown.
263                            Err(_) => {
264                                if !self.expected_disconnect.load(Ordering::Relaxed) {
265                                    warn!("Transport event channel closed; reconnecting.");
266                                    return Err(ReadLoopError::ChannelClosed);
267                                } else {
268                                    return Ok(ReadLoopExit::Expected);
269                                }
270                            }
271                            Ok(crate::transport::TransportEvent::Connected) => {
272                                // Already handled during handshake, but could be useful for logging
273                                debug!("Transport connected event received");
274                            }
275                    }
276                }
277            }
278        }
279    }
280
281    /// Decrypt a frame and return the parsed node as a zero-copy OwnedNodeRef.
282    /// This must be called sequentially due to noise protocol counter requirements.
283    #[cfg_attr(
284        feature = "tracing",
285        tracing::instrument(name = "wa.conn.decrypt_frame", level = "trace", skip_all)
286    )]
287    pub(crate) fn decrypt_frame(
288        &self,
289        noise_socket: &NoiseSocket,
290        encrypted_frame: bytes::BytesMut,
291    ) -> Option<wacore_binary::OwnedNodeRef> {
292        let decrypted_payload = match noise_socket.decrypt_frame(encrypted_frame) {
293            Ok(p) => p,
294            Err(e) => {
295                log::error!("Failed to decrypt frame: {e}");
296                return None;
297            }
298        };
299
300        let buffer = match wacore_binary::util::unpack_bytes(decrypted_payload) {
301            Ok(data) => data,
302            Err(e) => {
303                log::warn!(target: "Client/Recv", "Failed to decompress frame: {e}");
304                return None;
305            }
306        };
307
308        match wacore_binary::OwnedNodeRef::new(buffer) {
309            Ok(owned) => Some(owned),
310            Err(e) => {
311                log::warn!(target: "Client/Recv", "Failed to unmarshal node: {e}");
312                None
313            }
314        }
315    }
316
317    /// Process an already-decrypted node.
318    /// This can be spawned concurrently since it doesn't depend on noise protocol state.
319    /// The node is wrapped in Arc to avoid cloning when passing through handlers.
320    pub(crate) async fn process_decrypted_node(
321        self: &Arc<Self>,
322        node: wacore_binary::OwnedNodeRef,
323    ) {
324        // ACKs need shared ownership only for opt-in raw/node observers. The
325        // usual response-waiter path borrows the node and can skip the Arc.
326        if node.tag() == "ack"
327            && !self.raw_node_forwarding_enabled()
328            && self.node_waiter_count.load(Ordering::Acquire) == 0
329            && !self.offline_sync_metrics.active.load(Ordering::Acquire)
330        {
331            use wacore::xml::DisplayableNodeRef;
332            debug!(target: "Client/Recv", "{}", DisplayableNodeRef(node.get()));
333            self.handle_ack_response_owned(node);
334            return;
335        }
336
337        // Wrap in Arc once - all handlers will share this same allocation
338        let node_arc = Arc::new(node);
339        self.process_node(node_arc).await;
340    }
341
342    /// Process a node wrapped in Arc. Handlers receive the Arc and can share/store it cheaply.
343    #[cfg_attr(
344        feature = "tracing",
345        tracing::instrument(name = "wa.conn.node", level = "trace", skip_all, fields(tag = %node.get().tag.as_ref()))
346    )]
347    pub(crate) async fn process_node(self: &Arc<Self>, node: Arc<wacore_binary::OwnedNodeRef>) {
348        use wacore::xml::DisplayableNodeRef;
349        let nr = node.get();
350
351        // --- Offline Sync Tracking ---
352        if nr.tag.as_ref() == "ib" {
353            // Check for offline_preview child to get expected count
354            if let Some(preview) = nr.get_optional_child("offline_preview") {
355                let count: usize = preview
356                    .get_attr("count")
357                    .map(|v| v.as_str())
358                    .and_then(|s| s.parse().ok())
359                    .unwrap_or(0);
360
361                if count == 0 {
362                    self.offline_sync_metrics
363                        .active
364                        .store(false, Ordering::Release);
365                    debug!(target: "Client/OfflineSync", "Sync COMPLETED: 0 items.");
366                } else {
367                    // Use stronger memory ordering for state transitions
368                    self.offline_sync_metrics
369                        .total_messages
370                        .store(count, Ordering::Release);
371                    self.offline_sync_metrics
372                        .processed_messages
373                        .store(0, Ordering::Release);
374                    self.offline_sync_metrics
375                        .active
376                        .store(true, Ordering::Release);
377                    match self.offline_sync_metrics.start_time.lock() {
378                        Ok(mut guard) => *guard = Some(wacore::time::Instant::now()),
379                        Err(poison) => *poison.into_inner() = Some(wacore::time::Instant::now()),
380                    }
381                    debug!(target: "Client/OfflineSync", "Sync STARTED: Expecting {} items.", count);
382                }
383            } else if self.offline_sync_metrics.active.load(Ordering::Acquire)
384                && nr.get_optional_child("offline").is_some()
385            {
386                // Handle end marker: <ib><offline count="N"/> signals sync completion
387                // Only <ib> with an <offline> child is a real end marker.
388                // Other <ib> children (thread_metadata, edge_routing, dirty) are NOT end markers.
389                let processed = self
390                    .offline_sync_metrics
391                    .processed_messages
392                    .load(Ordering::Acquire);
393                let elapsed = match self.offline_sync_metrics.start_time.lock() {
394                    Ok(guard) => guard.map(|t| t.elapsed()).unwrap_or_default(),
395                    Err(poison) => poison.into_inner().map(|t| t.elapsed()).unwrap_or_default(),
396                };
397                debug!(target: "Client/OfflineSync", "Sync COMPLETED: End marker received. Processed {} items in {:.2?}.", processed, elapsed);
398                self.offline_sync_metrics
399                    .active
400                    .store(false, Ordering::Release);
401            }
402        }
403
404        // Track progress if active
405        if self.offline_sync_metrics.active.load(Ordering::Acquire) {
406            // Check for 'offline' attribute on relevant stanzas
407            if nr.get_attr("offline").is_some() {
408                let processed = self
409                    .offline_sync_metrics
410                    .processed_messages
411                    .fetch_add(1, Ordering::Release)
412                    + 1;
413                let total = self
414                    .offline_sync_metrics
415                    .total_messages
416                    .load(Ordering::Acquire);
417
418                if processed.is_multiple_of(50) || processed == total {
419                    trace!(target: "Client/OfflineSync", "Sync Progress: {}/{}", processed, total);
420                }
421
422                // Drive WA Web pull-batch loop (non-adaptive `$13`): when
423                // remaining drops to <=C and no batch request is in flight,
424                // schedule the next one.
425                let pending = total.saturating_sub(processed);
426                offline_resume::on_offline_stanza_arrived(self, pending);
427
428                if processed >= total {
429                    let elapsed = match self.offline_sync_metrics.start_time.lock() {
430                        Ok(guard) => guard.map(|t| t.elapsed()).unwrap_or_default(),
431                        Err(poison) => poison.into_inner().map(|t| t.elapsed()).unwrap_or_default(),
432                    };
433                    debug!(target: "Client/OfflineSync", "Sync COMPLETED: Processed {} items in {:.2?}.", processed, elapsed);
434                    self.offline_sync_metrics
435                        .active
436                        .store(false, Ordering::Release);
437                }
438            }
439        }
440        // --- End Tracking ---
441
442        if nr.tag.as_ref() == "iq"
443            && let Some(sync_node) = nr.get_optional_child("sync")
444            && let Some(collection_node) = sync_node.get_optional_child("collection")
445        {
446            let name = collection_node.attrs().optional_string("name");
447            let name = name.as_deref().unwrap_or("<unknown>");
448            debug!(target: "Client/Recv", "Received app state sync response for '{name}' (hiding content).");
449        } else {
450            debug!(target: "Client/Recv","{}", DisplayableNodeRef(nr));
451        }
452
453        // Prepare deferred ACK cancellation flag (sent after dispatch unless cancelled)
454        let mut cancelled = false;
455
456        // Emit raw node before any early returns so all decoded stanzas
457        // (including IQ responses and xmlstreamend) reach external observers
458        if self.raw_node_forwarding_enabled() {
459            self.core
460                .event_bus
461                .dispatch(Event::RawNode(Arc::clone(&node)));
462        }
463
464        if nr.tag.as_ref() == "xmlstreamend" {
465            if self.expected_disconnect.load(Ordering::Relaxed) {
466                debug!("Received <xmlstreamend/>, expected disconnect.");
467            } else {
468                // A bare <xmlstreamend/> is the server cleanly ending the stream
469                // (a recycle). We reconnect, so this is routine, not an error.
470                info!("Received <xmlstreamend/> (server stream end); reconnecting.");
471            }
472            self.notify_connection_shutdown();
473            return;
474        }
475
476        // Check generic node waiters (zero-cost when none registered)
477        if self.node_waiter_count.load(Ordering::Acquire) > 0 {
478            self.resolve_node_waiters(&node);
479        }
480
481        if nr.tag.as_ref() == "iq"
482            && let Some(id) = nr.get_attr("id").map(|v| v.as_str())
483            && let Some(waiter) = self.response_waiters_guard().remove(id.as_ref())
484        {
485            // An IQ id never carries a phash waiter (those are registered under
486            // message ids), so a mismatch here means the id space collided.
487            match waiter {
488                ResponseWaiter::Iq(sender) => {
489                    #[cfg(feature = "voip-runtime")]
490                    self.bind_pending_call_link_join_ack(nr);
491                    if sender.send(Arc::clone(&node)).is_err() {
492                        warn!(target: "Client/IQ", "Failed to send IQ response to waiter. Receiver was likely dropped.");
493                    }
494                }
495                ResponseWaiter::Phash(_) => {
496                    warn!(target: "Client/IQ", "IQ id collided with a pending phash waiter; dropping the phash check");
497                }
498            }
499            return;
500        }
501
502        // Most messages do not need a transport <ack> from this generic gate.
503        // Move those nodes into their chat lane instead of retaining a second
504        // Arc in this dispatcher while decryption starts. Besides removing an
505        // atomic refcount pair, this lets a large uniquely-owned pkmsg donate
506        // its receive buffer to authenticated in-place decryption. Newsletter
507        // and status messages keep the extra owner until their deferred ack is
508        // encoded, preserving the existing acknowledgement semantics.
509        let should_ack = self.should_ack(nr);
510        let deferred_ack_node = should_ack.then(|| Arc::clone(&node));
511
512        // Bypass async_trait's boxed future for the hot built-in handlers while
513        // retaining router registration for direct router callers.
514        match nr.tag.as_ref() {
515            "ack" => {
516                self.handle_ack_response_arc(&node);
517            }
518            "receipt" => {
519                self.handle_receipt_inline(node);
520            }
521            "message" => {
522                crate::handlers::message::MessageHandler::handle_inline(
523                    self.clone(),
524                    node,
525                    &mut cancelled,
526                )
527                .await;
528            }
529            // Differs from a `<message>` only in tag, so WA Web retags it and
530            // runs the same pipeline.
531            "status" if is_status_broadcast_stanza(nr) => {
532                crate::handlers::message::MessageHandler::handle_inline(
533                    self.clone(),
534                    node,
535                    &mut cancelled,
536                )
537                .await;
538            }
539            _ => {
540                let handled = self
541                    .stanza_router
542                    .dispatch(self.clone(), Arc::clone(&node), &mut cancelled)
543                    .await;
544                if !handled {
545                    warn!(
546                        "Received unknown top-level node: {}",
547                        DisplayableNodeRef(node.get())
548                    );
549                    // The nack is this stanza's acknowledgement.
550                    cancelled |= self.nack_unrecognized_stanza(node.get());
551                }
552            }
553        }
554
555        if !cancelled && let Some(node) = deferred_ack_node {
556            self.maybe_deferred_ack(node).await;
557        }
558    }
559
560    /// Whether a decrypted node must stay on the read loop instead of moving to
561    /// a spawned task. success/failure/stream:error carry connection state the
562    /// rest depends on, and `ib` sets up offline-sync tracking before the batch
563    /// arrives. message and status@broadcast only enqueue here, and a spawned
564    /// enqueue could put a group message ahead of the pkmsg that establishes its
565    /// session. Acks and receipts qualify only while nothing observes them.
566    pub(crate) fn processes_inline(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
567        match node.tag.as_ref() {
568            "success" | "failure" | "stream:error" | "message" | "ib" => true,
569            "status" => is_status_broadcast_stanza(node),
570            "receipt" => {
571                !self.synchronous_ack
572                    && !self.raw_node_forwarding_enabled()
573                    && !self
574                        .core
575                        .event_bus
576                        .has_handler_for(wacore::types::events::EventKind::Receipt)
577            }
578            "ack" => {
579                !self.raw_node_forwarding_enabled()
580                    && !self
581                        .core
582                        .event_bus
583                        .has_handler_for(wacore::types::events::EventKind::ServerAck)
584            }
585            _ => false,
586        }
587    }
588
589    /// Answering nothing leaves the stanza in the offline queue forever, which
590    /// is how an unhandled `<status>` kept recycling the stream. Returns whether
591    /// a nack was queued; one without `id`/`from` would have nothing to address.
592    fn nack_unrecognized_stanza(self: &Arc<Self>, node: &wacore_binary::NodeRef<'_>) -> bool {
593        if node.get_attr("id").is_none() || node.get_attr("from").is_none() {
594            return false;
595        }
596        self.spawn_stanza_nack(
597            node,
598            wacore::protocol::nack::NackReason::UnrecognizedStanza,
599            None,
600        );
601        true
602    }
603
604    /// Per WA Web (`Handle/MsgSendReceipt.js`), only newsletter `<message>`
605    /// gets `<ack class="message">` on the success path; DM/group use
606    /// `<receipt>`. Failure paths (retry/backfill/nack) emit `<ack>` from
607    /// their dedicated handlers, not via this gate.
608    ///
609    /// status@broadcast is included as a fallback: drop paths in
610    /// `process_group_enc_batch` (expired status, missing sender key, generic
611    /// decrypt error) intentionally skip the delivery receipt to avoid
612    /// inflating the server-side offline counter for messages we'll never
613    /// process. Without the transport `<ack>` from this gate, the server
614    /// would redeliver indefinitely. WA Web emits `<receipt context="status">`
615    /// in the success path on top of this; the duplicate is tolerated.
616    pub(crate) fn should_ack(&self, node: &wacore_binary::NodeRef<'_>) -> bool {
617        let tag = node.tag.as_ref();
618        if node.get_attr("id").is_none() {
619            return false;
620        }
621        if node.get_attr("from").is_none() {
622            return false;
623        }
624        match tag {
625            "receipt" | "notification" | "call" => true,
626            "message" => from_jid_matches(node, |j| j.is_newsletter() || j.is_status_broadcast()),
627            "status" => is_status_broadcast_stanza(node),
628            _ => false,
629        }
630    }
631
632    /// Possibly send a deferred ack: either immediately or through the ack
633    /// worker. Handlers can cancel by setting `cancelled` to true.
634    /// Uses Arc<OwnedNodeRef> so queueing does not clone the node.
635    ///
636    /// The deferred path feeds one persistent worker rather than spawning a
637    /// task per ack, which also makes acks leave in arrival order.
638    async fn maybe_deferred_ack(self: &Arc<Self>, node: Arc<wacore_binary::OwnedNodeRef>) {
639        if self.synchronous_ack {
640            if let Err(e) = self.send_ack_for(node.get()).await
641                && !e.is_transport_unavailable()
642            {
643                warn!("Failed to send ack: {e:?}");
644            }
645            return;
646        }
647        // A closed scope means disconnect is already running; the spawned task
648        // it replaces would have failed on an unavailable transport anyway.
649        let Some(guard) = self.outbound_flush.try_track() else {
650            return;
651        };
652        let tx = self
653            .transport_ack_queue
654            .get_or_init(|| self.start_transport_ack_worker());
655        // Only fails once the worker is gone (client teardown).
656        let _ = tx.try_send((node, guard));
657    }
658
659    /// Whether queued outbound work should be dropped rather than sent.
660    ///
661    /// This is the gate [`Self::send_ack_for`] applies before every ack, hoisted
662    /// so the burst path applies it too: during an expected teardown (an
663    /// intentional disconnect, or a 515) queued acks are deliberately dropped
664    /// rather than raced against the disconnect, and sending them anyway would
665    /// also hold the outbound flush open until its timeout.
666    pub(crate) fn outbound_teardown_in_progress(&self) -> bool {
667        self.expected_disconnect.load(Ordering::Relaxed) || !self.is_connected()
668    }
669
670    /// How many queued acks one burst may take.
671    ///
672    /// Measured, not guessed: the send-job channel holds 8, so a larger burst
673    /// fills it and makes unrelated producers (a reply, a receipt) wait for a
674    /// slot. At 16 the harness showed 29% fewer writes but 3.7% worse pong
675    /// latency (paired t = 2.8); at 4 the write saving is ~16% and latency is
676    /// no worse than main. Raising the channel instead recovers the latency but
677    /// gives back most of the coalescing, because a sender that never waits
678    /// consumes jobs one at a time.
679    const MAX_ACK_BURST: usize = 4;
680
681    /// Worker shared by every deferred ack. Holds a `Weak`, so a dropped
682    /// `Client` closes the channel and ends the task instead of keeping the
683    /// client alive.
684    fn start_transport_ack_worker(
685        self: &Arc<Self>,
686    ) -> async_channel::Sender<(
687        Arc<wacore_binary::OwnedNodeRef>,
688        crate::flush_scope::FlushGuard,
689    )> {
690        let (tx, rx) = async_channel::unbounded::<(
691            Arc<wacore_binary::OwnedNodeRef>,
692            crate::flush_scope::FlushGuard,
693        )>();
694        let client = Arc::downgrade(self);
695        self.runtime.spawn_detached(Box::pin(async move {
696            // Reuse the bounded control buffers for the worker's lifetime.
697            // Encoded payload allocations still move into `Bytes`; only
698            // the outer storage stays here.
699            let mut batch = Vec::with_capacity(Self::MAX_ACK_BURST);
700            let mut frames = Vec::with_capacity(Self::MAX_ACK_BURST);
701            let mut guards = Vec::with_capacity(Self::MAX_ACK_BURST);
702            let mut results = Vec::with_capacity(Self::MAX_ACK_BURST);
703            while let Ok(first) = rx.recv().await {
704                let Some(client) = client.upgrade() else {
705                    break;
706                };
707
708                // Take everything already waiting, not just the one job that
709                // woke us. Awaiting each ack before reading the next is what
710                // kept the noise sender from ever seeing two frames at once,
711                // so its batching only fired when some *other* producer
712                // happened to interleave. `try_recv` only: this never waits
713                // for work that has not arrived.
714                batch.push(first);
715                while batch.len() < Self::MAX_ACK_BURST
716                    && let Ok(next) = rx.try_recv()
717                {
718                    batch.push(next);
719                }
720
721                // The queue is still drained, exactly as the
722                // one-at-a-time worker did; only the send is skipped.
723                if client.outbound_teardown_in_progress() {
724                    batch.clear();
725                    continue;
726                }
727
728                // Encoding is synchronous, so the whole burst is marshalled
729                // before anything is sent and arrival order survives.
730                for (node, guard) in batch.drain(..) {
731                    match client.encode_ack_from_snapshot(
732                        node.get(),
733                        AckParticipantPolicy::OmitReceiptDestinationDuplicate,
734                    ) {
735                        Ok(buf) => {
736                            frames.push(buf);
737                            guards.push(guard);
738                        }
739                        // Matches the single-ack path: log and drop this one
740                        // rather than failing the rest of the burst.
741                        Err(e) => warn!("Failed to encode ack: {e}"),
742                    }
743                }
744                if frames.is_empty() {
745                    continue;
746                }
747
748                // The per-ack `wa.conn.ack` span lived in `send_ack_for`,
749                // which this path no longer calls; a burst reports itself
750                // once, with its size, rather than N times. The result
751                // inspection is inside the instrumented future, not after
752                // it: a failure has to be recorded while the span is open,
753                // the way `send_ack_for`'s `err(Debug)` used to. And
754                // `instrument` rather than `entered()`, because an
755                // EnteredSpan is not Send and cannot cross the await.
756                let frame_count = frames.len();
757                let send_and_report = async {
758                    match client.send_raw_bytes_burst(&mut frames, &mut results).await {
759                        Ok(()) => {
760                            for result in results.drain(..) {
761                                if let Err(e) = result
762                                    && !e.is_transport_unavailable()
763                                {
764                                    warn!("Failed to send ack: {e:?}");
765                                }
766                            }
767                        }
768                        Err(e) => {
769                            if !matches!(e, ClientError::NotConnected) {
770                                warn!("Failed to send ack burst: {e:?}");
771                            }
772                        }
773                    }
774                };
775                #[cfg(feature = "tracing")]
776                {
777                    use tracing::Instrument;
778                    send_and_report
779                        .instrument(tracing::trace_span!(
780                            "wa.conn.ack_burst",
781                            frames = frame_count
782                        ))
783                        .await;
784                }
785                #[cfg(not(feature = "tracing"))]
786                {
787                    let _ = frame_count;
788                    send_and_report.await;
789                }
790                debug_assert!(
791                    frames.is_empty(),
792                    "send_raw_bytes_burst must always drain its input"
793                );
794                guards.clear();
795            }
796        }));
797        tx
798    }
799
800    #[inline]
801    fn encode_ack_from_snapshot(
802        &self,
803        node: &wacore_binary::NodeRef<'_>,
804        participant_policy: AckParticipantPolicy,
805    ) -> Result<Vec<u8>, crate::features::StanzaResponseError> {
806        let device = self.persistence_manager.get_device_snapshot();
807        let encoded = encode_ack_bytes(node, device.pn.as_ref(), participant_policy);
808        drop(device);
809        encoded
810    }
811
812    /// Build and send an <ack/> node corresponding to the given stanza.
813    #[cfg_attr(
814        feature = "tracing",
815        tracing::instrument(name = "wa.conn.ack", level = "trace", skip_all, err(Debug))
816    )]
817    pub(crate) async fn send_ack_for(
818        &self,
819        node: &wacore_binary::NodeRef<'_>,
820    ) -> Result<(), ClientError> {
821        if self.expected_disconnect.load(Ordering::Relaxed) {
822            return Ok(());
823        }
824        if !self.is_connected() {
825            return Err(ClientError::NotConnected);
826        }
827        let buf = match self
828            .encode_ack_from_snapshot(node, AckParticipantPolicy::OmitReceiptDestinationDuplicate)
829        {
830            Ok(buf) => buf,
831            Err(e) => {
832                log::warn!("Failed to encode ack: {e}");
833                return Ok(());
834            }
835        };
836        self.send_raw_bytes(buf).await
837    }
838
839    /// Confirm a received stanza using its original borrowed node.
840    ///
841    /// Unlike the tolerant automatic receive path, malformed input is returned
842    /// to the caller and no successful outcome is reported unless the response
843    /// reaches the transport.
844    #[cfg_attr(
845        feature = "tracing",
846        tracing::instrument(name = "wa.conn.ack_explicit", level = "debug", skip_all, err(Debug))
847    )]
848    pub async fn acknowledge_stanza(
849        &self,
850        stanza: &wacore_binary::NodeRef<'_>,
851    ) -> Result<(), crate::features::StanzaResponseError> {
852        let bytes = self.encode_ack_from_snapshot(stanza, AckParticipantPolicy::Preserve)?;
853        self.send_raw_bytes(bytes).await?;
854        Ok(())
855    }
856
857    /// Send a transport ack so the server stops replaying a stanza from the
858    /// offline queue. Awaitable so callers can order it after a retry receipt
859    /// in a single flushed task.
860    pub(crate) async fn send_transport_ack(&self, info: &crate::types::message::MessageInfo) {
861        let source = message_ack_source_node(info);
862        let encoded =
863            self.encode_ack_from_snapshot(&source.as_node_ref(), AckParticipantPolicy::Preserve);
864        match encoded {
865            Ok(buf) => {
866                if let Err(e) = self.send_raw_bytes(buf).await
867                    && !e.is_transport_unavailable()
868                {
869                    log::warn!("Failed to send transport ack for undecryptable message: {e:?}");
870                }
871            }
872            Err(e) => log::warn!("Failed to encode transport ack: {e}"),
873        }
874    }
875
876    /// Spawn [`Self::send_transport_ack`], tracked via `outbound_flush` so
877    /// `disconnect()` flushes it (issue #571), same as delivery receipts.
878    pub(crate) fn spawn_message_ack(
879        self: &Arc<Self>,
880        info: &Arc<crate::types::message::MessageInfo>,
881    ) {
882        let client = Arc::clone(self);
883        let info = Arc::clone(info);
884        self.outbound_flush.spawn(&*self.runtime, async move {
885            client.send_transport_ack(&info).await;
886        });
887    }
888
889    /// Tracked ack encoded from the original node. Use when the stanza carries
890    /// `recipient` (LID-routed/hosted-companion/peer) since `MessageInfo`
891    /// drops it on non-self branches and the server needs it for routing.
892    pub(crate) async fn spawn_node_transport_ack(
893        self: &Arc<Self>,
894        node: &wacore_binary::NodeRef<'_>,
895    ) {
896        let buf = match self.encode_ack_from_snapshot(node, AckParticipantPolicy::Preserve) {
897            Ok(buf) => buf,
898            Err(e) => {
899                log::warn!("Failed to encode node transport ack: {e}");
900                return;
901            }
902        };
903        let client = Arc::clone(self);
904        self.outbound_flush.spawn(&*self.runtime, async move {
905            if let Err(e) = client.send_raw_bytes(buf).await
906                && !e.is_transport_unavailable()
907            {
908                log::warn!("Failed to send node transport ack: {e:?}");
909            }
910        });
911    }
912
913    #[cfg_attr(
914        feature = "tracing",
915        tracing::instrument(name = "wa.conn.success", level = "debug", skip_all)
916    )]
917    pub(crate) async fn handle_success(self: &Arc<Self>, node: &wacore_binary::NodeRef<'_>) {
918        #[cfg(feature = "client-lifecycle")]
919        let login_transition = self
920            .login_transition
921            .lock()
922            .unwrap_or_else(|poisoned| poisoned.into_inner());
923        // Skip processing if an expected disconnect is pending (e.g., 515 received).
924        // This prevents race conditions where a spawned success handler runs after
925        // cleanup_connection_state has already reset is_logged_in.
926        if self.expected_disconnect.load(Ordering::Relaxed) {
927            debug!("Ignoring <success> stanza: expected disconnect pending");
928            return;
929        }
930
931        // Guard against multiple <success> stanzas (WhatsApp may send more than one during
932        // routing/reconnection). Only process the first one per connection.
933        if self.is_logged_in.swap(true, Ordering::SeqCst) {
934            debug!("Ignoring duplicate <success> stanza (already logged in)");
935            return;
936        }
937
938        // Increment connection generation to invalidate any stale post-login tasks
939        // from previous connections (e.g., during 515 reconnect cycles).
940        let current_generation = self.connection_generation.fetch_add(1, Ordering::SeqCst) + 1;
941        #[cfg(feature = "client-lifecycle")]
942        if let Some(lifecycle) = &self.lifecycle {
943            let opened = lifecycle.begin_scope_if_current(current_generation, || {
944                self.connection_generation.load(Ordering::SeqCst) == current_generation
945                    && !self.expected_disconnect.load(Ordering::Acquire)
946            });
947            if !opened {
948                self.is_logged_in.store(false, Ordering::SeqCst);
949                debug!("Ignoring <success> stanza retired during lifecycle publication");
950                return;
951            }
952        }
953        #[cfg(feature = "client-lifecycle")]
954        drop(login_transition);
955
956        info!(
957            "Successfully authenticated with WhatsApp servers! (gen={})",
958            current_generation
959        );
960        // The generation this connection will be admitted under is now final.
961        // Published here, after the increment, and not by `is_logged_in` above —
962        // that one is the duplicate-`<success>` guard and has to be set first,
963        // which leaves a window where the client looks authenticated on a
964        // generation that is about to change. Work binding a scope in that
965        // window had every attempt rejected as retired.
966        self.authenticated_generation
967            .store(current_generation, Ordering::SeqCst);
968        // Only now is there something worth waking for: released here and not at
969        // `socket_ready_notifier`, which fires before login, so an IQ sent in
970        // that gap is answered by nobody.
971        self.notify_session_state();
972        // Record the auth time but DON'T reset the backoff counter yet: WA Web
973        // resets only after the connection has been stable for ~30s
974        // (`resetDelay`). Resetting on <success> alone lets a server that
975        // authenticates then immediately drops keep us in a 1s reconnect storm.
976        // The run loop does the stability-gated reset on the next disconnect.
977        self.connected_at_ms
978            .store(wacore::time::now_millis(), Ordering::Relaxed);
979        // Fresh connection starts un-penalized (see backoff_reset_suppressed).
980        self.backoff_reset_suppressed
981            .store(false, Ordering::Relaxed);
982
983        self.update_server_time_offset(node);
984
985        // Extract LID from the node before spawning (node isn't Send).
986        let lid_from_server = match node.get_attr("lid") {
987            Some(lid_value) => match lid_value.to_jid() {
988                Some(lid) => Some(lid),
989                None => {
990                    warn!("Failed to parse LID from success stanza: {lid_value}");
991                    None
992                }
993            },
994            None => {
995                warn!("LID not found in <success> stanza. Group messaging may fail.");
996                None
997            }
998        };
999
1000        let client_clone = self.clone();
1001        let task_generation = current_generation;
1002        self.runtime.spawn_detached(Box::pin(async move {
1003            // Update LID if changed (moved here to avoid blocking the read loop
1004            // on Device snapshot + write lock).
1005            if let Some(lid) = lid_from_server {
1006                let device_snapshot =
1007                    client_clone.persistence_manager.get_device_snapshot();
1008                if device_snapshot.lid.as_ref() != Some(&lid) {
1009                    debug!("Updating LID from server to '{}'", lid.observe());
1010                    client_clone
1011                        .persistence_manager
1012                        .process_command(DeviceCommand::SetLid(Some(lid)))
1013                        .await;
1014                }
1015            }
1016
1017            // WA Web bumps `lc` after each successful auth (Start/Backend.js
1018            // listener on `onOpenSocketStream`). The Comms `onConnect` handler
1019            // gates the trigger on `isRegistered()`, so the bump only happens
1020            // for already-paired logins — never during the pairing XX
1021            // handshake. We mirror that by skipping when `device.pn` is None.
1022            let already_paired = client_clone
1023                .persistence_manager
1024                .get_device_snapshot()
1025                .pn
1026                .is_some();
1027            if already_paired {
1028                client_clone
1029                    .persistence_manager
1030                    .process_command(DeviceCommand::IncrementLoginCounter)
1031                    .await;
1032            }
1033
1034            // Macro to check if this task is still valid (connection hasn't been replaced)
1035            macro_rules! check_generation {
1036                () => {
1037                    if client_clone.connection_generation.load(Ordering::SeqCst) != task_generation
1038                    {
1039                        debug!("Post-login task cancelled: connection generation changed");
1040                        return;
1041                    }
1042                };
1043            }
1044
1045            debug!(
1046                "Starting post-login initialization sequence (gen={})...",
1047                task_generation
1048            );
1049
1050            // Check if we need initial app state sync (empty pushname indicates fresh pairing
1051            // where pushname will come from app state sync's setting_pushName mutation)
1052            let device_snapshot = client_clone.persistence_manager.get_device_snapshot();
1053            let needs_pushname_from_sync = device_snapshot.push_name.is_empty();
1054            if needs_pushname_from_sync {
1055                debug!("Push name is empty - will be set from app state sync (setting_pushName)");
1056            }
1057
1058            // Check connection before network operations.
1059            // During pairing, a 515 disconnect happens quickly after success,
1060            // so the socket may already be gone.
1061            if !client_clone.is_connected() {
1062                debug!(
1063                    "Skipping post-login init: connection closed (likely pairing phase reconnect)"
1064                );
1065                return;
1066            }
1067
1068            check_generation!();
1069            client_clone.send_unified_session().await;
1070
1071            // === Establish session with primary phone for PDO ===
1072            // This must happen BEFORE we exit passive mode (before offline messages arrive).
1073            // PDO needs a session with device 0 to request decrypted content from our phone.
1074            // Matches WhatsApp Web's bootstrapDeviceCapabilities() pattern.
1075            check_generation!();
1076            if let Err(e) = client_clone
1077                .establish_primary_phone_session_immediate()
1078                .await
1079            {
1080                warn!(target: "Client/PDO", "Failed to establish session with primary phone on login: {:?}", e);
1081                // Don't fail login - PDO will retry via ensure_e2e_sessions fallback
1082            }
1083
1084            check_generation!();
1085            if !client_clone.is_connected() {
1086                debug!("Skipping passive tasks: connection closed");
1087                return;
1088            }
1089            // WA Web PassiveTasks: the pre-key upload is a passive task, not a gate
1090            // on going active — it only publishes keys for peers' FUTURE sessions
1091            // (the offline backlog uses keys we already hold, and a fresh device's
1092            // server pool is empty). Awaiting it here just delayed offline delivery,
1093            // so spawn it like RotateKeyJob below.
1094            // Pre-key upload then RotateKeyJob, ordered on ONE detached task.
1095            // Both re-declare the signed pre-key to the server — the upload bundles
1096            // the CURRENT one with its one-time keys, rotation uploads a freshly
1097            // promoted one. Run as two independent tasks they can overlap, and if
1098            // rotation lands first, the upload (built from a pre-rotation snapshot)
1099            // reverts the server to the stale signed pre-key; once that key is
1100            // pruned, pkmsg sessions the server hands out become undecryptable.
1101            // Ordering them here keeps set_passive un-gated (still detached) while
1102            // making rotation read the upload's persisted state.
1103            check_generation!();
1104            let key_client = client_clone.clone();
1105            let key_generation = task_generation;
1106            client_clone
1107                .runtime
1108                .spawn_detached(Box::pin(async move {
1109                    // A newer connection may have taken over between spawn and now.
1110                    if key_client.connection_generation.load(Ordering::SeqCst) != key_generation {
1111                        return;
1112                    }
1113                    if let Err(e) = key_client.upload_pre_keys_at_login().await
1114                        && !key_client.is_shutting_down()
1115                    {
1116                        warn!("Failed to upload pre-keys during startup: {e:?}");
1117                    }
1118
1119                    // The upload awaited network I/O; re-check before rotating so a
1120                    // stale generation doesn't upload a duplicate signed pre-key.
1121                    if key_client.connection_generation.load(Ordering::SeqCst) != key_generation {
1122                        return;
1123                    }
1124                    if let Err(e) = key_client.maybe_rotate_signed_pre_key().await
1125                        && !key_client.is_shutting_down()
1126                    {
1127                        warn!("Signed pre-key rotation check failed: {e:?}");
1128                    }
1129                }));
1130
1131            // === Send active IQ ===
1132            // The server sends <ib><offline count="X"/></ib> AFTER we exit passive mode.
1133            // This matches WhatsApp Web's behavior: executePassiveTasks() -> sendPassiveModeProtocol("active")
1134            check_generation!();
1135            if !client_clone.is_connected() {
1136                debug!("Skipping active IQ: connection closed");
1137                return;
1138            }
1139            if let Err(e) = client_clone.set_passive(false).await
1140                && !client_clone.is_shutting_down()
1141            {
1142                warn!("Failed to send post-connect active IQ: {e:?}");
1143            }
1144
1145            // === Wait for offline sync to complete ===
1146            // The server sends <ib><offline count="X"/></ib> after we exit passive mode.
1147            client_clone.wait_for_offline_delivery_end().await;
1148
1149            // Check if connection was replaced while waiting
1150            check_generation!();
1151
1152            // Re-check connection and generation before sending presence
1153            check_generation!();
1154            if !client_clone.is_connected() {
1155                debug!("Skipping presence: connection closed");
1156                return;
1157            }
1158
1159            // Background initialization queries (can run in parallel, non-blocking)
1160            let bg_client = client_clone.clone();
1161            let bg_generation = task_generation;
1162            client_clone.runtime.spawn_detached(Box::pin(async move {
1163                // Check connection and generation before starting background queries
1164                if bg_client.connection_generation.load(Ordering::SeqCst) != bg_generation {
1165                    debug!("Skipping background init queries: connection generation changed");
1166                    return;
1167                }
1168                if !bg_client.is_connected() {
1169                    debug!("Skipping background init queries: connection closed");
1170                    return;
1171                }
1172
1173                debug!(
1174                    "Sending background initialization queries (Props, Blocklist, Privacy, Digest, Devices)..."
1175                );
1176
1177                let props_fut = bg_client.fetch_props();
1178                let binding = bg_client.blocking();
1179                let blocklist_fut = binding.get_blocklist();
1180                let privacy_fut = bg_client.fetch_privacy_settings();
1181                let digest_fut = bg_client.validate_digest_key();
1182                // Off the pre-active critical path: WA Web's passive tasks don't
1183                // include an own-device usync (it resolves device lists on demand
1184                // at send time), so syncing here instead of before the active IQ
1185                // starts offline delivery one round-trip sooner.
1186                let device_list_fut = bg_client.sync_own_device_list();
1187
1188                let (r_props, r_block, r_priv, r_digest, r_devices) = futures::join!(
1189                    props_fut,
1190                    blocklist_fut,
1191                    privacy_fut,
1192                    digest_fut,
1193                    device_list_fut
1194                );
1195
1196                // Suppress warnings if connection closed while queries were in-flight
1197                if !bg_client.is_shutting_down() {
1198                    if let Err(e) = r_props {
1199                        warn!("Background init: Failed to fetch props: {e:?}");
1200                    }
1201                    if let Err(e) = r_block {
1202                        warn!("Background init: Failed to fetch blocklist: {e:?}");
1203                    }
1204                    match r_priv {
1205                        Ok(settings) => {
1206                            use wacore::iq::privacy::{PrivacyCategory, PrivacyValue};
1207                            // Persist so the gate is correct on reconnect before the next fetch
1208                            // runs; this is also the cross-device refresh path (WA Web reads
1209                            // readreceipts from local prefs).
1210                            let disabled = matches!(
1211                                settings.get_value(&PrivacyCategory::ReadReceipts),
1212                                Some(PrivacyValue::None)
1213                            );
1214                            // Re-check generation: after the fetch's round-trip a superseded
1215                            // connection must not persist its now-stale privacy value.
1216                            let stale = bg_client.connection_generation.load(Ordering::SeqCst)
1217                                != bg_generation;
1218                            if !stale
1219                                && disabled
1220                                    != bg_client
1221                                        .persistence_manager
1222                                        .get_device_snapshot()
1223                                        .read_receipts_disabled
1224                            {
1225                                bg_client
1226                                    .persistence_manager
1227                                    .process_command(DeviceCommand::SetReadReceiptsDisabled(
1228                                        disabled,
1229                                    ))
1230                                    .await;
1231                                if let Err(e) = bg_client.persistence_manager.flush().await {
1232                                    warn!(
1233                                        "Background init: Failed to persist readreceipts privacy: {e:?}"
1234                                    );
1235                                }
1236                            }
1237                        }
1238                        Err(e) => {
1239                            warn!("Background init: Failed to fetch privacy settings: {e:?}");
1240                        }
1241                    }
1242                    if let Err(e) = r_digest {
1243                        warn!("Background init: Failed to validate digest key: {e:?}");
1244                    }
1245                    if let Err(e) = r_devices {
1246                        bg_client.log_sync_error("sync own device list", &e);
1247                    }
1248                }
1249
1250                // Prune expired tcTokens on connect (matches WhatsApp Web's PrivacyTokenJob)
1251                if let Err(e) = bg_client.tc_token().prune_expired().await
1252                    && !bg_client.is_shutting_down()
1253                {
1254                    warn!("Background init: Failed to prune expired tc_tokens: {e:?}");
1255                }
1256            }));
1257
1258            check_generation!();
1259
1260            let flag_set = client_clone.needs_initial_full_sync.is_armed();
1261            let needs_initial_sync = flag_set || needs_pushname_from_sync;
1262
1263            if needs_initial_sync {
1264                // === Fresh pairing path ===
1265                // Like WhatsApp Web's syncCriticalData(): await critical collections before
1266                // dispatching Connected, so blocklist/privacy settings are applied first.
1267                debug!(
1268                    target: "Client/AppState",
1269                    "Starting Initial App State Sync (flag_set={flag_set}, needs_pushname={needs_pushname_from_sync})"
1270                );
1271
1272                // Single deadline for the whole critical path (key-share grace + batched
1273                // IQ + missing-key fallback). Matches WhatsApp Web's WAWebSyncBootstrap
1274                // 180s critical-data deadline. Armed before the wait so every step below
1275                // is bounded by the same clock.
1276                const CRITICAL_SYNC_TIMEOUT_SECS: u64 = 180;
1277                let critical_deadline = wacore::time::Instant::now()
1278                    + Duration::from_secs(CRITICAL_SYNC_TIMEOUT_SECS);
1279                // Explicit "critical sync completed" signal for the watchdog. A push_name
1280                // check is not a reliable proxy: a business account gets push_name set
1281                // from business_name at pairing (src/pair.rs) while still needing the
1282                // full sync, so the watchdog would wrongly stand down on a failed sync.
1283                let critical_sync_done =
1284                    Arc::new(AtomicBool::new(false));
1285                let timeout_client = client_clone.clone();
1286                let timeout_generation = task_generation;
1287                let timeout_rt = client_clone.runtime.clone();
1288                let timeout_done = critical_sync_done.clone();
1289                let critical_sync_timeout_handle = timeout_rt.spawn(Box::pin(async move {
1290                    timeout_client.runtime.sleep(Duration::from_secs(CRITICAL_SYNC_TIMEOUT_SECS)).await;
1291                    // Check generation — if connection was replaced, this timeout is stale
1292                    if timeout_client.connection_generation.load(Ordering::SeqCst)
1293                        != timeout_generation
1294                    {
1295                        return;
1296                    }
1297                    if timeout_done.load(Ordering::SeqCst) {
1298                        debug!(
1299                            target: "Client/AppState",
1300                            "Critical sync timeout fired but critical sync already completed"
1301                        );
1302                    } else {
1303                        warn!(
1304                            target: "Client/AppState",
1305                            "Critical app state sync did not complete within {CRITICAL_SYNC_TIMEOUT_SECS}s. \
1306                             Reconnecting to retry."
1307                        );
1308                        // WhatsApp Web does socketLogout here which clears device identity.
1309                        // We reconnect instead — preserving credentials and keeping the
1310                        // run loop active so auto-reconnect can retry the sync.
1311                        timeout_client.reconnect_immediately().await;
1312                    }
1313                }));
1314
1315                // Brief grace for the auto-shared key that the primary sends at pairing
1316                // (the WA Web primary path). The listener is registered before the flag
1317                // check because the notifier is not sticky — a key-share landing in the
1318                // load→listen gap would otherwise be missed. This wait is only an
1319                // optimization to avoid a redundant explicit key request in the common
1320                // fast case; if the key is late (heavy history sync) or never
1321                // auto-shared, the batched sync below falls back to an explicit
1322                // AppStateSyncKeyRequest bounded by `critical_deadline`, so correctness
1323                // does not depend on this grace.
1324                const KEY_SHARE_GRACE_SECS: u64 = 10;
1325                let key_share_listener = client_clone.initial_keys_synced_notifier.listen();
1326                if !client_clone
1327                    .initial_app_state_keys_received
1328                    .load(Ordering::Relaxed)
1329                {
1330                    debug!(
1331                        target: "Client/AppState",
1332                        "Waiting up to {KEY_SHARE_GRACE_SECS}s for the auto-shared app state key..."
1333                    );
1334                    let _ = rt_timeout(
1335                        &*client_clone.runtime,
1336                        Duration::from_secs(KEY_SHARE_GRACE_SECS),
1337                        key_share_listener,
1338                    )
1339                    .await;
1340
1341                    // Check if connection was replaced while waiting
1342                    check_generation!();
1343                }
1344
1345                // Await critical collections via batched IQ before dispatching Connected.
1346                // The deadline lets the missing-key fallback recover a late/never-shared
1347                // key on this connection instead of stalling to the watchdog.
1348                check_generation!();
1349                // Critical collections that missed for a reason a retry can still
1350                // fix. Only filled on the refused path below, which connects
1351                // anyway: the watchdog cannot be the retry there, because the
1352                // collection the server refused would fail again on every
1353                // reconnect and the two would loop for good. So they ride along
1354                // with the background sync instead of being dropped.
1355                let mut critical_retry: Vec<WAPatchName> = Vec::new();
1356                // Whether a critical collection was refused outright. Terminal,
1357                // so it is not retried, but it still means the bootstrap never
1358                // finished.
1359                let mut critical_refused = false;
1360                let critical_scope = client_clone.sync_scope(Some(critical_deadline));
1361                match client_clone
1362                    .sync_collections_batched(
1363                        vec![WAPatchName::CriticalBlock, WAPatchName::CriticalUnblockLow],
1364                        critical_scope,
1365                    )
1366                    .await
1367                {
1368                    Ok(outcome) if outcome.all_synced() => {
1369                        // Critical sync completed — signal the watchdog, then cancel it.
1370                        critical_sync_done.store(true, Ordering::SeqCst);
1371                        critical_sync_timeout_handle.abort();
1372
1373                        check_generation!();
1374
1375                        client_clone
1376                            .resubscribe_presence_subscriptions(task_generation)
1377                            .await;
1378
1379                        check_generation!();
1380
1381                        // Dispatch Connected after critical sync completes.
1382                        // Presence is NOT sent here — WhatsApp Web sends presence from the
1383                        // setting_pushName mutation handler (WAWebPushNameSync), not from
1384                        // criticalSyncDone. Our setting_pushName handler already does this.
1385                        client_clone.dispatch_connected(task_generation).await;
1386                    }
1387                    // The server refused a critical collection outright, and it
1388                    // will refuse the same request again. Reconnecting cannot
1389                    // clear a 400/404, and `needs_initial_full_sync` is only
1390                    // cleared further down, so leaving the watchdog armed here
1391                    // would reconnect into this same state every 180s — for
1392                    // good, since `needs_pushname_from_sync` is derived from the
1393                    // persisted push name and survives a restart.
1394                    //
1395                    // WA Web's answer is to notify the primary and log out
1396                    // (`WAWebSyncdFatal`), which a library must not do on a
1397                    // consumer's behalf. So: stop retrying, connect without the
1398                    // collection, and hand the decision over as an event. The
1399                    // account is reachable but missing whatever that collection
1400                    // carried — for `critical_block` that includes the push
1401                    // name, so presence stays unavailable until it arrives.
1402                    Ok(outcome) if !outcome.fatal.is_empty() => {
1403                        critical_sync_timeout_handle.abort();
1404                        // Armed first, before anything a consumer handler can
1405                        // interrupt. A refusal means the bootstrap is unfinished
1406                        // whatever happens next, and everything below —
1407                        // resubscribe, `Connected`, the failure event — can
1408                        // retire this generation and take the decision with it,
1409                        // leaving the flag false with the push name already
1410                        // populated so the replacement skips what it still owes.
1411                        client_clone.settle_bootstrap(critical_scope, true);
1412                        // A refusal does not make the batch's other misses
1413                        // terminal, and leaving them to the watchdog is not an
1414                        // option once we connect. Retry them below instead.
1415                        critical_retry
1416                            .extend(outcome.retryable.iter().chain(&outcome.skipped).copied());
1417                        // The refusal is not in `critical_retry` — retrying it
1418                        // is pointless — but the bootstrap is still unfinished
1419                        // because of it. Without carrying that, a clean
1420                        // background run would stand the gate down for a
1421                        // collection that never synced.
1422                        critical_refused = true;
1423                        warn!(
1424                            target: "Client/AppState",
1425                            "Critical app state sync refused for {:?}; connecting without it (retrying {:?})",
1426                            outcome.fatal, critical_retry
1427                        );
1428                        check_generation!();
1429                        client_clone
1430                            .resubscribe_presence_subscriptions(task_generation)
1431                            .await;
1432                        check_generation!();
1433                        client_clone.dispatch_connected(task_generation).await;
1434                        // After the readiness transition, not before: the report
1435                        // claims the session is usable, and until `Connected` is
1436                        // actually published that claim can still be falsified by
1437                        // a disconnect during the resubscribe above.
1438                        //
1439                        // Re-checked once more here because publishing
1440                        // `Connected` runs consumer handlers, and one of them
1441                        // disconnecting would retire this generation between the
1442                        // two dispatches — long enough to hand the next session
1443                        // a refusal it never earned.
1444                        check_generation!();
1445                        client_clone.dispatch_app_state_sync_failed(
1446                            &outcome,
1447                            client_clone.is_ready.load(Ordering::Relaxed),
1448                        );
1449                    }
1450                    // Nothing terminal: a retryable error, a decode key that
1451                    // never landed, or a collection held by another writer. The
1452                    // watchdog stays alive to force the reconnect that retries.
1453                    // detach() so this early return doesn't abort it on drop
1454                    // (AbortHandle aborts the task when dropped).
1455                    Ok(outcome) => {
1456                        warn!(
1457                            target: "Client/AppState",
1458                            "Critical app state sync incomplete (retryable={:?} skipped={:?}); will retry",
1459                            outcome.retryable, outcome.skipped
1460                        );
1461                        // Same reason as the arm above: never publish an outcome
1462                        // that belongs to a retired socket. Returning here drops
1463                        // the watchdog handle, which aborts it — correct for a
1464                        // generation that already has its own.
1465                        check_generation!();
1466                        // Armed before returning, because the watchdog is not the
1467                        // whole guarantee. This path is reachable with the flag
1468                        // already false — an empty push name alone opens the
1469                        // bootstrap — and a mixed response can apply
1470                        // `critical_block`, push name included, while leaving
1471                        // `critical_unblock_low` behind. The forced reconnect
1472                        // would then see a populated name and a clear flag, take
1473                        // the ordinary path, and never retry what is missing.
1474                        client_clone.settle_bootstrap(critical_scope, true);
1475                        client_clone.dispatch_app_state_sync_failed(&outcome, false);
1476                        critical_sync_timeout_handle.detach();
1477                        return;
1478                    }
1479                    Err(e) => {
1480                        client_clone.log_sync_error("critical app state sync", &e);
1481                        // Armed for the same reason as the incomplete arm above,
1482                        // and it matters just as much here: a batch can fail
1483                        // partway, after `critical_block` already dispatched and
1484                        // persisted `setting_pushName`. The watchdog's reconnect
1485                        // would then find a populated push name and a clear flag
1486                        // and take the ordinary path, never retrying the rest.
1487                        //
1488                        client_clone.settle_bootstrap(critical_scope, true);
1489                        // The sync failed — the watchdog must stay alive to force a reconnect.
1490                        critical_sync_timeout_handle.detach();
1491                        return;
1492                    }
1493                }
1494
1495                // Spawn remaining non-critical collections in background
1496                let sync_client = client_clone.clone();
1497                let sync_generation = task_generation;
1498                client_clone.runtime.spawn_detached(Box::pin(async move {
1499                    if sync_client.connection_generation.load(Ordering::SeqCst) != sync_generation {
1500                        debug!("App state sync cancelled: connection generation changed");
1501                        return;
1502                    }
1503
1504                    // Any critical collection the refused path handed over goes
1505                    // first: it is the one the account actually needs.
1506                    let mut to_sync = critical_retry;
1507                    to_sync.extend([
1508                        WAPatchName::RegularLow,
1509                        WAPatchName::RegularHigh,
1510                        WAPatchName::Regular,
1511                    ]);
1512                    let requested = to_sync.clone();
1513                    let scope = sync_client.sync_scope(None);
1514                    let result = sync_client.sync_collections_batched(to_sync, scope).await;
1515
1516                    let complete = !critical_refused
1517                        && result.as_ref().is_ok_and(|outcome| outcome.all_synced());
1518
1519                    // Settled before the report, because reporting dispatches to
1520                    // consumer handlers synchronously and one of them
1521                    // disconnecting would retire the scope and take this
1522                    // decision with it — leaving an unfinished bootstrap
1523                    // unarmed, which is the failure this path exists to prevent.
1524                    // `settle_bootstrap` is what makes the "only for this
1525                    // connection" part impossible to forget.
1526                    sync_client.settle_bootstrap(scope, !complete);
1527
1528                    // A refused critical collection is not in `requested` and
1529                    // never will be retried, but it is why the bootstrap is
1530                    // unfinished. Handing that to the scheduler keeps a later
1531                    // clean round from standing the gate down on its behalf.
1532                    sync_client.report_background_sync_stranded(
1533                        "non-critical app state sync",
1534                        scope,
1535                        SyncSettles::InitialSync,
1536                        &requested,
1537                        critical_refused,
1538                        result,
1539                    );
1540                }));
1541            } else {
1542                // === Reconnection path ===
1543                // Pushname is already known, send presence and Connected immediately.
1544                let device_snapshot = client_clone.persistence_manager.get_device_snapshot();
1545                if !device_snapshot.push_name.is_empty() {
1546                    if let Err(e) = client_clone.presence().set_available().await {
1547                        warn!("Failed to send initial presence: {e:?}");
1548                    } else {
1549                        debug!("Initial presence sent successfully.");
1550                    }
1551                }
1552
1553                client_clone
1554                    .resubscribe_presence_subscriptions(task_generation)
1555                    .await;
1556
1557                // Re-check generation after awaits to avoid dispatching Connected
1558                // for an outdated connection that was replaced mid-await.
1559                check_generation!();
1560
1561                client_clone.dispatch_connected(task_generation).await;
1562            }
1563        }));
1564    }
1565
1566    /// Ack entry point for callers that already share the node: the waiter
1567    /// receives an `Arc` clone instead of a ~1 KB re-encode + re-parse.
1568    pub(crate) fn handle_ack_response_arc(
1569        self: &Arc<Self>,
1570        node: &Arc<wacore_binary::OwnedNodeRef>,
1571    ) -> bool {
1572        let Some(waiter) = self.take_ack_waiter(node.get()) else {
1573            return false;
1574        };
1575        match waiter {
1576            ResponseWaiter::Iq(sender) => {
1577                #[cfg(feature = "voip-runtime")]
1578                self.bind_pending_call_link_join_ack(node.get());
1579                if let Err(rejected) = sender.send(Arc::clone(node)) {
1580                    Self::warn_ack_waiter_dropped(&rejected);
1581                }
1582            }
1583            ResponseWaiter::Phash(waiter) => self.check_phash_against_ack(node.get(), waiter),
1584        }
1585        true
1586    }
1587
1588    /// Ack entry point for the read-loop fast path, which owns the node: the
1589    /// `Arc` is built from the existing allocation, and only when a waiter is
1590    /// actually waiting.
1591    pub(crate) fn handle_ack_response_owned(
1592        self: &Arc<Self>,
1593        node: wacore_binary::OwnedNodeRef,
1594    ) -> bool {
1595        let Some(waiter) = self.take_ack_waiter(node.get()) else {
1596            return false;
1597        };
1598        match waiter {
1599            ResponseWaiter::Iq(sender) => {
1600                #[cfg(feature = "voip-runtime")]
1601                self.bind_pending_call_link_join_ack(node.get());
1602                if let Err(rejected) = sender.send(Arc::new(node)) {
1603                    Self::warn_ack_waiter_dropped(&rejected);
1604                }
1605            }
1606            ResponseWaiter::Phash(waiter) => self.check_phash_against_ack(node.get(), waiter),
1607        }
1608        true
1609    }
1610
1611    /// Inline half of the phash check. The comparison is a string equality on
1612    /// the read loop; only a disagreement pays for a task, and that path
1613    /// re-reads caches and can force a sender-key redistribution.
1614    fn check_phash_against_ack(
1615        self: &Arc<Self>,
1616        node: &wacore_binary::NodeRef<'_>,
1617        waiter: PhashWaiter,
1618    ) {
1619        let Some(server) = node.get_attr("phash") else {
1620            return;
1621        };
1622        if server.as_str() == waiter.expected {
1623            return;
1624        }
1625        let client = Arc::clone(self);
1626        let server = server.as_str().to_string();
1627        self.runtime.spawn_detached(Box::pin(async move {
1628            client
1629                .handle_phash_mismatch(
1630                    &waiter.jid,
1631                    &waiter.expected,
1632                    &server,
1633                    waiter.invalidate_group_cache,
1634                )
1635                .await;
1636        }));
1637    }
1638
1639    fn warn_ack_waiter_dropped(rejected: &Arc<wacore_binary::OwnedNodeRef>) {
1640        warn!(
1641            target: "Client/Ack",
1642            "Failed to send ACK response to waiter for ID {:?}. Receiver was likely dropped.",
1643            rejected.get().get_attr("id")
1644        );
1645    }
1646
1647    /// Shared ack prologue: log nack codes, dispatch `ServerAck` when
1648    /// observed, and pull the matching response waiter out of the map.
1649    #[cfg_attr(
1650        feature = "tracing",
1651        tracing::instrument(name = "wa.conn.ack_response", level = "debug", skip_all)
1652    )]
1653    fn take_ack_waiter(&self, node: &wacore_binary::NodeRef<'_>) -> Option<ResponseWaiter> {
1654        let ack_id = node.get_attr("id");
1655        let ack_error = node.get_attr("error");
1656
1657        // Surface server nack codes for diagnosability. A nacked send still
1658        // resolves Ok to the caller, so without this the failure is invisible.
1659        if let Some(error_code) = &ack_error {
1660            let code = error_code.as_str();
1661            let id = ack_id.as_ref().map(|v| v.as_str());
1662            match code.as_ref() {
1663                "463" => {
1664                    warn!(
1665                        target: "Client/Ack",
1666                        "Received 463 (MissingTcToken) nack for msg {:?}. \
1667                         The recipient requires a valid tctoken or cstoken. \
1668                         This may indicate a reachout timelock on the account.",
1669                        id
1670                    );
1671                }
1672                "479" => {
1673                    warn!(
1674                        target: "Client/Ack",
1675                        "Received 479 (SmaxInvalid) nack for msg {:?}. \
1676                         A stanza field has an incorrect format (e.g. wrong JID format or content type).",
1677                        id
1678                    );
1679                }
1680                other => {
1681                    warn!(
1682                        target: "Client/Ack",
1683                        "Received {other} nack for msg {:?}; the message was likely \
1684                         not delivered (e.g. 400 = malformed stanza, 404 = recipient \
1685                         not found, 503 = service unavailable).",
1686                        id
1687                    );
1688                }
1689            }
1690        }
1691
1692        // Dispatched before waiter resolution; gated on interest so the hot path
1693        // allocates nothing when nobody is listening.
1694        if self
1695            .core
1696            .event_bus
1697            .has_handler_for(wacore::types::events::EventKind::ServerAck)
1698            && let Some(id) = &ack_id
1699        {
1700            let ack = wacore::types::events::ServerAck::builder()
1701                .id(id.as_str().to_string())
1702                .maybe_class(node.get_attr("class").map(|v| v.as_str().to_string()))
1703                .maybe_from(node.get_attr("from").and_then(|v| v.as_str().parse().ok()))
1704                .maybe_timestamp(
1705                    node.get_attr("t")
1706                        .and_then(|v| v.as_str().parse::<i64>().ok())
1707                        .and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)),
1708                )
1709                .maybe_error(ack_error.as_ref().map(|v| v.as_str().to_string()))
1710                .build();
1711            self.core.event_bus.dispatch(Event::ServerAck(ack));
1712        }
1713
1714        let id = ack_id.map(|v| v.as_str())?;
1715        self.response_waiters_guard().remove(id.as_ref())
1716    }
1717
1718    #[cfg_attr(
1719        feature = "tracing",
1720        tracing::instrument(name = "wa.conn.stream_error", level = "debug", skip_all)
1721    )]
1722    pub(crate) async fn handle_stream_error(&self, node: &wacore_binary::NodeRef<'_>) {
1723        wacore::telemetry::stream_error();
1724        // is_logged_in handling: opt-in branches (515/516/401/409/conflict) clear it
1725        // in the disconnect block below; 429/503 clear it inline because the server
1726        // explicitly rejected the session and outgoing sends should bail fast; the
1727        // unknown/code-less catch-all keeps it true so is_fully_ready()-gated work
1728        // (notably prekey uploads) survives ack-shaped routing errors.
1729        let mut attrs = node.attrs();
1730        let code_cow = attrs.optional_string("code");
1731        let code = code_cow.as_deref().unwrap_or("");
1732        let conflict_type = node
1733            .get_optional_child("conflict")
1734            .map(|n| {
1735                n.attrs()
1736                    .optional_string("type")
1737                    .as_deref()
1738                    .unwrap_or("")
1739                    .to_string()
1740            })
1741            .unwrap_or_default();
1742
1743        // Whether to proactively disconnect the transport after handling.
1744        let mut should_disconnect = false;
1745
1746        if !conflict_type.is_empty() {
1747            info!(
1748                "Got stream error indicating client was removed or replaced (conflict={}). Logging out.",
1749                conflict_type
1750            );
1751            self.expected_disconnect.store(true, Ordering::Relaxed);
1752            self.enable_auto_reconnect.store(false, Ordering::Relaxed);
1753
1754            let event = if conflict_type == "replaced" {
1755                Event::StreamReplaced(crate::types::events::StreamReplaced::builder().build())
1756            } else {
1757                Event::LoggedOut(
1758                    crate::types::events::LoggedOut::builder()
1759                        .on_connect(false)
1760                        .reason(ConnectFailureReason::LoggedOut)
1761                        .raw(node.to_owned())
1762                        .build(),
1763                )
1764            };
1765            self.core.event_bus.dispatch(event);
1766            should_disconnect = true;
1767        } else {
1768            match code {
1769                "515" => {
1770                    info!(
1771                        "Got 515 stream error, server is closing stream (expected after pairing). Will auto-reconnect."
1772                    );
1773                    self.expected_disconnect.store(true, Ordering::Relaxed);
1774                    should_disconnect = true;
1775                }
1776                "516" => {
1777                    info!("Got 516 stream error (device removed). Logging out.");
1778                    self.expected_disconnect.store(true, Ordering::Relaxed);
1779                    self.enable_auto_reconnect.store(false, Ordering::Relaxed);
1780                    self.core.event_bus.dispatch(Event::LoggedOut(
1781                        crate::types::events::LoggedOut::builder()
1782                            .on_connect(false)
1783                            .reason(ConnectFailureReason::LoggedOut)
1784                            .raw(node.to_owned())
1785                            .build(),
1786                    ));
1787                    should_disconnect = true;
1788                }
1789                "401" => {
1790                    info!("Got 401 stream error (unauthorized). Logging out.");
1791                    self.expected_disconnect.store(true, Ordering::Relaxed);
1792                    self.enable_auto_reconnect.store(false, Ordering::Relaxed);
1793                    self.core.event_bus.dispatch(Event::LoggedOut(
1794                        crate::types::events::LoggedOut::builder()
1795                            .on_connect(false)
1796                            .reason(ConnectFailureReason::LoggedOut)
1797                            .raw(node.to_owned())
1798                            .build(),
1799                    ));
1800                    should_disconnect = true;
1801                }
1802                "409" => {
1803                    info!("Got 409 stream error (conflict). Another session replaced this one.");
1804                    self.expected_disconnect.store(true, Ordering::Relaxed);
1805                    self.enable_auto_reconnect.store(false, Ordering::Relaxed);
1806                    self.core.event_bus.dispatch(Event::StreamReplaced(
1807                        crate::types::events::StreamReplaced::builder().build(),
1808                    ));
1809                    should_disconnect = true;
1810                }
1811                "429" => {
1812                    // Server signalled rate-limit on this session: mark logged-out so
1813                    // outgoing sends bail fast instead of being interpreted as abuse
1814                    // while we wait for the (likely-imminent) reconnect.
1815                    warn!(
1816                        "Got 429 stream error (rate limited). Will auto-reconnect with extended backoff."
1817                    );
1818                    self.is_logged_in.store(false, Ordering::Relaxed);
1819                    self.auto_reconnect_errors.fetch_add(5, Ordering::Relaxed);
1820                    // Deliberate rate-limit backoff: the stability reset must
1821                    // not erase it even if the connection had been up >= 30s.
1822                    self.backoff_reset_suppressed.store(true, Ordering::Relaxed);
1823                }
1824                "503" => {
1825                    // Server is going down/restarting: mark logged-out so sends fail
1826                    // fast against the soon-to-die socket. Auto-reconnect handles recovery.
1827                    info!("Got 503 service unavailable, will auto-reconnect.");
1828                    self.is_logged_in.store(false, Ordering::Relaxed);
1829                }
1830                _ => {
1831                    // Server wraps per-stanza routing failures in <stream:error> without a
1832                    // code (e.g. <ack/>): treat as informational so we don't trigger reconnect
1833                    // storms. is_logged_in stays true on purpose — whatsmeow clears it eagerly,
1834                    // but here is_fully_ready() gates prekey uploads and we want them to keep
1835                    // working while the socket is still alive. Severity is warn!, not error!,
1836                    // because the connection is intentionally preserved.
1837                    // WA Web (StreamError.js) knows <stream:error><ack/> (type "ack");
1838                    // name it instead of "Unknown". Root cause is usually an un-acked
1839                    // offline stanza; the server's <xmlstreamend/> drives the reconnect.
1840                    if node.get_optional_child("xml-not-well-formed").is_some() {
1841                        // WA Web (Handle/StreamError.js): "bad xml, closing socket"
1842                        // → CLOSE_SOCKET. A malformed frame desyncs the stream, so
1843                        // recycle the socket proactively instead of keeping the
1844                        // broken connection and waiting for the server to end it.
1845                        // Counts toward the reconnect backoff (not an expected
1846                        // disconnect); is_logged_in clears so sends bail fast.
1847                        warn!(
1848                            "Stream error <xml-not-well-formed>: closing socket to recycle the stream"
1849                        );
1850                        self.is_logged_in.store(false, Ordering::Relaxed);
1851                        should_disconnect = true;
1852                    } else if let Some(ack) = node.get_optional_child("ack") {
1853                        let id = ack
1854                            .get_attr("id")
1855                            .map(|v| v.as_str().to_string())
1856                            .unwrap_or_default();
1857                        let class = ack
1858                            .get_attr("class")
1859                            .map(|v| v.as_str().to_string())
1860                            .unwrap_or_default();
1861                        warn!(
1862                            "Stream error carrying <ack> (class={class:?}, id={id}): the server is \
1863                             still owed a transport ack for that stanza and recycles the stream \
1864                             until it arrives; reconnect follows on stream end"
1865                        );
1866                    } else {
1867                        warn!("Unknown stream error: {}", DisplayableNodeRef(node));
1868                    }
1869                    self.core.event_bus.dispatch(Event::StreamError(
1870                        crate::types::events::StreamError::builder()
1871                            .code(code.to_string())
1872                            .raw(node.to_owned())
1873                            .build(),
1874                    ));
1875                }
1876            }
1877        }
1878
1879        // Single is_logged_in clear + transport disconnect for every opt-in branch
1880        // (515/516/401/409 and conflict). 429/503/unknown fall through so the
1881        // socket layer notices a real teardown without us forcing one.
1882        if should_disconnect {
1883            self.is_logged_in.store(false, Ordering::Relaxed);
1884            let transport_opt = self.transport.lock().await.clone();
1885            if let Some(transport) = transport_opt {
1886                self.runtime.spawn_detached(Box::pin(async move {
1887                    transport.disconnect().await;
1888                }));
1889            }
1890            info!("Notifying connection shutdown from stream error handler");
1891            self.notify_connection_shutdown();
1892        }
1893    }
1894
1895    #[cfg_attr(
1896        feature = "tracing",
1897        tracing::instrument(name = "wa.conn.connect_failure", level = "debug", skip_all)
1898    )]
1899    pub(crate) async fn handle_connect_failure(&self, node: &wacore_binary::NodeRef<'_>) {
1900        self.expected_disconnect.store(true, Ordering::Relaxed);
1901
1902        let failure = wacore::stanza::connect_failure::ConnectFailureStanza::parse(node);
1903        // A `<failure>` with no usable `reason` is not a failure we can classify:
1904        // treat it as unknown, which stops auto-reconnect rather than looping
1905        // against a server that just refused us. (WA Web drops the stanza
1906        // outright; it has a UI to fall back on, an embedder does not.)
1907        let reason = failure.reason.unwrap_or(ConnectFailureReason::Unknown(0));
1908
1909        if reason.should_reconnect() {
1910            self.expected_disconnect.store(false, Ordering::Relaxed);
1911        } else {
1912            self.enable_auto_reconnect.store(false, Ordering::Relaxed);
1913        }
1914        // Announced after the classification, not before it. This notify is what
1915        // wakes work parked in `await_connection`, and that work answers by
1916        // reading the state — so announcing first offers it the state of a
1917        // client that has not yet decided, and the decision that follows makes
1918        // no sound of its own. Nothing awaits between the stores and here, so
1919        // the pair is what a waiter observes.
1920        self.notify_connection_shutdown();
1921
1922        // Every branch below keeps the stanza on its event. The server states
1923        // things here exactly once — an account lock's one-time `appeal_token`,
1924        // a ban's support URL — and a `warn!` line is not a delivery channel.
1925        if reason.is_logged_out() {
1926            // `location` (e.g. "rva") is a routing token, not the cause.
1927            warn!(
1928                "Got {reason:?} connect failure, logging out: {}",
1929                DisplayableNodeRef(node)
1930            );
1931            self.core.event_bus.dispatch(Event::LoggedOut(
1932                crate::types::events::LoggedOut::builder()
1933                    .on_connect(true)
1934                    .reason(reason)
1935                    .maybe_logout_message(failure.logout_message())
1936                    .raw(node.to_owned())
1937                    .build(),
1938            ));
1939        } else if let ConnectFailureReason::TempBanned = reason
1940            && let Some(expire_secs) = failure.expire
1941            && let Some(ban_code) = failure.code
1942            && let Ok(expire_secs) = i64::try_from(expire_secs)
1943            && let Some(expire_duration) = chrono::Duration::try_seconds(expire_secs)
1944        {
1945            warn!(
1946                "Temporary ban connect failure: {}",
1947                DisplayableNodeRef(node)
1948            );
1949            self.core.event_bus.dispatch(Event::TemporaryBan(
1950                crate::types::events::TemporaryBan::builder()
1951                    .code(crate::types::events::TempBanReason::from(ban_code))
1952                    .expire(expire_duration)
1953                    .maybe_message(failure.message.as_deref().map(str::to_owned))
1954                    .maybe_url(failure.url.as_deref().map(str::to_owned))
1955                    .raw(node.to_owned())
1956                    .build(),
1957            ));
1958        } else if let ConnectFailureReason::ClientOutdated = reason {
1959            error!("Client is outdated and was rejected by server.");
1960            self.core.event_bus.dispatch(Event::ClientOutdated(
1961                crate::types::events::ClientOutdated::builder()
1962                    .raw(node.to_owned())
1963                    .build(),
1964            ));
1965        } else {
1966            // Also the landing spot for a 402 whose `code`/`expire` is missing
1967            // or does not fit a `Duration`: WA Web errors out there instead of
1968            // reporting a zero-length ban, so the raw stanza is all we can
1969            // honestly hand over.
1970            warn!("Unknown connect failure: {}", DisplayableNodeRef(node));
1971            self.core.event_bus.dispatch(Event::ConnectFailure(
1972                crate::types::events::ConnectFailure::builder()
1973                    .reason(reason)
1974                    .maybe_message(failure.message.as_deref().map(str::to_owned))
1975                    .raw(node.to_owned())
1976                    .build(),
1977            ));
1978        }
1979    }
1980
1981    #[cfg_attr(
1982        feature = "tracing",
1983        tracing::instrument(name = "wa.conn.iq_in", level = "debug", skip_all)
1984    )]
1985    pub(crate) async fn handle_iq(self: &Arc<Self>, node: &wacore_binary::NodeRef<'_>) -> bool {
1986        // Pong a server-initiated ping (a request: type="get" or, like WA Web's
1987        // type-agnostic handleIq, an absent type), but not a type="result"/"error"
1988        // ping — that's a response to our own ping, and ponging it back is wrong.
1989        // The previous gate required type=="get" exactly, dropping an absent-type
1990        // ping and risking a keepalive timeout/disconnect.
1991        let is_ping_request = node.get_attr("type").is_none_or(|s| s.as_str() == "get")
1992            && (node.get_optional_child("ping").is_some()
1993                || node
1994                    .get_attr("xmlns")
1995                    .is_some_and(|s| s.as_str() == "urn:xmpp:ping"));
1996        if is_ping_request {
1997            debug!("Received ping, sending pong.");
1998            let mut parser = node.attrs();
1999            let from_jid = parser.jid("from");
2000            let id = parser.optional_string("id").map(|s| s.to_string());
2001            let pong = build_pong(from_jid.to_string(), id.as_deref());
2002            if let Err(e) = self.send_node(pong).await {
2003                warn!("Failed to send pong: {e:?}");
2004            }
2005            return true;
2006        }
2007
2008        if pair::handle_iq(self, node).await {
2009            return true;
2010        }
2011
2012        false
2013    }
2014
2015    pub(crate) fn update_server_time_offset(&self, node: &wacore_binary::NodeRef<'_>) {
2016        self.unified_session.update_server_time_offset(node);
2017    }
2018}