Skip to main content

Overview

whatsapp-rust uses WebSocket for transport and the Noise Protocol for encryption. All messages are encrypted at the transport layer before being sent over the network.

Architecture

The WebSocket handling system has several layers:

Noise protocol handshake

The handshake establishes an encrypted channel using the Noise Protocol. whatsapp-rust supports two interactive patterns to match WhatsApp Web’s behavior:
  • Noise XX — three-message mutual authentication used on cold start, after pairing, and as a fallback when IK fails. The server’s static public key is unknown ahead of time and is delivered (and verified against a cert chain) inside the ServerHello.
  • Noise IK — a single round-trip resumed handshake used when a previously verified server static key is cached. This saves a server round trip on reconnect by pre-encrypting the client static and the 0-RTT ClientPayload against the cached server static.
  • Noise XXfallback — a recovery pattern the server transparently triggers when the cached server static used by an IK attempt no longer matches its current key. The transcript pivots from IK to XX without restarting the connection.
A 20-second timeout (NOISE_HANDSHAKE_RESPONSE_TIMEOUT) is applied when waiting for the server’s handshake response, ensuring the client does not hang indefinitely if the server is unresponsive.

Pattern selection

do_handshake picks the pattern based on cached state:
A handshake uses IK only when all of the following are true:
  • The device is registered (paired).
  • A cached server_cert_chain is present in Device.
  • Both the leaf and intermediate certificates are inside their not_before/not_after validity window for the current wall-clock time.
  • The process has not already observed an IK failure this session — the client allows at most IK_FAILURE_THRESHOLD = 1 IK failure per process before forcing XX on subsequent connects.
Otherwise, the client falls back to XX. A successful XX (or XXfallback) handshake refreshes the cached server_cert_chain so that the next reconnect can attempt IK again.

Handshake state types

The wacore_noise crate exposes one state machine per pattern:
The pattern strings are "Noise_XX_25519_AESGCM_SHA256\0\0\0\0" for XX and XXfallback (the longer "Noise_XXfallback_25519_AESGCM_SHA256" name is hashed to derive h0 because it exceeds HASHLEN).

XX handshake (cold start / fallback)

Breaking change: do_handshake’s last parameter used to be stats: Option<Arc<wacore::stats::SessionStats>>. It is now observers: SendObservers, the same struct NoiseSocket::with_observers takes — see below. A caller that only wants wire-byte accounting passes SendObservers::with_stats(stats); a caller that wants neither observer passes SendObservers::default().
Step-by-step process for XX:
  1. Prepare client payload:
    The payload contains client version, platform, and device details.
  2. Initialize XX state:
  3. Send ClientHello:
  4. Receive ServerHello (with 20s timeout):
  5. Send ClientFinish:
  6. Complete handshake and persist the cert chain:

IK handshake (resumed)

When select_pattern returns HandshakePattern::Ik(server_static_pub), the client uses the cached server static and skips the second round trip:
On Continue, no SetServerCertChain command is issued — the on-disk cache remains authoritative.

XXfallback (server-driven recovery)

If read_server_hello returns IkServerHelloOutcome::Fallback(inputs), the server has signalled that the cached static is stale. The client constructs an XxFallbackHandshakeState from the IK transcript and finishes the handshake as if it had been XX from the start, without reconnecting:
A fallback_taken flag is flipped before any operation that could fail. Failures after this pivot are not treated as crypto-fatal for IK cache invalidation purposes — by that point the server has already accepted the IK ClientHello and the cache is no longer the implicated party.

IK failure handling

Errors are partitioned into two buckets in HandshakeError:
If do_handshake selected IK, the pivot to XXfallback was not taken, and the error returns is_crypto_fatal() == true, the client:
  1. Increments a process-local ik_handshake_failures: AtomicU32.
  2. Issues DeviceCommand::ClearServerCertChain to drop the cached chain.
  3. Forces XX on the next connect via select_pattern.
Programmer-side variants (Proto, generic Crypto(String), HkdfExpandFailed, CounterExhausted) are explicitly not crypto-fatal — they indicate a code defect, and clearing the cache would mask it. Location: src/handshake.rs

Edge routing pre-intro

For optimized reconnection, the client can include edge routing info in the initial frame:
Location: wacore/noise/src/edge_routing.rs Both XX and IK send their first handshake message through send_first_handshake_message, so the prologue (and any edge-routing pre-intro) is identical for the two patterns. This is required for the wire-side server to re-derive h0 for transcript MAC checks regardless of which pattern is in use.

Handshake Errors

Location: src/handshake.rs

NoiseSocket

The NoiseSocket provides encrypted send/receive operations after handshake.

Architecture

Location: src/socket/noise_socket.rs:50-66

Design Patterns

Dedicated sender task

The socket uses a dedicated task for sending to ensure frame ordering:
NoiseSocket::new (used by the illustrative handshake walkthrough above) is unchanged: it’s a thin wrapper that calls with_observers with SendObservers::default(), for the callers — mainly tests — that want neither observer.
Breaking change: with_stats(..., stats: Option<Arc<SessionStats>>) is now with_observers(..., observers: SendObservers). SendObservers is one struct rather than one parameter per observer, so the next thing that wants to watch sends — SentFrame was the first — plugs in there instead of widening this constructor (and do_handshake’s) again. A caller that only wants what with_stats gave it passes SendObservers::with_stats(stats), which is pub and usable from any crate. with_sent_frames and the client’s sent_frame_tap field, by contrast, are pub(crate) — internal wiring the client itself uses to chain .with_sent_frames(client.sent_frame_tap.clone()) when it builds its own socket, not something callable from outside whatsapp-rust. An embedder enables sent-frame forwarding the same way any consumer does: through Client::acquire_sent_frame_forwarding(), which wires the tap internally. VoIP relay sockets and most tests pass SendObservers::default(), reporting to neither — same as passing None before.
Why a dedicated task?
  1. Ordering guarantee: Frames must be sent with sequential counters
  2. Non-blocking: Callers don’t block on encryption or network I/O
  3. Backpressure: A bounded channel (8 jobs) backpressures producers instead of queuing unboundedly
  4. Frame coalescing: whatever is already queued when the task wakes gets encrypted into one buffer and written in a single transport.send() — see Write batching below
Location: src/socket/noise_socket.rs:81-117

Sender task implementation

A DM round trip can answer one inbound message with several independent producers (reply, delivery receipt, stanza ack) queuing at nearly the same instant. Rather than writing each as its own syscall/TLS record/WebSocket message, the sender drains whatever is already queued (try_recv, never a blocking wait) into one buffer and hands the transport a single write:
Two ceilings bound a batch: MAX_BATCH_FRAMES (16) and MAX_BATCH_WIRE_BYTES (64 KiB). The byte ceiling only stops a batch from growing: you check it against the next frame’s projected wire size before appending, and hold a frame that would overflow over (carry_over) to open the next batch instead. A dropped held-over job (e.g. on shutdown) drops its response channel, which its caller observes as a closed sender — a held-over job can be lost, but it can never hang its caller. The ceiling does not shrink a single frame that is already too big. The first job of a batch is always encrypted and appended before either ceiling is checked, so a plaintext whose framed ciphertext alone exceeds 64 KiB (frames up to the 16 MB protocol limit are valid — see Frame Format) still goes out, alone, in a write larger than the ceiling. The ceiling governs coalescing, not the size of any one frame. The whole batch shares the fate of its one transport.send() call. A crypto or framing error is detected before any byte reaches the wire. It leaves the write counter untouched. Only the offending job’s caller sees the error — every frame already encrypted into the buffer still goes out, because the peer’s counters must stay contiguous. A transport error is different: the peer may have partially or fully received the write, so you can’t tell which frames landed. The sender poisons itself, and every waiter in the batch is told the send was lost. EncryptSendError wraps an anyhow::Error, which isn’t Clone, so you can’t hand each waiter its own copy of the real cause. Instead every waiter gets its own EncryptSendError (kind Transport) so SendResult stays Result<(), EncryptSendError> for every caller, and each one’s source wraps the same shared Arc<EncryptSendError> via a small SharedSendFailure type — so all of them can still downcast to the one real cause instead of a re-worded copy. A batch that coalesces more than one frame logs at debug level (noise: coalesced {n} frames into one {bytes}-byte write) — the only externally visible sign that batching happened. Location: src/socket/noise_socket.rs:119-289

Encryption

encrypt_frame_into (renamed from the pre-batching process_send_job) only encrypts and frames a single plaintext into the shared out_buf — it no longer performs the transport write itself, since a batch’s write happens once, after every already-queued frame has been folded in.

Small Messages (≤16KB)

Encrypted inline to avoid thread pool overhead:
Location: src/socket/noise_socket.rs:309-317

Large Messages (>16KB)

Offloaded to the runtime’s blocking pool. The Bytes plaintext is moved into the blocking closure (a refcount bump) rather than copied:
Location: src/socket/noise_socket.rs:291-337
The 16KB threshold is chosen based on benchmarking. Smaller messages benefit from inline encryption (no thread spawning overhead), while larger messages benefit from parallel execution. The write counter is burned as soon as the framed ciphertext is committed to out_buf — at encrypt time, before the batch’s single transport.send() — not when the write is confirmed, since a failed send says nothing about how many bytes the peer actually received.

Write batching (frame coalescing)

wacore/noise/src/framing.rs splits the old encode_frame_into into two functions:
  • encode_frame_into — clears out first, then appends. Unchanged behavior for existing one-frame-per-buffer callers.
  • append_frame_into — the same encoder without the clear, so several frames can be laid out back to back. encode_frame_into is now implemented as clear + append_frame_into.
This is what makes coalescing possible: out_buf accumulates every already-queued frame across successive encrypt_frame_into calls before the sender task hands it to transport.send() once. On the wire, this is functionally the same thing the official WhatsApp Web client already does — WAFrameSocket.sendFrame (WAWeb/Open/ChatSocket.js) concatenates its handshake prefix and payload into one buffer before a single requestSend(), and its receive side already loops multiple frames out of one buffered chunk.

Send API

plaintext is a bytes::Bytes, and SendResult = Result<(), EncryptSendError> — there is no buffer round-trip; the caller doesn’t get anything back to reuse. A caller has no way to tell from this API alone whether its frame was written alone or coalesced into a batch with others queued at the same time.
encrypt_and_send split into enqueue and await halves (#1139). encrypt_and_send is unchanged from a caller’s perspective — it’s now just enqueue_send immediately followed by await_send — but the split lets a multi-frame caller enqueue every frame before awaiting any of them, which is what burst sends below need to reach the sender task queued together instead of one completion apart.
Location: src/socket/noise_socket.rs:416-460

Burst sends

Client::send_raw_bytes_burst (src/client/messaging.rs) sends several pre-marshaled stanzas as one burst and returns a result per stanza in the same order, for the two callers — the ack worker and the receipt worker — that already have more than one stanza ready at once. It exists because a worker that awaits each encrypt_and_send before starting the next never has two frames queued at the same time, so the coalescing described above never fires for it: batching only helps a caller that hands over the whole burst up front. A single-frame burst — the common case — is just encrypt_and_send. A multi-frame burst enqueues every frame via enqueue_send before awaiting any of them, holding the returned receivers in a SmallVec<[_; MAX_INLINE_BURST]> (MAX_INLINE_BURST is 4, matching the ack and receipt workers’ own burst caps, MAX_ACK_BURST and MAX_RECEIPT_BURST) so a real burst never spills to the heap. results is a caller-owned, reused Vec rather than a return value, and frames is always fully drained — both retain their allocation across calls instead of being rebuilt per send.
Before #1139, a multi-frame burst allocated its result storage anyway (#1138). The out-parameter results above was added by #1137 to remove the single-frame burst’s per-send allocation, but the multi-frame path still called futures::future::join_all over the sends and copied its returned Vec into resultsjoin_all allocates storage for the futures it joins and a fresh Vec for their outputs, so that path kept paying what it paid before the out-parameter existed. Splitting encrypt_and_send into enqueue and await halves removed both allocations for a burst up to MAX_INLINE_BURST frames.
If an enqueue fails partway through — the sender task is gone, which no later frame recovers from either — the remaining frames are still drained from frames and reported failed in their own positions, so results stays aligned with the frames the caller handed in rather than shifting out of step. Location: src/client/messaging.rs

Decryption

Decryption is synchronous because:
  1. Frames arrive sequentially in the transport receiver
  2. Decryption is fast (AES-GCM hardware acceleration)
  3. No ordering concerns (unlike send)
Unaffected by write batching: coalescing only changes how outbound frames are packed into a write, not how inbound frames are decoded off the wire (FrameDecoder, covered below, already loops multiple frames out of one buffered chunk). Location: src/socket/noise_socket.rs:462-474

Cleanup

The sender task is aborted on drop automatically: _sender_task_handle is an AbortHandle whose own Drop impl does the work, so NoiseSocket no longer needs a manual Drop implementation. Location: src/socket/noise_socket.rs:65,113-116

Frame Protocol

Messages are framed before encryption:
Location: wacore/src/framing/mod.rs:10-30

Frame Format

Frame length limits:
  • Maximum frame size: 16MB (enforced by framing layer)
  • Typical message frame: < 1KB
  • Media messages: 100KB - 2MB (encrypted metadata)

Frame Decoder

FrameDecoder (in wacore/noise/src/framing.rs) keeps one long-lived accumulation BytesMut and splits complete frames out of it — the shape tokio_util::codec::Decoder uses. Every inbound read goes through a single feed(&[u8]); there is no owned/borrowed split.
Location: wacore/noise/src/framing.rs Why an accumulation buffer instead of adopting each payload. An earlier version had feed_bytes(Bytes), an owned path meant to adopt the transport’s payload allocation zero-copy. It used Bytes::try_into_mut to check whether the decoder’s buffer was empty and the payload had no other references. In practice that fast path never fired. tokio-websockets builds every payload via BytesMut::split_to on its own read buffer, which leaves the payload sharing storage with that read buffer. A shared payload always fails the try_into_mut uniqueness check, so it fell back to copying anyway. The steady-state branch made this worse: it also donated the decoder’s whole buffer downstream via mem::take, forcing a fresh allocation on the very next read. feed_bytes was removed rather than fixed. The accumulation-buffer design amortizes the buffer’s allocation over every frame that fits in a CHUNK_SIZE (1 KiB) chunk, instead of allocating per read. A buffer produced by split_to already uses shared, reference-counted storage, so BytesMut::freeze() further down the receive path (in-place AEAD decryption) becomes a pointer move rather than a fresh allocation. MAX_IDLE_CAPACITY (64 KiB) bounds how much capacity a drained buffer is allowed to keep. MAX_EAGER_RESERVE (64 KiB) caps how far the decoder will grow ahead of an announced-but-not-yet-received frame. This cap matters because the 3-byte length prefix is only the peer’s claim, not evidence of data actually sent — without it, a hostile peer could pin an arbitrary allocation by announcing a large frame and sending almost none of it.

Transport Abstraction

The transport layer abstracts WebSocket implementation:
Location: src/transport/mod.rs

WebSocket implementation

The transport is generic over any AsyncRead + AsyncWrite stream. The from_websocket function wraps an already-upgraded WebSocketStream into a Transport + event channel:
Internally, the WebSocket is split into a write half (guarded by Arc<Mutex>) and a read half (moved to a spawned read_pump task). A watch channel coordinates graceful shutdown between the transport and the read pump. TokioWebSocketTransportFactory handles the default DNS/TCP/TLS connection and delegates to from_websocket. For custom connection strategies (IPv4 preference, TCP keepalive, proxies), call from_websocket directly. Location: transports/tokio-transport/src/lib.rs

Connection Lifecycle

Connect timeout

Both the transport connection and the version fetch are wrapped in a 20-second timeout (TRANSPORT_CONNECT_TIMEOUT), matching WhatsApp Web’s MQTT CONNECT_TIMEOUT and DGW connectTimeoutMs defaults. Without this, a dead network would block on the OS TCP SYN timeout (~60-75s).
The client runs the version fetch and transport connection in parallel using tokio::join!, both under this timeout:
If either times out, the connection attempt fails with a descriptive error (e.g., "Transport connect timed out after 20s"). Location: src/client.rs:108-877

1. Connect

2. Message loop (read loop)

The read_messages_loop runs inline on whichever task drives the connection — run()’s loop, or (as of PR #1258) the caller of Connection::read_until_disconnected() — not spawned, so the keepalive loop (which runs in a separate spawned task) is never blocked by frame processing. This eliminates a class of bugs where a long-running batch of frames (e.g., offline sync) could starve the keepalive timer.
Key design decisions:
  • select_biased! — the shutdown listener has priority over transport events, ensuring the loop exits promptly when shutdown_notifier fires (e.g., on stream error or disconnect)
  • Batch timestamp refresh — after processing multiple frames, last_data_received_ms is updated again so the keepalive loop sees the batch completion time rather than the arrival time. This prevents false-positive dead-socket triggers during large offline sync batches that take seconds to drain
  • Cooperative yielding — the loop yields to the runtime every yield_frequency() frames, preventing a large burst of frames from monopolizing the executor

Inline vs concurrent node processing

Frame decryption is always sequential (noise protocol counter ordering), but node processing uses a hybrid strategy:
The marshal_auto function automatically selects an appropriate buffer capacity based on the node’s characteristics. For nodes exceeding certain thresholds (24+ attributes, 64+ children, or 8KB+ scalar content), it pre-estimates the capacity to avoid reallocations. For typical small nodes, it uses the default 1024-byte capacity. This replaces the previous manual Vec::with_capacity(1024) + marshal_to pattern.
Key cleanup actions include invalidating chat lanes (so stale message processing workers don’t survive with outdated crypto state), clearing the signal cache, draining IQ response waiters, and resetting offline sync state. See disconnect cleanup for the full list.

Connection state tracking

The client tracks whether the noise socket is established using a dedicated AtomicBool (is_connected) rather than probing the noise socket mutex. This design prevents a TOCTOU race where try_lock() on the mutex fails due to contention (e.g., during frame encryption), not because the socket is absent — which previously caused is_connected() to return false on live connections, silently dropping receipt acks. State transitions: The Release/Acquire ordering ensures that any task reading is_connected() == true is guaranteed to see the noise socket as Some, and any task reading false after cleanup sees the socket as None.
This is critical for the keepalive loop and stanza acknowledgment, both of which call is_connected() to decide whether to send data. Under the old try_lock() approach, concurrent send_node() calls holding the mutex would cause false negatives, leading to skipped keepalive pings or dropped ack stanzas.

Error Handling

Socket Errors

EncryptSendError is one struct with a kind and a single source: anyhow::Error field, not a distinct payload per kind — there are no plaintext_buf/out_buf fields to recover. For Crypto/Transport/Framing/Join, source is the real downstream error: Cipher wraps a NoiseError (from wacore::handshake), which itself carries a typed CryptoProviderError source, and walking the chain with std::error::Error::source() lets callers downcast to the original AES-GCM, libsignal, or binary-protocol error without parsing strings. Poisoned’s source is different in kind: there is no downstream failure to wrap, since the poisoning is detected in-process rather than reported by a lower layer, so its source is a fixed explanatory message ("noise sender disabled after a transport failure; reconnect to rekey") rather than something to downcast. is_transport_unavailable() returns true for Transport, ChannelClosed, and Poisoned — all three mean the same thing to a caller: stop retrying on this connection and reconnect. Unlike an older version of this API, no variant returns buffers for reuse — there is no into_buffers() method. This matches the Send API above: encrypt_and_send takes an owned bytes::Bytes and gives nothing back on either the success or the error path, so there is nothing for a caller to recover from a failed send. Location: src/socket/error.rs

Send poisoning after a transport failure

A transport.send() call that returns Err says nothing about how much of the frame reached the peer — it may have been fully consumed, partially written, or not sent at all. Because the write counter feeds directly into the AES-GCM nonce, that ambiguity can’t be resolved safely:
  • Reusing the counter on the next frame reuses the nonce under the same write key. Two ciphertexts under one key/nonce pair leak both plaintexts.
  • Skipping the counter instead desyncs the peer’s read counter, since the peer’s actual state depends on whether it saw the failed frame.
Both recovery paths are unrecoverable in-band, so the sender task instead poisons itself on the first transport error:
  1. The write counter is incremented at encrypt time, inside encrypt_frame_into, before the frame joins the batch’s shared buffer — not when the batch’s single transport.send() returns — so “a counter value is never used twice” holds regardless of whether the send later fails.
  2. The first Transport-kind error flips an in-memory poisoned flag on the sender task and calls transport.disconnect(). Closing the transport drives the existing disconnect/reconnect path, since nothing else would otherwise notice a write-only failure on an otherwise-open socket.
  3. Every send after that point is rejected immediately with EncryptSendError::poisoned() — it is never encrypted or written to the wire. The connection can only become usable again by reconnecting, which performs a fresh Noise handshake and installs new keys and counters.
Crypto and Framing errors do not poison the sender: both are detected before any byte reaches the wire, so the counter and keystream are untouched and the connection stays usable for the next send. Interaction with write batching: since the sender coalesces several queued frames into one write (see Write batching), a poisoning transport error fails every frame in that batch, not just one — each waiter gets its own EncryptSendError whose source downcasts to the same shared cause (see above), rather than a separately-worded copy. A crypto/framing error, by contrast, only fails the one job that produced it; every frame already folded into the buffer ahead of it still goes out.

Stream error handling

When the server sends a <stream:error> stanza, it is processed inline (not spawned concurrently) because stream errors are critical for connection state. The StanzaRouter dispatches the node to a StreamErrorHandler, which calls Client::handle_stream_error(). Each stream error sets is_logged_in = false and fires the shutdown_notifier to exit the keepalive loop and other background tasks. Error code behavior:
Before v0.6 the client treated every unknown stream-error code as fatal: it set is_logged_in = false, fired shutdown_notifier, and ended up in a “zombie” state where the connection survived but background tasks (keepalive, prekey upload) refused to run. v0.6 keeps the fatal set listed above and downgrades everything else (including code 500 and code-less <stream:error><ack/> routing wrappers) to a warning. is_logged_in stays true, the transport stays open, and any reconnect is driven by the server’s <xmlstreamend/> — not by the stream-error handler itself.
<xml-not-well-formed/> is checked before the ack-wrapper case above and is an exception to that downgrade: WA Web (Handle/StreamError.js) treats it as “bad xml, closing socket” (CLOSE_SOCKET). A malformed frame desyncs the stream, so the client proactively recycles the socket (is_logged_in = false, should_disconnect = true) instead of waiting for the server to end it. It counts toward the reconnect backoff like a normal disconnect — it is not an expected disconnect (515), so no immediate reconnect. Like every other case in this code-less/unknown-code branch, an Event::StreamError is still dispatched before the socket is closed — consumers see the event even though the connection is being force-recycled rather than gracefully downgraded.
Processing pipeline:

Stanza acknowledgment

The client automatically sends <ack/> nodes in response to incoming stanzas (messages, receipts, notifications, calls). The ack construction follows WhatsApp Web and whatsmeow behavior: Class gating for newsletter and status: Newsletter and status@broadcast inbound messages produce a <ack class="message"/> instead of a <receipt>, matching WA Web’s WAWebSendMsgAckOrReceiptJob. Regular DMs and groups continue to skip the message-class ack because they ride on the regular receipt path. Ack attributes: type attribute rules: The type attribute is handled differently depending on the stanza:
Sending incorrect type attributes in ack stanzas can cause the server to issue <stream:error> disconnections. The library handles this automatically — you don’t need to build ack nodes manually.
Location: src/client.rs (build_ack_node, is_encrypt_identity_notification)

Fibonacci backoff

The reconnection backoff follows the Fibonacci sequence, matching WhatsApp Web’s behavior:
For rate-limited errors (429), the client increments the backoff counter by 5 before the normal increment, causing the delay to jump significantly on the next reconnection attempt. Since #1263, the client also dispatches Event::StreamError for 429 — WA Web gives its UI no signal here at all (429 is outside the 500..600 range its handler special-cases), but an embedder has no UI to fall back on, so the rate limit is reported like every other coded stream error. Stability-gated reset. The backoff counter does not reset to its base immediately on a successful <success> authentication. Instead, connected_at_ms records the auth time, and the counter only resets when the next disconnect finds the connection was stable for at least STABLE_CONNECTION_RESET_MS (30s) — matching WA Web’s resetDelay. A connection that authenticates and then immediately drops keeps escalating the backoff instead of resetting to 1s and retrying in a tight loop. An explicit penalty applied during the connection — a 429 rate-limit or a manual Client::reconnect() — sets backoff_reset_suppressed, which survives even a stable (≥30s) connection and prevents the next disconnect from erasing that deliberate backoff step (matching WA Web’s cancelReset()). The suppression flag is cleared on the next successful <success>, so it does not carry over indefinitely. An expected disconnect (e.g., 515) resets connected_at_ms to 0 so a later failed connect cannot read the prior cycle’s stale timestamp as “stable.”

Retry strategy

The run() method handles reconnection automatically:
As of PR #1258, run()’s per-connection work (read loop, teardown, Disconnected dispatch) is shared with a directly-driven Connection::read_until_disconnected() via an internal drive_connection() helper, so the two paths cannot drift on what a connection ending means.
self.shutdown_signal() above is Client::shutdown_signal(), the client’s own terminal-shutdown listener — not the crate-level whatsapp_rust::shutdown_signal() helper that waits for SIGINT/SIGTERM.This races the wait against that terminal signal, not the per-connection one keepalive_loop watches. The per-connection signal fires on every disconnect the loop exists to reconnect from. Watching it here would collapse every backoff into a no-op. Only disconnect(), logout(), and signal_shutdown_sync() fire the terminal signal and cut the backoff short. A routine disconnect-and-retry does not.

Keepalive and dead socket detection

The keepalive loop monitors connection health, matching WhatsApp Web’s behavior precisely.

Constants

Timestamp safety

All timestamp conversions from now_millis() (which returns i64) to u64 are guarded with .max(0) before casting. This prevents silent wrap-around on negative clock values (e.g., from NTP corrections or virtualized environments) that would otherwise produce incorrect timestamps.

Keepalive loop behavior

The loop runs every 15-30 seconds (randomized, matching WA Web’s 15 * (1 + random()) formula) and performs these checks in order:
  1. Skip if recently active — if data was received within KEEP_ALIVE_INTERVAL_MIN (15s), the connection is proven alive; skip the ping and reset the error counter
  2. Send keepalive ping — sends the ping before the dead-socket check so that a successful pong updates last_data_received_ms and prevents false-positive dead-socket detection on idle-but-healthy connections
  3. RTT-adjusted clock skew — on pong, calculates server time offset using the midpoint formula: (startTime + rtt/2) / 1000 - serverTime, matching WA Web’s onClockSkewUpdate
  4. Skip ping when IQ pending — if there are already pending IQ responses, the connection is implicitly being tested; skip the explicit ping

Dead socket detection

Dead socket detection mirrors WA Web’s deadSocketTimer.onOrBefore pattern, which keeps the earliest armed deadline rather than the most recent one:
  • Not armed if nothing has been sent since the last receive (the anchor is zero)
  • Cancelled if data was received after the anchor was armed
  • Fires if DEAD_SOCKET_TIME (20s) has elapsed since the anchor with no receive since
The watchdog is anchored to SessionStats::first_send_since_recv_ms — the first send since the last receive, not the most recent send. record_frame_sent only stores a new anchor when the current one is unset or stale (<= the last-received timestamp); once armed, further sends leave it in place. Every receive resets the anchor to zero, and the next send re-arms it. Anchoring on the most recent send instead (the pre-fix behavior) let continued outgoing traffic — messages, receipts, presence — keep pushing the deadline forward, hiding a half-open socket (a peer that silently disappeared while writes still buffer and reads hang) for as long as the app kept emitting frames. The dead-socket check runs on every keepalive tick — not just after a failed ping. This catches scenarios where pending IQs caused the ping to be skipped, or where the ping “succeeded” but the connection died immediately after. When a dead socket is detected, the client calls reconnect_immediately() and exits the keepalive loop. WA Web’s deadSocketTimer.onOrBefore (WA/Shift/Timer.js) arms on the first callStanza after a receive and is cancelled by parseAndHandleStanza; subsequent sends never push the deadline back out. The keepalive loop approximates this by checking is_dead_socket_at(first_send_since_recv, last_recv, now) unconditionally each iteration, where first_send_since_recv is the armed-anchor value described above. The tick reads the clock once into now and evaluates both the dead-socket check and the elapsed-time log message against that single instant, rather than re-reading the clock for each. There is no last_data_sent_ms field — nothing reads a “most recent send” timestamp, only the armed anchor.

Error classification

Keepalive errors are classified exhaustively (compile-time enforced for new error variants):

Periodic maintenance

Approximately every 12 keepalive ticks (~5 minutes), the keepalive loop runs background cleanup of expired sent messages from the database, based on CacheConfig::sent_message_ttl_secs.

Performance Considerations

Buffer Sizing

The sender task’s outbound batch buffer (out_buf) starts at BytesMut::with_capacity(OUT_BUF_IDLE_CAPACITY) — 4096 bytes — and grows to whatever a batch needs to hold. Because out_buf.split() hands the written bytes to the transport without copying, that growth used to be permanent: once a single large stanza (a media-sized frame, say) pushed the buffer past its idle size, the allocation never shrank back down, so any socket that had ever sent one large frame carried the high-water-mark capacity — measured at 60 KiB resident per socket, against 8 KiB for a socket that only ever sent small frames — for the rest of the connection.
The batch buffer now shrinks back after a burst ends (#1246). After SMALL_BATCHES_BEFORE_SHRINK (32) consecutive small batches following a large one, the sender task replaces out_buf with a fresh BytesMut::with_capacity(OUT_BUF_IDLE_CAPACITY), freeing the grown allocation. The counter only advances on a small batch and resets to zero on every large one, so a burst spread across many batches is never interrupted mid-flight to reallocate — only a connection that sends enough small batches after a burst pays the (one-time) regrowth cost the next time it sends something large.
This buffer isn’t a dedicated line item in either report. memory_report()’s named collections don’t include it — it’s local to the spawned sender task, with no handle anything outside that task can read. resource_report() is less absolute: the sender task is spawned through runtime.spawn(), so if the host wires up BotBuilder::with_alloc_meter, this buffer’s growth and shrink allocations are folded into the client’s aggregate alloc churn like any other task allocation — just not attributed to this buffer by name, and not as a live retained-capacity figure. Location: src/socket/noise_socket.rs (OUT_BUF_IDLE_CAPACITY, SMALL_BATCHES_BEFORE_SHRINK, should_release_batch_buffer)

Write batching

The sender task coalesces whatever send jobs are already queued (never blocking to wait for more) into one encrypted buffer and issues a single transport.send() for the batch. Coalescing stops at 16 frames or 64 KiB, whichever comes first — except a single frame already larger than 64 KiB, which still goes out alone rather than being truncated. See Write batching (frame coalescing) under NoiseSocket for the mechanics. Measured against a 120k-message/12k-per-second pingpong harness, this cut write syscalls per message by ~8.7% and allocator calls by ~0.76%; CPU impact was not statistically significant in that workload. The gain scales with how many independent producers (replies, delivery receipts, stanza acks) happen to queue within the same scheduler tick — a lone frame is written exactly as before.

SIMD Encryption

The Noise cipher uses hardware AES acceleration when available:

Zero-Copy Patterns

Send path — reuse the marshal and output buffers across sends:
Receive path — every inbound payload is copied once into FrameDecoder’s accumulation buffer, but that buffer’s allocation is amortized over every frame that fits in a chunk rather than paid per read:
See Frame Decoder for why this amortized-allocation design replaced an earlier per-payload zero-copy adoption path (feed_bytes) that never actually avoided a copy in practice.

Testing

Mock Transport

Location: src/transport/mock.rs

Test Cases

Key test scenarios:
queued_frames_leave_in_one_write_in_counter_order and a_batch_never_overshoots_the_byte_ceiling use a GatedTransport whose send() blocks on a semaphore, so a test can queue every job into the sender’s channel before releasing any of them — the precondition for exercising coalescing at all. Location: src/socket/noise_socket.rs:381-1082

References