Skip to content

fix(cache): don't FIFO-evict strongly-held coordination locks (session_locks) - #993

Closed
jlucaso1 wants to merge 1 commit into
mainfrom
claude/fix-portable-cache-evict-guard
Closed

fix(cache): don't FIFO-evict strongly-held coordination locks (session_locks)#993
jlucaso1 wants to merge 1 commit into
mainfrom
claude/fix-portable-cache-evict-guard

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What

Give PortableCache an opt-in eviction guard so a capacity-only coordination-lock cache never FIFO-evicts an entry that a task still holds, and wire session_locks with |lock| Arc::strong_count(lock) == 1.

Why (bug)

CacheInner::insert_new evicted the capacity-FIFO head unconditionally — no strong_count guard, unlike the sibling reclaim_init_lock (strong_count <= 2) and run_pending_tasks (retain strong_count > 1) in the same file.

session_locks is a capacity-only (10_000, no TTL) Cache<String, Arc<async_lock::Mutex<()>>>. session_lock_for hands out clones of the Arc, held across a Signal decrypt/encrypt. Eviction is pure insertion-order FIFO (get never refreshes ordering), so a long-lived, actively-used address is the first victim. If task A holds mutex M1 across an .await inside message_decrypt and A's address is FIFO-evicted, task B's session_lock_for misses, mints a fresh M2 ≠ M1, and enters the ratchet for the same session concurrently → counter/nonce reuse on encrypt or SessionError on decrypt. The lifecycle comment already flags exactly this hazard for time-based eviction ("would silently break serialisation") — capacity eviction had the same flaw, only mitigated by sizing.

This is the same locking-discipline family as the already-merged #990 (send fan-out) and #992 (group inbound); this closes the lock-cache side.

How

  • insert_new gains an optional evict_guard: Option<&dyn Fn(&V) -> bool>. When set, it evicts the oldest entry the guard allows, skipping still-referenced entries. If nothing is evictable it leaves the map to exceed capacity — a bounded, transient overshoot (held locks are released at the end of each critical section; concurrency is capped by the processing semaphore) that self-corrects on the next insert. Without a guard, behavior is unchanged plain FIFO.
  • PortableCacheBuilder::evict_guard(f) wires it; session_locks uses Arc::strong_count(lock) == 1.

Tests

  • test_evict_guard_skips_strongly_held_entries — a held (strong_count > 1) FIFO head is skipped and the next unheld entry is evicted instead.
  • test_evict_guard_overflows_when_all_held_then_recovers — all-held → bounded overflow (no live lock dropped); once released, a later insert evicts back to capacity.
  • Existing test_capacity_eviction, test_session_lock_pattern, and the FIFO-order tests still pass (default path unchanged). cargo fmt / clippy -p whatsapp-rust --lib --tests clean.

🤖 Generated with Claude Code


Generated by Claude Code

PortableCache::insert_new evicted the capacity-FIFO head unconditionally, with no
strong_count guard — unlike the sibling reclaim_init_lock / run_pending_tasks
paths. session_locks is a capacity-only (10k, no TTL) Cache<String, Arc<Mutex>>
whose values are handed out as clones held across a decrypt/encrypt. Evicting an
address that is actively held lets the next session_lock_for miss and mint a
second mutex, so two writers enter the same Signal ratchet concurrently ->
counter/nonce reuse or SessionError. The FIFO victim is insertion-order, so a
long-lived, actively-used address is the first to go.

Add an opt-in eviction guard: insert_new skips capacity-eviction of entries the
guard rejects, and overflows (bounded, transient) if none are evictable rather
than dropping a live lock. Wire session_locks with
`|lock| Arc::strong_count(lock) == 1`. Default (no guard) stays plain FIFO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1geaAZffSxDhP7dpNrbbt
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8c77aa3a-8500-4744-b7d0-424dad8de914

📥 Commits

Reviewing files that changed from the base of the PR and between 4eecf0e and 59020d1.

📒 Files selected for processing (2)
  • src/client/lifecycle.rs
  • src/portable_cache.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added smarter cache eviction control for stored values, letting in-use entries be preserved during eviction.
    • Cache capacity handling can now skip entries that should not be removed, reducing disruption for active items.
  • Bug Fixes

    • Fixed an issue where actively held cached values could be evicted too early.
    • Improved cache behavior so it can temporarily exceed capacity rather than removing protected entries.

Walkthrough

PortableCache gains an optional evict_guard predicate that gates capacity eviction to entries whose value satisfies the predicate, allowing bounded temporary overflow when held. Client::new_with_cache_config applies this to session_locks, evicting only mutexes with strong count 1.

Changes

Value-aware eviction for PortableCache

Layer / File(s) Summary
EvictGuard type and struct fields
src/portable_cache.rs
Adds EvictGuard<V> type alias and private evict_guard fields on PortableCache and PortableCacheBuilder.
Builder setter and conditional eviction logic
src/portable_cache.rs
Adds PortableCacheBuilder::evict_guard(...) setter, wires it into build(), and changes CacheInner::insert_new to select an evictable entry based on the guard instead of unconditional FIFO eviction; updates insert, insert_and_return, and Clone accordingly.
Tests and session_locks integration
src/portable_cache.rs, src/client/lifecycle.rs
Adds tests validating held entries are skipped during eviction and can temporarily overflow capacity; applies an evict_guard on session_locks requiring Arc::strong_count(lock) == 1 before eviction.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PortableCache
  participant CacheInner
  Client->>PortableCache: insert(session_key, lock)
  PortableCache->>CacheInner: insert_new(evict_guard)
  CacheInner->>CacheInner: check strong_count(lock) == 1 for oldest entries
  alt unheld lock found
    CacheInner->>CacheInner: evict unheld lock
  else all locks held
    CacheInner->>CacheInner: allow temporary overflow
  end
  CacheInner-->>Client: insertion complete
Loading

Possibly related PRs

  • oxidezap/whatsapp-rust#334: Both PRs address eviction of still-held session_locks entries, one via TTL-avoidance config, the other via evict_guard predicate.
  • oxidezap/whatsapp-rust#763: Both PRs modify CacheInner::insert_new eviction mechanics in src/portable_cache.rs.

Suggested labels: api-design

Listen, this isn't a small tweak, it's an infrastructure investment. We can't have mutexes getting evicted while they're doing real work — that's not how you build a platform that connects the world. So now, we check strong_count before we evict, like any serious engineering org would. Move fast, but don't break sessions.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: preventing FIFO eviction of strongly held session locks.
Description check ✅ Passed The description accurately explains the cache eviction guard, the session_locks wiring, and the added tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-portable-cache-evict-guard

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.

@jlucaso1 jlucaso1 closed this Jul 7, 2026
@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a FIFO-eviction race in session_locks: when a long-lived sender's lock was evicted while an in-flight task held it, the next caller would mint a fresh Arc<Mutex> for the same session and race the Signal ratchet. The fix adds an opt-in evict_guard predicate to PortableCache that skips still-referenced entries during capacity eviction; session_locks is wired with Arc::strong_count == 1 as the guard.

  • PortableCache::insert_new gains an evict_guard parameter; when no evictable entry exists (all held), the cache transiently exceeds capacity rather than drop a live lock — a bounded overshoot that self-corrects on the next insert.
  • PortableCacheBuilder::evict_guard(f) exposes the predicate to callers; session_locks in lifecycle.rs is the only consumer so far.
  • Two new tests cover the skip-and-evict and all-held-overflow paths; existing tests are unchanged.

Confidence Score: 4/5

The eviction guard logic is correct and well-tested; the only open question is whether chat_lanes intentionally omits the same guard given its ChatLane::enqueue_lock is the same Arc<Mutex<()>> type.

The core fix is sound and the tests cover both the skip-and-evict and bounded-overflow paths. The chat_lanes omission is worth confirming before merging, as the same race condition could apply there.

src/client/lifecycle.rs — verify whether chat_lanes intentionally omits the evict_guard given its ChatLane::enqueue_lock is the same Arc<Mutex<()>> type.

Important Files Changed

Filename Overview
src/portable_cache.rs Adds evict_guard to PortableCache and wires it through insert_new; logic is sound and tests cover both skip-and-evict and bounded-overflow paths. Minor: the in-function comment in insert_new is verbose and explains "what" as well as "why".
src/client/lifecycle.rs Wires evict_guard on session_locks with the correct Arc::strong_count == 1 predicate. chat_lanes (also a coordination-lock cache holding Arc<Mutex<()>>) does not receive the same guard.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant A as Task A
    participant B as Task B
    participant Cache as session_locks cache
    participant M1 as Mutex M1

    A->>Cache: "get_with("addr") → Arc<M1>"
    Cache-->>A: "M1 (strong_count=2)"
    A->>M1: lock().await (holds across decrypt/encrypt)

    Note over Cache: capacity reached, insert "addr2"

    alt Before fix (no evict_guard)
        Cache->>Cache: FIFO pop_first → evict "addr" (M1)
        B->>Cache: get_with("addr") → miss
        Cache->>Cache: "create fresh Arc<M2>"
        Cache-->>B: "M2 (strong_count=2)"
        B->>M2: lock().await
        Note over A,B: A holds M1, B holds M2 — RACE on ratchet
    else "After fix (evict_guard = strong_count == 1)"
        Cache->>Cache: "find oldest where strong_count==1 → skip M1, evict next"
        B->>Cache: get_with("addr") → hit M1
        Cache-->>B: "M1 (strong_count=3)"
        B->>M1: lock().await (waits for A)
        Note over A,B: Serialised correctly
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant A as Task A
    participant B as Task B
    participant Cache as session_locks cache
    participant M1 as Mutex M1

    A->>Cache: "get_with("addr") → Arc<M1>"
    Cache-->>A: "M1 (strong_count=2)"
    A->>M1: lock().await (holds across decrypt/encrypt)

    Note over Cache: capacity reached, insert "addr2"

    alt Before fix (no evict_guard)
        Cache->>Cache: FIFO pop_first → evict "addr" (M1)
        B->>Cache: get_with("addr") → miss
        Cache->>Cache: "create fresh Arc<M2>"
        Cache-->>B: "M2 (strong_count=2)"
        B->>M2: lock().await
        Note over A,B: A holds M1, B holds M2 — RACE on ratchet
    else "After fix (evict_guard = strong_count == 1)"
        Cache->>Cache: "find oldest where strong_count==1 → skip M1, evict next"
        B->>Cache: get_with("addr") → hit M1
        Cache-->>B: "M1 (strong_count=3)"
        B->>M1: lock().await (waits for A)
        Note over A,B: Serialised correctly
    end
Loading

Comments Outside Diff (1)

  1. src/client/lifecycle.rs, line 168-170 (link)

    P2 chat_lanes stores a ChatLane which itself contains enqueue_lock: Arc<async_lock::Mutex<()>> — structurally identical to the session_locks value type. If a task holds a clone of a ChatLane's enqueue_lock across an .await and that lane is FIFO-evicted, the next message for the same chat mints a fresh ChatLane and bypasses the serialisation lock, the same race this PR fixes for session_locks. Was chat_lanes intentionally excluded from the evict guard (e.g. covered by a separate PR), or should it receive the same guard?

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/client/lifecycle.rs
    Line: 168-170
    
    Comment:
    `chat_lanes` stores a `ChatLane` which itself contains `enqueue_lock: Arc<async_lock::Mutex<()>>` — structurally identical to the `session_locks` value type. If a task holds a clone of a `ChatLane`'s `enqueue_lock` across an `.await` and that lane is FIFO-evicted, the next message for the same chat mints a fresh `ChatLane` and bypasses the serialisation lock, the same race this PR fixes for `session_locks`. Was `chat_lanes` intentionally excluded from the evict guard (e.g. covered by a separate PR), or should it receive the same guard?
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/portable_cache.rs:100-107
The 6-line block comment inside `insert_new` narrates the algorithm step-by-step ("Pick the oldest entry…", "Without a guard that is simply the FIFO head…"), which violates the project's style guide (AGENTS.md): comments should only explain *why*, not *what*, and should be kept concise. The same verbosity appears in the test comment (`// F6: a coordination-lock cache must not FIFO-evict…`) and in the two-line comment added to `lifecycle.rs`. A single "why" sentence at the call-site is sufficient; the mechanics are evident from the code.

```suggestion
                // With a guard, skip entries the guard disallows; if none are evictable,
                // overshoot capacity until the held entries are released.
                let victim = match evict_guard {
                    None => self.order.iter().next().map(|(&seq, k)| (seq, k.clone())),
                    Some(guard) => self
                        .order
                        .iter()
                        .find(|(_, k)| self.map.get(*k).is_none_or(|e| guard(&e.value)))
                        .map(|(&seq, k)| (seq, k.clone())),
                };
```

### Issue 2 of 2
src/client/lifecycle.rs:168-170
`chat_lanes` stores a `ChatLane` which itself contains `enqueue_lock: Arc<async_lock::Mutex<()>>` — structurally identical to the `session_locks` value type. If a task holds a clone of a `ChatLane`'s `enqueue_lock` across an `.await` and that lane is FIFO-evicted, the next message for the same chat mints a fresh `ChatLane` and bypasses the serialisation lock, the same race this PR fixes for `session_locks`. Was `chat_lanes` intentionally excluded from the evict guard (e.g. covered by a separate PR), or should it receive the same guard?

Reviews (1): Last reviewed commit: "fix(cache): don't FIFO-evict strongly-he..." | Re-trigger Greptile

Comment thread src/portable_cache.rs
Comment on lines +100 to +107
let victim = match evict_guard {
None => self.order.iter().next().map(|(&seq, k)| (seq, k.clone())),
Some(guard) => self
.order
.iter()
.find(|(_, k)| self.map.get(*k).is_none_or(|e| guard(&e.value)))
.map(|(&seq, k)| (seq, k.clone())),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The 6-line block comment inside insert_new narrates the algorithm step-by-step ("Pick the oldest entry…", "Without a guard that is simply the FIFO head…"), which violates the project's style guide (AGENTS.md): comments should only explain why, not what, and should be kept concise. The same verbosity appears in the test comment (// F6: a coordination-lock cache must not FIFO-evict…) and in the two-line comment added to lifecycle.rs. A single "why" sentence at the call-site is sufficient; the mechanics are evident from the code.

Suggested change
let victim = match evict_guard {
None => self.order.iter().next().map(|(&seq, k)| (seq, k.clone())),
Some(guard) => self
.order
.iter()
.find(|(_, k)| self.map.get(*k).is_none_or(|e| guard(&e.value)))
.map(|(&seq, k)| (seq, k.clone())),
};
// With a guard, skip entries the guard disallows; if none are evictable,
// overshoot capacity until the held entries are released.
let victim = match evict_guard {
None => self.order.iter().next().map(|(&seq, k)| (seq, k.clone())),
Some(guard) => self
.order
.iter()
.find(|(_, k)| self.map.get(*k).is_none_or(|e| guard(&e.value)))
.map(|(&seq, k)| (seq, k.clone())),
};

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/portable_cache.rs
Line: 100-107

Comment:
The 6-line block comment inside `insert_new` narrates the algorithm step-by-step ("Pick the oldest entry…", "Without a guard that is simply the FIFO head…"), which violates the project's style guide (AGENTS.md): comments should only explain *why*, not *what*, and should be kept concise. The same verbosity appears in the test comment (`// F6: a coordination-lock cache must not FIFO-evict…`) and in the two-line comment added to `lifecycle.rs`. A single "why" sentence at the call-site is sufficient; the mechanics are evident from the code.

```suggestion
                // With a guard, skip entries the guard disallows; if none are evictable,
                // overshoot capacity until the held entries are released.
                let victim = match evict_guard {
                    None => self.order.iter().next().map(|(&seq, k)| (seq, k.clone())),
                    Some(guard) => self
                        .order
                        .iter()
                        .find(|(_, k)| self.map.get(*k).is_none_or(|e| guard(&e.value)))
                        .map(|(&seq, k)| (seq, k.clone())),
                };
```

**Context Used:** AGENTS.md ([source](https://app.greptile.com/oxidezap/github/oxidezap/whatsapp-rust/-/custom-context?memory=26029e85-0dae-44f2-ab23-b8de43e5e9c7))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@jlucaso1
jlucaso1 deleted the claude/fix-portable-cache-evict-guard branch August 18, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants