Skip to content

perf!: Arc<Event> event bus to eliminate deep clones on dispatch - #515

Merged
jlucaso1 merged 8 commits into
mainfrom
perf/arc-event-bus
Apr 12, 2026
Merged

perf!: Arc<Event> event bus to eliminate deep clones on dispatch#515
jlucaso1 merged 8 commits into
mainfrom
perf/arc-event-bus

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • CoreEventBus::dispatch takes Event by value, wraps in Arc::new once internally, shares via Arc::clone to all handlers
  • Releases the handler RwLock before calling handlers (prevents deadlock if a handler calls add_handler)
  • Skips Arc allocation entirely when no handlers are registered (early return)
  • EventHandler::handle_event receives Arc<Event> instead of &Event
  • BotEventHandler moves the Arc into the spawned task — zero clone
  • ChannelEventHandler sends the Arc through the channel — zero clone
  • on_event closure receives Arc<Event>; users match with match &*event { ... }
  • Added Event::as_message() and Event::message_text() helpers
  • Added MessageContext::from_parts() (direct construction) and from_event() (convenience wrapper)
  • TestEventCollector stores Arc<Event> instead of deep-cloning events
  • Removed dead SharedData<T> type, dead handle_message function, dead commented-out Event::Qr dispatch
  • Updated LazyConversation docs — Arc dispatch gives parse-once across handlers for free
  • Extracted duplicated TestEventCollector to shared test_utils
  • Fixed stale doc examples to use &*event matching

Why

The old bus dispatched &Event and every handler called event.clone() to own it for async tasks. Event::Message(Box<wa::Message>, MessageInfo) is expensive to deep-clone — the protobuf message has 40+ nested fields with Box allocations.

With Arc<Event>, the bus creates one shared allocation per dispatch. Each handler gets a cheap Arc::clone (atomic increment, zero bytes allocated).

Lab results (experiments/heaptrack-impact-lab)

Scenario Alloc reduction Bytes reduction Time reduction
Core bus, 1 handler -94.1% -98.8% -77.3%
Core bus, 3 handlers -94.1% -98.8% -87.5%

Breaking changes

  • EventHandler::handle_event takes Arc<Event> instead of &Event
  • ChannelEventHandler::new() returns Receiver<Arc<Event>> instead of Receiver<Event>
  • on_event closure takes Arc<Event> instead of Event — match with &*event
  • CoreEventBus::dispatch takes Event by value instead of &Event

Migration guide

// EventHandler impl
// Before:
fn handle_event(&self, event: &Event) {
    let owned = event.clone();
}
// After:
fn handle_event(&self, event: Arc<Event>) {
    let shared = Arc::clone(&event); // cheap — no deep copy
}

// on_event closure
// Before:
.on_event(|event, client| async move {
    match event {
        Event::Message(msg, info) => { /* msg/info owned */ }
    }
})
// After:
.on_event(|event, client| async move {
    match &*event {
        Event::Message(msg, info) => {
            // msg/info are references — zero clone for read-only handlers
            // Use from_parts when you need MessageContext:
            let ctx = MessageContext::from_parts(msg, info, client);
        }
    }
})

// ChannelEventHandler
// Before:
while let Ok(event) = rx.recv().await {
    match event { ... }
}
// After:
while let Ok(event) = rx.recv().await {
    match &*event { ... }
}

// MessageContext — still constructible from any source
let ctx = MessageContext {
    message: Box::new(msg_from_db),
    info: info_from_db,
    client,
};

Test plan

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

CoreEventBus::dispatch now takes Event by value, wraps it in Arc once
internally, and shares via Arc::clone to all handlers. Skips the Arc
allocation entirely when no handlers are registered.

EventHandler::handle_event receives Arc<Event> instead of &Event.
BotEventHandler moves the Arc into the spawned task — no clone.
ChannelEventHandler sends the Arc through the channel — no clone.
on_event closure receives Arc<Event>; users match with &*event.

MessageContext keeps owned fields so users can construct it from any
source (events, DB, custom data). Users who need MessageContext clone
explicitly in their handler; users who only read/filter events pay
nothing.

Lab results (experiments/heaptrack-impact-lab):
- Core bus, 1 handler: -94.1% allocs, -98.8% bytes, -77.3% time
- Core bus, 3 handlers: -94.1% allocs, -98.8% bytes, -87.5% time
@coderabbitai

coderabbitai Bot commented Apr 12, 2026

Copy link
Copy Markdown

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

Core event ownership changed: CoreEventBus::dispatch now takes Event by value, wraps it once in Arc, and handlers receive Arc. Call sites, handler signatures, bot APIs, channels, and many tests were updated to use Arc and borrow matches (&*event).

Changes

Cohort / File(s) Summary
Core Event System
wacore/src/types/events.rs
Removed SharedData<T>; CoreEventBus::dispatch(&Event)dispatch(Event) (wraps once in Arc); EventHandler::handle_event(&Event)handle_event(Arc<Event)); added Event::as_message(&self).
Event Producers (dispatch sites)
src/client.rs, src/client/sessions.rs, src/message.rs, src/pair.rs, src/pair_code.rs, src/pdo.rs, src/features/chat_actions.rs, src/history_sync.rs, src/receipt.rs
Replaced many event_bus.dispatch(&Event::...) call sites with event_bus.dispatch(Event::...) (owned dispatch).
Handlers & Notification
src/handlers/notification.rs, src/handlers/presence.rs, src/handlers/ib.rs
Updated handlers to dispatch owned Event values and to work with Arc-based event flow; tests adapted to use centralized TestEventCollector.
Bot API & Examples
src/bot.rs, src/main.rs, examples/benchmark.rs
Handler callback type changed to accept Arc<Event>; added MessageContext::from_parts/from_event; example/main updated to match on &*event and build contexts via helpers; ping/media gating added in example.
Channeling & Test Utilities
wacore/src/types/events.rs, src/test_utils.rs, tests/e2e/src/lib.rs
ChannelEventHandler and test channels now use async_channel::Receiver<Arc<Event>>; added TestEventCollector (stores Arc<Event>); TestClient/event helpers updated to return Arc<Event>.
E2E & Unit Tests
tests/e2e/tests/*, many test files
Tests updated to receive/inspect Arc<Event>: pattern matches now use &*event or event.as_message(); clones added for previously-moved inner fields.
Small adjustments & examples
examples/benchmark.rs, src/main.rs, other small files
Adjusted local matching to borrow event (match &*event) and use MessageContext::from_parts rather than moving fields out of Event.

Sequence Diagram(s)

sequenceDiagram
participant Producer as Producer\n(Module / Client)
participant Core as CoreEventBus
participant HandlerA as Handler A
participant HandlerN as Handler N

Producer->>Core: dispatch(Event)
note right of Core: CoreEventBus::dispatch(event: Event)\nlet shared = Arc::new(event)
Core->>HandlerA: deliver(shared.clone())
Core->>HandlerN: deliver(shared.clone())
HandlerA->>HandlerA: handle_event(shared)
HandlerN->>HandlerN: handle_event(shared)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐇 I wrap each Event in Arc so neat,
Hopped from producer to every seat,
No moves stolen, just shared and cloned,
I nibble bugs while code is honed,
A carrot hop — the pipeline’s complete! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'perf!: Arc event bus to eliminate deep clones on dispatch' directly summarizes the main change: wrapping Event in Arc to avoid deep clones during event dispatch.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/arc-event-bus

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review carefully, check how we can be dry, also more performatic and also improve the ergonomics of the API

@claude

claude Bot commented Apr 12, 2026

Copy link
Copy Markdown

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


Review: Arc<Event> event bus

  • Read core event bus changes (wacore/src/types/events.rs)
  • Read handler implementations (src/bot.rs, src/client.rs)
  • Read dispatch sites (handlers, features, pair, receipt, etc.)
  • Read e2e test changes
  • Analyze DRY opportunities
  • Analyze performance improvements
  • Analyze API ergonomics

The core design is solid — wrapping once in Arc::new at dispatch and sharing via Arc::clone is the right call. The early return on empty handlers is a nice touch. Here's the detailed review across all three axes:


DRY

1. TestEventCollector is duplicated verbatim in two files

src/receipt.rs:217-228 and src/handlers/notification.rs:1440-1451 have identical TestEventCollector structs with the same EventHandler impl. Extract to a shared test utility (e.g. src/test_utils.rs or a #[cfg(test)] module re-exported for in-crate tests).

Fix this →

2. MessageContext construction boilerplate repeated at every call site

Both src/main.rs:107-111 and examples/benchmark.rs:52-56 have:

let ctx = MessageContext {
    message: msg.clone(),
    info: info.clone(),
    client,
};

This is the tax every on_event user pays for Event::Message. Add a constructor:

impl MessageContext {
    pub fn new(msg: &wa::Message, info: &MessageInfo, client: Arc<Client>) -> Self {
        Self {
            message: Box::new(msg.clone()),
            info: info.clone(),
            client,
        }
    }
}

Or even better, one that works directly with the Arc'd event (see ergonomics section below).

3. SharedData<T> is dead code

wacore/src/types/events.rs:17-40SharedData<T> is defined but never used by any Event variant or anywhere else in the codebase. With events now wrapped in Arc<Event>, individual field wrapping in Arc is no longer needed. Remove it.

Fix this →


Performance

1. LazyConversation doc comment is stale — you're getting a free win

The comment at wacore/src/types/events.rs:55-58 says:

Multi-handler note: if the event bus fans out to N handlers, each clone parses independently.

This is no longer true. With Arc<Event>, all handlers share the same LazyConversation instance. Since parsed is OnceLock (which is Sync), the first handler to call .get() parses, and all others get the cached result for free. The comment should reflect this — it's a bonus win from this PR.

Also, line 51-52 says "Clones get their own OnceLock ... because the original is dropped right after event dispatch" — this assumption is also stale since the Arc keeps the original alive across all handlers.

Fix this →

2. Everything else looks correct

  • dispatch wraps once, Arc::clone for each handler — optimal.
  • BotEventHandler::handle_event moves the Arc into the spawned task — zero clone.
  • ChannelEventHandler sends the Arc through the channel — zero clone.
  • The has_handlers() guard is useful for callers that do expensive prep before dispatch.

API Ergonomics

1. (Major) Add MessageContext::from_event to eliminate the most common boilerplate

The Event::Message variant is by far the most common case users handle. Right now every user writes:

match &*event {
    Event::Message(msg, info) => {
        let ctx = MessageContext {
            message: msg.clone(),
            info: info.clone(),
            client,
        };
        // ...
    }
}

Add a constructor that does this in one call:

impl MessageContext {
    /// Extract from an Arc<Event> if it's a Message variant. Clones only the needed fields.
    pub fn from_event(event: &Event, client: Arc<Client>) -> Option<Self> {
        if let Event::Message(msg, info) = event {
            Some(Self {
                message: msg.clone(),
                info: info.clone(),
                client,
            })
        } else {
            None
        }
    }
}

Then the usage becomes:

.on_event(|event, client| async move {
    if let Some(ctx) = MessageContext::from_event(&event, client.clone()) {
        handle_message(&ctx).await;
    }
    // handle other events with match &*event { ... }
})

This keeps MessageContext constructible from any source (the PR's stated goal) while making the golden path clean.

Fix this →

2. (Major) E2E test double-match is painful — needs helper methods

tests/e2e/tests/offline_groups.rs:152-158 and :443-449 have this pattern:

Ok(ref event) if matches!(**event, Event::Message(_, _)) => {
    if let Event::Message(msg, _) = &**event {
        // finally can use msg
    }
}

This double-match (guard + inner if-let) exists because you can't destructure through Arc in a match arm wrapped in Ok(...). It's repeated multiple times across the offline test files.

Options to fix:

  • Option A: Add TestClient::wait_for_message(timeout) -> Result<(Box<Message>, MessageInfo)> that handles the unwrap internally. Most tests only care about the message content anyway.
  • Option B: Add Event::as_message(&self) -> Option<(&Box<wa::Message>, &MessageInfo)> helper to avoid the match entirely.
  • Option C: Both. as_message() is useful everywhere; wait_for_message() reduces e2e boilerplate.

Fix this →

3. (Minor) wait_for_event predicate design is good

The predicate: FnMut(&Event) taking &Event (via Arc deref) is the right ergonomic choice — predicates don't need to think about Arc at all. Well done.

4. (Minor) Consider Event::message_text() convenience

Many e2e tests and the wait_for_text helper dig through Event::Message(msg, _) -> msg.conversation. A small helper could clean this up:

impl Event {
    pub fn message_text(&self) -> Option<&str> {
        if let Event::Message(msg, _) = self {
            msg.conversation.as_deref()
        } else {
            None
        }
    }
}

Summary

Area Verdict
Core Arc<Event> design Correct and clean
Performance win Real — lab numbers match the design
DRY TestEventCollector duplicated; SharedData dead code; MessageContext construction repeated
Ergonomics MessageContext::from_event() and Event::as_message() would eliminate the most common boilerplate and make the double-match test patterns unnecessary
Stale docs LazyConversation multi-handler comment is now wrong in the user's favor

The PR is a solid perf improvement. The suggestions above are refinements, not blockers — the from_event constructor and as_message helper are the highest-value additions that would make the breaking change feel like an upgrade rather than a tax.

@github-actions

github-actions Bot commented Apr 12, 2026

Copy link
Copy Markdown

Benchmark Results

59 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,879 3,879 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 11,851 11,851 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,398 43,398 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 68,814 68,814 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,781 76,781 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,214 2,214 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,939 5,939 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 179,615 179,616 -0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 193,204 193,123 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 893,733 893,733 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 995,047 995,927 -0.1%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,537,873 1,537,871 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,783,319 2,783,890 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 10,328,766 10,296,447 +0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 49,247,919 49,255,112 -0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,650,444 12,548,658 +0.8%
binary_benchmark::marshal_group::bench_marshal_allocating 95,585 95,585 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 95,618 95,618 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 113,974 113,974 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 102,895 102,895 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 95,685 95,685 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 15,762 15,762 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 15,806 15,806 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 17,592 17,592 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 533,122 533,122 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 532,688 532,688 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 534,046 534,046 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 13,413,012 13,413,012 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 13,357,245 13,357,245 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 26,652,528 26,652,528 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,498 2,498 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 38,500 38,500 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 773 773 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 556,090 556,090 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 5,039 5,039 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 7,484 7,484 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 90,824 90,824 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 7,511 7,511 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 90,860 90,860 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 8,838 8,838 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 104,670 104,670 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 475,970 475,970 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 13,493 13,493 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,367,438 17,114,570 +1.5%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 162,069 162,069 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,512,660 5,512,660 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 163,085 163,085 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 298,493 298,493 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 713,231 713,231 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,507,481 12,585,959 -0.6%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,574,959 27,578,826 -0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 3,467,011 3,467,011 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 124,281,993 126,557,903 -1.8%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,970 46,970 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,119,851 5,119,851 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 299,173 299,173 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,247,117 14,247,117 +0.0%
No significant changes detected.

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

Caution

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

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

106-113: 🧹 Nitpick | 🔵 Trivial

Consider cloning msg/info only when a response path is taken.

MessageContext construction currently deep-clones every incoming message. In this hot path, delaying clones until ping/media-ping branches helps retain the event-bus allocation win.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main.rs` around lines 106 - 113, Event::Message currently deep-clones msg
and info up-front when constructing MessageContext; instead avoid the allocation
by not cloning unless you need an owned response path: pass references (&msg,
&info) into handle_message (or change MessageContext to hold references) and
only clone into an owned MessageContext when entering the ping/media-ping
response branches; adjust Event::Message handling and the MessageContext
construction so clones happen inside the ping/media-ping branches (or inside
handle_message when those branches are taken), keeping symbols Event::Message,
MessageContext, handle_message, msg and info as the touchpoints.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/benchmark.rs`:
- Around line 50-55: Event::Message currently clones msg and info
unconditionally when constructing MessageContext, which wastes work on the hot
path; change the code so msg.clone() and info.clone() are moved inside the
branch that actually needs owned data (e.g., the "ping" handling branch) and
only create MessageContext (and perform the clones) there. Locate the
Event::Message match arm and the MessageContext construction and defer cloning
of msg/info until you enter the branch that uses owned values (check the "ping"
branch), replacing the unconditional MessageContext creation with a branch-local
creation to preserve Arc/event-bus performance.

In `@src/pair.rs`:
- Around line 119-120: Remove the obsolete commented-out dispatch of Event::Qr
to reduce noise: delete the commented lines referencing
client.core.event_bus.dispatch(Event::Qr(Qr { codes })) and the accompanying
comment noting "We no longer dispatch the raw Event::Qr" so only the active,
current event handling remains; look for the Event::Qr and Qr symbols and the
client.core.event_bus.dispatch call to locate the exact commented block to
remove.

In `@wacore/src/types/events.rs`:
- Around line 229-237: The dispatch method is holding the RwLock read guard
while calling user handlers, which can block add_handler or deadlock if handlers
try to write to the bus; modify dispatch (the dispatch function that reads
self.handlers) to clone or collect the handler references (e.g., into a Vec or
Arc list) while the read lock is held, then drop the read guard before creating
the Arc<Event> and iterating to call handler.handle_event(Arc::clone(&event));
ensure you still share the same Arc<Event> instance with all handlers and only
call handle_event after the lock has been released.

---

Outside diff comments:
In `@src/main.rs`:
- Around line 106-113: Event::Message currently deep-clones msg and info
up-front when constructing MessageContext; instead avoid the allocation by not
cloning unless you need an owned response path: pass references (&msg, &info)
into handle_message (or change MessageContext to hold references) and only clone
into an owned MessageContext when entering the ping/media-ping response
branches; adjust Event::Message handling and the MessageContext construction so
clones happen inside the ping/media-ping branches (or inside handle_message when
those branches are taken), keeping symbols Event::Message, MessageContext,
handle_message, msg and info as the touchpoints.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ecc5f107-2bff-4661-aafb-43624d5fa57b

📥 Commits

Reviewing files that changed from the base of the PR and between 61d6ed2 and 70c52b5.

📒 Files selected for processing (28)
  • examples/benchmark.rs
  • src/bot.rs
  • src/client.rs
  • src/client/sessions.rs
  • src/features/chat_actions.rs
  • src/handlers/ib.rs
  • src/handlers/notification.rs
  • src/handlers/presence.rs
  • src/history_sync.rs
  • src/main.rs
  • src/message.rs
  • src/pair.rs
  • src/pair_code.rs
  • src/pdo.rs
  • src/receipt.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/app_state.rs
  • tests/e2e/tests/media.rs
  • tests/e2e/tests/messaging.rs
  • tests/e2e/tests/newsletter.rs
  • tests/e2e/tests/offline_groups.rs
  • tests/e2e/tests/offline_messages.rs
  • tests/e2e/tests/offline_receipts.rs
  • tests/e2e/tests/prekey_sessions.rs
  • tests/e2e/tests/profile.rs
  • tests/e2e/tests/profile_picture.rs
  • tests/e2e/tests/session_reuse.rs
  • wacore/src/types/events.rs
👮 Files not reviewed due to content moderation or server errors (3)
  • src/handlers/notification.rs
  • src/bot.rs
  • tests/e2e/src/lib.rs

Comment thread examples/benchmark.rs Outdated
Comment thread src/pair.rs Outdated
Comment thread wacore/src/types/events.rs
- Remove dead SharedData<T> (superseded by Arc<Event>)
- Update LazyConversation comment (Arc dispatch gives parse-once for free)
- Add Event::as_message() helper for clean destructuring
- Add MessageContext::from_event() to eliminate construction boilerplate
- Extract duplicated TestEventCollector to test_utils
- Simplify double-match patterns in e2e tests with as_message()
- Defer MessageContext cloning until a handler branch actually needs it
  (benchmark.rs: only clone on "ping", main.rs: only clone on ping/media)
- Release RwLock before calling handlers to prevent deadlock if a handler
  calls add_handler
- Remove commented-out Event::Qr dispatch in pair.rs
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude please review carefully again. I want your opinions about the changes, check the claims, and also suggest things that make sense.

@claude

claude Bot commented Apr 12, 2026

Copy link
Copy Markdown

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


Review: Arc<Event> event bus (round 2)

  • Read all changed files and understand the full diff
  • Verify PR claims (allocation reduction, zero-clone paths)
  • Check for correctness issues (lock safety, soundness)
  • Evaluate DRY / code duplication
  • Evaluate API ergonomics
  • Suggest improvements that make sense
  • Final review summary

I read all 29 changed files and the full diff against origin/main. Here's my assessment across every axis you asked about.


Claim verification

"Arc::new once, Arc::clone to all handlers"Confirmed. dispatch() at wacore/src/types/events.rs:190-203 takes Event by value, wraps in Arc::new, iterates handlers with Arc::clone. Correct.

"BotEventHandler moves the Arc into the spawned task — zero clone"Confirmed. src/bot.rs:101-113event is moved directly into handler_clone(event, client_clone).await. No event.clone() anywhere. The old code had let event_clone = event.clone() — that's gone.

"ChannelEventHandler sends the Arc through the channel — zero clone"Confirmed. wacore/src/types/events.rs:158-160self.tx.try_send(event) moves the Arc. No clone. Previously this was event.clone().

"Skips Arc allocation entirely when no handlers are registered"Confirmed. The if handlers.is_empty() { return; } check at line 196 exits before Arc::new(event). Good.

"LazyConversation parse-once across handlers"Confirmed. OnceLock is Sync, so multiple handler threads calling .get() on the same LazyConversation through Arc<Event> will race-free: one thread initializes, others wait and get the cached result. The updated doc comment (lines 15-19) correctly describes this. This is a real bonus win from this PR.

"Releases the handler RwLock before calling handlers"Confirmed. Line 191-195 acquires read lock, .clone()s the Vec<Arc<dyn EventHandler>>, drops the guard, then iterates. This was addressed in commit 15c2a7e (review nitpick fix). The Vec<Arc<...>> clone is cheap — typically 1-2 handlers in production, each clone is just an Arc refcount bump.

Lab results (-94.1% allocations, -98.8% bytes)Plausible. The old path cloned Event (which contains Box<wa::Message> with 40+ nested fields) per handler. The new path does one Arc::new + N atomic increments. The magnitude of reduction is consistent with replacing deep protobuf clones with pointer sharing.


Correctness

Lock safety — Good. The handlers.clone() pattern is the standard approach for iterating while allowing concurrent add_handler calls. The clone cost is negligible (1-2 Arc refcount bumps in practice).

try_send on unbounded channelChannelEventHandler uses async_channel::unbounded(), so try_send will only fail if the receiver is dropped. The let _ = discard is intentional (fire-and-forget semantics). Fine.

TestEventCollector clones out of Arcsrc/test_utils.rs:137 does (*event).clone() to store owned Event values in the test collector. This is correct for tests — they need owned values for assertions. The deep clone cost doesn't matter in test code.

No soundness issues found. The Arc<Event> is Send + Sync (Event is Clone + Send), so sharing across handler threads is safe.


DRY

TestEventCollector — fixed. Previously duplicated in src/receipt.rs and src/handlers/notification.rs. Now consolidated in src/test_utils.rs:128-148 and imported from both. Good.

SharedData<T> — removed. Dead code that was superseded by Arc<Event>. Correct removal.

Dead Event::Qr comment — removed. The stale // We no longer dispatch the raw Event::Qr comment in src/pair.rs was deleted in commit 15c2a7e. Good.

MessageContext::from_event — added. src/bot.rs:39-46 eliminates the repeated match-and-construct pattern. Both src/main.rs:110 and examples/benchmark.rs:55 use it. Clean.


API ergonomics

Event::as_message() is well-designed. Returns Option<(&wa::Message, &MessageInfo)> — borrows through the Arc without cloning. Used effectively in offline_groups.rs:152 to avoid the old double-match pattern.

MessageContext::from_event() is the right abstraction. Takes &Event (not Arc<Event>), returns Option<Self>, clones only when a Message variant is found. The golden path in src/main.rs:110 is now a one-liner.

on_event closure ergonomics are reasonable. match &*event { ... } is the standard Rust pattern for matching through smart pointers. Users who only need references pay zero cost. Users who need owned data can clone selectively.

wait_for_event predicate takes &Event — smart choice. The FnMut(&Event) -> bool signature at tests/e2e/src/lib.rs:294 means predicate closures don't need to think about Arc at all. The deref happens inside wait_for_event.


Suggestions

1. (Minor) Consider Event::message_text() for the extremely common "get text content" pattern

Many e2e tests and both examples ultimately just need the message text. This is a 4-line helper that would clean up a lot of matching:

impl Event {
    pub fn message_text(&self) -> Option<&str> {
        let (msg, _) = self.as_message()?;
        msg.conversation.as_deref()
    }
}

This would simplify wait_for_text in tests/e2e/src/lib.rs:317-323 and the offline_groups.rs event collection loops. Not a blocker — just a natural extension of the as_message() pattern.

2. (Minor) offline_groups.rs:152-154 has an unwrap() that could use as_message() more cleanly

Current code:

Ok(ref event) if event.as_message().is_some() => {
    let (msg, _) = event.as_message().unwrap();  // can't fail, but reads awkwardly

This could be:

Ok(ref event) if let Some((msg, _)) = event.as_message() => {

If if let guards are stable in your MSRV, this avoids the redundant as_message() call and the .unwrap(). Same applies to line 441-442.

3. (Nit) The ChannelEventHandler doc example uses matches!(&*event, ...) which is correct

The doc example at wacore/src/types/events.rs:144 is properly updated. Just noting it's consistent.


What I would NOT change

  • The handlers.clone() approach is correct. An alternative (snapshot into Arc<Vec<...>> to avoid per-dispatch Vec allocation) would be premature — handler registration is a one-time setup cost, not a hot path.
  • The try_send semantics on unbounded channel are fine. Switching to .send().await would require handle_event to be async, which is a much larger change for no real benefit.
  • The breaking change surface is appropriate for the perf gain. The migration guide in the PR description is clear and complete.

Summary

This is a clean, well-executed performance optimization. All claims check out. The previous review's concerns (lock safety, deferred cloning, dead code, DRY) have been addressed in follow-up commits. The Event::as_message() + MessageContext::from_event() additions make the breaking change feel like an upgrade. The e2e test patterns work but could benefit from minor polish (suggestions 1-2 above). No blockers.


- Add Event::message_text() for the common "get text content" pattern
- Use if-let match guards to eliminate redundant as_message() + unwrap()
- Simplify wait_for_text with message_text()

@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

♻️ Duplicate comments (2)
examples/benchmark.rs (1)

50-55: ⚠️ Potential issue | 🟠 Major

Don't rebuild MessageContext for every message in the benchmark path.

This eagerly clones wa::Message/MessageInfo before you know whether the message is "ping", so the benchmark starts measuring the deep-clone work that Arc<Event> is supposed to remove.

♻️ Suggested hot-path fix
                 match &*event {
                     Event::Message(_, _) => {
-                        let ctx = MessageContext::from_event(&event, client).unwrap();
-
-                        if let Some(text) = ctx.message.text_content()
-                            && text == "ping"
-                        {
+                        if let Some((msg, info)) = event.as_message()
+                            && msg.text_content() == Some("ping")
+                        {
+                            let ctx = MessageContext {
+                                message: Box::new(msg.clone()),
+                                info: info.clone(),
+                                client,
+                            };
                             info!("Received text ping, sending pong...");
 
                             let pong_text = format!("pong {}", ctx.info.id);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/benchmark.rs` around lines 50 - 55, The benchmark path is
constructing MessageContext (via MessageContext::from_event) before verifying
the message text, which forces deep clones of wa::Message/MessageInfo; change
the hot path so you first inspect the Event::Message payload (borrow the message
or use a non-cloning accessor) and check ctx.message.text_content() == "ping"
(or equivalent) before calling MessageContext::from_event; only construct
MessageContext after the text equals "ping" to avoid measuring the clone
work—look for Event::Message, MessageContext::from_event, and
ctx.message.text_content() to make this change.
wacore/src/types/events.rs (1)

190-197: ⚠️ Potential issue | 🔴 Critical

Release the handler lock before invoking user code.

dispatch() still calls handlers while the RwLock read guard is alive. Any handler path that registers/removes handlers can stall or deadlock the bus; collect the Arc<dyn EventHandler> values first, drop the guard, then iterate.

🔧 Minimal fix
 pub fn dispatch(&self, event: Event) {
-    let handlers = self.handlers.read().expect("RwLock should not be poisoned");
-    if handlers.is_empty() {
-        return;
-    }
+    let handlers = {
+        let handlers = self.handlers.read().expect("RwLock should not be poisoned");
+        if handlers.is_empty() {
+            return;
+        }
+        handlers.clone()
+    };
     let event = Arc::new(event);
-    for handler in handlers.iter() {
+    for handler in handlers {
         handler.handle_event(Arc::clone(&event));
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/events.rs` around lines 190 - 197, In dispatch, avoid
holding the RwLock read guard while invoking user code: acquire the read lock on
self.handlers, collect/cloned the Arc<dyn EventHandler> entries into a local Vec
(e.g., Vec<Arc<dyn EventHandler>>), then drop the read guard before iterating;
after the guard is released iterate over the collected handlers and call
EventHandler::handle_event(Arc::clone(&event)) so registration/removal inside
handlers cannot deadlock the bus.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bot.rs`:
- Around line 39-46: Add a message-specific constructor to avoid the
downcast+Option from_event() forces callers to use when they already have an
Event::Message; implement a new pub fn from_message(message: &Message, info:
&MessageInfo, client: Arc<Client>) -> Self (or named from_parts) that constructs
and returns Self directly by boxing/cloning the provided message and cloning
info into the same fields used in from_event (mirrors message:
Box::new(msg.clone()), info: info.clone(), client), leaving from_event() as the
convenience wrapper that calls this new constructor after as_message().

In `@src/test_utils.rs`:
- Around line 127-147: The collector currently stores owned Event clones; change
TestEventCollector::events field to Mutex<Vec<Arc<Event>>> and update impl
EventHandler::handle_event to push a clone of the Arc<Event> (i.e.,
self.events.lock()...push(Arc::clone(&event))) instead of dereferencing and
cloning the Event; also change TestEventCollector::events() to return
Vec<Arc<Event>> (by cloning the Arc entries under the lock) so tests can observe
the shared Arc/LazyConversation without deep-cloning payloads.

---

Duplicate comments:
In `@examples/benchmark.rs`:
- Around line 50-55: The benchmark path is constructing MessageContext (via
MessageContext::from_event) before verifying the message text, which forces deep
clones of wa::Message/MessageInfo; change the hot path so you first inspect the
Event::Message payload (borrow the message or use a non-cloning accessor) and
check ctx.message.text_content() == "ping" (or equivalent) before calling
MessageContext::from_event; only construct MessageContext after the text equals
"ping" to avoid measuring the clone work—look for Event::Message,
MessageContext::from_event, and ctx.message.text_content() to make this change.

In `@wacore/src/types/events.rs`:
- Around line 190-197: In dispatch, avoid holding the RwLock read guard while
invoking user code: acquire the read lock on self.handlers, collect/cloned the
Arc<dyn EventHandler> entries into a local Vec (e.g., Vec<Arc<dyn
EventHandler>>), then drop the read guard before iterating; after the guard is
released iterate over the collected handlers and call
EventHandler::handle_event(Arc::clone(&event)) so registration/removal inside
handlers cannot deadlock the bus.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0c003695-f6e6-414b-9916-ea792f1dd8f8

📥 Commits

Reviewing files that changed from the base of the PR and between 70c52b5 and d743337.

📒 Files selected for processing (8)
  • examples/benchmark.rs
  • src/bot.rs
  • src/handlers/notification.rs
  • src/main.rs
  • src/receipt.rs
  • src/test_utils.rs
  • tests/e2e/tests/offline_groups.rs
  • wacore/src/types/events.rs

Comment thread src/bot.rs Outdated
Comment thread src/test_utils.rs
…tructor

- TestEventCollector stores Arc<Event> instead of deep-cloning events
- Add MessageContext::from_parts() for direct construction from &Message + &Info
- from_event() delegates to from_parts()
- Update test patterns to deref through Arc

@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 the current code and only fix it if needed.

Inline comments:
In `@examples/benchmark.rs`:
- Around line 50-56: The call to MessageContext::from_event(&event,
client).unwrap() in the Event::Message handling should avoid unwrap; replace it
with a safe check (e.g., use if let Some(ctx) =
MessageContext::from_event(&event, client) { ... } or match
MessageContext::from_event(...) { Some(ctx) => { /* existing handling */ }, None
=> { info!/warn!/error and return/continue } }) so the code handles the None
case gracefully and doesn't panic at runtime.

In `@src/main.rs`:
- Around line 106-111: The code calls MessageContext::from_event(&event,
client).unwrap() inside the Event::Message arm; replace the .unwrap() with an if
let to safely handle the Option: call MessageContext::from_event(&event, client)
and if it returns Some(ctx) await handle_message(&ctx), otherwise skip this
branch (e.g., continue the loop or log and return) so no panic can occur; keep
this change inside the same Event::Message arm that already checks
build_media_pong and PING_TRIGGER to preserve 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49b0ccd9-af58-4b2c-af3c-181e45922720

📥 Commits

Reviewing files that changed from the base of the PR and between d743337 and 8d9ff02.

📒 Files selected for processing (6)
  • examples/benchmark.rs
  • src/main.rs
  • src/pair.rs
  • tests/e2e/src/lib.rs
  • tests/e2e/tests/offline_groups.rs
  • wacore/src/types/events.rs

Comment thread examples/benchmark.rs
Comment thread src/main.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.

Caution

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

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

428-435: ⚠️ Potential issue | 🟡 Minor

Update the on_event example in rustdoc to dereference Arc<Event> when pattern-matching.

The on_event signature now takes Arc<Event> (line 430), but the example in the with_pair_code doc comment (lines 553–560) pattern-matches event by value. This will fail to compile because Rust does not auto-deref smart pointers like Arc in match patterns. The example must explicitly dereference:

Fix
     .on_event(|event, client| async move {
-        match event {
+        match &*event {
             Event::PairingCode { code, timeout } => {
                 println!("Enter this code on your phone: {}", code);
             }
             _ => {}
         }
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bot.rs` around lines 428 - 435, The rustdoc example for on_event uses
pattern matching on `event` by value but the handler signature now receives
`Arc<Event>`; update the example in the `with_pair_code` doc comment to
dereference the Arc when matching (e.g., use `match &*event { ... }` or `match
event.as_ref() { ... }` or `let Event::X = &*event => ...`) so the patterns
match `Event` behind the `Arc<Event>`; edit the example in the doc comment near
`with_pair_code` accordingly to reference `Arc<Event>` dereferencing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/bot.rs`:
- Around line 428-435: The rustdoc example for on_event uses pattern matching on
`event` by value but the handler signature now receives `Arc<Event>`; update the
example in the `with_pair_code` doc comment to dereference the Arc when matching
(e.g., use `match &*event { ... }` or `match event.as_ref() { ... }` or `let
Event::X = &*event => ...`) so the patterns match `Event` behind the
`Arc<Event>`; edit the example in the doc comment near `with_pair_code`
accordingly to reference `Arc<Event>` dereferencing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 858245e7-66da-424b-863b-f05da44ed441

📥 Commits

Reviewing files that changed from the base of the PR and between 8d9ff02 and 42c736e.

📒 Files selected for processing (4)
  • src/bot.rs
  • src/handlers/notification.rs
  • src/receipt.rs
  • src/test_utils.rs

@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 the current code and only fix it if needed.

Inline comments:
In `@src/main.rs`:
- Around line 106-111: The guard is causing build_media_pong(msg) to run twice
on the media path; compute the media pong once and reuse it instead of
rebuilding inside handle_message. In the Event::Message arm, first call
build_media_pong(msg) and store the Option (e.g., media_pong), then use
media_pong.is_some() || msg.text_content() == Some(PING_TRIGGER) for the
conditional and pass the media_pong (or a flag) into MessageContext or
handle_message so handle_message doesn’t call build_media_pong again;
alternatively implement and use is_media_ping(&msg) (as suggested) in the guard
and only call build_media_pong once when creating the context or before invoking
handle_message. Ensure references to build_media_pong, handle_message,
MessageContext::from_parts and Event::Message are updated accordingly.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: cdcc4c52-07c6-4006-aa32-785b356416ef

📥 Commits

Reviewing files that changed from the base of the PR and between 42c736e and ce75f91.

📒 Files selected for processing (2)
  • examples/benchmark.rs
  • src/main.rs

Comment thread src/main.rs Outdated
- Update with_pair_code doc example to use match &*event (Arc<Event>)
- Inline handle_message into the event handler so build_media_pong
  runs once instead of twice (guard + handler)
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