From da476a8eca3643085b41e70b44cafaefe00f13a1 Mon Sep 17 00:00:00 2001 From: CPerezz Date: Wed, 18 Feb 2026 14:52:16 +0100 Subject: [PATCH] eth/catalyst: fix sync restart loop during partial state snap sync The stateless block check in forkchoiceUpdated was calling BeaconSync() on every FCU (~12 seconds) during active snap sync, restarting the entire sync cycle each time. This prevented state download from ever completing. Guard the check with ConfigSyncMode: during active snap sync, the downloader is already working, so just return STATUS_SYNCING without restarting. Only trigger BeaconSync for stateless blocks after snap sync has completed (FullSync mode). Co-Authored-By: Claude Opus 4.6 --- eth/catalyst/api.go | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index d23a174afa..c575d9c404 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -295,28 +295,30 @@ func (api *ConsensusAPI) forkchoiceUpdated(ctx context.Context, update engine.Fo } return engine.STATUS_SYNCING, nil } - // In partial state mode during snap sync, the block may have been persisted - // (by WriteBlockWithoutState in newPayload) but we have no state for it yet. - // If we try to SetCanonical, it will fail because HasState returns false and - // partial state can't recoverAncestors. Instead, treat it like an unknown - // block and trigger BeaconSync so the skeleton can start the sync cycle. - // - // After sync, the computed root may differ from the header root (unresolved - // untracked storage roots), so we also check partialState's tracked root. - partialRoot := common.Hash{} - if api.eth.BlockChain().SupportsPartialState() { - partialRoot = api.eth.BlockChain().PartialState().Root() - } + // In partial state mode, a block may exist in DB (from WriteBlockWithoutState + // in newPayload) but have no state yet. During active snap sync, this is + // expected — the downloader is already syncing state. Just return SYNCING + // without triggering a restart. After snap sync completes, if we still see + // a stateless block, trigger BeaconSync to re-sync for it. if api.eth.BlockChain().SupportsPartialState() && - !api.eth.BlockChain().HasState(block.Root()) && - (partialRoot == common.Hash{} || !api.eth.BlockChain().HasState(partialRoot)) { - log.Info("Forkchoice: block known but stateless (partial state sync in progress), triggering BeaconSync", - "number", block.NumberU64(), "hash", update.HeadBlockHash, "root", block.Root()) - finalized := api.remoteBlocks.get(update.FinalizedBlockHash) - if err := api.eth.Downloader().BeaconSync(block.Header(), finalized); err != nil { - return engine.STATUS_SYNCING, err + !api.eth.BlockChain().HasState(block.Root()) { + partialRoot := api.eth.BlockChain().PartialState().Root() + if partialRoot == (common.Hash{}) || !api.eth.BlockChain().HasState(partialRoot) { + if api.eth.Downloader().ConfigSyncMode() == ethconfig.SnapSync { + // Snap sync active — downloader is already working. Don't restart. + log.Debug("Forkchoice: stateless block during snap sync, not restarting", + "number", block.NumberU64(), "hash", update.HeadBlockHash) + return engine.STATUS_SYNCING, nil + } + // Snap sync done but block has no state — trigger BeaconSync. + log.Info("Forkchoice: block known but stateless, triggering BeaconSync", + "number", block.NumberU64(), "hash", update.HeadBlockHash, "root", block.Root()) + finalized := api.remoteBlocks.get(update.FinalizedBlockHash) + if err := api.eth.Downloader().BeaconSync(block.Header(), finalized); err != nil { + return engine.STATUS_SYNCING, err + } + return engine.STATUS_SYNCING, nil } - return engine.STATUS_SYNCING, nil } // Block is known locally, just sanity check that the beacon client does not // attempt to push us back to before the merge.