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
A verification error following queued future blocks is still discarded by the final nil-error return.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Queues entire future-block batches to prevent downloader peer eviction near the chain tip.
Changes:
- Accepts
ErrFutureBlockwhile queuing descendants. - Adds tests for first/mid-batch queuing and resumed import.
File summaries
| File | Description |
|---|---|
core/blockchain.go |
Expands future-block queue handling. |
core/blockchain_futureblocks_test.go |
Tests queuing and later processing. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
633d5cc to
8d97961
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Two error paths can omit bad-block reporting or silently report an incomplete import as successful.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
core/blockchain.go:1959
- These exemptions currently suppress both the bad-block report and the return. If iteration stops on
ErrPrunedAncestororErrKnownBlock,blockis still unconsumed but execution falls through to the final nil return, recreating a silent partial import. Exempt legitimate states only fromreportBlock; every non-nil iterator error must still be returned.
if err != nil && !errors.Is(err, consensus.ErrFutureBlock) &&
!errors.Is(err, consensus.ErrPrunedAncestor) && !errors.Is(err, ErrKnownBlock) {
bc.reportBlock(block, nil, err)
return it.index, events, coalescedLogs, err
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
15971f6 to
e960998
Compare
4862bd0 to
8c37dc4
Compare
8c37dc4 to
fd0ad7b
Compare
fd0ad7b to
188a9cc
Compare
f4cb97b to
60a41d9
Compare
f3c783e to
5dc50e4
Compare
5dc50e4 to
96bcd0d
Compare
| if !signable { | ||
| log.Debug("Imported block is not canonical, refusing to sign", "peer", peer, "number", block.Number(), "hash", hash) |
| continue | ||
| } | ||
| if time.Now().After(parked.deadline) { | ||
| log.Warn("Block was not imported in time, dropping its signature", "number", parked.block.Number(), "hash", hash) |
520351f to
71fac08
Compare
Problem ------- A nil error from insertBlock is not a promise that the block is on the chain. The inserter wired up in eth/handler.go returns nil without importing while fast sync is still running, the single-block import returns nil without storing a block the downloader is already fetching, and a batch import reports success while parking its future tail in the future queue instead of writing it. The fetcher read that nil as "the block is on the chain" and then signed it and handed it to the proposed-block handler. That is a consensus hazard, not a logging one: processQC writes highestQuorumCert before it checks that the block its QC points at exists, so a master node can end up voting for a block that is not in its chain. The downloader already asks the question - handoffProposedBlock hands over the head a batch left behind, never its tail - so this closes the fetcher as the remaining site that trusted the return value. Changes ------- * Gate the sign hook, the proposed-block handling and the final broadcast on the block being in the chain. Relaying is the one action that is safe without it: a receiver that already has the block drops it again. Both hooks keep this existence gate: it is what closes the processQC hazard, so it must not be relaxed. * Park a block that reported success without being imported and sign it once another importer stores it - the future-block loop when its timestamp arrives, or the downloader. Parking is shared and bounded: one goroutine, one ticker and at most maxParkedSignatures entries, so a master equivocating at the same height cannot accumulate unbounded blocks, DB reads or timers. A parked block is signed only when it is on the canonical chain and still within the uncle distance; a block whose number has fallen below the head is dropped early. The vote is not compensated: procFutureBlocks feeds the imported block to the consensus engine itself. * Do not park while snap sync is running: the inserter discards propagated blocks outright, so there is nothing that can arrive to sign. This also removes the per-block timeout warning that window used to produce. * The wait is bounded by parkedImportTimeout, a generous upper bound rather than the future queue's window: the fetcher imports single blocks, so the reachable case is a block the downloader is already fetching and that can span several download requests. Tests ----- TestUnimportedBlockSkipsConsensusHandling pins both halves: neither hook runs when the import reported success without storing the block, and both still run for a block that reaches the chain. TestParkedBlockSignsAfterFutureImport pins the deferred signature: no signing before the block is stored, the relay regardless, and the hook once the block is imported, while the consensus handler stays reserved for procFutureBlocks. TestParkedSignature* pin the bound: a block discarded while syncing is not parked, a never-stored block is dropped at its deadline, a stale block is dropped, and the parked set is capped. The insertChain half of this work is already on the base - the future queue accepts consensus.ErrFutureBlock since "fix(core): park the whole tail when a batch starts in the future" - so this commit keeps only the fetcher half.
…onical The fetcher's immediate path signs any block insertBlock stored, without asking whether it is on the canonical chain. writeBlockWithState writes the hash->number mapping before the canonical check, so a side entry - or a branch this node declined to adopt (adopted == nil) - is readable by hash and passes the existence gate; the block is then signed even though it is not on this node's chain. This predates the deferred-signature work: the base signed on any successful insert with no gate at all, and kept signing these blocks after the existence gate was added. Route the immediate path through canSign too, so a signature transaction is created only for a block that is canonical and still within the uncle distance. A stored block can turn canonical moments later (the body is written before the canonical head), so the refusal must not give the signature up for good: route the refusal through parkForSignature too, so the parked set owns every deferred signature. A parked block is signed once it becomes canonical and still within the uncle distance, and dropped by the uncle-distance or deadline bound otherwise. The refusal arm is a leaf that only logs; it must not return. A return there would skip handleProposedBlock, which reaches the engine's processQC path, and most tests would not catch it because handleProposedBlock is a no-op there and canonical blocks never take the refuse arm. Only a signing failure returns. Voting is untouched - the refusal arm keeps running handleProposedBlock on the existence gate alone, and procFutureBlocks remains the owner of the vote. Parking a refused block costs a parked slot (capped by maxParkedSignatures and drained by the deadline), so a master equivocating at a height can fill the set, but the slots clear within parkedImportTimeout. The deadline log now distinguishes a block that was stored but never became signable (Debug) from one no importer ever wrote (Warn), which the old message conflated. Tests: TestImportedNonCanonicalBlockIsNotSigned stores a side block through the fetcher's inserter and pins that the consensus handler still runs while the sign hook does not, that the refused side block is parked, that a reorg adopting its branch revives the signature exactly once, and that no signature exists before that. TestParkedSignatureTimeoutDropsStoredButNeverSignable pins the deadline arm for a block that was stored but never became signable. REV-20261008-153532-P3-01, REV-20261008-153532-P3-03


Summary
A nil error from
insertBlockis not a promise that the block is on the chain, and the fetcher still read it as one. Three ways a nil error arrives without a stored block:eth/handler.goreturns nil without importing while fast sync is still running;That is a consensus hazard, not a logging one:
processQCwriteshighestQuorumCertbefore it checks that the block its QC points at exists, so a master node can end up voting for a block that is not in its chain. The downloader already asks the question -handoffProposedBlockhands over the head a batch left behind, never its tail - so this closes the fetcher as the remaining site that trusted the return value.scanParkedSignatureslater signs a parked block once it is stored andcanSignholds, and keeps it parked - rather than dropping it - while it is merely stored but not yet canonical.Scope
This PR carries only the fetcher half of the branch it replaces. The
insertChainhalf - acceptingconsensus.ErrFutureBlockin both future-block loops - is already on the base, landed as commitfix(core): park the whole tail when a batch starts in the future, so this revision drops it along with its core and engine-v2 tests. The proposed-block gating revision is out of scope as well; it lives on thefix-downloader-canonical-tail-gatebranch (#2554).A block that is stored but not canonical is covered here too, as a second commit.
writeBlockWithStatewrites the hash -> number mapping before the canonical decision, so a side entry - or a branch this node declined to adopt - is readable by hash and used to pass the existence gate, and the signature transaction was then created for a block that is not on this node's chain. That is a pre-existing defect, not something the existence gate introduced: both the immediate path and the parked path now gate the signature oncanSign(canonical at its height, and within the uncle distance).Evidence
Mutation-verified in both directions: the test hangs on the gates, not on the code around them.
The never-fires mutation no longer reaches the sign hook: the canonicality gate added by the second commit refuses an unimported block (
canSignfails, the tester's chain has no canonical hash at that height), so the mutation is caught by the consensus-handler half instead.TestUnimportedBlockSkipsConsensusHandlingcovers both halves - neither hook for a block that was not stored, both hooks for one that reached the chain.TestParkedBlockSignsAfterFutureImportpins the deferred signature: no signing before the block is stored, the relay regardless, and the hook once the block is imported, while the consensus handler stays reserved forprocFutureBlocks.The bound is pinned by
TestParkedSignatureSkippedWhileSyncing(nothing is parked while the insert is discarded),TestParkedSignatureWithoutSignHook(a node without a signing hook parks nothing),TestParkedSignatureTimeoutDropsBlockandTestParkedSignatureDroppedWhenStale(the two eviction paths) andTestParkedSignatureLimit(the cap).TestParkedSignatureWaitsForCanonicalis two-phase: stored but not canonical -> not signed and still parked; then canonical -> signed and removed.TestImportedNonCanonicalBlockIsNotSignedpins the immediate path: a stored side entry is not signed while its proposed-block handling still runs.TestParkedSignaturePutsBackWhenNoLongerSignablepins the re-check between the scan and the signature: a block that stops being signable in that window goes back to the parked set with its original deadline instead of being dropped, and is signed once its branch is re-adopted.gofmt/goimportsclean;make all,go test ./eth/fetcher/andmake quick-testall pass.Merge Danger
Door: two-way
Reverting restores the previous unconditional calls. Nothing is written to the chain, to the wire protocol or to the consensus rules.
Blast Radius: eth/fetcher
Only the propagated-block path changes. A node that used to sign and vote for a block it had not stored stops doing so, and a block another importer stores gets its sign transaction from a single shared park (one goroutine, one ticker, capped at
maxParkedSignatures) instead of a per-block poll. A node without a signing hook (non-XDPoS) parks nothing, unchanged. During snap sync nothing is parked either: the inserter discards propagated blocks outright, so this path no longer emits a signature - or its timeout warning - for them. Blocks that do reach the chain on this path keep the handling they had.A stored but non-canonical block is no longer signed: the signature transaction is created only when
canSignholds (canonical at its height, and within the uncle distance). A block that loses that race is skipped with a log line and a per-reason counter (theeth/fetcher/block/parked/{refused,stale,deadline,full}meters), and stays parked until it becomes canonical or reaches the stale/deadline bound - it is not dropped for merely being unready.Suggested label:
eth.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 in the first commit (fix(eth): don't sign or vote for an unimported block), and the two-phase testTestParkedSignatureWaitsForCanonicalpins it. The re-check variant of the same loss - an entry deleted before the signature is attempted and not put back when the re-check fails - was found in review and fixed in the first commit as well;TestParkedSignaturePutsBackWhenNoLongerSignablepins 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.scanParkedSignatures(putBackParked, pinned byTestParkedSignaturePutsBackWhenNoLongerSignable); the consolidation must preserve it.Tracked as P3-04 / P3-10 in
.codebuddy/reviews/20261007-230522-8297eadcee.md.