Skip to main content

Overview

whatsapp-rust uses a strict state management architecture to ensure consistency and prevent race conditions. All device state modifications must go through the PersistenceManager using the DeviceCommand pattern.
Critical: Never modify Device state directly. Always use DeviceCommand + PersistenceManager::process_command() for writes, or get_device_snapshot() for reads.

Architecture

The state management system has three main components:

Device State

The Device struct holds all client state (defined in wacore::store::device):
Location: wacore/src/store/device.rs server_cert_chain caches the verified server cert chain returned by a successful XX (or XXfallback) handshake. Device exposes the leaf key on the next connect so do_handshake can attempt Noise IK and skip a server round trip. See WebSocket & Noise Protocol — Noise Protocol Handshake.

Serialization details

The Device struct uses several custom serde strategies: The account field holds an ADVSignedDeviceIdentity (a buffa-generated protobuf type that lacks serde::Deserialize). The account_serde module bridges this gap by encoding the protobuf struct to bytes on serialization and decoding on deserialization:
The #[serde(default)] attribute on account ensures backward compatibility — data serialized before this field existed will deserialize with account: None.

PersistenceManager

The PersistenceManager is the gatekeeper for all state changes.

Architecture

device_snapshot is rebuilt under the device write guard inside modify_device — the single mutation funnel — so it is always coherent with committed state. Location: src/store/persistence_manager.rs

Key Methods

Read-Only Access

Location: src/store/persistence_manager.rs

State Modification

Location: src/store/persistence_manager.rs

Command Processing

Location: src/store/persistence_manager.rs:145-150

Background Saver

The persistence manager runs a background task that periodically saves dirty state:
How it works:
  1. Wakes up when notified OR every interval (typically 30s)
  2. Checks if state is dirty (dirty flag)
  3. If dirty, serializes device state and saves to database
  4. Clears dirty flag
Location: src/store/persistence_manager.rs:123-140

Initialization

Location: src/store/persistence_manager.rs:23-55

DeviceCommand Pattern

The DeviceCommand enum defines all possible state mutations:
Location: wacore/src/store/commands.rs
DeviceCommand cannot derive Debug once a variant holds a KeyPairKeyPair deliberately omits Debug so private key material never formats into logs. The enum has a hand-written Debug impl instead; SetSignedPreKey’s logs only id and rotation_ms, redacting the key pair and signature via finish_non_exhaustive().

Why Commands?

The command pattern provides:
  1. Type safety: All state changes are explicitly defined
  2. Auditability: Easy to log/trace state mutations
  3. Testability: Commands can be tested in isolation
  4. Consistency: Single code path for all modifications
  5. Future compatibility: Easy to add undo/redo or migration logic

Applying Commands

Commands are applied via pattern matching:
SetLidMigrated gates outbound DM wire addressing (LID vs. PN) on whether the account is 1:1-LID-migrated. Runtime paths only ever set truefalse is reserved for pair-success, where a fresh pairing of a different account must not inherit the previous account’s migration state. See Authentication — one-to-one LID migration state.
Location: wacore/src/store/commands.rs

Usage Patterns

Reading device state

get_device_snapshot() is a plain fn (not async) and returns Arc<Device> — a refcount bump, no Device clone. Borrow fields directly from the held Arc; clone individual fields only when ownership escapes the scope. Don’t hold the snapshot across an await point — doing so pins an old allocation while the live state may have advanced.

Modifying device state (simple)

For simple state changes, use commands:

Modifying device state (complex)

For complex logic involving multiple fields or conditionals:
Keep the closure passed to modify_device as short as possible. It holds a write lock on the device state, blocking all other modifications.

Concurrency Patterns

RwLock Semantics

Device state uses two separate locks with different roles:
  • device_snapshot (std::sync::RwLock<Arc<Device>>): read by get_device_snapshot(). Readers never contend with writers — they take a brief std::sync read lock to clone the Arc, then release immediately. The snapshot is updated inside the tokio::sync::RwLock write guard in modify_device, so readers always see fully committed state.
  • device (tokio::sync::RwLock<Device>): write-locked inside modify_device(). Only store-adapter code that needs &mut Device trait access (get_device_arc()) takes a read lock here directly.
In practice: get_device_snapshot() is contention-free for readers, and the tokio write lock is only held during actual mutations (rare).

Session locks and message queues

The Client uses two cache-based lock mechanisms for per-chat and per-device serialization:
Both use PortableCache with capacity-based eviction (configurable via CacheConfig), so stale entries are automatically cleaned up. On disconnect, chat_lanes is cleared via the async clear() to drop per-chat queue senders. This causes worker tasks from the old connection to exit via channel close, preventing them from surviving reconnects with outdated Signal session state that would cause decryption failures. See disconnect cleanup for the full list of resources reset on disconnect. Location: src/client.rs

Blocking Operations

CPU-heavy or blocking operations must use spawn_blocking to avoid stalling the async runtime:
For more details on async patterns, see the Architecture guide.

Storage Backend

Backend Trait

The Backend trait is a combination of four domain-specific traits:
See Storage Traits for the full trait definitions. Location: wacore/src/store/traits.rs

SQLite Implementation

The default storage backend uses SQLite with the Diesel ORM. See Storage Traits for details on SqliteStore, including connection pooling, WAL mode, and multi-device support. Location: storages/sqlite-storage/

Serialization

The Device struct derives Serialize and Deserialize (from serde) for persistence. The PersistenceManager handles serializing device state to the backend via the DeviceStore trait’s save() and load() methods.

Debugging State

Database Snapshots

The debug-snapshots feature enables database snapshots for debugging:
This creates a timestamped copy of the database:
Location: src/store/persistence_manager.rs:99-121

Logging

State changes are logged at debug level:
Output:

Best Practices

1. Always use commands for state changes

2. Minimize lock duration

3. Use chat locks for chat-specific operations

4. Offload heavy operations

Cache patching strategy

Beyond device state, whatsapp-rust maintains several in-memory caches (device registry, group metadata, LID-PN mappings) that require real-time updates when server notifications arrive.

Granular patching vs. invalidation

The client uses granular cache patching rather than the simpler invalidate-and-refetch approach. When a notification indicates a change (for example, a new device added or a group participant removed), the client reads the cached value, applies the diff in memory, and writes the updated value back — all without making any network requests. This avoids an extra IQ round-trip per update. If no cache entry exists when the notification arrives, the patch is silently skipped, and the next read fetches authoritative state from the server.

Concurrency model

The get → mutate → insert sequence is not atomic. A concurrent notification for the same key could race and cause one update to be lost. This is acceptable because:
  1. The cache is best-effort — a full server fetch on the next read corrects any drift
  2. Races are rare in practice (device and group notifications for the same user rarely overlap)
  3. Device registry patches persist to the backend store immediately, so even if the cache entry is evicted, the persistent state is correct

Where patching is used

For full details including the write-policy gate, the live-query reconcile path, and data flow, see Granular cache patching.

References

  • Implementation: src/store/persistence_manager.rs
  • Commands: wacore/src/store/commands.rs
  • Device structure: wacore/src/store/device.rs
  • Backend trait: wacore/src/store/traits.rs
  • SQLite backend: storages/sqlite-storage/src/sqlite_store.rs