Skip to content

refactor: split message/client/send monoliths into per-theme modules - #732

Merged
jlucaso1 merged 2 commits into
mainfrom
refactor/split-monoliths
Jun 6, 2026
Merged

refactor: split message/client/send monoliths into per-theme modules#732
jlucaso1 merged 2 commits into
mainfrom
refactor/split-monoliths

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

This is a pure code-movement refactor that breaks up the three largest source files into module directories grouped by theme. There are no logic changes: the only edits that are not relocations are private to pub(crate) visibility bumps for items that are now referenced across modules or from tests, plus the re-export lines that keep every existing path working. No public API changes.

It follows the pattern that already exists in src/client/, where the root .rs file stays the module root and a sibling directory holds the per-theme submodules.

What moved

src/message.rs (12467 to 167 LOC at the root). The root keeps the helper structs, the free fns and the RetryReason re-export. The impl Client decrypt/receive pipeline is split into message/{dispatch, msg_secret, retry, receive, special}.rs, and the tests move to message/tests.rs.

src/client.rs (7117 to 810 LOC at the root). The root keeps the Client struct, the public types (NodeFilter, ClientError, MemoryDiagnostics) and the ack free fns. The 107 impl Client methods are split into client/{lifecycle, node_io, app_state, iq_ops, adapters, messaging, accessors}.rs, and the tests move to client/tests.rs. The pre-existing client submodules (device_registry, lid_pn, sessions, sender_keys, offline_resume, context_impl) are untouched.

wacore/src/send.rs (5322 to 84 LOC at the root). The root keeps mod stanza, StanzaType and the re-exports that preserve the wacore::send::* paths. The free fns are split into send/{classify, encrypt, dm, peer, group, status}.rs, and the tests move to send/tests.rs.

How paths stay intact

Each submodule is just use super::*; plus the moved block, so it inherits the root module imports through the glob. impl methods are path independent and only needed visibility bumps to pub(crate). Free functions are re-exported from the root via pub use submod::* (and a couple of pub(crate) use lines for the internal helpers that cross module boundaries), so wacore::send::prepare_dm_stanza, crate::message::RetryReason and friends resolve exactly as before.

Verification

cargo fmt --all applied. cargo clippy --all-targets -- -D warnings is clean. cargo test --workspace --exclude e2e-tests passes with 3975 passed and 0 failed. Test counts are preserved: 147 message, 73 client, 89 send, none lost in the move.

The test files are now in dedicated tests.rs modules out of the production files. They are still large (message/tests.rs is around 9.5k lines), so splitting them further by topic is a possible follow-up that I left out of this PR to keep it a clean, low-risk move.

Pure code movement with no logic changes. Decomposes the three largest source files (src/message.rs 12467 LOC, src/client.rs 7117 LOC, wacore/src/send.rs 5322 LOC) into module directories grouped by theme, following the existing src/client/ submodule pattern. The only non-move edits are private to pub(crate) visibility bumps for items now referenced across modules or from tests, plus re-export lines. No public API changes.

src/message.rs (167 LOC root) keeps the helper structs, free fns and the RetryReason re-export; the impl Client decrypt/receive pipeline moves into message/{dispatch,msg_secret,retry,receive,special}.rs and the tests into message/tests.rs.

src/client.rs (810 LOC root) keeps the Client struct, public types (NodeFilter, ClientError, MemoryDiagnostics) and the ack free fns; the impl Client methods move into client/{lifecycle,node_io,app_state,iq_ops,adapters,messaging,accessors}.rs and the tests into client/tests.rs.

wacore/src/send.rs (84 LOC root) keeps mod stanza, StanzaType and the re-exports that preserve the wacore::send::* paths; the free fns move into send/{classify,encrypt,dm,peer,group,status}.rs and the tests into send/tests.rs.

Verified with cargo fmt --all, cargo clippy --all-targets -- -D warnings (clean) and cargo test --workspace --exclude e2e-tests (3975 passed, 0 failed). Test counts preserved: 147 message, 73 client, 89 send.
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Too much diff to scan? Review this PR in Change Stack to start with the highest-impact changes.

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a0dc6772-3d42-4ce6-a7aa-081a5f80acb2

📥 Commits

Reviewing files that changed from the base of the PR and between 7bccf69 and 3a3edf6.

📒 Files selected for processing (4)
  • src/client/messaging.rs
  • src/message/receive.rs
  • src/message/special.rs
  • src/message/tests.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Message editing, server-side reactions, and improved delivery/receipt handling
    • Newsletter/plaintext and secret-encrypted bot-reply support
    • Privacy controls, business profile fetch, call rejection, and device/profile management
    • App-state sync with retry and batched collection sync
  • Refactor

    • Overhauled connection lifecycle, reconnect/backoff, and inbound/outbound messaging pipelines
    • Improved encryption/send preparation for DM/group/peer flows
  • Tests

    • Extensive integration and unit tests for messaging, sync, encryption, and connection behavior

Walkthrough

Implements a complete client runtime: lifecycle and reconnect logic, transport read loop and stanza routing, a two-pass incoming-message decryption pipeline with retry/secret handling, outgoing stanza builders and per-device encryption, app-state sync, accessors/adapters/IQ ops, and a broad test suite.

Changes

Complete Client Messaging Pipeline

Layer / File(s) Summary
Client core lifecycle
src/client/lifecycle.rs
Client construction, run/connect loops, shutdown/reconnect, per-connection cleanup, waiters, and connected/logged-in accessors.
Accessors, adapters, IQ operations
src/client/accessors.rs, src/client/adapters.rs, src/client/iq_ops.rs
Runtime config getters/setters, group cache lazy init, memory diagnostics, push-name/JID accessors and update+notify, node waiter APIs, Signal/sender-key adapters, session locks, noise socket access, flush helpers, and IQ APIs for props/privacy/profile/calls.
Transport read loop & node routing
src/client/node_io.rs
Transport event loop, frame decode/decrypt, per-node processing, offline <ib> tracking, ACK scheduling, and handlers for <success>, <ack/>, <stream:error>, and <failure>.
Incoming message pipeline
src/message/receive.rs
Two-phase classify → process flow, PASS-1 session decrypts, PASS-2 group decrypts, PN↔LID migration retry, plaintext validation, device-sync triggering, and message-info parsing.
Retry & undecryptable handling
src/message/retry.rs
Deduplicated undecryptable event dispatch, retry-count tracking, retry receipt send flow with PDO fallback and transport-ack ordering.
Message-secret handling
src/message/msg_secret.rs
Capture/persist message secrets, decrypt secret-encrypted messages with backend+resolver fallback, edit-window enforcement, and msmsg bot-reply decryption/dispatch.
Post-decrypt dispatch & special handlers
src/message/dispatch.rs, src/message/special.rs
Dispatch parsed messages (ephemeral normalization, secret capture), ack path selection, newsletter plaintext, app-state key-share persistence, and sender-key distribution handler.
Outgoing messaging primitives
src/client/messaging.rs
send_raw_bytes/send_node, unified-session sends, edit_message, server reactions, protocol receipts, ack waiter registration, chatstate handlers, and receipt-activity utilities.
Send classification & encryption core
wacore/src/send/classify.rs, wacore/src/send/encrypt.rs
Message classification, media-type mapping, decrypt-fail hide rules, SignalStores, encrypt_group_message, encrypt_for_devices with prekey/session establishment and bounded fanout.
Stanza builders: DM/Group/Peer/Status
wacore/src/send/dm.rs, wacore/src/send/group.rs, wacore/src/send/peer.rs, wacore/src/send/status.rs
DM dual-encrypt and retry builders, group SKDM distribution and phash, peer stanza builder with pkmsg guard, status participants and privacy-meta helpers, SKDM serialization and stale-device computation.
App-state sync & mutation dispatch
src/client/app_state.rs
AppStateProcessor init, sync-task routing, fetch-with-retry, batched collection sync with external blob pre-download, single-collection pagination, missing-key requests dedup, mutation dispatch (push-name, NCT salt, chat/label delegations), and dirty-bit cleanup IQ.
Comprehensive client tests
src/client/tests.rs
Extensive tests covering ACK policy, LID↔PN cache, offline sync lifecycle, session-establishment guards, unified-session invariants, Signal cache flush ordering, ping/pong, ACK wire roundtrip, stream:error semantics, backoff timing, concurrency, disconnect ordering, and shutdown semantics.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Note: review the transport/read-loop and decrypt retry ordering carefully — these areas must be correct for reliability.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/split-monoliths

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

Benchmark Results

67 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,925 2,925 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,446 8,446 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,485 49,485 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 55,001 55,001 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,679 1,679 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,393 4,393 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 113,204 113,065 +0.1%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,729 1,656,620 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 651,743 651,790 -0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 875,781 875,885 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,083,501 2,083,509 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 749,157 748,282 +0.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,326,484 1,329,814 -0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,375,278 4,373,668 +0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 519,993 518,087 +0.4%
binary_benchmark::marshal_group::bench_marshal_allocating 45,395 45,395 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,445 45,445 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,348 66,348 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,506 43,506 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,501 45,501 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,936 4,936 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,967 4,967 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,738 6,738 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,539 528,539 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,152 528,152 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,398 529,398 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,742 5,417,742 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,091 5,362,091 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,430 13,276,430 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,274 48,274 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,335 48,335 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,659 66,659 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,282 8,282 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,142,506 4,139,311 +0.1%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,131 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,264,189 4,264,189 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 100,399 100,399 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,262 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,921 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 507,564 507,778 -0.0%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,976,352 11,974,077 +0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,938,792 4,894,762 +0.9%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,414 37,404 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,617,967 3,617,967 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 230,648 230,658 -0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%
No significant changes detected.

@jlucaso1

jlucaso1 commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jun 6, 2026

Copy link
Copy Markdown

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


Claude PR Review — pure code-movement refactor

  • Gather context and verify the refactor's claim of pure code movement
  • Inspect each new submodule structure (message/, client/, send/)
  • Verify re-exports preserve external paths
  • Check visibility bumps (pub(crate))
  • Check CI / tests
  • Provide review feedback

Verdict

LGTM. This is exactly what the description says: a no-logic, pure-relocation refactor. All 5 CI runs (Rust CI, WASM Build, Benchmark, Integration Benchmark, E2E Tests) are green, and benchmark deltas are at noise level (largest is +0.5% on bench_group_send_skdm skdm_256, which is well within run-to-run jitter — perf-sensitive paths like bench_dm_recv, bench_group_recv, bench_group_send_* all came back ±0.1%).

What I verified

  1. No name collisions across pub use submod::* globs in wacore/src/send.rs. I listed every pub fn, pub(crate) fn, pub struct, pub enum declaration across the 6 send submodules — every identifier is unique, so the chained pub use classify::*; pub use dm::*; ... does not produce hidden ambiguity or shadow anything. (wacore/src/send.rs:73-81)

  2. External path preservation. The two pub(crate) use dm::partition_dm_devices; / pub(crate) use dm::pkmsg_would_be_emitted; lines are necessary (the pub use dm::* glob can't re-export pub(crate) items). The pub(crate) use wacore::protocol::retry::RetryReason; at src/message.rs:136 preserves the crate::message::RetryReason path, which is still used by src/retry.rs:2,2252,2339,2422. No callers are broken.

  3. Module structure is consistent. Every new submodule starts with //! doc and use super::*;, matching the pre-existing pattern in src/client/{device_registry,lid_pn,sessions,...}.rs. Each new file holds a single logical theme; no impl block straddles two files.

  4. All submodules are private. src/client.rs:1-13 and the corresponding mod lines in src/message.rs:138-142 declare every new submodule as mod (not pub mod), so the only externally-visible names are still the ones that were exported before. Nothing new leaks from src/lib.rs.

  5. +24147/-24092 line delta (~55 lines net add) matches what 18 new files × header-and-use super::* would produce, plus the few mod/pub use declarations and the re-export bridge in src/message.rs. No "stray" content snuck in with the move.

Observations / non-blocking notes

  1. src/message.rs:138-142 declares the submodules after the helper structs (EncPayload, ClassifiedMessage, etc.). That's fine for resolution, but the conventional Rust house style (and the pattern used in src/client.rs:1-13) is to put mod declarations at the top of the file. Optional follow-up — purely cosmetic.

  2. src/message/tests.rs at 9.5k lines is acknowledged as a follow-up. The split-by-topic suggestion in the PR description is the right next step; deferring it keeps this PR a clean revert if anything ever goes wrong.

  3. The pub(crate) visibility bumps slightly enlarge the crate-internal API surface (anything previously fn foo() inside a 12k-line file that is now reachable from a sibling module). Mechanically required for the split, but worth a mental note: future code-review of new callers from outside the original module is now slightly easier to miss.

  4. The use super::*; glob inside every submodule means adding an import to the root file silently makes it available to every submodule. Standard pattern, but it does couple the submodules to whatever the root happens to import. Not actionable here — just the cost of this pattern.

Bottom line

Clean low-risk move. No logic changes detected, re-exports are correct, CI is green, benchmarks are flat. Ship it.
— branch: refactor/split-monoliths

@jlucaso1

jlucaso1 commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jlucaso1

jlucaso1 commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

🤖 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/client/app_state.rs`:
- Around line 480-493: Extract the duplicated download logic into a small helper
so both places reuse the same implementation: add a helper like
get_pre_downloaded_blob(pre_downloaded: &PreDownloadedMap, ext:
&wa::ExternalBlobReference) -> anyhow::Result<Vec<u8>> (or a factory fn
make_download_closure(pre_downloaded: &PreDownloadedMap) -> impl
Fn(&wa::ExternalBlobReference) -> anyhow::Result<Vec<u8>>), then replace the
inline download closure here and the similar closure in
sync_collections_batched_inner with calls to that helper; ensure it checks
ext.direct_path, looks up pre_downloaded by path, returns the bytes clone on hit
or an anyhow error with the same messages ("external blob not pre-downloaded:
{}" and "external blob has no directPath") so behavior remains identical.
- Around line 622-632: The IQ being sent from this patch uses
crate::request::InfoQuery without a timeout, so add a 30-second timeout to the
InfoQuery before calling self.send_iq(iq). Specifically, set the
InfoQuery.timeout field to Some(Duration::from_secs(30)) (importing
std::time::Duration if needed) in the block that builds the iq (the InfoQuery
instance used with self.send_iq), mirroring the existing 30s timeout pattern
used elsewhere.
- Around line 411-419: The IQ being constructed in app_state.rs (variable iq of
type crate::request::InfoQuery) currently sets timeout: None which can hang the
sync; update the iq timeout field to a sensible timeout (e.g.
Some(Duration::from_secs(30))) consistent with the batched sync path so the
InfoQuery has a finite wait; ensure you import or reference std::time::Duration
if needed and apply the change where iq is created so timeout is not None.

In `@src/client/messaging.rs`:
- Around line 174-178: The warning logs the client's UUID (self.unique_id)
instead of the failed receipt's ID; locate the block around the
self.send_node(node).await call in messaging.rs and change the log to include
the receipt's identifier (e.g., receipt_id or receipt.unique_id — whichever
variable holds the receipt ID in this scope) in place of self.unique_id so the
message reads the failed receipt ID, keeping receipt_type and the error e as
before.

In `@src/client/node_io.rs`:
- Around line 396-414: The deferred-ACK branch currently bypasses outbound_flush
by using runtime.spawn directly, allowing disconnect() to close the transport
while the ACK is in flight; update maybe_deferred_ack so the ACK task is
submitted/tracked through the same outbound_flush mechanism used for other
outbound work (instead of calling runtime.spawn directly). Concretely, replace
the runtime.spawn(Box::pin(...)) usage in maybe_deferred_ack with the codepath
that enqueues or registers the async closure/future with outbound_flush (so
disconnect() will wait for it), preserving the existing
send_ack_for(node.get()).await and the transport-unavailable check/logging.
Ensure you reference maybe_deferred_ack, send_ack_for, outbound_flush and
disconnect when making the change.

In `@src/message/receive.rs`:
- Around line 841-847: The code assigns SessionBatchOutcome::undecryptable from
the return of handle_decrypt_failure, which overwrites previous true values for
earlier payloads; change these direct assignments (e.g., outcome.undecryptable =
self.handle_decrypt_failure(...).await) to accumulate using |= so undecryptable
remains true if any payload set it. Update every retry/decrypt-failure site that
currently assigns into outcome.undecryptable (including the occurrences around
the handle_decrypt_failure calls shown and the other similar blocks noted) to
use outcome.undecryptable |= self.handle_decrypt_failure(...).await; this
mirrors the parse-error branches and prevents emitting duplicate
process_classified_message events.
- Around line 1519-1526: The code currently calls
self.persistence_manager.get_device_arc() and reads guard.pn/guard.lid directly;
replace that with a call to
self.persistence_manager.get_device_snapshot().await, then extract snapshot.pn
and snapshot.lid (use the same default_jid fallback for pn and pass
lid.as_ref()) and feed those into wacore::messages::parse_message_info(...) so
parsing uses the repository snapshot instead of the internal Device Arc; update
references from get_device_arc, guard, own_pn and own_lid to get_device_snapshot
and snapshot.pn/snapshot.lid.

In `@src/message/special.rs`:
- Around line 24-41: The code currently bails silently when
plaintext_node.content_bytes() returns None; add a log entry to surface that
case: locate the block around plaintext_node.content_bytes(), and when it yields
None emit a debug or warn log (including info.id and info.source.chat)
explaining that the plaintext node existed but had no content bytes so decoding
was skipped; leave the existing wa::Message::decode and
self.dispatch_parsed_message(...) behavior unchanged for the Some(bytes) path.
🪄 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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: fcbabcef-edf2-4878-90f5-b6f329b79d51

📥 Commits

Reviewing files that changed from the base of the PR and between 09a3b0c and 7bccf69.

📒 Files selected for processing (24)
  • src/client.rs
  • src/client/accessors.rs
  • src/client/adapters.rs
  • src/client/app_state.rs
  • src/client/iq_ops.rs
  • src/client/lifecycle.rs
  • src/client/messaging.rs
  • src/client/node_io.rs
  • src/client/tests.rs
  • src/message.rs
  • src/message/dispatch.rs
  • src/message/msg_secret.rs
  • src/message/receive.rs
  • src/message/retry.rs
  • src/message/special.rs
  • src/message/tests.rs
  • wacore/src/send.rs
  • wacore/src/send/classify.rs
  • wacore/src/send/dm.rs
  • wacore/src/send/encrypt.rs
  • wacore/src/send/group.rs
  • wacore/src/send/peer.rs
  • wacore/src/send/status.rs
  • wacore/src/send/tests.rs

Comment thread src/client/app_state.rs
Comment on lines +411 to +419
let iq = crate::request::InfoQuery {
namespace: "w:sync:app:state",
query_type: crate::request::InfoQueryType::Set,
to: server_jid().clone(),
target: None,
id: None,
content: Some(wacore_binary::NodeContent::Nodes(vec![sync_node])),
timeout: None,
};

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 | ⚡ Quick win

Missing timeout on IQ request creates hang risk.

Look, we've got timeout: Some(Duration::from_secs(30)) in the batched sync path (line 205), but here we're sending IQ requests with timeout: None. If the server decides to ghost us, this entire sync operation just... hangs. Forever. That's not how we build resilient systems at scale.

The pagination cap at line 384 protects against infinite loops from has_more_patches, but it won't save us from a single stuck request blocking the entire sync pipeline.

🔧 Proposed fix: Add consistent timeout
             let iq = crate::request::InfoQuery {
                 namespace: "w:sync:app:state",
                 query_type: crate::request::InfoQueryType::Set,
                 to: server_jid().clone(),
                 target: None,
                 id: None,
                 content: Some(wacore_binary::NodeContent::Nodes(vec![sync_node])),
-                timeout: None,
+                timeout: Some(Duration::from_secs(30)),
             };
📝 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
let iq = crate::request::InfoQuery {
namespace: "w:sync:app:state",
query_type: crate::request::InfoQueryType::Set,
to: server_jid().clone(),
target: None,
id: None,
content: Some(wacore_binary::NodeContent::Nodes(vec![sync_node])),
timeout: None,
};
let iq = crate::request::InfoQuery {
namespace: "w:sync:app:state",
query_type: crate::request::InfoQueryType::Set,
to: server_jid().clone(),
target: None,
id: None,
content: Some(wacore_binary::NodeContent::Nodes(vec![sync_node])),
timeout: Some(Duration::from_secs(30)),
};
🤖 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/client/app_state.rs` around lines 411 - 419, The IQ being constructed in
app_state.rs (variable iq of type crate::request::InfoQuery) currently sets
timeout: None which can hang the sync; update the iq timeout field to a sensible
timeout (e.g. Some(Duration::from_secs(30))) consistent with the batched sync
path so the InfoQuery has a finite wait; ensure you import or reference
std::time::Duration if needed and apply the change where iq is created so
timeout is not None.

Comment thread src/client/app_state.rs
Comment on lines +480 to +493
let download = |ext: &wa::ExternalBlobReference| -> anyhow::Result<Vec<u8>> {
if let Some(path) = &ext.direct_path {
if let Some(bytes) = pre_downloaded.get(path) {
Ok(bytes.clone())
} else {
Err(anyhow::anyhow!(
"external blob not pre-downloaded: {}",
path
))
}
} else {
Err(anyhow::anyhow!("external blob has no directPath"))
}
};

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 | 💤 Low value

Consider extracting duplicate download closure logic.

This closure is a near-exact copy of lines 259-272 in sync_collections_batched_inner. When we copy-paste code, we're basically setting ourselves up for a future where one gets fixed and the other doesn't.

A small helper function that takes the pre_downloaded map and returns the closure (or directly performs the lookup) would keep things DRY.

🤖 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/client/app_state.rs` around lines 480 - 493, Extract the duplicated
download logic into a small helper so both places reuse the same implementation:
add a helper like get_pre_downloaded_blob(pre_downloaded: &PreDownloadedMap,
ext: &wa::ExternalBlobReference) -> anyhow::Result<Vec<u8>> (or a factory fn
make_download_closure(pre_downloaded: &PreDownloadedMap) -> impl
Fn(&wa::ExternalBlobReference) -> anyhow::Result<Vec<u8>>), then replace the
inline download closure here and the similar closure in
sync_collections_batched_inner with calls to that helper; ensure it checks
ext.direct_path, looks up pre_downloaded by path, returns the bytes clone on hit
or an anyhow error with the same messages ("external blob not pre-downloaded:
{}" and "external blob has no directPath") so behavior remains identical.

Comment thread src/client/app_state.rs
Comment on lines +622 to +632
let iq = crate::request::InfoQuery {
namespace: "w:sync:app:state",
query_type: crate::request::InfoQueryType::Set,
to: server_jid().clone(),
target: None,
id: None,
content: Some(wacore_binary::NodeContent::Nodes(vec![sync_node])),
timeout: None,
};

self.send_iq(iq).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 | ⚡ Quick win

Same missing timeout issue on patch send IQ.

Same story here - we're sending an IQ request with no timeout. If the server doesn't respond after we push our patch, we're stuck. Consistency matters. Let's use the same 30-second timeout pattern.

🔧 Proposed fix
         let iq = crate::request::InfoQuery {
             namespace: "w:sync:app:state",
             query_type: crate::request::InfoQueryType::Set,
             to: server_jid().clone(),
             target: None,
             id: None,
             content: Some(wacore_binary::NodeContent::Nodes(vec![sync_node])),
-            timeout: None,
+            timeout: Some(Duration::from_secs(30)),
         };
📝 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
let iq = crate::request::InfoQuery {
namespace: "w:sync:app:state",
query_type: crate::request::InfoQueryType::Set,
to: server_jid().clone(),
target: None,
id: None,
content: Some(wacore_binary::NodeContent::Nodes(vec![sync_node])),
timeout: None,
};
self.send_iq(iq).await?;
let iq = crate::request::InfoQuery {
namespace: "w:sync:app:state",
query_type: crate::request::InfoQueryType::Set,
to: server_jid().clone(),
target: None,
id: None,
content: Some(wacore_binary::NodeContent::Nodes(vec![sync_node])),
timeout: Some(Duration::from_secs(30)),
};
self.send_iq(iq).await?;
🤖 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/client/app_state.rs` around lines 622 - 632, The IQ being sent from this
patch uses crate::request::InfoQuery without a timeout, so add a 30-second
timeout to the InfoQuery before calling self.send_iq(iq). Specifically, set the
InfoQuery.timeout field to Some(Duration::from_secs(30)) (importing
std::time::Duration if needed) in the block that builds the iq (the InfoQuery
instance used with self.send_iq), mirroring the existing 30s timeout pattern
used elsewhere.

Comment thread src/client/messaging.rs
Comment thread src/client/node_io.rs
Comment on lines +396 to +414
async fn maybe_deferred_ack(self: &Arc<Self>, node: Arc<wacore_binary::OwnedNodeRef>) {
if self.synchronous_ack {
if let Err(e) = self.send_ack_for(node.get()).await
&& !e.is_transport_unavailable()
{
warn!("Failed to send ack: {e:?}");
}
} else {
let this = self.clone();
self.runtime
.spawn(Box::pin(async move {
if let Err(e) = this.send_ack_for(node.get()).await
&& !e.is_transport_unavailable()
{
warn!("Failed to send ack: {e:?}");
}
}))
.detach();
}

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 | ⚡ Quick win

Track deferred ACK sends through outbound_flush.

The async branch goes straight through runtime.spawn(). disconnect() only waits on outbound_flush, so cleanup can close the transport while this ACK is still in flight. That drops the ACK under disconnect pressure and reopens the replay / <stream:error><ack/> path.

Suggested fix
         } else {
             let this = self.clone();
-            self.runtime
-                .spawn(Box::pin(async move {
-                    if let Err(e) = this.send_ack_for(node.get()).await
-                        && !e.is_transport_unavailable()
-                    {
-                        warn!("Failed to send ack: {e:?}");
-                    }
-                }))
-                .detach();
+            self.outbound_flush.spawn(&*self.runtime, async move {
+                if let Err(e) = this.send_ack_for(node.get()).await
+                    && !e.is_transport_unavailable()
+                {
+                    warn!("Failed to send ack: {e:?}");
+                }
+            });
         }
     }
🤖 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/client/node_io.rs` around lines 396 - 414, The deferred-ACK branch
currently bypasses outbound_flush by using runtime.spawn directly, allowing
disconnect() to close the transport while the ACK is in flight; update
maybe_deferred_ack so the ACK task is submitted/tracked through the same
outbound_flush mechanism used for other outbound work (instead of calling
runtime.spawn directly). Concretely, replace the runtime.spawn(Box::pin(...))
usage in maybe_deferred_ack with the codepath that enqueues or registers the
async closure/future with outbound_flush (so disconnect() will wait for it),
preserving the existing send_ack_for(node.get()).await and the
transport-unavailable check/logging. Ensure you reference maybe_deferred_ack,
send_ack_for, outbound_flush and disconnect when making the change.

Comment thread src/message/receive.rs Outdated
Comment thread src/message/receive.rs
Comment on lines +1519 to +1526
let (own_pn, own_lid) = {
let arc = self.persistence_manager.get_device_arc().await;
let guard = arc.read().await;
(guard.pn.clone(), guard.lid.clone())
};
let default_jid = Jid::default();
let own_jid = own_pn.as_ref().unwrap_or(&default_jid);
wacore::messages::parse_message_info(node, own_jid, own_lid.as_ref())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use the device snapshot here.

This read path reaches into get_device_arc() just to pull pn and lid. That bypasses the repository contract and couples message parsing to Device internals for no gain. Read a snapshot instead.

As per coding guidelines, use get_device_snapshot() for Device reads.

🤖 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/message/receive.rs` around lines 1519 - 1526, The code currently calls
self.persistence_manager.get_device_arc() and reads guard.pn/guard.lid directly;
replace that with a call to
self.persistence_manager.get_device_snapshot().await, then extract snapshot.pn
and snapshot.lid (use the same default_jid fallback for pn and pass
lid.as_ref()) and feed those into wacore::messages::parse_message_info(...) so
parsing uses the repository snapshot instead of the internal Device Arc; update
references from get_device_arc, guard, own_pn and own_lid to get_device_snapshot
and snapshot.pn/snapshot.lid.

Source: Coding guidelines

Comment thread src/message/special.rs
…s, uniformity, test)

Follow-up to the monolith split addressing the Codex review. None of these change the runtime path: they fix logging, normalize an accumulator, and add batch coverage.

messaging.rs: send_protocol_receipt logged self.unique_id (the client UUID) under a "message ID {}" label; log the receipt id instead. The id is read by borrow (.attr("id", id.as_str())) so it stays available for the error log without an extra clone.

message/receive.rs: normalize the 5 remaining `outcome.undecryptable = handle_decrypt_failure(...)` sites to `|=`, matching the other 10 accumulator sites. handle_decrypt_failure always returns true today, so this is inert; the `|=` keeps every undecryptable accumulation site uniform and avoids a clobber footgun if that return ever becomes dedup-aware.

message/special.rs: log at debug when a newsletter <plaintext> node has no content bytes (was silently skipped).

tests: add a batch-level invariant test where two undecryptable payloads sharing one (chat,id) accumulate `undecryptable` and dispatch exactly one UndecryptableMessage (single-flight dedup through process_session_enc_batch).

Review items intentionally skipped: the two IQ "missing timeout" nits (send_iq applies a 75s default when timeout is None, so no hang) and switching parse_message_info to get_device_snapshot (the snapshot clones the whole Device, while the per-message hot path intentionally clones only pn/lid).
@jlucaso1
jlucaso1 merged commit a4f8b45 into main Jun 6, 2026
9 of 10 checks passed
@jlucaso1
jlucaso1 deleted the refactor/split-monoliths branch June 6, 2026 01:03
@coderabbitai coderabbitai Bot mentioned this pull request Jun 23, 2026
jlucaso1 added a commit that referenced this pull request Jun 28, 2026
Resolves the stale PR by merging current main and finishing the protobuf
codegen migration from prost to buffa. Tracks the tagged upstream release
anthropics/buffa v0.8.0 (the jlucaso1 fork is dropped now that oneof_attribute
is upstream).

The .proto stays in the upstream/whatspec camelCase form; build.rs snake_cases
the committed descriptor for the Rust API via buffa-descriptor, so consumers
never need protoc.

Zero prost: the sqlite-storage on-disk blobs and the voip/mlow runtime constant
tables now generate buffa types from their own .proto + checked-in descriptor
(wire-compatible by field number, no protoc for consumers); the manual
DecryptionErrorMessageProto codec moves to the buffa Message trait; and
prost/prost-build/prost-types are removed from the workspace.

main's #732 monolith splits (wacore send; src client/message/send) are adopted
over the branch's pre-split monoliths, and the whole src/ crate is migrated to
the buffa API: MessageField presence/accessors, decode_from_slice, typed enum
fields, ADV* type casing, and snake_case oneof fields.
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