Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 55 additions & 10 deletions advanced/websocket-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,14 @@ pub async fn do_handshake(
ik_handshake_failures: &AtomicU32,
transport: Arc<dyn Transport>,
transport_events: &mut async_channel::Receiver<TransportEvent>,
observers: SendObservers,
) -> Result<Arc<NoiseSocket>>
```

<Note>
**Breaking change:** `do_handshake`'s last parameter used to be `stats: Option<Arc<wacore::stats::SessionStats>>`. It is now `observers: SendObservers`, the same struct [`NoiseSocket::with_observers`](#dedicated-sender-task) takes — see below. A caller that only wants wire-byte accounting passes `SendObservers::with_stats(stats)`; a caller that wants neither observer passes `SendObservers::default()`.
</Note>

**Step-by-step process for XX:**

1. **Prepare client payload:**
Expand Down Expand Up @@ -329,13 +334,31 @@ Location: `src/socket/noise_socket.rs:50-66`
The socket uses a dedicated task for sending to ensure frame ordering:

```rust
/// What a socket reports its sends to. Both halves belong to the `Client`; a
/// VoIP relay socket and most tests pass `Default`, reporting to neither.
#[derive(Default, Clone)]
pub struct SendObservers {
/// Wire-byte accounting, recorded after the transport write.
stats: Option<Arc<wacore::stats::SessionStats>>,
/// Publisher for the plaintext of each frame that reached the transport.
sent_frames: Option<Arc<SentFrameTap>>,
}

impl SendObservers {
/// Report wire bytes into `stats` and nothing else.
pub fn with_stats(stats: Arc<wacore::stats::SessionStats>) -> Self { /* ... */ }

/// Also publish each sent frame's plaintext through `tap`.
pub(crate) fn with_sent_frames(mut self, tap: Arc<SentFrameTap>) -> Self { /* ... */ }
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

impl NoiseSocket {
pub fn with_stats(
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 {
let write_key = Arc::new(write_key);
let read_key = Arc::new(read_key);
Expand All @@ -350,7 +373,7 @@ impl NoiseSocket {
transport.clone(),
write_key.clone(),
send_job_rx,
stats,
observers,
)));

Self {
Expand All @@ -363,6 +386,12 @@ impl NoiseSocket {
}
```

`NoiseSocket::new` (used by the illustrative handshake walkthrough above) is unchanged: it's a thin wrapper that calls `with_observers` with `SendObservers::default()`, for the callers — mainly tests — that want neither observer.

<Note>
**Breaking change:** `with_stats(..., stats: Option<Arc<SessionStats>>)` is now `with_observers(..., observers: SendObservers)`. `SendObservers` is one struct rather than one parameter per observer, so the next thing that wants to watch sends — [`SentFrame`](/concepts/events#sentframe) was the first — plugs in there instead of widening this constructor (and `do_handshake`'s) again. A caller that only wants what `with_stats` gave it passes `SendObservers::with_stats(stats)`; the main WA session socket also chains `.with_sent_frames(client.sent_frame_tap.clone())` to wire up sent-frame forwarding, gated by [`Client::acquire_sent_frame_forwarding()`](/api/client#acquire_sent_frame_forwarding). VoIP relay sockets and most tests pass `SendObservers::default()`, reporting to neither — same as passing `None` before.
</Note>

**Why a dedicated task?**

1. **Ordering guarantee**: Frames must be sent with sequential counters
Expand All @@ -382,14 +411,19 @@ async fn sender_task(
transport: Arc<dyn Transport>,
write_key: Arc<NoiseCipher>,
send_job_rx: async_channel::Receiver<SendJob>,
stats: Option<Arc<wacore::stats::SessionStats>>,
observers: SendObservers,
) {
let SendObservers { stats, sent_frames } = observers;
let mut write_counter: u32 = 0;
let mut enc_buf = Vec::with_capacity(4096);
let mut out_buf = BytesMut::with_capacity(4096);
let mut poisoned = false;
let mut waiters: Vec<(oneshot::Sender<SendResult>, usize)> = Vec::new();
let mut carry_over: Option<SendJob> = None;
// Plaintexts of this batch's frames, held only while a consumer is
// watching; empty (unallocated) otherwise. Kept until after the write so
// what is published is what the transport actually accepted.
let mut observed: Vec<bytes::Bytes> = Vec::new();

loop {
let job = match carry_over.take() {
Expand All @@ -404,10 +438,18 @@ async fn sender_task(
continue;
}

// Encrypt everything already queued into out_buf. Stop at the
// MAX_BATCH_FRAMES / MAX_BATCH_WIRE_BYTES ceiling, or when try_recv
// finds nothing more waiting.
// Encrypt everything already queued into out_buf, cloning each
// plaintext into `observed` first when `sent_frames` is enabled. Stop
// at the MAX_BATCH_FRAMES / MAX_BATCH_WIRE_BYTES ceiling, or when
// try_recv finds nothing more waiting.
// ... encrypt_frame_into loop, then a single transport.send(out_buf) ...

// After the write succeeds: stats.record_frame_sent for each wire
// frame, then — re-checking sent_frames.enabled() rather than trusting
// the read at capture time, so a batch that outlived its last lease
// stays quiet — tap.publish(plaintext) for each entry in `observed`.
// A write that fails clears `observed` instead: a frame the transport
// refused is not reported as sent.
}
}
```
Expand Down Expand Up @@ -769,11 +811,14 @@ impl Client {
).await??;

// Perform Noise handshake
let device = self.persistence_manager.get_device_snapshot();
let noise_socket = do_handshake(
&device,
self.runtime.clone(),
&self.persistence_manager,
&self.ik_handshake_failures,
transport,
&mut events
&mut events,
SendObservers::with_stats(self.stats.clone())
.with_sent_frames(self.sent_frame_tap.clone()),
).await?;

// Store socket and start receivers
Expand Down
33 changes: 33 additions & 0 deletions api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2038,6 +2038,39 @@ while let Ok(event) = event_rx.recv().await {
// Drop `_lease` to stop forwarding.
```

### acquire_sent_frame_forwarding

```rust
pub fn acquire_sent_frame_forwarding(self: &Arc<Self>) -> SentFrameLease
```

Acquire a lease that keeps [`Event::SentFrame`](/concepts/events#sentframe) enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its `interest()` away from the default `EventInterest::ALL` also needs `EventKind::SentFrame` added back in, or it won't see the event even while a lease is held. This is the outbound counterpart of [`acquire_decrypted_payload_forwarding`](#acquire_decrypted_payload_forwarding): the event carries the marshaled plaintext of every frame the transport accepted, and — unlike [`wait_for_sent_node`](#wait_for_sent_node) — it is neither filtered nor one-shot, and covers every send path, including acks, delivery receipts, and direct-encoded IQs that never build a `Node` at all.

<ResponseField name="SentFrameLease" type="SentFrameLease">
RAII lease. `Event::SentFrame` stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive.
</ResponseField>

<Note>
While no lease is held, nothing is emitted and nothing is cloned — the path costs one relaxed atomic load on the noise sender task. Under a lease, the forwarded frame is the same `bytes::Bytes` the caller handed to the socket, so forwarding it is a refcount bump rather than a copy.
</Note>

**Example:**
```rust
use wacore::types::events::{ChannelEventHandler, Event};

let _lease = client.acquire_sent_frame_forwarding();

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 Register the handler before acquiring the frame lease

When the connected client can send concurrently—for example, from a keepalive, acknowledgement worker, or another task—acquiring the lease here activates publication before the handler is registered. Any frame sent between these statements is dispatched without this consumer and is lost, which undermines the documented session-recording use case. Register the handler first, then acquire the lease so every frame produced after activation has a receiver.

Useful? React with 👍 / 👎.


let (handler, event_rx) = ChannelEventHandler::new();
client.register_handler(handler);

while let Ok(event) = event_rx.recv().await {
if let Event::SentFrame(frame) = &*event {
println!("sent {} bytes", frame.plaintext.len());
}
}
// Drop `_lease` to stop forwarding.
```

---

## Call management
Expand Down
44 changes: 44 additions & 0 deletions concepts/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ pub enum Event {

// Decrypted payload (opt-in)
DecryptedPayload(DecryptedPayload),

// Sent frame (opt-in)
SentFrame(SentFrame),
}
```

Expand Down Expand Up @@ -2700,6 +2703,47 @@ Event::DecryptedPayload(payload) => {

See [`acquire_decrypted_payload_forwarding`](/api/client#acquire_decrypted_payload_forwarding) for the lease API.

## Sent frame events

### `SentFrame`

**Emitted:** One marshaled stanza, exactly as it was handed to the noise frame encryption, after the transport accepts the write. To receive this event, hold a lease from `client.acquire_sent_frame_forwarding()` and include `EventKind::SentFrame` in your handler's `interest()`. While no lease is held, nothing is emitted and nothing is cloned.

```rust
#[derive(Debug, Clone, Serialize, bon::Builder)]
#[non_exhaustive]
pub struct SentFrame {
#[serde(skip)]
pub plaintext: Bytes,
}

Event::SentFrame(SentFrame)
```

**Fields:**
- `plaintext` — The marshaled stanza, keeping the leading format byte the binary protocol writes, so decoding it is `wacore_binary::marshal::unmarshal_packed_ref(&plaintext)`. This is the plaintext handed to noise encryption, not a transport frame — the length prefix and the AEAD tag are added after it, and only `SessionStats` (see [WebSocket & Noise Protocol Handling](/advanced/websocket-handling#dedicated-sender-task)) accounts for those. A `Bytes`, so forwarding it is a refcount bump, not a copy.

This is the outbound counterpart of [`RawNode`](#rawnode) and a library extension with no WhatsApp Web equivalent. Before it existed, the send side had no observer at all: `wait_for_sent_node` is a filtered one-shot waiter for a single expected stanza, and it only ever sees stanzas that pass through the marshal-and-send path, so the paths that hand pre-marshaled bytes straight to the socket — acks, delivery receipts, direct-encoded IQs — were invisible even to that. `SentFrame` is emitted from the noise sender task, the single point every send path crosses (including the paths above and the coalesced-burst writes the ack and receipt workers use), so it covers all of them from one place. It exists for recording a session for replay, asserting the wire form in an integration test, and reading back a stanza the server rejected without rebuilding with logging on.

Handshake frames are pre-noise and are not covered, and neither are VoIP relay sockets, which — like `SessionStats` — receive no observers at all.

**Example:**
```rust
let _lease = client.acquire_sent_frame_forwarding();

// In your event handler:
Event::SentFrame(frame) => {
println!("sent {} bytes", frame.plaintext.len());
}
// Drop `_lease` to stop forwarding.
```

<Note>
`plaintext` is skipped during serialization (`#[serde(skip)]`) — like `RawNode` and `DecryptedPayload`, `Serialize` on an event is for diagnostics, and no text format carries raw bytes without an encoding choice this type has no business making.
</Note>

See [`acquire_sent_frame_forwarding`](/api/client#acquire_sent_frame_forwarding) for the lease API, and [WebSocket & Noise Protocol Handling](/advanced/websocket-handling#noisesocket) for how `SendObservers` wires it into the noise sender.

## Event handler patterns

### Bot builder pattern
Expand Down