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 configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced 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 |
28f0727 to
062b0a6
Compare
7ae508f to
56d02be
Compare
049988f to
aba6ac4
Compare
aba6ac4 to
c04b0c0
Compare
80bf917 to
9a7df19
Compare
9a7df19 to
d9000bd
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Gap-schedule handling remains inconsistent, and unchanged-head batches can still trigger duplicate consensus handoffs.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 48/48 changed files
- Comments generated: 2
- Review effort level: Balanced
03895cd to
34e98c6
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Permanent local failures can hot-loop in the future queue, and the proposed-head handoff has a race with concurrent block imports.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (2)
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
It substantially changes consensus-adjacent import and reorg behavior while multi-epoch, network compatibility, and dependent-branch validation remain outstanding.
Review effort: Balanced
Findings: None
Resolved since last review (2)
Groundwork for XinFinOrg#2534 and XinFinOrg#2535: the classification lands here with its first caller - the import loop reports the block its error stopped on, which is the one the caller is about to blame the peer for - so the fixes that follow only have to return the right sentinel. The insertion paths ask an error the same four questions - may the block stay parked, may the peer be blamed, must it be recorded as a bad block, is it worth a warning - and each question had its own hand-written errors.Is list to keep in step. classifyInsertErr answers all four from one table, so a sentinel is registered once, and IsLocalInsertError exposes the answer the downloader needs through its own BlockChain interface, without importing core; the new check_baddeps rule keeps that boundary, reporting test-only violations as warnings. It also adds HasExecutedBlock: writeBlockWithoutState stores a sidechain block without its state or receipts, and naming a root that happens to exist was enough to look executed, so "this node already did this work" now asks for the receipts execution leaves behind. It also collapses the gap-block predicate into isGapBlockNumber. The (num+Gap)%Epoch == 0 form engine_v1 writes and loads its snapshots with and the num%Epoch == Epoch-Gap form engine_v2 refreshes the masternode set on agree only while 0 < Gap <= Epoch, and the three UpdateM1 call sites turn a rejection from that engine into a log.Crit, so the two have to be the same test. setHeadBeyondRoot now picks the snapshots a rewind deletes with it, and the warning for a configuration it cannot match covers Epoch == 0 as well, so a rewind that deletes no snapshot always says so. Finally, it moves the code the later fixes land on so that each of them can be reverted on its own - getResultBlock reads the competitor's total difficulty before the head's, and insertBlock asks blockAlreadyImported instead of spelling the predicate out at the call site - and lands the block the loop reports when its error stopped on a block of the batch here rather than in the fix that needs it, for the same reason. The moved statements are the same code this branch ran before, in another order. Refs XinFinOrg#2534
addFutureBlock refuses a block dated more than maxTimeFutureBlocks ahead of the local clock and returned a plain error. Nothing is wrong with such a block - it is this node's clock that cannot place it yet - but the plain error read like a consensus failure, so the downloader wrapped it in errInvalidChain and dropped the peer that had served a valid delivery. Wrap the refusal in ErrLocalInsertCondition, so the chain and the downloader read it for what it is: a condition of this node, which heals once the clock catches up. Refs XinFinOrg#2534
CheckpointCh was unbuffered and every sender ran with the chain mutex held, so an epoch switch could stall block import until the staking loop in cmd/XDC got around to receiving - and that loop deliberately does nothing with the signal while syncing, which is exactly when blocks arrive fastest. Give the channel a buffer of one and send through SignalCheckpoint, which drops the signal when one is already pending. The receiver re-reads the head when it runs, so a pending signal already covers this epoch switch. The two open-coded senders now share the helper, and miner/worker.go goes through it as well: it is the only consumer of the mined-block queue, so a stall there would hold up the whole pipeline. Refs XinFinOrg#2535
An insertion can fail for a reason that lives in this node - the chain is stopping, InterruptInsert cut the import short, or this node no longer holds an ancestor's state. None of those was distinguishable from an invalid block, so the downloader wrapped every unclassified error in errInvalidChain and dropped the peer that had served a valid batch: a recoverable local stall became a lost peer, and the head stayed where it was, so every following attempt failed on the same range again. The chain now answers the question: insertErrClass says per sentinel whether a parked block may be retried, whether the peer may be blamed and whether the block must be recorded as bad. The downloader asks it through its own BlockChain interface - importing core would pull back the implementation it abstracts - and cancels the content processing instead of dropping the peer; the head number logged next to the cancel makes a repeating failure visible as the same head over and over. The same verdict keeps the simulated backend from panicking on a closed chain, keeps the file importer and the admin RPC from calling an interrupted import a corrupt one, and keeps puppeth from publishing a genesis whose system contracts carry no code. Refs XinFinOrg#2534
insertChain's entry guard returned a nil error when the chain was already terminating, so a batch that was never even looked at was reported to its caller as imported in full. The downloader advanced past blocks this node never got, failed one batch later on an unrelated symptom, and left the head stuck where it was. Return ErrInsertionInterrupted instead: nothing was imported, and that sentinel is what tells the downloader to cancel the content processing rather than blame the peer that served the batch. Refs XinFinOrg#2534
The import loop ended on two different causes - a chain that is terminating, or a block that failed verification or body validation - and the trailing return discarded both, so a batch that stopped in the middle was reported as a successful import of the whole batch. Set ErrInsertionInterrupted when the chain is stopping, and return the error the loop actually ended with otherwise. Only a failure the caller will blame on the peer is logged at warn level as well: the downloader logs it at debug level, which used to leave no usable trace of why a range was never imported. TestInsertChainReportsBadBlockBehindKnownPrefix is added here rather than with the fork-choice adoption that first exercises it: the reject it asserts is this commit's, so a revert of this commit takes the case away with it. Refs XinFinOrg#2534
InsertReceiptChain returned a zero index and a nil error when the chain was stopping, although the blocks it had processed before the interruption may already have been flushed by the periodic batch write. The downloader read that nil as a complete import of the batch. Return ErrInsertionInterrupted together with the index the loop stopped at, so the caller can tell how much of the batch was written and the downloader cancels the content processing instead of advancing past receipts this node never got. Refs XinFinOrg#2534
XDPoS v1 and v2 check a header's timestamp before its parent and with zero tolerance, so the children of a future block report ErrFutureBlock and never ErrUnknownAncestor. The future-block loops of insertChain accepted only the latter, so a batch that started or ran into the future stopped at the block after it - which the downloader wrapped in errInvalidChain and dropped the peer for. queueFutureTail accepts both errors and is shared by the two future paths, so the whole tail is parked and procFutureBlocks imports it once its timestamps are reached. A genuine verification error behind the tail still surfaces: the helper stops at the first error it cannot queue and the caller reports that block, and whether it is worth recording as a bad block is now asked of classifyInsertErr rather than of a hand-kept exclusion list. Refs XinFinOrg#2534
procFutureBlocks handed the engine the sorted queue tail whenever the last InsertChain returned no error, and the downloader handed it the tail of every batch InsertChain accepted. Neither tail is the head: a fork batch is stored as side entries, and a future tail is only parked in the future queue, with the head advancing later. Feeding a non-canonical block into the hook advances the consensus state - QC and vote - for a block that never entered the chain. procFutureBlocks now tracks the highest block that actually advanced the canonical head and hands that one to the engine, and the downloader skips its hook unless the batch tail is the current head. The same pass evicts the entries it proved unimportable - a block whose parent is neither in the chain nor parked was re-verified and re-reported as a bad block on every tick - while keeping the ones whose failure classifyInsertErr calls retryable, and counts what it drops, because the queue is the only place a parked block lives. Refs XinFinOrg#2534
A batch that ran into blocks this node had already imported stopped there: the import loop skipped every known block at or below the head, and a batch made only of stored blocks was rejected as an invalid chain, which dropped the peer that had served exactly the range it was asked for. The range stayed unimported, so every following sync asked for it again - on the reported node that was about 1000 blocks rewritten per 6 blocks of progress. Adopt a known block that wins the same fork choice an executed block would - blockBeatsHead, now shared with writeBlockWithState - instead of skipping it. A re-import only has to move the markers, so writeKnownBlock updates the head, the block hash cache and the signing-tx cache, refreshes the masternode set on a gap block, and announces the block the way the canonical import path does; a known block that loses fork choice is dropped from the future queue as well. reorg now leaves the new head to its caller, which is what lets writeKnownBlock be the single place that writes it; its own transactions stay in the rebirth set, and logs are published only for blocks that become canonical for the first time, so an adopted block's logs are not delivered twice. setHeadBeyondRoot asks the same gap-block predicate, so the head path and a rewind agree on which block refreshes the set. A known prefix followed by a block whose header or body does not verify stops the skip loop on that error, and the stop is left to the import loop's shared exit, which reports the block it ended on through the same table. Reporting it in both places would put the same bad block in the database twice: reportBlock overwrites the entry and increments its counter again. TestInsertChainReportsBadBlockBehindKnownPrefix lives with the commit that records the reject (report the error that stopped the import loop): this commit only adopts the block, and that case belongs with the behaviour it asserts. Refs XinFinOrg#2535
insertSideChain routes a batch whose first block has a pruned ancestor into the sidechain path. When its scan stopped on a block that was already stored, the batch was left where it was unless the whole tail was stored too, and the stall was hidden behind a debug-level ErrInsertionInterrupted - so the next sync asked for the same range again. Adopt the last stored block of the prefix instead - it was imported, only the head did not follow - and hand the rest of the batch back to insertChain, which executes it on top of the adopted state like any other batch. A batch raises a single head event, for the highest block that moved the head, so the adoption's event waits for that second import instead of announcing the head twice. A tail with nothing stored reports ErrLocalInsertCondition at warn level, so the downloader cancels the content processing rather than blaming the peer. The tail is remote data the scan never pulled a verification result for, so the batch's own verification level decides how it is imported: threading verifySeals through keeps out the blocks the engine knows how to reject, while blocks read back out of the local database are still re-imported with it off. Refs XinFinOrg#2535
A leftover of the insertion paths that predates this branch, in the same function the local total difficulty was hardened in: insertSideChain accumulated the segment's total difficulty with big.Int.Add, which dereferences its operands, so a parent whose record is not stored panicked in the middle of an import instead of failing the call. The read is now checked and reported as ErrLocalInsertCondition - the same answer getResultBlock gives for the same read - which keeps the failure out of the peer-blame and bad-block paths of the classification. Refs XinFinOrg#2534
insertSideChain detects a shadow-state attack by comparing the state root of a scanned block against the canonical block at that height: a sidechain block whose state already exists cannot have been executed by this node without its ancestors being verified. Re-importing a canonical block whose state was pruned matches the same roots, so the normal ending of a pruned segment was reported as an attack and the segment was refused. Check the hash first. An exact match is a re-import of a block this node imported itself, so the scan skips it - and carries the total difficulty of the last stored canonical block over, because the scan can pass several of them before it reaches the fork point; keeping only the first would drop the difficulty of every block in between and let a heavier sidechain lose a reorg it should have won. A batch that yields no sidechain block at all then carries no total difficulty to compare against, and added nothing to the chain either. Report it as the local condition it is - the missing numbers, not the blocks, are what stopped the segment - instead of comparing against a total difficulty that was never accumulated. Refs XinFinOrg#2535
The insertion paths read HasBlockAndFullState as "this node already did this work": ValidateBody turns it into ErrKnownBlock, insertSideChain adopts the prefix it covers, insertBlock returns early, and a file import resumes behind it. What it answers is only whether the root named by the header resolves in the trie database. writeBlockWithoutState stores a sidechain block without its state and without its receipts, so naming a root that happens to exist looks executed - and trie.New resolves no node at all for types.EmptyRootHash, so naming an empty root is enough. Such a block was then adopted as the head through the known-block path, without Process and ValidateState ever running on it. Ask for the receipts execution leaves behind as well. HasExecutedBlock is that question, and every call site that means "already done" now asks it: a block that is on disk with a resolvable root but without receipts is executed like any other, and ValidateState then rejects a state root its own execution does not reproduce. Nothing writes receipts without writing the state beside them, and nothing removes them without taking the body with them, so a block this node executed keeps its answer. Refs XinFinOrg#2534
notifyEpochSwitchBlock reported a header whose epoch switch the engine cannot read with reportBlock, which writes the block into the bad-block table - the inline epoch-switch checks in insertChain and insertBlock that the helper replaced did the same. No block this node stored can fail that read: engine_v2 decodes the very same extra fields in verifyHeader, outside its fullVerify gate, so a header that passed verification cannot fail it here, and engine_v1 never fails it at all. The report could therefore only ever name a block this node had already accepted and adopted, so log it instead and keep the reasoning on the helper the three paths ask, so the policy is stated once. Refs XinFinOrg#2534
A reorg rewrites the blocks between the common ancestor and the head its caller adopts, and it never signalled a checkpoint for them: the send lives in the import paths, which only ever see the block they process. An epoch switch block that becomes canonical as one of those intermediates therefore moved the head across the epoch boundary without waking the staking loop in cmd/XDC, which is what revalidates the consensus parameters and the masternode duty for the new epoch - it kept the previous epoch's parameters until the next epoch switch block was imported. A head that jumps over a stored prefix is exactly the shape a re-import of already stored blocks produces. Signal for every promoted block the engine reports as an epoch switch block. The signal coalesces, so a block that was canonical before the rewind signalling a second time costs nothing. isEpochSwitchBlock becomes the shared predicate and leaves a header it cannot decode to its caller: both callers only log it, see notifyEpochSwitchBlock for why. Refs XinFinOrg#2535
Three pre-existing defects in the insertion error paths, all in code this branch touched but did not introduce: - getResultBlock added the parent total difficulty with big.Int.Add, which dereferences a nil operand: a parent whose TD is not stored panicked instead of failing the call. It is reported as ErrLocalInsertCondition, so the classification does not blame the blocks for a record this node is missing. - insertSideChain's heavy segment reported index 0 both for a failed re-import and for the interruption after it. Every return of insertSideChain is relative to the batch the caller handed in, never to a segment rebuilt from stored ancestors, which have no offset into it, so the index is it.index. - insertChain shadowed the loop's err with the one from state.NewWithChainConfig, which made the ErrInsertionInterrupted assignment look like it wrote a variable nothing reads. Refs XinFinOrg#2534
Two leftovers of the insertion paths that predate this branch, both in the same function: - The head's total difficulty was read with GetTd and compared straight away. big.Int methods dereference their receiver, so a node whose own head has no record left - a pruned or truncated database - panicked in the middle of an import instead of failing the call. The read now goes through headTd, which reports the missing record as ErrLocalInsertCondition, so the classification keeps it away from the peer-blame and bad-block paths. insertSideChain reads its local total difficulty the same way, and the walk up for a stored ancestor gets the same sentinel for the same reason. - The ValidateBody fallback still called reportBlock directly, the only stop of the insertion paths that does not ask the classification. Behaviour is unchanged today - a body that fails validation is the block's fault - but the answer stays in the one place that owns it. Refs XinFinOrg#2534
A propagated block reaches the chain through InsertBlock, never through insertChain: eth/handler.go hands it to the fetcher, so the adoption that path performs for a known block was missing here. getResultBlock only reports ErrKnownBlock while the head sits at or above the block, so a head that stopped below an already executed block went on to re-execute it, and insertBlock then recognised the block as executed, threw the result away and reported success. Nothing moved the head until a full sync reassigned it, which is the stall the batch path no longer has. Adopt it through writeKnownBlock, the same call insertChain makes: it asks the fork choice writeBlockWithState asks, so the single-block path adopts exactly the chain the batch path would, and the head event is raised for the block that was adopted. HasExecutedBlock stays the guard: a block on disk without the receipts its execution leaves behind still has to go through writeBlockWithState. Refs XinFinOrg#2534
insertSideChain writes a pruned segment to disk as side entries and then compares its total difficulty against the head. When the segment stays below the head, this node refuses the reorg and returns early - and the error it returned was the one the scan had stopped on, normally ErrUnknownAncestor. Nothing about that verdict is the peer's: the segment linked, was verified and was written to disk, and this node decided - on totals it holds itself - not to switch to it. The downloader reads ErrUnknownAncestor as errInvalidChain and drops the peer that served exactly the range it was asked for, so wrap the refusal in ErrLocalInsertRefused. Refused rather than ErrLocalInsertCondition, because the head does not move: the same segment loses the same comparison on every retry, so procFutureBlocks has to evict a parked block for it instead of re-verifying it on every tick. The nil error is left as it was - a segment that ran out of blocks rather than into a failure was fully delivered - and the sidechain import tests rely on that shape. Refs XinFinOrg#2534
…e-running it insertBlock hands every block it is given to getResultBlock before it looks at whether this node already executed it. A block that is on disk with its receipts has nothing left to compute, but getResultBlock only reports ErrKnownBlock while the head sits at or above it: below that it runs the block in full - the whole EVM execution plus the state validation - and the adoption that follows throws the result away. That work is pure overhead, and the shape producing it is the one the adoption was added for: a crash or an interrupted rollback leaving the head below blocks that are already executed, with the fetcher still delivering them. Look the block up before that call instead, and adopt it when this node executed it, which skips the computation entirely. The look is taken without the chain mutex so that the execution stays out of the lock, and adoptExecutedBlock asks again under it - the answer the adoption acts on, so a block that turns out not to be executed is still run like any other. Pre-existing: the call order that turns the computation into waste has been there since the single-block path was written. It was found while reviewing the insertion-error work for XinFinOrg#2534. Refs XinFinOrg#2534
hasAllBlocks answered on the body alone, so a batch whose blocks this node only wrote as side entries - stored by writeBlockWithoutState without their receipts and state - was reported as already imported and skipped whole: the import returned success with the head left where it was and nothing of the batch executed. Historical issue rather than one introduced here: the precheck has been body-only since before this branch, and upstream geth draws the line the same way there (no upstream fix to port). The CLI importer was already sharpened to the executed-block rule by cmd/utils.missingBlocks in this branch; ask the same question here, with the same head-aware split - below the head the state is available at the head, so a body on disk is what says the block was imported, while at or above it HasExecutedBlock is. Refs XinFinOrg#2534
reorg reports errInvalidOldChain/errInvalidNewChain when it reads a record of the chain - a block, or a header whose marker it has just written - and does not find it. That is this node's own chain disagreeing with itself, not anything about the blocks it was handed. classifyInsertErr did not know either sentinel, so the default class applied: the block's fault, not local. The downloader turned them into errInvalidChain and dropped the peer that served the batch, while the known-block path wrapped the very same reorg failure in ErrLocalInsertRefused, which is local and does not blame the peer - one failure, two opposite conclusions depending on which adoption path reached reorg. Register both in the table as local and not retryable, beside ErrLocalInsertRefused and for the same reason: the head does not move, so the same read sees the same records on the next attempt. DescribeLocalInsertFailure reports them as an inconsistent local chain rather than as an interruption, because that is what an operator has to act on. This is a pre-existing gap rather than one the adoption work introduced: upstream go-ethereum carries the same two sentinels, raises them on the same reads and hands them up unwrapped as well, so the fix lands as its own commit instead of folded into the commit that added the classification table. Upstream has no counterpart fix - it has no classification to register them in - so there is no geth PR to cite.
…me path writeBlockWithState reads the parent's total difficulty and answers consensus.ErrUnknownAncestor whenever the read comes back empty, which folds two different things into one answer: a parent this node does not hold, and a parent it does hold while the record beside it is gone. Only the second reaches this point - the verifiers ask for the parent's header first, and XDPoS answers ErrUnknownAncestor there for a parent it does not have - and it is the same read insertSideChain already reports as ErrLocalInsertCondition. The classification is what decides who pays for it: ErrUnknownAncestor is not local, so the downloader turns it into errInvalidChain and drops the peer that served the batch. No peer can supply the record that is missing, so the next one is dropped for the same batch, and the one after that. A parent whose header is gone keeps the old answer - that one is the peer's to answer for. Historical issue rather than one introduced here: the read and its answer are unchanged since before this branch. Upstream geth asks for the parent's header there and never for its total difficulty, so it cannot carry the same confusion and there is no geth PR to cite. This does not make the import succeed: a record this node has lost is not one a retry repairs, so the batch stops with the local condition reported instead of a peer being dropped for it. Refs XinFinOrg#2535


Closes #2534
Closes #2535
Five pre-existing defects turned up while fixing these and are carried by this PR: A14 — a total difficulty this node does not hold was dereferenced instead of reported — A17 — a block could pass for done without ever having been executed — A18, the admin importer's precheck — A19, the chain's own records disagreeing with each other charged to the peer — and A20, a parent whose total-difficulty record is gone answered as
ErrUnknownAncestor, so the peer that served the batch was dropped for a record no peer can supply. All five sit in the same "a stored block cannot move the head" area, and A17 is what makes the known-block classification trustworthy. Everything else that turned up is deliberately out of scope; see Impact Scope.Background
Both issues are the same stall, reported from two ends on a mainnet archive node (
--syncmode full --gcmode archive, v2.9.0-devnet).#2534 — a truncated import was reported as a success.
insertChainreturnednilfor a batch that had stopped in the middle. The downloader read that as "the batch is done", advanced past blocks this node never received, and only failed one batch later on an unrelated symptom (handle proposed block has error ... block not found), with the head stuck below blocks that were already on disk.findAncestorthen treats those stored blocks as a valid common ancestor, so the skipped range is never requested again: the node stops making progress with no error, no retry and no peer drop to explain it.#2535 — a stored block could not move the head. A block already stored together with its state but sitting above the head never became the head. There was no
writeKnownBlockand noskipBlockequivalent, so the import loop skipped it, and every following sync re-downloaded the same range: 58 minutes bought 5114 blocks, with roughly 1000 blocks rewritten per 6 blocks of actual progress.Dropping the peer is an aggravating factor rather than the reported symptom: an unclassified local failure was wrapped into
errInvalidChain, so the node not only stalled but also lost the peers that had served perfectly valid batches. Fixing the classification is what makes the stall recoverable instead of terminal.Changes
24 commits — the classification refactor first, then one commit per defect (the four commits about a total-difficulty record this node cannot read take one each) — so the branch can be reviewed incrementally and taken apart one change at a time. Every defect below was confirmed to still exist on the base
dev-upgradebefore it was fixed, with the one exception called out in the table: A13's carry-over half is introduced and fixed inside this branch. Otherwise this is a fix for pre-existing behaviour, not a self-correction of code introduced here.#2534 — a truncated import was reported as a success (8)
insertChainentry guard returns0,nil,nil,nilwhen the chain is terminatingInterruptInsert, and the trailingreturn ..., nilis alwaysnilInsertReceiptChainreturns0, nilon interruptioninsertSideChainonly reports the pruned-prefix rebuild and never looks at the rest of the batch; an interrupted segment also returns0,nil,nil,nilerrInsertionInterrupted/errChainStoppedare unexported and there is noIsLocalInsertError, so the downloader wraps every unclassified error intoerrInvalidChainand drops the peerErrUnknownAncestor, so a batch whose later block reportsErrFutureBlockfails as a wholeaddFutureBlockreturns a plainfmt.Errorfpast the 30s window, which is then blamed on the peerhandleProposedBlockwith the batch tail#2535 — a stored block could not move the head (6)
current >= block.NumberU64(); there is nowriteKnownBlockand noblockBeatsHead/skipBlockreorgwrites the new head itself, unlike upstream go-ethereumCheckpointChis unbuffered, so an absent receiver blocks the whole import pathRoot()only and not the hash, so re-importing our own canonical block is mistaken for an attackexternTdis read only once, throughif externTd == nil(basecore/blockchain.go:2080-2081), so the baseline the segment accumulates onto is set by whichever block first reaches that branch rather than by the fork point. The total-difficulty carry-over this PR adds for a canonical re-import lives in that same branch, and its first form kept an unreadable record in place — weighing the segment from below the fork point instead of reporting it. That second half is introduced and fixed inside this branch, so it is the one entry here that was not a defect of the baseprocFutureBlockscallsHandleProposedBlockfor the sorted queue tail instead of the block that actually advanced the headFound in the same area (4)
HasBlockAndFullStateis read as "this node already executed the block", but it only asks whether the header's state root resolves in the trie database.writeBlockWithoutStatestores a sidechain block with no state and no receipts, so a block naming a root that happens to exist —types.EmptyRootHashincluded, for whichnewTrieReaderreturns before it ever opens the database (trie/trie_reader.go:44-50) andtrie.Newskips the resolution altogether (trie/trie.go:123) — was adopted as the head through the known-block path, withProcessandValidateStatenever running on ithasAllBlocks, the precheckadmin_importChainruns on every batch, asked only whether the body is on disk. A batch whose blocks this node wrote as side entries was therefore reported as already imported and skipped whole: the import returned success with nothing run and the head left where it waserrInvalidOldChain/errInvalidNewChain—reorgreading a record of the chain it does not find — were not registered in the classification, so the default class applied and the downloader turned them intoerrInvalidChainand dropped the peer: this node's own chain disagreeing with itself was charged to whoever served the batch. The known-block path wraps the very samereorgfailure inErrLocalInsertRefused, which is local, so one failure reached two opposite conclusions depending on which adoption path found it. Both are local and not retryable now, andDescribeLocalInsertFailurenames them as an inconsistent local chain rather than an interruption, which is what an operator has to act onwriteBlockWithStateread the parent's total difficulty and answeredconsensus.ErrUnknownAncestorwhenever it came back empty (basecore/blockchain.go:1502-1504), folding two different things into one answer: a parent this node does not hold, and a parent it holds while the record beside it is gone. Only the second reaches that read — the verifiers ask for the parent's header first, and XDPoS answersErrUnknownAncestorthere for a parent it does not have — so the peer was dropped for a record no peer can supply, and the next peer for the same batch. The parent's header decides now: gone with it,ErrUnknownAncestoras before; still there, the same local conditioninsertSideChainreports for the same readAlso fixed here — a nil total difficulty was dereferenced instead of reported (A14, 3)
A total difficulty this node does not hold is reported as
ErrLocalInsertConditionnow, at the three sites that used to panic on it. All three were verified on the base branch, where the dereference happens in the middle of an import that the caller could otherwise have retried:core/blockchain.go:2083if externTd == nil { externTd = bc.GetTd(block.ParentHash(), ...) }followed bynew(big.Int).Add(externTd, block.Difficulty())externTdnil and theAdddereferences it:2103localTd := bc.GetTd(bc.CurrentBlock().Hash(), current)thenlocalTd.Cmp(externTd):2234localTd := bc.GetTd(...)andnew(big.Int).Add(bc.GetTd(block.ParentHash(), ...), block.Difficulty())getResultBlock's competitor branch: either read can come back nilOne commit per site — guard a missing parent TD and fix the sidechain index, harden
getResultBlockagainst missing total difficulty, guard the sidechain scan against a missing parent TD. The report is a local condition because the record lives in this node's database rather than in the block, so the failure stays out of the peer-blame and bad-block paths of the classification.Single-block path, same area (2)
The adoption above is repeated on the entry point the fetcher uses, which never goes through
insertChain:getResultBlockand having the result thrown away.Design notes
The classification is one table, not another list.
insertErrClassincore/blockchain_insert_err.goanswers, per sentinel, the three questions the insertion paths ask — may a parked block be retried, may the peer be blamed, must the block be recorded as a bad block — andclassifyInsertErrreads it. Adding a sentinel is one row instead of a fourth hand-kepterrors.Islist.ErrLocalInsertRefused,ErrLocalInsertAheadOfClockand the two chain-record sentinels were added this way.ErrUnknownAncestorstays out of the local set on purpose: a batch that cannot be linked to our chain is something the peer can be held accountable for. What a missing record earns is decided by what is actually missing: a parent whose header is gone keepsErrUnknownAncestor(A20), while a parent that is there with its total difficulty gone is the local conditioninsertSideChainalready reports for the same read.The retryable flag is reserved for the condition that heals.
consensus.ErrFutureBlockis absent fromIsLocalInsertErrorfor a different reason — insideinsertChainevery future block is consumed byqueueFutureTail, and the one path that can still hand it out wraps it intoErrLocalInsertAheadOfClock, the one local condition a parked block may wait for. Every other local condition — a reorg this node refuses, a chain whose records disagree with each other, a record this node is missing — is registered as not retryable, soprocFutureBlocksevicts the parked block instead of re-verifying it on everyfutureBlocksLooptick — which is what charging all of them toErrLocalInsertConditionhad been doing at 10 Hz, with one Debug line to show for it.blockBeatsHeadis shared withwriteBlockWithStateon purpose. A known block must never be adopted under a rule an executed block would have lost, otherwise re-delivering a batch could move the head onto a branch that is not canonical. Upstream has no counterpart because itsskipBlockdecides from snapshot availability, not fork choice; XDPoS batches can be re-delivered on a branch that must not win.reorgno longer writes the new head, matching upstream: its callers already did, so the write insidereorgwas a duplicate that also forced the marker cleanup to keep a marker that should have been deleted.writeKnownBlockfollows the same contract.The checkpoint signal is a coalescing wake-up, not a queue.
CheckpointChis buffered for one pending signal andSignalCheckpointnever blocks. The receiver re-reads the chain head when it runs, so dropping a duplicate loses nothing — whereas blocking, with the chain mutex held, would stall block import whenever the staking loop is busy.HasExecutedBlock(A17) asks for the receipts as well as the state, because "the state root resolves" is not "this node executed the block". Nothing writes receipts without writing the state beside them except a fast sync, which writes no state at all; nothing removes receipts without taking the body with them. The consequence is fail-closed: a block on disk with a resolvable root but no receipts is executed like any other, andValidateStaterejects the root its own execution does not reproduce.The downloader learns the classification through its own
BlockChaininterface instead of importingcore, which keepscoreout of the downloader's dependency tree;make baddepsnow enforces that.Commit granularity and revertibility
Each commit was checked by reverting it alone against the branch tip, building it, and running the short tests of the packages this PR touches — first with the commit's own tests taken out along with it, then again with them restored, so that a revert that only looks clean is told apart from one its own assertions catch.
21 of the 24 revert cleanly. The three that do not are all apply-time conflicts — none of them is a build failure. They are:
core/blockchain_insert_err.go, its test andcore/blockchain_testfixture_test.go, and later commits rewrite all three, so the revert hits modify/delete conflicts on those files, a content conflict incore/blockchain.goand one ineth/downloader/downloader_test.go;core/blockchain.gofor both;core/blockchain_insert_err.go, its test andeth/downloader/downloader.gofor the first).The 21 that revert cleanly are not all equally safe to take apart: 15 fail their own tests as soon as the fix is gone; two — adopt a known block that wins fork choice and signal the checkpoint on a reorg over an epoch switch block — leave those tests unable to build, because they are written against an API the commit introduces (
blockBeatsHead's second return value,isEpochSwitchBlock), which is a stronger pin than a failing assertion; one — signal the checkpoint without blocking import — leaves thecoretests hanging until the 150 s timeout, which is the stall the fix removes seen from the test side; one — guard a missing parent TD and fix the sidechain index — has no test of its own and reverts in silence; and two — hardengetResultBlockagainst missing total difficulty and adopt a known block on the single-block import path — still pass with their tests restored, so the suite does not distinguish them.That set is a property of the tip rather than of the commits, and it is re-measured each time the branch is restructured — the previous revision had a different one. A commit only becomes revertible by ending up above everything that rewrites it; a commit that must stay revertible cannot be rewritten underneath. Undoing one of the three is done by reverting the commits above it first, or by rebasing it out.
To keep the neighbouring fixes independently revertible, the classification commit both moves the code the later ones land on —
getResultBlockreads the competitor's total difficulty before the head's, andinsertBlockasksblockAlreadyImportedinstead of spelling the predicate out at the call site — and lands the loop's report of the block its error stopped on itself, rather than leaving the fix that needs it to insert that block against the future-block gate's comment, which a later commit rewrites. The moved statements are the same code the branch ran before, in another order.Verification
Tests are the bulk of the diff: 37 test files,
+5386/-1; the other 11 files carry+1846/-196. Each commit carries the tests for its own defect — the largest beingcore/blockchain_knownblock_test.go,core/blockchain_sidechain_prefix_test.go,core/blockchain_testfixture_test.go,eth/downloader/downloader_proposedhandler_test.go,eth/downloader/downloader_localinsert_test.go,core/blockchain_insert_err_test.go,core/blockchain_sidechain_empty_test.goandeth/api_admin_test.go.make allat every commitmake quick-testat the commits whose content changedmake allat the tipmake quick-testat the tipgo vet ./core/... ./eth/... ./cmd/... ./miner/...gofmt/goimportson every modified filemake tidymake generatemake baddepsmake baddepsreportscore/rawdb -> ethdb/leveldb. It is on the base branch as well — verified there — and is unrelated to this PR. The rule this PR adds for the downloader passes for the shipped code;eth/downloader's test files do importcore, which is reported as a warning because it never reaches the binary. The same run also warns that the pre-existingcore/rawdb -> ethdb/pebbledbrule matches neither build: the rule and the missing dependency are the base branch's, the check that now says so is this PR's.Negative checks were run for the cases that came out of review, so that each fix is pinned to its own assertion rather than to the surrounding behaviour:
hasAllBlocksto a body-only check makesTestHasAllBlocksAsksForAnExecutedBlockfail;TestInsertSideChainDoesNotCarryAnUnreadableBaselinefail;ErrLocalInsertConditionback into the retryable group makesTestClassifyInsertErr/local_insert_conditionandTestProcFutureBlocksEvictsParkedBlockOnLocalInsertConditionfail, which is the pair the retryable/not-retryable split hangs on.Local private-network run
A three-signer XDPoS chain from its genesis block (
epoch 900,gap 450,period 2s, v2 from block 0), plus an observer, driven for 38 minutes over 1107 blocks on this branch. It exercises the paths this PR changes, and it is where B1 and B2 of TODO turned up (both re-observed there).Block synchronisation started, segments of 15 and 34 blocks, then block by block up to the network headadmin_exportChain/admin_importChainround tripblocks=90executed,ignored=151, 98 ms), the second was answered byhasAllBlocksand skipped whole in 10 msCheckpoint!!!andUpdate consensus parameters, and the 2.00 s cadence is unchanged across the boundarydebug.setHeadon the observer, three timesThe last row is the shape both issues are about. It is built by writing the head marker back on a stopped node (
XDC db put … LastBlock <hash>) and leaving the blocks above it on disk with their receipts;debug.setHeadcannot produce it, becausesetHeadBeyondRootdeletes the bodies and receipts it rewinds over.loadLastStatethen reads the lower head, and the node has to take up blocks it already executed. Both columns are the same datadir and the same target block (826), observed for 60 s each:dev-upgradeImported new chain segmentWriting previously known block— the stored copies, not run againFail to processQCandprocessQC Block not found14 each,verifyQCandgetEpochSwitchInfo9 each, 9 bad-block dumps — plusSynchronisation terminatedandhandle proposed block has error14 eachError: unknown ancestor, although the network has it as canonical; the next process to open the database reportsBad blocks in db count=1So the base does not merely leave the head behind: every sync attempt terminates on the same range, the head never moves again, and a block the rest of the network accepted is written into the bad-block table, where it outlives the run. That is #2534 seen from the other end, and the row above is the fix working. The bad-block row is evidence about the base rather than a comparison — the branch is not given the chance to write one, because it never reaches the state that produces it. The run predates the last restructure of the branch, which changed a comment and no code.
Impact Scope
Functional changes are concentrated in
core/blockchain.go(+1313/-156) andeth/downloader/downloader.go(+151/-17);eth/api_admin.go(+43/-2) carries the follow-on adaptation plus the head-aware precheck below;cmd/utils/cmd.go(+25/-2),cmd/XDC/main.go(+4/-4),ethclient/simulated/backend.go(+39/-2),accounts/abi/bind/backends/simulated.go(+17/-0),core/block_validator.go(+11/-2) andminer/worker.go(+4/-1) are the same kind of adaptation.build/ci.go(+61/-10) adds one dependency rule and the check that reports a rule matching neither build. Two new files carry the classification:core/blockchain_insert_err.goand its test.Interface change:
eth/downloader.BlockChaingainsIsLocalInsertError(error) bool.New exported API in
core:ErrInsertionInterrupted,ErrChainStopped,ErrLocalInsertCondition,ErrLocalInsertAheadOfClock,ErrLocalInsertRefused,IsLocalInsertError,DescribeLocalInsertFailure,SignalCheckpoint,HasExecutedBlock; the first two replace the unexportederrInsertionInterrupted/errChainStopped.Behaviour changes:
IsLocalInsertErrorclassifies, includingErrKnownBlockandconsensus.ErrPrunedAncestor, which previously dropped the peer;admin_importChainreport an interruption instead of blaming the file, and the simulated backend no longer panics when the chain it commits to was stopped;futureBlocksLooptick — a reorg this node refuses, the chain's own records disagreeing (A19), a total-difficulty record this node is missing (A20);ErrLocalInsertAheadOfClockrather than the catch-allErrLocalInsertCondition;consensus.ErrUnknownAncestor, so the peer that served the batch is no longer dropped for a record no peer can supply (A20);ProcessandValidateState(A17);admin_importChainweighs a batch by the same line the CLI importer already drew — a body below the head,HasExecutedBlockat or above it — so blocks this node only stored as side entries are imported instead of being skipped with the head left where it was (A18). It weighs the batch; it is not a head-recovery path - see Out of scope below.Also worth a look during review:
reorgno longer publishes rebirth logs for blocks that were already canonical, so an adopted block's logs are not delivered twice; andwriteKnownBlockrefreshes the masternode set on a gap block, which can now happen on a path that previously never reached it.Out of scope: A15 — the verified-header cache is keyed by header hash alone and never records the level it was filled at, so a header that passed at
fullVerify=falseshort-circuits a laterfullVerify=truerequest. Checked line by line on both engines:engine_v1(engine.go)engine_v2(engine.go+verifyHeader.go):50*lru.Cache[common.Hash, struct{}], built at:103:44, built at:116verifyHeaderWithCache:138;return nilat:139-141verifyHeader,verifyHeader.go:27;return nilat:28-30:144, afterverifyHeaderreturns nilverifyHeader.go:206, at the end ofverifyHeaderverifyHeader:175(validator signature, block time) and:168-170(testnet forcesfalse);verifyCascadingFields:256;verifySeal:657:43; masternode and penalty check:138The cached value is
struct{}, so nothing in the cache can tell a header checked atfalsefrom one checked attrue; the two engines differ only in where the lookup sits — v1 wrapsverifyHeaderinverifyHeaderWithCache, v2 inlines both inverifyHeader.fullVerify=falseis reachable here:core/blockchain.gocallsinsertChain(..., false)for the winner re-import ingetResultBlockand for the rebuilt prefix ininsertSideChain, so the shorter check does get cached.Both engine files are identical to the base branch, so A15 is pre-existing code whose fix stays in its own change (#2570). This PR's only contact with that area is handing
insertSideChainthe batch'sverifySealslevel for the tail the scan never looked at, instead of always re-importing with verification off; the cache itself is untouched.The importers are not head-recovery tools. A block this node executed at or above a head that stops below it answers as imported, so
XDC importandadmin_importChainskip that range without running anything and without moving the head (the CLI half is additionally blocked by B1 below). The adoption paths this PR adds are reached by the sync paths instead - the batch importer the downloader drives, andinsertBlockfor a propagated block - which is what Local private-network run above exercises.Notes for review
Merge order. Two of the defects listed above are not fixed here: the
downloadingBlockmarks that outlive the import (which short-circuits the known-block adoption added by this PR, since that check runs first) andPrepareBlock's cache keys. Both root fixes live on their own branches and both come with tests:downloadingBlockmarks are only added, never removedfix-downloading-block-marksPrepareBlockreads its caches with a different key than it writes themfix-prepared-block-cache-keyfix-downloading-block-marksshould merge before this PR, because the known-block adoption paths this PR adds sit behind that check — landing this one first leaves them unreachable for blocks the downloader recently imported.TODO
Two defects turned up while running this branch on a private network (see Verification / Local private-network run), and both were re-observed on the current revision. Both are pre-existing, neither is fixed here, and each wants its own issue. The files that carry them are untouched by this PR.
XDC importcannot open the chain database at all, whatever the--datadircmd/XDC/chaincmd.go:216builds the node withmakeFullNode, whoseRegisterEthServicecallseth.Newand opens the chain database;:222then hasutils.MakeChainopen the same database a second timeXDC importalways ends inFatal: Could not open database: resource temporarily unavailable— leveldb takes the database exclusively, so the second open fails. ThreeAllocated cache and file handleslines for the samechaindatain one run, the third immediately before the fatal.XDC exportis unaffected because it opens read-only (:294/:297). Upstream draws this line differently: itsimportChainusesmakeConfigNode(cmd/geth/chaincmd.go:367), so no service is registered and the database is opened once — the fork'simportChainis the only chain command here that builds the node withmakeFullNodeconsensus/XDPoS/engines/engine_v2/engine.go:1253ERROR [FindParentBlockToAssign] Can not find parent block from highestQC proposedBlockInfolines perdebug.setHead, clearing by themselves within 1–2 s:XDPoS.FindParentBlockToAssignfalls back to the current block, so the miner keeps working and nothing stalls. Only the rewound node logs it. The level is the part that is wrong — a state the node repairs on its own should not read as a fault. The engine file is identical to the base branch, and the call site inminer/worker.go(:766) is not one of the lines this PR changesThe rest of what is still open is in the unchecked boxes of Self-check checklist: a multi-epoch private-network run, a devnet/testnet end-to-end run, and co-existence with nodes on the previous version.
Self-check checklist
Proposed changes
Types of changes —
_Put an ✅ in the boxes that apply_Impacted components —
_Put an ✅ in the boxes that apply_Checklist —
_Put an ✅ in the boxes once you have confirmed below actions (or provide reasons on not doing so)_Refs XinFinOrg#2534/Refs XinFinOrg#2535)gofmtandgoimportsrun on every modified filemake allbuilds every command, at every commit (24/24) and at the tipmake quick-testpasses at the tip and at every commit whose content changedmake tidyandmake generatereport no changes