Skip to content

perf(handlers): shrink the inbound non-message stanza path; inbound_stanza bench - #1399

Merged
jlucaso1 merged 2 commits into
mainfrom
claude/whatsapp-rust-perf-inbound
Sep 3, 2026
Merged

perf(handlers): shrink the inbound non-message stanza path; inbound_stanza bench#1399
jlucaso1 merged 2 commits into
mainfrom
claude/whatsapp-rust-perf-inbound

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

The non-message half of the second parallel exploration (receipts, presence, notifications, acks and the event bus), after #1388#1396. The event bus itself measured as already optimal (0 allocations with no subscriber, exactly 1 Arc<Event> with any number); what was left is on the producers. All numbers below are from the new benches/inbound_stanza, release, one box; the honest metric is bytes per stanza, not wall time.

The notification handler's future was sized for every arm

async_trait boxes NotificationHandler's future once per inbound <notification>, and every arm of handle_notification_impl was awaited unboxed. Awaiting a plain async fn inlines its state machine into the caller's — the file's own comment claimed separate async fns already avoided this, and they do not — so that one allocation was the union of all seventeen arms: a <notification type="picture"> paid for handle_devices_notification's locals. The asynchronous arms are now Box::pinned; only the arm that runs pays its own small block.

per <notification type="picture"> before after
no subscriber 2 allocs / 2306 B (one 2272 B block) 2 allocs / 146 B
with a subscriber 3 allocs / 2850 B 3 allocs / 691 B
500-item drain, per item 2850 B 690 B

A reconnect draining 500 notifications sheds ~1.1 MB of transient allocation. Allocation count is unchanged and wall time (540 → 471 ns median) is inside the run-to-run spread. A unit test (notification_future_is_not_sized_for_every_arm) pins the largest single block under 1 KiB through the real StanzaHandler::handle; it was verified to fail at 2272 bytes with the arms un-boxed, so a future .await on an unboxed arm is caught in nextest.

The receipt subscriber gate runs before the parse

handle_receipt_inline ran its has_handler_for(EventKind::Receipt) bail only after parsing from, id, participant, recipient, participant_pn, offline, t and the feature-incapable child scan. It now reads type and id first and bails on the type (the missing-id warning stays ahead of the gate, so a malformed receipt is still reported whether or not anything is subscribed). Gating on the raw type is behaviour-preserving because downgrade_for_feature_incapable only ever rewrites Delivered to Sent and can never produce the Retry the gate lets through; a new test in wacore/src/stanza/receipt.rs pins that over every variant. This path runs inline on the read loop exactly when nothing is subscribed: 383 → 345 ns per ignored receipt, small and at the edge of noise.

Payloads built only for a listener

CoreEventBus::dispatch_with(kind, || event) (additive) builds the payload only when a handler for kind is registered. dispatch already costs nothing without one, but the payload has been built by then. Applied where it is more than a couple of Jid clones: GroupUpdate (a whole GroupNotificationAction with its participant Vec, once per action per notification), DeviceListUpdate, IdentityChange, BusinessStatusUpdate, MexNotification, ContactNumberChanged. Cheap producers keep plain dispatch. Not in the table above (none of its stanzas reaches those sites); the payoff is proportional to the payload, on a consumer that does not subscribe to that kind.

The guard

benches/inbound_stanza on ReceiveHarness (feature bench-harness, so the CodSpeed client shard runs it): <receipt>, <presence>, <notification> and <ack> with and without a subscriber, plus a 500-stanza burst per kind, reporting bytes and allocations per stanza through divan's AllocProfiler.

Not in this PR

size_of::<Event>() is 528 B because of GroupUpdate (528) and IncomingCall (432), so every dispatched event is a 544 B Arc regardless of payload; boxing those two takes it to ~280 B but changes the frozen Event surface, so it comes as its own perf(events)! PR.

Verification

  • cargo clippy -p wacore -p whatsapp-rust --all-targets --features bench-harness -- -D warnings clean
  • cargo test -p whatsapp-rust --lib, cargo test -p wacore --lib pass, including the two new tests

CI note: Semver Checks (informational) is red on main as well and is not this PR's; no fix exists for it in this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_0172fpxasGTrouFyYH5UGmjN

Three independent changes on the read loop's non-message path, plus the
benchmark that keeps them from silently reverting. All numbers below come
from the new `benches/inbound_stanza`, release, on one box; the honest
metric is bytes per stanza, not wall time.

`NotificationHandler`'s future is boxed once per inbound `<notification>` by
`async_trait`, and every arm of `handle_notification_impl` was awaited
unboxed, so that one allocation was sized for the union of all of them: a
`<notification type="picture">` paid for `handle_devices_notification`'s
locals. The file's own comment claimed separate async fns already avoided
this. They do not — awaiting a plain async fn inlines its state machine into
the caller's. Boxing the asynchronous arms takes a notification from 2306 B
to 146 B (with a subscriber, 2850 B to 691 B), and a 500-notification drain
from 2850 B to 690 B per item, so a reconnect draining 500 of them sheds
~1.1 MB of transient allocation. The allocation *count* is unchanged and
wall time moved 540 -> 471 ns median, inside this fixture's run-to-run
spread: this is allocation volume, not latency.

`handle_receipt_inline` ran its `has_handler_for(EventKind::Receipt)` bail
only after parsing the whole stanza — `from`, `id`, `participant`,
`recipient`, `participant_pn`, `offline`, `t`, and the feature-incapable
child scan. It now reads `type` first and bails on that. Gating on the raw
type is behaviour-preserving because `downgrade_for_feature_incapable` only
ever rewrites Delivered to Sent and so can never produce the Retry the gate
lets through; a new test in `wacore/src/stanza/receipt.rs` pins that over
every variant. This path runs inline on the read loop exactly when nothing
is subscribed, so it is read-loop time: 383 -> 345 ns median per ignored
receipt, small and at the edge of the noise.

`CoreEventBus::dispatch_with(kind, || event)` (purely additive) builds the
payload only when a handler is registered. Applied where the payload is more
than a couple of `Jid` clones: `GroupUpdate` (a whole
`GroupNotificationAction` with its participant `Vec`, once per action per
notification), `DeviceListUpdate`, `IdentityChange`,
`BusinessStatusUpdate`, `MexNotification` and `ContactNumberChanged`.
Cheap producers keep plain `dispatch`. It does not show up in the benchmark
rows above — none of their stanzas reaches those sites — because the payoff
is proportional to the payload and lands on a consumer that does not
subscribe to that kind.

The guard: `benches/inbound_stanza` (feature `bench-harness`, so CodSpeed's
client shard picks it up) covers `<receipt>`, `<presence>`,
`<notification>` and `<ack>` with and without a subscriber, plus a
500-stanza burst per kind, reporting bytes and allocations per stanza
through divan's `AllocProfiler`. What that cannot gate is the size of a
single block, which is the whole regression here, so the ceiling lives in a
unit test instead: `notification_future_is_not_sized_for_every_arm` asserts
the largest single block a notification allocates stays under 1 KiB. It was
verified to fail, reporting 2272 bytes, with the arms un-boxed. The crate's
counting test allocator gained a max-block tracker to support it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172fpxasGTrouFyYH5UGmjN
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Team

Run ID: d11846f6-d499-41d6-aca8-e4d8b8df3c80

📥 Commits

Reviewing files that changed from the base of the PR and between 39e4fe1 and c85c1ff.

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

Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review.


📝 Summary

Summary by CodeRabbit

  • Performance

    • Reduced overhead when processing inbound notifications, especially when no handlers are interested.
    • Improved allocation efficiency during notification handling.
    • Receipt filtering now occurs earlier, avoiding unnecessary parsing when receipt handling is disabled.
  • Bug Fixes

    • Preserved correct retry classification across all receipt types.
    • Ensured notification events continue to dispatch with the correct event types.
  • Testing

    • Expanded coverage for receipt handling and inbound stanza processing.
    • Added benchmarks for receipt, presence, notification, and acknowledgment traffic.

Walkthrough

The PR adds lazy event construction, boxes asynchronous notification handlers, moves receipt filtering earlier, and adds allocation checks. It also adds a receive harness and Divan benchmarks for receipt, presence, notification, and ack stanzas.

Changes

Inbound stanza flow

Layer / File(s) Summary
Lazy event dispatch and allocation control
wacore/src/types/events.rs, src/handlers/notification/*, src/lib.rs
CoreEventBus::dispatch_with skips event construction when no handler is interested. Notification handlers use this API and box asynchronous arms. Allocation metrics and a regression test track the largest allocation block.
Early receipt filtering
src/receipt.rs, wacore/src/stanza/receipt.rs
Receipt subscriber gating occurs before optional receipt data parsing. Tests verify receipt retry classification across all receipt types.
Non-message stanza benchmark coverage
src/bench_support.rs, benches/inbound_stanza.rs, Cargo.toml
The receive harness counts stanza events, processes individual stanzas and bursts, and benchmarks subscribed and unsubscribed receipt, presence, notification, and ack handling.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to c85c1

This change moves receipt filtering earlier while preserving retry handling and missing-ID reporting, reducing unnecessary inbound processing without an identified merge-readiness risk.

Sequence Diagram(s)

sequenceDiagram
  participant NotificationHandler
  participant CoreEventBus
  participant EventHandler
  NotificationHandler->>CoreEventBus: dispatch_with(EventKind, builder)
  CoreEventBus->>CoreEventBus: Check handler interest
  CoreEventBus->>EventHandler: Dispatch built Event
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 86.27% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 51 functions across 11 files.
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.
Title check ✅ Passed The title clearly identifies the performance change to inbound non-message stanza handling and the added benchmark.
Description check ✅ Passed The description is directly related to the changeset and explains the allocation reductions, receipt gating, lazy event construction, benchmarks, and verification.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/whatsapp-rust-perf-inbound

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.

@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown

Greptile Summary

The PR reduces allocation overhead on inbound non-message stanza paths while preserving event delivery behavior.

  • Boxes asynchronous notification-handler arms to keep the outer handler future small.
  • Defers construction of selected event payloads until a matching subscriber exists.
  • Moves receipt type and required-ID parsing ahead of the subscriber gate.
  • Adds allocation regression coverage and inbound stanza benchmarks.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported missing-ID diagnostic is now evaluated before the receipt subscriber gate.

Important Files Changed

Filename Overview
src/receipt.rs Moves receipt type and missing-ID validation before the no-subscriber gate, completing the previously requested diagnostic fix.
src/handlers/notification/mod.rs Boxes individual asynchronous notification arms to prevent their state machines from inflating the outer async-trait future.
wacore/src/types/events.rs Adds subscriber-gated event construction through dispatch_with.
src/handlers/notification/groups.rs Uses lazy event construction for group and MEX notification payloads.
src/handlers/notification/device.rs Uses lazy event construction for identity and device-list updates.
benches/inbound_stanza.rs Adds allocation-focused benchmarks for receipts, presence, notifications, acknowledgements, and burst processing.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Inbound stanza] --> B{Stanza type}
    B -->|Receipt| C[Parse type and required ID]
    C --> D{Retry or Receipt subscriber?}
    D -->|No| E[Return without full payload parse]
    D -->|Yes| F[Parse and process receipt]
    B -->|Notification| G[Select notification type]
    G --> H[Box only the selected async arm]
    H --> I{Matching event subscriber?}
    I -->|No| J[Skip event payload construction]
    I -->|Yes| K[Build and dispatch event]
Loading

Reviews (2): Last reviewed commit: "fix(receipt): keep the missing-id warnin..." | Re-trigger Greptile

Comment thread src/receipt.rs
A receipt without an `id` is a protocol error worth its warning whether or
not anything is subscribed, and the early gate had moved that check behind
itself. `id` is now the one other attribute read before the gate: a single
inline copy, so the ignored-receipt path still parses two attributes and
not the stanza.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172fpxasGTrouFyYH5UGmjN
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.41 MiB 10.41 MiB +2.69 KiB (+0.03%) 🔺
bin .text 8.34 MiB 8.35 MiB +7.94 KiB (+0.09%) 🔺
bin allocated (text+data+bss) 10.41 MiB 10.41 MiB +3.22 KiB (+0.03%) 🔺
llvm-lines wacore 568,301 568,301 0
llvm-lines wacore copies 18,649 18,649 0
llvm-lines whatsapp-rust lib 789,920 790,451 +531 (+0.07%) 🔺
llvm-lines whatsapp-rust lib copies 25,148 25,172 +24 (+0.10%) 🔺
deps crates (Cargo.lock) 468 468 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.91 MiB 1.92 MiB +12.27 KiB (+0.63%) 🔺
.text wacore 746.41 KiB 742.18 KiB -4.23 KiB (-0.57%) 🔽
.text wacore_binary 81.60 KiB 81.60 KiB 0
.text wacore_libsignal 191.12 KiB 191.12 KiB 0
.text wacore_appstate 28.34 KiB 28.34 KiB 0
.text wacore_noise 20.92 KiB 20.92 KiB 0
.text waproto 1.79 MiB 1.79 MiB 0
.text whatsapp_rust_sqlite_storage 566.83 KiB 566.83 KiB 0
.text whatsapp_rust_tokio_transport 40.57 KiB 40.57 KiB 0
.text whatsapp_rust_ureq_http_client 12.75 KiB 12.75 KiB 0
.text std 1.01 MiB 1.01 MiB -67 B (-0.01%) 🔽
.text other deps 1.94 MiB 1.94 MiB 0
Top movers (cargo-bloat attribution)
Crate main PR Δ
whatsapp_rust 1.91 MiB 1.92 MiB +12.27 KiB (+0.63%)
wacore 746.41 KiB 742.18 KiB -4.23 KiB (-0.57%)

Baseline: 803c5e687 (latest main run) · Head: 4c06ce4fb · Graphs

@jlucaso1
jlucaso1 merged commit bdf8215 into main Sep 3, 2026
56 of 58 checks passed
@jlucaso1
jlucaso1 deleted the claude/whatsapp-rust-perf-inbound branch September 3, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants