From 7e9855adef0a9d05e00f5b38df2b6f93f7e758ef Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 4 Jul 2026 08:04:31 +0000 Subject: [PATCH] fix(network): follow the verified body tip when Zakura header sync falls behind When this node is ahead of its Zakura header-sync overlay peers (they are behind) and legacy body sync keeps committing, the verified body tip can outrun the header-sync frontier, leaving header sync stuck below the local verified state with no peer to fetch from. Those blocks are locally verified, so once the verified tip leads the frontier by more than HEADER_SYNC_FOLLOW_VERIFIED_TIP_GAP, header sync now re-anchors up to the verified tip (reusing the existing re-anchor path, which rebuilds the frontier history tree there). The gap gate keeps the re-anchor's status broadcast well under the inbound status-spam interval even while the verified tip climbs fast. --- .../src/zakura/header_sync/reactor.rs | 19 ++++ zebra-network/src/zakura/header_sync/tests.rs | 93 +++++++++++++++++++ zebra-network/src/zakura/header_sync/wire.rs | 13 +++ 3 files changed, 125 insertions(+) diff --git a/zebra-network/src/zakura/header_sync/reactor.rs b/zebra-network/src/zakura/header_sync/reactor.rs index acfa1d3f4bb..5c7d154b6e8 100644 --- a/zebra-network/src/zakura/header_sync/reactor.rs +++ b/zebra-network/src/zakura/header_sync/reactor.rs @@ -589,6 +589,25 @@ impl HeaderSyncReactor { self.state.finalized_height = frontiers.finalized_height; self.state.verified_block_tip = frontiers.verified_block_tip; self.state.verified_block_hash = frontiers.verified_block_hash; + + // The verified body tip can lead the header-sync frontier when legacy body sync catches up + // while this node has no Zakura header-sync peer ahead of it (its overlay peers are behind). + // Those blocks are locally verified, so once the gap is large enough advance the header + // frontier up to the verified tip instead of sitting behind it, reusing the re-anchor path + // (which repositions the tip and reloads the history tree at the verified tip). Gated on + // `HEADER_SYNC_FOLLOW_VERIFIED_TIP_GAP` so the re-anchor's status broadcast stays under the + // status-spam limit even while the verified tip climbs. + if self.state.verified_block_tip.0 + > self + .state + .best_header_tip + .0 + .saturating_add(HEADER_SYNC_FOLLOW_VERIFIED_TIP_GAP) + { + self.reanchor_to_verified_block_tip().await; + return; + } + if self.state.best_header_tip <= self.state.verified_block_tip { self.state.stale_anchor.reset(); } diff --git a/zebra-network/src/zakura/header_sync/tests.rs b/zebra-network/src/zakura/header_sync/tests.rs index 09e34caa2ab..dc813e7dc23 100644 --- a/zebra-network/src/zakura/header_sync/tests.rs +++ b/zebra-network/src/zakura/header_sync/tests.rs @@ -4542,6 +4542,99 @@ async fn reanchor_dispatches_history_tree_rebuild_and_resumes_forward() { panic!("after the rebuild, header sync did not resume a forward range from the verified tip"); } +/// The verified body tip can outrun the header-sync frontier when this node is ahead of its Zakura +/// overlay peers (they are behind) and legacy body sync keeps committing. A lead within +/// `HEADER_SYNC_FOLLOW_VERIFIED_TIP_GAP` is left alone, but once it exceeds the gap header sync +/// follows the verified tip: it re-anchors up to it and rebuilds the frontier tree there, rather +/// than sitting behind the local verified state. +#[tokio::test(flavor = "current_thread")] +async fn verified_tip_beyond_gap_reanchors_header_sync_to_follow_it() { + let network = regtest_network(); + let verified = (block::Height(0), network.genesis_hash()); + let stranded_tip = (block::Height(3), block::Hash([3; 32])); + let mut startup = HeaderSyncStartup::new( + network.clone(), + verified, + HeaderSyncFrontiers { + finalized_height: verified.0, + verified_block_tip: verified.0, + verified_block_hash: verified.1, + }, + Some(stranded_tip), + ZakuraHeaderSyncConfig::default(), + LOCAL_MAX_MESSAGE_BYTES, + ); + startup.range_state_actions_enabled = true; + let mut fixture = spawn_test_reactor(startup); + + // A verified lead exactly at the gap boundary must not move the frontier (strictly-greater gate). + let at_gap = block::Height(stranded_tip.0 .0 + HEADER_SYNC_FOLLOW_VERIFIED_TIP_GAP); + fixture + .handle + .send(HeaderSyncEvent::StateFrontiersChanged( + HeaderSyncFrontiers { + finalized_height: verified.0, + verified_block_tip: at_gap, + verified_block_hash: block::Hash([7; 32]), + }, + )) + .await + .unwrap(); + + // A verified lead one block beyond the gap re-anchors the frontier up to the verified tip. + let far_tip = ( + block::Height(stranded_tip.0 .0 + HEADER_SYNC_FOLLOW_VERIFIED_TIP_GAP + 1), + block::Hash([9; 32]), + ); + fixture + .handle + .send(HeaderSyncEvent::StateFrontiersChanged( + HeaderSyncFrontiers { + finalized_height: verified.0, + verified_block_tip: far_tip.0, + verified_block_hash: far_tip.1, + }, + )) + .await + .unwrap(); + + // The first frontier-tree rebuild must be for `far_tip`, not the at-gap lead — proving the + // at-gap lead did not re-anchor, and the beyond-gap one did. + let mut saw_rebuild = false; + for _ in 0..24 { + match tokio::time::timeout( + std::time::Duration::from_millis(200), + fixture.actions.recv(), + ) + .await + { + Ok(Some(HeaderSyncAction::QueryBestHeaderHistoryTree { + verified_block_tip, + best_header_tip, + })) => { + assert_eq!( + verified_block_tip, far_tip.0, + "the at-gap lead must not have re-anchored" + ); + assert_eq!(best_header_tip, far_tip.0); + saw_rebuild = true; + break; + } + Ok(Some(_)) => {} + _ => break, + } + } + assert!( + saw_rebuild, + "a verified tip beyond the gap must re-anchor header sync and rebuild the tree there", + ); + assert_eq!( + fixture.handle.best_header_tip(), + far_tip, + "header sync follows the verified tip past the gap", + ); +} + #[tokio::test(flavor = "current_thread")] async fn single_peer_forward_link_failures_do_not_reanchor_globally() { let network = regtest_network(); diff --git a/zebra-network/src/zakura/header_sync/wire.rs b/zebra-network/src/zakura/header_sync/wire.rs index 04a42ed8563..6450a5443af 100644 --- a/zebra-network/src/zakura/header_sync/wire.rs +++ b/zebra-network/src/zakura/header_sync/wire.rs @@ -54,6 +54,19 @@ pub(super) const DEFAULT_HS_STATUS_REFRESH_INTERVAL: Duration = Duration::from_s pub(super) const DEFAULT_HS_INBOUND_STATUS_MIN_INTERVAL: Duration = Duration::from_secs(5); pub(super) const DEFAULT_HS_INBOUND_NEW_BLOCK_MIN_INTERVAL: Duration = Duration::from_secs(5); +/// How far the verified body tip may lead the Zakura header-sync frontier before header sync +/// re-anchors up to it. +/// +/// The verified body tip can outrun the header-sync frontier when legacy body sync catches up while +/// this node has no Zakura header-sync peer ahead of it (its overlay peers are behind). Those blocks +/// are locally verified — their headers and roots are the trusted committed rows — so header sync +/// should follow the verified tip rather than sit behind it. Following is gated on this gap so the +/// re-anchor (which broadcasts a status refresh) fires at most once per this many blocks of drift, +/// keeping it well under [`DEFAULT_HS_INBOUND_STATUS_MIN_INTERVAL`] even while the verified tip +/// climbs fast: the tightest case is a full-speed legacy catch-up (~tens of blocks/sec), where a +/// 256-block gap still spaces re-anchors seconds apart. +pub(super) const HEADER_SYNC_FOLLOW_VERIFIED_TIP_GAP: u32 = 256; + const _: () = assert!(MAX_HS_MESSAGE_BYTES < LOCAL_MAX_MESSAGE_BYTES as usize); const _: () = assert!( HEADER_SYNC_MESSAGE_TYPE_BYTES