Skip to content

fix(device-list): always keep the primary device after a raw_id mismatch patch - #797

Merged
jlucaso1 merged 2 commits into
mainfrom
fix/device-list-preserve-primary
Jun 9, 2026
Merged

fix(device-list): always keep the primary device after a raw_id mismatch patch#797
jlucaso1 merged 2 commits into
mainfrom
fix/device-list-preserve-primary

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

When a device-add notification carries a key-index-list whose raw_id differs from the stored one (an identity rotation or relink), patch_device_add clears the device list and rebuilds it from the notification. filter_devices_by_key_index only keeps the primary (device 0) when it is already present in the input, so after the clear the primary is dropped: the record is persisted as just the notified companion (for example [{device_id: 19}]), or as an empty list when the notified device's key index is rejected.

A device record that exists but is missing the primary is a broken state. get_user_devices treats any present record as authoritative and only goes to the network when there is no record at all, so the bad list sticks and the self-healing usync re-fetch never fires. Downstream this leaves the user with a device set that does not cover the primary.

Fix

Mirror WA Web's WAWebHandleAdvDeviceNotificationApi. In handleDeviceAddNotification, after filtering the existing devices and adding the notified ones, it unconditionally re-adds the primary with C.push({ id: DEFAULT_DEVICE_ID, keyIndex: 0 }) (and the remove path does the same). The invariant is that the rebuilt device list always contains device 0.

patch_device_add now ensures device 0 is present after the rebuild, so a raw_id mismatch can never drop the primary or persist an empty list. whatsmeow keeps the same invariant on its device-list rebuild.

Tests

cargo test -p whatsapp-rust --lib client::device_registry::tests (37 passing), full cargo test -p whatsapp-rust --lib (739 passing), cargo test -p wacore, and cargo clippy --all-targets -- -D warnings.

New tests:

  • test_patch_device_add_raw_id_mismatch_preserves_primary: a raw_id mismatch clears the stale companion but keeps device 0 and adds the new companion. Guards the [{device_id: N}] (no primary) record.
  • test_patch_device_add_raw_id_mismatch_rejected_device_keeps_primary: a mismatch where the notified key index is rejected rebuilds to just the primary instead of an empty list.
  • Updated test_patch_device_add_deduplicates to the realistic seed (a record that includes the primary) and to assert the dedupe plus the primary invariant.

Breaking

None.

…tch patch

On a raw_id mismatch (identity rotation/relink), patch_device_add clears the
device list and rebuilds it from the notification. filter_devices_by_key_index
only keeps device 0 when it is already in the input, so the primary was dropped
and the record was persisted without it (or empty when the notified device's key
index is rejected). Such a record sticks because get_user_devices treats any
present record as authoritative and never re-fetches.

Mirror WA Web's WAWebHandleAdvDeviceNotificationApi, which re-adds the primary
unconditionally at the end of the rebuild, so the device list always contains
device 0.
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b1ba758b-2ccf-4045-9da6-f57b9d01fb52

📥 Commits

Reviewing files that changed from the base of the PR and between 1b63e47 and 04784cf.

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

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Ensured the primary device is always preserved when rebuilding the device list, preventing its accidental omission after mismatch/rebuild flows and avoiding duplicate entries.
  • Tests

    • Added regression tests covering raw-id mismatch and rebuild scenarios, and updated tests to validate deduplication and primary-device preservation across key-handling cases.

Walkthrough

This PR ensures patch_device_add re-inserts the primary device (device 0) when missing after ADVSignedKeyIndexList decode/filter rebuilds, and updates/adds tests to validate deduplication and raw-id mismatch regression scenarios.

Changes

Primary Device Preservation in Device Registry

Layer / File(s) Summary
Primary device preservation in patch_device_add
src/client/device_registry.rs
After rebuilding and filtering the device list, patch_device_add now re-seeds DeviceInfo { device_id: 0, key_index: None } if device 0 is absent. (range_3440d6698402)
Updated dedupe test
src/client/device_registry.rs
test_patch_device_add_deduplicates seeds the cache with devices 0 and 3; verifies re-adding 3 does not duplicate it, device 0 remains, and final device count is correct. (range_f34462ee3e5d)
Raw-id mismatch regression tests & helpers
src/client/device_registry.rs
New helpers encode ADVSignedKeyIndexList bytes and construct device records with custom raw_id; two regression tests assert device 0 preservation when the notified companion key index is accepted or rejected after a raw-id mismatch rebuild. (range_e0de00b74d59)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#489: Modifies src/client/device_registry.rs and overlaps on device list update logic related to primary device handling.

Suggested reviewers

  • Ari4ka
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly addresses the main fix: ensuring the primary device persists after a raw_id mismatch, which is the core change in the diff and PR objectives.
Description check ✅ Passed The description thoroughly explains the problem (primary device drop on raw_id mismatch), the fix (re-adding device 0 unconditionally), and test coverage, all directly aligned with the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/device-list-preserve-primary

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 Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

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


Code Review

  • Read PR diff and changed file
  • Inspect patch_device_add / filter_devices_by_key_index logic
  • Cross-check the new tests against the fix
  • Post review

The fix is small, surgical, and correct. The bug is real: filter_devices_by_key_index (wacore/src/adv.rs:149) only keeps device 0 when it is already in the input, so the record.devices.clear() at src/client/device_registry.rs:366 drops it before the filter ever runs. get_devices_from_registry (src/client/device_registry.rs:642) then returns that primary-less list, and usync::get_user_devices (src/usync.rs:19) treats any present record as authoritative and never re-fetches — exactly the failure mode the PR describes.

Strengths

  • Mirrors WA Web faithfully. The reseed at src/client/device_registry.rs:398-403 is the direct Rust analogue of WA Web's C.push({ id: DEFAULT_DEVICE_ID, keyIndex: 0 }). The inline comment is excellent — it names the WA Web function, explains why the filter can't recover this on its own, and the persistence-of-bad-state consequence that suppresses usync re-fetch.
  • Placed at the end of the function, so it applies uniformly across all three branches (decoded + mismatch, decoded + same raw_id, no/failed signed bytes). That gives a useful self-healing side effect: any patch on a previously corrupted record restores device 0 without an explicit migration.
  • Tests are well-chosen. test_patch_device_add_raw_id_mismatch_preserves_primary covers the "valid notified companion" case; test_patch_device_add_raw_id_mismatch_rejected_device_keeps_primary covers the harder edge case (notified key-index rejected, rebuilt list would otherwise be []). Reseeding the realistic &[0, 3] baseline in test_patch_device_add_deduplicates is the right call — the previous &[3] seed wasn't reachable in production.
  • make_signed_key_index_bytes builds the ADVSignedKeyIndexList correctly via prost and only populates the fields decode_key_index_list actually reads; the unsigned shape is fine because local signature verification isn't on this path.

Observations / follow-ups (not blocking)

  1. patch_device_remove does not enforce the same invariant. The PR description explicitly notes that WA Web's remove path also re-adds the primary unconditionally, but the fix here is scoped to add. At src/client/device_registry.rs:498, record.devices.retain(|d| d.device_id != device_id) would drop device 0 if a remove notification arrived for it. The server shouldn't do that, but the invariant is symmetric in WA Web — worth a separate small PR for defense in depth.

  2. key_index value for the reseeded primary. The new code pushes key_index: Some(0) (matches WA Web's literal keyIndex: 0). Other code paths (and the new test helper record_with_raw_id at line ~1190) use key_index: None for device 0. Since filter_devices_by_key_index short-circuits on device_id == 0 regardless of the key index, this is functionally inert today, but the resulting persisted records can now contain device 0 in either shape. Not a problem, but if anything ever starts caring about key_index for the primary, the two shapes will diverge. Either way is defensible; calling it out.

  3. Possible extra test for the no-signed_bytes fallback. A test asserting that the append-only branch (no key_index_info) also benefits from the reseed — i.e. starting from a corrupt record that's missing device 0 — would lock in the self-healing property explicitly rather than as an emergent behavior. Low priority.

Verdict

LGTM. Ship it; the two notes above are good candidates for a small follow-up PR.
• Branch: fix/device-list-preserve-primary

@jlucaso1

jlucaso1 commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

Benchmark Results

67 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,927 2,927 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,448 8,448 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,487 49,487 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 55,003 55,003 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,679 1,679 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,393 4,393 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 112,965 112,968 -0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,730 1,656,733 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 644,452 644,489 -0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 868,251 868,484 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,076,371 2,076,481 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 741,849 741,992 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,322,861 1,322,840 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,355,744 4,378,970 -0.5%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 513,818 514,400 -0.1%
binary_benchmark::marshal_group::bench_marshal_allocating 45,381 45,381 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,431 45,431 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,334 66,334 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,492 43,492 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,487 45,487 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,945 4,945 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,976 4,976 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,747 6,747 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,544 528,544 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,165 528,165 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,411 529,411 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,732 5,417,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,047 5,362,047 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,365 13,276,365 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,274 48,274 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,335 48,335 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,659 66,659 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,291 8,291 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,141,800 4,141,907 -0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,131 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,264,189 4,264,189 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 100,399 100,399 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,262 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,921 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 510,102 511,003 -0.2%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,973,903 11,979,717 -0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,919,672 4,895,812 +0.5%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,404 37,404 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,617,967 3,617,967 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 230,648 230,658 -0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%
No significant changes detected.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/device_registry.rs`:
- Around line 1248-1283: Add an assertion after retrieving updated (from
client.device_registry_cache.get) in the test async fn
test_patch_device_add_raw_id_mismatch_rejected_device_keeps_primary to verify
the primary device's key_index is set to Some(0); specifically, after the
existing asserts on updated.devices.len() and updated.devices[0].device_id,
assert that updated.devices[0].key_index == Some(0) to document the expected
rebuilt state of device 0 after calling client.patch_device_add.
- Around line 1206-1242: In
test_patch_device_add_raw_id_mismatch_preserves_primary, after retrieving
updated from device_registry_cache, locate the device with device_id == 0 (e.g.
via updated.devices.iter().find(|d| d.device_id == 0).unwrap()) and add an
assertion that its key_index equals Some(0) (assert_eq!(dev0.key_index, Some(0),
...)) so the test documents the expected key_index for the primary after the
raw_id mismatch rebuild.
- Around line 398-403: The code adds a primary device with device_id == 0 but
sets key_index to Some(0), creating inconsistent state; change the DeviceInfo
construction so device_id 0 uses key_index: None instead of Some(0) (update the
record.devices.push of wacore::store::traits::DeviceInfo for device_id 0 to use
key_index: None) so it matches filter_devices_by_key_index and the rest of the
code/tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 105e7447-a3f0-475a-b65c-f5512b834643

📥 Commits

Reviewing files that changed from the base of the PR and between 99c3ff9 and 1b63e47.

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

Comment thread src/client/device_registry.rs
Comment on lines +1206 to +1242
async fn test_patch_device_add_raw_id_mismatch_preserves_primary() {
let client = create_test_client().await;

client
.device_registry_cache
.insert(
"15551234567".to_string(),
Arc::new(record_with_raw_id("15551234567", &[0, 5], 1)),
)
.await;

// New raw_id (2) != stored (1) → clear + rebuild. Notified device 19 has a
// valid key index, so the rebuilt list is the companion plus the primary.
let signed = make_signed_key_index_bytes(2, 0, vec![7]);
let key_index_info = wacore::stanza::devices::KeyIndexInfo {
timestamp: 100,
signed_bytes: Some(signed),
};
let elem = make_device_element(19, Some(7));
client
.patch_device_add("15551234567", &elem, Some(&key_index_info))
.await;

let updated = client
.device_registry_cache
.get("15551234567")
.await
.unwrap();
assert!(
updated.devices.iter().any(|d| d.device_id == 0),
"primary (device 0) must survive a raw_id mismatch clear, got {:?}",
updated.devices
);
assert!(updated.devices.iter().any(|d| d.device_id == 19));
// Stale companion from the old identity is dropped by the clear.
assert!(!updated.devices.iter().any(|d| d.device_id == 5));
}

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 | ⚡ Quick win

Consider asserting device 0's key_index value in this test.

The test verifies device 0 is present after the raw_id mismatch rebuild, but it doesn't check what key_index value device 0 has. Given the inconsistency I flagged earlier (Some(0) in production code vs None in test helpers), we should explicitly verify the expected key_index here. That way if we need to change it later, the test will document the correct behavior.

At WhatsApp's scale, we can't afford ambiguity about device state. Add an assertion like:

let dev0 = updated.devices.iter().find(|d| d.device_id == 0).unwrap();
assert_eq!(dev0.key_index, Some(0), "device 0 should have key_index 0 after rebuild");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/device_registry.rs` around lines 1206 - 1242, In
test_patch_device_add_raw_id_mismatch_preserves_primary, after retrieving
updated from device_registry_cache, locate the device with device_id == 0 (e.g.
via updated.devices.iter().find(|d| d.device_id == 0).unwrap()) and add an
assertion that its key_index equals Some(0) (assert_eq!(dev0.key_index, Some(0),
...)) so the test documents the expected key_index for the primary after the
raw_id mismatch rebuild.

Comment thread src/client/device_registry.rs
Address review feedback: device 0's key_index is never read
(filter_devices_by_key_index keeps the primary regardless and is_key_index_valid
is not applied to it), so store None to match how device 0 is recorded
everywhere else instead of a one-off Some(0). Tests now assert the rebuilt
primary's key_index.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant