Skip to content

fix: WA Web-compliant DM retry handler (chat/requester separation + alternate key lookup) - #550

Merged
jlucaso1 merged 10 commits into
mainfrom
fix/dm-retry-chat-requester-separation
Apr 15, 2026
Merged

fix: WA Web-compliant DM retry handler (chat/requester separation + alternate key lookup)#550
jlucaso1 merged 10 commits into
mainfrom
fix/dm-retry-chat-requester-separation

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Separates chat/requester JIDs in the DM retry handler, mirroring WA Web's getActualChatInfo + getTargetChat (WAWebHandleRetryRequest). The raw from JID is split into: bare chat (message lookup), device-specific requester (session ops), and original_from (stanza to attribute).
  • Adds alternate PN/LID key lookup in take_recent_message, matching WA Web's getAlternateMsgKey (WAWebLidMigrationUtils). Handles PN-to-LID mapping changes between send time and retry time with zero redundant cache fetches in the common case.
  • Normalizes requester namespace after alternate key hit, matching WA Web's d.isLid() ? toLid(e.from) : toPn(e.from) + createDeviceWidFromUserAndDevice. Skips resolve_encryption_jid to avoid undoing the normalization.
  • Handles peer device and bot retries via the recipient node attribute, matching WA Web's getTargetChat (RetryRequest.js:339-371).
  • Adds ensure_e2e_sessions_resolved that skips resolve_lid_mappings, matching WA Web where ensureE2ESessions uses input JIDs as-is (E2ESessionsJob.js:56-84).
  • Removes double resolution in process_retry_key_bundle that undid alternate-path normalization.

Problem

The DM retry handler used the raw from JID for both message lookup and session management. When the server includes a device suffix (e.g. :33), message lookup silently fails because ChatMessageId equality includes the device field but messages are stored under bare JIDs. When the server sends bare from, session operations could target the wrong device.

WA Web compliance achieved

WA Web Behavior Implementation
getTargetChat — bot/peer/recipient routing resolve_retry_chat_info with own_pn, own_lid, recipient attr
getActualChatInfo — chat/requester split RetryChatInfo { chat, requester, original_from, is_bot }
getAlternateMsgKey — PN/LID fallback take_recent_message with server-comparison optimization
d.isLid() ? toLid(e.from) : toPn(e.from) alt_chat namespace normalization inline
e.from.isBot() ? (p = e.from) !info.is_bot guard on normalization
m = e.from for stanza to original_from field
ensureE2ESessions([g]) as-is ensure_e2e_sessions_resolved (no re-resolution)
processKeyBundle with normalized JID Removed resolve_encryption_jid in process_retry_key_bundle

Files changed

File Change
src/retry.rs RetryChatInfo + resolve_retry_chat_info with peer/bot/recipient handling. Namespace normalization. All call sites updated.
src/client/sender_keys.rs take_recent_message returns (msg, Option<Jid>). try_take_by_key extracted. Alternate key with server-comparison optimization.
src/client/lid_pn.rs swap_pn_lid_namespace shared helper.
src/client/sessions.rs ensure_e2e_sessions_resolved + extracted ensure_sessions_inner.

Test plan

  • resolve_retry_chat_info_dm_with_device — PN DM, device stripped for chat, preserved for requester
  • resolve_retry_chat_info_lid_dm_with_device — LID DM, same behavior
  • resolve_retry_chat_info_dm_bare — bare from, chat == requester
  • resolve_retry_chat_info_group — group JID preserved, participant extracted
  • resolve_retry_chat_info_status_broadcast — participant from attr
  • resolve_retry_chat_info_status_broadcast_no_participant — fallback to sender
  • resolve_retry_chat_info_peer_device_with_recipient — chat = recipient
  • resolve_retry_chat_info_peer_device_without_recipient — fallback warning
  • resolve_retry_chat_info_peer_via_lid — peer detected via LID match
  • resolve_retry_chat_info_bot_with_recipient — bot + recipient → chat = recipient
  • resolve_retry_chat_info_bot_without_recipient — fallback to from
  • resolve_retry_chat_info_preserves_original_from — device suffix kept
  • dm_retry_message_lookup_uses_bare_jid — store/take/re-add round-trip
  • alternate_key_lookup_pn_to_lid — LID input, message under PN, swap fallback
  • alternate_key_lookup_pn_input_server_changed — PN input, server-comparison optimization
  • no_alternate_without_mapping — no mapping → primary only
  • alternate_key_both_miss — both keys miss → None
  • swap_pn_lid_namespace_preserves_device — LID↔PN swap + device + group→None
  • All 52 tests pass, clippy clean

The retry handler conflated the raw `from` JID for both message lookup
and session management. When the server includes a device suffix in
`from` (e.g. `:33`), the message lookup silently fails because messages
are stored under bare JIDs.

Mirrors WA Web's `getActualChatInfo` (WAWebHandleRetryRequest):
- `chat`: bare JID (no device) for message lookup and stanza addressing
- `requester`: device-specific JID for session management

Also adds alternate PN/LID key lookup in `take_recent_message`, matching
WA Web's `getAlternateMsgKey` (WAWebLidMigrationUtils). This handles
PN-to-LID mapping changes between send time and retry time.
@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

take_recent_message now returns an alternate-chat hint and centralizes cache/DB consume into try_take_by_key. Retry handling was refactored to use RetryChatInfo { chat, requester, original_from, is_bot } and preserve requester device/namespace when falling back PN↔LID. A swap_pn_lid_namespace helper was added.

Changes

Cohort / File(s) Summary
Message consumption & cache/db helpers
src/client/sender_keys.rs
take_recent_message signature changed to return Option<(wa::Message, Option<Jid>)>; added try_take_by_key(&ChatMessageId) to centralize L1-cache-first consume and DB fallback; added alternate PN↔LID key lookup and unified logging/cache error messages to use key.chat/key.id.
Retry receipt resolution & flow updates
src/retry.rs
Added RetryChatInfo { chat, requester, original_from, is_bot } and resolve_retry_chat_info(...); refactored Client::handle_retry_receipt to use resolved info across dedupe, recent-message consume/re-add, device checks, key-bundle processing, group/status handling, and requester normalization when an alternate chat namespace is returned. Tests updated/expanded for resolver and fallback cases.
LID↔PN namespace helper
src/client/lid_pn.rs
Added pub(crate) async fn swap_pn_lid_namespace(&self, jid: &Jid) -> Option<Jid> to map PN↔LID namespaces while preserving device/agent/integrator; returns None when unmapped or irrelevant.
Session entrypoints & sharing
src/client/sessions.rs
Refactored session logic into ensure_sessions_inner(Vec<Jid>); added ensure_e2e_sessions_resolved(&self, jids: &[Jid]); ensure_e2e_sessions now delegates to the shared helper.
Tests
tests/..., src/... (new/updated tests)
Updated tests to expect (message, Option<Jid>); added unit tests for resolve_retry_chat_info (DMs/groups/status, device/no-device, bot/peer cases) and tests covering alternate-key lookup + namespace-swap/device-preservation scenarios.

Sequence Diagram(s)

sequenceDiagram
  participant Client as "Client"
  participant Resolver as "Retry Resolver"
  participant Cache as "L1 Cache"
  participant Backend as "DB/Backend"

  Client->>Resolver: resolve_retry_chat_info(receipt)
  Resolver-->>Client: RetryChatInfo { chat, requester, original_from, is_bot }

  Client->>Cache: try_take_by_key(primary: ChatMessageId)
  alt primary cache hit
    Cache-->>Client: Message
    Client->>Backend: background take_sent_message(primary)
    Client-->>Client: return (Message, None)
  else primary cache miss
    Client->>Backend: take_sent_message(primary)
    alt primary backend hit
      Backend-->>Client: Message
      Client-->>Client: return (Message, None)
    else primary backend miss
      Client->>Client: construct alternate key (PN↔LID)
      Client->>Cache: try_take_by_key(alternate)
      alt alternate hit
        Cache-->>Client: Message
        Client->>Backend: background take_sent_message(alternate)
        Client-->>Client: return (Message, Some(alt_chat))
      else
        Client-->>Client: return None
      end
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hop through cache and dusty DB stack,
I try one key, then another — quick track.
Chat and requester kept snug in their place,
A message returns with a whiskered grace.
🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title directly summarizes the main changes: WA Web compliance in the DM retry handler with chat/requester separation and alternate key lookup, matching the core focus of the PR.

✏️ 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-retry-chat-requester-separation

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 this nitpicky

@jlucaso1
jlucaso1 requested a review from Copilot April 15, 2026 03:33
@claude

claude Bot commented Apr 15, 2026

Copy link
Copy Markdown

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


Nitpicky Code Review

  • Read changed files and diff
  • Review src/retry.rs
  • Review src/client/sender_keys.rs
  • Post detailed findings

Overall this is a solid, well-motivated change. The separation of chat vs. requester JIDs is correct and the test coverage is thorough. Here's the nitpicky pass you asked for:


src/retry.rs

1. Unnecessary clone when from is already bare (line 113-117)

let requester = from.clone();
let chat = if from.device() == 0 && from.agent == 0 {
    requester.clone()  // <-- second clone of `from`
} else {
    from.to_non_ad()
};

When the JID is bare, requester is cloned from from, then chat is cloned again from requester. You could avoid the second allocation:

let chat = from.to_non_ad();
let requester = if from.device() == 0 && from.agent == 0 {
    chat.clone()  // bare == non-ad, one clone
} else {
    from.clone()
};

This way to_non_ad() is always called (it's cheap — just zeroes device/agent), and you get a single clone in the bare case instead of two. The PR description mentions "skips to_non_ad() when from has no device suffix" as an optimization, but to_non_ad() is just a struct copy with user.clone() + zeroed fields — the same cost as the extra .clone() it's trying to avoid. The current code pays 2 allocations in the bare path vs. 1 with the rewrite. Fix this →

2. agent field accessed inconsistently (line 113)

let chat = if from.device() == 0 && from.agent == 0 {

device() is called via the JidExt trait method, but agent is accessed directly as a field (from.agent). There's no agent() method so this is fine functionally, but it's a minor asymmetry. Not blocking — just noting it for consistency if an agent() accessor is ever added.

3. make_test_receipt docstring says "Receipt + Node pair" but only returns Receipt (line 1848)

/// Helper to build a Receipt + Node pair for testing resolve_retry_chat_info.
fn make_test_receipt(from: &str) -> Receipt {

The doc says "Receipt + Node pair" but it only returns a Receipt. Trivial, but misleading.

4. resolve_retry_chat_info_group test doesn't use make_test_receipt (lines 1903-1929)

The group test manually constructs a Receipt while all other resolve_retry_chat_info_* tests use the make_test_receipt helper. This is because the group case needs sender different from chat. Consider extending the helper with a second param (or adding make_test_group_receipt) to keep the tests uniform. Not a big deal since it's 8 lines.

5. node is constructed but unused by resolve_retry_chat_info in DM tests (lines 1867-1871, 1890-1894)

In the DM tests (resolve_retry_chat_info_dm_with_device, resolve_retry_chat_info_dm_bare), the node is built with attributes but resolve_retry_chat_info only reads node.attrs().optional_jid("participant") in the group/status branch. For DMs, the node parameter is effectively ignored. The tests still pass a node (correctly, since the function signature requires it), but the node attributes are dead weight in these specific tests. A NodeBuilder::new("receipt").build() would suffice and make it clearer that the node isn't consulted for DMs.

6. Log message still references receipt.source.sender (line 174-176)

log::debug!(
    "Ignoring duplicate retry for message {} from {}: already handled.",
    message_id,
    receipt.source.sender  // <-- still uses receipt.source.sender
);

After resolving to info.requester, this debug log still prints receipt.source.sender. For DMs they'll differ when there's a device suffix. Consider using info.requester for consistency with the rest of the function. Same applies to the receipt.source.sender on line 159 (in the max-retry-count guard), though that one fires before info is constructed so it's fine structurally.


src/client/sender_keys.rs

7. chat_str allocation in try_take_by_key even when L1 cache hits early (line 97)

async fn try_take_by_key(&self, key: &wacore::types::message::ChatMessageId) -> Option<wa::Message> {
    use prost::Message;
    let chat_str = key.chat.to_string();  // allocated unconditionally
    let has_l1_cache = ...;

chat_str is computed eagerly at line 97 but is only needed for the DB cleanup spawn (line 108) and DB fallback (line 126). If the L1 cache misses the remove call (line 101 returns None) and has_l1_cache is true, you skip to the DB path and use chat_str there — so it's needed most of the time. But if L1 capacity is 0 (DB-only mode), the allocation is always needed anyway. This is a micro-optimization at best — the to_string() is cheap relative to the DB/cache I/O. Just noting it since the PR description calls out "avoids chat_str.clone() in L1 cache hit path" — the old code cloned chat_str; the new code doesn't clone but still allocates it even when only the L1 path fires. Net improvement, but the claim is slightly overstated.

8. Fully-qualified wacore::types::message::ChatMessageId used in multiple places (lines 94, 160, 162, 184)

try_take_by_key and alternate_message_key both spell out wacore::types::message::ChatMessageId repeatedly. Since ChatMessageId is already imported via use wacore::types::message::ChatMessageId inside alternate_message_key, consider hoisting that import to the top of the impl block or the file. This would clean up the function signatures.

9. alternate_message_key constructs JIDs with ..Default::default() (lines 170-174, 177-181)

Jid {
    user: pn_user.into(),
    server: wacore_binary::Server::Pn,
    ..Default::default()
}

This is correct (device=0, agent=0, integrator=0 — a bare JID), but it relies on Default zeroing the numeric fields. Consider adding a brief inline comment like // bare JID to make the intent explicit to future readers. Minor.


Broader Observations

10. RetryChatInfo is not Debug-derivable (line 87)

Adding #[derive(Debug)] to RetryChatInfo would make it easier to log during development/debugging. It's a private struct so there's no API concern.

11. No test for LID DM with device suffix

The DM tests cover PN JIDs (@s.whatsapp.net). There's no test for a LID DM retry where from is something like 236395184570386:5@lid. The code path is identical (same branch in resolve_retry_chat_info), but for completeness given this is LID-migration-adjacent code, a LID DM test would add confidence.


Verdict

Clean, well-structured PR. The core logic is correct and matches the documented WA Web behavior. The test suite is comprehensive. The nits above are mostly cosmetic or micro-optimization level — none are blocking. The most actionable one is #1 (double clone in bare DM path) and #6 (stale receipt.source.sender in log after info resolution).


Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Improves retry-receipt handling by correctly separating “chat” vs “requester” JIDs (especially for DM retries with device-suffixed from JIDs), and enhances recent-message lookup to tolerate PN↔LID mapping changes between send and retry time.

Changes:

  • Added RetryChatInfo + resolve_retry_chat_info() and updated retry handling to use bare chat JIDs for lookup/addressing and device-scoped requester JIDs for session ops.
  • Extended take_recent_message() with PN↔LID alternate-key fallback via alternate_message_key() and refactored lookup into try_take_by_key().
  • Added/updated tests covering DM/group/status retry chat resolution and PN→LID fallback lookup behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/retry.rs Introduces RetryChatInfo resolution and updates retry flow to use separated chat/requester JIDs; adds targeted tests.
src/client/sender_keys.rs Refactors recent-message retrieval to support alternate PN/LID key fallback and reduces unnecessary cloning in hot paths.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/retry.rs Outdated
self.add_recent_message(&info.chat, &message_id, &original_msg)
.await;

// Resolved JID for session operations; keep original for stanza addressing

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

The inline comment says "keep original for stanza addressing", but the code below uses info.chat (bare/non-AD) when building both DM and group retry stanzas. This is a bit misleading/confusing now that chat/requester are separated; please update the comment to reflect that resolved_jid is for session/encryption operations, while info.chat is used for stanza addressing and message lookup.

Suggested change
// Resolved JID for session operations; keep original for stanza addressing
// Resolve the requester's JID for session/encryption operations; use
// info.chat for stanza addressing and recent-message lookup.

Copilot uses AI. Check for mistakes.
@github-actions

github-actions Bot commented Apr 15, 2026

Copy link
Copy Markdown

Benchmark Results

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

Benchmark Current Baseline Change
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,760,817 12,459,604 +2.4%
58 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,933 3,933 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 12,038 12,038 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,414 43,414 +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,478 68,478 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,578 76,578 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,230 2,230 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,988 5,988 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 170,171 170,585 -0.2%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 191,890 191,890 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 875,151 875,959 -0.1%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 966,203 966,140 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,454,059 1,454,014 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,585,821 2,585,803 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 9,423,152 9,422,885 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 44,694,644 44,693,724 +0.0%
binary_benchmark::marshal_group::bench_marshal_allocating 71,207 71,207 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 71,240 71,240 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 98,328 98,328 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 78,762 78,762 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 71,307 71,307 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 7,518 7,518 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 7,562 7,562 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 9,274 9,274 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 530,499 530,499 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 530,066 530,066 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 531,423 531,423 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 8,507,096 8,507,096 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 8,451,427 8,451,427 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 19,679,084 19,679,084 +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 788 788 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 556,229 556,229 +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() 5,330 5,330 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 66,315 66,315 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 5,357 5,357 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 66,351 66,351 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 6,734 6,734 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 89,630 89,630 +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() 11,599 11,599 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,329,146 17,417,140 -0.5%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 157,923 157,923 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,511,084 5,511,084 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 158,737 158,737 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 296,767 296,767 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 707,098 707,098 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,536,054 12,495,104 +0.3%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,563,397 27,672,983 -0.4%
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_signature_verification verify:setup_keypair_with_message() 126,124,443 126,613,343 -0.4%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +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,090,932 5,090,932 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 316,987 316,987 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,254,317 14,254,317 +0.0%

- Rewrite DM bare-JID path: to_non_ad() first, single clone in bare
  case instead of two
- Use info.requester in duplicate-retry debug log for consistency
- Fix stale make_test_receipt docstring
- Simplify DM test nodes (attrs unused in DM branch)
- Hoist ChatMessageId import to file level in sender_keys.rs
- Add LID DM with device suffix test

@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: 4b8f1f5ca3

ℹ️ 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/retry.rs Outdated
Comment on lines +112 to +113
let chat = from.to_non_ad();
let requester = if from.device() == 0 && from.agent == 0 {

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 Preserve AD fields when deriving DM retry chat JID

In resolve_retry_chat_info, the DM branch always normalizes from with to_non_ad(), which clears agent/device for every non-group chat. This regresses retries for non-PN/LID direct chats (for example @bot, @hosted, or other AD JIDs): add_recent_message stores under the original chat JID via make_chat_message_id, but take_recent_message now looks up a stripped JID, so the original message cannot be found and the retry is skipped. Before this change, these fields were preserved because lookup used receipt.source.chat directly.

Useful? React with 👍 / 👎.

When take_recent_message finds the message via the alternate key (e.g.,
stored under PN but retry arrives as LID), the Signal session lives in
the stored message's namespace. Normalize the requester to match and
use it directly as the encryption target, skipping resolve_encryption_jid
which would map it back to the wrong namespace.

Mirrors WA Web's getActualChatInfo: d.isLid() ? toLid(e.from) : toPn(e.from)
- Extract shared swap_pn_lid_namespace() helper to client/lid_pn.rs,
  used by both alternate_message_key and normalize_requester_namespace
- Add normalize_requester_after_alternate_key_hit test verifying both
  LID→PN and PN→LID namespace swaps with device preservation

@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

Caution

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

⚠️ Outside diff range comments (1)
src/retry.rs (1)

232-251: ⚠️ Potential issue | 🟠 Major

Keep the receipt requester separate from the normalized session target.

After Line 226 mutates info.requester, the has_device() check and peer detection here run against the swapped PN/LID user instead of the actual requester from the receipt. That can reject legitimate retries once add_lid_pn_mapping() has migrated device-registry entries to the LID namespace, because the retry came from LID but this code now looks up the old PN user. Store the normalized JID in a separate variable for Signal/session work and leave info.requester as the on-the-wire requester.

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

In `@src/retry.rs` around lines 232 - 251, The code currently mutates
info.requester earlier (via add_lid_pn_mapping) so subsequent checks like
has_device(&sender_user, ...) and the peer detection logic using device_snapshot
and is_peer run against the normalized/ swapped JID instead of the original
on-the-wire requester; to fix, preserve the original requester by copying it to
a new variable (e.g., let original_requester = info.requester.clone() or let
normalized_target = ... depending on intent) and use original_requester for
has_device and peer detection while using a separate normalized variable for
Signal/session operations; update references in this block (info.requester,
sender_user, has_device, device_snapshot, is_peer) to use the correct variable
so lookups remain against the true incoming requester and session targeting uses
the normalized JID.
🤖 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/retry.rs`:
- Around line 225-230: The alternate-namespace resolved_jid is being overwritten
because process_retry_key_bundle() re-runs resolve_encryption_jid() instead of
using the already-final resolved_jid computed in the caller; update the call
site around resolved_jid (where normalize_requester_namespace() and
resolve_encryption_jid() are used) to pass the final resolved_jid into
process_retry_key_bundle(), and modify process_retry_key_bundle() (and any
helpers it calls) to accept an optional/pre-resolved JID (or a skip-resolve
flag) and use that value directly instead of calling
resolve_encryption_jid(&info.requester) when the pre-resolved JID is supplied;
reference symbols: resolved_jid, normalize_requester_namespace,
resolve_encryption_jid, process_retry_key_bundle, and info.requester.

---

Outside diff comments:
In `@src/retry.rs`:
- Around line 232-251: The code currently mutates info.requester earlier (via
add_lid_pn_mapping) so subsequent checks like has_device(&sender_user, ...) and
the peer detection logic using device_snapshot and is_peer run against the
normalized/ swapped JID instead of the original on-the-wire requester; to fix,
preserve the original requester by copying it to a new variable (e.g., let
original_requester = info.requester.clone() or let normalized_target = ...
depending on intent) and use original_requester for has_device and peer
detection while using a separate normalized variable for Signal/session
operations; update references in this block (info.requester, sender_user,
has_device, device_snapshot, is_peer) to use the correct variable so lookups
remain against the true incoming requester and session targeting uses the
normalized JID.
🪄 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: b85fa0b1-4b94-47f2-9275-128b375c54d5

📥 Commits

Reviewing files that changed from the base of the PR and between 9d4bb99 and f943238.

📒 Files selected for processing (3)
  • src/client/lid_pn.rs
  • src/client/sender_keys.rs
  • src/retry.rs

Comment thread src/retry.rs Outdated
- Remove normalize_requester_namespace (did a second cache lookup for
  the same PN/LID mapping already fetched by alternate_message_key)
- Return alt_chat Jid from take_recent_message instead of bool, so the
  caller builds the normalized requester directly from it — zero extra
  cache fetches
- Remove redundant to_non_ad() in alternate_message_key (input key.chat
  is already bare from make_chat_message_id)
- Update test to exercise swap_pn_lid_namespace directly (the shared
  helper both paths use)

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

ℹ️ 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/retry.rs
agent: requester.agent,
integrator: requester.integrator,
};
info.requester.clone()

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 Re-establish retry session in the same namespace

When take_recent_message hits the alternate PN/LID key path, this branch returns resolved_jid in the alternate namespace, but the DM flow later calls ensure_e2e_sessions(&resolved_jid) which normalizes PN→LID (resolve_lid_mappings in src/client/sessions.rs). Because delete_dm_retry_session_target has already deleted the session for resolved_jid, we can end up recreating a session under LID while prepare_dm_retry_stanza still encrypts with the original PN resolved_jid, causing retry encryption to fail in the exact PN/LID migration scenario this change adds.

Useful? React with 👍 / 👎.

When resolve_encryption_jid changed the namespace (PN→LID), the original
input `to` IS the alternate key. Use it directly instead of round-tripping
through swap_pn_lid_namespace (which just recovers the original). Removes
alternate_message_key method entirely — logic is now inline.

Cache fetches in alternate-hit path: 2 → 1 (common case), 2 (LID input).
- alternate_key_lookup_pn_input_server_changed: PN input where
  resolve_encryption_jid changes namespace — exercises the server !=
  server optimization that skips the cache round-trip
- no_alternate_without_mapping: no PN/LID mapping → no alternate tried,
  primary hit returns None alt_chat
- alternate_key_both_miss: mapping exists but no message stored under
  either key → returns None

@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: 40509439db

ℹ️ 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/retry.rs
Comment on lines +224 to +225
let resolved_jid = if let Some(alt_chat) = alt_chat
&& !is_group_or_status

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 Preserve alt namespace through retry key-bundle processing

When an alternate PN/LID key hit occurs, this branch intentionally keeps resolved_jid in the alternate namespace, but process_retry_key_bundle later re-runs resolve_encryption_jid on that JID and establishes the refreshed session under the primary namespace instead. In PN↔LID migration retries that include <keys>, the flow can then delete/use the alternate resolved_jid while the rebuilt session exists under the other namespace, so prepare_dm_retry_stanza may encrypt against a missing/stale session and the resend fails. Fresh evidence: this mismatch is already introduced before ensure_e2e_sessions runs because process_retry_key_bundle always re-resolves its input.

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/retry.rs`:
- Around line 224-238: In process_retry_key_bundle, stop re-resolving the
requester JID via resolve_encryption_jid and instead pass the already-determined
requester_jid directly into to_protocol_address so the prekey is stored under
the correct Signal address; update the code in process_retry_key_bundle to
remove the resolve_encryption_jid call/variable and use requester_jid when
calling to_protocol_address (preserving the PN namespace when alt_chat produced
the requester_jid).
🪄 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: d694644c-62ef-4178-a76e-491c4086b0ad

📥 Commits

Reviewing files that changed from the base of the PR and between e749caf and 4050943.

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

Comment thread src/retry.rs
process_retry_key_bundle called resolve_encryption_jid on the requester
JID, undoing the alternate-path namespace normalization (PN back to LID).
The caller already passes the correctly-resolved JID — use it directly
via to_protocol_address().
Gap 1 - ensure_e2e_sessions re-resolution:
  Extract ensure_sessions_inner, add ensure_e2e_sessions_resolved that
  skips resolve_lid_mappings. DM retries use the resolved variant since
  the caller already normalized the JID. Matches WA Web where
  ensureE2ESessions uses input JIDs as-is (E2ESessionsJob.js:56-84).

Gap 2 - recipient attribute for peer/bot retries:
  resolve_retry_chat_info now takes own_pn/own_lid and reads the
  `recipient` node attr. For peer devices (from=our JID) and bots
  (from.is_bot()), uses recipient as the chat target. Matches WA Web
  getTargetChat (RetryRequest.js:339-371).

Gap 3 - bot JID namespace normalization exception:
  Bots skip the alternate PN/LID namespace normalization. Matches WA Web
  getActualChatInfo: `e.from.isBot() ? (p = e.from)` (RetryRequest.js:311).

Gap 4 - stanza `to` uses original_from:
  Added original_from field to RetryChatInfo. DM retry stanza now uses
  the raw `from` JID for the `to` attribute, matching WA Web variable `m`
  (RetryRequest.js:30,97).
@jlucaso1 jlucaso1 changed the title fix: separate chat/requester JIDs in DM retry handler fix: WA Web-compliant DM retry handler (chat/requester separation + alternate key lookup) Apr 15, 2026

@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: 46404129c0

ℹ️ 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/retry.rs
Comment on lines +154 to +155
let requester = if from.device() == 0 && from.agent == 0 {
chat.clone()

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 Keep requester bound to raw from JID for recipient retries

In resolve_retry_chat_info, the bare-from fallback assigns requester = chat.clone(). That is incorrect when chat was rewritten from recipient (peer/device-sync or bot retries), because requester then points at the conversation partner instead of the retrying sender. In that case the downstream has_device/session/key-bundle path and prepare_dm_retry_stanza participant JID are built for the wrong user, so bare-from retries with a recipient attribute can be skipped or resent to an invalid target.

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/client/sessions.rs`:
- Line 168: The inner redundant import "use wacore::types::jid::JidExt;" should
be removed — JidExt is already imported at module scope, so delete the duplicate
use statement (the inner use of JidExt) to avoid redundancy and keep imports
clean; verify no other local shadowing depends on that inner import.
🪄 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: 2c32d42d-34be-47ce-9df4-65ccede767b8

📥 Commits

Reviewing files that changed from the base of the PR and between 704a16b and 4640412.

📒 Files selected for processing (2)
  • src/client/sessions.rs
  • src/retry.rs

Comment thread src/client/sessions.rs

/// Core session-check + prekey-fetch logic shared by both entry points.
async fn ensure_sessions_inner(&self, jids: Vec<Jid>) -> Result<()> {
use wacore::types::jid::JidExt;

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

Redundant inner import.

JidExt is already imported at module level (line 7). This inner use can be removed.

♻️ Suggested fix
     /// Core session-check + prekey-fetch logic shared by both entry points.
     async fn ensure_sessions_inner(&self, jids: Vec<Jid>) -> Result<()> {
-        use wacore::types::jid::JidExt;
-
         let device_store = self.persistence_manager.get_device_arc().await;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
use wacore::types::jid::JidExt;
/// Core session-check + prekey-fetch logic shared by both entry points.
async fn ensure_sessions_inner(&self, jids: Vec<Jid>) -> Result<()> {
let device_store = self.persistence_manager.get_device_arc().await;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/sessions.rs` at line 168, The inner redundant import "use
wacore::types::jid::JidExt;" should be removed — JidExt is already imported at
module scope, so delete the duplicate use statement (the inner use of JidExt) to
avoid redundancy and keep imports clean; verify no other local shadowing depends
on that inner import.

@jlucaso1
jlucaso1 merged commit 88e4808 into main Apr 15, 2026
12 checks passed
@jlucaso1
jlucaso1 deleted the fix/dm-retry-chat-requester-separation branch April 15, 2026 05:12
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.

2 participants