Skip to content

fix!: migrate stale PN sessions to LID to fix NoSession decryption failures - #475

Merged
jlucaso1 merged 3 commits into
mainfrom
fix/lid-first-signal-sessions
Apr 1, 2026
Merged

fix!: migrate stale PN sessions to LID to fix NoSession decryption failures#475
jlucaso1 merged 3 commits into
mainfrom
fix/lid-first-signal-sessions

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Fixes perpetual SessionNotFound errors when receiving messages from the primary phone. The session was stored under a PN address from an old pairing, but the phone now sends from a LID address. Aligns with WA Web's createSignalAddress() which always resolves PN→LID before any session operation.
  • Adds on-the-fly PN→LID session migration in the SessionNotFound handler, so existing databases are fixed without re-pairing.
  • Proactively migrates own device 0 session at login time.

Changes

Core fix: PN→LID session migration (src/client/lid_pn.rs)

  • New migrate_signal_sessions_on_lid_discovery() — scans PN sessions for a user (devices 0-99), migrates them to LID addresses. Identity migration runs independently of session existence (survives session deletion and re-establishment). Identity keys use protocol address format (with .0 suffix) matching the Signal store backend.

Login-time migration (src/client/sessions.rs)

  • establish_primary_phone_session_immediate() rewritten: checks LID session → cleans stale PN | migrates PN→LID | establishes fresh via fetch_and_establish_sessions (bypasses wait_for_offline_delivery_end since we're at login).

On-the-fly migration in decryption (src/message.rs)

  • Extracted try_pn_to_lid_migration_decrypt() helper: on SessionNotFound for a LID address, attempts PN→LID migration, reloads session+identity into signal cache, retries decryption. Handles DuplicatedMessage gracefully in the post-migration retry path (silently ignored). Falls back to retry receipt only if migration doesn't help.
  • Deduplicated the 75-line inline sender_encryption_jid block into cache_lid_pn_from_message() + resolve_encryption_jid().

Retry path (src/retry.rs)

  • resolve_encryption_jid() applied early in handle_retry_receipt so all downstream session operations (key bundle processing, registration ID checks, base key collision detection, session deletion) use the resolved LID address.

Self-message sender_alt (wacore/src/messages.rs)

  • parse_message_info now populates sender_alt for self-messages from the known own PN↔LID pair, so the LID-PN cache warms from own-device messages.

Test plan

  • 7 new E2E tests in tests/e2e/tests/lid_sessions.rs (JIDs masked in logs):
    • test_sessions_stored_under_lid_not_pn — bidirectional check, both sides LID-only
    • test_multiple_sends_stay_lid_only — 5 sequential sends don't regress to PN
    • test_stale_pn_session_does_not_break_lid_messaging — injected stale PN doesn't break LID
    • test_lid_session_survives_reconnect — LID sessions survive DB reload, no PN creep
    • test_own_device_0_has_lid_session_after_login — own device 0 LID session + no PN session
    • test_no_undecryptable_events_during_messaging — no UndecryptableMessage events
    • test_pn_only_session_causes_undecryptable_on_lid_lookupreproduces and fixes the production bug: injects PN-only session, reconnects, verifies message decrypts via on-the-fly migration, asserts no UndecryptableMessage, confirms session is under LID after fix
  • 722+ unit tests pass (including updated sender_alt assertions)
  • 0 clippy warnings
  • Runtime-agnostic: wacore change is pure sync logic, all src/ changes use trait-based async
  • Verified in production: migration triggered at login, messages from own phone decrypting successfully, zero NoSession errors

Summary by CodeRabbit

  • New Features

    • Automatic migration of legacy phone-number sessions and identity keys to LID-backed sessions during discovery and message processing.
  • Bug Fixes

    • Login flow simplified to favor LID, reducing login-time failures.
    • Decryption now retries with on-the-fly migration to prevent undecryptable messages.
    • Self-sent messages include an explicit alternate sender identifier for correct routing.
  • Tests

    • Added end-to-end tests validating LID-only session storage, migrations, reconnects, and event correctness.

@coderabbitai

coderabbitai Bot commented Apr 1, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4f78e92d-16f0-41f4-b158-d48efa7848f7

📥 Commits

Reviewing files that changed from the base of the PR and between ee76654 and 1933a8d.

📒 Files selected for processing (2)
  • src/retry.rs
  • tests/e2e/tests/lid_sessions.rs

📝 Walkthrough

Walkthrough

Implements PN→LID Signal session and identity migration on LID discovery, simplifies primary-phone login LID gating, prewarms LID↔PN mappings and retries decryption with on-the-fly migration for SessionNotFound, adjusts retry encryption-JID resolution, and adds end-to-end tests asserting LID-only session storage and migration behavior.

Changes

Cohort / File(s) Summary
Session Migration
src/client/lid_pn.rs
Added migrate_signal_sessions_on_lid_discovery(pn, lid) to iterate device IDs (0..=99), migrate or delete PN Signal sessions to/from LID keys, copy 32-byte identity keys when needed, and log persistence read/write/delete errors while continuing.
Session Establishment Logic
src/client/sessions.rs
Simplified establish_primary_phone_session_immediate: early-return when own LID absent, derive primary_phone_lid as own_lid.with_device(0), use unwrap_or(false) on session existence checks, and removed prior PN→LID migration+establish branching.
Message Decryption & Caching
src/message.rs, wacore/src/messages.rs
Prewarm LID↔PN mapping via cache_lid_pn_from_message(...) before resolving sender encryption JID; on SignalProtocolError::SessionNotFound attempt PN→LID migration and cache reload then retry decryption before falling back to sending a retry receipt; set sender_alt for self-sent messages when applicable.
Retry / Registration Handling
src/retry.rs
Resolve encryption JIDs via resolve_encryption_jid(...) for session/registration and signaling operations; use resolved JID for signal-address derivation and bundle processing; expand peer detection to match stored PN or LID users.
E2E Tests
tests/e2e/tests/lid_sessions.rs
Added Tokio e2e tests and helpers (scan_sessions, assert_lid_only_sessions, mask_addr) with seven tests validating LID-only persistence, migration behavior, reconnect resilience, absence of undecryptable events, and own-device LID session presence after login.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant Client as Client
    participant Msg as MessageHandler
    participant Cache as SignalCache
    participant DB as Persistence
    participant Signal as SignalProtocol

    Note over App,Client: LID discovery triggers migration
    App->>Client: add_lid_pn_mapping(pn, lid)
    Client->>Client: migrate_signal_sessions_on_lid_discovery(pn, lid)
    loop device_id in 0..99
        Client->>DB: read PN session (pn@*.device_id)
        alt PN session exists
            Client->>DB: check LID session (lid@*.device_id)
            alt LID exists
                Client->>DB: delete PN session
                Client->>Cache: remove PN session from cache
            else LID missing
                Client->>DB: write session under LID key
                Client->>DB: delete PN session
                Client->>Cache: remove PN session from cache
            end
            Client->>DB: copy identity key if LID identity absent
        end
    end

    Note over Msg,Signal: Decryption with migration retry
    App->>Msg: incoming message
    Msg->>Msg: cache_lid_pn_from_message(msg)
    Msg->>Msg: resolve_encryption_jid(sender)
    Msg->>Signal: attempt decrypt using resolved JID
    alt Signal returns SessionNotFound
        Msg->>Client: migrate_signal_sessions_on_lid_discovery(pn, lid)
        Client->>DB: migrate sessions & identities
        Msg->>Cache: reload sessions/identities from DB
        Msg->>Signal: retry decrypt
        alt retry succeeds
            Msg->>App: deliver plaintext
        else
            Msg->>App: send retry-receipt fallback
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I hopped through cache and store tonight,
Found PN crumbs and set them right.
I nudged the keys from old to new,
LID-first paths now guide the crew.
Messages dance, decrypted in light.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: migrating stale PN sessions to LID to fix SessionNotFound decryption failures, which is the core objective of the PR.
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/lid-first-signal-sessions

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

jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

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


Code Review: PN→LID Session Migration

  • Read all changed files
  • Review src/client/lid_pn.rs
  • Review src/client/sessions.rs
  • Review src/message.rs
  • Review src/retry.rs
  • Review wacore/src/messages.rs
  • Review tests/e2e/tests/lid_sessions.rs
  • Post final review

Overall this is a well-structured fix for a real production bug. The approach of migrating sessions on-the-fly during decryption failure is sound and mirrors how WA Web handles the PN→LID transition. The E2E tests are thorough and the reproduction test (test_pn_only_session_causes_undecryptable_on_lid_lookup) is excellent. A few items below.

Issues

1. Nesting depth in SessionNotFound handlersrc/message.rs:878-958

The on-the-fly migration block is ~80 lines nested 8 levels deep inside the decryption error match arm. This makes it hard to follow the control flow (success → continue, failure → fall through to retry receipt). Consider extracting this into a helper method like try_migrate_and_retry_decrypt() that returns Option<Vec<u8>> (the plaintext on success), which would flatten the nesting significantly:

if let SignalProtocolError::SessionNotFound(_) = e {
    if let Some(plaintext) = self.try_pn_lid_migration_decrypt(
        &sender_encryption_jid, &signal_address, &parsed_message,
        &mut adapter, &mut rng, info, &enc_type, padding_version,
    ).await {
        any_success = true;
        continue;
    }
    // fall through to retry receipt...
}

Refactor this →

2. Device range 0..=99 is iterated sequentially with two DB calls eachsrc/client/lid_pn.rs:184-248

migrate_signal_sessions_on_lid_discovery does 200 sequential DB reads (100 devices × session + identity check) even though most will be None. In practice this is probably fine since the vast majority are cache hits returning None quickly, and this runs at most once per new LID discovery. But worth noting — if perf becomes a concern, a batch query or a prefix scan would be more efficient.

3. sender_alt for self-messages assumes own PN/LID are always setwacore/src/messages.rs:199-207

The new logic populates sender_alt for self-messages from the known own PN↔LID pair. When from.server == DEFAULT_USER_SERVER, it returns own_lid.cloned() — but own_lid could be None (early in pairing before LID is assigned). The code handles this correctly by producing None in that case (the else branch), but it's worth noting this is the expected behavior and not a missed case.

4. Missing session lock for migration in the SessionNotFound handlersrc/message.rs:884-910

The migration calls migrate_signal_sessions_on_lid_discovery and then reloads the session into the signal cache, all while the outer session lock (_session_guard) is already held. This is correct and intentional — the per-sender lock serializes access. Good.

However, migrate_signal_sessions_on_lid_discovery itself acquires no lock and operates directly on the backend. If two messages from the same sender arrive concurrently and both hit SessionNotFound, only one will enter this path (thanks to the session lock), but a different sender with the same PN could theoretically race. In practice this is benign since both would perform the same migration, but the warn! logs from the losing race could be noisy.

Nits

5. src/client/lid_pn.rs:190pn_session_key and lid_session_key are computed via to_string() on the ProtocolAddress. This is the same format used by the backend (name.device_id). Just confirming this matches — it does based on the test at src/client/sessions.rs:524.

6. src/message.rs:894signal_name is computed but only used 10 lines later for load_identity. Consider moving it closer to its use, or better yet, into the if let guard where it's consumed.

7. tests/e2e/tests/lid_sessions.rs:546-562 — The reproduction test has a wait_for_text with a 5-second timeout. If the fix is working, this should succeed quickly. But if the test is running on a slow CI, 5 seconds might be tight for message delivery + migration + retry. The other tests use 30 seconds. Consider bumping this to at least 15 for CI stability.

Fix timeout →

What looks good

  • The cache_lid_pn_from_message() extraction is a clean refactor — eliminates 75 lines of duplicated inline logic and makes the flow readable.
  • resolve_encryption_jid() as a shared utility between send and receive paths ensures session keys are consistent in both directions. Clean API.
  • The establish_primary_phone_session_immediate() rewrite handles the three cases (LID exists / PN exists / neither) with clear branching and appropriate fallbacks.
  • The retry.rs change is minimal and correct — sessions from retry key bundles now also go through LID resolution.
  • Self-message sender_alt population in wacore/src/messages.rs is a thoughtful addition that ensures the LID-PN cache warms from own-device messages without requiring additional network round-trips.
  • E2E tests are comprehensive and cover both the happy path and the exact production bug scenario.

@github-actions

github-actions Bot commented Apr 1, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchfix/lid-first-signal-sessions
Testbedubuntu-latest

🚨 1 Alert

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

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,197.00
(-4.44%)Baseline: 6,484.77
6,809.01
(91.01%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-26.08%)Baseline: 709,319.39
744,785.35
(70.40%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-5.40%)Baseline: 22,058.80
23,161.74
(90.10%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,202.00
(-14.84%)Baseline: 115,309.08
121,074.53
(81.11%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,230.00
(-9.58%)Baseline: 108,631.67
114,063.25
(86.12%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.10%)Baseline: 533,489.89
560,164.38
(95.14%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,870.00
(-4.44%)Baseline: 16,608.16
17,438.57
(91.01%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,715,201.00
(-7.55%)Baseline: 15,916,956.24
16,712,804.06
(88.05%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,358.00
(-19.61%)Baseline: 147,222.74
154,583.88
(76.57%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.10%)Baseline: 534,911.30
561,656.86
(95.14%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,919.00
(-3.97%)Baseline: 18,659.33
19,592.29
(91.46%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,066,347.00
(-20.60%)Baseline: 35,345,985.25
37,113,284.51
(75.62%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.10%)Baseline: 533,928.89
560,625.33
(95.14%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,843.00
(-7.03%)Baseline: 17,041.45
17,893.52
(88.54%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,716,627.00
(-7.55%)Baseline: 15,918,095.56
16,714,000.34
(88.05%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
107,945.00
(-11.72%)Baseline: 122,274.87
128,388.61
(84.08%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,302.00
(-9.57%)Baseline: 108,703.67
114,138.85
(86.12%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-5.13%)Baseline: 95,891.83
100,686.42
(90.35%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-3.30%)Baseline: 7,629.69
8,011.18
(92.10%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-1.69%)Baseline: 92,566.57
97,194.90
(93.63%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.34%)Baseline: 7,375.92
7,744.71
(95.56%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-1.44%)Baseline: 108,351.57
113,769.15
(93.87%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.28%)Baseline: 8,887.92
9,332.31
(95.51%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-7.33%)Baseline: 45,310.69
47,576.23
(88.26%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-2.60%)Baseline: 2,789.51
2,928.98
(92.76%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+1.57%)Baseline: 547,510.15
574,885.66
(96.73%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.26%)Baseline: 772.98
811.63
(94.99%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,582,728.00
(-0.42%)Baseline: 27,699,385.13
29,084,354.39
(94.84%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,544,828.00
(-0.05%)Baseline: 5,547,762.07
5,825,150.17
(95.19%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
175,061.00
(-1.29%)Baseline: 177,357.55
186,225.43
(94.00%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
175,710.00
(-1.36%)Baseline: 178,130.28
187,036.79
(93.94%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,345,514.00
(+0.37%)Baseline: 17,281,865.15
18,145,958.41
(95.59%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
298,417.00
(+0.54%)Baseline: 296,818.42
311,659.34
(95.75%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,400,903.00
(-1.55%)Baseline: 12,595,895.22
13,225,689.98
(93.76%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
719,597.00
(+0.37%)Baseline: 716,944.78
752,792.02
(95.59%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
🚨 view alert (🔔)
47,129.00
(+8.75%)Baseline: 43,337.96
45,504.86
(103.57%)

libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,766.91
16,339,855.25
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,378,778.00
(-1.77%)Baseline: 5,475,880.00
5,749,674.00
(93.55%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
312,188.00
(-60.72%)Baseline: 794,821.93
834,563.02
(37.41%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,830,547.00
(+0.17%)Baseline: 2,825,630.26
2,966,911.77
(95.40%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,452,844.00
(-0.51%)Baseline: 3,470,610.85
3,644,141.39
(94.75%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
123,550,848.00
(-1.45%)Baseline: 125,362,816.03
131,630,956.83
(93.86%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
12,002.00
(+1.48%)Baseline: 11,827.40
12,418.77
(96.64%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,930.00
(+2.39%)Baseline: 3,838.15
4,030.06
(97.52%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,073.00
(-0.77%)Baseline: 87,745.34
92,132.60
(94.51%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,022.00
(-0.95%)Baseline: 79,778.29
83,767.21
(94.34%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
50,375.00
(-1.03%)Baseline: 50,901.47
53,446.54
(94.25%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,956.00
(+3.05%)Baseline: 5,779.76
6,068.75
(98.14%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,227.00
(+4.34%)Baseline: 2,134.33
2,241.04
(99.37%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(-0.00%)Baseline: 21,920.25
23,016.26
(95.24%)
🐰 View full continuous benchmarking report in Bencher

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

ℹ️ 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/client/lid_pn.rs Outdated
Comment on lines +232 to +233
let pn_id_key = pn_jid.to_signal_address_string();
let lid_id_key = lid_jid.to_signal_address_string();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use protocol-address keys for identity migration

Identity records in this codebase are read/written with ProtocolAddress::as_str() (for example via Device::save_identity / load_identity), which includes the .0 suffix, but this migration builds identity keys with to_signal_address_string() (no suffix). As a result, PN identities are never found and migrated to the LID key actually used by Signal operations, so after PN→LID session migration the LID identity remains unset and trust checks silently fall back to TOFU for that contact/device.

Useful? React with 👍 / 👎.

Comment thread src/message.rs Outdated
Comment on lines +894 to +895
let signal_name = sender_encryption_jid.to_signal_address_string();
if let Ok(Some(session_data)) =

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 Load migrated identity using protocol-address format

This retry path reloads identity data with to_signal_address_string() (no .0), but identities are persisted under protocol-address keys (with .0). In the PN→LID migration flow that means load_identity will miss and the identity cache is not repopulated for the retried decrypt, which can leave the new LID address in an uninitialized trust state even though an identity exists in storage.

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

Caution

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

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

146-155: ⚠️ Potential issue | 🟠 Major

Don't send the "immediate" login repair through ensure_e2e_sessions().

ensure_e2e_sessions() waits for offline delivery first on Lines 153-154, so this device-0 repair runs only after the window it is supposed to protect. And if that establish still fails, Lines 352-357 just log and return Ok(()), so the caller never learns that the primary-phone LID session is still missing. Call fetch_and_establish_sessions() directly here and propagate the error.

⚙️ Minimal fix
-        if let Err(e) = self
-            .ensure_e2e_sessions(std::slice::from_ref(&primary_phone_lid))
-            .await
-        {
-            log::warn!("Failed to establish session with own device 0: {e}");
-        }
+        self.fetch_and_establish_sessions(std::slice::from_ref(&primary_phone_lid))
+            .await
+            .map(|_| ())
+            .map_err(|e| {
+                anyhow::anyhow!(
+                    "Failed to establish session with own device 0 {}: {}",
+                    primary_phone_lid,
+                    e
+                )
+            })?;

Also applies to: 347-357

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

In `@src/client/sessions.rs` around lines 146 - 155, ensure_e2e_sessions currently
waits for offline delivery (wait_for_offline_delivery_end) and therefore defers
the "immediate" primary-phone login repair; instead, remove sending that
immediate repair inside ensure_e2e_sessions and call
fetch_and_establish_sessions(...) directly so the repair runs immediately and
any error is propagated to the caller (do not swallow it). Also update the code
path around where ensure_e2e_sessions previously logged and returned Ok(()) (the
block handling the fetch/establish result) to propagate the error (return
Err(...)) rather than only logging; use the existing function names
fetch_and_establish_sessions and ensure_e2e_sessions to locate and modify these
behaviors.
src/retry.rs (1)

215-243: ⚠️ Potential issue | 🟠 Major

Use the resolved address for the whole retry flow, not only process_retry_key_bundle().

This change writes and locks the session under resolved_jid, but the registration-mismatch cleanup on Lines 215-243 and the DM base-key/session deletion on Lines 365-443 still use participant_jid.to_protocol_address(). For a PN retry receipt with a known LID mapping, that leaves the real @lid session untouched and can keep the stale-session loop alive.

Also applies to: 365-443, 551-553

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

In `@src/retry.rs` around lines 215 - 243, The cleanup uses
participant_jid.to_protocol_address() but the session was written/locked under
the resolved JID; change all places in the retry flow that compute
signal_address from participant_jid (including the registration-mismatch block
around extract_registration_id_from_node(node), the DM base-key/session deletion
code paths, and the other occurrences noted) to use
resolved_jid.to_protocol_address() (the same resolved_jid used by
process_retry_key_bundle()), so that calls to
self.signal_cache.get_session(...), self.signal_cache.delete_session(...), and
flush_signal_cache() operate on the resolved JID-backed session/address instead
of the original participant_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/client/lid_pn.rs`:
- Around line 193-196: The identity migration currently only runs when
backend.get_session(&pn_session_key) returns Some(data) and the early continue
skips the identity-key migration; move the identity migration logic that updates
the contact trusted key (the “identity-key” block that migrates `@c.us` → `@lid`
TOFU) out of the session-present branch so it executes regardless of whether
backend.get_session(&pn_session_key) returns None; apply the same change to the
analogous block around the code mentioned (the second occurrence handling lines
~231-246) so identity migration survives PN session cleanup and subsequent
re-establishment.
- Around line 182-224: This migrator currently reads/writes only the persistence
backend (backend.get_session/put_session/delete_session) and then invalidates
only the PN in-memory cache (signal_cache.delete_session), which can race with
concurrent encrypt/decrypt and lose in-memory ratchet state; fix it by taking
the per-address session lock(s) from session_locks for the involved sender
addresses before reading/migrating (use the same lock key you use for
encrypt/decrypt, e.g. the pn_proto/lid_proto protocol address or their session
key), then inside that critical section: re-check/read the backend session, read
and migrate any in-memory Signal session state from signal_cache atomically
along with put_session/delete_session, and only release the lock after both
backend and cache have been updated; ensure you also use message_enqueue_locks
when migrating during per-chat processing if applicable and keep the existing
delete_session fallback behavior (references: backend.get_session,
backend.put_session, backend.delete_session, signal_cache.delete_session,
pn_proto, lid_proto, session_locks, message_enqueue_locks).

In `@src/message.rs`:
- Around line 913-955: The retry branch after calling message_decrypt needs to
special-case SignalProtocolError::DuplicatedMessage so post-migration duplicate
messages are silently ignored instead of being treated as SessionNotFound;
inside the Err(retry_err) arm of the retry_res match, match on retry_err and if
it is SignalProtocolError::DuplicatedMessage(..) simply log a debug/info message
and continue (do not set any_failure, do not fall through to the outer
SessionNotFound handling or trigger retry/undecryptable receipts), otherwise
keep the existing warning behavior and fallback handling; locate this change
around the message_decrypt retry block and the call to
self.clone().handle_decrypted_plaintext to implement the early-continue for
duplicated-message errors.

In `@tests/e2e/tests/lid_sessions.rs`:
- Around line 55-67: The test currently prints raw PII (pn_addr, lid_addr,
pn_user, lid_user) and full session lists (lid_sessions, pn_sessions) to logs
and assertion messages; change assertions and info! calls in the LID session
test to avoid outputting real JIDs by logging counts and masked identifiers
instead — for example, replace "{pn_user}@c.us" / "{lid_user}@lid" and
"{pn_sessions:?}" / "{lid_sessions:?}" with masked values (e.g.,
mask_jid(pn_addr), mask_jid(lid_addr)) or just the number of sessions
(pn_sessions.len(), lid_sessions.len()), update the assert! failure messages to
include context plus either counts or masked IDs, and modify the
info!("[{context}] LID-only sessions verified: {lid_sessions:?}") line to print
something like info!("[{context}] LID-only sessions verified: count={}
masked_first={}", lid_sessions.len(), mask_jid(lid_sessions.get(0))) using
existing helper or add a small mask_jid utility used across tests to redact real
JIDs; apply the same masking/count approach to the other mentioned occurrences
(uses of pn_addr, lid_addr, pn_sessions, lid_sessions at the other ranges).
- Around line 544-562: The test currently only calls
client_a.wait_for_text(test_text, 5) which can hide transient
UndecryptableMessage events; modify the test around the match on msg_result to
explicitly assert no UndecryptableMessage was emitted (use
client_a.assert_no_event or equivalent to check for UndecryptableMessage) before
treating the case as a success, and after the send verify the session mapping is
back under lid_addr (e.g., call the session lookup/assertion that checks the
session owner/address is lid_addr rather than pn_addr) so the regression is
caught deterministically.

---

Outside diff comments:
In `@src/client/sessions.rs`:
- Around line 146-155: ensure_e2e_sessions currently waits for offline delivery
(wait_for_offline_delivery_end) and therefore defers the "immediate"
primary-phone login repair; instead, remove sending that immediate repair inside
ensure_e2e_sessions and call fetch_and_establish_sessions(...) directly so the
repair runs immediately and any error is propagated to the caller (do not
swallow it). Also update the code path around where ensure_e2e_sessions
previously logged and returned Ok(()) (the block handling the fetch/establish
result) to propagate the error (return Err(...)) rather than only logging; use
the existing function names fetch_and_establish_sessions and ensure_e2e_sessions
to locate and modify these behaviors.

In `@src/retry.rs`:
- Around line 215-243: The cleanup uses participant_jid.to_protocol_address()
but the session was written/locked under the resolved JID; change all places in
the retry flow that compute signal_address from participant_jid (including the
registration-mismatch block around extract_registration_id_from_node(node), the
DM base-key/session deletion code paths, and the other occurrences noted) to use
resolved_jid.to_protocol_address() (the same resolved_jid used by
process_retry_key_bundle()), so that calls to
self.signal_cache.get_session(...), self.signal_cache.delete_session(...), and
flush_signal_cache() operate on the resolved JID-backed session/address instead
of the original participant_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: d9e25ed7-c1df-4ce0-a3ce-0bf75d4dc51a

📥 Commits

Reviewing files that changed from the base of the PR and between 38c27aa and 1d47f97.

📒 Files selected for processing (6)
  • src/client/lid_pn.rs
  • src/client/sessions.rs
  • src/message.rs
  • src/retry.rs
  • tests/e2e/tests/lid_sessions.rs
  • wacore/src/messages.rs

Comment thread src/client/lid_pn.rs Outdated
Comment on lines +182 to +224
let backend = self.persistence_manager.backend();

for device_id in 0..=99u16 {
let pn_jid = Jid::pn_device(pn.to_string(), device_id);
let lid_jid = Jid::lid_device(lid.to_string(), device_id);

let pn_proto = pn_jid.to_protocol_address();
let lid_proto = lid_jid.to_protocol_address();
let pn_session_key = pn_proto.to_string();
let lid_session_key = lid_proto.to_string();

// Check for PN session
let session_data = match backend.get_session(&pn_session_key).await {
Ok(Some(data)) => data,
Ok(None) => continue,
Err(e) => {
warn!("Failed to read PN session {pn_session_key}: {e}");
continue;
}
};

match backend.get_session(&lid_session_key).await {
Ok(Some(_)) => {
// LID session already exists — just clean up the stale PN session
if let Err(e) = backend.delete_session(&pn_session_key).await {
warn!("Failed to delete stale PN session {pn_session_key}: {e}");
}
self.signal_cache.delete_session(&pn_proto).await;
info!(
"Deleted stale PN session {pn_session_key} (LID {lid_session_key} exists)"
);
}
Ok(None) => {
// No LID session — migrate
if let Err(e) = backend.put_session(&lid_session_key, &session_data).await {
warn!("Failed to write LID session {lid_session_key}: {e}");
continue;
}
if let Err(e) = backend.delete_session(&pn_session_key).await {
warn!("Failed to delete PN session {pn_session_key} after migration: {e}");
}
self.signal_cache.delete_session(&pn_proto).await;
info!("Migrated session {pn_session_key} -> {lid_session_key}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Don't migrate Signal state behind the cache and without per-address locks.

wacore/src/store/signal_cache.rs:28-65 shows session state can exist as dirty or negative cache entries in memory. This helper reads and writes only through backend.get_session()/put_session() and then invalidates only the PN cache key, so a concurrent encrypt/decrypt can race the migration, an unflushed PN ratchet update can be lost, and a cached LID miss can survive until some later reload. Please take the relevant session_locks and migrate the cache state atomically with the persistent rows. As per coding guidelines: Use session_locks to serialize per-sender Signal encrypt/decrypt operations and message_enqueue_locks to serialize per-chat incoming message processing.

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

In `@src/client/lid_pn.rs` around lines 182 - 224, This migrator currently
reads/writes only the persistence backend
(backend.get_session/put_session/delete_session) and then invalidates only the
PN in-memory cache (signal_cache.delete_session), which can race with concurrent
encrypt/decrypt and lose in-memory ratchet state; fix it by taking the
per-address session lock(s) from session_locks for the involved sender addresses
before reading/migrating (use the same lock key you use for encrypt/decrypt,
e.g. the pn_proto/lid_proto protocol address or their session key), then inside
that critical section: re-check/read the backend session, read and migrate any
in-memory Signal session state from signal_cache atomically along with
put_session/delete_session, and only release the lock after both backend and
cache have been updated; ensure you also use message_enqueue_locks when
migrating during per-chat processing if applicable and keep the existing
delete_session fallback behavior (references: backend.get_session,
backend.put_session, backend.delete_session, signal_cache.delete_session,
pn_proto, lid_proto, session_locks, message_enqueue_locks).

Comment thread src/client/lid_pn.rs Outdated
Comment thread src/message.rs Outdated
Comment thread tests/e2e/tests/lid_sessions.rs
Comment thread tests/e2e/tests/lid_sessions.rs Outdated
…ilures

Messages from the primary phone arrive from a LID address but the session
was stored under PN from an old pairing, causing perpetual SessionNotFound
errors. This aligns with WA Web's createSignalAddress() which always
resolves PN→LID before any session operation.

Changes:
- Add migrate_signal_sessions_on_lid_discovery() to move PN sessions and
  identity keys to LID when a new LID-PN mapping is discovered
- At login, proactively check own device 0 session and migrate PN→LID or
  establish fresh via fetch_and_establish_sessions (bypasses offline wait)
- On SessionNotFound for LID address, attempt PN→LID migration and retry
  decryption before falling back to retry receipt; handle DuplicatedMessage
  gracefully in the post-migration retry path
- Use resolve_encryption_jid() in retry receipt handling to ensure all
  session operations use the resolved LID address
- Deduplicate sender_encryption_jid resolution into
  cache_lid_pn_from_message() + resolve_encryption_jid(); extract
  try_pn_to_lid_migration_decrypt() helper to reduce nesting
- Populate sender_alt for self-messages so LID-PN cache warms from own
  device messages

Identity migration runs independently of session migration (survives
session deletion and re-establishment). Identity keys use protocol address
format (with .0 suffix) matching the Signal store backend.

BREAKING: establish_primary_phone_session_immediate() now actively migrates
and establishes sessions instead of passively checking.
@jlucaso1
jlucaso1 force-pushed the fix/lid-first-signal-sessions branch from 1d47f97 to a9b3c06 Compare April 1, 2026 18:18
@jlucaso1

jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 3m 6s —— View job


Code Review: PN→LID Session Migration (post-fix commit a9b3c06)

  • Read all changed files
  • Review src/client/lid_pn.rs
  • Review src/client/sessions.rs
  • Review src/message.rs
  • Review src/retry.rs
  • Review wacore/src/messages.rs
  • Review tests/e2e/tests/lid_sessions.rs
  • Post final review

This is a solid fix for a real production bug. The commit addresses most of the review feedback from CodeRabbit and the previous Claude review (extracted try_pn_to_lid_migration_decrypt, handled DuplicatedMessage in migration retry, masked JIDs in tests, added explicit assert_no_event + session assertions in the reproduction test). Good work.

One real issue and a few observations below.

Bug: is_peer check broken after PN→LID resolution

src/retry.rs:193-196 — After resolving participant_jid from PN to LID at line 179, the is_peer check compares participant_jid.user (now the LID user, e.g. "100000012345678") against our_pn.user (the phone number, e.g. "559999999999"). This will never match for LID-resolved JIDs, so is_peer is always false for own-device retries when the JID was resolved.

This means the safety check at src/retry.rs:573 (which rejects registration ID changes for peer devices) is silently bypassed for LID-addressed own-device retries.

Fix should also check against device_snapshot.lid:

let is_peer = device_snapshot
    .pn
    .as_ref()
    .is_some_and(|our_pn| participant_jid.user == our_pn.user)
    || device_snapshot
        .lid
        .as_ref()
        .is_some_and(|our_lid| participant_jid.user == our_lid.user);

Fix this →

Observations

1. Identity migration is now independent of session — good fix (src/client/lid_pn.rs:213-225)

The previous CodeRabbit review flagged that identity migration was gated behind the session-present branch. This commit correctly moved it out (line 213: // Migrate identity independently of session). The let-chain with backend.load_identity(pn_addr_key) + backend.load_identity(lid_addr_key) is clean.

2. try_pn_to_lid_migration_decrypt extraction is clean (src/message.rs:1175-1258)

The previous review asked for the 80-line nested block to be extracted — this is done well. The method returns bool (success/failure), handles DuplicatedMessage as success, and the caller is simple:

if self.try_pn_to_lid_migration_decrypt(...).await {
    any_success = true;
    continue;
}

3. Test JID masking (tests/e2e/tests/lid_sessions.rs:19-31)

The mask_addr helper and its usage throughout assertions/logs addresses the PII concern. The masking is applied to all info! calls and assertion messages.

4. Reproduction test is solid (tests/e2e/tests/lid_sessions.rs:490-595)

The test now:

  • Asserts wait_for_text with 15s timeout (up from 5s)
  • Explicitly asserts assert_no_event for UndecryptableMessage
  • Verifies session is under LID and PN is cleaned up post-migration

5. establish_primary_phone_session_immediate correctly bypasses offline wait (src/client/sessions.rs:340-343)

The previous CodeRabbit review suggested calling fetch_and_establish_sessions directly instead of going through ensure_e2e_sessions (which waits for offline delivery). This is already done — the comment at line 340-341 documents the reasoning.

6. Device range 0..=99 iteration (src/client/lid_pn.rs:179)

Still iterates 100 device IDs sequentially. As noted in the previous review, this is fine for now since most will be None cache hits. The identity migration adds another 100 calls per discovery, but this runs at most once per new LID mapping.

Summary

The PR is in good shape. The only actionable item is the is_peer bug in src/retry.rs:193-196 where LID-resolved JIDs bypass the peer device safety check. Everything else looks correct and well-tested.


|

- establish_primary_phone_session_immediate now only logs session state;
  migration is fully lazy via try_pn_to_lid_migration_decrypt on first message
- Fix is_peer check in retry handler: after PN→LID resolution the user part
  is LID, so also compare against device_snapshot.lid (not just pn)
- Test: mask JIDs in logs, assert no UndecryptableMessage + verify session
  under LID after migration, bump timeout to 15s for CI stability

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

ℹ️ 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/client/lid_pn.rs
Comment on lines +189 to +193
if let Ok(Some(session_data)) = backend.get_session(pn_addr_key).await {
match backend.get_session(lid_addr_key).await {
Ok(Some(_)) => {
if let Err(e) = backend.delete_session(pn_addr_key).await {
warn!("Failed to delete stale PN session {pn_addr_key}: {e}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Serialize PN→LID session migration with session locks

This migration path reads/writes sessions directly through backend and then calls signal_cache.delete_session without acquiring the per-address session_lock_for lock that the send/decrypt/retry paths rely on. If a message is concurrently advancing the PN session in cache, migration can copy an older backend record to LID and then drop the newer PN state before it is flushed, which can roll back ratchet state and trigger follow-up BadMac/SessionNotFound failures for active chats.

Useful? React with 👍 / 👎.

Comment thread src/retry.rs Outdated
// Device existence check (matches WhatsApp Web's WAWebApiDeviceList.hasDevice).
// This prevents processing retry receipts from unknown/stale devices.
// Resolve PN→LID so all session operations use the correct address
let participant_jid = self.resolve_encryption_jid(&participant_jid).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve peer-device detection after PN→LID normalization

Normalizing participant_jid to LID here happens before peer detection, but is_peer still compares only against device_snapshot.pn.user; when a retry receipt from our own account arrives as PN and resolves to LID, is_peer becomes false. That bypasses the stricter peer-device registration-ID change rejection in process_retry_key_bundle, so a peer reinstall/key change can be treated as a normal contact session replacement instead of being blocked.

Useful? React with 👍 / 👎.

@jlucaso1

jlucaso1 commented Apr 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 1, 2026

Copy link
Copy Markdown

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


Code Review: PN→LID Session Migration (commits a9b3c06, 9956dc6)

  • Read all changed files
  • Review src/client/lid_pn.rs
  • Review src/client/sessions.rs
  • Review src/message.rs
  • Review src/retry.rs
  • Review wacore/src/messages.rs
  • Review tests/e2e/tests/lid_sessions.rs
  • Post final review

Well-structured fix for a real production bug. The approach — on-the-fly PN→LID migration during SessionNotFound, plus lazy login-time detection — is sound and aligns with WA Web's createSignalAddress(). The code across both commits addresses the earlier review feedback well (extracted try_pn_to_lid_migration_decrypt, DuplicatedMessage handling, JID masking, explicit assert_no_event + session assertions in the reproduction test).

Previous reviews have already covered several items that were fixed in commit 9956dc6. I'll focus on what remains and what's new.

Verified as fixed (from earlier reviews)

  • Identity migration independent of session — moved out of the session-present branch (lid_pn.rs:213-225). Good.
  • DuplicatedMessage handling in migration retry — try_pn_to_lid_migration_decrypt returns true for dupes (message.rs:1244-1249). Good.
  • is_peer LID check — now checks both device_snapshot.pn and device_snapshot.lid (retry.rs:193-200). Good.
  • Test JID maskingmask_addr helper used throughout (lid_sessions.rs:19-31). Good.
  • Reproduction test hardenedassert_no_event + session assertions added, timeout bumped to 15s. Good.

Remaining observations

1. Session migration still bypasses session_lockssrc/client/lid_pn.rs:189-211

The migration reads/writes through backend (persistent store) and calls signal_cache.delete_session without holding the per-address session lock. CodeRabbit and Codex both flagged this. In practice, the risk is low because:

  • During login, there's no concurrent message processing.
  • During on-the-fly migration in try_pn_to_lid_migration_decrypt, the outer _session_guard is already held for the LID address.
  • The PN address being migrated from is typically idle (no concurrent encrypt/decrypt targeting it).

However, if two concurrent messages trigger migration for different LID addresses that map to the same PN (theoretically impossible in WA's addressing model, but worth noting), there's a potential race. Since this is defense-in-depth rather than a practical bug, I'd consider it a low-priority improvement.

2. Login session check is lazy — good tradeoffsrc/client/sessions.rs:262-300

The previous establish_primary_phone_session_immediate has been simplified to just log the current state. This is a good change — the actual migration happens on first message via try_pn_to_lid_migration_decrypt, avoiding the wait_for_offline_delivery_end timing issue that CodeRabbit flagged. The method name is now slightly misleading (it doesn't "establish" anything), but the comment at line 263 documents this clearly.

3. try_pn_to_lid_migration_decrypt identity reloadsrc/message.rs:1208-1212

The identity is loaded from backend using addr_key (which is signal_address.as_str(), the protocol address format with .0 suffix). Codex flagged that this might not match the backend key format, but looking at lid_pn.rs:214-220, the migration writes identity under pn_addr_key/lid_addr_key which are also ProtocolAddress::as_str() values. So the formats are consistent. The Codex P1 about key format mismatch appears to be a false positive.

4. cache_lid_pn_from_message is cleansrc/message.rs:1261-1300

The extraction from the previous inline block eliminates ~75 lines of duplication. The LID-PN pair extraction handles both directions (LID sender with PN alt, PN sender with LID alt) and includes the self-message case via wacore/src/messages.rs:197-204. Clean.

5. E2E test lid_sessions.rs:196-198 still logs raw session addresses

scan_sessions returns full session address strings, and line 196 logs them with {lid_sessions:?}. While assert_lid_only_sessions uses mask_addr, this specific log in test_multiple_sends_stay_lid_only does not:

info!(
    "LID session count after 5 sends: {} ({lid_sessions:?})",
    lid_sessions.len()
);

Minor nit — could mask these too for consistency.

6. The resolve_encryption_jid in retry pathsrc/retry.rs:179

The resolve happens early, which is good — all downstream operations (registration ID check at line 221, process_retry_key_bundle at line 208) now use the resolved address. The registration-mismatch block at lines 220-249 correctly uses participant_jid.to_protocol_address() after resolution. This addresses CodeRabbit's concern about inconsistent address usage in the retry flow.

Summary

The PR is solid. The core fix (on-the-fly migration + lazy login check) is correct and well-tested. The only substantive item from previous reviews that remains is the session lock concern in migrate_signal_sessions_on_lid_discovery, which is low-risk in practice. The E2E tests are thorough, especially the reproduction test that validates the exact production bug scenario. LGTM with the minor nit above.


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

♻️ Duplicate comments (2)
tests/e2e/tests/lid_sessions.rs (1)

195-198: ⚠️ Potential issue | 🟠 Major

Mask the remaining raw addresses in logs and assertion messages.

mask_addr() is used in the helper, but these paths still print live @c.us / @lid addresses into CI output and failure text. Please reuse mask_addr() here too, or replace the identifiers with counts/context only.

Based on learnings: Applies to **/test.rs : Never use real PII (phone numbers and JIDs) in test code; use fictitious values instead.

Also applies to: 248-248, 280-283, 407-423, 530-538

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

In `@tests/e2e/tests/lid_sessions.rs` around lines 195 - 198, The logs and
assertion messages print real JIDs (e.g., "@c.us" / "@lid") from lid_sessions
which is PII; update the logging and assertions to call the existing mask_addr()
helper for each JID (or replace with non-identifying summaries like counts)
wherever lid_sessions or similar session JIDs are used (e.g., the info! call
referencing lid_sessions.len() and entries, and the other occurrences noted at
lines ~248, ~280-283, ~407-423, ~530-538); locate usages of lid_sessions, any
debug/info! or assert messages that interpolate JIDs and wrap those identifiers
with mask_addr(jid) or use a masked/map view so no raw addresses appear in test
output.
src/client/lid_pn.rs (1)

189-206: ⚠️ Potential issue | 🔴 Critical

Make the PN→LID move atomic with signal_cache and the session locks.

This migrator reads/writes only through the backend and then invalidates only the PN cache key. If the PN session is dirty in signal_cache, or the LID side is cached as a miss, you can lose newer ratchet state and still fail the immediate LID re-check even though SQLite was updated. Move the session under the relevant session_locks and update/invalidate both cache entries in the same critical section as the persistence writes.

As per coding guidelines: Use session_locks to serialize per-sender Signal encrypt/decrypt operations and message_enqueue_locks to serialize per-chat incoming message processing.

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

In `@src/client/lid_pn.rs` around lines 189 - 206, The PN→LID migration must be
done under the per-sender session_locks and update/invalidate both cache entries
atomically: acquire the session_locks for the sender (using the same key you use
for pn_proto/pn_addr_key), then re-check backend.get_session(pn_addr_key) and
backend.get_session(lid_addr_key) while holding the lock, perform
backend.put_session(lid_addr_key, &session_data) and
backend.delete_session(pn_addr_key) inside that locked section, and update the
signal_cache for both sides inside the same critical section (write/insert or
invalidate the LID cache entry and then delete/invalidate the PN cache entry)
before releasing the lock so no concurrent Signal operations can see mixed
state; use the existing symbols backend.get_session, backend.put_session,
backend.delete_session, self.signal_cache (delete/update), session_locks and
pn_proto/pn_addr_key/lid_addr_key to locate and implement this change.
🤖 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`:
- Around line 272-275: The check in the block that inspects device_snapshot.lid
(the let Some(ref own_lid) = device_snapshot.lid else { ... return Ok(()); })
must not silently no-op; instead, when get_device_snapshot() yields lid == None,
enqueue or re-schedule the primary-phone migration/prekey fetch work to run once
the own LID becomes available (e.g., push a task to your existing retry/worker
queue or register a callback/listener that triggers the same primary-phone
session check when the appstate/offline sync populates lid). Update the logic
around get_device_snapshot(), own_lid, and the primary phone session check so
that the function returns a pending/rescheduled outcome rather than Ok(()) when
lid is missing, and ensure the re-scheduled action uses the same code path that
runs when own_lid is present.

In `@src/message.rs`:
- Around line 872-889: The current try_pn_to_lid_migration_decrypt call (used in
the message handling path) returns only a bool so all post-migration failures
collapse into RetryReason::NoSession; change try_pn_to_lid_migration_decrypt to
return a typed outcome (e.g., Result<(), SignalProtocolError> or a small enum
like MigrationOutcome { Success, DuplicateMessage, Err(SignalProtocolError) })
and propagate the specific error back to the caller so the caller can
distinguish DuplicatedMessage, UntrustedIdentity, BadMac/InvalidMessage,
InvalidPreKeyId, etc.; update the call sites in the message processing flow (the
block around try_pn_to_lid_migration_decrypt and the similar block at 1172-1259)
to match on the new outcome and continue normal remediation paths instead of
always treating false as NoSession.

In `@src/retry.rs`:
- Around line 178-179: In handle_retry_receipt(), do not rebind participant_jid
with the resolved value; call resolve_encryption_jid(&participant_jid).await and
store the result in a new variable (e.g., encryption_jid or resolved_jid) so the
original raw participant_jid remains available for the PN peer check and group
resend logic; update calls that need the session address (such as
prepare_group_retry_stanza and any session operations) to use the new
encryption_jid, leaving the original participant_jid unchanged for peer
comparisons and other logic.

In `@tests/e2e/tests/lid_sessions.rs`:
- Around line 34-50: The helper scan_sessions currently checks only the
persistent backend via persistence_manager().backend() / backend.get_session
which misses in-memory cached sessions; update the test to also assert the
live/runtime Signal store by querying Client.signal_cache (or the in-memory
runtime store API) for each address (e.g., inside scan_sessions or the callers
around lines 54-87) or, alternatively, force a client reconnect/evict cache
(call the client's reconnect or cache-clear method) before each backend
assertion so the test exercises cache/backend consistency and fails when the
runtime store differs from the SQLite backend.

---

Duplicate comments:
In `@src/client/lid_pn.rs`:
- Around line 189-206: The PN→LID migration must be done under the per-sender
session_locks and update/invalidate both cache entries atomically: acquire the
session_locks for the sender (using the same key you use for
pn_proto/pn_addr_key), then re-check backend.get_session(pn_addr_key) and
backend.get_session(lid_addr_key) while holding the lock, perform
backend.put_session(lid_addr_key, &session_data) and
backend.delete_session(pn_addr_key) inside that locked section, and update the
signal_cache for both sides inside the same critical section (write/insert or
invalidate the LID cache entry and then delete/invalidate the PN cache entry)
before releasing the lock so no concurrent Signal operations can see mixed
state; use the existing symbols backend.get_session, backend.put_session,
backend.delete_session, self.signal_cache (delete/update), session_locks and
pn_proto/pn_addr_key/lid_addr_key to locate and implement this change.

In `@tests/e2e/tests/lid_sessions.rs`:
- Around line 195-198: The logs and assertion messages print real JIDs (e.g.,
"@c.us" / "@lid") from lid_sessions which is PII; update the logging and
assertions to call the existing mask_addr() helper for each JID (or replace with
non-identifying summaries like counts) wherever lid_sessions or similar session
JIDs are used (e.g., the info! call referencing lid_sessions.len() and entries,
and the other occurrences noted at lines ~248, ~280-283, ~407-423, ~530-538);
locate usages of lid_sessions, any debug/info! or assert messages that
interpolate JIDs and wrap those identifiers with mask_addr(jid) or use a
masked/map view so no raw addresses appear in test output.
🪄 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: 65090fc5-e6dc-436e-9ffa-cfe94ce7f240

📥 Commits

Reviewing files that changed from the base of the PR and between 1d47f97 and a9b3c06.

📒 Files selected for processing (6)
  • src/client/lid_pn.rs
  • src/client/sessions.rs
  • src/message.rs
  • src/retry.rs
  • tests/e2e/tests/lid_sessions.rs
  • wacore/src/messages.rs

Comment thread src/client/sessions.rs
Comment on lines +272 to +275
let Some(ref own_lid) = device_snapshot.lid else {
log::debug!("No own LID yet, skipping primary phone session check");
return 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.

⚠️ Potential issue | 🟠 Major

Don't silently no-op when the own LID hasn't been populated yet.

get_device_snapshot() can legitimately return lid = None during login before appstate/offline sync fills it in. Returning Ok(()) here means the proactive own-device migration/prekey fetch never runs on that path, so device 0 can remain PN-only until some later flow repairs it. Please defer or reschedule this step once the own LID is available instead of skipping it.

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

In `@src/client/sessions.rs` around lines 272 - 275, The check in the block that
inspects device_snapshot.lid (the let Some(ref own_lid) = device_snapshot.lid
else { ... return Ok(()); }) must not silently no-op; instead, when
get_device_snapshot() yields lid == None, enqueue or re-schedule the
primary-phone migration/prekey fetch work to run once the own LID becomes
available (e.g., push a task to your existing retry/worker queue or register a
callback/listener that triggers the same primary-phone session check when the
appstate/offline sync populates lid). Update the logic around
get_device_snapshot(), own_lid, and the primary phone session check so that the
function returns a pending/rescheduled outcome rather than Ok(()) when lid is
missing, and ensure the re-scheduled action uses the same code path that runs
when own_lid is present.

Comment thread src/message.rs
Comment on lines +872 to +889
// Try PN→LID session migration before sending retry receipt
if let SignalProtocolError::SessionNotFound(_) = e {
if self
.try_pn_to_lid_migration_decrypt(
sender_encryption_jid,
&signal_address,
&parsed_message,
&mut adapter,
&mut rng,
&enc_type,
padding_version,
info,
)
.await
{
any_success = true;
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t collapse post-migration failures into NoSession.

try_pn_to_lid_migration_decrypt() returns false for every retry error other than DuplicatedMessage, so the caller on Line 895 always falls back to RetryReason::NoSession. That skips the existing UntrustedIdentity, BadMac/InvalidMessage, and InvalidPreKeyId remediation paths once a PN session has been migrated to LID, and can leave the stale LID session/identity state in place. Return the retry error, or a typed outcome enum, so the normal handlers can continue after migration.

Also applies to: 1172-1259

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

In `@src/message.rs` around lines 872 - 889, The current
try_pn_to_lid_migration_decrypt call (used in the message handling path) returns
only a bool so all post-migration failures collapse into RetryReason::NoSession;
change try_pn_to_lid_migration_decrypt to return a typed outcome (e.g.,
Result<(), SignalProtocolError> or a small enum like MigrationOutcome { Success,
DuplicateMessage, Err(SignalProtocolError) }) and propagate the specific error
back to the caller so the caller can distinguish DuplicatedMessage,
UntrustedIdentity, BadMac/InvalidMessage, InvalidPreKeyId, etc.; update the call
sites in the message processing flow (the block around
try_pn_to_lid_migration_decrypt and the similar block at 1172-1259) to match on
the new outcome and continue normal remediation paths instead of always treating
false as NoSession.

Comment thread src/retry.rs Outdated
Comment on lines +34 to +50
async fn scan_sessions(
backend: &dyn SignalStore,
user: &str,
server: &str,
) -> anyhow::Result<Vec<String>> {
let mut results = Vec::new();
for device_id in 0..=99u16 {
let addr = if device_id == 0 {
format!("{user}@{server}.0")
} else {
format!("{user}:{device_id}@{server}.0")
};
if backend.get_session(&addr).await?.is_some() {
results.push(addr);
}
}
Ok(results)

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

These helpers only assert SQLite state, not the live Signal store.

persistence_manager().backend() bypasses Client.signal_cache, so a PN session or cached LID miss left in memory would still satisfy these checks until a reconnect. For this regression, add a live-session assertion via the runtime store, or force a reconnect before each storage assertion, so the suite exercises cache/backend consistency too.

Also applies to: 54-87

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

In `@tests/e2e/tests/lid_sessions.rs` around lines 34 - 50, The helper
scan_sessions currently checks only the persistent backend via
persistence_manager().backend() / backend.get_session which misses in-memory
cached sessions; update the test to also assert the live/runtime Signal store by
querying Client.signal_cache (or the in-memory runtime store API) for each
address (e.g., inside scan_sessions or the callers around lines 54-87) or,
alternatively, force a client reconnect/evict cache (call the client's reconnect
or cache-clear method) before each backend assertion so the test exercises
cache/backend consistency and fails when the runtime store differs from the
SQLite backend.

@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: 9956dc65ef

ℹ️ 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
// Device existence check (matches WhatsApp Web's WAWebApiDeviceList.hasDevice).
// This prevents processing retry receipts from unknown/stale devices.
// Resolve PN→LID so all session operations use the correct address
let participant_jid = self.resolve_encryption_jid(&participant_jid).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve retry participant namespace when resolving PN to LID

Normalizing participant_jid to LID here rewrites the value that is later used as the outgoing participant attribute in prepare_group_retry_stanza, even when the group is still PN-addressed (addressing_mode="pn"). In that case we can emit a retry stanza with PN addressing mode but a LID participant, which can target the wrong namespace and cause retries to be ignored for affected groups/devices. Keep the original participant JID for stanza addressing and only use the resolved JID for Signal session/identity lookups.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (6)
src/retry.rs (2)

178-179: ⚠️ Potential issue | 🟠 Major

Rebinding participant_jid loses the original address for downstream use.

Reassigning participant_jid at line 179 means the original PN address is no longer available. While the is_peer check (lines 193-200) now correctly handles both PN and LID comparisons, this creates two issues:

  1. Line 470 calls resolve_encryption_jid(&participant_jid) again on the already-resolved JID (redundant/no-op).
  2. Line 484 passes the resolved participant_jid to prepare_group_retry_stanza, but the original participant address may be needed for proper envelope addressing.

Consider keeping the original and using a separate variable for the resolved address:

Suggested fix
-        // Resolve PN→LID so all session operations use the correct address
-        let participant_jid = self.resolve_encryption_jid(&participant_jid).await;
+        // Resolve PN→LID so session operations use the correct address
+        let resolved_participant_jid = self.resolve_encryption_jid(&participant_jid).await;

-        let sender_device_id = participant_jid.device() as u32;
-        let sender_user = participant_jid.user.clone();
+        let sender_device_id = resolved_participant_jid.device() as u32;
+        let sender_user = resolved_participant_jid.user.clone();
         if !self.has_device(&sender_user, sender_device_id).await {
             // ...
         }

         // Check if this is a retry from our own device (peer).
         let device_snapshot = self.persistence_manager.get_device_snapshot().await;
         let is_peer = device_snapshot
             .pn
             .as_ref()
-            .is_some_and(|our_pn| participant_jid.user == our_pn.user)
+            .is_some_and(|our_pn| participant_jid.is_same_user_as(our_pn))
             || device_snapshot
                 .lid
                 .as_ref()
-                .is_some_and(|our_lid| participant_jid.user == our_lid.user);
+                .is_some_and(|our_lid| participant_jid.is_same_user_as(our_lid));

         // ...
         
         if !receipt.source.chat.is_status_broadcast() {
             let key_bundle_result = self
-                .process_retry_key_bundle(node, &participant_jid, is_peer)
+                .process_retry_key_bundle(node, &resolved_participant_jid, is_peer)
                 .await;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` around lines 178 - 179, Rebinding participant_jid loses the
original PN address and causes redundant/resolved use later; keep the original
PN JID and store the resolved LID in a new variable (e.g.,
resolved_participant_jid) by calling resolve_encryption_jid(&participant_jid).
Update downstream uses: use resolved_participant_jid for session operations and
is_peer comparisons when LID is required, avoid calling resolve_encryption_jid
again at line 470, and pass the original participant_jid into
prepare_group_retry_stanza when the original envelope addressing is needed.

193-200: 🧹 Nitpick | 🔵 Trivial

is_peer expansion correctly handles both PN and LID addresses.

The dual check against our_pn.user and our_lid.user ensures peer detection works regardless of whether the participant JID came in as PN or LID format. This aligns with the PR's goal of supporting LID-first addressing.

However, consider using is_same_user_as() instead of direct user string comparison for more robust JID equivalence checking:

         let is_peer = device_snapshot
             .pn
             .as_ref()
-            .is_some_and(|our_pn| participant_jid.user == our_pn.user)
+            .is_some_and(|our_pn| participant_jid.is_same_user_as(our_pn))
             || device_snapshot
                 .lid
                 .as_ref()
-                .is_some_and(|our_lid| participant_jid.user == our_lid.user);
+                .is_some_and(|our_lid| participant_jid.is_same_user_as(our_lid));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` around lines 193 - 200, The is_peer check currently compares
participant_jid.user to our_pn.user and our_lid.user directly; replace those
direct string comparisons in the is_peer expression (referencing
device_snapshot.pn, device_snapshot.lid and participant_jid.user / our_pn.user /
our_lid.user) with the more robust JID equality helper is_same_user_as(), i.e.,
call is_same_user_as(participant_jid, our_pn) and
is_same_user_as(participant_jid, our_lid) (or equivalent) inside the is_some_and
closures so peer detection uses the canonical JID equivalence function.
src/client/sessions.rs (1)

262-275: 🧹 Nitpick | 🔵 Trivial

Function name no longer reflects behavior; early return when LID is missing may hide incomplete state.

The function establish_primary_phone_session_immediate now only logs session state without establishing anything. The name is misleading—consider renaming to log_primary_phone_session_state or similar to match the doc comment.

Additionally, returning Ok(()) when device_snapshot.lid is None (lines 272-274) means the caller (in src/client.rs around line 1920) will proceed without knowing the proactive check was skipped. Since the caller only logs a warning on Err, it won't be aware that LID was missing at login time. If this is intentional (lazy migration on first message), consider returning a sentinel or logging at warn! level to improve observability.

♻️ Suggested improvements
-    /// Log primary phone (device 0) session state at login.
-    /// Migration is lazy via try_pn_to_lid_migration_decrypt on first message.
-    pub(crate) async fn establish_primary_phone_session_immediate(&self) -> Result<()> {
+    /// Log primary phone (device 0) session state at login.
+    /// Migration is lazy via try_pn_to_lid_migration_decrypt on first message.
+    pub(crate) async fn log_primary_phone_session_state(&self) -> Result<()> {
         let device_snapshot = self.persistence_manager.get_device_snapshot().await;
 
         let own_pn = device_snapshot
             .pn
             .clone()
             .ok_or_else(|| anyhow::Error::from(crate::client::ClientError::NotLoggedIn))?;
 
         let Some(ref own_lid) = device_snapshot.lid else {
-            log::debug!("No own LID yet, skipping primary phone session check");
+            log::warn!("No own LID yet, skipping primary phone session check (will migrate on first message)");
             return Ok(());
         };
tests/e2e/tests/lid_sessions.rs (3)

194-198: ⚠️ Potential issue | 🟡 Minor

Raw session addresses logged in debug output.

Line 196 logs {lid_sessions:?} which contains full JID-based session addresses. This can expose real user identifiers in CI logs.

Suggested fix
     let lid_sessions = scan_sessions(&*backend_a, &lid_b.user, "lid").await?;
     info!(
-        "LID session count after 5 sends: {} ({lid_sessions:?})",
-        lid_sessions.len()
+        "LID session count after 5 sends: {}, first: {}",
+        lid_sessions.len(),
+        lid_sessions.first().map(|s| mask_addr(s)).unwrap_or_default()
     );

Based on learnings: Applies to **/test.rs : Never use real PII (phone numbers and JIDs) in test code; use fictitious values instead.

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

In `@tests/e2e/tests/lid_sessions.rs` around lines 194 - 198, The test currently
logs raw session addresses via the info! call that prints `{lid_sessions:?}`,
exposing JID/PII; update the logging in tests/e2e/tests/lid_sessions.rs to avoid
printing real JIDs by either logging only lid_sessions.len() or mapping/masking
each entry returned by scan_sessions(&*backend_a, &lid_b.user, "lid") into a
non-PII form (e.g., replace domain/localpart with a fixed placeholder or
generate sequential fake IDs) before passing to info!; ensure the change touches
the lid_sessions variable usage and the info! call so no real phone numbers/JIDs
appear in CI logs.

19-31: 🧹 Nitpick | 🔵 Trivial

Masking helper partially redacts addresses; sufficient for test logs but not fully anonymized.

The mask_addr function reveals the first 2 and last 2 characters of the user portion (e.g., 55...99@lid.0). While this reduces exposure, patterns may still be identifiable in production logs. Since this is e2e test code that connects to live accounts, consider logging only counts or using fully synthetic placeholders in CI output.

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

In `@tests/e2e/tests/lid_sessions.rs` around lines 19 - 31, The mask_addr helper
leaks fragments of the user part (mask_addr) which may expose patterns; update
it to fully anonymize user identifiers in test logs by replacing the entire
local part with a fixed placeholder or a deterministic safe token (e.g.,
"<REDACTED>" or "user-<short-hash>") while preserving the domain for test
diagnostics, and ensure the function returns "<REDACTED>@domain" (or similar)
for inputs containing '@' and leaves non-address strings unchanged.

246-248: ⚠️ Potential issue | 🟡 Minor

Raw PN address logged.

Line 248 logs the full pn_addr which contains the real user identifier.

-    info!("Injected PN session at {pn_addr}");
+    info!("Injected PN session at {}", mask_addr(&pn_addr));

Based on learnings: Applies to **/test.rs : Never use real PII (phone numbers and JIDs) in test code.

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

In `@tests/e2e/tests/lid_sessions.rs` around lines 246 - 248, The test currently
logs a raw PII-containing value via pn_addr (the string like "{}@c.us.0") after
calling backend_a.put_session(&pn_addr, &lid_session_data); change the logging
to avoid printing the real JID/phone: mask or redact pn_addr (e.g., replace the
localpart with a fixed token, hash it, or log only the domain), or log a non-PII
placeholder (e.g., "Injected PN session for <redacted>") instead; update the
info! call that references pn_addr so it never emits the full pn_addr value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/e2e/tests/lid_sessions.rs`:
- Around line 528-536: The test is logging full PII-like addresses via the info!
calls using lid_addr and pn_addr; change the logging to redact or obfuscate
those values (e.g., log a masked form, last N chars, or a short hash) instead of
the raw lid_addr/pn_addr, and ensure any similar info! usage in this file or
other tests (e.g., the "Moved session" message and the earlier "Read LID
session" message) uses the same masked representation; keep
backend_a.put_session and backend_a.delete_session unchanged, only adjust what
is passed into info! to avoid printing raw addresses.
- Around line 405-421: Tests are currently logging raw PII via lid_addr and
pn_addr; update the assertions and info! calls to avoid printing real addresses
by replacing lid_addr and pn_addr with a redacted or masked representation
(e.g., mask_local_part or a constant like "<REDACTED_DEVICE>") before use;
change the assert! messages and info! invocations that reference lid_addr and
pn_addr (the variables lid_addr and pn_addr and the info!/assert! sites in the
own-device test) to use the redacted string or a deterministic hash so no raw
PII appears in test output.

---

Duplicate comments:
In `@src/retry.rs`:
- Around line 178-179: Rebinding participant_jid loses the original PN address
and causes redundant/resolved use later; keep the original PN JID and store the
resolved LID in a new variable (e.g., resolved_participant_jid) by calling
resolve_encryption_jid(&participant_jid). Update downstream uses: use
resolved_participant_jid for session operations and is_peer comparisons when LID
is required, avoid calling resolve_encryption_jid again at line 470, and pass
the original participant_jid into prepare_group_retry_stanza when the original
envelope addressing is needed.
- Around line 193-200: The is_peer check currently compares participant_jid.user
to our_pn.user and our_lid.user directly; replace those direct string
comparisons in the is_peer expression (referencing device_snapshot.pn,
device_snapshot.lid and participant_jid.user / our_pn.user / our_lid.user) with
the more robust JID equality helper is_same_user_as(), i.e., call
is_same_user_as(participant_jid, our_pn) and is_same_user_as(participant_jid,
our_lid) (or equivalent) inside the is_some_and closures so peer detection uses
the canonical JID equivalence function.

In `@tests/e2e/tests/lid_sessions.rs`:
- Around line 194-198: The test currently logs raw session addresses via the
info! call that prints `{lid_sessions:?}`, exposing JID/PII; update the logging
in tests/e2e/tests/lid_sessions.rs to avoid printing real JIDs by either logging
only lid_sessions.len() or mapping/masking each entry returned by
scan_sessions(&*backend_a, &lid_b.user, "lid") into a non-PII form (e.g.,
replace domain/localpart with a fixed placeholder or generate sequential fake
IDs) before passing to info!; ensure the change touches the lid_sessions
variable usage and the info! call so no real phone numbers/JIDs appear in CI
logs.
- Around line 19-31: The mask_addr helper leaks fragments of the user part
(mask_addr) which may expose patterns; update it to fully anonymize user
identifiers in test logs by replacing the entire local part with a fixed
placeholder or a deterministic safe token (e.g., "<REDACTED>" or
"user-<short-hash>") while preserving the domain for test diagnostics, and
ensure the function returns "<REDACTED>@domain" (or similar) for inputs
containing '@' and leaves non-address strings unchanged.
- Around line 246-248: The test currently logs a raw PII-containing value via
pn_addr (the string like "{}@c.us.0") after calling
backend_a.put_session(&pn_addr, &lid_session_data); change the logging to avoid
printing the real JID/phone: mask or redact pn_addr (e.g., replace the localpart
with a fixed token, hash it, or log only the domain), or log a non-PII
placeholder (e.g., "Injected PN session for <redacted>") instead; update the
info! call that references pn_addr so it never emits the full pn_addr value.
🪄 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: 4dd73ffa-31df-4bd9-bcc5-699544017b57

📥 Commits

Reviewing files that changed from the base of the PR and between a9b3c06 and 9956dc6.

📒 Files selected for processing (3)
  • src/client/sessions.rs
  • src/retry.rs
  • tests/e2e/tests/lid_sessions.rs

Comment thread tests/e2e/tests/lid_sessions.rs
Comment thread tests/e2e/tests/lid_sessions.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.

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)

178-189: ⚠️ Potential issue | 🟠 Major

Don't reject valid retries on a namespace-only device miss.

resolved_jid fixes the session path, but the guard on Lines 181-188 still calls has_device() with participant_jid.user only. src/client/device_registry.rs:99-135 caches device rows under the backend's actual namespace, so after a reconnect/cold lid_pn_cache a PN retry can miss a LID-backed record here and return on Line 188 before process_retry_key_bundle() or resend runs. Probe both the raw and resolved users before dropping the receipt.

Suggested change
-        let sender_device_id = participant_jid.device() as u32;
-        let sender_user = participant_jid.user.clone();
-        if !self.has_device(&sender_user, sender_device_id).await {
+        let sender_device_id = participant_jid.device() as u32;
+        let sender_user = participant_jid.user.clone();
+        let resolved_sender_user = resolved_jid.user.clone();
+        let has_sender_device = self.has_device(&sender_user, sender_device_id).await
+            || (resolved_sender_user != sender_user
+                && self.has_device(&resolved_sender_user, sender_device_id).await);
+        if !has_sender_device {
             warn!(
-                "handle_retry_receipt: device not found for device={}, user={}",
-                sender_device_id, sender_user
+                "handle_retry_receipt: device not found for device={}, user={} (resolved={})",
+                sender_device_id, sender_user, resolved_sender_user
             );
             return Ok(());
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/retry.rs` around lines 178 - 189, The guard that bails out when a device
is missing uses has_device(&sender_user, sender_device_id) but only checks the
original participant_jid.user, which can falsely miss a LID-backed cached record
that was stored under the backend namespace resolved by resolve_encryption_jid;
update the check in the retry handling (around resolved_jid, participant_jid,
sender_device_id) to probe both the raw user and the resolved user (i.e., call
has_device with participant_jid.user and also with resolved_jid.user) and only
return early if both checks fail so process_retry_key_bundle() / resend logic
can still run for namespace-mapped devices.
♻️ Duplicate comments (2)
tests/e2e/tests/lid_sessions.rs (2)

195-198: ⚠️ Potential issue | 🟠 Major

Finish redacting live session addresses in test output.

These messages still emit raw identifiers via {lid_sessions:?}, {lid_addr}, and {pn_addr}. A failing run will leak real test-account JIDs into CI logs even though mask_addr() is already available in this file. Use masked values or counts consistently here too.

Based on learnings: Applies to **/test.rs : Never use real PII (phone numbers and JIDs) in test code; use fictitious values instead.

Also applies to: 280-283, 407-419

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

In `@tests/e2e/tests/lid_sessions.rs` around lines 195 - 198, The info logs
currently print real identifiers (lid_sessions via "{lid_sessions:?}", and
variables lid_addr and pn_addr) which leaks PII; update every log site (e.g.,
the info! call referencing lid_sessions, and the places around lid_addr and
pn_addr noted) to either log only counts (lid_sessions.len()) or map each
address through the existing mask_addr() helper before formatting (e.g.,
lid_sessions.iter().map(|a| mask_addr(a)).collect::<Vec<_>>() or
mask_addr(&lid_addr), mask_addr(&pn_addr)); ensure mask_addr is in scope and
replace any "{...:?}" usages that would emit raw JIDs/phone numbers with the
masked values or counts consistently across the file (also at the other ranges
referenced).

33-50: 🧹 Nitpick | 🔵 Trivial

These invariants still bypass the live Signal store.

scan_sessions() only checks persistence_manager().backend(), so every caller can pass while Client.signal_cache still holds a stale PN session or a cached LID miss. Add a live-store assertion here, or force a reconnect/cache clear before each invariant check, so the suite catches cache/backend divergence too.

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

In `@tests/e2e/tests/lid_sessions.rs` around lines 33 - 50, scan_sessions
currently only queries the persistence_manager().backend() via
backend.get_session, which lets callers pass while Client.signal_cache remains
stale; update scan_sessions to either force a live-store check or
clear/reconnect the client cache before the loop: add an argument to accept the
test Client (or a cache-control helper) and call the client's cache clear or
reconnect method (eg. Client.clear_signal_cache() / Client.reconnect()) or, for
each addr, also query the live store via the client's live-session lookup and
assert it matches backend.get_session(addr); ensure you reference scan_sessions,
backend.get_session, and Client.signal_cache (or the client's cache
clear/reconnect method) so the suite detects cache/backend divergence.
🤖 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/retry.rs`:
- Around line 178-189: The guard that bails out when a device is missing uses
has_device(&sender_user, sender_device_id) but only checks the original
participant_jid.user, which can falsely miss a LID-backed cached record that was
stored under the backend namespace resolved by resolve_encryption_jid; update
the check in the retry handling (around resolved_jid, participant_jid,
sender_device_id) to probe both the raw user and the resolved user (i.e., call
has_device with participant_jid.user and also with resolved_jid.user) and only
return early if both checks fail so process_retry_key_bundle() / resend logic
can still run for namespace-mapped devices.

---

Duplicate comments:
In `@tests/e2e/tests/lid_sessions.rs`:
- Around line 195-198: The info logs currently print real identifiers
(lid_sessions via "{lid_sessions:?}", and variables lid_addr and pn_addr) which
leaks PII; update every log site (e.g., the info! call referencing lid_sessions,
and the places around lid_addr and pn_addr noted) to either log only counts
(lid_sessions.len()) or map each address through the existing mask_addr() helper
before formatting (e.g., lid_sessions.iter().map(|a|
mask_addr(a)).collect::<Vec<_>>() or mask_addr(&lid_addr), mask_addr(&pn_addr));
ensure mask_addr is in scope and replace any "{...:?}" usages that would emit
raw JIDs/phone numbers with the masked values or counts consistently across the
file (also at the other ranges referenced).
- Around line 33-50: scan_sessions currently only queries the
persistence_manager().backend() via backend.get_session, which lets callers pass
while Client.signal_cache remains stale; update scan_sessions to either force a
live-store check or clear/reconnect the client cache before the loop: add an
argument to accept the test Client (or a cache-control helper) and call the
client's cache clear or reconnect method (eg. Client.clear_signal_cache() /
Client.reconnect()) or, for each addr, also query the live store via the
client's live-session lookup and assert it matches backend.get_session(addr);
ensure you reference scan_sessions, backend.get_session, and Client.signal_cache
(or the client's cache clear/reconnect method) so the suite detects
cache/backend divergence.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3c0e12f5-61e4-46e4-9344-7e20388a9142

📥 Commits

Reviewing files that changed from the base of the PR and between 9956dc6 and ee76654.

📒 Files selected for processing (2)
  • src/retry.rs
  • tests/e2e/tests/lid_sessions.rs

resolve_encryption_jid result stored as resolved_jid instead of rebinding
participant_jid, so PN-addressed groups get the correct participant
namespace in retry stanzas. Also use is_same_user_as for is_peer check
and mask remaining raw JIDs in test logs.
@jlucaso1
jlucaso1 force-pushed the fix/lid-first-signal-sessions branch from ee76654 to 1933a8d Compare April 1, 2026 19:00
@jlucaso1
jlucaso1 merged commit ae2ea21 into main Apr 1, 2026
7 of 8 checks passed
@jlucaso1
jlucaso1 deleted the fix/lid-first-signal-sessions branch April 1, 2026 19:00

@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: 1933a8d3bc

ℹ️ 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
// Try to process key bundle if present
let key_bundle_result = self
.process_retry_key_bundle(node, &participant_jid, is_peer)
.process_retry_key_bundle(node, &resolved_jid, is_peer)

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 peer reinstall check across PN-only session state

Passing resolved_jid into process_retry_key_bundle makes the registration-ID guard read only the LID session, so when a peer device still has its existing session stored under PN (the exact legacy state this PR is migrating), existing_reg_id is treated as missing and the peer reinstall/change check is skipped. In that PN-only scenario, a retry from our own reinstalled device can be accepted instead of rejected, weakening the intended peer-device protection path.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant