mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
146 lines
5.4 KiB
Go
146 lines
5.4 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// red_pendingcontext_dos_test.go — the catch-up path must stay bounded against a
|
|
// Byzantine peer.
|
|
//
|
|
// The frontier-sync wiring connects the AcceptedFrontier handler to
|
|
// requestContext(), which records each requested blockID in b.pendingContext.
|
|
// With nothing evicting from that map, a peer streaming AcceptedFrontier frames
|
|
// each naming a distinct random tip grows it without bound → OOM, and a peer that
|
|
// takes a request then withholds Context strands the asker forever.
|
|
// requestContext reaps entries past pendingContextTTL and hard-caps the map at
|
|
// maxPendingContext. These tests pin both properties.
|
|
package chains
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/math/set"
|
|
"github.com/luxfi/node/mesh"
|
|
"github.com/luxfi/node/message"
|
|
"github.com/luxfi/node/proto/p2p"
|
|
)
|
|
|
|
// redStubNet implements the one Network method requestContext uses (Send); every
|
|
// other method is inherited from the embedded nil interface and is never called.
|
|
type redStubNet struct {
|
|
mesh.Network
|
|
sends int
|
|
}
|
|
|
|
func (s *redStubNet) Send(_ message.OutboundMessage, nodeIDs set.Set[ids.NodeID], _ ids.ID, _ uint32) set.Set[ids.NodeID] {
|
|
s.sends++
|
|
return nodeIDs
|
|
}
|
|
|
|
// redStubMsg implements the one OutboundMsgBuilder method requestContext uses.
|
|
type redStubMsg struct {
|
|
message.OutboundMsgBuilder
|
|
}
|
|
|
|
func (redStubMsg) GetAncestors(_ ids.ID, _ uint32, _ time.Duration, _ ids.ID, _ p2p.EngineType) (message.OutboundMessage, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func newRedTestHandler(net mesh.Network) *blockHandler {
|
|
return &blockHandler{
|
|
logger: log.NewNoOpLogger(),
|
|
net: net,
|
|
msgCreator: redStubMsg{},
|
|
chainID: ids.GenerateTestID(),
|
|
pendingContext: make(map[ids.ID]contextRequest),
|
|
}
|
|
}
|
|
|
|
// TestPendingContext_BoundedUnderFlood: a peer streaming distinct fake tips into
|
|
// requestContext can NEVER grow pendingContext past maxPendingContext.
|
|
func TestPendingContext_BoundedUnderFlood(t *testing.T) {
|
|
stubNet := &redStubNet{}
|
|
bh := newRedTestHandler(stubNet)
|
|
|
|
const N = 10_000 // ≫ the maxPendingContext cap — enough to prove "stays bounded"
|
|
from := ids.GenerateTestNodeID()
|
|
for i := 0; i < N; i++ {
|
|
bh.requestContext(context.Background(), from, ids.GenerateTestID())
|
|
}
|
|
|
|
if got := len(bh.pendingContext); got > maxPendingContext {
|
|
t.Fatalf("pendingContext unbounded: %d entries exceeds cap %d", got, maxPendingContext)
|
|
}
|
|
t.Logf("bounded: %d entries after a %d-distinct-tip flood (cap %d, sends=%d)",
|
|
len(bh.pendingContext), N, maxPendingContext, stubNet.sends)
|
|
}
|
|
|
|
// TestPendingContext_StaleEntriesReaped: a request whose Context is withheld past
|
|
// its TTL is reaped on the next requestContext, so the block is re-requestable from
|
|
// an honest peer rather than stranding the asker.
|
|
func TestPendingContext_StaleEntriesReaped(t *testing.T) {
|
|
bh := newRedTestHandler(&redStubNet{})
|
|
from := ids.GenerateTestNodeID()
|
|
|
|
stale := ids.GenerateTestID()
|
|
bh.pendingContext[stale] = contextRequest{
|
|
nodeID: from,
|
|
requestID: 1,
|
|
blockID: stale,
|
|
timestamp: time.Now().Add(-2 * pendingContextTTL),
|
|
}
|
|
|
|
// Any later request runs the reaper before recording its own entry.
|
|
bh.requestContext(context.Background(), from, ids.GenerateTestID())
|
|
|
|
if _, stillThere := bh.pendingContext[stale]; stillThere {
|
|
t.Fatalf("stale pendingContext entry (%v old) not reaped → re-strand persists", 2*pendingContextTTL)
|
|
}
|
|
}
|
|
|
|
// TestPendingContext_StaleEntryForTheSameBlockIsReRequested: the case the test
|
|
// above CANNOT see. It reaps by asking for a *different* block, which reaches the
|
|
// sweep. A node that is behind asks for exactly one block -- the one it is missing
|
|
// -- so it never takes that path: the dedup early-return fires first, every time,
|
|
// and the sweep is unreachable for the only blockID that matters.
|
|
//
|
|
// So the suppression is unbounded in time: the slot for that block sits expired
|
|
// indefinitely past pendingContextTTL, since only a request for some OTHER block
|
|
// would sweep it. It is also self-sustaining -- the wedge keeps the node asking
|
|
// for the same block, and asking for the same block is what keeps it wedged.
|
|
//
|
|
// Without the same-block reap, sends stays 1: the expired slot suppresses
|
|
// forever.
|
|
func TestPendingContext_StaleEntryForTheSameBlockIsReRequested(t *testing.T) {
|
|
stubNet := &redStubNet{}
|
|
bh := newRedTestHandler(stubNet)
|
|
from := ids.GenerateTestNodeID()
|
|
missing := ids.GenerateTestID() // the ONE block this node needs
|
|
|
|
bh.requestContext(context.Background(), from, missing)
|
|
if stubNet.sends != 1 {
|
|
t.Fatalf("setup: want 1 send, got %d", stubNet.sends)
|
|
}
|
|
|
|
// Positive control: while genuinely in flight, a re-ask MUST still be
|
|
// suppressed. Expiry must not become "no dedup at all".
|
|
bh.requestContext(context.Background(), from, missing)
|
|
if stubNet.sends != 1 {
|
|
t.Fatalf("live in-flight request was not deduped: sends=%d, want 1", stubNet.sends)
|
|
}
|
|
|
|
// The peer took the request and never answered. Age the slot past its TTL.
|
|
req := bh.pendingContext[missing]
|
|
req.timestamp = time.Now().Add(-2 * pendingContextTTL)
|
|
bh.pendingContext[missing] = req
|
|
|
|
// Same block, asked again -- the only thing a behind node ever does.
|
|
bh.requestContext(context.Background(), from, missing)
|
|
if stubNet.sends != 2 {
|
|
t.Fatalf("expired request still suppressed its own block: sends=%d, want 2 "+
|
|
"-- the block a behind node needs is the block it can never ask for again",
|
|
stubNet.sends)
|
|
}
|
|
}
|