fix(chains): drive REAL initial sync — empty/behind C-Chain node fetches+executes to the frontier

The node declared a chain 'bootstrapped' the instant the engine started, at
whatever LOCAL last-accepted height it had: monitorBootstrap polled
engine.IsBootstrapped() which Start() sets true immediately (genesis for an empty
DB, the partial height for a behind node). No block-fetch sync ever ran, so an
empty luxd-0 stuck at C-Chain height 0 ('finished bootstrapping pollCount=1',
fetched nothing) and a behind node stuck at its partial height while the producers
held the full chain.

Wire the consensus bootstrap loop (engine/chain/bootstrap) over the EXISTING node
transport. The blockHandler becomes the loop's BlockSource (fetch) AND Chain
(execute), in a new file bootstrap_sync.go (compile-asserted against both
interfaces):
  - FrontierTip / Ancestors: synchronous adapters over GetAcceptedFrontier /
    GetAncestors, correlating the async replies through bsFrontierCh / bsAncestorCh.
  - ParseBlock / LastAccepted / Has / AcceptBootstrapBlock: the execute sink
    (AcceptBootstrapBlock delegates to the engine's frontier-trust accept authority).
  - runBootstrapThenPoll: runs the loop to the frontier, ENDS the engine's bootstrap
    phase (FinishBootstrap — only the α-of-K cert-gate finalizes thereafter), flips
    bootstrapDone, THEN starts the live frontier poller. On stall it leaves
    bootstrapDone false so monitorBootstrap surfaces a real failure (never masks).

manager.go (minimal hooks): AcceptedFrontier + handleContext route replies to the
loop while bsActive (else the live cert/vote path is unchanged); buildChain starts
runBootstrapThenPoll instead of the bare poller; monitorBootstrap GATES the chain on
the handler's real BootstrapComplete() (initial sync reached the frontier), not the
engine's premature 'am I started' flag.

Tests (node, via the go.work workspace -> local consensus): an EMPTY node converges
genesis->50 over the real GetAcceptedFrontier/GetAncestors path; an invalid block
HALTS sync at the block below it; framing round-trip; reply gating. Existing chains +
op-table suites stay green.

Depends on the consensus feat/chain-bootstrap-fetch-execute branch (new
AcceptBootstrapBlock / FinishBootstrap / bootstrap pkg). Integrated for dev via
go.work (use ./consensus); DEPLOY = tag consensus (next patch) + bump this require +
keep go.mod pinned (no committed local replace).
This commit is contained in:
zeekay
2026-06-26 16:08:15 -07:00
parent dc81a2fa35
commit 58f1e04928
3 changed files with 733 additions and 15 deletions
+312
View File
@@ -0,0 +1,312 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// bootstrap_sync.go — the node-side wiring for INITIAL SYNC. The blockHandler is both
// the fetch transport (chainbootstrap.BlockSource) and the execute sink
// (chainbootstrap.Chain) the engine/chain/bootstrap loop drives, so an EMPTY or BEHIND
// node converges from its local last-accepted to the network frontier by fetch+execute
// (re-execute each fetched block; no vote, no cert). When the loop reaches the
// frontier the driver ENDS the engine's bootstrap phase (FinishBootstrap — only the
// α-of-K cert-gate finalizes thereafter) and flips bootstrapDone, the REAL ready
// signal monitorBootstrap gates the chain on. Decomplected from the live cert/vote
// path: when bsActive is false every method below is inert and the handler behaves
// exactly as it did before initial sync existed.
package chains
import (
"context"
"encoding/binary"
"time"
cblock "github.com/luxfi/consensus/engine/chain/block"
chainbootstrap "github.com/luxfi/consensus/engine/chain/bootstrap"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
"github.com/luxfi/node/proto/p2p"
)
// The blockHandler IS the bootstrap loop's fetch transport AND execute sink. If it
// ever stops satisfying either, the chain cannot initial-sync — catch it at compile
// time, not in production (the same discipline as the ancestorRequester assertion).
var (
_ chainbootstrap.BlockSource = (*blockHandler)(nil)
_ chainbootstrap.Chain = (*blockHandler)(nil)
)
const (
// bootstrapFrontierTimeout bounds how long FrontierTip waits for a peer to name its
// accepted tip before giving up this round (the driver re-samples).
bootstrapFrontierTimeout = 6 * time.Second
// bootstrapAncestorsTimeout bounds how long Ancestors waits for a peer to serve a
// batch (longer than the 10s GetAncestors deadline so a slow-but-honest peer is not
// abandoned prematurely).
bootstrapAncestorsTimeout = 12 * time.Second
)
// samplePeerTrackingChain returns a small sample of connected peers that track this
// chain. ok=false when none exist (a single-node / dev chain, or a momentarily
// peerless start) — the caller then treats the node as already at the frontier
// (nothing to sync to).
func (b *blockHandler) samplePeerTrackingChain() (set.Set[ids.NodeID], bool) {
if b.net == nil {
return nil, false
}
peers := b.net.PeerInfo(nil)
sample := set.NewSet[ids.NodeID](frontierPollSample)
for _, p := range peers {
if p.TrackedChains.Contains(b.chainID) {
sample.Add(p.ID)
if sample.Len() >= frontierPollSample {
break
}
}
}
return sample, sample.Len() > 0
}
// FrontierTip implements chainbootstrap.BlockSource: ask a sample of peers for their
// accepted tip and return the first reply (the network frontier). ok=false when no
// peer is reachable — the loop treats that as "nothing to sync to" and finishes.
func (b *blockHandler) FrontierTip(ctx context.Context) (ids.ID, bool) {
if b.net == nil || b.msgCreator == nil {
return ids.Empty, false
}
sample, ok := b.samplePeerTrackingChain()
if !ok {
return ids.Empty, false
}
ch := make(chan ids.ID, frontierPollSample)
b.bsMu.Lock()
b.bsFrontierCh = ch
b.bsMu.Unlock()
defer func() {
b.bsMu.Lock()
b.bsFrontierCh = nil
b.bsMu.Unlock()
}()
b.contextRequestMu.Lock()
b.requestIDCounter++
requestID := b.requestIDCounter
b.contextRequestMu.Unlock()
msg, err := b.msgCreator.GetAcceptedFrontier(b.chainID, requestID, 10*time.Second)
if err != nil {
return ids.Empty, false
}
b.net.Send(msg, sample, b.networkID, 0)
select {
case tip := <-ch:
return tip, tip != ids.Empty
case <-time.After(bootstrapFrontierTimeout):
return ids.Empty, false
case <-ctx.Done():
return ids.Empty, false
}
}
// Ancestors implements chainbootstrap.BlockSource: fetch up to maxBlocks blocks ending
// at blockID, OLDEST-FIRST, from a peer (wire: GetAncestors -> Ancestors). An empty
// result (no error) means the sampled peer did not serve — the loop re-samples.
func (b *blockHandler) Ancestors(ctx context.Context, blockID ids.ID, maxBlocks int) ([][]byte, error) {
if b.net == nil || b.msgCreator == nil {
return nil, nil
}
sample, ok := b.samplePeerTrackingChain()
if !ok {
return nil, nil
}
b.contextRequestMu.Lock()
b.requestIDCounter++
requestID := b.requestIDCounter
b.contextRequestMu.Unlock()
ch := make(chan [][]byte, 1)
b.bsMu.Lock()
b.bsAncestorCh[requestID] = ch
b.bsMu.Unlock()
defer func() {
b.bsMu.Lock()
delete(b.bsAncestorCh, requestID)
b.bsMu.Unlock()
}()
msg, err := b.msgCreator.GetAncestors(b.chainID, requestID, 10*time.Second, blockID, p2p.EngineType_ENGINE_TYPE_CHAIN)
if err != nil {
return nil, err
}
b.net.Send(msg, sample, b.networkID, 0)
select {
case blocks := <-ch:
return blocks, nil
case <-time.After(bootstrapAncestorsTimeout):
return nil, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// ParseBlock implements chainbootstrap.Chain: decode block bytes through the SAME
// builder the engine parses through (identity-preserving), so the loop reads each
// block's height + parent for ordering and the descent.
func (b *blockHandler) ParseBlock(ctx context.Context, raw []byte) (cblock.Block, error) {
return b.vm.ParseBlock(ctx, raw)
}
// LastAccepted implements chainbootstrap.Chain: the local last-accepted id + height.
func (b *blockHandler) LastAccepted(ctx context.Context) (ids.ID, uint64, error) {
id, err := b.vm.LastAccepted(ctx)
if err != nil {
return ids.Empty, 0, err
}
if id == ids.Empty {
return ids.Empty, 0, nil
}
blk, err := b.vm.GetBlock(ctx, id)
if err != nil {
return id, 0, nil // id known, height unknown — treat as 0 (genesis-ish)
}
return id, blk.Height(), nil
}
// Has implements chainbootstrap.Chain: whether the node already holds block id (so the
// loop can detect it has reached the frontier).
func (b *blockHandler) Has(ctx context.Context, id ids.ID) bool {
if id == ids.Empty || b.vm == nil {
return false
}
_, err := b.vm.GetBlock(ctx, id)
return err == nil
}
// AcceptBootstrapBlock implements chainbootstrap.Chain: re-execute + finalize a fetched
// block on frontier-trust via the engine's bootstrap accept authority.
func (b *blockHandler) AcceptBootstrapBlock(ctx context.Context, raw []byte) error {
return b.engine.AcceptBootstrapBlock(ctx, raw)
}
// BootstrapComplete reports whether initial sync has reached the frontier. This is the
// REAL bootstrap-ready signal monitorBootstrap gates the chain on — replacing the
// premature engine.IsBootstrapped() poll that returned true at the local last-accepted
// height (the bug: an empty node declared itself bootstrapped at genesis and never
// synced).
func (b *blockHandler) BootstrapComplete() bool { return b.bootstrapDone.Load() }
// deliverBootstrapFrontier routes a frontier reply to the waiting FrontierTip when the
// bootstrap loop is driving. Returns true iff consumed (the caller must then NOT run
// the live auto-fetch). Non-blocking: an extra reply (several peers under one
// requestID) is dropped.
func (b *blockHandler) deliverBootstrapFrontier(containerID ids.ID) bool {
if !b.bsActive.Load() {
return false
}
b.bsMu.Lock()
ch := b.bsFrontierCh
b.bsMu.Unlock()
if ch != nil {
select {
case ch <- containerID:
default:
}
}
return true
}
// deliverBootstrapAncestors routes an Ancestors reply (framed block batch) to the
// waiting Ancestors call when the bootstrap loop is driving. Returns true iff consumed.
func (b *blockHandler) deliverBootstrapAncestors(requestID uint32, data []byte) bool {
if !b.bsActive.Load() {
return false
}
raw := decodeContextBlocks(data)
b.bsMu.Lock()
ch := b.bsAncestorCh[requestID]
b.bsMu.Unlock()
if ch != nil {
select {
case ch <- raw:
default:
}
}
return true
}
// runBootstrapThenPoll is the chain's startup sync driver. It (1) runs the
// fetch+execute bootstrap loop to converge to the network frontier, (2) on success
// ENDS the engine's bootstrap phase (FinishBootstrap — only the α-of-K cert-gate
// finalizes thereafter) and flips bootstrapDone (so monitorBootstrap marks the chain
// ready), then (3) hands off to the live frontier poller (runtime catch-up via the
// cert path). On bootstrap failure it leaves bootstrapDone false so monitorBootstrap
// surfaces a real failure (timeout) rather than masking it. Node-lifetime goroutine
// (started in buildChain); exits when ctx is done (shutdown).
func (b *blockHandler) runBootstrapThenPoll(ctx context.Context) {
if b.engine == nil || b.net == nil || b.msgCreator == nil {
// No transport/engine to drive sync — nothing to bootstrap; treat as ready so a
// degenerate handler does not pin the chain unbootstrapped.
if b.engine != nil {
b.engine.FinishBootstrap()
}
b.bootstrapDone.Store(true)
return
}
b.bsActive.Store(true)
bs := chainbootstrap.New(chainbootstrap.Config{
Source: b,
Chain: b,
Log: b.logger,
})
err := bs.Run(ctx)
b.bsActive.Store(false)
if err != nil {
if ctx.Err() != nil {
return // shutdown — not a bootstrap failure
}
// Initial sync did not complete (stalled / gap-too-large). Do NOT mark the chain
// ready — monitorBootstrap times out and surfaces the failure. The operator
// state-syncs (deep gap) or fixes peering, then restarts.
b.logger.Warn("chain initial sync did not complete — chain NOT marked bootstrapped",
log.Stringer("chainID", b.chainID),
log.Err(err))
return
}
// Reached the frontier: end the bootstrap phase (fail-close the bootstrap accept
// authority) and mark the chain ready, THEN run the live frontier poller.
b.engine.FinishBootstrap()
b.bootstrapDone.Store(true)
b.logger.Info("chain initial sync complete — entering live consensus",
log.Stringer("chainID", b.chainID))
b.runFrontierPoller(ctx)
}
// decodeContextBlocks extracts the raw block bytes (oldest-first) from a framed
// Ancestors payload — the inverse of GetContext's framing, shared by the bootstrap
// fetch path. Each outer [entryLen:4][entry] entry is either a v2 cert-carrying frame
// (we take just the block) or a legacy raw block (the entry IS the block). Strict: a
// malformed length stops the walk (returns what parsed cleanly so far).
func decodeContextBlocks(data []byte) [][]byte {
var out [][]byte
remaining := data
for len(remaining) >= 4 {
entryLen := int(binary.BigEndian.Uint32(remaining[:4]))
remaining = remaining[4:]
if entryLen <= 0 || entryLen > len(remaining) {
break
}
entry := remaining[:entryLen]
remaining = remaining[entryLen:]
if blockBytes, _, isV2 := decodeCatchupEntry(entry); isV2 {
out = append(out, blockBytes)
} else {
out = append(out, entry)
}
}
return out
}
+359
View File
@@ -0,0 +1,359 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// bootstrap_sync_test.go — node-side proof that the blockHandler's bootstrap wiring
// (BlockSource transport adapter + Chain execute sink) converges an EMPTY node from
// genesis to a peer's tip over the real GetAcceptedFrontier/GetAncestors message path,
// plus unit coverage of the framing decode and the reply gating. The fetch+execute
// ALGORITHM itself is proven in consensus (engine/chain/bootstrap); here we prove the
// node TRANSPORT correlates frontier/ancestor replies and drives the loop to the tip.
package chains
import (
"context"
"encoding/binary"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
consensuschain "github.com/luxfi/consensus/engine/chain"
cblock "github.com/luxfi/consensus/engine/chain/block"
chainbootstrap "github.com/luxfi/consensus/engine/chain/bootstrap"
consensusconfig "github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
"github.com/luxfi/node/message"
"github.com/luxfi/node/network"
"github.com/luxfi/node/network/peer"
"github.com/luxfi/node/proto/p2p"
)
// ----- mock block + VM ------------------------------------------------------
type bsTestBlock struct {
id, parent ids.ID
height uint64
bytes []byte
valid bool
accepts int
}
func (b *bsTestBlock) ID() ids.ID { return b.id }
func (b *bsTestBlock) Parent() ids.ID { return b.parent }
func (b *bsTestBlock) ParentID() ids.ID { return b.parent }
func (b *bsTestBlock) Height() uint64 { return b.height }
func (b *bsTestBlock) Timestamp() time.Time { return time.Unix(int64(b.height), 0) }
func (b *bsTestBlock) Status() uint8 { return 0 }
func (b *bsTestBlock) Verify(context.Context) error {
if !b.valid {
return errBSInvalid
}
return nil
}
func (b *bsTestBlock) Accept(context.Context) error { b.accepts++; return nil }
func (b *bsTestBlock) Reject(context.Context) error { return nil }
func (b *bsTestBlock) Bytes() []byte { return b.bytes }
type bsErr string
func (e bsErr) Error() string { return string(e) }
const (
errBSInvalid = bsErr("invalid block")
errBSNoBuild = bsErr("no build")
errBSNotStored = bsErr("not accepted")
errBSUnknown = bsErr("unknown bytes")
)
// bsTestVM models a node VM: it can PARSE any block on the chain (the wire codec is
// deterministic), but only GETS blocks it has ACCEPTED (stored). SetPreference marks a
// block stored — the engine calls it right after Accept, so Has/LastAccepted advance
// exactly as the node syncs.
type bsTestVM struct {
all map[ids.ID]*bsTestBlock
byBytes map[string]*bsTestBlock
accepted map[ids.ID]bool
lastAcc ids.ID
}
func newBSVM(chain []*bsTestBlock) *bsTestVM {
vm := &bsTestVM{
all: map[ids.ID]*bsTestBlock{},
byBytes: map[string]*bsTestBlock{},
accepted: map[ids.ID]bool{},
}
for _, b := range chain {
vm.all[b.id] = b
vm.byBytes[string(b.bytes)] = b
}
// Empty node: only genesis is accepted/stored.
vm.accepted[chain[0].id] = true
vm.lastAcc = chain[0].id
return vm
}
func (m *bsTestVM) BuildBlock(context.Context) (cblock.Block, error) { return nil, errBSNoBuild }
func (m *bsTestVM) GetBlock(_ context.Context, id ids.ID) (cblock.Block, error) {
if m.accepted[id] {
return m.all[id], nil
}
return nil, errBSNotStored
}
func (m *bsTestVM) ParseBlock(_ context.Context, b []byte) (cblock.Block, error) {
if blk, ok := m.byBytes[string(b)]; ok {
return blk, nil
}
return nil, errBSUnknown
}
func (m *bsTestVM) LastAccepted(context.Context) (ids.ID, error) { return m.lastAcc, nil }
func (m *bsTestVM) SetPreference(_ context.Context, id ids.ID) error {
m.accepted[id] = true
m.lastAcc = id
return nil
}
// ----- mock outbound message + builder + peer net ---------------------------
type bsOutMsg struct {
op string
blockID ids.ID
requestID uint32
}
func (*bsOutMsg) BypassThrottling() bool { return true }
func (*bsOutMsg) Op() message.Op { return 0 }
func (*bsOutMsg) Bytes() []byte { return nil }
func (*bsOutMsg) BytesSavedCompression() int { return 0 }
type bsMsgBuilder struct{ message.OutboundMsgBuilder }
func (bsMsgBuilder) GetAcceptedFrontier(_ ids.ID, requestID uint32, _ time.Duration) (message.OutboundMessage, error) {
return &bsOutMsg{op: "frontier", requestID: requestID}, nil
}
func (bsMsgBuilder) GetAncestors(_ ids.ID, requestID uint32, _ time.Duration, blockID ids.ID, _ p2p.EngineType) (message.OutboundMessage, error) {
return &bsOutMsg{op: "ancestors", blockID: blockID, requestID: requestID}, nil
}
// bsPeerNet is a peer holding the full chain. Send dispatches the matching reply back
// into the handler's bootstrap channels (synchronously — the channels are buffered, so
// the reply is queued before the waiting FrontierTip/Ancestors selects on it).
type bsPeerNet struct {
network.Network
bh *blockHandler
chainID ids.ID
byID map[ids.ID]*bsTestBlock
tip *bsTestBlock
peer ids.NodeID
}
func (n *bsPeerNet) PeerInfo([]ids.NodeID) []peer.Info {
return []peer.Info{{ID: n.peer, TrackedChains: set.Of(n.chainID)}}
}
func (n *bsPeerNet) Send(msg message.OutboundMessage, _ set.Set[ids.NodeID], _ ids.ID, _ uint32) set.Set[ids.NodeID] {
m, ok := msg.(*bsOutMsg)
if !ok {
return nil
}
switch m.op {
case "frontier":
n.bh.deliverBootstrapFrontier(n.tip.id)
case "ancestors":
n.bh.deliverBootstrapAncestors(m.requestID, n.frame(m.blockID))
}
return nil
}
// frame serves up to 256 blocks ending at blockID, OLDEST-FIRST, in the same outer
// [entryLen:4][entry] framing GetContext produces (entry = encodeCatchupEntry).
func (n *bsPeerNet) frame(blockID ids.ID) []byte {
tip, ok := n.byID[blockID]
if !ok {
return nil
}
var rev []*bsTestBlock
cur := tip
for i := 0; i < 256; i++ {
rev = append(rev, cur)
if cur.parent == ids.Empty {
break
}
cur = n.byID[cur.parent]
if cur == nil {
break
}
}
var data []byte
for i := len(rev) - 1; i >= 0; i-- { // oldest-first
entry := encodeCatchupEntry(rev[i].bytes, nil)
var lp [4]byte
binary.BigEndian.PutUint32(lp[:], uint32(len(entry)))
data = append(data, lp[:]...)
data = append(data, entry...)
}
return data
}
// buildBSChain builds genesis..N. invalidAt (≥0) marks that height's block invalid.
func buildBSChain(n int, invalidAt int) ([]*bsTestBlock, map[ids.ID]*bsTestBlock) {
chain := make([]*bsTestBlock, 0, n+1)
byID := map[ids.ID]*bsTestBlock{}
var parent ids.ID
for h := 0; h <= n; h++ {
b := &bsTestBlock{
id: ids.GenerateTestID(),
parent: parent,
height: uint64(h),
bytes: []byte("n-blk@" + strconv.Itoa(h) + ":" + ids.GenerateTestID().String()),
valid: h != invalidAt,
}
chain = append(chain, b)
byID[b.id] = b
parent = b.id
}
return chain, byID
}
// newBSHandlerAndEngine wires a blockHandler over a fresh engine (K=1, no verifier) and
// the mock VM, seeded at genesis — the node-side of an EMPTY node.
func newBSHandlerAndEngine(t *testing.T, vm *bsTestVM) (*blockHandler, ids.ID) {
t.Helper()
chainID := ids.GenerateTestID()
networkID := ids.GenerateTestID()
eng := consensuschain.NewRuntime(consensuschain.NetworkConfig{
ChainID: chainID,
NetworkID: networkID,
NodeID: ids.GenerateTestNodeID(),
Logger: log.NewNoOpLogger(),
Params: &consensusconfig.Parameters{K: 1, AlphaPreference: 1, AlphaConfidence: 1, Beta: 1},
VM: vm,
})
// Seed consensus at the VM's genesis (height 0), as buildChain does via
// SyncStateFromVM — this establishes the finalized tip the first gap block extends.
if _, _, err := consensuschain.SyncStateFromVM(context.Background(), vm, eng.Transitive); err != nil {
t.Fatalf("seed: %v", err)
}
bh := &blockHandler{
logger: log.NewNoOpLogger(),
engine: eng,
vm: vm,
chainID: chainID,
networkID: networkID,
pendingContext: make(map[ids.ID]contextRequest),
bsAncestorCh: make(map[uint32]chan [][]byte),
}
return bh, chainID
}
// TestNodeBootstrap_EmptyNodeConvergesViaTransport: a fresh/empty node (only genesis)
// FETCHES blocks 1..N from a peer over the real GetAcceptedFrontier/GetAncestors path,
// re-EXECUTES them, and ACCEPTS up to N — the node-level analog of the consensus
// empty→tip test, proving the transport adapter actually drives the loop.
func TestNodeBootstrap_EmptyNodeConvergesViaTransport(t *testing.T) {
const N = 50
chain, byID := buildBSChain(N, -1)
vm := newBSVM(chain)
bh, chainID := newBSHandlerAndEngine(t, vm)
peerNet := &bsPeerNet{bh: bh, chainID: chainID, byID: byID, tip: chain[N], peer: ids.GenerateTestNodeID()}
bh.net = peerNet
bh.msgCreator = bsMsgBuilder{}
// Drive the SAME loop buildChain runs, with bsActive set (so the peer replies route
// to the loop's channels).
bh.bsActive.Store(true)
bs := chainbootstrap.New(chainbootstrap.Config{Source: bh, Chain: bh, Log: log.NewNoOpLogger()})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := bs.Run(ctx)
bh.bsActive.Store(false)
require.NoError(t, err, "bootstrap loop must converge over the transport")
// The empty node reached the peer's tip purely by fetch+execute.
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N].id, last, "empty node must sync to the peer tip (height %d), not stay at genesis", N)
require.Equal(t, 1, chain[N].accepts, "tip block must be VM-accepted exactly once")
require.True(t, bh.Has(ctx, chain[N].id), "node must hold the tip after sync")
}
// TestNodeBootstrap_InvalidBlockHaltsTransport: a peer that serves a corrupt block at
// height bad makes the node accept 1..bad-1 then STOP — it never advances past the
// unverifiable block (the safety property, over the real transport).
func TestNodeBootstrap_InvalidBlockHaltsTransport(t *testing.T) {
const N = 40
const bad = 25
chain, byID := buildBSChain(N, bad)
vm := newBSVM(chain)
bh, chainID := newBSHandlerAndEngine(t, vm)
peerNet := &bsPeerNet{bh: bh, chainID: chainID, byID: byID, tip: chain[N], peer: ids.GenerateTestNodeID()}
bh.net = peerNet
bh.msgCreator = bsMsgBuilder{}
bh.bsActive.Store(true)
// A short RetryInterval makes the post-halt stall return promptly; the node has
// already halted at bad-1 on the first pass (the assertion below), the rest is just
// the loop confirming it cannot pass the invalid block.
bs := chainbootstrap.New(chainbootstrap.Config{Source: bh, Chain: bh, Log: log.NewNoOpLogger(), RetryInterval: time.Millisecond})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = bs.Run(ctx) // stalls (cannot pass the invalid block) — error is the stall, not a panic
bh.bsActive.Store(false)
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[bad-1].id, last, "sync must halt at the block below the invalid one (height %d)", bad-1)
}
// TestDecodeContextBlocks_RoundTrip: the bootstrap framing decode recovers exactly the
// block bytes GetContext framed, oldest-first, for both v2 (cert-carrying) and legacy
// entries.
func TestDecodeContextBlocks_RoundTrip(t *testing.T) {
want := [][]byte{[]byte("oldest"), []byte("mid"), []byte("newest")}
var data []byte
for i, bz := range want {
var entry []byte
if i == 1 {
entry = bz // legacy raw entry (no magic) — decode must treat the entry AS the block
} else {
entry = encodeCatchupEntry(bz, nil) // v2 frame, empty cert
}
var lp [4]byte
binary.BigEndian.PutUint32(lp[:], uint32(len(entry)))
data = append(data, lp[:]...)
data = append(data, entry...)
}
got := decodeContextBlocks(data)
require.Equal(t, want, got, "decodeContextBlocks must recover the framed blocks oldest-first")
}
// TestDeliverBootstrap_GatedByActive: the reply hooks deliver ONLY while the bootstrap
// loop is driving (bsActive). When inactive they return false so the live cert/vote
// path runs unchanged.
func TestDeliverBootstrap_GatedByActive(t *testing.T) {
bh := &blockHandler{bsAncestorCh: make(map[uint32]chan [][]byte)}
// Inactive: both hooks are inert (return false → caller uses the live path).
require.False(t, bh.deliverBootstrapFrontier(ids.GenerateTestID()))
require.False(t, bh.deliverBootstrapAncestors(1, nil))
// Active: a registered FrontierTip channel receives the reply.
bh.bsActive.Store(true)
fch := make(chan ids.ID, 1)
bh.bsFrontierCh = fch
tip := ids.GenerateTestID()
require.True(t, bh.deliverBootstrapFrontier(tip))
require.Equal(t, tip, <-fch)
// Active: a registered Ancestors channel receives the decoded blocks.
ach := make(chan [][]byte, 1)
bh.bsAncestorCh[7] = ach
entry := encodeCatchupEntry([]byte("blk"), nil)
var lp [4]byte
binary.BigEndian.PutUint32(lp[:], uint32(len(entry)))
require.True(t, bh.deliverBootstrapAncestors(7, append(lp[:], entry...)))
require.Equal(t, [][]byte{[]byte("blk")}, <-ach)
}
+62 -15
View File
@@ -17,6 +17,7 @@ import (
"path/filepath"
"strings"
"sync"
gatomic "sync/atomic"
"time"
"github.com/luxfi/database"
@@ -928,10 +929,14 @@ func (m *manager) createChain(chainParams ChainParameters) {
defer startCancel()
chain.Engine.Start(startCtx, !m.CriticalChains.Contains(chainParams.ID))
// Start a goroutine to monitor bootstrap completion and notify the chain
// Start a goroutine to monitor bootstrap completion and notify the chain.
// This is required because the health check (m.Nets.Bootstrapping()) reports
// chains as not bootstrapped until sb.Bootstrapped(chainID) is called
go m.monitorBootstrap(chain.Engine, sb, chainParams.ID)
// chains as not bootstrapped until sb.Bootstrapped(chainID) is called. Gate on
// the HANDLER's real initial-sync completion (BootstrapComplete), not the
// engine's "am I started" flag — the latter returned true immediately at the
// local last-accepted height, so a behind/empty node was marked bootstrapped
// before fetching anything.
go m.monitorBootstrap(chain.Engine, chain.Handler, sb, chainParams.ID)
} else {
// DAG chains (X-Chain, Q-Chain) manage their own consensus and don't have
// a standard Engine. Mark them as bootstrapped immediately since the DAG
@@ -1459,7 +1464,12 @@ func (m *manager) buildChain(chainParams ChainParameters, sb nets.Net) (*chainIn
// model as the WaitForEvent bridge above).
pollerCtx, pollerCancel := context.WithCancel(context.Background())
bh.pollerCancel = pollerCancel
go bh.runFrontierPoller(pollerCtx)
// Drive INITIAL SYNC first (fetch+execute from the local tip to the network
// frontier — the fix for a node that previously declared itself bootstrapped at
// its local height and synced nothing), then hand off to the live frontier
// poller. bootstrapDone is the REAL ready signal monitorBootstrap gates on. See
// bootstrap_sync.go.
go bh.runBootstrapThenPoll(pollerCtx)
createdChain = &chainInfo{
Name: chainName,
VMID: chainParams.VMID,
@@ -1819,16 +1829,26 @@ var errBootstrapTimeout = errors.New("chain failed to bootstrap within timeout")
//
// IMPORTANT: If bootstrap times out, the chain is NOT marked as bootstrapped. This ensures
// real bootstrap failures are surfaced rather than masked by forcing a "ready" state.
func (m *manager) monitorBootstrap(engine Engine, sb nets.Net, chainID ids.ID) {
// Check if the engine supports IsBootstrapped
type bootstrapChecker interface {
IsBootstrapped() bool
}
checker, ok := engine.(bootstrapChecker)
if !ok {
// Engine doesn't support IsBootstrapped, immediately mark as bootstrapped
// This is safe because if we can't check, we assume the chain is ready
m.Log.Info("engine does not support IsBootstrapped, marking chain as bootstrapped",
func (m *manager) monitorBootstrap(engine Engine, h handler.Handler, sb nets.Net, chainID ids.ID) {
// The READY signal is the handler's real initial-sync completion: the bootstrap
// loop has fetched+executed up to the discovered network frontier. This REPLACES
// the old engine.IsBootstrapped() poll, which returned true the instant the engine
// started — at whatever LOCAL last-accepted height the node had (genesis for an
// empty DB) — so a behind/empty node was declared bootstrapped before syncing a
// single block. A handler that does not drive initial sync (no BootstrapComplete)
// falls back to the engine's started flag (degenerate / DAG-less paths).
type bootstrapCompleter interface{ BootstrapComplete() bool }
type bootstrapChecker interface{ IsBootstrapped() bool }
var ready func() bool
if completer, ok := h.(bootstrapCompleter); ok {
ready = completer.BootstrapComplete
} else if checker, ok := engine.(bootstrapChecker); ok {
ready = checker.IsBootstrapped
} else {
// Cannot check readiness — assume ready (safe: if we cannot tell, do not pin
// the chain unbootstrapped forever).
m.Log.Info("no bootstrap-completion signal available, marking chain as bootstrapped",
log.Stringer("chainID", chainID))
sb.Bootstrapped(chainID)
return
@@ -1851,7 +1871,7 @@ func (m *manager) monitorBootstrap(engine Engine, sb nets.Net, chainID ids.ID) {
select {
case <-ticker.C:
pollCount++
if checker.IsBootstrapped() {
if ready() {
m.Log.Info("chain finished bootstrapping, notifying chain",
log.Stringer("chainID", chainID),
log.Int("pollCount", pollCount))
@@ -2353,6 +2373,18 @@ type blockHandler struct {
// buffer the event and drain when the block arrives
pendingQbits map[ids.ID][]QbitEvent // Map from blockID to buffered Qbit events
pendingQbitMu sync.Mutex // Protects pendingQbits
// INITIAL SYNC (bootstrap). The handler is BOTH the fetch transport (BlockSource)
// and the execute sink (Chain) the engine/chain/bootstrap loop drives — see
// bootstrap_sync.go. While bsActive, inbound frontier/ancestor replies route to the
// correlation channels below (so the synchronous loop can await them) instead of
// the live cert/vote path; bootstrapDone is the REAL ready signal monitorBootstrap
// gates on. bsActive false ⇒ every method behaves exactly as before this change.
bootstrapDone gatomic.Bool // true once initial sync reached the frontier
bsActive gatomic.Bool // true while the bootstrap loop is driving
bsMu sync.Mutex // guards bsFrontierCh + bsAncestorCh
bsFrontierCh chan ids.ID // one-shot frontier reply for the current FrontierTip
bsAncestorCh map[uint32]chan [][]byte // requestID -> ancestors reply for the current Ancestors
}
// QbitEvent is the normalized internal representation of a received Qbit message.
@@ -2385,6 +2417,7 @@ func newBlockHandler(vm consensuschain.BlockBuilder, logger log.Logger, engine *
pendingContext: make(map[ids.ID]contextRequest),
maxContextBlocks: 256, // Default max context blocks to request/serve
pendingQbits: make(map[ids.ID][]QbitEvent),
bsAncestorCh: make(map[uint32]chan [][]byte),
}
}
@@ -2785,6 +2818,14 @@ func (b *blockHandler) handleContext(ctx context.Context, nodeID ids.NodeID, req
return nil
}
// INITIAL SYNC: while the bootstrap loop is driving, this Ancestors reply is the
// oldest-first batch the loop's Ancestors() requested — hand the raw blocks to that
// waiting call (correlated by requestID); the loop decides ordering + re-execution.
// See bootstrap_sync.go.
if b.deliverBootstrapAncestors(requestID, data) {
return nil
}
b.logger.Debug("received context response",
log.Stringer("from", nodeID),
log.Uint32("requestID", requestID),
@@ -2893,6 +2934,12 @@ func (b *blockHandler) AcceptedFrontier(ctx context.Context, nodeID ids.NodeID,
if b.vm == nil || containerID == ids.Empty {
return nil
}
// INITIAL SYNC: while the bootstrap loop is driving, this reply is the frontier the
// loop asked for — hand it to FrontierTip and do NOT auto-fetch (the loop owns the
// descent). See bootstrap_sync.go.
if b.deliverBootstrapFrontier(containerID) {
return nil
}
if _, err := b.vm.GetBlock(ctx, containerID); err == nil {
return nil // we already have the peer's tip — not behind
}