Skip to content

fix: validate phash from server ack to detect stale device lists - #494

Merged
jlucaso1 merged 1 commit into
mainfrom
fix/phash-mismatch-handling
Apr 6, 2026
Merged

fix: validate phash from server ack to detect stale device lists#494
jlucaso1 merged 1 commit into
mainfrom
fix/phash-mismatch-handling

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

When sending a group message, the server ack includes a phash (participant list hash). If it differs from ours, the participant/device list changed during the send — some devices may not have received the message. Previously we never read the server ack, so these mismatches were silently lost.

How it works

send_node()          → fire-and-forget (no latency change)
     │
     └── background task waits for ack (10s timeout)
              │
              ├── phash matches → nothing to do
              ├── phash mismatch → invalidate sender_key_device_cache + group_cache
              └── timeout/disconnect → ignored (message was sent)

Zero latency impactsend_message_impl and send_status_message return immediately after writing to the socket, same as before. The phash validation happens in a detached background task.

What changes on mismatch

  • sender_key_device_cache invalidated → SKDM targets recomputed on next send
  • Group info cache invalidated → participants re-fetched on next send
  • Warning logged with both phash values

This matches whatsmeow's approach (send.go:448-464). WA Web does a full resend to missing devices (Resend/GroupMsg.js), but cache invalidation is sufficient — the next message reaches everyone.

Files changed

  • src/client.rsregister_ack_waiter(): registers oneshot waiter for server ack by message ID
  • src/send.rssend_message_impl() and send_status_message(): register waiter before send, spawn background phash validation

Test plan

  • cargo test -p whatsapp-rust — 345 tests pass
  • Clippy clean
  • No latency regression (send is still fire-and-forget)

Summary by CodeRabbit

  • New Features
    • Added server acknowledgment validation (10s timeout) for messages that include delivery proof, confirming server-returned verification before proceeding.
  • Bug Fixes
    • Cache handling adjusted to invalidate outdated keys only after validation failure, reducing stale-state collisions for direct, status, and group messages.

@coderabbitai

coderabbitai Bot commented Apr 6, 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: 08c22309-ae08-462c-ae66-69d7c86b7717

📥 Commits

Reviewing files that changed from the base of the PR and between 1669f31 and 344ba72.

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

📝 Walkthrough

Walkthrough

Adds a crate-visible async Client::register_ack_waiter method and updates send paths to extract optional outgoing stanza phash, register ack waiters only when phash exists, spawn a 10s background ack-validation task after send, and invalidate sender/device and optionally group caches on phash mismatch.

Changes

Cohort / File(s) Summary
Ack Waiter Registration
src/client.rs
Added pub(crate) async fn register_ack_waiter(&self, message_id: &str) -> futures::channel::oneshot::Receiver<wacore_binary::Node>: creates a oneshot (tx, rx), inserts tx into response_waiters keyed by message_id, returns rx.
Ack Validation & Send Paths
src/send.rs
send_status_message and send_message_impl now extract optional outgoing stanza phash, register ack waiter only when phash present, call send_node(), and spawn a detached 10s background task that awaits the ACK, compares server phash to local phash, and invalidates sender_key_device_cache (and group cache when applicable) on mismatch. Removed prior inline comments about cache timing.

Sequence Diagram

sequenceDiagram
    participant App as App
    participant Client as Client
    participant Sender as Send Function
    participant Network as Server/Network
    participant AckTask as Ack Validation Task
    participant Cache as Cache System

    App->>Client: register_ack_waiter(message_id)
    Client->>Client: create oneshot (tx, rx) and insert tx into response_waiters
    Client-->>App: return rx

    App->>Sender: send_message_impl/status(stanza)
    Sender->>Sender: extract phash (if present)
    alt phash present
        Sender->>Client: register_ack_waiter(message_id)
        Client-->>Sender: return rx
    end
    Sender->>Network: send_node(stanza)
    alt phash present
        Sender->>AckTask: spawn background task (10s) waiting on rx
    end

    Network-->>Client: ack_response received
    Client->>Client: handle_ack_response -> resolve oneshot sender
    Client->>AckTask: oneshot delivers ack Node

    AckTask->>AckTask: compare server phash vs local phash
    alt phash mismatch
        AckTask->>Cache: invalidate sender_key_device_cache (jid)
        AckTask->>Cache: invalidate group cache (if group send)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • Ari4ka

Poem

🐰✨ I planted a waiter by message ID,
A little hop to catch the server's sigh,
If phash differs, I twitch and clear,
Old keys swept out with a nibbling cheer,
Then I hop off to log the sky.

🚥 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 clearly and accurately summarizes the main change: adding server ack phash validation to detect stale device lists, 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/phash-mismatch-handling

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 6, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 6, 2026

Copy link
Copy Markdown

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


Code Review: phash mismatch handling

  • Read changed files and gather context
  • Review src/client.rs changes
  • Review src/send.rs changes
  • Post review feedback

Overall this is a clean, well-scoped change. The approach of fire-and-forget phash validation in a background task is sound and matches the whatsmeow reference. A few items worth addressing:

Bug: waiter leak when msg_id is empty

src/send.rs:1107-1111 — If the stanza somehow has no id attribute, msg_id becomes "" via unwrap_or_default(). This registers a waiter under the empty string key, which will never be resolved (no ack will match ""), leaving a dangling entry in response_waiters until the 10s timeout drops the receiver. More importantly, if this happens twice, the second insert("") silently drops the first waiter's sender.

Consider either skipping registration when the id is missing (like send_status_message implicitly does via request_id), or using a guard:

let ack_rx = if our_phash.is_some() && !msg_id.is_empty() {
    Some(self.register_ack_waiter(&msg_id).await)
} else {
    None
};

In practice id is always present on outgoing stanzas, so this is defensive — but it avoids a subtle foot-gun.

Code duplication

The background phash-validation spawn block is duplicated nearly identically between send_status_message (send.rs:481-510) and send_message_impl (send.rs:1123-1157). The only differences are:

  1. Status broadcast only invalidates sender_key_device_cache; group messages also invalidate group_cache
  2. The log message text

Consider extracting a helper like:

fn spawn_phash_validator(&self, rx, our_phash, cache_key, invalidate_group_cache: bool)

This would reduce the ~30 lines of duplicated async spawning to a single call in each site. Not blocking, but it'd make future maintenance easier.

Missing group cache invalidation for status broadcasts

In send_status_message (line ~496-506), on phash mismatch only sender_key_device_cache is invalidated. In send_message_impl (line ~1138-1152), both sender_key_device_cache and group_cache are invalidated. Is the asymmetry intentional? Status broadcasts also have group-like participant lists — if the participant set changed, the group info cache may also be stale.

Minor: comment removal

Lines 512-513 and 1159 removed existing comments ("Invalidate device registry for users whose devices returned 406", "Flush cached Signal state to DB after encryption", "Update SKDM recipient cache AFTER server ACK"). These comments explained why those operations happen — per CLAUDE.md, comments explaining "why" should be preserved. Consider keeping them or confirming they're no longer needed.

Looks good

  • register_ack_waiter is clean and reuses the existing response_waiters infrastructure correctly
  • The 10s timeout + silent return on failure is the right call — the message was already sent
  • The let-chain style matches the project's collapsible-if convention
  • No latency impact on the happy path — send returns immediately

@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchfix/phash-mismatch-handling
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.03 x 1e3
(+7.64%)Baseline: 43.69 x 1e3
45.88 x 1e3
(102.52%)

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,199.00
(-2.97%)Baseline: 6,388.43
6,707.85
(92.41%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
523,708.00
(-24.22%)Baseline: 691,045.83
725,598.12
(72.18%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,910.00
(-4.70%)Baseline: 21,941.47
23,038.55
(90.76%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,703.00
(-11.39%)Baseline: 111,394.14
116,963.85
(84.39%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,731.00
(-8.13%)Baseline: 107,463.02
112,836.18
(87.50%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
533,017.00
(-0.08%)Baseline: 533,429.85
560,101.34
(95.16%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,955.00
(-3.46%)Baseline: 16,526.20
17,352.51
(91.95%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,813,622.00
(-6.14%)Baseline: 15,782,736.11
16,571,872.92
(89.39%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,631.00
(-17.59%)Baseline: 143,957.73
151,155.62
(78.48%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,447.00
(-0.08%)Baseline: 534,852.24
561,594.85
(95.17%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
18,004.00
(-3.09%)Baseline: 18,577.12
19,505.98
(92.30%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,200,726.00
(-18.32%)Baseline: 34,523,846.51
36,250,038.84
(77.80%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,456.00
(-0.08%)Baseline: 533,868.85
560,562.29
(95.16%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,928.00
(-5.90%)Baseline: 16,926.09
17,772.39
(89.62%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,815,122.00
(-6.14%)Baseline: 15,783,909.37
16,573,104.84
(89.39%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
108,446.00
(-9.00%)Baseline: 119,169.39
125,127.86
(86.67%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,803.00
(-8.12%)Baseline: 107,535.02
112,911.78
(87.50%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,558.00
(-3.93%)Baseline: 95,299.21
100,064.17
(91.50%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,431.00
(-2.15%)Baseline: 7,594.65
7,974.38
(93.19%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,589.00
(-0.88%)Baseline: 92,401.15
97,021.21
(94.40%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,454.00
(+1.01%)Baseline: 7,379.80
7,748.79
(96.20%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
107,134.00
(-0.97%)Baseline: 108,181.42
113,590.49
(94.32%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,966.00
(+0.83%)Baseline: 8,891.80
9,336.39
(96.03%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-5.93%)Baseline: 44,637.65
46,869.53
(89.59%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,716.00
(-1.73%)Baseline: 2,763.76
2,901.95
(93.59%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,090.00
(+0.24%)Baseline: 554,770.42
582,508.95
(95.46%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
773.00
(+0.09%)Baseline: 772.34
810.96
(95.32%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,559,452.00
(-0.50%)Baseline: 27,698,090.00
29,082,994.50
(94.76%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,544,698.00
(-0.04%)Baseline: 5,546,939.77
5,824,286.75
(95.20%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
175,063.00
(-1.16%)Baseline: 177,112.90
185,968.54
(94.14%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
175,711.00
(-1.22%)Baseline: 177,874.02
186,767.72
(94.08%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,276,440.00
(-0.02%)Baseline: 17,280,237.16
18,144,249.01
(95.22%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
298,495.00
(+0.51%)Baseline: 296,987.25
311,836.61
(95.72%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,471,242.00
(-0.98%)Baseline: 12,594,579.36
13,224,308.33
(94.31%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
719,752.00
(+0.36%)Baseline: 717,173.86
753,032.56
(95.58%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
🚨 view alert (🔔)
47,034.00
(+7.64%)Baseline: 43,693.76
45,878.45
(102.52%)

libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,917.00
(+0.00%)Baseline: 15,561,775.53
16,339,864.31
(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,377,818.00
(-1.63%)Baseline: 5,466,711.21
5,740,046.77
(93.69%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
311,551.00
(-58.42%)Baseline: 749,317.30
786,783.17
(39.60%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,830,506.00
(+0.16%)Baseline: 2,826,091.16
2,967,395.72
(95.39%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,452,844.00
(-0.46%)Baseline: 3,468,942.94
3,642,390.09
(94.80%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
126,228,458.00
(+0.69%)Baseline: 125,362,750.62
131,630,888.15
(95.90%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
11,851.00
(+0.08%)Baseline: 11,841.64
12,433.72
(95.31%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,879.00
(+0.86%)Baseline: 3,845.89
4,038.19
(96.06%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
86,863.00
(-0.91%)Baseline: 87,658.68
92,041.61
(94.37%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
78,912.00
(-0.97%)Baseline: 79,684.96
83,669.21
(94.31%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
50,362.00
(-0.95%)Baseline: 50,843.24
53,385.40
(94.34%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,939.00
(+2.41%)Baseline: 5,798.96
6,088.91
(97.54%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,214.00
(+3.28%)Baseline: 2,143.73
2,250.91
(98.36%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(-0.00%)Baseline: 21,920.16
23,016.17
(95.24%)
🐰 View full continuous benchmarking report in Bencher

@jlucaso1
jlucaso1 force-pushed the fix/phash-mismatch-handling branch from 1669f31 to d1d5fc7 Compare April 6, 2026 22:20
Register an ack waiter before sending group/status messages, then
validate the server's phash response in a background task. On mismatch
(participant list changed during send), invalidate sender key device
cache and group info cache so the next send uses fresh device lists.

Non-blocking: send returns immediately after writing to socket. The
phash validation runs in a detached background task with 10s timeout.

Matches whatsmeow's approach (send.go:448-464) of cache invalidation
on phash mismatch. WA Web does a full resend to missing devices, but
cache invalidation is sufficient — the next message reaches everyone.
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