Skip to content

fix(contacts): use fn items for LID mapping extractors so boxed futures compile - #826

Merged
jlucaso1 merged 2 commits into
mainfrom
fix/issue-825-hrtb-closures
Jun 10, 2026
Merged

fix(contacts): use fn items for LID mapping extractors so boxed futures compile#826
jlucaso1 merged 2 commits into
mainfrom
fix/issue-825-hrtb-closures

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Problem

is_on_whatsapp() and get_user_info() failed to compile for any consumer that boxes their futures, which is exactly what #[async_trait] does:

error: implementation of `FnOnce` is not general enough
  = note: closure with signature `fn(&'0 IsOnWhatsAppResult) -> (&Jid, Option<&Jid>)`
          must implement `FnOnce<(&'1 IsOnWhatsAppResult,)>`, for any two lifetimes `'0` and `'1`...

Reported in #825 with an excellent minimal repro. The RwLock in the report is incidental: the trigger is the Box<dyn Future + Send> that async_trait produces, which forces the compiler to prove auto traits and higher-ranked bounds for the whole future tree. The real culprit lives in this library: the persist_lid_mappings call sites passed closures returning references tied to their argument (|r| (&r.jid, r.lid.as_ref())). Rust infers such closures at a concrete lifetime rather than the higher-ranked for<'r> Fn(&'r _), and since the closure types are embedded in the public methods' future types, the unprovable obligation leaks to every boxing consumer. None of the user-side workarounds can fix it, since the problem is inside the library's future type.

Fix

Replace the three borrowing closures (two in is_on_whatsapp, one in get_user_info) with fn items, which implement Fn for every lifetime by construction. Zero clones, zero public API change, identical behavior.

Swept the rest of the workspace for the same class: the other generic helpers with borrowed-item bounds (GroupInfo::add_participants, DeviceTopology::record, DeviceRegistryCache::insert, participant_list_hash) are either synchronous (the iterator never lives inside a future) or only ever receive closure-free iterators (slices/Chain), and the FDownload closures in appstate return owned values, which generalize fine. The contacts call sites were the only affected surface.

Tests

New tests/async_trait_boxed_future_compat.rs: a compile-time regression guard reproducing the consumer shape from the report (#[async_trait] + RwLock<Option<Arc<Client>>> calling both methods). This class of error only manifests in the downstream crate's context, so the guard IS the compilation. Verified it catches the regression: with the previous closures restored, the guard fails with six "not general enough" errors; with the fn items it compiles and passes.

  • cargo fmt --all
  • cargo clippy --all-targets -- -D warnings
  • cargo test -p whatsapp-rust --lib (765 passing)
  • cargo test -p whatsapp-rust --test async_trait_boxed_future_compat

Breaking

None.

Fixes #825

…es compile

Closures returning references tied to their argument are inferred at a
concrete lifetime; embedded in is_on_whatsapp/get_user_info's future
types, they made any consumer that boxes those futures (async_trait)
fail with "implementation of FnOnce is not general enough". Fn items
implement Fn for every lifetime by construction. A compile-time
regression test reproduces the consumer shape from the report.

Fixes #825
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

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: 3ec07f48-2fbf-4e39-9e23-541a5b830a27

📥 Commits

Reviewing files that changed from the base of the PR and between fb11438 and 9f0649a.

📒 Files selected for processing (2)
  • src/features/contacts.rs
  • tests/async_trait_boxed_future_compat.rs

📝 Walkthrough

Summary by CodeRabbit

  • Tests
    • Added a new regression test to ensure compatibility with async operations and system stability.

Walkthrough

This PR fixes a lifetime inference failure that prevented is_on_whatsapp() and get_user_info() from compiling within async-trait implementations paired with RwLock contexts. The fix replaces inline closure-based mappings with non-closure fn item helpers and adds a regression test to prevent future regressions.

Changes

Async-Trait Closure Lifetime Fix

Layer / File(s) Summary
Closure-to-fn item mapping helpers and documentation
src/features/contacts.rs
Introduces three fn item helpers (forward_lid_pair, reverse_lid_pair, user_info_lid_pair) to transform result types into the tuples consumed by persist_lid_mappings, with documentation clarifying that fn items (not closures) must be passed to avoid async-trait boxed-future lifetime issues.
Integration into Contacts methods
src/features/contacts.rs
Updates Contacts::is_on_whatsapp and Contacts::get_user_info to use the new fn item mapping helpers instead of inline closures for persistence calls.
Async-trait boxed-future compatibility regression test
tests/async_trait_boxed_future_compat.rs
Adds a compile-time regression test that reproduces the async-trait + RwLock scenario from issue #825 by implementing a WhatsAppGateway trait over RwLock<Option<Arc<Client>>> and validating the trait implementation compiles correctly.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#453: Earlier changes to is_on_whatsapp and LID mapping persistence behavior that this PR refactors to resolve the async-trait lifetime issue.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: replacing closures with fn items in LID mapping extractors to enable boxed futures to compile.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the problem, root cause, solution, test additions, and validation clearly.
Linked Issues check ✅ Passed The PR fully addresses issue #825 by replacing borrowing closures with fn items in is_on_whatsapp and get_user_info, adding a regression test, and verifying the fix prevents HRTB compilation errors in async_trait contexts.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the HRTB lifetime issue: replacing three closures with fn items, adding documentation, and creating a compile-time regression test. No extraneous modifications are present.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-825-hrtb-closures

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

❤️ Share

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

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jun 10, 2026

Copy link
Copy Markdown

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


Review

Verdict: LGTM — surgical, correct fix with a well-targeted compile-time regression guard.

Why this fix is right

The diagnosis in the PR body is precisely the failure mode. Rust infers closures at concrete lifetimes; once the closure type is captured inside the public method's future, every downstream caller that boxes the future (#[async_trait] does this for you) gets dragged into proving a higher-ranked bound the compiler cannot derive from a non-HRTB closure. Lifting the three borrowing closures into fn items dodges the problem entirely — fn items implement Fn for every lifetime by construction, so the future type only refers to a plain function pointer, no HRTB obligation leaks out.

Behaviorally the three fn items (forward_lid_pair, reverse_lid_pair, user_info_lid_pair at src/features/contacts.rs:33-47) produce the same tuples the old closures produced — no clones, no API change, no observable diff. Confirmed by reading the diff against origin/main.

Test design

tests/async_trait_boxed_future_compat.rs is the right shape for this class of bug: the only thing that reliably reproduces it is a downstream #[async_trait] impl, and that's exactly what the test sets up. assert_impl::<GatewayImpl>() is a minimal trait-bound check that fails the build if GatewayImpl: WhatsAppGateway doesn't hold (and WhatsAppGateway: Send + Sync forces auto-trait propagation through the boxed future). The retained RwLock<Option<Arc<Client>>> mirrors the original repro from #825 even though it's dropped before the .await — fine, since the trigger is the boxing, not the lock. async-trait is already a regular dep at Cargo.toml:137, so no new dep weight.

Sweep

Verified the author's claim that other Item = (&Jid, Option<&Jid>) generic helpers aren't affected: GroupInfo::add_participants at wacore/src/client/context.rs:105 is pub fn (sync), so its closure type never lives inside a future. All other call sites (tests/e2e/..., src/features/groups.rs:433, src/handlers/notification/groups.rs:141) pass slices/Chain, no borrowing closures. The contacts call sites really were the only affected surface.

Suggestions (non-blocking)

  1. Move the "don't pass closures here" warning to the receiver, not the senders. The HRTB rationale is currently a doc block on the three fn items. The next person to add a persist_lid_mappings call site will read the bound on persist_lid_mappings (src/features/contacts.rs:58), not the existing extractors, and is likely to write a closure again. A one-line note on persist_lid_mappings itself — something like "callers must pass fn items, not closures; see comment on forward_lid_pair" — keeps the warning where future callers will encounter it. Fix this →

  2. Optional: one-liner semantic doc on each extractor. Names alone don't communicate that reverse_lid_pair only fires when the primary JID is LID and a pn_jid is present. A six-word comment per fn (/// PN-primary result -> (PN, LID). / /// LID-primary result inverted to (PN, LID); skipped otherwise.) makes the call site self-documenting. Not load-bearing, just nice.

Neither is a blocker. Ship it.


  • Read PR diff and changed files
  • Analyze src/features/contacts.rs changes
  • Analyze tests/async_trait_boxed_future_compat.rs
  • Check persist_lid_mappings call sites and signature
  • Verify sweep claim (other generic helpers are sync or take non-closure iterators)
  • Post review findings
    • Branch: fix/issue-825-hrtb-closures

@github-actions

github-actions Bot commented Jun 10, 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,927 2,927 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,448 8,448 +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,487 49,487 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 55,003 55,003 +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,061 113,063 -0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,622 1,656,626 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 641,886 641,939 -0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 852,900 852,915 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,989,750 1,989,680 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 734,120 734,144 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,288,022 1,284,060 +0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,177,414 4,156,415 +0.5%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 518,958 515,573 +0.7%
binary_benchmark::marshal_group::bench_marshal_allocating 40,690 40,690 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 40,743 40,743 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 61,909 61,909 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 38,953 38,953 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 40,796 40,796 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 5,144 5,144 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 5,174 5,174 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,954 6,954 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,339 528,339 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 527,963 527,963 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,211 529,211 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 4,769,980 4,769,980 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 4,769,621 4,769,621 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 12,643,701 12,643,701 +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() 28,069 28,069 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,888 672,888 +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,845 3,845 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 47,180 47,180 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,871 3,871 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 47,241 47,241 +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() 65,610 65,610 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 234,591 234,591 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,579 8,579 +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,138,520 4,144,657 -0.1%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,133 +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() 509,976 509,047 +0.2%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,978,002 11,977,245 +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,939,172 4,905,942 +0.7%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,351 2,043,351 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,404 37,414 -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,658 230,648 +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.

Review follow-up: the next call-site author reads the receiver's
signature, not the existing extractors, so the HRTB warning lives there
too. Also one-line semantics on each extractor.

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

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread src/features/contacts.rs
Comment on lines +27 to +31
/// Mapping extractors as fn items, NOT closures. A closure returning
/// references tied to its argument is inferred at a concrete lifetime, and
/// because its type is embedded in the public methods' future types, callers
/// that box those futures (`#[async_trait]`, `Box<dyn Future + Send>`) hit
/// "implementation of `FnOnce` is not general enough" (issue #825). Fn items

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make the extractor comment concise

AGENTS.md says, “When adding comments to the code, dont be so verbose, also only explain why, not what.” This new doc block explains the implementation mechanics and downstream compiler behavior in detail, including what fn items do, so it violates the local comment guidance and leaves commit/issue-level rationale in source. Please trim it to the minimal why, or move the longer explanation to external documentation.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 merged commit 59cccb8 into main Jun 10, 2026
13 checks passed
@jlucaso1
jlucaso1 deleted the fix/issue-825-hrtb-closures branch June 10, 2026 11:31
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.

Lifetime Issue: is_on_whatsapp() Fails Within Async Trait + RwLock Context

1 participant