Skip to content

Commit 2cc7de4

Browse files
committed
bch(m5): windowed headers-first block download — close the IBD block-body gap
header_sync drove the HEADER chain to the peer tip (ContinueSync re-issues getheaders), but those headers blocks were never getdata d: only the BIP130 tip-announce path (<=3 headers) pulled bodies. Cold-start IBD therefore walked 2000-header batches forward forever and downloaded zero block bodies — yet the embedded daemon ABLA size feed and full-block->mempool reconciliation need real block data, so IBD could never complete. block_download.hpp: pure, peer-free BlockDownloadWindow — enqueue learned header hashes in chain order, keep at most MAX_BLOCKS_IN_FLIGHT (16, BCHN/Bitcoin per-peer default) getdata(MSG_BLOCK) outstanding, top the window up oldest-first as each block arrives. Dedupes against queued/in-flight/received so an overlapping locator batch or re-announce never double-downloads. In-flight timeout/eviction deferred (robustness once blocks flow). Wired into NodeP2P: ContinueSync enqueues the batch + drains the window; the block handler frees the slot on arrival and tops up. bch stays skip-green (header-only, build-inert). Zero p2pool-merged-v36 surface — pure SPV/IBD wire-sync; per-coin isolation (src/impl/bch/ only). bch_block_download_test: window bound, top-up ordering, dedupe, unsolicited, degenerate-window. ctest 10/10 -> 11/11.
1 parent b22fc39 commit 2cc7de4

4 files changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#pragma once
2+
// ---------------------------------------------------------------------------
3+
// bch::coin::block_download -- M5 full-block body. Windowed headers-first block
4+
// download, factored out of NodeP2P as a PURE, peer-free state machine so the
5+
// in-flight window policy is unit-testable without a live socket.
6+
//
7+
// THE GAP THIS CLOSES: header_sync.hpp drives the *header* chain forward --
8+
// ContinueSync re-issues getheaders so cold-start IBD walks the whole header
9+
// chain to the peer tip. But those headers' BLOCKS were never getdata'd: only
10+
// the tiny BIP130 tip-announce path (RequestBlocks, <= 3 headers) pulled full
11+
// blocks. So IBD synced 2000-header batches forward indefinitely and never
12+
// downloaded a single block body -- and the embedded daemon's ABLA size feed
13+
// (abla_block_feed.hpp) and the full-block -> mempool reconciliation
14+
// (block_connector.hpp) need REAL block data, not just headers. Cold-start IBD
15+
// could never actually complete.
16+
//
17+
// POLICY (mirrors Bitcoin/BCHN headers-first block download, net_processing):
18+
// * headers learned during IBD are enqueued in chain order;
19+
// * at most MAX_BLOCKS_IN_FLIGHT getdata(MSG_BLOCK) are outstanding at once
20+
// (a bounded window -- never blast the whole 2000-header batch as getdata
21+
// and stall on a slow peer / unbounded memory);
22+
// * each arriving block frees one window slot, and the window tops up from
23+
// the front of the queue (oldest-first), so blocks stream in at the peer's
24+
// pace until the queue drains.
25+
//
26+
// DEDUPE: a hash already queued, in flight, or already received is never
27+
// re-requested, so an overlapping getheaders locator batch or a re-announce
28+
// cannot double-download.
29+
//
30+
// NOT IN THIS SLICE (deferred per integrator 2026-06-18): in-flight timeout /
31+
// eviction (a block requested but never delivered by a stalling peer). That is
32+
// robustness hardening worth doing only once blocks are actually flowing; this
33+
// slice builds the happy-path window first.
34+
//
35+
// p2pool-merged-v36 SURFACE: NONE -- pure SPV/IBD wire-sync plumbing; no PoW
36+
// hash, share format, coinbase commitment, AuxPoW, or PPLNS math. PER-COIN
37+
// ISOLATION: src/impl/bch/coin/ only; header-only, build-INERT (bch stays
38+
// skip-green).
39+
// ---------------------------------------------------------------------------
40+
41+
#include <cstddef>
42+
#include <deque>
43+
#include <unordered_set>
44+
#include <vector>
45+
46+
#include <core/uint256.hpp>
47+
48+
namespace bch::coin::block_download {
49+
50+
/// Default cap on outstanding getdata(MSG_BLOCK) requests. 16 matches the
51+
/// Bitcoin/BCHN per-peer MAX_BLOCKS_IN_FLIGHT default -- enough to keep a fast
52+
/// peer's pipe full without unbounded memory on a slow one.
53+
inline constexpr std::size_t DEFAULT_MAX_BLOCKS_IN_FLIGHT = 16;
54+
55+
/// Bounded headers-first block-download window. Peer-free + deterministic:
56+
/// callers feed it learned header hashes (enqueue), drain the requests it wants
57+
/// issued now (next_requests), and report arrivals (on_block_received) which
58+
/// free window slots for the next drain.
59+
class BlockDownloadWindow {
60+
/// Low-64 of a cryptographic hash is already uniform -- no further mixing.
61+
struct HashHasher {
62+
std::size_t operator()(const uint256& h) const { return h.GetLow64(); }
63+
};
64+
65+
public:
66+
explicit BlockDownloadWindow(std::size_t max_in_flight = DEFAULT_MAX_BLOCKS_IN_FLIGHT)
67+
: m_max_in_flight(max_in_flight ? max_in_flight : 1) {}
68+
69+
/// Enqueue block hashes learned from a headers batch, in chain order.
70+
/// Skips any hash already queued, in flight, or already received so a
71+
/// re-announce / overlapping locator batch never double-requests. Returns
72+
/// the count newly enqueued.
73+
std::size_t enqueue(const std::vector<uint256>& hashes)
74+
{
75+
std::size_t added = 0;
76+
for (const auto& h : hashes) {
77+
if (m_known.count(h)) continue;
78+
m_known.insert(h);
79+
m_queue.push_back(h);
80+
++added;
81+
}
82+
return added;
83+
}
84+
85+
/// Pop hashes to request now, up to the free window slots
86+
/// (max_in_flight - in_flight). Marks them in flight. Oldest-first
87+
/// (chain order) so block bodies are pulled in the order they were learned.
88+
std::vector<uint256> next_requests()
89+
{
90+
std::vector<uint256> out;
91+
while (m_in_flight.size() < m_max_in_flight && !m_queue.empty()) {
92+
uint256 h = m_queue.front();
93+
m_queue.pop_front();
94+
m_in_flight.insert(h);
95+
out.push_back(h);
96+
}
97+
return out;
98+
}
99+
100+
/// Report an arrived block. Removes it from the in-flight set (freeing a
101+
/// window slot) and remembers it so a later re-announce is not re-queued.
102+
/// Returns true iff it was one we actually had in flight (vs. an
103+
/// unsolicited / already-handled block).
104+
bool on_block_received(const uint256& h)
105+
{
106+
auto it = m_in_flight.find(h);
107+
if (it == m_in_flight.end()) {
108+
// Unsolicited or already handled: still remember it so a later
109+
// headers batch never queues it, but report not-in-flight.
110+
m_known.insert(h);
111+
return false;
112+
}
113+
m_in_flight.erase(it);
114+
return true;
115+
}
116+
117+
std::size_t in_flight() const { return m_in_flight.size(); }
118+
std::size_t queued() const { return m_queue.size(); }
119+
std::size_t max_in_flight() const { return m_max_in_flight; }
120+
/// True when there is window room AND something queued to fill it.
121+
bool has_capacity() const { return m_in_flight.size() < m_max_in_flight && !m_queue.empty(); }
122+
/// True when nothing is queued and nothing is outstanding (IBD drained).
123+
bool idle() const { return m_queue.empty() && m_in_flight.empty(); }
124+
125+
private:
126+
std::size_t m_max_in_flight;
127+
std::deque<uint256> m_queue; // pending, chain order
128+
std::unordered_set<uint256, HashHasher> m_in_flight; // outstanding getdata
129+
std::unordered_set<uint256, HashHasher> m_known; // queued ∪ inflight ∪ received
130+
};
131+
132+
} // namespace bch::coin::block_download

src/impl/bch/coin/p2p_node.hpp

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
#include "mempool.hpp"
5151
#include "merkle.hpp"
5252
#include "header_sync.hpp"
53+
#include "block_download.hpp"
5354

5455
#include <memory>
5556

@@ -129,6 +130,10 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core::
129130
uint256 m_sent_cmpct_hash;
130131
// External mempool for compact block tx matching
131132
Mempool* m_mempool{nullptr};
133+
// Headers-first windowed block download (cold-start IBD). Bounds outstanding
134+
// getdata(MSG_BLOCK) so the synced header stream is pulled as block bodies at
135+
// the peer's pace instead of stalling after header sync. See block_download.hpp.
136+
block_download::BlockDownloadWindow m_block_dl;
132137

133138
// Callbacks for broadcaster integration
134139
using AddrCallback = std::function<void(const std::vector<NetService>&)>;
@@ -348,6 +353,24 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core::
348353
}
349354
}
350355

356+
/// Drain the IBD block-download window: issue getdata(MSG_BLOCK) for every
357+
/// hash the window releases (bounded by MAX_BLOCKS_IN_FLIGHT). Called after
358+
/// enqueuing a synced headers batch and again on each block arrival to top
359+
/// the window back up. No-op when the window is full or the queue is empty.
360+
void drain_block_window()
361+
{
362+
if (!m_peer) return;
363+
for (const auto& bhash : m_block_dl.next_requests()) {
364+
auto getdata_msg = message_getdata::make_raw(
365+
{inventory_type(inventory_type::block, bhash)});
366+
m_peer->write(getdata_msg);
367+
LOG_DEBUG_COIND << "[" << m_chain_label << "] IBD getdata block "
368+
<< bhash.GetHex().substr(0, 16) << "... (in flight "
369+
<< m_block_dl.in_flight() << ", queued "
370+
<< m_block_dl.queued() << ")";
371+
}
372+
}
373+
351374
/// Whether this peer supports compact blocks (BIP 152).
352375
bool supports_compact_blocks() const { return m_peer_supports_cmpct; }
353376
/// Peer's service flags from version message (for NODE_BLOOM check etc.)
@@ -678,6 +701,11 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core::
678701
<< blockhash.GetHex().substr(0, 16) << "..."
679702
<< " txs=" << block.m_txs.size();
680703
emit_full_block(block, blockhash);
704+
705+
// IBD window: this block freed a slot (if it was one we requested) --
706+
// top the window back up so the next queued block bodies stream in.
707+
m_block_dl.on_block_received(blockhash);
708+
drain_block_window();
681709
}
682710

683711
ADD_P2P_HANDLER(headers)
@@ -717,6 +745,20 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core::
717745
LOG_INFO << "[" << m_chain_label << "] IBD: got "
718746
<< vheaders.size() << " headers, continuing from "
719747
<< last_hash.GetHex().substr(0, 16) << "...";
748+
749+
// Headers-first BLOCK download: queue this batch's blocks and
750+
// pull them through the bounded in-flight window. Without this
751+
// IBD walked the header chain to the tip but never downloaded
752+
// a single block body (ABLA size feed / block-connector need
753+
// real block data). block_download.hpp dedupes + bounds.
754+
std::vector<uint256> batch_hashes;
755+
batch_hashes.reserve(vheaders.size());
756+
for (auto& hdr : vheaders) {
757+
auto p = pack(hdr);
758+
batch_hashes.push_back(Hash(p.get_span()));
759+
}
760+
m_block_dl.enqueue(batch_hashes);
761+
drain_block_window();
720762
}
721763
break;
722764
case Followup::RequestBlocks:

src/impl/bch/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ if(BUILD_TESTING)
1414
abla_block_feed_test
1515
abla_block_feed_gap_test
1616
header_sync_test # M5: headers-first IBD continuation policy (pure)
17+
block_download_test # M5: windowed headers-first block download (pure)
1718
)
1819
foreach(t IN LISTS BCH_ABLA_TESTS)
1920
add_executable(bch_${t} ${t}.cpp)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// ---------------------------------------------------------------------------
2+
// bch::coin::block_download::BlockDownloadWindow -- M5 full-block body.
3+
// Pins the windowed headers-first block-download policy that NodeP2P drives
4+
// during cold-start IBD: enqueue learned header hashes, keep at most
5+
// MAX_BLOCKS_IN_FLIGHT getdata(MSG_BLOCK) outstanding, and top the window up
6+
// as blocks arrive. Header-only over coin/block_download.hpp + <core/uint256>;
7+
// no peer, socket, or coin lib.
8+
//
9+
// The behavior under test (the gap this slice closed): before this, IBD walked
10+
// the header chain to the peer tip but never getdata'd a single block body, so
11+
// the ABLA size feed / block-connector had no real block data and cold-start
12+
// IBD could not complete. The window turns the synced header stream into a
13+
// bounded, self-topping-up block download.
14+
//
15+
// p2pool-merged-v36 surface: NONE. per-coin isolation: src/impl/bch/ only.
16+
// ---------------------------------------------------------------------------
17+
18+
#include <cassert>
19+
#include <iostream>
20+
#include <vector>
21+
22+
#include <core/uint256.hpp>
23+
24+
#include "../coin/block_download.hpp"
25+
26+
using bch::coin::block_download::BlockDownloadWindow;
27+
using bch::coin::block_download::DEFAULT_MAX_BLOCKS_IN_FLIGHT;
28+
29+
// Deterministic distinct hashes (no Math.random) -- hash i = uint256(i+1).
30+
static uint256 H(uint64_t i) { return uint256(base_uint<256>(i + 1)); }
31+
32+
static std::vector<uint256> hashes(uint64_t lo, uint64_t hi) {
33+
std::vector<uint256> v;
34+
for (uint64_t i = lo; i < hi; ++i) v.push_back(H(i));
35+
return v;
36+
}
37+
38+
int main()
39+
{
40+
// ---- Window bounds outstanding getdata at max_in_flight ----------------
41+
{
42+
BlockDownloadWindow w(4);
43+
assert(w.idle());
44+
// Enqueue a maximal-ish IBD batch of 10 headers.
45+
assert(w.enqueue(hashes(0, 10)) == 10);
46+
assert(w.queued() == 10);
47+
assert(w.has_capacity());
48+
49+
// First drain requests exactly the window size (4), oldest-first.
50+
auto req = w.next_requests();
51+
assert(req.size() == 4);
52+
assert(req[0] == H(0) && req[3] == H(3)); // chain order preserved
53+
assert(w.in_flight() == 4);
54+
assert(w.queued() == 6);
55+
56+
// Window is full -> a second drain with no arrivals yields nothing.
57+
assert(w.next_requests().empty());
58+
assert(w.in_flight() == 4);
59+
}
60+
61+
// ---- Arrivals free slots; window tops up oldest-first ------------------
62+
{
63+
BlockDownloadWindow w(4);
64+
w.enqueue(hashes(0, 10));
65+
w.next_requests(); // H0..H3 in flight
66+
67+
// H0 arrives (was in flight) -> frees one slot.
68+
assert(w.on_block_received(H(0)) == true);
69+
assert(w.in_flight() == 3);
70+
auto req = w.next_requests(); // top up by 1
71+
assert(req.size() == 1 && req[0] == H(4));
72+
assert(w.in_flight() == 4 && w.queued() == 5);
73+
74+
// Drain the rest as blocks arrive, in order.
75+
for (uint64_t i = 1; i < 10; ++i) {
76+
assert(w.on_block_received(H(i)) == true);
77+
w.next_requests();
78+
}
79+
assert(w.idle());
80+
assert(w.queued() == 0 && w.in_flight() == 0);
81+
}
82+
83+
// ---- Dedupe: re-announce / overlapping locator batch never re-requests --
84+
{
85+
BlockDownloadWindow w(8);
86+
assert(w.enqueue(hashes(0, 5)) == 5);
87+
// Overlapping batch (3..8): only 5,6,7 are new.
88+
assert(w.enqueue(hashes(3, 8)) == 3);
89+
assert(w.queued() == 8);
90+
91+
auto req = w.next_requests(); // window 8 >= 8 queued
92+
assert(req.size() == 8);
93+
// No hash requested twice.
94+
std::vector<uint256> seen;
95+
for (auto& h : req) {
96+
for (auto& s : seen) assert(!(s == h));
97+
seen.push_back(h);
98+
}
99+
100+
// A block we already pulled, re-announced, is not re-queued.
101+
assert(w.on_block_received(H(2)) == true);
102+
assert(w.enqueue({H(2)}) == 0);
103+
assert(w.queued() == 0);
104+
}
105+
106+
// ---- Unsolicited block: not in flight, reported false, still remembered --
107+
{
108+
BlockDownloadWindow w(4);
109+
assert(w.on_block_received(H(99)) == false); // never requested
110+
// ...and remembering it means a later headers batch won't queue it.
111+
assert(w.enqueue({H(99)}) == 0);
112+
}
113+
114+
// ---- Degenerate window (0 -> clamped to 1) still makes progress --------
115+
{
116+
BlockDownloadWindow w(0);
117+
assert(w.max_in_flight() == 1);
118+
w.enqueue(hashes(0, 3));
119+
auto req = w.next_requests();
120+
assert(req.size() == 1 && req[0] == H(0));
121+
assert(w.next_requests().empty()); // window full at 1
122+
assert(w.on_block_received(H(0)) == true);
123+
req = w.next_requests();
124+
assert(req.size() == 1 && req[0] == H(1));
125+
}
126+
127+
// ---- Default window size is the BCHN/Bitcoin per-peer default ----------
128+
{
129+
BlockDownloadWindow w;
130+
assert(w.max_in_flight() == DEFAULT_MAX_BLOCKS_IN_FLIGHT);
131+
assert(DEFAULT_MAX_BLOCKS_IN_FLIGHT == 16);
132+
}
133+
134+
std::cout << "bch block_download window: ALL PASS\n";
135+
return 0;
136+
}

0 commit comments

Comments
 (0)