Skip to content

refactor: architecture audit — cache bounds, circuit breaker, correctness fixes - #478

Merged
jlucaso1 merged 1 commit into
mainfrom
refactor/architecture-audit-fixes
Apr 1, 2026
Merged

refactor: architecture audit — cache bounds, circuit breaker, correctness fixes#478
jlucaso1 merged 1 commit into
mainfrom
refactor/architecture-audit-fixes

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Addresses findings from a senior-level architecture audit of concurrency, error handling, memory, and correctness patterns.

Changes

P0: Signal cache capacity bound (wacore/src/store/signal_cache.rs)

  • Add max_entries (default 10K per store) to SignalStoreCache
  • New with_max_entries() constructor for custom limits
  • evict_if_needed() uses O(n) two-vec partition (negative entries evicted first)
  • Runtime-agnostic — plain HashMap with manual eviction, no new deps

P0: Background saver circuit breaker (src/store/persistence_manager.rs)

  • Halt after 10 consecutive flush failures to prevent silent data loss

P1: Transport cleanup on handshake failure (src/client.rs)

  • Explicit transport.disconnect() when do_handshake() fails

P1: Propagate flush errors in retry path (src/retry.rs)

  • ? instead of unwrap_or_else(warn) on critical paths

P2: Pre-sized retry cache key (src/message.rs)

P3: Clear stale app_state_key_requests on reconnect (src/client.rs)

P3: RelaxedAcquire for node_waiter_count (src/client.rs)

P3: Document lock ordering invariant (src/send.rs)

Evaluated and deferred

Item Reason
Arc<MessageInfo> pipeline Breaking public API. Clone cost negligible at real-world rates.
Streaming upload Bottleneck is HttpRequest.body: Vec<u8>. Needs new HttpClient trait method.
Streaming history sync decompression Parser needs full blob for Bytes::slice() zero-copy.
Lazy protobuf decoding Breaking public API. Decode cost negligible at human rates.

Test plan

  • All unit tests pass
  • 0 clippy warnings (-D warnings)
  • Runtime-agnostic

Summary by CodeRabbit

  • Bug Fixes

    • Improved connection error handling to ensure proper cleanup on failed handshakes.
    • Enhanced retry handling so certain failures halt retry flows and surface errors.
  • Performance & Reliability

    • Added safeguards to background persistence to stop after repeated failures.
    • Introduced capacity limits and eviction for in-memory signal caches to control memory growth.
    • Tightened synchronization for waiter resolution to ensure correct timing/visibility.
  • New

    • Added a status accessor to report when the background saver has halted.

@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 211e5026-e547-4a60-8ece-24f5be2c92b4

📥 Commits

Reviewing files that changed from the base of the PR and between 8d83174 and 4215867.

📒 Files selected for processing (6)
  • src/client.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/store/persistence_manager.rs
  • wacore/src/store/signal_cache.rs

📝 Walkthrough

Walkthrough

Handshake failures now explicitly disconnect the transport; node-waiter synchronization uses stronger atomic ordering. Retry paths propagate signal-cache flush errors in some resend flows. Persistence saver halts after repeated failures with an observable flag. Message retry keys use a pre-sized String. Signal cache gains bounded capacity and eviction.

Changes

Cohort / File(s) Summary
Client / Connection
src/client.rs
On handshake Err, await transport.disconnect() before returning the error; changed node_waiter_count.load from Ordering::Relaxed to Ordering::Acquire when deciding to resolve waiters.
Retry & Message
src/retry.rs, src/message.rs
handle_retry_receipt now propagates flush_signal_cache() errors in DM and group resend paths (registration-ID mismatch path remains warn-only); make_retry_cache_key uses a preallocated String with write! instead of format!.
Send docs
src/send.rs
Doc comment for build_session_lock_keys updated to state an INVARIANT about sorted ordering to prevent deadlocks (documentation-only).
Background persistence
src/store/persistence_manager.rs
Added saver_halted: Arc<AtomicBool>, pub fn is_saver_halted(), track consecutive_failures, halt background saver after MAX_CONSECUTIVE_FAILURES, and simplified save-notification branch.
Signal cache (wacore)
wacore/src/store/signal_cache.rs
Added DEFAULT_MAX_CACHE_ENTRIES, max_entries field and with_max_entries() ctor; new() delegates; added eviction (evict_if_needed) invoked after mutations and on flush, evicting negative-cache entries first and skipping dirty/deleted items.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Handshake as Handshake
  participant Transport as Transport

  Client->>Handshake: do_handshake(...).await
  alt Handshake succeeds
    Handshake-->>Client: Ok(session)
    Client->>Transport: mark connected / proceed
  else Handshake fails
    Handshake-->>Client: Err(e)
    Client->>Transport: disconnect().await
    Transport-->>Client: disconnected
    Client-->>Client: return Err(e.into())
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I tapped the wire when the handshake fell apart,
I nudged the transport, gave the saver a heart.
When caches grow heavy, I nibble them clean,
Ten stumbles and quiet—no more frantic routine.
Hops and bytes—my code is a springtime art.

🚥 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 summarizes the three main architectural improvements: cache capacity bounds (signal cache), circuit breaker logic (background saver), and correctness fixes (transport cleanup, error propagation, atomic ordering).
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 refactor/architecture-audit-fixes

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.

@jlucaso1

jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

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


Code Review: Architecture Audit Fixes (P0-P3)

  • Read and analyze all 6 changed files
  • Review P0: Signal cache capacity bound
  • Review P0: Background saver circuit breaker
  • Review P1: Transport cleanup & flush error propagation
  • Review P2-P3: Minor fixes
  • Post final review

Overall this is a solid set of hardening fixes. The changes are well-scoped and each addresses a real concern. A few items worth flagging:

Bug: Eviction does not actually prioritize negative cache entries

In SessionStoreState::evict_if_needed (wacore/src/store/signal_cache.rs:72-92), the comment says:

// Negative entries first (is_none = true sorts after false with reverse)

But .rev() on a Vec collected from HashMap::iter() just reverses arbitrary iteration order — it doesn't sort by the boolean. HashMap has no guaranteed ordering, so .rev() provides no meaningful prioritization. The same issue exists in the identical ByteStoreState::evict_if_needed and SenderKeyStoreState::evict_if_needed.

To actually prioritize negative entries, you'd need to sort:

.collect::<Vec<_>>();
candidates.sort_by_key(|(_, is_none)| std::cmp::Reverse(*is_none));
candidates.into_iter()
    .take(...)

Fix this →

Severity: Medium — The eviction still works (removes clean entries), it just doesn't prefer negative entries as intended.

P0: Signal cache eviction — consider code dedup

The three evict_if_needed implementations (SessionStoreState, SenderKeyStoreState, ByteStoreState) are near-identical. The only difference is that SenderKeyStoreState has no deleted set to check. A shared helper or macro could reduce the ~60 lines of duplication, but this is a style preference — not blocking.

P0: Background saver circuit breaker — looks good

The circuit breaker in persistence_manager.rs is clean. Counter resets on success, halts after 10 consecutive failures. One minor thought: after halting, the client continues running with no persistence. Consider whether the client should also be notified/disconnected (e.g., via the event bus) so it doesn't silently operate without persistence. But that could be a follow-up.

P1: Transport cleanup on handshake failure — good catch

src/client.rs:1076-1087: Explicitly disconnecting the transport on handshake failure is correct. The match-based error handling is clean.

P1: Flush error propagation in retry.rs — careful with the change

src/retry.rs:445 and src/retry.rs:492: Converting flush_signal_cache() from warn-and-continue to ? propagation is a behavior change — previously a flush failure wouldn't abort the retry flow, now it will. This is arguably correct (a failed flush means the session delete didn't persist, so retrying on a stale session could loop), but it's worth noting that this makes retry more fragile if the persistence layer has transient issues. The reg-ID mismatch path keeping warn-and-continue (src/retry.rs:245) is the right call since it's non-critical.

P2: Pre-sized retry cache key

src/message.rs:164-168: The String::with_capacity optimization is fine but marginal — this runs once per incoming message, not in a hot loop. The let _ = write!(...) silently discards write errors (which can't happen for String, but let _ = on fallible ops can mask bugs in other contexts). Minor nit, not blocking.

P3: Atomic ordering fix

src/client.rs:1606: RelaxedAcquire for the load is correct. The stores at lines 3201, 3241, 3245 all use Release, and Acquire on the load side pairs properly to ensure the waiter registration (and associated data writes) are visible before resolve_node_waiters runs. Good fix.

P3: Clear app_state_key_requests on reconnect

src/client.rs:1225-1226: Clearing stale requests on reconnect is straightforward and correct.

P3: Lock ordering comment in send.rs

src/send.rs:1419-1421: The deadlock-prevention invariant comment is useful and more focused than the previous version. Good improvement.


Summary: Solid hardening PR. The main actionable issue is the eviction priority bug where .rev() doesn't actually sort by negative-entry status. Everything else looks correct and well-motivated.

@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: 310a429d56

ℹ️ 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/retry.rs
self.flush_signal_cache().await.unwrap_or_else(|e| {
log::warn!("Failed to flush signal cache after session delete: {e}");
});
self.flush_signal_cache().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 Continue retry resend when signal-cache flush transiently fails

In the DM retry path, self.flush_signal_cache().await? now aborts handle_retry_receipt before the resend happens. This function inserts the dedupe key near the top and does not remove it on error, so a temporary backend flush failure can mark the retry as handled while sending nothing, and subsequent retry receipts for the same message are ignored until the dedupe TTL expires. That can leave retry recovery stalled after transient storage errors; either keep this path warn-and-continue or clear dedupe state when this flush fails.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Apr 1, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchrefactor/architecture-audit-fixes
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
(+8.62%)Baseline: 43.39 x 1e3
45.56 x 1e3
(103.45%)

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
(-4.24%)Baseline: 6,471.18
6,794.74
(91.20%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-25.81%)Baseline: 706,713.54
742,049.21
(70.66%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-5.33%)Baseline: 22,041.95
23,144.05
(90.17%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,202.00
(-14.30%)Baseline: 114,587.89
120,317.29
(81.62%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,230.00
(-9.43%)Baseline: 108,461.15
113,884.20
(86.25%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.10%)Baseline: 533,481.01
560,155.06
(95.14%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,870.00
(-4.37%)Baseline: 16,596.06
17,425.86
(91.07%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,715,201.00
(-7.44%)Baseline: 15,897,255.34
16,692,118.11
(88.16%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,358.00
(-19.35%)Baseline: 146,749.55
154,087.02
(76.81%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.10%)Baseline: 534,902.56
561,647.69
(95.14%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,919.00
(-3.91%)Baseline: 18,647.19
19,579.55
(91.52%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,066,347.00
(-20.33%)Baseline: 35,226,646.92
36,987,979.26
(75.88%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.10%)Baseline: 533,920.01
560,616.01
(95.14%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,843.00
(-6.94%)Baseline: 17,024.80
17,876.04
(88.63%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,716,627.00
(-7.43%)Baseline: 15,898,399.36
16,693,319.32
(88.16%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
107,945.00
(-11.28%)Baseline: 121,667.13
127,750.49
(84.50%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,302.00
(-9.43%)Baseline: 108,533.15
113,959.80
(86.26%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-5.07%)Baseline: 95,830.61
100,622.14
(90.41%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-3.26%)Baseline: 7,626.56
8,007.89
(92.13%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-1.66%)Baseline: 92,540.97
97,168.02
(93.66%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.33%)Baseline: 7,376.33
7,745.14
(95.56%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-1.42%)Baseline: 108,325.97
113,742.27
(93.89%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.28%)Baseline: 8,888.33
9,332.74
(95.50%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-7.13%)Baseline: 45,212.47
47,473.09
(88.45%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-2.47%)Baseline: 2,785.88
2,925.18
(92.88%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+1.38%)Baseline: 548,545.85
575,973.15
(96.55%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.24%)Baseline: 772.88
811.53
(95.01%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,637,140.00
(-0.22%)Baseline: 27,698,985.79
29,083,935.08
(95.03%)
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.05%)Baseline: 5,547,725.84
5,825,112.14
(95.19%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
175,061.00
(-1.28%)Baseline: 177,329.20
186,195.66
(94.02%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
175,710.00
(-1.34%)Baseline: 178,100.40
187,005.42
(93.96%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,307,542.00
(+0.15%)Baseline: 17,281,776.28
18,145,865.09
(95.38%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
298,417.00
(+0.53%)Baseline: 296,837.89
311,679.79
(95.74%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,741,982.00
(+1.17%)Baseline: 12,595,247.10
13,225,009.45
(96.35%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
719,597.00
(+0.37%)Baseline: 716,977.30
752,826.17
(95.59%)
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
(+8.62%)Baseline: 43,388.74
45,558.17
(103.45%)

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,767.95
16,339,856.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.75%)Baseline: 5,474,579.53
5,748,308.51
(93.57%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
312,188.00
(-60.40%)Baseline: 788,358.08
827,775.98
(37.71%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,830,547.00
(+0.17%)Baseline: 2,825,695.82
2,966,980.61
(95.40%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,452,844.00
(-0.51%)Baseline: 3,470,373.96
3,643,892.65
(94.76%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
126,554,868.00
(+0.95%)Baseline: 125,365,126.14
131,633,382.45
(96.14%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
12,002.00
(+1.46%)Baseline: 11,829.77
12,421.26
(96.62%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,930.00
(+2.36%)Baseline: 3,839.47
4,031.44
(97.48%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,073.00
(-0.75%)Baseline: 87,734.27
92,120.98
(94.52%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,022.00
(-0.93%)Baseline: 79,766.14
83,754.45
(94.35%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
50,375.00
(-1.02%)Baseline: 50,893.70
53,438.39
(94.27%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,956.00
(+3.00%)Baseline: 5,782.55
6,071.68
(98.09%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,227.00
(+4.27%)Baseline: 2,135.80
2,242.59
(99.31%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(-0.00%)Baseline: 21,920.24
23,016.25
(95.24%)
🐰 View full continuous benchmarking report in Bencher

@jlucaso1
jlucaso1 force-pushed the refactor/architecture-audit-fixes branch from 310a429 to 6751ada Compare April 1, 2026 20:33
@jlucaso1 jlucaso1 changed the title refactor: architecture audit fixes (P0-P3) refactor: architecture audit fixes Apr 1, 2026

@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: 6751ada298

ℹ️ 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 on lines +160 to +164
error!(
"Background saver: {consecutive_failures} consecutive flush failures, \
halting to prevent silent data loss. Last error: {e}"
);
return;

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 background saver running after transient flush failures

Returning from the background saver after 10 consecutive save_to_disk() errors permanently disables periodic persistence for the rest of the process lifetime, so a temporary backend outage can turn into ongoing unsaved device state even after storage recovers. Because this loop is the only automatic save path, the return here creates a durability regression compared to the previous behavior that kept retrying.

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: 8

🤖 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/client.rs`:
- Around line 1606-1608: The atomic counters node_waiter_count and
sent_node_waiter_count are incremented (fetch_add) before the waiter is actually
pushed into the mutex-protected vectors (node_waiters / sent_node_waiters),
allowing resolve_node_waiters to observe a positive count while the vector is
still empty; to fix, move each fetch_add() so it happens after acquiring the
respective mutex and after the waiter is inserted (i.e. inside wait_for_node()
and wait_for_sent_node(), acquire the lock, push the waiter into
node_waiters/sent_node_waiters, then perform the fetch_add on
node_waiter_count/sent_node_waiter_count), leaving resolve_node_waiters()
unchanged.

In `@src/message.rs`:
- Around line 168-170: The buffer is being reserved from chat.user.len() and
sender.user.len(), which underestimates size because the write! uses full
formatted JIDs (including `@server` and optional :device). Before allocating key,
compute the full string forms for chat and sender (e.g., chat_jid =
chat.to_string() / formatted_jid, sender_jid = sender.to_string() /
formatted_jid), then reserve capacity using chat_jid.len() + msg_id.len() +
sender_jid.len() + 2 (for the two ':' separators); finally write the precomputed
strings into key (write!(key, "{chat_jid}:{msg_id}:{sender_jid}")). This avoids
reallocations when JIDs include domain/device parts.

In `@src/retry.rs`:
- Line 445: handle_retry_receipt() currently uses fallible awaits like
self.flush_signal_cache().await? and returns the error into a detached caller
which just logs it, causing lost retries; change these to explicit
error-handling paths that either (A) roll back any consumed retry state before
returning (undo the dedupe key insertion and re-insert the message into
recent_messages) when flush_signal_cache() or send_message_impl() fail, or (B)
propagate the error to the detached caller by returning a clear enum/error that
the caller in receipt.rs can use to requeue/escalate; update the code paths
around flush_signal_cache(), insert_dedupe_key/remove from recent_messages, and
send_message_impl() in handle_retry_receipt() to perform rollback on failure or
return a requeueable error instead of using `?`.

In `@src/store/persistence_manager.rs`:
- Around line 159-164: The background saver currently just logs and returns when
consecutive_failures >= MAX_CONSECUTIVE_FAILURES, leaving the process running
without persistence; change that branch to surface the tripped circuit-breaker
to the owner by updating a monitored health/fatal state instead of only logging.
Specifically, in persistence_manager.rs where you check consecutive_failures and
call error!(...), set a shared, observable flag or send a one-shot message
(e.g., set an Arc<AtomicBool> like persistence_failed, call a
PersistenceManager::mark_unrecoverable(), or send on a provided mpsc/oneshot
channel) so src/bot.rs (which fire-and-forgets the task) can detect the failure
and take action (alert, reject writes, or terminate); ensure the
PersistenceManager exposes a getter or channel to read this health signal so
modify_device() and the owner can observe the non-durable state.

In `@wacore/src/store/signal_cache.rs`:
- Around line 70-92: The three nearly-identical evict_if_needed implementations
(in SessionStoreState, SenderKeyStoreState, and ByteStoreState) should be
consolidated into a single helper function to remove duplication and fix the
sorting bug: extract the eviction logic into a function (e.g.,
evict_clean_entries) that accepts &mut HashMap<Arc<str>, Option<V>>,
&HashSet<Arc<str>> for dirty, Option<&HashSet<Arc<str>>> for deleted (or
&HashSet when always present), and max_entries, perform candidate collection,
sort to prioritize None (negative) entries first, and remove the excess keys;
then call this helper from each evict_if_needed to replace the duplicated code
paths.
- Around line 77-88: The eviction currently reverses iteration but doesn't
prioritize negative entries; change the logic that builds to_remove from
iterating HashMap into collecting pairs (key, is_none) (e.g., Vec<(Arc<str>,
bool)>), then sort that Vec so entries with is_none == true come first (use
sort_by_key or sort_by comparing the boolean), then take the excess entries
using take(self.cache.len().saturating_sub(max_entries)) and map to the keys;
update references to self.cache, to_remove, max_entries, and the is_none boolean
accordingly so negative (None) entries are evicted before positives.
- Around line 219-229: The current to_remove construction in evict_if_needed
reverses the collected iterator before taking entries, causing the wrong
(most-recent) items to be evicted; fix it by collecting the filtered (k.clone(),
v.is_none()) pairs into a Vec, then take the excess count first and only then
reverse (or map keys) so that the oldest entries are removed. In practice,
change the chain around the collect() on self.cache to perform
.into_iter().take(self.cache.len().saturating_sub(max_entries)).rev().map(|(k,
_)| k).collect() (mirroring the fixes applied in the other two evict_if_needed
implementations) so to_remove contains the oldest keys not in dirty/deleted.
- Around line 138-148: The to_remove computation in signal_cache.rs reverses the
iteration with .rev() which breaks the eviction priority; apply the same change
as in SessionStoreState::evict_if_needed: stop reversing the iterator and
instead take the first N candidates to evict (compute n =
self.cache.len().saturating_sub(max_entries)) from the filtered list of (k,
v.is_none()) entries, then map to k; update the chain that builds to_remove (the
let to_remove: Vec<Arc<str>> = ... expression) to remove .rev() and take the
correct number of entries in forward order so eviction prioritization works as
intended.
🪄 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: 0d5d6919-06ce-4f2e-9fdb-43f81c4d3b50

📥 Commits

Reviewing files that changed from the base of the PR and between c0b19c5 and 310a429.

📒 Files selected for processing (6)
  • src/client.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/store/persistence_manager.rs
  • wacore/src/store/signal_cache.rs

Comment thread src/client.rs
Comment on lines +1606 to 1608
if self.node_waiter_count.load(Ordering::Acquire) > 0 {
self.resolve_node_waiters(&node);
}

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:

#!/bin/bash
set -euo pipefail

# Verify the publication order for incoming and outgoing node waiters.
nl -ba src/client.rs | sed -n '1603,1608p;3196,3207p;3215,3226p'

Repository: jlucaso1/whatsapp-rust

Length of output: 108


🏁 Script executed:

cat -n src/client.rs | sed -n '1603,1610p;3196,3230p'

Repository: jlucaso1/whatsapp-rust

Length of output: 1933


Race condition in waiter registration: count published before insertion into protected vector.

Lines 3201 and 3220 increment node_waiter_count and sent_node_waiter_count respectively, before acquiring the mutex to insert the waiter into the vector. A stanza arriving on another thread can observe node_waiter_count > 0 at line 1606 (via Acquire load), lock node_waiters in resolve_node_waiters(), and find the vector still empty since waiters.push() hasn't executed yet (lines 3206, 3225). The Acquire/Release ordering synchronizes only those two atomic operations, not the subsequent mutex-protected insertion.

Move fetch_add() into the mutex-protected section in both wait_for_node() and wait_for_sent_node() to ensure the count is incremented after the waiter is enqueued.

Suggested fix
 pub fn wait_for_node(
     &self,
     filter: NodeFilter,
 ) -> futures::channel::oneshot::Receiver<Arc<Node>> {
     let (tx, rx) = futures::channel::oneshot::channel();
-    self.node_waiter_count.fetch_add(1, Ordering::Release);
     let mut waiters = self
         .node_waiters
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
+    self.node_waiter_count.fetch_add(1, Ordering::Release);
     waiters.push(NodeWaiter { filter, tx });
     rx
 }

 pub fn wait_for_sent_node(
     &self,
     filter: NodeFilter,
 ) -> futures::channel::oneshot::Receiver<Arc<Node>> {
     let (tx, rx) = futures::channel::oneshot::channel();
-    self.sent_node_waiter_count.fetch_add(1, Ordering::Release);
     let mut waiters = self
         .sent_node_waiters
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
+    self.sent_node_waiter_count.fetch_add(1, Ordering::Release);
     waiters.push(SentNodeWaiter { filter, tx });
     rx
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1606 - 1608, The atomic counters
node_waiter_count and sent_node_waiter_count are incremented (fetch_add) before
the waiter is actually pushed into the mutex-protected vectors (node_waiters /
sent_node_waiters), allowing resolve_node_waiters to observe a positive count
while the vector is still empty; to fix, move each fetch_add() so it happens
after acquiring the respective mutex and after the waiter is inserted (i.e.
inside wait_for_node() and wait_for_sent_node(), acquire the lock, push the
waiter into node_waiters/sent_node_waiters, then perform the fetch_add on
node_waiter_count/sent_node_waiter_count), leaving resolve_node_waiters()
unchanged.

Comment thread src/message.rs
Comment on lines +168 to +170
let mut key =
String::with_capacity(chat.user.len() + msg_id.len() + sender.user.len() + 20);
let _ = write!(key, "{chat}:{msg_id}:{sender}");

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

Reserve space from the full formatted JIDs, not just user.len().

Line 169 sizes the buffer from user.len() plus a fixed 20, but the key writes full JIDs (@server and optional :device). Two @s.whatsapp.net JIDs alone add 30 bytes before the separators, so many 1:1 retry keys still reallocate and lose most of the intended hot-path win.

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

In `@src/message.rs` around lines 168 - 170, The buffer is being reserved from
chat.user.len() and sender.user.len(), which underestimates size because the
write! uses full formatted JIDs (including `@server` and optional :device). Before
allocating key, compute the full string forms for chat and sender (e.g.,
chat_jid = chat.to_string() / formatted_jid, sender_jid = sender.to_string() /
formatted_jid), then reserve capacity using chat_jid.len() + msg_id.len() +
sender_jid.len() + 2 (for the two ':' separators); finally write the precomputed
strings into key (write!(key, "{chat_jid}:{msg_id}:{sender_jid}")). This avoids
reallocations when JIDs include domain/device parts.

Comment thread src/retry.rs
self.flush_signal_cache().await.unwrap_or_else(|e| {
log::warn!("Failed to flush signal cache after session delete: {e}");
});
self.flush_signal_cache().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.

⚠️ Potential issue | 🟠 Major

These ?s still drop the retry on the floor.

handle_retry_receipt() is only awaited from the detached task in src/receipt.rs (Lines 66-83), and that caller just logs Err. On the DM branch, Line 445 can now return after the dedupe key is inserted and the message is removed from recent_messages, but before send_message_impl() runs, so a transient flush_signal_cache() failure becomes a terminal lost resend instead of triggering recovery. Line 492 has the same detached-caller problem, just after the send has already happened. Either make the caller requeue/escalate these failures, or roll back the consumed retry state before returning them.

Also applies to: 492-492

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

In `@src/retry.rs` at line 445, handle_retry_receipt() currently uses fallible
awaits like self.flush_signal_cache().await? and returns the error into a
detached caller which just logs it, causing lost retries; change these to
explicit error-handling paths that either (A) roll back any consumed retry state
before returning (undo the dedupe key insertion and re-insert the message into
recent_messages) when flush_signal_cache() or send_message_impl() fail, or (B)
propagate the error to the detached caller by returning a clear enum/error that
the caller in receipt.rs can use to requeue/escalate; update the code paths
around flush_signal_cache(), insert_dedupe_key/remove from recent_messages, and
send_message_impl() in handle_retry_receipt() to perform rollback on failure or
return a requeueable error instead of using `?`.

Comment thread src/store/persistence_manager.rs
Comment thread wacore/src/store/signal_cache.rs
Comment thread wacore/src/store/signal_cache.rs Outdated
Comment thread wacore/src/store/signal_cache.rs Outdated
Comment thread wacore/src/store/signal_cache.rs Outdated
@jlucaso1 jlucaso1 changed the title refactor: architecture audit fixes refactor: architecture audit — cache bounds, circuit breaker, correctness fixes Apr 1, 2026
@jlucaso1
jlucaso1 force-pushed the refactor/architecture-audit-fixes branch from 6751ada to 89735c7 Compare April 1, 2026 20:40
@jlucaso1

jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

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


Code Review: Architecture Audit Fixes (P0-P3)

  • Read all changed files (current state after fixes)
  • Review P0: Signal cache capacity bound
  • Review P0: Background saver circuit breaker
  • Review P1: Transport cleanup & flush error propagation
  • Review P2-P3: Minor fixes
  • Post final review

Solid hardening PR. The eviction sorting bug from the first review round is now properly fixed using negative.into_iter().chain(positive) — much cleaner than sorting. Here's what I found on the current state:

P0: Signal cache eviction — looks correct now

The evict_if_needed implementations (signal_cache.rs:71-94, :132-155, :216-238) now properly partition candidates into negative and positive vecs and chain them so negative entries are evicted first. This is a clean approach that avoids the previous .rev() bug.

Minor note: The three implementations are near-identical (the only difference is SenderKeyStoreState has no deleted set). A shared helper could reduce ~75 lines of duplication, but this is a style preference — not blocking.

P0: Background saver circuit breaker — good, but consider follow-up

persistence_manager.rs:129-171: Clean implementation. Counter resets on success, halts after 10 consecutive failures.

Worth noting for follow-up: After halting, the process continues running with no persistence path. src/receipt.rs:70-83 shows the detached task pattern — the owner fire-and-forgets this. A crash after the saver halts means all state changes since the last successful flush are lost with no signal to the application. Consider exposing a health signal (e.g., Arc<AtomicBool>) so callers can detect the non-durable state. Not blocking for this PR though.

P1: Transport cleanup on handshake failure — correct

client.rs:1076-1087: Explicitly disconnecting the transport on handshake failure prevents lingering TIME_WAIT connections. The match-based error handling is clean.

P1: Flush error propagation in retry.rs — needs attention

retry.rs:445 and :492: Converting flush_signal_cache() from warn-and-continue to ? is a behavior change worth careful consideration.

The caller in receipt.rs:66-83 is a detached task that just logs Err. On the DM path (line 445), a transient flush failure now aborts handle_retry_receipt after the session has been deleted from cache (line 444) but before the resend happens (lines 495-504). Since the dedupe key may already be inserted upstream, this could leave the retry in a state where:

  1. The session is deleted from cache (in-memory)
  2. The flush failed (deletion not persisted)
  3. The resend never happened
  4. Subsequent retry receipts for the same message may be deduped

The reg-ID mismatch path keeping warn-and-continue (retry.rs:242-244) is the right call. But for lines 445 and 492, consider either:

  • Keeping warn-and-continue (the session delete is already in the cache, it just didn't persist — the resend can still proceed)
  • Or adding rollback of consumed retry state on failure

Fix this →

Severity: Medium — transient persistence failures could cause lost retries.

P3: Atomic ordering fix — correct

client.rs:1603: RelaxedAcquire for the load pairs properly with Release stores at lines 3201, 3220, 3241, 3245. This ensures waiter registration data is visible before resolve_node_waiters runs.

Note on CodeRabbit's race condition comment: CodeRabbit flagged that fetch_add happens before the mutex-protected push (lines 3201-3206), creating a window where resolve_node_waiters sees a positive count but an empty vector. In practice this is benign — resolve_node_waiters also acquires the same mutex (node_waiters.lock()), so it will block until wait_for_node releases it. The count is an optimization to skip locking when zero, not a correctness gate. No change needed here.

P2: Pre-sized retry cache key — fine

message.rs:164-170: The String::with_capacity optimization is marginal (once per message, not hot loop). The capacity estimate using user.len() underestimates since write! formats full JIDs including @s.whatsapp.net, but the worst case is a single reallocation — not worth complicating the code for.

P3: Clear stale app_state_key_requests — correct

client.rs:1225-1226: Straightforward cleanup of stale state on reconnect.

P3: Lock ordering comment — improved

send.rs:1419-1421: The INVARIANT comment documenting the deadlock-prevention requirement is clear and well-placed.


Summary: Good hardening PR. The eviction bug is properly fixed. The main actionable concern is the flush error propagation in retry.rs (lines 445/492) where ? can silently drop retries on transient persistence failures — the previous warn-and-continue behavior was arguably safer here since the in-memory cache state is already correct.


@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: 89735c7efb

ℹ️ 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 +81 to +82
if self.dirty.contains(k.as_ref()) || self.deleted.contains(k.as_ref()) {
continue;

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 Enforce cap after dirty entries are flushed

This eviction logic skips dirty/deleted keys, so during write-heavy periods overflow can remain unresolved because almost every entry is dirty. After flush() clears dirty/deleted sets, no eviction is triggered, which means the cache can stay well above max_entries indefinitely until some later get/put happens. In long-lived processes this defeats the new capacity bound and can retain peak memory long after traffic drops.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 force-pushed the refactor/architecture-audit-fixes branch from 89735c7 to 96681f4 Compare April 1, 2026 20:54

@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

♻️ Duplicate comments (4)
src/message.rs (1)

168-170: ⚠️ Potential issue | 🟡 Minor

Preallocation still underestimates key length.

chat.user.len() / sender.user.len() exclude @server and optional :device, so this still reallocates frequently in the hot path.

Suggested fix
-        let mut key =
-            String::with_capacity(chat.user.len() + msg_id.len() + sender.user.len() + 20);
-        let _ = write!(key, "{chat}:{msg_id}:{sender}");
+        let chat_jid = chat.to_string();
+        let sender_jid = sender.to_string();
+        let mut key = String::with_capacity(chat_jid.len() + msg_id.len() + sender_jid.len() + 2);
+        let _ = write!(key, "{chat_jid}:{msg_id}:{sender_jid}");
         key
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` around lines 168 - 170, The preallocation uses
chat.user.len() and sender.user.len(), which omit the "@server" and optional
":device" parts and thus still underestimates capacity; change the capacity
calculation for the key (the variable named key used with write!(key,
"{chat}:{msg_id}:{sender}")) to use the full slice lengths (e.g. chat.len() and
sender.len()) plus msg_id.len() and the number of separator chars (':'
occurrences) — e.g. String::with_capacity(chat.len() + msg_id.len() +
sender.len() + 2) — so the buffer is sized correctly before calling write!.
src/client.rs (1)

1606-1607: ⚠️ Potential issue | 🔴 Critical

Acquire load here does not fix waiter publication race.

Line 1606 now uses Ordering::Acquire, but the root race remains: waiter count is incremented before the waiter is inserted (Line 3201 before Line 3206). The resolver can observe count > 0 and still see no waiter, dropping a matching stanza.

Suggested fix
 pub fn wait_for_node(
     &self,
     filter: NodeFilter,
 ) -> futures::channel::oneshot::Receiver<Arc<Node>> {
     let (tx, rx) = futures::channel::oneshot::channel();
-    self.node_waiter_count.fetch_add(1, Ordering::Release);
     let mut waiters = self
         .node_waiters
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
     waiters.push(NodeWaiter { filter, tx });
+    self.node_waiter_count.fetch_add(1, Ordering::Release);
     rx
 }

 pub fn wait_for_sent_node(
     &self,
     filter: NodeFilter,
 ) -> futures::channel::oneshot::Receiver<Arc<Node>> {
     let (tx, rx) = futures::channel::oneshot::channel();
-    self.sent_node_waiter_count.fetch_add(1, Ordering::Release);
     let mut waiters = self
         .sent_node_waiters
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
     waiters.push(SentNodeWaiter { filter, tx });
+    self.sent_node_waiter_count.fetch_add(1, Ordering::Release);
     rx
 }
#!/bin/bash
set -euo pipefail

# Verify publication order around waiter counters and insertion points.
nl -ba src/client.rs | sed -n '1602,1610p;3196,3208p;3215,3227p'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1606 - 1607, The resolver sees node_waiter_count
> 0 before a waiter is actually published because the registration path
increments node_waiter_count (the fetch_add on node_waiter_count in the waiter
registration code) before inserting the waiter into the waiter list; fix by
publishing the waiter first and then updating the counter (or by making the
counter update a Release store after the waiter insertion and keeping the
resolver's load as Acquire). Concretely: move the node waiter insertion (the
code that pushes/inserts the waiter into the list at the registration site) to
occur before calling node_waiter_count.fetch_add, or change that fetch_add to be
the Release/paired ordering so resolve_node_waiters' load(Ordering::Acquire)
reliably synchronizes with the published waiter; update the registration routine
and verify resolve_node_waiters still uses load(Ordering::Acquire).
src/retry.rs (1)

445-445: ⚠️ Potential issue | 🟠 Major

Don't fail-fast here unless the caller requeues the retry.

By Line 445 the dedupe key is already inserted and the DM payload has been removed from recent_messages. If the receipt worker still just logs handle_retry_receipt() errors, a transient flush_signal_cache() failure becomes a permanently dropped resend. Either roll back the consumed retry state before returning, or surface a requeueable outcome that the caller actually handles.

Verify the caller contract and the absence/presence of rollback paths with a read-only scan:

#!/bin/bash
set -euo pipefail

echo "== handle_retry_receipt call sites =="
rg -n -C4 '\bhandle_retry_receipt\s*\(' src

echo
echo "== receipt worker error handling around retry processing =="
rg -n -C6 'handle_retry_receipt|detach\(|spawn\(|warn!\(|error!\(' src/receipt.rs src

echo
echo "== retry state consumed before the DM flush =="
rg -n -C3 '\bretried_group_messages\b|\btake_recent_message\b|\badd_recent_message\b' src/retry.rs
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` at line 445, The call to self.flush_signal_cache().await? can
cause a transient failure after the dedupe key was inserted and the DM payload
removed from recent_messages, leading to permanently dropped retries; modify
handle_retry_receipt (and the retry path that calls it) to either roll back
consumed retry state on flush_signal_cache() failure (e.g., reinsert the DM into
recent_messages and remove the dedupe key) or change its return type to surface
a requeueable error so the caller can requeue the retry; locate symbols
flush_signal_cache(), handle_retry_receipt(), recent_messages,
take_recent_message/add_recent_message and the dedupe-key insertion site in
retry.rs to implement the rollback or propagate a specific RequeueableError and
update callers to handle that outcome.
src/store/persistence_manager.rs (1)

157-164: ⚠️ Potential issue | 🟠 Major

Expose the tripped saver circuit breaker to the owner.

Returning here only emits a log. src/bot.rs:650-656 still detaches this task, so after the 10th failure the process can keep accepting modify_device() writes with no background persistence path and lose them on crash. Please surface an observable fatal/health state or otherwise make writers/the owner react when this loop halts.

🤖 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 157 - 164, The background
saver currently returns on MAX_CONSECUTIVE_FAILURES without notifying the owner;
add an observable circuit-breaker flag to PersistenceManager (e.g., a new field
like saver_tripped: Arc<AtomicBool> or an Arc<Notify>/watch::Sender) and set it
to true right before the early return in the background flush loop where
save_to_disk().await errors are counted (the block that compares
consecutive_failures >= MAX_CONSECUTIVE_FAILURES); also provide an accessor or
expose the watch/channel so bot.rs (or callers of modify_device) can observe the
tripped state and react (e.g., stop accepting writes, mark health unhealthy, or
restart the task). Ensure the field is initialized when PersistenceManager is
constructed and updated atomically inside the loop where the error is logged.
🤖 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/client.rs`:
- Around line 1226-1227: The cleanup path redundantly clears
app_state_key_requests via self.app_state_key_requests.lock().await.clear() and
then later replaces it with a new HashMap in cleanup_connection_state(); remove
the earlier clear to avoid duplicate locking and work. Locate the first call to
self.app_state_key_requests.lock().await.clear() in the connection cleanup
sequence and delete that statement, keeping the single reset performed in
cleanup_connection_state() (the assignment that replaces the map with a new
HashMap) so only one lock and reset occurs.

In `@src/retry.rs`:
- Around line 244-247: Wrap the delete+flush sequence that calls
self.signal_cache.delete_session(&signal_address).await and
self.flush_signal_cache().await inside the same per-sender session lock used by
session_lock_for(...) so the delete and subsequent flush are serialized with
other Signal operations; mirror the same change for the other occurrence that
mutates the same session (the block referenced alongside
process_retry_key_bundle()), ensuring you acquire the session_locks entry for
signal_address before calling delete_session and hold it until after
flush_signal_cache completes.

In `@wacore/src/store/signal_cache.rs`:
- Around line 280-287: The cache can remain oversized after flush() clears
dirty/deleted flags because eviction is only run on put/update; to fix, invoke
state.evict_if_needed(self.max_entries) after any flush() that may make entries
evictable so the size bound is enforced immediately. Locate the flush()
implementations (the ones manipulating dirty/deleted flags and calling
sessions.lock()/state.flush()) and add a call to
state.evict_if_needed(self.max_entries) after the flush completes (and while
still holding the same mutex), similarly update any other flush paths referenced
(the other flush occurrences around the same logic) so they all re-run
evict_if_needed using the same max_entries field. Ensure you reference the
existing methods flush(), evict_if_needed(), put_session(), and the
self.max_entries field when making the changes.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 1606-1607: The resolver sees node_waiter_count > 0 before a waiter
is actually published because the registration path increments node_waiter_count
(the fetch_add on node_waiter_count in the waiter registration code) before
inserting the waiter into the waiter list; fix by publishing the waiter first
and then updating the counter (or by making the counter update a Release store
after the waiter insertion and keeping the resolver's load as Acquire).
Concretely: move the node waiter insertion (the code that pushes/inserts the
waiter into the list at the registration site) to occur before calling
node_waiter_count.fetch_add, or change that fetch_add to be the Release/paired
ordering so resolve_node_waiters' load(Ordering::Acquire) reliably synchronizes
with the published waiter; update the registration routine and verify
resolve_node_waiters still uses load(Ordering::Acquire).

In `@src/message.rs`:
- Around line 168-170: The preallocation uses chat.user.len() and
sender.user.len(), which omit the "@server" and optional ":device" parts and
thus still underestimates capacity; change the capacity calculation for the key
(the variable named key used with write!(key, "{chat}:{msg_id}:{sender}")) to
use the full slice lengths (e.g. chat.len() and sender.len()) plus msg_id.len()
and the number of separator chars (':' occurrences) — e.g.
String::with_capacity(chat.len() + msg_id.len() + sender.len() + 2) — so the
buffer is sized correctly before calling write!.

In `@src/retry.rs`:
- Line 445: The call to self.flush_signal_cache().await? can cause a transient
failure after the dedupe key was inserted and the DM payload removed from
recent_messages, leading to permanently dropped retries; modify
handle_retry_receipt (and the retry path that calls it) to either roll back
consumed retry state on flush_signal_cache() failure (e.g., reinsert the DM into
recent_messages and remove the dedupe key) or change its return type to surface
a requeueable error so the caller can requeue the retry; locate symbols
flush_signal_cache(), handle_retry_receipt(), recent_messages,
take_recent_message/add_recent_message and the dedupe-key insertion site in
retry.rs to implement the rollback or propagate a specific RequeueableError and
update callers to handle that outcome.

In `@src/store/persistence_manager.rs`:
- Around line 157-164: The background saver currently returns on
MAX_CONSECUTIVE_FAILURES without notifying the owner; add an observable
circuit-breaker flag to PersistenceManager (e.g., a new field like
saver_tripped: Arc<AtomicBool> or an Arc<Notify>/watch::Sender) and set it to
true right before the early return in the background flush loop where
save_to_disk().await errors are counted (the block that compares
consecutive_failures >= MAX_CONSECUTIVE_FAILURES); also provide an accessor or
expose the watch/channel so bot.rs (or callers of modify_device) can observe the
tripped state and react (e.g., stop accepting writes, mark health unhealthy, or
restart the task). Ensure the field is initialized when PersistenceManager is
constructed and updated atomically inside the loop where the error is logged.
🪄 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: 97ae0430-e7ef-4cef-96cb-9a52dd42c252

📥 Commits

Reviewing files that changed from the base of the PR and between 310a429 and 89735c7.

📒 Files selected for processing (6)
  • src/client.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/store/persistence_manager.rs
  • wacore/src/store/signal_cache.rs

Comment thread src/client.rs Outdated
Comment on lines +1226 to +1227
// Clear stale app state requests from previous connection
self.app_state_key_requests.lock().await.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.

🧹 Nitpick | 🔵 Trivial

Remove redundant app-state map clearing in cleanup path.

Line 1227 clears app_state_key_requests, but Line 1270 immediately replaces the same map with a new HashMap. Keep one reset path to avoid duplicate lock/work in cleanup_connection_state().

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

In `@src/client.rs` around lines 1226 - 1227, The cleanup path redundantly clears
app_state_key_requests via self.app_state_key_requests.lock().await.clear() and
then later replaces it with a new HashMap in cleanup_connection_state(); remove
the earlier clear to avoid duplicate locking and work. Locate the first call to
self.app_state_key_requests.lock().await.clear() in the connection cleanup
sequence and delete that statement, keeping the single reset performed in
cleanup_connection_state() (the assignment that replaces the map with a new
HashMap) so only one lock and reset occurs.

Comment thread src/retry.rs
Comment on lines 244 to +247
self.signal_cache.delete_session(&signal_address).await;
self.flush_signal_cache().await.unwrap_or_else(|e| {
if let Err(e) = self.flush_signal_cache().await {
log::warn!("Failed to flush session deletion for reg ID mismatch: {e}");
});
}

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

Take the per-sender session lock around both delete+flush paths.

Both branches mutate signal_address's Signal session outside session_lock_for(...), so concurrent encrypt/decrypt work for the same sender can race the delete and repopulate stale state. process_retry_key_bundle() already uses that lock later in this file; these paths need the same guard.

As per coding guidelines, "Use session_locks to serialize per-sender Signal encrypt/decrypt operations and message_enqueue_locks to serialize per-chat incoming message processing".

Also applies to: 442-445

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

In `@src/retry.rs` around lines 244 - 247, Wrap the delete+flush sequence that
calls self.signal_cache.delete_session(&signal_address).await and
self.flush_signal_cache().await inside the same per-sender session lock used by
session_lock_for(...) so the delete and subsequent flush are serialized with
other Signal operations; mirror the same change for the other occurrence that
mutates the same session (the block referenced alongside
process_retry_key_bundle()), ensuring you acquire the session_locks entry for
signal_address before calling delete_session and hold it until after
flush_signal_cache completes.

Comment thread wacore/src/store/signal_cache.rs
@jlucaso1
jlucaso1 force-pushed the refactor/architecture-audit-fixes branch from 96681f4 to 8d83174 Compare April 1, 2026 21:11

@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

♻️ Duplicate comments (2)
src/client.rs (1)

1606-1608: ⚠️ Potential issue | 🔴 Critical

Acquire here only orders the counter, not the waiter publication.

src/client.rs::wait_for_node() still increments node_waiter_count before it acquires the mutex and pushes into node_waiters (and src/client.rs::wait_for_sent_node() has the same pattern). That means src/client.rs::resolve_node_waiters() can observe > 0, lock the vector, and still find it empty, which loses the stanza for a just-registered waiter. Move the fetch_add() under the same mutex hold as the push() so the count is never published independently of the vector update.

Suggested fix
 pub fn wait_for_node(
     &self,
     filter: NodeFilter,
 ) -> futures::channel::oneshot::Receiver<Arc<Node>> {
     let (tx, rx) = futures::channel::oneshot::channel();
-    self.node_waiter_count.fetch_add(1, Ordering::Release);
     let mut waiters = self
         .node_waiters
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
+    self.node_waiter_count.fetch_add(1, Ordering::Release);
     waiters.push(NodeWaiter { filter, tx });
     rx
 }

 pub fn wait_for_sent_node(
     &self,
     filter: NodeFilter,
 ) -> futures::channel::oneshot::Receiver<Arc<Node>> {
     let (tx, rx) = futures::channel::oneshot::channel();
-    self.sent_node_waiter_count.fetch_add(1, Ordering::Release);
     let mut waiters = self
         .sent_node_waiters
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
+    self.sent_node_waiter_count.fetch_add(1, Ordering::Release);
     waiters.push(SentNodeWaiter { filter, tx });
     rx
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client.rs` around lines 1606 - 1608, The counter node_waiter_count is
incremented (fetch_add) before the waiter is actually pushed into the
node_waiters vector, allowing resolve_node_waiters to see a positive count but
find an empty vector; change wait_for_node() (and wait_for_sent_node()) to
acquire the same mutex that guards node_waiters before performing the push and
then increment node_waiter_count while still holding that mutex so the
publication of the count cannot race ahead of the vector update; ensure
resolve_node_waiters still reads the count with Ordering::Acquire and locks the
same mutex around inspecting/popping node_waiters.
src/store/persistence_manager.rs (1)

157-166: ⚠️ Potential issue | 🟠 Major

Surface the tripped saver breaker instead of just returning.

After this branch returns, the detached background saver is gone, but src/store/persistence_manager.rs::modify_device() still accepts writes, sets dirty = true, and notifies an event with no listener behind it. That silently degrades durability until an explicit flush() happens. Expose a health/fatal flag or similar monitored signal so the owner can alert or reject writes once persistence is no longer running.

🤖 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 157 - 166, The background
saver currently returns on repeated save_to_disk() errors but leaves persistence
running and modify_device() still accepting writes (setting dirty = true and
notifying events), so add a monitored fatal/health flag on the
PersistenceManager (e.g., a AtomicBool or an enum field like fatal_error) and
set it when consecutive_failures >= MAX_CONSECUTIVE_FAILURES inside the save
loop (where save_to_disk() errors are handled). Update modify_device() to check
that flag and either reject new writes (return an error) or route them to a safe
fallback, and ensure any event notifications include the health state so the
owner can observe the broken saver; also expose a public
is_healthy()/has_fatal_error() accessor and ensure flush() and any callers
respect that flag.
🤖 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 284-287: delete_session, delete_identity, and delete_sender_key
currently only insert a negative entry and do not run the eviction hook, leaving
evictable entries in memory until the next flush; update each of these delete_*
methods to call the same eviction routine used by put_* and flush (i.e., after
acquiring the lock and inserting the tombstone/negative entry call
state.evict_if_needed(self.max_entries)) so the cache cap is enforced
immediately; reference the existing put_session pattern and the
state.evict_if_needed(self.max_entries) call as the model to follow.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 1606-1608: The counter node_waiter_count is incremented
(fetch_add) before the waiter is actually pushed into the node_waiters vector,
allowing resolve_node_waiters to see a positive count but find an empty vector;
change wait_for_node() (and wait_for_sent_node()) to acquire the same mutex that
guards node_waiters before performing the push and then increment
node_waiter_count while still holding that mutex so the publication of the count
cannot race ahead of the vector update; ensure resolve_node_waiters still reads
the count with Ordering::Acquire and locks the same mutex around
inspecting/popping node_waiters.

In `@src/store/persistence_manager.rs`:
- Around line 157-166: The background saver currently returns on repeated
save_to_disk() errors but leaves persistence running and modify_device() still
accepting writes (setting dirty = true and notifying events), so add a monitored
fatal/health flag on the PersistenceManager (e.g., a AtomicBool or an enum field
like fatal_error) and set it when consecutive_failures >=
MAX_CONSECUTIVE_FAILURES inside the save loop (where save_to_disk() errors are
handled). Update modify_device() to check that flag and either reject new writes
(return an error) or route them to a safe fallback, and ensure any event
notifications include the health state so the owner can observe the broken
saver; also expose a public is_healthy()/has_fatal_error() accessor and ensure
flush() and any callers respect that flag.
🪄 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: 0c9deac6-895c-4d7b-831d-e5f488072ba7

📥 Commits

Reviewing files that changed from the base of the PR and between 89735c7 and 96681f4.

📒 Files selected for processing (6)
  • src/client.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/store/persistence_manager.rs
  • wacore/src/store/signal_cache.rs

Comment thread wacore/src/store/signal_cache.rs
@jlucaso1
jlucaso1 force-pushed the refactor/architecture-audit-fixes branch from 8d83174 to c036184 Compare April 1, 2026 21:21
- Signal cache: add capacity bound (10K entries per store), evict
  clean/negative entries when exceeded (runtime-agnostic, no moka)
- Background saver: circuit breaker after 10 consecutive flush failures
- Transport: explicit disconnect on handshake failure
- Retry: propagate flush_signal_cache errors instead of swallowing
- Cache key: pre-sized String builder for retry cache key hot path
- Reconnect: clear stale app_state_key_requests
- Atomics: Relaxed→Acquire for node_waiter_count
- Docs: lock ordering invariant in send.rs
@jlucaso1
jlucaso1 force-pushed the refactor/architecture-audit-fixes branch from c036184 to 4215867 Compare April 1, 2026 21:24

@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 (4)
src/retry.rs (2)

245-247: ⚠️ Potential issue | 🟠 Major

Take the per-sender session lock around both delete+flush sequences.

Both branches mutate signal_address outside session_lock_for(...), so concurrent encrypt/decrypt work can race the deletion and repopulate stale session state. process_retry_key_bundle() already serializes the same address.

🔒 Suggested pattern
+let session_mutex = self.session_lock_for(signal_address.as_str()).await;
+let _session_guard = session_mutex.lock().await;
 self.signal_cache.delete_session(&signal_address).await;
 self.flush_signal_cache().await?;

As per coding guidelines, "Use session_locks to serialize per-sender Signal encrypt/decrypt operations and message_enqueue_locks to serialize per-chat incoming message processing".

Also applies to: 445-445

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

In `@src/retry.rs` around lines 245 - 247, The deletion+flush sequence around
signal_address must be executed while holding the per-sender session lock to
prevent races with concurrent encrypt/decrypt; wrap the existing delete and the
subsequent call to flush_signal_cache() inside the same session_lock_for(...)
used elsewhere (e.g., where process_retry_key_bundle() serializes that address)
so both branches acquire the session lock before mutating signal_address and
only release it after the delete and flush complete, ensuring you call
session_lock_for(signal_address).await (or the equivalent lock guard) around the
delete and flush calls.

445-445: ⚠️ Potential issue | 🟠 Major

Don't propagate this DM flush error without restoring retry state.

By Line 445 the dedupe entry is already inserted and the DM recent_message has already been consumed. If flush_signal_cache() fails here, the resend never reaches send_message_impl(), but later retry receipts are skipped because the message is already marked handled. Either move the dedupe/take step after this flush succeeds, or roll both structures back on error.

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

In `@src/retry.rs` at line 445, The DM dedupe entry and the DM's recent_message
are being consumed before calling flush_signal_cache(), so if
flush_signal_cache() fails the retry state remains mutated and future retries
are skipped; update the logic around where the dedupe entry is inserted and
recent_message is taken (the code that prepares the resend before calling
send_message_impl()) so that either (a) you move the dedupe insertion and
recent_message take to after flush_signal_cache() completes successfully, or (b)
on any error returned from flush_signal_cache() you roll back both the dedupe
map entry and restore the DM recent_message to its prior state before returning
the error. Ensure you reference and adjust the code paths that call
flush_signal_cache(), the dedupe insertion site, and the recent_message
consumption so state is consistent on error.
wacore/src/store/signal_cache.rs (1)

284-287: ⚠️ Potential issue | 🟡 Minor

Apply the same eviction hook to the delete_* paths.

These new hooks enforce the cap on read/write/flush, but an uncached delete_session, delete_identity, or delete_sender_key still leaves the old clean working set resident until some later access or flush. That makes the new bound inconsistent on delete-heavy paths.

♻️ Proposed fix
 pub async fn delete_session(&self, address: &ProtocolAddress) {
-    self.sessions.lock().await.delete(address.as_str());
+    let mut state = self.sessions.lock().await;
+    state.delete(address.as_str());
+    state.evict_if_needed(self.max_entries);
 }

 pub async fn delete_identity(&self, address: &ProtocolAddress) {
-    self.identities.lock().await.delete(address.as_str());
+    let mut state = self.identities.lock().await;
+    state.delete(address.as_str());
+    state.evict_if_needed(self.max_entries);
 }

 pub async fn delete_sender_key(&self, cache_key: &str) {
-    self.sender_keys.lock().await.delete(cache_key);
+    let mut state = self.sender_keys.lock().await;
+    state.delete(cache_key);
+    state.evict_if_needed(self.max_entries);
 }

Also applies to: 333-336, 364-367

🤖 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 284 - 287, The delete paths
currently remove entries but don't call the eviction hook, leaving the in-memory
working set larger than the enforced cap; update the delete methods to mirror
put_session by calling state.evict_if_needed(self.max_entries) after performing
the deletion. Specifically, in the methods delete_session, delete_identity, and
delete_sender_key (the same locked state used in put_session), invoke
state.evict_if_needed(self.max_entries) immediately after state.delete(...)
while holding the lock so the cap is consistently enforced on delete-heavy
paths.
src/client.rs (1)

1604-1605: ⚠️ Potential issue | 🔴 Critical

Acquire here still does not fix the waiter-registration race.

The counters are still published before the waiter is enqueued, so process_node()/send_node() can observe > 0, take the mutex, and miss the just-registered waiter for the very stanza being awaited. Move the fetch_add() calls under the same mutex and before push(...); strengthening the load alone does not close that gap.

Suggested fix
 pub fn wait_for_node(
     &self,
     filter: NodeFilter,
 ) -> futures::channel::oneshot::Receiver<Arc<Node>> {
     let (tx, rx) = futures::channel::oneshot::channel();
-    self.node_waiter_count.fetch_add(1, Ordering::Release);
     let mut waiters = self
         .node_waiters
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
+    self.node_waiter_count.fetch_add(1, Ordering::Release);
     waiters.push(NodeWaiter { filter, tx });
     rx
 }

 pub fn wait_for_sent_node(
     &self,
     filter: NodeFilter,
 ) -> futures::channel::oneshot::Receiver<Arc<Node>> {
     let (tx, rx) = futures::channel::oneshot::channel();
-    self.sent_node_waiter_count.fetch_add(1, Ordering::Release);
     let mut waiters = self
         .sent_node_waiters
         .lock()
         .unwrap_or_else(|poisoned| poisoned.into_inner());
+    self.sent_node_waiter_count.fetch_add(1, Ordering::Release);
     waiters.push(SentNodeWaiter { filter, tx });
     rx
 }

Also applies to: 3198-3204, 3217-3223

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

In `@src/client.rs` around lines 1604 - 1605, The atomic counter increments
(node_waiter_count.fetch_add) are happening before the waiter is actually
enqueued, so a racing path in process_node()/send_node() can see the counter >
0, take the mutex and miss the newly-registered waiter; fix by moving the
fetch_add() calls to occur while holding the same mutex used to protect the
waiter queue and place them immediately before the push(...) that enqueues the
waiter (so the counter is published only when the waiter is guaranteed
enqueued), then update all similar sites (including the other occurrences around
resolve_node_waiters, process_node, send_node) to use the same pattern.
🤖 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/client.rs`:
- Around line 1604-1605: The atomic counter increments
(node_waiter_count.fetch_add) are happening before the waiter is actually
enqueued, so a racing path in process_node()/send_node() can see the counter >
0, take the mutex and miss the newly-registered waiter; fix by moving the
fetch_add() calls to occur while holding the same mutex used to protect the
waiter queue and place them immediately before the push(...) that enqueues the
waiter (so the counter is published only when the waiter is guaranteed
enqueued), then update all similar sites (including the other occurrences around
resolve_node_waiters, process_node, send_node) to use the same pattern.

In `@src/retry.rs`:
- Around line 245-247: The deletion+flush sequence around signal_address must be
executed while holding the per-sender session lock to prevent races with
concurrent encrypt/decrypt; wrap the existing delete and the subsequent call to
flush_signal_cache() inside the same session_lock_for(...) used elsewhere (e.g.,
where process_retry_key_bundle() serializes that address) so both branches
acquire the session lock before mutating signal_address and only release it
after the delete and flush complete, ensuring you call
session_lock_for(signal_address).await (or the equivalent lock guard) around the
delete and flush calls.
- Line 445: The DM dedupe entry and the DM's recent_message are being consumed
before calling flush_signal_cache(), so if flush_signal_cache() fails the retry
state remains mutated and future retries are skipped; update the logic around
where the dedupe entry is inserted and recent_message is taken (the code that
prepares the resend before calling send_message_impl()) so that either (a) you
move the dedupe insertion and recent_message take to after flush_signal_cache()
completes successfully, or (b) on any error returned from flush_signal_cache()
you roll back both the dedupe map entry and restore the DM recent_message to its
prior state before returning the error. Ensure you reference and adjust the code
paths that call flush_signal_cache(), the dedupe insertion site, and the
recent_message consumption so state is consistent on error.

In `@wacore/src/store/signal_cache.rs`:
- Around line 284-287: The delete paths currently remove entries but don't call
the eviction hook, leaving the in-memory working set larger than the enforced
cap; update the delete methods to mirror put_session by calling
state.evict_if_needed(self.max_entries) after performing the deletion.
Specifically, in the methods delete_session, delete_identity, and
delete_sender_key (the same locked state used in put_session), invoke
state.evict_if_needed(self.max_entries) immediately after state.delete(...)
while holding the lock so the cap is consistently enforced on delete-heavy
paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1ac5beff-29c0-4d87-bb88-13049a7264b9

📥 Commits

Reviewing files that changed from the base of the PR and between 96681f4 and 8d83174.

📒 Files selected for processing (6)
  • src/client.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/store/persistence_manager.rs
  • wacore/src/store/signal_cache.rs

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