sync: the min-gap threshold is for nodes that have a choice

State sync is skipped when the chain is not far enough ahead of local state,
because bootstrapping would be faster. That trade only exists while
bootstrapping can run at all. It replays blocks against the state beneath them,
so a node missing the state at its own head cannot replay a single block — it
fails to materialize the next one and stops there, whatever the gap is.

On such a node the threshold declines the one mechanism that could repair it,
and small-gap corruption becomes permanent: it cannot execute forward, and it is
not far enough behind to be allowed to sync. The threshold now applies only when
the node holds the state its head is built on. A chain that cannot be read
answers no — the cost of being wrong that way is a sync that was not strictly
needed, against a node that could otherwise never come back.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-16 00:31:24 -07:00
co-authored by Hanzo Dev
parent 8308671775
commit 4f05ea3bde
+30 -1
View File
@@ -160,7 +160,14 @@ func (client *stateSyncerClient) acceptSyncSummary(proposedSummary message.SyncS
// Skip syncing if the blockchain is not significantly ahead of local state,
// since bootstrapping would be faster.
// (Also ensures we don't sync to a height prior to local state.)
if client.lastAcceptedHeight+client.stateSyncMinBlocks > proposedSummary.Height() {
//
// That trade only exists while bootstrapping can actually run. It replays
// blocks against the state under them, so a node missing the state at its
// own head cannot replay at all — it fails to materialize the very next
// block and stops there, whatever the gap. Skipping on a small gap then
// declines the one mechanism that could repair it and leaves the node
// stuck for good. So the threshold applies to nodes that have a choice.
if client.canReplayLocally() && client.lastAcceptedHeight+client.stateSyncMinBlocks > proposedSummary.Height() {
log.Info(
"last accepted too close to most recent syncable block, skipping state sync",
"lastAccepted", client.lastAcceptedHeight,
@@ -369,3 +376,25 @@ func (client *stateSyncerClient) updateVMMarkers() error {
// Error returns a non-nil error if one occurred during the sync.
func (client *stateSyncerClient) Error() error { return client.stateSyncErr }
// canReplayLocally reports whether this node holds the state its own head is
// built on, which is what bootstrapping needs to execute the next block. Without
// it there is nothing to replay from and state sync is the only way back.
//
// A chain that cannot be read answers no: an unproven measurement is not a
// passing one, and the cost of being wrong here is a sync that was not strictly
// necessary, against a node that can never recover.
func (client *stateSyncerClient) canReplayLocally() bool {
if client.chain == nil {
return false
}
bc := client.chain.BlockChain()
if bc == nil {
return false
}
head := bc.CurrentBlock()
if head == nil {
return false
}
return bc.HasState(head.Root)
}