perf(signal): move backend I/O and user scans out of the global cache locks - #1226
Conversation
… locks Three unrelated call sites held a process-wide lock across work that did not need it, so an operation on one chain serialized every unrelated Signal read behind it. get_sender_key held the global sender-key mutex across the backend round-trip and the record decode, unlike get_session, peek_session, has_session and get_identity, which already probe under the lock, drop it, do the I/O, then re-lock and re-check. It now follows the same shape. The re-check returns a concurrent writer's value instead of overwriting it, so a put or delete that lands during the round-trip is not clobbered by the reader that arrives late with older bytes, and the incarnation is read after the re-lock so a lossy clear mid-flight cannot make a stale record look like an exact reload. The Signal store adapters took the device RwLock on every operation only to reach &*device.backend, and held that read guard across the backend round-trip. Device::backend is set once in Device::new and never reassigned, so the guard bought nothing; because async_lock::RwLock is write-preferring, one process_command arriving mid-round-trip put every later Signal read behind it. The adapters now carry the Arc<Device> snapshot that get_device_snapshot() already returns. has_state_for_user walked every key of both the session and identity caches, bounded at 2000 entries each, while holding both global mutexes. Those maps now carry a per-user index maintained on insert. It is a superset by construction: removals leave it alone, so it can only over-report, costing one migration pass that finds nothing, and never under-report, which is the direction that would silently skip a migration.
📝 WalkthroughSummary by CodeRabbit
WalkthroughSignal adapter construction is now synchronous and persistence-manager-backed. Adapter operations use fresh device snapshots. Signal caches add indexed user tracking, memory accounting, and race-aware concurrent sender-key reads with expanded tests. ChangesSignal storage and adapter flow
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
| Filename | Overview |
|---|---|
| wacore/src/store/signal_cache.rs | Adds indexed cache bookkeeping and a bounded race-aware sender-key cold-read protocol without an eligible blocking follow-up issue. |
| src/store/signal_adapter.rs | Replaces device read guards with per-operation snapshots and retries signed-pre-key misses against a fresh snapshot. |
| src/store/persistence_manager.rs | Updates documentation to reflect that store adapters no longer require direct mutable device access. |
| src/client/adapters.rs | Makes adapter construction synchronous and passes the shared persistence manager into each adapter. |
| wacore/src/store/in_memory.rs | Adds a test-only gate for deterministically exercising the signed-pre-key promotion race. |
Sequence Diagram
sequenceDiagram
participant Caller
participant Cache as Sender-key cache
participant Backend
Caller->>Cache: Probe and stamp incarnation/removal sequence
Cache-->>Caller: Miss
Caller->>Backend: Load and decode without global cache lock
Backend-->>Caller: Stored record
Caller->>Cache: Re-lock and re-check
alt Racer populated or deleted slot
Cache-->>Caller: Return racer's cached result
else Key removed or incarnation changed
Caller->>Backend: Retry, then locked fallback if needed
Backend-->>Caller: Current durable record
else Slot remained untouched
Caller->>Cache: Install decoded record
end
Reviews (7): Last reviewed commit: "test(signal): cover the opaque removal b..." | Re-trigger Greptile
The index keys on the address prefix up to the first '@' or ':', which is exactly what the scan predicate matched for a plain user. An addressed-device JID renders as `user:device@server.N`, so `user:device` also prefix-matches it under that predicate while never being an index key. Answering from the set there would be the one false negative the index can produce, and a false negative silently skips a migration for a user that has state. Concede instead. Not reachable from today's callers, which pass a device-less Jid user, so this is insurance against a future caller rather than a live fix.
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fde9ad8a64
ℹ️ 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".
| let device = self.0.device.as_ref(); | ||
| WacoreSignedPreKeyStore::load_signed_prekey(device, signed_prekey_id.into()) |
There was a problem hiding this comment.
Refresh device state when loading a signed pre-key
When an adapter is constructed just before a concurrent signed-pre-key rotation promotes a new key, it permanently retains the old Device snapshot. After rotation removes the newly promoted key's staged backend row, Device::load_signed_prekey sees that key as neither current in this snapshot nor retained in storage, so decrypting a pre-key message for the new ID returns InvalidSignedPreKeyId; the previous per-call read guard observed the promoted device state. Keep live device access for this mutable signed-pre-key state or refresh the snapshot at this call.
Useful? React with 👍 / 👎.
| /// without registering its user. | ||
| struct UserIndexedCache<V> { | ||
| map: HashMap<Arc<str>, V>, | ||
| users: HashSet<Arc<str>>, |
There was a problem hiding this comment.
Count the user indexes in Signal memory estimates
Each UserIndexedCache now retains a second hash table plus separately allocated user strings, but SignalStoreCache::memory_stats() still sums only the primary map's keys and record payloads. At the configured watermarks this omits thousands of allocations across the session, identity, and sender-key stores, materially understating Client::memory_report() and obscuring cache-growth attribution; include the index's retained bytes in the corresponding collection estimates.
AGENTS.md reference: AGENTS.md:L59-L59
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 4119696 — memory_stats now adds users_bytes() into each of the three stores' totals.
Generated by Claude Code
| // Backend I/O outside the lock | ||
| let backend_result = backend.get_sender_key(key).await?; |
There was a problem hiding this comment.
Detect clean eviction before installing a sender-key read
If a cold direct sender-key probe overlaps a same-chain write that is flushed and then removed by capacity eviction or clear_after_flush(), this unlocked backend read can capture the old row while the later re-check finds no cache entry and installs those stale bytes. Clean eviction preserves the cache incarnation, so the obsolete record is treated as an exact reload rather than fast-forwarded; a subsequent group encryption can therefore resume an already-published iteration and reuse sender-key material. Preserve a per-key generation across removal or otherwise reject reads that began before the intervening write.
AGENTS.md reference: AGENTS.md:L57-L57
Useful? React with 👍 / 👎.
| // installs a new one, and decoding under the pre-clear incarnation | ||
| // would claim an exact reload for counters that may already be on the | ||
| // wire instead of burning to the stored reservation ceiling. | ||
| let record = match backend_result { |
There was a problem hiding this comment.
Move sender-key deserialization outside the global mutex
After the backend round-trip finishes, this code reacquires sender_keys before calling SenderKeyRecord::deserialize_for_store, so a cold record containing up to MAX_MESSAGE_KEYS (2000) skipped keys still blocks every unrelated sender-key read and write while it is parsed and allocated. This leaves the CPU-heavy half of the contention targeted by the change under the process-wide lock; snapshot the incarnation or a generation, decode without the guard, then re-lock and validate before installing the result.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
1 issue found and verified against the latest diff
Confidence score: 4/5
- In
wacore/src/store/signal_cache.rs, the user-ID split logic inhas_state_for_usercan mis-parse LID addresses containing:, so entries like111:5@lid.0miss the cache and repeatedly hit the backend; this risks unnecessary load and latency for affected users — update the parsing to preserve the full local part (split only at@, or otherwise handle:safely) and add a regression test for LID-formatted IDs.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="wacore/src/store/signal_cache.rs">
<violation number="1" location="wacore/src/store/signal_cache.rs:100">
P2: LID users can contain `:`, but this split truncates them before `@`, so cached state for addresses such as `111:5@lid.0` is not recognized by `has_state_for_user` and every check falls through to the backend. Splitting at `@` first, and treating `:` as a delimiter only when no `@` is present, preserves the existing prefix-matching semantics.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| match address.find(['@', ':']) { | ||
| Some(end) => &address[..end], | ||
| None => address, | ||
| } |
There was a problem hiding this comment.
P2: LID users can contain :, but this split truncates them before @, so cached state for addresses such as 111:5@lid.0 is not recognized by has_state_for_user and every check falls through to the backend. Splitting at @ first, and treating : as a delimiter only when no @ is present, preserves the existing prefix-matching semantics.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At wacore/src/store/signal_cache.rs, line 100:
<comment>LID users can contain `:`, but this split truncates them before `@`, so cached state for addresses such as `111:5@lid.0` is not recognized by `has_state_for_user` and every check falls through to the backend. Splitting at `@` first, and treating `:` as a delimiter only when no `@` is present, preserves the existing prefix-matching semantics.</comment>
<file context>
@@ -84,6 +94,112 @@ fn protocol_address_matches_user(address: &str, user: &str) -> bool {
+/// The user half of a protocol address, matching the prefix
+/// [`protocol_address_matches_user`] tests.
+fn user_of_protocol_address(address: &str) -> &str {
+ match address.find(['@', ':']) {
+ Some(end) => &address[..end],
+ None => address,
</file context>
| match address.find(['@', ':']) { | |
| Some(end) => &address[..end], | |
| None => address, | |
| } | |
| if let Some(end) = address.find('@') { | |
| &address[..end] | |
| } else { | |
| address.split(':').next().unwrap_or(address) | |
| } |
There was a problem hiding this comment.
Not taking this one — splitting at @ first would introduce the false negative it's trying to avoid.
For 111:5@lid.0, the existing scan predicate matches both 111 and 111:5: strip_prefix("111") leaves :5@lid.0, which starts with :. And 111 is what the real callers pass — migrate_signal_sessions_on_lid_discovery gets a device-less Jid user. With the suggested split the key becomes 111:5, so a query for 111 misses the index and reports no state for a user that has it, which silently skips the migration.
The current split (first @ or :) is what keeps the key equal to the query on that hot path. The 111:5 form is covered by the sibling comment on has_user, which I did take: the query is now normalized through the same function, so 111:5 and 111 collapse to one key and neither falls through to the backend. Test is the_user_index_never_misses_state_across_the_mutation_paths, which asserts the scan predicate really does match that address shape.
Generated by Claude Code
📦 Binary size report
.text per crate
Top movers (cargo-bloat attribution)
Baseline: |
…cked reads opened Reviewers found two real holes in the previous two commits, both created by dropping a lock that had been hiding them. A cold sender-key read that finds the slot absent, releases the mutex, and finds it absent again cannot distinguish "never written" from "written, flushed, and then dropped by a clean eviction or clear_after_flush". A clean removal keeps the cache incarnation, so the bytes read before that write would deserialize as a trusted exact reload rather than fast-forwarding to the stored reservation ceiling, letting the chain resume an iteration that has already been published. The map now carries a removal epoch, and an install requires both it and the incarnation to be unchanged across the round-trip; a read that loses twice falls back to reading under the lock, which cannot race at all. Decoding also moves outside the mutex now that the incarnation is captured rather than read after the re-lock, so a chain carrying a full skipped-key backlog no longer parses under the global lock. The Signal adapters pinned one device snapshot for their whole lifetime. That is wrong for signed pre-keys: rotation promotes the new id into the device field and then deletes its staged backend row, so an adapter built before the promotion resolves neither, and a pre-key message naming the new id fails with InvalidSignedPreKeyId where the old read guard would have seen the promoted state. The adapters now hold the persistence manager and snapshot per call, which is still lock-free and strictly fresher than the guard was. Also narrows the user-index concession for a separator-carrying query. A matching address begins with the query, so a separator inside it is the address's first one too, and both forms normalize to the same key; conceding the whole separator space was unnecessary and skipped the backend probe. Counts the index in memory_stats, which was reporting only the primary map.
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 41196969bc
ℹ️ 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".
| let device = self.0.device(); | ||
| WacoreSignedPreKeyStore::load_signed_prekey(device.as_ref(), signed_prekey_id.into()) |
There was a problem hiding this comment.
Retry signed pre-key lookup after concurrent rotation
When an inbound decrypt snapshots the old device immediately before rotate_signed_pre_key_inner promotes new_id, the rotation can complete and remove the staged row at src/features/rotate_key.rs:259-276 before this lookup reaches the backend. The old snapshot then classifies new_id as non-current, the removed backend row yields None, and a valid pre-key message fails with InvalidSignedPreKeyId; the former device read guard forced rotation to wait. Although refreshing per call fixes the previously reported adapter-pinning case, the current rotation ordering is fresh evidence of this intra-call TOCTOU, so refresh/retry after a backend miss or otherwise synchronize lookup with promotion and staged-row removal.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 1
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)
1171-1226: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy liftThe removal epoch is cache-wide, so the "bounded races" argument does not hold under load — and the fallback puts backend I/O back under the global mutex.
Look, the race handling itself is right. Epoch plus incarnation catches the put-flush-evict shape, and the tests prove it. No argument there. My problem is the bound.
removal_epochis one counter for the entire sender-key cache. It is bumped by any removal of any key. Line 78-80 justifiesSENDER_KEY_UNLOCKED_READ_ATTEMPTS = 2on the claim that losing twice "needs a flush plus an eviction each time." That reasoning assumes the epoch tracks this chain. It does not.evict_if_neededruns on everyput_sender_key, everydelete_sender_key_durable, and every flush. Once the cache sits above the high watermark, unrelated group traffic bumps the epoch continuously. A cold reader for one chain then loses both attempts to churn it has nothing to do with, and falls through.Now look at where it falls through to. Line 1212-1224 takes the mutex and then awaits
backend.get_sender_key(key)and runsdeserialize_for_storewhile still holding it. That is the process-wide sender-key lock held across disk I/O plus a MAX_MESSAGE_KEYS parse. This whole PR exists to stop doing that. Under a busy cache it starts doing it again, and it starts doing it exactly when the cache is hottest — which is the worst possible time.Two things need to happen. Scope the removal signal to the key so unrelated churn stops invalidating good reads. And stop deserializing under the lock in the fallback.
♻️ Narrow the fallback so decode does not run under the lock
// Repeatedly raced. Read under the lock, which cannot be raced at all. let mut state = self.sender_keys.lock().await; if let Some(cached) = state.cache.get(key) { return Ok(cached.clone()); } - let record = match backend.get_sender_key(key).await? { - Some(bytes) => Some(Arc::new(SenderKeyRecord::deserialize_for_store( - &bytes, - &state.incarnation, - )?)), - None => None, - }; + let incarnation = state.incarnation; + let bytes = backend.get_sender_key(key).await?; + // Decoding a cold chain walks up to MAX_MESSAGE_KEYS skipped keys. + // Holding the store mutex across that stalls every unrelated chain. + let record = match bytes { + Some(bytes) => Some(Arc::new(SenderKeyRecord::deserialize_for_store( + &bytes, + &incarnation, + )?)), + None => None, + }; state.cache.insert(Arc::from(key), record.clone());For the epoch scoping, a per-key removal counter (or a small
HashMap<Arc<str>, u64>of removal generations consulted only on the install path) keeps the two-attempt bound honest. Want me to draft that?🤖 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 1171 - 1226, Scope removal-generation tracking to the requested sender key instead of using the cache-wide removal_epoch, updating the relevant removal paths and install validation around the sender-key cache flow so unrelated evictions do not consume SENDER_KEY_UNLOCKED_READ_ATTEMPTS. Refactor the retry fallback after the bounded loop to fetch and deserialize the backend record without holding sender_keys, then reacquire the lock only to recheck the cache and install the result using the same incarnation/key-generation validation.
🤖 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.rs`:
- Around line 2149-2206: Add a regression test for the locked fallback path in
the existing signal-cache race tests, using the gated backend fixture with
gated_reads set to 2. Coordinate both read windows so the cache epoch changes
during each attempt, forcing execution through the fallback around the locked
lookup path, then assert the returned and subsequently cached sender-key record
remains the latest expected value.
---
Outside diff comments:
In `@wacore/src/store/signal_cache.rs`:
- Around line 1171-1226: Scope removal-generation tracking to the requested
sender key instead of using the cache-wide removal_epoch, updating the relevant
removal paths and install validation around the sender-key cache flow so
unrelated evictions do not consume SENDER_KEY_UNLOCKED_READ_ATTEMPTS. Refactor
the retry fallback after the bounded loop to fetch and deserialize the backend
record without holding sender_keys, then reacquire the lock only to recheck the
cache and install the result using the same incarnation/key-generation
validation.
🪄 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: 3b95db21-ad3f-4d3b-8104-8a3abe32610b
📒 Files selected for processing (13)
src/client/adapters.rssrc/client/device_registry.rssrc/client/sessions.rssrc/features/signal.rssrc/message/receive.rssrc/message/tests.rssrc/retry.rssrc/send/mod.rssrc/store/persistence_manager.rssrc/store/signal_adapter.rssrc/test_utils.rssrc/voip/facade.rswacore/src/store/signal_cache.rs
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…ecode errors Two reviewers independently flagged that the removal epoch guarding the unlocked sender-key read was a single cache-wide counter, so a removal on any chain invalidated an unrelated reader's install. The claim that this fires constantly is overstated, since eviction early-returns below the high watermark and so bumps roughly once per slack inserts rather than once per put, but the objection stands: unrelated churn should cost a reader nothing, and as written a busy cache could push cold reads onto the locked fallback, which is the contention this change exists to remove. The signal is now per chain. A cold read registers itself under its key, any removal of that key marks it, and the reader consults its own mark on install. Both maps are bounded by concurrent cold misses rather than by cache size. A clear or a retain, which cannot report which keys went, concedes every in-flight read. Overlapping readers of one key all observe the mark before the last one out clears it, and an insert clears it too, so a mark stranded by a cancelled read heals instead of pinning that chain to the slow path. Moving the decode outside the lock had also made a decode error preempt the re-check, so an unreadable row could fail an operation the cache could already answer from a concurrent write. The error is now held and raised only on the path that would have installed it. Also retries the signed pre-key lookup against a fresh snapshot after a miss. Rotation promotes the new id into the device field and only then drops its staged row, so a snapshot taken just before the promotion resolves it in neither place, and a pre-key message naming it would be rejected. This window is intra-call, so per-call snapshotting alone does not close it.
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 47db86d145
ℹ️ 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".
| if let Some(cached) = state.cache.get(key) { | ||
| return Ok(cached.clone()); | ||
| } | ||
| state.cache.begin_cold_read(key); |
There was a problem hiding this comment.
Clean up cold-read bookkeeping when reads are cancelled
When a get_sender_key future is dropped while awaiting the backend, this increment is never paired with finish_cold_read, leaving the key permanently retained in cold_reads; even clear() preserves that tracking map. Repeated cancellation of unique cold lookups therefore grows the cache without bound, and after a later removal the stranded count also keeps the key marked invalid so every subsequent lookup retries and falls back to backend I/O under the global sender-key mutex. Use cancellation-safe/RAII bookkeeping or otherwise remove registrations when the future is dropped.
AGENTS.md reference: AGENTS.md:L59-L59
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Right, and the fix I'd already made only covered half of it — an insert cleared the stranded mark, but nothing reclaimed the cold_reads entry itself, and you're correct that clear() deliberately preserves that map.
Rather than add RAII around an async mutex, I dropped the per-reader registration entirely in f0e22c7. There's now a fixed window of the most recent removals plus a sequence number: a reader stamps the sequence before releasing the lock and asks removed_since(key, stamp). No per-reader state exists, so a dropped future leaves nothing to reclaim, and the bookkeeping is bounded by the window rather than by reader behavior. It stays per key, so the unrelated-churn property survives.
Two cases answer conservatively — a removal that can't name its keys (clear, retain), and a reader older than the retained window. Both say "removed", which costs a re-read rather than admitting bytes that predate a write.
a_cancelled_cold_read_leaves_no_bookkeeping aborts a reader parked inside the backend and asserts the next read of that chain installs on its first attempt.
Generated by Claude Code
The per-key tracking registered each cold read under its key and unregistered it on install, so a future dropped while awaiting the backend never unregistered. That leaked an entry per cancelled read of a distinct chain, and once a removal marked such a key the stranded registration kept the mark alive, pinning that chain to the retry-then-locked path. Replaced with a fixed window of the most recent removals plus a sequence number. A reader stamps the sequence before releasing the lock and asks whether its own key was removed after that stamp. There is no per-reader state at all, so cancellation leaves nothing behind and the bookkeeping is bounded by the window rather than by reader behavior. It stays per key, so unrelated churn still costs a reader nothing. Two cases answer conservatively: a removal that cannot name its keys (clear, retain) and a reader older than the retained window. Both report "removed", which costs a re-read rather than admitting bytes that predate a write.
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0e22c7156
ℹ️ 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".
| if cache.users_len() > high_watermark(max_entries) { | ||
| cache.compact_users(); | ||
| } |
There was a problem hiding this comment.
Compact only after the user index has drifted
When more than the high watermark of distinct users remain live because their entries are dirty or checked out—for example, while backend flushes keep failing—compact_users() cannot reduce users_len() below this threshold. Consequently, every subsequent cache update performs a full O(n) rebuild and reallocates every indexed user while holding the global store mutex. Require the index to be larger than the live map as well as over the watermark before compacting.
Useful? React with 👍 / 👎.
| let mut record = WacoreSignedPreKeyStore::load_signed_prekey(self.0.device().as_ref(), id) | ||
| .await | ||
| .map_err(signal_err("backend"))? | ||
| .map_err(signal_err("backend"))?; |
There was a problem hiding this comment.
Protect retained signed pre-keys from concurrent pruning
When this requests the oldest retained signed pre-key while another task rotates, the lookup can take its snapshot and then be overtaken by promotion and the pruning at src/features/rotate_key.rs:279-289; its backend read then returns None, and the retry also fails because the requested ID is neither the new current ID nor retained anymore. The retained-key pruning path is fresh evidence not covered by the earlier promoted-key retry: the previous device read guard prevented a rotation that started later from pruning the row before this lookup completed, so a valid in-flight pre-key message can now fail with InvalidSignedPreKeyId; synchronize retained-key lookup with pruning or preserve the selected record through the rotation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The mechanism checks out — rotation takes the device write lock in process_command before it prunes, so the old read guard did incidentally serialize this, and the retry can't help because a pruned row is gone rather than relocated.
I'm not fixing it in this PR, and I'd rather say why than paper over it.
There's no cheap correct fix. Once remove_signed_prekey lands, the bytes don't exist anywhere, so no amount of re-reading recovers them. Closing it properly means serializing signed-prekey lookups against rotation — either reinstating the coupling item 2 exists to remove, or introducing a dedicated rotation lock. That's a design change with its own ordering questions against the flush and the staged-row delete, and it deserves its own PR rather than being bolted onto one that has already turned over four times.
The impact is bounded in a specific way: the only id at risk is the one crossing out of the retention window at that instant (SIGNED_PRE_KEY_RETENTION is 3, so current plus two rotated-out). A pre-key message naming that id is already at the boundary and would fail on the next rotation regardless — the race costs it one rotation interval of remaining life, and the recovery path is the same one that already handles an expired retained key.
Added to ## Checked and not changed with this reasoning so it stays visible instead of being rediscovered.
The sibling findings in this round I did take, both in 4858351: compaction now also requires the index to exceed the live key count, so a store full of dirty entries stops rebuilding on every update; and memory_stats now covers the removal window.
Generated by Claude Code
…unt for the removal window Compaction fired on the watermark alone, but a rebuild lands at the number of distinct users in the map, which cannot go below the watermark when that many are live. A store holding more distinct users than the watermark in entries eviction cannot trim, as a run of failing flushes produces, therefore rebuilt the whole index on every update while holding the global mutex. Requiring the index to also exceed the live key count makes the rebuild self-limiting: it is false immediately afterwards and becomes true again only on real drift. memory_stats also missed the removal window, whose keys outlive the entries they name and are owned solely there. Folded into the same overhead figure as the user index so the report covers everything retained beside the map.
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
There was a problem hiding this comment.
Actionable comments posted: 1
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)
2224-2274: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winThe opaque removal path has no concurrency test, and that is the branch where a mistake corrupts a chain.
Every race test here drives removals through
drop_clean_sender_key_for_test, which callsUserIndexedCache::remove— the keyed path. Nothing exercisesnote_opaque_removal, soretainat Line 264 andclearat Line 273 are uncovered against a concurrent cold reader.Think about which direction the bug runs. The opaque check at Line 179 over-reports, so a bug there just costs re-reads. But if
retainever stopped bumpingopaque_removal_seq— someone adds an early return, someone reorders the length comparison — a stale reader's pre-write bytes get installed and trusted as an exact reload. That resumes an already-published chain iteration. Same corruption this whole mechanism exists to prevent, and the test suite would stay green.Model it the way
a_write_dropped_by_eviction_is_not_replaced_by_the_stale_readalready does, but drive the removal through the realclear_after_flushpath instead of the keyed test helper, then assert the stale bytes still do not land.Want me to write it?
🤖 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 2224 - 2274, Add a concurrency regression test alongside a_write_dropped_by_eviction_is_not_replaced_by_the_stale_read that races a cold get_sender_key reader with clear_after_flush, exercising the opaque removal path through retain and clear rather than drop_clean_sender_key_for_test. Use the existing gated backend and newer-chain setup, then assert the reader and subsequent cache load return the post-removal record, confirming stale pre-write bytes are not installed.
🤖 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 `@src/store/signal_adapter.rs`:
- Around line 582-590: Rework the test around SignalProtocolStoreAdapter::new so
the adapter is created before promotion and its first signed-pre-key load is
deliberately held on the pre-promotion snapshot. Use the existing gated-backend
test pattern from signal_cache.rs to park the initial load, perform promotion,
release the load, and then verify the retry resolves the promoted ID; remove the
currently unexercised retry block.
---
Outside diff comments:
In `@wacore/src/store/signal_cache.rs`:
- Around line 2224-2274: Add a concurrency regression test alongside
a_write_dropped_by_eviction_is_not_replaced_by_the_stale_read that races a cold
get_sender_key reader with clear_after_flush, exercising the opaque removal path
through retain and clear rather than drop_clean_sender_key_for_test. Use the
existing gated backend and newer-chain setup, then assert the reader and
subsequent cache load return the post-removal record, confirming stale pre-write
bytes are not installed.
🪄 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: e233a9d0-baea-4222-b561-bee20a089764
📒 Files selected for processing (2)
src/store/signal_adapter.rswacore/src/store/signal_cache.rs
There was a problem hiding this comment.
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: The PR restructures core cache locking (probe/drop/re-check with removal window, per-call device snapshots) and introduces a new UserIndexedCache with concurrency-sensitive removal tracking. These are subtle architectural tradeoffs requiring human review to verify correctness in all interleavings.
Re-trigger cubic
…retry Both mechanisms shipped guarded only by tests that could not fail if they regressed. Every sender-key race test drove removals through the keyed path, so `clear` and `retain` were uncovered. Those cannot name the keys they drop and take a separate branch that concedes every in-flight reader; if that bump were ever lost, a stale reader's pre-write bytes would be installed and trusted as an exact reload. The new test drives the real flush-then-clear_after_flush sequence, which also required the fake backend to accept writes so a flush lands where a later read samples it. The signed pre-key test asserted the hazard but not the fix: it built the adapter after the promotion, so both snapshots were already fresh and deleting the retry left it green. Covering it needs the promotion to land inside the call, between the first snapshot and its backend lookup, so InMemoryBackend gains a read gate alongside its existing test hooks and the test parks the first lookup there. Both were verified by breaking what they guard: dropping the opaque sequence bump fails the first, removing the retry fails the second.
Dismissed because a newer commit was pushed; Greptile will re-review the current head.
There was a problem hiding this comment.
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)
2064-2064: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winUse the required fictitious NANP format.
The new
1999555...JIDs use NPA999. Several line numbers also fall outside0100through0199. Replace these fixtures with1+ a real NPA +555+ a line number in the reserved range, such as12025550119. Restrict the churn loop to that line-number range.As per coding guidelines, test data must not contain real PII. Based on learnings, fictional NANP numbers must use a real NPA, the
555exchange, and line numbers from0100through0199.Also applies to: 2099-2100, 2146-2147, 2190-2191, 2239-2240, 2297-2298, 2343-2344, 2390-2393, 2442-2443, 2490-2640
🤖 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` at line 2064, Update the SenderKeyName test fixtures and related churn-loop values around SenderKeyName::from_parts to use fictional NANP numbers with a real NPA, the 555 exchange, and line numbers only from 0100 through 0199 (for example, 12025550119); replace every listed fixture consistently and constrain the loop to that reserved line-number range.Sources: Coding guidelines, Learnings
🤖 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`:
- Line 2064: Update the SenderKeyName test fixtures and related churn-loop
values around SenderKeyName::from_parts to use fictional NANP numbers with a
real NPA, the 555 exchange, and line numbers only from 0100 through 0199 (for
example, 12025550119); replace every listed fixture consistently and constrain
the loop to that reserved line-number range.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: f7277e08-ce50-4698-9da3-62111172dc02
📒 Files selected for processing (3)
src/store/signal_adapter.rswacore/src/store/in_memory.rswacore/src/store/signal_cache.rs
There was a problem hiding this comment.
0 issues found across 3 files (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: Sender-key cache locking becomes a release/re-check protocol with a removal-window sequence; PR acknowledges a signed-pre-key pruning race remains. The truncated diff cannot establish correctness, so this concurrency/security tradeoff needs sign-off.
Re-trigger cubic
Summary
Three call sites held a process-wide lock across work that did not need it, so an operation on one chain or one address serialized every unrelated Signal read behind it.
get_sender_keyheld the global sender-key mutex across the backend round-trip and the record decode. The other four cold-read paths in the same file (checkout_session,peek_session,has_session,get_identity) already probe under the lock, drop it, do the I/O, then re-lock and re-check;delete_sender_key_durablestates the intent outright ("unrelated chains must not queue behind backend latency on the global cache mutex"). This was the one that didn't.Correctness here does not rest on the per-chain
sender_key_lock, and I want to flag that explicitly because it's tempting to assume it does. The audit note I started from claimed the callers always hold that lock, citing the documented precondition ongroup_cipher'sgroup_encrypt/group_decrypt/process_sender_key_distribution_message. That precondition is real for theload_sender_keytrait path, but several production callers reach the cache directly without it —SignalFeature::has_sender_key(src/features/signal.rs) takes no chain lock at all, and theforce_skdmprobe insrc/send/mod.rsholds onlygroup_distribution_lock, which is per-group, not per-chain. So the safety argument has to be the re-check protocol itself.Getting that protocol right took several passes, and the review threads carry the reasoning.
The obvious version — re-lock, and if the slot holds anything, defer to it — handles a concurrent
putordelete(thedeletecase is the sharp one: the cache stores a tombstone asSome(None), and deferring to it is what stops a retired chain being resurrected). But it is not sufficient, because an absent slot is ambiguous. A newer record can be written, flushed, and then dropped by a capacity eviction orclear_after_flush()while the read is in flight, leaving the slot absent again. A clean removal keeps the cache incarnation, so the bytes read before that write would deserialize as a trusted exact reload rather than fast-forwarding to the stored reservation ceiling, and the chain could resume an iteration that has already been published.So the map keeps a bounded window of its most recent removals plus a sequence number. A cold read stamps the sequence before releasing the lock and, on install, asks whether its own key was removed since that stamp. A read that loses falls back to reading under the lock, which cannot be raced at all.
Two properties of that shape matter. It is per key, so churn on unrelated chains — the normal state of a cache sitting at its eviction watermark — costs a reader nothing. And it holds no per-reader state, so a future cancelled mid-backend leaves nothing to reclaim and the bookkeeping stays fixed-size regardless of reader behavior. Two cases answer conservatively: a removal that cannot name its keys (
clear,retain), and a reader older than the retained window. Both report "removed", which costs a re-read rather than admitting bytes that predate a write.Capturing the incarnation up front also lets the decode move outside the mutex, so a cold chain carrying a full skipped-key backlog no longer parses under the global lock. The decode error is held rather than raised, so an unreadable row cannot fail an operation a concurrent write already answered.
The Signal store adapters took the device
RwLockon every operation (11 sites) only to reach&*device.backend, and held that read guard across the backend round-trip.Device::backendis set once inDevice::newand never reassigned (load_from_serializablereplaces onlycore), so the guard bought nothing; becauseasync_lock::RwLockis write-preferring, a singleprocess_commandarriving mid-round-trip queued ahead of every later reader and blocked all of them.is_trusted_identityhad already been converted with this exact reasoning in its comment; the rest hadn't.has_state_for_userwalked every key of both the session and identity caches (2000 entries each) while holding both global mutexes, once per mapping inmigrate_lid_pn_batch, which deliberately runs outside the processing permit and therefore concurrently with encrypt/decrypt.Design
Item 2 — per-call snapshot, not a pinned one. I first pinned one
Arc<Device>snapshot per adapter, reasoning that the only fields read off the device rather than the backend (identity_key,registration_id) are registration-time constants. That reasoning was incomplete:signed_pre_key_idis not constant. Rotation promotes the new id into the device field and then deletes its staged backend row, so an adapter pinned before the promotion resolves the new id neither in its snapshot nor in storage, and a pre-key message naming it fails withInvalidSignedPreKeyId.So the adapters now hold
Arc<PersistenceManager>and callget_device_snapshot()per operation — the alternative design this change was weighed against at the start, with the signed-pre-key case deciding it. It is still lock-free (astdread guard held just long enough to clone anArc, never across an await) and strictly fresher than the read guard it replaces, which only ever observed state as of its own acquisition. The cost over a pinned snapshot is one atomic refcount bump per store call.Per-call snapshotting alone does not close that window, because it is also intra-call: the promotion can land between the snapshot and the backend lookup.
get_signed_pre_keytherefore re-reads the snapshot after a miss, which always resolves for the promoted key — once the staged row is gone, the device field holds it. (It does not resolve the pruned retained key case; see below.)Item 3 — conservative superset, not an exact counter. The session and identity maps are wrapped in a
UserIndexedCachenewtype owning both the map and aHashSetof users. An exact per-user counter would need every removal path to decrement, and a single missed decrement is a false negative — the unsafe direction, silently skipping a migration for a user that has state. The superset inverts that: removals leave the set alone, so its only error is a staletrue, which costs one migration pass that finds nothing (both production callers treattrueas "may have state, do the scan"). Worth stating precisely, since it is easy to get backwards: a staletrueshort-circuits before the backend probe, so the cost is a redundant migration pass, not a redundant probe.Making it a newtype rather than a convention is the point —
insertis the only way into the map, so the compiler enforces that no entry lands without registering its user, removing the missed-increment failure mode by construction.The query is normalized through the same function that derives the keys. A matching address begins with the query, so a separator inside the query is also the address's first one, and an addressed
19995551006:5and a bare19995551006collapse to one key. (I did not take the suggestion to split on@first — that would key the index on111:5and make the device-less111that real callers pass miss, which is the unsafe direction. Reasoning is in the thread.)Drift is bounded two ways:
compact_usersrebuilds from live keys once the set exceeds both the eviction high watermark and the live key count, and teardown'sclear_clean_entriescompacts unconditionally. The second condition is what makes the rebuild self-limiting — a rebuild always lands at or below the live key count, so it cannot re-fire immediately, which matters for a store holding more distinct users than the watermark in entries eviction cannot trim. After compaction the index equals the original "any cache key for this user" predicate exactly.I did not change
has_pending_pairwise_writes_for_user, despite it being in the same audit item — see below.Changes
get_sender_key: probe under lock, drop, backend I/O, decode, re-lock, re-check, install — matching the four sibling paths. A racer's value wins the re-check, and an install additionally requires that this key was not removed and the incarnation did not change, so an absent slot cannot be mistaken for "never written".get_sender_key: bounded unlocked attempts, then a fallback read under the lock, so the retry cannot spin. Decode errors are deferred past the re-check.UserIndexedCache<V>newtype backing the session, identity and sender-key maps: user index forhas_state_for_user, plus a fixed-size window of recent removals for the above, both maintained on the only mutation entry points.get_signed_pre_keyre-reads the device snapshot after a miss, closing the promotion window described above.SignalProtocolStoreAdapter::newandSenderKeyAdapter::newtakeArc<PersistenceManager>instead ofArc<RwLock<Device>>. Migration: pass thePersistenceManagerhandle where you previously passedget_device_arc().await.Client::signal_adapter()/sender_key_adapter()/signal_adapter_from()arepub(crate)and became synchronous.memory_statscounts the user index and the removal window in each store's total.InMemoryBackendgainsgate_next_signed_prekey_read, a test hook alongside its existing ones, so the rotation race can be driven deterministically.get_device_arc's doc no longer claims store adapters need it — they no longer do.Cost
Measured in-process with a temporary harness (removed before this PR; nothing bench-shaped is in the diff). Backend latency is a deterministic gate — a held
async_lock::Mutexreleased on a timer, not a wall-clock sleep. Parallelism isstd::thread+futures::executor::block_on, sowacorestays Tokio-free. Baseline and patched forms ran in the same binary as two functions, so codegen differences between builds can't masquerade as an effect.Every number is victim latency: a warm cache-hit operation needing the same mutex, timed while a slow operation holds (baseline) or does not hold (patched) it. That is the quantity these items change; a single-threaded ns/op bench cannot show it.
Three rounds, each with an unmodified control. Control p50 was 118 / 116 / 118 ns across rounds — stable, so the board is signal, not noise.
Item 1 — victim latency, warm read of an unrelated chain during a 20 ms cold miss
Baseline victim latency equals the backend latency, because that is what it is: the victim waits out the whole round-trip. Patched is independent of it. Under the baseline the concurrency tests don't merely fail, they deadlock — verified by pointing them at the old implementation.
Item 1 — cost side, uncontended cold miss (the extra lock acquisition)
A cold miss takes the mutex twice instead of once: ~50 ns, ~16%. Warm hits are unchanged at one acquisition. These numbers predate the removal window, which adds a
u64read and a scan of at most 64 entries inside critical sections already being taken — below this measurement's resolution.Item 2 — victim latency, model of a read guard held across a round-trip with one writer arriving mid-flight
RwLockguard heldArcsnapshotHonest qualifier: this is a model of the write-preferring head-of-line behavior, not an end-to-end adapter measurement — the two adapter forms can't coexist in one binary. It confirms the mechanism; it does not by itself size the end-to-end win.
Item 3 — partly refuted. Direct per-call cost is a real and stable win:
But the contention premise did not reproduce. Victim latency for a warm session read taken while the scan runs was 118 / 118 / 120 ns baseline versus 117 / 144 / 121 ns patched — inside the control's own spread, i.e. no measurable effect. A 16.7 µs scan holds each mutex too briefly, and runs too rarely (once per new mapping), for a concurrent reader to collide with it often enough to matter.
So: the O(n) → O(1) is confirmed, the "stalls concurrent encrypt/decrypt" claim is not. I kept the change because the per-call win is real, it removes a cost that scales with cache occupancy from a path running concurrently with message processing, and a large migration batch pays it per mapping (a 300-mapping batch is ~5 ms of global-mutex hold time). Anyone hoping this fixes encrypt/decrypt tail latency should not expect it to.
Where the gain does and doesn't appear: items 1 and 2 pay off when backend latency is non-trivial and concurrency is real — a cold group send fanning out over slow storage, an offline drain, a device write landing mid-round-trip. On a warm cache with fast local SQLite and no concurrency they do nothing, and item 1 costs ~50 ns per cold miss.
Caveat on methodology: a single pinned core is impossible for a contention benchmark — the victims need to run in parallel. This is a 4-vCPU container, not a hybrid P/E machine, so I pinned to a fixed set (
taskset -c 0-3) for stable affinity instead. The stable control across rounds is the evidence that this was good enough.Checked and not changed
process_commandbefore pruning retained keys, so a held read guard used to block it. A lookup for the oldest retained id can now be overtaken by that pruning, and unlike the promotion case the retry cannot help — a pruned row is gone, not relocated. I did not fix it here. There is no cheap correct fix: closing it means serializing signed-prekey lookups against rotation, either reinstating the coupling item 2 removes or adding a dedicated rotation lock, which has its own ordering questions against the flush and the staged-row delete and deserves its own change. The exposure is narrow and self-limiting: the only id at risk is the one crossing out of the retention window at that instant (SIGNED_PRE_KEY_RETENTIONis 3), and a message naming it would fail on the next rotation regardless, recovering the same way an expired retained key already does.has_pending_pairwise_writes_for_user— same audit item, left alone. It scansdirty∪deleted, bounded by writes since the last flush rather than by cache capacity, and cleared on every flush; in steady state they are small, and the 2000-entry measurement that motivated the index does not transfer. Serving it would need a second index over sets that genuinely shrink, reintroducing the missed-decrement failure mode the newtype was chosen to avoid. An O(1) early-out on both sets being empty wouldn't help either: the documented caller reaches it after a failed flush, when they are non-empty by construction.peek_session,has_session,checkout_session) — these have the same probe/drop/re-check shape and, as far as I can tell, the same absent-slot ambiguity the removal window now closes for sender keys:SessionStoreState::clear()also preserves the incarnation. I did not extend the fix there. It is pre-existing rather than introduced here, sessions have their own checkout-token and recovery-generation machinery I have not fully traced against this specific interleaving, and changing those paths is outside this change's scope and unmeasured. Flagging it explicitly so it isn't mistaken for "reviewed and fine".flush()'s lock scope — load-bearing and untouched. The lock spans snapshot, I/O and clear precisely so dirty sets clear only after successful writes; splitting it would open a snapshot/clear race. Sessions and consumed pre-keys must stay in one scope so the prekey delete is atomic with the session put.delete_sender_key_durable— already drops the cache mutex around the backend delete and holds the per-chain lock instead. Correct as-is; it was the model for item 1.sender_key_lock— still required and still load-bearing for the paths that mutate a chain. Item 1 neither weakens nor depends on it.std::sync::Mutexconversion — not attempted. Guards returned bylock_sessions()cross function boundaries and would become!Send, andwacoretargets single-threaded wasm32/ESP32.SessionEntry::Absentcounting as state inhas_state_for_user— preserved. Key presence, not value presence, is the predicate.Validation
No existing test needed adaptation for behavior. Test edits are confined to
signal_adapter.rs's own module and follow mechanically from the constructor signature change: six constructors now build aPersistenceManager, and three#[test]fns became#[tokio::test]because that constructor is async. The assertions are unchanged.New tests, in
wacore: a cold sender-key miss loading from the backend; two concurrent cold readers both reaching the backend and converging on one cached value; a concurrentputnot overwritten by the late reader; a concurrentdeletenot resurrected by it; a write dropped by a keyed removal not replaced by the stale in-flight read; the same for an opaque removal, driven through the real flush-then-clear_after_flushsequence; a cancelled cold read leaving no bookkeeping behind; unrelated-chain churn not forcing a re-read; a read that loses every unlocked attempt falling back to the locked path; the user index answering across every public mutation path including an addressed-device query; and the index surviving eviction, compaction and a lossy clear while deferring to the backend when cold. Inwhatsapp-rust: a promoted signed pre-key being resolvable only from a fresh snapshot, and a promotion landing mid-lookup being resolved by the re-read.Every one of these was verified to fail (or deadlock) against the implementation it guards, so none passes vacuously. That includes pairs pinning opposite directions of the same mechanism — disabling the install guard fails the keyed stale-read test, widening the removal check back to cache-wide fails the unrelated-churn test, dropping the
opaque_removal_seqbump fails the opaque test, and deleting the signed-pre-key re-read fails the mid-lookup test.cargo clippy --workspace --all-targetscould not run locally —alsa-sysfails to build for the VoIP targets without ALSA dev headers in this container, unrelated to this change. Full matrix left to CI.