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,11 +1137,16 @@ 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 (`SessionRecord::reserve_sender_chain_counters`). A send covered by an unexhausted lease is already durable, so it just schedules the same coalesced write-behind as the receive path below. Only the send that exhausts the lease — roughly 1 in 64 — raises the ceiling and flushes **synchronously, before the stanza reaches the wire**, propagating a persistence failure by aborting the send. Either way, 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 (field 100 in the encoded `SessionRecord`, outside the vendored `whatsapp.proto`), and `SessionRecord::deserialize` fast-forwards the sender chain to the lease ceiling on every load, so a crash or reconnect mid-lease can never re-derive a possibly-spent counter.

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 Document pending gates for lease-covered sends

When a different session or sender-key chain has an unpersisted wire gate, such as after a backend failure while raising a lease, Client::persist_signal_state_pre_wire() uses a global needs_pre_wire_flush() predicate, so this otherwise lease-covered DM send will synchronously flush and can return Err. Saying it “just schedules” the coalescer and that only the lease-raising send flushes misdocuments an observable send failure mode that callers may need to handle.

Useful? React with 👍 / 👎.

- **Send (group and status sends)** is unaffected by the lease and 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.

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.

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 | 🟡 Minor | ⚡ Quick win

Apply the documentation style rules consistently across the updated pages.

The new durability explanations combine too many independent guarantees into single dense sentences and frequently use passive phrasing. Split each explanation into concise, reader-directed statements or bullets.

  • advanced/signal-protocol.mdx#L1140-L1144: Separate lease, flushing, encoding, recovery, and failure guarantees.
  • api/client.mdx#L1466-L1466: Separate send-path, receive-path, success, timeout, and failure behavior.
  • api/send.mdx#L47-L47: Separate lease, group/status, counter-reuse, and persistence-failure guarantees.

As per coding guidelines, MDX documentation must use active voice and second person, and keep sentences concise with one idea per sentence.

📍 Affects 3 files
  • advanced/signal-protocol.mdx#L1140-L1144 (this comment)
  • api/client.mdx#L1466-L1466
  • api/send.mdx#L47-L47
🤖 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 `@advanced/signal-protocol.mdx` around lines 1140 - 1144, Rewrite the updated
durability documentation at advanced/signal-protocol.mdx:1140-1144,
api/client.mdx:1466-1466, and api/send.mdx:47-47 into concise, active-voice,
second-person sentences or bullets. At the signal protocol site, separate lease
behavior, flushing timing, encoding scope, recovery, and failure guarantees; at
the client site, separate send, receive, success, timeout, and failure behavior;
at the send site, separate lease, group/status, counter-reuse, and
persistence-failure guarantees. Preserve all existing technical guarantees while
limiting each sentence to one idea.

Source: Coding guidelines


<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 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 `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.
Expand Down
2 changes: 1 addition & 1 deletion api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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: DM sends are usually already covered by a durable sender-chain counter lease and only schedule the coalesced write-behind (flushing synchronously only on the roughly-1-in-64 send that raises the lease), group and status sends still flush synchronously on every send, and the live receive path always 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.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

<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. The send that raises the lease, roughly 1 in 64, persists to the backend **synchronously, before the stanza is transmitted**. Group and status sends always persist their sender-key ratchet advance synchronously, before the stanza is transmitted. 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