mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
node v1.32.11: C-Chain restart/recovery serving hardening (isBootstrapped truth + wipe-path ancestry)
Two node/bootstrap-layer fixes for a clean rolling validator upgrade. Consensus engine untouched (v1.35.5 boot-seed from v1.32.10 carried forward). [HIGH — correctness] info.isBootstrapped(C) tracks REAL state, not premature true. manager.IsBootstrapped(id) returned true the instant a chain merely EXISTED in m.chains (set right after createChain launched the async, possibly-stalling bootstrap goroutine) — so a C-Chain stalled at genesis (head 0x0) reported info.isBootstrapped(C)=true, masking the stall from any wait-for-healthy gate. Now keys on the SAME sb.Bootstrapped signal the readiness health check uses (m.Nets.IsChainBootstrapped), set only after runInitialSync reaches the named frontier and the VM goes to normal operation (head advanced, eth-RPC live). GetChains().Bootstrapped fixed identically. New per-chain query on nets.Net + chains.Nets (nil-safe). Regression test TestIsBootstrappedTracksRealConvergence. [MEDIUM — robustness] WIPE-path GetAncestors fetch hardened for ≥2 peers at genesis. Applying the proven avalanchego contract (studied in snowman/bootstrap + getter): (a) Ancestors buffers the whole sample and SKIPS empty batches, returning the first NON-EMPTY one — a size-1 channel let a fast empty reply from a genesis peer win the race and starve a peer that actually held the ancestry; (b) sampleAncestorBeacons PREFERS beacons the frontier round found genuinely ahead (they hold the ancestry) — ava's PeerTracker "ask a prover" bias sourced from the replies we already have, no separate tracker. Tests: TestNodeBootstrap_WipePath_MixedGenesisPeers_ObtainsAncestry, TestSampleAncestorBeacons_PrefersAheadBeacons. Build: main+chains+nets+service/info compile CGO_ENABLED=0 (ARC/Dockerfile path); full chains+nets suites green. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+59
-12
@@ -157,8 +157,27 @@ func (b *blockHandler) sampleAncestorBeacons() (set.Set[ids.NodeID], bool) {
|
||||
if len(connected) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
start := int(b.bsRotor.Add(1)-1) % len(connected)
|
||||
// Prefer beacons that reported an ahead tip in the last frontier round — they HOLD the
|
||||
// ancestry the descent needs, so asking them (rather than a rotated peer that may be at
|
||||
// genesis) is what lets a re-bootstrapping node obtain ancestry even when ≥2 peers are still
|
||||
// at genesis. This is ava's PeerTracker "ask a prover" bias sourced from the frontier replies
|
||||
// we already have (no separate tracker). Fill any remaining slots from the rotated full set so
|
||||
// the sample never shrinks below what blind rotation would pick (defense against a stale/empty
|
||||
// ahead-set). Empty ahead-set ⇒ pure rotation, identical to prior behavior.
|
||||
b.bsMu.Lock()
|
||||
ahead := b.bsAheadBeacons
|
||||
b.bsMu.Unlock()
|
||||
|
||||
sample := set.NewSet[ids.NodeID](bootstrapAncestorSample)
|
||||
for _, id := range connected {
|
||||
if sample.Len() >= bootstrapAncestorSample {
|
||||
break
|
||||
}
|
||||
if ahead != nil && ahead.Contains(id) {
|
||||
sample.Add(id)
|
||||
}
|
||||
}
|
||||
start := int(b.bsRotor.Add(1)-1) % len(connected)
|
||||
for i := 0; i < len(connected) && sample.Len() < bootstrapAncestorSample; i++ {
|
||||
sample.Add(connected[(start+i)%len(connected)])
|
||||
}
|
||||
@@ -538,6 +557,17 @@ func (b *blockHandler) collectFrontierReplies(ctx context.Context, connected []i
|
||||
|
||||
replies := make([]BeaconReply, 0, len(connected))
|
||||
seen := make(map[ids.NodeID]struct{}, len(connected))
|
||||
// ahead = beacons reporting a tip this node has NOT accepted (they hold blocks we lack, so
|
||||
// they can SERVE the ancestry the descent needs). sampleAncestorBeacons prefers them so the
|
||||
// GetAncestors sample targets peers that can serve — never a peer still at genesis (our own
|
||||
// tip). Recorded per round; publishes into b.bsAheadBeacons before returning.
|
||||
ahead := set.NewSet[ids.NodeID](len(connected))
|
||||
record := func() []BeaconReply {
|
||||
b.bsMu.Lock()
|
||||
b.bsAheadBeacons = ahead
|
||||
b.bsMu.Unlock()
|
||||
return replies
|
||||
}
|
||||
deadline := time.After(bootstrapFrontierWindow)
|
||||
for {
|
||||
select {
|
||||
@@ -551,13 +581,16 @@ func (b *blockHandler) collectFrontierReplies(ctx context.Context, connected []i
|
||||
}
|
||||
seen[rep.nodeID] = struct{}{}
|
||||
replies = append(replies, BeaconReply{NodeID: rep.nodeID, Tip: rep.tip, Weight: w})
|
||||
if _, accepted := b.acceptedHeight(rep.tip); !accepted {
|
||||
ahead.Add(rep.nodeID) // reported a tip we have not accepted → genuinely ahead
|
||||
}
|
||||
if len(seen) >= len(connected) {
|
||||
return replies // every connected beacon answered — resolve now, do not wait the window
|
||||
return record() // every connected beacon answered — resolve now, do not wait the window
|
||||
}
|
||||
case <-deadline:
|
||||
return replies
|
||||
return record()
|
||||
case <-ctx.Done():
|
||||
return replies
|
||||
return record()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,7 +614,15 @@ func (b *blockHandler) Ancestors(ctx context.Context, blockID ids.ID, maxBlocks
|
||||
requestID := b.requestIDCounter
|
||||
b.contextRequestMu.Unlock()
|
||||
|
||||
ch := make(chan [][]byte, 1)
|
||||
// Buffer the whole sample so EVERY sampled beacon's reply can queue — the loop
|
||||
// then skips the EMPTY ones (a beacon that lacks the requested block, e.g. a peer
|
||||
// still at genesis) and returns the first NON-EMPTY batch. With a size-1 channel a
|
||||
// fast empty reply won the race and starved a slower peer that actually held the
|
||||
// ancestry, so a re-bootstrapping node with ≥2 peers at genesis could keep drawing
|
||||
// empties and stall. This mirrors the proven avalanchego contract: an empty Ancestors
|
||||
// reply means "this peer can't serve — take another's," never "done" (getter serves an
|
||||
// EXPLICIT empty batch when it lacks the block; see GetContext).
|
||||
ch := make(chan [][]byte, bootstrapAncestorSample)
|
||||
b.bsMu.Lock()
|
||||
b.bsAncestorCh[requestID] = ch
|
||||
b.bsMu.Unlock()
|
||||
@@ -597,13 +638,19 @@ func (b *blockHandler) Ancestors(ctx context.Context, blockID ids.ID, maxBlocks
|
||||
}
|
||||
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()
|
||||
deadline := time.After(bootstrapAncestorsTimeout)
|
||||
for {
|
||||
select {
|
||||
case blocks := <-ch:
|
||||
if len(blocks) == 0 {
|
||||
continue // this beacon can't serve the block — wait for a peer that can
|
||||
}
|
||||
return blocks, nil
|
||||
case <-deadline:
|
||||
return nil, nil // no beacon in the sample served — the loop re-samples (rotated)
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,13 @@ type bsBeaconNet struct {
|
||||
serveAncestors bool // beacons serve ancestry (false models name-only beacons)
|
||||
ancestorsEmpty bool // beacons REPLY to GetAncestors but serve an EMPTY batch (cross-version / withholding)
|
||||
|
||||
// emptyResponders models a MIXED fleet on a WIPE-path recovery: the named beacons REPLY to
|
||||
// GetAncestors with an EMPTY batch (they lack the block — e.g. still at genesis after a
|
||||
// simultaneous wipe), while the rest serve the real ancestry. When set, the mock delivers the
|
||||
// empties FIRST, proving the fetcher (blockHandler.Ancestors) skips them and still returns the
|
||||
// non-empty batch — never starved by a peer that can't serve. Requires serveAncestors=true.
|
||||
emptyResponders set.Set[ids.NodeID]
|
||||
|
||||
// tipFor optionally overrides the tip a specific beacon reports (models DISAGREEMENT —
|
||||
// beacons connected but split across tips, so no ⅔ quorum forms → FrontierNoQuorum).
|
||||
tipFor map[ids.NodeID]ids.ID
|
||||
@@ -334,6 +341,23 @@ func (n *bsBeaconNet) Send(msg message.OutboundMessage, nodeIDs set.Set[ids.Node
|
||||
n.bh.deliverBootstrapFrontier(n.malicious, n.forgedTip)
|
||||
}
|
||||
case "ancestors":
|
||||
if n.emptyResponders != nil {
|
||||
// Mixed fleet: emptyResponders reply EMPTY (they lack the block), the rest serve.
|
||||
// Deliver the empties FIRST so the test proves Ancestors skips them and returns the
|
||||
// slower non-empty batch (the WIPE-path ≥2-at-genesis case).
|
||||
var servers []ids.NodeID
|
||||
for id := range nodeIDs {
|
||||
if n.emptyResponders.Contains(id) {
|
||||
n.bh.deliverBootstrapAncestors(m.requestID, nil)
|
||||
} else {
|
||||
servers = append(servers, id)
|
||||
}
|
||||
}
|
||||
for range servers {
|
||||
n.bh.deliverBootstrapAncestors(m.requestID, n.frame(m.blockID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if n.serveAncestors {
|
||||
n.bh.deliverBootstrapAncestors(m.requestID, n.frame(m.blockID))
|
||||
} else if n.ancestorsEmpty {
|
||||
@@ -512,6 +536,66 @@ func TestNodeBootstrap_EmptyNodeConvergesViaTransport(t *testing.T) {
|
||||
require.True(t, bh.Accepted(ctx, chain[N].id), "node must hold the tip after sync")
|
||||
}
|
||||
|
||||
// TestNodeBootstrap_WipePath_MixedGenesisPeers_ObtainsAncestry is the regression guard for the
|
||||
// WIPE-path ≥2-at-genesis stall (deliverable 3). A re-bootstrapping node samples a fleet where
|
||||
// SOME beacons reply to GetAncestors with an EMPTY batch (they lack the block — still at genesis
|
||||
// after a simultaneous wipe) while the rest serve the real ancestry, and the empties arrive FIRST.
|
||||
// With the pre-fix size-1 reply channel + first-reply-wins, the empty reply won the race and the
|
||||
// good peer's non-empty batch was dropped, so the descent got nothing every round and stalled. The
|
||||
// fix buffers the whole sample and SKIPS empty batches, returning the first non-empty one — so the
|
||||
// node obtains ancestry from the peers that CAN serve, even when ≥2 peers are at genesis.
|
||||
func TestNodeBootstrap_WipePath_MixedGenesisPeers_ObtainsAncestry(t *testing.T) {
|
||||
const N = 30
|
||||
chain, byID := buildBSChain(N, -1)
|
||||
vm := newBSVM(chain)
|
||||
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 5)
|
||||
|
||||
// 2 of 5 beacons are "still at genesis" — they reply EMPTY to GetAncestors. The mock
|
||||
// delivers those empties FIRST so a first-reply-wins fetcher would be starved.
|
||||
empties := set.NewSet[ids.NodeID](2)
|
||||
empties.Add(beacons[0], beacons[1])
|
||||
bh.net = &bsBeaconNet{
|
||||
bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[N],
|
||||
serveAncestors: true, emptyResponders: empties,
|
||||
}
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, runBS(t, bh), "node must converge despite ≥2 peers serving empty ancestry")
|
||||
|
||||
last, _ := vm.LastAccepted(ctx)
|
||||
require.Equal(t, chain[N].id, last, "node must sync to the tip via the peers that CAN serve")
|
||||
require.True(t, bh.Accepted(ctx, chain[N].id))
|
||||
}
|
||||
|
||||
// TestSampleAncestorBeacons_PrefersAheadBeacons proves change 2: the ancestry sample PREFERS
|
||||
// beacons the frontier round found genuinely AHEAD (they hold the ancestry), so a re-bootstrapping
|
||||
// node asks peers that can serve rather than wasting the bounded sample on genesis peers. With more
|
||||
// connected beacons than the sample size, blind rotation would periodically EXCLUDE any given
|
||||
// beacon; the preference guarantees the recorded ahead-beacon is ALWAYS sampled.
|
||||
func TestSampleAncestorBeacons_PrefersAheadBeacons(t *testing.T) {
|
||||
const numBeacons = 8 // > bootstrapAncestorSample (4), so rotation alone would sometimes miss one
|
||||
chain, byID := buildBSChain(1, -1)
|
||||
vm := newBSVM(chain)
|
||||
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, numBeacons)
|
||||
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[1]}
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
|
||||
// Record ONE beacon as ahead (the frontier round's output). It must appear in EVERY sample.
|
||||
ahead := beacons[numBeacons-1]
|
||||
bh.bsMu.Lock()
|
||||
bh.bsAheadBeacons = set.Of(ahead)
|
||||
bh.bsMu.Unlock()
|
||||
|
||||
for i := 0; i < 2*numBeacons; i++ {
|
||||
sample, ok := bh.sampleAncestorBeacons()
|
||||
require.True(t, ok)
|
||||
require.LessOrEqual(t, sample.Len(), bootstrapAncestorSample)
|
||||
require.True(t, sample.Contains(ahead),
|
||||
"the recorded ahead-beacon must be preferred into every ancestry sample (call %d)", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_FrozenVMLastAccepted_ConvergesOffFinalizedLedger is the regression guard for red
|
||||
// HIGH-1: the convergence-recognition must ride the IN-PROCESS consensus finalized ledger, NOT
|
||||
// the VM's LastAccepted cache — which the real ZAP client FREEZES at the boot snapshot for the
|
||||
@@ -775,9 +859,9 @@ func TestNodeBootstrap_NoBeaconSet_ReportsNoBeacons(t *testing.T) {
|
||||
func TestNodeBootstrap_FreshNet_SelfVoteUnderFullConnectivity_CaughtUp(t *testing.T) {
|
||||
const N = 5
|
||||
chain, byID := buildBSChain(N, -1)
|
||||
vm := newBSVM(chain) // the node is at genesis (lastAccepted = genesis)
|
||||
vm := newBSVM(chain) // the node is at genesis (lastAccepted = genesis)
|
||||
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 2) // 2 equal-weight (100) beacons
|
||||
bh.selfNodeID = beacons[0] // THIS node is beacon 0 — it is itself a validator
|
||||
bh.selfNodeID = beacons[0] // THIS node is beacon 0 — it is itself a validator
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
|
||||
// FULL connectivity: the ONE other beacon is connected and reports GENESIS (a fresh net — every
|
||||
@@ -824,7 +908,7 @@ func TestNodeBootstrap_SelfVote_PeerAheadDefeatsCaughtUp(t *testing.T) {
|
||||
func TestNodeBootstrap_SelfVote_PartialConnectivityFailsSafe(t *testing.T) {
|
||||
const N = 5
|
||||
chain, byID := buildBSChain(N, -1)
|
||||
vm := newBSVM(chain) // node at genesis
|
||||
vm := newBSVM(chain) // node at genesis
|
||||
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 3) // self + 2 others
|
||||
bh.selfNodeID = beacons[0]
|
||||
bh.msgCreator = bsMsgBuilder{}
|
||||
@@ -1852,8 +1936,8 @@ func TestBootstrap_AcceptedHeight_StoreVsAcceptance(t *testing.T) {
|
||||
const M = 30
|
||||
const Top = 40
|
||||
chain, _ := buildBSChain(Top, -1)
|
||||
vm := newBSVMAt(chain, M) // accepted 0..M
|
||||
vm.store(chain[M+1 : Top+1]...) // M+1..Top GOSSIPED into the store but UNACCEPTED
|
||||
vm := newBSVMAt(chain, M) // accepted 0..M
|
||||
vm.store(chain[M+1 : Top+1]...) // M+1..Top GOSSIPED into the store but UNACCEPTED
|
||||
bh := &blockHandler{logger: log.NewNoOpLogger(), vm: vm}
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -46,6 +46,30 @@ func (s *Nets) GetOrCreate(chainID ids.ID) (nets.Net, bool) {
|
||||
return chain, true
|
||||
}
|
||||
|
||||
// IsChainBootstrapped reports whether the given chain has finished initial sync
|
||||
// (reached the network frontier and transitioned its VM to normal operation) on
|
||||
// this node — Bootstrapped(chainID) was called for it in its validation net. A
|
||||
// chain that is merely tracked (its sync goroutine launched) but has NOT converged
|
||||
// reads false. This is the per-chain truth manager.IsBootstrapped / info.isBootstrapped
|
||||
// key on, replacing the mere-existence test that returned true the instant a chain
|
||||
// was added to the manager (the premature-true masking bug: a C-Chain stalled at
|
||||
// genesis reported bootstrapped=true). A chainID is added to exactly one net's
|
||||
// tracking, so the first net that reports it bootstrapped is authoritative.
|
||||
func (s *Nets) IsChainBootstrapped(chainID ids.ID) bool {
|
||||
if s == nil {
|
||||
return false // no net tracking wired ⇒ nothing has been marked bootstrapped
|
||||
}
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
for _, chain := range s.chains {
|
||||
if chain.IsChainBootstrapped(chainID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Bootstrapping returns the chainIDs of any chains that are still
|
||||
// bootstrapping.
|
||||
func (s *Nets) Bootstrapping() []ids.ID {
|
||||
|
||||
+27
-8
@@ -2252,6 +2252,17 @@ func watchBootstrapProgress(
|
||||
}
|
||||
}
|
||||
|
||||
// IsBootstrapped reports whether [id] has ACTUALLY finished initial sync on this
|
||||
// node: the chain exists AND its validation net has marked it bootstrapped —
|
||||
// monitorBootstrap called sb.Bootstrapped(id) only after runInitialSync reached the
|
||||
// named network frontier and transitioned the VM to normal operation (head advanced
|
||||
// to the frontier, eth-RPC serving live). The old body returned true the instant the
|
||||
// chain merely EXISTED in m.chains — set right after createChain launched the (async,
|
||||
// possibly-stalling) bootstrap goroutine — so a C-Chain stalled at genesis (head 0x0,
|
||||
// never converged) still reported info.isBootstrapped(C)=true, masking the stall from
|
||||
// any readiness gate keyed on it. Keying on the SAME sb.Bootstrapped signal the health
|
||||
// check (m.Nets.Bootstrapping) already uses makes info.isBootstrapped track real state,
|
||||
// so a hands-off rolling upgrade's wait-for-healthy gate can trust it.
|
||||
func (m *manager) IsBootstrapped(id ids.ID) bool {
|
||||
m.chainsLock.Lock()
|
||||
_, exists := m.chains[id]
|
||||
@@ -2259,9 +2270,7 @@ func (m *manager) IsBootstrapped(id ids.ID) bool {
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Bootstrapped chains start in NormalOp
|
||||
return true
|
||||
return m.Nets.IsChainBootstrapped(id)
|
||||
}
|
||||
|
||||
func (m *manager) GetChains() []ChainInfo {
|
||||
@@ -2271,10 +2280,12 @@ func (m *manager) GetChains() []ChainInfo {
|
||||
result := make([]ChainInfo, 0, len(m.chains))
|
||||
for id, info := range m.chains {
|
||||
result = append(result, ChainInfo{
|
||||
ID: id,
|
||||
Name: info.Name,
|
||||
VMID: info.VMID,
|
||||
Bootstrapped: true,
|
||||
ID: id,
|
||||
Name: info.Name,
|
||||
VMID: info.VMID,
|
||||
// Real per-chain convergence, not mere existence (same fix as IsBootstrapped):
|
||||
// a tracked-but-still-syncing chain reports Bootstrapped=false.
|
||||
Bootstrapped: m.Nets.IsChainBootstrapped(id),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -2726,10 +2737,18 @@ type blockHandler struct {
|
||||
// poll the always-green /ext/health/liveness) would otherwise be a permanent brick.
|
||||
bootstrapConnecting gatomic.Bool
|
||||
bsActive gatomic.Bool // true while the bootstrap loop is driving
|
||||
bsMu sync.Mutex // guards bsFrontierCh + bsAncestorCh
|
||||
bsMu sync.Mutex // guards bsFrontierCh + bsAncestorCh + bsAheadBeacons
|
||||
bsFrontierCh chan bsFrontierReply // weighted frontier replies for the current FrontierTip
|
||||
bsAncestorCh map[uint32]chan [][]byte // requestID -> ancestors reply for the current Ancestors
|
||||
bsRotor gatomic.Uint32 // round-robins the Ancestors peer sample (M1: no monopoly)
|
||||
// bsAheadBeacons is the set of beacons whose accepted tip, reported in the most recent
|
||||
// FrontierTip round, is a block this node does NOT hold — i.e. beacons genuinely AHEAD that
|
||||
// therefore HAVE the ancestry the descent needs. sampleAncestorBeacons prefers them so a
|
||||
// re-bootstrapping node asks GetAncestors of peers that can serve, never wasting the sample on
|
||||
// peers still at genesis (the ava PeerTracker "ask a prover" bias, without a full tracker).
|
||||
// Recorded in collectFrontierReplies; empty ⇒ sampleAncestorBeacons falls back to the full
|
||||
// rotated set (identical to prior behavior). Guarded by bsMu.
|
||||
bsAheadBeacons set.Set[ids.NodeID]
|
||||
|
||||
// vmReady transitions the VM to NORMAL OPERATION (vm.Ready → the EVM's
|
||||
// onNormalOperationsStarted: block building, mempool gossip, validator dispatch).
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/container/buffer"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/nets"
|
||||
lux "github.com/luxfi/utxo"
|
||||
"github.com/luxfi/utxo/nftfx"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
@@ -163,8 +165,10 @@ func TestHoldsAuthorizationNFT(t *testing.T) {
|
||||
|
||||
// newGateManager builds the minimal manager needed to exercise the gate:
|
||||
// only the fields authorizeChainActivation reads. xChainVM, when non-nil, is
|
||||
// installed as the X-Chain's tracked VM so IsBootstrapped(XChainID) is true and
|
||||
// xChainUTXOReader() resolves to it.
|
||||
// installed as the X-Chain's tracked VM AND marked bootstrapped in its net so
|
||||
// IsBootstrapped(XChainID) is true (the gate consults the X-Chain UTXO set, which
|
||||
// is only valid once the X-Chain has finished initial sync — mere presence in
|
||||
// m.chains is no longer sufficient) and xChainUTXOReader() resolves to it.
|
||||
func newGateManager(
|
||||
xChainID ids.ID,
|
||||
stakingAddr ids.ShortID,
|
||||
@@ -172,17 +176,29 @@ func newGateManager(
|
||||
critical set.Set[ids.ID],
|
||||
xChainVM xChainUTXOReader,
|
||||
) *manager {
|
||||
netsTracker, err := NewNets(ids.GenerateTestNodeID(), map[ids.ID]nets.Config{
|
||||
constants.PrimaryNetworkID: {},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m := &manager{
|
||||
chains: make(map[ids.ID]*chainInfo),
|
||||
gatedAttempts: make(map[ids.ID]int),
|
||||
}
|
||||
m.Log = log.NewNoOpLogger()
|
||||
m.Nets = netsTracker
|
||||
m.XChainID = xChainID
|
||||
m.StakingXAddress = stakingAddr
|
||||
m.ChainAuthorizations = authz
|
||||
m.CriticalChains = critical
|
||||
if xChainVM != nil {
|
||||
m.chains[xChainID] = &chainInfo{Name: "X-Chain", VM: xChainVM}
|
||||
// The X-Chain has finished initial sync (its UTXO set is valid) — the real
|
||||
// precondition the gate depends on, now reflected in the net tracking.
|
||||
sb, _ := netsTracker.GetOrCreate(constants.PrimaryNetworkID)
|
||||
sb.AddChain(xChainID)
|
||||
sb.Bootstrapped(xChainID)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -278,11 +294,14 @@ func TestAuthorizeChainActivation(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("gated chain, X-Chain tracked but VM lacks reader, opts out", func(t *testing.T) {
|
||||
// X-Chain is in m.chains (IsBootstrapped true) but its VM does not
|
||||
// satisfy xChainUTXOReader. The gate must fail closed (ready, !authz),
|
||||
// not panic.
|
||||
// X-Chain is bootstrapped (in m.chains AND marked bootstrapped in its net, so
|
||||
// IsBootstrapped true) but its VM does not satisfy xChainUTXOReader. The gate must
|
||||
// fail closed (ready, !authz), not panic.
|
||||
m := newGateManager(xChainID, addr, collection, nil, nil)
|
||||
m.chains[xChainID] = &chainInfo{Name: "X-Chain", VM: struct{}{}}
|
||||
sb, _ := m.Nets.GetOrCreate(constants.PrimaryNetworkID)
|
||||
sb.AddChain(xChainID)
|
||||
sb.Bootstrapped(xChainID)
|
||||
authorized, ready := m.authorizeChainActivation(gatedChain)
|
||||
require.True(t, ready)
|
||||
require.False(t, authorized)
|
||||
|
||||
@@ -177,6 +177,69 @@ func TestIsBootstrapped(t *testing.T) {
|
||||
require.False(m.IsBootstrapped(chainID))
|
||||
}
|
||||
|
||||
// TestIsBootstrappedTracksRealConvergence is the regression guard for the
|
||||
// premature-true masking bug: manager.IsBootstrapped must report true ONLY once the
|
||||
// chain has ACTUALLY finished initial sync (its net marked it Bootstrapped), NOT the
|
||||
// instant it is merely tracked (added to m.chains with its sync goroutine launched).
|
||||
// Before the fix, a C-Chain stalled at genesis (head 0x0) reported
|
||||
// info.isBootstrapped(C)=true, masking the stall from any readiness gate.
|
||||
func TestIsBootstrappedTracksRealConvergence(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
chainConfigs := map[ids.ID]nets.Config{
|
||||
constants.PrimaryNetworkID: {},
|
||||
}
|
||||
netsTracker, err := NewNets(ids.GenerateTestNodeID(), chainConfigs)
|
||||
require.NoError(err)
|
||||
|
||||
config := &ManagerConfig{
|
||||
Log: log.NewNoOpLogger(),
|
||||
Metrics: metric.NewMultiGatherer(),
|
||||
VMManager: vms.NewManager(),
|
||||
ChainDataDir: t.TempDir(),
|
||||
Nets: netsTracker,
|
||||
}
|
||||
m, err := New(config)
|
||||
require.NoError(err)
|
||||
mImpl := m.(*manager)
|
||||
|
||||
// A native chain validated by the primary network — the C-Chain shape.
|
||||
chainID := ids.GenerateTestID()
|
||||
|
||||
// Simulate createChain's tracking: the chain EXISTS in m.chains and is registered
|
||||
// as bootstrapping in its validation net — but has NOT converged (initial sync is
|
||||
// still driving, e.g. stalled at genesis fetching ancestry).
|
||||
mImpl.chainsLock.Lock()
|
||||
mImpl.chains[chainID] = &chainInfo{Name: "C-Chain"}
|
||||
mImpl.chainsLock.Unlock()
|
||||
sb, _ := netsTracker.GetOrCreate(constants.PrimaryNetworkID)
|
||||
require.True(sb.AddChain(chainID))
|
||||
|
||||
// THE FIX: exists-but-not-converged must be FALSE (was true — the masking bug).
|
||||
require.False(m.IsBootstrapped(chainID),
|
||||
"a tracked-but-still-syncing chain must not report bootstrapped")
|
||||
for _, ci := range m.(*manager).GetChains() {
|
||||
if ci.ID == chainID {
|
||||
require.False(ci.Bootstrapped, "GetChains must not report a syncing chain bootstrapped")
|
||||
}
|
||||
}
|
||||
|
||||
// Initial sync reaches the frontier → monitorBootstrap calls sb.Bootstrapped.
|
||||
sb.Bootstrapped(chainID)
|
||||
|
||||
// Now — and only now — it reports bootstrapped (head advanced to frontier, VM live).
|
||||
require.True(m.IsBootstrapped(chainID),
|
||||
"a converged chain must report bootstrapped")
|
||||
found := false
|
||||
for _, ci := range m.(*manager).GetChains() {
|
||||
if ci.ID == chainID {
|
||||
found = true
|
||||
require.True(ci.Bootstrapped, "GetChains must report a converged chain bootstrapped")
|
||||
}
|
||||
}
|
||||
require.True(found)
|
||||
}
|
||||
|
||||
// TestToEngineChannelFlow verifies the toEngine channel notification flow
|
||||
// This tests the goroutine that reads from toEngine and triggers block building
|
||||
func TestToEngineChannelFlow(t *testing.T) {
|
||||
|
||||
+16
@@ -34,6 +34,15 @@ type Net interface {
|
||||
// IsBootstrapped returns true if the chains in this chain are done bootstrapping
|
||||
IsBootstrapped() bool
|
||||
|
||||
// IsChainBootstrapped reports whether a SPECIFIC chain in this net has finished
|
||||
// initial sync — i.e. Bootstrapped(chainID) was called for it (the chain reached
|
||||
// the network frontier and its VM went to normal operation). This is the per-chain
|
||||
// truth that info.isBootstrapped keys on, distinct from IsBootstrapped() which is
|
||||
// the net-wide "no chain still bootstrapping" aggregate. A chain that is merely
|
||||
// tracked (added, sync goroutine launched) but has NOT converged is still in the
|
||||
// bootstrapping set and reads false here — closing the premature-true masking bug.
|
||||
IsChainBootstrapped(chainID ids.ID) bool
|
||||
|
||||
// Bootstrapped marks the chain as done bootstrapping
|
||||
Bootstrapped(chainID ids.ID)
|
||||
|
||||
@@ -66,6 +75,13 @@ func (s *chain) IsBootstrapped() bool {
|
||||
return s.bootstrapping.Len() == 0
|
||||
}
|
||||
|
||||
func (s *chain) IsChainBootstrapped(chainID ids.ID) bool {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
return s.bootstrapped.Contains(chainID)
|
||||
}
|
||||
|
||||
func (s *chain) Bootstrapped(chainID ids.ID) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.22.79
|
||||
1.32.11
|
||||
|
||||
@@ -76,8 +76,8 @@ var (
|
||||
// These should match the latest git tag
|
||||
const (
|
||||
defaultMajor = 1
|
||||
defaultMinor = 30
|
||||
defaultPatch = 6
|
||||
defaultMinor = 32
|
||||
defaultPatch = 11
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
Reference in New Issue
Block a user