Skip to content

Commit e01f593

Browse files
lklimekclaude
andcommitted
fix(consensus): end a catch-up pass that cannot send
beginCatchupAttempt charged a budget slot before loadMeta, the part-set header check and loadPart, none of which ended the pass when they failed. The peer supplies both the missing bit-array a pass is budgeted from and the part-set header those checks run against, and ValidateBasic accepts any well-formed pair, so it could open a maximum-size pass under a header matching no stored block. That bought two things. A pass that can never send charged a block-store read and an error log to every gossip tick for its whole budget - roughly 160s at the production 100ms cadence - and since the retry deadline was armed when the pass opened, it had long expired by the time the budget ran out, so the next pass began immediately and the interval never throttled anything. The same inflated budget also survived the peer replacing its state, at the same height, with the stored block's real header and a single missing part: the budget is deliberately not re-derived once a pass is open, so the remainder landed as duplicate sends of that one part. Move the send into sendCatchupBlockPart and abandon the pass, arming a fresh retry deadline, whenever it reports that no part reached the peer. A pass now costs at most one failed attempt per interval, and only a pass that got as far as sending keeps its budget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoXmbFrVf1BqVW1BHZv6Va
1 parent da7ddea commit e01f593

2 files changed

Lines changed: 140 additions & 8 deletions

File tree

internal/consensus/gossiper.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package consensus
44

55
import (
66
"context"
7+
"errors"
78
"fmt"
89
"time"
910

@@ -191,28 +192,41 @@ func (g *msgGossiper) GossipBlockPartsForCatchup(
191192
if !g.beginCatchupAttempt(prs) {
192193
return
193194
}
194-
index, ok := prs.ProposalBlockParts.Not().PickRandom()
195-
if !ok {
196-
return
195+
if err := g.sendCatchupBlockPart(ctx, prs); err != nil {
196+
// The peer controls both the budget a pass opens with and the inputs that
197+
// fail here, so a pass that cannot produce a send is abandoned rather than
198+
// charged one slot per tick.
199+
g.endCatchupPass()
197200
}
201+
}
202+
203+
// sendCatchupBlockPart sends the peer one part of its incomplete block, drawn at
204+
// random from the parts it reports missing. A non-nil error reports only that no
205+
// part reached the peer; the caller is not expected to inspect it.
206+
func (g *msgGossiper) sendCatchupBlockPart(ctx context.Context, prs *cstypes.PeerRoundState) error {
198207
logger := g.logger.With([]any{
199208
"height", prs.Height,
200209
"round", prs.Round,
201210
})
211+
index, ok := prs.ProposalBlockParts.Not().PickRandom()
212+
if !ok {
213+
// The peer replaced its bit-array since the pass drew a budget from it.
214+
return errors.New("peer reports no missing block part")
215+
}
202216
meta, err := g.blockStore.loadMeta(prs.Height)
203217
if err != nil {
204218
logger.Error("couldn't find a block meta", "error", err)
205-
return
219+
return err
206220
}
207221
err = g.ensurePeerPartSetHeader(meta.BlockID.PartSetHeader, prs.ProposalBlockPartSetHeader)
208222
if err != nil {
209223
logger.Error("block and peer part-set headers do not match", "error", err)
210-
return
224+
return err
211225
}
212226
part, err := g.blockStore.loadPart(prs.Height, index)
213227
if err != nil {
214228
logger.Error("couldn't find a block part", "part_index", index, "error", err)
215-
return
229+
return err
216230
}
217231
// Catch-up gossip: do NOT optimistically record the part as delivered.
218232
//
@@ -227,13 +241,16 @@ func (g *msgGossiper) GossipBlockPartsForCatchup(
227241
err = g.syncProposalBlockPart(ctx, part, prs.Height, meta.Round, false)
228242
if err != nil {
229243
logger.Error("failed to sync proposal block part to the peer", "error", err)
244+
return err
230245
}
246+
return nil
231247
}
232248

233249
// beginCatchupAttempt draws a send from the current catch-up pass, reporting
234250
// false once the pass is spent and until its retry interval elapses. A pass is
235-
// worth one send per part the peer reported missing when it opened, so a peer
236-
// that dropped those parts is served again every interval while it stays behind.
251+
// worth at most one send per part the peer reported missing when it opened, so a
252+
// peer that dropped those parts is served again every interval while it stays
253+
// behind. Any attempt that fails ends the pass early, via endCatchupPass.
237254
func (g *msgGossiper) beginCatchupAttempt(prs *cstypes.PeerRoundState) bool {
238255
if prs.ProposalBlockParts == nil {
239256
return false
@@ -267,6 +284,15 @@ func (g *msgGossiper) beginCatchupAttempt(prs *cstypes.PeerRoundState) bool {
267284
return true
268285
}
269286

287+
// endCatchupPass abandons the rest of the current pass and starts its retry
288+
// interval now. Without it a pass that cannot send costs a block-store read and
289+
// an error log on every gossip tick until its budget runs out, by which point
290+
// the deadline armed when it opened has long expired.
291+
func (g *msgGossiper) endCatchupPass() {
292+
g.catchupRemaining = 0
293+
g.catchupRetryAt = g.clock.Now().Add(catchupResendInterval)
294+
}
295+
270296
// GossipCommit sends a commit message to the peer
271297
func (g *msgGossiper) GossipCommit(ctx context.Context, rs cstypes.RoundState, prs *cstypes.PeerRoundState) {
272298
if prs.HasCommit {

internal/consensus/gossiper_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
p2pmocks "github.com/dashpay/tenderdash/internal/p2p/mocks"
1919
"github.com/dashpay/tenderdash/internal/state/mocks"
2020
"github.com/dashpay/tenderdash/internal/test/factory"
21+
"github.com/dashpay/tenderdash/libs/bits"
2122
"github.com/dashpay/tenderdash/libs/log"
2223
tmrand "github.com/dashpay/tenderdash/libs/rand"
2324
tmcons "github.com/dashpay/tenderdash/proto/tendermint/consensus"
@@ -682,6 +683,111 @@ func (suite *GossiperSuiteTest) TestGossipBlockPartsForCatchupBudgetsMissingPart
682683
suite.Require().Equal(2, sends, "the missing part is retried once the interval elapses")
683684
}
684685

686+
// TestGossipBlockPartsForCatchupMismatchedHeaderEndsPass covers a pass that can
687+
// never produce a send. The peer supplies both the part-set header and the size
688+
// of the missing bit-array, and only their encoding is validated, so a pass
689+
// opened on a maximum-size array with a header matching no stored block would
690+
// otherwise charge a block-store read and an error log to every gossip tick for
691+
// the whole budget - and, its deadline having been armed when the pass opened,
692+
// reopen immediately afterwards.
693+
func (suite *GossiperSuiteTest) TestGossipBlockPartsForCatchupMismatchedHeaderEndsPass() {
694+
ctx, cancel := context.WithCancel(context.Background())
695+
defer cancel()
696+
697+
stored := types.NewPartSetFromData(tmrand.Bytes(100), 100)
698+
blockMeta := types.BlockMeta{BlockID: types.BlockID{PartSetHeader: stored.Header()}}
699+
700+
// Largest pass the peer can ask for: every bit of a maximum-length array
701+
// reported missing, under a header no stored block can match.
702+
bogus := types.NewPartSetFromData(tmrand.Bytes(100), 100)
703+
suite.Require().False(bogus.Header().Equals(stored.Header()))
704+
suite.ps.PRS = cstypes.PeerRoundState{
705+
Height: 999,
706+
Round: 0,
707+
ProposalBlockParts: bits.NewBitArray(int(types.MaxBlockPartsCount)),
708+
ProposalBlockPartSetHeader: bogus.Header(),
709+
}
710+
711+
// The data channel has no expectations: a mismatched header must send nothing.
712+
metaReads := 0
713+
suite.blockStore.On("LoadBlockMeta", int64(999)).
714+
Run(func(_ mock.Arguments) { metaReads++ }).
715+
Return(&blockMeta)
716+
717+
for range 50 {
718+
suite.gossiper.GossipBlockPartsForCatchup(ctx, cstypes.RoundState{}, suite.ps.GetRoundState())
719+
}
720+
suite.Require().Equal(1, metaReads,
721+
"a pass that cannot send must cost one attempt per interval, not one per tick")
722+
723+
suite.clock.Advance(catchupResendInterval)
724+
for range 50 {
725+
suite.gossiper.GossipBlockPartsForCatchup(ctx, cstypes.RoundState{}, suite.ps.GetRoundState())
726+
}
727+
suite.Require().Equal(2, metaReads, "each elapsed interval allows exactly one further attempt")
728+
}
729+
730+
// TestGossipBlockPartsForCatchupMalformedHeaderDoesNotFundSends pins the budget
731+
// to a pass that got as far as sending. A pass is deliberately not re-derived
732+
// from the peer's bit-array once open, so a budget opened against an unusable
733+
// header would otherwise stay spendable when the peer replaces its state, at the
734+
// same height, with the stored block's real header and a single missing part:
735+
// the whole inflated budget then lands as duplicate sends of that one part.
736+
func (suite *GossiperSuiteTest) TestGossipBlockPartsForCatchupMalformedHeaderDoesNotFundSends() {
737+
ctx, cancel := context.WithCancel(context.Background())
738+
defer cancel()
739+
740+
partSet := types.NewPartSetFromData(tmrand.Bytes(100), 100)
741+
suite.Require().Equal(uint32(1), partSet.Total())
742+
blockMeta := types.BlockMeta{BlockID: types.BlockID{PartSetHeader: partSet.Header()}}
743+
744+
bogus := types.NewPartSetFromData(tmrand.Bytes(100), 100)
745+
suite.Require().False(bogus.Header().Equals(partSet.Header()))
746+
747+
// Open the largest pass available, under a header that cannot match.
748+
suite.ps.PRS = cstypes.PeerRoundState{
749+
Height: 999,
750+
Round: 0,
751+
ProposalBlockParts: bits.NewBitArray(int(types.MaxBlockPartsCount)),
752+
ProposalBlockPartSetHeader: bogus.Header(),
753+
}
754+
755+
suite.blockStore.On("LoadBlockMeta", int64(999)).Return(&blockMeta)
756+
suite.blockStore.On("LoadBlockPart", int64(999), 0).Return(partSet.GetPart(0))
757+
758+
sends := 0
759+
suite.dataCh.On("Send", mock.Anything, mock.Anything).
760+
Run(func(args mock.Arguments) {
761+
env, ok := args.Get(1).(p2p.Envelope)
762+
if !ok {
763+
return
764+
}
765+
if _, ok := env.Message.(*tmcons.BlockPart); ok {
766+
sends++
767+
}
768+
}).
769+
Return(nil)
770+
771+
suite.gossiper.GossipBlockPartsForCatchup(ctx, cstypes.RoundState{}, suite.ps.GetRoundState())
772+
773+
// Same height, now with the real header and one part outstanding.
774+
suite.ps.PRS.ProposalBlockPartSetHeader = partSet.Header()
775+
suite.ps.PRS.ProposalBlockParts = partSet.BitArray().Not()
776+
777+
for range 50 {
778+
suite.gossiper.GossipBlockPartsForCatchup(ctx, cstypes.RoundState{}, suite.ps.GetRoundState())
779+
}
780+
suite.Require().Equal(0, sends,
781+
"a budget opened against an unusable header must not fund sends after the peer swaps it out")
782+
783+
// The next interval opens a fresh pass, budgeted on what the peer now reports.
784+
suite.clock.Advance(catchupResendInterval)
785+
for range 50 {
786+
suite.gossiper.GossipBlockPartsForCatchup(ctx, cstypes.RoundState{}, suite.ps.GetRoundState())
787+
}
788+
suite.Require().Equal(1, sends, "the fresh pass is worth the single part the peer now reports missing")
789+
}
790+
685791
// TestGossipBlockPartsForCatchupPeerHasEveryPart covers the peer reporting a
686792
// complete part set: there is nothing to send, and no pass may be opened.
687793
func (suite *GossiperSuiteTest) TestGossipBlockPartsForCatchupPeerHasEveryPart() {

0 commit comments

Comments
 (0)