Skip to content

perf: integration benchmarks + allocation optimizations (-23% total, -43% connect allocs) - #551

Merged
jlucaso1 merged 17 commits into
mainfrom
feat/bench-integration
Apr 15, 2026
Merged

perf: integration benchmarks + allocation optimizations (-23% total, -43% connect allocs)#551
jlucaso1 merged 17 commits into
mainfrom
feat/bench-integration

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds allocation-counting integration benchmarks with mock server and applies a series of memory optimizations guided by DHAT profiling.

Measured impact (DHAT full session):

Metric Before After Delta
Total bytes allocated 37.9 MB 29.1 MB -8.7 MB (-23.1%)
Total heap blocks ~158K 103K -55K (-34.8%)
Peak live bytes 5.59 MB 5.46 MB -221 KB (-3.9%)
Connect-to-ready allocs 21,420 12,274 -9,146 (-42.7%)
Connect-to-ready bytes 5.19 MB 3.91 MB -1.28 MB (-24.7%)
Reconnect bytes 955 KB 532 KB -423 KB (-44.3%)
Send/receive per-message ~120 KB ~120 KB stable (no regression)

Changes

Infrastructure

  • bench-integration crate: counting allocator + DHAT profiling with mock server scenarios (connect, send, receive, reconnect). Outputs customSmallerIsBetter JSON for CI.
  • CI workflow: .github/workflows/bench-integration.yml with Bartender mock server
  • e2e tests: switched from SqliteStore to InMemoryBackend, disabled sqlite default feature for faster builds
  • Profiling profile: [profile.profiling] with debug symbols for DHAT/heaptrack

Optimizations (10 commits)

  1. Transport channel 10K->1K (-5.8 MB): EVENT_CHANNEL_CAPACITY reduced, ring buffer pre-alloc shrinks proportionally
  2. Slice APIs for prekey upload: build_upload_prekeys_request accepts &[u8] instead of Vec<u8>, eliminating 812+ intermediate .to_vec() calls
  3. Shared ProtocolAddress: encrypt_group_message, prepare_peer_stanza, create_sender_key_distribution_message_for_group accept &ProtocolAddress -- callers reuse instead of rebuilding
  4. Zlib decompressor pool: thread_local! Decompress instance with reset(true) reuse (-1.5 MB from avoided 48KB allocs)
  5. Bytes for prekey store: SignalStore trait prekey methods use Bytes -- clone is O(1) refcount instead of O(n) memcpy (-10K blocks)
  6. CompactString for props cache: AB prop values stored inline (<=24 bytes), transport send() accepts Bytes
  7. Shared prekey encode buffer: single contiguous Vec + Bytes::slice() replaces 812 individual encode_to_vec() calls. Sender task owns reusable enc_buf for in-place encryption
  8. Zero-copy digest validation: extract_prekey_public_key() reads protobuf field directly without full prost decode. Smarter marshal_auto detects nested large child lists
  9. Split handle_incoming_message: classify phase (borrows node) + process phase (owned data only). process_session_enc_batch/process_group_enc_batch accept &[EncPayload] instead of &[&NodeRef]
  10. Zero-copy EncPayload: OwnedNodeRef::slice_bytes() returns Bytes sub-view via pointer arithmetic -- ciphertext shares WebSocket frame buffer, no memcpy

Test plan

  • cargo test --all passes (872 unit tests verified)
  • cargo clippy --all clean
  • Integration benchmarks run against Bartender mock server
  • E2E tests pass with InMemoryBackend
  • DHAT profiling produces valid call trees

Summary by CodeRabbit

  • New Features

    • Integration Benchmark CI workflow to run, upload, and compare timed & allocation benchmarks on PRs and pushes.
  • Bug Fixes & Improvements

    • Reduced allocations and improved buffer reuse across networking, encryption, and storage for better performance.
    • Pooled zlib decompression with strict limits for safer, faster decompression.
    • Consistent protocol-address handling for peer/group messaging and pre-key handling migrated to zero-copy byte buffers.
    • Reduced transport channel capacity for memory predictability.
  • Tests

    • Added benchmark crate, allocation instrumentation, and integration scenarios.
  • Chores

    • Ignore dhat-heap.json in VCS.

jlucaso1 added 10 commits April 15, 2026 02:42
Allocation-counting benchmarks that run against Bartender (mock server)
to track real protocol-level allocation regressions in CI.

Scenarios: connect-to-ready, send message, send+receive round-trip,
reconnect. Outputs customSmallerIsBetter JSON for github-action-benchmark.

Optional `dhat-heap` feature for full call-tree profiling via DHAT viewer.

Also switches e2e tests from SqliteStore to InMemoryBackend and disables
the sqlite-storage default feature on both test crates for faster builds.
DHAT profiling showed `concurrent_queue::Bounded<TransportEvent>::new`
as the #1 allocation site at 6.2 MB per connection. The bounded ring
buffer pre-allocates all 10,000 slots upfront.

1,024 slots is still generous headroom -- WA Web processes messages
inline and a backlog that large means the client can't keep up anyway.

Measured impact (bench-integration with mock server):
- connect_to_ready: 5.19 MB -> 4.24 MB (-18.4%)
- reconnect: 955 KB -> 522 KB (-45.3%)
- DHAT total: 37.9 MB -> 32.1 MB (-15.3%)
- send/receive paths: unchanged (as expected)
P2: build_upload_prekeys_request now accepts &[u8] slices instead of
Vec<u8>, removing 812+ intermediate .to_vec() calls in build_iq().
The NodeBuilder still allocates internally, but the caller no longer
creates throwaway Vecs that are immediately consumed.

P3: encrypt_group_message, prepare_peer_stanza, and
create_sender_key_distribution_message_for_group now accept
&ProtocolAddress instead of computing it from &Jid internally.
Callers that already have the address (or call multiple functions
with the same JID) avoid redundant 64-byte String allocations.
In prepare_group_stanza, the address is now built once and shared
between SKDM creation and group encryption.
flate2::Decompress allocates ~48KB of internal zlib state on every
Decompress::new(). DHAT showed 24 instances (1.1MB binary protocol +
786KB history sync) created and destroyed during a session.

Use a thread_local pool that reuses both the Decompress instance
(via reset(true)) and the output Vec between calls. The pooled
decompressor is used for both binary protocol node decompression
and history sync blob inflation.

Measured impact:
- DHAT total: 32.1 MB -> 30.6 MB (-1.5 MB, -4.6%)
- connect_to_ready: 4.25 MB -> 4.12 MB (-130 KB)
Change SignalStore trait prekey methods from Vec<u8> to bytes::Bytes.
Bytes::clone() is O(1) (atomic refcount) vs Vec::clone() which copies
the entire buffer. The prekey upload path calls store_prekeys_batch
twice (pre-upload + mark-uploaded) with 812 keys each.

Measured impact:
- connect_to_ready allocs: 21,397 -> 19,638 (-1,759 allocs, -8.2%)
- connect_to_ready bytes: 4.12 MB -> 4.01 MB (-106 KB)
- reconnect bytes: 522 KB -> 511 KB
… reuse

Props: Switch AbProp.config_value and AbPropsCache storage from String
to CompactString. Most prop values (\"1\", \"true\", \"enabled\") are <=24
bytes and stored inline without heap allocation. Also avoids String
clone on cache insertion.

Transport: Change Transport::send from Vec<u8> to Bytes. The sender
task in NoiseSocket now owns a reusable Vec<u8> for framing output
instead of receiving a fresh one per send. Callers no longer need to
pre-allocate an encrypted_buf.

Measured impact:
- connect_to_ready allocs: 19,638 -> 17,172 (-2,466 allocs, -12.6%)
- connect_to_ready bytes: 4.01 MB -> 3.98 MB
- DHAT total blocks: 147,279 -> 131,823 (-15,456 blocks, -10.5%)
- DHAT total bytes: 29.9 MB -> 29.9 MB (block count reduction, not bytes)
Prekey encoding: Replace 812 individual encode_to_vec() calls with a
single contiguous buffer + Bytes::slice() sub-views. All prekey records
are encoded into one pre-sized Vec, then sliced into zero-copy Bytes
views for the store and upload paths. Eliminates 4,872 small Vec allocs.

Sender task: encrypt_and_send now accepts Bytes instead of Vec<u8>.
The sender task owns a reusable enc_buf for in-place encryption,
avoiding per-send buffer allocations for the common small-message path.

Measured impact:
- connect_to_ready allocs: 17,172 -> 15,597 (-1,575 allocs, -9.2%)
- DHAT total blocks: 131,823 -> 123,528 (-8,295 blocks, -6.3%)
- DHAT peak live (t-gmax): 20,951 blocks -> 9,285 blocks (-55.7%)
…ndler future

Three optimizations targeting remaining allocation hotspots:

1. Zero-copy prekey digest validation: extract_prekey_public_key() reads
   the publicKey field directly from stored protobuf bytes without full
   prost decode. Eliminates all PreKeyRecordStructure decode allocs in
   validate_digest_key (was ~4,872 blocks per session).

2. Smarter marshal_auto: detect large nested child lists (e.g. <iq> ->
   <list> -> 812 prekey nodes) to trigger capacity pre-estimation. The
   prekey upload IQ previously started at 256B and grew repeatedly to
   ~40KB; now it pre-allocates near the correct size.

3. Smaller MessageHandler future: replace get_with_by_ref (which captures
   the entire worker-spawning closure in the async state machine) with
   get + conditional insert. The handler future no longer carries the
   large init closure across the .await on cache hits (~29KB -> smaller).

Measured impact:
- connect_to_ready allocs: 15,597 -> 12,316 (-3,281 allocs, -21.0%)
- connect_to_ready bytes: 4.03 MB -> 3.87 MB (-163 KB)
- DHAT total: 30.4 MB -> 29.3 MB (-1.1 MB, -3.6%)
- DHAT blocks: 123,528 -> 103,097 (-20,431 blocks, -16.5%)
Split the large monolithic handle_incoming_message async function into
two phases:

1. classify_incoming_message: borrows the node tree, extracts owned
   EncPayload structs (ciphertext Bytes + enc_type + padding_version),
   returns ClassifiedMessage with all owned data.

2. process_classified_message: holds no node borrows across the heavy
   decrypt .await points. The async state machine is smaller because
   it only carries owned data (Arc<MessageInfo>, Vec<EncPayload>, Jid)
   instead of Vec<&NodeRef> + the entire node tree.

Also changes process_session_enc_batch and process_group_enc_batch to
accept &[EncPayload] instead of &[&NodeRef], completing the decoupling
of decrypt phase from node tree lifetime.

The benefit scales with message volume -- each spawned message worker
task has a smaller future, reducing per-message heap allocation.
Add OwnedNodeRef::slice_bytes() which returns a Bytes sub-view into
the node's backing buffer using pointer arithmetic -- no memcpy.

EncPayload::from_owned_node() uses this to extract ciphertext bytes
as a zero-copy Bytes view instead of Bytes::copy_from_slice().

Measured impact vs previous commit:
- DHAT total: 29.47 MB -> 29.15 MB (-328 KB, -1.1%)
- DHAT peak bytes: 5.68 MB -> 5.46 MB (-221 KB, -3.9%)
- connect_to_ready bytes: -58 KB (-1.5%)
@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e00b21eb-b58d-48ce-87a2-0d36700dd94d

📥 Commits

Reviewing files that changed from the base of the PR and between c1abd3d and 36224a8.

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

📝 Walkthrough

Walkthrough

Adds an integration benchmark workflow and a bench binary; migrates prekey and transport/storage APIs to zero-copy bytes::Bytes; refactors incoming-message classification to owned payloads; adds pooled zlib decompression; updates stores, tests, workspace, and related callsites to use CompactString and Bytes.

Changes

Cohort / File(s) Summary
CI & Benchmark crate
​.github/workflows/bench-integration.yml, tests/bench-integration/Cargo.toml, tests/bench-integration/src/main.rs, tests/bench-integration/src/counting_alloc.rs
Adds a GitHub Actions "Integration Benchmark" workflow and new bench-integration binary with allocator options, scenarios, Docker mock-server readiness checks, artifact upload, and benchmark-action comparison/storage.
Workspace & Cargo profiles
Cargo.toml
Adds tests/bench-integration workspace member and a [profile.profiling] inheriting from release with debug symbols and no strip.
Transport surface & implementations
wacore/src/net.rs, src/transport.rs, transports/tokio-transport/src/lib.rs, storages/sqlite-storage/Cargo.toml
Changes Transport::send to accept bytes::Bytes across interfaces and implementations; adds bytes workspace dependency to sqlite-storage crate.
Noise socket & send plumbing
src/socket/noise_socket.rs, src/client.rs
Refactors NoiseSocket to accept bytes::Bytes, change SendJob ownership, reuse in-task encryption/out buffers, and update callers to pass Bytes.
Message pipeline & handlers
src/message.rs, src/handlers/message.rs, src/features/signal.rs
Introduces EncPayload and ClassifiedMessage, splits classification and processing (owned payloads for batch decryption), and extracts chat-lane init into create_chat_lane.
wacore send & related callsites
wacore/src/send.rs, src/send.rs, wacore/benches/send_receive_benchmark.rs
Switches send APIs to use ProtocolAddress references for sender/signal addressing and updates callsites to avoid cloning JIDs.
Prekeys, parsing & APIs
wacore/src/prekeys.rs, wacore/src/iq/prekeys.rs, src/prekeys.rs, tests/e2e/tests/digest_key.rs
Adds extract_prekey_public_key (protobuf-scan helper); changes digest/upload helpers to accept borrowed slices and reduce allocations.
Store trait & storage impls
wacore/src/store/traits.rs, wacore/src/store/in_memory.rs, storages/sqlite-storage/src/sqlite_store.rs
Migrates SignalStore APIs and implementations from Vec<u8> to bytes::Bytes for prekey batch/store/load methods; updates default impls and conversions.
Zlib decompression pooling
wacore/binary/src/zlib_pool.rs, wacore/binary/src/util.rs, wacore/src/history_sync.rs
Adds pooled thread-local zlib decompressor with reusable scratch buffer; callers delegate to pooled helper while preserving max-size checks.
Binary node utilities & marshal
wacore/binary/src/node.rs, wacore/binary/src/marshal.rs, wacore/binary/src/lib.rs
Adds OwnedNodeRef::slice_bytes (zero-copy Bytes), extends auto-reserve logic to nested grandchildren, and exports new zlib_pool module.
AB props & CompactString
wacore/src/iq/props.rs, wacore/src/store/ab_props.rs
Replaces config_value/cache storage with CompactString; updates serialization, deserialization, cache API, and tests.
E2E & tests adjustments, misc
tests/e2e/Cargo.toml, tests/e2e/src/lib.rs, src/appstate_sync.rs, src/store/signal.rs, src/retry.rs, tests/e2e/tests/retry_dm_multidevice.rs, tests/e2e/tests/digest_key.rs, .gitignore
E2E tests switched to InMemoryBackend; test mocks updated for Bytes and prekey parsing; retry test assertions adjusted; .gitignore adds dhat-heap.json.

Sequence Diagram(s)

sequenceDiagram
    participant GH as GitHub Actions
    participant Runner as CI Runner
    participant Docker as Mock Server (Docker)
    participant Artifact as Artifact Store

    GH->>Runner: on push/PR -> checkout, setup toolchain, protoc, cache
    Runner->>Docker: start mock-server (port 8080, env CHATSTATE_TTL_SECS=3)
    Runner->>Docker: poll https://localhost:8080/ until ready
    Runner->>Runner: run `cargo run -p bench-integration --release`
    Runner->>Artifact: upload `bench_integration_results.json` + `bench_integration.log`
    Runner->>GH: invoke benchmark-action (compare or store baseline)
Loading
sequenceDiagram
    participant App as Incoming OwnedNodeRef
    participant Classify as classify_incoming_message
    participant Process as process_classified_message
    participant Decrypt as Decryption layer
    participant Deliver as Delivery

    App->>Classify: hand OwnedNodeRef
    activate Classify
    Classify->>Classify: build Vec<EncPayload> + ClassifiedMessage
    Classify-->>App: return ClassifiedMessage
    deactivate Classify

    App->>Process: pass ClassifiedMessage
    activate Process
    Process->>Decrypt: process_session_enc_batch / process_group_enc_batch (owned payloads)
    Decrypt-->>Process: decrypted messages
    Process->>Deliver: deliver messages
    deactivate Process
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble bytes and stash them tight,
Pools hum zlib through the night.
Benchmarks count each hop and heap,
Messages owned, no borrow to keep.
A cheerful rabbit hops — data neat.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: performance improvements through integration benchmarks and allocation optimizations with specific metrics.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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 feat/bench-integration

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 for some possible bugs introduced, regressions, what can be improved to be DRY and also reduce allocations

@claude

claude Bot commented Apr 15, 2026

Copy link
Copy Markdown

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


Reviewing PR #551 — Performance Benchmarks + Allocation Optimizations

  • Gather context — read all changed files
  • Review core optimizations for correctness bugs
  • Review for regressions in behavior
  • Check for DRY improvements
  • Identify further allocation reduction opportunities
  • Post final review

Bug: TOCTOU Race in Chat Lane Creation (High Severity)

src/handlers/message.rs:44-91

The refactored chat lane initialization has a race condition that can violate the per-chat sequential message processing guarantee.

The old code used single-flight get_with_by_ref:

// OLD — atomic: only one init closure runs for a given key
let lane = client.chat_lanes.get_with_by_ref(&chat_jid, async { ... }).await;

The new code splits into separate get() + insert():

// NEW — TOCTOU race window
let lane = if let Some(existing) = client.chat_lanes.get(&chat_jid).await {
    existing
} else {
    // ... spawn worker, create lane ...
    client.chat_lanes.insert(chat_jid.clone(), lane.clone()).await;
    lane
};

What can go wrong: Two concurrent messages for the same new chat both see get() == None, each spawn their own worker task with separate channels. The second insert() overwrites the first lane. Messages enqueued on the first lane are processed by worker1, while future messages go to worker2 — messages from the same chat can execute in parallel, breaking the per-chat ordering invariant that the entire ChatLane design exists to enforce.

Fix: Restore the single-flight pattern. If the goal is to shrink the async state machine, extract the lane construction to a non-async helper and pass it to get_with_by_ref:

let lane = client.chat_lanes.get_with_by_ref(&chat_jid, async {
    Self::create_chat_lane(&client, spawn_generation)
}).await;

Fix this →


Bug: out_buf Not Actually Reused in NoiseSocket Sender (Low Severity)

src/socket/noise_socket.rs:80-81 + :147-148

// Line 80-81: Comment claims reuse
// Reusable buffers -- capacity stays allocated between sends
let mut enc_buf = Vec::with_capacity(4096);
let mut out_buf = Vec::with_capacity(4096);

But at line 147:

let frame = bytes::Bytes::from(std::mem::take(out_buf));

std::mem::take replaces out_buf with Vec::new() (zero capacity). Every iteration re-allocates out_buf from scratch. Only enc_buf is truly reused (via .clear() which preserves capacity).

The comment is misleading and the 4096 initial capacity for out_buf only helps on the very first send.

Fix: Either use Bytes::copy_from_slice(out_buf) so out_buf retains its capacity (small overhead for typical frames), or update the comment to be accurate and note that only enc_buf is reused.


Bug: Misleading Comment in Zlib Pool (Low Severity)

wacore/binary/src/zlib_pool.rs:55-58

// Return the data by swapping out to avoid cloning.
// The scratch buffer keeps its capacity for the next call.  <-- WRONG
let mut result = Vec::new();
std::mem::swap(scratch, &mut result);

Same issue: mem::swap moves the capacity to result, leaving scratch at zero capacity. The Decompress state (~48KB) is correctly reused, which is the main win. But the output buffer is reallocated each call.

Fix: Correct the comment. If you want true output buffer reuse, change to scratch.clone() + clear (trades a memcpy for avoiding reallocation), but this is likely not worth it since the Decompress reuse dominates.


Potential Regression: history_sync Ignores Size Hint

wacore/src/history_sync.rs:51

_compressed_size_hint: Option<u64>,

The compressed_size_hint parameter (previously used for initial buffer sizing) is now ignored. The pooled decompressor uses a generic compressed.len() * 4 estimate. For large history sync blobs where the caller had a better estimate, this could lead to more reallocations during decompression.

Impact: Minimal — the pooled decompressor handles growth fine, and the Decompress state reuse is the bigger win. But consider passing the hint through to decompress_zlib_pooled as an optional capacity hint.


DRY: #[cfg] Duplication in Benchmark Harness

tests/bench-integration/src/main.rs

Every scenario has the same #[cfg(not(feature = "dhat-heap"))] / #[cfg(feature = "dhat-heap")] branching pattern. Consider a macro or unifying the measure return type:

struct Measurement<T> {
    result: T,
    elapsed: Duration,
    #[cfg(not(feature = "dhat-heap"))]
    delta: AllocDelta,
}

Then a single results.record_measured(...) that conditionally accesses delta would eliminate ~60 lines of duplicated cfg blocks.


DRY: EncPayload Constructors

src/message.rs:40-69

from_owned_node and from_node_ref share identical logic for enc_type and padding_version. Only the ciphertext extraction differs. Consider:

impl EncPayload {
    fn from_parts(ciphertext: Bytes, enc_node: &NodeRef<'_>) -> Option<Self> {
        let enc_type = enc_node.attrs().optional_string("type")?.to_string();
        let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as u8;
        Some(Self { ciphertext, enc_type, padding_version })
    }

    pub(crate) fn from_owned_node(owner: &OwnedNodeRef, enc_node: &NodeRef<'_>) -> Option<Self> {
        Self::from_parts(owner.slice_bytes(enc_node.content_bytes()?), enc_node)
    }

    #[cfg(test)]
    pub(crate) fn from_node_ref(node: &NodeRef<'_>) -> Option<Self> {
        Self::from_parts(Bytes::copy_from_slice(node.content_bytes()?), node)
    }
}

Allocation Optimization: enc_type: StringEncType Enum

src/message.rs:35

EncPayload stores enc_type: String which heap-allocates for values like "pkmsg", "msg", "skmsg". Since EncType already exists in wacore::message_processing:

pub(crate) struct EncPayload {
    pub ciphertext: bytes::Bytes,
    pub enc_type: EncType,       // Copy, zero-alloc
    pub padding_version: u8,
}

The classification in classify_incoming_message already calls EncType::from_wire() to decide which vec to push into — move this parse into EncPayload construction and store the enum directly. This eliminates one String allocation per enc node and the redundant from_wire() parse in process_session_enc_batch.


Minor: Removed Debug Log

src/message.rs (in process_classified_message)

The log::debug!("[msg:{}] Sender retry count {} pre-seeded into cache", ...) was removed. This is useful for debugging retry-heavy scenarios. Consider keeping it.


Summary

Category Issue Severity
Bug TOCTOU race in chat lane creation — violates per-chat ordering High
Bug out_buf reuse comment is wrong; capacity lost each send Low
Bug Zlib pool scratch comment is wrong; capacity not retained Low
Regression compressed_size_hint ignored in history sync Very Low
DRY Benchmark #[cfg] duplication Low
DRY EncPayload constructor duplication Low
Optimization enc_type: StringEncType enum (eliminate alloc) Low

The optimizations are well-targeted and the benchmark infrastructure is solid. The main issue to fix before merge is the chat lane race condition — it's a concurrency bug that defeats the purpose of the per-chat ordering machinery.

- e2e digest_key test: use extract_prekey_public_key() instead of full
  prost decode, fix Bytes::as_slice() -> as_ref(), adapt to new
  compute_key_bundle_digest(&[&[u8]]) signature
- message.rs: remove 5 needless_borrow warnings (&enc_type -> enc_type)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79a282cc10

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/binary/src/zlib_pool.rs Outdated
Comment on lines +46 to +50
match status {
Status::StreamEnd => break,
Status::Ok | Status::BufError => {
// Need more output space
scratch.reserve(scratch.capacity().max(4096));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Abort on zlib BufError without input progress

When decompress_vec returns Status::BufError because the stream is truncated/corrupt and needs more input (not more output), this loop keeps reserving output space and retries with the same tail, so it can spin forever (and keep growing allocations) instead of returning an error. In this code path (frame/history decompression), that can stall the receive pipeline on malformed compressed payloads.

Useful? React with 👍 / 👎.

Comment thread wacore/binary/src/zlib_pool.rs Outdated
Comment on lines +55 to +58
// Return the data by swapping out to avoid cloning.
// The scratch buffer keeps its capacity for the next call.
let mut result = Vec::new();
std::mem::swap(scratch, &mut result);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep pooled scratch buffer capacity across calls

This swap moves the entire scratch allocation into result, leaving the thread-local scratch vec empty, so the next call reallocates from scratch. That defeats the stated pooling behavior and reintroduces per-call output-buffer allocations in hot decompression paths.

Useful? React with 👍 / 👎.

- Detect truncated/corrupt zlib streams by checking if total_in and
  total_out made no progress on BufError, and return an error instead
  of spinning forever growing the output buffer.
- Use scratch.clone() instead of mem::swap to return data, so the
  pooled scratch Vec retains its capacity for subsequent calls.

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

Caution

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

⚠️ Outside diff range comments (1)
storages/sqlite-storage/src/sqlite_store.rs (1)

1283-1302: ⚠️ Potential issue | 🟠 Major

Keep Bytes through the retry loop.

Line 1291 flattens every prekey back into a Vec<u8>, and Line 1302 deep-clones those buffers again on each retry. That reintroduces the O(total_prekey_bytes) copies this PR is trying to remove on the upload/store hot path. Bytes is already cheap to clone here; only borrow record.as_ref() at the Diesel call site.

♻️ Proposed fix
-        let keys: Vec<(u32, Vec<u8>)> = keys.iter().map(|(id, b)| (*id, b.to_vec())).collect();
+        let keys: Arc<Vec<(u32, Bytes)>> =
+            Arc::new(keys.iter().map(|(id, b)| (*id, b.clone())).collect());

         const MAX_RETRIES: u32 = 5;

         for attempt in 0..=MAX_RETRIES {
@@
-            let keys_clone = keys.clone();
+            let keys_clone = Arc::clone(&keys);

             let result =
                 tokio::task::spawn_blocking(move || -> std::result::Result<(), DieselOrStore> {
@@
-                        for (id, record) in &keys_clone {
+                        for (id, record) in keys_clone.iter() {
                             diesel::insert_into(prekeys::table)
                                 .values((
                                     prekeys::id.eq(*id as i32),
-                                    prekeys::key.eq(record),
+                                    prekeys::key.eq(record.as_ref()),
                                     prekeys::uploaded.eq(uploaded),
                                     prekeys::device_id.eq(device_id),
                                 ))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storages/sqlite-storage/src/sqlite_store.rs` around lines 1283 - 1302,
store_prekeys_batch currently converts incoming Bytes to Vec<u8> up front and
then deep-clones those Vecs on each retry, reintroducing O(total_prekey_bytes)
copies; change the local keys to keep Bytes (e.g., let keys: Vec<(u32, Bytes)> =
keys.iter().map(|(id, b)| (*id, b.clone())).collect()), remove the Vec<u8>
conversion and avoid deep-cloning keys inside the retry loop (don't clone
buffers when creating keys_clone), and only call record.as_ref() (or to_vec())
at the Diesel call site inside the transaction/insert where a borrow or owned
slice is required so the expensive copy happens only once if absolutely
necessary; refer to function store_prekeys_batch, constants MAX_RETRIES and
variables db_semaphore, device_id when locating the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/bench-integration.yml:
- Around line 9-13: The workflow currently grants overly broad permissions
(contents: write and deployments: write) for all triggers; modify the workflow
so the PR-run uses least privilege by changing the top-level permissions for
pull_request runs to contents: read and only the permission needed to post PR
comments (issues: write or pull-requests: write depending on which API you use),
and remove deployments: write from the PR path; then split the
baseline-publishing/publish job into a push-only job (triggered on push) that
retains contents: write and deployments: write so only the push path can modify
the repo or create deployments.
- Around line 29-45: The workflow uses mutable refs: the mock-server image
"ghcr.io/whiskeysockets-devtools/bartender:latest" and the action
"dtolnay/rust-toolchain@master"; replace them with pinned immutable refs by
updating the mock-server image to the exact image digest (sha256:...) instead of
:latest and change dtolnay/rust-toolchain@master to a specific commit SHA or
released tag; ensure the new image digest and action SHA/tag are recorded in the
workflow so benchmarks remain reproducible and the steps referencing
"mock-server" and the "dtolnay/rust-toolchain" action use those pinned values.

In `@src/handlers/message.rs`:
- Around line 41-91: The current fast-path else branch races: two tasks can both
miss client.chat_lanes and spawn separate ChatLane workers (enqueue_lock and
queue_tx are lane-local), breaking per-chat FIFO; fix by replacing the
get/insert pattern with a single-flight initializer such as
chat_lanes.get_with_by_ref(...) or an atomic get-or-init helper so only one
initializer runs for a given chat_jid, move the heavy worker spawn logic into a
small helper function (e.g., spawn_chat_worker(client.clone(), rx,
spawn_generation)) so the outer future stays small, and ensure you use the
existing session_locks and message_enqueue_locks to serialize per-sender Signal
ops and per-chat enqueueing while creating/inserting the ChatLane (work with
ChatLane, enqueue_lock, queue_tx, client.handle_incoming_message, chat_jid,
chat_lanes).

In `@src/message.rs`:
- Around line 313-324: handle_incoming_message currently drops the node and
calls process_classified_message without acquiring the per-chat
message_enqueue_locks, so classify_incoming_message and subsequent processing
can interleave across messages from the same chat (allowing pkmsg/skmsg
reordering and NoSenderKeyState/duplicate-retry issues). Fix by acquiring the
appropriate message_enqueue_locks guard for the chat/sender before dropping the
node and holding that guard through process_classified_message (similar to how
process_session_enc_batch uses session_lock), i.e. locate
handle_incoming_message and process_classified_message and ensure a
message_enqueue_locks guard is obtained using the chat/sender key prior to
declassification and retained during the async await to serialize per-chat
incoming processing; keep session_lock usage for encrypt/decrypt serialization
as currently done.

In `@src/socket/noise_socket.rs`:
- Around line 81-83: The problem is that using
Bytes::from(std::mem::take(out_buf)) discards the original Vec capacity so
buffer reuse is lost; replace the mem::take call with a mem::replace that
creates a new Vec with the same capacity (e.g. std::mem::replace(&mut out_buf,
Vec::with_capacity(out_buf.capacity()))) so the moved Vec keeps its allocation
and out_buf is replaced with an empty Vec having the original capacity; update
the same pattern at the other occurrence around the second mention (the lines
noted 148-149) and keep the same change for enc_buf if you used the same pattern
there.

In `@tests/bench-integration/src/counting_alloc.rs`:
- Around line 9-19: Implement GlobalAlloc::realloc and alloc_zeroed on
CountingAlloc so reallocations and zeroed allocations forward to System without
inflating ALLOC_COUNT: add an unsafe fn realloc(&self, ptr: *mut u8, old_layout:
Layout, new_size: usize) that calls System.realloc(ptr, old_layout, new_size)
and updates ALLOC_BYTES by adding the positive difference when new_size >
old_layout.size() or subtracting when smaller (use fetch_add/fetch_sub on
ALLOC_BYTES with Ordering::Relaxed), and do NOT increment ALLOC_COUNT in
realloc; also add unsafe fn alloc_zeroed(&self, layout: Layout) that increments
ALLOC_COUNT and ALLOC_BYTES (layout.size()) and forwards to
System.alloc_zeroed(layout) with Ordering::Relaxed. Ensure you reference
CountingAlloc, realloc, alloc_zeroed, ALLOC_COUNT, ALLOC_BYTES, System.realloc
and System.alloc_zeroed in the patch.

In `@tests/bench-integration/src/main.rs`:
- Around line 183-193: The benchmark is measuring allocations from format!
inside the timed closure (const N, measure async || { ...
client_a.client.send_message(... text_msg(&format!("bench-send-{i}"))) ... }),
so move message construction out of the allocation-counted closure: precompute
the N message Strings (or pre-format them into a Vec<String> or Vec<&'static
str>) before calling measure and then inside the measured async closure call
text_msg/send_message using those prebuilt messages to ensure
alloc_count/alloc_bytes reflect only the library send/receive path.

In `@tests/e2e/src/lib.rs`:
- Around line 57-59: The e2e helper connect_inner currently hardcodes
InMemoryBackend which skips the sqlite path; modify connect_inner to accept a
backend selector (e.g., an enum or Option<&str> backend_type) or use
cfg(feature="bench") to choose InMemoryBackend only for benchmarks, and
otherwise construct the sqlite-backed implementation (wrap as Arc<dyn Backend>)
so e2e tests exercise sqlite storage; update call sites that invoke
connect_inner (tests/bench harness) to pass the appropriate selector or enable
the bench feature, and keep
TokioWebSocketTransportFactory::new().with_url(mock_server_url()) usage
unchanged.

In `@wacore/binary/src/marshal.rs`:
- Around line 147-155: The auto-reserve heuristics diverge:
should_auto_reserve_node checks one level deeper for large nested lists but
should_auto_reserve_node_ref does not, causing marshal_ref_auto to miss the same
reservation opportunity; update should_auto_reserve_node_ref to mirror the logic
in should_auto_reserve_node by returning true if children.len() >=
AUTO_RESERVE_CHILDREN_THRESHOLD or if any direct child has content
Some(NodeContent::Nodes(gc)) with gc.len() >= AUTO_RESERVE_CHILDREN_THRESHOLD so
both heuristics stay in sync.

In `@wacore/src/prekeys.rs`:
- Around line 69-77: decode_varint currently accepts a malformed 10th varint
byte which can have payload bits > 1 and yield an incorrect offset; update
decode_varint so that when iterating the 10th byte (i == 9) you explicitly
reject any payload > 1 (i.e. if (byte & 0x7F) > 1 return None), and continue to
return None if the loop finishes without a terminating byte; this fixes callers
like extract_prekey_public_key that rely on decode_varint returning None for
invalid encodings.

---

Outside diff comments:
In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 1283-1302: store_prekeys_batch currently converts incoming Bytes
to Vec<u8> up front and then deep-clones those Vecs on each retry, reintroducing
O(total_prekey_bytes) copies; change the local keys to keep Bytes (e.g., let
keys: Vec<(u32, Bytes)> = keys.iter().map(|(id, b)| (*id,
b.clone())).collect()), remove the Vec<u8> conversion and avoid deep-cloning
keys inside the retry loop (don't clone buffers when creating keys_clone), and
only call record.as_ref() (or to_vec()) at the Diesel call site inside the
transaction/insert where a borrow or owned slice is required so the expensive
copy happens only once if absolutely necessary; refer to function
store_prekeys_batch, constants MAX_RETRIES and variables db_semaphore, device_id
when locating the change.
🪄 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: f38ec1c6-d1ab-4376-a475-71b0cbe4f081

📥 Commits

Reviewing files that changed from the base of the PR and between 88e4808 and 79a282c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (37)
  • .github/workflows/bench-integration.yml
  • .gitignore
  • Cargo.toml
  • src/appstate_sync.rs
  • src/client.rs
  • src/features/signal.rs
  • src/handlers/message.rs
  • src/handshake.rs
  • src/message.rs
  • src/prekeys.rs
  • src/send.rs
  • src/socket/noise_socket.rs
  • src/store/signal.rs
  • src/transport.rs
  • storages/sqlite-storage/Cargo.toml
  • storages/sqlite-storage/src/sqlite_store.rs
  • tests/bench-integration/Cargo.toml
  • tests/bench-integration/src/counting_alloc.rs
  • tests/bench-integration/src/main.rs
  • tests/e2e/Cargo.toml
  • tests/e2e/src/lib.rs
  • transports/tokio-transport/src/lib.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/marshal.rs
  • wacore/binary/src/node.rs
  • wacore/binary/src/util.rs
  • wacore/binary/src/zlib_pool.rs
  • wacore/src/history_sync.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/props.rs
  • wacore/src/net.rs
  • wacore/src/prekeys.rs
  • wacore/src/send.rs
  • wacore/src/store/ab_props.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment thread .github/workflows/bench-integration.yml Outdated
Comment on lines +29 to +45
mock-server:
image: ghcr.io/whiskeysockets-devtools/bartender:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.BARTENDER_GHCR_TOKEN }}
ports:
- 8080:8080
env:
CHATSTATE_TTL_SECS: "3"
options: --log-driver none
steps:
- uses: actions/checkout@v6

- uses: dtolnay/rust-toolchain@master
with:
toolchain: nightly-2026-04-05

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n .github/workflows/bench-integration.yml | sed -n '1,60p'

Repository: jlucaso1/whatsapp-rust

Length of output: 1873


🏁 Script executed:

# Search for any version tags or references to bartender in the repo
rg -i "bartender" --type yaml --type md

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🌐 Web query:

dtolnay rust-toolchain action pinning best practices recommended version

💡 Result:

Best practices for pinning the dtolnay/rust-toolchain GitHub Action: Use specific tags or commit SHAs for reproducibility and security. The action's @rev determines the default toolchain (e.g., dtolnay/rust-toolchain@stable installs the current stable Rust, dtolnay/rust-toolchain@1.94.0 installs Rust 1.94.0). Recommended approaches: - For latest stable: - uses: dtolnay/rust-toolchain@stable - For pinned version: - uses: dtolnay/rust-toolchain@1.94.0 (matches current stable as of 2026-04-15) - For explicit toolchain with master: - uses: dtolnay/rust-toolchain@master with: toolchain: '1.94.0' For maximum security, pin to a full commit SHA from master branch history (e.g., dtolnay/rust-toolchain@315e265), as tags like @stable are lightweight and follow latest in channel. Combine with rust-toolchain.toml in repo root for project-specific pinning (e.g., channel = "1.94.0"), which rustup respects automatically. This ensures consistent builds, avoids surprise lints from un-pinned updates, and follows GitHub security guidelines for third-party actions. Current stable Rust version (2026-04-15): 1.94.1 (released 2026-03-26), recommended for most projects.

Citations:


🌐 Web query:

whiskeysockets bartender docker image releases versions

💡 Result:

No official whiskeysockets/bartender Docker image or releases found. WhiskeySockets maintains Baileys (WhatsApp Web API library) with latest release v7.0.0-rc.9 on 2025-11-21. "Bartender" appears in Baileys PR #2191 context as an internal testing/benchmark tool for socket changes (e.g., "destructive benchmark test on bartender"), but no public repository, Docker image, or version releases for it exist in search results. Related Baileys Docker images like devjuanda/baileys-server-pro exist but are third-party. Unrelated projects like barassistant/server (cocktail app) have Docker tags including 'dev'.

Citations:


Pin the benchmark environment to immutable refs.

bartender:latest and dtolnay/rust-toolchain@master both use mutable references that drift over time, making benchmark baselines non-reproducible and risking unexpected workflow breakage. Pin the image to a digest and the action to a commit SHA or tag.

Proposed pinning pattern
-        image: ghcr.io/whiskeysockets-devtools/bartender:latest
+        image: ghcr.io/whiskeysockets-devtools/bartender@sha256:<verified-digest>
...
-      - uses: dtolnay/rust-toolchain@master
+      - uses: dtolnay/rust-toolchain@<verified-commit-sha>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/bench-integration.yml around lines 29 - 45, The workflow
uses mutable refs: the mock-server image
"ghcr.io/whiskeysockets-devtools/bartender:latest" and the action
"dtolnay/rust-toolchain@master"; replace them with pinned immutable refs by
updating the mock-server image to the exact image digest (sha256:...) instead of
:latest and change dtolnay/rust-toolchain@master to a specific commit SHA or
released tag; ensure the new image digest and action SHA/tag are recorded in the
workflow so benchmarks remain reproducible and the steps referencing
"mock-server" and the "dtolnay/rust-toolchain" action use those pinned values.

Comment thread src/handlers/message.rs Outdated
Comment thread src/message.rs
Comment on lines 313 to +324
pub(crate) async fn handle_incoming_message(self: Arc<Self>, node: Arc<OwnedNodeRef>) {
// Phase 1: classify borrows the node tree, extracts owned payloads, returns quickly.
// Phase 2: process_classified_message holds no node borrows across heavy .await points,
// keeping the async state machine small.
let classified = match self.classify_incoming_message(&node).await {
Some(c) => c,
None => return,
};
// node is no longer borrowed here -- drop it before the heavy phase
drop(node);
self.process_classified_message(classified).await;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This refactor still leaves per-chat incoming processing unordered.

process_session_enc_batch() keeps the per-sender session_lock, but the new handle_incoming_message()/process_classified_message() path still doesn't take a message_enqueue_locks guard. That allows two messages from the same chat to interleave across classify/decrypt, which can reorder an SKDM-bearing pkmsg behind a later skmsg and reintroduce NoSenderKeyState/duplicate-retry behavior.

As per coding guidelines, "Use session_locks to serialize per-sender Signal encrypt/decrypt operations and message_enqueue_locks to serialize per-chat incoming message processing".

Also applies to: 518-692

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

In `@src/message.rs` around lines 313 - 324, handle_incoming_message currently
drops the node and calls process_classified_message without acquiring the
per-chat message_enqueue_locks, so classify_incoming_message and subsequent
processing can interleave across messages from the same chat (allowing
pkmsg/skmsg reordering and NoSenderKeyState/duplicate-retry issues). Fix by
acquiring the appropriate message_enqueue_locks guard for the chat/sender before
dropping the node and holding that guard through process_classified_message
(similar to how process_session_enc_batch uses session_lock), i.e. locate
handle_incoming_message and process_classified_message and ensure a
message_enqueue_locks guard is obtained using the chat/sender key prior to
declassification and retained during the async await to serialize per-chat
incoming processing; keep session_lock usage for encrypt/decrypt serialization
as currently done.

Comment thread src/socket/noise_socket.rs
Comment on lines +9 to +19
unsafe impl GlobalAlloc for CountingAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
ALLOC_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed);
unsafe { System.alloc(layout) }
}

unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

GlobalAlloc realloc default implementation Rust std behavior

💡 Result:

The GlobalAlloc trait in Rust's standard library provides a default implementation for the realloc method. This default behavior, used by any global allocator that does not override it (including the platform-specific System allocator when it relies on the trait default), performs realloc by allocating a new block of the requested size and alignment, copying the minimum of the old and new sizes from the old pointer to the new one using ptr::copy_nonoverlapping, deallocating the old block, and returning the new pointer (or null if allocation fails). Here is the exact source code from core::alloc::global.rs: unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { // SAFETY: the caller must ensure that the new_size does not overflow. // layout.align comes from a Layout and is thus guaranteed to be valid. let new_layout = unsafe { Layout::from_size_align_unchecked(new_size, layout.align) }; // SAFETY: the caller must ensure that new_layout is greater than zero. let new_ptr = unsafe { self.alloc(new_layout) }; if !new_ptr.is_null { // SAFETY: the previously allocated block cannot overlap the newly allocated block. // The safety contract for dealloc must be upheld by the caller. unsafe { ptr::copy_nonoverlapping(ptr, new_ptr, cmp::min(layout.size, new_size)); self.dealloc(ptr, layout); } } new_ptr } The default global allocator for executables is platform-specific and unspecified (often System, which wraps system calls like malloc/realloc on Unix or HeapReAlloc on Windows), but since System implements GlobalAlloc and the docs do not indicate it overrides realloc, it uses this trait default. For libraries like cdylib and staticlib, System is guaranteed. The realloc free function in std::alloc and alloc::alloc forwards to the registered global allocator's realloc (or the default).

Citations:


🏁 Script executed:

# Check the repository structure and find related context
cd "$(git rev-parse --show-toplevel)" 2>/dev/null || true
find . -type f -name "*.rs" | grep -E "(alloc|bench)" | head -20

Repository: jlucaso1/whatsapp-rust

Length of output: 358


🏁 Script executed:

# Look at the actual CountingAlloc implementation and its usage
find . -name "counting_alloc.rs" -exec cat -n {} +

Repository: jlucaso1/whatsapp-rust

Length of output: 1723


🏁 Script executed:

# Check if there are any existing tests or usage of CountingAlloc
rg "CountingAlloc" -A 5 -B 5

Repository: jlucaso1/whatsapp-rust

Length of output: 1793


🏁 Script executed:

# Check what the benchmark does and if it might trigger realloc/alloc_zeroed
cd "$(git rev-parse --show-toplevel)" 2>/dev/null || true
cat -n tests/bench-integration/src/main.rs | head -100

Repository: jlucaso1/whatsapp-rust

Length of output: 3631


🏁 Script executed:

# Search for any explicit uses of realloc or alloc_zeroed in the codebase
rg "realloc|alloc_zeroed" -i --type rs

Repository: jlucaso1/whatsapp-rust

Length of output: 92


🏁 Script executed:

# Search for explicit uses of realloc or alloc_zeroed in the codebase
rg "realloc|alloc_zeroed" -i

Repository: jlucaso1/whatsapp-rust

Length of output: 254


🏁 Script executed:

# Check if any dependencies or standard library usage would trigger realloc/alloc_zeroed
# Look at what allocations might happen during benchmarks
cd "$(git rev-parse --show-toplevel)" 2>/dev/null || true
cat Cargo.toml | head -50

Repository: jlucaso1/whatsapp-rust

Length of output: 1368


🏁 Script executed:

# Check if Vec, String, or other containers that might use realloc are used in benchmarks
rg "Vec|String|realloc" tests/bench-integration/src/main.rs | head -30

Repository: jlucaso1/whatsapp-rust

Length of output: 164


Override realloc and alloc_zeroed to forward to System with counting.

The default GlobalAlloc::realloc implementation calls self.alloc() internally, which increments your allocation counters. This transforms every reallocation into a counted allocation event, inflating both ALLOC_COUNT and ALLOC_BYTES. Collections like Vec and String in the benchmark will trigger this, distorting the metrics you're measuring. alloc_zeroed has the same issue—it allocates via self.alloc() then zeros the memory.

Proposed fix
 unsafe impl GlobalAlloc for CountingAlloc {
     unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
         ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
         ALLOC_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed);
         unsafe { System.alloc(layout) }
     }
 
+    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
+        ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
+        ALLOC_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed);
+        unsafe { System.alloc_zeroed(layout) }
+    }
+
+    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
+        ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
+        ALLOC_BYTES.fetch_add(new_size as u64, Ordering::Relaxed);
+        unsafe { System.realloc(ptr, layout, new_size) }
+    }
+
     unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
         unsafe { System.dealloc(ptr, layout) }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/bench-integration/src/counting_alloc.rs` around lines 9 - 19, Implement
GlobalAlloc::realloc and alloc_zeroed on CountingAlloc so reallocations and
zeroed allocations forward to System without inflating ALLOC_COUNT: add an
unsafe fn realloc(&self, ptr: *mut u8, old_layout: Layout, new_size: usize) that
calls System.realloc(ptr, old_layout, new_size) and updates ALLOC_BYTES by
adding the positive difference when new_size > old_layout.size() or subtracting
when smaller (use fetch_add/fetch_sub on ALLOC_BYTES with Ordering::Relaxed),
and do NOT increment ALLOC_COUNT in realloc; also add unsafe fn
alloc_zeroed(&self, layout: Layout) that increments ALLOC_COUNT and ALLOC_BYTES
(layout.size()) and forwards to System.alloc_zeroed(layout) with
Ordering::Relaxed. Ensure you reference CountingAlloc, realloc, alloc_zeroed,
ALLOC_COUNT, ALLOC_BYTES, System.realloc and System.alloc_zeroed in the patch.

Comment thread tests/bench-integration/src/main.rs
Comment thread tests/e2e/src/lib.rs
Comment on lines +57 to 59
async fn connect_inner(_prefix: &str, push_name: Option<String>) -> anyhow::Result<Self> {
let backend = Arc::new(InMemoryBackend::new()) as Arc<dyn Backend>;
let transport_factory = TokioWebSocketTransportFactory::new().with_url(mock_server_url());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't hardwire the e2e harness to the in-memory backend.

This helper now stops exercising the sqlite-backed storage path entirely. Since this PR also changes storages/sqlite-storage/src/sqlite_store.rs, backend-specific regressions can pass e2e unnoticed. I'd keep the benchmark path on InMemoryBackend, but make the e2e helper backend-selectable or retain a sqlite-backed e2e lane for the default feature set.

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

In `@tests/e2e/src/lib.rs` around lines 57 - 59, The e2e helper connect_inner
currently hardcodes InMemoryBackend which skips the sqlite path; modify
connect_inner to accept a backend selector (e.g., an enum or Option<&str>
backend_type) or use cfg(feature="bench") to choose InMemoryBackend only for
benchmarks, and otherwise construct the sqlite-backed implementation (wrap as
Arc<dyn Backend>) so e2e tests exercise sqlite storage; update call sites that
invoke connect_inner (tests/bench harness) to pass the appropriate selector or
enable the bench feature, and keep
TokioWebSocketTransportFactory::new().with_url(mock_server_url()) usage
unchanged.

Comment thread wacore/binary/src/marshal.rs
Comment thread wacore/src/prekeys.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f47371abec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/handlers/message.rs Outdated
Comment on lines +44 to +46
let lane = if let Some(existing) = client.chat_lanes.get(&chat_jid).await {
existing
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use atomic lane initialization for new chat entries

This miss-path now does a separate get and later insert, which is racy under concurrent first messages for the same chat: two tasks can both observe a miss, each spawn its own worker/queue, and enqueue different stanzas to different lanes before one insert wins. That breaks the per-chat serialization guarantee in this handler (the reason this queue exists), so pkmsg/skmsg ordering can be inverted and decryption/retry behavior can become inconsistent.

Useful? React with 👍 / 👎.

Comment thread src/socket/noise_socket.rs Outdated
}

if let Err(e) = transport.send(out_buf).await {
let frame = bytes::Bytes::from(std::mem::take(out_buf));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep sender out buffer capacity between sends

Converting with Bytes::from(std::mem::take(out_buf)) moves the backing allocation out of the reusable buffer and leaves out_buf empty, so the next encode_frame_into call must allocate again. In high-throughput sends this defeats the sender-task buffer pooling introduced in this change and reintroduces per-message allocations in the hot path.

Useful? React with 👍 / 👎.

…enum

- Restore get_with_by_ref for chat lane creation to fix TOCTOU race
  that could create duplicate workers for the same chat. Extract init
  logic to create_chat_lane() helper to keep the future small.
- Fix out_buf reuse in NoiseSocket: use Bytes::copy_from_slice + clear
  instead of mem::take so the Vec retains capacity between sends.
- Change EncPayload.enc_type from String to EncType enum, eliminating
  one heap allocation per enc node and redundant from_wire() parsing.
- DRY EncPayload constructors via shared from_parts().
- Restore removed debug log for retry count pre-seeding.

@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

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

313-324: ⚠️ Potential issue | 🟠 Major

Serialize this path with message_enqueue_locks.

handle_incoming_message() still hands the classified message straight into process_classified_message() without a per-chat enqueue guard. That means two messages from the same chat can still classify/decrypt concurrently and let a later skmsg overtake the earlier pkmsg/SKDM, bringing back NoSenderKeyState and duplicate retry noise.

As per coding guidelines, "Use session_locks to serialize per-sender Signal encrypt/decrypt operations and message_enqueue_locks to serialize per-chat incoming message processing".

Also applies to: 518-692

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

In `@src/message.rs` around lines 313 - 324, handle_incoming_message currently
calls process_classified_message immediately after classification, allowing
concurrent processing of messages from the same chat; fix by acquiring the
per-chat enqueue lock from message_enqueue_locks (using the ClassifiedMessage's
chat identifier) before calling process_classified_message and hold it until
processing completes, ensuring serialization; keep the existing drop(node)
before the heavy phase, obtain the lock after classification and release it
after process_classified_message finishes so other messages for the same chat
are queued; update handle_incoming_message to use message_enqueue_locks with the
ClassifiedMessage returned by classify_incoming_message.
🤖 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/message.rs`:
- Around line 41-45: The code in from_owned_node is truncating out-of-range
padding versions by casting optional_u64("v") as u8 which wraps values (e.g. 256
-> 0); change this to validate the u64 before conversion and reject invalid
values instead of truncating: retrieve the optional u64, attempt a fallible
conversion (e.g. u8::try_from or .try_into()), and if conversion fails treat the
node as invalid (return None) or use the same clamping/validation semantics used
for count; apply the same fix to the other occurrence around the block at the
second padding-version read (the lines noted as 55-58) so both places explicitly
check range and do not silently wrap.
- Around line 32-35: EncPayload currently stores enc_type as a heap String and
is reparsed immediately in process_session_enc_batch(); change
EncPayload.enc_type to use the enum/type EncType directly, update all
constructors/builders that create EncPayload (search for new EncPayload {
enc_type: ... } and helpers that populate session_payloads/group_payloads) to
pass an EncType instead of a String, and remove the redundant parse in
process_session_enc_batch() so it consumes the EncType directly. Also update any
serde/serialization, trait bounds (Clone/Copy/Serialize/Deserialize) and
function signatures that assumed a String so they accept or clone EncType, and
apply the same change to the other payload structs/usages mentioned (the similar
structs/usages around the other occurrences referenced) so all consumers operate
on EncType rather than re-parsing a String.

In `@wacore/binary/src/zlib_pool.rs`:
- Around line 67-68: The code currently returns Ok(scratch.clone()) which
preserves the buffer's full capacity and can pin large allocations; instead
create a new Vec that only contains the used bytes so capacity isn't retained.
Replace the clone return with returning a trimmed copy (e.g. use
scratch.as_slice().to_vec() or scratch[..used_len].to_vec()) so the pooled
scratch buffer can keep its smaller capacity while callers get a right-sized
Vec; update the return in the function that constructs/returns scratch (the
variable named scratch) accordingly.

---

Duplicate comments:
In `@src/message.rs`:
- Around line 313-324: handle_incoming_message currently calls
process_classified_message immediately after classification, allowing concurrent
processing of messages from the same chat; fix by acquiring the per-chat enqueue
lock from message_enqueue_locks (using the ClassifiedMessage's chat identifier)
before calling process_classified_message and hold it until processing
completes, ensuring serialization; keep the existing drop(node) before the heavy
phase, obtain the lock after classification and release it after
process_classified_message finishes so other messages for the same chat are
queued; update handle_incoming_message to use message_enqueue_locks with the
ClassifiedMessage returned by classify_incoming_message.
🪄 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: f3273c5d-8fed-4a3b-b477-664e3817150a

📥 Commits

Reviewing files that changed from the base of the PR and between 79a282c and f47371a.

📒 Files selected for processing (3)
  • src/message.rs
  • tests/e2e/tests/digest_key.rs
  • wacore/binary/src/zlib_pool.rs

Comment thread src/message.rs
Comment thread src/message.rs Outdated
Comment on lines +41 to +45
pub(crate) fn from_owned_node(owner: &OwnedNodeRef, enc_node: &NodeRef<'_>) -> Option<Self> {
let raw = enc_node.content_bytes()?;
let ciphertext = owner.slice_bytes(raw);
let enc_type = enc_node.attrs().optional_string("type")?.to_string();
let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as u8;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Reject out-of-range padding versions instead of truncating them.

optional_u64("v") as u8 silently wraps malformed values (256 -> 0, etc.). On network input that can turn an invalid payload into an apparently valid padding version instead of skipping it cleanly. Validate the range explicitly here, the same way count is clamped above.

Also applies to: 55-58

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

In `@src/message.rs` around lines 41 - 45, The code in from_owned_node is
truncating out-of-range padding versions by casting optional_u64("v") as u8
which wraps values (e.g. 256 -> 0); change this to validate the u64 before
conversion and reject invalid values instead of truncating: retrieve the
optional u64, attempt a fallible conversion (e.g. u8::try_from or .try_into()),
and if conversion fails treat the node as invalid (return None) or use the same
clamping/validation semantics used for count; apply the same fix to the other
occurrence around the block at the second padding-version read (the lines noted
as 55-58) so both places explicitly check range and do not silently wrap.

Comment thread wacore/binary/src/zlib_pool.rs Outdated
Comment on lines +67 to +68
// Clone data out so scratch retains its capacity for the next call
Ok(scratch.clone())

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

Cap retained pooled buffer capacity after large payloads.

Keeping full capacity forever (scratch.clone()) can pin tens of MiB per thread after a single large history-sync payload.

♻️ Proposed refinement
+        const RETAIN_CAP: usize = 8 * 1024 * 1024;
         // Clone data out so scratch retains its capacity for the next call
-        Ok(scratch.clone())
+        let out = scratch.clone();
+        if scratch.capacity() > RETAIN_CAP {
+            scratch.shrink_to(RETAIN_CAP);
+        }
+        Ok(out)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Clone data out so scratch retains its capacity for the next call
Ok(scratch.clone())
const RETAIN_CAP: usize = 8 * 1024 * 1024;
// Clone data out so scratch retains its capacity for the next call
let out = scratch.clone();
if scratch.capacity() > RETAIN_CAP {
scratch.shrink_to(RETAIN_CAP);
}
Ok(out)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/binary/src/zlib_pool.rs` around lines 67 - 68, The code currently
returns Ok(scratch.clone()) which preserves the buffer's full capacity and can
pin large allocations; instead create a new Vec that only contains the used
bytes so capacity isn't retained. Replace the clone return with returning a
trimmed copy (e.g. use scratch.as_slice().to_vec() or
scratch[..used_len].to_vec()) so the pooled scratch buffer can keep its smaller
capacity while callers get a right-sized Vec; update the return in the function
that constructs/returns scratch (the variable named scratch) accordingly.

- zlib pool: enforce max_size cap before decompress_vec to prevent
  compressed bombs; zero-copy return via mem::take + re-reserve scratch
- extract_prekey_public_key: use last-one-wins semantics per protobuf
  spec; gracefully skip unknown wire types instead of returning None
- decode_varint: reject 10th byte with payload > 1 bit (overflow guard)
- marshal_ref_auto: mirror nested child list check from marshal_auto
  so both heuristics stay in sync
- CountingAlloc: implement realloc (adjusts bytes, not count) and
  alloc_zeroed (tracks both) for accurate measurement
- bench-integration: precompute format! strings outside measured closure
- sqlite store_prekeys_batch: keep Bytes through retry loop, only
  call as_ref() at diesel bind site
- CI workflow: split into PR (read-only) and push (write) jobs with
  least-privilege permissions

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

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

279-300: 🧹 Nitpick | 🔵 Trivial

Heads-up: Potential test flakiness in FIFO ordering assertion.

The test spawns 10 concurrent tasks and expects the recorded order to be exactly [0,1,2,...,9]. While the sender channel preserves FIFO order for jobs as they arrive, there's no guarantee that spawned tasks will call encrypt_and_send in their spawn order—task scheduling is non-deterministic.

In practice this often passes because tasks execute their first await quickly, but it could flake under load. Consider either:

  • Removing the strict ordering assertion (verify all 10 completed, order can vary), or
  • Introducing synchronization (e.g., a barrier or sequential awaits) to enforce enqueue order if strict FIFO testing is the goal.

Since this is pre-existing test logic (only the Bytes conversion changed), this is informational rather than blocking.

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

In `@src/socket/noise_socket.rs` around lines 279 - 300, The FIFO ordering
assertion is flaky because spawned tasks may call encrypt_and_send out of spawn
order; update the test around the spawn loop and the assert on recorded_order to
either (A) remove the strict ordering check and only assert that all 10 distinct
ids were recorded (i.e., verify length and contents ignoring order), or (B)
enforce enqueue order by synchronizing task start before calling
socket.encrypt_and_send (e.g., create a tokio::sync::Barrier or have the loop
await each send sequentially) so that the calls into encrypt_and_send occur in
deterministic order; adjust the code that creates handles (the for i in 0..10 {
... tokio::spawn(...) }) and the final assertion on recorded_order to implement
your chosen approach.
♻️ Duplicate comments (1)
src/message.rs (1)

40-40: ⚠️ Potential issue | 🟡 Minor

Padding version truncation issue persists.

The cast optional_u64("v") as u8 silently wraps out-of-range values (e.g., 256 → 0). On network input, this could turn an invalid payload into an apparently valid padding version. Validate the range explicitly before conversion.

🛡️ Proposed fix to validate padding version range
-        let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as u8;
+        let padding_version = enc_node
+            .attrs()
+            .optional_u64("v")
+            .unwrap_or(2)
+            .try_into()
+            .ok()
+            .filter(|&v: &u8| v <= 2)?; // Known versions are 0, 1, 2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/message.rs` at line 40, The current cast let padding_version =
enc_node.attrs().optional_u64("v").unwrap_or(2) as u8 can silently wrap
out-of-range values; change it to read the optional_u64 result into a temporary
(e.g., let v_opt = enc_node.attrs().optional_u64("v")), then if Some(v) validate
v is <= u8::MAX before converting to u8 (otherwise return/propagate an error or
reject the payload); keep the default of 2 when None. Ensure you reference
enc_node.attrs().optional_u64("v") and the padding_version variable when
implementing the explicit range check and error handling.
🤖 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/handlers/message.rs`:
- Line 85: The worker loop is unnecessarily boxing the future returned by the
async function client.handle_incoming_message; remove the Box::pin(...) wrapper
and await the future directly (i.e., replace
Box::pin(client.handle_incoming_message(msg_node)).await with
client.handle_incoming_message(msg_node).await) to avoid the heap allocation in
this hot path, and remove any now-unused imports or allocations introduced
solely for boxing.

---

Outside diff comments:
In `@src/socket/noise_socket.rs`:
- Around line 279-300: The FIFO ordering assertion is flaky because spawned
tasks may call encrypt_and_send out of spawn order; update the test around the
spawn loop and the assert on recorded_order to either (A) remove the strict
ordering check and only assert that all 10 distinct ids were recorded (i.e.,
verify length and contents ignoring order), or (B) enforce enqueue order by
synchronizing task start before calling socket.encrypt_and_send (e.g., create a
tokio::sync::Barrier or have the loop await each send sequentially) so that the
calls into encrypt_and_send occur in deterministic order; adjust the code that
creates handles (the for i in 0..10 { ... tokio::spawn(...) }) and the final
assertion on recorded_order to implement your chosen approach.

---

Duplicate comments:
In `@src/message.rs`:
- Line 40: The current cast let padding_version =
enc_node.attrs().optional_u64("v").unwrap_or(2) as u8 can silently wrap
out-of-range values; change it to read the optional_u64 result into a temporary
(e.g., let v_opt = enc_node.attrs().optional_u64("v")), then if Some(v) validate
v is <= u8::MAX before converting to u8 (otherwise return/propagate an error or
reject the payload); keep the default of 2 when None. Ensure you reference
enc_node.attrs().optional_u64("v") and the padding_version variable when
implementing the explicit range check and error handling.
🪄 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: a6f407fa-427b-4fa9-8a00-95ec85fb36d9

📥 Commits

Reviewing files that changed from the base of the PR and between f47371a and 1563f49.

📒 Files selected for processing (3)
  • src/handlers/message.rs
  • src/message.rs
  • src/socket/noise_socket.rs

Comment thread src/handlers/message.rs
}
let start = wacore::time::now_millis() as u64;
let client = client_for_worker.clone();
Box::pin(client.handle_incoming_message(msg_node)).await;

@coderabbitai coderabbitai Bot Apr 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify handle_incoming_message signature and current call sites.
# Expectation: if it returns an awaitable future directly, Box::pin is unnecessary.

set -euo pipefail

rg -n -C3 '\bhandle_incoming_message\s*\(' --type rust

Repository: jlucaso1/whatsapp-rust

Length of output: 3552


Remove unnecessary Box::pin() wrapping in the worker loop.

handle_incoming_message is an async fn that returns a future directly; it can be awaited without boxing. All other call sites in the codebase use direct .await, and the heap allocation from Box::pin() is unnecessary in this hot path.

♻️ Suggested change
-                let client = client_for_worker.clone();
-                Box::pin(client.handle_incoming_message(msg_node)).await;
+                let client = client_for_worker.clone();
+                client.handle_incoming_message(msg_node).await;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/message.rs` at line 85, The worker loop is unnecessarily boxing
the future returned by the async function client.handle_incoming_message; remove
the Box::pin(...) wrapper and await the future directly (i.e., replace
Box::pin(client.handle_incoming_message(msg_node)).await with
client.handle_incoming_message(msg_node).await) to avoid the heap allocation in
this hot path, and remove any now-unused imports or allocations introduced
solely for boxing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The Box::pin here is intentional -- handle_incoming_message takes self: Arc and returns a large async future. Without Box::pin, that future is inlined into the worker loop's state machine, making the outer spawned future ~29KB (the exact problem we were trying to fix). With Box::pin, the outer future stays small and the large message-handling future is heap-allocated only when actually processing a message.

This is the opposite of unnecessary -- it's a deliberate optimization. Removing it would undo the future-size reduction we achieved.

The Box::pin on line 73 (runtime.spawn(Box::pin(async move { ... }))) is required by the spawn API which takes Pin<Box>.

No change needed here. The review suggestion is incorrect for this case.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@jlucaso1, you're absolutely right — thank you for the detailed explanation. The Box::pin is a well-known technique for controlling async state machine sizes: without it, the compiler inlines the entire handle_incoming_message future into the worker loop's state machine, bloating every spawned task instance regardless of whether a message is currently being processed. With Box::pin, the outer task remains compact and the large future is only heap-allocated on demand. My suggestion was incorrect. No change needed.


✏️ Learnings added
Learnt from: jlucaso1
Repo: jlucaso1/whatsapp-rust PR: 551
File: src/handlers/message.rs:85-85
Timestamp: 2026-04-15T16:06:29.137Z
Learning: In `src/handlers/message.rs` (whatsapp-rust), the `Box::pin(client.handle_incoming_message(msg_node)).await` pattern inside the per-chat worker loop is intentional and must NOT be removed. `handle_incoming_message` returns a large async future; without `Box::pin`, the Rust compiler inlines that future into the outer worker loop's state machine, inflating every spawned task to ~29KB. `Box::pin` keeps the outer future small and heap-allocates the large inner future only when a message is actually being processed. Removing this would undo a deliberate future-size optimization. Do not flag this as unnecessary boxing.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: jlucaso1
Repo: jlucaso1/whatsapp-rust PR: 506
File: src/message.rs:309-329
Timestamp: 2026-04-08T14:24:14.247Z
Learning: In whatsapp-rust (src/message.rs), when an `<unavailable>` child node is present in an incoming message, retry receipts (RetryReason::NoSession or any other reason) must NOT be sent. The correct recovery is PDO-only: call spawn_pdo_request_with_options and dispatch an UndecryptableMessage event. Sending a retry receipt here is protocol-incorrect because <unavailable> is a server-side routing failure (bare-JID fanout to companion devices), not a Signal session failure. This matches WA Web and whatsmeow behavior. Do not suggest routing the <unavailable> path through handle_decrypt_failure.

Learnt from: jlucaso1
Repo: jlucaso1/whatsapp-rust PR: 511
File: src/send.rs:658-661
Timestamp: 2026-04-11T04:49:14.681Z
Learning: In whatsapp-rust (src/send.rs), response_waiters uses a simple String key (message_id) for phash ACK waiters. This is intentional and safe: every send path produces a structurally unique ID via generate_message_id(), so the same ID can never be registered twice within the 10-second waiter window. The SendOptions.message_id override (for explicit caller-controlled resends) does not create a collision risk because any prior waiter for that ID is always removed before a retry. Adding compound registration tokens would be unnecessary complexity. This matches WhatsApp Web's own ackHandlers (ackHandlers array in Comms.js). Do not flag response_waiters cleanup as a race condition.

Learnt from: jlucaso1
Repo: jlucaso1/whatsapp-rust PR: 425
File: src/send.rs:111-119
Timestamp: 2026-03-22T23:20:20.069Z
Learning: In `src/send.rs` (whatsapp-rust), `SendOptions.extra_stanza_nodes` is intentionally a transparent pass-through. The `infer_stanza_metadata` helper always prepends its inferred `<meta>` node without checking for existing nodes in `extra_stanza_nodes`. Deduplication/idempotency is deliberately NOT performed — if a caller provides their own `<meta>` node alongside a poll/event message, it is treated as intentional and both nodes are sent. Do not flag this as a bug.

Learnt from: jlucaso1
Repo: jlucaso1/whatsapp-rust PR: 506
File: src/message.rs:310-321
Timestamp: 2026-04-08T14:22:21.072Z
Learning: In whatsapp-rust (src/message.rs), all `<unavailable>` child node types — including `type="view_once"` (UnavailableType::ViewOnce) and the default Unknown — are handled identically: they all unconditionally trigger spawn_pdo_request_with_options and an UndecryptableMessage event. This matches WA Web and whatsmeow behavior. Do not suggest gating the PDO request path based on unavailable_type.

Learnt from: jlucaso1
Repo: jlucaso1/whatsapp-rust PR: 316
File: src/handlers/notification.rs:620-628
Timestamp: 2026-03-11T16:19:23.298Z
Learning: In Rust code path src/handlers/, for hash-based picture notifications, avoid relying on upgrading to a JID via from.clone() as a general fallback. This should be treated as a temporary approximation only in rare edge cases where a contact hash has no JID. A proper fix would involve a contact hash registry (e.g., getContactRecordByHash()) backed by address book sync. Until such infrastructure exists, document this fallback's as-is behavior and consider revisiting for maintainability and correctness in future refactors. This guidance applies broadly to similar files handling hash-based notifications in src/handlers, not only to this exact function.

@github-actions

github-actions Bot commented Apr 15, 2026

Copy link
Copy Markdown

Benchmark Results

1 improvement(s):

Benchmark Current Baseline Change
binary_benchmark::unpack_group::bench_unpack_compressed 526,732 556,229 -5.3%
58 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,933 3,933 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 12,038 12,038 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,414 43,414 +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,478 68,478 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,578 76,578 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,230 2,230 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,988 5,988 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 170,037 170,577 -0.3%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 191,890 191,742 +0.1%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 875,955 875,947 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 966,985 966,903 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,453,874 1,454,032 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,585,366 2,586,009 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 9,389,170 9,422,939 -0.4%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 44,697,913 44,695,314 +0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,642,910 12,742,223 -0.8%
binary_benchmark::marshal_group::bench_marshal_allocating 71,236 71,207 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 71,289 71,240 +0.1%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 98,357 98,328 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 78,791 78,762 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 71,336 71,307 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 7,517 7,518 -0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 7,560 7,562 -0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 9,273 9,274 -0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 530,503 530,499 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 530,067 530,066 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 531,423 531,423 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 8,506,182 8,507,096 -0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 8,450,483 8,451,427 -0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 19,678,088 19,679,084 -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 787 788 -0.1%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 5,028 5,024 +0.1%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 5,330 5,330 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 66,324 66,315 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 5,357 5,357 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 66,390 66,351 +0.1%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 6,734 6,734 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 89,639 89,630 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 477,570 477,570 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 11,605 11,599 +0.1%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,256,169 17,258,585 -0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 157,923 157,923 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,511,084 5,511,084 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 158,737 158,737 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 296,767 296,767 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 707,098 707,098 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,660,472 12,690,090 -0.2%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,548,205 27,564,884 -0.1%
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,242,113 124,986,423 -0.6%
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,003 46,003 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,090,932 5,090,932 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 316,987 316,987 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,254,317 14,254,317 +0.0%

@Salientekill

Copy link
Copy Markdown
Contributor

Depois da uma olhada na questão de keys dos grupos, dps de um tempo ele tem um grande delay para responder a mensagem, mas as seguintes são de boas

@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)
storages/sqlite-storage/src/sqlite_store.rs (1)

1283-1324: 🧹 Nitpick | 🔵 Trivial

Avoid cloning the full prekey batch vector on each retry attempt.

keys.clone() in the retry loop still reallocates/copies tuple metadata each attempt. You can keep one owned batch and clone only an Arc pointer per retry.

♻️ Proposed refactor to reduce retry-path allocations
-        let keys: Vec<(u32, Bytes)> = keys.to_vec();
+        let keys: Arc<[(u32, Bytes)]> = keys.to_vec().into();

@@
-            let keys_clone = keys.clone();
+            let keys_clone = Arc::clone(&keys);

@@
-                        for (id, record) in &keys_clone {
+                        for (id, record) in keys_clone.iter() {
                             diesel::insert_into(prekeys::table)
                                 .values((
                                     prekeys::id.eq(*id as i32),
                                     prekeys::key.eq(record.as_ref()),
                                     prekeys::uploaded.eq(uploaded),
                                     prekeys::device_id.eq(device_id),
                                 ))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storages/sqlite-storage/src/sqlite_store.rs` around lines 1283 - 1324,
store_prekeys_batch currently clones the full keys Vec each retry via
keys.clone(); instead wrap the owned Vec<(u32, Bytes)> in an Arc once (e.g. let
keys = Arc::new(keys);) before the retry loop and inside the loop clone only the
Arc (let keys_clone = Arc::clone(&keys)); update the spawn_blocking closure
signature to move the Arc<Vec<...>> and iterate over &*keys_clone (or
keys_clone.iter()) instead of cloning the Vec, leaving pool_clone and device_id
handling unchanged; this eliminates reallocations on each retry while preserving
ownership for the blocking task.
♻️ Duplicate comments (2)
.github/workflows/bench-integration.yml (1)

29-29: 🧹 Nitpick | 🔵 Trivial

Pin the Docker image and action to immutable refs.

The workflow still uses mutable references that can drift:

  • bartender:latest tag can change without notice
  • dtolnay/rust-toolchain@master is a moving target

These make benchmark baselines non-reproducible. Pin to a digest for the image and a commit SHA for the action.

-        image: ghcr.io/whiskeysockets-devtools/bartender:latest
+        image: ghcr.io/whiskeysockets-devtools/bartender@sha256:<verified-digest>
...
-      - uses: dtolnay/rust-toolchain@master
+      - uses: dtolnay/rust-toolchain@<commit-sha>
         with:
           toolchain: nightly-2026-04-05

Also applies to: 41-43, 113-113, 125-127

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

In @.github/workflows/bench-integration.yml at line 29, Replace mutable refs
with immutable pins: for the Docker image reference
"ghcr.io/whiskeysockets-devtools/bartender:latest" replace the :latest tag with
a specific image digest (sha256@...) and for the GitHub Action usage
"dtolnay/rust-toolchain@master" replace the branch ref with a specific commit
SHA; update every occurrence of these mutable refs (the other occurrences of the
bartender image and dtolnay/rust-toolchain in the workflow) so the workflow uses
the digest and commit SHA consistently to ensure reproducible benchmarks.
tests/bench-integration/src/main.rs (1)

258-271: ⚠️ Potential issue | 🟡 Minor

The receive benchmark still has format! inside the measured closure.

The amortized round-trip measurement includes harness allocations from format!("bench-recv-{i}") at line 262. Unlike the send benchmark which was fixed, this still inflates alloc_count and alloc_bytes.

Proposed fix
     // -- Amortized N round-trips --
     const N: u64 = 20;
+    let recv_texts: Vec<String> = (0..N).map(|i| format!("bench-recv-{i}")).collect();
     let m = measure(async || {
-        for i in 0..N {
-            let text = format!("bench-recv-{i}");
+        for text in &recv_texts {
             client_a
                 .client
-                .send_message(jid_b.clone(), text_msg(&text))
+                .send_message(jid_b.clone(), text_msg(text))
                 .await?;
-            client_b.wait_for_text(&text, 30).await?;
+            client_b.wait_for_text(text, 30).await?;
         }
         Ok(())
     })
     .await?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/bench-integration/src/main.rs` around lines 258 - 271, The measured
closure for the receive benchmark still performs allocations via
format!("bench-recv-{i}") inside the async closure passed to measure; move that
allocation out of the measured region by precomputing the N message strings
(e.g., build a Vec<String> of format!("bench-recv-{i}") for i in 0..N) before
calling measure, then inside the closure iterate over those precomputed strings
and call client_a.client.send_message(jid_b.clone(), text_msg(&precomputed[i]))
and client_b.wait_for_text(&precomputed[i], 30).await? to avoid inflating
alloc_count/alloc_bytes; keep the existing symbols measure, N, client_a,
client_b, and text_msg when locating where to change.
🤖 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 `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 1283-1324: store_prekeys_batch currently clones the full keys Vec
each retry via keys.clone(); instead wrap the owned Vec<(u32, Bytes)> in an Arc
once (e.g. let keys = Arc::new(keys);) before the retry loop and inside the loop
clone only the Arc (let keys_clone = Arc::clone(&keys)); update the
spawn_blocking closure signature to move the Arc<Vec<...>> and iterate over
&*keys_clone (or keys_clone.iter()) instead of cloning the Vec, leaving
pool_clone and device_id handling unchanged; this eliminates reallocations on
each retry while preserving ownership for the blocking task.

---

Duplicate comments:
In @.github/workflows/bench-integration.yml:
- Line 29: Replace mutable refs with immutable pins: for the Docker image
reference "ghcr.io/whiskeysockets-devtools/bartender:latest" replace the :latest
tag with a specific image digest (sha256@...) and for the GitHub Action usage
"dtolnay/rust-toolchain@master" replace the branch ref with a specific commit
SHA; update every occurrence of these mutable refs (the other occurrences of the
bartender image and dtolnay/rust-toolchain in the workflow) so the workflow uses
the digest and commit SHA consistently to ensure reproducible benchmarks.

In `@tests/bench-integration/src/main.rs`:
- Around line 258-271: The measured closure for the receive benchmark still
performs allocations via format!("bench-recv-{i}") inside the async closure
passed to measure; move that allocation out of the measured region by
precomputing the N message strings (e.g., build a Vec<String> of
format!("bench-recv-{i}") for i in 0..N) before calling measure, then inside the
closure iterate over those precomputed strings and call
client_a.client.send_message(jid_b.clone(), text_msg(&precomputed[i])) and
client_b.wait_for_text(&precomputed[i], 30).await? to avoid inflating
alloc_count/alloc_bytes; keep the existing symbols measure, N, client_a,
client_b, and text_msg when locating where to change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 37760ed8-e210-47ca-8457-305762230a9a

📥 Commits

Reviewing files that changed from the base of the PR and between 1563f49 and 2ba5f6e.

📒 Files selected for processing (7)
  • .github/workflows/bench-integration.yml
  • storages/sqlite-storage/src/sqlite_store.rs
  • tests/bench-integration/src/counting_alloc.rs
  • tests/bench-integration/src/main.rs
  • wacore/binary/src/marshal.rs
  • wacore/binary/src/zlib_pool.rs
  • wacore/src/prekeys.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ba5f6e72e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread wacore/src/prekeys.rs
Comment on lines +52 to +53
let len = len as usize;
if pos + len > record.len() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard length arithmetic in prekey parser

extract_prekey_public_key casts a varint length directly to usize and then does pos + len without checked arithmetic. A malformed/corrupted prekey record with a very large length can overflow this addition and panic during digest validation, where the previous prost-based decode path would have returned an error and continued safely. This should use checked conversion/addition and return None on overflow.

Useful? React with 👍 / 👎.

Comment on lines +4 to +5
pull_request:
branches: [main]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use a secret-capable trigger for benchmark PR job

This workflow runs on pull_request, but the PR benchmark job authenticates the service container with secrets.BARTENDER_GHCR_TOKEN. For PRs from forks, repository secrets are not provided, so the mock-server service cannot authenticate/pull and the benchmark job fails before tests run. That makes the new benchmark CI path unreliable for external contributions.

Useful? React with 👍 / 👎.

The retry resend stanza to attribute was using the raw from JID
from the retry receipt, which can include a device suffix (e.g. :33)
depending on server behavior. The device-specific routing is already
handled by the participants/to child node, so the outer to should be
the bare user-level chat target.

Normalizes original_from to the bare chat JID, matching the normal
send path and preventing flaky failures when the server includes
device suffixes in receipt JIDs.

@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/retry.rs`:
- Around line 161-166: The field original_from is being assigned the normalized
bare JID (`chat`) but its name/docs still imply a raw `from`; update the field
and docs to reflect normalization to avoid routing mistakes: either rename the
field to something like `normalized_from` (and update all references/usages) or
change the doc comment on `original_from` to explicitly state it contains the
normalized bare JID used for stanza `to` (no device suffix), and adjust the
inline comment in retry.rs where `original_from: chat` is set to mention
normalization; ensure tests and any serialization/deserialization or storage
code that reference `original_from` are updated to the new name or semantics.
🪄 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: 6335775b-2a53-4910-b750-0c4586c52e8e

📥 Commits

Reviewing files that changed from the base of the PR and between 2ba5f6e and 8c599aa.

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

Comment thread src/retry.rs Outdated
WA Web uses the raw receipt from JID (with device suffix) as the retry
stanza to attribute (confirmed in WAWebHandleRetryRequest.js line 30/97).
The mock server may assign non-zero device IDs under CI load, making
the to JID device-qualified. Changed assertion to check user part only
via is_same_user_as instead of exact string match.
…ice suffix)

Reverts the normalization from 8c599aa. WA Web's HandleRetryRequest.js
captures m = e.from (raw, with device suffix) at line 30 BEFORE any
normalization, then uses to: m at line 97 for the retry stanza.

The e2e test flake was already fixed in c1abd3d by relaxing the
assertion to is_same_user_as instead of exact string match.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 36224a8cc2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +34 to +38
if scratch.len() >= cap {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("decompressed payload exceeds {max_size} bytes"),
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset pooled scratch on zlib error exits

This return path exits before the function reaches the std::mem::take(scratch) reset at the end, so any malformed/oversized stream that has already grown scratch leaves a large thread-local allocation pinned for future calls. In history_sync (64 MiB cap), one bad payload can permanently retain tens of MiB per runtime thread, which is a production memory-footprint regression; reset or shrink the pooled buffer on all error paths, not only on successful StreamEnd.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 merged commit 2641743 into main Apr 15, 2026
12 checks passed
@jlucaso1
jlucaso1 deleted the feat/bench-integration branch April 15, 2026 16:53
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