Skip to content

fix(prekey): remove the consumed one-time prekey atomically with the session flush - #722

Merged
jlucaso1 merged 6 commits into
mainfrom
fix/prekey-remove-atomic-with-session-flush
Jun 5, 2026
Merged

fix(prekey): remove the consumed one-time prekey atomically with the session flush#722
jlucaso1 merged 6 commits into
mainfrom
fix/prekey-remove-atomic-with-session-flush

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

On an inbound pkmsg decrypt, message_decrypt_prekey promotes the new SessionRecord and stores it via the session adapter, which is cache-only: the record lands in the volatile SignalStoreCache and is not durable until the post-loop flush_signal_cache runs. Right after that, the consumed one-time prekey is removed via PreKeyStore::remove_pre_key, which on our adapter went straight to the backend DB synchronously.

The cache flush only had session, identity and sender-key lanes, so there was a window where:

  • the one-time prekey was already durably deleted from the backend, but
  • the freshly promoted session was still only in memory.

A crash or a failed flush in that window loses both. The peer can redeliver the same pkmsg (it does on reconnection), and decryption then fails with InvalidPreKeyId because the prekey is gone and there is no session to fall back on, leaving that message permanently undecryptable.

Change

Make the consumed-prekey removal durable atomically with the session, matching WAWebSignalProtocolStoreUnifiedApi, which buffers removePreKey in memory and commits it alongside the session put under one storage lock.

  • SignalStoreCache gains a small buffer of consumed prekey ids.
  • The session adapter's remove_pre_key now buffers the id instead of deleting from the backend.
  • flush deletes the buffered prekeys only after the session batch is committed, draining the buffer under its lock and clearing ids only once every backend delete succeeds (a failed flush keeps them for the next attempt).
  • clear (disconnect) drops the buffer together with the volatile sessions it belongs to, so a still-durable prekey survives and a redelivered pkmsg can rebuild the session.

This also removes the per-pkmsg synchronous backend round-trip for the prekey delete; it now rides the existing batched flush.

The PreKeyStore trait is unchanged, so other callers are unaffected. Only the decrypt-path session adapter changes behavior.

Tests

  • consumed_prekey_stays_durable_until_session_flush: after decrypt but before flush, the prekey is still in the backend and the session is not yet durable; after flush, the session is durable and the prekey is deleted.
  • clear_before_flush_keeps_prekey_so_pkmsg_can_rebuild: a disconnect before flush drops the volatile session and keeps the still-durable prekey, and a later flush of the now-empty buffer does not delete it.
  • failed_session_flush_does_not_delete_prekey: when the session write fails, the flush aborts before the prekey lane, the prekey stays durable, and the buffered id remains for the next attempt.
  • remove_pre_key_defers_backend_delete_to_flush: the session adapter's remove_pre_key does not touch the backend; the prekey is only deleted by the subsequent flush.

Performance

Removes one synchronous backend delete (and its SQLite transaction) per inbound pkmsg; the deletion now batches into the existing flush.

…session flush

On an inbound pkmsg the promoted SessionRecord was put into the volatile
SignalStoreCache while the consumed one-time prekey was deleted from the
backend synchronously. The cache flush has no prekey lane and runs after
the payload loop, so the prekey was durably gone while the new session
was still only in memory. A crash or a failed flush in that window lost
both, leaving a redelivered pkmsg permanently undecryptable
(InvalidPreKeyId).

Buffer the consumed prekey id in the cache and delete it from the backend
only in the same flush that persists the sessions, after the session
batch is durable. This mirrors WAWebSignalProtocolStoreUnifiedApi, which
buffers removePreKey and commits it alongside the session put under one
storage lock, and removes the per-pkmsg synchronous backend round-trip.
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability of consumed one-time pre-key cleanup by deferring deletion until the corresponding session state is persisted, preventing potential data consistency issues during message decryption.

Walkthrough

Decryption reports consumed_prekey_id; decrypt callsites buffer consumed one-time prekeys into SignalStoreCache per-session. SignalStoreCache defers backend deletion until a flush persists the owning session and then deletes buffered prekeys under the sessions lock; clear() drops buffered entries.

Changes

Pre-key removal deferral

Layer / File(s) Summary
Report consumed prekey from libsignal decrypt
wacore/libsignal/src/protocol/session_cipher.rs
Adds public DecryptionResult.consumed_prekey_id and stops inline deletion inside pkmsg decryption.
Buffer consumed-prekey at decrypt callsites
src/message.rs, src/features/signal.rs
Main batch decrypt, UntrustedIdentity retry, PN→LID migration, and decrypt_message now call adapter.pre_key_store.buffer_consumed_prekey(prekey_id, signal_addr) when decrypted.consumed_prekey_id is present.
Adapter: immediate vs buffered removal
src/store/signal_adapter.rs
Keeps immediate backend deletion for remove_pre_key; adds PreKeyAdapter::buffer_consumed_prekey that enqueues removals into SignalStoreCache and adds tests for both behaviors.
Cache buffer and constructor init
wacore/src/store/signal_cache.rs
Adds removed_prekeys buffer and initializes it in SignalStoreCache::with_max_entries.
Buffering API and flush-time drain
wacore/src/store/signal_cache.rs
remove_prekey(prekey_id, session_address) records buffered deletions; flush() persists only SessionEntry::Present sessions, then under the sessions lock drains buffered IDs by calling backend.remove_prekey(id) for persisted session owners and removes buffered entries only after successful backend deletion.
Clear behavior and atomicity tests
wacore/src/store/signal_cache.rs
clear() clears removed_prekeys. Adds consumed_prekey_atomicity_tests covering durability until flush, checked-out deferral, mixed prekeys, clear-before-flush, failed-flush retry, and a concurrent decrypt-vs-flush invariant test.

Sequence Diagram

sequenceDiagram
  participant MessageHandler as Message handler
  participant PreKeyAdapter as PreKeyAdapter
  participant Cache as SignalStoreCache
  participant Backend as Backend store
  MessageHandler->>PreKeyAdapter: buffer_consumed_prekey(prekey_id, session_addr)
  PreKeyAdapter->>Cache: remove_prekey(prekey_id, session_addr)
  Cache->>Cache: store in removed_prekeys
  MessageHandler->>Cache: flush()
  Cache->>Backend: persist session batch
  Backend-->>Cache: success
  Cache->>Backend: remove_prekey(prekey_id) for each buffered deletable
  Backend-->>Cache: success
  Cache->>Cache: remove entries from removed_prekeys
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#664: Both PRs modify the decrypt pipeline in src/message.rs—notably process_session_enc_batch and the retry/migration decrypt paths.
  • oxidezap/whatsapp-rust#482: Both PRs touch try_pn_to_lid_migration_decrypt in src/message.rs; #482 removed redundant backend reloads after migration while this PR adds consumed-prekey buffering.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: making consumed one-time prekey removal atomic with session flush, which is the core problem being solved.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the problem, solution, tests, and performance improvement.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/prekey-remove-atomic-with-session-flush

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

❤️ Share

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6be5678528

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

Benchmark Results

1 regression(s) detected (>2% threshold):

Benchmark Current Baseline Change
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 230,638 223,397 +3.2%
66 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,838 2,838 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,272 8,272 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,398 49,398 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 54,827 54,827 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,592 1,592 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,219 4,219 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 112,838 112,835 +0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,388 1,656,174 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 650,213 650,214 -0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 874,383 874,271 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,082,099 2,082,136 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 747,511 747,442 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,328,755 1,328,154 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,374,638 4,372,442 +0.1%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 517,036 516,974 +0.0%
binary_benchmark::marshal_group::bench_marshal_allocating 45,401 45,401 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,451 45,451 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,354 66,354 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,512 43,512 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,507 45,507 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,930 4,930 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,961 4,961 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,732 6,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,529 528,529 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,150 528,150 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,396 529,396 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,786 5,417,786 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,043 5,362,043 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,336 13,276,336 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,283 48,283 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,344 48,344 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,668 66,668 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,286 8,286 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,139,903 4,143,584 -0.1%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,133 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,264,189 4,263,827 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 100,399 99,803 +0.6%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,262 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,921 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 510,217 510,410 -0.0%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,978,801 11,977,470 +0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,844,502 4,858,802 -0.3%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,404 37,950 -1.4%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,617,967 3,616,956 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%

jlucaso1 added 2 commits June 4, 2026 21:00
The consumed-prekey buffer was drained in a separate lock scope, acquired
after the sessions lock was already released. Flushes are not serialized
against decrypts, so in the gap between releasing the sessions lock and
taking removed_prekeys a concurrent sender could promote its session into
the volatile cache and buffer its consumed prekey, which the in-flight
flush would then durably delete while that session was still only in
memory. A crash before the second sender's own flush lost its session
while its prekey was gone, making a redelivered pkmsg undecryptable
(InvalidPreKeyId) again.

Move the prekey drain inside the sessions lock scope so the session
commit and the prekey delete are atomic against concurrent buffering. A
sender that buffers after the session batch is snapshotted is blocked
from inserting into removed_prekeys until the lock frees, so a flush can
never delete a prekey whose session it did not also persist. This matches
WAWebSignalProtocolStoreUnifiedApi holding its cache mutexes across the
whole flush. Identity and sender-key lanes keep independent scopes.
The flush drained all buffered consumed-prekey IDs even when a dirty (promoted-but-not-yet-durable) session was skipped because a concurrent reader had it checked out. process_session_enc_batch drops the per-address session lock before the post-loop flush, so a concurrent send/retry can check out the just-promoted session; the flush then deleted its one-time prekey while the session stayed volatile, recreating the crash-orphan window. Track whether any dirty session was checked out during the session batch and defer the whole prekey drain to a later flush when so (IDs stay buffered, bounded by consumed prekeys between flushes). Adds a regression test.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against 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 571-585: The current flush logic uses dirty_keys (u32 IDs) and a
single dirty_session_checked_out flag so a CheckedOut session
(SessionEntry::CheckedOut) prevents deleting all buffered prekey removals, which
can leak other addresses' consumed prekeys; change the approach to track
removals per-address/session: when iterating dirty_keys, consult
state.cache.get(address.as_ref()) and collect deletable addresses into a
dedicated list (e.g., deletable_prekeys) only for SessionEntry::Present records
you successfully serialized (the ones you push into batch), skip or defer
entries that are CheckedOut without setting a global suppression flag, and then
delete/drain only the deletable_prekeys from the removal buffer (instead of
calling clear()), ensuring clear() is not used to drop the whole buffer
prematurely and that ownership ties each buffered removal to its address/session
so only safe removals are applied.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e743eef6-a550-4657-8e57-5fc7d7cfd215

📥 Commits

Reviewing files that changed from the base of the PR and between d277524 and 6ffc71c.

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

Comment thread wacore/src/store/signal_cache.rs Outdated
The flush deferred the entire consumed-prekey buffer whenever any dirty
session happened to be checked out, so a prekey whose session WAS persisted
this flush stayed buffered. A later clear() (disconnect) drops the buffer,
leaving that prekey durable forever while its session is live: a one-time
prekey that should have been consumed leaks.

Key the buffer by the session address that consumed each prekey, and have
message_decrypt report the consumed id instead of removing it internally
(DecryptionResult carries consumed_prekey_id; the receive path buffers it
with the peer address via PreKeyAdapter::buffer_consumed_prekey). The flush
now deletes a prekey as soon as ITS own session is durable and defers only
the prekeys of sessions still checked out, never holding back the prekeys of
sessions it did persist. clear() still drops the whole buffer: every
still-buffered prekey has a volatile session at that point, so the durable
prekey is kept so a redelivered pkmsg can rebuild.

The drain stays under the sessions lock, so the session commit and prekey
delete remain atomic against a concurrent decrypt that promotes a session
and buffers its prekey, matching WAWebSignalProtocolStoreUnifiedApi bundling
bulkPutSession with bulkRemovePreKey under one storage lock. remove_pre_key
becomes the plain immediate-removal primitive (no longer on the consume path).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48330559e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against 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 55-65: The long explanatory comment describing "Consumed one-time
pre-key IDs awaiting durable deletion" in signal_cache.rs should be replaced
with a tight "why" statement: compress to one or two sentences that state the
invariant and rationale (e.g., that pre-keys are buffered keyed by session
address so deletes occur only when the session is durably persisted to avoid
losing both session and prekey on crash). Apply the same concise rewrite pattern
to the other verbose comment blocks noted (around lines 611-633 and 912-918),
keeping only the essential invariant and rationale, removing flow/mechanics
details.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3e4fc3b9-2b7c-46e6-b6ca-6cb9d231bfd2

📥 Commits

Reviewing files that changed from the base of the PR and between 6ffc71c and 4833055.

📒 Files selected for processing (5)
  • src/features/signal.rs
  • src/message.rs
  • src/store/signal_adapter.rs
  • wacore/libsignal/src/protocol/session_cipher.rs
  • wacore/src/store/signal_cache.rs

Comment thread wacore/src/store/signal_cache.rs Outdated
Resolve the signal_cache.rs conflict with #721's amortized eviction: keep
main's high_watermark eviction and the read-path evict calls, keep this
branch's per-address consumed-prekey buffer and selective flush drain, and
keep both test modules (consumed_prekey_atomicity_tests + eviction_tests).

Also fold in the Codex P1 fix: gate each buffered prekey on membership in the
batch THIS flush persisted, not on the live cache entry. A session that was
checked out, deleted, or dropped by a concurrent clear() before its buffer
insert landed is absent from the batch, so its prekey is deferred instead of
durably deleted with no session behind it (which would make a redelivered
pkmsg permanently undecryptable). This also fixes the symmetric eviction leak
the entry-based predicate had (a persisted-then-evicted session read as None).
Adds prekey_without_a_persisted_session_survives_flush as the regression lock
and tightens the now-verbose buffer/flush comments.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c92f59058a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/store/signal_cache.rs Outdated
The batch-membership predicate only deleted a buffered prekey when ITS session
was persisted by the same flush. If a concurrent flush ran between the decrypt's
store_session and the receive path's buffer_consumed_prekey, it persisted the
session (empty buffer) and cleared its dirty bit; the prekey then buffered for an
address that never re-enters a batch, so it stayed durable forever (one-time
prekey leak).

Decide durability per session instead: Present (clean at drain) is persisted, so
delete; CheckedOut is the still-volatile promoted copy, so defer; an
absent/deleted/evicted/cleared entry is ambiguous, so fall back to
backend.has_session. This deletes prekeys of already-durable sessions (the leak),
still defers prekeys whose session never reached the backend (no InvalidPreKeyId
on redelivery), and handles a durable-but-evicted session. The backend probe only
runs for the rare non-Present cases; the hot path stays in memory. Adds
prekey_buffered_after_session_already_durable_is_deleted as the regression lock.

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

748-755: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make clear() drop sessions and buffered prekeys atomically.

Line 749 clears sessions and releases that guard before Line 755 clears removed_prekeys. A concurrent decrypt can put_session() in that gap and then have its buffered prekey removal erased by clear(), so the next flush can persist the session while the consumed one-time prekey stays durable forever. Clear those two structures under the same lock order that flush() already uses.

Suggested fix
 pub async fn clear(&self) {
-    self.sessions.lock().await.clear();
+    {
+        let mut sessions = self.sessions.lock().await;
+        let mut removed_prekeys = self.removed_prekeys.lock().await;
+        sessions.clear();
+        removed_prekeys.clear();
+    }
     self.identities.lock().await.clear();
     self.sender_keys.lock().await.clear();
-    // Drop buffered prekey removals together with the volatile sessions they
-    // belong to: the promoted session is gone, so the still-durable prekey
-    // must stay so a redelivered pkmsg can rebuild the session.
-    self.removed_prekeys.lock().await.clear();
 }
🤖 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 748 - 755, The clear() method
currently unlocks sessions before cleared removed_prekeys which allows a race;
fix it by acquiring the same lock ordering flush() uses and clearing sessions
and removed_prekeys while holding both guards atomically — specifically, in
clear() obtain the sessions lock and then the removed_prekeys lock (matching
flush()), clear both collections while both guards are held, then release; keep
clearing identities and sender_keys as before but ensure sessions +
removed_prekeys are cleared together to prevent a concurrent put_session() from
winning between the two clears.
🤖 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 748-755: The clear() method currently unlocks sessions before
cleared removed_prekeys which allows a race; fix it by acquiring the same lock
ordering flush() uses and clearing sessions and removed_prekeys while holding
both guards atomically — specifically, in clear() obtain the sessions lock and
then the removed_prekeys lock (matching flush()), clear both collections while
both guards are held, then release; keep clearing identities and sender_keys as
before but ensure sessions + removed_prekeys are cleared together to prevent a
concurrent put_session() from winning between the two clears.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: df8de63b-1766-492c-9a59-03d7f48de698

📥 Commits

Reviewing files that changed from the base of the PR and between 4833055 and 506ffa4.

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

@jlucaso1
jlucaso1 merged commit e55fa4a into main Jun 5, 2026
11 checks passed
@jlucaso1
jlucaso1 deleted the fix/prekey-remove-atomic-with-session-flush branch June 5, 2026 01:48
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.

1 participant