Files
node/vms/platformvm/uptime_state_race_test.go
zeekayandHanzo Dev e70f687c10 fix(node): serialize P-chain Connected/Disconnected + plumb real peer version (RED CRITICAL #1/#2)
The uptime event-delivery plumbing (node/chain_router.go + chains/manager.go
blockHandler) dropped two invariants avalanchego's handler upholds — the
consensus lock and the peer version — each a mainnet-fleet crash. The uptime
tracker itself (vms/platformvm/uptime_tracker.go) is RED-cleared and UNCHANGED.

CRITICAL #2 — P-chain state race (concurrent map writes -> fatal):
chainRouter dispatched Connected/Disconnected on the peer-lifecycle goroutine,
where VM.Disconnected -> tracker.Disconnect (state.SetUptime) + state.Commit
(state.write) ran concurrently with the block acceptor's state.CommitBatch
(state.write) on the engine accept goroutine — no shared lock, so ordinary peer
churn triggered Go "concurrent map writes" and crashed the P-chain node.
Fix: one VM-owned stateLock serializes every commit of shared platform state
that originates outside the engine's lock-free VM.Accept call-out —
  - block DECISION: block/executor Block.Accept/Reject hold &vm.stateLock
    (supplied to executor.NewManager) around the whole acceptor visit;
  - peer/lifecycle: VM.Disconnected and the onReady/onBootstrapStarted/Shutdown
    Start/StopTracking uptime flushes hold vm.stateLock.
This is avalanchego's ctx.Lock invariant (accept serialized with
engine.Connected/Disconnected), scoped to the state the platform VM owns and
implemented at the VM: the Lux engine invokes VM.Accept as a lock-free call-out
(no ctx.Lock over accept exists) and a chain-agnostic blockHandler cannot reach a
per-VM accept lock, so routing "through the engine" is not feasible.

CRITICAL #1 — C-Chain nil-version panic on state sync:
blockHandler.Connected passed connector.Connected(ctx, nodeID, nil). proposervm
promotes Connected to coreth, whose state-sync peer tracker compares peer
versions; a nil version deref panics any C-Chain node running state sync (a fresh
join OR a validator rejoining after falling behind — the launch's core invariant).
Fix: plumb the REAL peer version through a versionedConnector capability —
chainRouter.Connected converts the node peer version (luxfi/node/version) to the
VM boundary type (luxfi/version = chain.VersionInfo) and delivers it via
blockHandler.ConnectedWithVersion -> connector.Connected. Audit: geth in-process
Connected is a no-op stub (nil-safe); xvm stores the pointer without deref
(nil-safe); the real coreth plugin derefs -> fixed by feeding the real version.

Tests (SDKROOT + CGO_ENABLED=1, -race):
- vms/platformvm/uptime_state_race_test.go: state.Commit (VM.Disconnected path)
  concurrent with state.CommitBatch (acceptor path) under the shared lock — no
  race/fatal; verified meaningful (an unlocked probe trips DATA RACE).
- chains/blockhandler_connected_version_test.go + node/chain_router_connected_version_test.go:
  the real, converted version reaches the connector non-nil (dedup covered).
Uptime tracker 13/13 and the block/executor reward gate untouched and green;
go build ./... and go vet clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-24 00:34:00 -07:00

151 lines
4.8 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package platformvm
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/constants"
"github.com/luxfi/database/memdb"
"github.com/luxfi/log"
"github.com/luxfi/metric"
"github.com/luxfi/runtime"
validators "github.com/luxfi/validators"
"github.com/luxfi/node/upgrade"
"github.com/luxfi/node/vms/platformvm/config"
"github.com/luxfi/node/vms/platformvm/genesis/genesistest"
platformvmmetrics "github.com/luxfi/node/vms/platformvm/metrics"
"github.com/luxfi/node/vms/platformvm/reward"
"github.com/luxfi/node/vms/platformvm/state"
)
// newRaceTestState builds a REAL platform state (state.New, memdb + genesis) —
// the same constructor the VM uses. The genesis carries a validator set, so
// state.write (invoked by BOTH state.Commit and state.CommitBatch) iterates
// non-empty validator/metadata maps: exactly the shared maps that mutate
// concurrently in the fatal this test guards against.
func newRaceTestState(t *testing.T) state.State {
t.Helper()
require := require.New(t)
reg := metric.NewRegistry()
m, err := platformvmmetrics.New(reg)
require.NoError(err)
execCfg, err := config.GetConfig(nil)
require.NoError(err)
st, err := state.New(
memdb.New(),
genesistest.NewBytes(t, genesistest.Config{}),
reg,
validators.NewManager(),
upgrade.GetConfig(constants.UnitTestID),
execCfg,
&runtime.Runtime{
NetworkID: constants.UnitTestID,
ChainID: constants.PlatformChainID,
Log: log.Noop(),
},
m,
reward.NewCalculator(reward.Config{
MaxConsumptionRate: 120_000,
MinConsumptionRate: 100_000,
MintingPeriod: 365 * 24 * time.Hour,
SupplyCap: 720 * constants.MegaLux,
}),
)
require.NoError(err)
return st
}
// TestStateCommitSerializedWithAcceptNoRace is the regression guard for RED
// CRITICAL #2 (P-chain state race).
//
// Before the fix the event-delivery plumbing drove VM.Disconnected on the
// node's peer-lifecycle goroutine, where it called state.Commit() (→ state.write)
// concurrently with the block acceptor's state.CommitBatch() (→ state.write) on
// the consensus accept goroutine. Both mutate the shared currentValidator /
// staker / metadata maps with no common lock → Go "concurrent map writes" FATAL
// on ordinary peer churn.
//
// The fix installs ONE lock — the platform VM's stateLock — held by BOTH sides:
// - block DECISION: block/executor Block.Accept/Reject hold &vm.stateLock
// (supplied to executor.NewManager), around the whole acceptor visit, and
// - peer/lifecycle commits: VM.Disconnected and the Start/StopTracking uptime
// flushes hold vm.stateLock directly.
//
// This test drives the two REAL racing state operations — state.Commit()
// (VM.Disconnected's exact call) and state.SetUptime()+state.CommitBatch()
// (the tracker-flush + acceptor's exact calls) — from two goroutines, each
// holding the SAME lock the fix installs. Under `-race` it must complete with
// no data race and no fatal. Remove the shared lock and `-race` immediately
// reports the concurrent write inside state.write() (the regression).
func TestStateCommitSerializedWithAcceptNoRace(t *testing.T) {
st := newRaceTestState(t)
// The single serializer the fix installs. In production this is
// &vm.stateLock, held by Block.Accept (via the executor manager) and by
// VM.Disconnected/Start/StopTracking.
var stateLock sync.Mutex
// A genesis validator so SetUptime writes a real record (mirrors
// tracker.Disconnect → state.SetUptime before state.Commit).
nodeID := genesistest.DefaultNodeIDs[0]
netID := constants.PrimaryNetworkID
const iterations = 300
var (
wg sync.WaitGroup
acceptErr error
disconnectErr error
)
wg.Add(2)
// Accept side: the acceptor's state.CommitBatch() + state.Abort() — the exact
// calls block/executor acceptor.standardBlock makes, both routed through
// state.write(). Block.Accept holds stateLock around this.
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
stateLock.Lock()
_, err := st.CommitBatch()
st.Abort()
stateLock.Unlock()
if err != nil {
acceptErr = err
return
}
}
}()
// Disconnect side: tracker.Disconnect (state.SetUptime, error ignored exactly
// as the tracker's updateUptimeLocked ignores ErrNotFound) followed by
// state.Commit() — VM.Disconnected's exact sequence. Holds the SAME stateLock.
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
stateLock.Lock()
_ = st.SetUptime(nodeID, netID, time.Duration(i)*time.Second, time.Unix(int64(i), 0))
err := st.Commit()
stateLock.Unlock()
if err != nil {
disconnectErr = err
return
}
}
}()
wg.Wait()
require := require.New(t)
require.NoError(acceptErr)
require.NoError(disconnectErr)
}