fix(blocksync): survive transient peer failures and stalls - #1396
fix(blocksync): survive transient peer failures and stalls#1396QuantumExplorer wants to merge 1 commit into
Conversation
|
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:
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
Two ways block sync gives up far too easily. A single failed block request dropped the peer. consumeJobResult called RemovePeer and reported a PeerError for any errBlockFetch, including a plain 15s request timeout, and PeerManager.Errored evicts on the first report. Peers serve block requests one at a time, so a busy peer times out long before it is unhealthy, and dropping it also fails the up to 20 requests already in flight to it - each of which then drops another peer. Count consecutive failures per peer instead and drop only after maxConsecutiveFailures in a row, resetting the count on the first successful response. A failed request also stops being counted as pending, which it previously only did by virtue of the peer being deleted. The p2p client reported the peer itself on every request timeout, which bypassed any caller-side policy. Reject the promise and let the caller decide: blocksync now counts timeouts, statesync's backfill already said it did not want to punish on timeout, and a genuinely dead connection is still caught by the transport's ping/pong. GetBlock is the only production caller of a promise-returning client method, so this is confined to the block sync path. A 60s stall handed over to consensus even when far behind. Nothing switches back to block sync except the state sync path, so a stalled node was demoted permanently to consensus catch-up, which is much slower. A stall is now only a reason to stop when no peer reports a height above ours, or when it has outlasted maxSyncStall, so a wedged synchronizer can still hand over rather than blocking forever. WaitForSync returns whether the node actually caught up, and poolRoutine uses that instead of a second IsCaughtUp call racing the synchronizer it just stopped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4071e32 to
09530ab
Compare
CI follow-up
|
Audit Summary — PR #1396
|
| Class | Sev | OWASP / CWE | Location | Finding |
|---|---|---|---|---|
| 🚫 blocking | MEDIUM | A04 Insecure Design, A05 Security Misconfiguration; CWE-841, CWE-799, CWE-400 | internal/blocksync/peer_store.go:130-144, synchronizer.go:239-263, p2p_msg_handler.go:56-64 |
Consecutive-failure counter is reset by any peer-sent StatusResponse, so the new eviction threshold is unreachable for a selectively-failing peer |
| 🚫 blocking | MEDIUM | A04 Insecure Design; CWE-191 Integer Underflow, CWE-770 | internal/blocksync/peer_store.go:136-144 |
AddFailure decrements numPending on a path that previously deleted the peer — failing makes a peer invisible to the slow-peer check and keeps it selectable |
| 🚫 blocking | MEDIUM | logic, doc-accuracy, concurrency | internal/blocksync/synchronizer.go:40-44 |
The "consecutive" failure counter actually counts concurrent in-flight timeouts — the documented intent is not delivered |
| non-blocking | MEDIUM | A04 Insecure Design; CWE-807 Untrusted Input in Security Decision, CWE-400 | internal/blocksync/synchronizer.go:322-404, reactor.go:33-38 |
maxSyncStall extends a peer-controlled block-sync lock-out from 60s to 10 minutes, keyed off an unauthenticated peer-reported height |
| non-blocking | MEDIUM | A04 Insecure Design, A09 Logging & Monitoring Failures; CWE-390, CWE-400 | internal/p2p/client/client.go:397-427,167-227 |
newPromise's timeout no longer reports the peer on any of its 4 shared APIs; only blocksync compensates, and the new caller-owns-punishment contract is undocumented and untested |
| non-blocking | MEDIUM | logging, observability (execution-verified) | internal/blocksync/synchronizer.go:352-362 |
Peer-triggerable Error-level log flood: up to ~540 identical lines per stall episode, proven by execution |
| non-blocking | MEDIUM | test-coverage, concurrency | internal/blocksync/synchronizer.go:322-370, reactor.go:293-300 |
WaitForSync's actual loop and its effect on SwitchToConsensus's skipWAL argument are untested — only the extracted pure function is covered |
| non-blocking | LOW | observability, operator-ux | internal/blocksync/synchronizer.go:339-341 |
Block sync gives up silently on the stopNothingToFetch path — no log line at all |
| non-blocking | LOW | observability, metrics | internal/blocksync/synchronizer.go:244-263 |
New failure/stall states are observable only by grepping logs; no metric, despite existing consensus.Metrics wiring |
| non-blocking | LOW | idiom, encapsulation, DRY | internal/blocksync/peer_store.go:136-144 |
AddFailure breaks the peer store's UpdateFunc idiom, bypasses the wrapper that maintains maxHeight, and hides a pending-count mutation |
| non-blocking | LOW | DRY, readability | internal/blocksync/synchronizer.go:339-362 |
The syncTimeout threshold is evaluated twice — once inside stallVerdictFor, once again in its caller |
| non-blocking | LOW | concurrency, consistency | internal/blocksync/synchronizer.go:330-338 |
The stall decision is assembled from four separately-locked reads that never coexisted |
| non-blocking | LOW | logging, operator-ux | internal/blocksync/synchronizer.go:249-254 |
"keeping peer" is logged for peers that are already gone — AddFailure's bool conflates "kept" with "not found" |
| non-blocking | LOW | dead-branch, naming, test-accuracy | internal/blocksync/synchronizer.go:379-380 |
stopNothingToFetch as documented is unreachable, and one test case exercises a state production cannot produce |
Pre-existing / outside-diff (follow-up, not this PR)
Four findings are real but sit outside this diff's responsibility. They are reported here only, with no inline comments:
- MEDIUM —
internal/p2p/client/— A pre-existing issue was identified in this area during review; this PR marginally widens its exposure window. Given its nature, it is being handled through a private security-disclosure channel rather than described in this public thread. - LOW —
internal/blocksync/reactor.go:32-38— stall constants live inreactor.gobut are used only bysynchronizer.go. - LOW —
docs/tendermint-core/block-sync/reactor.md:240-247— block-sync spec doc still describes the old switch-to-consensus condition. - LOW —
internal/blocksync/peer_store.go:236-237— new godoc sentences omit the trailing period required bySTYLE_GUIDE.md.
Positive observations
- The TOCTOU fix is genuinely correct: threading
caughtUpout ofWaitForSyncintoSwitchToConsensus(ctx, state, caughtUp || stateSynced)removes a real race against a synchronizer that was just stopped, exactly as the PR's own comment claims. - The bounded stall/handover mechanism is well-shaped: replacing an indefinite wedge with an explicit
maxSyncStallceiling is the right structural move, and extractingstallVerdictForas a pure, table-tested decision function is good practice —TestStallVerdictForcovers the decision table thoroughly. - Stack integration is clean: interaction with fix(blocksync): don't punish peers for duplicate block responses #1393's
RemovePeerand perf(blocksync): remove redundant work from the block apply path #1394'sUpdateMonitorwas checked and holds up; no conflicts or double-eviction paths. - Reviewer effort was verification-first: all three blocking findings were reproduced with executable probes rather than asserted from reading the diff.
🤖 Co-authored by Claudius the Magnificent AI Agent
Issue being fixed or feature implemented
Two ways block sync gives up far more easily than it should. Neither is a
percentage problem — both make a node stop syncing — so they outweigh any of the
throughput work in the rest of this stack.
1. A single failed block request dropped the peer
consumeJobResultcalledRemovePeerand reported ap2p.PeerErrorfor anyerrBlockFetch, including a plain 15s request timeout.PeerManager.Erroredevicts on the first report:
Peers serve block requests one at a time, so a busy peer times out long before it
is unhealthy. Worse, dropping it also fails the up to 20 requests already in
flight to it (
maxPendingRequestsPerPeer), and each of those failures droppedanother peer in turn.
2. The p2p client reported the peer itself, behind the caller's back
newPromisesent aPeerErroron every request timeout, so no caller-sidepolicy could soften it. Statesync's backfill already documents that it does not
want this:
3. A 60s stall handed over to consensus even when far behind
WaitForSyncreturned ontime.Since(lastAdvance) > syncTimeoutandpoolRoutinethen switched to consensus unconditionally. That is a one-way door— only the state sync path ever calls
SwitchToBlockSync— so a node thatstalled for 60s while thousands of blocks behind was permanently demoted to
consensus catch-up, which is far slower than block sync.
What was done?
maxConsecutiveFailures(5) failures in a row; the count resets on the firstsuccessful response. A failed request also stops being counted as pending,
which previously only happened as a side effect of deleting the peer.
newPromisenow rejectswithout reporting.
GetBlockis the only production caller of anypromise-returning client method —
GetChunk/GetParams/GetLightBlockexiston the interface but statesync uses direct channel sends and its own dispatcher
— so this is confined to the block sync path. A genuinely dead connection is
still caught by the transport's ping/pong (60s interval, 90s timeout).
a height above ours, or when the stall outlasts
maxSyncStall(10 min) so awedged synchronizer can still hand over rather than blocking forever. The
decision is factored into
stallVerdictForso it is testable without waitingon the 1s ticker.
WaitForSyncreturns whether the node actually caught up, andpoolRoutineuses that rather than a second
IsCaughtUp()call racing the synchronizer itjust stopped.
How Has This Been Tested?
New tests:
TestStallVerdictFor— table over the stall decision: progressing, stalledwith nothing left to fetch, stalled while ahead of every peer, stalled with
peers still holding blocks, and either side of the wedge limit.
TestConsumeJobResultKeepsPeerOnTransientFailure— failures below thethreshold keep the peer; the client mock has no
Sendexpectation, soreporting it would fail the test. The next failure crosses the threshold and
does report and remove.
TestAddFailure— threshold behaviour, that a success clears the run, and thatan unknown peer is never reported (so the remaining in-flight failures of an
already-removed peer do not report it again).
TestAddFailureClearsPending— a failed request stops being pending.Two cases in the existing
TestConsumeJobResultencoded the oldone-failure-one-eviction policy. Rather than delete them, the second now seeds
the peer one failure short of the threshold so it still exercises removal and the
re-request of that peer's pending blocks.
go build ./internal/... ./types/... ./node/... ./cmd/...go test -race -tags=deadlock -p 1overblocksync,p2p/...,statesync,consensus,state,store— all passgolangci-lint: 69 issues, identical to the base branchBuilt and tested against
DOCKER/Dockerfile'sdepsstage (Go 1.26.5).Breaking Changes
None to the wire protocol or on-disk format. Behavioural: peers are no longer
evicted on a single failed block request or a single request timeout. That is the
point of the change, but it does mean a bad peer is tolerated for up to 5
requests before removal.
Checklist:
For repository code-owners and collaborators only
🤖 Generated with Claude Code