From 9be80e360e4fd069be65b0f668a81f1573f7d972 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:18:52 -0300
Subject: [PATCH 01/10] docs: document flush_pending_signal_state (PR #1022)
---
api/client.mdx | 25 ++++++++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/api/client.mdx b/api/client.mdx
index 014b9ed..41034b7 100644
--- a/api/client.mdx
+++ b/api/client.mdx
@@ -1455,6 +1455,29 @@ Send pre-marshaled plaintext bytes through the noise socket. The bytes must be a
This bypasses node logging and `wait_for_sent_node` waiter resolution. Use [`send_node`](#send_node) for normal stanza sending. This method is intended for performance-critical paths where you already have marshaled bytes.
+### flush_pending_signal_state
+
+```rust
+pub async fn flush_pending_signal_state(&self) -> Result<(), anyhow::Error>
+```
+
+Forces any pending write-behind Signal cache state to the backend, returning once the flush completes (or fails).
+
+The live receive path schedules a coalesced flush instead of writing through (sends already flush synchronously before the stanza hits the wire). On success the backend normally trails the cache by about the coalescing window, but that is **not** a hard wall-clock bound — the timer can slip under runtime starvation, and the flush can wait on locks or slow/failing storage (a backend outage extends it until the retry loop succeeds). Use this to settle durability deterministically before reading persisted state directly, or ahead of a non-graceful shutdown. Check the returned `Result` — a failure leaves state pending.
+
+
+Call this from a control task, never from inside an event handler or an [`InboundDurabilityHook`](/advanced/inbound-durability). During an offline-sync drain, those run while the processing permit is held, and settling routes through that same permit — re-entering it would deadlock.
+
+
+**Example:**
+```rust
+// Force durability before reading Signal state directly, or before a
+// non-graceful shutdown (process kill, container stop).
+client.flush_pending_signal_state().await?;
+```
+
+See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model.
+
### generate_message_id
```rust
@@ -2022,4 +2045,4 @@ pub enum ClientError {
- [Bot](/api/bot) - High-level builder with event handlers
- [Events](/concepts/events) - Event system and types
- [Sending Messages](/guides/sending-messages) - Sending and receiving messages
-- [Group Management](/guides/group-management) - Working with groups
+- [Group Management](/guides/group-management) - Working with groups
\ No newline at end of file
From a2aa96ab28750d432467c9956a3c3c1717af0fb1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:23:50 -0300
Subject: [PATCH 02/10] docs: fix trailing newline in client.mdx (PR #1022)
---
api/client.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/client.mdx b/api/client.mdx
index 41034b7..20910ce 100644
--- a/api/client.mdx
+++ b/api/client.mdx
@@ -2045,4 +2045,4 @@ pub enum ClientError {
- [Bot](/api/bot) - High-level builder with event handlers
- [Events](/concepts/events) - Event system and types
- [Sending Messages](/guides/sending-messages) - Sending and receiving messages
-- [Group Management](/guides/group-management) - Working with groups
\ No newline at end of file
+- [Group Management](/guides/group-management) - Working with groups
From f73a93febc2c1c46891e73408301719e6d75c953 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:29:10 -0300
Subject: [PATCH 03/10] docs: document send/receive Signal-cache flush
scheduling (PR #1022)
---
advanced/signal-protocol.mdx | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/advanced/signal-protocol.mdx b/advanced/signal-protocol.mdx
index 940ede7..9367bfb 100644
--- a/advanced/signal-protocol.mdx
+++ b/advanced/signal-protocol.mdx
@@ -1133,6 +1133,19 @@ impl SessionStore for SessionAdapter {
}
```
+### Flush scheduling: send vs. receive
+
+*When* the dirty Signal cache reaches the backend differs by direction, because the two directions have different recovery properties:
+
+- **Send** (DM, group, and status sends) flushes **synchronously, before the stanza reaches the wire**, and propagates a persistence failure by aborting the send. Reusing an outbound counter reuses its message key and IV, so the ratchet advance must be durable before anyone can act on the ciphertext — the send must not transmit an advance it couldn't save.
+- **Receive** (live traffic, outside the offline-drain batcher) routes through a single-flight coalescing scheduler (`src/signal_flush.rs`) instead of flushing per stanza: a burst of receives folds into one flush per ~25ms window, retried with exponential backoff (up to a 5s cap) on backend failure. This is safe because a lost receive-side advance simply re-derives forward on the next message (the receiving chain derives `CK_n → CK_n+1`), and a consumed one-time prekey stays buffered until its session is durable — a crash inside the window is recoverable.
+
+The scheduler is generation-scoped (embeds the connection generation in its atomic state), so a reconnect during an in-flight flush needs no explicit reset: a stale worker from the previous connection cannot mutate the new generation's state, and stands down when it observes a foreign generation.
+
+The offline drain, retry-receipt recovery, identity-change recovery, and teardown all keep their own **synchronous** flushes — they gate acks, receipts, or follow-up reads on durability and are not routed through the receive coalescer. See [Inbound Durability Hook](/advanced/inbound-durability) for the drain-batch commit ordering, which this coalescing does not change.
+
+Call [`Client::flush_pending_signal_state()`](/api/client#flush_pending_signal_state) to force a deterministic settle — e.g. before reading persisted Signal state directly, or ahead of a non-graceful shutdown. Never call it from inside an event handler or an `InboundDurabilityHook`, since settling re-enters the processing permit those run under and would deadlock during an offline-sync drain.
+
## Security Considerations
### Identity key trust
From b9f6fa8a61a750e911564da95daf7a700771e2c1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:31:35 -0300
Subject: [PATCH 04/10] docs: note synchronous pre-wire Signal-state durability
for sends (PR #1022)
---
api/send.mdx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/api/send.mdx b/api/send.mdx
index eddcc63..9a6fd67 100644
--- a/api/send.mdx
+++ b/api/send.mdx
@@ -43,6 +43,10 @@ pub async fn send_message(
Contains the `message_id` (unique ID for tracking receipts, edits, revokes) and `to` (resolved recipient JID). Use `send_result.message_key()` to get a `wa::MessageKey` for album child linking, pinning, or other operations that reference this message.
+
+For DMs, group, and status sends, the outbound Signal ratchet advance is persisted to the backend **synchronously, before the stanza is transmitted** — reusing an outbound counter would reuse its message key and IV, so the advance must be durable before anyone can act on the ciphertext. If that persistence write fails, `send_message` returns `Err` instead of transmitting an advance that couldn't be saved. See [Signal Protocol — flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive) for the full durability model.
+
+
### SendResult
Result of a successfully sent message. Provides the message ID and a convenience method to construct a `MessageKey` for follow-up operations like album child linking.
@@ -1252,7 +1256,7 @@ pub enum SendError {
- `Iq` — IQ request required by the send path failed
- `InvalidRequest` — the send request was malformed (e.g., invalid JID, bad message shape)
- `Client` — underlying transport/connection error
-- `Internal` — catch-all for errors not yet assigned a typed variant
+- `Internal` — catch-all for errors not yet assigned a typed variant. Includes a failure to durably persist the outbound Signal ratchet advance before the stanza was sent — see the durability note under [`send_message`](#send_message).
**Example:**
From 587fb6b9f21c4fc720e8ed11d96b8d2b87d72a09 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:43:42 -0300
Subject: [PATCH 05/10] docs: narrow flush_pending_signal_state deadlock
warning to permit-holding callers
---
api/client.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/client.mdx b/api/client.mdx
index 20910ce..5bec874 100644
--- a/api/client.mdx
+++ b/api/client.mdx
@@ -1466,7 +1466,7 @@ Forces any pending write-behind Signal cache state to the backend, returning onc
The live receive path schedules a coalesced flush instead of writing through (sends already flush synchronously before the stanza hits the wire). On success the backend normally trails the cache by about the coalescing window, but that is **not** a hard wall-clock bound — the timer can slip under runtime starvation, and the flush can wait on locks or slow/failing storage (a backend outage extends it until the retry loop succeeds). Use this to settle durability deterministically before reading persisted state directly, or ahead of a non-graceful shutdown. Check the returned `Result` — a failure leaves state pending.
-Call this from a control task, never from inside an event handler or an [`InboundDurabilityHook`](/advanced/inbound-durability). During an offline-sync drain, those run while the processing permit is held, and settling routes through that same permit — re-entering it would deadlock.
+Never call this from inside an [`InboundDurabilityHook`](/advanced/inbound-durability) — during an offline-sync drain it runs while the processing permit is held, and settling routes through that same permit, so re-entering it would deadlock. The same risk applies to a custom `EventHandler::handle_event` implementation that itself blocks synchronously inline (dispatch is synchronous). It does **not** apply to ordinary [`Bot`](/api/bot) closure handlers (`.on_message()`, etc.) — both the default concurrent and ordered delivery modes run your callback in a detached task that never holds the permit, so calling `flush_pending_signal_state()` from inside one of those is safe.
**Example:**
From 83bd0ebf7981a08185b6d2645a1f28e3abb7d339 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:48:32 -0300
Subject: [PATCH 06/10] docs: narrow flush_pending_signal_state deadlock
warning to permit-holding callers
---
advanced/signal-protocol.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/advanced/signal-protocol.mdx b/advanced/signal-protocol.mdx
index 9367bfb..82d7a42 100644
--- a/advanced/signal-protocol.mdx
+++ b/advanced/signal-protocol.mdx
@@ -1144,7 +1144,7 @@ The scheduler is generation-scoped (embeds the connection generation in its atom
The offline drain, retry-receipt recovery, identity-change recovery, and teardown all keep their own **synchronous** flushes — they gate acks, receipts, or follow-up reads on durability and are not routed through the receive coalescer. See [Inbound Durability Hook](/advanced/inbound-durability) for the drain-batch commit ordering, which this coalescing does not change.
-Call [`Client::flush_pending_signal_state()`](/api/client#flush_pending_signal_state) to force a deterministic settle — e.g. before reading persisted Signal state directly, or ahead of a non-graceful shutdown. Never call it from inside an event handler or an `InboundDurabilityHook`, since settling re-enters the processing permit those run under and would deadlock during an offline-sync drain.
+Call [`Client::flush_pending_signal_state()`](/api/client#flush_pending_signal_state) to force a deterministic settle — e.g. before reading persisted Signal state directly, or ahead of a non-graceful shutdown. Never call it from inside an `InboundDurabilityHook` or a synchronous, inline `EventHandler::handle_event` implementation, since settling re-enters the processing permit those run under and would deadlock during an offline-sync drain. Ordinary `Bot` closure handlers are unaffected — both default delivery modes run the callback in a detached task off the permit.
## Security Considerations
From e186d7da30eb82d0a4ed9b6bfb055eb72159984d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:52:38 -0300
Subject: [PATCH 07/10] docs: clarify flush_pending_signal_state success
guarantee vs ambient lag
---
api/client.mdx | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/api/client.mdx b/api/client.mdx
index 5bec874..fb64f43 100644
--- a/api/client.mdx
+++ b/api/client.mdx
@@ -1463,7 +1463,7 @@ pub async fn flush_pending_signal_state(&self) -> Result<(), anyhow::Error>
Forces any pending write-behind Signal cache state to the backend, returning once the flush completes (or fails).
-The live receive path schedules a coalesced flush instead of writing through (sends already flush synchronously before the stanza hits the wire). On success the backend normally trails the cache by about the coalescing window, but that is **not** a hard wall-clock bound — the timer can slip under runtime starvation, and the flush can wait on locks or slow/failing storage (a backend outage extends it until the retry loop succeeds). Use this to settle durability deterministically before reading persisted state directly, or ahead of a non-graceful shutdown. Check the returned `Result` — a failure leaves state pending.
+Ordinarily — without calling this method — the backend trails the in-memory cache: sends already flush synchronously, but the live receive path only schedules a coalesced flush every ~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)). A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call itself has **no hard wall-clock bound**, though — it can wait on locks or on slow/failing storage (a backend outage extends it until the retry loop succeeds). Check the returned `Result`: a failure means the flush did not complete and state is still pending, not persisted.
Never call this from inside an [`InboundDurabilityHook`](/advanced/inbound-durability) — during an offline-sync drain it runs while the processing permit is held, and settling routes through that same permit, so re-entering it would deadlock. The same risk applies to a custom `EventHandler::handle_event` implementation that itself blocks synchronously inline (dispatch is synchronous). It does **not** apply to ordinary [`Bot`](/api/bot) closure handlers (`.on_message()`, etc.) — both the default concurrent and ordered delivery modes run your callback in a detached task that never holds the permit, so calling `flush_pending_signal_state()` from inside one of those is safe.
@@ -2024,9 +2024,7 @@ if let Some(alloc) = report.alloc {
Storage, transport, and HTTP reports are supplied by the trait implementations behind `Client` — see [`DeviceStore::resource_report`](/api/store#resource_report), [`Transport::resource_report`](/api/transport#resource_report), and [`HttpClient::resource_report`](/api/http-client#resource_report). `AllocSnapshot`, `StorageResourceReport`, `TransportResourceReport`, and `HttpResourceReport` are re-exported from `wacore::stats`; all four are also re-exported from the `whatsapp_rust` crate root.
----
-
-## Error Types
+### Error Types
```rust
pub enum ClientError {
From 4256898f15f052f94f1a636952b997bd8b13ebc4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:58:00 -0300
Subject: [PATCH 08/10] docs: fix Error Types heading dropped during previous
push
---
api/client.mdx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/api/client.mdx b/api/client.mdx
index fb64f43..0ed558f 100644
--- a/api/client.mdx
+++ b/api/client.mdx
@@ -2024,7 +2024,9 @@ if let Some(alloc) = report.alloc {
Storage, transport, and HTTP reports are supplied by the trait implementations behind `Client` — see [`DeviceStore::resource_report`](/api/store#resource_report), [`Transport::resource_report`](/api/transport#resource_report), and [`HttpClient::resource_report`](/api/http-client#resource_report). `AllocSnapshot`, `StorageResourceReport`, `TransportResourceReport`, and `HttpResourceReport` are re-exported from `wacore::stats`; all four are also re-exported from the `whatsapp_rust` crate root.
-### Error Types
+---
+
+## Error Types
```rust
pub enum ClientError {
From 7d780c2313b05c9174109a372b5d94faec5d73fb Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 14 Jul 2026 13:00:32 +0000
Subject: [PATCH 09/10] docs: fix grammar in flush_pending_signal_state
durability note
Co-Authored-By: Claude Sonnet 5
---
api/client.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/client.mdx b/api/client.mdx
index 0ed558f..bf5a850 100644
--- a/api/client.mdx
+++ b/api/client.mdx
@@ -1463,7 +1463,7 @@ pub async fn flush_pending_signal_state(&self) -> Result<(), anyhow::Error>
Forces any pending write-behind Signal cache state to the backend, returning once the flush completes (or fails).
-Ordinarily — without calling this method — the backend trails the in-memory cache: sends already flush synchronously, but the live receive path only schedules a coalesced flush every ~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)). A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call itself has **no hard wall-clock bound**, though — it can wait on locks or on slow/failing storage (a backend outage extends it until the retry loop succeeds). Check the returned `Result`: a failure means the flush did not complete and state is still pending, not persisted.
+Ordinarily — without calling this method — the backend trails the in-memory cache: outbound sends already flush synchronously, but the live receive path only schedules a coalesced flush every ~25ms window (see [flush scheduling](/advanced/signal-protocol#flush-scheduling-send-vs-receive)). A successful call to `flush_pending_signal_state()` closes that gap deterministically: everything dirty as of the call is persisted by the time it returns `Ok`. The call itself has **no hard wall-clock bound**, though — it can wait on locks or on slow/failing storage (a backend outage extends it until the retry loop succeeds). Check the returned `Result`: a failure means the flush did not complete and state is still pending, not persisted.
Never call this from inside an [`InboundDurabilityHook`](/advanced/inbound-durability) — during an offline-sync drain it runs while the processing permit is held, and settling routes through that same permit, so re-entering it would deadlock. The same risk applies to a custom `EventHandler::handle_event` implementation that itself blocks synchronously inline (dispatch is synchronous). It does **not** apply to ordinary [`Bot`](/api/bot) closure handlers (`.on_message()`, etc.) — both the default concurrent and ordered delivery modes run your callback in a detached task that never holds the permit, so calling `flush_pending_signal_state()` from inside one of those is safe.
From c0f54e0fc49a46f8f2f829c33005c025463b0297 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Lucas?=
<55464917+jlucaso1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 10:08:20 -0300
Subject: [PATCH 10/10] docs: narrow flush_pending_signal_state deadlock
warning to permit-holding callers
---
api/client.mdx | 2 --
1 file changed, 2 deletions(-)
diff --git a/api/client.mdx b/api/client.mdx
index bf5a850..6ec71d0 100644
--- a/api/client.mdx
+++ b/api/client.mdx
@@ -2024,8 +2024,6 @@ if let Some(alloc) = report.alloc {
Storage, transport, and HTTP reports are supplied by the trait implementations behind `Client` — see [`DeviceStore::resource_report`](/api/store#resource_report), [`Transport::resource_report`](/api/transport#resource_report), and [`HttpClient::resource_report`](/api/http-client#resource_report). `AllocSnapshot`, `StorageResourceReport`, `TransportResourceReport`, and `HttpResourceReport` are re-exported from `wacore::stats`; all four are also re-exported from the `whatsapp_rust` crate root.
----
-
## Error Types
```rust