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>
This commit is contained in:
zeekay
2026-07-24 00:34:00 -07:00
co-authored by Hanzo Dev
parent 88bb914cd6
commit e70f687c10
8 changed files with 527 additions and 34 deletions
@@ -0,0 +1,107 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chains
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/log"
version "github.com/luxfi/version"
"github.com/luxfi/vm/chain"
)
// recordingConnector is a chain.ChainVM that records the version it is handed on
// Connected. Every other method comes from the embedded (nil) interface and is
// never invoked by the connect path.
type recordingConnector struct {
chain.ChainVM
mu sync.Mutex
called bool
gotVersion *version.Application
}
func (c *recordingConnector) Connected(_ context.Context, _ ids.NodeID, nodeVersion *chain.VersionInfo) error {
c.mu.Lock()
defer c.mu.Unlock()
c.called = true
c.gotVersion = nodeVersion
return nil
}
func (c *recordingConnector) Disconnected(context.Context, ids.NodeID) error { return nil }
// TestBlockHandlerConnectedWithVersionDeliversRealVersion is the regression
// guard for RED CRITICAL #1 (C-Chain nil-version panic).
//
// Before the fix blockHandler.Connected forwarded connector.Connected(ctx,
// nodeID, nil) — a hardcoded nil version. proposervm promotes Connected to the
// inner C-Chain VM (coreth), whose state-sync peer tracker compares peer
// versions; a nil version there dereferences nil (version.Application.Compare)
// and PANICS a state-syncing node (a fresh join, or a validator rejoining after
// falling behind — the launch's core invariant).
//
// The fix plumbs the REAL peer version through ConnectedWithVersion to the
// connector. This test drives the real blockHandler with a real version and
// asserts the connector receives that non-nil version — never nil.
func TestBlockHandlerConnectedWithVersionDeliversRealVersion(t *testing.T) {
require := require.New(t)
rc := &recordingConnector{}
bh := newBlockHandler(
nil, // BlockBuilder — unused by the connect path
rc, // connector under test
log.Noop(), // logger
nil, // engine
nil, // net
nil, // msgCreator
ids.Empty, // chainID
ids.Empty, // networkID
nil, // beacons
ids.NodeID{}, // selfNodeID
false, // expectsStakedBeacons
)
nodeID := ids.GenerateTestNodeID()
peerVersion := &version.Application{Name: "lux", Major: 1, Minor: 36, Patch: 27}
require.NoError(bh.ConnectedWithVersion(context.Background(), nodeID, peerVersion))
rc.mu.Lock()
defer rc.mu.Unlock()
require.True(rc.called, "connector.Connected must be invoked")
require.NotNil(rc.gotVersion, "connector must receive a NON-nil version (coreth dereferences it in state-sync)")
require.Equal("lux", rc.gotVersion.Name)
require.Equal(1, rc.gotVersion.Major)
require.Equal(36, rc.gotVersion.Minor)
require.Equal(27, rc.gotVersion.Patch)
}
// TestBlockHandlerConnectedDedupsButStillCarriesVersion confirms the version
// survives the once-only dedup: the first (versioned) dispatch reaches the
// connector; a duplicate is a no-op (not a nil-version overwrite).
func TestBlockHandlerConnectedDedupsButStillCarriesVersion(t *testing.T) {
require := require.New(t)
rc := &recordingConnector{}
bh := newBlockHandler(nil, rc, log.Noop(), nil, nil, nil, ids.Empty, ids.Empty, nil, ids.NodeID{}, false)
nodeID := ids.GenerateTestNodeID()
peerVersion := &version.Application{Name: "lux", Major: 1, Minor: 36, Patch: 27}
require.NoError(bh.ConnectedWithVersion(context.Background(), nodeID, peerVersion))
// A duplicate connect (e.g. dispatched again per tracked network) must be a
// no-op — never a second call that could clobber the stored version with nil.
require.NoError(bh.ConnectedWithVersion(context.Background(), nodeID, nil))
rc.mu.Lock()
defer rc.mu.Unlock()
require.NotNil(rc.gotVersion, "the deduped duplicate must not overwrite the real version with nil")
require.Equal(27, rc.gotVersion.Patch)
}
+29 -7
View File
@@ -69,6 +69,7 @@ import (
"github.com/luxfi/node/upgrade"
"github.com/luxfi/node/vms"
validators "github.com/luxfi/validators"
version "github.com/luxfi/version"
"github.com/luxfi/vm/fx"
// "github.com/luxfi/node/vms/metervm" // Temporarily disabled - needs consensus package updates
@@ -4065,11 +4066,33 @@ func (b *blockHandler) GetStateSummary(ctx context.Context, nodeID ids.NodeID, r
func (b *blockHandler) StateSummary(ctx context.Context, nodeID ids.NodeID, requestID uint32, summary []byte) error {
return nil
}
// Connected forwards a peer connection to this chain's VM exactly once. The
// P-chain VM records it in its uptime tracker; other VMs use it for their own
// peer sets. A nil connector makes this a no-op. On a forwarding error the dedup
// entry is rolled back so a subsequent dispatch of the same connection retries.
// Connected satisfies handler.Handler, whose interface carries no peer version.
// The real delivery path is ConnectedWithVersion (chainRouter uses it via the
// versionedConnector capability); this nil-version entry exists only for the
// interface contract and any non-router caller.
func (b *blockHandler) Connected(ctx context.Context, nodeID ids.NodeID) error {
return b.connect(ctx, nodeID, nil)
}
// ConnectedWithVersion forwards a peer connection WITH its real application
// version to this chain's VM. chainRouter invokes this (detecting the
// versionedConnector capability) so the version survives to the inner VM:
// proposervm promotes Connected to the C-Chain (coreth), whose state-sync peer
// tracker compares peer versions — a nil version there dereferences nil and
// panics a state-syncing node (a fresh join OR a validator rejoining after
// falling behind). This mirrors avalanchego, which delivers msg.NodeVersion to
// engine.Connected.
func (b *blockHandler) ConnectedWithVersion(ctx context.Context, nodeID ids.NodeID, nodeVersion *version.Application) error {
return b.connect(ctx, nodeID, nodeVersion)
}
// connect forwards a peer connection to this chain's VM exactly once, carrying
// nodeVersion (nil only on the interface path). The P-chain VM records it in its
// uptime tracker; the C-Chain/X-Chain VMs store the version for their peer sets.
// A nil connector makes this a no-op. On a forwarding error the dedup entry is
// rolled back so a subsequent dispatch of the same connection retries.
func (b *blockHandler) connect(ctx context.Context, nodeID ids.NodeID, nodeVersion *version.Application) error {
if b.connector == nil {
return nil
}
@@ -4081,8 +4104,7 @@ func (b *blockHandler) Connected(ctx context.Context, nodeID ids.NodeID) error {
b.connectedNodes.Add(nodeID)
b.connMu.Unlock()
// The node's p2p layer ignores the version argument; pass nil.
if err := b.connector.Connected(ctx, nodeID, nil); err != nil {
if err := b.connector.Connected(ctx, nodeID, nodeVersion); err != nil {
b.connMu.Lock()
b.connectedNodes.Remove(nodeID)
b.connMu.Unlock()
@@ -4106,7 +4128,7 @@ func (b *blockHandler) Disconnected(ctx context.Context, nodeID ids.NodeID) erro
return b.connector.Disconnected(ctx, nodeID)
}
func (b *blockHandler) HealthCheck(ctx context.Context) (interface{}, error) { return nil, nil }
func (b *blockHandler) HealthCheck(ctx context.Context) (interface{}, error) { return nil, nil }
func (b *blockHandler) Stop(ctx context.Context) {
if b.pollerCancel != nil {
b.pollerCancel()