diff --git a/zebra-crosslink/zebra-crosslink/src/lib.rs b/zebra-crosslink/zebra-crosslink/src/lib.rs index 6fe95a10..28b82b01 100644 --- a/zebra-crosslink/zebra-crosslink/src/lib.rs +++ b/zebra-crosslink/zebra-crosslink/src/lib.rs @@ -2005,11 +2005,22 @@ async fn tfl_block_sequence( .await; if let Ok(StateResponse::BlockHashes(chunk_hashes)) = res { - if c == 0 && include_start_hash && !chunk_hashes.is_empty() { - assert_eq!( - chunk_hashes[0], start_hash, - "first hash is not the one requested" + if c == 0 && include_start_hash && !chunk_hashes.is_empty() && chunk_hashes[0] != start_hash { + // The best chain switched between resolving start_hash and this + // walk: FindBlockHashes follows the CURRENT best chain, so a + // same-height sibling switch (e.g. a stale internal-miner submit + // displacing a just-committed block, see #39) makes the first + // returned hash differ from the one requested. That is a normal + // reorg race, not an internal error. Return what we have; the + // callers' paranoid guards already retry against the new chain. + // This was a fatal assert that took whole nodes down whenever + // the race hit (multiple operator reports 2026-07-20/21, + // "panicked at zebra-crosslink/src/lib.rs:1939/:1959"). + println!( + "PARANOID != WRONG: first hash {} is not the requested {} (best chain switched mid-walk)", + chunk_hashes[0], start_hash ); + break; } chunk = chunk_hashes; @@ -2186,3 +2197,127 @@ async fn _tfl_dump_block_sequence( .await; tfl_dump_blocks(&blocks[..], &infos[..]); } + +#[cfg(test)] +mod tfl_block_sequence_reorg_tests { + use super::*; + use crate::service::TFLServiceCalls; + use std::sync::atomic::{AtomicUsize, Ordering}; + use zebra_chain::serialization::ZcashDeserializeInto; + + /// Builds `TFLServiceCalls` whose state procedure answers with the given + /// sync closure. The other procedures are unused by `tfl_block_sequence` + /// and fail the call if ever hit. + fn state_only_calls( + state: impl Fn(StateRequest) -> StateResponse + Send + Sync + 'static, + ) -> TFLServiceCalls { + let state = Arc::new(state); + TFLServiceCalls { + state: Arc::new(move |req| { + let state = state.clone(); + Box::pin(async move { Ok(state(req)) }) + }), + read_state: Arc::new(|_| Box::pin(async { Err("read_state unused in test".into()) })), + mempool: Arc::new(|_| Box::pin(async { Err("mempool unused in test".into()) })), + force_feed_pos: Arc::new(|_, _| { + Box::pin(async { Err("force_feed_pos unused in test".to_string()) }) + }), + } + } + + fn test_blocks() -> (Arc, Arc) { + let block_1 = zebra_test::vectors::BLOCK_MAINNET_1_BYTES + .zcash_deserialize_into::>() + .expect("block 1 should deserialize"); + let block_2 = zebra_test::vectors::BLOCK_MAINNET_2_BYTES + .zcash_deserialize_into::>() + .expect("block 2 should deserialize"); + (block_1, block_2) + } + + /// A best-chain switch between resolving the start hash and walking + /// `FindBlockHashes` makes element 0 a same-height sibling of the block we + /// started from. This used to be a fatal assert that aborted the node; it + /// must return empty vectors instead, so the viz2 caller's paranoid guards + /// reject the empty sequence and re-read the tip on the next loop tick. + #[tokio::test] + async fn sibling_first_hash_returns_empty_instead_of_panicking() { + let (block_1, block_2) = test_blocks(); + let start_hash = block_1.hash(); + // stands in for the sibling that displaced block_1 at the same height + let sibling_hash = block_2.hash(); + let header = block_1.header.clone(); + + let calls = state_only_calls(move |req| match req { + StateRequest::BlockHeader(_) => StateResponse::BlockHeader { + header: header.clone(), + hash: start_hash, + height: ZebBlockHeight(1), + next_block_hash: None, + }, + StateRequest::FindBlockHashes { .. } => { + StateResponse::BlockHashes(vec![sibling_hash]) + } + other => panic!("unexpected state request in test: {other:?}"), + }); + + let (hashes, blocks) = tfl_block_sequence( + &calls, + start_hash, + Some((ZebBlockHeight(3), sibling_hash)), + true, + true, + ) + .await; + + assert!(hashes.is_empty(), "no sibling sequence may be consumed"); + assert!( + blocks.is_empty(), + "no blocks may be fetched or published for a sibling walk" + ); + } + + /// The guard must not fire on a consistent walk: element 0 equal to the + /// requested start hash returns the sequence as before. + #[tokio::test] + async fn consistent_walk_still_returns_the_sequence() { + let (block_1, block_2) = test_blocks(); + let start_hash = block_1.hash(); + let next_hash = block_2.hash(); + let header = block_1.header.clone(); + + let find_calls = AtomicUsize::new(0); + let calls = state_only_calls(move |req| match req { + StateRequest::BlockHeader(_) => StateResponse::BlockHeader { + header: header.clone(), + hash: start_hash, + height: ZebBlockHeight(1), + next_block_hash: None, + }, + StateRequest::FindBlockHashes { .. } => { + if find_calls.fetch_add(1, Ordering::SeqCst) == 0 { + StateResponse::BlockHashes(vec![start_hash, next_hash]) + } else { + // end of chain + StateResponse::BlockHashes(Vec::new()) + } + } + other => panic!("unexpected state request in test: {other:?}"), + }); + + let (hashes, blocks) = tfl_block_sequence( + &calls, + start_hash, + Some((ZebBlockHeight(2), next_hash)), + true, + false, + ) + .await; + + assert_eq!( + hashes.iter().map(|h| h.1).collect::>(), + vec![start_hash, next_hash], + ); + assert!(blocks.is_empty(), "read_extra_info=false returns no blocks"); + } +}