Overview
whatsapp-rust uses a strict state management architecture to ensure consistency and prevent race conditions. All device state modifications must go through thePersistenceManager using the DeviceCommand pattern.
Architecture
The state management system has three main components:Device State
TheDevice struct holds all client state (defined in wacore::store::device):
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
TheDevice 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:
#[serde(default)] attribute on account ensures backward compatibility — data serialized before this field existed will deserialize with account: None.
PersistenceManager
ThePersistenceManager 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
src/store/persistence_manager.rs
State Modification
src/store/persistence_manager.rs
Command Processing
src/store/persistence_manager.rs:145-150
Background Saver
The persistence manager runs a background task that periodically saves dirty state:- Wakes up when notified OR every
interval(typically 30s) - Checks if state is dirty (
dirtyflag) - If dirty, serializes device state and saves to database
- Clears dirty flag
src/store/persistence_manager.rs:123-140
Initialization
src/store/persistence_manager.rs:23-55
DeviceCommand Pattern
TheDeviceCommand enum defines all possible state mutations:
wacore/src/store/commands.rs
DeviceCommand cannot derive Debug once a variant holds a KeyPair — KeyPair 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:- Type safety: All state changes are explicitly defined
- Auditability: Easy to log/trace state mutations
- Testability: Commands can be tested in isolation
- Consistency: Single code path for all modifications
- 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 true — false 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.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:Concurrency Patterns
RwLock Semantics
Device state uses two separate locks with different roles:device_snapshot(std::sync::RwLock<Arc<Device>>): read byget_device_snapshot(). Readers never contend with writers — they take a briefstd::syncread lock to clone theArc, then release immediately. The snapshot is updated inside thetokio::sync::RwLockwrite guard inmodify_device, so readers always see fully committed state.device(tokio::sync::RwLock<Device>): write-locked insidemodify_device(). Only store-adapter code that needs&mut Devicetrait access (get_device_arc()) takes a read lock here directly.
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
TheClient uses two cache-based lock mechanisms for per-chat and per-device serialization:
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 usespawn_blocking to avoid stalling the async runtime:
Storage Backend
Backend Trait
TheBackend trait is a combination of four domain-specific traits:
wacore/src/store/traits.rs
SQLite Implementation
The default storage backend uses SQLite with the Diesel ORM. See Storage Traits for details onSqliteStore, including connection pooling, WAL mode, and multi-device support.
Location: storages/sqlite-storage/
Serialization
TheDevice 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
Thedebug-snapshots feature enables database snapshots for debugging:
src/store/persistence_manager.rs:99-121
Logging
State changes are logged at debug level: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
Theget → 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:
- The cache is best-effort — a full server fetch on the next read corrects any drift
- Races are rare in practice (device and group notifications for the same user rarely overlap)
- 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.
Related Components
- Signal Protocol - Cryptographic state stored in Device
- Binary Protocol - Protocol messages modify device state
- WebSocket Handling - Connection state in Device
- Storage - Cache patching and pluggable cache stores
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