Skip to content

perf(signal): lease outbound counters in batches instead of flushing every send - #1026

Merged
jlucaso1 merged 3 commits into
mainfrom
feat/sender-chain-counter-lease
Jul 14, 2026
Merged

perf(signal): lease outbound counters in batches instead of flushing every send#1026
jlucaso1 merged 3 commits into
mainfrom
feat/sender-chain-counter-lease

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every send serialized the entire SessionRecord (protobuf) and committed a storage transaction before the stanza went to the wire: ~2.26 KB/msg, 229 MB per 100k messages in the pingpong profile, plus one SQLite fsync per message on the hot path. The pre-wire flush is mandatory because the message key and IV derive deterministically from the sender-chain counter, so a crash between the wire write and persistence would re-derive an already-used (key, IV) pair.

Insight

What has to be durable per operation is not the ratchet state — it is the promise that no counter repeats. That promise can be bought in bulk.

Mechanics

  • SessionRecord gains a counter lease (reservedSenderChainIndex): when the reservation is exhausted, encrypt raises the ceiling by SENDER_CHAIN_RESERVATION_BATCH (64) counters and marks the record pending; only that send flushes synchronously before the wire.
  • The field is local-only, hand-encoded at the RecordStructure level (the encoder was already hand-rolled in serialize_into), so the vendored whatspec whatsapp.proto is untouched. Field number 100, far from the upstream fields (1, 2); old readers skip the unknown field.
  • SessionRecord::deserialize fast-forwards the sender chain to the lease ceiling, so every possibly-spent counter becomes underivable after any reload (crash OR reconnect). Cost is <= 64 HMACs, only on a load with an active lease.
  • Sends covered by the lease only schedule the coalesced write-behind (the same 25 ms path as receive) via Client::persist_signal_state_pre_wire.
  • Group sender keys still gate the wire on every send (leasing for groups is a follow-up), but now via an explicit encrypt flag (mark_wire_gated), so group decrypt dirtiness no longer forces a synchronous flush on unrelated DM sends.

Measured effect (A/B vs main, pingpong)

dhat, in-memory backend, 20k-message monologue at rate 1000 (client keeps up, pong_failed=0 on both sides):

metric main this PR delta
total allocations / msg 233.3 223.8 -9.5 (-4.1%)
total bytes / msg 36392 33812 -2580 (-7.1%)
SessionRecord::serialize_into / msg 4.01 0.18 -95%
flush_signal_cache / msg 9.10 0.43 -95%

The per-message serialize and per-message signal-cache flush both drop ~95% (to roughly 1-in-64, as designed). A second run at rate 12000 / 100k reproduced the same delta (229.2 -> 220.6 allocs/msg, serialize_into 4.01 -> 0.19).

Throughput is rate-limited in this harness, so at a fixed rate both sides sustain the same ~11.4k msg/s with pong_failed=0; client CPU trends lower but sits inside run-to-run noise on a loaded box, so no CPU number is claimed. Note the bench uses the in-memory backend, so the per-message SQLite fsync this PR also removes is not exercised here — the allocation reduction is the part this harness can measure.

Safety interleavings (all tested)

Scenario Guarantee
Crash/reload mid-lease resumes past the ceiling; the peer decrypts across the gap (<< MAX_FORWARD_JUMPS)
DH ratchet whose new chain was never persisted unrecoverable by construction (fresh random ephemeral); the reloaded old chain resumes past its own lease
Promotion of archived state promote_state burns the lease on the promoted chain; promote_fresh_state (new ratchets) zeroes instead of burning
Failed flush the gate stays closed until a successful flush; checked-out sessions stay gated
Legacy records (no field) load with reservation 0; the first send re-reserves; a corrupt/implausible reservation is rejected at load

Tests

  • wacore/libsignal/tests/counter_lease.rs — 6 end-to-end Alice/Bob scenarios (crash-sim via serialize/reload)
  • Unit: promotion / fast-forward / corrupt-reservation rejection; the SignalStoreCache gate (5 tests)
  • src/signal_flush.rs — the client-side gate: a lease send flushes synchronously, a covered send coalesces
  • e2e session_reuse.rs rewritten for the new contract: the durable resume position always covers spent counters; a send that raises the lease aborts pre-wire if persistence fails; a covered send survives a storage failure (new test)

cargo fmt / clippy --all --tests clean; 2604 unit/integration tests passing. E2e run in CI (mock server).

Caveats

  • Downgrade after a crash: an old version of the lib ignores the lease field and could reuse counters — worth a release note.
  • A counter gap becomes visible to the receiver after a crash/reconnect (<= 64 per reload, handled as out-of-order messages, semantics the protocol already supports).
  • Natural follow-up: an iteration lease for group sender keys (would remove the synchronous flush from group sends too).

…every send

Every send used to serialize the whole SessionRecord and commit a storage
transaction before the stanza could hit the wire, because outbound message
keys/IVs derive deterministically from the sender-chain counter and a crash
between wire-write and persistence would re-derive a spent (key, IV) pair.
At 100k messages that was ~229 MB of protobuf encoding and one SQLite
transaction per message on the hot path.

The durable requirement is not the ratchet state itself — it is the promise
that no counter ever repeats. That promise can be bought in batches: the
record now leases SENDER_CHAIN_RESERVATION_BATCH counters ahead
(reservedSenderChainIndex, a record-level field hand-encoded next to the
RecordStructure fields, since the whatspec proto cannot carry local fields),
and deserialize fast-forwards the loaded chain past the whole lease, making
every possibly-spent counter underivable after a reload.

Send-path effect: only the send that raises the lease (or advances a group
sender-key chain, which has no lease yet) still flushes synchronously before
the wire; every lease-covered send just schedules the same coalesced
write-behind the receive path uses. Steady-state ping-pong (counter 0 of a
freshly ratcheted chain every time) never re-raises the lease, so the
per-message serialize + transaction disappears entirely from that profile.

Safety interleavings covered by tests:
- crash/reload mid-lease resumes past the ceiling; the peer decrypts across
  the burned gap (bounded, well under MAX_FORWARD_JUMPS)
- a DH ratchet whose new chain never reached storage is unrecoverable by
  construction (fresh random ephemeral), and the reloaded old chain resumes
  past its own lease
- archived-state promotion burns the lease into the promoted chain
  (promote_state); freshly ratcheted states reset it (promote_fresh_state)
- a raised lease gates the wire until a flush SUCCEEDS (failed flushes keep
  the gate closed; checked-out sessions stay gated across a flush)
- group sender-key encrypts still gate the wire, but group decrypt dirtiness
  no longer forces a sync flush onto unrelated DM sends
- legacy records (no lease field) load with a zero reservation; the encoding
  round-trips and rejects implausible (corrupt) reservations

Old readers skip the unknown record field, but a downgrade after a crash
would ignore the lease — release notes should flag that.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Outbound Signal sessions now reserve sender-chain counter leases, persist lease metadata, and fast-forward after reloads. Session and sender-key cache entries track pending wire gates. Send paths synchronously persist required gates and coalesce other flushes.

Changes

Sender-chain durability leases

Layer / File(s) Summary
Protocol lease state
wacore/libsignal/src/protocol/consts.rs, wacore/libsignal/src/protocol/state/session.rs, wacore/libsignal/src/protocol/session.rs, wacore/libsignal/src/protocol/session_cipher.rs, wacore/libsignal/tests/counter_lease.rs
Session records reserve sender-chain counter batches, serialize reservation ceilings, fast-forward leased counters during reload or promotion, and validate crash-recovery behavior.
Wire-gated protocol and cache state
wacore/libsignal/src/protocol/group_cipher.rs, wacore/libsignal/src/protocol/sender_keys.rs, wacore/src/store/signal_cache.rs, wacore/src/store/in_memory.rs
Outbound sender-key advances are marked wire-gated, while pending session and sender-key gates remain active until successful batch persistence; test hooks cover sender-key write failures.
Pre-wire persistence integration
src/client/adapters.rs, src/features/signal.rs, src/send/mod.rs, src/signal_flush.rs, tests/e2e/tests/session_reuse.rs
Encryption and send paths synchronously flush pending durability gates, schedule coalesced flushes for covered advances, and test persistence failures, lease coverage, and monotonic resume positions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SignalEncrypt
  participant Client
  participant SignalStoreCache
  participant Backend
  SignalEncrypt->>Client: persist_signal_state_pre_wire
  Client->>SignalStoreCache: needs_pre_wire_flush
  alt Gate pending
    Client->>SignalStoreCache: flush_signal_cache_batch_safe
    SignalStoreCache->>Backend: Persist session or sender-key state
  else Lease-covered advance
    Client->>Client: schedule_signal_flush
  end
  Client-->>SignalEncrypt: Continue outbound transmission
Loading

Possibly related PRs

Suggested labels: performance

Suggested reviewers: greptile-apps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: batching outbound counter leases to reduce per-send flushing.
Description check ✅ Passed The description matches the PR: it explains batched counter leases, pre-wire persistence, sender-key handling, and the test coverage.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sender-chain-counter-lease

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR batches durable Signal sender-counter reservations to reduce persistence work on outbound messages. The main changes are:

  • Adds serialized counter leases with validation and restore-time fast-forwarding.
  • Uses coalesced write-behind for sends covered by an existing durable lease.
  • Keeps synchronous pre-wire persistence for new leases and group sender-key advances.
  • Adds tests for reloads, state promotion, storage failures, and durable resume positions.

Confidence Score: 5/5

This looks safe to merge.

  • No distinct blocking issue remains after applying the follow-up scope.
  • The known global durability-gate behavior is unchanged and does not support a separate new finding.

Important Files Changed

Filename Overview
src/client/adapters.rs Adds the pre-wire durability decision between synchronous flushing and coalesced persistence.
src/send/mod.rs Routes message and status sends through the new durability gate.
wacore/src/store/signal_cache.rs Tracks pending session reservations and outbound sender-key gates across cache flushes.
wacore/libsignal/src/protocol/state/session.rs Serializes counter reservations and fast-forwards restored or promoted sender chains.
wacore/libsignal/src/protocol/session_cipher.rs Extends the sender-counter reservation when encryption reaches the current limit.
wacore/libsignal/src/protocol/group_cipher.rs Marks outbound group sender-key advances for synchronous pre-wire persistence.
tests/e2e/tests/session_reuse.rs Updates end-to-end coverage for leased counters and storage failure behavior.

Reviews (3): Last reviewed commit: "fix(signal): fail closed on an unreadabl..." | Re-trigger Greptile

Comment thread src/client/adapters.rs
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.86 MiB 10.87 MiB +8.97 KiB (+0.08%) 🔺
bin .text 8.86 MiB 8.86 MiB +8.56 KiB (+0.09%) 🔺
bin allocated (text+data+bss) 10.86 MiB 10.86 MiB +8.09 KiB (+0.07%) 🔺
llvm-lines wacore 505,737 505,894 +157 (+0.03%) 🔺
llvm-lines wacore copies 17,371 17,374 +3 (+0.02%) 🔺
llvm-lines whatsapp-rust lib 772,606 773,047 +441 (+0.06%) 🔺
llvm-lines whatsapp-rust lib copies 25,079 25,094 +15 (+0.06%) 🔺
deps crates (Cargo.lock) 472 472 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.68 MiB 1.68 MiB +3.63 KiB (+0.21%) 🔺
.text wacore 527.79 KiB 528.61 KiB +834 B (+0.15%) 🔺
.text wacore_binary 148.45 KiB 148.45 KiB 0
.text wacore_libsignal 179.42 KiB 183.68 KiB +4.26 KiB (+2.37%) ⚠️
.text wacore_appstate 158.25 KiB 158.25 KiB 0
.text wacore_noise 26.05 KiB 26.05 KiB 0
.text waproto 1.60 MiB 1.60 MiB -202 B (-0.01%) 🔽
.text whatsapp_rust_sqlite_storage 513.00 KiB 513.00 KiB 0
.text whatsapp_rust_tokio_transport 43.79 KiB 43.79 KiB 0
.text whatsapp_rust_ureq_http_client 10.47 KiB 10.47 KiB 0
.text std 1.01 MiB 1.01 MiB +25 B (+0.00%) 🔺
.text other deps 2.95 MiB 2.95 MiB 0
Top movers (cargo-bloat attribution)
Crate main PR Δ
wacore_libsignal 179.42 KiB 183.68 KiB +4.26 KiB (+2.37%)
whatsapp_rust 1.68 MiB 1.68 MiB +3.63 KiB (+0.21%)
rustix 1.88 KiB 191 B -1.69 KiB (-90.08%)
buffa_descriptor 2.98 KiB 4.67 KiB +1.69 KiB (+56.87%)

Baseline: 9476f3752 (latest main run) · Head: a3707048d · Graphs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 13 files

Confidence score: 1/5

  • wacore/libsignal/src/protocol/session_cipher.rs has a high-confidence downgrade path where a crash plus rollback can reuse a sender-chain counter because older readers ignore the lease field, which can cause cryptographic state reuse and message security/integrity regressions if merged as-is. Add a downgrade-safe migration or explicit rejection of stale snapshots on downgrade (and verify with a crash→downgrade test) before merging.

You’re at about 98% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="wacore/libsignal/src/protocol/session_cipher.rs">

<violation number="1" location="wacore/libsignal/src/protocol/session_cipher.rs:249">
P0: A crash followed by library downgrade can reuse a sender-chain counter: older readers ignore the lease field and resume from the stale chain snapshot. Add a downgrade-safe migration/rejection mechanism (or block this format from being opened by older builds) before emitting leased records.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread wacore/libsignal/src/protocol/session_cipher.rs
Comment thread wacore/libsignal/tests/counter_lease.rs
Comment thread wacore/src/store/signal_cache.rs
Comment thread wacore/libsignal/src/protocol/state/session.rs Outdated
Comment thread src/client/adapters.rs
Comment thread wacore/libsignal/src/protocol/sender_keys.rs
Comment thread src/client/adapters.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@wacore/src/store/signal_cache.rs`:
- Around line 2043-2067: Add a sender-key failed-flush test alongside
only_encrypt_marked_sender_keys_gate_the_wire that marks an outbound
SenderKeyRecord as wire-gated, uses a backend configured to fail the sender-key
write, asserts flush returns an error and needs_pre_wire_flush() remains true,
then verifies a subsequent successful flush clears the gate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d4d275e4-bd20-4c2d-9082-9610922f03dc

📥 Commits

Reviewing files that changed from the base of the PR and between 9476f37 and 6d001d5.

📒 Files selected for processing (13)
  • src/client/adapters.rs
  • src/features/signal.rs
  • src/send/mod.rs
  • src/signal_flush.rs
  • tests/e2e/tests/session_reuse.rs
  • wacore/libsignal/src/protocol/consts.rs
  • wacore/libsignal/src/protocol/group_cipher.rs
  • wacore/libsignal/src/protocol/sender_keys.rs
  • wacore/libsignal/src/protocol/session.rs
  • wacore/libsignal/src/protocol/session_cipher.rs
  • wacore/libsignal/src/protocol/state/session.rs
  • wacore/libsignal/tests/counter_lease.rs
  • wacore/src/store/signal_cache.rs

Comment thread wacore/src/store/signal_cache.rs
Mirrors the session-lease failed-flush test: a flush that errors writing the
chain advance must leave the gate closed. Adds the matching
fail_sender_key_writes hook to the in-memory backend.
…ate's edges

Review follow-ups on the counter-lease PR.

Real fix — decode_reserved_index skipped a field-100 tag it could not read
(wrong wire type), and let a duplicate win under last-one-wins. Both paths
silently yield reservation 0, which disables the load-time fast-forward and
re-enables every counter the lease had already spent: fail-open in exactly the
place the mechanism exists to prevent reuse. Both now fail the load, which is
recoverable where a reused (key, IV) pair is not.

Documented, not changed:
- clear() dropping a pending gate is safe only because no clear can race a
  live wire: every caller runs before the transport exists (connect) or after
  cleanup_connection_state took the noise socket, and a send resolves that
  socket after its pre-wire gate — so a send that sees the cleared set fails
  NotConnected instead of reaching a peer. That ordering is load-bearing and
  was undocumented; moving a clear ahead of the socket teardown would
  reintroduce reuse for lease-raising sends.
- the pre-wire gate is a global predicate, so another session's unpersisted
  lease can still make this send flush (and its failure abort this send).
  Erring toward an extra flush is the safe direction; scoping it to the
  stanza's addresses would need them threaded back up the send path.
- downgrading the library across a crash resumes a leased chain from its stale
  snapshot: already-released readers skip unknown fields by definition, so no
  code here can gate it. Release-note constraint.

set_states_for_testing now resets wire_gated with the states it replaces.

Adds guards for both fixture and format: peers_generate_independent_keys pins
that make_rng seeds from OS entropy (a deterministic fixture would make the
crash assertions pass for the wrong reason), and the decode test covers the
wrong-wire-type and duplicate-field cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant