Skip to content

Commit bcfd21c

Browse files
authored
Merge pull request #208 from frstrtr/bch/m5-ibd-robust-locator
bch(M5): HeaderChain back-off locator for IBD getheaders continuation
2 parents ada0260 + 8657bdf commit bcfd21c

5 files changed

Lines changed: 241 additions & 5 deletions

File tree

src/impl/bch/coin/embedded_daemon.hpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class EmbeddedDaemon {
123123
wire_chain_ingest(); // new_headers --> HeaderChain (height advance)
124124
pin_cold_start_anchor(); // operator-approved floor-equivalent anchor
125125
m_node.start_p2p(peer); // read-only P2P connect to the BCHN peer
126+
bind_locator_provider(); // ContinueSync uses HeaderChain back-off locator
126127
}
127128

128129
/// NEAR-TIP variant of the read-only IBD harness (UID 1375 follow-up). The
@@ -159,6 +160,7 @@ class EmbeddedDaemon {
159160
wire_chain_ingest(); // new_headers --> HeaderChain (height advance)
160161
pin_cold_start_anchor(); // ABLA anchor @ the SAME height as the seed
161162
m_node.start_p2p(peer); // read-only P2P connect to the BCHN peer
163+
bind_locator_provider(); // ContinueSync uses HeaderChain back-off locator
162164
}
163165

164166
/// True once the BCHN peer handshake (version/verack) has completed, so the
@@ -198,6 +200,19 @@ class EmbeddedDaemon {
198200
m_coin_node = std::make_unique<CoinNode>(&m_embedded, m_node.rpc());
199201
}
200202

203+
/// Wire NodeP2P's IBD getheaders continuation to the authoritative
204+
/// HeaderChain locator (exponential back-off from the tip). Without this the
205+
/// ContinueSync follow-up falls back to a single-hash locator anchored at
206+
/// the last learned header, which a peer cannot anchor if that header is on
207+
/// a minority fork -- IBD then stalls silently. m_chain has already ingested
208+
/// each batch (wire_chain_ingest) before ContinueSync fires, so get_locator()
209+
/// reflects the just-learned tip. Call AFTER start_p2p (the p2p object must
210+
/// exist). p2pool-merged-v36 surface: NONE (local SPV wire-sync only).
211+
void bind_locator_provider() {
212+
if (auto* p2p = m_node.p2p())
213+
p2p->set_locator_provider([this]{ return m_chain.get_locator(); });
214+
}
215+
201216
/// Drive the authoritative HeaderChain from the live P2P header stream.
202217
/// The P2P front-end (NodeP2P) parses `headers` messages and fires
203218
/// new_headers; until this subscription existed NOTHING advanced m_chain

src/impl/bch/coin/header_sync.hpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
// ---------------------------------------------------------------------------
3737

3838
#include <cstddef>
39+
#include <vector>
3940

4041
namespace bch::coin::header_sync {
4142

@@ -68,4 +69,23 @@ inline Followup classify_headers_batch(
6869
return Followup::Idle;
6970
}
7071

72+
/// Choose the getheaders locator for a ContinueSync IBD follow-up. PURE.
73+
///
74+
/// Prefer the robust HeaderChain-backed locator (`chain_locator`): an
75+
/// exponential back-off set of hashes lets the peer find a common ancestor even
76+
/// if our latest header sits on a minority fork. Fall back to a single-hash
77+
/// locator anchored at the last learned header (`last_hash`) ONLY when the
78+
/// chain-backed locator is unavailable -- i.e. no provider wired yet -- in which
79+
/// case the peer may stall if it cannot anchor that lone hash (degraded, but
80+
/// still forward-progressing on the common-chain case). Templated on the hash
81+
/// type so it stays dependency-light and unit-testable without uint256/coin lib.
82+
template <class Hash>
83+
inline std::vector<Hash> choose_continue_locator(
84+
std::vector<Hash> chain_locator, const Hash& last_hash)
85+
{
86+
if (!chain_locator.empty())
87+
return chain_locator;
88+
return {last_hash};
89+
}
90+
7191
} // namespace bch::coin::header_sync

src/impl/bch/coin/p2p_node.hpp

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,15 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core::
148148
AddrCallback m_addr_callback;
149149
using PeerHeightCallback = std::function<void(uint32_t)>;
150150
PeerHeightCallback m_on_peer_height;
151+
// Robust getheaders locator provider (exponential back-off over the
152+
// authoritative HeaderChain). When set, IBD continuation re-issues
153+
// getheaders with this multi-hash locator so the peer can always find a
154+
// common ancestor even if our latest header sits on a minority fork. When
155+
// unset we fall back to a single-hash locator anchored at the last header
156+
// (degraded -- may stall if the peer cannot anchor that lone hash). Pure
157+
// SPV wire-sync; no PoW/share/coinbase/PPLNS surface.
158+
using LocatorProvider = std::function<std::vector<uint256>()>;
159+
LocatorProvider m_locator_provider;
151160

152161
public:
153162
NodeP2P(io::io_context* context, bch::interfaces::Node* coin, config_t* config,
@@ -321,6 +330,10 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core::
321330
void set_addr_callback(AddrCallback cb) { m_addr_callback = std::move(cb); }
322331
/// Set callback for peer's reported chain height (from version message).
323332
void set_on_peer_height(PeerHeightCallback cb) { m_on_peer_height = std::move(cb); }
333+
/// Set provider for the robust IBD getheaders locator (HeaderChain
334+
/// exponential back-off). Without it, ContinueSync falls back to a
335+
/// single-hash locator anchored at the last learned header.
336+
void set_locator_provider(LocatorProvider cb) { m_locator_provider = std::move(cb); }
324337

325338
/// Send getaddr to request peer addresses.
326339
void send_getaddr()
@@ -829,15 +842,25 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core::
829842
switch (header_sync::classify_headers_batch(vheaders.size())) {
830843
case Followup::ContinueSync:
831844
if (m_peer) {
832-
// Anchor the next locator at the last header we just learned
833-
// so the peer streams the next batch forward to its tip.
845+
// The header chain already ingested this batch (new_headers
846+
// fired above), so a HeaderChain-backed locator is anchored
847+
// at the just-learned tip AND carries exponential back-off
848+
// references -- the peer can find a common ancestor even if
849+
// our tip is on a minority fork. Fall back to a single-hash
850+
// anchor only when no provider is wired (degraded).
834851
auto last_packed = pack(vheaders.back());
835852
auto last_hash = Hash(last_packed.get_span());
853+
std::vector<uint256> chain_locator;
854+
if (m_locator_provider)
855+
chain_locator = m_locator_provider();
856+
auto locator = header_sync::choose_continue_locator(
857+
std::move(chain_locator), last_hash);
836858
send_getheaders(m_peer_version ? m_peer_version : 1,
837-
{last_hash}, uint256::ZERO);
859+
locator, uint256::ZERO);
838860
LOG_INFO << "[" << m_chain_label << "] IBD: got "
839861
<< vheaders.size() << " headers, continuing from "
840-
<< last_hash.GetHex().substr(0, 16) << "...";
862+
<< last_hash.GetHex().substr(0, 16) << "... ("
863+
<< locator.size() << "-hash locator)";
841864
}
842865
break;
843866
case Followup::RequestBlocks:

src/impl/bch/test/header_sync_progress_test.cpp

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@
3333
// PPLNS math). per-coin isolation: src/impl/bch/coin/ only.
3434
// ---------------------------------------------------------------------------
3535

36+
#include <algorithm>
3637
#include <cstddef>
3738
#include <cstdint>
3839
#include <iostream>
40+
#include <vector>
3941

4042
#include "../coin/header_sync.hpp"
4143

@@ -83,6 +85,105 @@ SyncRun drive_ibd(std::uint64_t gap, std::size_t cap = MAX_HEADERS_RESULTS,
8385
return r;
8486
}
8587

88+
// --- Fork-convergence soak: drive the IBD loop through choose_continue_locator
89+
// against a peer whose tip sits past a FORK from our latest header. The
90+
// COMPOSITIONAL counterpart to header_sync_test's pure choose_continue_locator
91+
// cases: proves the back-off locator makes the *loop* CONVERGE where the
92+
// degraded single-hash locator STALLS -- the silent-stall failure PR #208 fixed.
93+
// Header-layer "false_evict" analog = headers the peer re-serves that we already
94+
// hold; anchoring at the exact common ancestor keeps that at ZERO.
95+
using bch::coin::header_sync::choose_continue_locator;
96+
97+
// Hash stand-in: main-chain blocks hash to their height; our fork-only blocks
98+
// hash to height + FORK_TAG so the peer (which holds only the main chain) can
99+
// never anchor a fork-only locator entry.
100+
constexpr std::uint64_t FORK_TAG = 1'000'000'000ull;
101+
102+
inline std::uint64_t our_hash(std::uint64_t height, std::uint64_t fork_point) {
103+
return (height <= fork_point) ? height : height + FORK_TAG;
104+
}
105+
inline bool peer_can_anchor(std::uint64_t hash, std::uint64_t peer_tip) {
106+
return hash < FORK_TAG && hash <= peer_tip; // a main-chain hash the peer holds
107+
}
108+
109+
// Mirror of HeaderChain::get_locator_internal (BIP31 back-off): dense head of 11
110+
// consecutive heights from the tip, then the step doubles each entry down to 0.
111+
std::vector<std::uint64_t> make_chain_locator(std::uint64_t tip_height,
112+
std::uint64_t fork_point) {
113+
std::vector<std::uint64_t> loc;
114+
std::int64_t step = 1;
115+
std::int64_t h = static_cast<std::int64_t>(tip_height);
116+
while (h >= 0) {
117+
loc.push_back(our_hash(static_cast<std::uint64_t>(h), fork_point));
118+
if (h == 0) break;
119+
h -= step;
120+
if (h < 0) h = 0;
121+
if (loc.size() > 10) step *= 2;
122+
}
123+
return loc;
124+
}
125+
126+
struct ForkRun {
127+
std::uint64_t synced = 0; // highest MAIN-chain height we hold at halt
128+
std::size_t reissues = 0; // ContinueSync getheaders re-issues
129+
std::size_t redundant = 0; // already-held headers re-served (false_evict analog)
130+
bool converged = false;
131+
bool stalled = false;
132+
};
133+
134+
// Drive the headers-first IBD continuation against a forked peer.
135+
// fork_tip : height of OUR latest header (on a minority fork above fork_point)
136+
// fork_point : last height common to us and the peer (the true common ancestor)
137+
// peer_tip : the peer's main-chain tip we must converge to
138+
// use_provider: true -> robust HeaderChain back-off locator wired (THE FIX)
139+
// false -> degraded single-hash fallback (no provider) -> empty chain_locator
140+
ForkRun drive_forked_ibd(std::uint64_t fork_tip, std::uint64_t fork_point,
141+
std::uint64_t peer_tip, bool use_provider,
142+
std::size_t cap = MAX_HEADERS_RESULTS) {
143+
ForkRun r;
144+
std::uint64_t our_tip = fork_tip; // height of our latest header
145+
std::uint64_t main_progress = fork_point; // highest main height we already hold
146+
const std::size_t MAX_ROUNDS = 100000; // runaway guard: a real stall trips it
147+
std::size_t rounds = 0;
148+
while (rounds < MAX_ROUNDS) {
149+
++rounds;
150+
// Build the continuation locator exactly as NodeP2P now does.
151+
std::vector<std::uint64_t> chain_loc =
152+
use_provider ? make_chain_locator(our_tip, fork_point)
153+
: std::vector<std::uint64_t>{}; // no provider -> degraded
154+
std::vector<std::uint64_t> loc =
155+
choose_continue_locator(chain_loc, our_hash(our_tip, fork_point));
156+
157+
// Peer anchors at the FIRST (highest) locator entry it holds on its main
158+
// chain; if none, it returns nothing (the silent stall #208 addressed).
159+
std::int64_t anchor = -1;
160+
for (std::uint64_t hh : loc) {
161+
if (peer_can_anchor(hh, peer_tip)) { anchor = static_cast<std::int64_t>(hh); break; }
162+
}
163+
if (anchor < 0) { r.stalled = true; break; } // cannot anchor -> stall
164+
165+
std::uint64_t from = static_cast<std::uint64_t>(anchor);
166+
if (from >= peer_tip) break; // nothing new to serve -> caught up
167+
std::uint64_t want = peer_tip - from;
168+
std::size_t batch = (want >= cap) ? cap : static_cast<std::size_t>(want);
169+
std::uint64_t served_to = from + batch;
170+
171+
// Headers in (from, main_progress] are ones we already hold -> re-served.
172+
if (from < main_progress)
173+
r.redundant += static_cast<std::size_t>(std::min(served_to, main_progress) - from);
174+
175+
our_tip = served_to; // now on the peer's main chain
176+
if (served_to > main_progress) main_progress = served_to;
177+
r.synced = main_progress;
178+
179+
Followup f = classify_headers_batch(batch, DEFAULT_ANNOUNCE_THRESHOLD, cap);
180+
if (f == Followup::ContinueSync) { ++r.reissues; continue; }
181+
break; // converged / caught up
182+
}
183+
r.converged = (r.synced == peer_tip) && !r.stalled;
184+
return r;
185+
}
186+
86187
} // namespace
87188

88189
int main()
@@ -141,6 +242,46 @@ int main()
141242
CHECK(empty.final_action == Followup::Idle); // nothing arrived -> Idle
142243
}
143244

245+
// ---- inv. 6: shallow fork -> back-off locator anchors at the EXACT common
246+
// ancestor, converges to peer tip, re-walks ZERO already-held headers
247+
// (false_evict analog == 0). Loop-level proof of PR #208 over a live-shaped
248+
// cursor (anchor 955700 -> peer tip 955822, the VM300 bchn-bch range).
249+
{
250+
const std::uint64_t peer_tip = 955822, fork_point = 955700;
251+
const std::uint64_t fork_tip = fork_point + 3; // 3-block minority fork
252+
ForkRun r = drive_forked_ibd(fork_tip, fork_point, peer_tip, /*use_provider=*/true);
253+
CHECK(r.converged); // reached peer tip (no stall)
254+
CHECK(r.synced == peer_tip);
255+
CHECK(r.redundant == 0); // anchored at exact ancestor: 0 re-walk
256+
CHECK(r.reissues == (peer_tip - fork_point - 1) / CAP); // bounded re-issues
257+
CHECK(!r.stalled);
258+
}
259+
260+
// ---- inv. 7: SAME fork, degraded single-hash locator (no provider) STALLS.
261+
// The peer cannot anchor our lone minority-fork tip -> serves nothing -> IBD
262+
// never converges. Proves the back-off provider is LOAD-BEARING, not cosmetic:
263+
// remove it and cold-start IBD across a fork silently hangs (the pre-#208 bug).
264+
{
265+
const std::uint64_t peer_tip = 955822, fork_point = 955700;
266+
const std::uint64_t fork_tip = fork_point + 3;
267+
ForkRun r = drive_forked_ibd(fork_tip, fork_point, peer_tip, /*use_provider=*/false);
268+
CHECK(r.stalled); // degraded path hangs on the fork
269+
CHECK(!r.converged);
270+
CHECK(r.synced < peer_tip);
271+
}
272+
273+
// ---- inv. 8: DEEP fork (50 blocks) still converges via back-off, never
274+
// stalls; the re-walk stays bounded by one batch, not unbounded.
275+
{
276+
const std::uint64_t peer_tip = 955822, fork_point = 955700;
277+
const std::uint64_t fork_tip = fork_point + 50; // deep minority fork
278+
ForkRun r = drive_forked_ibd(fork_tip, fork_point, peer_tip, /*use_provider=*/true);
279+
CHECK(r.converged);
280+
CHECK(r.synced == peer_tip);
281+
CHECK(!r.stalled);
282+
CHECK(r.redundant < CAP); // re-walk bounded, finite
283+
}
284+
144285
if (failures == 0)
145286
std::cout << "bch header_sync sync-to-peer-tip soak: ALL PASS\n";
146287
else

src/impl/bch/test/header_sync_test.cpp

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@
1515

1616
#include <cassert>
1717
#include <iostream>
18+
#include <string>
19+
#include <vector>
1820

1921
#include "../coin/header_sync.hpp"
2022

2123
using bch::coin::header_sync::Followup;
2224
using bch::coin::header_sync::classify_headers_batch;
2325
using bch::coin::header_sync::MAX_HEADERS_RESULTS;
2426
using bch::coin::header_sync::DEFAULT_ANNOUNCE_THRESHOLD;
27+
using bch::coin::header_sync::choose_continue_locator;
2528

2629
int main()
2730
{
@@ -53,6 +56,40 @@ int main()
5356
// Custom small cap: at-cap batch is ContinueSync.
5457
assert(classify_headers_batch(10, /*announce=*/3, /*cap=*/10) == Followup::ContinueSync);
5558

56-
std::cout << "bch header_sync classify: ALL PASS\n";
59+
// -- choose_continue_locator: ContinueSync locator selection (fork safety) --
60+
// Use std::string as the hash stand-in so this stays pure (no uint256/coin lib).
61+
const std::string last = "last_header";
62+
63+
// No provider / empty chain locator -> degraded single-hash fallback anchored
64+
// at the last learned header (forward-progress on the common-chain case).
65+
{
66+
std::vector<std::string> empty_chain;
67+
auto loc = choose_continue_locator(empty_chain, last);
68+
assert(loc.size() == 1);
69+
assert(loc[0] == last);
70+
}
71+
72+
// Provider returns a robust HeaderChain back-off locator -> it is used
73+
// VERBATIM (multi-hash, fork-resilient), NOT the single-hash fallback. THE FIX.
74+
{
75+
std::vector<std::string> chain = {"tip", "tip-1", "tip-3", "tip-7", "genesis"};
76+
auto loc = choose_continue_locator(chain, last);
77+
assert(loc.size() == 5);
78+
assert(loc.front() == "tip"); // anchored at the just-learned tip
79+
assert(loc.back() == "genesis"); // back-off reaches a deep ancestor
80+
// The lone last_hash is NOT substituted when a real locator exists.
81+
assert(loc != std::vector<std::string>{last});
82+
}
83+
84+
// A single-element chain locator is still preferred over the raw fallback
85+
// (the provider, not the handler, decided that one hash suffices).
86+
{
87+
std::vector<std::string> chain = {"only_tip"};
88+
auto loc = choose_continue_locator(chain, last);
89+
assert(loc.size() == 1);
90+
assert(loc[0] == "only_tip");
91+
}
92+
93+
std::cout << "bch header_sync classify + continue-locator: ALL PASS\n";
5794
return 0;
5895
}

0 commit comments

Comments
 (0)