From 9f5889dc66cb84cf0dfa7c8dac60102b1281ed9a Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:30:00 -0700 Subject: [PATCH 1/3] net: drop IBD getdata inflight that is off the work path Leftover orphan hashes kept path_drained false at the peer horizon (mainnet 08:16:23: ordered empty, h2h=0, inflight=7) so catch-up never exited to tip follow. Tip+1 and reorg-need hashes stay inflight. Co-authored-by: Cursor --- crates/rbitcoin-net/src/ibd/assign.rs | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/rbitcoin-net/src/ibd/assign.rs b/crates/rbitcoin-net/src/ibd/assign.rs index 04f434d1..93711983 100644 --- a/crates/rbitcoin-net/src/ibd/assign.rs +++ b/crates/rbitcoin-net/src/ibd/assign.rs @@ -62,6 +62,33 @@ pub(crate) fn prune_satisfied_inflight( } } +/// Drop getdata that cannot feed the work path or a reorg gather. +/// +/// Off-path leftovers otherwise block [`path_drained`](super::exit::path_drained) +/// forever (mainnet 08:16:23: ordered empty, h2h=0, inflight=7). +pub(crate) fn prune_off_path_inflight(st: &mut IbdWorkState) { + let reorg_need: HashSet = st.reorg.need_getdata().into_iter().collect(); + let drop: Vec = st + .inflight + .keys() + .copied() + .filter(|h| { + if st.ordered_set.contains(h) || reorg_need.contains(h) { + return false; + } + if let Some(&ht) = st.hash_height.get(h) { + if st.is_on_path(h, ht) { + return false; + } + } + true + }) + .collect(); + for h in drop { + clear_hash_inflight(&mut st.slots, &mut st.inflight, h); + } +} + /// Record `peer` as requesting `hash` (tip-hole / park race may accumulate peers). pub(crate) fn inflight_add_peer( inflight: &mut HashMap, @@ -107,6 +134,7 @@ pub(crate) fn assign_work_ordered( } prune_satisfied_inflight(&mut st.slots, &mut st.inflight, hub); + prune_off_path_inflight(st); let _ = super::reorg::consider_disconnected_heavier(st, hub); @@ -805,6 +833,46 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + /// Off-path getdata (mainnet 08:16:23: ordered empty, h2h=0, inflight=7) + /// must not occupy slots; tip+1 and reorg-need hashes stay. + #[test] + fn prune_off_path_inflight_drops_orphans_keeps_path_and_reorg() { + let (dir, hub) = tmp_hub(); + hub.ensure_genesis().unwrap(); + let mut st = IbdWorkState::new(vec![dummy_slot(0)], hub.tip_hash(), hub.tip_height()); + assert!(st.ordered.is_empty()); + assert!(st.height_to_hash.is_empty() || st.height_to_hash.len() <= 1); + + for i in 0..7u32 { + let hash = h(1000 + i); + st.slots[0].in_flight.insert(hash); + inflight_add_peer(&mut st.inflight, hash, 0); + } + let want = h(0x11); + let ht = hub.tip_height().unwrap_or(0).saturating_add(1); + st.record_height(want, ht); + st.slots[0].in_flight.insert(want); + inflight_add_peer(&mut st.inflight, want, 0); + let reorg_h = h(0x22); + st.reorg.register_explore(std::iter::once(reorg_h), None); + st.slots[0].in_flight.insert(reorg_h); + inflight_add_peer(&mut st.inflight, reorg_h, 0); + assert_eq!(st.inflight.len(), 9); + + prune_off_path_inflight(&mut st); + + assert_eq!(st.inflight.len(), 2, "orphans dropped; path+reorg kept"); + assert!(st.inflight.contains_key(&want), "tip+1 occupant stays"); + assert!(st.inflight.contains_key(&reorg_h), "reorg need stays"); + for i in 0..7u32 { + let hash = h(1000 + i); + assert!(!st.inflight.contains_key(&hash), "orphan {i} dropped"); + assert!(!st.slots[0].in_flight.contains(&hash)); + } + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn scale_and_saturated_helpers() { assert!(!archive_pipeline_saturated(0, 20, false)); From 496cf44dc2ffad1bdfcdcb8afb9d309bcd09acdc Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:31:56 -0700 Subject: [PATCH 2/3] net: pin IBD catch-up complete after off-path inflight prune The 08:16:23 matrix (tip at peer horizon, headers_done, seven orphan getdata) completes only after prune_off_path_inflight. Mid-chain on-path inflight still refuses tip mode. path_drained stays ordered+inflight empty. Co-authored-by: Cursor --- crates/rbitcoin-net/src/ibd/exit.rs | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/rbitcoin-net/src/ibd/exit.rs b/crates/rbitcoin-net/src/ibd/exit.rs index 42612e61..24cf2a1a 100644 --- a/crates/rbitcoin-net/src/ibd/exit.rs +++ b/crates/rbitcoin-net/src/ibd/exit.rs @@ -74,6 +74,10 @@ pub(crate) fn should_reseed_work_path_on_empty_lag(streak: u32) -> bool { } /// Work path idle: no ordered hashes, no inflight getdata. +/// +/// Off-path leftover getdata is dropped by +/// [`super::assign::prune_off_path_inflight`] before this is consulted — not a +/// second meaning of drained. #[inline] pub fn path_drained(st: &IbdWorkState) -> bool { st.ordered.is_empty() && st.inflight.is_empty() @@ -210,6 +214,38 @@ mod tests { near.max_ready_height = 105; assert!(!peer_caught_up(&near, 100)); assert_eq!(header_lag_behind_peers(&near, 100), 0); // archived ≥ peer + + // Mainnet 08:16:23: ordered empty, headers_done, tip=horizon, 7 off-path + // inflight. Prune then drain — do not treat leftover getdata as work. + use super::super::assign::prune_off_path_inflight; + use super::super::state::InflightReq; + let mut at_horizon = IbdWorkState::new(Vec::new(), None, Some(964_108)); + at_horizon.max_peer_height = 964_108; + at_horizon.max_ready_height = 964_108; + at_horizon.headers_done = true; + for i in 1u8..=7 { + at_horizon + .inflight + .insert(BlockHash::from_byte_array([i; 32]), InflightReq::new(0)); + } + assert!(!path_drained(&at_horizon)); + assert!(!catchup_complete_after_drain(&at_horizon, 964_108)); + prune_off_path_inflight(&mut at_horizon); + assert!(path_drained(&at_horizon)); + assert!(catchup_complete_after_drain(&at_horizon, 964_108)); + + // Mid-chain on-path inflight (tip+1 in h2h) must not complete after prune. + let mut mid_inf = IbdWorkState::new(Vec::new(), None, Some(161_249)); + mid_inf.max_peer_height = 958_820; + mid_inf.max_ready_height = 161_000; + mid_inf.headers_done = true; + let on_path = BlockHash::from_byte_array([0x2a; 32]); + mid_inf.record_height(on_path, 161_250); + mid_inf.inflight.insert(on_path, InflightReq::new(0)); + prune_off_path_inflight(&mut mid_inf); + assert!(mid_inf.inflight.contains_key(&on_path)); + assert!(!path_drained(&mid_inf)); + assert!(!catchup_complete_after_drain(&mid_inf, 161_249)); } /// Empty-headers lag WARN/reget cadence (mainnet log flood regression). From e6cb8f528229915575ada72f37ab3c097a9234f1 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:35:49 -0700 Subject: [PATCH 3/3] net: unlatch IBD headers_done when lag > 2 even with inflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latch used to clear only after path_drained, so leftover getdata blocked getheaders when peers moved first. lag ≤ 2 stays latched to avoid the tip storm. Changelog notes the horizon exit. Co-authored-by: Cursor --- CHANGELOG.md | 5 ++++ crates/rbitcoin-net/src/ibd/exit.rs | 37 +++++++++++++++++++++++++++++ crates/rbitcoin-net/src/ibd/mod.rs | 11 ++++----- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b6d8fa..394d39c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,11 @@ before 1.0). ### Changed +- **IBD exits to tip follow at the peer horizon:** leftover off-path + `getdata` is dropped so catch-up can complete; if peers then advertise + a higher tip (`lag > 2`), `headers_done` unlatches and `getheaders` + resumes. Near-tip (`lag ≤ 2`) still does not re-fan. + - **In-flight keep-until:** pin layers stay until drain+fence **and** `class_a_hi >= until` (`until = lookup_started_hi.max(hi)` frozen at write). Stamp walks `InFlightView` only (then live_union, then TipOnly). diff --git a/crates/rbitcoin-net/src/ibd/exit.rs b/crates/rbitcoin-net/src/ibd/exit.rs index 24cf2a1a..b0d711f5 100644 --- a/crates/rbitcoin-net/src/ibd/exit.rs +++ b/crates/rbitcoin-net/src/ibd/exit.rs @@ -64,6 +64,16 @@ pub(crate) fn empty_path_header_fan(lag: u32, inflight: usize, alive: usize) -> alive.min(4).max(1) } +/// Clear `headers_done` so empty-path `getheaders` can resume. +/// +/// Latch is only for lag ≤ 2 (stop the tip storm). When peers advertise a +/// higher tip, unlatch even if leftover getdata is still inflight — that +/// used to sit behind [`path_drained`] and stall until SIGINT. +#[inline] +pub(crate) fn should_unlatch_headers_done(st: &IbdWorkState, tip_h: u32) -> bool { + st.headers_done && st.ordered.is_empty() && header_lag_behind_peers(st, tip_h) > 2 +} + /// Full `seed_work_path_from_store` (O(header_count) walk) while empty-lagging. /// /// Must stay rare: mainnet ~1M headers ≈ 200–300ms per call. Same cadence as @@ -305,4 +315,31 @@ mod tests { assert_eq!(empty_path_header_fan(100, 5, 29), 1); assert_eq!(empty_path_header_fan(100, 0, 2), 2); } + + /// Unlatch `headers_done` on lag>2 even with leftover inflight; never at lag≤2. + #[test] + fn should_unlatch_headers_done() { + use super::super::state::InflightReq; + use bitcoin::hashes::Hash; + use bitcoin::BlockHash; + + let mut st = IbdWorkState::new(Vec::new(), None, Some(100)); + st.max_peer_height = 105; + st.max_ready_height = 100; + st.headers_done = true; + for i in 1u8..=7 { + st.inflight + .insert(BlockHash::from_byte_array([i; 32]), InflightReq::new(0)); + } + assert_eq!(header_lag_behind_peers(&st, 100), 5); + assert!(super::should_unlatch_headers_done(&st, 100)); + + st.max_peer_height = 100; + assert_eq!(header_lag_behind_peers(&st, 100), 0); + assert!(!super::should_unlatch_headers_done(&st, 100)); + + st.max_peer_height = 105; + st.ordered.push_back(BlockHash::from_byte_array([0xab; 32])); + assert!(!super::should_unlatch_headers_done(&st, 100)); + } } diff --git a/crates/rbitcoin-net/src/ibd/mod.rs b/crates/rbitcoin-net/src/ibd/mod.rs index fc033fac..cdb60b8b 100644 --- a/crates/rbitcoin-net/src/ibd/mod.rs +++ b/crates/rbitcoin-net/src/ibd/mod.rs @@ -42,7 +42,8 @@ use events::{ }; use exit::{ all_peers_dead_action, catchup_complete_after_drain, empty_path_header_fan, - header_lag_behind_peers, path_drained, peer_caught_up, AllPeersDead, + header_lag_behind_peers, path_drained, peer_caught_up, should_unlatch_headers_done, + AllPeersDead, }; use path::{seed_work_path_from_store, work_path_tips}; use peer_io::{PeerCmd, PeerEvent, PeerEventSinks}; @@ -595,6 +596,9 @@ pub async fn ibd_cancellable( ); let under_hard = live < MAX_ORDERED_HEADERS; let under_soft = live < ORDERED_HEADERS_SOFT_CAP; + if should_unlatch_headers_done(&st, hub.tip_height().unwrap_or(0)) { + st.headers_done = false; + } if !st.headers_done && under_hard && (under_soft || need_ready_headroom) { let tip_h = hub.tip_height().unwrap_or(0); let lag = header_lag_behind_peers(&st, tip_h); @@ -945,11 +949,6 @@ pub async fn ibd_cancellable( ); break; } - if header_lag_behind_peers(&st, tip_h) > 2 { - st.headers_done = false; - let tips = work_path_tips(&st); - let _ = request_headers(&st.slots, &hub, &mut st.header_req_seq, &tips); - } } // All peers dead — never treat mid-chain peer death as catch-up complete. if st.slots.iter().all(|s| !s.alive) {