Skip to content

perf: five cuts to the per-message allocation count - #1131

Merged
jlucaso1 merged 16 commits into
mainfrom
perf/alloc-round3
Jul 26, 2026
Merged

perf: five cuts to the per-message allocation count#1131
jlucaso1 merged 16 commits into
mainfrom
perf/alloc-round3

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Five independent cuts to the DM round trip, one commit each. A sixth was implemented, measured, and reverted inside this PR; that is explained below because the reason is more useful than the change would have been. They come from two independent analyses of the same dhat profile (one Opus, one Codex, neither aware of the other), and the split is worth noting: apart from the ProtocolAddress reuse, they found different things. Every per-message figure below was confirmed against the profile before implementing.

Denominator throughout is 145.84 blocks/msg, the library's share; the raw 152.86 includes 7.02 owned by the harness client.

The cuts

1. One request_id per send. The same id existed three times: cloned into SendResult, cloned again for the message-secret write, and re-read from the stanza attribute into a fresh String. SendPipelineOptions.request_id is now Option<&str>, so the outer frame holds the only String and lends it out. Four off-hot-path callers stopped cloning too.

Both original invariants survive: the phash waiter is still registered before send_node (a fast link can ACK while it is still returning), and the stanza remains the authority on its own id, now enforced with a debug_assert_eq! instead of an owned re-read.

2. The DM fan-out writes into the stanza it is filling. prepare_dm_stanza already sized its final vector, but each of the two fan-outs per message built a Vec<Node> and a Vec<Jid> first. A new encrypt_for_devices_into appends straight into that vector. EncryptResult (which groups need for the SKDM partial-distribution check) and EncryptForDevicesRaw (VoIP) are untouched.

3. Lock each session as its mutex is resolved. session_mutexes_for materialised a Vec<Arc<Mutex<()>>> and then a second vector of guards. Only the first is removable, and acquisition order is unchanged, which matters because that order is what prevents deadlock: a test now fails if the loop reverses.

4. (reverted) Carry the session phase's address into the encrypt. Implemented, then measured against the rest of the branch and dropped: 128.70 allocations per message without it, 129.86 with it, i.e. no saving and not distinguishable from noise (t = 0.90).

The reason is item 6, further down. When this was written, building a ProtocolAddress meant a 64-byte heap allocation, so handing the session phase's buffer to the encrypt saved one. Once the buffer moved inline, the second construction allocates nothing, and all that threading an Option<ProtocolAddress> through SessionPlan buys is 72 bytes of state and a rewrite that has to be exactly right.

Its tests stayed. They pin that a cold PN device the session phase upgraded to LID encrypts against the LID session, which is a property of the send path rather than of the optimisation, and they pass unchanged without it. That case is also what caught a real bug while the optimisation still existed: the obvious version reuses the buffer's contents, which on that upgrade path leaves the PN name in front of a session that now lives under the LID address.

5. Copy each whitelisted reporting-token field once. The extractor staged a Vec<(u32, Vec<u8>)> plus a to_vec() per flat field before sorting by field number. Now a SmallVec<[(u32, Piece); 4]> where Piece is either a Range into the input or an owned buffer for nested fields. Three allocations become one, and the token bytes are unchanged: the existing golden tests pass untouched.

6. Keep a protocol address in the value itself. ProtocolAddress was { String, name_len, device_id } with a 64-byte heap allocation for a name of ~20-32 characters. It is now inline up to 47 bytes, spilling to a String beyond that.

Eq/Ord/Hash read as_str() only, so an inline key finds a spilled entry in the session cache. This is the property that makes the whole change safe rather than a silent cache-miss generator, and it is tested through a real HashMap.

Recorded so nobody re-derives it: compact_str does not work here. Its inline limit is 24 bytes and a PN address ("5511987650001:5@s.whatsapp.net.0") is 32.

ProtocolAddress grows 40 -> 72 bytes. The send_futures_stay_small gate (<=192 B) still passes and no other size assertion in the tree touches it.

Measurements

Harness pingpong, 120k messages at 12k/s, MODE=rss, three interleaved ABBA/BAAB pairs against main, pre-built binaries with the sha256 recorded per run. Every run had lost=0 and ack=120000.

metric main branch delta Welch t
allocator calls per message 145.28 (sd 0.36) 128.12 (sd 0.95) -11.81% -28.33
bytes requested per message 28 881 (sd 10) 27 415 (sd 39) -5.08% -62.83

17.16 allocations per message removed. One baseline run is excluded: its wchar came in at 115 MB against ~82 MB for every other run, which is host I/O during that window rather than branch behaviour. With it included the figures are -12.47% and -6.49%, the same story with a worse denominator.

Worth noting against the earlier revision of this PR: with the reverted item still in, the same measurement gave 17.26 allocations per message. Removing it changed the total by 0.10, which is the cleanest evidence that it was not contributing.

The byte figure is the largest of this campaign, and item 6 is doing that work: the other cuts remove small allocations, while the inline address removes a 64-byte heap block per construction.

No CPU claim. The host had competing load during these runs, so any timing from them is worthless; allocation counts survive that because they are deterministic. Earlier profiling on this workload put malloc+free at ~2.8% with the bottleneck in curve25519, so expect little to no throughput change.

Testing

42 mutants killed across the items. The ones worth naming, because they are the failure modes that would not show up as a crash:

Mutation Caught by
reuse the address buffer without rewriting it a_cold_pn_device_upgraded_to_lid_encrypts_against_the_new_lid_session (the bug the reverted item would have shipped)
Hash reads the representation instead of the contents the_same_characters_compare_and_hash_alike_from_either_representation
acquire session locks in reverse order the acquisition-order test added with item 3
drop the sort in the token extractor the golden token tests
waiter registered under a different key the phash waiter tests

Two of those were re-run independently on this branch after the rebase, and both still fail under mutation, so the coverage is not accidental.

Worth recording that two tests failed to kill their mutant on the first attempt and had to be rewritten: the Piece::len test landed on the same size after the vector doubled, hiding an understated reservation, and the item-4 tests missed the PN-to-LID case entirely for the store reason above. A test that passes against the broken version proves nothing, and both were caught by actually running the mutation rather than assuming.

Edge cases covered across the items: empty device list, one device, several devices; empty message id; unicode ids; a reporting token with no fields and one with a nested field; an address that fits inline, one that spills, and equality/hash between the two representations.

Verification

cargo fmt --all --check, cargo clippy --workspace --all-targets with zero warnings, and green suites: whatsapp-rust 1230, wacore 1262, wacore-libsignal 195, wacore-binary 113.

Review findings folded in after opening

  • Debug printed the buffer, not the address. Resetting an address only rewinds the length, so the tail of an inline buffer still held the bytes of whoever it named before. The derived Debug printed the whole array, which means a log line formatting an address, or an error embedding one such as SessionNotFound, could show an unrelated peer's JID.
  • ProtocolAddress::empty collided as a map key. Hash, Eq and Ord read the rendered string, so empty(1) and empty(2) compared alike. The device suffix is written up front now.
  • Two tests asserted an order the code does not guarantee. Within one fan-out half the devices drain from a FuturesUnordered in completion order; those tests passed only because the current-thread runtime never let the futures pend. They now assert the half boundary, which is guaranteed, and membership within each half, plus a count so a set cannot hide a duplicate.
  • A test depended on allocator behaviour. capacity() == len() can fail on an allocator that rounds a request up. The property worth pinning, that the reservation matches the bytes written, is now a debug_assert inside the extractor that every calling test exercises.
  • session_guards_for trusted its callers. A duplicate key would await a lock the same call already holds: a send that never returns rather than a panic. A debug_assert makes it fail loudly.

Deliberately left out

  • The third vector in the encrypt fan-out (~2/msg). Removing it needs a sink threaded through the raw fan-out, which is the path groups and VoIP share; that belongs in its own PR rather than riding along with item 2.
  • Narrowing ProtocolAddress to 64 bytes (inline 39 + a u16 length). It would fit a cache line but trades inline coverage on a type that is mostly a map key. One constant if wanted.

jlucaso1 added 6 commits July 26, 2026 17:47
The id a send is known by existed three times: a copy for the returned
SendResult, a copy kept for the post-send messageSecret write, and a
third read back out of the finished stanza's attributes because the
original had already been moved into a branch builder.

Lend it instead. The outer frame keeps the only String and hands the
branch requests, the phash ack-waiter and the secret persistence a
&str, so downstream callers that already own an id (peer PDO, app
state key requests, group member labels) stop copying it as well.

The two properties that made the third copy look necessary are kept:
the ack-waiter is still registered before the stanza reaches the
socket, and the stanza is still the authority on the id it carries --
now asserted in debug builds rather than re-read on every send.
A DM already sizes one participant vector for its whole fan-out, then
threw two more away filling it: each half built a node list and a list
of the devices it encrypted for, only to be moved into that vector and
dropped. Two halves per message, so four vectors per message.

The device list exists for the group path, which compares it against
the distribution list to tell a partial SKDM from a complete one. A DM
never asks that, so it gets its own entry point that appends straight
into the caller's buffer and reports only the two batch flags. The
shared EncryptResult and the raw form the VoIP offer uses are
untouched.
Every caller that needed the per-device session locks resolved the
whole handle set into a vector and then walked it locking each one.
The handle vector existed only to be walked: nothing reads a mutex it
does not immediately lock.

Locking as each handle is resolved drops that vector and leaves the
guard vector, which has to stay -- it is what holds the locks open for
the caller's scope. Acquisition order is unchanged and stays the
caller-supplied (sorted) order, which is the only thing keeping two
overlapping sends from deadlocking; it now has a test that fails if
the loop ever runs the other way.

The handle-only helper survives for tests that inspect the mutexes
without taking them.
A one-device fan-out named the same device twice: the session phase
built a scratch address to ask the store whether a session existed,
dropped it, and the encrypt built a second one for the address it had
just been told about.

The plan now carries that buffer forward, so the single-device branch
rewrites it instead of allocating. It is a rewrite, not a reuse of the
contents: on a cold PN device the session phase leaves the buffer
holding the PN name while the session it just created lives under the
device's LID address, so the encrypt must restate which address it
wants.

The multi-device branch is unchanged -- its per-job addresses outlive
the call in spawned tasks -- and a plan built by assume_ready carries
no buffer and keeps building its own.
Field extraction copied every flat field into a vector of its own, put
those in a heap-allocated staging list, then copied them a second time
into the result. Three allocations to answer a question whose answer,
for a plain text message, is one slice of the input.

A flat field is now recorded as a range into the input and copied
straight into the result; only a nested field, which is re-framed under
a fresh tag and length, still owns bytes. The staging list is inline
for the field counts a real message has and spills only past four.

The token's bytes are a wire contract, so what did not change: the sort
is still by ascending field number and still stable, so repeats of one
field keep their wire order, and the concatenation is byte for byte the
same.
Every protocol address was a 64-byte heap allocation for a string that
fits in a cache line: a real one is `"5511987650001:5@c.us.0"`, 22
bytes. The send path builds several per message -- one to ask the store
about a session, one per encrypt, one per lock key -- and each of them
paid for a buffer it then filled with twenty-odd characters.

The buffer is now inline up to 47 bytes and spills to a String beyond
that, so an address that outgrows it is no worse off than every address
used to be. Which arm holds the characters is deliberately not part of
the value: equality, ordering and hashing all read the string, because
the type is a HashMap key in the session cache and an inline key must
find a spilled entry.

Two things follow from the buffer no longer being a String. The address
format has one writer serving both a plain String and this buffer, so
it takes a small sink trait rather than existing twice. And building an
address from a JID writes into it directly instead of formatting a
String to copy in and drop -- which is what removes the allocation from
the paths that build one per message.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds inline/heap-backed protocol-address storage, borrows send request IDs, centralizes session guards, writes DM fan-out nodes into existing buffers, reduces reporting-token allocations, and updates dependency declarations and call sites.

Changes

Core send and buffer changes

Layer / File(s) Summary
Inline protocol-address storage and reuse
wacore/libsignal/src/core/address.rs, wacore/src/types/jid.rs, related tests
ProtocolAddress uses AddressBuf; address writers support reusable sinks and callers pass borrowed strings.
Borrowed request-ID send pipeline
src/send/mod.rs, src/client/..., src/features/groups.rs, src/message/special.rs, src/pdo.rs, wacore/src/send/peer.rs
Request IDs are borrowed through stanza construction, waiter handling, secret persistence, and send results, with regression coverage.
Shared session guard acquisition
src/send/mod.rs, src/client/..., src/features/signal.rs, src/voip/facade.rs
Session-lock users call session_guards_for, with lifecycle and deadlock-safety tests.
DM fan-out buffering
wacore/src/send/encrypt.rs, wacore/src/send/dm.rs, wacore/src/send/tests.rs
Encryption appends participant nodes directly into the caller’s buffer and validates reused protocol-address behavior.
Reporting-token extraction
wacore/src/reporting_token.rs
Whitelist extraction stages borrowed input ranges and owned nested payloads using SmallVec.
Dependency and manifest updates
Cargo.toml, wacore/Cargo.toml, wacore/binary/Cargo.toml, tests/*/Cargo.toml, transports/tokio-transport/Cargo.toml
smallvec is added through workspace dependencies and unchanged feature lists are reformatted.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SendPipeline
  participant SessionGuards
  participant DmEncryption
  participant Stanza
  Client->>SendPipeline: submit borrowed request_id
  SendPipeline->>SessionGuards: acquire ordered session guards
  SessionGuards-->>SendPipeline: return guards
  SendPipeline->>DmEncryption: encrypt devices into participant buffer
  DmEncryption->>Stanza: append participant nodes
  SendPipeline-->>Client: return send result
Loading

Possibly related PRs

Suggested labels: performance, breaking-change

Suggested reviewers: greptile-apps, cubic-dev-ai

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the main retained change set: five allocation-reduction cuts in per-message send paths.
Description check ✅ Passed The description is directly about the same allocation-cut work and the reverted sixth change, so it is on topic.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/alloc-round3

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

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR reduces allocations throughout the direct-message send path while preserving message identity, session-lock ordering, encryption addressing, reporting-token bytes, and Signal cache-key semantics.

  • Borrows one request ID across stanza construction, ACK waiter registration, secret persistence, and send results.
  • Appends encrypted device nodes directly into the destination stanza and acquires session locks without staging mutex handles.
  • Reuses protocol-address storage during encryption while explicitly rewriting PN/LID address state.
  • Extracts reporting-token fields using borrowed ranges where possible.
  • Stores common protocol addresses inline and compares, orders, and hashes them by textual content.

Confidence Score: 5/5

The PR appears safe to merge within the eligible follow-up-review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
src/send/mod.rs Borrows request IDs across the send pipeline, replaces staged session mutex resolution with ordered guard acquisition, and adds focused invariant tests.
wacore/src/send/encrypt.rs Adds direct append-based device encryption to avoid temporary fan-out vectors while retaining existing encryption result paths.
wacore/src/send/dm.rs Builds DM participant nodes directly in the final stanza allocation.
wacore/libsignal/src/core/address.rs Introduces inline-or-spilled protocol-address storage with content-based equality, ordering, hashing, and reset semantics.
wacore/src/types/jid.rs Generalizes Signal-address rendering to reusable sinks and supports explicit protocol-address rewrites.
wacore/src/reporting_token.rs Uses borrowed input ranges and small-vector storage to reduce reporting-token extraction allocations without changing output ordering.
wacore/src/store/signal_cache.rs Adapts Signal cache operations to the new protocol-address representation while retaining textual cache keys.
src/voip/facade.rs Uses the shared ordered session-guard helper in VoIP encryption paths.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant Pipeline as Send pipeline
  participant Locks as Session locks
  participant Signal
  participant Store
  participant Socket

  Caller->>Pipeline: send(message, request_id)
  Pipeline->>Locks: acquire sorted device locks
  Pipeline->>Signal: encrypt using rewritten ProtocolAddress
  Signal-->>Pipeline: append encrypted nodes into stanza
  Pipeline->>Store: persist Signal state before wire
  Pipeline->>Pipeline: register phash waiter by request_id
  Pipeline->>Socket: send stanza
  Socket-->>Pipeline: send completed
  Pipeline->>Store: persist message secret by request_id
  Pipeline-->>Caller: SendResult with same request_id
Loading

Reviews (9): Last reviewed commit: "test: apply the ordering fix to the DM s..." | Re-trigger Greptile

jlucaso1 added 3 commits July 26, 2026 19:04
Rustdoc rejects a public item documenting itself through a private one, so
the inline capacity is stated as a number instead of a link.
Both members pinned "1.15" independently, which is how the two drift.

The version comment above the workspace list is gone because cargo sort
does not preserve leading comments in the table it rewrites.
Mechanical reordering only; no dependency added, removed or repinned.
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 9.99 MiB 10.01 MiB +24.84 KiB (+0.24%) 🔺
bin .text 8.03 MiB 8.06 MiB +24.69 KiB (+0.30%) 🔺
bin allocated (text+data+bss) 9.99 MiB 10.01 MiB +24.31 KiB (+0.24%) 🔺
llvm-lines wacore 492,251 493,295 +1,044 (+0.21%) 🔺
llvm-lines wacore copies 16,337 16,368 +31 (+0.19%) 🔺
llvm-lines whatsapp-rust lib 720,949 720,425 -524 (-0.07%) 🔽
llvm-lines whatsapp-rust lib copies 22,792 22,770 -22 (-0.10%) 🔽
deps crates (Cargo.lock) 471 471 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.82 MiB 1.83 MiB +12.25 KiB (+0.66%) 🔺
.text wacore 647.18 KiB 655.05 KiB +7.87 KiB (+1.22%) ⚠️
.text wacore_binary 89.70 KiB 89.69 KiB -16 B (-0.02%) 🔽
.text wacore_libsignal 163.66 KiB 171.09 KiB +7.43 KiB (+4.54%) ⚠️
.text wacore_appstate 22.36 KiB 22.34 KiB -20 B (-0.09%) 🔽
.text wacore_noise 21.60 KiB 21.60 KiB 0
.text waproto 1.74 MiB 1.74 MiB 0
.text whatsapp_rust_sqlite_storage 514.91 KiB 515.17 KiB +266 B (+0.05%) 🔺
.text whatsapp_rust_tokio_transport 39.91 KiB 39.91 KiB 0
.text whatsapp_rust_ureq_http_client 10.40 KiB 10.33 KiB -75 B (-0.70%) 🔽
.text std 1.07 MiB 1.07 MiB -2.29 KiB (-0.21%) 🔽
.text other deps 1.89 MiB 1.89 MiB -628 B (-0.03%) 🔽
Top movers (cargo-bloat attribution)
Crate main PR Δ
whatsapp_rust 1.82 MiB 1.83 MiB +12.25 KiB (+0.66%)
wacore 647.18 KiB 655.05 KiB +7.87 KiB (+1.22%)
wacore_libsignal 163.66 KiB 171.09 KiB +7.43 KiB (+4.54%)
std 1.07 MiB 1.07 MiB -2.29 KiB (-0.21%)

Baseline: f6a20a38a (latest main run) · Head: ce092477a · Graphs

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

ℹ️ 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/libsignal/src/core/address.rs Outdated
jlucaso1 added 2 commits July 26, 2026 19:13
Resetting an address only rewinds the length, so the tail of an inline
buffer still holds the bytes of whoever it named before. The derived Debug
printed the whole array, which means any log line formatting an address, or
an error embedding one such as SessionNotFound, could show an unrelated
peer's JID and send someone chasing the wrong session.
@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will improve performance by ×4.6

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 4 improved benchmarks
✅ 194 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory bench_content_extraction_simple 173 B 30 B ×5.8
Memory bench_full_token_generation_simple 173 B 30 B ×5.8
Memory bench_content_extraction_extended 471 B 129 B ×3.7
Memory bench_full_token_generation_extended 471 B 129 B ×3.7

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/alloc-round3 (647d57d) with main (f6a20a3)

Open in CodSpeed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

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

Inline comments:
In `@src/send/mod.rs`:
- Around line 2481-2524: Update session_guards_for to debug-assert that each jid
is strictly greater than the preceding jid according to cmp_for_lock_order
before acquiring its lock, catching unsorted or duplicate keys without changing
release behavior. Track the previous jid while iterating, preserve the existing
ordered locking and guard collection, and leave session_mutexes_for unchanged.

In `@wacore/libsignal/src/core/address.rs`:
- Around line 476-495: Extend the boundary tests around
`the_inline_boundary_holds_on_both_sides` with a multi-digit device ID case
using the suggested name length so the suffix causes the spill. Assert the
buffer is not inline and that both `name()` and `as_str()` preserve the original
name and complete `.123` suffix.
- Around line 329-337: Update ProtocolAddress::empty so its returned value
remains distinct under Hash, PartialEq, and Ord for different device IDs: either
initialize the buffer with the device-id suffix immediately, or restrict this
constructor to internal use until reset_with is required. Preserve the reusable
empty-buffer behavior only if it cannot expose equal as_str() values for
different devices.

In `@wacore/src/reporting_token.rs`:
- Around line 1807-1843: Update the test
the_result_is_allocated_once_for_the_exact_length_it_holds so it no longer
relies on exact Vec capacity or allocator growth behavior. Replace the
capacity-equals-length assertion and related comment with a portable assertion
of the staging allocation contract, or use an explicit allocation-counting setup
that verifies a single allocation without inspecting capacity rounding.

In `@wacore/src/send/encrypt.rs`:
- Around line 374-427: Leave the current DM behavior unchanged; no code change
is required for this non-blocking observation. If addressing it later, update
the DM callers of encrypt_for_devices_into to consume
EncryptFanoutSummary.had_unregistered_device and perform the same device-list
invalidation used by the group path.
- Around line 846-857: The address reuse around encryption_jid should not be
threaded through SessionPlan. Remove the reusable_addr plumbing and construct
the protocol address directly via encryption_jid.to_protocol_address(), keeping
SessionPlan focused on session data; only retain reuse if a benchmark
demonstrates a throughput benefit.

In `@wacore/src/send/tests.rs`:
- Around line 4444-4466: Update the ordering assertions in
many_devices_append_in_order_after_the_existing_content and
a_dm_stanza_carries_both_halves_in_one_participants_node to avoid requiring
device order within each fan-out half. Preserve and assert that the first half’s
members appear before the second half’s members, while comparing membership
within each half without sequence ordering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c1e01b86-01aa-4fae-81d2-461e3fe03be6

📥 Commits

Reviewing files that changed from the base of the PR and between 1118084 and 0832467.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (37)
  • Cargo.toml
  • src/client/app_state.rs
  • src/client/context_impl.rs
  • src/client/messaging.rs
  • src/client/tests.rs
  • src/features/groups.rs
  • src/features/signal.rs
  • src/message/special.rs
  • src/pdo.rs
  • src/send/mod.rs
  • src/signal_flush.rs
  • src/store/signal.rs
  • src/store/signal_adapter.rs
  • src/voip/facade.rs
  • tests/bench-integration/Cargo.toml
  • tests/e2e/Cargo.toml
  • tests/signal_durability_sqlite.rs
  • transports/tokio-transport/Cargo.toml
  • wacore/Cargo.toml
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/libsignal/benches/libsignal_benchmark.rs
  • wacore/libsignal/src/core/address.rs
  • wacore/libsignal/src/core/mod.rs
  • wacore/libsignal/src/protocol/mod.rs
  • wacore/libsignal/src/protocol/session_cipher.rs
  • wacore/libsignal/src/protocol/storage/traits_hook_tests.rs
  • wacore/libsignal/tests/counter_lease.rs
  • wacore/libsignal/tests/session_divergence.rs
  • wacore/src/reporting_token.rs
  • wacore/src/send/dm.rs
  • wacore/src/send/encrypt.rs
  • wacore/src/send/peer.rs
  • wacore/src/send/tests.rs
  • wacore/src/store/signal_cache.rs
  • wacore/src/store/signal_cache_durability_chaos.rs
  • wacore/src/types/jid.rs

Comment thread src/send/mod.rs
Comment on lines +2481 to 2524
/// Take every per-device session lock, in `jids` order.
///
/// INVARIANT: acquisition order IS `jids` order, and callers pass keys from
/// [`Self::build_session_lock_keys`], which sorts them. That single order is
/// what keeps two sends overlapping on a device from deadlocking, so a
/// change here has to preserve it.
///
/// Each mutex is locked as it is resolved rather than resolving the whole
/// set first: the handles exist only to be locked, so the vector holding
/// them was pure staging. The guards themselves must still be collected —
/// they are what keeps the locks held for the caller's scope.
pub(crate) async fn session_guards_for(
&self,
jids: &[Jid],
) -> Vec<async_lock::MutexGuardArc<()>> {
let mut guards = Vec::with_capacity(jids.len());
// A `ProtocolAddress` IS the "{name}.0" string the lock map is keyed by,
// and it holds it inline, so the whole loop names its keys without
// allocating a formatting buffer.
let mut addr = wacore::types::jid::make_reusable_protocol_address();
for jid in jids {
jid.reset_protocol_address(&mut addr);
let mutex = self.session_lock_for(addr.as_str()).await;
guards.push(mutex.lock_arc().await);
}
guards
}

/// The mutexes [`Self::session_guards_for`] would take, without taking
/// them. Only tests need this: production code always wants the guards, and
/// resolving handles it does not lock is what this commit removed.
#[cfg(test)]
pub(crate) async fn session_mutexes_for(
&self,
jids: &[Jid],
) -> Vec<std::sync::Arc<async_lock::Mutex<()>>> {
let mut mutexes = Vec::with_capacity(jids.len());
let mut buf = wacore::types::jid::make_address_buffer();
let mut addr = wacore::types::jid::make_reusable_protocol_address();
for jid in jids {
wacore::types::jid::write_protocol_address_to(jid, &mut buf);
mutexes.push(self.session_lock_for(&buf).await);
jid.reset_protocol_address(&mut addr);
mutexes.push(self.session_lock_for(addr.as_str()).await);
}
mutexes
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Lock this in: session_guards_for trusts callers not to hand it duplicate keys.

Look, the doc comment is crystal clear that jids must already be sorted and deduped by build_session_lock_keys, and every caller today does exactly that. That's great discipline. But if someone six months from now wires up a new send path and skips build_session_lock_keys, a duplicate jid in the list means mutex.lock_arc().await blocks on a lock this same call already holds — a silent, hard-to-diagnose self-deadlock, not a panic that fails fast. I'd rather this fail loud in debug builds than hang in someone's production incident. A cheap debug_assert! on strictly-increasing order (per cmp_for_lock_order) would close that gap for free.

🛡️ Proposed defensive assertion
     pub(crate) async fn session_guards_for(
         &self,
         jids: &[Jid],
     ) -> Vec<async_lock::MutexGuardArc<()>> {
         let mut guards = Vec::with_capacity(jids.len());
+        debug_assert!(
+            jids.windows(2)
+                .all(|w| wacore::types::jid::cmp_for_lock_order(&w[0], &w[1]).is_lt()),
+            "session_guards_for requires sorted, deduped keys (use build_session_lock_keys)"
+        );
         // A `ProtocolAddress` IS the "{name}.0" string the lock map is keyed by,
         // and it holds it inline, so the whole loop names its keys without
         // allocating a formatting buffer.
         let mut addr = wacore::types::jid::make_reusable_protocol_address();
📝 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
/// Take every per-device session lock, in `jids` order.
///
/// INVARIANT: acquisition order IS `jids` order, and callers pass keys from
/// [`Self::build_session_lock_keys`], which sorts them. That single order is
/// what keeps two sends overlapping on a device from deadlocking, so a
/// change here has to preserve it.
///
/// Each mutex is locked as it is resolved rather than resolving the whole
/// set first: the handles exist only to be locked, so the vector holding
/// them was pure staging. The guards themselves must still be collected —
/// they are what keeps the locks held for the caller's scope.
pub(crate) async fn session_guards_for(
&self,
jids: &[Jid],
) -> Vec<async_lock::MutexGuardArc<()>> {
let mut guards = Vec::with_capacity(jids.len());
// A `ProtocolAddress` IS the "{name}.0" string the lock map is keyed by,
// and it holds it inline, so the whole loop names its keys without
// allocating a formatting buffer.
let mut addr = wacore::types::jid::make_reusable_protocol_address();
for jid in jids {
jid.reset_protocol_address(&mut addr);
let mutex = self.session_lock_for(addr.as_str()).await;
guards.push(mutex.lock_arc().await);
}
guards
}
/// The mutexes [`Self::session_guards_for`] would take, without taking
/// them. Only tests need this: production code always wants the guards, and
/// resolving handles it does not lock is what this commit removed.
#[cfg(test)]
pub(crate) async fn session_mutexes_for(
&self,
jids: &[Jid],
) -> Vec<std::sync::Arc<async_lock::Mutex<()>>> {
let mut mutexes = Vec::with_capacity(jids.len());
let mut buf = wacore::types::jid::make_address_buffer();
let mut addr = wacore::types::jid::make_reusable_protocol_address();
for jid in jids {
wacore::types::jid::write_protocol_address_to(jid, &mut buf);
mutexes.push(self.session_lock_for(&buf).await);
jid.reset_protocol_address(&mut addr);
mutexes.push(self.session_lock_for(addr.as_str()).await);
}
mutexes
}
/// Take every per-device session lock, in `jids` order.
///
/// INVARIANT: acquisition order IS `jids` order, and callers pass keys from
/// [`Self::build_session_lock_keys`], which sorts them. That single order is
/// what keeps two sends overlapping on a device from deadlocking, so a
/// change here has to preserve it.
///
/// Each mutex is locked as it is resolved rather than resolving the whole
/// set first: the handles exist only to be locked, so the vector holding
/// them was pure staging. The guards themselves must still be collected —
/// they are what keeps the locks held for the caller's scope.
pub(crate) async fn session_guards_for(
&self,
jids: &[Jid],
) -> Vec<async_lock::MutexGuardArc<()>> {
let mut guards = Vec::with_capacity(jids.len());
debug_assert!(
jids.windows(2)
.all(|w| wacore::types::jid::cmp_for_lock_order(&w[0], &w[1]).is_lt()),
"session_guards_for requires sorted, deduped keys (use build_session_lock_keys)"
);
// A `ProtocolAddress` IS the "{name}.0" string the lock map is keyed by,
// and it holds it inline, so the whole loop names its keys without
// allocating a formatting buffer.
let mut addr = wacore::types::jid::make_reusable_protocol_address();
for jid in jids {
jid.reset_protocol_address(&mut addr);
let mutex = self.session_lock_for(addr.as_str()).await;
guards.push(mutex.lock_arc().await);
}
guards
}
/// The mutexes [`Self::session_guards_for`] would take, without taking
/// them. Only tests need this: production code always wants the guards, and
/// resolving handles it does not lock is what this commit removed.
#[cfg(test)]
pub(crate) async fn session_mutexes_for(
&self,
jids: &[Jid],
) -> Vec<std::sync::Arc<async_lock::Mutex<()>>> {
let mut mutexes = Vec::with_capacity(jids.len());
let mut addr = wacore::types::jid::make_reusable_protocol_address();
for jid in jids {
jid.reset_protocol_address(&mut addr);
mutexes.push(self.session_lock_for(addr.as_str()).await);
}
mutexes
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/send/mod.rs` around lines 2481 - 2524, Update session_guards_for to
debug-assert that each jid is strictly greater than the preceding jid according
to cmp_for_lock_order before acquiring its lock, catching unsorted or duplicate
keys without changing release behavior. Track the previous jid while iterating,
preserve the existing ordered locking and guard collection, and leave
session_mutexes_for unchanged.

Comment thread wacore/libsignal/src/core/address.rs Outdated
Comment thread wacore/libsignal/src/core/address.rs
Comment thread wacore/src/reporting_token.rs
Comment thread wacore/src/send/encrypt.rs
Comment thread wacore/src/send/encrypt.rs Outdated
Comment thread wacore/src/send/tests.rs
An unnamed ProtocolAddress carried no device in its rendered form, so
empty(1) and empty(2) hashed and compared alike and would have collided as
session-cache keys. The suffix is now written up front.

The reporting-token test asserted capacity() == len(), which an allocator
is free to break by rounding a request up. The property worth pinning is
that the reservation matches the bytes written, so that is now a
debug_assert inside the extractor and every test that calls it exercises it.

The fan-out sink tests asserted an order that the production code says it
does not guarantee: within one half the devices drain from a
FuturesUnordered in completion order. They passed only because the
current-thread runtime never let those futures pend. They now assert what
is guaranteed, that each half lands entirely after the previous one.
@coderabbitai coderabbitai Bot removed the api-design label Jul 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@wacore/src/send/tests.rs`:
- Around line 4466-4479: Add an assertion on written.len() before split_at in
the participant ordering test, verifying it equals first.len() + second.len().
Keep the existing half splitting and BTreeSet content assertions unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8a2121c0-bdd8-4093-8521-b061ddaaa600

📥 Commits

Reviewing files that changed from the base of the PR and between 0832467 and 7e0cbef.

📒 Files selected for processing (3)
  • wacore/libsignal/src/core/address.rs
  • wacore/src/reporting_token.rs
  • wacore/src/send/tests.rs

Comment thread wacore/src/send/tests.rs Outdated
jlucaso1 added 2 commits July 26, 2026 19:50
Comparing each half as a set was the fix for asserting an order the
fan-out does not guarantee, but a set also hides a duplicate: a device
written twice while another was dropped would still match. The count is
also what makes the split into halves mean anything.
session_guards_for documents that its keys arrive sorted and deduped, and
every caller honours that, but a future path that forgets would await a
lock the same call already holds: a send that never returns rather than a
panic. A debug_assert makes it fail loudly instead.

The inline boundary was only covered with device 0, which writes its suffix
in one push. A multi-digit id goes through write!, so the spill can land
between the dot and the digits.
Measured, and it does not pay. Isolating this commit against the rest of
the branch gives 128.70 allocations per message without it and 129.86 with
it: no saving, and not distinguishable from noise either way (t = 0.90).

The reason is the inline address later in this branch. When this was
written, building a ProtocolAddress meant a 64-byte heap allocation, so
handing the session phase's buffer to the encrypt saved one. Now the buffer
lives in the value, so the second construction allocates nothing and the
only thing threading an Option<ProtocolAddress> through SessionPlan buys is
72 bytes of state and a rewrite that has to be exactly right.

The tests stay. They pin that a cold PN device the session phase upgraded
to LID encrypts against the LID session, which is a property of the send
path rather than of the optimisation, and they pass unchanged without it.
@coderabbitai coderabbitai Bot removed the api-design label Jul 26, 2026

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

♻️ Duplicate comments (1)
wacore/src/send/tests.rs (1)

4444-4464: 🎯 Functional Correctness | 🟡 Minor

Keep fan-out tests deterministic without requiring completion order.

Both assertions assume input order within a fan-out half, but the implementation drains concurrent work in completion order. Preserve and verify the half boundary, while comparing membership inside each half.

  • wacore/src/send/tests.rs#L4444-L4464: split the written participants at first.len(), compare the first half as a set or sorted list, and retain exact ordering for the second half.
  • wacore/src/send/tests.rs#L4512-L4584: compare recipient devices without sequence ordering, then assert the own-device suffix remains after them.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wacore/src/send/tests.rs` around lines 4444 - 4464, The fan-out tests
incorrectly require completion order within concurrent batches. In
wacore/src/send/tests.rs lines 4444-4464, update
many_devices_append_in_order_after_the_existing_content to split written
participants at first.len(), compare the first half as an unordered set or
sorted list, and retain exact ordering for the second half; in lines 4512-4584,
compare recipient devices without sequence ordering while still asserting the
own-device suffix follows all recipient devices.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@wacore/src/send/tests.rs`:
- Around line 4444-4464: The fan-out tests incorrectly require completion order
within concurrent batches. In wacore/src/send/tests.rs lines 4444-4464, update
many_devices_append_in_order_after_the_existing_content to split written
participants at first.len(), compare the first half as an unordered set or
sorted list, and retain exact ordering for the second half; in lines 4512-4584,
compare recipient devices without sequence ordering while still asserting the
own-device suffix follows all recipient devices.

ℹ️ Review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: af3b9c15-27af-4572-8ce6-84118d5a8af6

📥 Commits

Reviewing files that changed from the base of the PR and between 1216d12 and d5e862c.

📒 Files selected for processing (2)
  • wacore/src/send/encrypt.rs
  • wacore/src/send/tests.rs

The first pass only fixed the sink tests. This one asserted the same
unguaranteed order one level up: within the recipient half the devices
drain from a FuturesUnordered, so which lands first is not promised. The
boundary is, because the two halves are sequential awaits, and swapping
them now fails the test.
@jlucaso1 jlucaso1 changed the title perf: six cuts to the per-message allocation count perf: five cuts to the per-message allocation count Jul 26, 2026
@jlucaso1
jlucaso1 merged commit d2a3a16 into main Jul 26, 2026
19 of 20 checks passed
@jlucaso1
jlucaso1 deleted the perf/alloc-round3 branch July 26, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant