Skip to content

feat: add Signal protocol feature API and consolidate internal helpers - #474

Merged
jlucaso1 merged 5 commits into
mainfrom
feat/signal-feature-api
Apr 1, 2026
Merged

feat: add Signal protocol feature API and consolidate internal helpers#474
jlucaso1 merged 5 commits into
mainfrom
feat/signal-feature-api

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

New Signal feature API (client.signal().*) exposing low-level Signal protocol operations, plus DRY consolidation of internal helpers across the codebase.

Signal Feature API (src/features/signal.rs)

Method Description
encrypt_message(jid, plaintext) 1:1 Signal encrypt → (EncType, ciphertext)
decrypt_message(jid, enc_type, ciphertext) 1:1 Signal decrypt → raw padded plaintext
encrypt_group_message(group_jid, plaintext) Sender-key encrypt → (Option<skdm>, ciphertext)
decrypt_group_message(group_jid, sender_jid, ciphertext) Sender-key decrypt → raw padded plaintext
create_participant_nodes(jids, message) Full device resolution + session setup + encrypt for all devices
validate_session(jid) Check if Signal session exists
delete_sessions(jids) Delete session + identity (matches WA Web deleteRemoteSession)
assert_sessions(jids) Ensure E2E sessions exist
get_user_devices(jids) Resolve device JIDs via usync

All methods that take JIDs resolve PN→LID via resolve_encryption_jid, matching the internal send/receive paths.

WA Web compliance

  • SKDM creation: encrypt_group_message only creates SKDM when no sender key exists (matches WA Web — not on every call)
  • Session deletion: delete_sessions removes both session and identity (matches WA Web's deleteRemoteSession)
  • PN→LID resolution: All methods resolve PN to LID before Signal operations (matches WA Web's checkPnToLidMapping)
  • Deferred-write flush: All mutating methods call flush_signal_cache() after success (matches WA Web's flushBufferToDiskIfNotMemOnlyMode)
  • Type safety: EncType enum (PreKeyMessage / Message / SenderKey) instead of stringly-typed "msg" / "pkmsg"
  • Raw padded bytes: Decrypt methods return raw padded plaintext — caller unpads with stanza's v attribute (matches how internal paths handle versioned padding)

Other changes

  • Event::RawNode: New event variant for raw stanza observation before router dispatch, gated by Client::set_raw_node_forwarding() (zero-cost atomic check when disabled). Library extension — no WA Web equivalent.
  • #[non_exhaustive] added to Event enum for forward compatibility.
  • Client::send_raw_bytes: Send pre-marshaled bytes through noise socket. send_node now delegates to it.
  • tokio::spawnself.runtime.spawn().detach() in message.rs tctoken reissue (aligns with runtime abstraction).

DRY consolidation

Helper Replaces Sites
Client::signal_adapter() / signal_adapter_from() Inline SignalProtocolStoreAdapter::new(...) 12
Client::session_lock_for() Inline session_locks.get_with_by_ref(...) 6
Client::get_noise_socket() Inline noise socket lock + match 3
SignalProtocolStoreAdapter::as_signal_stores() Inline SignalStores { ... } struct init 6

Test plan

  • cargo fmt --all — clean
  • cargo clippy --all --tests — clean
  • cargo test --all --lib — all 1,076 tests pass, 0 failures
  • E2E tests (flaky timeouts unrelated to this change)

Expose low-level Signal protocol operations (encrypt, decrypt, session
management, participant node creation) via `client.signal().*` following
the established feature pattern. Add `Event::RawNode` for raw stanza
observation gated by an atomic flag.

Consolidate duplicated patterns into shared helpers:
- `Client::signal_adapter()` / `signal_adapter_from()` — replaces 12
  inline `SignalProtocolStoreAdapter::new()` calls
- `Client::session_lock_for()` — replaces 6 inline session lock patterns
- `Client::get_noise_socket()` — replaces 3 inline noise socket patterns
- `SignalProtocolStoreAdapter::as_signal_stores()` — replaces 6 inline
  `SignalStores` struct constructions
- `Client::send_raw_bytes()` — `send_node` now delegates to it
@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Signal feature and Client-level Signal handle, centralizes Signal store and per-session lock access via new Client helpers, introduces raw-node forwarding and a public raw-bytes send API, exposes encryption result types in wacore, and refactors send/decrypt/retry flows to use the new helpers.

Changes

Cohort / File(s) Summary
Signal feature & re-exports
src/features/signal.rs, src/features/mod.rs, src/lib.rs
New Signal<'a> handle on Client providing async Signal protocol APIs (encrypt/decrypt, group sender-key, session management, participant node creation); re-exported via features and lib.
Client core: raw forwarding & helpers
src/client.rs
Added raw_node_forwarding: AtomicBool, pub fn set_raw_node_forwarding(...), pub async fn send_raw_bytes(...), plus signal_adapter(), signal_adapter_from(...), session_lock_for(...), get_noise_socket(); decrypt/send flows refactored to use these helpers and conditionally emit Event::RawNode.
Session establishment & lock usage
src/client/sessions.rs, src/message.rs, src/retry.rs, src/send.rs
Replaced inline SignalProtocolStoreAdapter::new(...) with self.signal_adapter()/signal_adapter_from(...) and replaced session_locks.get_with_by_ref(...) usage with self.session_lock_for(...) across fetch/establish, decryption, send, and retry paths.
Signal adapter utility
src/store/signal_adapter.rs
Added as_signal_stores(&mut self) to produce wacore::send::SignalStores borrowing underlying adapters for send paths.
wacore public API changes
wacore/src/send.rs
Made EncryptResult and its fields public and promoted encrypt_for_devices to pub async fn (visibility change only).
Event enum extension
wacore/src/types/events.rs
Marked Event as #[non_exhaustive] and added RawNode(Arc<Node>) with #[serde(skip)]; Client emits this variant when raw-node forwarding is enabled.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(150,150,255,0.5)
    participant C as Client
    participant S as Signal
    participant P as Persistence
    participant W as wacore::send
    participant N as NoiseSocket
  end

  C->>S: signal().encrypt_message(jid, plaintext)
  S->>P: load devices & acquire per-session locks
  S->>W: as_signal_stores -> encrypt_for_devices
  W-->>S: participant nodes + ciphertexts
  S->>P: flush signal cache
  S-->>C: participant nodes (+ prekey flag)
  C->>C: marshal Node -> plaintext bytes
  C->>N: send_raw_bytes(plaintext) -> get_noise_socket() -> encrypt + transmit
  N-->>C: ack / update last_data_sent_ms
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through code and found a key,
I stitched up sessions, set raw-forward free,
I padded bytes and wrapped them snug and bright,
Then nudged them out through Noise into the night—
Hooray for hops that keep our messages light.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add Signal protocol feature API and consolidate internal helpers' accurately summarizes the main changes: introducing a new Signal protocol feature API and refactoring internal helpers across multiple files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/signal-feature-api

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

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread src/features/signal.rs Outdated
/// Delete Signal sessions for the given JIDs (cache + persistent store).
pub async fn delete_sessions(&self, jids: &[Jid]) -> Result<()> {
let device_store = self.client.persistence_manager.get_device_arc().await;
let device_guard = device_store.read().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reorder delete_sessions locks to avoid deadlock

delete_sessions holds the device store read lock before awaiting the per-address session mutex, but other Signal paths take the session mutex first and can then require a device write lock (e.g. identity updates during decrypt). That opposite lock order can deadlock when these paths overlap, stalling message processing and session cleanup. Acquire the session lock first (or narrow device-lock scope per JID) so lock ordering matches the rest of the Signal flows.

Useful? React with 👍 / 👎.

Comment thread src/features/signal.rs
Comment on lines +225 to +229
let result = wacore::send::encrypt_for_devices(
&mut stores,
self.client,
&device_jids,
&plaintext,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Serialize participant encryption with session locks

create_participant_nodes calls encrypt_for_devices without taking session_locks, unlike the normal send path that locks all involved session keys before encrypting. Because this routine mutates Signal sessions, concurrent decrypt/send operations on the same devices can interleave ratchet state updates and produce duplicate/failed decrypt behavior. Wrap this encryption block with the same per-session locking strategy used in send_message_impl.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Apr 1, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchfeat/signal-feature-api
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()Instructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
47.13 x 1e3
(+8.79%)Baseline: 43.32 x 1e3
45.49 x 1e3
(103.61%)

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,197.00
(-4.50%)Baseline: 6,489.30
6,813.77
(90.95%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-26.18%)Baseline: 710,204.63
745,714.86
(70.31%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-5.42%)Baseline: 22,064.53
23,167.76
(90.07%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,202.00
(-15.01%)Baseline: 115,549.44
121,326.91
(80.94%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,230.00
(-9.62%)Baseline: 108,689.78
114,124.27
(86.07%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.10%)Baseline: 533,492.92
560,167.56
(95.14%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,870.00
(-4.47%)Baseline: 16,612.28
17,442.90
(90.98%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,715,201.00
(-7.59%)Baseline: 15,923,669.96
16,719,853.46
(88.01%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,358.00
(-19.69%)Baseline: 147,383.99
154,753.19
(76.48%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.10%)Baseline: 534,914.28
561,659.99
(95.14%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,919.00
(-3.99%)Baseline: 18,663.46
19,596.64
(91.44%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,066,347.00
(-20.69%)Baseline: 35,386,653.62
37,155,986.30
(75.54%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.10%)Baseline: 533,931.92
560,628.51
(95.14%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,843.00
(-7.06%)Baseline: 17,047.10
17,899.45
(88.51%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,716,627.00
(-7.59%)Baseline: 15,924,807.68
16,721,048.06
(88.01%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
107,945.00
(-11.87%)Baseline: 122,477.41
128,601.28
(83.94%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,302.00
(-9.62%)Baseline: 108,761.78
114,199.87
(86.08%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-5.15%)Baseline: 95,912.58
100,708.21
(90.33%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-3.31%)Baseline: 7,630.76
8,012.29
(92.08%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-1.70%)Baseline: 92,575.29
97,204.06
(93.62%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.34%)Baseline: 7,375.78
7,744.57
(95.56%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-1.45%)Baseline: 108,360.29
113,778.31
(93.86%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.28%)Baseline: 8,887.78
9,332.17
(95.51%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-7.40%)Baseline: 45,343.44
47,610.61
(88.19%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-2.64%)Baseline: 2,790.71
2,930.25
(92.72%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+1.63%)Baseline: 547,164.91
574,523.16
(96.79%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.26%)Baseline: 773.01
811.66
(94.99%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,731,604.00
(+0.12%)Baseline: 27,699,211.15
29,084,171.70
(95.35%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,544,828.00
(-0.05%)Baseline: 5,547,774.34
5,825,163.06
(95.19%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
175,061.00
(-1.30%)Baseline: 177,367.16
186,235.52
(94.00%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
175,710.00
(-1.36%)Baseline: 178,140.41
187,047.43
(93.94%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,273,801.00
(-0.04%)Baseline: 17,281,281.79
18,145,345.88
(95.20%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
298,417.00
(+0.54%)Baseline: 296,811.73
311,652.32
(95.75%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,625,593.00
(+0.24%)Baseline: 12,595,754.71
13,225,542.45
(95.46%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
719,597.00
(+0.37%)Baseline: 716,933.91
752,780.61
(95.59%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
🚨 view alert (🔔)
47,129.00
(+8.79%)Baseline: 43,320.73
45,486.77
(103.61%)

libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,766.55
16,339,854.88
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,378,778.00
(-1.78%)Baseline: 5,476,321.38
5,750,137.45
(93.54%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
312,188.00
(-60.83%)Baseline: 797,015.72
836,866.50
(37.30%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,830,547.00
(+0.17%)Baseline: 2,825,608.01
2,966,888.41
(95.40%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,452,844.00
(-0.51%)Baseline: 3,470,691.24
3,644,225.80
(94.75%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
123,538,088.00
(-1.45%)Baseline: 125,361,026.43
131,629,077.75
(93.85%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
12,002.00
(+1.48%)Baseline: 11,826.60
12,417.93
(96.65%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,930.00
(+2.40%)Baseline: 3,837.71
4,029.60
(97.53%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,073.00
(-0.77%)Baseline: 87,749.03
92,136.48
(94.50%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,022.00
(-0.95%)Baseline: 79,782.35
83,771.46
(94.33%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
50,375.00
(-1.04%)Baseline: 50,904.05
53,449.26
(94.25%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,956.00
(+3.07%)Baseline: 5,778.83
6,067.77
(98.16%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,227.00
(+4.37%)Baseline: 2,133.84
2,240.53
(99.40%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(-0.00%)Baseline: 21,920.25
23,016.26
(95.24%)
🐰 View full continuous benchmarking report in Bencher

@jlucaso1

jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

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


Code Review: Signal Protocol Feature API

  • Read all changed files
  • Analyze Signal feature API (src/features/signal.rs)
  • Analyze client helper consolidation (src/client.rs)
  • Analyze refactored modules (send.rs, message.rs, retry.rs, sessions.rs)
  • Analyze store adapter changes
  • Analyze event/core changes
  • Evaluate codex review comments (deadlock & session lock concerns)
  • Post review

Overall this is a clean DRY consolidation. The helper extraction (signal_adapter(), session_lock_for(), get_noise_socket(), as_signal_stores()) is well-scoped and the refactored call sites in send.rs, message.rs, and retry.rs are strict 1:1 replacements. The Event::RawNode gating via AtomicBool with Ordering::Relaxed is the right zero-cost pattern. The send_nodesend_raw_bytes delegation is clean.

Here are the issues I found:


1. delete_sessions — Lock ordering inversion (real deadlock risk)

src/features/signal.rs:183-203

delete_sessions acquires the device read lock first, then the per-address session lock inside the loop:

let device_guard = device_store.read().await;  // Lock A (read)
for jid in jids {
    let lock = self.client.session_lock_for(...).await;
    let _guard = lock.lock().await;  // Lock B
    ...
}

But the encrypt/decrypt paths (e.g. process_session_enc_batch in message.rs:685) do the opposite: session lock first, then the SignalProtocolStoreAdapter can take a device write lock internally via save_identity (signal_adapter.rs:129):

session_lock → signal_adapter → save_identity → device.write()

This is a classic AB/BA deadlock. Since RwLock write requests block new readers, a concurrent save_identity waiting for the write lock will prevent delete_sessions from ever acquiring the read lock if it hasn't yet, while delete_sessions holds the read lock and waits for the session lock that the encrypt path holds.

Fix: Move the device_store.read() inside the loop body, after acquiring the session lock, so lock ordering matches the rest of the codebase (session lock → device lock). Or better yet, build a signal_adapter() per-JID inside the loop (which lazily acquires the device lock), same as encrypt_message/decrypt_message do.

Fix this →


2. create_participant_nodes — Missing session locks

src/features/signal.rs:211-236

This method calls encrypt_for_devices without acquiring per-address session locks. Compare with the DM send path (src/send.rs:1027-1040) which acquires session locks for all target devices before encrypting:

// send.rs DM path:
let lock_keys = self.build_session_lock_keys(&all_dm_jids).await;
for key in &lock_keys { _session_mutexes.push(self.session_lock_for(key).await); }
for mutex in &_session_mutexes { _session_guards.push(mutex.lock().await); }
// ... then encrypt_for_devices

Since encrypt_for_devices mutates Signal session ratchet state, concurrent calls to create_participant_nodes and the normal send/decrypt paths on the same devices can produce ratchet state corruption (duplicate counters → failed decrypts on the receiver side).

Fix: Before calling encrypt_for_devices, acquire session locks for all device_jids using the same pattern as the DM send path. Consider extracting build_session_lock_keys if it isn't already pub(crate).

Fix this →


3. encrypt_group_message — Missing session lock for sender key operations

src/features/signal.rs:109-143

The method creates an SKDM and encrypts in sequence without any session lock. While sender key operations don't use the same per-address Signal session locks, the SenderKeyStore is accessed through the cache adapter. If encrypt_group_message runs concurrently with group message decryption (which processes SKDM in message.rs), the sender key state could race. The internal send path in send.rs doesn't lock for group either (group messages note "No client-level lock needed" at line 818), so this may be acceptable by design — but it's worth noting that this public API exposes that concurrency to external callers who may not be aware.

Suggestion (non-blocking): Add a doc comment noting that callers must not call this concurrently with decrypt_group_message for the same group, or consider whether the sender key cache is internally synchronized.


4. Minor: PollOptionResult not re-exported in src/features/mod.rs

src/features/mod.rs:45PollOptionResult is re-exported but looking at this PR, I see Polls feature already existed. Not related to this PR, just noting for completeness.


5. Visibility change in wacore/src/send.rsEncryptResult and encrypt_for_devices made public

wacore/src/send.rs:320-328

Making these pub is necessary for the Signal feature API, but it widens wacore's public surface. Since wacore is the platform-agnostic core crate, this seems intentional and appropriate for the feature pattern. Just flagging that downstream crates can now call encrypt_for_devices directly, which is a powerful primitive that requires correct session locking by the caller — consider adding a doc comment on encrypt_for_devices noting the session lock requirement.


6. tokio::spawnself.runtime.spawn() change is correct

src/message.rs:933-940

Good catch aligning with the runtime abstraction. The .detach() is correct here since the tctoken reissue is fire-and-forget.


Summary

The DRY consolidation and feature pattern are well-executed. The two critical issues are both in src/features/signal.rs:

Priority Issue Location
High Lock ordering inversion in delete_sessions (deadlock risk) signal.rs:183-203
High Missing session locks in create_participant_nodes (ratchet corruption risk) signal.rs:211-236
Low Missing concurrency docs on encrypt_group_message signal.rs:109-143
Low Consider doc comment on newly-public encrypt_for_devices wacore/src/send.rs:328

The Codex review flagged issues #1 and #2 correctly — I've verified both are real concerns after tracing the lock ordering through the codebase.

The benchmark alert (+8.79% on bench_decrypt_with_previous_session) appears unrelated — this PR doesn't change any Signal protocol internals, and the benchmark measures raw libsignal operations. Likely CI noise.


- delete_sessions: move device read lock inside loop body, after
  session lock acquisition, to match encrypt/decrypt lock ordering
  and prevent AB/BA deadlock
- create_participant_nodes: acquire per-device session locks before
  calling encrypt_for_devices (matches DM send path)
- encrypt_group_message: document concurrent usage restriction
- encrypt_for_devices: document session lock requirement for callers

@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: 6

Caution

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

⚠️ Outside diff range comments (2)
src/client/sessions.rs (1)

201-223: ⚠️ Potential issue | 🟠 Major

Canonicalize the LID JID before locking and storing the session.

This block normalizes only the prekey-bundle lookup key. process_prekey_bundle() and session_lock_for() still use jid.to_protocol_address() from the raw JID, so a non-canonical LID input can create/lock a different session address than the rest of the send path, which already forces LID agent 0 before session creation. That lets ensure_e2e_sessions() report success while later encrypt/decrypt still misses the session.

🔧 Proposed fix
-        for jid in jids {
-            if let Some(bundle) = prekey_bundles.get(&jid.normalize_for_prekey_bundle()) {
-                let signal_addr = jid.to_protocol_address();
+        for jid in jids {
+            let canonical_jid = jid.normalize_for_prekey_bundle();
+            if let Some(bundle) = prekey_bundles.get(&canonical_jid) {
+                let signal_addr = canonical_jid.to_protocol_address();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/sessions.rs` around lines 201 - 223, The prekey bundle lookup uses
jid.normalize_for_prekey_bundle() but session_lock_for(), to_protocol_address(),
and process_prekey_bundle() still use the original jid, allowing non-canonical
LIDs to create different session addresses; fix by canonicalizing the JID once
(e.g., let canonical = jid.normalize_for_prekey_bundle()) and then use
canonical.to_protocol_address() for signal_addr, use canonical for the
prekey_bundles lookup, pass the canonical signal_addr into session_lock_for()
and process_prekey_bundle(), and ensure any session storage/locking consistently
uses that canonical JID rather than the raw jid.
wacore/src/send.rs (1)

320-335: 🛠️ Refactor suggestion | 🟠 Major

Expose a locked wrapper, not an unlocked session-mutating primitive.

encrypt_for_devices now becomes public, but its correctness still depends on callers serializing per-sender Signal operations. That makes the new low-level API easy to misuse from parallel tasks. Please either expose only locked wrappers from client.signal() or add an explicit doc contract that this function must run under the caller’s per-sender session lock.

Based on learnings: Use session_locks to serialize per-sender Signal encrypt/decrypt operations and message_enqueue_locks to serialize per-chat incoming message processing.

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

In `@wacore/src/send.rs` around lines 320 - 335, encrypt_for_devices is a public
low-level function that mutates Signal sessions and must be run under the
per-sender session lock; to fix, do one of two: (A) hide this primitive (make
encrypt_for_devices non-public) and add a locked wrapper method on the Signal
client (e.g., client.signal().encrypt_for_devices_locked(...)) that acquires the
appropriate session_locks for the sender (and message_enqueue_locks for
chat-level serialization if applicable) before calling encrypt_for_devices, or
(B) keep it public but add a mandatory documentation contract and runtime assert
that the caller holds the per-sender session lock (using session_locks) and
serialize caller usage via message_enqueue_locks where needed; update visibility
and add the wrapper on the type that exposes SignalStores (client.signal()) and
reference the symbols encrypt_for_devices, SignalStores, session_locks,
message_enqueue_locks, and client.signal() so callers use the safe locked API.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client.rs`:
- Around line 1588-1594: The dispatch of Event::RawNode is currently after
early-return paths so some decoded stanzas (e.g., IQ responses and xmlstreamend)
never get forwarded; move the block that checks raw_node_forwarding and calls
self.core.event_bus.dispatch(&Event::RawNode(Arc::clone(&node))) so it executes
immediately after decoding the stanza and before any early returns/handling code
paths (keep the atomic load raw_node_forwarding.load(Ordering::Relaxed) and the
Arc::clone(&node) usage intact) to ensure every decoded node is emitted to raw
observers prior to router dispatch or short-circuit handlers.

In `@src/features/signal.rs`:
- Around line 45-51: The public Signal-mutating paths (e.g., the message_encrypt
call in signal.rs as well as the sender-key and encrypt_for_devices code paths)
advance session/sender-key state but do not persist it; after each successful
mutating operation you must call the crate-private flush function that persists
the deferred-write signal cache (flush_signal_cache or the equivalent used in
client.rs) using the same adapter/signal_cache instance so ratchet updates are
durable; add the flush call immediately after the awaited success returns (only
on success) for message_encrypt, the sender-key encryption path, and
encrypt_for_devices to ensure session_store/identity_store advances are written
before returning.
- Around line 97-98: The low-level decryptors in signal.rs currently hard-code
WhatsApp v2 unpadding by calling MessageUtils::unpad_message_ref(&padded, 2)
(also at the similar site around lines 166-167), which breaks raw round-trips
and ignores the stanza `v`; change the decryptor APIs to stop assuming a padding
scheme: remove the fixed "2" and either (a) accept an explicit unpad parameter
(e.g., pass-through a v/pad value from the caller) or (b) return the raw padded
bytes from the low-level decrypt functions (leave unpadding to higher-level
code). If you still want WhatsApp-specific convenience, add a separate helper
(e.g., unpad_whatsapp_v2 or decrypt_with_whatsapp_v) that reads the stanza `v`
and calls MessageUtils::unpad_message_ref with the correct value. Ensure
references to MessageUtils::unpad_message_ref are updated accordingly and that
encrypt_message round-trips are preserved.
- Around line 114-121: The code obtains own_jid by directly reading
persistence_manager.get_device_snapshot().pn which always yields a phone-number
JID; for groups using LID-addressing this is wrong—use the client's helper that
respects the group's addressing mode instead. Replace the direct snapshot access
when computing own_jid with a call to Client::get_own_jid_for_group (i.e.,
self.client.get_own_jid_for_group(...)) passing the group identifier/context
used here so the correct JID (PN or LID) is returned; update any error handling
to propagate the same anyhow!("not logged in") behavior if that helper returns
None/Err. Ensure you reference own_jid and get_own_jid_for_group in the change
so the sender-key/ciphertext is generated under the proper sender identity.

In `@src/send.rs`:
- Around line 1326-1328: The dedup lock uses sender.to_non_ad() which differs
between PN and LID aliases and thus can allow duplicate tasks to race on the
same tc-token row; replace the computation of bare and the lock acquisition to
use the exact canonical key used for tc-token storage (i.e., call the same
helper you use when reading/writing tc-token rows) instead of
sender.to_non_ad().to_string(), then pass that canonical key into
self.session_lock_for(...) so session_lock_for and tc-token operations use the
identical identifier.

In `@wacore/src/types/events.rs`:
- Around line 453-458: You added a new Event::RawNode(Arc<Node>) variant which
is a breaking change for downstream exhaustive matches; either revert adding the
variant and instead expose raw-node delivery via a separate handler/callback API
(e.g., add Client::set_raw_node_handler or a RawNodeHandler trait and route raw
nodes there, leaving Event unchanged) and wire that to
Client::set_raw_node_forwarding(true), or if you truly intend a breaking
release, document and perform a semver-major bump and release note for the Event
enum change; do not add the RawNode variant to the public Event enum without one
of these two approaches.

---

Outside diff comments:
In `@src/client/sessions.rs`:
- Around line 201-223: The prekey bundle lookup uses
jid.normalize_for_prekey_bundle() but session_lock_for(), to_protocol_address(),
and process_prekey_bundle() still use the original jid, allowing non-canonical
LIDs to create different session addresses; fix by canonicalizing the JID once
(e.g., let canonical = jid.normalize_for_prekey_bundle()) and then use
canonical.to_protocol_address() for signal_addr, use canonical for the
prekey_bundles lookup, pass the canonical signal_addr into session_lock_for()
and process_prekey_bundle(), and ensure any session storage/locking consistently
uses that canonical JID rather than the raw jid.

In `@wacore/src/send.rs`:
- Around line 320-335: encrypt_for_devices is a public low-level function that
mutates Signal sessions and must be run under the per-sender session lock; to
fix, do one of two: (A) hide this primitive (make encrypt_for_devices
non-public) and add a locked wrapper method on the Signal client (e.g.,
client.signal().encrypt_for_devices_locked(...)) that acquires the appropriate
session_locks for the sender (and message_enqueue_locks for chat-level
serialization if applicable) before calling encrypt_for_devices, or (B) keep it
public but add a mandatory documentation contract and runtime assert that the
caller holds the per-sender session lock (using session_locks) and serialize
caller usage via message_enqueue_locks where needed; update visibility and add
the wrapper on the type that exposes SignalStores (client.signal()) and
reference the symbols encrypt_for_devices, SignalStores, session_locks,
message_enqueue_locks, and client.signal() so callers use the safe locked API.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d926d68c-8afe-4b01-9d50-1eefc3302e45

📥 Commits

Reviewing files that changed from the base of the PR and between 36ead37 and f84b8c8.

📒 Files selected for processing (11)
  • src/client.rs
  • src/client/sessions.rs
  • src/features/mod.rs
  • src/features/signal.rs
  • src/lib.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/store/signal_adapter.rs
  • wacore/src/send.rs
  • wacore/src/types/events.rs

Comment thread src/client.rs Outdated
Comment thread src/features/signal.rs
Comment thread src/features/signal.rs Outdated
Comment thread src/features/signal.rs Outdated
Comment thread src/send.rs
Comment on lines 1326 to +1328
// Dedup via session_locks — bare JID won't collide with protocol addresses ("user:device")
let bare = sender.to_non_ad().to_string();
let mutex = self
.session_locks
.get_with_by_ref(bare.as_str(), async {
std::sync::Arc::new(async_lock::Mutex::new(()))
})
.await;
let mutex = self.session_lock_for(&bare).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Deduplicate reissue using the same canonical key you use for tc-token storage.

sender.to_non_ad() is different for PN and LID aliases of the same contact, but both paths later collapse to the same tc-token row. That lets duplicate fire-and-forget tasks issue the same IQ and race on the same backend key.

♻️ Proposed fix
-        // Dedup via session_locks — bare JID won't collide with protocol addresses ("user:device")
-        let bare = sender.to_non_ad().to_string();
-        let mutex = self.session_lock_for(&bare).await;
+        let token_jid = self.resolve_to_lid_jid(sender).await.user;
+        let mutex = self
+            .session_lock_for(&format!("tctoken:{token_jid}"))
+            .await;
         let Some(_guard) = mutex.try_lock() else {
             return;
         };
-
-        let token_jid = if sender.is_lid() {
-            sender.user.clone()
-        } else {
-            match self.lid_pn_cache.get_current_lid(&sender.user).await {
-                Some(lid) => lid,
-                None => sender.user.clone(),
-            }
-        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/send.rs` around lines 1326 - 1328, The dedup lock uses sender.to_non_ad()
which differs between PN and LID aliases and thus can allow duplicate tasks to
race on the same tc-token row; replace the computation of bare and the lock
acquisition to use the exact canonical key used for tc-token storage (i.e., call
the same helper you use when reading/writing tc-token rows) instead of
sender.to_non_ad().to_string(), then pass that canonical key into
self.session_lock_for(...) so session_lock_for and tc-token operations use the
identical identifier.

@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: 2

♻️ Duplicate comments (3)
src/features/signal.rs (3)

45-58: ⚠️ Potential issue | 🟠 Major

Session state mutations are not flushed to persistent storage.

After message_encrypt advances the Signal ratchet, changes remain only in the in-memory cache. Without calling flush_signal_cache(), a reconnect or process restart will roll back session state, causing message decryption failures on the peer side.

This applies to all mutating paths in this file: encrypt_message, decrypt_message, encrypt_group_message, decrypt_group_message, and create_participant_nodes.

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

In `@src/features/signal.rs` around lines 45 - 58, The Signal ratchet state
changes performed by message_encrypt (and other mutating functions
encrypt_message, decrypt_message, encrypt_group_message, decrypt_group_message,
create_participant_nodes) are not persisted; after calling these functions you
must call adapter.flush_signal_cache().await and propagate any errors so state
is written to persistent storage; locate the match block handling
CiphertextMessage in encrypt_message (and the equivalent return paths in
decrypt_message, encrypt_group_message, decrypt_group_message,
create_participant_nodes) and insert a call to flush_signal_cache().await before
returning Ok(...), handling/returning failures from flush_signal_cache() so the
function fails rather than silently losing state.

97-98: ⚠️ Potential issue | 🟠 Major

Hard-coded v2 unpadding breaks raw byte round-trips.

encrypt_message accepts arbitrary raw bytes, but decrypt_message always calls unpad_message_ref(&padded, 2). This makes raw round-trips impossible and ignores the actual stanza v value. Consider either returning raw bytes from this low-level API or accepting an explicit padding version parameter.

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

In `@src/features/signal.rs` around lines 97 - 98, decrypt_message currently
hard-codes unpadding with MessageUtils::unpad_message_ref(&padded, 2), which
breaks round-trips for arbitrary raw bytes and ignores the stanza version;
change decrypt_message (or the low-level API) to derive the padding version from
the stanza (use the stanza.v field) or accept an explicit padding_version
parameter and pass that into MessageUtils::unpad_message_ref, or alternatively
document/implement that the API returns raw padded bytes; update references in
encrypt_message and any callers to use the matching padding behavior so
round-trips succeed.

117-124: ⚠️ Potential issue | 🟠 Major

Hardcoded phone-number JID ignores LID-addressing groups.

This always uses .pn from the device snapshot, but sender-key groups can use LID addressing. When the group is LID-addressed, the SKDM and ciphertext will be created under the wrong sender identity, causing peers to look up a different sender-key record.

Use Client::get_own_jid_for_group(group_jid) which respects the group's addressing mode.

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

In `@src/features/signal.rs` around lines 117 - 124, The code currently obtains
the sender JID from the device snapshot via
self.client.persistence_manager.get_device_snapshot().pn which forces PN
addressing and breaks LID-addressed groups; replace that logic in the section
that computes own_jid for group messages by calling the client helper that
respects group addressing: Client::get_own_jid_for_group(group_jid) (e.g.,
self.client.get_own_jid_for_group(group_jid)). Ensure you pass the group JID
being handled and propagate any resulting error (same anyhow!("not logged in")
style or appropriate error) instead of using .pn so sender-key records and
ciphertext are created under the correct identity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/features/signal.rs`:
- Around line 148-171: Add the same concurrency caveat to
decrypt_group_message's doc comment (matching encrypt_group_message) or
implement locking around sender-key ops: protect calls that access
adapter.sender_key_store (in decrypt_group_message and any sender-key paths)
with the same mutex used by encrypt_group_message (or introduce a SenderKey lock
in the client) to prevent concurrent encrypt/decrypt races; also replace the
hard-coded unpad version argument in decrypt_group_message (currently
MessageUtils::unpad_message_ref(&padded, 2)) with the shared/central version
constant or API (e.g., use MessageUtils::DEFAULT_VERSION or a
MessageUtils::unpad_message_ref(&padded,
MessageUtils::detected_version(&padded))) so the unpad version is not
hard-coded.
- Around line 186-206: delete_sessions currently acquires per-JID session locks
in caller order which can deadlock against other callers that sort lock keys
(e.g. create_participant_nodes/build_session_lock_keys); to fix, compute the
canonical lock ordering before acquiring any locks (reuse the same
build_session_lock_keys logic or sort by the same key used by
build_session_lock_keys), iterate over the sorted keys to acquire each session
lock via session_lock_for, and then perform the signal_cache.delete_session and
backend.delete_session calls for each corresponding Jid (map sorted keys back to
their Jid) so locks are always taken in the same global order; update
delete_sessions to use this sorted acquisition strategy (functions to touch:
delete_sessions, session_lock_for, build_session_lock_keys,
signal_cache.delete_session, backend.delete_session).

---

Duplicate comments:
In `@src/features/signal.rs`:
- Around line 45-58: The Signal ratchet state changes performed by
message_encrypt (and other mutating functions encrypt_message, decrypt_message,
encrypt_group_message, decrypt_group_message, create_participant_nodes) are not
persisted; after calling these functions you must call
adapter.flush_signal_cache().await and propagate any errors so state is written
to persistent storage; locate the match block handling CiphertextMessage in
encrypt_message (and the equivalent return paths in decrypt_message,
encrypt_group_message, decrypt_group_message, create_participant_nodes) and
insert a call to flush_signal_cache().await before returning Ok(...),
handling/returning failures from flush_signal_cache() so the function fails
rather than silently losing state.
- Around line 97-98: decrypt_message currently hard-codes unpadding with
MessageUtils::unpad_message_ref(&padded, 2), which breaks round-trips for
arbitrary raw bytes and ignores the stanza version; change decrypt_message (or
the low-level API) to derive the padding version from the stanza (use the
stanza.v field) or accept an explicit padding_version parameter and pass that
into MessageUtils::unpad_message_ref, or alternatively document/implement that
the API returns raw padded bytes; update references in encrypt_message and any
callers to use the matching padding behavior so round-trips succeed.
- Around line 117-124: The code currently obtains the sender JID from the device
snapshot via self.client.persistence_manager.get_device_snapshot().pn which
forces PN addressing and breaks LID-addressed groups; replace that logic in the
section that computes own_jid for group messages by calling the client helper
that respects group addressing: Client::get_own_jid_for_group(group_jid) (e.g.,
self.client.get_own_jid_for_group(group_jid)). Ensure you pass the group JID
being handled and propagate any resulting error (same anyhow!("not logged in")
style or appropriate error) instead of using .pn so sender-key records and
ciphertext are created under the correct identity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: bcb24335-4f9b-4f6f-99a7-6e14f6a90f5a

📥 Commits

Reviewing files that changed from the base of the PR and between f84b8c8 and e0c7703.

📒 Files selected for processing (2)
  • src/features/signal.rs
  • wacore/src/send.rs

Comment thread src/features/signal.rs
Comment on lines +148 to +171
/// Decrypt a group (sender-key) message.
pub async fn decrypt_group_message(
&self,
group_jid: &Jid,
sender_jid: &Jid,
ciphertext: &[u8],
) -> Result<Vec<u8>> {
let sender_key_name = SenderKeyName::new(
group_jid.to_string(),
sender_jid.to_protocol_address().to_string(),
);

let mut adapter = self.client.signal_adapter().await;

let padded = wacore::libsignal::protocol::group_decrypt(
ciphertext,
&mut adapter.sender_key_store,
&sender_key_name,
)
.await?;

let unpadded = MessageUtils::unpad_message_ref(&padded, 2)?;
Ok(unpadded.to_vec())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Sender-key decryption also lacks concurrency protection.

The doc comment on encrypt_group_message warns it's not safe to call concurrently with decrypt_group_message, but decrypt_group_message has no corresponding warning. Consider adding the same caveat here for API symmetry, or implementing proper locking for sender-key operations.

Also, line 169 has the same hard-coded v2 unpadding issue as decrypt_message.

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

In `@src/features/signal.rs` around lines 148 - 171, Add the same concurrency
caveat to decrypt_group_message's doc comment (matching encrypt_group_message)
or implement locking around sender-key ops: protect calls that access
adapter.sender_key_store (in decrypt_group_message and any sender-key paths)
with the same mutex used by encrypt_group_message (or introduce a SenderKey lock
in the client) to prevent concurrent encrypt/decrypt races; also replace the
hard-coded unpad version argument in decrypt_group_message (currently
MessageUtils::unpad_message_ref(&padded, 2)) with the shared/central version
constant or API (e.g., use MessageUtils::DEFAULT_VERSION or a
MessageUtils::unpad_message_ref(&padded,
MessageUtils::detected_version(&padded))) so the unpad version is not
hard-coded.

Comment thread src/features/signal.rs
…order

- RawNode dispatch moved before xmlstreamend/IQ-response early returns
  so all decoded stanzas reach external observers
- Add flush_signal_cache() after all Signal-mutating operations in the
  feature API (encrypt_message, decrypt_message, encrypt_group_message,
  decrypt_group_message, create_participant_nodes) — the signal cache
  uses deferred writes and needs explicit flushing
- decrypt_message/decrypt_group_message now return raw padded bytes
  instead of hardcoding v2 unpadding — callers use
  MessageUtils::unpad_message_ref with the stanza's v attribute
- encrypt_group_message uses get_own_jid_for_group() instead of
  hardcoded .pn, respecting LID-addressing groups
- Add #[non_exhaustive] to Event enum for forward compatibility

@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: 19a5b6145b

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread src/features/signal.rs Outdated
Comment on lines +38 to +39
let signal_addr = jid.to_protocol_address();
let signal_addr_str = jid.to_protocol_address_string();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve PN JIDs to LID before Signal encrypt/decrypt

When a contact has migrated to LID sessions (a common path in this codebase’s send flow), encrypt_message/decrypt_message use jid.to_protocol_address() directly instead of first normalizing with resolve_encryption_jid. In that scenario, callers passing a PN JID will look up/mutate the wrong session key, causing unexpected SessionNotFound/decrypt failures even though a valid LID session exists; this diverges from the normal send path that does LID-first resolution.

Useful? React with 👍 / 👎.

… API

- encrypt_message/decrypt_message: resolve PN→LID via
  resolve_encryption_jid before Signal session lookup, matching the
  internal send/receive paths that do LID-first resolution
- encrypt_message/decrypt_message: derive lock key from ProtocolAddress
  display cache instead of computing to_signal_address_string() twice
- decrypt_group_message: use to_protocol_address_string() directly for
  SenderKeyName, avoiding intermediate ProtocolAddress allocation
- decrypt_group_message: add concurrency caveat matching encrypt side
- delete_sessions: sort lock keys to prevent AB/BA deadlocks with
  create_participant_nodes; hoist get_device_arc() outside loop
@jlucaso1

jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 5s —— View job


Code Review: Signal Protocol Feature API (Round 2)

  • Read all changed files in the PR
  • Analyze fixes from previous review rounds
  • Check for remaining issues
  • Post review

The previous review rounds caught real issues — lock ordering in delete_sessions, missing flush_signal_cache(), hardcoded v2 unpadding, PN-only own_jid for groups, and RawNode dispatch ordering. All have been addressed across commits e0c770319a5b61b403569. Here's my assessment of the current state:

Previously flagged issues — now fixed

Issue Status
Lock ordering inversion in delete_sessions Fixed — session locks acquired in sorted order before device lock
Missing session locks in create_participant_nodes Fixed — uses build_session_lock_keys pattern
Missing flush_signal_cache() calls Fixed — all 5 mutating paths flush on success
Hardcoded v2 unpadding Fixed — returns raw padded bytes, docs point to unpad_message_ref
PN-only own_jid for groups Fixed — uses get_own_jid_for_group()
RawNode dispatch after early returns Fixed — moved before xmlstreamend check
Event breaking change Fixed — #[non_exhaustive] added
PN→LID resolution in encrypt/decrypt Fixed — uses resolve_encryption_jid()
drop(_guard) before flush Done correctly in encrypt/decrypt paths

Remaining observations

1. validate_session and delete_sessions don't resolve PN→LIDsignal.rs:193, signal.rs:214

encrypt_message and decrypt_message resolve PN→LID via resolve_encryption_jid(), but validate_session and delete_sessions use the raw JID directly. If a caller passes a PN JID but the session was established under the corresponding LID (which is the common case after migration), validate_session will return false and delete_sessions will miss the session.

This is a minor API consistency issue — callers who use the encrypt/decrypt APIs would naturally pass the same JIDs here. But it could be a footgun for external consumers who pass PN JIDs from their contact list.

Suggestion (non-blocking): Either resolve PN→LID in these methods too, or document that callers should pass the same JID form used for encrypt/decrypt.

Fix this →

2. send_raw_bytes is public without rate limiting or size guardclient.rs:858

This is a powerful primitive that bypasses node marshaling and the sent_node_waiter mechanism. It sends arbitrary bytes through the noise socket. While this is intentional for pre-serialized stanzas, there's no size check or documentation warning about protocol-level consequences (e.g., sending malformed data will get the socket closed by the server).

Suggestion (non-blocking): Add a brief doc comment noting that the bytes must be a valid marshaled WABinary stanza.

3. lock_arc() vs lock() in delete_sessionssignal.rs:222

delete_sessions uses lock_arc() while all other lock sites use lock(). This works correctly (the Arc keeps the lock alive), but the inconsistency is worth noting. lock_arc() was likely chosen because the locks are collected into a Vec that outlives the loop iteration, which is the right call here.

Summary

The PR is in good shape after 3 rounds of fixes. The DRY consolidation is clean, the Signal feature API follows the established pattern, and the critical concurrency/persistence issues from earlier reviews are resolved. The two non-blocking suggestions above are minor consistency items.

Verdict: Looks good to merge once the PN→LID resolution consistency is considered (can be a follow-up).


@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

117-176: ⚠️ Potential issue | 🟠 Major

Sender-key APIs still race each other.

encrypt_group_message() and decrypt_group_message() both mutate adapter.sender_key_store, but neither takes any internal mutex for the SenderKeyName. The doc warning only moves the burden to callers; concurrent use on the same group/sender can still corrupt sender-key state or produce ciphertext/plaintext from different ratchet steps. Please add an internal cached lock keyed by the sender-key identity and hold it through flush_signal_cache().

🔒 Suggested direction
     pub async fn encrypt_group_message(
         &self,
         group_jid: &Jid,
         plaintext: &[u8],
     ) -> Result<(Vec<u8>, Vec<u8>)> {
         let own_jid = self.client.get_own_jid_for_group(group_jid).await?;
+        let lock = self.client.sender_key_lock_for(group_jid, &own_jid).await;
+        let _guard = lock.lock().await;
 
         let mut adapter = self.client.signal_adapter().await;
@@
     pub async fn decrypt_group_message(
         &self,
         group_jid: &Jid,
         sender_jid: &Jid,
         ciphertext: &[u8],
     ) -> Result<Vec<u8>> {
+        let lock = self.client.sender_key_lock_for(group_jid, sender_jid).await;
+        let _guard = lock.lock().await;
         let sender_key_name = SenderKeyName::new(
             group_jid.to_string(),
             sender_jid.to_protocol_address().to_string(),
         );
As per coding guidelines, "Use `session_locks` to serialize per-sender Signal encrypt/decrypt operations and `message_enqueue_locks` to serialize per-chat incoming message processing".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/signal.rs` around lines 117 - 176, encrypt_group_message and
decrypt_group_message both mutate adapter.sender_key_store without per-sender
locking; add a cached per-sender lock (using the existing session_locks
mechanism) keyed by the SenderKeyName (or group_jid+sender_jid identity) and
acquire it at the start of encrypt_group_message and decrypt_group_message, hold
the lock across calls to wacore functions and through the call to
self.client.flush_signal_cache(), then release the lock; ensure the lock key
generation matches SenderKeyName construction so concurrent ops for the same
sender/group serialize while different keys remain parallel.
wacore/src/types/events.rs (1)

382-460: ⚠️ Potential issue | 🟠 Major

This is still a breaking public Event change.

#[non_exhaustive] makes future additions easier, but adding it to an existing public enum still forces downstream exhaustive matches to add a wildcard arm. If this release is meant to stay non-breaking, this needs a different surface; otherwise it needs the corresponding breaking-version bump and release note.

In Rust, is marking an existing public enum as `#[non_exhaustive]` a breaking API change for downstream crates that match it exhaustively?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/events.rs` around lines 382 - 460, The PR added
#[non_exhaustive] to the public enum Event which is a breaking change for
downstream crates that previously exhaustively matched Event; either remove the
#[non_exhaustive] attribute from Event to keep the API backwards-compatible, or
if you intend a breaking release, keep the attribute but update the crate
version with a breaking semver bump and add a release note calling out the
changed Event enum so downstream consumers can adjust their match arms; locate
the enum by the Event type in wacore/src/types/events.rs (and any usages of
RawNode/variants) to apply the chosen fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/src/types/events.rs`:
- Around line 454-459: The Event enum currently derives Serialize but has the
RawNode(Arc<Node>) variant marked with #[serde(skip)], which breaks derived
serialization when raw-node forwarding is enabled; implement a custom Serialize
for Event (replace the derive on Event) that serializes every variant as before
but special-cases RawNode to either omit it (skip serialization) or emit a safe
placeholder (e.g., unit or null) so serializing Event never errors; reference
the Event enum and the RawNode(Arc<Node>) variant when making the change and
keep manual/bridge-level serialization for the actual Arc<Node> in the bridge
code.

---

Duplicate comments:
In `@src/features/signal.rs`:
- Around line 117-176: encrypt_group_message and decrypt_group_message both
mutate adapter.sender_key_store without per-sender locking; add a cached
per-sender lock (using the existing session_locks mechanism) keyed by the
SenderKeyName (or group_jid+sender_jid identity) and acquire it at the start of
encrypt_group_message and decrypt_group_message, hold the lock across calls to
wacore functions and through the call to self.client.flush_signal_cache(), then
release the lock; ensure the lock key generation matches SenderKeyName
construction so concurrent ops for the same sender/group serialize while
different keys remain parallel.

In `@wacore/src/types/events.rs`:
- Around line 382-460: The PR added #[non_exhaustive] to the public enum Event
which is a breaking change for downstream crates that previously exhaustively
matched Event; either remove the #[non_exhaustive] attribute from Event to keep
the API backwards-compatible, or if you intend a breaking release, keep the
attribute but update the crate version with a breaking semver bump and add a
release note calling out the changed Event enum so downstream consumers can
adjust their match arms; locate the enum by the Event type in
wacore/src/types/events.rs (and any usages of RawNode/variants) to apply the
chosen fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ade7452c-4d6e-42ad-a273-71f7d1c9dce9

📥 Commits

Reviewing files that changed from the base of the PR and between e0c7703 and 19a5b61.

📒 Files selected for processing (3)
  • src/client.rs
  • src/features/signal.rs
  • wacore/src/types/events.rs

Comment on lines +454 to +459
/// Raw decoded stanza, emitted before router dispatch.
/// Allows external consumers to observe protocol-level nodes (e.g. for voice calls).
/// Gated by `Client::set_raw_node_forwarding(true)` to avoid overhead when unused.
/// Serialized manually in the bridge (not via serde).
#[serde(skip)]
RawNode(Arc<Node>),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For Serde in Rust, what happens when serializing an enum variant marked #[serde(skip)]?

💡 Result:

When serializing an enum variant marked with #[serde(skip)] in Rust using Serde's derive macros, serialization fails with an error. The generated Serialize impl explicitly returns an error like "the enum variant MyEnum::SkippedVariant cannot be serialized" if code attempts to serialize that variant. This prevents serialization of skipped variants entirely, unlike fields where #[serde(skip_serializing)] unconditionally skips them. This behavior is documented in the official Serde variant attributes page, which states #[serde(skip)] means "Never serialize or deserialize this variant." GitHub issues confirm attempting to serialize such a variant fails, as the derive code generates an error branch for skipped variants. There are known inconsistencies with binary formats like bincode due to variant indices, but for self-describing formats like JSON, the primary effect is the serialization error when hitting the skipped variant. To conditionally skip serialization of a variant's data (e.g., serialize only the tag), use #[serde(skip_serializing)] on its fields instead, or implement a custom Serialize impl.

Citations:


🏁 Script executed:

# Read the Event enum definition around the RawNode variant
sed -n '440,470p' wacore/src/types/events.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 1264


🏁 Script executed:

# Search for where Event is serialized in the codebase
rg -t rs 'serialize.*Event|Event.*serialize' --context 2

Repository: jlucaso1/whatsapp-rust

Length of output: 92


🏁 Script executed:

# Check if there's a custom Serialize impl for Event
ast-grep --pattern 'impl Serialize for Event { $$$ }'

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Search for bridge or logger code that might serialize Event
rg -t rs -i 'bridge|logger' --context 1 | grep -A 5 -B 5 'Event'

Repository: jlucaso1/whatsapp-rust

Length of output: 92


🏁 Script executed:

# Check for Serialize derive on Event enum
rg 'pub enum Event' wacore/src/types/events.rs -A 5

Repository: jlucaso1/whatsapp-rust

Length of output: 222


🏁 Script executed:

# Search for serialization of Event in the codebase
rg 'serde_json::to|serde_json::value|\.serialize\(' --max-count 20

Repository: jlucaso1/whatsapp-rust

Length of output: 10896


🏁 Script executed:

# Look for bridge-related files that might serialize Event
fd -t f -i bridge

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Check what the Event enum derives
head -100 wacore/src/types/events.rs | grep -A 10 'pub enum Event'

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Look for Event enum definition with its derive macros
rg 'derive.*Serialize' wacore/src/types/events.rs -B 5 | head -30

Repository: jlucaso1/whatsapp-rust

Length of output: 729


🏁 Script executed:

# Check for custom Serialize impl for Event
rg 'impl.*Serialize.*Event' wacore/src/types/events.rs -A 20

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Search for where Event is actually serialized (JSON, etc)
rg 'serde_json.*Event|Event.*json' --max-count 20

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Look for any references to RawNode in the codebase
rg 'RawNode' --max-count 20

Repository: jlucaso1/whatsapp-rust

Length of output: 393


🏁 Script executed:

# Search for bridge-related code that might serialize Event
rg 'bridge|Bridge' wacore/src --type rust -l

Repository: jlucaso1/whatsapp-rust

Length of output: 155


🏁 Script executed:

# Look in src/ directory for bridge code
rg 'bridge|Bridge' src --type rust -l

Repository: jlucaso1/whatsapp-rust

Length of output: 89


🏁 Script executed:

# Search more broadly for any code that serializes Event to JSON or other formats
rg 'Event' wacore/src/types/events.rs | grep -i serialize

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Check if there's a custom Serialize impl that handles Event specially
rg 'impl.*Serialize' wacore/src/types/events.rs -A 30

Repository: jlucaso1/whatsapp-rust

Length of output: 2367


🏁 Script executed:

# Check the bridge implementations found
cat wacore/src/store/in_memory.rs | head -100

Repository: jlucaso1/whatsapp-rust

Length of output: 3244


🏁 Script executed:

# Look for serialization patterns in in_memory bridge
rg 'serialize.*Event|Event.*serialize|serde_json' wacore/src/store/in_memory.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Check message_processing for bridge-related serialization
rg 'serialize.*Event|serde_json.*Event' wacore/src/message_processing.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Check if there's an actual bridge module or FFI layer
fd -t f 'bridge|ffi' --hidden

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Look for any logger that might serialize Event
rg -i 'logger|log.*Event|Event.*log' --max-count 20

Repository: jlucaso1/whatsapp-rust

Length of output: 15053


RawNode cannot round-trip through Event's derived Serialize.

Event derives Serialize, but the RawNode variant is marked with #[serde(skip)], which causes serialization to error rather than silently omit the value. Any generic bridge, logger, or telemetry that serializes Event directly will fail as soon as raw-node forwarding is enabled.

The code comment notes manual serialization in the bridge, but there's no custom Serialize impl to enforce this. Implement a custom serializer or use a bridge-specific DTO to prevent accidental failures.

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

In `@wacore/src/types/events.rs` around lines 454 - 459, The Event enum currently
derives Serialize but has the RawNode(Arc<Node>) variant marked with
#[serde(skip)], which breaks derived serialization when raw-node forwarding is
enabled; implement a custom Serialize for Event (replace the derive on Event)
that serializes every variant as before but special-cases RawNode to either omit
it (skip serialization) or emit a safe placeholder (e.g., unit or null) so
serializing Event never errors; reference the Event enum and the
RawNode(Arc<Node>) variant when making the change and keep manual/bridge-level
serialization for the actual Arc<Node> in the bridge code.

@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: b403569b88

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread src/features/signal.rs Outdated
// and the DM send path, preventing AB/BA deadlocks.
let mut keyed: Vec<(String, &Jid)> = jids
.iter()
.map(|jid| (jid.to_protocol_address_string(), jid))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve PN to LID before deleting Signal sessions

When callers pass a PN JID for a contact whose active session was migrated to LID, this method computes lock/delete keys from the PN address and never touches the LID session entry. The call can return Ok(()) while the real session remains usable, and the lock key also won’t serialize against the session actually used by send/decrypt paths (which normalize via LID-first logic). Normalize each input JID with resolve_encryption_jid before building lock keys and deleting.

Useful? React with 👍 / 👎.

Comment thread src/features/signal.rs Outdated

/// Check whether a Signal session exists for `jid`.
pub async fn validate_session(&self, jid: &Jid) -> Result<bool> {
let signal_addr = jid.to_protocol_address();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize JIDs when checking session existence

validate_session checks only jid.to_protocol_address(), so a PN JID will report false if the established session is stored under the contact’s LID address. That yields false negatives for migrated users and can drive incorrect caller behavior (e.g., unnecessary session re-establishment). This should use the same PN→LID resolution used by encrypt/decrypt paths.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/features/signal.rs`:
- Around line 210-236: delete_sessions currently derives lock keys from
jid.to_protocol_address_string(), which is inconsistent with
encrypt_message/decrypt_message and build_session_lock_keys (they use
resolve_encryption_jid), causing ineffective locking and wrong session
deletions; fix by resolving each JID with resolve_encryption_jid before building
keys and before deleting sessions so locking and deletion use the same canonical
address: update the keyed construction in delete_sessions to call
resolve_encryption_jid(jid) and
to_protocol_address_string()/to_protocol_address() on the resolved JID, perform
deduping on those resolved keys, acquire session locks from
session_lock_for(resolved_key), and call signal_cache.delete_session and
device_store.backend.delete_session with the resolved addr (use the same
addr.as_str()) so behavior matches create_participant_nodes,
build_session_lock_keys, encrypt_message, and decrypt_message.
- Around line 192-202: validate_session currently builds the signal address with
jid.to_protocol_address(), which can mismatch encrypt_message/decrypt_message
that first call resolve_encryption_jid; change validate_session to resolve the
jid via resolve_encryption_jid(jid) and then call to_protocol_address() on the
resolved Jid before checking the session so
signal_cache.has_session(&signal_addr, &*device_guard.backend) uses the same
PN→LID-resolved address as encrypt_message/decrypt_message; keep the same
device_store/read and error mapping logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0d7e6694-21a4-45fb-872f-ef5eb7f6abd1

📥 Commits

Reviewing files that changed from the base of the PR and between 19a5b61 and b403569.

📒 Files selected for processing (1)
  • src/features/signal.rs

Comment thread src/features/signal.rs
Comment thread src/features/signal.rs Outdated
@jlucaso1

jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

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


Code Review: Signal Protocol Feature API (Round 3)

  • Read all changed files (current state)
  • Verify previously flagged issues are fixed
  • Check for new/remaining issues
  • Post review

All critical issues from previous rounds have been addressed. Here's the current state:

Previously flagged issues — all resolved

Issue Status Commit
Lock ordering inversion in delete_sessions Fixed — per-JID lock + resolve ef35301
Missing session locks in create_participant_nodes Fixed — build_session_lock_keys pattern b403569
Missing flush_signal_cache() calls Fixed — all 5 mutating paths flush 19a5b61
Hardcoded v2 unpadding Fixed — returns raw padded bytes 19a5b61
PN-only own_jid for groups Fixed — get_own_jid_for_group() 19a5b61
RawNode dispatch after early returns Fixed — moved before xmlstreamend check 19a5b61
Event breaking change Fixed — #[non_exhaustive] added 19a5b61
PN→LID in validate_session/delete_sessions Fixed — resolve_encryption_jid() ef35301

Remaining observations (non-blocking)

1. delete_sessions acquires locks one-at-a-time in iteration ordersignal.rs:237-253

The current code acquires one lock per loop iteration, which means it acquires-and-releases sequentially rather than holding all locks simultaneously. This is fine for correctness (no AB/BA deadlock since only one lock is held at a time), but it means a concurrent encrypt_message call between iterations could interleave — e.g., delete session A, then encrypt races on session B before it's deleted. Compare with create_participant_nodes which holds all locks simultaneously.

For a delete_sessions use case this is likely acceptable (deleting is destructive anyway), but worth noting the behavioral difference.

2. #[serde(skip)] on RawNode will error if serializedevents.rs:457

Event derives Serialize, and #[serde(skip)] on an enum variant causes serde to return an error (not silently skip) when that variant is serialized. If any consumer serializes Event values generically (e.g., logging, telemetry), enabling raw-node forwarding will cause runtime errors. Currently the codebase doesn't appear to serialize Event directly, so this is safe today, but it's a latent footgun.

Suggestion: Either implement a custom Serialize that maps RawNode to a unit/null placeholder, or add a doc comment warning that this variant will error on serialization.

3. encrypt_for_devices doc comment is goodwacore/src/send.rs:328-331

The doc comment now explicitly states "Callers must hold per-device session locks before calling this function" — this addresses the concern about the newly-public API being misusable.

4. send_raw_bytes doc comment is goodclient.rs:857-864

The doc now warns about malformed data and explains it bypasses node logging. Well done.

DRY consolidation verification

All refactored sites are strict 1:1 replacements:

Helper Verified in
signal_adapter() message.rs, retry.rs, send.rs
session_lock_for() message.rs, retry.rs, send.rs
get_noise_socket() send_raw_bytes, send_node (implicit)
as_signal_stores() send.rs, signal.rs

The tokio::spawnself.runtime.spawn().detach() change in message.rs:933-940 is correct (fire-and-forget tctoken reissue aligned with runtime abstraction).

Verdict

The PR is in good shape. All critical concurrency, persistence, and correctness issues from earlier rounds are fixed. The two non-blocking observations above are minor and could be addressed in follow-ups. LGTM.


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