Repository navigation
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
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 |
There was a problem hiding this comment.
🟡 Changes recommended
The canonical hash can reference a fast-synced header whose full block was never stored, so the new gate needs an additional block-presence check.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a canonicality gate before downloaded blocks reach XDPoS consensus handling.
Changes:
- Adds
GetCanonicalHashto the downloader blockchain interface. - Adds canonicality scenarios and downloader test hooks.
File summaries
| File | Description |
|---|---|
eth/downloader/downloader.go |
Gates proposed-block handling on canonical hash. |
eth/downloader/downloader_test.go |
Adds canonicality and concurrent-head test scenarios. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
678dd3d to
3978be7
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The test fork-choice model diverges from production for equal-TD and shorter heavier reorgs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
eth/downloader/downloader_test.go:390
- This fork-choice model does not fully match
BlockChain.writeBlockWithState: production also promotes an equal-TD block when it has a higher number (core/blockchain.go:1670-1675), and a reorg to a shorter but heavier fork deletes canonical markers above the new head (core/blockchain.go:2614-2627). As written, the tester can report a different canonical hash than the real chain for both cases, weakening tests built onGetCanonicalHash. Mirror the tie-break and clear entries above every newly selected head.
_, headHash := dl.canonicalHead(true)
if td := dl.ownChainTd[headHash]; td == nil || dl.ownChainTd[block.Hash()].Cmp(td) > 0 {
dl.canonicalize(block.Hash(), block.NumberU64())
}
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
b46701f to
f7e17f2
Compare
834c8f7 to
f5e3893
Compare
f2e2f12 to
661dabe
Compare
91778ec to
a9d1790
Compare
…rage A master node could vote for, and commit state against, a block it had just reorged away. The proposed-block handler ran on whatever header its caller handed it, and nothing downstream caught it: the fetcher ran it on blocks fast sync had propagated but never executed (during snapSync its inserter and prepare only refuse a block by returning nil), while processQC writes highestQuorumCert, lockQuorumCert and the commit block before it looks the block up - and looking it up cannot separate a reorged-away fork from the canonical block, since the fork stays in the database. A nil header or nil number panicked in the dispatch that dereferences it. The downloader half of that is already closed on the base: 20e3373 hands the engine the head a batch left behind (handoffProposedBlock) and has procFutureBlocks track the block that actually advanced the canonical head (lastCanon). This commit gates the callers still exposed - the v2 engine twice, the fetcher once - on one judgment, consensus.ShouldHandleProposedBlock, so they cannot drift: a header is handled only if it is the canonical block at its height and its body is stored. core.BlockChain asserts both capabilities at compile time; a HeaderChain deliberately lacks BlockStorer and fails loudly as SkipUnjudgeable instead of silently judging every block unstored. No outcome is an error - every skip is a reason - so each caller collapses to one !ok branch. The engine gates before processQC and again before sendVote: x.lock serializes this handler, not InsertChain, so a reorg can still land in between. processQC's writes stay (monotonic round-wise updates, no rollback path), so only the vote is dropped; the other processQC entries (initial, SyncInfoHandler) stay ungated, because their QCs already passed verifyQC's quorum threshold and following the quorum majority is how a forked node converges. The fetcher gate judges the snapSync flag and executed state and leaves canonicality to the engine; its state half is HasBlockAndExecutedState, not HasBlockAndFullState, because a missing XDCX trading or lending piece is not re-derivable and demanding it would halt QC with no self-healing path. A fast-sync transition grace keeps below-pivot canonical blocks at Warn. Every gate returns nil, never an error: the fetcher's import loop treats a handler error as an import failure and suppresses the block's broadcast. Skips are graded and counted per gate, so a persistent QC or voting stall is observable beyond the logs. The downloader no longer runs this judgment, so the pre-filter mechanism built for it is not carried over: PreFilterSkipLogLevel, SkipReason's preFilter grade and the downloader-side tests are omitted rather than kept as dead code. Gated entry points ------------------ The judgment runs at every site that could hand a header to the v2 proposed-block handler, so no caller can reach it with a block that is not the canonical block at its height or whose body is not stored: * the v2 engine's ProposedBlockHandler, before processQC and again before sendVote; * the XDPoS wrapper's HandleProposedBlock, ahead of the engine dispatch; * the miner's own mined block (miner/worker.go), through that wrapper; * the fetcher's fast-sync gate (eth/proposed_block.go, wired from eth/handler.go). A parked header is keyed by hash, so parking the same header twice keeps one entry and the consensus handler runs at most once; TestParkProposedBlockDeduplicates pins this.
Summary
The proposed-block handler ran on whatever header its caller handed it, and the engine's own re-check could not make up for it:
processQCwriteshighestQuorumCert,lockQuorumCertand the commit block before it looks the block up, and looking the block up cannot tell a reorged-away fork from the canonical block — the fork stays in the database. A master node could vote for, and commit state against, a block it had just reorged away.The downloader half of that is already closed on
dev-upgradeby20e3373fb(fix(core,eth): hand the engine the head, not the batch tail):handoffProposedBlockhands the engine the head a batch left behind, andprocFutureBlockstracks the block that actually advanced the head. No batch tail reaches the handler any more, so this PR does not reimplement that — it gates the callers that are still exposed: the v2 engine twice, the fetcher once.One judgment, shared so the call sites cannot drift:
consensus.ShouldHandleProposedBlockasks a minimalCanonicalChainfor the header at the block's height, and aBlockStorerfor the body key. Storage goes throughHasBlock, notGetBlock— only existence matters, andGetBlockwould decode every imported block's body.core.BlockChainasserts both capabilities at compile time; aHeaderChaindeliberately lacksBlockStorer, so a header-only chain fails loudly asSkipUnjudgeableinstead of silently judging every block unstored. The judgment has no error channel — every outcome is a skip reason — so each caller collapses to one!okbranch.The fetcher's state half requires
HasBlockAndExecutedState, deliberately stopping short ofHasBlockAndFullState: a missing XDCX trading or lending state piece is not re-derivable, so demanding it would halt QC processing with no self-healing path. A fast-sync transition grace keeps below-pivot canonical blocks atWarn, so the stall alert does not fire on them.19 files, 2102 insertions and 28 deletions.
Evidence
Before — putting the old behaviour back with
go test -overlay(the judgment forced to accept, and the fetcher gate short-circuited to the consensus handler), not by reading the tests:After — unmutated:
make allandmake quick-testare green;gofmtandgoimportsreport nothing. Coverage follows the decisions: the judgment's outcomes and its nil shapes, the per-reason log grades andIsCanonicalAt(consensus/XDPoS/utils/proposed_block_test.go); the non-canonical skip, the reorg beforeprocessQC, the dropped vote for a reorged block, a canonical header without a body, the miner's self-vote path and the malformed-extra case (consensus/tests/engine_v2_tests/proposed_block_test.go);HasBlockAndExecutedStateand the header chain that is not aBlockStorer(core/); the fetcher's gates, its wiring, its counter names and the nil-handler normalization (eth/).Merge Danger
Door: Two-way. No config, no RPC and no database format changes, so a revert is a plain code revert — the counters and log lines simply stop.
Blast Radius: Consensus-internal — the path a v2 node takes from receiving a proposed block to casting a vote, on both the fetcher and the engine side.
nil, never an error: the fetcher's import loop treats a handler error as an import failure and suppresses the block's broadcast, so erroring would trade a wrong vote for a lost block.processQC's writes are deliberately not rolled back. They are monotonic round-wise updates with no rollback path, so only the vote is dropped. The residual reorg window between the second gate and the broadcast is documented and accepted; closing it for real means sinking the canonicality check intoprocessQCbefore its writes, which is left as aTODO(convergence).processQCentries stay ungated.initialandSyncInfoHandlerfeed QCs that already passedverifyQC's quorum threshold, so following the quorum majority is how a forked node converges instead of stalling.BlockStorerjudgesSkipUnjudgeableatError, and carries no counter on purpose — production wiring cannot reach it, because every entry point takesProposedBlockChain.skipped-proposed-block/—no-canonical-headerandnon-canonical(a persistent rate means the node keeps being handed forked blocks),body-not-stored(transient during fast sync), and the fetcher'sstateandsnap-sync. One regex over that prefix aggregates them; the engine gates'proposed-block-skip-totalis named outside it on purpose, to be read as a cross-check rather than double-counted. A sustainedstaterate once the head is past the pivot is a QC/voting stall that needs manual intervention.PreFilterSkipLogLevel,SkipReason'spreFiltergrade and the downloader-side tests — is omitted rather than kept as dead code.Follow-up for the second merge (P3-04 / P3-10)
These two PRs were developed independently, and each adds a "park and retry" mechanism with the same shape but a different purpose:
eth/fetcher)eth)parkedSign{block, deadline}parkedProposed{header, deadline}canSign(canonical + freshness)HasBlockAndExecutedState(canonicality stays with the engine)maxParkedSignatures64parkedProposedMax64maxUncleDist7parkedProposedStaleDistance7Neither PR is wrong on its own, and nothing breaks if both land as they are. But after both are merged the same process runs two parkers, and the invariant they now share is written in two files:
That invariant already regressed once during this work —
scanParkedSignaturesused to delete an entry as soon as the block was readable but not yet canonical, losing the deferred signature permanently (rawdb.WriteBlockflushes the block well beforeadoptHeadwrites the canonical head). It was fixed in96bcd0d73b, and the two-phase testTestParkedSignatureWaitsForCanonicalpins it.Merge order: #2550 first, then #2554, then do the consolidation:
parkedProposedStaleDistance(7) duplicates the parking use ofmaxUncleDist(7). Share it in the direction "theethside references the fetcher's parking semantics" — do not pushparkedProposedStaleDistanceback into the fetcher, wheremaxUncleDistalso servesIsPlausibleAnnouncementand the queue-drop predicate.Tracked as P3-04 / P3-10 in
.codebuddy/reviews/20261007-230522-8297eadcee.md.