Skip to content

fix(pdo): request a placeholder resend at most once per message - #841

Merged
jlucaso1 merged 2 commits into
mainfrom
fix/pdo-once-per-message
Jun 11, 2026
Merged

fix(pdo): request a placeholder resend at most once per message#841
jlucaso1 merged 2 commits into
mainfrom
fix/pdo-once-per-message

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Context

On 2026-06-10 a peer device with cloned Signal state redelivered the same undecryptable pkmsg every ~11s for 3h09 (982 copies of one message id, all InvalidPreKeyId because its sibling device had already consumed the referenced one-time prekey). The protocol side behaved correctly (retry receipts with fresh keys, sender eventually re-established), but our handling amplified the noise:

  • 692 PDO placeholder resend requests to our own phone, one per redelivered copy, each answered without content. The capped retry path fires an "immediate PDO" on every copy, and pdo_pending_requests only dedups in-flight requests, so it empties the moment the phone answers (~800ms).
  • 2 decrypt ERRORs per copy: the PN to LID migration fallback re-ran a full decrypt even when there was no PN state to migrate, which is guaranteed to fail identically.
  • 1 WARN per copy for the skipped skmsg plus 1 WARN per content-less PDO response.

Changes

One placeholder resend per message. New pdo_requested memo (24h TTL, 512 entries) gating send_pdo_placeholder_resend_request, mirroring the session-lifetime Set in WAWebNonMessageDataRequestPlaceholderMessageResendUtils (each message id gets at most one resend request per session there). The slot is released when the send itself fails, so a transient error does not block recovery, and it survives a content-less response, since the phone has nothing to share and re-asking cannot help.

Skip the migration retry decrypt when nothing migrated. migrate_signal_sessions_on_lid_discovery now returns whether anything moved into a LID slot; try_pn_to_lid_migration_decrypt skips the second decrypt when it did not. No wire-visible change, the retry receipt still goes out the same.

Log levels per the WA Web oracle. WAWebMsgProcessingDecryptionHandler.canDecryptNext skips the skmsg silently after a retryable pkmsg failure, so the "Skipping skmsg decryption" line drops to debug. The placeholder resend response handler counts a missing webMessageInfoBytes/message in telemetry with no warning, so "missing message content" drops to info. The per-copy "Max retries reached" line drops to debug since the high-retry warn already fired on the way to the cap.

With these, the same storm would produce 1 PDO request, 1 decrypt error per copy and the existing retry receipt cap, instead of 4.6k WARN/ERROR lines and 692 peer messages.

Tests

  • pdo_request_skipped_when_already_requested: a memoized message does not create a new pending request.
  • pdo_request_failure_releases_once_per_message_slot: a failed send releases the memo so recovery is not permanently blocked.
  • pdo_missing_content_response_clears_pending_but_keeps_memo: a content-less phone response consumes the pending slot but keeps the memo.
  • migration_reports_whether_anything_moved: false with no PN state, true when a PN session moves, false again once drained.

cargo fmt, cargo clippy --all-targets -- -D warnings and the full workspace suite (minus e2e) are green.

A stuck sender redelivering the same undecryptable message every ~11s for 3h made the capped retry path fire an immediate PDO per copy: 692 encrypted peer messages to our own phone, each answered without content. The pending cache only dedups in-flight requests, so it emptied as soon as the phone answered.

Mirror WA Web's session-lifetime set (WAWebNonMessageDataRequestPlaceholderMessageResendUtils): a pdo_requested memo keyed by chat+id gates send_pdo_placeholder_resend_request, released only when the send itself fails. Same storm also ran the PN-to-LID migration retry decrypt per copy with nothing to migrate, logging a second identical decrypt failure each time; migrate_signal_sessions_on_lid_discovery now reports whether anything moved and the retry decrypt is skipped when it did not.

Log parity with the WA Web decryption handler: the skmsg skip after a failed pkmsg is silent there (canDecryptNext), and a content-less placeholder response is telemetry only, so downgrade both.
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jlucaso1, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 13 minutes and 3 seconds. Learn how PR review limits work.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d694988d-2e48-45fc-9894-91daf9c3444c

📥 Commits

Reviewing files that changed from the base of the PR and between 9d1c2ac and c14c1f7.

📒 Files selected for processing (1)
  • src/pdo.rs
📝 Walkthrough

Walkthrough

This PR wires placeholder-resend PDO request deduplication into the client through a new per-message cache, preventing redundant requests across server redeliveries. It makes the PN-to-LID signal migration observable by returning a boolean, allowing decrypt retries to short-circuit when no state actually moved. Configuration, initialization, and retry paths are updated coherently.

Changes

PDO Request Deduplication with Migration State Observable

Layer / File(s) Summary
PDO Request Memoization Cache Configuration
src/cache_config.rs
CacheConfig gains pdo_requested: CacheEntryConfig field with 24-hour TTL and 512 entry capacity. Debug and Default implementations updated to include and initialize the new cache.
Client Cache Initialization and Diagnostics
src/client.rs, src/client/lifecycle.rs, src/client/accessors.rs
Client struct adds pdo_requested: Cache<ChatMessageId, ()> field initialized from cache config during construction. Memory diagnostics extended to track pdo_requested entry count and include it in debug output.
LID→PN Migration State Observable Return
src/client/lid_pn.rs
migrate_signal_sessions_on_lid_discovery signature changed to return bool indicating whether any signal state was migrated. Function short-circuits early with false when no PN state exists, tracks migrated flag through session and identity migration steps, and returns final state. New unit test validates boolean semantics across fresh peers and drained state.
PDO Request Deduplication Logic
src/pdo.rs
send_pdo_placeholder_resend_request checks pdo_requested cache and returns early if message already has pending request. New entries recorded in pdo_requested before session setup. Error paths now clean up both pdo_pending_requests and pdo_requested entries to allow recovery. Log level for missing message content response adjusted. New tests validate memoization, send-failure memo release, and response-without-content handling.
Retry and Decrypt Path Integration
src/message/receive.rs, src/message/retry.rs
PN→LID migration decrypt logic now captures the migration boolean and returns early if nothing moved, skipping unnecessary decrypt retry. Retry documentation and logging clarify that PDO is fallback with per-message memo suppression on redeliveries. Receive-path logging adjusted for skipped decryption after retryable failures.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#664: Directly overlaps in PN→LID migration decrypt path refactoring; both PRs modify try_pn_to_lid_migration_decrypt control flow and signal state handling.
  • oxidezap/whatsapp-rust#677: Modifies migrate_signal_sessions_on_lid_discovery no-op behavior for fresh peers with early short-circuit logic that aligns with this PR's boolean return contract.
  • oxidezap/whatsapp-rust#509: Modifies PDO placeholder-resend pipeline around send_pdo_placeholder_resend_request keying and response guards, which this PR extends with memoization deduplication.

Suggested labels

breaking-change

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly matches the main objective: implementing a memo to request placeholder resends at most once per message, which is the core fix addressing the excessive PDO requests problem.
Description check ✅ Passed The description is highly detailed and directly related to the changeset, explaining the context, concrete changes made, and test coverage for the PDO memoization and migration improvements.
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/pdo-once-per-message

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.

@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: 9d1c2ace6b

ℹ️ 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/pdo.rs Outdated
@codspeed-hq

codspeed-hq Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 141 untouched benchmarks


Comparing fix/pdo-once-per-message (c14c1f7) with main (8ab2702)

Open in CodSpeed

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

101-132: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

At-most-once PDO is still race-prone under concurrent calls.

This sequence is non-atomic (getgetinsert), so two tasks can claim the same message and both send PDO. That breaks the core dedupe guarantee. We should atomically claim pdo_requested (single-flight/add-if-absent) before registering pending/sending, and add a concurrent regression test to lock this down.

🤖 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/pdo.rs` around lines 101 - 132, The current get→get→insert sequence on
pdo_requested and pdo_pending_requests is racy: two concurrent callers can both
observe absence and both proceed to send PDO; change this to an atomic claim
before registering pending and sending by using an add-if-absent/single-flight
pattern on self.pdo_requested (e.g., a try_insert or entry-or-insert API) so
only the first task wins and others return immediately; after successful atomic
claim insert the PendingPdoRequest into self.pdo_pending_requests as before;
also add a concurrent regression test that spawns multiple tasks calling the PDO
path for the same cache_key and asserts only one sender proceeds (use
PendingPdoRequest, pdo_requested, pdo_pending_requests, and the function that
currently contains this logic to locate and modify the code).
🤖 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.

Outside diff comments:
In `@src/pdo.rs`:
- Around line 101-132: The current get→get→insert sequence on pdo_requested and
pdo_pending_requests is racy: two concurrent callers can both observe absence
and both proceed to send PDO; change this to an atomic claim before registering
pending and sending by using an add-if-absent/single-flight pattern on
self.pdo_requested (e.g., a try_insert or entry-or-insert API) so only the first
task wins and others return immediately; after successful atomic claim insert
the PendingPdoRequest into self.pdo_pending_requests as before; also add a
concurrent regression test that spawns multiple tasks calling the PDO path for
the same cache_key and asserts only one sender proceeds (use PendingPdoRequest,
pdo_requested, pdo_pending_requests, and the function that currently contains
this logic to locate and modify the code).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9a53bd4e-5d18-45e2-ad84-33f19453859f

📥 Commits

Reviewing files that changed from the base of the PR and between e5c660f and 9d1c2ac.

📒 Files selected for processing (8)
  • src/cache_config.rs
  • src/client.rs
  • src/client/accessors.rs
  • src/client/lid_pn.rs
  • src/client/lifecycle.rs
  • src/message/receive.rs
  • src/message/retry.rs
  • src/pdo.rs

Decrypt-failure tasks are detached per redelivered copy, so the get-then-insert claim let two concurrent copies both pass the gate, and a losing send failure could release the winner's slot. Claim via the single-flight get_with arm already used by dispatch_undecryptable_event: only the claim winner proceeds to send, so only it may release the slot on failure.
@jlucaso1
jlucaso1 merged commit 4edba60 into main Jun 11, 2026
12 checks passed
@jlucaso1
jlucaso1 deleted the fix/pdo-once-per-message branch June 11, 2026 05:43

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

ℹ️ 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/message/receive.rs
// Nothing moved namespaces, so the retry would hit the exact same
// state, fail identically, and log a second decrypt failure for
// every redelivered copy of an undecryptable message.
if !migrated {

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 Retry after concurrent LID migration

When two LID decrypt paths or a mapping learner race, this code drops the LID session lock before migration, so another task can acquire it, move the PN session into the LID slot, and drain the PN side first. This call then gets migrated == false because there is no PN state left, even though the LID state changed after the original failed decrypt, and returns without retrying a decrypt that could now succeed; the message is unnecessarily treated as undecryptable. Consider retrying when the lock was dropped/reacquired, or distinguishing “no PN state existed” from “already migrated by someone else.”

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