Skip to content

feat: add awaited pre-ACK message hook - #919

Closed
Davincible wants to merge 1 commit into
oxidezap:mainfrom
Davincible:awaited-pre-ack-message-hook
Closed

feat: add awaited pre-ACK message hook#919
Davincible wants to merge 1 commit into
oxidezap:mainfrom
Davincible:awaited-pre-ack-message-hook

Conversation

@Davincible

@Davincible Davincible commented Jun 26, 2026

Copy link
Copy Markdown

Summary

  • add an awaited parsed-message pre-ACK hook on Client and BotBuilder for ACK-after-durable-commit consumers
  • run the hook after message normalization but before delivery receipt / transport ACK, suppressing ACK and Event::Message when the hook fails
  • preserve retry safety for Signal duplicate redelivery, status/newsletter fallback ACKs, and deferred Signal cache flushes while a pre-ACK commit is pending

Tests

  • RUSTC="/Users/tyler/.rustup/toolchains/nightly-2026-06-16-aarch64-apple-darwin/bin/rustc" rustup run nightly-2026-06-16 cargo check -p whatsapp-rust --all-targets
  • RUSTC="/Users/tyler/.rustup/toolchains/nightly-2026-06-16-aarch64-apple-darwin/bin/rustc" rustup run nightly-2026-06-16 cargo test -p whatsapp-rust pre_ack -- --nocapture
  • rustup run nightly-2026-06-16 cargo fmt -- --check

Note: the plain cargo command in this environment uses stable rustc and fails on the existing default simd feature; the commands above force the repository nightly toolchain.

Review in cubic

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added an optional pre-ACK message hook so apps can run custom logic before delivery receipts are sent.
    • Improved handling for redelivered messages, allowing deferred processing to retry safely before normal dispatch.
  • Bug Fixes

    • Prevents acknowledgements from being sent when pre-ACK processing fails.
    • Better preserves message handling consistency for status, newsletter, and group messages when delivery is deferred.

Walkthrough

The PR adds a parsed-message pre-ACK hook, pending replay state, and updated inbound ACK/dispatch handling. Client initialization, message classification, session/group processing, newsletter handling, and tests are updated to use the new hook-driven flow.

Changes

Parsed-message pre-ACK pipeline

Layer / File(s) Summary
Hook types and registration API
src/message.rs, src/client/accessors.rs, src/bot.rs, src/lib.rs
ParsedMessagePreAck* types, the client registration API, BotBuilder plumbing, and prelude re-exports are added.
Client hook installation and runtime guards
src/client/accessors.rs, src/client.rs, src/client/lifecycle.rs, src/client/node_io.rs, src/client/adapters.rs, src/bot.rs
Client state is initialized with pending pre-ACK storage and hook state, the hook is installed once, inbound processing is serialized, direct message ACKs are suppressed under the hook, and signal-cache flushes are deferred while a pending commit exists.
Dispatch and pending replay
src/message.rs, src/message/dispatch.rs
Dispatch runs the pre-ACK hook before ACK/event emission, stores failed messages for later replay, removes pending entries on success, and uses the post-hook ACK/drop helpers.
Incoming classification and duplicate replay
src/message/receive.rs
Classification now checks hook presence for status-broadcast ACK gating, duplicate handling can retry stored pre-ACK messages, and processing returns early when group or session pre-ACK handling fails.
Session and migration propagation
src/message/receive.rs
Session plaintext handling propagates pre_ack_hook_failed through decrypted results, retry paths, and PN-to-LID migration outcomes.
Group and newsletter drop ACKs
src/message/receive.rs, src/message/special.rs
Group batch processing propagates pre-ACK failure state through plaintext handling, duplicate sender-key handling, status drop branches, and newsletter drop branches.
Hook sequencing tests
src/message/tests.rs
Tests cover hook ordering, failure suppression, pending replay, status ACK gating, pending cleanup, and is_from_me status ACK behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ParsedMessagePreAckHook
  participant PendingParsedMessagePreAck
  participant NodeIO
  participant EventMessage as Event::Message

  Client->>ParsedMessagePreAckHook: ParsedMessagePreAckContext
  alt hook succeeds
    ParsedMessagePreAckHook-->>Client: Ok(())
    Client->>NodeIO: ack_parsed_message_after_pre_ack
    Client->>EventMessage: dispatch Event::Message
  else hook fails
    ParsedMessagePreAckHook-->>Client: Err(...)
    Client->>PendingParsedMessagePreAck: store_pending_pre_ack_message
  end
  loop duplicate redelivery
    Client->>PendingParsedMessagePreAck: load pending pre-ACK message
    Client->>ParsedMessagePreAckHook: ParsedMessagePreAckContext
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#647: Both PRs modify the parsed-message ACK/dispatch pipeline and overlap in message ACK emission, replay, and offline-queue behavior.
  • oxidezap/whatsapp-rust#648: Both PRs modify inbound classification and transport-ACK gating in classify_incoming_message.
  • oxidezap/whatsapp-rust#624: Both PRs modify Client::should_ack behavior around when <message/> stanzas get ACKed.

Suggested labels

api-design, breaking-change

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: an awaited pre-ACK message hook.
Description check ✅ Passed The description is clearly related to the changeset and matches the implemented hook, retry, and ACK behavior 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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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

🤖 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/adapters.rs`:
- Around line 68-70: The deferred flush path in flush_signal_cache_logged is
treating expected backpressure as an error, which causes noisy error-level
logging. Update the signal-cache flush flow so the pending pre-ACK commit case
is represented as a distinct deferred outcome from the core flush_signal_cache
logic, then have the _logged wrapper downgrade that case to debug or trace
instead of error, or make callers special-case it using the existing
anyhow::anyhow return point as the location to distinguish the status.
- Around line 62-71: The global pending-commit gate in flush_signal_cache is too
strict because a single stuck parsed-message pre-ACK commit can block all Signal
persistence forever, including cleanup_connection_state. Update the logic around
pending_parsed_message_pre_ack_count and parsed_message_pre_ack_hook so there is
an escape hatch such as a max pending age or retry limit; once exceeded, abandon
the pending entry, allow the cache flush to proceed, and leave the message for
redelivery. Keep the fix localized to the flush_signal_cache path and any helper
state that tracks pending pre-ACK commits.

In `@src/client/lifecycle.rs`:
- Around line 195-201: The pending_parsed_message_pre_ack cache in lifecycle
state is unbounded and never drained, so add a lifecycle path that removes stuck
entries or caps growth. Review pending_parsed_message_pre_ack,
pending_parsed_message_pre_ack_count, cleanup_connection_state, and
flush_signal_cache to ensure failed pre-ACK items are eventually evicted or
accounted for without relying on TTL, and make sure reconnect/cleanup logic
clears or bounds this cache like the other per-connection caches.

In `@src/client/node_io.rs`:
- Around line 24-32: The receive path in NodeIO is forced to a single in-flight
message whenever a parsed-message pre-ACK hook is installed, which serializes
the whole pipeline for the client’s lifetime. Update the public documentation
for set_parsed_message_pre_ack_hook to clearly call out this throughput cost and
that a heavy hook will back-pressure all inbound messages. Mention the behavior
change explicitly near the NodeIo / parsed_message_pre_ack_hook API so consumers
understand the tradeoff before enabling it.

In `@src/message/receive.rs`:
- Around line 690-695: `process_classified_message` and the payload-batch loops
are continuing after `pre_ack_hook_failed` becomes true, which can allow
additional ACK/nack/event emission in the same stanza. Update the branches that
merge `plaintext_outcome`/batch outcomes so they immediately return the
accumulated `outcome` as soon as `pre_ack_hook_failed` is set, and apply the
same early-exit pattern in the other affected batch-processing blocks mentioned
in the review.
- Around line 493-497: The fallback path in dispatch_pending_pre_ack_message
handling is using ack_received_message() for status messages, but when the
pre-ACK hook is installed that does not send the required transport ACK and can
leave status stanzas queued for redelivery. Update the None branch in the
receive flow to use the status post-pre-ACK helper instead of
ack_received_message(), and apply the same change in the other matching status
fallback site, keeping the logic centered around
dispatch_pending_pre_ack_message and the status ACK helper.

In `@src/message/tests.rs`:
- Around line 5388-5392: The test fixtures in Message tests are using phone-like
JIDs that could look real; replace these identifiers in the affected
create_test_message_info cases with clearly fictitious placeholder JIDs so no
plausible user numbers remain in test code. Update all new/related cases in
src/message/tests.rs, especially the create_test_message_info calls around the
listed PRE_ACK/related scenarios, to use obviously fake values consistently.
🪄 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 (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3a418d95-3c7d-4f4b-9101-ef21368b250c

📥 Commits

Reviewing files that changed from the base of the PR and between 302d478 and 92df171.

📒 Files selected for processing (12)
  • src/bot.rs
  • src/client.rs
  • src/client/accessors.rs
  • src/client/adapters.rs
  • src/client/lifecycle.rs
  • src/client/node_io.rs
  • src/lib.rs
  • src/message.rs
  • src/message/dispatch.rs
  • src/message/receive.rs
  • src/message/special.rs
  • src/message/tests.rs

Comment thread src/client/adapters.rs
Comment on lines +62 to +71
if self.parsed_message_pre_ack_hook.get().is_some()
&& self
.pending_parsed_message_pre_ack_count
.load(Ordering::Acquire)
> 0
{
return Err(anyhow::anyhow!(
"Signal cache flush deferred while parsed-message pre-ACK commit is pending"
));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

This is the part that has to work right: one stuck commit can wedge Signal persistence for the entire client, forever.

The gate is global, not per-message: as long as pending_parsed_message_pre_ack_count > 0, every flush_signal_cache call returns Err — including the one in cleanup_connection_state, which then keeps (never clears) the signal cache. So if a consumer's durable commit fails permanently for a single message (disk full, DB down, a bug in their hook), the result is:

  • Signal cache flushes are blocked for all chats, not just the stuck one.
  • The in-memory signal cache grows unbounded and its advanced session/sender-key state is never persisted, surviving reconnects only in memory.
  • Combined with the serialized receive lane, the whole pipeline degrades behind one poisoned entry.

There's no bound, timeout, or escape hatch here. Please add one — e.g. a max pending age/count after which the entry is abandoned (and the message left for redelivery) so a single bad commit can't brick Signal persistence indefinitely.

🤖 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/adapters.rs` around lines 62 - 71, The global pending-commit gate
in flush_signal_cache is too strict because a single stuck parsed-message
pre-ACK commit can block all Signal persistence forever, including
cleanup_connection_state. Update the logic around
pending_parsed_message_pre_ack_count and parsed_message_pre_ack_hook so there is
an escape hatch such as a max pending age or retry limit; once exceeded, abandon
the pending entry, allow the cache flush to proceed, and leave the message for
redelivery. Keep the fix localized to the flush_signal_cache path and any helper
state that tracks pending pre-ACK commits.

Comment thread src/client/adapters.rs
Comment on lines +68 to +70
return Err(anyhow::anyhow!(
"Signal cache flush deferred while parsed-message pre-ACK commit is pending"
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Don't scream error! for what is normal, expected backpressure.

flush_signal_cache_logged logs this deferral at error!. But a deferred flush while a pre-ACK commit is pending is expected, routine operation — it'll fire on every deferred call and drown the logs with errors that aren't errors. Either return a distinguishable "deferred" outcome that the _logged wrapper downgrades to debug!/trace!, or have callers special-case it. As-is, operators will think something's broken when it's working as designed.

🤖 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/adapters.rs` around lines 68 - 70, The deferred flush path in
flush_signal_cache_logged is treating expected backpressure as an error, which
causes noisy error-level logging. Update the signal-cache flush flow so the
pending pre-ACK commit case is represented as a distinct deferred outcome from
the core flush_signal_cache logic, then have the _logged wrapper downgrade that
case to debug or trace instead of error, or make callers special-case it using
the existing anyhow::anyhow return point as the location to distinguish the
status.

Comment thread src/client/lifecycle.rs
Comment on lines +195 to +201
// Intentionally unbounded/no-TTL: an entry means Signal decrypt may
// have advanced volatile state for a message the application has
// not durably committed. Expiring it would allow a later Signal
// flush to make that advance durable and turn server redelivery
// into an ACKed duplicate without rerunning the hook.
pending_parsed_message_pre_ack: Cache::builder().build(),
pending_parsed_message_pre_ack_count: AtomicUsize::new(0),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

The unbounded, never-cleared pending cache is the thing I'm worried about — let's make sure it can't grow forever.

Look, I get the design: an entry means Signal state advanced for an uncommitted message, so expiring it would be wrong. But this cache has no capacity bound and is never cleared in cleanup_connection_state (unlike chat_lanes, signal_cache, etc.). If a consumer's pre-ACK hook keeps failing for a message that never gets a successful redelivery, that entry — and the pending_parsed_message_pre_ack_count it bumps — stays around indefinitely. Across reconnects it just accumulates. The real-world fallout shows up in flush_signal_cache; I've left the detailed concern there. Please confirm there's a path that bounds this or drains stuck entries.

🤖 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/lifecycle.rs` around lines 195 - 201, The
pending_parsed_message_pre_ack cache in lifecycle state is unbounded and never
drained, so add a lifecycle path that removes stuck entries or caps growth.
Review pending_parsed_message_pre_ack, pending_parsed_message_pre_ack_count,
cleanup_connection_state, and flush_signal_cache to ensure failed pre-ACK items
are eventually evicted or accounted for without relying on TTL, and make sure
reconnect/cleanup logic clears or bounds this cache like the other
per-connection caches.

Comment thread src/client/node_io.rs
Comment on lines +24 to +32
let permits = if self.parsed_message_pre_ack_hook.get().is_some() {
// A pre-ACK hook can intentionally fail after Signal decrypt has
// advanced volatile state. Keep message processing serial while the
// hook is installed so pending retry/flush deferral has one inbound
// message to reason about at a time.
1
} else {
permits
};

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

This pins the whole receive pipeline to single-threaded for the client's entire lifetime — make sure consumers understand the cost.

Forcing permits = 1 whenever the hook is installed means every inbound message is processed strictly serially forever, not just during the pending-retry window. The reasoning is sound (you need one message to reason about at a time for the flush-deferral contract), but a slow durable-commit hook now serializes and back-pressures the entire receive lane. That's a real throughput ceiling. Worth calling out loudly in the public set_parsed_message_pre_ack_hook docs so nobody ships a heavy hook and wonders why their message throughput tanked.

🤖 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/node_io.rs` around lines 24 - 32, The receive path in NodeIO is
forced to a single in-flight message whenever a parsed-message pre-ACK hook is
installed, which serializes the whole pipeline for the client’s lifetime. Update
the public documentation for set_parsed_message_pre_ack_hook to clearly call out
this throughput cost and that a heavy hook will back-pressure all inbound
messages. Mention the behavior change explicitly near the NodeIo /
parsed_message_pre_ack_hook API so consumers understand the tradeoff before
enabling it.

Comment thread src/message/receive.rs
Comment on lines +493 to +497
match self.dispatch_pending_pre_ack_message(&info).await {
Some(true) => {}
Some(false) => return,
None => self.ack_received_message(&info),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Send the post-pre-ACK status ACK when no pending replay exists.

These None fallbacks are reachable for status messages when the pre-ACK hook is installed, but ack_received_message() intentionally does not send the status transport ACK. Since the deferred status gate is disabled under the hook, this can leave duplicate status stanzas queued for redelivery. Use the status post-pre-ACK helper in the fallback path.

Proposed fix
-                None => self.ack_received_message(&info),
+                None => {
+                    self.ack_received_message(&info);
+                    self.ack_status_drop_after_pre_ack_hook(&info);
+                }
                         None if !info.source.chat.is_status_broadcast()
                             || self.parsed_message_pre_ack_hook.get().is_some() =>
                         {
                             self.ack_received_message(info);
+                            self.ack_status_drop_after_pre_ack_hook(info);
                         }

Also applies to: 1211-1219

🤖 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/receive.rs` around lines 493 - 497, The fallback path in
dispatch_pending_pre_ack_message handling is using ack_received_message() for
status messages, but when the pre-ACK hook is installed that does not send the
required transport ACK and can leave status stanzas queued for redelivery.
Update the None branch in the receive flow to use the status post-pre-ACK helper
instead of ack_received_message(), and apply the same change in the other
matching status fallback site, keeping the logic centered around
dispatch_pending_pre_ack_message and the status ACK helper.

Comment thread src/message/receive.rs
Comment on lines 690 to 695
Ok(plaintext_outcome) => {
outcome.decrypted = true;
outcome.dispatched |= plaintext_outcome.dispatched;
outcome.pre_ack_hook_failed |= plaintext_outcome.pre_ack_hook_failed;
outcome.had_failure |= plaintext_outcome.pre_ack_hook_failed;
outcome.skdm_only |= plaintext_outcome.skdm_only;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stop processing the batch immediately after pre-ACK failure.

These branches set pre_ack_hook_failed, but the enclosing payload loops can continue processing later payloads before process_classified_message returns. That can still emit ACKs, nacks, or another message event in the same stanza after a failed commit. Return the accumulated outcome immediately when this flag is set.

Pattern to apply
 outcome.pre_ack_hook_failed |= plaintext_outcome.pre_ack_hook_failed;
 outcome.had_failure |= plaintext_outcome.pre_ack_hook_failed;
+if plaintext_outcome.pre_ack_hook_failed {
+    return outcome;
+}

For group batches:

 outcome.pre_ack_hook_failed |= plaintext_outcome.pre_ack_hook_failed;
+if plaintext_outcome.pre_ack_hook_failed {
+    return Ok(outcome);
+}

Also applies to: 810-816, 954-966, 1005-1017, 1065-1077, 1192-1194

🤖 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/receive.rs` around lines 690 - 695, `process_classified_message`
and the payload-batch loops are continuing after `pre_ack_hook_failed` becomes
true, which can allow additional ACK/nack/event emission in the same stanza.
Update the branches that merge `plaintext_outcome`/batch outcomes so they
immediately return the accumulated `outcome` as soon as `pre_ack_hook_failed` is
set, and apply the same early-exit pattern in the other affected
batch-processing blocks mentioned in the review.

Comment thread src/message/tests.rs
Comment on lines +5388 to +5392
let info = Arc::new(create_test_message_info(
"5511999998888@s.whatsapp.net",
"PRE_ACK_SUCCESS",
"5511777776666@s.whatsapp.net",
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use obviously fake test JIDs here.

These phone-like JIDs look plausibly real, and we need this airtight in test fixtures too. Replace them with clearly fictitious placeholders across all new cases so we do not ship possible real-user identifiers. As per coding guidelines, "Use fictitious phone numbers and JIDs in test code; never commit real user numbers (no real PII in tests)".

Also applies to: 5434-5438, 5482-5486, 5579-5583, 5609-5612

🤖 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/tests.rs` around lines 5388 - 5392, The test fixtures in Message
tests are using phone-like JIDs that could look real; replace these identifiers
in the affected create_test_message_info cases with clearly fictitious
placeholder JIDs so no plausible user numbers remain in test code. Update all
new/related cases in src/message/tests.rs, especially the
create_test_message_info calls around the listed PRE_ACK/related scenarios, to
use obviously fake values consistently.

Source: Coding guidelines

@jlucaso1

Copy link
Copy Markdown
Collaborator

Thanks @Davincible. Can you provide a use case for this feature? I want to evaluate the usage in a real scenario

@cubic-dev-ai cubic-dev-ai 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.

5 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/message/tests.rs">

<violation number="1" location="src/message/tests.rs:5389">
P2: These phone-like JIDs (`5511999998888@s.whatsapp.net`, `5511777776666@s.whatsapp.net`) look plausibly real. Use obviously fictitious placeholders (e.g., `10000000001@s.whatsapp.net` or `test_sender@s.whatsapp.net`) across all new test cases to ensure no real-user identifiers are shipped in test fixtures.</violation>
</file>

<file name="src/message/receive.rs">

<violation number="1" location="src/message/receive.rs:496">
P2: When the pre-ACK hook is installed, the deferred status ACK gate is disabled. In this `None` fallback for session duplicates, `ack_received_message` is called but `ack_status_drop_after_pre_ack_hook` is not — status messages in this path won't get their transport ACK sent, leaving duplicate status stanzas queued for redelivery. Add the post-pre-ACK status ACK call in the `None` fallback.</violation>

<violation number="2" location="src/message/receive.rs:693">
P1: After a pre-ACK hook failure, the batch loop continues processing subsequent payloads in the same stanza. This can emit ACKs, nacks, or additional `Event::Message` events for later payloads after a failed commit — violating the invariant that pre-ACK failure suppresses all further processing. Return the accumulated outcome immediately when `pre_ack_hook_failed` is set.</violation>
</file>

<file name="src/client/lifecycle.rs">

<violation number="1" location="src/client/lifecycle.rs:200">
P2: This cache is intentionally unbounded with no TTL, and it is never cleared in `cleanup_connection_state` (unlike `chat_lanes`, `signal_cache`, etc.). If a hook permanently fails for messages that never get successfully redelivered, entries and the associated `pending_parsed_message_pre_ack_count` accumulate indefinitely across reconnects. Consider adding at minimum a `max_capacity` or draining stale entries during connection cleanup to prevent unbounded growth.</violation>
</file>

<file name="src/client/adapters.rs">

<violation number="1" location="src/client/adapters.rs:68">
P2: This returns an `Err` for what is expected, routine backpressure (a deferred flush while a pre-ACK commit is pending). The `flush_signal_cache_logged` caller will log it at `error!` level on every deferred call, creating noisy false-alarm entries during normal operation. Either return a distinguishable "deferred" variant that the logged wrapper can downgrade to `debug!`/`trace!`, or use a dedicated error type that callers can match on to adjust log severity.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/message/receive.rs
Ok(plaintext_outcome) => {
outcome.decrypted = true;
outcome.dispatched |= plaintext_outcome.dispatched;
outcome.pre_ack_hook_failed |= plaintext_outcome.pre_ack_hook_failed;

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: After a pre-ACK hook failure, the batch loop continues processing subsequent payloads in the same stanza. This can emit ACKs, nacks, or additional Event::Message events for later payloads after a failed commit — violating the invariant that pre-ACK failure suppresses all further processing. Return the accumulated outcome immediately when pre_ack_hook_failed is set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/message/receive.rs, line 693:

<comment>After a pre-ACK hook failure, the batch loop continues processing subsequent payloads in the same stanza. This can emit ACKs, nacks, or additional `Event::Message` events for later payloads after a failed commit — violating the invariant that pre-ACK failure suppresses all further processing. Return the accumulated outcome immediately when `pre_ack_hook_failed` is set.</comment>

<file context>
@@ -673,6 +690,8 @@ impl Client {
                         Ok(plaintext_outcome) => {
                             outcome.decrypted = true;
                             outcome.dispatched |= plaintext_outcome.dispatched;
+                            outcome.pre_ack_hook_failed |= plaintext_outcome.pre_ack_hook_failed;
+                            outcome.had_failure |= plaintext_outcome.pre_ack_hook_failed;
                             outcome.skdm_only |= plaintext_outcome.skdm_only;
</file context>

Comment thread src/message/tests.rs
.expect("hook should register once");

let info = Arc::new(create_test_message_info(
"5511999998888@s.whatsapp.net",

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: These phone-like JIDs (5511999998888@s.whatsapp.net, 5511777776666@s.whatsapp.net) look plausibly real. Use obviously fictitious placeholders (e.g., 10000000001@s.whatsapp.net or test_sender@s.whatsapp.net) across all new test cases to ensure no real-user identifiers are shipped in test fixtures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/message/tests.rs, line 5389:

<comment>These phone-like JIDs (`5511999998888@s.whatsapp.net`, `5511777776666@s.whatsapp.net`) look plausibly real. Use obviously fictitious placeholders (e.g., `10000000001@s.whatsapp.net` or `test_sender@s.whatsapp.net`) across all new test cases to ensure no real-user identifiers are shipped in test fixtures.</comment>

<file context>
@@ -5339,6 +5339,301 @@ async fn capturing_client(
+        .expect("hook should register once");
+
+    let info = Arc::new(create_test_message_info(
+        "5511999998888@s.whatsapp.net",
+        "PRE_ACK_SUCCESS",
+        "5511777776666@s.whatsapp.net",
</file context>
Suggested change
"5511999998888@s.whatsapp.net",
"10000000001@s.whatsapp.net",

Comment thread src/message/receive.rs
match self.dispatch_pending_pre_ack_message(&info).await {
Some(true) => {}
Some(false) => return,
None => self.ack_received_message(&info),

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: When the pre-ACK hook is installed, the deferred status ACK gate is disabled. In this None fallback for session duplicates, ack_received_message is called but ack_status_drop_after_pre_ack_hook is not — status messages in this path won't get their transport ACK sent, leaving duplicate status stanzas queued for redelivery. Add the post-pre-ACK status ACK call in the None fallback.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/message/receive.rs, line 496:

<comment>When the pre-ACK hook is installed, the deferred status ACK gate is disabled. In this `None` fallback for session duplicates, `ack_received_message` is called but `ack_status_drop_after_pre_ack_hook` is not — status messages in this path won't get their transport ACK sent, leaving duplicate status stanzas queued for redelivery. Add the post-pre-ACK status ACK call in the `None` fallback.</comment>

<file context>
@@ -474,14 +482,19 @@ impl Client {
+            match self.dispatch_pending_pre_ack_message(&info).await {
+                Some(true) => {}
+                Some(false) => return,
+                None => self.ack_received_message(&info),
+            }
         } else if should_ack_skdm_only_session_fallback(session_outcome, bot_payloads.is_empty()) {
</file context>
Suggested change
None => self.ack_received_message(&info),
None => {
self.ack_received_message(&info);
self.ack_status_drop_after_pre_ack_hook(&info);
}

Comment thread src/client/lifecycle.rs
// not durably committed. Expiring it would allow a later Signal
// flush to make that advance durable and turn server redelivery
// into an ACKed duplicate without rerunning the hook.
pending_parsed_message_pre_ack: Cache::builder().build(),

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: This cache is intentionally unbounded with no TTL, and it is never cleared in cleanup_connection_state (unlike chat_lanes, signal_cache, etc.). If a hook permanently fails for messages that never get successfully redelivered, entries and the associated pending_parsed_message_pre_ack_count accumulate indefinitely across reconnects. Consider adding at minimum a max_capacity or draining stale entries during connection cleanup to prevent unbounded growth.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/client/lifecycle.rs, line 200:

<comment>This cache is intentionally unbounded with no TTL, and it is never cleared in `cleanup_connection_state` (unlike `chat_lanes`, `signal_cache`, etc.). If a hook permanently fails for messages that never get successfully redelivered, entries and the associated `pending_parsed_message_pre_ack_count` accumulate indefinitely across reconnects. Consider adding at minimum a `max_capacity` or draining stale entries during connection cleanup to prevent unbounded growth.</comment>

<file context>
@@ -192,6 +192,13 @@ impl Client {
+            // not durably committed. Expiring it would allow a later Signal
+            // flush to make that advance durable and turn server redelivery
+            // into an ACKed duplicate without rerunning the hook.
+            pending_parsed_message_pre_ack: Cache::builder().build(),
+            pending_parsed_message_pre_ack_count: AtomicUsize::new(0),
 
</file context>

Comment thread src/client/adapters.rs
.load(Ordering::Acquire)
> 0
{
return Err(anyhow::anyhow!(

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: This returns an Err for what is expected, routine backpressure (a deferred flush while a pre-ACK commit is pending). The flush_signal_cache_logged caller will log it at error! level on every deferred call, creating noisy false-alarm entries during normal operation. Either return a distinguishable "deferred" variant that the logged wrapper can downgrade to debug!/trace!, or use a dedicated error type that callers can match on to adjust log severity.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/client/adapters.rs, line 68:

<comment>This returns an `Err` for what is expected, routine backpressure (a deferred flush while a pre-ACK commit is pending). The `flush_signal_cache_logged` caller will log it at `error!` level on every deferred call, creating noisy false-alarm entries during normal operation. Either return a distinguishable "deferred" variant that the logged wrapper can downgrade to `debug!`/`trace!`, or use a dedicated error type that callers can match on to adjust log severity.</comment>

<file context>
@@ -59,6 +59,17 @@ impl Client {
+                .load(Ordering::Acquire)
+                > 0
+        {
+            return Err(anyhow::anyhow!(
+                "Signal cache flush deferred while parsed-message pre-ACK commit is pending"
+            ));
</file context>

@codspeed-hq

codspeed-hq Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 176 untouched benchmarks
⏩ 6 skipped benchmarks1


Comparing Davincible:awaited-pre-ack-message-hook (92df171) with main (302d478)

Open in CodSpeed

Footnotes

  1. 6 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@jlucaso1

Copy link
Copy Markdown
Collaborator

@Davincible take a look in #920

@jlucaso1 jlucaso1 closed this Jun 27, 2026
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.

2 participants