Skip to content

fix(eth/downloader): cap the ancestor search at the local head, close #2533 - #2547

Open
gzliudan wants to merge 1 commit into
XinFinOrg:dev-upgradefrom
gzliudan:fix-ancestor-above-head
Open

fix(eth/downloader): cap the ancestor search at the local head, close #2533#2547
gzliudan wants to merge 1 commit into
XinFinOrg:dev-upgradefrom
gzliudan:fix-ancestor-above-head

Conversation

@gzliudan

@gzliudan gzliudan commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

findAncestor could resolve the common ancestor above the local chain head and resume the sync there, stranding the range in between. This caps both the span search and the binary search at the local head, so no candidate above it is ever sampled, accepted, or probed.

closes #2533

Motivation & Context

Side chain blocks are written by hash without state, so a node whose head sits below them still answers HasBlock for the whole segment. findAncestor picked up one of those blocks as the common ancestor and the downloader resumed from there, leaving the blocks between the real head and that block unimported. On XDPoS the head then stalls for good: header verification at an epoch switch needs the masternode snapshot of the skipped block, and that snapshot is only written when the block is imported with state. The old calculateRequestSpan also derived its window from localHeight-1 in uint64, which underflows at head 0.

Changes

  • calculateRequestSpan is rewritten around a fixed two-sample window: the sampling top is the highest block below the remote head, clamped down to the local head, and the request is count=2, skip=1 over three consecutive blocks with max = from+2. The variable span/count arithmetic, including its MaxHeaderFetch/16 cap, is gone.
  • New usableAsAncestor helper adds the local-head guard to the per-mode known-block check and replaces the two inline switch mode blocks in findAncestor, so both the span search and the binary search reject above-head candidates.
  • findAncestor introduces ancestorLimitExclusive = localHeight+1 and caps the binary search interval with it, so a head far below the remote anchors in one round trip instead of burning the search on candidates that cannot be accepted.
  • A span hit no longer ends the search unconditionally: the true fork can sit on the skipped block, so a hit below the window top seeds the binary search with the gap under the next sample, clamped by min(gap, ancestorLimitExclusive, remoteHeight+1). A hit at the local head or at the window top still returns as is.

Testing

go test -count=1 ./eth/downloader/ passes (7.4s) on the pushed commit, on top of the make all, go test -race -count=1 ./eth/downloader/ and go test -count=1 -short ./eth/... ./core/... runs recorded by the review of the same tree. New and extended cases cover: blocks above the head in full and fast sync (TestFindAncestorIgnoresBlocksAboveHead, ...Fast), span candidate rejection (TestFindAncestorSpanRejectsAboveHeadCandidate, ...Fast), the head-0 and head-2 window clamps (TestFindAncestorSpanAnchorsAtHeadZero, ...HeadTwo), the gap refinement (TestFindAncestorSpanRefinesBelowLowestSample), a remote sitting at genesis (TestFindAncestorSpanRemoteAtGenesis), a binary-search fork variant (TestFindAncestorBinarySearchIgnoresBlocksAboveHead), per-mode head-guard boundaries (TestUsableAsAncestorBoundaries), the write-without-state semantics the bug rests on (TestWriteBlockWithoutState*), and the per-case request span and window max (TestRemoteHeaderRequestSpan). The above-head cases assert on the request sequence, so a probe above the local head or above the peer's head fails them.

Related Issues

Risk & Impact

Touches findAncestor and calculateRequestSpan, which run in every sync mode (full, fast, light) at the start of every sync round, so a regression shows up immediately as a sync that cannot start or that re-downloads from too far back. The sampling geometry changed from a variable span/count sized by the height difference to two samples spaced one skipped header apart: normal catch-up and small-lag scenarios still resolve in one round trip, deep forks keep the same order of probes, and the clamped window (from clamped up to zero) can top out above the head, which is why usableAsAncestor stays as a backstop. The binary search stays convergent: end = check shrinks strictly and the interval is bounded by localHeight+1.

Notes for Reviewers

The invariant to check is that neither search ever touches a block above the local head: the window top is clamped in calculateRequestSpan, the binary interval is capped by ancestorLimitExclusive, the gap refinement is capped by both the local head and the remote height, and usableAsAncestor rejects anything that still slips through. Suggested reading order: calculateRequestSpan and its doc comment for the window geometry, then the span-hit refinement in findAncestor, then usableAsAncestor.

TODO

None.

@coderabbitai

coderabbitai Bot commented Sep 3, 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: Team

Run ID: 7382d859-180f-4007-aa82-0f60bcfbd8c3

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(eth/downloader): cap the ancestor search at the local head fix(eth/downloader): cap the ancestor search at the local head, close #2533 Sep 3, 2026
@gzliudan
gzliudan requested a balanced review from Copilot September 3, 2026 03:09

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.

🔵 Needs a closer look

Ancestor selection affects every synchronization mode and warrants final human validation despite strong regression coverage.

Pull request overview

Caps downloader ancestor discovery at the local head, preventing stored side-chain blocks above it from causing skipped imports and stalled syncs.

Changes:

  • Adds head-aware ancestor validation and bounded binary search.
  • Reworks header sampling and gap refinement.
  • Adds extensive full/fast-sync regression and boundary tests.
File summaries
File Description
eth/downloader/downloader.go Bounds ancestor search and validates candidates.
eth/downloader/downloader_test.go Covers above-head blocks, search bounds, and edge cases.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…n head, close XinFinOrg#2533

Side chain blocks are written by hash without state, so a node whose head
sits below them still answers HasBlock for the whole segment; findAncestor
then resolved the ancestor above its own head and the sync resumed there,
stranding the range in between. On XDPoS this stalls the head for good:
epoch-switch header verification needs the gap block's masternode snapshot,
which is only written when the gap block imports with state.

Cap both searches at the local head. calculateRequestSpan now always asks
for two samples spaced one skipped header apart, taking its top sample from
the highest block below the remote head and clamping it down to the local
head, so the window never spends a round trip on a block the head guard
would reject; findAncestor bounds the binary search with the same limit. The
window keeps its count when 'from' is clamped up to zero, which can top it
out above the head, so usableAsAncestor still rejects those candidates and
keeps a span hit from being returned above the head. A hit on the lower
sample no longer ends the search: the true fork can sit on the skipped
block, so the hit seeds the binary search with the gap under the next
sample, clamped by the local head and by the remote height.

Covered by blocks-above-head tests in full and fast sync (stubs stored by
hash, carrying receipts in fast sync but never entering the canonical chain,
so the snap head stays pinned), span candidate rejection and the head-0 and
head-2 window clamps, the gap refinement, a remote sitting at genesis, a
binary-search fork variant, per-mode head-guard boundaries, the
write-without-state semantics the bug rests on, and the per-case request
span and window max.
@gzliudan
gzliudan force-pushed the fix-ancestor-above-head branch from 224775f to e8a6613 Compare September 3, 2026 03:40
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.

4 participants