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}