Skip to content

fix(offline): drain offline queue by acking duplicate and undecryptable messages - #647

Merged
jlucaso1 merged 1 commit into
mainfrom
fix/offline-ack-drain
May 28, 2026
Merged

fix(offline): drain offline queue by acking duplicate and undecryptable messages#647
jlucaso1 merged 1 commit into
mainfrom
fix/offline-ack-drain

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Every received message is now acknowledged so the server drains it from the offline queue instead of replaying it on each reconnect (which eventually force-closed the stream).

  • Ack duplicates (already-processed) and own-account fan-out. Own non-peer messages get a transport ack (correct to=from) since their delivery receipt is suppressed.
  • Send a transport ack on decrypt failure and on <unavailable>; the retry receipt alone does not clear the queue.
  • Nack generic group decrypt errors, matching the session catch-all.
  • Send the retry receipt, PDO request and transport ack in one ordered, flushed task so a disconnect cannot clear a stanza before its resend request goes out.
  • Send delivery receipts as type="inactive" unless marked online (presence available or forced), matching whatsmeow's background companion.
  • Raise message_retry_counts TTL to 1h so the retry cap survives spaced redeliveries.
  • Lower decrypt-fail="hide" failures to DEBUG.
  • Recognize stream:error carrying an ack child instead of logging it as unknown.

Test plan

  • cargo test --workspace --exclude e2e-tests
  • cargo clippy --all --tests
  • cargo fmt --all --check

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Extends message-retry TTL to 1 hour, adds client receipt-activity state and best-effort transport ACK plumbing, centralizes decrypt-failure log-levels, orders retry receipts before optional transport ACKs (skip for status@broadcast), refactors PDO to awaitable run, and updates receipt node shape and tests.

Changes

Undecryptable message acknowledgment and delivery receipt flow

Layer / File(s) Summary
Cache TTL semantic update
src/cache_config.rs
Default message_retry_counts TTL extended to 1 hour; docs/comments updated to explain preserving MAX_DECRYPT_RETRIES across spaced redeliveries.
Client ACK state & transport ACK plumbing
src/client.rs
Added send_active_receipts state, send_transport_ack/spawn_message_ack, message_ack_source_node, stream-error ACK logging, and unit tests for addressing modes and receipt activity.
Presence-driven receipt activity & PDO scheduling
src/features/presence.rs, src/pdo.rs
Presence now toggles receipt active/inactive; replaced detached PDO spawn helpers with awaitable run_pdo_request and updated tests to call it.
Decrypt-failure, retry receipts, and ACK gating
src/message.rs
Added decrypt_fail_log_level; centralized ack_received_message; split retry receipt into awaitable run_retry_receipt (production) and test-only spawn; handle_decrypt_failure now orders retry work then optional transport ACK (skips status@broadcast); updated edge-case handling and wire-level tests.
Receipt node shape & tests
src/receipt.rs
build_delivery_receipt_node(active: bool) added to emit type="inactive" when appropriate, always include participant for groups/status, set context="status" for status broadcasts; Client::should_send_delivery_receipt made pub(crate); tests updated for inactive/active semantics.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Transport
  participant Server
  Client->>Client: dispatch_parsed_message / handle_decrypt_failure
  Client->>Client: ack_received_message -> spawn_delivery_receipt / run_retry_receipt
  alt not status@broadcast
    Client->>Client: spawn_message_ack -> message_ack_source_node -> encode_ack_bytes
    Client->>Transport: send_raw_bytes (best-effort ACK)
  end
  Client->>Server: delivery receipt (network)
  Transport-->>Client: stream:error (may contain <ack/>)
  Client->>Client: handle_stream_error (log ack class/id or warn)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels: breaking-change

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main objective: fixing offline queue drainage by acknowledging duplicate and undecryptable messages.
Description check ✅ Passed The description comprehensively details the changes made, including queue draining via acknowledgments, transport acks, retry receipt handling, presence-based receipt activity, TTL updates, and log level changes.
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/offline-ack-drain

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

ℹ️ 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.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.

Actionable comments posted: 2

🤖 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.rs`:
- Around line 1951-1974: spawn_message_ack currently spawns a detached task that
calls encode_ack_bytes and then client.send_raw_bytes directly, which can race
with disconnect()/reconnect() and bypass the outbound drain; change it to route
ACKs through the client's outbound_flush so the ACK is enqueued and drained
during teardown. Concretely, replace the runtime.spawn + direct send_raw_bytes
path in spawn_message_ack (the block that calls encode_ack_bytes and
send_raw_bytes) with code that encodes the ACK using
encode_ack_bytes/message_ack_source_node as before but then enqueues/schedules
the resulting buffer via the client's outbound_flush API (e.g., an enqueue or
push method on outbound_flush) instead of calling send_raw_bytes; preserve the
same logging for encode/send errors but ensure sending happens via
outbound_flush so disconnect/reconnect will not close the transport before the
ACK is written.

In `@src/message.rs`:
- Around line 753-760: The duplicate-delivery branch missing the status
broadcast guard causes delivery receipts to be sent for status@broadcast
stanzas; update the conditional that checks session_had_duplicates &&
!session_decrypted_successfully && !session_dispatched_undecryptable to also
ensure !is_status_broadcast(&info) before calling
self.spawn_delivery_receipt(&info) so status broadcast duplicates are skipped
(use the existing is_status_broadcast(&info) helper and the
spawn_delivery_receipt call to locate and change the code).
🪄 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: 7f9179fe-8eb2-4817-9b17-72c66e9d98eb

📥 Commits

Reviewing files that changed from the base of the PR and between ddc13d1 and c077195.

📒 Files selected for processing (3)
  • src/cache_config.rs
  • src/client.rs
  • src/message.rs

Comment thread src/client.rs Outdated
Comment thread src/message.rs Outdated
@jlucaso1
jlucaso1 force-pushed the fix/offline-ack-drain branch 2 times, most recently from b0ae9bd to 2a0231c Compare May 27, 2026 23:45
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented May 27, 2026

Copy link
Copy Markdown

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


Review: fix(offline): drain offline queue by acking duplicate and undecryptable messages

  • Read the full diff and changed files
  • Review src/cache_config.rs changes
  • Review src/client.rs changes
  • Review src/message.rs changes
  • Review src/receipt.rs changes
  • Check if previous reviewer findings were addressed
  • Post final review

Overall: This is a well-structured fix for a real problem — un-acked offline stanzas replaying on every reconnect until the server force-closes the stream. The design is sound: every code path that receives a message now ensures the server gets a signal to drain it (delivery receipt, transport ack, or nack). The test coverage is thorough, with wire-level decode helpers that verify actual on-the-wire behavior.

What looks good

  1. ack_received_message abstraction (src/message.rs:113-122) — Clean decision tree: delivery receipt when eligible, transport ack otherwise, skip for status@broadcast (covered by should_ack). All callers converge here, making the logic auditable in one place.

  2. spawn_message_ack routed through outbound_flush (src/client.rs:1957) — The CodeRabbit review flagged the original detached spawn; the current code correctly uses outbound_flush.spawn() so disconnect() flushes pending acks before tearing down the transport. Good fix.

  3. message_ack_source_node (src/client.rs:4085-4101) — Reconstructing a minimal <message> node from MessageInfo after the original tree is dropped is a clean approach. The is_group dispatch (group → chat as from + participant, DM → sender as from) correctly mirrors whatsmeow's sendAck addressing.

  4. message_retry_counts TTL bump to 1 hour (src/cache_config.rs:251) — Well-motivated. A 5m TTL expired between reconnects, resetting the retry counter, so MAX_DECRYPT_RETRIES was never actually reached for spaced redeliveries.

  5. decrypt_fail_log_level helper (src/message.rs:78-84) — Reduces WARN noise for decrypt-fail="hide" stanzas (addon/fan-out) to DEBUG. Applied consistently across all four decrypt-failure log sites.

  6. Comprehensive test suitedecrypt_failure_emits_transport_ack, status_broadcast_decrypt_failure_skips_extra_ack, duplicate_message_is_acked_with_delivery_receipt, own_account_message_acked_via_transport_ack, unavailable_message_is_transport_acked — these all verify the actual on-the-wire behavior with frame decode helpers, not just internal state.

Findings

1. Stream error ack child parsing — minor robustness concern

src/client.rs:3369-3383

The handle_stream_error block extracts id and class from the ack child with unwrap_or_default(). This is fine for logging, but the get_optional_child("ack") lookup matches by tag name only. If a future protocol change adds a different child named ack inside a stream error, this would silently misclassify. Low risk, but worth noting — the current WhatsApp protocol does use <ack/> here per WA Web's StreamError.js, so it's correct today.

2. Group generic error: nack without retry receipt

src/message.rs:1374-1385

The catch-all Err(e) in process_group_enc_batch dispatches UndecryptableMessage and sends a nack, but does NOT send a retry receipt (unlike handle_decrypt_failure which sends both retry + ack). The comment says "mirrors the session catch-all" — but is the intent to NOT retry for unknown errors? If so, the nack alone clears the offline queue (good), but the user never gets a retry attempt. This seems intentional for truly unexpected errors, but it's worth confirming the design intent — a BadMac in a group context goes through handle_decrypt_failure via the NoSenderKeyState → retry path, while a generic SignalProtocolError variant just gets nacked.

3. Codex finding (false positive)

The Codex review flagged that ack_received_message at line 781 wouldn't clear self-fanout duplicates because should_send_delivery_receipt returns false for is_from_me. This is a false positiveack_received_message has a fallback: when should_send_delivery_receipt returns false and it's not status@broadcast, it calls spawn_message_ack instead. Own-account duplicates correctly get a transport ack.

4. should_send_delivery_receipt visibility change

src/receipt.rs:79 — Changed from private to pub(crate). Clean and minimal.

Summary

This is a solid fix. The ack/receipt coverage is now comprehensive across all message processing paths (success, decrypt failure, duplicate, unavailable, group errors), with consistent status@broadcast guards to avoid double-acking. The outbound_flush integration ensures acks survive disconnect races.

The test suite is notably thorough — wire-level frame decoding in tests is a strong validation approach that catches addressing bugs other tests would miss.

Verdict: Looks good to merge. The only design question is finding #2 (group generic error nack-without-retry), which is a pre-existing pattern this PR is extending, not introducing.


@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: 2a0231c00d

ℹ️ 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.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.

Actionable comments posted: 1

Caution

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

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

456-469: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Dispatch the undecryptable event before scheduling PDO/ack.

This flips the ordering contract: the unavailable-stub path can start PDO / wire ack before consumers see UndecryptableMessage, unlike handle_decrypt_failure. That makes this branch racey in exactly the flow the new tests are trying to pin down.

Suggested fix
-            self.spawn_pdo_request_with_options(&info, true);
-            if !info.source.chat.is_status_broadcast() {
-                self.spawn_message_ack(&info);
-            }
             self.dispatch_undecryptable_event(
                 Arc::clone(&info),
                 true,
                 unavailable_type,
                 crate::types::events::DecryptFailMode::Show,
             )
             .await;
+            self.spawn_pdo_request_with_options(&info, true);
+            if !info.source.chat.is_status_broadcast() {
+                self.spawn_message_ack(&info);
+            }
             return None;
🤖 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/message.rs` around lines 456 - 469, The ordering is wrong: move the call
to dispatch_undecryptable_event(...) so it runs before scheduling PDO/ack;
specifically, in the branch that currently calls
self.spawn_pdo_request_with_options(&info, true) and then optionally
self.spawn_message_ack(&info), call
self.dispatch_undecryptable_event(Arc::clone(&info), true, unavailable_type,
crate::types::events::DecryptFailMode::Show).await first (matching
handle_decrypt_failure's behavior), and only after that schedule
spawn_pdo_request_with_options and, if !info.source.chat.is_status_broadcast(),
spawn_message_ack, preserving the same arguments and behavior.
🤖 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/message.rs`:
- Around line 1371-1382: Currently the code skips calling
self.dispatch_undecryptable_event when info.source.chat.is_status_broadcast() is
true, which suppresses UndecryptableMessage events for status sender-key
failures; change the logic to always call
self.dispatch_undecryptable_event(Arc::clone(info), false,
crate::types::events::UnavailableType::Unknown, decrypt_fail_mode).await
unconditionally, and keep the spawn_nack(info, NackReason::UnhandledError, None)
call only inside a conditional gated by !info.source.chat.is_status_broadcast()
so status@broadcast still follows its special wire nack/ack behavior but
consumers receive the undecryptable event.

---

Outside diff comments:
In `@src/message.rs`:
- Around line 456-469: The ordering is wrong: move the call to
dispatch_undecryptable_event(...) so it runs before scheduling PDO/ack;
specifically, in the branch that currently calls
self.spawn_pdo_request_with_options(&info, true) and then optionally
self.spawn_message_ack(&info), call
self.dispatch_undecryptable_event(Arc::clone(&info), true, unavailable_type,
crate::types::events::DecryptFailMode::Show).await first (matching
handle_decrypt_failure's behavior), and only after that schedule
spawn_pdo_request_with_options and, if !info.source.chat.is_status_broadcast(),
spawn_message_ack, preserving the same arguments and behavior.
🪄 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: ede81ba8-2a24-4668-b7f4-3bc8ba237b84

📥 Commits

Reviewing files that changed from the base of the PR and between c077195 and b0ae9bd.

📒 Files selected for processing (4)
  • src/cache_config.rs
  • src/client.rs
  • src/message.rs
  • src/receipt.rs

Comment thread src/message.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.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/message.rs (2)

1374-1385: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep surfacing status sender-key failures to consumers.

This guard wraps both dispatch_undecryptable_event() and spawn_nack(), so generic skmsg decrypt failures on status@broadcast disappear entirely. The status-specific exception is the wire nack, not the event.

Suggested fix
-                    if !info.source.chat.is_status_broadcast() {
-                        self.dispatch_undecryptable_event(
-                            Arc::clone(info),
-                            false,
-                            crate::types::events::UnavailableType::Unknown,
-                            decrypt_fail_mode,
-                        )
-                        .await;
-                        self.spawn_nack(info, NackReason::UnhandledError, None);
-                    }
+                    self.dispatch_undecryptable_event(
+                        Arc::clone(info),
+                        false,
+                        crate::types::events::UnavailableType::Unknown,
+                        decrypt_fail_mode,
+                    )
+                    .await;
+                    if !info.source.chat.is_status_broadcast() {
+                        self.spawn_nack(info, NackReason::UnhandledError, None);
+                    }
🤖 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/message.rs` around lines 1374 - 1385, The current guard around
info.source.chat.is_status_broadcast() prevents dispatch_undecryptable_event()
from being called for status@broadcast messages; call
dispatch_undecryptable_event(Arc::clone(info), false,
crate::types::events::UnavailableType::Unknown, decrypt_fail_mode).await
unconditionally so consumers still see sender-key failures, and keep the
spawn_nack(info, NackReason::UnhandledError, None) call only inside the if
!info.source.chat.is_status_broadcast() block so the wire nack remains
status-specific.

774-781: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Skip the duplicate receipt on session-only status@broadcast redeliveries.

ack_received_message() still emits a delivery receipt here because should_send_delivery_receipt(info) is true for non-self status messages. The skmsg duplicate path already guards status@broadcast, so this msg/pkmsg-only branch reintroduces the redundant status receipt.

Suggested fix
-            self.ack_received_message(&info);
+            if !info.source.chat.is_status_broadcast() {
+                self.ack_received_message(&info);
+            }
🤖 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/message.rs` around lines 774 - 781, The duplicate-only branch calls
self.ack_received_message(&info) which re-emits a delivery receipt for
session-only status@broadcast redeliveries; update the conditional that checks
session_had_duplicates && !session_decrypted_successfully &&
!session_dispatched_undecryptable to also skip acking when the message is a
status@broadcast (i.e., add a guard like && !info.is_status_broadcast / &&
info.message_type != MessageType::StatusBroadcast or equivalent field on info)
so ack_received_message(&info) is not invoked for status@broadcast redeliveries
(note: preserve existing logic around should_send_delivery_receipt(info) and
only prevent the redundant status receipt in this duplicate path).
🤖 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.rs`:
- Around line 4080-4101: The ACK source builder in message_ack_source_node
currently only uses info.source.is_group to pick the `from` JID, which misses
broadcast/status messages that must also ACK to `chat`; change the selection so
that `from` is info.source.chat when the stanza is a group OR a broadcast/status
(detect the broadcast/status flag on info.source or the message type alongside
info.source.is_group), keeping the existing recipient and participant attr
handling (builder.attr("recipient", ...), builder.attr("participant", ...))
unchanged, and add a regression test for a status@broadcast/undecryptable
message next to the new addressing tests to ensure transport ACKs are sent to
chat not sender.

---

Duplicate comments:
In `@src/message.rs`:
- Around line 1374-1385: The current guard around
info.source.chat.is_status_broadcast() prevents dispatch_undecryptable_event()
from being called for status@broadcast messages; call
dispatch_undecryptable_event(Arc::clone(info), false,
crate::types::events::UnavailableType::Unknown, decrypt_fail_mode).await
unconditionally so consumers still see sender-key failures, and keep the
spawn_nack(info, NackReason::UnhandledError, None) call only inside the if
!info.source.chat.is_status_broadcast() block so the wire nack remains
status-specific.
- Around line 774-781: The duplicate-only branch calls
self.ack_received_message(&info) which re-emits a delivery receipt for
session-only status@broadcast redeliveries; update the conditional that checks
session_had_duplicates && !session_decrypted_successfully &&
!session_dispatched_undecryptable to also skip acking when the message is a
status@broadcast (i.e., add a guard like && !info.is_status_broadcast / &&
info.message_type != MessageType::StatusBroadcast or equivalent field on info)
so ack_received_message(&info) is not invoked for status@broadcast redeliveries
(note: preserve existing logic around should_send_delivery_receipt(info) and
only prevent the redundant status receipt in this duplicate path).
🪄 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: c3d9ece5-1fc6-41e1-bbd5-1d831e5f2bd8

📥 Commits

Reviewing files that changed from the base of the PR and between b0ae9bd and 2a0231c.

📒 Files selected for processing (4)
  • src/cache_config.rs
  • src/client.rs
  • src/message.rs
  • src/receipt.rs

Comment thread src/client.rs
@jlucaso1
jlucaso1 force-pushed the fix/offline-ack-drain branch from 2a0231c to 934bace Compare May 28, 2026 00:07
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented May 28, 2026

Copy link
Copy Markdown

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


Review: fix(offline): drain offline queue by acking duplicate and undecryptable messages

  • Read the full diff and all changed files
  • Review src/cache_config.rs changes
  • Review src/client.rs changes
  • Review src/message.rs changes
  • Review src/receipt.rs changes
  • Check if previous reviewer findings were addressed
  • Post final review

Overall: This is a solid, well-designed fix for a real production issue — un-acked offline stanzas replaying on every reconnect until the server force-closes the stream. Every code path that receives a message now ensures the server gets a signal to drain it. The test coverage is excellent, with wire-level frame decode helpers verifying actual on-the-wire behavior.

What looks good

  1. ack_received_message centralizes the decision tree (src/message.rs:113-122) — delivery receipt when eligible, transport ack otherwise, skip for status@broadcast. All callers converge here, making the ack contract auditable in one place.

  2. Retry + ack in a single outbound_flush task (src/message.rs:237-245) — This is the key design win. handle_decrypt_failure now runs both the retry receipt and transport ack in one ordered flushed task, so disconnect() flushes them together. If only one makes it to the wire, it's the retry (the sender gets a resend request before the stanza is cleared). This addresses the Codex P1 finding about retry/ack ordering during shutdown.

  3. spawn_message_ack routed through outbound_flush (src/client.rs:1975) — Correctly addressed the CodeRabbit finding about detached spawns racing with disconnect.

  4. message_ack_source_node (src/client.rs:4092-4108) — The is_group dispatch correctly handles all three cases (DM, group, status@broadcast) because the parser sets is_group = true for broadcast messages (wacore/src/messages.rs:181). The CodeRabbit finding about missing broadcast handling was a false positive — verified by the test_message_ack_source_node_status_addressing test.

  5. decrypt_fail_log_level helper (src/message.rs:78-84) — Reduces WARN noise for decrypt-fail="hide" stanzas to DEBUG. Applied consistently across all four decrypt-failure log sites.

  6. Group generic error path now dispatches UndecryptableMessage (src/message.rs:1385-1394) — Addresses the CodeRabbit finding: the event is always surfaced to consumers, while the nack is gated behind !is_status_broadcast().

  7. Thorough test suitedecrypt_failure_emits_transport_ack, decrypt_failure_sends_retry_before_ack, status_broadcast_decrypt_failure_skips_extra_ack, duplicate_message_is_acked_with_delivery_receipt, own_account_message_acked_via_transport_ack, unavailable_message_is_transport_acked — these verify actual wire behavior with frame decode, not just internal state. Excellent coverage.

Findings

1. Duplicate status@broadcast with session-only payloads — silent drop (minor, by design)

src/message.rs:778-781

A status@broadcast message that arrives as a session-only duplicate (no skmsg) hits the !info.source.chat.is_status_broadcast() guard and falls through to no ack. The comment says "status is acked by the should_ack gate" — this is correct if the framework's should_ack always fires for status messages regardless of this code path. Worth confirming that should_ack doesn't depend on any state set by process_classified_message, otherwise this stanza type would be silently unacked.

2. Group generic error: nack without retry receipt (pre-existing, by design)

src/message.rs:1363-1394

The catch-all Err(e) in process_group_enc_batch dispatches UndecryptableMessage and sends a nack, but does NOT send a retry receipt (unlike handle_decrypt_failure which sends retry + ack). The nack clears the offline queue (good), but the sender never gets a retry request. This is intentional for truly unexpected errors — a BadMac in a group context goes through handle_decrypt_failure via the NoSenderKeyState → retry path, while generic SignalProtocolError variants are nacked. This pattern was pre-existing; the PR just extended it with the dispatch_undecryptable_event call and the status@broadcast guard.

3. send_transport_ack error swallowing for Ok(None) (cosmetic)

src/client.rs:1962

encode_ack_bytes returns Ok(None) when the node has no id attr. In the send_transport_ack path, this means we silently skip acking. This is fine in practice since ack_received_message already early-returns for empty ids, but a debug-level log here could help diagnosis if a future caller forgets the guard.

Previous review findings status

All prior CodeRabbit and Codex findings have been addressed:

  • outbound_flush routing: Fixed (commit b0ae9bd, preserved in squash)
  • status@broadcast duplicate receipt guard: Fixed (commit b0ae9bd, preserved in squash)
  • dispatch_undecryptable_event for status sender-key failures: Fixed (commit 934bace)
  • message_ack_source_node broadcast routing: False positive — is_group is already true for status@broadcast in the parser. Verified by the test_message_ack_source_node_status_addressing test.
  • Codex P1 retry/ack ordering during disconnect: Addressed — both now run in a single ordered outbound_flush task.

Verdict

Looks good to merge. The design is clean, all automated review findings have been addressed, and the test coverage is thorough. No blocking issues.


View job

@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: 934bace945

ℹ️ 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.rs Outdated
Comment on lines +241 to +243
client.run_retry_receipt(&info, reason).await;
if !skip_ack {
client.send_transport_ack(&info).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 Only ack after a resend request is actually sent

When run_retry_receipt does not successfully put a resend request on the wire, this still sends the transport ack and clears the server's offline queue. That can happen when send_retry_receipt returns an error (for example missing device account when keys are required) or when MAX_DECRYPT_RETRIES is reached, where run_retry_receipt only spawns the PDO request detached and returns; during disconnect that PDO task can be skipped by the connection-shutdown signal while this flushed ack still lands. In those cases the original ciphertext is no longer redelivered and no peer/phone resend request was delivered, so the message can be lost; have run_retry_receipt report/await a successfully sent retry/PDO request and skip or delay this ack otherwise.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@src/message.rs`:
- Around line 237-245: The PDO path (run_retry_receipt) must be awaited and
executed inside the same outbound_flush flush task before the transport ack so
the ack cannot flush and disconnect before PDO runs; replace detached spawns or
spawn_* that call run_retry_receipt with sequencing inside the
outbound_flush.spawn closure: first await client.run_retry_receipt(&info,
reason).await (or equivalent awaitable PDO function), then if !skip_ack await
client.send_transport_ack(&info).await. Apply the same change to the other
occurrences referenced (the block around lines 330-339 and the one at 470-473)
so PDO is always run and awaited inside the flush task prior to sending the
transport ack.
🪄 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: 03ec6e12-0944-4535-990a-bc191ecccbd8

📥 Commits

Reviewing files that changed from the base of the PR and between 2a0231c and 934bace.

📒 Files selected for processing (4)
  • src/cache_config.rs
  • src/client.rs
  • src/message.rs
  • src/receipt.rs

Comment thread src/message.rs

@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: 38fb3553fd

ℹ️ 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.rs Outdated
Comment on lines +239 to +243
let skip_ack = info.source.chat.is_status_broadcast();
self.outbound_flush.spawn(&*self.runtime, async move {
client.run_retry_receipt(&info, reason).await;
if !skip_ack {
client.send_transport_ack(&info).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.

P2 Badge Track status failure acks in the flush scope

For status@broadcast decrypt failures this suppresses the flushed transport ack and relies on the generic should_ack path after dispatch, but that path uses maybe_deferred_ack, which spawns a detached task rather than outbound_flush. If the client disconnects right after processing the failed status stanza, disconnect() can flush the retry task while the detached status ack is dropped, leaving the status message in the server offline queue to be replayed on reconnect. Send the status ack from the same flushed task (or make the generic ack path flushable) for this failure case.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/client.rs (1)

4123-4139: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Broadcast-list ACKs still route to the wrong JID.

Line 4124 only switches on is_group. That covers status only if the parser marks it group-like, but regular broadcast-list sources (broadcast_list_owner.is_some()) still rebuild from from sender, so the transport ACK goes back to the participant instead of the chat and the offline stanza can keep replaying.

Diff
 fn message_ack_source_node(info: &crate::types::message::MessageInfo) -> Node {
-    let from = if info.source.is_group {
+    let from = if info.source.is_group || info.source.broadcast_list_owner.is_some() {
         &info.source.chat
     } else {
         &info.source.sender
     };
🤖 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.rs` around lines 4123 - 4139, The ACK source selection in
message_ack_source_node only checks info.source.is_group when choosing the
"from" JID, causing broadcast-list messages
(info.source.broadcast_list_owner.is_some()) to use sender instead of the chat;
update the logic in message_ack_source_node to treat broadcast-list sources like
groups when computing `from` (i.e., use &info.source.chat when
info.source.is_group || info.source.broadcast_list_owner.is_some()), but keep
the existing participant attribute handling (only set participant when
info.source.is_group). Ensure the optional recipient attr behavior remains
unchanged.
🤖 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.rs`:
- Around line 315-317: send_active_receipts (AtomicU32) currently carries
presence-driven state across reconnects; on connection teardown/start we must
reset the presence-driven value 1 back to 0 while preserving forced mode 2. In
the connection lifecycle code (e.g., the connect/disconnect or teardown/start
handlers where session state is reset) add an atomic small update on
send_active_receipts that only changes 1 -> 0 (use compare_exchange or
fetch_update on the AtomicU32) and leave any value 2 untouched; do this in both
the disconnect/teardown path and the connect/start path so presence state does
not leak across reconnections.

---

Duplicate comments:
In `@src/client.rs`:
- Around line 4123-4139: The ACK source selection in message_ack_source_node
only checks info.source.is_group when choosing the "from" JID, causing
broadcast-list messages (info.source.broadcast_list_owner.is_some()) to use
sender instead of the chat; update the logic in message_ack_source_node to treat
broadcast-list sources like groups when computing `from` (i.e., use
&info.source.chat when info.source.is_group ||
info.source.broadcast_list_owner.is_some()), but keep the existing participant
attribute handling (only set participant when info.source.is_group). Ensure the
optional recipient attr behavior remains unchanged.
🪄 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: 5ced3a87-c6c4-4e1d-bfa7-1083c8bc7a67

📥 Commits

Reviewing files that changed from the base of the PR and between 934bace and 38fb355.

📒 Files selected for processing (6)
  • src/cache_config.rs
  • src/client.rs
  • src/features/presence.rs
  • src/message.rs
  • src/pdo.rs
  • src/receipt.rs

Comment thread src/client.rs
@jlucaso1
jlucaso1 force-pushed the fix/offline-ack-drain branch from 38fb355 to c5ba746 Compare May 28, 2026 00:44

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

ℹ️ 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.rs
Comment on lines +3522 to +3523
self.send_active_receipts
.store(if active { 2 } else { 0 }, Ordering::Release);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve active presence when disabling forced receipts

When set_force_active_delivery_receipts(false) is called while the client is already available, this unconditionally stores 0, so subsequent delivery receipts are sent as type="inactive" even though presence is still active. The state machine already distinguishes presence-active (1) from forced (2), so clearing the force should not drop an active-presence state; otherwise any temporary force toggle during an online session suppresses normal active receipts until presence is resent.

Useful? React with 👍 / 👎.

Comment thread src/features/presence.rs
Comment on lines +88 to +92
PresenceStatus::Available => {
self.client.send_unified_session().await;
self.client.mark_receipts_active_on_presence();
}
PresenceStatus::Unavailable => self.client.mark_receipts_inactive_on_presence(),

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 Update receipt activity only after presence sends

In the PresenceStatus::Available path this marks receipts active before the presence stanza is successfully sent; if send_node returns a transport error, the method reports failure but future delivery receipts are still emitted as active even though the server never accepted the available presence. This affects failed/retrying presence changes, and the same stale-state problem exists in the unavailable path; move the state transition after a successful send or roll it back on error.

Useful? React with 👍 / 👎.

Duplicate, undecryptable, unavailable and own-account fan-out messages were
acknowledged inconsistently, so the server replayed them on every reconnect
until it force-closed the stream. Ensure every received message is acked:

- Ack duplicates and own-account fan-out (own non-peer gets a transport ack
  with the correct to=from, since its delivery receipt is suppressed).
- Send a transport ack on decrypt failure and on <unavailable>; the retry
  receipt alone does not clear the offline queue.
- Nack generic group decrypt errors, matching the session catch-all.
- Send the retry receipt, PDO request and transport ack in one ordered,
  flushed task so a disconnect can't clear a stanza before its resend
  request (retry/PDO) goes out.
- Send delivery receipts as type="inactive" unless marked online, matching
  whatsmeow's background-companion behavior.
- Raise message_retry_counts TTL to 1h so the retry cap survives spaced
  redeliveries.
- Lower decrypt-fail="hide" failures to DEBUG.
- Recognize stream:error with an ack child instead of logging it as unknown.
@jlucaso1
jlucaso1 force-pushed the fix/offline-ack-drain branch from c5ba746 to 73085eb Compare May 28, 2026 00:53
@jlucaso1
jlucaso1 merged commit ebb9faf into main May 28, 2026
9 of 10 checks passed
@jlucaso1
jlucaso1 deleted the fix/offline-ack-drain branch May 28, 2026 00:55

@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: 73085eb680

ℹ️ 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.rs
Comment on lines +491 to +497
let skip_ack = info.source.chat.is_status_broadcast();
self.outbound_flush.spawn(&*self.runtime, async move {
// Only ack once the PDO request is out (or skipped as ancient);
// a transient send failure leaves it queued for redelivery.
let pdo_sent = client.run_pdo_request(&info2).await;
if !skip_ack && pdo_sent {
client.send_transport_ack(&info2).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.

P2 Badge Keep status unavailable ack ordered with PDO

For status@broadcast stanzas that contain only <unavailable>, this skip_ack path avoids the ordered/flushed ack and relies on the generic should_ack path after the handler returns. That generic ack is spawned independently, so it can be sent during disconnect or simply race ahead of run_pdo_request; if it clears the offline stanza before the PDO request is successfully sent, the placeholder can no longer be recovered or redelivered. Treat status the same as non-status here (or otherwise suppress/order the generic ack) so the ack only goes out after the PDO request succeeds.

Useful? React with 👍 / 👎.

jlucaso1 added a commit that referenced this pull request May 28, 2026
…epair e2e

Codex P2: the unknown-enc fallback was reconstructing the ack from
MessageInfo, but parse_message_info only retains `recipient` on the self-sent
branch, so LID-routed / hosted-companion / peer stanzas lost the attribute
and the server replied <stream:error><ack/>. Add
`Client::spawn_node_transport_ack` which encodes from the original NodeRef so
`recipient` is echoed verbatim, and use it in the classify fallback.

Outside-diff comment: `EncPayload::from_owned_node` returns None for both
unknown types and known-but-empty content. Resolve `EncType::from_wire` up
front and only set `had_unknown_enc` when the type itself is unrecognized;
known-but-empty (malformed) falls back through the regular flow.

Repair the e2e regression introduced by PR #647: `with_push_name` pre-seeds
the name, so the setting_pushName mutation arrives with `old == new` and
skips the auto `presence().set_available()` that would flip
send_active_receipts to 1. As a result delivery receipts went out as
type="inactive" and `test_bidirectional_offline_receipt` /
`test_deferred_delivery_receipt_on_reconnect` failed in CI. Force active
receipts in `TestClient::connect_inner` (mirrors whatsmeow's
`SetForceActiveDeliveryReceipts`).

Tests:
- `unknown_only_enc_ack_preserves_recipient` for the LID-routed ack shape.
- `known_enc_type_with_empty_content_skips_fallback_ack` for the new gate.
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