Skip to content

feat(client): observe what the client sends, not only what it receives - #1260

Merged
jlucaso1 merged 11 commits into
mainfrom
claude/whatsapp-output-observability-56adcw
Aug 8, 2026
Merged

feat(client): observe what the client sends, not only what it receives#1260
jlucaso1 merged 11 commits into
mainfrom
claude/whatsapp-output-observability-56adcw

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Event::RawNode hands every decoded stanza to consumers before dispatch, lease-gated so it costs nothing when unused. The send side had no equivalent: wait_for_sent_node is a filtered one-shot waiter for a single expected stanza, and it only ever sees stanzas that pass through marshal_node_for_send, so the paths that hand pre-marshaled bytes straight to the socket (acks, delivery receipts, direct-encoded IQs) were invisible even to that. The consequences are the library's, not any consumer's: an integration test cannot assert what went to the wire without wrapping the transport, and a stanza the server rejected cannot be read back without rebuilding with logging on.

Event::SentFrame carries the marshaled plaintext of each frame the transport accepted. It is emitted from the noise sender task, which is the single point every send path crosses and the same chokepoint SessionStats::record_frame_sent already sits on. Observing bytes rather than nodes is what makes one point sufficient: two of the send paths never build a Node at all, and the bytes are what actually left. It is gated by Client::acquire_sent_frame_forwarding(), so an unwatched send is byte-for-byte the send it was before, and a watched one hands over the caller's own buffer rather than a copy.

Audit

Send paths, all of which reach the transport through NoiseSocket::enqueue_send:

path entry point observed
marshaled stanza send_nodemarshal_node_for_sendsend_raw_bytes (src/client/messaging.rs:112, :121) yes
pre-marshaled bytes send_raw_bytes (src/client/messaging.rs:14) — acks (src/client/node_io.rs:921, :938, :951, :990), delivery receipts (src/receipt.rs:611), direct-encoded IQs (src/request.rs:426) yes
coalesced burst send_raw_bytes_burst (src/client/messaging.rs:53) yes
ack worker start_transport_ack_worker → burst (src/client/node_io.rs:843) yes
receipt worker delivery-receipt worker → burst (src/message/dispatch.rs:202) yes
keepalive send_keepalivesend_iqsend_node (src/keepalive.rs:96) yes

Confirmed on the findings in the brief: wait_for_sent_node (src/client/accessors.rs:611) registers a oneshot that resolve_sent_node_waiters (src/client/accessors.rs:642) removes on the first match, and the filter is a required argument; send_raw_bytes never calls it, so every path in row two was unobservable. Handshake frames are pre-noise and are deliberately not covered; VoIP relay sockets pass no observers, same as they pass no stats.

The observation point is NoiseSocket::sender_task, after transport.send returns Ok. Publishing before the batch's waiters are answered means a caller that awaited a send can already see its frame, which is what lets a test assert without polling.

Changes

  • Event::SentFrame + SentFrame { plaintext: Bytes } (wacore), #[serde(skip)] on the payload for the reason DecryptedPayload::payload gives; EventKind::SentFrame appended last so no discriminant moves.
  • SentFrameTap on Client: the lease counter plus the bus handle, behind an Arc the noise sender borrows. Same shape as SessionStats — the gate has to be readable from the send path, and the sender task cannot hold the client without keeping it alive.
  • Client::acquire_sent_frame_forwarding() -> SentFrameLease, mirroring the raw-node and decrypted-payload leases.
  • SendObservers replaces the bare Option<Arc<SessionStats>> parameter on NoiseSocket and do_handshake. Breaking for NoiseSocket::with_stats (now with_observers) and for do_handshake's last argument; the alternative was a sixth positional parameter and a seventh for the next observer.
  • The tap publishes through catch_unwind: this runs on the task every send depends on, so an observer that panics must not end the connection's send half. A handler that blocks still stalls sends, which is the contract every bus handler already has on the read loop.
  • GatedForwarding in the plugin host learns the new gated kind, as its own doc comment requires — a plugin subscribing to SentFrame without a lease would otherwise get silence.
  • wait_for_sent_node unchanged, and no existing test touched it. It answers a different question (the Node before marshalling, filtered, resolved before the write); rebuilding it on the tap would have to unmarshal every frame to test its filter and would widen what its ~18 callers see. send_raw_bytes keeps its bypass semantics, which were only ever about node logging and sent-node waiters.

Cost

Allocations per send through the noise socket, min over 2000 windows against the process-wide counting allocator in src/lib.rs:

allocations/send
main 3
this branch, no observer 3
this branch, observer registered 5

Idle is unchanged: one relaxed load per frame, and the Vec that would hold the plaintexts is never allocated. Watching costs a constant 2 per frame — the Bytes promotion to a shared handle, and the Arc<Event> the bus dispatches — and neither scales with the stanza: an_observed_frame_is_the_buffer_the_caller_handed_over asserts the observer's Bytes has the same data pointer as the one handed to the socket, so a 64 KiB stanza costs the same as a 64-byte one. (The committed test reports 4 and 6 because its measurement window also allocates the payload it sends; it asserts a ceiling on idle and an exact delta of 2.)

size_of::<Client>(): 3912 → 3920, one Arc.

Validation

cargo fmt --all
cargo test -p whatsapp-rust --lib                        # 1449 passed
cargo test -p whatsapp-rust --lib --features plugins     # 1545 passed
cargo test -p whatsapp-rust --tests                      # all integration targets green
cargo test -p wacore --lib                               # 1374 passed
cargo test --doc -p whatsapp-rust -p wacore
cargo clippy -p whatsapp-rust --lib --tests --features plugins -- -D warnings
cargo clippy -p wacore --lib --tests -- -D warnings

Workspace clippy does not build in this sandbox (alsa-sys wants libasound), so the full matrix is left to CI.

New tests: every_send_path_is_observed_exactly_as_it_reached_the_wire (each path driven, observed frames compared byte for byte and in order against the transport's own frames decrypted under their counters, first frame decoded back to the stanza), sends_are_unobserved_until_a_consumer_asks, a_panicking_observer_leaves_the_client_sending, plus socket-level an_observed_frame_is_the_buffer_the_caller_handed_over, an_unwatched_send_publishes_nothing, a_refused_write_is_not_observed, a_panicking_observer_does_not_break_the_send, watching_costs_a_constant_per_frame_and_idling_costs_nothing.


Generated by Claude Code

The receive side hands every decoded stanza to consumers through
Event::RawNode, lease-gated so it costs nothing when unused. The send side
had no equivalent: wait_for_sent_node is a filtered one-shot for a single
expected stanza, and it only sees stanzas that go through
marshal_node_for_send, so acks, delivery receipts and direct-encoded IQs
were invisible even to that. A test could not assert what reached the wire
without wrapping the transport, and a stanza the server rejected could not
be read back without a rebuild.

Event::SentFrame carries the marshaled plaintext of each frame the transport
accepted. It is emitted from the noise sender task, which is the one point
all five send paths cross (send_node, send_raw_bytes, send_raw_bytes_burst,
and the ack and receipt workers through the burst) and the same chokepoint
SessionStats already records wire bytes at. Observing bytes rather than nodes
is what makes that possible: two of those paths never build a Node at all.

Cost follows the house pattern. The gate is a relaxed atomic load per frame
through the tap the sender already holds, so an unwatched send allocates
exactly what it did before. A watched one costs two allocations per frame,
neither of which scales with the stanza: the payload is handed over as a
refcount bump on the caller's own buffer, pinned by a pointer assertion. The
dispatch is caught, since it runs on the task every send depends on.

wait_for_sent_node stays as it is. It observes the Node before marshalling,
resolves before the write, and is filtered, so it answers a different
question than a stream of sent bytes; reimplementing it on top of this one
would have to unmarshal every frame to test its filter, and would widen what
its callers see. send_raw_bytes keeps its bypass semantics, which are now
only about node logging and sent-node waiters.
@coderabbitai

coderabbitai Bot commented Aug 8, 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 (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7e456226-7b2b-4b24-9602-1d2f81a3be52

📥 Commits

Reviewing files that changed from the base of the PR and between 50ca61e and f5e9abf.

📒 Files selected for processing (4)
  • agent_docs/observability.md
  • src/client.rs
  • src/client/lifecycle.rs
  • src/lib.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added outbound SentFrame events containing plaintext stanzas after successful transport writes.
    • Plugins and integrations can enable or disable sent-frame forwarding through subscriptions.
    • All outbound send paths, including raw sends, support consistent frame observation.
    • Forwarding remains active while an observation subscription is enabled.
  • Bug Fixes
    • Failed writes and pre-handshake frames are not reported as sent frames.
    • Observer errors no longer interrupt subsequent message delivery.
  • Documentation
    • Clarified sent-frame observability behavior and coverage.

Walkthrough

Adds lease-gated Event::SentFrame forwarding. Routes outbound plaintext observation through NoiseSocket::SendObservers after successful writes. Updates client, plugin, handshake, test, and observability documentation integrations.

Changes

Sent-frame observability

Layer / File(s) Summary
Event contract and forwarding tap
wacore/src/types/events.rs, src/client.rs
Adds Event::SentFrame with plaintext payloads. The shared tap gates publication and contains observer panics.
Client leases and plugin subscriptions
src/client/accessors.rs, src/client/lifecycle.rs, src/plugins/mod.rs, src/lib.rs
Adds client lease acquisition and lifecycle wiring. Plugin event interest enables and releases sent-frame forwarding.
Noise sender observer integration
src/socket/noise_socket.rs, src/handshake.rs, src/test_utils.rs
Adds SendObservers. The sender publishes retained plaintext only after successful transport writes.
Validation and documentation
src/client/tests.rs, src/socket/noise_socket.rs, src/test_utils.rs, tests/handshake_*.rs, agent_docs/observability.md
Adds wire-matching, lease, transport-failure, allocation, and panic-isolation tests. Updates handshake calls and observability documentation.

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

Sequence Diagram(s)

sequenceDiagram
  participant Plugin
  participant Client
  participant NoiseSocket
  participant Transport
  participant EventBus
  Plugin->>Client: acquire sent-frame forwarding lease
  Client->>NoiseSocket: configure SendObservers
  NoiseSocket->>Transport: write encrypted frame
  Transport-->>NoiseSocket: successful write
  NoiseSocket->>EventBus: emit Event::SentFrame with plaintext
Loading

Possibly related PRs

Suggested labels: api-design, breaking-change

Suggested reviewers: greptile-apps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding client-side observation of outbound frames.
Description check ✅ Passed The description directly explains outbound frame observation, lease gating, send-path coverage, implementation changes, and validation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/whatsapp-output-observability-56adcw

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

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Aug 8, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds lease-gated observation of successfully transmitted Noise-frame plaintext at the common outbound transport chokepoint.

  • Adds Event::SentFrame, its payload type, and public lease acquisition.
  • Routes session statistics and sent-frame forwarding through SendObservers.
  • Integrates gated forwarding with plugins and adds coverage for send paths, failed writes, lease release, panic containment, and allocation behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/client.rs Adds the aggregate sent-frame forwarding gate, publisher, and lease contract; the previously discussed non-fencing semantics are now explicit.
src/socket/noise_socket.rs Adds the common post-write observation point and rechecks forwarding after successful transport acceptance, resolving the reported final-lease race.
wacore/src/types/events.rs Adds the non-serialized SentFrame event and plaintext payload without moving existing event-kind discriminants.
src/plugins/mod.rs Extends plugin gated-forwarding lifecycle management to acquire and release sent-frame leases alongside existing gated event kinds.
src/client/lifecycle.rs Initializes the sent-frame tap on the client and supplies it to the main session Noise socket.
src/client/tests.rs Adds end-to-end coverage showing all principal send paths are observed in accepted wire order and remain gated when unused.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Client send paths] --> B[NoiseSocket sender task]
  B --> C[Encrypt and frame]
  C --> D[Transport write]
  D -->|failure| E[Discard captured plaintext]
  D -->|accepted| F{Sent-frame lease enabled?}
  F -->|no| G[Complete send without event]
  F -->|yes| H[Dispatch Event::SentFrame]
  D --> I[Record session statistics]
Loading

Reviews (9): Last reviewed commit: "docs(observability): bound the chokepoin..." | Re-trigger Greptile

Comment thread src/client.rs
Comment thread src/socket/noise_socket.rs Outdated
The gate was read once, at capture time, and the frame was published after
the write it belongs to. A lease released in between still saw that frame
arrive, which contradicts the documented contract that nothing is emitted
while no consumer holds one.

Reading the gate again before the publish closes it, for one relaxed load per
batch. The test parks the sender inside the transport write, so the frame is
captured and encrypted before the lease goes away: without the second read it
fails.

Also record what the whole-dispatch panic containment costs, since it is per
dispatch and not per handler: a panicking observer loses that frame for the
observers registered behind it. The bus has no per-handler isolation for any
kind, and the plugin host already wraps its own handlers, so isolating this
one kind would be a change to the bus rather than to this path.

@cubic-dev-ai cubic-dev-ai 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.

1 issue found and verified against the latest diff

Confidence score: 3/5

  • In src/client.rs, SentFrame dispatch currently recovers around the whole fan-out, so one panicking subscriber can prevent later subscribers from seeing that frame, leading to dropped notifications and inconsistent downstream state for that event — isolate recovery per handler invocation so one panic only skips the failing subscriber.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/client.rs">

<violation number="1" location="src/client.rs:185">
P2: A panicking `SentFrame` subscriber prevents every later `SentFrame` subscriber from receiving that frame, because the recovery surrounds the entire dispatch rather than each handler; only the sender task is isolated. Per-handler recovery in the event bus (or an equivalent isolated dispatch API) would keep one observer from suppressing other observers.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/client.rs
#[cfg(test)]
self.published.fetch_add(1, Ordering::Relaxed);
let dispatch = std::panic::AssertUnwindSafe(|| {
self.bus.dispatch(Event::SentFrame(

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: A panicking SentFrame subscriber prevents every later SentFrame subscriber from receiving that frame, because the recovery surrounds the entire dispatch rather than each handler; only the sender task is isolated. Per-handler recovery in the event bus (or an equivalent isolated dispatch API) would keep one observer from suppressing other observers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/client.rs, line 185:

<comment>A panicking `SentFrame` subscriber prevents every later `SentFrame` subscriber from receiving that frame, because the recovery surrounds the entire dispatch rather than each handler; only the sender task is isolated. Per-handler recovery in the event bus (or an equivalent isolated dispatch API) would keep one observer from suppressing other observers.</comment>

<file context>
@@ -103,6 +103,102 @@ impl Drop for RawNodeLease {
+        #[cfg(test)]
+        self.published.fetch_add(1, Ordering::Relaxed);
+        let dispatch = std::panic::AssertUnwindSafe(|| {
+            self.bus.dispatch(Event::SentFrame(
+                wacore::types::events::SentFrame::builder()
+                    .plaintext(plaintext)
</file context>

Comment thread wacore/src/types/events.rs Outdated
greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 8, 2026

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 2 files (changes from recent commits).

Confidence score: 3/5

  • In src/socket/noise_socket.rs, the check that guards dispatch can race with tap.publish, so a SentFrame may still be emitted after the final lease is dropped; this can violate shutdown/lifecycle expectations and cause stale or misleading socket events downstream — synchronize the lease release with the check-and-dispatch path (e.g., a shared read/write guard around both steps).
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/socket/noise_socket.rs">

<violation number="1" location="src/socket/noise_socket.rs:329">
P2: Dropping the final lease can still be followed by `SentFrame` publication when it races between this gate check and `tap.publish`; synchronize release with check-and-dispatch (for example, a shared read/write guard) so release completion excludes later dispatches.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// capture time: a lease released while this batch was in
// flight must not see a frame arrive after it let go.
if let Some(tap) = sent_frames.as_deref()
&& tap.enabled()

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: Dropping the final lease can still be followed by SentFrame publication when it races between this gate check and tap.publish; synchronize release with check-and-dispatch (for example, a shared read/write guard) so release completion excludes later dispatches.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/socket/noise_socket.rs, line 329:

<comment>Dropping the final lease can still be followed by `SentFrame` publication when it races between this gate check and `tap.publish`; synchronize release with check-and-dispatch (for example, a shared read/write guard) so release completion excludes later dispatches.</comment>

<file context>
@@ -322,7 +322,12 @@ impl NoiseSocket {
+                        // capture time: a lease released while this batch was in
+                        // flight must not see a frame arrive after it let go.
+                        if let Some(tap) = sent_frames.as_deref()
+                            && tap.enabled()
+                        {
                             for plaintext in observed.drain(..) {
</file context>

"Exactly as it was encrypted and written" reads as if the payload were the
bytes that went on the socket. It is the input to that encryption: no length
prefix, no AEAD tag. A consumer building a replay tool has to know which of
the two it is holding.
@greptile-apps
greptile-apps Bot dismissed their stale review August 8, 2026 04:21

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

Comment thread src/socket/noise_socket.rs
… does not

Reviewers read the recheck before publishing as a promise that no frame can
follow the last drop, and asked for a lock to close the remainder. A lock
there would deadlock an observer that drops its lease from inside its own
handler, and the guarantee it would buy is not one the gate ever offered: the
count is aggregate, and delivery is decided by subscription, exactly as for
the other gated kinds. Say so where a consumer reads it.

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 3 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.03 MiB 10.03 MiB +1.81 KiB (+0.02%) 🔺
bin .text 8.04 MiB 8.04 MiB +1.75 KiB (+0.02%) 🔺
bin allocated (text+data+bss) 10.03 MiB 10.03 MiB +3.95 KiB (+0.04%) 🔺
llvm-lines wacore 532,607 532,618 +11 (+0.00%) 🔺
llvm-lines wacore copies 17,373 17,374 +1 (+0.01%) 🔺
llvm-lines whatsapp-rust lib 756,170 757,253 +1,083 (+0.14%) 🔺
llvm-lines whatsapp-rust lib copies 23,652 23,697 +45 (+0.19%) 🔺
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.83 MiB 1.83 MiB +1.50 KiB (+0.08%) 🔺
.text wacore 691.75 KiB 691.75 KiB 0
.text wacore_binary 88.22 KiB 88.22 KiB 0
.text wacore_libsignal 178.88 KiB 178.88 KiB 0
.text wacore_appstate 22.35 KiB 22.35 KiB 0
.text wacore_noise 20.94 KiB 20.94 KiB 0
.text waproto 1.74 MiB 1.74 MiB -99 B (-0.01%) 🔽
.text whatsapp_rust_sqlite_storage 540.30 KiB 540.30 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 12.68 KiB 12.68 KiB 0
.text std 993.76 KiB 994.03 KiB +277 B (+0.03%) 🔺
.text other deps 1.90 MiB 1.90 MiB +99 B (+0.00%) 🔺
Top movers (cargo-bloat attribution)
Crate main PR Δ
whatsapp_rust 1.83 MiB 1.83 MiB +1.50 KiB (+0.08%)

Baseline: 42b47976a (latest main run) · Head: 9f4d9dc21 · Graphs

main split `do_handshake` into the socket build plus a `negotiate` that returns
the cipher pair, so the observers this branch threads through now reach
`NoiseSocket` from `do_handshake` rather than from inside the handshake span.
That keeps main's property that the connection's sender task is not spawned
under a one-shot span.

`tests/handshake_span_scope.rs`, new on main and gated on the `tracing`
feature, called `do_handshake` with the removed `stats` argument; it passes
`SendObservers::default()` like the other handshake tests.
greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@agent_docs/observability.md`:
- Around line 35-44: Update the observability paragraph to remove or correct the
inaccurate “five distinct send paths” count, reflecting that ack/receipt workers
and send_node reach the socket through already-listed paths. In the VoIP relay
socket description below, replace the stale `None` wording with
`SendObservers::default()` to match the current API.

In `@src/client/tests.rs`:
- Around line 3510-3518: Update the NoiseSocket construction in the test helper
to initialize SendObservers with the client’s stats observer, matching
connect_graph and create_iq_test_client, while retaining the sent-frame tap.
Ensure client.stats() records frames sent through this helper.

In `@src/socket/noise_socket.rs`:
- Around line 2060-2068: Update the allocation assertion around the watched and
idle measurements to avoid directly subtracting unsigned values; use a signed or
saturating difference while preserving the expected two-allocation comparison.
Ensure cases where watched is below idle fail through the assertion with a
readable message containing both measured values, rather than panicking on
underflow.

In `@src/test_utils.rs`:
- Around line 60-75: The frame-length parsing in the write-processing loop
should be centralized instead of duplicated. Extract a shared pub(crate) 3-byte
frame parser reusable by src/test_utils.rs, noise_socket.rs, and transport.rs,
then add decrypt_wire_frames to perform decryption and wire consumption using
that parser; update the existing loop to call these helpers while preserving
frame ordering and per-frame counters.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1b1ecb99-9287-47e7-8b6a-bf820b299e60

📥 Commits

Reviewing files that changed from the base of the PR and between 8e59de5 and 30d37a2.

📒 Files selected for processing (14)
  • agent_docs/observability.md
  • src/client.rs
  • src/client/accessors.rs
  • src/client/lifecycle.rs
  • src/client/messaging.rs
  • src/client/tests.rs
  • src/handshake.rs
  • src/lib.rs
  • src/plugins/mod.rs
  • src/socket/noise_socket.rs
  • src/test_utils.rs
  • tests/handshake_integration.rs
  • tests/handshake_span_scope.rs
  • wacore/src/types/events.rs

Comment thread agent_docs/observability.md Outdated
Comment thread src/client/tests.rs
Comment thread src/socket/noise_socket.rs
Comment thread src/test_utils.rs Outdated
Comment on lines +60 to +75
for write in writes {
let mut wire = &write[..];
while !wire.is_empty() {
let mut len = 0usize;
for byte in &wire[..wacore::framing::FRAME_LENGTH_SIZE] {
len = (len << 8) | *byte as usize;
}
let body =
&wire[wacore::framing::FRAME_LENGTH_SIZE..wacore::framing::FRAME_LENGTH_SIZE + len];
let mut body = bytes::BytesMut::from(body);
cipher
.decrypt_in_place_with_counter(plaintexts.len() as u32, &mut body)
.expect("each captured frame must decrypt under its own counter");
plaintexts.push(body.to_vec());
wire = &wire[wacore::framing::FRAME_LENGTH_SIZE + len..];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate every length-prefixed frame splitter in the crate.
rg -nP -C 6 '\bfn (split_frames|split_framed|decrypt_wire_frames)\s*\(' --type=rust
# Confirm FRAME_LENGTH_SIZE consumers.
rg -nP -C 3 'FRAME_LENGTH_SIZE' --type=rust

Repository: oxidezap/whatsapp-rust

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -u

echo "== rust files named test_utils/socket/noise_socket/transport =="
fd -e rs '^(test_utils|noise_socket|transport)\.rs$' . || true

echo "== tracked files containing likely symbols =="
rg -n -C 5 'fn (split_frames|split_framed|decrypt_wire_frames)|FRAME_LENGTH_SIZE|FRAME_LENGTH_SIZE' --glob '*.rs' . || true

echo "== git status/stat =="
git status --short || true
git diff --stat || true

Repository: oxidezap/whatsapp-rust

Length of output: 16400


🏁 Script executed:

#!/bin/bash
set -u

echo "== src/test_utils.rs =="
cat -n src/test_utils.rs

echo "== src/transport.rs relevant section =="
sed -n '40,75p' src/transport.rs | cat -n -v +L40

echo "== src/socket/noise_socket.rs tests split_frames section =="
sed -n '1505,1528p' src/socket/noise_socket.rs | cat -n -v +L1505

echo "== deterministic behavior probe against the 3 helper bodies =="
python3 - <<'PY'
from pathlib import Path
import re

def body(src):
    m = re.search(r'((?:pub\s+\)?)?(?:fn|async\s+fn|impl\s+[^{]+\{[^}]*{|class\s+[^{]+\{[^}]*\{)\s*([A-Za-z_]\w*)\s*\([^)]*\)(?:\s*->\s*[^{]+)?\s*(?:\{\s*#[^{]*\n)?\{', src, re.S)
    # not structural enough. simpler: just return full source.
    return src.read_text()

files = {
    "src/test_utils.rs": Path("src/test_utils.rs").read_text(),
    "src/socket/noise_socket.rs": Path("src/socket/noise_socket.rs").read_text(),
    "src/transport.rs": Path("src/transport.rs").read_text(),
}
for name, src in files.items():
    for needle in ["decrypt_wire_frames", "split_frames", "split_framed"]:
        idx = src.find(needle)
        print(f"\n{name}: {needle} at {idx}")
        start = src.rfind("\n", 0, max(0, idx-400))
        end = src.find("\n}", max(max(0, idx), start)+1)
        for off in range(-1, 8):
            print(src[idx:idx+1600])
            print("---")
            break
PY

Repository: oxidezap/whatsapp-rust

Length of output: 24753


Reuse one 3-byte frame splitter.

This length walk is repeated inside src/test_utils.rs, with src/socket/noise_socket.rs and src/transport.rs keeping another 3-byte splitter each. Extract one pub(crate) frame parser and move the decryption/consume step into decrypt_wire_frames. One shared parser is how we avoid broken test assumptions when framing changes.

🤖 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/test_utils.rs` around lines 60 - 75, The frame-length parsing in the
write-processing loop should be centralized instead of duplicated. Extract a
shared pub(crate) 3-byte frame parser reusable by src/test_utils.rs,
noise_socket.rs, and transport.rs, then add decrypt_wire_frames to perform
decryption and wire consumption using that parser; update the existing loop to
call these helpers while preserving frame ordering and per-frame counters.

`CapturingMockTransport::sent()` already splits each write into its frames,
prefix intact, so the helper was walking a length prefix that had been walked
for it and adding a third copy of that parse to the crate. It now checks the
declared length against the entry instead, which turns a caller that passes
raw writes into a failed assertion rather than a decrypt of garbage.

The noise socket the client-test helper installs now reports to the client's
stats as well as its tap, matching the real socket and the other fixture: a
test that later asserts on `frames_sent` would have read zero and blamed the
code under test.

The allocation delta is compared as a signed difference. Both sides are
empirical minima off a process-global counter, and an inversion has to print
the two numbers rather than panic inside the subtraction.

Also fixes the observability doc: it claimed five distinct send paths and then
listed callers that funnel through two, and still described VoIP sockets as
passing `None`, which this branch replaced with `SendObservers::default()`.
@greptile-apps
greptile-apps Bot dismissed their stale review August 8, 2026 06:21

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 8, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 4 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

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

ℹ️ 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 on lines +209 to 215
pub fn with_observers(
runtime: Arc<dyn Runtime>,
transport: Arc<dyn Transport>,
write_key: NoiseCipher,
read_key: NoiseCipher,
stats: Option<Arc<wacore::stats::SessionStats>>,
observers: SendObservers,
) -> Self {

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 Retain the existing NoiseSocket::with_stats entry point

Downstream code that constructs the publicly exported NoiseSocket with NoiseSocket::with_stats(..., Option<Arc<SessionStats>>) no longer compiles because this change replaces that public method with with_observers. The observer-aware constructor can be added without removing the existing API; retain with_stats as a compatibility wrapper that builds the appropriate SendObservers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct that it breaks, deliberate that it does. Pre-1.0, the break is called out in the PR body, and the Semver Checks job reports it for the changelog.

A with_stats wrapper would be a second constructor that cannot express the tap, so a caller reaching for the familiar name would silently get a socket nothing can observe — the "two doors to the same thing" that SendObservers exists to prevent. SendObservers::with_stats(stats) is the one-line migration, and new() still covers the no-observer case.


Generated by Claude Code

Comment thread src/handshake.rs
Comment on lines 174 to 178
ik_handshake_failures: &AtomicU32,
transport: Arc<dyn Transport>,
transport_events: &mut async_channel::Receiver<TransportEvent>,
stats: Option<Arc<wacore::stats::SessionStats>>,
observers: crate::socket::noise_socket::SendObservers,
) -> Result<Arc<NoiseSocket>> {

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 Preserve the public do_handshake signature

External callers of the public handshake::do_handshake currently pass Option<Arc<SessionStats>>; changing that parameter to SendObservers makes every such caller fail to compile even if it does not use sent-frame observation. Keep the existing entry point and route it through a new observer-aware helper, while the client can call that helper to install its private sent-frame tap.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Same call as on with_stats: the break is intended, documented in the PR body, and reported by Semver Checks.

Keeping the stats-only parameter and adding an observer-aware twin means two public handshake entry points where the older one quietly cannot observe sends — and widening this signature per observer is the thing SendObservers was introduced to stop. External callers pass SendObservers::with_stats(stats), or SendObservers::default() where they passed None.


Generated by Claude Code

claude added 2 commits August 8, 2026 07:39
…point

main added `unmarshal_packed_ref`, which validates the leading format byte
before decoding instead of assuming it. The `SentFrame` doc and the send-path
test both hand-rolled `&plaintext[1..]`, which is the same thing right up
until a payload arrives with the compressed bit set. Point them at the helper.

No conflicts this time. Rechecked the allocation test against main's send-path
work (#1256): the idle path and the two-per-frame watching cost are unchanged.
@greptile-apps
greptile-apps Bot dismissed their stale review August 8, 2026 07:52

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 8, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

jlucaso1 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

The red Semver Checks on this PR is not from this branch, and I got a detail wrong earlier that is worth correcting.

The failure is enum_variant_added on BinaryError::UnexpectedFormatByte (wacore/binary/src/error.rs:30), which came in with 28710ff. This branch does not touch wacore/binary/ at all, and the same job fails on main — job 93076856840 on c1c7841 is a failure; the workflow run only reads green because the job is continue-on-error: true. Of the three crates the job checks, the one this branch does touch (wacore) came back "196 checks: 196 pass … no semver update required", since Event and EventKind are both #[non_exhaustive].

The correction: on the two threads about NoiseSocket::with_stats and do_handshake I said this job would report those breaks for the changelog. It will not — it only checks -p wacore -p wacore-binary -p waproto, and both of those are in whatsapp-rust, which nothing in CI semver-checks. The breaks are still deliberate and still described in the PR body, but if you want them machine-visible, this job's package list is where that would have to change, and that belongs in its own PR rather than this one.


Generated by Claude Code

An adjacency conflict only: main's `server_error_iq` helper landed at the same
point in `test_utils` as the sent-frame recorder and the frame-decrypt helper.
All three are wanted, so all three stay.

main also gave `answer_iq` a return value; nothing on this branch calls it, so
the auto-merge stands as it is.
@greptile-apps
greptile-apps Bot dismissed their stale review August 8, 2026 12:27

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 8, 2026
Both sides added to the same re-export list: `Connection` on main, and this
branch's `SentFrameLease`. Both stay.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
wacore/src/types/events.rs (1)

1724-1742: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Freeze the public SentFrame payload API.

Add #[non_exhaustive] and #[derive(bon::Builder)] to SentFrame. Update its construction sites to use SentFrame::builder().

Proposed fix
+#[non_exhaustive]
+#[derive(bon::Builder)]
 pub struct SentFrame {

As per coding guidelines, “Event payloads are a frozen API: use #[non_exhaustive] and #[derive(bon::Builder)].”

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

In `@wacore/src/types/events.rs` around lines 1724 - 1742, Add #[non_exhaustive]
and #[derive(bon::Builder)] to SentFrame, then update every SentFrame
construction site to use SentFrame::builder() instead of direct struct literals.
Preserve the existing plaintext field and serialization behavior.

Source: Coding guidelines

src/client/tests.rs (1)

5770-5770: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use the repository fictional NANP JID format.

Line 5770 uses 15551234567, which has a 555 NPA and does not use the reserved fictional exchange and line range. Replace it with a valid fictitious value such as 12025550111.

Proposed fix
-        .attr("to", "15551234567@s.whatsapp.net")
+        .attr("to", "12025550111@s.whatsapp.net")

Based on learnings: fictional NANP test numbers use a real non-555 NPA, the 555 exchange, and a 01000199 line number.

🤖 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/tests.rs` at line 5770, Update the JID value in the test attribute
around the existing "to" field to use a repository-valid fictional NANP number,
such as 12025550111, preserving the `@s.whatsapp.net` suffix.

Source: Learnings

🤖 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 `@agent_docs/observability.md`:
- Around line 35-40: Update the observability documentation around
send_raw_bytes and send_raw_bytes_burst to define the chokepoint as covering all
post-handshake main-session outbound frames. Qualify the “everything” claim so
it excludes handshake frames that are not forwarded through Event::SentFrame,
while preserving the existing function and worker routing descriptions.

---

Outside diff comments:
In `@src/client/tests.rs`:
- Line 5770: Update the JID value in the test attribute around the existing "to"
field to use a repository-valid fictional NANP number, such as 12025550111,
preserving the `@s.whatsapp.net` suffix.

In `@wacore/src/types/events.rs`:
- Around line 1724-1742: Add #[non_exhaustive] and #[derive(bon::Builder)] to
SentFrame, then update every SentFrame construction site to use
SentFrame::builder() instead of direct struct literals. Preserve the existing
plaintext field and serialization behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e4f9eca2-7eb9-4571-aa54-115a548fe250

📥 Commits

Reviewing files that changed from the base of the PR and between 30d37a2 and 50ca61e.

📒 Files selected for processing (8)
  • agent_docs/observability.md
  • src/client/accessors.rs
  • src/client/messaging.rs
  • src/client/tests.rs
  • src/lib.rs
  • src/socket/noise_socket.rs
  • src/test_utils.rs
  • wacore/src/types/events.rs

Comment thread agent_docs/observability.md Outdated
@greptile-apps
greptile-apps Bot dismissed their stale review August 8, 2026 12:33

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

"Every outbound frame" overstated it: the XX/IK exchange writes to the transport
before the noise socket exists, so handshake frames cross no chokepoint and are
not forwarded. Say post-handshake, and scope "everything the client sends" to
the session socket.

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

@jlucaso1
jlucaso1 merged commit 73ba209 into main Aug 8, 2026
24 of 25 checks passed
@jlucaso1
jlucaso1 deleted the claude/whatsapp-output-observability-56adcw branch August 8, 2026 12:42
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.

2 participants