Skip to content

perf: lazy ciphertext in SignalMessage, eliminate redundant allocation - #510

Merged
jlucaso1 merged 1 commit into
mainfrom
perf/signal-message-lazy-ciphertext
Apr 9, 2026
Merged

jlucaso1 merged 1 commit into
mainfrom
perf/signal-message-lazy-ciphertext

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Remove redundant ciphertext: Box<[u8]> field from SignalMessage — it stored the same bytes already embedded in serialized
  • Replace with OnceLock<Box<[u8]>> lazy cache (mirrors existing SenderKeyMessage pattern)
  • On send path: .body() is never called, so the cache stays empty — eliminates 1 heap allocation + memcpy of N ciphertext bytes per sent message (44% less struct heap memory)
  • On receive path: TryFrom eagerly populates the cache, so .body() is still a zero-cost pointer return
  • Breaking: SignalMessage::body() return type changes from &[u8] to Result<&[u8]> (only caller updated in session_cipher.rs)
  • Adds iai-callgrind claim-validation benchmarks that isolate and measure each proposed optimization independently

Benchmark results

Path Instructions Change
DM encrypt (subsequent) 160,849 +0.06% (noise)
DM encrypt (first) 159,691 -0.11% (noise)
DM decrypt (first) 5,508,558 +0.006% (noise)

No measurable CPU regression on any real path.

Test plan

  • cargo fmt --all — clean
  • cargo clippy --all --tests — zero warnings
  • cargo test -p wacore-libsignal — 102 tests pass
  • cargo test -p whatsapp-rust — 345 tests pass
  • cargo bench -p wacore-libsignal --bench libsignal_benchmark — no regressions

Summary by CodeRabbit

  • Bug Fixes

    • Decryption now stops and surfaces errors encountered while retrieving or decoding message contents, preventing attempts to decrypt invalid data.
  • Refactor

    • Message handling now defers decoding ciphertext and caches decoded data lazily.
    • Cloning preserves the original serialized form and only duplicates cached plaintext when it has already been initialized, reducing eager memory use and work.

@coderabbitai

coderabbitai Bot commented Apr 9, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

SignalMessage now keeps canonical serialized bytes and lazily decodes/caches ciphertext in a OnceLock; body() is fallible (Result<&[u8]>). A shared get_or_try_init_bytes helper centralizes cache init. Call sites (e.g., session_cipher) propagate body() errors with ?.

Changes

Cohort / File(s) Summary
SignalMessage & SenderKeyMessage refactor
wacore/libsignal/src/protocol/protocol.rs
Removed eager ciphertext: Box<[u8]>; added ciphertext_cache: OnceLock<Box<[u8]>> and retained serialized: Box<[u8]>. Dropped #[derive(Clone)]; added manual impl Clone that only clones the cache if initialized. SignalMessage::body() now returns Result<&[u8]>; added private decode_ciphertext() and get_or_try_init_bytes helper. TryFrom<&[u8]> seeds the cache when decoding.
Call-site error propagation
wacore/libsignal/src/protocol/session_cipher.rs
decrypt_message_with_state now calls ciphertext.body()?, so ciphertext parsing errors are propagated before attempting decryption.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble serialized seeds and hide the rest,

A OnceLock burrow keeps the decoded best.
Clone hops lightly, only copying if awake,
Fail-fast hops back when parsing is opaque.
Hooray for lazy caches — a crunchy carrot break! 🥕

🚥 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: introducing lazy ciphertext initialization in SignalMessage to eliminate a redundant allocation.
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 perf/signal-message-lazy-ciphertext

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.

@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 `@wacore/libsignal/src/protocol/protocol.rs`:
- Around line 159-184: The body() method currently handles the OnceLock path
then calls decode_ciphertext() and sets the cache but still treats a subsequent
get() failure as a reachable error; simplify to mirror
SenderKeyMessage::ciphertext() by matching on self.ciphertext_cache.get(): if
Some(ct) return Ok(ct.as_ref()); if None, call decode_ciphertext() to produce
Box<[u8]>, set it into ciphertext_cache, then return the cached value
(unwrapping the get() result because a race loser here is unreachable).
Reference: body(), decode_ciphertext(), and ciphertext_cache (compare to
SenderKeyMessage::ciphertext()).
🪄 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: d96ed179-b78b-4e96-a349-5702d5956eed

📥 Commits

Reviewing files that changed from the base of the PR and between 71df4f0 and 3c259fb.

📒 Files selected for processing (3)
  • wacore/libsignal/benches/libsignal_benchmark.rs
  • wacore/libsignal/src/protocol/protocol.rs
  • wacore/libsignal/src/protocol/session_cipher.rs

Comment thread wacore/libsignal/src/protocol/protocol.rs Outdated
@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown

Benchmark Results

59 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,879 3,879 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 11,851 11,851 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,398 43,398 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 69,139 69,139 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 77,229 77,229 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,214 2,214 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,939 5,939 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 180,709 180,886 -0.1%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 193,347 192,731 +0.3%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 899,388 898,759 +0.1%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 1,010,379 1,010,330 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,617,127 1,617,694 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,819,165 2,820,502 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 10,499,036 10,475,816 +0.2%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 50,188,741 50,067,534 +0.2%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,506,689 12,619,329 -0.9%
binary_benchmark::marshal_group::bench_marshal_allocating 98,703 98,703 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 98,731 98,731 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 118,631 118,631 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 108,446 108,446 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 98,803 98,803 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 15,928 15,928 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 15,955 15,955 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 18,004 18,004 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 533,456 533,456 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 533,017 533,017 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 534,447 534,447 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 14,815,128 14,815,128 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 14,813,634 14,813,634 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 28,200,750 28,200,750 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,716 2,716 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 41,989 41,989 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 773 773 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 556,090 556,090 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 6,199 6,199 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 7,431 7,431 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 91,558 91,558 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 7,454 7,454 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 91,589 91,589 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 8,966 8,966 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 107,134 107,134 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 523,708 523,708 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 20,910 20,910 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,271,375 17,298,362 -0.2%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 162,069 162,318 -0.2%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,512,660 5,512,366 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 163,085 163,047 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 298,429 298,493 -0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 713,231 713,228 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,774,074 12,579,116 +1.5%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,323,081 27,653,737 -1.2%
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() 126,110,283 125,785,493 +0.3%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,970 47,033 -0.1%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,119,851 5,116,228 +0.1%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 299,173 298,844 +0.1%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,247,117 14,247,117 +0.0%
No significant changes detected.

@jlucaso1
jlucaso1 force-pushed the perf/signal-message-lazy-ciphertext branch 2 times, most recently from 7d7d7b3 to aad011c Compare April 9, 2026 14:46
@jlucaso1

jlucaso1 commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 9, 2026

Copy link
Copy Markdown

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


Code Review: perf: lazy ciphertext in SignalMessage

  • Read changed files and gather context
  • Review protocol.rs changes
  • Review session_cipher.rs changes
  • Compare with existing SenderKeyMessage pattern
  • Post review findings

Verdict: Looks good. Clean, well-motivated optimization that follows the established SenderKeyMessage pattern. A few observations below.

Strengths

  • Consistent pattern — The OnceLock lazy-cache approach exactly mirrors SenderKeyMessage::ciphertext() (lines 532–542), so the codebase stays internally consistent.
  • Send path wins — On the send path, body() is never called (confirmed: only callsite is session_cipher.rs:772 in the decrypt path), so the OnceLock stays empty and the redundant Box<[u8]> allocation + memcpy is completely eliminated.
  • Receive path is zero-costTryFrom<&[u8]> eagerly populates the cache (line 271–272), so body() on the receive path is a single pointer return with no re-parsing.
  • Manual Clone is correct — Properly propagates the cached value when present, leaves it empty otherwise.
  • Breaking API change is minimalbody() signature change from &[u8] to Result<&[u8]> only affects one callsite, and the ? propagation at session_cipher.rs:772 is the right fix.

Minor observation (not blocking)

SenderKeyMessage::ciphertext() still uses the defensive match pattern (lines 539–542) while SignalMessage::body() now uses expect("just set"). Both are correct — after set(), get() will always return Some regardless of races. The expect version is arguably clearer since it documents why it's safe. However, you might consider updating SenderKeyMessage::ciphertext() in a follow-up to use the same expect("just set") pattern for consistency, since the None arm there is truly unreachable.

previous_counter lint attribute removal

The removal of #[expect(dead_code)] from previous_counter is correct — the manual Clone impl now reads the field explicitly (self.previous_counter), which the compiler sees as a live use, unlike the derived Clone which didn't suppress the lint.

No issues found. LGTM.


@jlucaso1
jlucaso1 force-pushed the perf/signal-message-lazy-ciphertext branch from aad011c to e123a05 Compare April 9, 2026 14:49

@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)
wacore/libsignal/src/protocol/protocol.rs (1)

531-534: 🧹 Nitpick | 🔵 Trivial

Consider aligning SenderKeyMessage::ciphertext() with the SignalMessage::body() pattern.

SignalMessage::body() now uses expect("just set") (line 165) which is cleaner than the match pattern here. The error branch at line 533 is unreachable for the same reason — after set(), the value is always present.

♻️ Optional: Align with SignalMessage::body() pattern
     let ciphertext = self.decode_ciphertext()?;
     let _ = self.ciphertext_cache.set(ciphertext);
-    match self.ciphertext_cache.get() {
-        Some(ciphertext) => Ok(ciphertext.as_ref()),
-        None => Err(SignalProtocolError::InvalidProtobufEncoding),
-    }
+    // get() can't be None: even if a racing set() lost, the winner's value is stored
+    Ok(self.ciphertext_cache.get().expect("just set").as_ref())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/libsignal/src/protocol/protocol.rs` around lines 531 - 534,
SenderKeyMessage::ciphertext() currently matches on self.ciphertext_cache.get()
and returns an unreachable Err branch; update it to follow SignalMessage::body()
by calling self.ciphertext_cache.get().expect("just set") and returning its
as_ref() directly so the unreachable error path is removed and behavior is
consistent with SignalMessage::body().
🤖 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 `@wacore/libsignal/src/protocol/protocol.rs`:
- Around line 531-534: SenderKeyMessage::ciphertext() currently matches on
self.ciphertext_cache.get() and returns an unreachable Err branch; update it to
follow SignalMessage::body() by calling self.ciphertext_cache.get().expect("just
set") and returning its as_ref() directly so the unreachable error path is
removed and behavior is consistent with SignalMessage::body().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e416ac68-c1b4-4698-8630-6ef0292ed6a1

📥 Commits

Reviewing files that changed from the base of the PR and between aad011c and e123a05.

📒 Files selected for processing (2)
  • wacore/libsignal/src/protocol/protocol.rs
  • wacore/libsignal/src/protocol/session_cipher.rs

@jlucaso1
jlucaso1 force-pushed the perf/signal-message-lazy-ciphertext branch from e123a05 to 45f4530 Compare April 9, 2026 14:56
SignalMessage stored the ciphertext body in two places: a dedicated
`ciphertext: Box<[u8]>` field AND embedded inside the `serialized`
protobuf wire bytes. On the send path, `.body()` is never called, so
the dedicated field was dead weight — one extra heap allocation + memcpy
per sent message.

Replace with `OnceLock<Box<[u8]>>` (same pattern SenderKeyMessage
already uses). On the send path the cache stays empty (44% less struct
heap memory). On the receive path, TryFrom eagerly populates the cache
so `.body()` is still a simple pointer return.
@jlucaso1
jlucaso1 force-pushed the perf/signal-message-lazy-ciphertext branch from 45f4530 to c329759 Compare April 9, 2026 15:00
@jlucaso1

jlucaso1 commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 9, 2026

Copy link
Copy Markdown

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


Code Review: perf: lazy ciphertext in SignalMessage

  • Read changed files and understand the diff
  • Review protocol.rs changes
  • Review session_cipher.rs changes
  • Compare with existing SenderKeyMessage pattern
  • Post review findings

Verdict: LGTM. Clean optimization that unifies two code paths and removes a redundant allocation. No issues found.

What this PR does well

  1. Shared helper eliminates duplication — The new get_or_try_init_bytes() replaces the identical lazy-init pattern in both SignalMessage::body() and SenderKeyMessage::ciphertext(). Both call sites are now one-liners (protocol.rs:171, protocol.rs:531), and the unreachable error branch that existed in SenderKeyMessage::ciphertext() is gone.

  2. Send path allocation eliminatedSignalMessage::new() no longer stores the ciphertext separately (protocol.rs:141). Since body() is only called in session_cipher.rs:772 (the decrypt path), the send path avoids a heap allocation + memcpy entirely. Confirmed: body() has exactly one callsite in the codebase.

  3. Receive path is zero-costTryFrom<&[u8]> eagerly seeds the cache (protocol.rs:269-270), so body() on the decrypt path is a pointer return.

  4. Manual Clone is correct — Propagates cached value only if initialized (protocol.rs:85-99), consistent with the existing SenderKeyMessage manual Clone (protocol.rs:437-452).

  5. #[expect(dead_code)] removal is correctprevious_counter is now read explicitly in the manual Clone impl, making the lint attribute unnecessary.

  6. Error propagation at the callsite — The ? at session_cipher.rs:772 correctly propagates the new Result return from body().

One minor observation (not blocking)

The get_or_try_init_bytes helper uses expect("just set") which is correct — after set(), get() always returns Some regardless of racing threads. The comment on line 25 documents this clearly. Just noting that in a truly single-threaded context (which this is — Signal sessions aren't shared across threads), the race scenario never arises, making this even more straightforward.

No issues, no regressions, no further changes needed.


@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 `@wacore/libsignal/src/protocol/protocol.rs`:
- Around line 170-181: Change SignalMessage::body to be infallible: make pub fn
body(&self) -> &[u8] (not Result) and have it return the cached bytes by calling
the init helper and unwrapping safely based on the internal invariant (e.g., use
get_or_try_init_bytes(...).expect("ciphertext must be present") or switch to a
get_or_init_bytes helper if available) so decode_ciphertext can remain returning
Box<[u8]>; keep ciphertext_cache seeding behavior in TryFrom/new intact and then
update callers (like session_cipher.rs) to drop the unnecessary `?` since body
no longer returns Result.
🪄 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: 8c7b55cd-a4c3-4ef1-9484-d8c29882405f

📥 Commits

Reviewing files that changed from the base of the PR and between 45f4530 and c329759.

📒 Files selected for processing (2)
  • wacore/libsignal/src/protocol/protocol.rs
  • wacore/libsignal/src/protocol/session_cipher.rs

Comment on lines +170 to +181
pub fn body(&self) -> Result<&[u8]> {
get_or_try_init_bytes(&self.ciphertext_cache, || self.decode_ciphertext())
}

fn decode_ciphertext(&self) -> Result<Box<[u8]>> {
let proto_bytes = &self.serialized[1..self.serialized.len() - Self::MAC_LENGTH];
let proto = waproto::whatsapp::SignalMessage::decode(proto_bytes)
.map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?;
proto
.ciphertext
.ok_or(SignalProtocolError::InvalidProtobufEncoding)
.map(|v| v.into_boxed_slice())

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

Keep SignalMessage::body() infallible.

All valid SignalMessage instances already guarantee a ciphertext: new() serializes ciphertext: Some(...), and TryFrom<&[u8]> rejects missing ciphertext while eagerly seeding ciphertext_cache in Lines 239-279. Exposing Result<&[u8]> here turns an internal invariant into a public breaking change and forces avoidable churn into callers like session_cipher.rs for no reachable recovery path.

♻️ Suggested direction
-    pub fn body(&self) -> Result<&[u8]> {
-        get_or_try_init_bytes(&self.ciphertext_cache, || self.decode_ciphertext())
+    pub fn body(&self) -> &[u8] {
+        if let Some(ct) = self.ciphertext_cache.get() {
+            return ct.as_ref();
+        }
+
+        let ct = self
+            .decode_ciphertext()
+            .expect("SignalMessage invariant violated: missing ciphertext");
+        let _ = self.ciphertext_cache.set(ct);
+        self.ciphertext_cache.get().expect("just set").as_ref()
     }
// Then drop the `?` at the `ciphertext.body()` call site in
// `wacore/libsignal/src/protocol/session_cipher.rs`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/libsignal/src/protocol/protocol.rs` around lines 170 - 181, Change
SignalMessage::body to be infallible: make pub fn body(&self) -> &[u8] (not
Result) and have it return the cached bytes by calling the init helper and
unwrapping safely based on the internal invariant (e.g., use
get_or_try_init_bytes(...).expect("ciphertext must be present") or switch to a
get_or_init_bytes helper if available) so decode_ciphertext can remain returning
Box<[u8]>; keep ciphertext_cache seeding behavior in TryFrom/new intact and then
update callers (like session_cipher.rs) to drop the unnecessary `?` since body
no longer returns Result.

@jlucaso1
jlucaso1 merged commit 80db1bb into main Apr 9, 2026
10 checks passed
@jlucaso1
jlucaso1 deleted the perf/signal-message-lazy-ciphertext branch April 9, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant