Skip to content

fix(core,eth): stop full-sync stalls and peer drops on stored blocks, truncated and admin imports, close #2534 #2535 - #2566

Open
gzliudan wants to merge 24 commits into
XinFinOrg:dev-upgradefrom
gzliudan:fix-issue-2534-2535
Open

gzliudan wants to merge 24 commits into
XinFinOrg:dev-upgradefrom
gzliudan:fix-issue-2534-2535

Conversation

@gzliudan

@gzliudan gzliudan commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

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. insertChain returned nil for 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. findAncestor then 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 writeKnownBlock and no skipBlock equivalent, 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-upgrade before 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)

# Defect
A1 The insertChain entry guard returns 0,nil,nil,nil when the chain is terminating
A2 The import loop only breaks on InterruptInsert, and the trailing return ..., nil is always nil
A3 InsertReceiptChain returns 0, nil on interruption
A4 insertSideChain only reports the pruned-prefix rebuild and never looks at the rest of the batch; an interrupted segment also returns 0,nil,nil,nil
A5 errInsertionInterrupted / errChainStopped are unexported and there is no IsLocalInsertError, so the downloader wraps every unclassified error into errInvalidChain and drops the peer
A7 The future tail only accepts ErrUnknownAncestor, so a batch whose later block reports ErrFutureBlock fails as a whole
A8 addFutureBlock returns a plain fmt.Errorf past the 30s window, which is then blamed on the peer
A9 The downloader unconditionally calls handleProposedBlock with the batch tail

#2535 — a stored block could not move the head (6)

# Defect
A6 Known blocks are only skipped while current >= block.NumberU64(); there is no writeKnownBlock and no blockBeatsHead / skipBlock
A16 reorg writes the new head itself, unlike upstream go-ethereum
A11 CheckpointCh is unbuffered, so an absent receiver blocks the whole import path
A12 The sidechain ghost-state check compares Root() only and not the hash, so re-importing our own canonical block is mistaken for an attack
A13 externTd is read only once, through if externTd == nil (base core/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 base
A10 procFutureBlocks calls HandleProposedBlock for the sorted queue tail instead of the block that actually advanced the head

Found in the same area (4)

# Defect
A17 HasBlockAndFullState is read as "this node already executed the block", but it only asks whether the header's state root resolves in the trie database. writeBlockWithoutState stores a sidechain block with no state and no receipts, so a block naming a root that happens to exist — types.EmptyRootHash included, for which newTrieReader returns before it ever opens the database (trie/trie_reader.go:44-50) and trie.New skips the resolution altogether (trie/trie.go:123) — was adopted as the head through the known-block path, with Process and ValidateState never running on it
A18 hasAllBlocks, the precheck admin_importChain runs 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 was
A19 errInvalidOldChain / errInvalidNewChainreorg reading 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 into errInvalidChain and 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 same reorg failure in ErrLocalInsertRefused, which is local, so one failure reached two opposite conclusions depending on which adoption path found it. Both are local and not retryable now, and DescribeLocalInsertFailure names them as an inconsistent local chain rather than an interruption, which is what an operator has to act on
A20 writeBlockWithState read the parent's total difficulty and answered consensus.ErrUnknownAncestor whenever it came back empty (base core/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 answers ErrUnknownAncestor there 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, ErrUnknownAncestor as before; still there, the same local condition insertSideChain reports for the same read

Also fixed here — a nil total difficulty was dereferenced instead of reported (A14, 3)

A total difficulty this node does not hold is reported as ErrLocalInsertCondition now, 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:

Base core/blockchain.go Code Where
:2083 if externTd == nil { externTd = bc.GetTd(block.ParentHash(), ...) } followed by new(big.Int).Add(externTd, block.Difficulty()) the sidechain scan's accumulation: a parent record that cannot be read leaves externTd nil and the Add dereferences it
:2103 localTd := bc.GetTd(bc.CurrentBlock().Hash(), current) then localTd.Cmp(externTd) the head's own record; a missing one panics in the comparison
:2234 localTd := bc.GetTd(...) and new(big.Int).Add(bc.GetTd(block.ParentHash(), ...), block.Difficulty()) getResultBlock's competitor branch: either read can come back nil

One commit per site — guard a missing parent TD and fix the sidechain index, harden getResultBlock against 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:

  • a known block above the head is adopted there too, with the same fork-choice rule the batch path applies;
  • a block this node already executed is answered from the stored copy, instead of being run again by getResultBlock and having the result thrown away.

Design notes

The classification is one table, not another list. insertErrClass in core/blockchain_insert_err.go answers, 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 — and classifyInsertErr reads it. Adding a sentinel is one row instead of a fourth hand-kept errors.Is list. ErrLocalInsertRefused, ErrLocalInsertAheadOfClock and the two chain-record sentinels were added this way.

ErrUnknownAncestor stays 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 keeps ErrUnknownAncestor (A20), while a parent that is there with its total difficulty gone is the local condition insertSideChain already reports for the same read.

The retryable flag is reserved for the condition that heals. consensus.ErrFutureBlock is absent from IsLocalInsertError for a different reason — inside insertChain every future block is consumed by queueFutureTail, and the one path that can still hand it out wraps it into ErrLocalInsertAheadOfClock, 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, so procFutureBlocks evicts the parked block instead of re-verifying it on every futureBlocksLoop tick — which is what charging all of them to ErrLocalInsertCondition had been doing at 10 Hz, with one Debug line to show for it.

blockBeatsHead is shared with writeBlockWithState on 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 its skipBlock decides from snapshot availability, not fork choice; XDPoS batches can be re-delivered on a branch that must not win.

reorg no longer writes the new head, matching upstream: its callers already did, so the write inside reorg was a duplicate that also forced the marker cleanup to keep a marker that should have been deleted. writeKnownBlock follows the same contract.

The checkpoint signal is a coalescing wake-up, not a queue. CheckpointCh is buffered for one pending signal and SignalCheckpoint never 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, and ValidateState rejects the root its own execution does not reproduce.

The downloader learns the classification through its own BlockChain interface instead of importing core, which keeps core out of the downloader's dependency tree; make baddeps now 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:

  • the classification commit — it creates core/blockchain_insert_err.go, its test and core/blockchain_testfixture_test.go, and later commits rewrite all three, so the revert hits modify/delete conflicts on those files, a content conflict in core/blockchain.go and one in eth/downloader/downloader_test.go;
  • stop blaming peers for local failures and adopt the stored prefix of a sidechain segment — each is rewritten in place by the commits above it (core/blockchain.go for both; core/blockchain_insert_err.go, its test and eth/downloader/downloader.go for 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 the core tests 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 — harden getResultBlock against 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 — 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 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 being core/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.go and eth/api_admin_test.go.

Check Result
make all at every commit 24/24 pass
make quick-test at the commits whose content changed pass (exit 0)
make all at the tip pass
make quick-test at the tip pass (exit 0)
go vet ./core/... ./eth/... ./cmd/... ./miner/... no output
gofmt / goimports on every modified file no output
make tidy "No untidy module files detected"
make generate "No stale files detected"
make baddeps 1 violation — see below

make baddeps reports core/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 import core, which is reported as a warning because it never reaches the binary. The same run also warns that the pre-existing core/rawdb -> ethdb/pebbledb rule 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:

  • reverting hasAllBlocks to a body-only check makes TestHasAllBlocksAsksForAnExecutedBlock fail;
  • reverting the total-difficulty carry-over to keep a stale value makes TestInsertSideChainDoesNotCarryAnUnreadableBaseline fail;
  • putting ErrLocalInsertCondition back into the retryable group makes TestClassifyInsertErr/local_insert_condition and TestProcFutureBlocksEvictsParkedBlockOnLocalInsertCondition fail, 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).

Scenario Result
Three masters mining, observer joining afterwards pass — steady 2.00 s/block; 206 of the 214 samples on which all three masters answered put them at the same height, the other eight being ±1 sampling skew plus the restart window
Observer syncing from scratch through the downloader pass — Block synchronisation started, segments of 15 and 34 blocks, then block by block up to the network head
admin_exportChain / admin_importChain round trip pass — the first import ran the blocks above the head (blocks=90 executed, ignored=151, 98 ms), the second was answered by hasAllBlocks and skipped whole in 10 ms
Epoch switch (block 893, round 900) pass — all three signers and the observer logged Checkpoint!!! and Update consensus parameters, and the 2.00 s cadence is unchanged across the boundary
A master stopped for 45 s and restarted pass — caught up in 12 s; the slower blocks during the outage (6.8 s/block) are the expected timeouts of two-of-three signing
debug.setHead on the observer, three times pass — the stored blocks were re-imported within a second or two; the ERROR lines it leaves are B2
Head marker lowered on a stopped node while the blocks above it stay on disk, base vs this branch base stalls permanently, this branch recovers — see below

The 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.setHead cannot produce it, because setHeadBeyondRoot deletes the bodies and receipts it rewinds over. loadLastState then 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:

base dev-upgrade this branch
head after 4 s / after 59 s (network at 1013 → 1043) 826 after 4 s, 826 after 59 s — unmoved 826 after 2 s, 982 after 4 s = the network head
time to recover never 4 s
Imported new chain segment none one batch of 82 blocks in 27.8 ms, then block by block
blocks taken up 152, through Writing previously known block — the stored copies, not run again
error log 55 ERROR in 59 s — Fail to processQC and processQC Block not found 14 each, verifyQC and getEpochSwitchInfo 9 each, 9 bad-block dumps — plus Synchronisation terminated and handle proposed block has error 14 each none
bad blocks block 1019 written to the bad-block table with Error: unknown ancestor, although the network has it as canonical; the next process to open the database reports Bad blocks in db count=1 none

So 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) and eth/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) and miner/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.go and its test.

Interface change: eth/downloader.BlockChain gains IsLocalInsertError(error) bool.

New exported API in core: ErrInsertionInterrupted, ErrChainStopped, ErrLocalInsertCondition, ErrLocalInsertAheadOfClock, ErrLocalInsertRefused, IsLocalInsertError, DescribeLocalInsertFailure, SignalCheckpoint, HasExecutedBlock; the first two replace the unexported errInsertionInterrupted / errChainStopped.

Behaviour changes:

  • the downloader cancels content processing — keeping the peer — for every error IsLocalInsertError classifies, including ErrKnownBlock and consensus.ErrPrunedAncestor, which previously dropped the peer;
  • the file importer and admin_importChain report an interruption instead of blaming the file, and the simulated backend no longer panics when the chain it commits to was stopped;
  • a local condition a retry cannot repair is registered as not retryable, so the parked block is evicted rather than re-verified on every futureBlocksLoop tick — a reorg this node refuses, the chain's own records disagreeing (A19), a total-difficulty record this node is missing (A20);
  • only a block dated ahead of this node's clock stays parked: it is the one local condition that heals, and it now carries ErrLocalInsertAheadOfClock rather than the catch-all ErrLocalInsertCondition;
  • a parent whose header is on disk while its total-difficulty record is gone stops being consensus.ErrUnknownAncestor, so the peer that served the batch is no longer dropped for a record no peer can supply (A20);
  • a block on disk without the receipts of its own execution is no longer taken for executed: it goes through Process and ValidateState (A17);
  • admin_importChain weighs a batch by the same line the CLI importer already drew — a body below the head, HasExecutedBlock at 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: reorg no longer publishes rebirth logs for blocks that were already canonical, so an adopted block's logs are not delivered twice; and writeKnownBlock refreshes 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=false short-circuits a later fullVerify=true request. Checked line by line on both engines:

engine_v1 (engine.go) engine_v2 (engine.go + verifyHeader.go)
cache field :50 *lru.Cache[common.Hash, struct{}], built at :103 :44, built at :116
lookup in verifyHeaderWithCache :138; return nil at :139-141 inline in verifyHeader, verifyHeader.go:27; return nil at :28-30
store :144, after verifyHeader returns nil verifyHeader.go:206, at the end of verifyHeader
what the level steers verifyHeader :175 (validator signature, block time) and :168-170 (testnet forces false); verifyCascadingFields :256; verifySeal :657 timestamp check :43; masternode and penalty check :138

The cached value is struct{}, so nothing in the cache can tell a header checked at false from one checked at true; the two engines differ only in where the lookup sits — v1 wraps verifyHeader in verifyHeaderWithCache, v2 inlines both in verifyHeader. fullVerify=false is reachable here: core/blockchain.go calls insertChain(..., false) for the winner re-import in getResultBlock and for the rebuilt prefix in insertSideChain, 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 insertSideChain the batch's verifySeals level 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 import and admin_importChain skip 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, and insertBlock for 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 downloadingBlock marks that outlive the import (which short-circuits the known-block adoption added by this PR, since that check runs first) and PrepareBlock's cache keys. Both root fixes live on their own branches and both come with tests:

Defect Root fix
downloadingBlock marks are only added, never removed fix-downloading-block-marks
PrepareBlock reads its caches with a different key than it writes them fix-prepared-block-cache-key

fix-downloading-block-marks should 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.

# Defect Where Observed
B1 XDC import cannot open the chain database at all, whatever the --datadir cmd/XDC/chaincmd.go:216 builds the node with makeFullNode, whose RegisterEthService calls eth.New and opens the chain database; :222 then has utils.MakeChain open the same database a second time XDC import always ends in Fatal: Could not open database: resource temporarily unavailable — leveldb takes the database exclusively, so the second open fails. Three Allocated cache and file handles lines for the same chaindata in one run, the third immediately before the fatal. XDC export is unaffected because it opens read-only (:294/:297). Upstream draws this line differently: its importChain uses makeConfigNode (cmd/geth/chaincmd.go:367), so no service is registered and the database is opened once — the fork's importChain is the only chain command here that builds the node with makeFullNode
B2 A rewind leaves the engine holding a quorum certificate for a block the rewind deleted, and it reports that at ERROR level consensus/XDPoS/engines/engine_v2/engine.go:1253 Two ERROR [FindParentBlockToAssign] Can not find parent block from highestQC proposedBlockInfo lines per debug.setHead, clearing by themselves within 1–2 s: XDPoS.FindParentBlockToAssign falls 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 in miner/worker.go (:766) is not one of the lines this PR changes

The 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

  • The big picture is in Background; the per-defect breakdown is in Changes.

Types of changes_Put an ✅ in the boxes that apply_

  • fix: A bug fix
  • build / ci / chore / docs / feat / perf / refactor / revert / style / test

Impacted components_Put an ✅ in the boxes that apply_

  • Geth
  • Consensus (the checkpoint signal on epoch-switch and gap blocks)

Checklist_Put an ✅ in the boxes once you have confirmed below actions (or provide reasons on not doing so)_

  • This PR has sufficient test coverage — 37 test files, +5386 lines; each defect has its own case, and the review findings are pinned by negative checks (see Verification)
  • Commits follow Conventional Commits, one commit per defect (the four about a total-difficulty record this node cannot read take one each), each referencing the issue it closes (Refs XinFinOrg#2534 / Refs XinFinOrg#2535)
  • gofmt and goimports run on every modified file
  • make all builds every command, at every commit (24/24) and at the tip
  • make quick-test passes at the tip and at every commit whose content changed
  • make tidy and make generate report no changes
  • Every commit is reverted alone against the tip (apply + build + the short tests of the packages it touches, first with the commit's own tests taken out and then restored) — 21 of 24; the exceptions are listed in Commit granularity and revertibility
  • No dependencies added, removed or updated
  • No unrelated refactoring, renames or bundled fixes — the two moved statements and the loop's report in the first commit exist so the neighbouring fixes stay independently revertible
  • No binaries committed
  • Tested on a private network from the genesis block and monitored the chain operating correctly for multiple epochs — one epoch switch covered, from the genesis block (Verification / Local private-network run); a run across several epochs, and the long-running full-sync against a real network that is the check that matters for core: blocks already known with state never advance the head, making full sync crawl #2535, are still to be done
  • End-to-end test plan on devnet/testnet — not yet; the stall is reproducible by interrupting a full sync (restart the node mid-sync and watch the head advance again instead of re-downloading the same range)
  • Backwards compatibility — no on-disk format change, no consensus-rule change
  • Tested with XDC nodes running this version co-existing with those running the previous version — not yet
  • Relevant documentation has been updated as part of this PR (the new sentinels and helpers carry godoc explaining the contract each one is used under)

@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 7dfb7963-46dc-400b-85f3-8d0c36cf9b13

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gzliudan gzliudan changed the title fix(core,eth/downloader): fail partial imports and let known blocks advance the chain fix(core,eth/downloader): fail partial imports and let known blocks advance the chain, close #2534 #2535 Sep 14, 2026
@gzliudan
gzliudan force-pushed the fix-issue-2534-2535 branch 3 times, most recently from 28f0727 to 062b0a6 Compare September 14, 2026 04:44
@gzliudan gzliudan changed the title fix(core,eth/downloader): fail partial imports and let known blocks advance the chain, close #2534 #2535 fix(core,eth): fail partial imports and let known blocks advance the chain, close #2534 #2535 Sep 14, 2026
@gzliudan
gzliudan force-pushed the fix-issue-2534-2535 branch 7 times, most recently from 7ae508f to 56d02be Compare September 14, 2026 10:29
@gzliudan
gzliudan force-pushed the fix-issue-2534-2535 branch 2 times, most recently from 049988f to aba6ac4 Compare September 14, 2026 11:57
@gzliudan
gzliudan force-pushed the fix-issue-2534-2535 branch 2 times, most recently from 80bf917 to 9a7df19 Compare September 14, 2026 15:21
@gzliudan gzliudan changed the title fix(core,eth): fail partial imports and let known blocks advance the chain, close #2534 #2535 fix(core,eth): stop full sync stalls and peer drops on imported blocks, close #2534 #2535 Sep 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread eth/downloader/downloader.go Outdated
Comment thread core/blockchain.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity · 1 Medium severity

Open (2)
Resolved since last review (2)

Comment thread eth/downloader/downloader.go
Comment thread core/blockchain_insert_err.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The consensus and chain-recovery changes are extensive, and multi-epoch, mixed-version, and devnet/testnet validation remain incomplete.

Review effort: Balanced
Findings: None

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

It changes consensus-adjacent fork choice, reorg, and synchronization behavior while dependent-branch and network compatibility validation remain outstanding.

Review effort: Balanced
Findings: None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants