Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ sha1 = { version = "0.10.6", default-features = false }
sha2 = { version = "0.10.6", default-features = false }
thiserror = "2.0.17"
tokio = { version = "1.48.0", default-features = false }
uuid = { version = "1", default-features = false }

# Internal workspace crates
wacore = { path = "./wacore", default-features = false, version = "0.4.1" }
Expand Down Expand Up @@ -132,7 +133,7 @@ whatsapp-rust-tokio-transport = { path = "./transports/tokio-transport", version
whatsapp-rust-ureq-http-client = { path = "./http_clients/ureq-client", version = "0.4.1", optional = true }

[dev-dependencies]
uuid = { version = "1.0", features = ["v4"] }
uuid = { workspace = true, features = ["v4"] }

[[example]]
name = "benchmark"
Expand Down
6 changes: 0 additions & 6 deletions src/appstate_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@
pub use wacore::appstate::Mutation;
pub use wacore::appstate_sync::{AppStateProcessor, AppStateSyncDriver, AppStateSyncError};

// The following re-exports are used by the test module
#[allow(unused_imports)]
use wacore::appstate::hash::HashState;
#[allow(unused_imports)]
use wacore::appstate::patch_decode::{PatchList, WAPatchName};

#[cfg(test)]
mod tests {
use super::*;
Expand Down
33 changes: 20 additions & 13 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,11 @@ pub struct Client {
/// and DB writes are deferred to flush() after each message is processed.
pub(crate) signal_cache: Arc<crate::store::signal_cache::SignalStoreCache>,

/// Global semaphore that limits message processing concurrency.
/// During offline sync: permits=1 (sequential, like WA Web's allChatQueue)
/// After offline sync: permits=N (parallel per-chat processing)
/// Wrapped in std::sync::Mutex to allow replacing on reconnect.
/// Limits message processing concurrency (1 permit during offline sync, N after).
/// Wrapped in Mutex to allow replacing on reconnect.
pub(crate) message_processing_semaphore: std::sync::Mutex<Arc<async_lock::Semaphore>>,
/// Bumped on every semaphore swap so stale Arc clones are rejected.
pub(crate) message_semaphore_generation: Arc<AtomicU64>,

/// Per-device session locks for Signal protocol operations.
/// Prevents race conditions when multiple messages from the same sender
Expand Down Expand Up @@ -557,6 +557,7 @@ impl Client {
message_processing_semaphore: std::sync::Mutex::new(Arc::new(
async_lock::Semaphore::new(1),
)),
message_semaphore_generation: Arc::new(AtomicU64::new(0)),
// Coordination caches: capacity-only eviction, no TTL/TTI.
// These hold live mutexes and channel senders; time-based eviction
// while tasks hold references would silently break serialisation.
Expand Down Expand Up @@ -1012,10 +1013,17 @@ impl Client {
self.retried_group_messages.invalidate_all();
// Clear signal cache so stale state doesn't leak across connections
self.signal_cache.clear().await;
// Reset message processing semaphore to 1 permit (sequential mode for next offline sync).
// Old workers holding the previous semaphore Arc will finish normally.
*self.message_processing_semaphore.lock().unwrap() =
Arc::new(async_lock::Semaphore::new(1));
// Reset semaphore to 1 permit for next offline sync.
// Generation bump invalidates stale Arc clones. Block scopes the !Send MutexGuard.
{
self.message_semaphore_generation
.fetch_add(1, Ordering::SeqCst);
let mut guard = match self.message_processing_semaphore.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
*guard = Arc::new(async_lock::Semaphore::new(1));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
// Reset dead-socket timestamps so stale values from the previous
// connection don't trigger an immediate reconnect on the next one.
self.last_data_received_ms.store(0, Ordering::Relaxed);
Expand Down Expand Up @@ -3783,11 +3791,10 @@ mod tests {

let elapsed = start.elapsed();
// Count available permits by trying to acquire non-blockingly
let semaphore = client
.message_processing_semaphore
.lock()
.expect("message_processing_semaphore poisoned")
.clone();
let semaphore = match client.message_processing_semaphore.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
let mut guards = Vec::new();
while let Some(guard) = semaphore.try_acquire() {
guards.push(guard);
Expand Down
14 changes: 9 additions & 5 deletions src/client/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ impl Client {
// During offline sync, permits=1 serialized all message processing.
// Replace with a new semaphore with 64 permits for concurrent processing.
// Old workers holding the previous semaphore Arc will finish normally.
*self
.message_processing_semaphore
.lock()
.expect("message_processing_semaphore poisoned") =
std::sync::Arc::new(async_lock::Semaphore::new(64));
{
self.message_semaphore_generation
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let mut guard = match self.message_processing_semaphore.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
*guard = std::sync::Arc::new(async_lock::Semaphore::new(64));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Keep the new generation paired with the new 64-permit semaphore.

This has the same atomicity bug as cleanup_connection_state(): the generation is advanced before the mutex stops exposing the old 1-permit semaphore. That leaves a window where stale Arcs can still look current during the offline-sync → parallel-processing handoff. Swap first, then bump while the guard is still held. I’d also extract this into a small helper so both swap sites stay consistent.

Proposed fix
             {
-                self.message_semaphore_generation
-                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
                 let mut guard = match self.message_processing_semaphore.lock() {
                     Ok(g) => g,
                     Err(poisoned) => poisoned.into_inner(),
                 };
                 *guard = std::sync::Arc::new(async_lock::Semaphore::new(64));
+                self.message_semaphore_generation
+                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/sessions.rs` around lines 41 - 47, The generation bump races with
exposing the new 64-permit semaphore: in sessions.rs the code currently
increments message_semaphore_generation before replacing
message_processing_semaphore, allowing stale Arcs to appear current; to fix,
acquire the mutex, replace the Arc with the new async_lock::Semaphore::new(64)
while still holding the guard, then increment message_semaphore_generation
(fetch_add) before releasing the guard so the swap and bump are atomic from
readers' perspective; extract this sequence into a helper (e.g.,
swap_message_semaphore or set_message_semaphore_generation) and call it from
both this site and cleanup_connection_state() to keep behavior consistent.

}

self.offline_sync_notifier.notify(usize::MAX);

Expand Down
11 changes: 4 additions & 7 deletions src/features/chat_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ use crate::client::Client;
use anyhow::Result;
use chrono::DateTime;
use log::debug;
use std::sync::Arc;
use wacore::appstate::patch_decode::WAPatchName;
use wacore::types::events::{
ArchiveUpdate, ContactUpdate, Event, MarkChatAsReadUpdate, MuteUpdate, PinUpdate, StarUpdate,
Expand Down Expand Up @@ -208,13 +207,13 @@ pub(crate) fn dispatch_chat_mutation(

/// Feature handle for chat management actions.
///
/// Access via `client.chat_actions()` (requires `Arc<Client>`).
/// Access via `client.chat_actions()`.
pub struct ChatActions<'a> {
client: &'a Arc<Client>,
client: &'a Client,
}

impl<'a> ChatActions<'a> {
pub(crate) fn new(client: &'a Arc<Client>) -> Self {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}

Expand Down Expand Up @@ -450,9 +449,7 @@ impl<'a> ChatActions<'a> {

impl Client {
/// Access chat management actions (archive, pin, mute, star).
///
/// Requires `Arc<Client>` because app state mutations need key access.
pub fn chat_actions(self: &Arc<Self>) -> ChatActions<'_> {
pub fn chat_actions(&self) -> ChatActions<'_> {
ChatActions::new(self)
}
}
7 changes: 3 additions & 4 deletions src/features/media_reupload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use crate::client::{Client, ClientError, NodeFilter};
use anyhow::Result;
use log::debug;
use std::sync::Arc;
use std::time::Duration;
pub use wacore::media_retry::MediaRetryResult;
use wacore::media_retry::{
Expand All @@ -34,11 +33,11 @@ pub struct MediaReuploadRequest<'a> {
}

pub struct MediaReupload<'a> {
client: &'a Arc<Client>,
client: &'a Client,
}

impl<'a> MediaReupload<'a> {
pub(crate) fn new(client: &'a Arc<Client>) -> Self {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}

Expand Down Expand Up @@ -115,7 +114,7 @@ impl<'a> MediaReupload<'a> {

impl Client {
/// Access media reupload operations.
pub fn media_reupload(self: &Arc<Self>) -> MediaReupload<'_> {
pub fn media_reupload(&self) -> MediaReupload<'_> {
MediaReupload::new(self)
}
}
9 changes: 4 additions & 5 deletions src/features/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use crate::client::Client;
use crate::store::commands::DeviceCommand;
use anyhow::Result;
use log::{debug, warn};
use std::sync::Arc;
use wacore::iq::contacts::SetProfilePictureSpec;
use wacore::iq::profile::SetStatusTextSpec;
use wacore_binary::builder::NodeBuilder;
Expand All @@ -15,11 +14,11 @@ pub use wacore::iq::contacts::SetProfilePictureResponse;

/// Feature handle for profile operations.
pub struct Profile<'a> {
client: &'a Arc<Client>,
client: &'a Client,
}

impl<'a> Profile<'a> {
pub(crate) fn new(client: &'a Arc<Client>) -> Self {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}

Expand Down Expand Up @@ -163,8 +162,8 @@ impl<'a> Profile<'a> {
}

impl Client {
/// Access profile operations (requires Arc<Client>).
pub fn profile(self: &Arc<Self>) -> Profile<'_> {
/// Access profile operations.
pub fn profile(&self) -> Profile<'_> {
Profile::new(self)
}
}
30 changes: 26 additions & 4 deletions src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,11 +493,33 @@ impl Client {
);
}

// Acquire global processing permit. During offline sync (1 permit), this serializes
// ALL message processing globally, matching WA Web's allChatQueue pattern.
// After offline sync completes, permits are increased for parallel processing.
let semaphore = self.message_processing_semaphore.lock().unwrap().clone();
// Acquire global processing permit (1 during offline sync, N after).
// Generation check rejects stale Arc clones from a previous connection.
let generation = self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst);
let semaphore = match self.message_processing_semaphore.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if generation
!= self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst)
{
log::debug!("Stale semaphore generation, skipping message batch");
return;
}
let _global_permit = semaphore.acquire_arc().await;
// Post-acquire recheck: generation could have changed during the .await
if generation
!= self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst)
{
log::debug!("Semaphore generation changed during acquire, dropping stale permit");
return;
}
Comment on lines +496 to +522

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Retry generation mismatches instead of dropping the batch.

message_semaphore_generation is bumped before the mutex-protected swap in src/client.rs and src/client/sessions.rs, so a change between Line 498 and Line 501 can still leave semaphore pointing at the current Arc. Returning on Line 510 or Line 520 then silently drops a live message during reconnect/offline-sync races, including the same-connection 1→64 transition in complete_offline_sync. This path should retry until it gets a stable (generation, semaphore) pair, and reacquire if the generation changes while awaiting the permit.

🔁 Suggested fix
-        let generation = self
-            .message_semaphore_generation
-            .load(std::sync::atomic::Ordering::SeqCst);
-        let semaphore = match self.message_processing_semaphore.lock() {
-            Ok(guard) => guard.clone(),
-            Err(poisoned) => poisoned.into_inner().clone(),
-        };
-        if generation
-            != self
-                .message_semaphore_generation
-                .load(std::sync::atomic::Ordering::SeqCst)
-        {
-            log::debug!("Stale semaphore generation, skipping message batch");
-            return;
-        }
-        let _global_permit = semaphore.acquire_arc().await;
-        // Post-acquire recheck: generation could have changed during the .await
-        if generation
-            != self
-                .message_semaphore_generation
-                .load(std::sync::atomic::Ordering::SeqCst)
-        {
-            log::debug!("Semaphore generation changed during acquire, dropping stale permit");
-            return;
-        }
+        let _global_permit = loop {
+            let generation = self
+                .message_semaphore_generation
+                .load(std::sync::atomic::Ordering::SeqCst);
+            let semaphore = match self.message_processing_semaphore.lock() {
+                Ok(guard) => guard.clone(),
+                Err(poisoned) => poisoned.into_inner().clone(),
+            };
+
+            if generation
+                != self
+                    .message_semaphore_generation
+                    .load(std::sync::atomic::Ordering::SeqCst)
+            {
+                log::debug!("Semaphore generation changed while cloning, retrying");
+                continue;
+            }
+
+            let permit = semaphore.acquire_arc().await;
+
+            if generation
+                == self
+                    .message_semaphore_generation
+                    .load(std::sync::atomic::Ordering::SeqCst)
+            {
+                break permit;
+            }
+
+            log::debug!("Semaphore generation changed during acquire, retrying");
+        };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Acquire global processing permit (1 during offline sync, N after).
// Generation check rejects stale Arc clones from a previous connection.
let generation = self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst);
let semaphore = match self.message_processing_semaphore.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
if generation
!= self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst)
{
log::debug!("Stale semaphore generation, skipping message batch");
return;
}
let _global_permit = semaphore.acquire_arc().await;
// Post-acquire recheck: generation could have changed during the .await
if generation
!= self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst)
{
log::debug!("Semaphore generation changed during acquire, dropping stale permit");
return;
}
// Acquire global processing permit (1 during offline sync, N after).
// Generation check rejects stale Arc clones from a previous connection.
let _global_permit = loop {
let generation = self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst);
let semaphore = match self.message_processing_semaphore.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
if generation
!= self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst)
{
log::debug!("Semaphore generation changed while cloning, retrying");
continue;
}
let permit = semaphore.acquire_arc().await;
if generation
== self
.message_semaphore_generation
.load(std::sync::atomic::Ordering::SeqCst)
{
break permit;
}
log::debug!("Semaphore generation changed during acquire, retrying");
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 496 - 522, The current logic may drop live
messages when message_semaphore_generation changes; instead make the sequence
robust by looping to obtain a stable (generation, semaphore) pair: inside a
retry loop, lock message_processing_semaphore, clone the Arc semaphore and read
message_semaphore_generation into a local variable, then release the mutex and
call acquire_arc(), and after acquiring re-read message_semaphore_generation —
if it differs from the saved generation, drop/release the acquired permit and
retry the loop; only proceed when the saved generation equals the current
generation so the acquired permit corresponds to the current semaphore. Ensure
this uses the existing symbols message_semaphore_generation,
message_processing_semaphore, acquire_arc, and the local _global_permit
semantics so you don't leak permits on retries.


log::debug!(
"Starting PASS 1: Processing {} session establishment messages (pkmsg/msg)",
Expand Down
15 changes: 10 additions & 5 deletions src/pair_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,11 +317,8 @@ pub(crate) async fn handle_pair_code_notification(client: &Arc<Client>, node: &N
// Get device keys
let device_snapshot = client.persistence_manager.get_device_snapshot().await;

// Prepare encrypted key bundle
// TODO: Store `new_adv_secret` via DeviceCommand::SetAdvSecretKey to enable HMAC
// verification in pair-success. Currently the HMAC check in do_pair_crypto is
// commented out, so pairing works without it. See wacore/src/pair.rs:147-153.
let (wrapped_bundle, _new_adv_secret) = match PairCodeUtils::prepare_key_bundle(
// Prepare encrypted key bundle (includes rotated adv_secret_key)
let (wrapped_bundle, new_adv_secret) = match PairCodeUtils::prepare_key_bundle(
&ephemeral_keypair,
&primary_ephemeral_pub,
&primary_identity_pub,
Expand All @@ -334,6 +331,14 @@ pub(crate) async fn handle_pair_code_notification(client: &Arc<Client>, node: &N
}
};

// Persist rotated adv_secret_key so HMAC verification works in pair-success.
client
.persistence_manager
.process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey(
new_adv_secret,
))
.await;

// Build and send stage 2 IQ
let req_id = client.generate_request_id();
let identity_pub: [u8; 32] = device_snapshot
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ dhat-heap = ["dep:dhat"]
anyhow = { workspace = true }
dhat = { version = "0.3", optional = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
uuid = { version = "1.0", features = ["v4"] }
uuid = { workspace = true, features = ["v4"] }
wacore = { path = "../../wacore" }
whatsapp-rust = { path = "../..", features = ["danger-skip-tls-verify", "debug-diagnostics"] }
whatsapp-rust-sqlite-storage = { path = "../../storages/sqlite-storage" }
Expand Down
5 changes: 2 additions & 3 deletions wacore/libsignal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ ghash = "0.6.0"
hex = { workspace = true }
hkdf = { workspace = true }
hmac = { workspace = true }
itertools = { version = "0.14.0", default-features = false, features = ["use_alloc"] }
log = { workspace = true }
prost = { workspace = true }
rand = { workspace = true }
Expand All @@ -31,12 +30,12 @@ sha1 = { workspace = true }
sha2 = { workspace = true }
subtle = "2.6.1"
thiserror = { workspace = true }
uuid = { version = "1.18.1", default-features = false }
uuid = { workspace = true }
waproto = { workspace = true }
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }

[dev-dependencies]
futures = { version = "0.3", default-features = false, features = ["executor"] }
futures = { workspace = true, features = ["executor"] }
iai-callgrind = { workspace = true }

[[bench]]
Expand Down
Loading
Loading