diff --git a/advanced/metrics.mdx b/advanced/metrics.mdx index 693c1e8..0f75fee 100644 --- a/advanced/metrics.mdx +++ b/advanced/metrics.mdx @@ -85,6 +85,7 @@ All metrics are prefixed with `wa_` and emitted at the same boundaries as the ma | `wa_appstate_mutations_total` | — | App-state mutations applied | | `wa_identity_change_total` | — | Peer identity changes that triggered a session reset | | `wa_prekey_upload_total` | `outcome` = `ok`, `fail` | Pre-key uploads by outcome | +| `wa_session_record_quarantined_total` | — | Stored session rows that failed to decode and were treated as absent for recovery. Steady state is zero — see [session row quarantine](/concepts/storage#signalstorecache) | ### Histograms (seconds) diff --git a/advanced/signal-protocol.mdx b/advanced/signal-protocol.mdx index 0720f68..0e5d111 100644 --- a/advanced/signal-protocol.mdx +++ b/advanced/signal-protocol.mdx @@ -1300,6 +1300,18 @@ Retry-receipt recovery (`handle_retry_receipt` resending to a DM or group reques 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. +### DH ratchet resets rebase the lease + +A DH ratchet doesn't extend the current sender chain — it replaces it in place. The ratchet installs fresh key material from a new random ephemeral at counter zero and drops the retired chain instead of archiving it. The counter lease described above is a **record-level** ceiling, but the chain it bounds is **per-ratchet-epoch**. Without a matching lease rebase, the ceiling keeps describing a chain that no longer exists. + +For ping-pong traffic, that gap is one batch and you'd never notice it. It's different for a peer you only ever monologue at — say, your own other device, which gets a copy of every message you send but rarely replies. The chain climbs past `MAX_RESERVATION_FAST_FORWARD` before one reply triggers the ratchet, and the ceiling ends up stranded thousands of counters above a chain that just restarted at zero. + +A live reload never surfaces this, since a trusted-incarnation reload (above) skips the fast-forward entirely. The gap only shows up on **recovery** — a restart, or any lossy cache reset. There, the reload has to fast-forward across a span no send ever created. It refuses past `MAX_RESERVATION_FAST_FORWARD` and fails the whole record load. From that point the address is stranded: every path that could repair the session — inbound decrypt, the group-send fan-out, the retry-receipt handler — has to load the unloadable record first. + +`SessionRecord::rebase_lease_after_sender_chain_reset()` closes this gap. As part of the same mutation that swaps in the fresh chain, it lowers the ceiling to at most one `SENDER_CHAIN_RESERVATION_BATCH`. It only ever lowers, never raises, so you can never publish a counter under a ceiling that isn't yet durable. The lowering happens atomically with the chain swap, so no snapshot can pair the retired chain with a ceiling rebased for it, or vice versa. Rebasing to one batch instead of zero keeps the fresh chain's first counters lease-covered, so steady-state ping-pong keeps its write-behind send path instead of paying a synchronous flush on the very next send. A chain that's *archived* rather than discarded keeps its claim on the lease instead: `promote_fresh_state` burns the outgoing state to the ceiling before resetting it. + +If a record already has a stranded ceiling — written by a build that predates this fix — it recovers on its own the next time you use that address; you don't need to delete the row by hand. See [undecodable session rows](/concepts/storage#signalstorecache). + ## Record components `wacore-libsignal` exposes owned, validated projections of `SessionRecord` and `SenderKeyRecord` called **components**. Use them when you need to interchange or inspect session and sender-key record state without depending on the generated protobuf schema directly — for example in custom store implementations, migration tooling, or offline debugging. This API is purely additive: the protobuf-backed `serialize()`/`deserialize()` path is unchanged. A record does not round-trip through `into_components()` → `from_components()` → `serialize()` byte-for-byte — the conversion applies the validated, normalized export rules described below (counter-lease advancement, stale-chain removal, and bounded truncation), so treat it as a safe normalized re-encoding rather than a lossless copy. diff --git a/api/signal.mdx b/api/signal.mdx index 0defb00..9c7cd69 100644 --- a/api/signal.mdx +++ b/api/signal.mdx @@ -614,8 +614,10 @@ if !client.signal().has_sender_key(&group_jid, &my_jid).await? { ### Reset a broken session +Use this for a session that decodes fine but is logically wrong — for example, after a known identity compromise. A session row that fails to decode from storage doesn't need this: the next send or decrypt for that address recovers it automatically, by fetching a fresh pre-key bundle and replacing the row. See [undecodable session rows](/concepts/storage#signalstorecache) for the mechanics. + ```rust -// Delete the corrupted session +// Delete a session you know is logically wrong (e.g. after an identity compromise) client.signal().delete_sessions(&[jid.clone()]).await?; // Re-establish diff --git a/concepts/storage.mdx b/concepts/storage.mdx index 82c4b9a..82b0386 100644 --- a/concepts/storage.mdx +++ b/concepts/storage.mdx @@ -347,6 +347,14 @@ cache.clear().await; - Only clears dirty tracking after ALL writes succeed - On failure, dirty state is preserved for retry on next flush +**Undecodable session rows:** Deserialization is a pure function of the stored bytes. A session row that fails to decode once — from genuine corruption, or from a row written in a shape this build can no longer read, including a counter lease stranded by a since-fixed bug (see [DH ratchet resets rebase the lease](/advanced/signal-protocol#dh-ratchet-resets-rebase-the-lease)) — fails identically forever. + +- `get_session`, `checkout_session`, and `has_session` all report that row as **absent** instead of propagating a decode error. Loading the row doesn't repair it by itself — it only lets the caller treat the address as session-less. +- That matters because the paths that would otherwise repair the session — decrypting the peer's next pre-key message, the retry-receipt handler — have to load the record first. If the decode error propagated instead, it would strand the address until you deleted the row by hand. +- Reporting the row absent lets the ordinary no-session recovery run instead: the next send or decrypt for that address fetches a fresh pre-key bundle and persists a replacement session, overwriting the unreadable row. This build never derives key material from bytes it can't decode, so it loses nothing usable — but the overwrite is destructive to the original bytes. If you're rolling back to a build that could still decode that row, back up the database first; the original bytes don't survive the overwrite. +- `has_session()` decodes the row instead of only checking for its existence, so it never reports a quarantined row as present to a caller deciding whether to skip recovery. +- Each quarantine increments the `wa_session_record_quarantined_total` [counter](/advanced/metrics#counters). Watch for a non-zero rate — steady state is zero. + ## AppSyncStore **Purpose:** WhatsApp app state synchronization