Skip to content

fix: WA Web-compliant DM multi-device fanout with phash validation - #524

Merged
jlucaso1 merged 8 commits into
mainfrom
fix/dm-multi-device-fanout
Apr 14, 2026
Merged

fix: WA Web-compliant DM multi-device fanout with phash validation#524
jlucaso1 merged 8 commits into
mainfrom
fix/dm-multi-device-fanout

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Reworks DM multi-device encryption to match WA Web's actual architecture, verified against captured JS (WAWebSendUserMsgJob, WAWebDBDeviceListFanout, WAWebApiDeviceList, WAWebSendMsgCreateFanoutStanza).

Based on the work in #523 by @mcaldas — encrypts for all known recipient devices instead of bare JID only, fixing "Waiting for this message" on linked devices.

Changes

  • Local registry first: read device list from local cache/DB on the send path (WA Web reads IndexedDB only, no network I/O). Network fetch via get_user_devices only on cache miss, errors non-fatal — falls back to bare JID (server fanout)
  • Both recipient AND own devices use the local-first pattern — no hard .await? failure on either
  • Hosted device filtering: exclude device 99 / @hosted / @hosted.lid from DM fanout (WAWebDBDeviceListFanout skips device.id === 99 || device.isHosted)
  • Bare JID fallback only when no local record exists — if record exists but all devices are filtered, don't invent a bare JID target (matches WA Web without biz-coex gate)
  • DM phash validation: compute participant_list_hash and embed in stanza phash attr + DeviceSentMessage.phash field. Server can now signal device-list drift via ack, and spawn_phash_validation invalidates device cache on mismatch for self-healing
  • Avoid LID-migration side effects: check local cache before calling get_user_devices to avoid unnecessary write-heavy usync queries on the hot path

Not implemented

  • Single-<enc> fast path (WAWebSendMsgCreateFanoutStanza line 26): WA Web uses a bare <enc> node (no <participants> wrapper) when there's exactly 1 primary device. Our encrypt_for_devices always wraps in <to jid=...> nodes — implementing the fast path would require refactoring the encryption layer. The <participants> form is accepted by the server regardless.
  • isHosted per-device flag: WA Web stores isHosted per device in IDB records. Our DeviceInfo only has device_id + key_index. We rely on Jid::is_hosted() (device 99 + @hosted server) which covers the known hosted device patterns.

WA Web compliance matrix

Behavior WA Web This PR
Device list source on send path Local DB only (getDeviceIds → IndexedDB) Local registry only (get_devices_from_registry)
Network fetch on cache miss Never on send path (pre-populated via sync) Best-effort warming, errors non-fatal
Hosted device filtering device.id === 99 || device.isHosted Jid::is_hosted() (device 99 + @HosteD server)
Bare JID fallback Only when no local record Only when no local record
Phash in DM stanza Not sent (server returns in ack on mismatch) Sent in stanza attr + DSM field
Phash mismatch handling syncDeviceListJob → resend invalidate_device_cache → next send re-fetches
Single-enc fast path Yes (1 primary device) No (<participants> always)

Test plan

  • cargo fmt --all clean
  • cargo clippy --all --tests clean
  • All unit tests pass (944 tests, 0 failures)
  • E2E test failures are pre-existing (community + lid_sessions), verified identical on base branch
  • Manual test: send DM to user with WhatsApp Web active — message appears on all devices
  • Manual test: send DM with network down on first send — falls back to bare JID gracefully

Closes #523

Summary by CodeRabbit

  • Bug Fixes

    • Broader cache invalidation for 1:1 and status sends to keep per-user and sender device caches consistent.
    • Excluded sender device from DM delivery targets and deduplicated targets to prevent self-delivery.
  • Reliability & Performance

    • DM device discovery now prefers a local registry with fallback and skips hosted devices.
    • Participant hash (phash) is computed from the actual recipient+own device set and preserved through DM preparation; ACK phash handling updated.
  • Compatibility

    • Added a legacy key migration attempt to reduce decrypt failures and retry receipts.

@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 793422eb-1a3a-4e27-a70a-41322275a44d

📥 Commits

Reviewing files that changed from the base of the PR and between 0b7fe97 and 55997fb.

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

📝 Walkthrough

Walkthrough

DM send now resolves devices registry-first (fallback to network), builds per-device fanout including all recipient and companion devices (excluding the exact sender), computes and returns a DM participant hash (phash) via PreparedDmStanza, and invalidates user-scoped device cache on phash-mismatch ACKs for non-group/non-status recipients. Session-decrypt now attempts PN→LID migration on InvalidPreKeyId.

Changes

Cohort / File(s) Summary
DM send core
src/send.rs
Registry-first device lookups (get_devices_from_registry → fallback get_user_devices), build all_dm_jids from known recipient devices (fallback to bare JID) + own companion devices, filter out hosted devices, explicitly exclude sender device(s), deduplicate targets, and invalidate user-scoped device cache on ACK phash mismatch for 1:1 non-status recipients.
wacore DM stanza / phash
wacore/src/send.rs
prepare_dm_stanza now returns PreparedDmStanza { node, phash }; device partitioning and phash computed earlier from the actual sent-device list and propagated via the returned struct (stanza attrs no longer used to extract phash).
ACK handling / phash validation
src/send.rs (ACK paths)
ACK phash extraction adjusted to use returned prepared.phash (DM) and prepared.node; background phash validation now also invalidates per-user device cache for eligible 1:1 recipients; status send flips invalidate_group_cache.
Session decryption / migration
src/message.rs
On SignalProtocolError::InvalidPreKeyId during batch decrypt, attempt PN→LID legacy migration via try_pn_to_lid_migration_decrypt(...); treat migration success as a successful decrypt (skip retry receipt), otherwise retain prior invalid-key retry/undecryptable flow.

Sequence Diagram(s)

sequenceDiagram
  participant Sender as Sender Client
  participant Reg as Device Registry
  participant E2E as E2E Session Manager
  participant Server as WA Server

  Sender->>Reg: get_devices_from_registry(recipient, own)
  alt registry miss
    Reg-->>Sender: miss
    Sender->>Reg: get_user_devices(recipient/own) (network)
    Reg-->>Sender: devices
  else cached
    Reg-->>Sender: devices
  end
  Sender->>Sender: filter non-hosted, exclude exact sender, dedupe -> all_dm_jids
  Sender->>E2E: ensure_e2e_sessions(all_dm_jids)
  E2E-->>Sender: sessions ready
  Sender->>Sender: prepare_dm_stanza(all_dm_jids) => PreparedDmStanza(node, phash)
  Sender->>Server: send(prepared.node)
  Server-->>Sender: ack (phash)
  alt ack phash mismatch & recipient is user (not group/status)
    Sender->>Reg: invalidate_device_cache(recipient.user)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • Ari4ka

Poem

"I hopped through lists and counted every paw,
I left my own paw tucked back, excluded one I saw.
I stamped a tiny phash and tucked it in the node,
If ACKs dispute, I clear the cache down the road. 🐇"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: WA Web-compliant multi-device DM fanout with phash validation, which directly corresponds to the PR's core objectives of encrypting for all recipient devices and implementing phash validation.
Linked Issues check ✅ Passed The PR comprehensively implements the objectives from issue #523: encrypts DMs for all recipient devices instead of only bare JID, uses device-resolution results with safe fallback, filters hosted devices, and adds phash validation for device-cache self-healing on mismatch.
Out of Scope Changes check ✅ Passed All changes directly support the stated objectives: DM multi-device fanout improvements, phash validation, device-cache invalidation, and PN→LID migration handling in the post-identity-change path are all within scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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 fix/dm-multi-device-fanout

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.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 13, 2026

Copy link
Copy Markdown

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


Code Review: DM Multi-Device Fanout

  • Gather context and read changed files
  • Review src/send.rs changes
  • Review wacore/src/send.rs changes
  • Post review findings

Overall this is a well-structured change that aligns the DM send path with WA Web behavior. The local-registry-first approach, hosted device filtering, and phash validation are all sound. A few observations:

Potential issue: empty device list after hosted filtering

src/send.rs:1081-1088 — When get_devices_from_registry returns Some(devices), the hosted filter runs via .retain(). If every device in the registry record happens to be hosted (unlikely but possible), devices becomes empty. Unlike the None case which falls back to vec![recipient_bare], the Some(empty) case proceeds with zero recipient devices. This would produce a stanza with an empty <participants> node.

The PR description explicitly says "if record exists but all devices are filtered, don't invent a bare JID target (matches WA Web without biz-coex gate)" — so this appears intentional. Just flagging that this edge case would result in a message sent to nobody (no server fanout either). If in practice this only happens for business-coex accounts that WA Web also skips, it's fine.

Missing invalidate_device_cache in group phash handler (pre-existing)

src/send.rs:670 — The new client.invalidate_device_cache(&jid.user).await call is added inside spawn_phash_validation. This is the right fix: when the server signals a phash mismatch, the device registry was stale and needs invalidation. Without this, subsequent sends would re-use the same stale device list.

Note that this also runs for the group send path (line 504 calls spawn_phash_validation for status/group sends). Invalidating the device cache on group phash mismatch is reasonable — the server might be signaling that a participant's device list changed.

Graceful degradation for own devices

src/send.rs:1090-1094 — When own_cached is None (registry miss + network failure), own companion devices are silently excluded. This means self-sync won't work for that particular message — the sender's other devices won't receive a DeviceSentMessage. This is acceptable graceful degradation (the message still reaches the recipient), and matches the PR's stated "errors non-fatal" philosophy.

Phash computation and embedding

wacore/src/send.rs:709 — Phash is computed from all_devices before partition_dm_devices splits them, which is correct — it represents the full device set. The .ok() on participant_list_hash silently swallows hash errors (SHA-256 init failure), which is a fine choice since phash is advisory.

wacore/src/send.rs:715phash.clone().unwrap_or_default() embeds an empty string in the DSM proto when phash computation fails. Since the proto field is Option<String> and was previously Some(String::new()), this preserves backward compatibility.

wacore/src/send.rs:800-802 — Phash is set as a stanza attribute only when Some, so a failed hash computation means no phash attr on the stanza, which means spawn_phash_validation won't be registered (lines 1139-1152 in src/send.rs check for the attr). Clean fallthrough.

Minor: redundant reserve before append

src/send.rs:1092all_dm_jids.reserve(own_devices.len()) before .append(&mut own_devices)append already handles capacity internally via Vec::append which calls extend on the drain. The explicit reserve is harmless but unnecessary.

Style

Comments are concise and explain "why" per CLAUDE.md. The comment at wacore/src/send.rs:736-740 is a bit long for a "NOTE" but documents an intentional omission with a clear rationale, which is fine.


Verdict: The changes look correct and well-reasoned. The approach is sound — local-first device lookup, non-fatal network warming, hosted filtering, and phash validation for self-healing. No blocking issues found.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

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

Inline comments:
In `@src/send.rs`:
- Around line 1080-1096: The warmup currently calls
ensure_e2e_sessions(&all_dm_jids) before excluding the current sender device,
allowing a self-session to be created; update the code that builds all_dm_jids
(the match on recipient_cached and the handling of own_cached) to explicitly
filter out the current sender's full JID from own_devices (the same device JID
that prepare_dm_stanza later filters) before reserving/appending and before
calling ensure_e2e_sessions, so ensure_e2e_sessions never receives the sender's
device JID.
- Around line 669-670: The current call to
client.invalidate_device_cache(&jid.user) is wrong for group/status sends
because jid.user is the chat ID (group/status) not a participant account; change
the logic in send.rs so that invalidate_device_cache is only invoked for 1:1
sends (detect via the send path that handles direct JIDs) and for group or
status sends either do not call invalidate_device_cache with the chat ID or
instead accept and use the actual participant user IDs passed into the task to
invalidate each participant’s device cache; update the code paths that call
client.invalidate_device_cache and the task signature that triggers this call so
group/status flows pass the affected participant user IDs when eviction is
required.

In `@wacore/src/send.rs`:
- Around line 708-715: Compute participant_list_hash from the filtered DM fanout
(the list returned by partition_dm_devices / the variable holding the DM-target
devices) instead of from the raw all_devices; update both the
DeviceSentMessage.phash assignment and the stanza-level phash to use
MessageUtils::participant_list_hash(filtered_list). This ensures you call
MessageUtils::participant_list_hash on the same device list that is actually
sent (i.e., after removing the current sender device) so DeviceSentMessage.phash
and the stanza phash match the <participants> set.
🪄 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: c0d8b7f0-6caf-40a0-8ee8-3784d14d74d3

📥 Commits

Reviewing files that changed from the base of the PR and between 2194547 and 0845e02.

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

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

github-actions Bot commented Apr 13, 2026

Copy link
Copy Markdown

Benchmark Results

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

Benchmark Current Baseline Change
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 126,179,833 123,443,623 +2.2%
58 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,879 3,879 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 11,855 11,855 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,398 43,398 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 68,814 68,814 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,785 76,785 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,214 2,214 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,943 5,943 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 178,334 178,328 +0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 192,395 192,382 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 889,631 889,631 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 980,164 981,012 -0.1%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,466,136 1,466,140 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,672,963 2,666,458 +0.2%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 9,755,492 9,788,853 -0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 46,491,684 46,482,698 +0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,693,359 12,687,714 +0.0%
binary_benchmark::marshal_group::bench_marshal_allocating 95,742 95,742 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 95,775 95,775 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 114,155 114,155 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 102,854 102,854 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 95,842 95,842 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 15,748 15,748 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 15,792 15,792 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 17,581 17,581 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 533,115 533,115 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 532,681 532,681 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 534,042 534,042 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 13,423,541 13,423,541 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 13,367,806 13,367,806 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 26,668,743 26,668,743 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,498 2,498 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 38,500 38,500 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 785 785 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 556,214 556,214 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 5,024 5,024 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 7,483 7,483 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 90,792 90,792 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 7,510 7,510 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 90,828 90,828 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 8,838 8,838 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 104,662 104,662 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 477,570 477,570 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 13,468 13,468 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,334,562 17,110,056 +1.3%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 161,375 161,375 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,511,833 5,511,833 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 162,112 162,112 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 298,353 298,353 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 712,883 712,883 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,577,321 12,466,511 +0.9%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,686,156 27,458,744 +0.8%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 3,467,011 3,467,011 +0.0%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,474 2,830,474 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,003 46,003 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,106,732 5,106,732 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 317,585 317,585 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,247,117 14,247,117 +0.0%

@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: 0845e0269f

ℹ️ 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/send.rs Outdated
let recipient_plaintext = MessageUtils::encode_and_pad(&message_for_encryption);

// Compute phash before DSM so own companions learn the device set
let phash = MessageUtils::participant_list_hash(&all_devices).ok();

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 Compute DM phash from encrypted targets only

phash is derived from all_devices before partition_dm_devices removes the exact sending device, so the hash can include a device that never gets an <enc> payload. When the local own-device list contains the current sender (common for multi-device accounts), this makes the stanza-level phash inconsistent with the actual DM fanout set and can trigger false phash-mismatch invalidations (unnecessary cache/DB churn and avoidable re-syncs).

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

🤖 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/send.rs`:
- Around line 669-672: The device-cache invalidation currently runs for any
non-group chat but should skip the WhatsApp status broadcast JID (whose jid.user
== "status"); update the conditional around
client.invalidate_device_cache(&jid.user).await so it only runs when
!jid.is_group() AND jid.user != "status" (i.e., skip status@broadcast) to avoid
meaningless DB calls — change the check that surrounds invalidate_device_cache
to include this user check.
🪄 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: 45b8eef6-6b57-4f01-9e37-487f24ed8d19

📥 Commits

Reviewing files that changed from the base of the PR and between 0845e02 and 715c675.

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

Comment thread src/send.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 the current code and only fix it if needed.

Inline comments:
In `@src/send.rs`:
- Around line 1092-1104: After merging recipient_cached and own_cached into
all_dm_jids, remove duplicate device entries before the
is_sender-filter/ensure_e2e_sessions call: deduplicate all_dm_jids (e.g. by
turning it into a HashSet keyed by the device identity or by sorting+deduping on
the JID+device tuple) so that ensure_e2e_sessions() and prepare_dm_stanza()
never see duplicate companion devices; update the code around the all_dm_jids
manipulation (the block that appends own_devices and the subsequent retain using
own_lid) to perform this dedup step just before calling ensure_e2e_sessions().
🪄 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: f7a9e76a-453f-4ab4-9498-25cfdc6e77ba

📥 Commits

Reviewing files that changed from the base of the PR and between 715c675 and cf25577.

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

Comment thread src/send.rs

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

ℹ️ 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/send.rs
Comment on lines +1092 to +1094
if let Some(mut own_devices) = own_cached {
own_devices.retain(|j| !j.is_hosted());
all_dm_jids.append(&mut own_devices);

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 Deduplicate merged DM fanout devices

This merge path can duplicate targets when the recipient is your own account ("message yourself"): recipient_cached and own_cached both contain the same devices, and append adds them twice. The duplicated all_dm_jids is then used for DM encryption, so the stanza can contain repeated <to> payloads and a phash over duplicates, which can trigger unnecessary phash-mismatch invalidations and resend churn for self-DMs. Deduplicate the merged list (after PN/LID normalization) before session setup/encryption.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 force-pushed the fix/dm-multi-device-fanout branch from cf25577 to bcda340 Compare April 13, 2026 20:57

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

♻️ Duplicate comments (1)
src/send.rs (1)

1092-1104: ⚠️ Potential issue | 🟠 Major

Deduplicate all_dm_jids for self-DM (note-to-self) sends.

When to targets the current account, both recipient_cached and own_cached resolve to the same user's devices. After appending at Line 1094, all_dm_jids contains duplicates of companion devices. The sender filter (Lines 1100-1104) only removes the exact sender device, leaving other duplicates intact.

This causes duplicate <to> nodes in the stanza, incorrect phash computation, and redundant session warmup.

Suggested fix — deduplicate before ensure_e2e_sessions
     // Exclude exact sender device (WA Web: isMeDevice in getFanOutList)
     // so ensure_e2e_sessions never creates a self-session
     let own_lid = device_snapshot.lid.as_ref();
     all_dm_jids.retain(|j| {
         let is_sender = (j.is_same_user_as(own_jid) && j.device == own_jid.device)
             || own_lid.is_some_and(|lid| j.is_same_user_as(lid) && j.device == lid.device);
         !is_sender
     });

+    // Dedup for self-DMs where recipient and own devices overlap
+    wacore::types::jid::sort_dedup_by_device(&mut all_dm_jids);

     self.ensure_e2e_sessions(&all_dm_jids).await?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/send.rs` around lines 1092 - 1104, all_dm_jids can contain duplicate JIDs
when both recipient_cached and own_cached resolve to the same account, causing
duplicate <to> nodes and redundant session work; after appending own_devices
(the append at the block that uses own_cached) and after the sender filter that
removes only the exact sender, deduplicate all_dm_jids (e.g., by JID+device
identity) before calling ensure_e2e_sessions so only unique device JIDs are
used; locate the all_dm_jids variable in src/send.rs and apply a stable dedupe
(or HashSet-based unique filter) right after the retain that excludes the sender
and before ensure_e2e_sessions to prevent duplicate entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/send.rs`:
- Around line 1092-1104: all_dm_jids can contain duplicate JIDs when both
recipient_cached and own_cached resolve to the same account, causing duplicate
<to> nodes and redundant session work; after appending own_devices (the append
at the block that uses own_cached) and after the sender filter that removes only
the exact sender, deduplicate all_dm_jids (e.g., by JID+device identity) before
calling ensure_e2e_sessions so only unique device JIDs are used; locate the
all_dm_jids variable in src/send.rs and apply a stable dedupe (or HashSet-based
unique filter) right after the retain that excludes the sender and before
ensure_e2e_sessions to prevent duplicate entries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f88653bb-e4f5-4771-8c07-c4090d6e6a8c

📥 Commits

Reviewing files that changed from the base of the PR and between cf25577 and bcda340.

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

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

ℹ️ 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/send.rs
Comment on lines +670 to +672
if !jid.is_group() && !jid.is_status_broadcast() {
client.invalidate_device_cache(&jid.user).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.

P2 Badge Invalidate own device cache on DM phash mismatch

prepare_dm_stanza now hashes both recipient and own companion targets into DM phash, but this mismatch handler only invalidates the recipient user’s device cache (jid.user). If the mismatch is caused by our own stale companion list (e.g., a newly linked device), subsequent sends keep using the stale own-device registry and continue missing self-fanout/resync for that device. The DM phash self-healing path should also invalidate the local account’s device cache, not just the destination user.

Useful? React with 👍 / 👎.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 13, 2026

Copy link
Copy Markdown

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


Code Review: DM Multi-Device Fanout

  • Gather context and read changed files
  • Review src/send.rs changes
  • Review wacore/src/send.rs changes
  • Review src/message.rs changes
  • Check outstanding review comments (dedup, own device cache)
  • Post review findings

This is a solid PR. The previous automated review rounds (CodeRabbit, Codex) caught real issues — phash computed before partition, sender device in ensure_e2e_sessions, group JID in invalidate_device_cache, and status broadcast leaking through — and those have all been addressed across the follow-up commits. Here are my remaining findings:

1. Self-DM deduplication (outstanding from prior reviews)

src/send.rs:1082-1094 — When sending a "note to self" message (recipient == own account), recipient_cached and own_cached resolve to the same user's device list. After .append() at line 1094, all_dm_jids contains duplicate companion devices. The sender-device filter (lines 1100-1104) only removes the exact sending device, leaving other companions duplicated.

This causes:

  • Duplicate <to jid=...> nodes in the stanza (double-encrypting for the same device)
  • Incorrect phash (computed over duplicates in wacore/src/send.rs:714-717)

Self-DMs are a real use case ("Message Yourself"). A simple dedup after the sender filter would fix this:

// After line 1104 (the retain that excludes sender)
all_dm_jids.sort_unstable();
all_dm_jids.dedup();

This assumes Jid implements Ord — if not, a HashSet-based approach works. Fix this →

2. DM phash mismatch doesn't invalidate own device cache

src/send.rs:669-672 — When a DM phash mismatch occurs, only client.invalidate_device_cache(&jid.user) is called, where jid is the recipient. But the DM phash now covers both recipient and own companion devices (per the wacore/src/send.rs changes). If the mismatch is caused by a stale own-device list (e.g., user linked a new device), the sender's device cache isn't invalidated and subsequent sends keep using the stale list.

Consider also invalidating the sender's own device cache:

if !jid.is_group() && !jid.is_status_broadcast() {
    client.invalidate_device_cache(&jid.user).await;
    // Own devices are also in the DM phash — invalidate sender's cache too
    if let Some(snapshot) = client.get_device_snapshot().await {
        client.invalidate_device_cache(&snapshot.jid.user).await;
    }
}

Fix this →

3. invalidate_group_cache flag is inverted between call sites

src/send.rs:504 — Group/status path passes invalidate_group_cache: false.
src/send.rs:1172 — DM path passes invalidate_group_cache: true.

This is backwards: the group path is the one that might benefit from group cache invalidation on phash mismatch. The DM path passes true, but since jid is a DM recipient there's no group cache entry to invalidate — so the call is a no-op and this is functionally harmless. But it's confusing and if a future reader flips it "to fix the bug," it would break group phash handling. Worth swapping the booleans.

4. Minor: redundant double-partition of sender device

The sender device is excluded in two places:

  1. src/send.rs:1100-1104all_dm_jids.retain(...) before ensure_e2e_sessions
  2. wacore/src/send.rs:664partition_dm_devices calls is_exact_dm_sender_device which skips the same device

Since the sender was already removed in step 1, the check in partition_dm_devices is always a no-op for DMs. This is harmless (defensive coding) but worth a comment noting the sender is pre-filtered.

5. message.rs — PN→LID migration recovery looks good

The try_pn_to_lid_migration_decrypt addition (src/message.rs:953-973) is clean:

  • Only triggers for LID senders (line 1262)
  • Falls through to the existing retry-receipt path on failure (line 1312 returns false)
  • Correctly handles DuplicatedMessage as success (line 1305-1310)
  • Comment is concise and explains why, not what

Verdict: The core architecture (local-first registry, hosted filtering, phash from sent set, sender exclusion before session warmup) is correct and well-aligned with WA Web behavior. The two actionable items are the self-DM dedup (issue 1) and own device cache invalidation (issue 2). The inverted group cache boolean (issue 3) is cosmetic but worth fixing to avoid confusion.


View job

mcaldas and others added 6 commits April 13, 2026 20:56
The DM send path encrypted only for the recipient's bare JID (device 0)
and relied on server-side fanout to deliver to companion devices. However,
companion devices (WhatsApp Web, Desktop) often receive <unavailable>
instead of the encrypted payload, causing "Waiting for this message".

Fix: fetch all recipient devices via get_user_devices() and encrypt
for each one individually, same as mobile clients do. Falls back to
bare JID if no devices are known.

Combined with 704218e (retry receipt on <unavailable> for receive),
this fixes bidirectional linked device message delivery:
- Receive: bot gets <unavailable> → retry receipt → PDO relay ✓ (704218e)
- Send: bot encrypts for all devices → direct delivery ✓ (this commit)

Tested: messages now appear instantly on both primary phone AND
WhatsApp Web/Desktop clients.
…ering

Reworks the DM multi-device fanout from #523 to match WA Web behavior:

- Local registry first: read device list from local cache/DB only on the
  send path (WAWebApiDeviceList.getDeviceIds reads IndexedDB). Network
  fetch via get_user_devices only on cache miss, errors non-fatal.
- Both recipient AND own devices use local-first pattern (no hard .await?
  on get_user_devices for either).
- Filter hosted/Cloud API devices (device 99 / @HosteD) from DM fanout
  (WAWebDBDeviceListFanout excludes these).
- Bare JID fallback only when no local record exists (not when all
  devices are hosted-filtered).
- Compute and embed phash in DM stanzas + DSM wrapper so server can
  signal device-list drift via ack.
- Invalidate device cache on phash mismatch for self-healing.
- Avoid LID-migration side effects on hot path by checking cache before
  calling get_user_devices.

Co-authored-by: Mathias Caldas <mathiascaldas@gmail.com>
…sent set

- Exclude exact sender device (PN + LID) from all_dm_jids before
  ensure_e2e_sessions, matching WA Web's isMeDevice filter in
  getFanOutList. Prevents creating a self-session.
- Guard invalidate_device_cache to non-group JIDs only — device cache
  is user-scoped, group/status JID user parts are chat IDs not accounts.
- Compute phash after partition_dm_devices from the actual sent set
  (recipient_devices + own_other_devices), so DSM.phash and stanza
  phash match the <participants> set rather than including the sender.
When a PreKeyMessage references a consumed one-time prekey, the session
may exist under a PN address (legacy migration scenario). Attempting
PN→LID migration lets Signal use the existing ratchet state to decrypt
without needing the one-time prekey — same pattern already used for
SessionNotFound errors.
WA Web only sends phash on group/broadcast stanzas, not DMs. The
server returns phash in the ACK on mismatch. Keep the local phash
computation for spawn_phash_validation comparison but don't embed
it in the stanza attr or DeviceSentMessage proto.

Returns PreparedDmStanza from prepare_dm_stanza so the caller can
access the computed phash without it being on the wire.
@jlucaso1
jlucaso1 force-pushed the fix/dm-multi-device-fanout branch from 390e4c8 to c3a01d1 Compare April 13, 2026 23:56

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

ℹ️ 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/send.rs
Comment on lines +1152 to 1153
let ack = if let Some(phash) = dm_phash
&& let Some(msg_id) = stanza_to_send

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 Gate ACK phash validation on stanza phash, not DM-only state

This condition now checks dm_phash, which is only populated in the 1:1 DM branch, so normal group sends no longer register an ACK waiter even when their stanza carries a phash (set by prepare_group_stanza). As a result, group phash mismatches are never observed and the self-healing invalidation path in spawn_phash_validation no longer runs for group messages, leaving stale sender-key/device cache state in place after server-reported drift.

Useful? React with 👍 / 👎.

- Dedup all_dm_jids after sender exclusion to handle self-DMs where
  recipient and own device lists overlap (WA Web uses Map for dedup)
- Invalidate own device cache on DM phash mismatch, not just recipient
  (WA Web: syncDeviceListJob([recipient, me]))
- Swap inverted invalidate_group_cache booleans: true for group path,
  false for DM path
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 14, 2026

Copy link
Copy Markdown

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


Code Review: DM Multi-Device Fanout (Round 3)

  • Gather context and read current state of changed files
  • Review src/send.rs changes
  • Review wacore/src/send.rs changes
  • Review src/message.rs changes
  • Check status of previously flagged issues
  • Post review findings

This is the third review pass, covering all 7 commits (d44d1a9 through 0b7fe97). The PR has evolved well — most of the issues flagged by CodeRabbit, Codex, and prior Claude reviews have been addressed. Here's the current state:

Previously flagged issues — now resolved

Issue Status
Phash computed from pre-partition all_devices (CodeRabbit, Codex) Fixed — partition happens before phash in wacore/src/send.rs:718-728
Sender device in ensure_e2e_sessions (CodeRabbit) Fixedretain filter at src/send.rs:1107-1111 excludes sender before session warmup
invalidate_device_cache on group JID (CodeRabbit) Fixed — scoped to !jid.is_group() at src/send.rs:671
Status broadcast leaking into device cache invalidation (CodeRabbit round 2) Fixed!jid.is_status_broadcast() check at src/send.rs:671
Self-DM dedup (CodeRabbit round 3, Codex, Claude round 2) FixedHashSet-based dedup at src/send.rs:1113-1118
Own device cache invalidation on DM phash mismatch (Codex, Claude round 2) Fixed — invalidates own_pn.user at src/send.rs:673-677

Remaining issues

1. Group phash ACK validation is now silently broken P1

src/send.rs:1165 — The ACK waiter in send_message_impl now gates on dm_phash (set only for 1:1 DMs). For group sends through this path, dm_phash is always None, so the ACK waiter is never registered and spawn_phash_validation never runs. Group messages going through send_message_impl (not send_status_message) no longer get phash mismatch detection.

This is a regression from the pre-PR behavior where phash was read from the stanza's phash attr (line 1165 in the old code read stanza_to_send.attrs().optional_string("phash")), which worked for both group and DM stanzas.

The status broadcast path (send_status_message at line 485-504) still reads phash from stanza attrs and is unaffected.

Fix: For the group branch within send_message_impl, read phash from the stanza attrs (the old approach), or set dm_phash from the group prepared stanza's phash attr as well. Something like:

let ack = if let Some(phash) = dm_phash
    .or_else(|| stanza_to_send.attrs().optional_string("phash").map(|s| s.into_owned()))
    && let Some(msg_id) = stanza_to_send.attrs().optional_string("id").map(|s| s.into_owned())
{

The invalidate_group_cache boolean is already correct for this: group path passes true via the else branch implicit value would need to be adjusted too if using a shared variable approach.

Fix this →

2. invalidate_group_cache booleans are swapped P2

src/send.rs:504 — Status/group path calls spawn_phash_validation(..., true, ...) (invalidate group cache = true).
src/send.rs:1185 — DM path calls spawn_phash_validation(..., false, ...) (invalidate group cache = false).

This is now correct — status/group sends should invalidate the group cache on phash mismatch, and DMs should not. The earlier Claude review (round 2) incorrectly flagged this as "inverted." Looking at commit 0b7fe97, the booleans were swapped to their correct positions. Apologies for the false flag in round 2.

3. Own device cache invalidation uses pn but DM fanout may use LID P3, minor

src/send.rs:673-677 — On DM phash mismatch, own device cache is invalidated via device_snapshot.pn.user. But the DM fanout builds own_cached from get_devices_from_registry(own_jid) where own_jid comes from device_snapshot.pn. If the session was established with LID-addressed devices, the registry lookup key differs from the invalidation key. In practice this likely doesn't matter because invalidate_device_cache works on user strings and the PN user string should match, but it's worth verifying that get_devices_from_registry(&pn_jid) and invalidate_device_cache(&pn_jid.user) operate on the same cache/DB key.

4. Minor: partition_dm_devices still checks for sender device Informational

wacore/src/send.rs:664partition_dm_devices calls is_exact_dm_sender_device which skips the sender device. Since commit 715c675, the sender is already filtered out in src/send.rs:1107-1111 before all_dm_jids is passed to prepare_dm_stanza. The check in partition_dm_devices is now always a no-op. This is harmless defensive coding — just noting for future readers.

What looks good

  • Local-registry-first pattern (src/send.rs:1075-1087) — Clean cache-first with network fallback. The let _ = self.get_user_devices(...) pattern correctly swallows network errors for non-fatal warming.
  • Hosted device filtering (src/send.rs:1092, 1100) — is_hosted() filter applied in-place before appending to all_dm_jids, avoiding extra allocations.
  • PreparedDmStanza return type (wacore/src/send.rs:680-686) — Good separation: phash is returned to the caller rather than embedded as a stanza attr, giving the caller control over ACK validation without parsing the stanza back.
  • DSM phash set to None (wacore/src/send.rs:734) — Matches WA Web which only sets DSM phash for groups. Good correction from the earlier Some(String::new()).
  • PN→LID migration on InvalidPreKeyId (src/message.rs:953-973) — Clean fallthrough: try migration first, treat DuplicatedMessage as success, fall back to retry-receipt on failure.
  • Self-DM dedup (src/send.rs:1113-1118) — HashSet-based retain is the right approach for order-preserving dedup.

Verdict

One actionable issue: the group phash ACK regression (issue 1) is real and should be fixed before merge — group sends through send_message_impl silently lost their phash mismatch self-healing. The other items are minor/informational.


View job

@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 (1)
src/send.rs (1)

1064-1096: ⚠️ Potential issue | 🟠 Major

Self-DM sends can produce duplicate device entries in all_dm_jids.

When sending a note-to-self (where to targets the sender's own account), both recipient_cached and own_cached resolve to the same device list. After Lines 1084-1091 build from recipient_cached and Lines 1093-1096 append own_cached, the list contains duplicates.

These duplicates propagate to ensure_e2e_sessions (unnecessary session checks) and prepare_dm_stanza (duplicate encryption operations, potentially malformed phash).

Consider deduplicating after building the list:

Suggested fix
             if let Some(mut own_devices) = own_cached {
                 own_devices.retain(|j| !j.is_hosted());
                 all_dm_jids.append(&mut own_devices);
             }
 
+            // Dedup for self-DM case where recipient == own account
+            wacore::types::jid::sort_dedup_by_device(&mut all_dm_jids);
+
             // Exclude exact sender device (WA Web: isMeDevice in getFanOutList)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/send.rs` around lines 1064 - 1096, The DM fanout builds all_dm_jids from
recipient_cached and then appends own_cached which causes duplicate device
entries for self-DMs; after building all_dm_jids (the Vec built from
recipient_cached/recipient_bare and appended own_devices) deduplicate entries
before calling ensure_e2e_sessions and prepare_dm_stanza—e.g., collapse by
device JID identity (use the JID equality/hash or Jid::to_string) preserving one
entry per device; update the code around resolve_encryption_jid,
get_devices_from_registry, and the all_dm_jids construction to perform this
dedupe so ensure_e2e_sessions and prepare_dm_stanza receive unique device JIDs.
🤖 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/message.rs`:
- Around line 954-973: When handling the post-UntrustedIdentity retry where
retry_err is checked for InvalidPreKeyId (the branch that currently maps to
RetryReason::InvalidKeyId), call try_pn_to_lid_migration_decrypt(...) with the
same arguments used in the earlier direct InvalidPreKeyId branch
(sender_encryption_jid, &signal_address, &parsed_message, &mut adapter, &mut
rng, &enc_type, padding_version, info) and if it returns true treat it as a
successful decrypt (set any_success / continue) instead of immediately
converting the error to RetryReason::InvalidKeyId; this mirrors the PN→LID
fallback logic already implemented and ensures legacy PN-session migrations are
attempted after clearing an UntrustedIdentity.

---

Duplicate comments:
In `@src/send.rs`:
- Around line 1064-1096: The DM fanout builds all_dm_jids from recipient_cached
and then appends own_cached which causes duplicate device entries for self-DMs;
after building all_dm_jids (the Vec built from recipient_cached/recipient_bare
and appended own_devices) deduplicate entries before calling ensure_e2e_sessions
and prepare_dm_stanza—e.g., collapse by device JID identity (use the JID
equality/hash or Jid::to_string) preserving one entry per device; update the
code around resolve_encryption_jid, get_devices_from_registry, and the
all_dm_jids construction to perform this dedupe so ensure_e2e_sessions and
prepare_dm_stanza receive unique device JIDs.
🪄 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: f90e9d37-6fc9-42ba-9046-380373a7f62e

📥 Commits

Reviewing files that changed from the base of the PR and between 36ee384 and c3a01d1.

📒 Files selected for processing (3)
  • src/message.rs
  • src/send.rs
  • wacore/src/send.rs

Comment thread src/message.rs

@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: 0b7fe97f29

ℹ️ 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/send.rs
Comment on lines +1091 to +1093
Some(mut devices) => {
devices.retain(|j| !j.is_hosted());
devices

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 Add recipient fallback when cached device list filters to empty

When recipient_cached exists but becomes empty after hosted-device filtering, this branch returns an empty recipient fanout instead of adding recipient_bare. In that case the DM stanza can be built with only own-device payloads (or no recipient payload at all), so the remote user cannot decrypt/receive the message until cache state changes. This is a send-path regression versus the previous unconditional bare-recipient fallback and is reproducible with empty/stale registry records or hosted-only cached entries.

Useful? React with 👍 / 👎.

The post-UntrustedIdentity retry path handled InvalidPreKeyId by
immediately sending a retry receipt without attempting PN→LID session
migration. This mirrors the migration already added for the direct
InvalidPreKeyId error path.

@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)
src/send.rs (1)

1147-1162: ⚠️ Potential issue | 🟠 Major

Restore phash ACK validation for regular group sends.

dm_phash is only populated in the 1:1 branch, so the waiter registration below now returns None for non-status group messages. That means spawn_phash_validation() never runs after a group send, and stale sender-key / group caches will no longer self-heal on a group participant hash mismatch.

Suggested fix
-        let ack = if let Some(phash) = dm_phash
+        let phash_for_validation = if let Some(phash) = dm_phash {
+            Some((phash, false))
+        } else if tc_issue_target.is_group() {
+            stanza_to_send
+                .attrs()
+                .optional_string("phash")
+                .map(|s| (s.into_owned(), true))
+        } else {
+            None
+        };
+
+        let ack = if let Some((phash, invalidate_group_cache)) = phash_for_validation
             && let Some(msg_id) = stanza_to_send
                 .attrs()
                 .optional_string("id")
                 .map(|s| s.into_owned())
         {
             let rx = self.register_ack_waiter(&msg_id).await;
-            Some((rx, phash, msg_id))
+            Some((rx, phash, invalidate_group_cache, msg_id))
         } else {
             None
         };
@@
-        if let Some((rx, phash, msg_id)) = ack {
-            self.spawn_phash_validation(rx, phash, tc_issue_target.clone(), false, msg_id);
+        if let Some((rx, phash, invalidate_group_cache, msg_id)) = ack {
+            self.spawn_phash_validation(
+                rx,
+                phash,
+                tc_issue_target.clone(),
+                invalidate_group_cache,
+                msg_id,
+            );
         }

Also applies to: 1165-1186

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

In `@src/send.rs` around lines 1147 - 1162, The phash ACK validation was lost for
non-1:1 group sends because dm_phash is only set in the 1:1 branch; restore it
by assigning dm_phash = prepared.phash after calling
wacore::send::prepare_dm_stanza (and any equivalent prepare_* call used for
group messages) so the waiter registration receives a Some(phash) for regular
group sends too; ensure the subsequent waiter registration and
spawn_phash_validation() call use that dm_phash value (affecting the code paths
around prepare_dm_stanza/ prepared.node and the waiter registration /
spawn_phash_validation invocation in the nearby block).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/send.rs`:
- Around line 1147-1162: The phash ACK validation was lost for non-1:1 group
sends because dm_phash is only set in the 1:1 branch; restore it by assigning
dm_phash = prepared.phash after calling wacore::send::prepare_dm_stanza (and any
equivalent prepare_* call used for group messages) so the waiter registration
receives a Some(phash) for regular group sends too; ensure the subsequent waiter
registration and spawn_phash_validation() call use that dm_phash value
(affecting the code paths around prepare_dm_stanza/ prepared.node and the waiter
registration / spawn_phash_validation invocation in the nearby block).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d4d4177c-0f28-4f65-8032-9ded5affa7c5

📥 Commits

Reviewing files that changed from the base of the PR and between c3a01d1 and 0b7fe97.

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

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.

PDO-only approach for <unavailable> doesn't reliably deliver — add retry receipt

2 participants