mirror of
https://github.com/luxfi/node.git
synced 2026-07-28 10:46:10 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user