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:
src/store/
├── persistence_manager.rs  # Central state coordinator
├── commands.rs             # DeviceCommand pattern
├── device.rs              # Device state structure
└── backend/               # Storage backend (SQLite)

Device State

The Device struct holds all client state (defined in wacore::store::device):
#[derive(Clone, Serialize, Deserialize)]
pub struct Device {
    pub pn: Option<Jid>,              // Phone number JID
    pub lid: Option<Jid>,             // Linked Identity JID
    pub registration_id: u32,
    #[serde(with = "key_pair_serde")]
    pub noise_key: KeyPair,           // Noise protocol keypair
    #[serde(with = "key_pair_serde")]
    pub identity_key: KeyPair,        // Signal protocol identity
    #[serde(with = "key_pair_serde")]
    pub signed_pre_key: KeyPair,
    pub signed_pre_key_id: u32,
    #[serde(with = "BigArray")]
    pub signed_pre_key_signature: [u8; 64],
    pub adv_secret_key: [u8; 32],
    #[serde(with = "account_serde", default)]
    pub account: Option<wa::AdvSignedDeviceIdentity>,
    pub push_name: String,
    pub app_version_primary: u32,
    pub app_version_secondary: u32,
    pub app_version_tertiary: u32,
    pub app_version_last_fetched_ms: i64,
    #[serde(skip)]
    pub device_props: wa::DeviceProps,  // Not persisted
    #[serde(default)]
    pub edge_routing_info: Option<Vec<u8>>,
    #[serde(default)]
    pub props_hash: Option<String>,
    #[serde(default)]
    pub next_pre_key_id: u32,
}
Location: wacore/src/store/device.rs

Serialization details

The Device struct uses several custom serde strategies:
FieldStrategyNotes
noise_key, identity_key, signed_pre_keykey_pair_serdeCustom serde for Signal KeyPair types
signed_pre_key_signatureBigArrayHandles fixed-size arrays larger than 32 bytes
accountaccount_serdeBridges prost protobuf types to serde (see below)
device_props#[serde(skip)]Transient, not persisted
edge_routing_info, props_hash, next_pre_key_id#[serde(default)]Backward-compatible optional fields
The account field holds an AdvSignedDeviceIdentity (a prost-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:
pub mod account_serde {
    pub fn serialize<S: Serializer>(
        val: &Option<AdvSignedDeviceIdentity>, s: S,
    ) -> Result<S::Ok, S::Error> {
        // Encodes to protobuf bytes, then wraps as Option<Vec<u8>>
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(
        d: D,
    ) -> Result<Option<AdvSignedDeviceIdentity>, D::Error> {
        // Deserializes Option<Vec<u8>>, then decodes protobuf bytes
    }
}
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

pub struct PersistenceManager {
    device: Arc<RwLock<Device>>,
    backend: Arc<dyn Backend>,
    dirty: Arc<AtomicBool>,
    save_notify: Arc<Notify>,
}
Location: src/store/persistence_manager.rs:11-16

Key Methods

Read-Only Access

// Get a snapshot of device state (cheap clone)
pub async fn get_device_snapshot(&self) -> Device {
    self.device.read().await.clone()
}
Location: src/store/persistence_manager.rs:61-63

State Modification

// Modify device state with a closure
pub async fn modify_device<F, R>(&self, modifier: F) -> R
where
    F: FnOnce(&mut Device) -> R,
{
    let mut device_guard = self.device.write().await;
    let result = modifier(&mut device_guard);
    
    // Mark dirty and notify background saver
    self.dirty.store(true, Ordering::Relaxed);
    self.save_notify.notify_one();
    
    result
}
Location: src/store/persistence_manager.rs:69-80

Command Processing

// Process a device command (preferred for state changes)
pub async fn process_command(&self, command: DeviceCommand) {
    self.modify_device(|device| {
        apply_command_to_device(device, command);
    }).await;
}
Location: src/store/persistence_manager.rs:145-150

Background Saver

The persistence manager runs a background task that periodically saves dirty state:
pub fn run_background_saver(self: Arc<Self>, interval: Duration) {
    tokio::spawn(async move {
        loop {
            tokio::select! {
                _ = self.save_notify.notified() => {
                    debug!("Save notification received.");
                }
                _ = sleep(interval) => {}
            }
            
            if let Err(e) = self.save_to_disk().await {
                error!("Error saving device state: {e}");
            }
        }
    });
}
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

pub async fn new(backend: Arc<dyn Backend>) -> Result<Self> {
    // Ensure device row exists in database
    let exists = backend.exists().await?;
    if !exists {
        let id = backend.create().await?;
        debug!("Created device row with id={id}");
    }
    
    // Load existing state or create new
    let device = if let Some(serializable_device) = backend.load().await? {
        let mut dev = Device::new(backend.clone());
        dev.load_from_serializable(serializable_device);
        dev
    } else {
        Device::new(backend.clone())
    };
    
    Ok(Self {
        device: Arc::new(RwLock::new(device)),
        backend,
        dirty: Arc::new(AtomicBool::new(false)),
        save_notify: Arc::new(Notify::new()),
    })
}
Location: src/store/persistence_manager.rs:23-55

DeviceCommand Pattern

The DeviceCommand enum defines all possible state mutations:
pub enum DeviceCommand {
    SetId(Option<Jid>),
    SetLid(Option<Jid>),
    SetPushName(String),
    SetAccount(Option<wa::AdvSignedDeviceIdentity>),
    SetAppVersion((u32, u32, u32)),
    SetDeviceProps(
        Option<String>,
        Option<wa::device_props::AppVersion>,
        Option<wa::device_props::PlatformType>,
    ),
    SetPropsHash(Option<String>),
    SetNextPreKeyId(u32),
}
Location: wacore/src/store/commands.rs

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:
pub fn apply_command_to_device(device: &mut Device, command: DeviceCommand) {
    match command {
        DeviceCommand::SetId(id) => {
            device.pn = id;
        }
        DeviceCommand::SetLid(lid) => {
            device.lid = lid;
        }
        DeviceCommand::SetPushName(name) => {
            device.push_name = name;
        }
        DeviceCommand::SetAccount(account) => {
            device.account = account;
        }
        DeviceCommand::SetAppVersion((p, s, t)) => {
            device.app_version_primary = p;
            device.app_version_secondary = s;
            device.app_version_tertiary = t;
        }
        // ... handle all variants
    }
}
Location: wacore/src/store/commands.rs

Usage Patterns

Reading Device State

// In async function
let device = persistence_manager.get_device_snapshot().await;
println!("Device JID: {:?}", device.pn);
println!("Push name: {}", device.push_name);
get_device_snapshot() returns a cloned Device. This is efficient because most fields are cheap to clone (strings, numbers). Large data like cryptographic keys use Arc internally.

Modifying Device State (Simple)

For simple state changes, use commands:
use wacore::store::commands::DeviceCommand;

// Update push name
persistence_manager.process_command(
    DeviceCommand::SetPushName("My New Name".to_string())
).await;

// Update props hash
persistence_manager.process_command(
    DeviceCommand::SetPropsHash(Some("new_hash".to_string()))
).await;

Modifying Device State (Complex)

For complex logic involving multiple fields or conditionals:
persistence_manager.modify_device(|device| {
    // Complex mutation logic
    device.push_name = "Updated Name".to_string();
    device.edge_routing_info = Some(new_routing_data);
}).await;
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

The Device is protected by a tokio::sync::RwLock:
  • Multiple readers: get_device_snapshot() can be called concurrently
  • Single writer: modify_device() blocks all other access
  • Writer priority: Pending writes block new reads (avoid reader starvation)

Session locks and message queues

The Client uses two cache-based lock mechanisms for per-chat and per-device serialization:
pub struct Client {
    /// Per-device session locks for Signal protocol operations.
    /// Prevents race conditions when multiple messages from the same sender
    /// are processed concurrently across different chats.
    /// Keys are Signal protocol address strings (e.g., "user@s.whatsapp.net:0")
    pub(crate) session_locks: Cache<String, Arc<async_lock::Mutex<()>>>,

    /// Per-chat lane combining enqueue lock + message queue into a single cached entry.
    /// One cache lookup instead of two per incoming message.
    pub(crate) chat_lanes: Cache<Jid, ChatLane>,
    // ...
}
Both use moka Cache with capacity-based eviction (configurable via CacheConfig), so stale entries are automatically cleaned up. On disconnect, chat_lanes is explicitly invalidated via invalidate_all() 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:
use tokio::task::spawn_blocking;

// Bad: Blocks async runtime
let encrypted = expensive_crypto_operation(&data);

// Good: Offloads to thread pool
let encrypted = spawn_blocking(move || {
    expensive_crypto_operation(&data)
}).await?;
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:
pub trait Backend: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync {}

impl<T> Backend for T 
where 
    T: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync
{}
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:
// In error handler
if let Err(e) = decrypt_message(...) {
    persistence_manager.create_snapshot(
        "decrypt_error",
        Some(error_details.as_bytes())
    ).await?;
    
    return Err(e);
}
This creates a timestamped copy of the database:
chats.db
chats_snapshot_decrypt_error_20260228_143022.db
chats_snapshot_decrypt_error_20260228_143022.txt  (metadata)
Location: src/store/persistence_manager.rs:99-121

Logging

State changes are logged at debug level:
RUST_LOG=whatsapp_rust::store=debug cargo run
Output:
[DEBUG] PersistenceManager: Ensuring device row exists.
[DEBUG] PersistenceManager: Loaded existing device data (PushName: 'Alice')
[DEBUG] Device state is dirty, saving to disk.
[DEBUG] Device state saved successfully.

Best Practices

1. Always Use Commands for State Changes

// Bad: Direct modification
persistence_manager.modify_device(|device| {
    device.push_name = "New Name".to_string();
}).await;

// Good: Use command
persistence_manager.process_command(
    DeviceCommand::SetPushName("New Name".to_string())
).await;

2. Minimize Lock Duration

// Bad: Long lock duration
persistence_manager.modify_device(|device| {
    let data = expensive_calculation(&device.pn);  // Blocks all access!
    device.push_name = data;
}).await;

// Good: Release lock during expensive operation
let device = persistence_manager.get_device_snapshot().await;
let data = expensive_calculation(&device.pn);
persistence_manager.process_command(
    DeviceCommand::SetPushName(data)
).await;

3. Use Chat Locks for Chat-Specific Operations

// Per-chat locks serialize operations on the same chat
// The Client uses session_locks and chat_lanes internally
// to prevent race conditions during message processing.

4. Offload Heavy Operations

use tokio::task::spawn_blocking;

// Crypto operations should use spawn_blocking
let ciphertext = spawn_blocking(move || {
    encrypt_message(&plaintext, &key)
}).await??;

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.
// Internal patching pattern (not a public API)
if let Some(mut cached) = cache.get(&key).await {
    cached.apply_change(notification_data);
    cache.insert(key, cached).await;
}

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

CachePatched onPersistedFallback
Device registryDevice add/remove/update notificationsYes (backend store)Hash-only notifications trigger full invalidation
Group metadataParticipant add/remove notifications and API callsNo (cache-only)leave() and cache eviction trigger server re-fetch
LID-PN mappingsUsync, peer messages, device notificationsYes (backend store)Timestamp conflict resolution prevents stale overwrites
For full details including patching methods 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