Skip to content

refactor: runtime agnostic - #393

Merged
jlucaso1 merged 14 commits into
mainfrom
runtime-agnostic
Mar 20, 2026
Merged

refactor: runtime agnostic#393
jlucaso1 merged 14 commits into
mainfrom
runtime-agnostic

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Mar 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Major refactor to make whatsapp-rust runtime-agnostic. The core protocol logic (wacore) no longer depends on Tokio, enabling alternative runtimes (e.g., for WASM/embedded targets). Tokio remains the default runtime via the tokio-runtime feature flag.

What changed

New runtime abstraction (wacore::runtime)

  • Introduced Runtime trait with spawn, spawn_blocking, sleep methods
  • TokioRuntime implementation behind tokio-runtime feature flag
  • AbortHandle abstraction for task cancellation
  • Helper blocking() function for runtime-agnostic spawn_blocking

Crate-level changes

  • wacore: Removed direct Tokio dependency. All async primitives use the Runtime trait. Added wacore::time::now_millis() for portable timestamps.
  • whatsapp-rust: Client, Bot, and all subsystems accept Arc<dyn Runtime>. Builders require .with_runtime().
  • waproto: Build script fix for proto compilation.

Concurrency primitives replaced

  • tokio::sync::Notifyevent_listener::Event (runtime-agnostic)
  • tokio::sync::Mutex/RwLockasync_lock::Mutex/RwLock
  • tokio::time::sleepRuntime::sleep()
  • tokio::task::spawnRuntime::spawn()
  • tokio::task::spawn_blockingRuntime::spawn_blocking()
  • std::time::Instantwacore::time::now_millis() (WASM-compatible)

Portable cache (src/portable_cache.rs)

  • New PortableCache<K, V> that replaces moka::future::Cache when moka-cache feature is disabled
  • TTL/TTI support using wacore::time::now_millis()
  • API mirrors moka's Cache for drop-in replacement
  • Enabled via disabling the moka-cache feature (moka pulls in Tokio internals)

In-memory store (wacore::store::in_memory)

  • New InMemoryBackend implementing the Backend trait
  • Useful for testing and WASM targets where SQLite isn't available

Other changes

  • dashmap dependency removed (replaced by async_lock::RwLock<HashMap>)
  • BotHandle now uses AbortHandle for explicit cancellation
  • Background persistence saver uses Weak<Self> to avoid Arc leaks
  • save_to_disk() restores dirty flag on save failure (prevents data loss)
  • Keepalive and request shutdown listeners register before checking is_running (closes race window)
  • Media reupload feature added (src/features/media_reupload.rs)
  • Public flush() method added to persistence manager

Breaking Changes

1. BotBuilder requires .with_runtime()

Before:

let bot = Bot::builder()
    .with_backend(backend)
    .with_transport(transport)
    .build()
    .await?;

After:

use whatsapp_rust::TokioRuntime; // or your own Runtime impl

let bot = Bot::builder()
    .with_backend(backend)
    .with_transport(transport)
    .with_runtime(TokioRuntime)  // NEW - required
    .build()
    .await?;

2. Client::new() requires runtime parameter

Before:

let client = Client::new(backend, transport, http_client, event_handler).await?;

After:

let runtime: Arc<dyn Runtime> = Arc::new(TokioRuntime);
let client = Client::new(backend, transport, http_client, event_handler, runtime).await?;

3. Runtime trait re-exported from crate root

// Available at:
use whatsapp_rust::Runtime;       // the trait
use whatsapp_rust::TokioRuntime;  // default impl (requires `tokio-runtime` feature)

4. Bot::run() returns BotHandle instead of JoinHandle

Before:

let handle: tokio::task::JoinHandle<()> = bot.run().await?;
handle.await?;

After:

let handle: BotHandle = bot.run().await?;
handle.await?;   // wait for completion
// or
handle.abort();  // explicit cancellation

Important: Dropping BotHandle aborts the run task. If you want the bot to keep running, you must hold onto the handle.

5. Cache type changes (when moka-cache feature is disabled)

If you disable moka-cache, caches use PortableCache instead of moka::future::Cache. The API is compatible but:

  • get_with() / get_with_by_ref() are NOT single-flight (concurrent misses may run init twice)
  • invalidate_all() is best-effort (uses try_write())

6. PersistenceManager::run_background_saver() requires runtime parameter

Before:

persistence_manager.clone().run_background_saver(interval);

After:

persistence_manager.clone().run_background_saver(runtime.clone(), interval);

Known Limitations

  • No final flush on drop: PersistenceManager background saver does not perform a final save when dropped. Dirty state that hasn't been flushed within the interval (30s) will be lost. A proper shutdown() method is planned separately.
  • ureq-client on wasm32: The ureq-client HTTP backend uses blocking I/O and tokio::task::spawn_blocking — it won't work on wasm32 targets. A wasm-compatible HTTP client is needed for full wasm support.
  • PortableCache get_with race: When moka-cache is disabled, concurrent cache misses for the same key can run the initializer multiple times. This affects session lock creation — a single-flight mechanism is planned.

Test Plan

  • cargo fmt && cargo clippy --all-targets passes
  • cargo test --all passes
  • Verify existing bots work with TokioRuntime injected
  • Verify BotHandle cancellation works (abort + drop)
  • Test reconnection flow (keepalive shutdown race fix)

Summary by CodeRabbit

Release Notes

  • New Features

    • Added runtime abstraction layer enabling custom async runtime implementations
    • Introduced media reupload functionality for content redelivery
    • Added portable cache implementation supporting non-Moka environments
  • Breaking Changes

    • Bot::builder() now requires .with_runtime() configuration
    • Bot::run() returns BotHandle instead of JoinHandle
    • Client::new() signature updated to accept runtime parameter
  • Improvements

    • Enhanced WebAssembly platform support
    • Improved concurrency primitive portability across runtime types

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@coderabbitai

coderabbitai Bot commented Mar 19, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduce a runtime abstraction (wacore::runtime::Runtime) with a Tokio-backed implementation and thread it through Bot, Client, NoiseSocket, persistence and blocking helpers; replace many Tokio primitives with runtime + futures/async-channel/async-lock/event-listener equivalents; add multiple wacore modules (time, session, appstate_sync, message_processing, store caches, media/retry, etc.).

Changes

Cohort / File(s) Summary
Workspace & Cargo
Cargo.toml, wacore/Cargo.toml
Rewire workspace features and dependencies: add async-lock, event-listener, futures/std, getrandom/js; enable async-channel std feature; add moka-cache and tokio-runtime features; make moka and tokio optional; forward debug features to wacore.
Runtime core & impl
wacore/src/runtime.rs, src/runtime_impl.rs, src/lib.rs
Add runtime-agnostic Runtime trait, AbortHandle, timeout/blocking helpers and a Tokio-backed TokioRuntime (exported conditionally).
Bot & builder API
src/bot.rs, examples/benchmark.rs, src/main.rs, tests/e2e/*
Introduce typestate BotBuilder requiring .with_runtime(...); Bot::run returns abortable BotHandle; update examples/tests to supply TokioRuntime.
Client & concurrency primitives
src/client.rs, src/client/*, src/request.rs, src/history_sync.rs, src/keepalive.rs, src/client/sessions.rs
Thread runtime into Client; replace tokio spawn/sleep/timeout/oneshot/mpsc/Notify/Semaphore/Mutex/OnceCell with runtime + futures/async_channel/event_listener/async_lock; adjust loops, timeouts, notifier semantics and cache init patterns.
Socket, handshake & noise
src/socket/noise_socket.rs, src/handshake.rs, src/socket/error.rs
Inject runtime into NoiseSocket/handshake; swap send queue to async_channel; use runtime for blocking encryption; change join/abort handling to AbortHandle.
Blocking offload & background tasks
src/download.rs, src/pair_code.rs, src/upload.rs, src/pdo.rs
Replace tokio::task::spawn_blocking with wacore::runtime::blocking/Runtime::spawn_blocking; schedule background tasks on injected runtime and adjust join/error handling.
App-state & session (wacore)
wacore/src/appstate_sync.rs, wacore/src/session.rs, src/appstate_sync.rs
Add AppStateProcessor/AppStateSyncDriver and SessionManager in wacore; replace local session/appstate implementations with wacore re-exports; adapt callers to new runtime-taking constructors.
Message processing & handlers
wacore/src/message_processing.rs, wacore/src/messages.rs, src/message.rs, src/handlers/...
Add pure message-processing helpers and unwrap/util functions in wacore; replace many tokio::spawn with runtime spawn/detach; per-chat channels/locks -> async_channel/async_lock; make many async_trait usages target-conditional.
Caching: moka / portable
src/cache.rs, src/portable_cache.rs, src/cache_store.rs, src/cache_config.rs
Introduce PortableCache and a feature-gated Cache<K,V> alias (moka or portable); update TypedCache generics and code to use crate-local cache abstraction.
Persistence & store modules (wacore)
wacore/src/store/*, src/store/persistence_manager.rs, src/store/signal*.rs, src/store/signal_cache.rs
Add InMemoryBackend, PersistenceManager (with flush and runtime-driven background saver using Event), SignalStoreCache in wacore; adapt store adapters to async_lock primitives and re-export types.
Protocol helpers & stanza
wacore/src/protocol/*, wacore/src/stanza/*, wacore/src/iq/tctoken.rs
Add keepalive/retry helpers and stanza parsing utilities (notification/receipt), plus retry constants and helpers in wacore::protocol.
Time abstraction & replacements
wacore/src/time.rs, many src/* and wacore/* files
Introduce pluggable time provider and Instant wrapper; replace chrono/SystemTime calls across codebase with wacore::time::{now_millis, now_secs, now_utc} and wacore::time::Instant.
Async-trait target tuning
many src/..., wacore/..., storages/..., transports/...
Make async_trait conditional by target: use async_trait(?Send) on wasm32 and plain async_trait elsewhere for many traits/impls.
Tests & helpers
src/test_utils.rs, src/*/#[cfg(test)], tests/*
Widespread test updates to pass Arc::new(TokioRuntime) into constructors; adjust test locks/reads to async_lock and update cache tests to use portable-cache task semantics.

Sequence Diagram(s)

sequenceDiagram
  participant Builder as BotBuilder
  participant Bot
  participant Runtime as wacore::Runtime
  participant Client
  participant Backend

  Builder->>Bot: build(with runtime)
  Bot->>Runtime: spawn(main client loop)
  Runtime->>Client: run client task
  Client->>Runtime: spawn background tasks / blocking(f)
  Runtime->>Backend: perform blocking I/O/DB
  Backend-->>Client: return result
  Client-->>Runtime: sleep/timeout via runtime
  Bot->>Bot: BotHandle.abort() -> Runtime.abort()
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I boxed a future, hopped it on,
A runtime rose, the tasks moved on.
Locks now tiptoe, channels sing,
Caches sprout roots, background things.
Hooray — the rabbit nudged the dawn!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch runtime-agnostic

@claude

claude Bot commented Mar 19, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 1m 53s —— View job


PR Review: refactor: runtime agnostic

  • Gather context and understand changes
  • Review wacore runtime abstraction layer
  • Review store/persistence abstractions
  • Review protocol abstractions (keepalive, retry, session)
  • Review stanza abstractions (notification, receipt)
  • Review changes in main crate (whatsapp-rust)
  • Post final review

Overall this is a well-structured refactor that cleanly separates runtime-agnostic logic into wacore. The Runtime trait is minimal and focused, the AbortHandle pattern is solid, and the timeout/blocking helpers are well-designed. Good use of async-lock, futures, and event-listener instead of Tokio primitives in the core layer.

Here are the issues I found:


Duplicated code not consolidated

1. RetryReason enum is duplicated

RetryReason is defined identically in both wacore/src/protocol/retry.rs:26-50 and src/message.rs:39-63. The src/message.rs version should re-export from wacore instead of redefining it.

Fix this →

2. ms_since and is_dead_socket are duplicated

These functions exist in both wacore/src/protocol/keepalive.rs (public) and src/keepalive.rs:13-40 (private). The src/keepalive.rs versions should use the wacore imports instead.

Fix this →

3. Keepalive constants duplicated

KEEP_ALIVE_INTERVAL_MIN, KEEP_ALIVE_INTERVAL_MAX, KEEP_ALIVE_RESPONSE_DEADLINE, DEAD_SOCKET_TIME are defined in both wacore/src/protocol/keepalive.rs:10-18 and src/keepalive.rs:42-48.


Incomplete runtime decoupling

4. Tokio-specific imports remain in src/client.rs

The main client still uses Tokio-specific types:

  • src/client.rs:16use tokio::sync::watch;
  • src/client.rs:99use tokio::sync::OnceCell;

These should be replaced with runtime-agnostic alternatives (e.g., futures::channel or async-channel for watch, once_cell or async-lock for OnceCell) to fully deliver on the "runtime agnostic" promise.


Two diverging PersistenceManager implementations

5. There are now two PersistenceManager types:

  • wacore/src/store/persistence.rs — uses Device directly (stores/loads the struct as-is)
  • src/store/persistence_manager.rs — uses Device::new(backend) + to_serializable()/load_from_serializable()

Unlike SessionManager and SignalStoreCache which were cleanly moved to wacore with re-exports, the PersistenceManager exists as two different implementations. This creates confusion about which is canonical. If the intent is to eventually replace the main crate's version, consider documenting that in a TODO. If both are needed, they should have distinct names.


Design feedback (non-blocking)

6. AbortHandle abort-on-drop is good but worth documenting the footgun

The AbortHandle aborts on drop, which is the right default for preventing leaked tasks. The detach() method exists as an escape hatch. Consider adding a doc-comment on detach() noting that detached tasks will run until completion even if the parent is dropped — this is easy to miss.

7. blocking() helper panics if spawn_blocking drops the sender

In wacore/src/runtime.rs:112:

rx.await.expect("spawn_blocking task completed")

If the runtime shuts down and drops the spawned task before it completes, this will panic. Consider returning a Result or using unwrap_or_else with a more descriptive message.

8. InMemoryBackend — nice addition

The InMemoryBackend in wacore/src/store/in_memory.rs is well-implemented and will be valuable for testing and FFI bridges. The single-mutex design is simple and correct for this use case.


What's done well

  • The Runtime trait is minimal — only 3 methods (spawn, sleep, spawn_blocking), everything else uses runtime-agnostic crates
  • Clean re-export pattern in src/session.rs and src/store/signal_cache.rs preserves API compatibility
  • TokioRuntime implementation is straightforward and correct
  • Good test coverage in the moved modules — tests stay in the main crate where they can use #[tokio::test]
  • The timeout() function using futures::future::select is a clean runtime-agnostic implementation
  • Stanza helpers (notification.rs, receipt.rs) are well-extracted with thorough test coverage

@github-actions

github-actions Bot commented Mar 19, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchruntime-agnostic
Testbedubuntu-latest
Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,197.00
(-7.58%)Baseline: 6,704.92
7,040.16
(88.02%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-36.49%)Baseline: 825,491.84
866,766.43
(60.49%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-8.52%)Baseline: 22,812.36
23,952.98
(87.12%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,202.00
(-22.26%)Baseline: 126,326.64
132,642.97
(74.03%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,230.00
(-16.14%)Baseline: 117,142.12
122,999.23
(79.86%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.18%)Baseline: 533,933.25
560,629.92
(95.06%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,870.00
(-7.80%)Baseline: 17,212.11
18,072.72
(87.81%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,715,195.00
(-12.93%)Baseline: 16,900,212.05
17,745,222.65
(82.92%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,358.00
(-30.72%)Baseline: 170,839.34
179,381.31
(65.98%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.18%)Baseline: 535,347.64
562,115.02
(95.07%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,919.00
(-6.99%)Baseline: 19,265.05
20,228.30
(88.58%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,066,335.00
(-32.05%)Baseline: 41,302,055.94
43,367,158.74
(64.72%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.18%)Baseline: 534,372.25
561,090.87
(95.06%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,843.00
(-10.88%)Baseline: 17,776.86
18,665.70
(84.88%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,716,621.00
(-12.93%)Baseline: 16,901,116.81
17,746,172.65
(82.93%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
107,945.00
(-17.93%)Baseline: 131,530.41
138,106.93
(78.16%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,302.00
(-16.13%)Baseline: 117,214.12
123,074.83
(79.87%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-7.57%)Baseline: 98,429.06
103,350.51
(88.02%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-4.92%)Baseline: 7,759.55
8,147.53
(90.56%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-3.03%)Baseline: 93,844.21
98,536.42
(92.36%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.62%)Baseline: 7,355.39
7,723.16
(95.83%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-2.59%)Baseline: 109,629.21
115,110.67
(92.77%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.51%)Baseline: 8,867.39
9,310.76
(95.73%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-10.77%)Baseline: 47,058.25
49,411.16
(84.98%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-6.10%)Baseline: 2,893.55
3,038.22
(89.43%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+3.52%)Baseline: 537,190.95
564,050.50
(98.59%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.42%)Baseline: 774.29
813.00
(94.83%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,674,646.00
(-0.13%)Baseline: 27,711,087.81
29,096,642.20
(95.11%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,540,322.00
(-0.16%)Baseline: 5,549,425.90
5,826,897.19
(95.08%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
178,094.00
(+0.03%)Baseline: 178,045.58
186,947.86
(95.26%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
178,905.00
(+0.03%)Baseline: 178,856.89
187,799.74
(95.26%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,195,209.00
(-0.53%)Baseline: 17,286,142.09
18,150,449.19
(94.74%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
295,884.00
(+0.01%)Baseline: 295,847.90
310,640.29
(95.25%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,475,062.00
(-0.97%)Baseline: 12,596,728.01
13,226,564.41
(94.32%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
715,609.00
(-0.01%)Baseline: 715,701.96
751,487.05
(95.23%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
41,823.00
(+0.03%)Baseline: 41,812.36
43,902.98
(95.26%)
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,720.47
16,339,806.50
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,504,859.00
(-0.11%)Baseline: 5,510,698.95
5,786,233.90
(95.14%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
956,774.00
(-0.21%)Baseline: 958,776.06
1,006,714.86
(95.04%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,822,723.00
(-0.01%)Baseline: 2,822,868.23
2,964,011.65
(95.23%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,444,364.00
(-1.05%)Baseline: 3,480,877.48
3,654,921.35
(94.24%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
126,131,735.00
(+0.58%)Baseline: 125,398,259.31
131,668,172.28
(95.80%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
11,816.00
(+0.15%)Baseline: 11,798.44
12,388.36
(95.38%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,825.00
(+0.04%)Baseline: 3,823.40
4,014.57
(95.28%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,724.00
(-0.25%)Baseline: 87,943.43
92,340.60
(95.00%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,754.00
(-0.30%)Baseline: 79,991.16
83,990.72
(94.96%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
51,011.00
(-0.02%)Baseline: 51,020.50
53,571.53
(95.22%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,766.00
(+0.32%)Baseline: 5,747.36
6,034.73
(95.55%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,122.00
(+0.18%)Baseline: 2,118.19
2,224.10
(95.41%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(+0.02%)Baseline: 21,915.84
23,011.63
(95.26%)
🐰 View full continuous benchmarking report in Bencher

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/client/sessions.rs (1)

29-45: ⚠️ Potential issue | 🟠 Major

Don't publish offline-sync completion before the semaphore upgrade.

offline_sync_completed is flipped before the 64-permit semaphore is installed, and the flag is only using Relaxed ordering. The wait paths in this file short-circuit on that flag, so they can proceed while the old 1-permit Arc is still visible, and any task that already cloned that Arc stays serialized. Please make the wider semaphore visible before publishing completion, and use release/acquire semantics if this flag is the publication boundary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/sessions.rs` around lines 29 - 45, Move the installation of the
64-permit semaphore to occur before you set offline_sync_completed and make the
publish atomic with proper memory ordering: first acquire the lock on
message_processing_semaphore and replace the Arc with the new
async_lock::Semaphore::new(64), then perform the compare_exchange that flips
offline_sync_completed using Release on success (and Acquire for any loads that
read it elsewhere); only after a successful flip call
offline_sync_notifier.notify(usize::MAX). Ensure references to
offline_sync_completed, message_processing_semaphore, compare_exchange, and
offline_sync_notifier are updated so the wider semaphore is visible before you
publish completion.
src/request.rs (1)

159-178: ⚠️ Potential issue | 🟠 Major

Move shutdown_notifier.listen() before the is_running check to prevent missed shutdown notifications.

Line 161 registers the shutdown listener only after the is_running check and send_node(). Since event_listener::Event has snapshot semantics—notifications are lost if no active listeners are present—a disconnect that occurs between the is_running check and listen() registration will miss the shutdown notification. The IQ will then wait for the full timeout instead of returning NotConnected promptly.

Minimal fix
    pub async fn send_iq(&self, query: InfoQuery<'_>) -> Result<Node, IqError> {
+       let shutdown = self.shutdown_notifier.listen();
        // Fail fast if the client is shutting down
        if !self.is_running.load(Ordering::Relaxed) {
            return Err(IqError::NotConnected);
        }

        let req_id = query
            .id
            .clone()
@@ -158,8 +159,6 @@ impl Client {
        // Race the IQ response against shutdown so we fail fast on disconnect
        // instead of waiting the full timeout.
-       let shutdown = self.shutdown_notifier.listen();
        let iq_timeout = query.timeout.unwrap_or(default_timeout);

        futures::select! {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/request.rs` around lines 159 - 178, The shutdown listener is registered
too late (shutdown_notifier.listen() is called after the is_running check and
send_node()), risking missed disconnect notifications; move the call to
shutdown_notifier.listen() to occur before the is_running check and before
calling send_node() so the Event listener is active during the window where a
shutdown may occur (update the code around the is_running check and send_node()
usage in request.rs to create let shutdown = self.shutdown_notifier.listen();
early, then perform the is_running check/send_node(), and leave the existing
futures::select! logic unchanged).
src/socket/noise_socket.rs (1)

102-155: ⚠️ Potential issue | 🔴 Critical

Advance the Noise counter only after a successful send.

process_send_job() commits write_counter before the new blocking encryption path runs. If encryption/framing fails there, this socket consumes a nonce without ever producing a frame, and the next successful send uses a counter the peer never saw. That desynchronizes the session immediately.

🛠️ Proposed fix
     ) -> SendResult {
         let counter = *write_counter;
-        *write_counter = write_counter.wrapping_add(1);

         // For small messages, encrypt plaintext_buf in-place then frame into out_buf.
         // This avoids the previous triple-copy pattern (plaintext→out→plaintext→out).
         if plaintext_buf.len() <= INLINE_ENCRYPT_THRESHOLD {
             if let Err(e) = write_key.encrypt_in_place_with_counter(counter, &mut plaintext_buf) {
@@
         if let Err(e) = transport.send(out_buf).await {
             return Err(EncryptSendError::transport(e));
         }

+        *write_counter = counter.wrapping_add(1);
         Ok(())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/socket/noise_socket.rs` around lines 102 - 155, The code currently
increments *write_counter immediately (via counter = *write_counter;
*write_counter = write_counter.wrapping_add(1)) which advances the nonce even if
encryption/framing or transport.send fails; change this so the counter is
reserved locally but only commit the increment after transport.send succeeds:
compute let counter = *write_counter; let next = counter.wrapping_add(1); use
counter for encrypt_in_place_with_counter / encrypt_with_counter and for
framing, and only after if let Err(e) = transport.send(out_buf).await { ... }
else { *write_counter = next; } — ensure both the INLINE_ENCRYPT_THRESHOLD
(small-path using encrypt_in_place_with_counter) and the blocking-path
(encrypt_with_counter + framing) follow this pattern so failed
encrypt/framing/transport never advance *write_counter.
🧹 Nitpick comments (4)
wacore/src/runtime.rs (1)

103-113: Panic propagation may cause unexpected behavior.

If the blocking closure f panics, tx is dropped without sending, causing rx.await to return Err(Canceled), which then panics at .expect(). This propagates the panic to the async caller in a potentially unexpected way.

Consider returning a Result or using unwrap_or_else with an explicit panic message indicating the blocking task failed:

♻️ Suggested improvement for panic handling
 pub async fn blocking<T: Send + 'static>(
     rt: &dyn Runtime,
     f: impl FnOnce() -> T + Send + 'static,
-) -> T {
+) -> Result<T, BlockingTaskPanicked> {
     let (tx, rx) = futures::channel::oneshot::channel();
     rt.spawn_blocking(Box::new(move || {
         let _ = tx.send(f());
     }))
     .await;
-    rx.await.expect("spawn_blocking task completed")
+    rx.await.map_err(|_| BlockingTaskPanicked)
 }
+
+/// Error returned when a blocking task panics.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
+#[error("blocking task panicked")]
+pub struct BlockingTaskPanicked;

Alternatively, if panic propagation is intentional, consider documenting this behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/runtime.rs` around lines 103 - 113, The blocking helper currently
panics if the spawned blocking closure `f` panics because `tx` is dropped and
`rx.await.expect()` panics; update `blocking` to explicitly propagate
panic/errors by wrapping the closure with std::panic::catch_unwind and sending a
Result over `tx` (e.g., send Ok(value) on success or Err(panic_payload) on
unwind), change the receive side (`rx.await`) to return a Result (or change the
function signature to return Result<T, E>) and map the Canceled/Err case to a
clear error variant or message; locate the `blocking` function and the use of
`rt.spawn_blocking`, `tx`, and `rx` to implement catch_unwind inside the spawned
closure and handle the received Result instead of calling `.expect()`.
src/socket/error.rs (1)

25-26: Rename Join error text to runtime-neutral wording.

join(...) is now runtime-agnostic, but the kind message still says “tokio join error”, which is misleading in logs.

Proposed wording update
 pub enum EncryptSendErrorKind {
@@
-    #[error("tokio join error")]
+    #[error("task join error")]
     Join,

Also applies to: 61-64

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/socket/error.rs` around lines 25 - 26, The error variant Join in
src/socket/error.rs uses the message "tokio join error" which is
runtime-specific; update its Display/error string to a runtime-neutral phrase
like "task join error" (and apply the same change to the analogous variant
mentioned at lines 61-64). Locate the enum variant named Join and replace the
literal "tokio join error" with a neutral message (e.g., "task join error" or
"join error") so logs no longer reference Tokio specifically while preserving
the variant name and semantics.
src/handlers/message.rs (1)

48-54: Prefer Client::chat_locks here instead of a second per-chat lock map.

Using message_enqueue_locks creates another ordering domain for the same chat, so code paths that follow the repo-standard chat_locks discipline can still interleave with this queue. Reusing Client::chat_locks keeps the per-chat serialization boundary consistent.

As per coding guidelines, "Use Client::chat_locks to serialize per-chat operations for concurrency safety".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/message.rs` around lines 48 - 54, Replace the separate per-chat
lock map usage with the repository-standard Client::chat_locks to ensure a
single serialization domain: instead of calling
client.message_enqueue_locks.get_with_by_ref(&chat_id, ...) and using
enqueue_mutex/ _enqueue_guard, obtain the mutex from client.chat_locks (using
the same get_with_by_ref and async_lock::Mutex type), await the returned
mutex.lock().await, and use the guard to serialize the enqueue operation for
chat_id (preserving the await and scope of the guard).
src/bot.rs (1)

650-655: Avoid unwrap() in this non-test builder path.

Typestate already proves these fields are present, so destructuring self (or matching the Some(...) cases) keeps the same guarantee without a panic path. As per coding guidelines: "Do not use .unwrap() outside of test code".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bot.rs` around lines 650 - 655, The build method uses .unwrap() on
runtime, backend, transport_factory, and http_client; replace these panicking
calls by destructuring self to extract the Some(...) values (e.g. match or let
Self { runtime: Some(runtime), backend: Some(backend), transport_factory:
Some(transport_factory), http_client: Some(http_client), .. } = self) so the
typestate guarantee is preserved without any unwraps and without introducing a
panic path in Bot::build.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bot.rs`:
- Around line 115-127: BotHandle::abort() is currently a no-op; implement it to
call the underlying abort handle so callers can explicitly cancel the run task
instead of relying on Drop. In the abort(&self) method, invoke the AbortHandle's
abort method on the _abort_handle field (e.g. self._abort_handle.abort()) so the
task is cancelled immediately; keep the existing Drop behavior intact. Also
apply the same change to the other BotHandle implementation variant referenced
around lines 242-251 so both handle types provide explicit abort semantics.

In `@src/client.rs`:
- Around line 250-252: The Client struct's runtime must be Tokio-backed and we
should not accept arbitrary Runtime; change the runtime field to use
TokioRuntime (e.g., Arc<TokioRuntime>) and update all constructors/factory
functions (e.g., Client::new and any builders that currently accept Arc<dyn
Runtime>) to instantiate and inject a TokioRuntime internally instead of taking
a Runtime parameter; likewise ensure the persistence layer used by the main
client is the Diesel SQLite implementation (replace any runtime-agnostic or
alternative persistence injections with the Diesel SQLite persistence backend
used by whatsapp-rust) and update references to wacore::client::CoreClient
construction to use the TokioRuntime-backed Client.

In `@src/store/persistence_manager.rs`:
- Line 80: The background saver uses save_notify.notify(1) with
event_listener::Event which can drop notifications if no listener exists
(lost-wakeup), so ensure pending writes are always flushed on shutdown: modify
the persistence manager’s background task (the loop that calls save_to_disk) to
either 1) keep a persistent listener outside the select/save boundary and use
the "check dirty flag -> await listener -> re-check" pattern so notifications
aren't missed, or 2) add a graceful shutdown path that awaits the saver task and
forces a final save_to_disk when dirty is true before exiting; reference
save_notify, the atomic dirty flag, save_to_disk, and the background saver task
when implementing the chosen fix.

In `@wacore/src/protocol/retry.rs`:
- Around line 65-79: The function extract_registration_id_from_node currently
treats any bytes.len() >= 4 as valid and truncates longer payloads; change its
validation to only accept 1..=4 bytes and reject (return None) when the
registration payload is empty or longer than 4 bytes. Specifically, in
extract_registration_id_from_node (and using get_bytes_content and the
registration child lookup), replace the bytes.len() >= 4 branch with an exact
check for bytes.len() == 4, keep the 1..=3 variable-length branch for
bytes.len() between 1 and 3, return None for bytes.is_empty(), and also return
None when bytes.len() > 4 so oversized payloads are treated as invalid rather
than truncated.

In `@wacore/src/session.rs`:
- Around line 112-173: The code marks JIDs as in-flight by inserting jid_str
into self.processing before awaiting fetch_and_establish, but cleanup (removing
from self.processing and notifying self.pending) only runs after the await so
cancellation can permanently leave those JIDs marked; fix by introducing a
drop/RAII guard (e.g., a small struct like ProcessingGuard) created when you
insert jid_str into processing (or for each batch) that, on Drop, acquires the
same locks on self.processing and self.pending and removes the jid_str entries
and notifies waiters with an appropriate SessionError (e.g.,
SessionError::FetchFailed("cancelled")), and then disarm the guard (disable its
Drop behavior) when the normal post-await cleanup runs successfully; place the
guard creation right after processing.insert(...) (in the same scope where
to_process/to_wait are handled) and ensure notify logic uses the same
notify_result handling in both normal and Drop paths so waiters are never
stranded.

In `@wacore/src/store/in_memory.rs`:
- Around line 358-363: put_lid_mapping currently overwrites
lid_mappings[entry.lid] but doesn't remove the previous phone->lid reverse
entry, leaving a stale pn_to_lid key; to fix, inside put_lid_mapping check
s.lid_mappings.get(&entry.lid) before inserting, and if an existing entry exists
with a different phone_number, remove that old phone_number from s.pn_to_lid
(e.g., s.pn_to_lid.remove(&old.phone_number)), then proceed to insert the new
pn_to_lid and lid_mappings entries so the reverse index stays consistent;
references: put_lid_mapping, s.lid_mappings, s.pn_to_lid, and the
LidPnMappingEntry.phone_number field.
- Around line 538-540: The in-memory create() currently only increments
next_device_id, but PersistenceManager::new() expects create() to materialize
the device so exists() returns true and load() can read it; modify create() to
allocate a new device record in the in-memory storage (seed state.device) using
the generated id from next_device_id and appropriate default/placeholder fields
so subsequent exists() and load() observe the created device; update references
in create() (and any helper like state or storage map) to store that Device
entry and then return the id.

In `@wacore/src/store/persistence.rs`:
- Around line 124-143: run_background_saver currently takes self: Arc<Self> and
moves that strong Arc into a detached infinite task, preventing
PersistenceManager from ever being dropped; change the implementation to avoid
owning a strong Arc in the detached loop by converting the Arc<Self> to a
Weak<Self> (e.g., let weak = Arc::downgrade(&self)) and attempt to upgrade
inside the loop before calling save_to_disk or listening on save_notify,
aborting/returning when upgrade fails, or alternatively return/store an abort
handle from runtime.spawn so callers can cancel the background task; update
references in run_background_saver, the loop where save_notify.listen() and
save_to_disk() are used, and the task creation to use the Weak upgrade pattern
or to expose the AbortHandle.
- Around line 85-98: The save_to_disk implementation clears self.dirty before
awaiting backend.save, so if save fails the dirty flag is lost; change the
backend.save call so that on error you restore the dirty flag before propagating
the error (e.g. replace the .await.map_err(db_err)? pattern with handling that
on Err runs self.dirty.store(true, Ordering::Release) and then returns the
mapped StoreError). This touches save_to_disk, the dirty AtomicBool field, and
the backend.save call — ensure the restored dirty write happens before returning
the error.

---

Outside diff comments:
In `@src/client/sessions.rs`:
- Around line 29-45: Move the installation of the 64-permit semaphore to occur
before you set offline_sync_completed and make the publish atomic with proper
memory ordering: first acquire the lock on message_processing_semaphore and
replace the Arc with the new async_lock::Semaphore::new(64), then perform the
compare_exchange that flips offline_sync_completed using Release on success (and
Acquire for any loads that read it elsewhere); only after a successful flip call
offline_sync_notifier.notify(usize::MAX). Ensure references to
offline_sync_completed, message_processing_semaphore, compare_exchange, and
offline_sync_notifier are updated so the wider semaphore is visible before you
publish completion.

In `@src/request.rs`:
- Around line 159-178: The shutdown listener is registered too late
(shutdown_notifier.listen() is called after the is_running check and
send_node()), risking missed disconnect notifications; move the call to
shutdown_notifier.listen() to occur before the is_running check and before
calling send_node() so the Event listener is active during the window where a
shutdown may occur (update the code around the is_running check and send_node()
usage in request.rs to create let shutdown = self.shutdown_notifier.listen();
early, then perform the is_running check/send_node(), and leave the existing
futures::select! logic unchanged).

In `@src/socket/noise_socket.rs`:
- Around line 102-155: The code currently increments *write_counter immediately
(via counter = *write_counter; *write_counter = write_counter.wrapping_add(1))
which advances the nonce even if encryption/framing or transport.send fails;
change this so the counter is reserved locally but only commit the increment
after transport.send succeeds: compute let counter = *write_counter; let next =
counter.wrapping_add(1); use counter for encrypt_in_place_with_counter /
encrypt_with_counter and for framing, and only after if let Err(e) =
transport.send(out_buf).await { ... } else { *write_counter = next; } — ensure
both the INLINE_ENCRYPT_THRESHOLD (small-path using
encrypt_in_place_with_counter) and the blocking-path (encrypt_with_counter +
framing) follow this pattern so failed encrypt/framing/transport never advance
*write_counter.

---

Nitpick comments:
In `@src/bot.rs`:
- Around line 650-655: The build method uses .unwrap() on runtime, backend,
transport_factory, and http_client; replace these panicking calls by
destructuring self to extract the Some(...) values (e.g. match or let Self {
runtime: Some(runtime), backend: Some(backend), transport_factory:
Some(transport_factory), http_client: Some(http_client), .. } = self) so the
typestate guarantee is preserved without any unwraps and without introducing a
panic path in Bot::build.

In `@src/handlers/message.rs`:
- Around line 48-54: Replace the separate per-chat lock map usage with the
repository-standard Client::chat_locks to ensure a single serialization domain:
instead of calling client.message_enqueue_locks.get_with_by_ref(&chat_id, ...)
and using enqueue_mutex/ _enqueue_guard, obtain the mutex from client.chat_locks
(using the same get_with_by_ref and async_lock::Mutex type), await the returned
mutex.lock().await, and use the guard to serialize the enqueue operation for
chat_id (preserving the await and scope of the guard).

In `@src/socket/error.rs`:
- Around line 25-26: The error variant Join in src/socket/error.rs uses the
message "tokio join error" which is runtime-specific; update its Display/error
string to a runtime-neutral phrase like "task join error" (and apply the same
change to the analogous variant mentioned at lines 61-64). Locate the enum
variant named Join and replace the literal "tokio join error" with a neutral
message (e.g., "task join error" or "join error") so logs no longer reference
Tokio specifically while preserving the variant name and semantics.

In `@wacore/src/runtime.rs`:
- Around line 103-113: The blocking helper currently panics if the spawned
blocking closure `f` panics because `tx` is dropped and `rx.await.expect()`
panics; update `blocking` to explicitly propagate panic/errors by wrapping the
closure with std::panic::catch_unwind and sending a Result over `tx` (e.g., send
Ok(value) on success or Err(panic_payload) on unwind), change the receive side
(`rx.await`) to return a Result (or change the function signature to return
Result<T, E>) and map the Canceled/Err case to a clear error variant or message;
locate the `blocking` function and the use of `rt.spawn_blocking`, `tx`, and
`rx` to implement catch_unwind inside the spawned closure and handle the
received Result instead of calling `.expect()`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b36ab876-507a-41f0-9b5a-e8a85a538af9

📥 Commits

Reviewing files that changed from the base of the PR and between 9539740 and e313862.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (55)
  • Cargo.toml
  • examples/benchmark.rs
  • src/appstate_sync.rs
  • src/bot.rs
  • src/client.rs
  • src/client/device_registry.rs
  • src/client/sender_keys.rs
  • src/client/sessions.rs
  • src/download.rs
  • src/features/presence.rs
  • src/handlers/ib.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/router.rs
  • src/handshake.rs
  • src/history_sync.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/main.rs
  • src/message.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/prekeys.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/runtime_impl.rs
  • src/send.rs
  • src/session.rs
  • src/socket/error.rs
  • src/socket/noise_socket.rs
  • src/store/persistence_manager.rs
  • src/store/signal.rs
  • src/store/signal_adapter.rs
  • src/store/signal_cache.rs
  • src/test_utils.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/upload.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/connection.rs
  • wacore/Cargo.toml
  • wacore/src/lib.rs
  • wacore/src/protocol/keepalive.rs
  • wacore/src/protocol/mod.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/runtime.rs
  • wacore/src/session.rs
  • wacore/src/stanza/mod.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/stanza/receipt.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/mod.rs
  • wacore/src/store/persistence.rs
  • wacore/src/store/signal_cache.rs

Comment thread src/bot.rs
Comment on lines +115 to +127
/// Handle returned by [`Bot::run`] that can be awaited to wait for the
/// client's run loop to finish.
pub struct BotHandle {
done_rx: futures::channel::oneshot::Receiver<()>,
_abort_handle: wacore::runtime::AbortHandle,
}

impl BotHandle {
/// Abort the bot's run task.
pub fn abort(&self) {
// AbortHandle aborts on drop, but we also allow explicit abort
// by disconnecting. The actual abort happens via _abort_handle's Drop.
}

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 | 🟠 Major

BotHandle::abort() currently does nothing, while Drop aborts.

Line 124 is a no-op, but the returned handle still owns the AbortHandle, so dropping BotHandle becomes the only working cancellation path. That inverts the old JoinHandle behavior: callers who ignore the handle stop the bot immediately, while callers who keep it still cannot explicitly abort.

Also applies to: 242-251

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bot.rs` around lines 115 - 127, BotHandle::abort() is currently a no-op;
implement it to call the underlying abort handle so callers can explicitly
cancel the run task instead of relying on Drop. In the abort(&self) method,
invoke the AbortHandle's abort method on the _abort_handle field (e.g.
self._abort_handle.abort()) so the task is cancelled immediately; keep the
existing Drop behavior intact. Also apply the same change to the other BotHandle
implementation variant referenced around lines 242-251 so both handle types
provide explicit abort semantics.

Comment thread src/client.rs
Comment on lines 250 to 252
pub struct Client {
pub(crate) runtime: Arc<dyn Runtime>,
pub(crate) core: wacore::client::CoreClient,

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 | 🟠 Major

Keep the main client's public API Tokio-backed.

Lines 251, 497, and 516 now let downstream instantiate whatsapp-rust's main Client with any Runtime. That pushes the runtime-agnostic boundary out of wacore and into whatsapp-rust/src, which is explicitly outside the supported architecture. Please inject TokioRuntime at this boundary instead of accepting an arbitrary runtime here. Based on learnings: "Applies to whatsapp-rust/src/**/*.rs : whatsapp-rust main client must use Tokio runtime and SQLite persistence with Diesel ORM".

Also applies to: 496-522

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 250 - 252, The Client struct's runtime must be
Tokio-backed and we should not accept arbitrary Runtime; change the runtime
field to use TokioRuntime (e.g., Arc<TokioRuntime>) and update all
constructors/factory functions (e.g., Client::new and any builders that
currently accept Arc<dyn Runtime>) to instantiate and inject a TokioRuntime
internally instead of taking a Runtime parameter; likewise ensure the
persistence layer used by the main client is the Diesel SQLite implementation
(replace any runtime-agnostic or alternative persistence injections with the
Diesel SQLite persistence backend used by whatsapp-rust) and update references
to wacore::client::CoreClient construction to use the TokioRuntime-backed
Client.

Comment thread src/history_sync.rs
Comment on lines +157 to 163
let result = process_history_sync(
compressed_data,
own_user_ref,
Some(|raw_bytes: Bytes| {
// Send Bytes through channel (zero-copy clone)
let _ = tx.blocking_send(raw_bytes);
let _ = tx.send_blocking(raw_bytes);
}),

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd src && wc -l history_sync.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 85


🏁 Script executed:

cat -n src/history_sync.rs | sed -n '140,230p'

Repository: jlucaso1/whatsapp-rust

Length of output: 4677


Stop awaiting the full parse after shutdown or propagate a cancellation signal to the parser.

Line 162 ignores send_blocking errors, and line 203 still awaits the full parse completion even after the receive loop breaks for shutdown (line 179-184). This causes process_history_sync() to continue decompressing the entire blob even though results are no longer being processed, defeating the early-exit intent. For large history blobs, this unnecessarily delays shutdown and wastes CPU. Either propagate the closed-channel signal into the parser to halt early, or stop awaiting result_rx once shutdown has broken the dispatch loop.


self.dirty.store(true, Ordering::Relaxed);
self.save_notify.notify_one();
self.save_notify.notify(1);

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/store/persistence_manager.rs | sed -n '70,90p'

Repository: jlucaso1/whatsapp-rust

Length of output: 889


🏁 Script executed:

cat -n src/store/persistence_manager.rs | sed -n '120,150p'

Repository: jlucaso1/whatsapp-rust

Length of output: 1451


🏁 Script executed:

git log --oneline --all --grep="Notify" | head -20

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

rg "event_listener::Event" -A 5 -B 5

Repository: jlucaso1/whatsapp-rust

Length of output: 8261


🏁 Script executed:

web_search
event_listener::Event Rust crate listen() semantics notification race condition

Repository: jlucaso1/whatsapp-rust

Length of output: 281


🏁 Script executed:

rg "save_notify" -B 5 -A 5 src/store/persistence_manager.rs | head -60

Repository: jlucaso1/whatsapp-rust

Length of output: 1439


🏁 Script executed:

rg "pub fn run_background_saver|fn modify_device" -A 15 src/store/persistence_manager.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 1362


🏁 Script executed:

rg "dirty" -B 2 -A 2 src/store/persistence_manager.rs | head -40

Repository: jlucaso1/whatsapp-rust

Length of output: 826


🌐 Web query:

event_listener Rust crate github documentation listen() semantics

💡 Result:

The event_listener Rust crate (https://crates.io/crates/event-listener, latest v5.4.1 as of 2025-08-03, repo: https://github.com/smol-rs/event-listener) provides a synchronization primitive similar to eventcounts for notifying async tasks or threads. It allows turning non-blocking data structures into async or blocking ones. The relevant method is Event::listen(&self) -> EventListener, which returns a guard (EventListener) listening for a notification on that Event. It emits a SeqCst fence after registering the listener, ensuring proper synchronization. Semantics and usage: - Register listeners with event.listen to get an EventListener. - Await the listener (.await) for async waiting or call listener.wait for blocking. - Notifications via event.notify(n: usize) wake at least n active listeners (or event.notify_additional(n) for previously unnotified ones). Notifications lost if no listeners. - Listeners FIFO for fairness. - If a notified listener drops without consuming, it wakes another (depending on notify type). - Often used in loops: check condition, listen if not met, wait if still not met. - Stack-based alternative: listener!(event => listener) macro for efficiency (no heap alloc). Example from docs: use event_listener::Event; let event = Event::new; let listener = event.listen; event.notify(1); listener.wait; // or await listener {value: "The event_listener Rust crate (https://crates.io/crates/event-listener, latest v5.4.1 as of 2025-08-03, repo: https://github.com/smol-rs/event-listener) provides a synchronization primitive similar to eventcounts for notifying async tasks or threads. It allows turning non-blocking data structures into async or blocking ones.\n\nThe relevant method is Event::listen(&self) -> EventListener, which returns a guard (EventListener) listening for a notification on that Event. It emits a SeqCst fence after registering the listener, ensuring proper synchronization.\n\nSemantics and usage:\n- Register listeners with event.listen to get an EventListener.\n- Await the listener (.await) for async waiting or call listener.wait for blocking.\n- Notifications via event.notify(n: usize) wake at least n active listeners (or event.notify_additional(n) for previously unnotified ones). Notifications lost if no listeners.\n- Listeners FIFO for fairness.\n- If a notified listener drops without consuming, it wakes another (depending on notify type).\n- Often used in loops: check condition, listen if not met, wait if still not met.\n- Stack-based alternative: listener!(event => listener) macro for efficiency (no heap alloc).\n\nExample from docs:\n\nuse event_listener::Event;\nlet event = Event::new;\nlet listener = event.listen;\nevent.notify(1);\nlistener.wait; // or await listener\n", citations: [1,3,9,10,13], confidence: "high"}

Citations:


🏁 Script executed:

rg "Event::new|event_listener" src/store/persistence_manager.rs -B 3 -A 3

Repository: jlucaso1/whatsapp-rust

Length of output: 441


🏁 Script executed:

rg "dirty.store|dirty.swap" src/store/persistence_manager.rs -B 5 -A 5

Repository: jlucaso1/whatsapp-rust

Length of output: 664


🏁 Script executed:

git log --oneline --all -- src/store/persistence_manager.rs | head -10

Repository: jlucaso1/whatsapp-rust

Length of output: 715


🏁 Script executed:

rg "shutdown|detach|drop" src/store/persistence_manager.rs -B 3 -A 3

Repository: jlucaso1/whatsapp-rust

Length of output: 522


🏁 Script executed:

cat -n src/store/persistence_manager.rs | sed -n '1,70p'

Repository: jlucaso1/whatsapp-rust

Length of output: 3078


🏁 Script executed:

rg "PersistenceManager" src/ -l | head -5

Repository: jlucaso1/whatsapp-rust

Length of output: 166


🏁 Script executed:

git show aa3660a:src/store/persistence_manager.rs | head -150 | tail -80

Repository: jlucaso1/whatsapp-rust

Length of output: 2892


🏁 Script executed:

rg "run_background_saver" src/ -B 5 -A 2

Repository: jlucaso1/whatsapp-rust

Length of output: 1034


🏁 Script executed:

rg "detach|shutdown_notifier" src/client.rs | head -20

Repository: jlucaso1/whatsapp-rust

Length of output: 869


🏁 Script executed:

cat -n src/store/persistence_manager.rs | sed -n '85,100p'

Repository: jlucaso1/whatsapp-rust

Length of output: 729


🏁 Script executed:

rg "PersistenceManager::new|run_background_saver" src/ -B 3 -A 8 | head -50

Repository: jlucaso1/whatsapp-rust

Length of output: 2416


🏁 Script executed:

rg "run_background_saver|shutdown" src/bot.rs -B 5 -A 5

Repository: jlucaso1/whatsapp-rust

Length of output: 559


🏁 Script executed:

rg "async fn shutdown|async fn stop|async fn close" src/ -B 2 -A 8

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

cat -n src/store/persistence_manager.rs | sed -n '126,148p'

Repository: jlucaso1/whatsapp-rust

Length of output: 1172


🏁 Script executed:

rg "Notify::|event_listener::Event" src/store/ -B 2 -A 2

Repository: jlucaso1/whatsapp-rust

Length of output: 365


🏁 Script executed:

rg "process.*shutdown|drop.*persistence|stop.*persistence" src/ -B 3 -A 3

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

rg "\.notify\(|notify_one\(" src/store/persistence_manager.rs -B 3 -A 3

Repository: jlucaso1/whatsapp-rust

Length of output: 225


🏁 Script executed:

cat -n src/store/persistence_manager.rs | sed -n '75,105p'

Repository: jlucaso1/whatsapp-rust

Length of output: 1266


event_listener::Event creates a lost-wakeup window that can drop device updates on fast shutdown.

Unlike tokio::sync::Notify, event_listener::Event only wakes listeners that are already registered. In this loop, a listener only exists within the select! scope. If notify(1) fires before the next iteration's listen() call or during save_to_disk() execution, the notification is lost. The atomic dirty flag ensures the write isn't forgotten, but if the background saver task exits before the interval timeout (30s) fires, the pending write is never flushed to disk.

Either add a graceful shutdown handler that flushes pending writes, keep a persistent listener across the save boundary with the flag-check pattern mentioned, or wait for the saver to complete before shutdown.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/store/persistence_manager.rs` at line 80, The background saver uses
save_notify.notify(1) with event_listener::Event which can drop notifications if
no listener exists (lost-wakeup), so ensure pending writes are always flushed on
shutdown: modify the persistence manager’s background task (the loop that calls
save_to_disk) to either 1) keep a persistent listener outside the select/save
boundary and use the "check dirty flag -> await listener -> re-check" pattern so
notifications aren't missed, or 2) add a graceful shutdown path that awaits the
saver task and forces a final save_to_disk when dirty is true before exiting;
reference save_notify, the atomic dirty flag, save_to_disk, and the background
saver task when implementing the chosen fix.

Comment thread wacore/src/protocol/retry.rs
Comment thread wacore/src/store/in_memory.rs
Comment thread wacore/src/store/in_memory.rs
Comment on lines +60 to +62
pub fn device_arc(&self) -> Arc<RwLock<Device>> {
self.device.clone()
}

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 | 🟠 Major

Don't publish raw mutable access to Device.

Line 60 exposes the underlying RwLock<Device> directly, so callers can mutate state without setting dirty; line 72 adds a generic mutator that bypasses the DeviceCommand invariants entirely. These should be private/test-only helpers, with process_command() remaining the public mutation path. As per coding guidelines: "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() instead".

Also applies to: 72-83

Comment thread wacore/src/store/persistence.rs
Comment thread wacore/src/store/persistence.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/keepalive.rs`:
- Around line 100-101: The loop recreates the shutdown_notifier listener inside
each iteration causing a race where a shutdown can be missed; move the call to
shutdown_notifier.listen() to the top of the loop (before awaiting sleep or
other branches such as self.runtime.sleep(...).fuse()) so the listener is
registered first, then immediately check the is_running flag (call is_running()
or check the shared state) right after registration and break/return if not
running; apply the same change for the second occurrence around the code at
lines referenced (the block containing disconnect(), listen(), and the sleep
select) so disconnect() cannot fire between sleep completion and a new listen()
registration.

In `@wacore/src/runtime.rs`:
- Around line 113-125: The panic message in function blocking (symbols:
blocking, rt.spawn_blocking, tx, rx, f()) incorrectly attributes a canceled
oneshot to "runtime shutting down"; update the failure handling to use a
clearer, more generic message that covers both closure panics and runtime
shutdown (e.g., "blocking task failed to complete (closure panic or runtime
shutdown?)") or alternatively update the function/doc comment to explicitly note
that f() panicking will cause the oneshot to be canceled; modify the
rx.await.unwrap_or_else panic call to use the new generic message and ensure any
comment above blocking reflects the possible causes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1a8613ab-2794-4904-a014-0e2ae925d71a

📥 Commits

Reviewing files that changed from the base of the PR and between e313862 and 615cfad.

📒 Files selected for processing (4)
  • src/keepalive.rs
  • src/message.rs
  • wacore/src/runtime.rs
  • wacore/src/store/persistence.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • wacore/src/store/persistence.rs

Comment thread src/keepalive.rs
Comment thread wacore/src/runtime.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (3)
wacore/src/store/persistence.rs (1)

68-70: ⚠️ Potential issue | 🟠 Major

Don’t expose raw Device mutation from the public API.

device_arc() lets callers take a write lock without setting dirty, and modify_device() lets them bypass the DeviceCommand invariants entirely. The public surface should stay get_device_snapshot() + process_command(), with these helpers kept internal/test-only.

As per coding guidelines, "Never modify Device state directly; use DeviceCommand + PersistenceManager::process_command() instead" and "Read Device state via get_device_snapshot() method".

Also applies to: 80-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/store/persistence.rs` around lines 68 - 70, Publicly exposing
device_arc() (and any helpers like modify_device()) allows callers to mutate
Device without setting dirty flags or going through DeviceCommand invariants;
make these helper methods non-public (privated or cfg(test)) so the public API
only exposes get_device_snapshot() and PersistenceManager::process_command(),
and update any call sites to use get_device_snapshot() for reads and
process_command() for mutations instead.
wacore/src/store/in_memory.rs (1)

547-549: ⚠️ Potential issue | 🟠 Major

create() still needs to materialize the device row.

PersistenceManager::new() assumes exists() -> create() -> load() makes the backend observable immediately. As written, exists() stays false and load() stays None until some later save(), so repeated initialization against the same backend can keep allocating new ids without ever seeing the created device.

🛠️ Minimal fix
     async fn create(&self) -> Result<i32> {
         let id = self.next_device_id.fetch_add(1, Ordering::Relaxed);
+        let mut s = self.state.lock().await;
+        s.device.get_or_insert_with(Device::new);
         Ok(id)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/store/in_memory.rs` around lines 547 - 549, create() currently
only allocates an id via next_device_id.fetch_add but does not materialize the
device row, so PersistenceManager::new()'s assumed exists() -> create() ->
load() sequence fails; update create() to insert a new device entry into the
in-memory backend (the same data structure used by exists()/load()),
initializing any required fields/state for the device so exists() returns true
and load() returns the newly created device immediately (ensure you use the
generated id and update whatever map or collection holds devices, and keep
next_device_id usage as is).
src/store/persistence_manager.rs (1)

127-158: ⚠️ Potential issue | 🟠 Major

The detached saver can still lose the last dirty write.

A modify_device() call that lands during save_to_disk() or between listener lifetimes sets dirty = true, but its notify(1) is dropped by Event. If the manager is then dropped before the next interval tick, the weak-exit path returns without ever flushing that snapshot. Please re-check dirty before awaiting a new listener and add a final-save/shutdown path instead of relying on the next timer wakeup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/store/persistence_manager.rs` around lines 127 - 158, Re-check the
PersistenceManager's dirty flag and add a proper shutdown flush: inside
run_background_saver, after creating listener = this.save_notify.listen() (and
before dropping the strong Arc and awaiting), check this.dirty and call
this.save_to_disk().await if true so writes that happen between listener
lifetimes aren't lost; additionally implement a final-save/shutdown path by
adding a shutdown mechanism (e.g., a new shutdown_notify or a
shutdown_and_wait() on PersistenceManager) that calls save_notify.notify(1) and
waits for the background task to finish, and wire callers (or Drop) to invoke it
so the background saver can perform a last save before the manager is dropped.
Ensure references to save_notify.listen(), save_to_disk(), modify_device(), and
notify(1) are used to locate and implement these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/request.rs`:
- Around line 150-152: After calling self.shutdown_notifier.listen() (i.e.,
after creating the shutdown listener), immediately re-check the shutdown state
by calling self.is_running() (or the appropriate shutdown check used earlier)
and return early if it indicates shutdown; this ensures that any shutdown which
occurred between the earlier guard and the listener registration is handled
promptly so the request doesn't wait the full IQ timeout—update the code around
the send_node()/shutdown_notifier.listen() sequence to perform that second
is_running check and return the same early-fail behavior used for the initial
check.

In `@wacore/src/store/persistence.rs`:
- Around line 133-165: The background saver currently exits immediately when the
manager Arc is dropped, risking lost changes; add a graceful shutdown that
performs a final save: introduce a place to store the spawned task handle (e.g.,
a field like background_task: Mutex<Option<JoinHandle<()>>> on
PersistenceManager), stop calling .detach() in run_background_saver and instead
save the JoinHandle there, and implement an async shutdown(&self) method that
notifies the saver (use save_notify.notify_one()), awaits the background task
handle to finish, and calls save_to_disk(). Alternatively, if you need a
synchronous API, provide a shutdown_blocking(&self, runtime: Arc<dyn Runtime>)
that calls runtime.block_on(self.shutdown()). Ensure run_background_saver,
save_to_disk, save_notify, and the new background_task field are used
consistently so the final flush happens before the manager is dropped.

---

Duplicate comments:
In `@src/store/persistence_manager.rs`:
- Around line 127-158: Re-check the PersistenceManager's dirty flag and add a
proper shutdown flush: inside run_background_saver, after creating listener =
this.save_notify.listen() (and before dropping the strong Arc and awaiting),
check this.dirty and call this.save_to_disk().await if true so writes that
happen between listener lifetimes aren't lost; additionally implement a
final-save/shutdown path by adding a shutdown mechanism (e.g., a new
shutdown_notify or a shutdown_and_wait() on PersistenceManager) that calls
save_notify.notify(1) and waits for the background task to finish, and wire
callers (or Drop) to invoke it so the background saver can perform a last save
before the manager is dropped. Ensure references to save_notify.listen(),
save_to_disk(), modify_device(), and notify(1) are used to locate and implement
these changes.

In `@wacore/src/store/in_memory.rs`:
- Around line 547-549: create() currently only allocates an id via
next_device_id.fetch_add but does not materialize the device row, so
PersistenceManager::new()'s assumed exists() -> create() -> load() sequence
fails; update create() to insert a new device entry into the in-memory backend
(the same data structure used by exists()/load()), initializing any required
fields/state for the device so exists() returns true and load() returns the
newly created device immediately (ensure you use the generated id and update
whatever map or collection holds devices, and keep next_device_id usage as is).

In `@wacore/src/store/persistence.rs`:
- Around line 68-70: Publicly exposing device_arc() (and any helpers like
modify_device()) allows callers to mutate Device without setting dirty flags or
going through DeviceCommand invariants; make these helper methods non-public
(privated or cfg(test)) so the public API only exposes get_device_snapshot() and
PersistenceManager::process_command(), and update any call sites to use
get_device_snapshot() for reads and process_command() for mutations instead.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7c665c9f-5096-4055-8d76-5c26240cf2c9

📥 Commits

Reviewing files that changed from the base of the PR and between 615cfad and 01facb9.

📒 Files selected for processing (9)
  • src/bot.rs
  • src/client/sessions.rs
  • src/request.rs
  • src/socket/error.rs
  • src/store/persistence_manager.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/runtime.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/persistence.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/socket/error.rs
  • wacore/src/protocol/retry.rs
  • src/bot.rs

Comment thread src/request.rs
Comment on lines +133 to +165
pub fn run_background_saver(self: Arc<Self>, runtime: Arc<dyn Runtime>, interval: Duration) {
let rt = runtime.clone();
let weak = Arc::downgrade(&self);
drop(self); // Release the strong reference; the caller's Arc keeps it alive
runtime
.spawn(Box::pin(async move {
loop {
let Some(this) = weak.upgrade() else {
debug!("PersistenceManager dropped, exiting background saver.");
return;
};
let listener = this.save_notify.listen();
drop(this); // Don't hold strong ref while sleeping

futures::select! {
_ = listener.fuse() => {
debug!("Save notification received.");
}
_ = rt.sleep(interval).fuse() => {}
}

let Some(this) = weak.upgrade() else {
debug!("PersistenceManager dropped, exiting background saver.");
return;
};
if let Err(e) = this.save_to_disk().await {
error!("Error saving device state in background: {e}");
}
}
}))
.detach();
debug!("Background saver task started with interval {interval:?}");
}

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

🧩 Analysis chain

🏁 Script executed:

fd persistence.rs -t f | head -20

Repository: jlucaso1/whatsapp-rust

Length of output: 97


🏁 Script executed:

cat -n wacore/src/store/persistence.rs | sed -n '120,180p'

Repository: jlucaso1/whatsapp-rust

Length of output: 2709


🏁 Script executed:

# Look for save_notify definition and Event usage
rg "save_notify" wacore/src/store/persistence.rs -B 2 -A 2

Repository: jlucaso1/whatsapp-rust

Length of output: 599


🏁 Script executed:

# Look for the Event struct/enum definition
rg "enum Event|struct Event" wacore/src/store/persistence.rs -B 2 -A 5

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Find save_to_disk method
ast-grep --pattern 'fn save_to_disk($_) {
  $$$
}'

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

rg "use.*Event" wacore/src/store/persistence.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 92


🏁 Script executed:

# Check the full struct definition and imports at top of file
head -50 wacore/src/store/persistence.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 2106


🏁 Script executed:

# Look for any Drop implementations or cleanup mechanisms
rg "impl Drop" wacore/src/store/persistence.rs -A 10

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Check how dirty flag is checked/used
rg "dirty" wacore/src/store/persistence.rs -B 1 -A 1

Repository: jlucaso1/whatsapp-rust

Length of output: 1046


🏁 Script executed:

# Look for save_to_disk implementation to understand what it does
rg "fn save_to_disk" wacore/src/store/persistence.rs -A 20

Repository: jlucaso1/whatsapp-rust

Length of output: 872


🏁 Script executed:

rg "fn drop|impl Drop" wacore/src/store/persistence.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Check how run_background_saver is called and if there's a shutdown pattern
rg "run_background_saver" wacore/src -r -B 2 -A 2

Repository: jlucaso1/whatsapp-rust

Length of output: 376


🏁 Script executed:

# Look for any graceful shutdown or final save mechanism
rg "shutdown|finalize|flush" wacore/src/store/persistence.rs -B 2 -A 2

Repository: jlucaso1/whatsapp-rust

Length of output: 285


🏁 Script executed:

# Search for run_background_saver more carefully across the repo
find . -name "*.rs" -type f | xargs grep -l "run_background_saver" 2>/dev/null

Repository: jlucaso1/whatsapp-rust

Length of output: 147


🏁 Script executed:

# Look for any graceful shutdown patterns in the codebase
rg "drop.*Arc|Drop|shutdown" wacore/src --type rs | head -20

Repository: jlucaso1/whatsapp-rust

Length of output: 92


🏁 Script executed:

# Check the entire file structure around the background saver to understand context
sed -n '133,200p' wacore/src/store/persistence.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 2410


🏁 Script executed:

# Check how run_background_saver is used in bot.rs and persistence_manager.rs
grep -B 5 -A 5 "run_background_saver" ./src/bot.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 559


🏁 Script executed:

grep -B 5 -A 5 "run_background_saver" ./src/store/persistence_manager.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 505


🏁 Script executed:

# Check if there's a shutdown method or if Arc is held until end of program
grep -B 10 -A 10 "run_background_saver" ./src/store/persistence_manager.rs | head -40

Repository: jlucaso1/whatsapp-rust

Length of output: 861


Add a final-flush mechanism on Arc drop to prevent data loss.

The background saver exits immediately when the manager is dropped, without flushing any remaining dirty state. If modify_device() is called and the Arc is dropped before the next save interval, unsaved changes are lost. Implement a graceful shutdown (e.g., via a Drop impl or explicit shutdown() method) that performs a final save_to_disk() before the task exits.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/store/persistence.rs` around lines 133 - 165, The background saver
currently exits immediately when the manager Arc is dropped, risking lost
changes; add a graceful shutdown that performs a final save: introduce a place
to store the spawned task handle (e.g., a field like background_task:
Mutex<Option<JoinHandle<()>>> on PersistenceManager), stop calling .detach() in
run_background_saver and instead save the JoinHandle there, and implement an
async shutdown(&self) method that notifies the saver (use
save_notify.notify_one()), awaits the background task handle to finish, and
calls save_to_disk(). Alternatively, if you need a synchronous API, provide a
shutdown_blocking(&self, runtime: Arc<dyn Runtime>) that calls
runtime.block_on(self.shutdown()). Ensure run_background_saver, save_to_disk,
save_notify, and the new background_task field are used consistently so the
final flush happens before the manager is dropped.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 19, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 15s —— View job


PR Review: refactor: runtime agnostic

  • Gather context and understand changes
  • Review runtime abstraction layer (wacore/src/runtime.rs, src/runtime_impl.rs)
  • Review store/persistence changes
  • Review socket and connection changes
  • Review client and bot API changes
  • Review remaining changed files for issues
  • Post final review

This is a well-executed refactor. The Runtime trait is minimal (3 methods), the WASM variant is cleanly handled via cfg, and the AbortHandle abort-on-drop pattern is solid. Good use of async-lock, futures, and event-listener to replace Tokio-specific primitives in wacore. Several issues from the previous review were addressed (dirty flag restoration on save failure, Weak for background saver, put_lid_mapping stale reverse entry, RetryReason oversized payload). Here's what remains:

Issues to address

1. Noise counter advanced before successful sendsrc/socket/noise_socket.rs:109-110

write_counter is still incremented eagerly at line 110 before encryption or transport succeeds. If encryption fails or transport.send() errors, the counter advances without producing a frame, desynchronizing the Noise session with the peer. The counter should only be committed after transport.send() succeeds.

// Current (line 109-110):
let counter = *write_counter;
*write_counter = write_counter.wrapping_add(1);

// Should be:
let counter = *write_counter;
// ... encrypt + send ...
// Only after transport.send() succeeds:
*write_counter = counter.wrapping_add(1);

Fix this →

2. Keepalive loop shutdown listener racesrc/keepalive.rs:176

The shutdown_notifier.listen() is called inside the futures::select! branch (line 176), meaning there's a window between loop iterations where a shutdown notification can fire with no registered listener. Since event_listener::Event drops notifications when no listeners exist, this can cause the keepalive loop to miss shutdown and continue running. Move listen() to the top of the loop and check is_connected() immediately after.

Fix this →

3. InMemoryBackend::create() doesn't materialize the devicewacore/src/store/in_memory.rs:548-550

create() only bumps a counter but doesn't store anything, so exists() stays false and load() returns None after create(). PersistenceManager::new() assumes create()load() makes the backend observable. Either create() should seed state.device, or the persistence manager should handle this case.

Fix this →

4. send_iq shutdown race after listener registrationsrc/request.rs:152

The shutdown listener is correctly registered before send_node(), but there's no re-check of is_running after registration. If shutdown fires between the initial check (line 131) and listen() (line 152), the listener will miss it. Add is_running re-check after line 152.

Fix this →

5. Background saver has no final flush on dropwacore/src/store/persistence.rs:133-165 and src/store/persistence_manager.rs:127-159

Both persistence managers use Weak for the background saver (good fix from previous review), but when the last strong Arc is dropped, the saver exits without flushing. If modify_device() was called between the last save and the drop, that write is lost. Consider an explicit shutdown() method that forces a final save_to_disk(), or store the AbortHandle and add a Drop impl.

6. wacore::store::persistence::modify_device bypasses DeviceCommand invariantswacore/src/store/persistence.rs:80-91

The coding guidelines say "Never modify Device state directly. Use DeviceCommand + PersistenceManager::process_command()". The public modify_device() method lets callers mutate Device arbitrarily without going through DeviceCommand. Consider making this pub(crate) or documenting it as an escape hatch with a clear warning.

Non-blocking observations

  • Duplicate PersistenceManager: wacore/src/store/persistence.rs and src/store/persistence_manager.rs are nearly identical but diverge on Device::new() vs Device::new(backend). The file header at wacore/src/store/persistence.rs:1-7 acknowledges this — good that it's documented as a consolidation target.

  • tokio::sync::watch still used in src/client.rs:16: This is a remaining Tokio-specific import. Not critical for this PR but worth noting for future runtime-agnostic completeness.

  • Duplicate doc comments in wacore/src/runtime.rs:107-118: Lines 107-108 and 115-116 duplicate the "Offload a blocking closure" doc comment. Minor formatting issue.

  • Benchmarks show improvements: All binary/protocol benchmarks show neutral or improved performance (some up to -36%), confirming the abstraction layer doesn't add measurable overhead.

Overall this is solid work — the core abstraction is well-designed, the previous review feedback was addressed, and the codebase is in a good position for future WASM support. The noise counter issue (item 1) is the highest priority fix.


View job

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b480b2ce91

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bot.rs
Comment on lines +117 to +119
pub struct BotHandle {
done_rx: futures::channel::oneshot::Receiver<()>,
_abort_handle: wacore::runtime::AbortHandle,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve detached run loops when BotHandle is dropped

Storing AbortHandle inside BotHandle changes Bot::run() from the old JoinHandle semantics to "abort on drop". Any existing caller that does bot.run().await?; without keeping the returned handle alive, or lets the handle fall out of scope, will now cancel client.run() immediately after startup instead of leaving the bot running in the background.

Useful? React with 👍 / 👎.

Comment thread src/portable_cache.rs
Comment on lines +279 to +300
pub async fn get_with<F>(&self, key: K, init: F) -> V
where
F: std::future::Future<Output = V>,
{
if let Some(v) = self.get(&key).await {
return v;
}
let value = init.await;
self.insert(key, value.clone()).await;
value
}

/// Same as [`get_with`](Self::get_with) but takes the key by reference.
pub async fn get_with_by_ref<F>(&self, key: &K, init: F) -> V
where
F: std::future::Future<Output = V>,
{
if let Some(v) = self.get(key).await {
return v;
}
let value = init.await;
self.insert(key.clone(), value.clone()).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make PortableCache::get_with* single-flight per key

When moka-cache is disabled, these helpers no longer provide the atomic initialization behavior that the call sites rely on. src/handlers/message.rs explicitly uses get_with_by_ref to establish per-chat ordering, and src/send.rs uses get_with to reuse one mutex per session. Two concurrent misses for the same key can both run init, create different Arc<Mutex<_>>/channel instances, and then overwrite each other, which breaks serialization and can process same-chat messages out of order or establish the same session in parallel.

Useful? React with 👍 / 👎.

Comment thread src/portable_cache.rs
Comment on lines +262 to +266
pub fn invalidate_all(&self) {
if let Some(mut guard) = self.inner.try_write() {
guard.map.clear();
guard.insertion_order.clear();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not silently skip invalidate_all on lock contention

In the non-moka path this clear operation becomes a best-effort no-op whenever another task is reading or writing the cache. Client::mark_disconnected() calls retried_group_messages.invalidate_all() during reconnect (src/client.rs:1007) specifically to reset retry deduplication, so a concurrent cache access can leave stale entries behind for their full TTL and cause later retry receipts after reconnect to be ignored as duplicates.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/socket/noise_socket.rs (1)

152-158: ⚠️ Potential issue | 🔴 Critical

Do not reuse the Noise write counter after a send error.

Advancing write_counter only after transport.send() succeeds makes the next send reuse the same AEAD nonce whenever the transport reports Err. That error boundary is not strong enough to guarantee the frame never left the process, so this can desynchronize the Noise state and, in the worst case, reuse a nonce under the same key.

🔐 Safer counter ordering
-        if let Err(e) = transport.send(out_buf).await {
-            return Err(EncryptSendError::transport(e));
-        }
-
-        // Only advance the counter after the encrypted frame was successfully sent.
-        // If transport.send() fails, we can retry with the same counter value.
         *write_counter = write_counter.wrapping_add(1);
+        if let Err(e) = transport.send(out_buf).await {
+            return Err(EncryptSendError::transport(e));
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/socket/noise_socket.rs` around lines 152 - 158, The current code delays
advancing write_counter until after transport.send succeeds, which can cause
nonce reuse if transport reports Err; change the logic in the send path that
builds and sends out_buf so that *write_counter is incremented (using
write_counter.wrapping_add(1)) immediately after sealing the frame and before
calling transport.send(out_buf). Keep the same error mapping to
EncryptSendError::transport for transport.send failures but do not decrement or
reuse the counter on error; update the section around transport.send,
write_counter, out_buf, and EncryptSendError::transport so the counter is always
advanced prior to the send attempt.
♻️ Duplicate comments (3)
src/keepalive.rs (1)

95-105: ⚠️ Potential issue | 🟡 Minor

Also check is_running immediately after registering shutdown.

This still leaves a narrow lost-shutdown window: if disconnect() flips shutdown state just before the new listener is created, the event is gone and the loop can sleep one more full interval. Checking is_running immediately after Line 97 makes the exit deterministic.

Suggested patch
             let shutdown = self.shutdown_notifier.listen();
+            if !self.is_running.load(Ordering::Relaxed) {
+                debug!(target: "Client/Keepalive", "Shutdown already requested, exiting keepalive loop.");
+                return;
+            }

Expected result: disconnect() updates shutdown state independently of is_connected(), so the guard above is still needed to cover a missed notification.

#!/bin/bash
set -euo pipefail
sed -n '89,185p' src/keepalive.rs
rg -n -C2 'shutdown_notifier|is_running|is_connected|disconnect\(' src/client.rs src/keepalive.rs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/keepalive.rs` around lines 95 - 105, After calling
self.shutdown_notifier.listen() in the keepalive loop, immediately re-check the
shutdown state by calling is_running() (or equivalent) and break/return if it
reports stopped to avoid a lost-shutdown window; i.e., after creating the
listener from shutdown_notifier.listen() but before awaiting
self.runtime.sleep(interval) (and before the futures::select! sleep branch),
call self.is_running() and exit if false so disconnect() cannot be missed
between listener creation and the sleep. Use the existing symbols
shutdown_notifier.listen(), is_running(), and the surrounding keepalive loop to
place this check.
src/client.rs (1)

249-250: ⚠️ Potential issue | 🟠 Major

Keep whatsapp-rust::Client Tokio-backed at this boundary.

Accepting Arc<dyn Runtime> in the main client API pushes the runtime-agnostic surface out of wacore and into src/. Client::new* should construct/use TokioRuntime internally so downstream code cannot instantiate the main client on a non-Tokio executor. Based on learnings: Applies to whatsapp-rust/src/**/*.rs : whatsapp-rust main client must use Tokio runtime and SQLite persistence with Diesel ORM.

Also applies to: 496-522

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 249 - 250, Client currently exposes pub(crate)
runtime: Arc<dyn Runtime> on the Client boundary which allows non-Tokio runtimes
to be injected; change Client construction so the public API does not accept or
store a generic Runtime: make Client::new / Client::new_* constructors create
and embed a TokioRuntime internally (e.g., use TokioRuntime within the Client
implementation and remove or make private any APIs that accept Arc<dyn
Runtime>), ensure the Client struct no longer exposes a runtime-agnostic field
and that wacore-facing code uses the concrete TokioRuntime for execution and
SQLite/Diesel persistence initialization to keep the main client Tokio-backed.
src/history_sync.rs (1)

157-170: ⚠️ Potential issue | 🟠 Major

Shutdown still waits for the full history parse.

Closing rx here only makes send_blocking fail; it does not stop process_history_sync(), because the callback ignores that failure and parsing continues until the blob is fully consumed. result_rx.await then keeps this task alive anyway, so the early-exit path still burns CPU during shutdown. Please propagate cancellation back into process_history_sync from the callback, or stop awaiting result_rx once the dispatch loop breaks for shutdown.

Also applies to: 205-206

🧹 Nitpick comments (2)
wacore/src/protocol/keepalive.rs (1)

63-67: Loosen the near-zero timing assertion to reduce CI flakiness.
Line 66 (elapsed < 100) is fragile on busy runners; consider a wider tolerance or clock injection for deterministic tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/protocol/keepalive.rs` around lines 63 - 67, The test helper
ms_since_recent currently asserts elapsed < 100 which is flaky on CI; either
relax the tolerance (e.g., increase to a few hundred ms such as 500) or refactor
ms_since_recent to accept an injected time provider/value so tests can pass a
deterministic now_ms; update the call site to use crate::time::now_millis() (or
a mock) and adjust the assertion on ms_since(now_ms) accordingly to use the new
tolerance or injected clock in the ms_since_recent helper.
transports/tokio-transport/src/lib.rs (1)

136-137: Remove unnecessary wasm32 conditional on a Tokio-specific transport crate.

The conditional async_trait(?Send) pattern here is redundant because Tokio does not support wasm32 targets—it requires a real OS runtime. This crate (tokio-transport) will fail to compile for wasm32 during dependency resolution, not due to trait bounds. The conditional attributes suggest cross-platform compatibility that cannot be achieved through trait syntax alone.

If wasm32 support is needed, create a separate transport implementation (e.g., using browser WebSocket APIs), rather than relying on a Tokio-based transport with conditional trait attributes.

Remove the conditional async_trait declarations or document why this crate targets wasm32 despite Tokio's platform constraints.

Also applies to: 201-202

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@transports/tokio-transport/src/lib.rs` around lines 136 - 137, The
async_trait attribute is conditionally applied for wasm32 targets but this crate
(tokio-transport) relies on Tokio and cannot target wasm32; remove the redundant
conditional attributes. Edit the attribute usages of #[cfg_attr(target_arch =
"wasm32", async_trait(?Send))] / #[cfg_attr(not(target_arch = "wasm32"),
async_trait)] (and the duplicate occurrences later) and replace them with a
single plain #[async_trait] (or remove the cfg_attr wrapper altogether) so the
async_trait macro is consistently applied for the Tokio-based transport;
alternatively, if you intend to keep wasm32 documentation, add a crate-level
comment stating that tokio-transport does not support wasm32 and that a separate
transport is required for browser targets.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@http_clients/ureq-client/src/lib.rs`:
- Around line 42-43: The crate currently uses ureq::Agent and
tokio::task::spawn_blocking but only annotates the async_trait with a
conditional ?Send; add a compile-time exclusion for wasm by gating the entire
backend implementation with #[cfg(not(target_arch = "wasm32"))] (e.g. on the
crate root or on the module/impl containing the ureq client and functions that
call tokio::task::spawn_blocking) so the ureq-based code isn't compiled for
wasm32, or alternatively provide a separate wasm-compatible HTTP client
implementation and gate selection by target_arch; ensure the symbols to protect
include the module/impl containing ureq::Agent and any functions invoking
tokio::task::spawn_blocking.

In `@src/bot.rs`:
- Around line 415-425: The public with_runtime<Rt: Runtime> on BotBuilder
exposes arbitrary runtimes; keep the top-level crate Tokio-only by removing or
privatizing the generic runtime hook and defaulting/injecting a Tokio runtime
instead. Replace the public signature of BotBuilder::with_runtime (or make it
crate-private) so it no longer accepts a generic Rt: Runtime; ensure
BotBuilder::runtime is initialized with a TokioRuntime (or
Arc::new(TokioRuntime)) in the public builder path and move any generic Runtime
trait factory/with_runtime functionality into the internal wacore crate or test
helpers (keep references to BotBuilder, with_runtime, Runtime, and TokioRuntime
to locate and update the code).

In `@src/lib.rs`:
- Line 36: Re-exporting wacore::runtime::Runtime at the crate root exposes a
runtime-pluggable API; remove the public re-export (pub use
wacore::runtime::Runtime) so the Runtime trait stays internal, and instead
expose only the Tokio-backed surface from src (create or re-export a
Tokio-specific runtime type or factory in src, e.g., TokioRuntime or functions
that construct the Tokio runtime used by the main client), ensuring the public
API and main client remain Tokio-only and continue to use SQLite/Diesel
persistence as implemented.

In `@src/message.rs`:
- Around line 125-146: The increment_retry_count logic uses get+insert which is
not serialized and can race with detached tasks (e.g., spawn_retry_receipt) or
the sender-count preseed; wrap the per-chat retry updates using the per-chat
mutex in Client::chat_locks so only one operation mutates message_retry_counts
for a given cache_key at a time. Specifically, in increment_retry_count (and in
the sender-count preseed and spawn_retry_receipt code paths referenced), acquire
the chat lock for the chat/queue key from Client::chat_locks before calling
get/insert on message_retry_counts, perform the read/conditional-update while
holding the lock, then release it; ensure the lock key matches the same chat
identifier used by other retry-related routines so updates are properly
serialized.

In `@src/pdo.rs`:
- Around line 84-101: The current get-then-insert on self.pdo_pending_requests
allows a race where two coroutines both see missing entries and both insert;
make the check-and-insert atomic by using the cache's atomic API or by guarding
this section with a lock: for example, replace the get()+insert() sequence
around cache_key with an atomic insert_if_absent/entry or use a dedicated async
Mutex/RwLock keyed by cache_key to perform the "if absent then insert
PendingPdoRequest { message_info: info.clone(), requested_at: Instant::now() }"
in one critical section so only one request is created for the same message;
keep the debug return Ok(()) path when the insert fails because an entry already
existed.

In `@src/portable_cache.rs`:
- Around line 261-266: The invalidate_all() method currently uses
self.inner.try_write() and can silently no-op under contention; change it to
acquire a deterministic write lock (e.g., use self.inner.write() or loop until
try_write succeeds) so the clear is guaranteed to run. Locate invalidate_all and
the inner RwLock/lock field (self.inner, guard.map, guard.insertion_order) and
replace the non-blocking try_write with a blocking write acquisition or a
retrying strategy so the map and insertion_order are always cleared before
returning.
- Around line 209-229: The current insertion path ignores a configured zero
capacity because the eviction loop is skipped when cap == 0 but insertion still
proceeds; update the logic around self.max_capacity to explicitly treat Some(0)
as "no caching" by returning early (or skipping the insertion) when cap == 0.
Concretely, inside the block that reads self.max_capacity, check for cap == 0
and exit before mutating guard.insertion_order or guard.map; keep the eviction
loop for cap > 0 and only push to guard.insertion_order and insert into
guard.map when caching is enabled. Ensure you reference and modify the existing
symbols guard.insertion_order, guard.map, and self.max_capacity so the behavior
for max_capacity == Some(0) is correctly honored.
- Around line 279-301: get_with and get_with_by_ref perform get / init.await /
insert as separate steps which allows concurrent misses for the same key to run
the initializer concurrently and insert different values; change these helpers
to perform an atomic "get-or-init" so only one initializer runs per key (e.g.,
use the cache's entry API or a write lock to check-and-insert in one critical
section, or store a shared in-progress placeholder/future so other callers await
the same initializer), ensuring you call the existing get/insert symbols (get,
insert) only within that protected/atomic path and for get_with_by_ref clone the
key exactly when inserting (insert(key.clone(), ...)) so the semantics remain
correct.

In `@src/retry.rs`:
- Around line 128-137: The cleanup currently uses scopeguard::guard with a
synchronous try_lock on pending_retries which can lose the race and never remove
dedupe_key; change the cleanup to perform an awaited removal by spawning an
async task that acquires self.pending_retries.lock().await and then calls
remove(&key) (or otherwise ensure the lock is awaited) instead of using try_lock
in the scopeguard closure — i.e., replace the try_lock/remove logic in the
scopeguard closure with tokio::spawn(async move { let mut set =
client.pending_retries.lock().await; set.remove(&key); }) (or an equivalent
awaited cleanup) so the dedupe_key is reliably removed.

In `@src/runtime_impl.rs`:
- Around line 11-31: TokioRuntime must hold a tokio::runtime::Handle to avoid
panics when calling runtime APIs from non-entered contexts: add a Handle field
to the TokioRuntime struct and provide a constructor (e.g.,
TokioRuntime::new(handle: tokio::runtime::Handle) and/or
TokioRuntime::try_current() that uses Handle::try_current()). Replace direct
calls to tokio::spawn and tokio::task::spawn_blocking with handle.spawn(...) and
handle.spawn_blocking(...), and ensure any use of tokio::time::sleep remains
compatible (constructing the Sleep future is fine but keep the Handle for
spawn/spawn_blocking); update method implementations for spawn, spawn_blocking,
and sleep to use the stored Handle so the wrapper is self-contained and safe
from runtime-entry panics.

In `@wacore/src/appstate_sync.rs`:
- Around line 72-79: prefetch_keys currently swallows errors from
get_app_state_key causing partial application; change prefetch_keys (the async
fn prefetch_keys(&self, pl: &PatchList)) to propagate failures from
get_app_state_key instead of ignoring them (e.g., use the ? operator or
explicitly return Err when get_app_state_key(&key_id).await fails) so that
missing keys or backend read errors abort before any snapshot/patch is applied;
keep the existing key collection via collect_key_ids_from_patch_list and ensure
the function returns the error Result immediately when any get_app_state_key
call fails.

In `@wacore/src/message_processing.rs`:
- Around line 208-221: DecryptedMessageResult currently only stores
sender_key_distribution_message in its skdm field, so fast-ratchet sender-key
payloads held in fast_ratchet_key_sender_key_distribution_message are dropped
and fast-ratchet-only messages become protocol-only with no key material to
persist; update the code that builds/returns DecryptedMessageResult (the logic
around is_sender_key_distribution_only(),
fast_ratchet_key_sender_key_distribution_message, and
sender_key_distribution_message) so that DecryptedMessageResult.skdm carries the
fast-ratchet sender-key payload as well (e.g., normalize or merge
fast_ratchet_key_sender_key_distribution_message into the skdm field or add and
populate skdm from whichever of those two fields is present) and ensure
is_skdm_only tracks fast-ratchet-only cases accordingly.
- Line 137: The code currently assigns padding_version with silent truncation
via `let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as
u8;`; replace the direct `as u8` cast with `u8::try_from(...)` and explicitly
handle the Err case by rejecting the stanza (returning or propagating an error)
when the value is out of range, while preserving the default of 2 when the
attribute is absent; update the code paths around
`enc_node.attrs().optional_u64("v")` and the `padding_version` variable to use
the `try_from` result and return a clear error (e.g., InvalidPaddingVersion) on
conversion failure.

In `@wacore/src/protocol/keepalive.rs`:
- Around line 46-48: The timeout check in the keepalive logic uses a strict
greater-than which treats a socket as alive when elapsed == DEAD_SOCKET_TIME;
update the comparison in the ms_since(...).map(...) closure to use >= instead of
> so the dead-socket threshold is inclusive (change the expression using
ms_since, last_sent_ms and DEAD_SOCKET_TIME accordingly).

In `@wacore/src/store/in_memory.rs`:
- Around line 351-375: get_pn_mapping and pn_to_lid currently implement a
last-write-wins reverse index that ignores LidPnMappingEntry.updated_at, so
out-of-order replays can return older mappings; fix by making the reverse lookup
respect updated_at: either (A) change get_pn_mapping to ignore pn_to_lid and
instead scan s.lid_mappings for entries where entry.phone_number == phone and
return the entry with the greatest updated_at (use LidPnMappingEntry.updated_at
for ordering), or (B) keep pn_to_lid but update put_lid_mapping to only
overwrite s.pn_to_lid when the incoming entry.updated_at is >= the currently
indexed entry's updated_at (lookup existing via s.pn_to_lid -> lid ->
s.lid_mappings to compare timestamps) and otherwise skip replacing the reverse
index; reference functions/fields: get_pn_mapping, put_lid_mapping, pn_to_lid,
lid_mappings, LidPnMappingEntry.updated_at.

---

Outside diff comments:
In `@src/socket/noise_socket.rs`:
- Around line 152-158: The current code delays advancing write_counter until
after transport.send succeeds, which can cause nonce reuse if transport reports
Err; change the logic in the send path that builds and sends out_buf so that
*write_counter is incremented (using write_counter.wrapping_add(1)) immediately
after sealing the frame and before calling transport.send(out_buf). Keep the
same error mapping to EncryptSendError::transport for transport.send failures
but do not decrement or reuse the counter on error; update the section around
transport.send, write_counter, out_buf, and EncryptSendError::transport so the
counter is always advanced prior to the send attempt.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 249-250: Client currently exposes pub(crate) runtime: Arc<dyn
Runtime> on the Client boundary which allows non-Tokio runtimes to be injected;
change Client construction so the public API does not accept or store a generic
Runtime: make Client::new / Client::new_* constructors create and embed a
TokioRuntime internally (e.g., use TokioRuntime within the Client implementation
and remove or make private any APIs that accept Arc<dyn Runtime>), ensure the
Client struct no longer exposes a runtime-agnostic field and that wacore-facing
code uses the concrete TokioRuntime for execution and SQLite/Diesel persistence
initialization to keep the main client Tokio-backed.

In `@src/keepalive.rs`:
- Around line 95-105: After calling self.shutdown_notifier.listen() in the
keepalive loop, immediately re-check the shutdown state by calling is_running()
(or equivalent) and break/return if it reports stopped to avoid a lost-shutdown
window; i.e., after creating the listener from shutdown_notifier.listen() but
before awaiting self.runtime.sleep(interval) (and before the futures::select!
sleep branch), call self.is_running() and exit if false so disconnect() cannot
be missed between listener creation and the sleep. Use the existing symbols
shutdown_notifier.listen(), is_running(), and the surrounding keepalive loop to
place this check.

---

Nitpick comments:
In `@transports/tokio-transport/src/lib.rs`:
- Around line 136-137: The async_trait attribute is conditionally applied for
wasm32 targets but this crate (tokio-transport) relies on Tokio and cannot
target wasm32; remove the redundant conditional attributes. Edit the attribute
usages of #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] /
#[cfg_attr(not(target_arch = "wasm32"), async_trait)] (and the duplicate
occurrences later) and replace them with a single plain #[async_trait] (or
remove the cfg_attr wrapper altogether) so the async_trait macro is consistently
applied for the Tokio-based transport; alternatively, if you intend to keep
wasm32 documentation, add a crate-level comment stating that tokio-transport
does not support wasm32 and that a separate transport is required for browser
targets.

In `@wacore/src/protocol/keepalive.rs`:
- Around line 63-67: The test helper ms_since_recent currently asserts elapsed <
100 which is flaky on CI; either relax the tolerance (e.g., increase to a few
hundred ms such as 500) or refactor ms_since_recent to accept an injected time
provider/value so tests can pass a deterministic now_ms; update the call site to
use crate::time::now_millis() (or a mock) and adjust the assertion on
ms_since(now_ms) accordingly to use the new tolerance or injected clock in the
ms_since_recent helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1bfbdebc-9653-4ac9-85ce-ee32be8a6580

📥 Commits

Reviewing files that changed from the base of the PR and between 01facb9 and 3dd53f5.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (69)
  • Cargo.toml
  • http_clients/ureq-client/src/lib.rs
  • src/appstate_sync.rs
  • src/bot.rs
  • src/cache.rs
  • src/cache_config.rs
  • src/cache_store.rs
  • src/client.rs
  • src/client/context_impl.rs
  • src/client/sender_keys.rs
  • src/features/chat_actions.rs
  • src/features/profile.rs
  • src/features/tctoken.rs
  • src/handlers/basic.rs
  • src/handlers/chatstate.rs
  • src/handlers/ib.rs
  • src/handlers/iq.rs
  • src/handlers/message.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/handlers/receipt.rs
  • src/handlers/traits.rs
  • src/handlers/unimplemented.rs
  • src/history_sync.rs
  • src/keepalive.rs
  • src/lib.rs
  • src/message.rs
  • src/pair.rs
  • src/pdo.rs
  • src/portable_cache.rs
  • src/receipt.rs
  • src/request.rs
  • src/retry.rs
  • src/runtime_impl.rs
  • src/send.rs
  • src/socket/noise_socket.rs
  • src/store/signal.rs
  • src/store/signal_adapter.rs
  • src/transport.rs
  • src/types/enc_handler.rs
  • src/unified_session.rs
  • src/usync.rs
  • src/version.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • transports/tokio-transport/src/lib.rs
  • wacore/Cargo.toml
  • wacore/libsignal/benches/libsignal_benchmark.rs
  • wacore/libsignal/src/store/mod.rs
  • wacore/src/appstate_sync.rs
  • wacore/src/client/context.rs
  • wacore/src/ib.rs
  • wacore/src/iq/tctoken.rs
  • wacore/src/lib.rs
  • wacore/src/message_processing.rs
  • wacore/src/messages.rs
  • wacore/src/net.rs
  • wacore/src/protocol/keepalive.rs
  • wacore/src/request.rs
  • wacore/src/runtime.rs
  • wacore/src/send.rs
  • wacore/src/stanza/notification.rs
  • wacore/src/store/cache.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/persistence.rs
  • wacore/src/store/traits.rs
  • wacore/src/time.rs
  • wacore/src/types/lid_pn.rs
  • wacore/src/types/message.rs
✅ Files skipped from review due to trivial changes (3)
  • wacore/src/types/message.rs
  • src/features/profile.rs
  • src/store/signal_adapter.rs
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/types/enc_handler.rs
  • wacore/Cargo.toml
  • wacore/src/lib.rs
  • src/unified_session.rs
  • src/send.rs
  • src/request.rs
  • src/store/signal.rs
  • src/handlers/message.rs
  • wacore/src/store/persistence.rs
  • Cargo.toml

Comment thread http_clients/ureq-client/src/lib.rs Outdated
Comment thread src/bot.rs
Comment on lines +122 to +126
impl BotHandle {
/// Abort the bot's run task.
pub fn abort(&self) {
self._abort_handle.abort();
}

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 | 🟠 Major

Track every task started by run() under BotHandle.

run() detaches the sync worker and pair-code task, but BotHandle::abort() and done_rx only cover client_for_run.run(). After callers cancel or await the handle, those detached tasks can still sit on receiver.recv() / wait_for_socket() and keep operating on the same Client, so shutdown is only partial.

Also applies to: 167-200, 209-250

Comment thread src/bot.rs
Comment on lines +415 to +425
impl<B, T, H> BotBuilder<B, T, H, Missing> {
/// Set the async runtime implementation to use.
///
/// This is required to build a bot.
pub fn with_runtime<Rt: Runtime>(self, runtime: Rt) -> BotBuilder<B, T, H, Provided> {
BotBuilder {
backend: self.backend,
transport_factory: self.transport_factory,
http_client: self.http_client,
runtime: Some(Arc::new(runtime)),
event_handler: self.event_handler,

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 | 🟠 Major

Keep src::BotBuilder Tokio-only.

The public with_runtime<Rt: Runtime> hook makes arbitrary runtimes part of the supported API of the top-level crate. The runtime abstraction belongs in wacore, but src/** is still supposed to stay Tokio-backed; I'd default/inject TokioRuntime here and keep custom runtimes internal to wacore or tests.

Based on learnings, whatsapp-rust/src/**/*.rs main client must use Tokio runtime and SQLite persistence with Diesel ORM.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bot.rs` around lines 415 - 425, The public with_runtime<Rt: Runtime> on
BotBuilder exposes arbitrary runtimes; keep the top-level crate Tokio-only by
removing or privatizing the generic runtime hook and defaulting/injecting a
Tokio runtime instead. Replace the public signature of BotBuilder::with_runtime
(or make it crate-private) so it no longer accepts a generic Rt: Runtime; ensure
BotBuilder::runtime is initialized with a TokioRuntime (or
Arc::new(TokioRuntime)) in the public builder path and move any generic Runtime
trait factory/with_runtime functionality into the internal wacore crate or test
helpers (keep references to BotBuilder, with_runtime, Runtime, and TokioRuntime
to locate and update the code).

Comment thread src/lib.rs
pub mod runtime_impl;
#[cfg(not(target_arch = "wasm32"))]
pub use runtime_impl::TokioRuntime;
pub use wacore::runtime::Runtime;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Keep the main crate's public runtime API Tokio-only.

Re-exporting wacore::runtime::Runtime from the crate root makes whatsapp-rust look runtime-pluggable, but the repo guidance says the main client should stay on Tokio. I'd keep this trait internal and only expose the Tokio-backed surface from src.

Based on learnings: whatsapp-rust main client must use Tokio runtime and SQLite persistence with Diesel ORM.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib.rs` at line 36, Re-exporting wacore::runtime::Runtime at the crate
root exposes a runtime-pluggable API; remove the public re-export (pub use
wacore::runtime::Runtime) so the Runtime trait stays internal, and instead
expose only the Tokio-backed surface from src (create or re-export a
Tokio-specific runtime type or factory in src, e.g., TokioRuntime or functions
that construct the Tokio runtime used by the main client), ensuring the public
API and main client remain Tokio-only and continue to use SQLite/Diesel
persistence as implemented.

Comment thread src/message.rs
Comment on lines +125 to 146
/// Increments the retry count for a message and returns the new count.
/// Returns `None` if max retries have been reached.
///
/// Uses moka's `and_compute_with` for truly atomic read-modify-write operations,
/// preventing race conditions where concurrent calls could exceed MAX_DECRYPT_RETRIES.
/// Uses get + insert for portability across cache backends.
async fn increment_retry_count(&self, cache_key: &str) -> Option<u8> {
use moka::ops::compute::Op;

let result = self
.message_retry_counts
.entry_by_ref(cache_key)
.and_compute_with(|maybe_entry| {
let op = if let Some(entry) = maybe_entry {
let current = entry.into_value();
if current >= MAX_DECRYPT_RETRIES {
// Max retries reached, don't increment
Op::Nop
} else {
Op::Put(current + 1)
}
} else {
Op::Put(1_u8)
};
std::future::ready(op)
})
.await;

match result {
moka::ops::compute::CompResult::Inserted(entry) => Some(entry.into_value()),
moka::ops::compute::CompResult::ReplacedWith(entry) => Some(entry.into_value()),
moka::ops::compute::CompResult::Unchanged(_) => None, // Max retries reached
moka::ops::compute::CompResult::StillNone(_) => None,
moka::ops::compute::CompResult::Removed(_) => None,
let current = self.message_retry_counts.get(&cache_key.to_string()).await;
match current {
Some(count) if count >= MAX_DECRYPT_RETRIES => None,
Some(count) => {
let new_count = count + 1;
self.message_retry_counts
.insert(cache_key.to_string(), new_count)
.await;
Some(new_count)
}
None => {
self.message_retry_counts
.insert(cache_key.to_string(), 1_u8)
.await;
Some(1)
}
}

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 | 🟠 Major

Serialize retry-count updates.

increment_retry_count() and the sender-count preseed both switched to get + insert, while spawn_retry_receipt() still runs on detached tasks. Two failures for the same message can now reuse the same retry number or overwrite a higher cached sender count with a lower one, which weakens MAX_DECRYPT_RETRIES and can re-enable retry loops.

As per coding guidelines, "Use Client::chat_locks to serialize per-chat operations for concurrency safety".

Also applies to: 188-243, 483-488

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 125 - 146, The increment_retry_count logic uses
get+insert which is not serialized and can race with detached tasks (e.g.,
spawn_retry_receipt) or the sender-count preseed; wrap the per-chat retry
updates using the per-chat mutex in Client::chat_locks so only one operation
mutates message_retry_counts for a given cache_key at a time. Specifically, in
increment_retry_count (and in the sender-count preseed and spawn_retry_receipt
code paths referenced), acquire the chat lock for the chat/queue key from
Client::chat_locks before calling get/insert on message_retry_counts, perform
the read/conditional-update while holding the lock, then release it; ensure the
lock key matches the same chat identifier used by other retry-related routines
so updates are properly serialized.

Comment on lines +72 to +79
/// Pre-fetch and cache all keys needed for a patch list.
async fn prefetch_keys(&self, pl: &PatchList) -> Result<()> {
let key_ids = collect_key_ids_from_patch_list(pl.snapshot.as_ref(), &pl.patches);
for key_id in key_ids {
// This will fetch and cache if not already cached
let _ = self.get_app_state_key(&key_id).await;
}
Ok(())

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 | 🟠 Major

Fail before applying any snapshot/patch state when a key is missing.

prefetch_keys() ignores get_app_state_key() errors, so a missing key or backend read failure can be discovered only after earlier snapshot/patch work has already been processed and persisted. That turns a collection that should fail atomically into a partially-applied one.

💡 Minimal fix
     async fn prefetch_keys(&self, pl: &PatchList) -> Result<()> {
         let key_ids = collect_key_ids_from_patch_list(pl.snapshot.as_ref(), &pl.patches);
         for key_id in key_ids {
-            // This will fetch and cache if not already cached
-            let _ = self.get_app_state_key(&key_id).await;
+            self.get_app_state_key(&key_id).await?;
         }
         Ok(())
     }
📝 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
/// Pre-fetch and cache all keys needed for a patch list.
async fn prefetch_keys(&self, pl: &PatchList) -> Result<()> {
let key_ids = collect_key_ids_from_patch_list(pl.snapshot.as_ref(), &pl.patches);
for key_id in key_ids {
// This will fetch and cache if not already cached
let _ = self.get_app_state_key(&key_id).await;
}
Ok(())
/// Pre-fetch and cache all keys needed for a patch list.
async fn prefetch_keys(&self, pl: &PatchList) -> Result<()> {
let key_ids = collect_key_ids_from_patch_list(pl.snapshot.as_ref(), &pl.patches);
for key_id in key_ids {
self.get_app_state_key(&key_id).await?;
}
Ok(())
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/appstate_sync.rs` around lines 72 - 79, prefetch_keys currently
swallows errors from get_app_state_key causing partial application; change
prefetch_keys (the async fn prefetch_keys(&self, pl: &PatchList)) to propagate
failures from get_app_state_key instead of ignoring them (e.g., use the ?
operator or explicitly return Err when get_app_state_key(&key_id).await fails)
so that missing keys or backend read errors abort before any snapshot/patch is
applied; keep the existing key collection via collect_key_ids_from_patch_list
and ensure the function returns the error Result immediately when any
get_app_state_key call fails.

}
};

let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as u8;

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n wacore/src/message_processing.rs | sed -n '130,145p'

Repository: jlucaso1/whatsapp-rust

Length of output: 784


🏁 Script executed:

cat -n wacore/src/message_processing.rs | sed -n '1,50p'

Repository: jlucaso1/whatsapp-rust

Length of output: 2098


🌐 Web query:

Rust u64 as u8 casting truncation behavior untrusted input

💡 Result:

In Rust, casting a u64 to u8 using as performs truncation by retaining only the 8 least significant bits (LSB) of the u64 value, discarding the higher bits. This behavior is well-defined, follows C-like conventions for integer casts, and is safe (no panic or UB). For example: - 1000u64 as u8 == 232 (since 1000 in binary is ...1111101000, LSB 8 bits are 11101000 == 232) - This is equivalent to value % 256 for unsigned values within reasonable ranges. This truncation is deterministic and platform-independent for integer-to-smaller-integer casts. For untrusted input (e.g., potentially large u64 values from external sources), the cast silently truncates higher bits without error. This is expected behavior but can lead to logical errors if higher bits are significant (e.g., index out of bounds if assuming full u64 range fits in u8). Use checked methods like u64::checked_cast_to:: (nightly) or manual bounds checking (e.g., if value > u8::MAX { handle_error } else { value as u8 }) for safety. Official sources confirm: - Rust By Example: "the first 8 least significant bits (LSB) are kept, while the rest ... get truncated." - Rust Reference: Numeric casts truncate bit representation.

Citations:


Use u8::try_from() instead of silent truncation for the padding version attribute.

Line 137 casts an untrusted u64 attribute directly to u8 with as u8, which silently truncates oversized values (e.g., v="258" becomes 2). For malformed stanza data, reject invalid ranges explicitly using u8::try_from() instead of masking the error through truncation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/message_processing.rs` at line 137, The code currently assigns
padding_version with silent truncation via `let padding_version =
enc_node.attrs().optional_u64("v").unwrap_or(2) as u8;`; replace the direct `as
u8` cast with `u8::try_from(...)` and explicitly handle the Err case by
rejecting the stanza (returning or propagating an error) when the value is out
of range, while preserving the default of 2 when the attribute is absent; update
the code paths around `enc_node.attrs().optional_u64("v")` and the
`padding_version` variable to use the `try_from` result and return a clear error
(e.g., InvalidPaddingVersion) on conversion failure.

Comment on lines +208 to +221
pub struct DecryptedMessageResult {
/// The user-visible message content (with DeviceSentMessage unwrapped).
pub message: wa::Message,
/// The sender key distribution message, if present.
/// Must be processed to store the sender key for future group decryption.
pub skdm: Option<wa::message::SenderKeyDistributionMessage>,
/// Protocol-level messages that require special handling.
pub protocol_message: Option<ProtocolMessageInfo>,
/// True if the message contains only SKDM with no user-visible content.
/// These should not be surfaced as user events.
pub is_skdm_only: bool,
/// True if a DeviceSentMessage wrapper was present but the sender was
/// not "from me" (protocol violation — should be logged as a warning).
pub has_invalid_dsm: bool,

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 | 🟠 Major

Fast-ratchet sender-key payloads are dropped here.

is_sender_key_distribution_only() already treats fast_ratchet_key_sender_key_distribution_message as SKDM, but DecryptedMessageResult.skdm only carries sender_key_distribution_message. A fast-ratchet-only message will therefore be classified as protocol-only while exposing no key material to persist for later group decrypts.

Also applies to: 249-269

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/message_processing.rs` around lines 208 - 221,
DecryptedMessageResult currently only stores sender_key_distribution_message in
its skdm field, so fast-ratchet sender-key payloads held in
fast_ratchet_key_sender_key_distribution_message are dropped and
fast-ratchet-only messages become protocol-only with no key material to persist;
update the code that builds/returns DecryptedMessageResult (the logic around
is_sender_key_distribution_only(),
fast_ratchet_key_sender_key_distribution_message, and
sender_key_distribution_message) so that DecryptedMessageResult.skdm carries the
fast-ratchet sender-key payload as well (e.g., normalize or merge
fast_ratchet_key_sender_key_distribution_message into the skdm field or add and
populate skdm from whichever of those two fields is present) and ensure
is_skdm_only tracks fast-ratchet-only cases accordingly.

Comment on lines +46 to +48
ms_since(last_sent_ms)
.map(|elapsed| elapsed > DEAD_SOCKET_TIME.as_millis() as u64)
.unwrap_or(false)

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 | 🟡 Minor

Use inclusive timeout check for dead-socket threshold.
Line 47 uses >; at exactly DEAD_SOCKET_TIME the socket is still treated as alive for one extra tick. Use >= to match the documented timeout boundary.

Proposed fix
-    ms_since(last_sent_ms)
-        .map(|elapsed| elapsed > DEAD_SOCKET_TIME.as_millis() as u64)
-        .unwrap_or(false)
+    ms_since(last_sent_ms)
+        .map(|elapsed| elapsed >= DEAD_SOCKET_TIME.as_millis() as u64)
+        .unwrap_or(false)
📝 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
ms_since(last_sent_ms)
.map(|elapsed| elapsed > DEAD_SOCKET_TIME.as_millis() as u64)
.unwrap_or(false)
ms_since(last_sent_ms)
.map(|elapsed| elapsed >= DEAD_SOCKET_TIME.as_millis() as u64)
.unwrap_or(false)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/protocol/keepalive.rs` around lines 46 - 48, The timeout check in
the keepalive logic uses a strict greater-than which treats a socket as alive
when elapsed == DEAD_SOCKET_TIME; update the comparison in the
ms_since(...).map(...) closure to use >= instead of > so the dead-socket
threshold is inclusive (change the expression using ms_since, last_sent_ms and
DEAD_SOCKET_TIME accordingly).

Comment on lines +351 to +375
async fn get_pn_mapping(&self, phone: &str) -> Result<Option<LidPnMappingEntry>> {
let s = self.state.lock().await;
let entry = s
.pn_to_lid
.get(phone)
.and_then(|lid| s.lid_mappings.get(lid))
.cloned();
Ok(entry)
}

async fn put_lid_mapping(&self, entry: &LidPnMappingEntry) -> Result<()> {
let mut s = self.state.lock().await;
// Remove stale reverse entry if the LID was previously mapped to a different phone number
if let Some(old_phone) = s
.lid_mappings
.get(&entry.lid)
.filter(|old| old.phone_number != entry.phone_number)
.map(|old| old.phone_number.clone())
{
s.pn_to_lid.remove(&old_phone);
}
s.pn_to_lid
.insert(entry.phone_number.clone(), entry.lid.clone());
s.lid_mappings.insert(entry.lid.clone(), entry.clone());
Ok(())

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 | 🟠 Major

PN reverse lookups still ignore updated_at.

pn_to_lid makes the last write win. If mappings are replayed out of order, get_pn_mapping() can return an older LID even though the trait docs say "the most recent" mapping and the SQLite backend resolves this with ORDER BY updated_at DESC. Either pick the max-updated_at entry on lookup or only overwrite the reverse index when the incoming record is newer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/store/in_memory.rs` around lines 351 - 375, get_pn_mapping and
pn_to_lid currently implement a last-write-wins reverse index that ignores
LidPnMappingEntry.updated_at, so out-of-order replays can return older mappings;
fix by making the reverse lookup respect updated_at: either (A) change
get_pn_mapping to ignore pn_to_lid and instead scan s.lid_mappings for entries
where entry.phone_number == phone and return the entry with the greatest
updated_at (use LidPnMappingEntry.updated_at for ordering), or (B) keep
pn_to_lid but update put_lid_mapping to only overwrite s.pn_to_lid when the
incoming entry.updated_at is >= the currently indexed entry's updated_at (lookup
existing via s.pn_to_lid -> lid -> s.lid_mappings to compare timestamps) and
otherwise skip replacing the reverse index; reference functions/fields:
get_pn_mapping, put_lid_mapping, pn_to_lid, lid_mappings,
LidPnMappingEntry.updated_at.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/lib.rs (1)

33-37: ⚠️ Potential issue | 🟠 Major

Keep the crate-root runtime surface Tokio-only.

Line 34 and Line 37 currently expose both the Tokio implementation module and the generic Runtime trait from the main crate. That makes whatsapp-rust look runtime-pluggable even though this crate is supposed to stay Tokio-based. Keep the trait in wacore, make runtime_impl private, and only re-export TokioRuntime here.

Based on learnings: whatsapp-rust main client must use Tokio runtime and SQLite persistence with Diesel ORM.

Proposed API narrowing
 #[cfg(feature = "tokio-runtime")]
-pub mod runtime_impl;
+mod runtime_impl;
 #[cfg(feature = "tokio-runtime")]
 pub use runtime_impl::TokioRuntime;
-pub use wacore::runtime::Runtime;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib.rs` around lines 33 - 37, The crate currently exposes the internal
runtime module and re-exports the generic Runtime trait; make the runtime
implementation private and stop re-exporting the trait so the crate surface is
Tokio-only: change the module declaration from `pub mod runtime_impl` to a
private `mod runtime_impl` (keep the `#[cfg(feature = "tokio-runtime")]`), keep
the `pub use runtime_impl::TokioRuntime;` export, and remove the `pub use
wacore::runtime::Runtime;` re-export so only `TokioRuntime` is publicly exposed
while the `Runtime` trait stays in `wacore`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/lib.rs`:
- Around line 33-37: The crate currently exposes the internal runtime module and
re-exports the generic Runtime trait; make the runtime implementation private
and stop re-exporting the trait so the crate surface is Tokio-only: change the
module declaration from `pub mod runtime_impl` to a private `mod runtime_impl`
(keep the `#[cfg(feature = "tokio-runtime")]`), keep the `pub use
runtime_impl::TokioRuntime;` export, and remove the `pub use
wacore::runtime::Runtime;` re-export so only `TokioRuntime` is publicly exposed
while the `Runtime` trait stays in `wacore`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5a287cfb-d567-43c5-b8ce-2299afced263

📥 Commits

Reviewing files that changed from the base of the PR and between 3dd53f5 and 0c322d1.

📒 Files selected for processing (3)
  • Cargo.toml
  • src/lib.rs
  • wacore/Cargo.toml
✅ Files skipped from review due to trivial changes (1)
  • wacore/Cargo.toml

@Salientekill

Copy link
Copy Markdown
Contributor

Working hard on

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 20, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 3m 3s —— View job


PR Review: refactor: runtime agnostic

  • Gather context and understand changes
  • Review new/changed files since last review
  • Review portable cache implementation
  • Review runtime abstraction and remaining tokio coupling
  • Review noise socket counter fix
  • Review remaining issues from prior reviews
  • Post final review

Third review pass. Previous reviews covered the main architecture well — this focuses on what's been fixed, what hasn't, and new issues introduced in recent commits.

Previously reported issues — now fixed

These were flagged in reviews 1–2 and are confirmed resolved:

  • Dirty flag restored on save failuresrc/store/persistence_manager.rs:97-100 and wacore/src/store/persistence.rs both restore dirty = true on backend error ✓
  • Background saver uses Weak — Both persistence managers correctly downgrade to Weak<Self> and exit when upgrade fails ✓
  • Keepalive shutdown listener racesrc/keepalive.rs:97 registers listener before sleep/select ✓
  • send_iq shutdown listener racesrc/request.rs:150-158 registers listener first, then re-checks is_running with Acquire ordering ✓
  • BotHandle::abort() no longer a no-opsrc/bot.rs:125 calls self._abort_handle.abort()
  • put_lid_mapping stale reverse entrywacore/src/store/in_memory.rs:363-370 removes old phone→LID mapping when LID is remapped ✓
  • Socket error renamedsrc/socket/error.rs now says "task join error" instead of "tokio join error" ✓
  • blocking() panic messagewacore/src/runtime.rs now has accurate panic text covering both closure panics and runtime shutdown ✓

Issues still open from prior reviews

1. PortableCache::get_with() / get_with_by_ref() lack atomic get-or-initsrc/portable_cache.rs:279-301

This is the most critical remaining issue. The getinit.awaitinsert sequence has no lock held across the gap. Concurrent cache misses for the same key run independent initializers and insert different values. This directly breaks:

  • session_locks (6 call sites in src/send.rs, src/message.rs): Two concurrent messages from the same sender get different Arc<Mutex> instances → signal ratchet races that the lock was supposed to prevent
  • message_queues (src/handlers/message.rs): Two concurrent messages create different channel senders → messages dispatched to wrong/orphaned channels
  • message_enqueue_locks (src/handlers/message.rs): Same issue — per-chat serialization defeated

Moka's get_with handles this atomically. The portable fallback needs equivalent behavior — either hold the write lock across init, or use a per-key OnceCell/inflight map.

2. PortableCache::invalidate_all() silently fails under contentionsrc/portable_cache.rs:262-267

Uses try_write() which returns immediately on lock contention. Client::mark_disconnected() calls invalidate_all() during reconnect to reset retry deduplication (src/client.rs). If the lock is held by a concurrent read/write, stale entries survive reconnect.

Fix this →

3. pending_retries cleanup uses try_locksrc/retry.rs:132-138

The scopeguard uses try_lock() which silently fails under contention. If another retry holds the lock, the dedupe key is never removed and all future retries for that message are permanently blocked until restart.

Fix this →

4. PersistenceManager::flush() defined but never calledsrc/store/persistence_manager.rs:86

The flush() method exists but is never invoked. There's no shutdown path that calls it — when Client is dropped, the background saver exits (via Weak upgrade failure) without a final save. Any modify_device() calls between the last background save and drop are lost silently.

Fix this →

5. Bot::run() detaches sync worker and pair-code tasks not tracked by BotHandlesrc/bot.rs:167-237

BotHandle::abort() only aborts the client.run() task (line 242-245). The sync worker (line 172-200, .detach()) and pair-code task (line 212-237, .detach()) are fire-and-forget. After abort(), these tasks can still hold Client references (via Weak for sync worker — good, but via strong Arc for pair-code — bad) and continue operating.

New observations

6. PortableCache zero-capacity edge casesrc/portable_cache.rs:211

When max_capacity = Some(0), the eviction loop is skipped (condition cap > 0 is false) but the entry is still inserted. A zero-capacity cache stores 1 entry.

7. Noise write counter — current approach is correctsrc/socket/noise_socket.rs:152-158

The counter is now advanced after successful transport.send(). This is the right choice: if transport fails, retrying with the same nonce is safe because the sealed frame never reached the peer. Advancing before send (as CodeRabbit review 4 suggested) would risk nonce reuse on legitimate retries if the transport reported a false negative. The code is correct as-is.

What's done well

  • Previous review feedback was systematically addressed — shutdown races, dirty flag, Weak refs, abort implementation all fixed
  • The Runtime trait remains minimal and well-designed (3 methods)
  • Clean separation: wacore has no Tokio dependency, runtime-agnostic primitives work correctly
  • TokioRuntime implementation is straightforward and correct
  • Background saver Weak pattern properly drops strong refs during sleep
  • Benchmarks show neutral-to-improved performance across the board

Priority summary

# Issue Severity File
1 get_with race — concurrent misses create duplicate locks/channels Critical src/portable_cache.rs:279-301
2 invalidate_all silent no-op under contention Major src/portable_cache.rs:262-267
3 try_lock cleanup can permanently block retries Major src/retry.rs:132-138
4 flush() never called — data loss on shutdown Major src/store/persistence_manager.rs:86
5 Detached tasks not tracked by BotHandle Minor src/bot.rs:167-237
6 Zero-capacity cache still stores entries Minor src/portable_cache.rs:211

Issues 1-3 are functional correctness bugs that can cause races or stuck state in production. Issue 4 risks silent data loss. Issues 5-6 are edge cases.


View job

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 20, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 34s —— View job


PR Review: refactor: runtime agnostic

  • Gather context and understand changes since last review
  • Review fixes for previously reported issues
  • Review portable cache changes
  • Review remaining runtime coupling and new code
  • Check for new issues
  • Post final review

Fourth review pass. This focuses on what's changed since the last three reviews and assesses remaining issues.

Previously reported issues — status

Issue Status Notes
RetryReason duplication Fixed Now re-exported from wacore in src/message.rs:34
Keepalive constants/functions duplication Fixed Now imported from wacore::protocol::keepalive
tokio::sync::watch/OnceCell in src/client.rs Fixed No more Tokio-specific imports outside tests
Dirty flag restored on save failure Fixed Both persistence managers restore on error
Background saver uses Weak Fixed Correct Weak pattern with upgrade checks
Keepalive shutdown listener race Fixed src/keepalive.rs:97 registers listener before sleep
send_iq shutdown listener race Fixed src/request.rs:150-158 registers listener, then re-checks is_running with Acquire
BotHandle::abort() no-op Fixed src/bot.rs:125 calls self._abort_handle.abort()
put_lid_mapping stale reverse entry Fixed wacore/src/store/in_memory.rs:363-370 removes old mapping
blocking() panic message Fixed Now covers both closure panics and runtime shutdown
Socket error "tokio join error" Fixed Now says "task join error"
InMemoryBackend::create() materialization Fixed wacore/src/store/in_memory.rs:548-556 seeds state.device
PortableCache::get_with race (non-single-flight) Fixed Now uses per-key init locks with double-check pattern
PortableCache::invalidate_all silent no-op Fixed Now spins up to 64 times with warning fallback
Zero-capacity cache stores entries Fixed src/portable_cache.rs:222-225 returns early for cap == 0
Noise counter advanced before send Fixed src/socket/noise_socket.rs:156-158 advances after successful send
modify_device bypass warning Fixed wacore/src/store/persistence.rs:80-84 has explicit warning doc
No final flush on drop Documented wacore/src/store/persistence.rs:138-142 acknowledges limitation

Remaining issues

1. pending_retries cleanup uses try_locksrc/retry.rs:132-138

This was flagged in reviews 2 and 3 and remains unfixed. The scopeguard uses try_lock() which silently fails under contention. If another retry holds the lock, the dedupe key is never removed and all future retries for that message are permanently blocked until restart. Since this is a sync scopeguard but pending_retries is an async_lock::Mutex, try_lock() is the only sync option — but it means cleanup can be lost.

Consider spawning an async cleanup task instead:

let client_clone = self.clone();
let key_clone = dedupe_key.clone();
scopeguard::defer! {
    client_clone.runtime.spawn(Box::pin(async move {
        client_clone.pending_retries.lock().await.remove(&key_clone);
    })).detach();
}

Fix this →

2. PersistenceManager::flush() never called on shutdownsrc/store/persistence_manager.rs:86

The flush() method exists but is never invoked during shutdown. When Client is dropped, the background saver exits via Weak upgrade failure without a final save. Any modify_device() calls between the last periodic save (30s interval) and drop are lost silently. This is documented at wacore/src/store/persistence.rs:138-142 as a known limitation — just flagging that a flush() in Client::disconnect() or Bot::run() teardown would be a low-effort fix.

3. Bot detached tasks not tracked by BotHandlesrc/bot.rs:167-237

BotHandle::abort() only cancels the client.run() task (line 242-245). The sync worker (line 172-200, .detach()) and pair-code task (line 212-237, .detach()) are fire-and-forget. The sync worker uses Weak<Client> (good), but the pair-code task holds a strong Arc<Client> and can continue operating after abort(). This means shutdown is partial — the pair-code task could still be waiting on wait_for_socket() or running pair_with_code().

Design feedback (non-blocking)

4. Noise counter: current approach is correct

There was disagreement between CodeRabbit reviews about whether to advance the counter before or after transport.send(). The current approach (advance after success) is correct: if the frame never left the process, retrying with the same nonce is safe because the peer never saw it. Advancing before send risks nonce reuse on legitimate retries if transport reports a false negative. The code at src/socket/noise_socket.rs:152-158 is right as-is.

5. PortableCache::get_with single-flight implementation is well done

The per-key init lock approach at src/portable_cache.rs:325-346 with double-check after acquiring the lock is correct and well-tested. The test_session_lock_pattern test (line 664-699) validates the exact concurrency scenario that matters for session_locks. The init_locks cleanup in run_pending_tasks (line 396-399) prevents unbounded growth. Good work addressing this critical issue.

6. PortableCache::invalidate_all() spin loop is pragmatic

The 64-iteration spin with fallback warning (line 284-298) is a reasonable compromise for a sync method. The critical sections are short HashMap ops, so 64 spins should always suffice in practice. The log::warn fallback ensures visibility if it ever fails.

What's done well

  • Massive improvement from review 1 → now: Nearly all reported issues have been systematically addressed across 5 commits
  • PortableCache redesign: The single-flight get_with with per-key mutex is a significant improvement — properly tested with barrier-synchronized concurrent tests
  • Clean duplication removal: RetryReason, keepalive constants/functions, and ms_since/is_dead_socket are now properly re-exported from wacore
  • Shutdown race fixes: Both send_iq and keepalive_loop use the correct "register listener → check state → await" pattern
  • InMemoryBackend::create(): Now properly materializes the device so exists() returns true
  • modify_device documentation: The warning about bypassing DeviceCommand invariants is explicit and clear
  • Background saver Weak pattern: Both persistence managers correctly use Weak with strong ref held only during saves

Priority summary

# Issue Severity Status
1 try_lock cleanup can permanently block retries Major Open
2 flush() never called — data loss on shutdown Major Documented, not yet fixed
3 Detached tasks not tracked by BotHandle Minor Open

The PR is in very good shape after addressing feedback across multiple review rounds. Issue #1 is the only remaining functional correctness bug. Issues #2 and #3 are documented limitations that can be addressed in follow-up work.


View job

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 20, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@claude

claude Bot commented Mar 20, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 31s —— View job


PR Review: refactor: runtime agnostic

  • Gather context and understand changes since last review
  • Review changes in latest commits
  • Check status of previously reported issues
  • Review any new code or patterns
  • Post final review

Fifth review pass. This focuses on what changed in the latest commits (8a42811, f44b940) and the overall status of previously reported issues.

Previously reported issues — all addressed

Every significant issue from reviews 1–4 has been fixed. Here's the final status:

Issue Status
RetryReason duplication Fixed — re-exported from wacore
Keepalive constants/functions duplication Fixed — imported from wacore
tokio::sync::watch/OnceCell in client.rs Fixed — no more Tokio-specific imports outside tests
Dirty flag restored on save failure Fixed — both persistence managers restore on error
Background saver uses Weak Fixed — correct Weak pattern
Keepalive shutdown listener race Fixedsrc/keepalive.rs:97 registers listener before sleep
send_iq shutdown listener race Fixedsrc/request.rs:152 registers listener, re-checks is_running with Acquire at line 154-158
BotHandle::abort() no-op Fixedsrc/bot.rs:125 calls self._abort_handle.abort()
put_lid_mapping stale reverse entry Fixed
blocking() panic message Fixed — covers both closure panics and runtime shutdown
Socket error "tokio join error" Fixed — now "task join error"
InMemoryBackend::create() materialization Fixed
PortableCache::get_with race (non-single-flight) Fixed — per-key init locks with double-check pattern
PortableCache::invalidate_all silent no-op Fixed — spin-retry with warning fallback
Zero-capacity cache stores entries Fixed — early return for cap == 0
Noise counter advanced before send Fixed — advances after successful send at line 158
modify_device bypass warning Fixed — explicit warning doc
pending_retries cleanup uses try_lock Fixed — now spawns async task with .lock().await
flush() never called on shutdown Fixedsrc/client.rs:946 calls flush during disconnect

Remaining observations (non-blocking)

1. Bot detached tasks not tracked by BotHandlesrc/bot.rs:167-250

BotHandle::abort() only cancels client.run() (line 242-245). The sync worker (line 172-200, .detach()) and pair-code task (line 212-237, .detach()) are fire-and-forget. The sync worker uses Weak<Client> (good — it exits when client is dropped). The pair-code task holds a strong Arc<Client> and can continue operating after abort(). This is a minor concern since pair-code is a one-shot operation that completes quickly, but worth noting for future cleanup.

2. PortableCache::invalidate_all() spin loop is pragmatic but not airtightsrc/portable_cache.rs:284-299

The 64-iteration spin with log::warn fallback is a reasonable compromise. The critical sections are short HashMap ops, so it should always succeed in practice. However, it's still technically possible for the log-and-skip path to execute during reconnect, leaving stale retry entries. Given the rarity and the existing warning, this is acceptable.

3. yield_now method is a good additionwacore/src/runtime.rs:21-31

The yield_now() method on the Runtime trait with the Option return for zero-cost no-op on multi-threaded runtimes is well-designed. It's correctly used in the message processing loop at src/client.rs:1202. All direct tokio::task::yield_now() calls are properly confined to #[cfg(test)] blocks.

New code review: media reupload feature

wacore/src/media_retry.rs and src/features/media_reupload.rs — Clean implementation. Good separation: wacore handles the crypto (HKDF + AES-256-GCM) and node building, while the main crate handles the client interaction (sending receipt, waiting for notification). The 30-second timeout is reasonable.

What's done well

  • Systematic issue resolution: Every issue from 4 prior review rounds was addressed across multiple commits. This is exceptional follow-through.
  • PortableCache redesign: The per-key init lock with double-check pattern is correct and thoroughly tested (barrier-synchronized concurrent tests at lines 537-699).
  • pending_retries fix: The spawned async cleanup task properly awaits the lock, eliminating the permanent-block risk.
  • flush() on disconnect: src/client.rs:946 flushes dirty state before tearing down the connection — closes the data loss window.
  • Runtime::yield_now(): Smart API design — Option return avoids allocation overhead when yielding is unnecessary (multi-threaded runtimes).
  • Clean AbortHandle design: The detach() doc-comment explicitly notes the behavior. abort() is properly implemented with mutex poisoning recovery.
  • Benchmarks: All binary/protocol benchmarks show neutral-to-improved performance, confirming the abstraction layer adds no measurable overhead.

Verdict

This PR is in excellent shape. All previously reported functional correctness bugs have been fixed. The remaining observations are minor edge cases and documentation concerns that can be addressed in follow-up work. The runtime abstraction is clean, minimal, and well-tested.


View job

@jlucaso1
jlucaso1 merged commit 4a1d2ec into main Mar 20, 2026
8 checks passed
@jlucaso1
jlucaso1 deleted the runtime-agnostic branch March 20, 2026 16:33
jlucaso1 added a commit that referenced this pull request Mar 22, 2026
`#[serde(skip)]` on `Device.account` (added in #393) caused
`AdvSignedDeviceIdentity` to be lost on restart for custom stores
using serde. Without it, `<device-identity>` is missing from outgoing
stanzas and recipients silently drop pkmsg from unverifiable companion
devices.

Replace with a custom serde module that encodes as protobuf bytes.
Shared helpers used by both serde and the SQLite store (DRY).
@coderabbitai coderabbitai Bot mentioned this pull request Jun 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants