Skip to content

fix(signal): reject cold loads that span a flush and eviction - #1229

Merged
jlucaso1 merged 2 commits into
mainfrom
claude/cold-load-cache-incarnation-rf2a9l
Aug 7, 2026
Merged

fix(signal): reject cold loads that span a flush and eviction#1229
jlucaso1 merged 2 commits into
mainfrom
claude/cold-load-cache-incarnation-rf2a9l

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Four reads in SignalStoreCache share one shape: probe under the store lock, miss, do the backend I/O outside the lock, re-lock, re-check. The re-check asks only whether an entry is there now, and that question cannot distinguish "never written" from "written, flushed, and then removed from the cache". A clean removal keeps the incarnation, so bytes read before the write decode as an exact reload and are installed as if nothing had happened.

The sequence, all four steps concurrent with one cold read:

  1. the cold read fetches the row from the backend and releases the lock;
  2. a flush commits a newer record for the same key;
  3. evict_if_needed (or teardown) drops the now-clean entry;
  4. the cold read re-acquires, finds nothing, and installs the bytes from step 1.

checkout_session is the severe one: the record it returns is what the cipher advances, so installing the older version rewinds the sender chain index to one already on the wire. That is message key and IV reuse, which agent_docs/signal_durability.md treats as the non-negotiable property.

The other three are milder but the same kind:

  • peek_session caches the stale record, and the next checkout_session gets it out of the cache — the same rewind, one step later.
  • has_session caches the record it decoded, so it feeds that same checkout. It also has the opposite direction: a probe that found no row while a session was being written and flushed behind it negative-caches Absent, and the next send fetches a pre-key bundle and replaces a live session, discarding the peer's chain.
  • get_identity caches a superseded identity key, so the next comparison does not see the peer's identity change.

Scope of the bug

This is in main, not something a recent PR introduced. The four unlocked cold reads and the incarnation-based exact-reload trust are both present in the oldest revision of signal_cache.rs in this repo's history (c27aae0, 2026-07-28), so the race has been reachable for as long as those two have coexisted. #1224 and #1226 did not create it; #1226 moved the sender-key I/O out of the lock and added exactly the guard this PR applies to the remaining four, which is how it surfaced.

Found while reviewing #1222, which widens the window (its read-pool snapshot opens before the commit rather than after the permit). #1222 depends on this PR and is untouched here.

Design

One shape per call site, not a generic helper. The guard itself is two calls on UserIndexedCacheremoval_seq() before releasing the lock, removed_since(key, since) after — plus the incarnation comparison for the stores that have one. Both primitives and the whole rationale already live in one place: the doc comments on removal_seq and removed_since. The call sites point there instead of restating it.

A shared helper would have to abstract over three different state structs behind three different mutexes, over probes that return different things (a checkout mints a token and can fail with Busy; the others return a value), over three different installed entry types, and over the fact that ByteStoreState has no incarnation at all (identities carry no counters, and their lossy reset goes through UserIndexedCache::clear, which is already an opaque removal). That is a trait over the three states plus three closures, and get_sender_key — whose decode happens outside the lock — still would not fit it. The cost of the side I dropped is real: the condition now appears at five call sites, so a future third term has to be added five times. What holds that together is the tests: each of the four has a race test that fails the moment its guard goes, verified by pointing all four back at their pre-change bodies (below).

What I did factor out is the install step, which was duplicated between the retry loop and the locked fallback: checkout_loaded_session and install_loaded_session. Those are mechanical (decode, insert, evict) and have no ordering subtlety.

One shared retry constant. SENDER_KEY_UNLOCKED_READ_ATTEMPTS becomes UNLOCKED_COLD_READ_ATTEMPTS. The bound answers the same question on every path ("how many unlocked attempts before I take the lock and read there") and the losing condition is the same event everywhere: a removal for that key landing inside the window. Keeping the sender-key name while using it in four more places would make the name lie; giving each path its own constant with the same value would suggest they can be tuned apart, which nothing in the design supports.

On how often the bounded retry runs out: reaching the locked fallback needs a removal of that exact key (or an opaque removal, or an incarnation change) inside each of two consecutive backend round-trips. Nothing in the normal flush/drain cycle removes the same address twice that fast — in the test suite the only way to reach the fallback was to force a removal on every attempt — so I have no measured frequency to report, only the shape of what it takes. The fallback is correct at any frequency; it just holds the lock across one read.

Changes

  • checkout_session, peek_session, has_session, get_identity: stamp removal_seq (and, where the store has one, the incarnation) before releasing the lock; on re-check, install only if neither moved, otherwise re-read. Backend I/O stays outside the lock — that is the point of the guard.
  • A final read under the lock after the bounded retries, which cannot be raced and so installs without a stamp.
  • checkout_loaded_session / install_loaded_session: the install step, so the retry body and the locked fallback cannot drift apart.
  • SENDER_KEY_UNLOCKED_READ_ATTEMPTS renamed to UNLOCKED_COLD_READ_ATTEMPTS; same value, now used by all five cold reads.
  • New cold_read_race_tests: the four-step race per function, the chain-index property at the level the cipher sees, the opaque-removal branch driven through the real flush + clear_after_flush, the removal-window boundary, retry exhaustion into the locked fallback, and the two happy cases (unraced install serves later reads; a racer that wins keeps its value).
  • Chaos state machine: new ColdDmReadAcrossFlush action that holds a cold session read open across a commit, a flush and the removal after it, then sends with whatever the read returned — so the existing published_dm invariant covers this race instead of a cache-level assertion.

Cost

Temporary divan bench, both arms in one binary (the pre-change bodies kept as twins next to the new ones), taskset -c 2, 3 rounds, medians in ns, cold miss = no row on the backend so the shape runs without a decode. "full" = removal window at its 64-entry worst case for the removed_since scan. The bench is not part of this PR.

path before after delta
checkout_session 646 671 +25 (+4%)
checkout_session (full window) 641 671 +30 (+5%)
peek_session 409 441 +32 (+8%)
peek_session (full window) 406 446 +40 (+10%)
has_session 400 448 +48 (+12%)
has_session (full window) 402 458 +56 (+14%)
get_identity 401 453 +52 (+13%)
get_identity (full window) 402 449 +47 (+12%)

Smaller than the equivalent measured in #1226 (307 → 355 ns on get_sender_key), and for a different reason: these four already took the mutex twice on main, so the guard adds a stamp read and the window check, not an acquisition. A full window costs nothing measurable over an empty one — the scan stops at the sequence comparison.

Warm hits, same runs: peek_session 88.4 → 87.9, has_session 78.3 → 77.8, get_identity 90.2 → 91.3. All inside the run-to-run spread (±3 ns), which matches the code: a warm hit returns from the first probe, before the stamp is taken, so it is still a single acquisition.

Validation

cargo fmt --all
cargo test -p wacore --lib                              # 1368 passed
cargo test -p whatsapp-rust --lib                       # 1385 passed
cargo test -p wacore --lib signal_durability_chaos_smoke # passed
cargo clippy -p wacore --all-targets -- -D warnings      # clean

The new tests were confirmed to fail on the previous behavior: pointing the four public entry points at their pre-change bodies and re-running gives 8 failures out of 11 — a_cold_checkout_does_not_install_a_record_that_predates_a_flush (index 5 where 40 was published), a_checkout_after_the_race_never_rewinds_the_chain, the peek, both probe directions, the identity read, the clear_after_flush case, and the window boundary — while the three happy tests still pass. The retry-exhaustion test does not fail there, it hangs: without the guard the read installs on its first attempt and never takes the second one the test drives.

The chaos extension fails on the previous behavior too, with the invariant that matters rather than a cache assertion:

seed=0x51a6da7ab1e50001 step=25 action=ColdDmReadAcrossFlush:
raced cold read resumed a DM chain at published counter 0

No existing test needed adapting. Full matrix left to CI; e2e not run (no mock server here).


Generated by Claude Code

checkout_session, peek_session, has_session and get_identity read the
backend outside the store lock and then re-check only whether an entry
exists. That cannot separate "never written" from "written, flushed and
removed": a clean removal keeps the incarnation, so bytes read before
the write are adopted as an exact reload.

In checkout_session the record goes straight to the cipher, so the chain
index rewinds to one already published, which repeats a message key and
IV. peek_session and has_session cache the same stale record for the
checkout that follows, and a probe that missed a row written behind it
negative-caches a live session into a pre-key recovery. get_identity
caches a superseded identity key, hiding the peer's change.

Stamp the removal sequence before releasing the lock and re-check it
with removed_since, as get_sender_key already does: re-read while the
stamp says the key may have moved, then fall back to a read under the
lock, which cannot be raced. The retry bound is now one constant shared
by all five cold reads.

The chaos state machine gains an action that holds a cold session read
open across a commit, a flush and the removal after it, then sends with
whatever comes back, so the published-key invariant covers this race.
@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Greptile Summary

The PR prevents cold Signal-store reads from installing backend data that became stale across a concurrent flush and cache removal.

  • Adds removal-sequence and incarnation validation to session checkout, peek, existence, and identity cold reads.
  • Bounds unlocked retries and falls back to a backend read under the corresponding store lock.
  • Adds focused race tests and extends the durability chaos harness to verify that a raced session read cannot reuse a published DM key/IV.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
wacore/src/store/signal_cache.rs Adds bounded, removal-aware cold-read retries and locked fallbacks for sessions and identities, with comprehensive race coverage and no blocking defect identified.
wacore/src/store/signal_cache_durability_chaos.rs Extends the durability state machine with a cold-read-across-flush action that exercises the real sender-chain key/IV uniqueness invariant.

Sequence Diagram

sequenceDiagram
  participant R as Cold reader
  participant C as SignalStoreCache
  participant B as Backend
  participant W as Concurrent writer

  R->>C: Probe key and capture removal/incarnation stamp
  C-->>R: Cache miss
  R->>B: Read outside cache lock
  W->>C: Write newer record
  W->>B: Flush newer record
  W->>C: Remove clean cache entry
  B-->>R: Return pre-flush bytes
  R->>C: Re-lock and validate stamp
  alt Removal or incarnation changed
    C-->>R: Reject stale result and retry
  else No intervening invalidation
    C->>C: Decode and install result
    C-->>R: Return current value
  end
Loading

Reviews (2): Last reviewed commit: "fix(signal): spell out the peek probe's ..." | Re-trigger Greptile

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when session, identity, and sender-key data changes during concurrent reads or cache updates.
    • Prevented stale records from reappearing after removals or newer writes.
    • Improved fallback behavior when cached data is unavailable, reducing incorrect or missing results.
  • Tests

    • Added coverage for concurrent updates, durable removals, cache consistency, and recovery scenarios.
    • Added stress testing for reads spanning writes, flushes, and cache eviction.

Walkthrough

The cache now validates cold reads against concurrent writes, removals, and incarnation changes. Session, identity, and sender-key paths share retry and locked fallback behavior. New tests and a chaos scenario cover reads across flushes and cache eviction.

Changes

Cold-read race handling

Layer / File(s) Summary
Cold-read retry and installation
wacore/src/store/signal_cache.rs
Session checkout, peek, existence checks, identity loads, and sender-key reads retry invalidated backend reads. Repeated races use locked fallback reads. Shared helpers install decoded records without replacing newer cache state.
Race and fallback validation
wacore/src/store/signal_cache.rs
Tests cover concurrent writes, clean and opaque removals, negative-cache prevention, removal-window expiry, uncontended cache population, and locked fallback behavior.
Cold DM read chaos scenario
wacore/src/store/signal_cache_durability_chaos.rs
The chaos harness adds a gated cold session read across an update, flush, removal, and release. The scenario validates resumed DM sends, checkout commits, and duplicate key publication prevention.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the fix for cold reads that span flush and cache eviction.
Description check ✅ Passed The description directly explains the race, implementation, tests, chaos coverage, benchmarks, and validation for the changes.
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/cold-load-cache-incarnation-rf2a9l

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.

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wacore/src/store/signal_cache_durability_chaos.rs`:
- Around line 708-720: Bound the concurrent read/write operation in the chaos
action around tokio::join! so a missed cold read cannot hang indefinitely; on
timeout, fail with a clear diagnostic indicating that
GatedSessionRead::wait_for_read was not released. Preserve the existing cache
update, flush, cleanup, and release behavior on successful completion.

In `@wacore/src/store/signal_cache.rs`:
- Line 1109: Update the documentation comment for checkout_loaded_session so it
has a complete sentence matching the twin wording: describe decoding what a cold
read fetched and caching it.
- Around line 1158-1163: Update the match in the cache lookup around
SessionEntry::Present to explicitly handle SessionEntry::Absent and
SessionEntry::CheckedOut { .. } instead of using _. Keep the existing
Ok(Some(record.clone())) and Ok(None) results, matching the exhaustive variant
handling used at the other SessionEntry sites.
🪄 Autofix

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: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4ba47715-db79-4eaf-934c-1806fd542e7a

📥 Commits

Reviewing files that changed from the base of the PR and between 534054f and 1baedc3.

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

Comment thread wacore/src/store/signal_cache_durability_chaos.rs
Comment thread wacore/src/store/signal_cache.rs Outdated
Comment thread wacore/src/store/signal_cache.rs
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 9.99 MiB 9.99 MiB +4.88 KiB (+0.05%) 🔺
bin .text 8.00 MiB 8.01 MiB +4.50 KiB (+0.05%) 🔺
bin allocated (text+data+bss) 9.99 MiB 9.99 MiB +8.02 KiB (+0.08%) 🔺
llvm-lines wacore 515,449 515,594 +145 (+0.03%) 🔺
llvm-lines wacore copies 16,831 16,833 +2 (+0.01%) 🔺
llvm-lines whatsapp-rust lib 735,964 736,959 +995 (+0.14%) 🔺
llvm-lines whatsapp-rust lib copies 23,150 23,155 +5 (+0.02%) 🔺
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.82 MiB 1.82 MiB -1.02 KiB (-0.05%) 🔽
.text wacore 695.33 KiB 695.61 KiB +289 B (+0.04%) 🔺
.text wacore_binary 88.60 KiB 88.60 KiB 0
.text wacore_libsignal 173.44 KiB 178.88 KiB +5.45 KiB (+3.14%) ⚠️
.text wacore_appstate 22.35 KiB 22.35 KiB 0
.text wacore_noise 20.94 KiB 20.94 KiB 0
.text waproto 1.74 MiB 1.74 MiB 0
.text whatsapp_rust_sqlite_storage 515.62 KiB 515.62 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 11.83 KiB 11.83 KiB 0
.text std 992.42 KiB 991.84 KiB -598 B (-0.06%) 🔽
.text other deps 1.90 MiB 1.90 MiB +312 B (+0.02%) 🔺
Top movers (cargo-bloat attribution)
Crate main PR Δ
wacore_libsignal 173.44 KiB 178.88 KiB +5.45 KiB (+3.14%)
whatsapp_rust 1.82 MiB 1.82 MiB -1.02 KiB (-0.05%)

Baseline: 534054f63 (latest main run) · Head: 69e49e79a · Graphs

An exhaustive match makes a new SessionEntry variant a compile error at
every probe instead of silently answering None at this one.
@greptile-apps
greptile-apps Bot dismissed their stale review August 7, 2026 16:43

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread wacore/src/store/signal_cache.rs Outdated

@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.

Caution

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

⚠️ Outside diff range comments (1)
wacore/src/store/signal_cache.rs (1)

1095-1106: 🩺 Stability & Availability | 🔵 Trivial

Bound backend latency in the locked fallback.

These fallback branches await SignalStore while holding the cache mutex. If a backend call stalls, all session, identity, or sender-key operations on this cache wait behind it. Ensure the backend implementations enforce finite timeouts, or add a timeout at this boundary. Add a stalled-backend test before relying on this fallback for availability.

Also applies to: 1183-1192, 1296-1304, 1346-1354, 1444-1458

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wacore/src/store/signal_cache.rs` around lines 1095 - 1106, Bound backend
awaits in the fallback paths that hold the cache mutex, including the flow
around lock_sessions, backend.get_session, and the analogous session, identity,
and sender-key lookups. Enforce finite backend timeouts or apply a timeout at
these boundaries while preserving existing error handling. Add a stalled-backend
test verifying operations do not remain blocked indefinitely behind the mutex.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@wacore/src/store/signal_cache.rs`:
- Around line 1095-1106: Bound backend awaits in the fallback paths that hold
the cache mutex, including the flow around lock_sessions, backend.get_session,
and the analogous session, identity, and sender-key lookups. Enforce finite
backend timeouts or apply a timeout at these boundaries while preserving
existing error handling. Add a stalled-backend test verifying operations do not
remain blocked indefinitely behind the mutex.

ℹ️ Review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 090e34bc-b888-4bab-b9b9-cab7011072a3

📥 Commits

Reviewing files that changed from the base of the PR and between 1baedc3 and b14a562.

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

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

jlucaso1 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

On the out-of-diff note about the locked fallback awaiting the backend under the store mutex: that is the shape #1226 already established for get_sender_key, and it is deliberate. The fallback only runs after every unlocked attempt lost its stamp, which needs a flush plus a removal of the same key inside each attempt's round-trip, and it is the one place where the read cannot be raced at all — which is the whole reason it is correct without a stamp. Bounding it with a timeout is a separate policy question (a session read that gives up returns "no session", and that is a pre-key recovery over a live session, i.e. worse than waiting), so it does not belong in this fix.


Generated by Claude Code

@jlucaso1
jlucaso1 merged commit 05b3c88 into main Aug 7, 2026
33 checks passed
@jlucaso1
jlucaso1 deleted the claude/cold-load-cache-incarnation-rf2a9l branch August 7, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants