Skip to content

Commit 3dd2271

Browse files
committed
fix(eth): make peer removal idempotent to stop unregister flood
Concurrent removePeer callers (BFT broadcast loops, DAO fork timers and normal teardown) could all pass the Peer(id) lookup before the first one unregistered the peer, re-running the unregister sequence and flooding the logs with hundreds of "peer is not registered" warnings on every peer drop. Mark peer removal with an atomic flag so only the first caller proceeds, and make downloader.UnregisterPeer treat an already-unregistered peer as a silent no-op.
1 parent f13dc61 commit 3dd2271

6 files changed

Lines changed: 283 additions & 3 deletions

File tree

eth/downloader/downloader.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,15 +354,44 @@ func (d *Downloader) RegisterLightPeer(id string, version int, peer LightPeer) e
354354
return d.RegisterPeer(id, version, &lightPeerWrapper{peer})
355355
}
356356

357-
// UnregisterPeer remove a peer from the known list, preventing any action from
357+
// UnregisterPeer removes a peer from the known list, preventing any action from
358358
// the specified peer. An effort is also made to return any pending fetches into
359359
// the queue.
360+
//
361+
// Unregistering a peer that is not (or no longer) in the downloader returns
362+
// errNotRegistered — the exported contract is preserved. That case is logged at
363+
// Debug level since it is an expected condition, not an anomaly: the peer's
364+
// RegisterPeer call may have failed or been rejected, or its removal may have
365+
// raced ahead of registration (see ProtocolManager.removePeer and
366+
// registerDownloaderPeer). The caller is responsible for tolerating it as an
367+
// idempotent no-op.
368+
//
369+
// The caller must guarantee at-most-once semantics per peer. Today only
370+
// ProtocolManager.removePeer calls this, and its markRemoved guard ensures a
371+
// peer is unregistered at most once. errNotRegistered here can mean the peer
372+
// was never registered in the downloader (its RegisterPeer call failed or was
373+
// rejected), but it can also mean the removal raced ahead of the downloader
374+
// registration: removePeer ran in the window between pm.peers.Register and
375+
// pm.downloader.RegisterPeer in handle. handle closes that gap by re-checking
376+
// the peer's removed flag after RegisterPeer and undoing the registration, so
377+
// no stale downloader entry is left behind. If a second caller is ever added,
378+
// it must uphold the same at-most-once guarantee.
360379
func (d *Downloader) UnregisterPeer(id string) error {
361380
// Unregister the peer from the active peer set and revoke any fetch tasks
362381
logger := log.New("peer", id)
363382
logger.Trace("Unregistering sync peer")
364383
if err := d.peers.Unregister(id); err != nil {
365-
logger.Warn("Failed to unregister sync peer", "err", err)
384+
if errors.Is(err, errNotRegistered) {
385+
// Expected: the peer was never registered in the downloader (its
386+
// RegisterPeer call failed or was rejected), or its removal raced
387+
// ahead of registration. Keep it quiet — this is not an anomaly
388+
// worth a warning. The error is still returned so the exported
389+
// contract is preserved; the sole caller (removePeer) treats an
390+
// already-unregistered peer as an idempotent no-op.
391+
logger.Debug("Sync peer was never registered")
392+
} else {
393+
logger.Warn("Failed to unregister sync peer", "err", err)
394+
}
366395
return err
367396
}
368397
d.queue.Revoke(id)

eth/downloader/downloader_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,3 +2299,37 @@ func TestRequestTTL(t *testing.T) {
22992299
t.Fatalf("ttlLimit (%v) is below rttMaxEstimate (%v)", ttlLimit, rttMaxEstimate)
23002300
}
23012301
}
2302+
2303+
// TestDownloaderUnregisterPeerTwice verifies that unregistering a peer that is
2304+
// no longer present returns errNotRegistered, preserving the exported contract.
2305+
// Duplicate removals are normally prevented upstream by
2306+
// ProtocolManager.removePeer's markRemoved guard; when one slips through,
2307+
// removePeer tolerates errNotRegistered as an idempotent no-op.
2308+
func TestDownloaderUnregisterPeerTwice(t *testing.T) {
2309+
dl := newTester()
2310+
defer dl.terminate()
2311+
2312+
chain := newTestChain(1, testGenesis)
2313+
if err := dl.newPeer("unreg", 62, chain); err != nil {
2314+
t.Fatalf("failed to register test peer: %v", err)
2315+
}
2316+
if err := dl.downloader.UnregisterPeer("unreg"); err != nil {
2317+
t.Fatalf("first unregister failed: %v", err)
2318+
}
2319+
if err := dl.downloader.UnregisterPeer("unreg"); err != errNotRegistered {
2320+
t.Fatalf("second unregister error mismatch: got %v want %v", err, errNotRegistered)
2321+
}
2322+
}
2323+
2324+
// TestDownloaderUnregisterPeerNeverRegistered verifies that unregistering a
2325+
// peer that was never registered in the downloader returns errNotRegistered.
2326+
// That path is expected for peers whose RegisterPeer call was rejected, so it
2327+
// is logged at debug level rather than as a warning.
2328+
func TestDownloaderUnregisterPeerNeverRegistered(t *testing.T) {
2329+
dl := newTester()
2330+
defer dl.terminate()
2331+
2332+
if err := dl.downloader.UnregisterPeer("ghost"); err != errNotRegistered {
2333+
t.Fatalf("unregistering a never-registered peer error mismatch: got %v want %v", err, errNotRegistered)
2334+
}
2335+
}

eth/handler.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,19 +281,53 @@ func (pm *ProtocolManager) removePeer(id string) {
281281
if peer == nil {
282282
return
283283
}
284+
// Claim the removal exactly once. Concurrent callers (BFT broadcast loops,
285+
// DAO fork timers, normal teardown) that all passed the lookup above would
286+
// otherwise re-run the unregister sequence and flood the logs with
287+
// duplicate "peer is not registered" warnings.
288+
if !peer.markRemoved() {
289+
return
290+
}
284291
log.Debug("Removing Ethereum peer", "peer", id)
285292

286293
// Unregister the peer from the downloader and Ethereum peer set
287294
pm.downloader.UnregisterPeer(id)
288295
pm.txFetcher.Drop(id)
289296

297+
// Unregister is expected to succeed here: only removePeer removes peers
298+
// from the set, and the markRemoved guard above means this peer was still
299+
// present when the removal was claimed. If it ever failed, the peer would
300+
// stay in the set with `removed` already set, leaving it unreachable for
301+
// future removals — so treat a failure as a genuine anomaly, not a
302+
// retryable condition.
290303
if err := pm.peers.Unregister(id); err != nil {
291304
log.Debug("Peer removal failed", "peer", id, "err", err)
292305
}
293306
// Hard disconnect at the networking layer
294307
peer.Peer.Disconnect(p2p.DiscUselessPeer)
295308
}
296309

310+
// registerDownloaderPeer registers the peer with the downloader, undoing the
311+
// registration if the peer's removal was claimed in the window between
312+
// pm.peers.Register and this call. A BFT broadcaster (BroadcastVote /
313+
// BroadcastTimeout / BroadcastSyncInfo) can remove a peer in that gap: its
314+
// removePeer claims the removal, but the downloader UnregisterPeer there hits
315+
// errNotRegistered (the peer is not yet registered in the downloader), so
316+
// without this recheck the downloader would keep a stale entry that blocks a
317+
// reconnect of the same node id (errAlreadyRegistered). Returns DiscUselessPeer
318+
// to abort the handshake when the removal was already claimed, matching the
319+
// disconnect reason removePeer uses.
320+
func (pm *ProtocolManager) registerDownloaderPeer(p *peer) error {
321+
if err := pm.downloader.RegisterPeer(p.id, p.version, p); err != nil {
322+
return err
323+
}
324+
if p.removed.Load() {
325+
pm.downloader.UnregisterPeer(p.id)
326+
return p2p.DiscUselessPeer
327+
}
328+
return nil
329+
}
330+
297331
func (pm *ProtocolManager) Start(maxPeers int) {
298332
pm.maxPeers = maxPeers
299333

@@ -390,7 +424,7 @@ func (pm *ProtocolManager) handle(p *peer) error {
390424
defer pm.removePeer(p.id)
391425

392426
// Register the peer in the downloader. If the downloader considers it banned, we disconnect
393-
if err := pm.downloader.RegisterPeer(p.id, p.version, p); err != nil {
427+
if err := pm.registerDownloaderPeer(p); err != nil {
394428
return err
395429
}
396430
p.Log().Info("Register peer", "nodeid", p.ID().String(), "version", p.version, "addr", p.RemoteAddr())

eth/handler_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"math"
2222
"math/big"
2323
"math/rand"
24+
"sync"
2425
"testing"
2526
"time"
2627

@@ -36,6 +37,7 @@ import (
3637
"github.com/XinFinOrg/XDPoSChain/eth/ethconfig"
3738
"github.com/XinFinOrg/XDPoSChain/event"
3839
"github.com/XinFinOrg/XDPoSChain/p2p"
40+
"github.com/XinFinOrg/XDPoSChain/p2p/enode"
3941
"github.com/XinFinOrg/XDPoSChain/params"
4042
)
4143

@@ -765,3 +767,138 @@ func daoChallengeChainConfig(daoForkSupport bool) *params.ChainConfig {
765767

766768
return config
767769
}
770+
771+
// waitForPeerRegistration blocks until the peer with the given id has been
772+
// registered by the protocol manager's handle goroutine.
773+
func waitForPeerRegistration(t *testing.T, pm *ProtocolManager, id string) {
774+
t.Helper()
775+
deadline := time.After(2 * time.Second)
776+
for pm.peers.Peer(id) == nil {
777+
select {
778+
case <-deadline:
779+
t.Fatalf("test peer %s was not registered in time", id)
780+
case <-time.After(10 * time.Millisecond):
781+
}
782+
}
783+
}
784+
785+
// TestProtocolManagerRemovePeerIdempotent verifies that removePeer is a no-op
786+
// once the peer is gone, so duplicate removals (BFT broadcast loops, DAO fork
787+
// timers and normal teardown) cannot re-run the unregister sequence.
788+
func TestProtocolManagerRemovePeerIdempotent(t *testing.T) {
789+
pm, _ := newTestProtocolManagerMust(t, downloader.FullSync, 0, nil, nil)
790+
defer pm.Stop()
791+
792+
// Register a peer through the normal protocol handshake path.
793+
tp, errc := newTestPeer("test-peer", xdc165, pm, true)
794+
defer tp.close()
795+
defer tp.app.Close()
796+
defer func() {
797+
select {
798+
case <-errc:
799+
default:
800+
}
801+
}()
802+
waitForPeerRegistration(t, pm, tp.id)
803+
804+
if pm.peers.Len() != 1 {
805+
t.Fatalf("peer set size mismatch: got %d want 1", pm.peers.Len())
806+
}
807+
// The first removal performs the full unregister sequence.
808+
pm.removePeer(tp.id)
809+
if pm.peers.Peer(tp.id) != nil {
810+
t.Fatal("peer still registered after first removePeer")
811+
}
812+
if pm.peers.Len() != 0 {
813+
t.Fatalf("peer set size mismatch after removal: got %d want 0", pm.peers.Len())
814+
}
815+
// A duplicate removal must be a silent no-op. Note this second call is
816+
// already short-circuited by the peer == nil lookup above (the first
817+
// removal took the peer out of the set), so it does NOT exercise
818+
// markRemoved's atomic branch. That branch is covered by
819+
// TestPeerMarkRemovedOnce and TestProtocolManagerRemovePeerConcurrent.
820+
pm.removePeer(tp.id)
821+
if pm.peers.Len() != 0 {
822+
t.Fatalf("peer set size mismatch after second removePeer: got %d want 0", pm.peers.Len())
823+
}
824+
}
825+
826+
// TestProtocolManagerRemovePeerConcurrent verifies that concurrent removePeer
827+
// calls for the same peer (BFT broadcast loops, DAO fork timers and normal
828+
// teardown racing each other) remove the peer exactly once, without panicking
829+
// or racing on the removal flag.
830+
func TestProtocolManagerRemovePeerConcurrent(t *testing.T) {
831+
pm, _ := newTestProtocolManagerMust(t, downloader.FullSync, 0, nil, nil)
832+
defer pm.Stop()
833+
834+
// Register a peer through the normal protocol handshake path.
835+
tp, errc := newTestPeer("test-peer", xdc165, pm, true)
836+
defer tp.close()
837+
defer tp.app.Close()
838+
defer func() {
839+
select {
840+
case <-errc:
841+
default:
842+
}
843+
}()
844+
waitForPeerRegistration(t, pm, tp.id)
845+
846+
var wg sync.WaitGroup
847+
for i := 0; i < 16; i++ {
848+
wg.Add(1)
849+
go func() {
850+
defer wg.Done()
851+
pm.removePeer(tp.id)
852+
}()
853+
}
854+
wg.Wait()
855+
856+
if pm.peers.Peer(tp.id) != nil {
857+
t.Fatal("peer still registered after concurrent removePeer calls")
858+
}
859+
if pm.peers.Len() != 0 {
860+
t.Fatalf("peer set size mismatch after concurrent removal: got %d want 0", pm.peers.Len())
861+
}
862+
}
863+
864+
// TestRegisterDownloaderPeerUndoesRacedRemoval reproduces the window in handle()
865+
// between pm.peers.Register and the downloader registration, where a BFT
866+
// broadcaster (BroadcastVote / BroadcastTimeout / BroadcastSyncInfo) can remove
867+
// the peer. removePeer claims the removal, but its downloader.UnregisterPeer
868+
// hits errNotRegistered (the peer is not yet registered in the downloader), so
869+
// without the recheck in registerDownloaderPeer the downloader would keep a
870+
// stale entry that blocks a reconnect of the same node id (errAlreadyRegistered).
871+
func TestRegisterDownloaderPeerUndoesRacedRemoval(t *testing.T) {
872+
pm, _ := newTestProtocolManagerMust(t, downloader.FullSync, 0, nil, nil)
873+
defer pm.Stop()
874+
875+
// Build a real peer and register it in pm.peers only — the same state a
876+
// peer is in while handle() is between pm.peers.Register and the downloader
877+
// registration.
878+
app, net := p2p.MsgPipe()
879+
defer app.Close()
880+
var id enode.ID
881+
rand.Read(id[:])
882+
p := pm.newPeer(xdc165, p2p.NewPeer(id, "race-peer", nil), net, pm.txpool.Get)
883+
if err := pm.peers.Register(p); err != nil {
884+
t.Fatalf("failed to register test peer: %v", err)
885+
}
886+
if pm.peers.Len() != 1 {
887+
t.Fatalf("peer set size mismatch: got %d want 1", pm.peers.Len())
888+
}
889+
// A BFT broadcaster's failing send removes the peer inside the window.
890+
pm.removePeer(p.id)
891+
if pm.peers.Peer(p.id) != nil {
892+
t.Fatal("peer still present after removePeer")
893+
}
894+
// handle() now completes the downloader registration; the recheck must
895+
// detect the claimed removal, undo the registration and abort the handshake
896+
// with the same disconnect reason removePeer uses.
897+
if err := pm.registerDownloaderPeer(p); err != p2p.DiscUselessPeer {
898+
t.Fatalf("registerDownloaderPeer should abort with DiscUselessPeer a handshake whose removal was already claimed, got: %v", err)
899+
}
900+
// A reconnect of the same node id must not be blocked by a stale entry.
901+
if err := pm.downloader.RegisterPeer(p.id, p.version, p); err != nil {
902+
t.Fatalf("reconnect blocked by stale downloader entry: %v", err)
903+
}
904+
}

eth/peer.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"math/big"
2323
"sync"
24+
"sync/atomic"
2425
"time"
2526

2627
"github.com/XinFinOrg/XDPoSChain/common"
@@ -109,6 +110,13 @@ type peer struct {
109110

110111
term chan struct{} // Termination channel to stop the broadcaster
111112

113+
// removed is set exactly once when the peer is removed from the peer set.
114+
// It makes peer removal idempotent: concurrent removePeer calls (from the
115+
// BFT broadcast loops, DAO fork timers and normal teardown) that all pass
116+
// the Peer(id) lookup race can no longer re-run the unregister sequence
117+
// and flood the logs with duplicate "peer is not registered" warnings.
118+
removed atomic.Bool
119+
112120
knownVote mapset.Set[common.Hash] // Set of BFT Vote known to be known by this peer
113121
knownTimeout mapset.Set[common.Hash] // Set of BFT timeout known to be known by this peer
114122
knownSyncInfo mapset.Set[common.Hash] // Set of BFT Sync Info known to be known by this peer
@@ -285,6 +293,13 @@ func (p *peer) announceTransactions() {
285293
}
286294
}
287295

296+
// markRemoved claims the peer's removal. It returns true only for the first
297+
// caller; concurrent removePeer calls all observe the flag already set and
298+
// become no-ops, so the unregister sequence runs exactly once per peer.
299+
func (p *peer) markRemoved() bool {
300+
return !p.removed.Swap(true)
301+
}
302+
288303
// close signals the broadcast goroutine to terminate.
289304
func (p *peer) close() {
290305
close(p.term)

eth/peer_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,34 @@ func TestPeerSetRegisterRejectsDuplicateID(t *testing.T) {
2020
t.Fatalf("registered peer replaced: got %p want %p", got, first)
2121
}
2222
}
23+
24+
// TestPeerMarkRemovedOnce verifies that a peer's removal is claimed exactly
25+
// once, so concurrent removePeer calls cannot re-run the unregister sequence.
26+
func TestPeerMarkRemovedOnce(t *testing.T) {
27+
p := &peer{id: "once"}
28+
if !p.markRemoved() {
29+
t.Fatal("first markRemoved should claim the removal")
30+
}
31+
for i := 0; i < 10; i++ {
32+
if p.markRemoved() {
33+
t.Fatalf("markRemoved should not claim a removal after it was already claimed (iteration %d)", i)
34+
}
35+
}
36+
}
37+
38+
// TestPeerSetUnregisterTwice documents the contract removePeer relies on:
39+
// unregistering an already-removed peer reports errNotRegistered, which the
40+
// idempotency guard in removePeer prevents from ever being hit.
41+
func TestPeerSetUnregisterTwice(t *testing.T) {
42+
peers := newPeerSet()
43+
p := &peer{id: "twice"}
44+
if err := peers.Register(p); err != nil {
45+
t.Fatalf("register failed: %v", err)
46+
}
47+
if err := peers.Unregister("twice"); err != nil {
48+
t.Fatalf("first unregister failed: %v", err)
49+
}
50+
if err := peers.Unregister("twice"); err != errNotRegistered {
51+
t.Fatalf("second unregister error mismatch: got %v want %v", err, errNotRegistered)
52+
}
53+
}

0 commit comments

Comments
 (0)