fix(consensus): bound catch-up block-part resends and restore round timeouts in reactor test - #1418
fix(consensus): bound catch-up block-part resends and restore round timeouts in reactor test#1418lklimek wants to merge 21 commits into
Conversation
…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
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
⛔ Blockers found — Opus deferred (commit 4d92801) |
thepastaclaw
left a comment
There was a problem hiding this comment.
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
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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
Catch-up pass now ends when it cannot send — fixed in e01f593Answering the blocking finding carried in the review body of 5031834714 and 5039744991 ( Verified independently, not taken on faith. Both exploits reproduce. I wrote them as tests against the pushed head Fix. The send moved into Structuring it as a wrapper plus a helper rather than five calls to 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. Verification. Targeted group green under The storm bed needed care this time: an unrelated tenant on the measurement host swung an unmodified
The single failure hit both arms in the same round. No liveness or storm-bound regression. Separately: both reviews also report |
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
TestReactorValidatorSetChangesand 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.
…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>
…-validatorset-timing
…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>
…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>
…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
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
🔴 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']
| 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 |
There was a problem hiding this comment.
🔴 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']
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 storyandScenarioare 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.
TestReactorValidatorSetChangesintermittently 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_EvictPeersininternal/p2p— a separate matter (a hardcoded 1-second wait inp2ptest.RequireUpdateover a mocked transport), sharing no mechanism with this test.Root cause 1 — the test harness disabled consensus recovery
TestReactorValidatorSetChangesdrove its 7-node network withnewMockTickerFunc(true).mockTicker.ScheduleTimeoutforwards onlyRoundStepNewHeighttimeouts, and withonlyOnce=trueonly the first one; every other scheduled timeout is discarded. The network ran with notimeoutPropose, notimeoutPrevoteWait/timeoutPrecommitWait— so round changes were impossible — and, after the first height, notimeoutNewHeight, 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 + Precisionmakes every validator prevote nil and the round change that would recover never fires; separately, a node that applies a commit itself sits atRoundStepNewHeightforever, 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.
GossipBlockPartsForCatchupnever marks catch-up parts as delivered, soprs.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 theNewRoundStepmessages 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
internal/consensus/reactor_test.go—tickerFun: newTickerFunc()restores real round timeouts, andTimeout.Propose/Timeout.Voteare 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.internal/consensus/gossiper.go— catch-up is bounded to one part-set pass percatchupResendInterval(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
PeerStatebookkeeping — deliberately, becauseSetHasProposalBlockPartignores 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
Synchronyparams (no benefit), pacing block production viaCreateEmptyBlocksInterval(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:-count=10The unloaded baseline reproduced the CI signature exactly — all seven nodes reporting a
waitForAndValidateBlockdeadline, one withsubscription terminated by publisher.Mechanistic confirmation for commit 1: counting round changes after the last committed block, the old code shows
max_round = 0and oneentering new roundper 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 proposaloccurs 632 times across those runs with zero wedges.Unit tests: full
TestGossiperandTestPeerGossipWorkersuites 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
For repository code-owners and collaborators only
Follow-ups worth a look (deliberately out of scope)
internal/consensus/init_test.gosetsdeadlock.Opts.DeadlockTimeout = 5s. Under contention that detector fires on a benign lock wait andos.Exit(2)s the entire package test binary.newMockTickerFunc(true)is still used byhelper_test.go:216,replay_test.go:376andbyzantine_test.go:50. Same trap, unexamined.AGENTS.mdstates CI runs tests with-p 1, buttest-group-%in theMakefiledoes not pass it, so up toGOMAXPROCSpackage binaries run concurrently. That is the contention source behind this class of flake.Prior work
waitForAndValidateBlock's failure path; still in place, and what makes these logs legible enough to diagnose.Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent