From 2fef0272eb0d85a2ae137ae5d316172ce3aa6e2a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:22:21 +0000 Subject: [PATCH 1/8] perf(send): pin the warm group stanza as flat in group size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external profile of a 128-member group send reported ~736 instructions of growth per additional member per message, attributing part of it to the encoder. Measured in-process, `prepare_group_stanza` is flat: 333,963 instructions at 8 members and 334,099 at 512 (+136 over +504 members). A steady-state group send carries no per-participant data at all, so there is no encoded participant node to cache between sends. The reason the group benchmarks looked like they scaled is the fixture: `run_group_send` built and dropped an N-participant `GroupInfo` inside the measured body. Production resolves the group once and holds it behind an `Arc` across sends (`ensure_self_in_group` returns the same `Arc` whenever we are already a member, the steady state), so no send pays that. At 512 members it charged 26.8K instructions per send, 7.2% of the measurement, and was the whole of the apparent group-size growth. Hoist it into setup, pin the shape claim as a test (the encoded warm stanza is the same size for a group of 8 and a group of 512), and sweep the one group stanza whose encode really is proportional to participants — the sender-key distribution fan-out — so a regression that folds per-participant state into the warm stanza is distinguishable from a group that is merely redistributing. Per warm group send, by callgrind, before -> after: 8 members 341,312 -> 340,670 32 members 342,958 -> 339,932 128 members 349,612 -> 340,914 512 members 374,812 -> 340,890 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY --- wacore/benches/send_receive_benchmark.rs | 33 ++++-- wacore/binary/benches/binary_benchmark.rs | 26 ++++- wacore/src/send/tests.rs | 127 ++++++++++++++++++++++ 3 files changed, 173 insertions(+), 13 deletions(-) diff --git a/wacore/benches/send_receive_benchmark.rs b/wacore/benches/send_receive_benchmark.rs index ffd97155c..4035b65cd 100644 --- a/wacore/benches/send_receive_benchmark.rs +++ b/wacore/benches/send_receive_benchmark.rs @@ -655,7 +655,16 @@ fn setup_dm_recv() -> DmRecvData { struct GrpSendData { alice: User, group_jid: Jid, - participants: Vec, + /// Built in setup, not per iteration: production resolves the group once + /// and holds the result behind an `Arc` across sends (`ensure_self_in_group` + /// hands the same `Arc` straight back whenever we are already a member, the + /// steady state), so a send never constructs or drops a participant list. + /// Building it in the measured body charged every group send an + /// N-participant construct + teardown that no send performs — 26.8K + /// instructions at 512 members, and the entire reason this benchmark + /// appeared to scale with group size while `prepare_group_stanza` itself is + /// flat (334.0K at 8 members, 334.1K at 512). + group_info: GroupInfo, /// Warm-send fixture: the resolved set with its phash memo pre-warmed in /// setup, like the per-group device memo serves production repeat sends. resolved_for_phash: Option>, @@ -705,10 +714,18 @@ fn setup_group_send(n: usize) -> GrpSendData { .phash(&alice.jid) .expect("phash must warm in setup"); + // Self-append happens once here for the same reason production does it once + // per resolution: `prepare_group_stanza` expects the sender in the list. + let own_base = alice.jid.to_non_ad(); + if !participants.iter().any(|p| p.is_same_user_as(&own_base)) { + participants.push(own_base); + } + let group_info = GroupInfo::new(participants, AddressingMode::Pn); + GrpSendData { alice, group_jid, - participants, + group_info, resolved_for_phash: Some(resolved), force_skdm: false, resolver: MockResolver(devices), @@ -1045,15 +1062,7 @@ fn run_group_send(d: &mut GrpSendData) { // only emits a phash if it gets the full device set. Mirror the real // warm-send caller by passing it; the cold/force_skdm path resolves the set // itself and keeps None. - let mut group_info = GroupInfo::new(std::mem::take(&mut d.participants), AddressingMode::Pn); - let own_base = own_jid.to_non_ad(); - if !group_info - .participants - .iter() - .any(|p| p.is_same_user_as(&own_base)) - { - group_info.participants.push(own_base); - } + let group_info = &d.group_info; let mut stores = SignalStores { sender_key_store: &mut d.alice.sender_keys, session_store: &mut d.alice.sessions, @@ -1067,7 +1076,7 @@ fn run_group_send(d: &mut GrpSendData) { &mut stores, &d.resolver, GroupStanzaRequest { - group: &group_info, + group: group_info, own_jid: &own_jid, own_lid: &own_jid, account: Some(&d.account), diff --git a/wacore/binary/benches/binary_benchmark.rs b/wacore/binary/benches/binary_benchmark.rs index 4432abd0b..55586d027 100644 --- a/wacore/binary/benches/binary_benchmark.rs +++ b/wacore/binary/benches/binary_benchmark.rs @@ -38,7 +38,17 @@ fn create_ack_node() -> Node { /// A device fanout. Device-qualified JIDs encode as `AD_JID`, so what repeats /// per child is the packed decode and the AD path, not `read_jid_pair`. fn create_fanout_node() -> Node { - let devices: Vec = (0..8) + create_fanout_node_of_width(8) +} + +/// The `` shape a sender-key distribution puts on the wire: one +/// `` per recipient device, each wrapping its own ``. This is +/// the only group stanza whose encode cost is proportional to the participant +/// count — a steady-state group send distributes no keys and so carries none +/// of this (pinned by `warm_group_stanza_carries_no_per_participant_data` in +/// `wacore`), which is why the width is swept here rather than assumed. +fn create_fanout_node_of_width(width: usize) -> Node { + let devices: Vec = (0..width) .map(|i| { NodeBuilder::new("to") .attr("jid", format!("5511999990000:{i}@s.whatsapp.net")) @@ -228,6 +238,20 @@ fn bench_marshal_auto_many_children(bencher: divan::Bencher) { .bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap())); } +// Group sender-key distribution, swept across the recipient count reported for +// real groups. Marshalling is linear in the fan-out width, so this is what a +// cold group send (or a redistribution after a membership change) pays in the +// encoder; the steady-state send that follows carries no `` at +// all. Keeping both facts measurable is what tells a group-size regression +// ("the warm stanza grew a per-participant node") apart from a group that is +// merely redistributing. +#[divan::bench(args = [8, 32, 128, 512])] +fn bench_marshal_auto_group_fanout(bencher: divan::Bencher, width: usize) { + bencher + .with_inputs(|| create_fanout_node_of_width(width)) + .bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap())); +} + // The exact (plan + hint replay) strategy is the production send path for // message plaintext, so its worst case — many children, JID-heavy — gets its // own pin. diff --git a/wacore/src/send/tests.rs b/wacore/src/send/tests.rs index 56b0c893a..8e14f012b 100644 --- a/wacore/src/send/tests.rs +++ b/wacore/src/send/tests.rs @@ -3921,6 +3921,133 @@ mod mark_full_distribution_list { not aborting the whole cohort" ); } + + /// Nothing in a steady-state group stanza is per-participant. + /// + /// `` exists only to carry sender-key distributions, and a + /// warm send distributes none; the `phash` that covers the whole device set + /// is a fixed-width digest ("2:" + 8 base64 chars) memoized on the resolved + /// set. So the encoded stanza is byte-for-byte the same size for a group of + /// 8 and a group of 512, and the encoder cost of a repeat group send does + /// not scale with the group — there is no per-participant encoding to + /// cache between sends. + /// + /// Pinned as a test rather than left to the group benchmarks because the + /// claim is about the *shape* of the stanza: a future change that folded + /// participant state into it would still benchmark fine on a small group. + #[tokio::test] + async fn warm_group_stanza_carries_no_per_participant_data() { + async fn warm_stanza(members: usize) -> Node { + let own_jid: Jid = "12025550111:0@s.whatsapp.net".parse().unwrap(); + let own_lid: Jid = "100000000000001:0@lid".parse().unwrap(); + let group: Jid = "120363000000000001@g.us".parse().unwrap(); + + let participants: Vec = (0..members) + .map(|i| { + format!("{}@s.whatsapp.net", 12025550200u64 + i as u64) + .parse() + .unwrap() + }) + .collect(); + + let mut rng = rand::make_rng::(); + let mut sks = MemSenderKeyStore::default(); + // A warm send never creates the chain, so seed it exactly as the + // first (cold) send to this group would have. + let sk_name = make_sender_key_name(&group, &own_jid.to_protocol_address()); + crate::libsignal::protocol::create_sender_key_distribution_message( + &sk_name, &mut sks, &mut rng, + ) + .await + .expect("seed the sender key chain"); + + let mut ss = MemSessionStore::default(); + let mut is = MemIdentityStore { + pair: IdentityKeyPair::generate(&mut rng), + reg_id: 7, + known: Default::default(), + }; + let mut pks = UnusedPreKeyStore; + let spks = UnusedSignedPreKeyStore; + let mut stores = SignalStores { + sender_key_store: &mut sks, + session_store: &mut ss, + identity_store: &mut is, + prekey_store: &mut pks, + signed_prekey_store: &spks, + }; + + let mut group_participants = participants.clone(); + group_participants.push(own_jid.to_non_ad()); + let group_info = GroupInfo::new(group_participants, AddressingMode::Pn); + // The full resolved device set the warm send hashes into `phash`. + let resolved = ResolvedGroupDevices::new(participants); + let msg = wa::Message { + conversation: Some("steady state".into()), + ..Default::default() + }; + + prepare_group_stanza( + &TokioTestRuntime, + &mut stores, + &MockSendContextResolver::new(), + GroupStanzaRequest { + group: &group_info, + own_jid: &own_jid, + own_lid: &own_lid, + account: None, + to: &group, + message: &msg, + message_id: "WARMGROUPSCALE1", + force_distribution: false, + distribution_targets: None, + distribution_policy: SenderKeyDistributionPolicy::BestEffort, + phash_devices: Some(&resolved), + edit: None, + extra_nodes: &[], + pre_encoded: None, + }, + ) + .await + .expect("warm group send") + .node + } + + let small = warm_stanza(8).await; + let large = warm_stanza(512).await; + + for (label, node) in [("8-member", &small), ("512-member", &large)] { + assert!( + node.get_optional_child("participants").is_none(), + "{label} warm send must distribute no sender keys" + ); + assert_eq!( + node.attrs().optional_string("phash").map(|p| p.len()), + Some(10), + "{label} phash is a fixed-width digest" + ); + } + + let small_children: Vec<&str> = small + .children() + .unwrap_or(&[]) + .iter() + .map(|c| c.tag.as_ref()) + .collect(); + let large_children: Vec<&str> = large + .children() + .unwrap_or(&[]) + .iter() + .map(|c| c.tag.as_ref()) + .collect(); + assert_eq!(small_children, large_children, "same stanza shape"); + + assert_eq!( + wacore_binary::marshal::marshal(&small).unwrap().len(), + wacore_binary::marshal::marshal(&large).unwrap().len(), + "the encoded warm group stanza is the same size at 8 and 512 members" + ); + } } /// Item 3 — phash device-set construction. The set hashed is the full From 77bf9ab3b7632491375ae54de833f907f65841a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:34:36 +0000 Subject: [PATCH 2/8] test(send): pin the warm stanza against own-device fanout, not group size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the new fixtures, none touching production code. The size assertion was flaky and CI caught it: every plaintext is padded by a uniform random 1..=16 bytes, so the skmsg ciphertext length differs run to run (CI hit 274 vs 291). Normalise every byte payload to a fixed size before comparing — the claim under test is the stanza's structure and attributes, not the ciphertext. 40/40 stable locally after the change. The warm fixture also over-claimed. A steady-state send distributes no sender key to group *members*, but it does re-distribute to our own companions on every send, because own devices are never memoized warm (WA Web `!isMeDevice`). A blanket "no " assertion therefore pinned only the single-device shape. The test now runs both steady states — 0 and 2 own companions — at 8 and at 512 members, and asserts the distributed count equals the companion count in each. That is the real invariant: the stanza tracks our own device count, never the group's. The fan-out marshal sweep modelled recipients as one user numbered 0..width, so above 255 the device component overflowed `parse_jid_meta`'s `u8` and those recipients silently encoded as JID_PAIR — the 512-wide measurement was averaging two wire shapes. Recipients are now a typed `Jid` per device (as `build_participant_node` passes them, skipping a string classifier production never runs) spread over distinct users with 4 devices each, so the whole sweep stays on the AD_JID path. `create_fanout_node` keeps its original width-8 body, leaving `bench_unmarshal_fanout`'s baseline untouched. Sweep after the fix: 1.55 / 4.81 / 17.7 / 88.3 µs at widths 8 / 32 / 128 / 512 — still linear, ~0.17 µs per recipient. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY --- wacore/binary/benches/binary_benchmark.rs | 58 +++++++-- wacore/src/send/tests.rs | 136 +++++++++++++++------- 2 files changed, 142 insertions(+), 52 deletions(-) diff --git a/wacore/binary/benches/binary_benchmark.rs b/wacore/binary/benches/binary_benchmark.rs index 55586d027..edcea2edb 100644 --- a/wacore/binary/benches/binary_benchmark.rs +++ b/wacore/binary/benches/binary_benchmark.rs @@ -3,6 +3,7 @@ use flate2::Compression; use flate2::write::ZlibEncoder; use std::io::Write; use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::Jid; use wacore_binary::marshal::{ marshal, marshal_auto, marshal_exact, marshal_ref, marshal_ref_auto, marshal_ref_exact, marshal_to, unmarshal_ref, @@ -38,20 +39,53 @@ fn create_ack_node() -> Node { /// A device fanout. Device-qualified JIDs encode as `AD_JID`, so what repeats /// per child is the packed decode and the AD path, not `read_jid_pair`. fn create_fanout_node() -> Node { - create_fanout_node_of_width(8) + let devices: Vec = (0..8) + .map(|i| { + NodeBuilder::new("to") + .attr("jid", format!("5511999990000:{i}@s.whatsapp.net")) + .children(vec![ + NodeBuilder::new("enc") + .attr("v", "2") + .attr("type", "msg") + .bytes(vec![0xAB; 128]) + .build(), + ]) + .build() + }) + .collect(); + NodeBuilder::new("message") + .attr("to", "5511999990000@g.us") + .attr("id", "3EB0A1B2C3D4E5F60718") + .attr("type", "text") + .children(vec![ + NodeBuilder::new("participants").children(devices).build(), + ]) + .build() } /// The `` shape a sender-key distribution puts on the wire: one /// `` per recipient device, each wrapping its own ``. This is -/// the only group stanza whose encode cost is proportional to the participant -/// count — a steady-state group send distributes no keys and so carries none -/// of this (pinned by `warm_group_stanza_carries_no_per_participant_data` in -/// `wacore`), which is why the width is swept here rather than assumed. -fn create_fanout_node_of_width(width: usize) -> Node { - let devices: Vec = (0..width) +/// the only group stanza whose encode cost is proportional to the recipient +/// count — a steady-state group send distributes to our own companions only +/// and carries nothing per member (pinned by +/// `warm_group_stanza_size_tracks_own_devices_not_group_size` in `wacore`), +/// which is why the width is swept here rather than assumed. +/// +/// Recipients are a typed [`Jid`] per device, as `build_participant_node` +/// passes them, and are spread over distinct users with a handful of devices +/// each, the way a real fanout resolves. Both details decide the encoding: +/// a typed JID skips the string classifier production never runs here, and a +/// device id must stay within `u8` to take the `AD_JID` path — a single user +/// numbered up to 511 would silently encode half the sweep as `JID_PAIR` and +/// measure two wire shapes at once. +fn create_skdm_fanout_node(width: usize) -> Node { + const DEVICES_PER_USER: usize = 4; + let recipients: Vec = (0..width) .map(|i| { + let user = 5511999990000u64 + (i / DEVICES_PER_USER) as u64; + let device = (i % DEVICES_PER_USER) as u16; NodeBuilder::new("to") - .attr("jid", format!("5511999990000:{i}@s.whatsapp.net")) + .attr("jid", Jid::pn_device(user.to_string(), device)) .children(vec![ NodeBuilder::new("enc") .attr("v", "2") @@ -63,11 +97,13 @@ fn create_fanout_node_of_width(width: usize) -> Node { }) .collect(); NodeBuilder::new("message") - .attr("to", "5511999990000@g.us") + .attr("to", "120363000000000001@g.us") .attr("id", "3EB0A1B2C3D4E5F60718") .attr("type", "text") .children(vec![ - NodeBuilder::new("participants").children(devices).build(), + NodeBuilder::new("participants") + .children(recipients) + .build(), ]) .build() } @@ -248,7 +284,7 @@ fn bench_marshal_auto_many_children(bencher: divan::Bencher) { #[divan::bench(args = [8, 32, 128, 512])] fn bench_marshal_auto_group_fanout(bencher: divan::Bencher, width: usize) { bencher - .with_inputs(|| create_fanout_node_of_width(width)) + .with_inputs(|| create_skdm_fanout_node(width)) .bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap())); } diff --git a/wacore/src/send/tests.rs b/wacore/src/send/tests.rs index 8e14f012b..f2a99750f 100644 --- a/wacore/src/send/tests.rs +++ b/wacore/src/send/tests.rs @@ -3922,22 +3922,28 @@ mod mark_full_distribution_list { ); } - /// Nothing in a steady-state group stanza is per-participant. + /// A steady-state group stanza's size tracks our OWN device count, never + /// the group's. /// - /// `` exists only to carry sender-key distributions, and a - /// warm send distributes none; the `phash` that covers the whole device set - /// is a fixed-width digest ("2:" + 8 base64 chars) memoized on the resolved - /// set. So the encoded stanza is byte-for-byte the same size for a group of - /// 8 and a group of 512, and the encoder cost of a repeat group send does - /// not scale with the group — there is no per-participant encoding to - /// cache between sends. + /// `` carries sender-key distributions only. A warm send + /// distributes none to members — but it does re-distribute to our own + /// companions on every send, because own devices are never memoized warm + /// (WA Web `!isMeDevice`, see `update_sender_key_devices`). So the steady + /// state is one `` per own companion and nothing per member, and the + /// `phash` covering the whole device set is a fixed-width digest ("2:" plus + /// 8 base64 chars) memoized on the resolved set. Both the single-device and + /// the multi-device steady state are pinned below at 8 and at 512 members: + /// the encoded stanza is the same size either way, so a repeat group send + /// has no per-member encoding to cache between sends. /// /// Pinned as a test rather than left to the group benchmarks because the /// claim is about the *shape* of the stanza: a future change that folded - /// participant state into it would still benchmark fine on a small group. + /// member state into it would still benchmark fine on a small group. #[tokio::test] - async fn warm_group_stanza_carries_no_per_participant_data() { - async fn warm_stanza(members: usize) -> Node { + async fn warm_group_stanza_size_tracks_own_devices_not_group_size() { + // `members` is the group; `companions` are our own other devices, which + // receive a fresh SKDM on every send. + async fn warm_stanza(members: usize, companions: usize) -> Node { let own_jid: Jid = "12025550111:0@s.whatsapp.net".parse().unwrap(); let own_lid: Jid = "100000000000001:0@lid".parse().unwrap(); let group: Jid = "120363000000000001@g.us".parse().unwrap(); @@ -3949,6 +3955,9 @@ mod mark_full_distribution_list { .unwrap() }) .collect(); + let own_companions: Vec = (1..=companions) + .map(|d| format!("12025550111:{d}@s.whatsapp.net").parse().unwrap()) + .collect(); let mut rng = rand::make_rng::(); let mut sks = MemSenderKeyStore::default(); @@ -3961,12 +3970,27 @@ mod mark_full_distribution_list { .await .expect("seed the sender key chain"); + // Sessions already exist for the companions, as they do in the + // steady state, so the SKDM encrypts to `msg` (not `pkmsg`) and no + // prekey fetch or device-identity node enters the stanza. let mut ss = MemSessionStore::default(); let mut is = MemIdentityStore { pair: IdentityKeyPair::generate(&mut rng), reg_id: 7, known: Default::default(), }; + for companion in &own_companions { + process_prekey_bundle( + &companion.to_protocol_address(), + &mut ss, + &mut is, + &signed_prekey_bundle(), + &mut rng, + UsePQRatchet::No, + ) + .await + .expect("establish the companion session"); + } let mut pks = UnusedPreKeyStore; let spks = UnusedSignedPreKeyStore; let mut stores = SignalStores { @@ -4000,7 +4024,7 @@ mod mark_full_distribution_list { message: &msg, message_id: "WARMGROUPSCALE1", force_distribution: false, - distribution_targets: None, + distribution_targets: (!own_companions.is_empty()).then_some(own_companions), distribution_policy: SenderKeyDistributionPolicy::BestEffort, phash_devices: Some(&resolved), edit: None, @@ -4013,40 +4037,70 @@ mod mark_full_distribution_list { .node } - let small = warm_stanza(8).await; - let large = warm_stanza(512).await; + // Every ciphertext in the stanza varies in length run to run (WA pads + // each plaintext by a random 1..=16 bytes), so sizes are only + // comparable with the payloads normalised. What is under test is the + // stanza's structure and attributes, not the ciphertext. + fn with_fixed_payloads(node: &Node) -> Node { + use wacore_binary::node::NodeContent; + let mut out = node.clone(); + out.content = match out.content { + Some(NodeContent::Bytes(_)) => Some(NodeContent::Bytes(vec![0u8; 96])), + Some(NodeContent::Nodes(children)) => Some(NodeContent::Nodes( + children.iter().map(with_fixed_payloads).collect(), + )), + other => other, + }; + out + } - for (label, node) in [("8-member", &small), ("512-member", &large)] { - assert!( - node.get_optional_child("participants").is_none(), - "{label} warm send must distribute no sender keys" + fn child_tags(node: &Node) -> Vec<&str> { + node.children() + .unwrap_or(&[]) + .iter() + .map(|c| c.tag.as_ref()) + .collect() + } + + // Single-device account (no companions) and a two-companion one: the + // two steady states this client actually produces. + for companions in [0usize, 2] { + let small = warm_stanza(8, companions).await; + let large = warm_stanza(512, companions).await; + + for (label, node) in [("8-member", &small), ("512-member", &large)] { + let distributed = node + .get_optional_child("participants") + .and_then(Node::children) + .map_or(0, <[Node]>::len); + assert_eq!( + distributed, companions, + "{label} warm send distributes to our own companions only, \ + never to the {companions}-companion account's group members" + ); + assert_eq!( + node.attrs().optional_string("phash").map(|p| p.len()), + Some(10), + "{label} phash is a fixed-width digest" + ); + } + + assert_eq!( + child_tags(&small), + child_tags(&large), + "same stanza shape with {companions} companions" ); assert_eq!( - node.attrs().optional_string("phash").map(|p| p.len()), - Some(10), - "{label} phash is a fixed-width digest" + wacore_binary::marshal::marshal(&with_fixed_payloads(&small)) + .unwrap() + .len(), + wacore_binary::marshal::marshal(&with_fixed_payloads(&large)) + .unwrap() + .len(), + "the encoded warm group stanza is the same size at 8 and 512 members \ + with {companions} companions" ); } - - let small_children: Vec<&str> = small - .children() - .unwrap_or(&[]) - .iter() - .map(|c| c.tag.as_ref()) - .collect(); - let large_children: Vec<&str> = large - .children() - .unwrap_or(&[]) - .iter() - .map(|c| c.tag.as_ref()) - .collect(); - assert_eq!(small_children, large_children, "same stanza shape"); - - assert_eq!( - wacore_binary::marshal::marshal(&small).unwrap().len(), - wacore_binary::marshal::marshal(&large).unwrap().len(), - "the encoded warm group stanza is the same size at 8 and 512 members" - ); } } From 527d2454a8439e41212ec05a925e58f3e87df1ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:46:51 +0000 Subject: [PATCH 3/8] bench(binary): give the group fan-out sweep its own target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodSpeed flagged `bench_attr_parser` as +369 Ir/iteration, and it was real — not the Intel→AMD runner swap I first attributed it to. Measured locally with callgrind, base 8,605.4 Ir/iteration against head 8,906.3. None of it is library code. The fan-out fixture instantiates the typed-JID attribute path (`NodeBuilder::attr::`), and adding it to that crate root shifted inlining enough that `create_attr_node`'s builder stopped pre-sizing its `SmallVec` and grew through an extra realloc instead: the delta is `Attrs::insert` +261.7, `SmallVec::try_grow` +254.7, `realloc` +255 — the owned-node path, which `bench_attr_parser` reaches only from its setup, never from the `unmarshal_ref` + attr-parse body it measures. Moving the sweep to its own crate root puts `bench_attr_parser` back at 8,605.4 — exactly the base figure — and leaves `binary_benchmark.rs` byte-identical to main, so no baseline in it can move. A new fixture should not be able to shift an unrelated benchmark. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY --- wacore/binary/Cargo.toml | 4 + wacore/binary/benches/binary_benchmark.rs | 60 -------------- .../binary/benches/group_fanout_benchmark.rs | 78 +++++++++++++++++++ 3 files changed, 82 insertions(+), 60 deletions(-) create mode 100644 wacore/binary/benches/group_fanout_benchmark.rs diff --git a/wacore/binary/Cargo.toml b/wacore/binary/Cargo.toml index 716daa462..a182d042d 100644 --- a/wacore/binary/Cargo.toml +++ b/wacore/binary/Cargo.toml @@ -48,3 +48,7 @@ harness = false [lints] workspace = true + +[[bench]] +name = "group_fanout_benchmark" +harness = false diff --git a/wacore/binary/benches/binary_benchmark.rs b/wacore/binary/benches/binary_benchmark.rs index edcea2edb..4432abd0b 100644 --- a/wacore/binary/benches/binary_benchmark.rs +++ b/wacore/binary/benches/binary_benchmark.rs @@ -3,7 +3,6 @@ use flate2::Compression; use flate2::write::ZlibEncoder; use std::io::Write; use wacore_binary::builder::NodeBuilder; -use wacore_binary::jid::Jid; use wacore_binary::marshal::{ marshal, marshal_auto, marshal_exact, marshal_ref, marshal_ref_auto, marshal_ref_exact, marshal_to, unmarshal_ref, @@ -63,51 +62,6 @@ fn create_fanout_node() -> Node { .build() } -/// The `` shape a sender-key distribution puts on the wire: one -/// `` per recipient device, each wrapping its own ``. This is -/// the only group stanza whose encode cost is proportional to the recipient -/// count — a steady-state group send distributes to our own companions only -/// and carries nothing per member (pinned by -/// `warm_group_stanza_size_tracks_own_devices_not_group_size` in `wacore`), -/// which is why the width is swept here rather than assumed. -/// -/// Recipients are a typed [`Jid`] per device, as `build_participant_node` -/// passes them, and are spread over distinct users with a handful of devices -/// each, the way a real fanout resolves. Both details decide the encoding: -/// a typed JID skips the string classifier production never runs here, and a -/// device id must stay within `u8` to take the `AD_JID` path — a single user -/// numbered up to 511 would silently encode half the sweep as `JID_PAIR` and -/// measure two wire shapes at once. -fn create_skdm_fanout_node(width: usize) -> Node { - const DEVICES_PER_USER: usize = 4; - let recipients: Vec = (0..width) - .map(|i| { - let user = 5511999990000u64 + (i / DEVICES_PER_USER) as u64; - let device = (i % DEVICES_PER_USER) as u16; - NodeBuilder::new("to") - .attr("jid", Jid::pn_device(user.to_string(), device)) - .children(vec![ - NodeBuilder::new("enc") - .attr("v", "2") - .attr("type", "msg") - .bytes(vec![0xAB; 128]) - .build(), - ]) - .build() - }) - .collect(); - NodeBuilder::new("message") - .attr("to", "120363000000000001@g.us") - .attr("id", "3EB0A1B2C3D4E5F60718") - .attr("type", "text") - .children(vec![ - NodeBuilder::new("participants") - .children(recipients) - .build(), - ]) - .build() -} - fn create_large_node() -> Node { NodeBuilder::new("iq") .attr("to", "server@s.whatsapp.net") @@ -274,20 +228,6 @@ fn bench_marshal_auto_many_children(bencher: divan::Bencher) { .bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap())); } -// Group sender-key distribution, swept across the recipient count reported for -// real groups. Marshalling is linear in the fan-out width, so this is what a -// cold group send (or a redistribution after a membership change) pays in the -// encoder; the steady-state send that follows carries no `` at -// all. Keeping both facts measurable is what tells a group-size regression -// ("the warm stanza grew a per-participant node") apart from a group that is -// merely redistributing. -#[divan::bench(args = [8, 32, 128, 512])] -fn bench_marshal_auto_group_fanout(bencher: divan::Bencher, width: usize) { - bencher - .with_inputs(|| create_skdm_fanout_node(width)) - .bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap())); -} - // The exact (plan + hint replay) strategy is the production send path for // message plaintext, so its worst case — many children, JID-heavy — gets its // own pin. diff --git a/wacore/binary/benches/group_fanout_benchmark.rs b/wacore/binary/benches/group_fanout_benchmark.rs new file mode 100644 index 000000000..c699c178b --- /dev/null +++ b/wacore/binary/benches/group_fanout_benchmark.rs @@ -0,0 +1,78 @@ +//! Encoder cost of the group sender-key distribution fan-out, swept across +//! recipient counts. +//! +//! Its own target rather than a section of `binary_benchmark`: the fixture +//! below instantiates the typed-JID attribute path, and adding it to that +//! crate root changed inlining enough to cost `create_attr_node`'s builder an +//! extra `SmallVec` reallocation — ~300 instructions on `bench_attr_parser`, +//! a benchmark with no connection to this one. A separate crate root keeps a +//! new fixture from moving an unrelated baseline. + +use divan::black_box; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::Jid; +use wacore_binary::marshal::marshal_auto; +use wacore_binary::node::Node; + +fn main() { + divan::main(); +} + +/// The `` shape a sender-key distribution puts on the wire: one +/// `` per recipient device, each wrapping its own ``. This is +/// the only group stanza whose encode cost is proportional to the recipient +/// count — a steady-state group send distributes to our own companions only +/// and carries nothing per member (pinned by +/// `warm_group_stanza_size_tracks_own_devices_not_group_size` in `wacore`), +/// which is why the width is swept here rather than assumed. +/// +/// Recipients are a typed [`Jid`] per device, as `build_participant_node` +/// passes them, and are spread over distinct users with a handful of devices +/// each, the way a real fanout resolves. Both details decide the encoding: +/// a typed JID skips the string classifier production never runs here, and a +/// device id must stay within `u8` to take the `AD_JID` path — a single user +/// numbered up to 511 would silently encode half the sweep as `JID_PAIR` and +/// measure two wire shapes at once. +fn create_skdm_fanout_node(width: usize) -> Node { + const DEVICES_PER_USER: usize = 4; + let recipients: Vec = (0..width) + .map(|i| { + let user = 5511999990000u64 + (i / DEVICES_PER_USER) as u64; + let device = (i % DEVICES_PER_USER) as u16; + NodeBuilder::new("to") + .attr("jid", Jid::pn_device(user.to_string(), device)) + .children(vec![ + NodeBuilder::new("enc") + .attr("v", "2") + .attr("type", "msg") + .bytes(vec![0xAB; 128]) + .build(), + ]) + .build() + }) + .collect(); + NodeBuilder::new("message") + .attr("to", "120363000000000001@g.us") + .attr("id", "3EB0A1B2C3D4E5F60718") + .attr("type", "text") + .children(vec![ + NodeBuilder::new("participants") + .children(recipients) + .build(), + ]) + .build() +} + +// Group sender-key distribution, swept across the recipient count reported for +// real groups. Marshalling is linear in the fan-out width, so this is what a +// cold group send (or a redistribution after a membership change) pays in the +// encoder; the steady-state send that follows carries no `` at +// all. Keeping both facts measurable is what tells a group-size regression +// ("the warm stanza grew a per-participant node") apart from a group that is +// merely redistributing. +#[divan::bench(args = [8, 32, 128, 512])] +fn bench_marshal_auto_group_fanout(bencher: divan::Bencher, width: usize) { + bencher + .with_inputs(|| create_skdm_fanout_node(width)) + .bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap())); +} From e9a46cd7978ab254b2cc80fa9bb4b16c7d8ba6da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:55:53 +0000 Subject: [PATCH 4/8] bench(binary): sweep the fan-out through the marshaller sends actually use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Client::marshal_node_for_send` routes every outbound stanza through the two-pass `marshal_exact`; the sweep was calling one-pass `marshal_auto`. The two differ in exactly what this benchmark measures — one-pass reserves and grows, two-pass plans the size and replays a hint tape — so presenting the result as the encoder cost of a cold group send tracked a path no group send takes. `binary_benchmark` already pins `marshal_exact` as the production strategy for the same reason. Still linear, at the two-pass rate: 1.79 / 5.44 / 20.2 / 104.2 µs at widths 8 / 32 / 128 / 512, ~0.20 µs per recipient against 0.17 one-pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY --- wacore/binary/benches/group_fanout_benchmark.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/wacore/binary/benches/group_fanout_benchmark.rs b/wacore/binary/benches/group_fanout_benchmark.rs index c699c178b..7b730e6fc 100644 --- a/wacore/binary/benches/group_fanout_benchmark.rs +++ b/wacore/binary/benches/group_fanout_benchmark.rs @@ -11,7 +11,7 @@ use divan::black_box; use wacore_binary::builder::NodeBuilder; use wacore_binary::jid::Jid; -use wacore_binary::marshal::marshal_auto; +use wacore_binary::marshal::marshal_exact; use wacore_binary::node::Node; fn main() { @@ -70,9 +70,15 @@ fn create_skdm_fanout_node(width: usize) -> Node { // all. Keeping both facts measurable is what tells a group-size regression // ("the warm stanza grew a per-participant node") apart from a group that is // merely redistributing. +// +// `marshal_exact`, not `marshal_auto`: every outbound stanza goes through +// `Client::marshal_node_for_send`, which picks the two-pass exact strategy. +// The two differ in exactly what this sweep is measuring — one-pass reserves +// and grows, two-pass plans the size first and replays a hint tape — so the +// wrong one would track a path no group send takes. #[divan::bench(args = [8, 32, 128, 512])] -fn bench_marshal_auto_group_fanout(bencher: divan::Bencher, width: usize) { +fn bench_marshal_exact_group_fanout(bencher: divan::Bencher, width: usize) { bencher .with_inputs(|| create_skdm_fanout_node(width)) - .bench_refs(|node| black_box(marshal_auto(black_box(node)).unwrap())); + .bench_refs(|node| black_box(marshal_exact(black_box(node)).unwrap())); } From 9c2ee8cf8943d9176b546bcdfb8f48fc6c8fd6b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 16:11:46 +0000 Subject: [PATCH 5/8] test(send): hash the companions the warm stanza distributes to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-device fixture passed the two own companions as `distribution_targets` while handing `ResolvedGroupDevices` only the group members, so `` named devices the `phash` did not cover — a shape no send produces. Production filters the SKDM targets out of that very set (`filter_skdm_targets` over `all_devices_for_phash`), and the server validates the phash against every recipient device, so the targets are always a subset of what is hashed. The companions now go into the resolved set the phash is computed over. None of the assertions move: the count still matches the companions, the phash is still fixed-width, and the encoded sizes still match at 8 and 512. What changes is that the fixture now stands for a stanza the server would accept. Re-ran 25× to confirm the padding-normalised comparison is stable. Also states what the fan-out sweep's payload models. `type="msg"` is the redistribution shape, for devices that already hold a pairwise session; first contact emits `type="pkmsg"`, whose PreKeySignalMessage adds an identity key, a base key and the registration id — roughly twice the bytes. Marshalling is linear in payload size, so that case rides the same slope from a higher intercept, and the per-recipient term this sweep exists to pin is unmoved. Building real ciphertexts is not available here in any case: wacore-binary does not depend on libsignal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY --- wacore/binary/benches/group_fanout_benchmark.rs | 11 +++++++++++ wacore/src/send/tests.rs | 12 ++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/wacore/binary/benches/group_fanout_benchmark.rs b/wacore/binary/benches/group_fanout_benchmark.rs index 7b730e6fc..f443c39ad 100644 --- a/wacore/binary/benches/group_fanout_benchmark.rs +++ b/wacore/binary/benches/group_fanout_benchmark.rs @@ -33,6 +33,17 @@ fn main() { /// device id must stay within `u8` to take the `AD_JID` path — a single user /// numbered up to 511 would silently encode half the sweep as `JID_PAIR` and /// measure two wire shapes at once. +/// +/// The ciphertexts are `type="msg"`, the shape a redistribution to devices that +/// already hold a pairwise session emits. A device being contacted for the +/// first time gets `type="pkmsg"` instead, whose `PreKeySignalMessage` carries +/// an identity key, a base key and the registration id on top of the same inner +/// message — roughly twice the payload. Marshalling is linear in payload bytes, +/// so that case rides the same slope from a higher intercept; what this sweep +/// exists to pin is the per-recipient term, which the payload does not move. +/// Building the real ciphertexts is out of reach here regardless: `wacore-binary` +/// does not depend on libsignal, and giving it a dev-dependency on the Signal +/// stack to size a byte array would be a poor trade. fn create_skdm_fanout_node(width: usize) -> Node { const DEVICES_PER_USER: usize = 4; let recipients: Vec = (0..width) diff --git a/wacore/src/send/tests.rs b/wacore/src/send/tests.rs index f2a99750f..f385ae20d 100644 --- a/wacore/src/send/tests.rs +++ b/wacore/src/send/tests.rs @@ -4005,7 +4005,14 @@ mod mark_full_distribution_list { group_participants.push(own_jid.to_non_ad()); let group_info = GroupInfo::new(group_participants, AddressingMode::Pn); // The full resolved device set the warm send hashes into `phash`. - let resolved = ResolvedGroupDevices::new(participants); + // The companions belong inside it, not beside it: production filters + // the SKDM targets out of this very set (`filter_skdm_targets` over + // `all_devices_for_phash`), and the server validates the phash against + // every recipient device — so a stanza whose `` named a + // device the phash did not cover is a shape no send produces. + let mut resolved_devices = participants; + resolved_devices.extend(own_companions.iter().cloned()); + let resolved = ResolvedGroupDevices::new(resolved_devices); let msg = wa::Message { conversation: Some("steady state".into()), ..Default::default() @@ -4024,7 +4031,8 @@ mod mark_full_distribution_list { message: &msg, message_id: "WARMGROUPSCALE1", force_distribution: false, - distribution_targets: (!own_companions.is_empty()).then_some(own_companions), + distribution_targets: (!own_companions.is_empty()) + .then(|| own_companions.clone()), distribution_policy: SenderKeyDistributionPolicy::BestEffort, phash_devices: Some(&resolved), edit: None, From 10d0b42e42d73a2f92b227b41d393ac0449d3725 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 16:13:24 +0000 Subject: [PATCH 6/8] docs(bench): say which fan-out the sweep models in both comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture doc was corrected to name the `type="msg"` redistribution shape, but the bench comment above it still opened with "cold group send" — the exact wording that was wrong. Both now agree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY --- wacore/binary/benches/group_fanout_benchmark.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/wacore/binary/benches/group_fanout_benchmark.rs b/wacore/binary/benches/group_fanout_benchmark.rs index f443c39ad..98e50e8a9 100644 --- a/wacore/binary/benches/group_fanout_benchmark.rs +++ b/wacore/binary/benches/group_fanout_benchmark.rs @@ -76,11 +76,12 @@ fn create_skdm_fanout_node(width: usize) -> Node { // Group sender-key distribution, swept across the recipient count reported for // real groups. Marshalling is linear in the fan-out width, so this is what a -// cold group send (or a redistribution after a membership change) pays in the -// encoder; the steady-state send that follows carries no `` at -// all. Keeping both facts measurable is what tells a group-size regression -// ("the warm stanza grew a per-participant node") apart from a group that is -// merely redistributing. +// redistribution — a membership change, or a rotation — pays in the encoder, +// and a first-contact fan-out pays the same per-recipient term over the larger +// `pkmsg` payload (see the fixture). The steady-state send that follows carries +// no `` at all. Keeping both facts measurable is what tells a +// group-size regression ("the warm stanza grew a per-participant node") apart +// from a group that is merely redistributing. // // `marshal_exact`, not `marshal_auto`: every outbound stanza goes through // `Client::marshal_node_for_send`, which picks the two-pass exact strategy. From b4b54e9fe93ee9f2db5f630d2e6365b4fd60e0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 16:19:03 +0000 Subject: [PATCH 7/8] test(send): compare the whole warm stanza, and pin the phash version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `child_tags` only looked at the root's direct children, so a `` or `` subtree that grew with the group could slip past it — and a length-neutral change (a renamed attribute) would slip past the size comparison too. The shape is now walked recursively: tag, sorted attribute keys, then children. Attribute *keys* only, since the values legitimately differ — the phash digests two different device sets. Confirmed it bites by mutating a nested `` on the 512-member stanza alone: the old assertion passed, the new one fails with the full hierarchy in the message. The phash assertion accepted any ten-character value while the doc claimed "2:" plus 8 base64 chars. It now asserts the prefix as well, which is what makes it the phash the server expects rather than some other attribute that happens to be ten characters wide. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY --- wacore/src/send/tests.rs | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/wacore/src/send/tests.rs b/wacore/src/send/tests.rs index f385ae20d..9c0f58d26 100644 --- a/wacore/src/send/tests.rs +++ b/wacore/src/send/tests.rs @@ -4062,12 +4062,17 @@ mod mark_full_distribution_list { out } - fn child_tags(node: &Node) -> Vec<&str> { - node.children() - .unwrap_or(&[]) - .iter() - .map(|c| c.tag.as_ref()) - .collect() + // The whole hierarchy, not just the root's children: a `` or `` + // subtree that grew with the group would otherwise slip past, and a + // rename that happens to preserve the encoded length would slip past the + // size comparison too. Attribute *keys* only — the values legitimately + // differ (the phash digests two different device sets), and the phash is + // asserted on its own below. + fn shape(node: &Node) -> String { + let mut attrs: Vec<&str> = node.attrs.0.iter().map(|(k, _)| k.as_ref()).collect(); + attrs.sort_unstable(); + let children: Vec = node.children().unwrap_or(&[]).iter().map(shape).collect(); + format!("{}[{}]({})", node.tag, attrs.join(","), children.join(" ")) } // Single-device account (no companions) and a two-companion one: the @@ -4086,16 +4091,23 @@ mod mark_full_distribution_list { "{label} warm send distributes to our own companions only, \ never to the {companions}-companion account's group members" ); - assert_eq!( - node.attrs().optional_string("phash").map(|p| p.len()), - Some(10), - "{label} phash is a fixed-width digest" + // Version tag plus 8 base64 chars — the width is what makes the + // stanza size independent of the set hashed, and the `2:` is + // what makes it the phash the server expects rather than some + // other ten-character attribute. + let phash = node + .attrs() + .optional_string("phash") + .unwrap_or_else(|| panic!("{label} warm send must carry a phash")); + assert!( + phash.starts_with("2:") && phash.len() == 10, + "{label} phash is a fixed-width v2 digest, got {phash:?}" ); } assert_eq!( - child_tags(&small), - child_tags(&large), + shape(&small), + shape(&large), "same stanza shape with {companions} companions" ); assert_eq!( From d14a1236385a052a9ff54f5bfa1c25da37ec3b50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 16:29:38 +0000 Subject: [PATCH 8/8] test(send): make the warm fixture actually warm, and assert it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture claimed the companions' SKDM encrypts to `msg`, but `process_prekey_bundle` alone leaves the session holding a pending pre-key, so it was emitting `pkmsg` — first contact, not the steady state the test is named for. Adding an `enc type` assertion proved it: `left: Some("pkmsg")`. Clearing the pending key is what the companion's reply does in production, so the fixture now does that too and the assertion holds it there. Also compares the `` values against the expected companions rather than counting them, so a list of the right length addressing group members would fail — which is the regression this test exists to catch. Fixes a wrong claim in the fan-out sweep's doc as well. It said the larger `pkmsg` payload "rides the same slope from a higher intercept". It does not: `marshal_exact` copies every payload through the writer, so those bytes are paid once per recipient and raise the slope. The doc now says the sweep does not characterize a first-contact fan-out and must not be extrapolated to it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeyUovZnNhUUR2bNhL3QhY --- .../binary/benches/group_fanout_benchmark.rs | 30 ++++----- wacore/src/send/tests.rs | 66 ++++++++++++++++--- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/wacore/binary/benches/group_fanout_benchmark.rs b/wacore/binary/benches/group_fanout_benchmark.rs index 98e50e8a9..bc17ac006 100644 --- a/wacore/binary/benches/group_fanout_benchmark.rs +++ b/wacore/binary/benches/group_fanout_benchmark.rs @@ -35,15 +35,14 @@ fn main() { /// measure two wire shapes at once. /// /// The ciphertexts are `type="msg"`, the shape a redistribution to devices that -/// already hold a pairwise session emits. A device being contacted for the -/// first time gets `type="pkmsg"` instead, whose `PreKeySignalMessage` carries -/// an identity key, a base key and the registration id on top of the same inner -/// message — roughly twice the payload. Marshalling is linear in payload bytes, -/// so that case rides the same slope from a higher intercept; what this sweep -/// exists to pin is the per-recipient term, which the payload does not move. -/// Building the real ciphertexts is out of reach here regardless: `wacore-binary` -/// does not depend on libsignal, and giving it a dev-dependency on the Signal -/// stack to size a byte array would be a poor trade. +/// already hold a pairwise session emits — a membership change or a rotation. +/// **This sweep does not characterize a first-contact fan-out.** Those get +/// `type="pkmsg"`, whose `PreKeySignalMessage` carries an identity key, a base +/// key and the registration id on top of the same inner message, and that +/// larger payload is paid once *per recipient* — `marshal_exact` copies every +/// payload through the writer — so it raises the slope, not the intercept. Read +/// this sweep as a lower bound there, or measure a `pkmsg` payload separately; +/// do not extrapolate the cold cost from these numbers. fn create_skdm_fanout_node(width: usize) -> Node { const DEVICES_PER_USER: usize = 4; let recipients: Vec = (0..width) @@ -76,12 +75,13 @@ fn create_skdm_fanout_node(width: usize) -> Node { // Group sender-key distribution, swept across the recipient count reported for // real groups. Marshalling is linear in the fan-out width, so this is what a -// redistribution — a membership change, or a rotation — pays in the encoder, -// and a first-contact fan-out pays the same per-recipient term over the larger -// `pkmsg` payload (see the fixture). The steady-state send that follows carries -// no `` at all. Keeping both facts measurable is what tells a -// group-size regression ("the warm stanza grew a per-participant node") apart -// from a group that is merely redistributing. +// redistribution — a membership change, or a rotation — pays in the encoder. +// A first-contact fan-out pays a steeper per-recipient term over the larger +// `pkmsg` payload (see the fixture), so it is not what these numbers measure. +// The steady-state send that follows carries no `` at all. +// Keeping both facts measurable is what tells a group-size regression ("the +// warm stanza grew a per-participant node") apart from a group that is merely +// redistributing. // // `marshal_exact`, not `marshal_auto`: every outbound stanza goes through // `Client::marshal_node_for_send`, which picks the two-pass exact strategy. diff --git a/wacore/src/send/tests.rs b/wacore/src/send/tests.rs index 9c0f58d26..dd595da01 100644 --- a/wacore/src/send/tests.rs +++ b/wacore/src/send/tests.rs @@ -3941,8 +3941,16 @@ mod mark_full_distribution_list { /// member state into it would still benchmark fine on a small group. #[tokio::test] async fn warm_group_stanza_size_tracks_own_devices_not_group_size() { - // `members` is the group; `companions` are our own other devices, which - // receive a fresh SKDM on every send. + // Our own other devices, which receive a fresh SKDM on every send. + // Shared with the assertions so they can name the exact JIDs the stanza + // must address, not merely how many. + fn companion_jids(companions: usize) -> Vec { + (1..=companions) + .map(|d| format!("12025550111:{d}@s.whatsapp.net").parse().unwrap()) + .collect() + } + + // `members` is the group; `companions` are our own other devices. async fn warm_stanza(members: usize, companions: usize) -> Node { let own_jid: Jid = "12025550111:0@s.whatsapp.net".parse().unwrap(); let own_lid: Jid = "100000000000001:0@lid".parse().unwrap(); @@ -3955,9 +3963,7 @@ mod mark_full_distribution_list { .unwrap() }) .collect(); - let own_companions: Vec = (1..=companions) - .map(|d| format!("12025550111:{d}@s.whatsapp.net").parse().unwrap()) - .collect(); + let own_companions: Vec = companion_jids(companions); let mut rng = rand::make_rng::(); let mut sks = MemSenderKeyStore::default(); @@ -3980,8 +3986,9 @@ mod mark_full_distribution_list { known: Default::default(), }; for companion in &own_companions { + let addr = companion.to_protocol_address(); process_prekey_bundle( - &companion.to_protocol_address(), + &addr, &mut ss, &mut is, &signed_prekey_bundle(), @@ -3990,6 +3997,22 @@ mod mark_full_distribution_list { ) .await .expect("establish the companion session"); + // `process_prekey_bundle` alone leaves the session holding a + // pending pre-key, so its next encryption is still a `pkmsg` + // first contact. The steady state this fixture models is the + // one after the companion has answered, which is what clears + // the pending key — so clear it, and let the `enc type` + // assertion below hold the fixture to it. + let mut record = ss + .load_session(&addr) + .await + .expect("load") + .expect("session present"); + record + .session_state_mut() + .expect("session state") + .clear_unacknowledged_pre_key_message(); + ss.store_session(&addr, record).await.expect("store"); } let mut pks = UnusedPreKeyStore; let spks = UnusedSignedPreKeyStore; @@ -4082,15 +4105,38 @@ mod mark_full_distribution_list { let large = warm_stanza(512, companions).await; for (label, node) in [("8-member", &small), ("512-member", &large)] { - let distributed = node + // The JIDs, not just how many: a list of the right length that + // addressed group members instead of our companions would be + // exactly the regression this test exists to catch. + let distributed: Vec = node .get_optional_child("participants") .and_then(Node::children) - .map_or(0, <[Node]>::len); + .unwrap_or(&[]) + .iter() + .map(|to| to.attrs().jid("jid")) + .collect(); assert_eq!( - distributed, companions, + distributed, + companion_jids(companions), "{label} warm send distributes to our own companions only, \ - never to the {companions}-companion account's group members" + never to the group's members" ); + // The enc type is the whole premise of the fixture, so it is + // checked rather than asserted in a comment. + for to in node + .get_optional_child("participants") + .and_then(Node::children) + .unwrap_or(&[]) + { + let enc = to + .get_optional_child("enc") + .unwrap_or_else(|| panic!("{label} participant carries an enc")); + assert_eq!( + enc.attrs().optional_string("type").as_deref(), + Some("msg"), + "{label} companion SKDM ciphertext type" + ); + } // Version tag plus 8 base64 chars — the width is what makes the // stanza size independent of the set hashed, and the `2:` is // what makes it the phash the server expects rather than some