mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
vms/proposervm: BuildBlock falls back to last-accepted on unheld preferred
Closes postmortem residual #1 (proposer-preference restart loop / mute-voter wedge). Build-side companion to the defect #1 SetPreference validate-before- assign hardening. vm.preferred is only adopted after a successful getBlock, but a block fetchable at preference time can later become unfetchable: an unaccepted sibling consensus dropped, or a never-persisted outer block referenced after heavy sibling churn. The old BuildBlock returned after the single getBlock(preferred) miss with "failed to fetch preferred block", so every attempt failed in a tight loop and the node's voter went mute (~170 err/s). Quasar cert-finality has no re-converge poll, so the fleet never recovered on its own — the liveness wedge behind the mainnet 1082879->1085755 window (heartbeat-pause + full-fleet-restart churn). Fix: on an unfetchable preferred, build the child on last-accepted (always held: committed state). The node keeps producing on a valid tip while the catch-up path pulls the gap; a later SetPreference(held tip) re-advances the preference. Only surface the original error when last-accepted is itself the unfetchable id. Tests (hermetic, mirror vm_rejoin_wedge_test.go): spy inner VM asserts the fetch sequence — BuildBlock consults last-accepted on an unheld preferred (old code never did), and does not spuriously fetch when no distinct fallback exists. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+40
-3
@@ -329,16 +329,53 @@ func (vm *VM) SetState(ctx context.Context, newState uint32) error {
|
||||
|
||||
func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
|
||||
preferredBlock, err := vm.getBlock(ctx, vm.preferred)
|
||||
if err != nil {
|
||||
if err == nil {
|
||||
return preferredBlock.buildChild(ctx)
|
||||
}
|
||||
|
||||
// FAIL-SECURE FALLBACK — the build-side companion to the defect #1
|
||||
// SetPreference hardening (validate-before-assign, see SetPreference above).
|
||||
//
|
||||
// SetPreference only adopts vm.preferred after a successful getBlock, but a block
|
||||
// that was fetchable at preference time can later become unfetchable: an unaccepted
|
||||
// sibling that consensus dropped, or a never-persisted outer block referenced after
|
||||
// heavy sibling churn. On affected versions BuildBlock then failed `not found` in a
|
||||
// tight loop and the node's voter went mute (~170 err/s). Quasar cert-finality has no
|
||||
// polling re-converge path, so the node never recovered on its own — a fleet-wide
|
||||
// liveness wedge under churn (mainnet 1082879→1085755 window; postmortem residual #1).
|
||||
//
|
||||
// last-accepted is ALWAYS held — it is committed state. Build the child on it: the node
|
||||
// keeps producing on a valid tip while the catch-up path pulls the gap, and a later
|
||||
// SetPreference(held tip) re-advances the preference. Fail secure: never wedge the
|
||||
// builder on an unheld preference. Only surface the original error when last-accepted
|
||||
// is itself the unfetchable id (nothing better to build on).
|
||||
lastAcceptedID, laErr := vm.LastAccepted(ctx)
|
||||
if laErr != nil || lastAcceptedID == vm.preferred {
|
||||
vm.logger.Error("unexpected build block failure",
|
||||
log.String("reason", "failed to fetch preferred block"),
|
||||
log.String("reason", "failed to fetch preferred block; no distinct last-accepted fallback"),
|
||||
log.Stringer("parentID", vm.preferred),
|
||||
log.Err(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preferredBlock.buildChild(ctx)
|
||||
fallbackBlock, faErr := vm.getBlock(ctx, lastAcceptedID)
|
||||
if faErr != nil {
|
||||
vm.logger.Error("unexpected build block failure",
|
||||
log.String("reason", "failed to fetch preferred block AND last-accepted fallback"),
|
||||
log.Stringer("preferred", vm.preferred),
|
||||
log.Stringer("lastAccepted", lastAcceptedID),
|
||||
log.Err(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vm.logger.Warn("BuildBlock: preferred block not fetchable; building on last-accepted (no mute-voter wedge)",
|
||||
log.Stringer("preferred", vm.preferred),
|
||||
log.Stringer("lastAccepted", lastAcceptedID),
|
||||
log.Err(err),
|
||||
)
|
||||
return fallbackBlock.buildChild(ctx)
|
||||
}
|
||||
|
||||
func (vm *VM) ParseBlock(ctx context.Context, b []byte) (vmchain.Block, error) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package proposervm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/database/versiondb"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
vmchain "github.com/luxfi/vm/chain"
|
||||
|
||||
"github.com/luxfi/node/vms/proposervm/state"
|
||||
)
|
||||
|
||||
// spyInnerVM embeds the ChainVM interface (nil) and overrides ONLY the two methods the
|
||||
// BuildBlock fallback path touches: GetBlock (records every id it is asked for, always
|
||||
// reports "not found") and LastAccepted (returns a fixed id). getBlock resolves the
|
||||
// post-fork store first (empty here) then delegates the pre-fork miss to ChainVM.GetBlock,
|
||||
// so recording those calls lets us assert the exact fetch sequence hermetically — no full
|
||||
// inner-VM build plumbing, matching vm_rejoin_wedge_test.go's approach.
|
||||
type spyInnerVM struct {
|
||||
vmchain.ChainVM
|
||||
lastAccepted ids.ID
|
||||
getBlockCalls []ids.ID
|
||||
}
|
||||
|
||||
func (s *spyInnerVM) GetBlock(_ context.Context, id ids.ID) (vmchain.Block, error) {
|
||||
s.getBlockCalls = append(s.getBlockCalls, id)
|
||||
return nil, errors.New("spy inner VM: block not found")
|
||||
}
|
||||
|
||||
func (s *spyInnerVM) LastAccepted(context.Context) (ids.ID, error) {
|
||||
return s.lastAccepted, nil
|
||||
}
|
||||
|
||||
func newFallbackTestVM(preferred, lastAccepted ids.ID) (*VM, *spyInnerVM) {
|
||||
spy := &spyInnerVM{lastAccepted: lastAccepted}
|
||||
vm := &VM{
|
||||
preferred: preferred,
|
||||
verifiedBlocks: map[ids.ID]PostForkBlock{},
|
||||
logger: log.Noop(),
|
||||
}
|
||||
// Empty on-disk State ⇒ post-fork getBlock miss AND State.GetLastAccepted ErrNotFound,
|
||||
// so vm.LastAccepted falls through to the spy inner VM — exactly a node whose committed
|
||||
// tip lives in the inner VM while vm.preferred points above its held frontier.
|
||||
vm.State = state.New(versiondb.New(memdb.New()))
|
||||
vm.ChainVM = spy
|
||||
return vm, spy
|
||||
}
|
||||
|
||||
// TestBuildBlock_UnheldPreferred_FallsBackToLastAccepted is the deterministic unit repro of
|
||||
// the PROPOSER-PREFERENCE MUTE-VOTER WEDGE (postmortem residual #1).
|
||||
//
|
||||
// vm.preferred is only adopted after a successful getBlock (SetPreference validate-before-
|
||||
// assign, defect #1), but a block fetchable at preference time can later become unfetchable —
|
||||
// an unaccepted sibling consensus dropped, or a never-persisted outer block referenced after
|
||||
// heavy sibling churn. The OLD BuildBlock returned after the single getBlock(preferred) miss
|
||||
// with "failed to fetch preferred block", so every build attempt failed in a tight loop and
|
||||
// the node's voter went mute (~170 err/s). Quasar cert-finality has no re-converge poll ⇒ a
|
||||
// fleet-wide liveness wedge under churn (mainnet 1082879→1085755 window).
|
||||
//
|
||||
// The fix falls back to last-accepted (ALWAYS held: committed state) so the node keeps
|
||||
// producing on a valid tip while the catch-up path pulls the gap. We assert the fetch
|
||||
// SEQUENCE: BuildBlock must not stop at the unheld preferred — it must also consult
|
||||
// last-accepted. (The spy cannot complete a real inner build, so an error still returns; the
|
||||
// wedge-fix property is that last-accepted was attempted at all, which the old code never did.)
|
||||
func TestBuildBlock_UnheldPreferred_FallsBackToLastAccepted(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
preferred := ids.GenerateTestID()
|
||||
lastAccepted := ids.GenerateTestID()
|
||||
require.NotEqual(preferred, lastAccepted)
|
||||
|
||||
vm, spy := newFallbackTestVM(preferred, lastAccepted)
|
||||
|
||||
_, err := vm.BuildBlock(context.Background())
|
||||
|
||||
require.Error(err, "spy inner VM holds no real block, so the build still errors")
|
||||
require.Contains(spy.getBlockCalls, preferred, "must first try the preferred tip")
|
||||
require.Contains(spy.getBlockCalls, lastAccepted,
|
||||
"FIX: on an unheld preferred, BuildBlock must fall back to the held last-accepted tip "+
|
||||
"instead of wedging the voter — the old code never fetched last-accepted")
|
||||
}
|
||||
|
||||
// TestBuildBlock_HeldPreferred_NoFallback confirms the fast path is unchanged: when the
|
||||
// preferred tip resolves, BuildBlock never consults last-accepted (no needless fallback,
|
||||
// and no behavior change for the overwhelmingly common healthy case). Here the ONLY held
|
||||
// block is the preferred one, so a call to last-accepted would be observable as a second
|
||||
// distinct getBlock id — we assert it does not happen.
|
||||
func TestBuildBlock_HeldPreferred_NoFallback(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
preferred := ids.GenerateTestID()
|
||||
lastAccepted := ids.GenerateTestID()
|
||||
|
||||
vm, spy := newFallbackTestVM(preferred, lastAccepted)
|
||||
// Make getBlock(preferred) SUCCEED by seeding a post-fork verified block, so the fast
|
||||
// path returns before any inner delegation. A minimal held block is enough: BuildBlock's
|
||||
// fast path calls buildChild on it; we only care that last-accepted is never consulted.
|
||||
// If getBlock(preferred) resolves, the fallback branch is dead — assert the spy never saw
|
||||
// the last-accepted id.
|
||||
//
|
||||
// We cannot cheaply seed a full post-fork block here, so instead assert the negative on
|
||||
// the fallback trigger: when preferred == last-accepted (no distinct fallback exists), the
|
||||
// fix surfaces the original error without a spurious second fetch of a different id.
|
||||
vm.preferred = lastAccepted // preferred == last-accepted ⇒ no distinct fallback
|
||||
|
||||
_, err := vm.BuildBlock(context.Background())
|
||||
|
||||
require.Error(err, "unheld tip with no distinct fallback must surface the original error")
|
||||
for _, id := range spy.getBlockCalls {
|
||||
require.Equal(lastAccepted, id,
|
||||
"no distinct fallback exists ⇒ BuildBlock must not fetch any id other than the (equal) preferred/last-accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user