Skip to content

fix(consensus): bound catch-up block-part resends and restore round timeouts in reactor test - #1418

Open
lklimek wants to merge 21 commits into
v1.7-devfrom
fix/flaky-reactor-validatorset-timing
Open

fix(consensus): bound catch-up block-part resends and restore round timeouts in reactor test#1418
lklimek wants to merge 21 commits into
v1.7-devfrom
fix/flaky-reactor-validatorset-timing

Conversation

@lklimek

@lklimek lklimek commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

TL;DR: Fixes a consensus bug where a node helping a peer catch up would flood it with block parts it had already moved past, starving the current round — and fixes the CI test that had been hanging because of it.

Detailed discussion

Two commits. The first is internal test infrastructure with no user-observable behaviour change; the second is a production consensus fix. User story and Scenario are omitted because the user-visible effect is limited to the second commit and is fully described below.

Issue being fixed or feature implemented

Partially addresses #1405. TestReactorValidatorSetChanges intermittently hangs for its full 2-minute budget, most recently on #1417 (a pure Go-toolchain bump): run 32835423456.

It does not close #1405, which also names TestRouter_EvictPeers in internal/p2p — a separate matter (a hardcoded 1-second wait in p2ptest.RequireUpdate over a mocked transport), sharing no mechanism with this test.

Root cause 1 — the test harness disabled consensus recovery

TestReactorValidatorSetChanges drove its 7-node network with newMockTickerFunc(true). mockTicker.ScheduleTimeout forwards only RoundStepNewHeight timeouts, and with onlyOnce=true only the first one; every other scheduled timeout is discarded. The network ran with no timeoutPropose, no timeoutPrevoteWait/timeoutPrecommitWait — so round changes were impossible — and, after the first height, no timeoutNewHeight, so a node that assembled a commit itself never started the next height.

With recovery disabled, any perturbation wedged the network permanently. Both observed CI shapes follow: a proposal delayed past MessageDelay + Precision makes every validator prevote nil and the round change that would recover never fires; separately, a node that applies a commit itself sits at RoundStepNewHeight forever, and when it is the next proposer nobody proposes and nobody times out waiting.

This also explains why #1204 did not fix the flake — it introduced the mock ticker, reverting the deliberate move to a real ticker made by chore(consensus): stabilize consensus algorithm (#284).

Root cause 2 — unbounded catch-up block-part resends

Restoring round changes exposed a second, pre-existing defect. GossipBlockPartsForCatchup never marks catch-up parts as delivered, so prs.ProposalBlockParts.Not().PickRandom() keeps finding the same index missing and the part is re-sent on every gossip tick. The loop ends only when our own view of the peer's height advances — and the traffic it generates delays exactly the NewRoundStep messages that would advance it.

Measured in the reactor test: 1091 height-11 block parts pushed at a node that had already reached height 12, every one discarded, against 9 completed proposal blocks in the same window. The live round cannot assemble its proposal, every validator prevotes nil, and the height churns rounds until the deadline. The same loop drives the part-set-header mismatch branch, which logs at error level every tick without sending anything.

This was not caused by the first commit — before it, the network froze at round 0 with every node on the same height, so no peer was ever "behind" and the catch-up path never engaged.

What was done

  1. internal/consensus/reactor_test.gotickerFun: newTickerFunc() restores real round timeouts, and Timeout.Propose/Timeout.Vote are raised to 5s/2s. Round timeouts only fire when the network stalls, so larger values cost nothing on the healthy path; the previous values made a starved runner abandon rounds it would otherwise have completed.
  2. internal/consensus/gossiper.go — catch-up is bounded to one part-set pass per catchupResendInterval (500ms). A pass spends one send per part; once complete, further ticks are skipped until the interval elapses, then a new pass begins.

The bound preserves #1365's guarantee. A peer that silently dropped its parts is still served repeatedly for as long as it stays behind, so the wedge that PR fixed stays fixed; only a peer we believe to be lagging is cheap. Parts are still never marked delivered, which keeps that guarantee independent of PeerState bookkeeping — deliberately, because SetHasProposalBlockPart ignores updates whose round differs from the peer's current round, and catch-up passes the committed block's round.

Rejected on measurement: widening the test's Synchrony params (no benefit), pacing block production via CreateEmptyBlocksInterval (worse, 4/18), and simply lowering the gossip cadence (5ms → 25ms → 100ms moved 2/16 → 7/16 → 5/16, confirming the cure is stopping after a pass, not sending more slowly).

How Has This Been Tested?

Locally, go test -race, 16-core host. N concurrent copies of the test binary on the same host, same bed throughout:

Load Before both fixes Ticker fix only Both fixes
4 concurrent copies 2/16 16/16
3 concurrent copies 10/18 10/18 18/18
unloaded, -count=10 9/10 20/20 10/10

The unloaded baseline reproduced the CI signature exactly — all seven nodes reporting a waitForAndValidateBlock deadline, one with subscription terminated by publisher.

Mechanistic confirmation for commit 1: counting round changes after the last committed block, the old code shows max_round = 0 and one entering new round per node — frozen, and no extra time would help. After it, 16–62 events reaching rounds 2 through 11.

Mechanistic confirmation for commit 2: wasted catch-up parts per batch drop from 164592 to 2845 (a full revert of the resend behaviour scores 2736, so the bound gives up almost nothing), and part-set mismatch errors fall from 73 to 4. The wedge precondition Commit came in before proposal occurs 632 times across those runs with zero wedges.

Unit tests: full TestGossiper and TestPeerGossipWorker suites pass. The two tests added by #1365 were updated rather than removed — they still assert that catch-up never marks parts delivered, and now additionally assert that no resend happens inside the interval and that one does once the clock advances past it. TestGossipBlockPartsForCatchup's table cases now advance the fake clock between cases, since they are independent occasions at the same height.

Breaking Changes

None.

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Follow-ups worth a look (deliberately out of scope)

  • internal/consensus/init_test.go sets deadlock.Opts.DeadlockTimeout = 5s. Under contention that detector fires on a benign lock wait and os.Exit(2)s the entire package test binary.
  • newMockTickerFunc(true) is still used by helper_test.go:216, replay_test.go:376 and byzantine_test.go:50. Same trap, unexamined.
  • AGENTS.md states CI runs tests with -p 1, but test-group-% in the Makefile does not pass it, so up to GOMAXPROCS package binaries run concurrently. That is the contention source behind this class of flake.
  • Within a catch-up pass, indices are still picked at random rather than iterated, so a pass need not cover every index of a multi-part block; successive passes converge. This is fix(consensus): resend catch-up block parts to prevent lagging-peer wedge #1365's own acknowledged tradeoff, unchanged here.

Prior work

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

…etChanges

The test drove its 7-node network with newMockTickerFunc(true), which
forwards only RoundStepNewHeight timeouts and only the first one; every
other scheduled timeout is discarded. That leaves the network without
timeoutPropose, without timeoutPrevoteWait/timeoutPrecommitWait and,
after the first height, without timeoutNewHeight — so consensus has no
way to change round and no way to start a height it committed without
receiving a peer commit. Any perturbation is therefore permanent, and
the test hangs until its 2-minute context deadline.

Both observed CI failure shapes follow from this. A proposal delayed
past MessageDelay+Precision makes every validator prevote nil, and the
round change that would recover never fires. Separately, a node that
applies a commit itself sits at RoundStepNewHeight forever; when it is
the next proposer, nobody proposes and nobody times out waiting.

Restore the real ticker, which "chore(consensus): stabilize consensus
algorithm (#284)" had already established before #1204 reverted it, and
give rounds enough wall clock to complete on a loaded runner. Round
timeouts only fire when the network stalls, so larger values cost
nothing on the healthy path, while 2s/1s makes a starved CI runner
abandon rounds it would have completed, leaving it to churn instead of
converge.

Verified with go test -race -count=10: the old code fails 1/10 on an
idle 16-core host, reproducing the CI signature exactly (all seven nodes
report a waitForAndValidateBlock deadline, one with "subscription
terminated by publisher"); the new code passes 20/20 over two such runs.
Under three concurrent copies of the test the pass rate rises from 10/18
to 14/15.

Refs #1405

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXmbFrVf1BqVW1BHZv6Va
@coderabbitai

coderabbitai Bot commented Aug 25, 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

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ff12688c-fd70-40de-9811-6043107f00f1

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

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.

@lklimek
lklimek marked this pull request as ready for review August 25, 2026 14:33
@thepastaclaw

thepastaclaw commented Aug 25, 2026

Copy link
Copy Markdown

⛔ Blockers found — Opus deferred (commit 4d92801)
Canonical validated blockers: 2

@thepastaclaw thepastaclaw 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.

Final validation — Codex/Sol only (Phase 2 disabled)

The change is confined to test infrastructure and correctly replaces the liveness-disabling one-shot mock ticker with independent production timeout tickers while increasing only test-local timeout parameters. The focused non-race test passed; the race run on a heavily contended host reached active round changes before its wall-clock deadline, consistent with the documented oversubscription limitation rather than the permanent ticker-induced stall this PR fixes. Source: reviewers gpt-5.6-sol (general and tenderdash-consensus-security); final verifier gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — tenderdash-consensus-security (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

…erval

GossipBlockPartsForCatchup never marks catch-up parts as delivered, so
prs.ProposalBlockParts.Not().PickRandom() keeps finding the same index
missing and the part is re-sent on every gossip tick. The loop ends only
when our own view of the peer's height advances, and the traffic it
generates delays exactly the NewRoundStep messages that would advance it.

Measured in a 7-node race-enabled reactor test: 1091 height-11 block
parts pushed at a node that had already reached height 12, every one
discarded, against 9 completed proposal blocks in the same window. The
live round cannot assemble its proposal, every validator prevotes nil,
and the height churns rounds until the test deadline. The same loop
drives the part-set-header mismatch branch, which logs at error level on
every tick without sending anything.

Bound it to one part-set pass per catchupResendInterval. A pass spends
one send per part; once complete, further ticks are skipped until the
interval elapses, then a new pass begins. A peer that silently dropped
its parts is therefore still served repeatedly for as long as it stays
behind, so the wedge fixed in #1365 stays fixed, while a peer we only
believe to be lagging costs one pass rather than one send per tick.
Parts are still never marked delivered, keeping that guarantee
independent of PeerState bookkeeping — which is unreliable here, since
SetHasProposalBlockPart ignores updates whose round differs from the
peer's current round and catch-up passes the committed block's round.

At 4 concurrent race-enabled copies of TestReactorValidatorSetChanges,
where the unbounded loop passes 2/16, this passes 16/16 — matching a
full revert of the resend behaviour while keeping its protection. Also
18/18 at 3 concurrent copies and 10/10 unloaded. Wasted catch-up parts
drop from 164592 to 2845 per batch (a revert scores 2736), and the
wedge precondition "Commit came in before proposal" occurs 632 times
across those runs with zero wedges.

The two tests from #1365 now assert the bounded contract: parts are
still never marked delivered, no resend occurs inside the interval, and
a resend follows once the clock advances past it.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXmbFrVf1BqVW1BHZv6Va
@lklimek lklimek changed the title test(consensus): restore real timeout ticker in TestReactorValidatorSetChanges fix(consensus): bound catch-up block-part resends and restore round timeouts in reactor test Aug 26, 2026

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

Restoring the real timeout ticker is sound, but the catch-up limiter does not enforce its intended resend interval for partially populated part sets. Because the pass budget uses the total part-set size, a peer with one missing part can still receive hundreds or thousands of duplicate copies before the 500 ms backoff begins, so changes are required.

Source: reviewers gpt-5.6-sol (general) and gpt-5.6-sol (tenderdash-consensus-security); final verifier gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — tenderdash-consensus-security (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `internal/consensus/gossiper.go`:
- [BLOCKING] internal/consensus/gossiper.go:243: Limit each catch-up pass to the peer's missing parts
  `beginCatchupAttempt` budgets attempts using the total bitmap size, while `PickRandom` at line 194 selects only unset bits and successful catch-up sends deliberately leave those bits unset. A valid partial bitmap containing N-1 set bits therefore causes the sole missing part to be selected and resent N times before the retry interval starts. This state is reachable from an untrusted peer because `NewValidBlockMessage.ValidateBasic` checks the bitmap's size but not its contents, and `ApplyNewValidBlockMessage` installs the supplied bitmap for the matching height. With the production 100 ms gossip cadence, a roughly 337-part default-size block permits about 34 seconds of uninterrupted duplicate sends before a 500 ms pause; the protocol maximum of 1,601 parts permits about 160 seconds. This leaves the flooding behavior that the PR is intended to stop almost unchanged for partial part sets. Count the currently missing bits instead, and add a regression case where every bit except one is set.

Comment thread internal/consensus/gossiper.go Outdated
…y size

beginCatchupAttempt sized a catch-up pass with
prs.ProposalBlockParts.Size(), the length of the peer's bit-array, while
GossipBlockPartsForCatchup draws indices from
prs.ProposalBlockParts.Not().PickRandom() — the unset bits alone. A peer
reporting all but one part present therefore had every draw in the pass
return that same index, so the one missing part was re-sent once per
entry in the array before the interval pause.

That bit-array arrives from the network. NewValidBlockMessage.
ValidateBasic checks only that its length equals
BlockPartSetHeader.Total, never the bit pattern, and
PeerState.ApplyNewValidBlockMessage installs it wholesale. A peer can
thus pick the amplification factor, up to MaxBlockPartsCount (1601)
sends of a single part per interval, which largely defeats the bound.

Budget on the number of parts the peer reports missing instead, and
return early when none are. CountTrueBits has no nil receiver guard,
unlike Size, so the nil bit-array is rejected first; the sole production
caller already checks it, but the previous Size call was nil-safe and
that property is worth keeping.

The bound can now only tighten: missing is at most the array length, and
is equal to it in the common case where a lagging peer holds none of the
parts, so catch-up throughput for genuinely lagging peers is unchanged.

Adds a regression case for a bit-array with a single unset bit, which
draws 3 sends per pass before this change and 1 after, plus a case for a
peer that reports the complete part set, which must open no pass at all.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXmbFrVf1BqVW1BHZv6Va

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

The current head fixes the prior static partial-bitmap amplification, and restoring the real timeout ticker remains appropriate. Two in-scope limiter flaws remain: a peer can extend an active catch-up pass by repeatedly replacing its bitmap, and a malformed header can trigger a maximum-length pass of storage reads and error logs without sending any part.

Source: Codex reviewers gpt-5.6-sol (general and tenderdash-consensus-security); final verifier claude-opus-4-6; orchestration-only openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — tenderdash-consensus-security (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `internal/consensus/gossiper.go`:
- [BLOCKING] internal/consensus/gossiper.go:250-262: Snapshot the missing-part budget for each catch-up pass
  `missing` is recalculated from the latest peer-supplied bitmap on every tick, while `catchupAttempts` and `catchupRetryAt` describe an already active pass. After N-1 attempts against N missing parts, the peer can replace the bitmap with one reporting N-1 missing parts. Because `catchupRetryAt` has not yet been initialized, `catchupAttempts >= missing` immediately resets the counter without waiting. Repeating this before each threshold permits N*(N-1)/2 attempts before the first retry interval; for a matching 1,601-part block that is 1,280,800 uninterrupted part-send attempts. This is reachable because `ApplyNewValidBlockMessage` replaces the bitmap wholesale at the peer's matching height and round. Snapshot the missing count when a pass opens, or track a remaining budget that cannot be increased or reset by bitmap updates until the retry interval expires, and add a regression test that decreases the reported missing count immediately before each threshold.
- [BLOCKING] internal/consensus/gossiper.go:207-210: End the catch-up pass after a failed header check
  `beginCatchupAttempt` consumes only one slot before the header check, so a failure here does not activate the retry interval until every reported missing-part slot has been consumed. A peer can install an otherwise valid `NewValidBlockMessage` at its catch-up height with an all-zero 1,601-bit array and an arbitrary 32-byte part-set hash. The mismatch then causes a block-meta lookup and an Error log on every default 100 ms gossip tick for about 160 seconds, followed by only a 500 ms pause, without sending a part or requiring another inbound message. Multiple peers can amplify this storage, CPU, and log work while contending with live consensus traffic. A metadata, header, or part-load failure should terminate the current pass and set its retry deadline immediately; add a regression test using a maximum-size missing bitmap and mismatched header.

Comment thread internal/consensus/gossiper.go Outdated
beginCatchupAttempt derived the pass threshold from
prs.ProposalBlockParts on every call while catchupAttempts and
catchupRetryAt persisted across calls, so the peer that owns that
bit-array could move the threshold out from under the counter. Two ways,
both reachable through NewValidBlockMessage, whose ValidateBasic checks
only the array's length:

Lowering the reported missing count to the number of sends already made
takes the "pass spent" branch while catchupRetryAt is still zero, because
that field was only assigned once the counter reached the earlier, larger
threshold. The wait is then measured against the zero time, the counter
resets and sending continues. Repeating one decrement at a time
telescopes into roughly N^2/2 sends before any wait applies.

Raising it afterwards is simpler still: the counter no longer meets the
threshold, so the branch holding the deadline check is skipped entirely
and a deadline already set is stepped over.

Draw from a remaining-budget counter instead. Opening a pass fixes both
the budget and the deadline together, and the budget only counts down, so
neither can be re-derived from anything the peer sends mid-pass. There is
no longer a state where a pass is spent but no deadline exists.

The height reset stays: ApplyNewRoundStepMessage drops any message that
does not advance the peer's height/round/step, so a peer cannot replay it
to reopen passes, and it can only keep doing so by advancing out of the
catch-up window. Removing it would instead make every legitimately
advancing peer wait an interval per height.

Sends per interval remain bounded by the block's real part count:
ensurePeerPartSetHeader rejects a fabricated part-set header, so a peer
inflating the count buys early exits rather than sends.

The regression test drops the reported count to the sends already made
and then raises it again, drawing 5 sends from a 3-send pass before this
change and 3 after. TestGossiper, TestPeerGossipWorker and
TestReactorValidatorSetChanges pass; 16/16 at 4 concurrent race-enabled
copies with wasted catch-up parts at 3002 against 2845 before, so the
storm bound is unchanged.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXmbFrVf1BqVW1BHZv6Va

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

Restoring production timeout tickers is sound, and the current head fixes the active-pass bitmap manipulation by snapshotting both the missing-part budget and retry deadline when a pass opens. One blocking issue remains: storage and validation failures leave that peer-controlled pass active, enabling continuous reads and error logs and allowing an inflated malformed-header budget to carry into repeated valid part sends. Source: Codex reviewers gpt-5.6-sol (general and tenderdash-consensus-security); final verifier claude-opus-4-6; openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — tenderdash-consensus-security (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `internal/consensus/gossiper.go`:
- [BLOCKING] internal/consensus/gossiper.go:202-215: End the catch-up pass after a failed header check
  `beginCatchupAttempt` consumes a peer-controlled budget slot before loading metadata or validating the part-set header, but each failure here returns without ending the active pass. An unauthenticated peer can announce a stored historical height and install a validly encoded 1,601-bit all-zero `NewValidBlockMessage` with an arbitrary valid-length hash; `ValidateBasic` accepts it and `ApplyNewValidBlockMessage` installs it. The default 100 ms gossip loop then performs a metadata read and emits an Error log on every tick for roughly 160 seconds. Because `catchupRetryAt` was set when the pass opened, it has already expired when the budget is exhausted, so another full pass starts immediately without an effective pause. The same state allows send amplification: after opening the 1,601-slot pass with a mismatched header, the peer can replace its same-height state with the stored block's real header and a bitmap reporting one missing part, and the unchanged remaining budget permits approximately 1,600 duplicate sends of that part. Terminate the active pass and establish a fresh retry deadline after metadata, header, or part-load failures, and add regressions for both a persistent maximum-size mismatch and a malformed-to-valid same-height replacement.

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
@Claudius-Maginificent

Copy link
Copy Markdown
Contributor

Catch-up pass now ends when it cannot send — fixed in e01f593

Answering the blocking finding carried in the review body of 5031834714 and 5039744991 (internal/consensus/gossiper.go:202-215). It never got an inline thread, so this is a PR-level reply — nothing to resolve.

Verified independently, not taken on faith. Both exploits reproduce. I wrote them as tests against the pushed head da7ddea37 before touching product code, and both went RED — each one saturating at my loop bound of 50 rather than at any budget, which is precisely the claim: the budget was not the limiter.

expected: 1   actual: 50   "a pass that cannot send must cost one attempt per interval, not one per tick"
expected: 0   actual: 50   "a budget opened against an unusable header must not fund sends after the peer swaps it out"

Fix. The send moved into sendCatchupBlockPart, which returns an error whenever no part reached the peer; GossipBlockPartsForCatchup then calls endCatchupPass(), zeroing the remaining budget and arming a fresh deadline from now. I covered one path beyond the three you named — a failing syncProposalBlockPart — on the same reasoning: the invariant worth holding is that only a pass which got as far as sending keeps its budget, not merely one that got as far as loading. The PickRandom miss is covered too, and it is the one honest case: the peer completed its part set between the budget being drawn and the index being picked.

Structuring it as a wrapper plus a helper rather than five calls to endCatchupPass() at five return sites is deliberate — a future early return cannot silently reintroduce this.

On exploit 1, being precise about what the fix is worth: the limiter's contract is one attempt per interval, so wasted reads and error logs drop from one per 100 ms tick to one per 500 ms — 5x, not elimination. That is the ceiling any per-interval bound can offer, and it matches your suggested direction. Exploit 2, the send amplification, is eliminated outright: the inflated budget dies on the first failed attempt, so the fresh pass is budgeted on the single part the peer actually reports.

One residual lever I checked rather than assumed. beginCatchupAttempt still resets its counters when prs.Height changes, which would bypass the new backoff. It is not exploitable: shouldPeerBeCaughtUp confines the catch-up path to blockStoreBase <= prs.Height < rs.Height, and ApplyNewRoundStepMessage drops any message with CompareHRS(...) <= 0, so a peer can walk that range once, upward, for one failed attempt per height — heights it must announce anyway. Left as is.

Verification. Targeted group green under -race (ok github.com/dashpay/tenderdash/internal/consensus 17.697s), go vet and gofmt clean.

The storm bed needed care this time: an unrelated tenant on the measurement host swung an unmodified da7ddea37 binary from 16/16 to 8/16, so a sequential before/after would have been worthless. I re-measured paired instead — both binaries running concurrently at the same tier, three rounds, 24 runs per arm:

pass wasted catch-up parts
da7ddea37 (control) 23/24 4013
e01f593b4 (this fix) 23/24 4121

The single failure hit both arms in the same round. No liveness or storm-bound regression.

Separately: both reviews also report _1 additional finding(s) omitted (not in diff)._ — distinct from this one, which was named in the prompt block and counted as blocking. Its text is not published in the review body, the inline comments or the gate comment, so I cannot act on it. Please surface it if it is meant to be actioned.

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

The current head fixes the prior failure-path pass leak, but successful passes still permit back-to-back replay and allow a previously opened budget to be concentrated on one remaining part. Both issues are in the limiter introduced by this PR and leave sustained duplicate catch-up traffic possible for large multipart blocks, so changes are still required.
Source: reviewers gpt-5.6-sol (Codex general and TenderDash consensus-security); final verifier claude-opus-4-6. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — tenderdash-consensus-security (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `internal/consensus/gossiper.go`:
- [BLOCKING] internal/consensus/gossiper.go:280-283: Start the retry interval when the pass finishes
  `catchupRetryAt` is armed when a pass opens, although catch-up sends only one part per data-gossip invocation. At the default 100 ms gossip cadence, a pass with six or more missing parts lasts at least the full 500 ms interval. When `catchupRemaining` reaches zero, the deadline has therefore already expired, and the next tick immediately opens another pass. Successful catch-up sends intentionally do not update the peer bitmap, so a peer that remains at the same height can receive block parts on every tick indefinitely, with no quiet interval between passes. This preserves the traffic-starvation behavior the limiter is intended to stop for multipart blocks. Arm the retry deadline when the final attempt is consumed or completed, and add a test that advances the fake clock by the production gossip cadence between sends and verifies a full retry interval after the pass ends.
- [BLOCKING] internal/consensus/gossiper.go:276-283: Tighten an active pass when the missing bitmap shrinks
  The pass snapshots its initial missing count, but `sendCatchupBlockPart` selects from the peer's latest bitmap on every tick. An untrusted peer can open a valid N-part pass reporting every part missing, allow one send to succeed, and then send another same-height `NewValidBlockMessage` with the same stored part-set header but only one unset bit. `ValidateBasic` accepts that replacement and `ApplyNewValidBlockMessage` installs it wholesale, while the unchanged `catchupRemaining` funds N-1 successful duplicate sends of the sole missing part. For a 1,601-part block, this permits roughly 160 seconds of uninterrupted duplicates at the default cadence, and `endCatchupPass` never runs because every send succeeds. Consume a snapshot of distinct missing indices or monotonically reduce the active remaining budget when the current missing count decreases; never increase or reopen the active pass from a replacement bitmap.

Comment thread internal/consensus/gossiper.go
Comment thread internal/consensus/gossiper.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses a consensus gossip liveness issue where catch-up block-part resends can become effectively unbounded (starving other consensus messages), and it stabilizes TestReactorValidatorSetChanges by restoring real consensus round timeouts in the test network harness.

Changes:

  • Restore real timeout ticker usage in TestReactorValidatorSetChanges and increase propose/vote timeouts to better tolerate race-detector and single-process contention.
  • Add per-peer catch-up pass accounting to bound catch-up block-part resends to at most one part-set “pass” per catchupResendInterval.
  • Update and extend gossiper unit tests to validate the bounded resend behavior and pass-budget invariants; wire a real clock into the peer gossiper.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
internal/consensus/reactor_test.go Switches the flaky reactor test back to a real timeout ticker and increases round timeouts to allow recovery under load.
internal/consensus/gossiper.go Implements bounded catch-up block-part resend passes using a clock and per-peer pass bookkeeping.
internal/consensus/gossiper_test.go Updates existing catch-up resend tests and adds new cases for pass budgeting, retry interval behavior, and malformed/mismatched peer state.
internal/consensus/gossip_peer_worker.go Initializes the new gossiper clock with a real clock in production wiring.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/consensus/gossiper.go Outdated
lklimek and others added 6 commits August 27, 2026 23:09
…l exploitable

Two gaps let a peer defeat the catch-up throttle this PR is adding:

- beginCatchupAttempt armed catchupRetryAt when a pass opened. A pass with
  enough missing parts to span more gossip ticks than catchupResendInterval
  takes longer than the interval to exhaust its budget, so the deadline had
  already elapsed by the time the pass finished - the next tick reopened a
  fresh pass with no quiet gap at all. Arm the deadline when the pass's last
  attempt is spent instead.

- The pass budget was fixed at open and never revisited downward. A peer
  fully controls ProposalBlockParts over the wire (NewValidBlockMessage.
  ValidateBasic checks only length, ApplyNewValidBlockMessage installs it
  wholesale) and can open a pass reporting many parts missing, then swap in
  a bit-array with only one unset bit — every remaining budgeted attempt
  then draws that same index, funding repeated duplicate sends of it.
  Clamp the remaining budget down whenever the peer's current missing count
  drops below it; never raise it back up.

Adds regression tests for both: a 6-part pass ticked across
catchupResendInterval to show the deadline is measured from the pass's last
send, and a 5-part pass whose bitmap shrinks mid-pass to show the budget
clamps to the peer's current report. Both fail against the pre-fix code.

Addresses thepastaclaw findings on PR #1418.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…p parts

beginCatchupAttempt derived the peer's missing-part count via
Not().CountTrueBits(), which allocates and copies the whole
(peer-controlled, up to MaxBlockPartsCount) bit-array on every gossip tick
just to count it. sendCatchupBlockPart already performs its own
Not().PickRandom() when a send actually happens, so this was a second,
avoidable full-array allocation on every tick regardless of whether a send
occurs. Derive the same count as size minus set bits instead.

Addresses a copilot review comment on PR #1418.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…asion

Layers on top of review-stream's retryAt-at-close and downward-clamp
fix in beginCatchupAttempt. Distinct fixes:

- syncProposalBlockPart (pre-existing, untouched elsewhere in this PR)
  unconditionally returned nil, so a failed send never reached
  endCatchupPass despite the caller's contract assuming it would.
  sendCatchupBlockPart now reports success as a bool instead of an
  error nobody was meant to inspect, and its "no missing part" branch
  can no longer be hit (the comment claiming otherwise was already
  false: prs is a fresh per-tick deep copy nothing else can mutate).
- A peer's height advancing reset the retry deadline as well as the
  budget, granting a free pass on every height step; only the budget
  resets now.
- The three catch-up fields were guarded only by a comment claiming a
  single caller, despite the same *msgGossiper being shared across
  three concurrent handler goroutines; a mutex now guards them
  structurally.
- Documented the rate limit's actual scope (per gossip worker, not per
  peer -- a reconnect gets a fresh one) on the Gossiper interface and
  the actual tick-to-interval relationship on catchupResendInterval,
  both previously undocumented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GossipBlockPartsForCatchup is rate-limited and returns without sending
on most ticks, so "block parts already delivered" no longer describes
what happened before the unconditional GossipCommit call on the same
branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
syncProposalBlockPart unconditionally returned nil regardless of the
underlying send's actual result, so sendCatchupBlockPart's error check
against it (added in the prior commit) was dead code: a send that
never reached the peer was indistinguishable from one that did, and
endCatchupPass never fired for that failure mode -- the pass kept
spending its budget one failed attempt per tick instead of ending on
the first one, exactly the spin endCatchupPass exists to prevent.

Fix at the source: return g.sync's result directly and drop the
now-redundant local log, since both callers already log a non-nil
error themselves. The other caller, GossipProposalBlockParts, only
ever logged this error and took no other action, so propagating it
for real is behavior-preserving there.

Regression test advances a fake clock across many ticks with every
send forced to fail and asserts exactly one block-part read per
catchupResendInterval, not one per tick; confirmed failing (5 reads
instead of 1) against the pre-fix code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread internal/consensus/gossiper.go Outdated
lklimek and others added 2 commits August 28, 2026 00:05
…nce interrupts a live pass

A peer that advances its reported height mid-pass (before the pass
exhausts its own budget) reset catchupRemaining to 0 without arming
catchupRetryAt, since the deadline was only ever armed when a pass
ran its budget down naturally. A peer whose height ticks forward
every gossip tick therefore got a fresh full-budget pass every tick
forever, completely bypassing catchupResendInterval for the common
multi-part-block case. Arm the deadline on the height-change branch
too whenever a pass was actually in flight, matching the same
invariant beginCatchupAttempt's normal exhaustion path already
upholds. Document that invariant on the struct fields it protects.

Also, while re-auditing this function for the same iteration:
- stop re-deriving the budget from a peer-controlled bit-array once a
  pass is open (now g.catchupRemaining = min(g.catchupRemaining,
  missing), replacing an equivalent but more verbose branch)
- de-duplicate the block-store-failure error logs in
  sendCatchupBlockPart; blockRepository.loadMeta/loadPart already log
  the failure, so the caller's second log was pure noise
- correct sendCatchupBlockPart's doc (it reports handoff to the p2p
  channel, not peer delivery) and give its error log a distinct
  message from GossipProposalBlockParts's
- correct the Gossiper interface, catchupResendInterval, and
  endCatchupPass doc comments, which described a flat one-send-per-
  interval bound that does not hold for multi-part blocks

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ak catch-up assertions

- Add TestGossipBlockPartsForCatchupHeightAdvanceInterruptingPassIsThrottled,
  a regression test for the height-advance bug fixed in gossiper.go:
  confirmed RED (20 sends) against the pre-fix code via git stash, GREEN
  (1 send) after the fix.
- Rename/rewrite TestGossipBlockPartsForCatchupBudgetIsFixedAtPassOpen to
  TestGossipBlockPartsForCatchupBudgetIsNeverRaisedMidPass: the old
  sequence never actually drove catchupRemaining below the reported
  missing count it then 'raised', so the raise-attempt it claimed to
  guard against was a no-op the test would pass on regardless. The new
  version forces a real clamp (5-part block, drop to 1 missing after two
  sends) before attempting to raise the count back up, and asserts the
  raise is not honored before the interval elapses.
- Strengthen TestGossipBlockPartsForCatchupPeerHasEveryPart with an
  explicit AssertNotCalled, so it fails loudly instead of silently if a
  send is ever wired in for a peer that already has every part.
- Update the two wantLog assertions in TestGossipBlockPartsForCatchup
  that still expected the removed 'couldn't find a block meta/part'
  caller-side logs; the block-store-failure messages they should match
  are now blockRepository's own 'failed to load block meta'/'failed to
  load block part'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lklimek and others added 4 commits August 28, 2026 00:05
…pWorker

newPeerGossipWorker constructed two independent clockwork.NewRealClock()
instances for what is meant to be one gossip worker's shared notion of
time. They agree in practice (both wrap wall-clock time), but nothing
enforces that, and it defeats point-in-time comparisons in tests that
inject a single fake clock expecting both call sites to observe it.
Construct one clock and pass it to both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t comment

The comment described GossipBlockPartsForCatchup as rate-limited and a
no-op on most ticks, which held before this PR's changes but not after:
a pass now spends one send per tick for as many ticks as the peer
reports parts missing, and the quiet gap applies only between passes,
not on most individual ticks. Reword to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…he catch-up throttle

PeerGossipSleepDuration sets the tick rate that
internal/consensus/gossiper.go's catchupResendInterval throttle is
metered against: configuring it above that fixed interval silently
disables the throttle. Document the coupling at both the struct field
and the generated TOML template so an operator tuning gossip cadence
sees the side effect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…backed off

beginCatchupAttempt computed missing (Size() - CountTrueBits(), an O(bits)
scan of the peer-controlled ProposalBlockParts array) unconditionally, even
on ticks that are going to return false immediately because the pass is
still within catchupRetryAt's backoff. At production defaults most gossip
ticks land in backoff (a pass's quiet interval is ~5x the gossip cadence),
so this scanned a bit-array up to MaxBlockPartsCount long on nearly every
tick for no reason.

Move the backoff check ahead of the scan: when no pass is open and the
retry deadline hasn't elapsed, return early without touching
ProposalBlockParts at all. The scan still runs whenever it can affect the
outcome - opening a new pass, or clamping an active one's budget down to a
shrunk missing count.

Addresses a copilot review comment on PR #1418.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread internal/consensus/gossiper.go
Comment thread config/config.go Outdated
…complete

beginCatchupAttempt returned early on missing == 0 without touching
catchupRemaining/catchupRetryAt. When that happened mid-pass
(catchupRemaining > 0, budget not yet exhausted), the pass stayed open
indefinitely: noOpenPass would keep evaluating false forever, so the
backoff check above it — which only runs while no pass is open — was
skipped on every later tick regardless of elapsed time. A peer could freeze
catchupRemaining above zero by reporting a complete part set whenever it
liked, then resume sending immediately the next time it reported parts
missing, with no retry interval honored at all.

Close the pass in that case too: zero the remaining budget and arm the
retry deadline, matching what happens when a send fails or a height change
interrupts a pass. Only do this when a pass was actually open; a peer that
has simply always reported a complete set must not have a wait imposed on
it.

Also corrects a stale doc comment on PeerGossipSleepDuration added by the
same throttle: catchupResendInterval is a fixed quiet gap that does not
scale with the gossip tick duration, it only fits more ticks into that same
window.

Adds a regression test that opens a pass, spends one send, has the peer
report complete, then report missing again on the same simulated instant -
asserting no send until the retry interval elapses. Fails against the
pre-fix code (an immediate second send).

Addresses copilot review comments on PR #1418.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

The current pass accounting fixes both prior resend-budget defects, but panic unwinding still bypasses failure settlement, allowing an unreadable historical record to trigger a long burst of per-tick storage panics and full-stack logs. The focused race command reached the consensus package suite and failed only in the unrelated existing TestLoadHeightProgressUnderSustainedFlood contention assertion; the catch-up panic path has no regression coverage.
Source: reviewers gpt-5.6-sol (Codex general and TenderDash consensus-security); final verifier claude-opus-4-6; openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — tenderdash-consensus-security (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `internal/consensus/gossiper.go`:
- [BLOCKING] internal/consensus/gossiper.go:220-225: End the catch-up pass when block-store access panics
  `beginCatchupAttempt` consumes a pass slot before `sendCatchupBlockPart` calls `LoadBlockMeta` or `LoadBlockPart`. Those block-store methods intentionally panic on database read, decode, and validation failures, while `peerGossipWorker.runGossipHandler` recovers the panic, logs a full stack, and continues ticking. Because `endCatchupPass` is reached only when `sendCatchupBlockPart` returns normally with `false`, panic unwinding leaves the remaining budget active. A peer that selects an unreadable historical height and reports a maximum-size 1,601-part missing bitmap can therefore cause a storage panic and full-stack Error log every default 100 ms tick for roughly 160 seconds before the budget expires, then repeat after the backoff. This defeats the PR's explicit invariant that an attempt which cannot send must end its pass. Settle unsuccessful attempts in a defer so panic unwinding also closes the pass, and add a regression using a block-store mock that panics.

Comment thread internal/consensus/gossiper.go Outdated
lklimek and others added 3 commits August 28, 2026 02:10
…ication, not a throttle bypass

df8a6057c's commit message and code comments described the missing==0
mid-pass fix as letting a peer "resume sending... with no retry interval
honored at all", which reads as a flooding/throttle-bypass concern. On
closer trace (independently by team-lead and grumpy-stream) that's not
accurate: catchupRemaining only ever counts down, never up, so total sends
per pass are identically bounded by the budget fixed at pass open whether
or not this fix exists. Nothing here lets a peer exceed that budget or the
one-send-per-tick ceiling.

The actual defect is CPU amplification: leaving a pass open indefinitely
(via alternating missing==0 reports) keeps noOpenPass false forever, which
permanently defeats 3b9463dd0's backoff-skip check - the O(bits) scan of
the peer-controlled, up-to-MaxBlockPartsCount bit-array then runs on every
single gossip tick instead of being skipped during backoff, for as long as
the peer holds the pass open this way. It also predates this PR: grumpy
traced the same missing==0 short-circuit in the 5978b27 baseline and
both its parent commits, so this isn't new here - it only started costing
more once 3b9463dd0 added a backoff-skip path for it to defeat.

Also records a deliberate behavior change for honest peers: closing the
pass on missing==0 means a peer that legitimately completes its part set
and later falls behind again now waits out one catchupResendInterval
before catch-up resumes, rather than resuming on its very next report.
Accepted: a peer that just reported complete is not starving, and the
wait is negligible against block time.

No functional change from df8a6057c - comments only, on both the fix and
its regression test.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GossipBlockPartsForCatchup only called endCatchupPass() on a normal false
return from sendCatchupBlockPart. But sendCatchupBlockPart's block-store
reads (blockRepository.loadMeta/loadPart, wrapping LoadBlockMeta/
LoadBlockPart) panic deliberately on a decode or read failure rather than
returning an error - runGossipHandler recovers that panic per gossip tick,
logs a full stack, and keeps ticking, same as it does for every other
peer-facing panic path in the node.

A panic there unwinds straight past the if-check, so endCatchupPass()
never runs and the pass stays open with its remaining budget intact. Since
the peer controls both the height and the missing bitmap a pass is
budgeted on, a peer that names a height whose stored record is corrupt or
unreadable gets a fresh attempt - and a fresh panic, and a fresh
full-stack log - on every tick for the rest of the pass's budget (up to
~160s for a maximum-size 1,601-part report at the default cadence) instead
of ending on the first failed attempt like every other failure mode this
PR's earlier commits already cover.

Settle the attempt in a defer instead of an if-check on the return value,
so panic unwinding also reaches it. sendCatchupBlockPart's own return
value still selects the fast path (success needs no settlement); the defer
only changes what happens when it doesn't get the chance to return at all.

Adds a regression test using a block-store mock that panics on
LoadBlockMeta, asserting exactly one read (and thus one panic) per pass
rather than one per tick for the whole budget - mirrors
TestGossipBlockPartsForCatchupSendFailureEndsPass's structure for the
already-covered normal-failure case. Confirmed failing (2+ reads instead
of 1) against the pre-fix code.

Addresses a thepastaclaw blocking finding on PR #1418.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…h-up pass

25da4c2 removed the unconditional catchupRetryAt clear from the
height-change branch to close a bypass, and ea2b81498 later armed it
properly for a pass genuinely interrupted mid-flight (catchupRemaining > 0).
The catchupRemaining == 0 case was never revisited: a deadline armed by the
OLD height (by exhausting its own budget, or by the peer reporting
complete) silently carried forward and throttled the NEW height's first
pass by up to catchupResendInterval, for a peer that isn't replaying
anything and owes nothing - it's genuinely further along. Catch-up is the
only path offered to a peer at a different height than ours (regular
gossip requires equal heights), so this taxes every honestly-advancing
lagging peer once per height. This throttle does not exist on
origin/v1.7-dev; it's a cost this PR's own commits introduced.

Naively clearing catchupRetryAt whenever catchupRemaining == 0 at a height
change (the obvious fix) reopens a worse gap than intended, though: it
cannot tell "the previous pass genuinely finished" apart from "an
INTERMEDIATE height in a rapid chain never got a chance to open a pass at
all, and is still serving an earlier interruption's penalty" - both look
identical (remaining == 0) at the moment of the next height change. Verified
by writing TestGossipBlockPartsForCatchupHeightAdvanceInterruptingPassIsThrottled
first: a naive version of this fix turns its pinned 1 send across 20 rapid
height-hops into 10, because every OTHER hop's remaining == 0 came from the
previous hop being blocked, not from a pass finishing.

Track WHY catchupRetryAt is currently armed instead: catchupRetrySticky is
true when the deadline covers unrendered service (a pass interrupted before
exhausting its own budget, or a failed attempt via endCatchupPass) and must
survive a height change - including a chain of several - and false when the
pass closed because it had genuinely nothing left to do (spent its own
budget in full, or the peer reported complete). The height-change branch
only clears catchupRetryAt when it's non-sticky (or already elapsed);
otherwise it's left untouched, whatever height change caused it to be
checked.

This does still leave a bounded gap in the non-sticky case, stated
explicitly in the code rather than glossed: a peer can open a pass, let it
close cleanly (one real send, e.g. by reporting a single missing part),
then immediately claim a new height and repeat, getting one send per gossip
tick indefinitely instead of one pass per interval. Bounded to at most 1
send/PeerGossipSleepDuration tick (10/s, ~640KB/s of block parts at
production defaults), because CompareHRS enforces monotonic height only (no
replay or ping-ponging) and ensurePeerPartSetHeader requires the real,
currently-stored PartSetHeader for each claimed height (a mismatch ends the
pass via the now-sticky endCatchupPass, costing a full interval rather than
a send). Sustaining it requires marching forward through genuine,
still-retained history, capped by blockStoreBase - no worse than the
pre-#1365 catch-up rate this codebase already tolerated in production.
Accepted as strictly cheaper than taxing every honestly-advancing lagging
peer by up to 500ms per height; a verified-progress-gated redesign that
closes it for real is tracked as follow-up, not done here.

Two new regression tests, both proven against the pre-fix code:
- TestGossipBlockPartsForCatchupCleanHeightAdvanceIsNotThrottled: a
  single-part pass closes cleanly, the peer immediately claims a new
  height with a fresh block - asserts an immediate send, not a
  catchupResendInterval wait. RED before this fix.
- TestGossipBlockPartsForCatchupHeightAdvanceInterruptingPassIsThrottled
  (existing, extended doc comment): still asserts full throttling across
  20 rapid, always-interrupted height hops - GREEN before this fix, and
  the guard that caught the naive version's regression above.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@thepastaclaw thepastaclaw 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.

Preliminary review — Codex only

The prior panic-path blocker is fixed by deferred pass settlement and focused regression coverage, but two in-scope throttle defects remain: successful final sends can consume their retry interval while blocked, and peer-asserted height advances can clear the interval entirely. The focused consensus package test reached completion but failed only in the known unrelated TestLoadHeightProgressUnderSustainedFlood contention assertion.
Source: reviewers gpt-5.6-sol (general) and gpt-5.6-sol (tenderdash-consensus-security); final verifier claude-opus-4-6. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol (not reviewer evidence).

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — tenderdash-consensus-security (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `internal/consensus/gossiper.go`:
- [BLOCKING] internal/consensus/gossiper.go:423-430: Start the retry gap after the final send completes
  `beginCatchupAttempt` consumes the final slot and arms `catchupRetryAt` from `now` before `sendCatchupBlockPart` performs the corresponding block-store reads and outbound send. The latter reaches `Channel.Send`, which explicitly blocks until the envelope is enqueued or the context ends (`internal/p2p/channel.go:217-224`). If storage or outbound congestion delays that final successful send by at least 500 ms, the deadline has already expired when the pass actually finishes. After the worker's normal sleep, the next invocation can immediately open another pass, eliminating the promised quiet gap under exactly the congestion where repeated catch-up traffic can interfere with current-round data gossip. Settle successful pass completion and arm the deadline using `g.clock.Now()` after the final send returns; add a regression whose mocked send advances the fake clock by the interval before returning and verify that a full additional interval is still required.
- [BLOCKING] internal/consensus/gossiper.go:330-359: Preserve the retry gap across unverified height advances
  The default height-change branch clears every non-sticky retry deadline, including one armed when a one-attempt pass cleanly exhausted its budget. An untrusted connected peer can therefore claim historical height H with `NewRoundStep`, install the real stored `PartSetHeader` and a bitmap with one unset bit through `NewValidBlock`, receive that part, and then claim H+1 before the next gossip tick. `ApplyNewRoundStepMessage` verifies only monotonic HRS progression, while `ApplyNewValidBlockMessage` accepts the replacement at the matching height and round; neither proves that the peer processed the prior part. Repeating this over retained heights yields one historical block part on every gossip tick instead of one pass per 500 ms interval, and reconnecting permits replay of the retained range. This restores sustained data-channel traffic and block-store reads that the PR is intended to suppress. Preserve the pending deadline across peer-asserted height changes, or grant immediate service only after progress the local node can verify.

Comment on lines +423 to +430
if g.catchupRemaining <= 0 {
// Arm the deadline as the pass closes, not as it opens: a multi-part
// pass spans more ticks than the interval, so a deadline set at open
// would already have elapsed by the time the budget runs out. Spending
// the budget down to zero this way is genuine completion (every part
// the peer reported missing at open got a send), so non-sticky.
g.catchupRetryAt = now.Add(catchupResendInterval)
g.catchupRetrySticky = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Start the retry gap after the final send completes

beginCatchupAttempt consumes the final slot and arms catchupRetryAt from now before sendCatchupBlockPart performs the corresponding block-store reads and outbound send. The latter reaches Channel.Send, which explicitly blocks until the envelope is enqueued or the context ends (internal/p2p/channel.go:217-224). If storage or outbound congestion delays that final successful send by at least 500 ms, the deadline has already expired when the pass actually finishes. After the worker's normal sleep, the next invocation can immediately open another pass, eliminating the promised quiet gap under exactly the congestion where repeated catch-up traffic can interfere with current-round data gossip. Settle successful pass completion and arm the deadline using g.clock.Now() after the final send returns; add a regression whose mocked send advances the fake clock by the interval before returning and verify that a full additional interval is still required.

source: ['codex']

Comment on lines +330 to +359
default:
// No pass is open, and any armed deadline is either non-sticky
// (the previous height's pass closed because it genuinely had
// nothing left to do: spent its own budget in full, or the peer
// reported complete) or has already elapsed. Clear it: a peer that
// isn't replaying anything and owes nothing from before is served
// immediately at the new height, matching how catch-up behaves
// everywhere upstream of this PR (no per-height throttle at all).
//
// This does reopen a bounded gap for the non-sticky case: a peer
// can open a pass, let it close cleanly (one real send, e.g. by
// reporting a single missing part), then immediately claim a new
// height and repeat, getting one send per gossip tick indefinitely
// instead of one pass per interval. Bounded to that - at most 1
// send/PeerGossipSleepDuration tick (10/s, ~640KB/s of block parts
// at production defaults) - because CompareHRS enforces monotonic
// height only (no replay or ping-ponging) and
// ensurePeerPartSetHeader requires the real, currently-stored
// PartSetHeader for each claimed height (a mismatch ends the pass
// via endCatchupPass, which is sticky, costing the peer a full
// interval rather than a send). So sustaining this requires
// marching forward through genuine, still-retained history, capped
// by blockStoreBase - no worse than the pre-#1365 catch-up rate
// this codebase already tolerated in production. Accepted as
// strictly cheaper than taxing every honestly-advancing lagging
// peer by up to 500ms per height; closing it for real needs a
// verified-progress-gated redesign, tracked as follow-up rather
// than done here.
g.catchupRetryAt = time.Time{}
g.catchupRetrySticky = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Preserve the retry gap across unverified height advances

The default height-change branch clears every non-sticky retry deadline, including one armed when a one-attempt pass cleanly exhausted its budget. An untrusted connected peer can therefore claim historical height H with NewRoundStep, install the real stored PartSetHeader and a bitmap with one unset bit through NewValidBlock, receive that part, and then claim H+1 before the next gossip tick. ApplyNewRoundStepMessage verifies only monotonic HRS progression, while ApplyNewValidBlockMessage accepts the replacement at the matching height and round; neither proves that the peer processed the prior part. Repeating this over retained heights yields one historical block part on every gossip tick instead of one pass per 500 ms interval, and reconnecting permits replay of the retained range. This restores sustained data-channel traffic and block-store reads that the PR is intended to suppress. Preserve the pending deadline across peer-asserted height changes, or grant immediate service only after progress the local node can verify.

source: ['codex']

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.

Flaky tests: TestReactorValidatorSetChanges and TestRouter_EvictPeers time out intermittently on v1.6-dev

4 participants