Skip to content
7 changes: 6 additions & 1 deletion advanced/signal-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1137,9 +1137,14 @@ impl SessionStore for SessionAdapter {

*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.
- **Send (DM/1:1 sessions)** persists through a batched **counter lease**. `SessionRecord` reserves its outbound sender-chain counter `SENDER_CHAIN_RESERVATION_BATCH` (64) values at a time, via `SessionRecord::reserve_sender_chain_counters`. A send covered by an unexhausted lease is already durable — it only schedules the same coalesced write-behind as the receive path below. The send that exhausts the lease, roughly 1 in 64, raises the ceiling and flushes **synchronously, before the stanza reaches the wire**. If that flush fails, the send aborts instead of transmitting an advance it couldn't save. Reusing an outbound counter reuses its message key and IV, so no counter can ever be used before its lease is durable. The lease field is local-only: it's field 100 in the encoded `SessionRecord`, outside the vendored `whatsapp.proto`. On every load, `SessionRecord::deserialize` fast-forwards the sender chain to the lease ceiling, so a crash or reconnect mid-lease can never re-derive a possibly-spent counter.
- **Send (group and status sends)** ignores the lease. It still flushes **synchronously, before the stanza reaches the wire**, on every send. Sender-key leasing is a potential follow-up, not implemented yet.
- **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.

<Warning>
Downgrading to a version that predates the counter lease after running a leased version: the older version ignores the lease field and could reuse counters that were only reserved (not yet actually sent) by the lease. Avoid downgrading a device's local state across this boundary.

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 Warn that downgrades can reuse already-sent counters

In the crash/downgrade scenario, the stale chain snapshot can be behind counters that were already transmitted after the durable lease was written; an older reader ignores the lease and resumes from that stale snapshot, so it can re-derive already-spent counters, not merely counters that were reserved but unsent. This warning understates the cryptographic risk and could lead operators to accept an unsafe rollback after a crash.

Useful? React with 👍 / 👎.

</Warning>
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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.
Expand Down
4 changes: 3 additions & 1 deletion api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1463,7 +1463,9 @@ 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: 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.
Ordinarily — without calling this method — the backend trails the in-memory cache. Most DM sends are already covered by a durable sender-chain counter lease, so they only schedule the coalesced write-behind; only the roughly-1-in-64 send that exhausts the current lease flushes synchronously — and because the pre-wire flush check is global, a pending flush on an unrelated session can force a synchronous flush too. Group sends and status posts flush synchronously on every send; status reactions are the DM-branch exception and follow the lease behavior instead. The live receive path always schedules a coalesced flush, on a ~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 has **no hard wall-clock bound** — it can wait on locks, or on slow or failing storage, and 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.

<Warning>
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.
Expand Down
2 changes: 1 addition & 1 deletion api/send.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pub async fn send_message(
</ResponseField>

<Note>
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.
For DMs, the outbound Signal ratchet advance is persisted through a batched counter lease. The sender-chain counter is reserved 64 at a time, so most sends are already covered by a durable lease and only schedule a coalesced write-behind — though the pre-wire flush check is global, so a pending flush on an unrelated session can still force this send to flush synchronously. The send that exhausts the current lease, roughly 1 in 64, persists to the backend **synchronously, before the stanza is transmitted**. Group sends, and status posts sent via `client.status()`, always persist their sender-key ratchet advance synchronously, before the stanza is transmitted. Status *reactions* (`send_reaction` targeting `status@broadcast`) are the exception: they route through the same DM branch as an ordinary 1:1 message, addressed to the status author's device, so they follow the DM counter-lease behavior instead. Reusing an outbound counter would reuse its message key and IV, so the advance is always durable before it can be reused. If a required 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.
</Note>

### SendResult
Expand Down