Skip to content

fix: address 9 audit findings across correctness, safety, and performance - #460

Merged
jlucaso1 merged 4 commits into
mainfrom
fix/audit-findings
Mar 29, 2026
Merged

fix: address 9 audit findings across correctness, safety, and performance#460
jlucaso1 merged 4 commits into
mainfrom
fix/audit-findings

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Mar 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes 9 issues found during a codebase audit, covering correctness bugs, safety improvements, and performance optimizations.

HIGH — Correctness bugs

1. Sender key check bypasses signal cache (src/send.rs)

  • Group message path used device_guard.load_sender_key() (direct DB read with write lock)
  • Now uses signal_cache.get_sender_key() with read lock, matching the status broadcast path
  • Previously caused unnecessary SKDM re-distribution on every group send until cache flush

2. Missing UndecryptableMessage event for group decrypt failures (src/message.rs)

  • NoSenderKeyState errors spawned retry receipt but never dispatched UndecryptableMessage
  • Consumer/UI never learned the message was pending decryption
  • Now dispatches the event before retry, matching the session-based decrypt path

3. Double cleanup_connection_state on transport disconnect (src/client.rs)

  • cleanup_connection_state() was called both inside the message loop (on Disconnected event) and in run() after the loop exits
  • Removed the redundant call inside the message loop — run() handles it

MEDIUM — Safety and performance

4. increment_retry_count TOCTOU (src/message.rs)

  • Documented the theoretical get-then-insert race window
  • spawn_retry_receipt detaches via runtime.spawn, so per-chat serialization does not cover this path; in practice retries for the same message are rare and recipients deduplicate by message ID

5. Signal cache flush() per-store lock-through-IO (wacore/src/store/signal_cache.rs)

  • Each store (sessions, identities, sender_keys) is flushed independently under its own lock
  • Only ONE store is locked during its I/O — the other two remain free for concurrent encrypt/decrypt
  • No dirty-set race: the lock is held from snapshot through write through clear, so mutations to the same store are blocked until flush completes
  • Strictly better than original (all 3 locks held simultaneously) and correct unlike the 3-phase approach (which had a race where mutations between snapshot and clear lost dirty markers)

6. Message queue capacity reduced (src/handlers/message.rs)

  • bounded(10000)bounded(500) per chat
  • Prevents memory amplification with many active chats while still handling bursts

LOW — Defensive improvements

7. PortableCache removal (src/portable_cache.rs)

  • Kept the original retain()-based removal — the lazy deletion optimization was reverted because remove→reinsert breaks FIFO eviction order (stale key causes live entry to be evicted)
  • O(capacity) retain is correct and bounded by max_capacity

8. now_millis() as u64 guard (keepalive.rs, client.rs)

  • Added .max(0) before as u64 cast at all 11 sites to prevent silent wrap on negative clock values

9. Fragile JID type heuristic (src/message.rs)

  • Replaced server.contains(".us") with proper is_group() / is_broadcast_list() / is_status_broadcast() methods

Test plan

  • cargo clippy --all --tests — zero warnings
  • cargo test --all --exclude e2e-tests — all tests pass
  • Verified sender key check matches the existing status broadcast pattern
  • Verified UndecryptableMessage event signature matches existing dispatch_undecryptable_event
  • Verified signal cache flush holds only one store lock at a time during I/O
  • Verified dirty sets are cleared only after successful writes, under the same lock that snapshotted them
  • Verified no dirty-set race: lock held from snapshot through clear prevents mid-flush mutations from losing markers

Summary by CodeRabbit

  • Bug Fixes

    • More accurate message sender classification for correct message-type handling.
    • Hardened timestamp handling to prevent negative-time casting issues in connection tracking.
    • Now emits an undecryptable-message event and triggers retry receipts when decryption lacks keys.
    • Improved disconnection event handling to avoid duplicate or premature disconnect notifications.
  • Chores

    • Reduced per-chat queue capacity to lower memory use under bursts.
    • Safer, incremental encryption key cache flushes to minimize lock scope and backend errors.

…ance

HIGH:
1. Sender key check now reads through signal_cache with a read lock
   instead of bypassing cache and taking a write lock (src/send.rs)
2. Dispatch UndecryptableMessage event for group NoSenderKeyState
   decrypt failures — matches the session-based path (src/message.rs)
3. Remove double cleanup_connection_state call on transport disconnect
   — the call in run() already covers it (src/client.rs)

MEDIUM:
4. Document TOCTOU window in increment_retry_count — mitigated by
   per-chat mailbox serialization (src/message.rs)
5. Signal cache flush uses snapshot-then-release pattern — locks are
   held only during serialization, not during I/O (signal_cache.rs)
6. Reduce per-chat message queue capacity from 10000 to 500 to limit
   memory amplification (src/handlers/message.rs)

LOW:
7. PortableCache::remove_key uses lazy deletion (O(1)) instead of
   O(n) VecDeque scan (src/portable_cache.rs)
8. Guard now_millis() casts with .max(0) before as u64 to prevent
   silent wrap on negative clock values (keepalive.rs, client.rs)
9. Replace fragile .contains(".us") heuristic with proper JID type
   methods is_group()/is_broadcast_list()/is_status_broadcast()
@coderabbitai

coderabbitai Bot commented Mar 28, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 294c42fe-0374-4a12-8859-fe9057c6de6c

📥 Commits

Reviewing files that changed from the base of the PR and between 782515c and a65d796.

📒 Files selected for processing (1)
  • src/client.rs

📝 Walkthrough

Walkthrough

Reworked disconnect handling to centralize cleanup and Disconnected dispatch, clamped millisecond timestamps before casting, reduced per-chat message queue capacity, improved JID sender classification and undecryptable handling, switched sender-key existence checks to use the signal cache, and refactored signal_cache flush to per-store lock-scoped flushing.

Changes

Cohort / File(s) Summary
Client / Transport / Keepalive
src/client.rs, wacore/src/protocol/keepalive.rs
Centralized cleanup: run() now computes unexpected_disconnect and dispatches Event::Disconnected after cleanup_connection_state().await; read_messages_loop() no longer unconditionally calls cleanup or dispatches Disconnected. Clamped now_millis() via .max(0) before casting to u64 for dead-socket timestamps.
Message queue & handlers
src/handlers/message.rs, src/message.rs
Per-chat async channel capacity reduced from 10000 to 500. Replaced substring-based sender heuristics with explicit Jid checks (is_group(), is_broadcast_list(), is_status_broadcast()); process_group_enc_batch now uses decrypt_fail_mode to emit UndecryptableMessage before scheduling retry on NoSenderKeyState.
Send / Sender-key lookup
src/send.rs
Group-send sender-key existence check now uses self.signal_cache.get_sender_key(...) with a device_store_arc.read().await read guard instead of loading sender-key via a write-guard from the device store; force_skdm computation unchanged.
Signal cache flush
wacore/src/store/signal_cache.rs
Refactored SignalStoreCache::flush() to flush per store with lock-scoped snapshots: lock each store, collect dirty/deleted keys, perform backend put/delete while holding that store lock, then remove flushed keys from dirty/deleted sets; removed global snapshot/clear step and adjusted sender-key handling to snapshot only dirty keys and propagate deletions explicitly.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  rect rgba(200,200,255,0.5)
    participant Client
    participant ReadLoop as ReadLoop/Transport
    participant Cleanup as CleanupState
    participant Dispatcher as EventDispatcher
  end

  Client->>ReadLoop: spawn read_messages_loop()
  ReadLoop-->>Client: TransportEvent::Data / DataReceived
  Client->>Client: update last_data_received_ms (now_millis().max(0) as u64)
  ReadLoop->>Client: TransportEvent::Disconnected or Err
  Client->>Client: compute expected_disconnect / unexpected_disconnect
  Client->>Cleanup: await cleanup_connection_state()
  alt unexpected_disconnect
    Client->>Dispatcher: dispatch Event::Disconnected
  else expected/graceful
    note right of Client: skip immediate Disconnected dispatch
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I hopped through timestamps, soft and quick,

clamped the milliseconds, fixed the trick.
Queues grew smaller, locks breathed light,
keys found via cache, retries took flight.
A rabbit’s nibble, tidy and bright.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the primary change: fixing nine audit findings across multiple dimensions (correctness, safety, performance). It directly reflects the main objectives of the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 fix/audit-findings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@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: b1acfcba80

ℹ️ 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 wacore/src/store/signal_cache.rs Outdated
Comment on lines +310 to +311
let dirty_keys: Vec<_> = state.dirty.drain().collect();
let deleted_keys: Vec<_> = state.deleted.drain().collect();

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 Keep dirty flags until backend flush completes

flush() now drains dirty/deleted before performing backend I/O, so any mid-flush error (for example a transient SQLite write failure) returns with those markers already cleared and unchanged entries will not be retried on later flushes. That can silently drop pending session/identity/sender-key persistence and lose crypto state after process restart; the same drain-before-write pattern is repeated for all three stores in this function.

Useful? React with 👍 / 👎.

Comment thread src/portable_cache.rs Outdated
Comment on lines +54 to +57
// Lazy deletion: remove from map but leave stale key in insertion_order.
// Stale keys are skipped during FIFO eviction (map.remove returns None).
// run_pending_tasks() periodically compacts insertion_order.
self.map.remove(key)

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 Remove stale FIFO keys when invalidating cache entries

This lazy deletion change leaves insertion_order entries behind on every remove/invalidate, but compaction only happens in run_pending_tasks() and is not part of normal cache operations. In moka-cache-disabled builds, workloads that frequently invalidate and reinsert keys can grow insertion_order indefinitely while map remains small, causing avoidable long-term memory and eviction overhead.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Mar 28, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchfix/audit-findings
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()Instructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
47.13 x 1e3
(+9.20%)Baseline: 43.16 x 1e3
45.32 x 1e3
(104.00%)

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
(-5.24%)Baseline: 6,539.85
6,866.84
(90.25%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-27.04%)Baseline: 718,570.16
754,498.66
(69.49%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-5.65%)Baseline: 22,118.64
23,224.58
(89.85%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,202.00
(-16.58%)Baseline: 117,713.57
123,599.25
(79.45%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,230.00
(-10.08%)Baseline: 109,243.53
114,705.71
(85.64%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.11%)Baseline: 533,521.76
560,197.85
(95.14%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,870.00
(-4.69%)Baseline: 16,651.58
17,484.16
(90.77%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,715,201.00
(-7.96%)Baseline: 15,987,647.73
16,787,030.12
(87.66%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,358.00
(-20.52%)Baseline: 148,920.66
156,366.70
(75.69%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.11%)Baseline: 534,942.67
561,689.80
(95.14%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,919.00
(-4.19%)Baseline: 18,702.88
19,638.02
(91.25%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,066,347.00
(-21.55%)Baseline: 35,774,199.26
37,562,909.23
(74.72%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.11%)Baseline: 533,960.76
560,658.80
(95.14%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,843.00
(-7.35%)Baseline: 17,100.48
17,955.51
(88.23%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,716,627.00
(-7.96%)Baseline: 15,988,770.18
16,788,208.69
(87.66%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
107,945.00
(-13.16%)Baseline: 124,301.07
130,516.13
(82.71%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,302.00
(-10.07%)Baseline: 109,315.53
114,781.31
(85.64%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-5.34%)Baseline: 96,107.53
100,912.90
(90.15%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-3.44%)Baseline: 7,640.73
8,022.77
(91.96%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-1.78%)Baseline: 92,658.42
97,291.34
(93.54%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.36%)Baseline: 7,374.44
7,743.16
(95.58%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-1.52%)Baseline: 108,443.42
113,865.59
(93.79%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.30%)Baseline: 8,886.44
9,330.76
(95.52%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-7.92%)Baseline: 45,601.24
47,881.30
(87.69%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-3.75%)Baseline: 2,822.97
2,964.12
(91.66%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+2.21%)Baseline: 544,061.27
571,264.33
(97.34%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.30%)Baseline: 773.29
811.95
(94.96%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,669,375.00
(-0.11%)Baseline: 27,701,103.43
29,086,158.60
(95.13%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,544,828.00
(-0.06%)Baseline: 5,547,889.63
5,825,284.12
(95.19%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
175,061.00
(-1.35%)Baseline: 177,457.40
186,330.27
(93.95%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
175,710.00
(-1.42%)Baseline: 178,235.51
187,147.28
(93.89%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,309,005.00
(+0.16%)Baseline: 17,280,999.73
18,145,049.72
(95.39%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
298,417.00
(+0.56%)Baseline: 296,748.92
311,586.36
(95.77%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,747,423.00
(+1.21%)Baseline: 12,595,511.07
13,225,286.63
(96.39%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
719,597.00
(+0.39%)Baseline: 716,829.70
752,671.19
(95.61%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
🚨 view alert (🔔)
47,129.00
(+9.20%)Baseline: 43,158.29
45,316.21
(104.00%)

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,763.19
16,339,851.35
(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,378,778.00
(-1.86%)Baseline: 5,480,482.00
5,754,506.10
(93.47%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
312,188.00
(-61.82%)Baseline: 817,695.57
858,580.35
(36.36%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,830,547.00
(+0.18%)Baseline: 2,825,398.34
2,966,668.26
(95.41%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,452,844.00
(-0.54%)Baseline: 3,471,448.91
3,645,021.35
(94.73%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
125,432,468.00
(+0.06%)Baseline: 125,351,060.23
131,618,613.24
(95.30%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
12,002.00
(+1.54%)Baseline: 11,819.41
12,410.38
(96.71%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,930.00
(+2.51%)Baseline: 3,833.76
4,025.45
(97.63%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,073.00
(-0.81%)Baseline: 87,780.13
92,169.14
(94.47%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,022.00
(-1.00%)Baseline: 79,816.76
83,807.59
(94.29%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
50,375.00
(-1.08%)Baseline: 50,927.35
53,473.72
(94.21%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,956.00
(+3.22%)Baseline: 5,770.40
6,058.92
(98.30%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,227.00
(+4.58%)Baseline: 2,129.42
2,235.89
(99.60%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(+0.01%)Baseline: 21,918.24
23,014.15
(95.25%)
🐰 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: 3

Caution

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

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

1311-1318: ⚠️ Potential issue | 🟠 Major

Emit Disconnected only after connection state is cleared.

Cleanup now happens later in run() at Line 894, so handlers triggered by Event::Disconnected can still observe is_connected == true and even get ClientError::AlreadyConnected if they try to reconnect immediately. Clear the connection-visible state before dispatching here, or move the event emission to the post-cleanup path.

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

In `@src/client.rs` around lines 1311 - 1318, The Event::Disconnected is
dispatched while connection-visible state still reports connected, causing
handlers to see is_connected == true and possibly raise
ClientError::AlreadyConnected; modify the logic in the disconnect branch (the
block using self.expected_disconnect.load, the
self.core.event_bus.dispatch(&Event::Disconnected(...)) call) to clear the
connection state flag(s) that back is_connected (the same state mutated in
run()) before emitting the event, or alternatively move the dispatch into the
post-cleanup path in run() so that handlers only see disconnected state; ensure
the change references the same symbols (self.expected_disconnect,
self.core.event_bus.dispatch, Event::Disconnected, run(), is_connected) so
handlers observe the cleared state when they run.
🤖 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/message.rs`:
- Around line 135-137: The TOCTOU risk arises because spawn_retry_receipt()
detaches before calling increment_retry_count, allowing two failure paths for
the same cache_key to race through get()+insert(); move the retry-count
increment into the serialized path or protect it with the per-key lock used for
incoming processing: acquire the same message_enqueue_locks key inside
handle_incoming_message (or before detaching) and perform increment_retry_count
while holding that lock (or alternatively wrap increment_retry_count itself with
the per-key lock), ensuring MessageHandler's serialized processing
(handle_incoming_message) covers the increment for the {chat}:{msg_id}:{sender}
cache_key and prevents double-send retries/PDO.

In `@wacore/src/store/signal_cache.rs`:
- Around line 299-305: The flush() implementation snapshots and drains
dirty/deleted sets before performing backend writes, which can permanently stale
the backend if concurrent flushes or write failures occur; change flush()
(wacore::store::signal_cache::flush) to serialize all flush operations with a
dedicated mutex (e.g., a flush_mutex) so only one flush runs at a time, perform
the backend write using the snapshot while retaining dirty/deleted state, and
only clear or reconcile the dirty and deleted sets after the backend write
succeeds; ensure that on write failure the dirty/deleted markers remain
untouched (or are merged back) so retries can reattempt, and update any code
paths that currently drain the sets prior to calling backend methods on the
SignalStore to instead clear them post-success.

---

Outside diff comments:
In `@src/client.rs`:
- Around line 1311-1318: The Event::Disconnected is dispatched while
connection-visible state still reports connected, causing handlers to see
is_connected == true and possibly raise ClientError::AlreadyConnected; modify
the logic in the disconnect branch (the block using
self.expected_disconnect.load, the
self.core.event_bus.dispatch(&Event::Disconnected(...)) call) to clear the
connection state flag(s) that back is_connected (the same state mutated in
run()) before emitting the event, or alternatively move the dispatch into the
post-cleanup path in run() so that handlers only see disconnected state; ensure
the change references the same symbols (self.expected_disconnect,
self.core.event_bus.dispatch, Event::Disconnected, run(), is_connected) so
handlers observe the cleared state when they run.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5b79e5f2-813c-4626-a414-91c65241db70

📥 Commits

Reviewing files that changed from the base of the PR and between 72980a6 and b1acfcb.

📒 Files selected for processing (7)
  • src/client.rs
  • src/handlers/message.rs
  • src/message.rs
  • src/portable_cache.rs
  • src/send.rs
  • wacore/src/protocol/keepalive.rs
  • wacore/src/store/signal_cache.rs

Comment thread src/message.rs Outdated
Comment on lines +135 to +137
/// Note: get-then-insert has a theoretical TOCTOU window, but messages are
/// processed sequentially per-chat (mailbox pattern in MessageHandler), so
/// concurrent increments for the same cache_key are practically impossible.

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

The TOCTOU window is still reachable here.

MessageHandler only serializes handle_incoming_message. spawn_retry_receipt() detaches before calling increment_retry_count, so two failure paths for the same {chat}:{msg_id}:{sender} can still race through get()+insert() and double-send retries/PDO. Move the increment into the serialized path or guard it with its own per-key lock.

Based on learnings: Use message_enqueue_locks to serialize per-chat incoming message processing.

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

In `@src/message.rs` around lines 135 - 137, The TOCTOU risk arises because
spawn_retry_receipt() detaches before calling increment_retry_count, allowing
two failure paths for the same cache_key to race through get()+insert(); move
the retry-count increment into the serialized path or protect it with the
per-key lock used for incoming processing: acquire the same
message_enqueue_locks key inside handle_incoming_message (or before detaching)
and perform increment_retry_count while holding that lock (or alternatively wrap
increment_retry_count itself with the per-key lock), ensuring MessageHandler's
serialized processing (handle_incoming_message) covers the increment for the
{chat}:{msg_id}:{sender} cache_key and prevents double-send retries/PDO.

Comment thread src/portable_cache.rs Outdated
Comment thread wacore/src/store/signal_cache.rs Outdated
Comment on lines 299 to 305
/// Uses a snapshot-then-release pattern: serialize dirty data under the lock,
/// release locks, then write to the backend. This avoids blocking all
/// encrypt/decrypt operations for the duration of I/O.
///
/// Dirty sets are drained before the write phase. If a write fails, the
/// data remains in the cache and will be re-dirtied on the next modification.
pub async fn flush(&self, backend: &dyn SignalStore) -> Result<()> {

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

Snapshot-then-drain can permanently stale the backend.

These dirty/deleted sets are cleared before any backend write and flush() itself is not serialized. A transient write error—or flush A(old snapshot) -> mutate -> flush B(new snapshot) -> B writes -> A writes—can therefore leave sessions/identities/sender keys persisted at the old value with no dirty markers left to retry. Keep a single flush mutex and only clear/reconcile dirty state after the write phase succeeds.

Also applies to: 308-384

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

In `@wacore/src/store/signal_cache.rs` around lines 299 - 305, The flush()
implementation snapshots and drains dirty/deleted sets before performing backend
writes, which can permanently stale the backend if concurrent flushes or write
failures occur; change flush() (wacore::store::signal_cache::flush) to serialize
all flush operations with a dedicated mutex (e.g., a flush_mutex) so only one
flush runs at a time, perform the backend write using the snapshot while
retaining dirty/deleted state, and only clear or reconcile the dirty and deleted
sets after the backend write succeeds; ensure that on write failure the
dirty/deleted markers remain untouched (or are merged back) so retries can
reattempt, and update any code paths that currently drain the sets prior to
calling backend methods on the SignalStore to instead clear them post-success.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 28, 2026

Copy link
Copy Markdown

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


Code Review: PR #460 — Audit Findings

  • Read and analyze all 7 changed files
  • Verify correctness of each audit fix
  • Evaluate concerns raised by CodeRabbit and Codex
  • Post review feedback

Overall this is a solid set of fixes. The 3 HIGH correctness items are clearly correct. I have one real concern about the signal cache flush refactor (finding #5), and a few minor notes.


Finding #1 — Sender key check bypasses signal cache (src/send.rs) ✅

Switching from device_guard.load_sender_key() (write lock) to signal_cache.get_sender_key() (read lock) is correct. This matches the existing pattern at line 369 for status broadcasts. Good fix.

Finding #2 — Missing UndecryptableMessage for group decrypt failures (src/message.rs) ✅

Adding dispatch_undecryptable_event before spawn_retry_receipt in the NoSenderKeyState arm is correct. The session-based decrypt path already does this. Without this, consumers had no way to know a group message was pending decryption.

Finding #3 — Double cleanup_connection_state (src/client.rs) ✅

Removing the call inside the message loop is correct — run() calls it unconditionally at line 894 after the loop exits. One minor note: CodeRabbit flagged that Event::Disconnected is now dispatched before cleanup_connection_state() runs, so handlers could observe is_connected == true. This is pre-existing behavior for the Err(_) arm (which never called cleanup before dispatching), and in practice run() cleans up immediately after the loop returns. Not a regression from this PR.

Finding #4increment_retry_count TOCTOU (src/message.rs) ✅

The comment accurately documents the theoretical race and its mitigation via per-chat mailbox serialization. This is adequate — the MessageHandler ensures handle_incoming_message runs sequentially per chat, and spawn_retry_receipt is only called from within that serialized path.

Finding #5 — Signal cache flush() snapshot-then-release (wacore/src/store/signal_cache.rs) ⚠️

This is the one change I'd push back on. Both CodeRabbit and Codex flagged this, and I agree with the concern:

The dirty/deleted sets are drained (cleared) under the lock before the backend writes happen. If a backend write fails partway through (e.g., transient SQLite error), those dirty markers are already gone — the data is still in the cache but will never be retried on the next flush. This silently drops persistence for crypto state, which could cause session loss after restart.

The old code had it right: snapshot without draining, write, then clear on success. The lock contention concern is valid, but the fix trades correctness for performance.

Suggested approach: Keep the snapshot-then-release pattern for reduced lock contention, but either:

  1. Re-insert dirty markers on write failure (merge snapshots back), or
  2. Only drain() after all backend writes succeed — you can do this by re-acquiring locks after the write phase, or
  3. Add a dedicated flush_mutex to prevent concurrent flushes, and keep dirty sets until writes complete.

Option (2) is simplest: collect snapshots under lock (via iter().cloned().collect() not drain()), release locks, do I/O, then re-acquire locks and remove only the successfully-written keys.

Fix this →

Finding #6 — Message queue capacity 10000 → 500 (src/handlers/message.rs) ✅

Reasonable reduction. 500 is still generous for burst handling while reducing worst-case memory (10K nodes × many chats was excessive). The comment is improved too.

Finding #7 — PortableCache lazy deletion (src/portable_cache.rs) ⚠️

The lazy deletion approach is sound in principle (O(1) remove vs O(n) retain), but has a subtle correctness issue with remove-then-reinsert sequences:

With capacity 2: insert(a), insert(b), remove(a), insert(a), insert(c) — the FIFO eviction will pop the stale a from insertion_order, map.remove returns None (it's the live entry!), then pops b. So the fresh a survives but b is evicted based on a's original insertion time. This is probably fine in practice since the eviction order is only approximately FIFO anyway, but worth noting.

More importantly: insertion_order can grow without bound if keys are frequently removed and reinserted, since run_pending_tasks() only compacts keys that are still in the map. Each reinsert adds a new entry while the stale one also passes the map.contains_key check. This is a slow leak proportional to churn rate between compaction cycles.

For the current usage patterns (retry counts, message queues), this is likely fine since those caches don't see high remove/reinsert churn. But it's worth documenting the limitation.

Finding #8now_millis().max(0) guard ✅

now_millis() returns i64. Clamping to 0 before as u64 cast prevents wrap-around on negative values from clock anomalies. Simple, correct, no-overhead defensive fix. Applied consistently across all cast sites.

Finding #9 — JID type heuristic (src/message.rs) ✅

Replacing server.contains(".us") with is_group() / is_broadcast_list() / is_status_broadcast() is strictly better — uses the type system instead of fragile string matching.


Summary

7 of 9 findings are clean and correct. Two items need attention:

# Finding Verdict
5 Signal cache flush drain-before-write Needs fix — dirty markers lost on write failure
7 PortableCache lazy deletion Minor — document unbounded growth on remove/reinsert churn

The benchmark alert (bench_decrypt_with_previous_session +9.2%) is unrelated to these changes — none of the modified code is in that benchmark's hot path. Likely CI noise.


… TOCTOU comment

- Signal cache flush: snapshot dirty sets without draining, clear only
  after all writes succeed (preserves retry on partial failure)
- PortableCache: revert lazy deletion — remove→reinsert broke FIFO
  eviction order; O(capacity) retain is correct and bounded
- increment_retry_count: fix inaccurate comment about per-chat
  serialization (spawn_retry_receipt detaches)
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 28, 2026

Copy link
Copy Markdown

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


Code Review: PR #460 — Audit Findings (after follow-up commit b950542)

  • Read all changed files (current state after both commits)
  • Review each of the 9 audit findings
  • Check if previous review concerns were addressed in b950542
  • Post final review

The follow-up commit (b950542) addressed the three main concerns from the first review. Here's a fresh assessment of the full diff.


Finding #1 — Sender key check bypasses signal cache (src/send.rs) ✅

Switching from device_guard.load_sender_key() (write lock) to signal_cache.get_sender_key() (read lock) is correct. Matches the existing status broadcast path. Good fix.

Finding #2 — Missing UndecryptableMessage for group decrypt failures (src/message.rs) ✅

Adding dispatch_undecryptable_event before spawn_retry_receipt in the NoSenderKeyState arm is correct. Without this, consumers had no way to learn a group message was pending decryption.

Finding #3 — Double cleanup_connection_state (src/client.rs) ✅

Removing the redundant call inside the message loop is correct — run() handles it at line 894. The CodeRabbit concern about Event::Disconnected being dispatched before cleanup is pre-existing behavior (the Err(_) arm never called cleanup before dispatching either), and run() cleans up immediately after the loop returns. Not a regression.

Finding #4increment_retry_count TOCTOU (src/message.rs) ✅

The updated comment in b950542 is more accurate: it acknowledges that spawn_retry_receipt detaches (so per-chat serialization doesn't cover it), but correctly notes that duplicate retries are benign since recipients deduplicate by message ID. This is an honest documentation of a theoretical race with no practical impact.

Finding #5 — Signal cache flush() snapshot-then-release (wacore/src/store/signal_cache.rs) ✅ (was ⚠️)

Fixed in b950542. The flush now uses iter().cloned().collect() instead of drain(), and only removes flushed keys from dirty sets in Phase 3 after all writes succeed. This preserves retry semantics on partial failure.

One remaining consideration: concurrent flushes are still possible (flush is called from ~10 sites with no serialization). Two concurrent flushes could both snapshot the same dirty key, both write it, and both remove it from the dirty set — which is harmless (idempotent writes). However, the interleaving flush_A snapshots → mutate key → flush_B snapshots new value → flush_B writes → flush_A writes old value would persist a stale value with the dirty marker removed by flush_B. This is a narrow window and unlikely in practice since flushes are typically awaited inline. If you want belt-and-suspenders, a tokio::sync::Mutex<()> flush guard would close it, but I wouldn't block the PR on this.

Finding #6 — Message queue capacity 10000 → 500 (src/handlers/message.rs) ✅

Reasonable. 500 is generous for burst handling while preventing worst-case memory with many active chats.

Finding #7 — PortableCache removal (src/portable_cache.rs) ✅ (was ⚠️)

Fixed in b950542. Reverted to retain()-based removal, which is correct and bounded by max_capacity. The lazy deletion was correctly identified as breaking FIFO eviction order on remove→reinsert sequences.

Finding #8now_millis().max(0) guard ✅

Simple, correct, zero-overhead defensive fix applied consistently across all cast sites.

Finding #9 — JID type heuristic (src/message.rs) ✅

Replacing server.contains(".us") with proper is_group() / is_broadcast_list() / is_status_broadcast() is strictly better.


Benchmark Alert

The bench_decrypt_with_previous_session alert (+9.2%) is unrelated to these changes — none of the modified code is in that benchmark's hot path. Likely CI noise.

Verdict

All 9 findings look good. The follow-up commit addressed the flush durability and cache eviction concerns from the first review. The only remaining theoretical issue (concurrent flush interleaving) is narrow enough that it doesn't warrant blocking.

LGTM 👍


View job

@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: b950542452

ℹ️ 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 wacore/src/store/signal_cache.rs Outdated
Comment on lines +389 to +390
for key in &session_dirty_keys {
state.dirty.remove(key);

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 Keep keys dirty when modified during flush

In SignalStoreCache::flush, phase 3 unconditionally removes every snapshotted key from dirty, but phase 2 performs backend I/O without holding the store locks. If the same key is updated after phase 1 snapshotting and before this cleanup, that newer update does not add a second marker (because dirty is a set), so remove clears the only dirty flag for the newer value. The backend then only has the stale snapshot written earlier, and the later mutation may never be retried, which can silently lose session/identity/sender-key state across restarts.

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.

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

305-409: ⚠️ Potential issue | 🔴 Critical

This can still drop newer Signal state during a "successful" flush.

Phase 3 removes every snapshotted key from dirty/deleted unconditionally. If the same sender key or session is updated again while Phase 2 is doing backend I/O, the backend can end up with the older snapshot while the newer in-memory value is no longer marked dirty. Because SignalStoreCache::flush() is still unsynchronized, flush(old) can also race flush(new) and write stale bytes last. Since src/message.rs::Client::handle_incoming_message() flushes after every message and sender keys ratchet on each group decrypt, this is reachable in normal traffic.

Serialize flush() itself and only clear a key when the current cached value or tombstone still matches the snapshot that was written.

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

In `@wacore/src/store/signal_cache.rs` around lines 305 - 409,
SignalStoreCache::flush currently unconditionally removes all snapshotted keys
from sessions/identities/sender_keys dirty and deleted sets after Phase 2, which
can drop newer in-memory updates that occurred during the backend I/O; to fix,
serialize flush() (i.e., ensure only one flush runs at a time) and, in Phase 3,
only clear a key if the current cache entry or tombstone still equals the
snapshot that was written — for example, for sessions use the
session_dirty_keys/session_delete_keys snapshots taken earlier and, under
sessions.lock().await, compare state.cache.get(address) (and state.deleted)
against the serialized snapshot or tombstone before calling
state.dirty.remove(key) or state.deleted.remove(key); apply the same
guarded-compare-and-remove logic for identities
(identity_writes/identity_delete_keys) and sender_keys
(sender_key_ops/sender_key_dirty_keys).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@wacore/src/store/signal_cache.rs`:
- Around line 305-409: SignalStoreCache::flush currently unconditionally removes
all snapshotted keys from sessions/identities/sender_keys dirty and deleted sets
after Phase 2, which can drop newer in-memory updates that occurred during the
backend I/O; to fix, serialize flush() (i.e., ensure only one flush runs at a
time) and, in Phase 3, only clear a key if the current cache entry or tombstone
still equals the snapshot that was written — for example, for sessions use the
session_dirty_keys/session_delete_keys snapshots taken earlier and, under
sessions.lock().await, compare state.cache.get(address) (and state.deleted)
against the serialized snapshot or tombstone before calling
state.dirty.remove(key) or state.deleted.remove(key); apply the same
guarded-compare-and-remove logic for identities
(identity_writes/identity_delete_keys) and sender_keys
(sender_key_ops/sender_key_dirty_keys).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 04c042c4-2b56-4932-9bf8-93fbebb52a41

📥 Commits

Reviewing files that changed from the base of the PR and between b1acfcb and b950542.

📒 Files selected for processing (2)
  • src/message.rs
  • wacore/src/store/signal_cache.rs

Replace the 3-phase snapshot-then-release pattern with per-store
lock-through-IO: each store (sessions, identities, sender_keys) is
flushed independently under its own lock.

This eliminates the race where a mutation between Phase 1 (snapshot)
and Phase 3 (clear) could lose a dirty marker for a newer value,
since the lock is now held from snapshot through write through clear.

Only one store is locked during its I/O — the other two remain free
for concurrent encrypt/decrypt. This is strictly better than the
original code (which held all 3 locks simultaneously) and correct
unlike the 3-phase approach (which had the dirty-set race).

@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: 1

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

Inline comments:
In `@wacore/src/store/signal_cache.rs`:
- Around line 306-387: The current flush behavior clears each store's dirty set
after that store's writes succeed, which yields per-store partial-failure
semantics (e.g., sessions cleared even if identities later fail) and differs
from the PR text claiming atomic "clear dirty sets only after all writes
succeed"; update the code/docs to match intent: either (A) change the PR
description to state per-store clearing semantics, or (B) modify flush to only
clear any dirty/deleted sets after all three sections succeed by moving the
removals out of the per-store blocks and performing them after all backend calls
complete; refer to the flush method and the per-store states sessions,
identities, and sender_keys and their state.dirty/state.deleted manipulations
when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 70f3d374-0209-4d3a-bf4c-06d85e3399fb

📥 Commits

Reviewing files that changed from the base of the PR and between b950542 and 782515c.

📒 Files selected for processing (1)
  • wacore/src/store/signal_cache.rs

Comment on lines 306 to 387
pub async fn flush(&self, backend: &dyn SignalStore) -> Result<()> {
let mut sessions = self.sessions.lock().await;
let mut identities = self.identities.lock().await;
let mut sender_keys = self.sender_keys.lock().await;

// Snapshot dirty/deleted sets WITHOUT draining — preserve on failure
let session_dirty: Vec<_> = sessions.dirty.iter().cloned().collect();
let session_deleted: Vec<_> = sessions.deleted.iter().cloned().collect();
let identity_dirty: Vec<_> = identities.dirty.iter().cloned().collect();
let identity_deleted: Vec<_> = identities.deleted.iter().cloned().collect();
let sender_key_dirty: Vec<_> = sender_keys.dirty.iter().cloned().collect();

// Persist dirty sessions — serialize only here, not on every store_session
for address in &session_dirty {
if let Some(Some(record)) = sessions.cache.get(address.as_ref()) {
let bytes = record
.serialize()
.map_err(|e| anyhow::anyhow!("session serialize for {address}: {e}"))?;
backend.put_session(address, &bytes).await?;
// Flush sessions
{
let mut state = self.sessions.lock().await;
let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect();
let deleted_keys: Vec<_> = state.deleted.iter().cloned().collect();

for address in &dirty_keys {
if let Some(Some(record)) = state.cache.get(address.as_ref()) {
let bytes = record
.serialize()
.map_err(|e| anyhow::anyhow!("session serialize for {address}: {e}"))?;
backend.put_session(address, &bytes).await?;
}
}
for address in &deleted_keys {
backend.delete_session(address).await?;
}
}
for address in &session_deleted {
backend.delete_session(address).await?;
}

for address in &identity_dirty {
if let Some(Some(data)) = identities.cache.get(address.as_ref()) {
let key: [u8; 32] = data.as_ref().try_into().map_err(|_| {
anyhow::anyhow!(
"Corrupted identity key for {address}: expected 32 bytes, got {}",
data.len()
)
})?;
backend.put_identity(address, key).await?;
for key in &dirty_keys {
state.dirty.remove(key);
}
for key in &deleted_keys {
state.deleted.remove(key);
}
}
for address in &identity_deleted {
backend.delete_identity(address).await?;
}

for name in &sender_key_dirty {
match sender_keys.cache.get(name.as_ref()) {
Some(Some(record)) => {
let bytes = record
.serialize()
.map_err(|e| anyhow::anyhow!("sender key serialize for {name}: {e}"))?;
backend.put_sender_key(name, &bytes).await?;
}
Some(None) => {
// Deleted via delete_sender_key — propagate to backend
backend.delete_sender_key(name).await?;
// Flush identities
{
let mut state = self.identities.lock().await;
let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect();
let deleted_keys: Vec<_> = state.deleted.iter().cloned().collect();

for address in &dirty_keys {
if let Some(Some(data)) = state.cache.get(address.as_ref()) {
let key: [u8; 32] = data.as_ref().try_into().map_err(|_| {
anyhow::anyhow!(
"Corrupted identity key for {address}: expected 32 bytes, got {}",
data.len()
)
})?;
backend.put_identity(address, key).await?;
}
None => {}
}
for address in &deleted_keys {
backend.delete_identity(address).await?;
}

for key in &dirty_keys {
state.dirty.remove(key);
}
for key in &deleted_keys {
state.deleted.remove(key);
}
}

// All writes succeeded — clear dirty sets (matches WA Web's clearDirty())
sessions.dirty.clear();
sessions.deleted.clear();
identities.dirty.clear();
identities.deleted.clear();
sender_keys.dirty.clear();
// Flush sender keys
{
let mut state = self.sender_keys.lock().await;
let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect();

for name in &dirty_keys {
match state.cache.get(name.as_ref()) {
Some(Some(record)) => {
let bytes = record
.serialize()
.map_err(|e| anyhow::anyhow!("sender key serialize for {name}: {e}"))?;
backend.put_sender_key(name, &bytes).await?;
}
Some(None) => {
backend.delete_sender_key(name).await?;
}
None => {}
}
}

for key in &dirty_keys {
state.dirty.remove(key);
}
}

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.

🧹 Nitpick | 🔵 Trivial

Note partial-failure semantics for future reference.

If, say, sessions flush succeeds but identities flush fails, the sessions dirty set is cleared before the error is returned. On retry, only identities (and sender_keys) will be re-flushed since sessions are already persisted and no longer dirty. This is correct behavior since the session writes did succeed.

This differs slightly from the PR description's "clearing dirty sets only after all writes succeed" (which implies a global all-or-nothing), but per-store clearing is the more practical approach given the independent store design.

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

In `@wacore/src/store/signal_cache.rs` around lines 306 - 387, The current flush
behavior clears each store's dirty set after that store's writes succeed, which
yields per-store partial-failure semantics (e.g., sessions cleared even if
identities later fail) and differs from the PR text claiming atomic "clear dirty
sets only after all writes succeed"; update the code/docs to match intent:
either (A) change the PR description to state per-store clearing semantics, or
(B) modify flush to only clear any dirty/deleted sets after all three sections
succeed by moving the removals out of the per-store blocks and performing them
after all backend calls complete; refer to the flush method and the per-store
states sessions, identities, and sender_keys and their state.dirty/state.deleted
manipulations when making the change.

@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: 782515c36e

ℹ️ 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/client.rs
Comment on lines +1311 to 1313
if !self.expected_disconnect.load(Ordering::Relaxed) {
self.core.event_bus.dispatch(&Event::Disconnected(crate::types::events::Disconnected));
debug!("Transport disconnected unexpectedly.");

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 Clear connection state before firing Disconnected handlers

Event::Disconnected is now dispatched before cleanup_connection_state() runs, so synchronous handlers can observe stale connection state (is_connected, transport/noise handles, caches) and make incorrect decisions (for example, skipping reconnect logic because the client still appears connected during the callback). This regression comes from removing the in-loop cleanup call without preserving the prior cleanup-before-dispatch ordering for unexpected disconnects.

Useful? React with 👍 / 👎.

The removal of cleanup_connection_state() from inside the message loop
created a regression: Event::Disconnected was dispatched while
is_connected was still true and transport handles still set. Handlers
could observe stale state.

Move the dispatch to run() after cleanup_connection_state() completes,
matching the original ordering where cleanup ran before the event.
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 29, 2026

Copy link
Copy Markdown

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


Code Review: PR #460 — Audit Findings (Final State, 4 commits)

  • Read all changed files (final state after 4 commits)
  • Review each of the 9 audit findings
  • Check outstanding review concerns from CodeRabbit/Codex
  • Post final review

This is a clean set of 9 focused fixes. All prior review concerns (flush durability, cache eviction, disconnect ordering) have been addressed across the 4 commits. Here's my assessment of each finding.


Finding #1 — Sender key check uses signal cache (src/send.rs:367-373) ✅

Switching from device_guard.load_sender_key() (write lock on device store) to signal_cache.get_sender_key() (read lock) is correct. This now matches the existing status broadcast path and avoids unnecessary SKDM redistribution.

Finding #2UndecryptableMessage for group decrypt failures (src/message.rs:1120-1121) ✅

dispatch_undecryptable_event is now called before spawn_retry_receipt in the NoSenderKeyState arm, matching the session-based decrypt path. Consumers now learn about pending decryption.

Finding #3 — Disconnect event after cleanup (src/client.rs:898-905) ✅

Commit a65d796 addressed the CodeRabbit/Codex concern: Event::Disconnected is now dispatched after cleanup_connection_state() at line 900-905, so handlers see cleared connection state. The event is only dispatched for unexpected disconnects, which is correct — expected disconnects (logout, 515 stream restart) don't need a consumer-facing event.

Finding #4increment_retry_count TOCTOU comment (src/message.rs:135-138) ✅

The comment accurately documents the theoretical race: spawn_retry_receipt detaches via runtime.spawn, so per-chat serialization doesn't cover the increment. In practice this is benign — duplicate retries are deduplicated by message ID on the recipient side.

Finding #5 — Signal cache flush per-store locking (wacore/src/store/signal_cache.rs:306-387) ✅

This is the area that received the most scrutiny across reviews. The final implementation is correct:

  • Lock held throughout: Each store's lock is held from snapshot through write through clear (src/store/signal_cache.rs:309-331 for sessions). No mutations can slip in between snapshot and clear.
  • Dirty sets cleared only after writes: state.dirty.remove(key) happens after backend.put_session() succeeds (line 325-327).
  • Per-store partial failure semantics: If sessions flush succeeds but identities fails, session dirty markers are correctly cleared (those writes succeeded) and the error propagates so identities will be retried. This is the right design — it matches the independent store model.
  • No concurrent flush race: Since the lock is held for the entire per-store flush (snapshot + I/O + clear), a concurrent flush on the same store will block until the first completes and then see an empty dirty set. This eliminates the stale-write race that earlier reviewers flagged.

One remaining theoretical concern: cross-store atomicity — if sessions flush succeeds but identities flush fails, the next retry only re-flushes identities. This is fine because each store is independent and a partial flush doesn't leave any store in an inconsistent state.

Finding #6 — Queue capacity 10000 → 500 (src/handlers/message.rs:63) ✅

Reasonable reduction. 500 handles burst traffic while preventing worst-case memory amplification with many active chats.

Finding #7 — PortableCache retain()-based removal ✅

The lazy deletion was correctly reverted in b950542. retain() is O(capacity) but bounded and correct, avoiding the FIFO eviction ordering issue with remove→reinsert.

Finding #8now_millis().max(0) as u64 (keepalive.rs:26, client.rs:1269,3330) ✅

Consistent defensive fix at all cast sites. Zero-overhead guard against negative clock values from NTP adjustments or VM time travel.

Finding #9 — JID type heuristic (src/message.rs:544-546) ✅

Replacing server.contains(".us") with is_group() / is_broadcast_list() / is_status_broadcast() is strictly better — uses proper JID type methods instead of fragile substring matching.


Benchmark Alert

The bench_decrypt_with_previous_session alert (+9.2%) is unrelated — none of the changed code is in that benchmark's hot path (binary protocol, archived session handling). Likely CI noise from the measurement environment.

Verdict

All 9 findings look good. The follow-up commits (b950542, 782515c, a65d796) addressed every concern raised in previous reviews:

  • Flush durability: dirty markers preserved until writes succeed ✅
  • Cache eviction: retain() restored, no FIFO ordering bug ✅
  • Disconnect ordering: event dispatched after cleanup ✅

LGTM 👍


View job

@jlucaso1
jlucaso1 merged commit 8b9aed6 into main Mar 29, 2026
8 checks passed
@jlucaso1
jlucaso1 deleted the fix/audit-findings branch March 29, 2026 02:41

@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: a65d796a9a

ℹ️ 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/client.rs
Comment on lines 879 to 881
if self.expected_disconnect.load(Ordering::Relaxed)
|| self.intentional_reconnect.swap(false, Ordering::Relaxed)
{

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 Emit Disconnected for intentional reconnects

intentional_reconnect is folded into the “expected” path here, which makes unexpected_disconnect false and skips the later Event::Disconnected dispatch in run(). In practice, calling reconnect() now drops the transport without emitting a disconnect event, so consumers that rely on Disconnected callbacks (e.g., reconnect-state/UI transitions) will miss that lifecycle transition even though the socket was torn down.

Useful? React with 👍 / 👎.

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.

1 participant