Skip to content

fix(consensus): resend same-round votes the peer never acknowledged - #1599

Draft
Claudius-Maginificent wants to merge 9 commits into
devfrom
fix/consensus-vote-redelivery
Draft

Claudius-Maginificent wants to merge 9 commits into
devfrom
fix/consensus-vote-redelivery

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

TL;DR: Validators now resend a consensus vote that a peer never confirmed receiving, so a vote lost in transit can no longer stall a round.

User story

As a validator operator, I want the network to recover by itself when consensus votes get lost during congestion, to achieve continued block production without manual restarts.

Scenario

Base flow

In each round, every validator votes and passes the votes it knows about on to its peers. A round moves on once a validator has seen votes from more than two-thirds of the voting power.

Actual behavior

A node treats a vote as delivered to a peer the moment it hands the vote to the network layer, and never sends that vote to that peer again. If the vote is dropped later on (for example, by an overloaded outgoing queue or by the receiver's rate limit), the peer never gets it. When only honest validators are voting and some of their votes are lost this way, no validator sees enough votes to start the timer that would move the round on. The round stalls until a node reconnects or restarts.

Expected behavior

A vote that the peer has not confirmed is offered again after a short delay, so lost votes are eventually delivered and the round completes.

Detailed discussion

What was done

  • Production vote gossip is optimistic (optimistic: true in gossip_peer_worker.go). GossipVote marks the vote in the peer's bit array on enqueue, and PeerState.PickVoteToSend never offers a marked vote again.
  • Delivery can still fail after enqueue:
    • internal/p2p/rqueue.go drops envelopes above maxSize;
    • receiver-side admission limits can drop messages.
  • Prevote and Precommit start no timer without +2/3-any, and VoteSetMaj23/VoteSetBits reconciliation starts only from a local TwoThirdsMajority(). The case of honest validators only, with fewer than 67 votes visible on each side, therefore had no recovery path.
  • PeerState now distinguishes a vote that was sent from one that was acknowledged:
    • SetVoteSent, used by the gossiper, sets the peer bit as before and records the vote for the peer's current (height, round). It records the vote only if the bit was still clear; a set bit means an acknowledgement already landed during the unlocked send.
    • Acknowledgements come from HasVote, from the peer's own vote, and from VoteSetBits, and they drop the entry.
    • ReofferUnackedVotes, called at the start of GossipVote, clears the peer bit of unacknowledged votes so ordinary gossip offers them again.
    • The first resend happens after one interval. The wait doubles on each re-offer, capped at 3 doublings: 2, 4, 8, 16, 16 … s with the default PeerQueryMaj23SleepDuration of 2 s.
    • Re-offers never stop entirely, because the receiver never acknowledges a duplicate vote and giving up would reintroduce permanent loss.
    • All tracking is dropped when the peer moves to another round or height.
  • Healthy path: no extra messages while an acknowledgement is merely in flight. Memory is about 2 × N × 32 B per peer, where N is the local validator set size (about 25 KB at N = 400).
  • Resends are paced by ordinary vote gossip, at most one vote per PeerGossipSleepDuration, and receivers reject duplicates cheaply before signature verification.
  • The interval is passed to NewPeerState via the new WithVoteResendInterval option in reactor.peerUp. peer-query-maj23-sleep-duration is documented as also pacing vote redelivery (config comment, TOML template, docs/nodes/configuration.md).
  • Receiver cost of votes it rejects. A rejected vote is never acknowledged, so it is re-offered for the rest of the round. Copies are still fully verified, because fix(consensus): deliver and process a polka block for a validator left in its round #1600 needs a precommit rejected before the block was processed to be accepted on a later copy. Logging and evidence are kept cheap:
    • BlockExecutor.VerifyVoteExtension returns the new sentinel sm.ErrVoteExtensionRejected, which is classified as peer-floodable, so each copy logs at Debug.
    • When the application refuses the extensions of a block this node has processed, the first refusal per (height, round, validator, block) logs Warn and later copies Debug. Refusals of a block not processed yet are a normal transient state of a lagging node and log at Debug only.
    • A conflicting vote is reported to the evidence pool and logged at Error once per height; later copies log at Debug and are not appended again. The evidence pool already deduplicates when it forms evidence.
    • This memory is capped at 4096 entries per height. Past the cap every copy is reported as before, so no evidence is dropped.
  • The feat(consensus)!: hand a silent proposer's idle round to the next proposer #1572 comments that said a lost vote is never sent again now describe the redelivery.

Testing

  • New tests, each failing on dev or on the first commit before its fix:
    • TestGossiper/TestGossipVoteRedeliversUnacknowledged/{prevote,precommit}
    • TestGossiper/TestGossipVoteRedeliveryStopsWhenPeerMovesOn; a mutation check confirmed it fails when the stale-round reset is removed
    • TestVoteRedeliveryWindows
    • TestVoteRedeliveryIgnoresAckBeforeSent
    • TestVoteRedeliveryBacksOff
    • TestVoteRedeliveryVoteSetBitsAck
    • TestVoteRedeliveryDropsBookkeepingOnHeightChange (mutation-checked)
    • TestRejectedVoteExtensionLogsOnceThenDebug (unprocessed → processed block, other block, other round; mutation-checked)
    • TestAddVoteErrorMwReportsEachConflictOnce, TestRejectedVotesBounded, an added TestIsPeerFloodableError case
  • Also passing: TestSetHasVote, TestApplyHasVoteMessage, TestPeerState, TestReactorBasic, TestReactorWithEvidence, TestReactorVotingPowerChange, TestReactorValidatorSetChanges, and the Maj23/HasVote/VoteSetBits tests.
  • -race runs of the gossiper and redelivery tests pass. make lint reports 0 issues.
  • A full independent review (security, design/reuse, correctness) and follow-up reviews of the fixes found only low-severity items. All in-scope items are fixed and re-verified. Two are tracked separately: unifying this mechanism with feat(consensus)!: hand a silent proposer's idle round to the next proposer #1572's resend handler, and a previous-height vote-bit bug (fix(consensus): stop a previous-height precommit marking the peer's current-height votes #1601).

Breaking changes

None.

Prior work

🤖 Generated with Claude Code

https://claude.ai/code/session_01WunYYgcoPDNh3uebhUCwig

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 2 commits November 9, 2026 11:16
Vote gossip marks a vote as held by the peer the moment it is handed to the
transport, and nothing inside a round puts it back on offer: a copy dropped
after enqueue (router queue overflow, receiver over its allowance) is never
sent again. The only in-round repair, the VoteSetMaj23 exchange, needs one
side to already hold a +2/3 majority, so when the lost votes are exactly what
both sides lack to reach one, the round stalls with no timeout to fall back on
(prevote-wait starts only on HasTwoThirdsAny).

PeerState now tracks current-round prevotes and precommits that were sent but
not confirmed by the peer (HasVote, VoteSetBits, or the peer's own copy), in
two redelivery windows rotated once per interval. A vote still unconfirmed
after a full window is unmarked so regular gossip offers it again; the window
scheme means nothing is resent while its acknowledgement is merely in flight,
and bookkeeping of a round the peer has left is dropped. The interval follows
PeerQueryMaj23SleepDuration, the cadence of the existing periodic repair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An acknowledgement applied between picking a vote and recording it as sent
was discarded: SetVoteSent tracked the vote regardless, so two rotations later
it was resent — and a resend can never be acknowledged, since the receiver
drops the duplicate before any HasVote is published. The vote then cycled for
the rest of the round. The same loop followed a HasVote lost in transit.

SetVoteSent now tracks a vote only when the peer's bit was still clear at the
time of the call; the per-peer gossip goroutine picks votes only while their
bit is clear, so a set bit means an ack landed in that window. Marking the bit
and acknowledging are split (setVoteBit / setHasVote) so the sent path cannot
ack itself. Tracking is per vote, with the send time and a re-offer count:
each re-offer doubles the wait before the next, capped at voteResendMaxBackoff
doublings, so a vote whose ack was lost is resent at a falling rate rather
than every interval, and is never given up on.

Tests: cover the ack-before-sent interleaving, the backoff schedule, the
VoteSetBits acknowledgement path, and make the moved-on test reach the
stale-round reset (round-0 bookkeeping must not clear the peer's POL bits that
now stand for round 0).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@thepastaclaw

thepastaclaw commented Nov 9, 2026 •

Copy link
Copy Markdown

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit 9856707. Normal review starts when eligible; priority review starts as soon as a slot is available.

…elivery

# Conflicts:
#	internal/consensus/peer_state.go
lklimek and others added 4 commits November 9, 2026 14:09
A precommit the application refuses, or a vote conflicting with one already
held, is never stored, so no HasVote acknowledges it and its sender re-offers
it for the rest of the round. Each copy logged at Error and, for a conflict,
was appended to the evidence pool's buffer again.

BlockExecutor.VerifyVoteExtension now returns sm.ErrVoteExtensionRejected,
classified as peer-floodable so copies log at Debug; the first rejection of a
vote at its height is logged at Warn with whether the voted block is processed.
A conflicting vote is reported to the evidence pool and logged at Error once
per height; copies log at Debug.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…round

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e resend

The round vote resend handler's comments said a vote lost after the channel
took it is never sent again; it is re-offered after a redelivery interval,
with backoff. peer-query-maj23-sleep-duration also paces that redelivery,
which its config comment, TOML template and docs now say, and the
WithVoteResendInterval comment names where the interval comes from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lklimek and others added 2 commits November 9, 2026 14:23
A precommit the application refuses for a block this node has not processed
is expected and now logs at Debug only; the first refusal of a processed
block logs at Warn, counted from when the block was processed, so an early
refusal cannot hide a later disagreement over the extensions.

The rejected-vote memory is capped: nothing stored bounds a rejected vote and
a validator key signs one for any round and block, so past the cap a vote is
reported on every copy rather than remembered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…arning

A refused precommit for another block of the processed round, or for the
processed block in another round, must not warn: each case fails if its guard
is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants