eth/txtracker: prevent disconnected peers from leaking back into stats

NotifyPeerDrop deleted t.peers[peer] but left t.txs entries pointing
to that peer. When those txs later finalized, checkFinalization
recreated the peer entry, and the EMA loop decayed it forever.

Fix: create peer entries in NotifyAccepted (when txs are first
accepted), not in handleChainHead or checkFinalization. Both chain
event handlers now skip peers with no entry — disconnected peers
whose entries were deleted by NotifyPeerDrop stay deleted.
This commit is contained in:
Csaba Kiraly 2026-04-11 16:31:44 +02:00
parent 7f1720b3dc
commit e2b620ab44
2 changed files with 14 additions and 11 deletions

View file

@ -131,6 +131,10 @@ func (t *Tracker) NotifyAccepted(peer string, hashes []common.Hash) {
t.txs[hash] = peer t.txs[hash] = peer
t.order = append(t.order, hash) t.order = append(t.order, hash)
} }
// Ensure the delivering peer has a stats entry.
if len(hashes) > 0 && t.peers[peer] == nil {
t.peers[peer] = &peerStats{}
}
// Evict oldest entries if over capacity. // Evict oldest entries if over capacity.
for len(t.txs) > maxTracked { for len(t.txs) > maxTracked {
oldest := t.order[0] oldest := t.order[0]
@ -192,12 +196,9 @@ func (t *Tracker) handleChainHead(ev core.ChainHeadEvent) {
blockIncl[peer]++ blockIncl[peer]++
} }
} }
// Ensure peers with inclusions in this block have entries. // Only credit peers that are still tracked (not disconnected).
for peer := range blockIncl { // Don't create entries for unknown peers — they may have been
if t.peers[peer] == nil { // removed by NotifyPeerDrop and should not be resurrected.
t.peers[peer] = &peerStats{}
}
}
// Update EMA for all tracked peers (decay inactive ones). // Update EMA for all tracked peers (decay inactive ones).
for peer, ps := range t.peers { for peer, ps := range t.peers {
ps.recentIncluded = (1-emaAlpha)*ps.recentIncluded + emaAlpha*float64(blockIncl[peer]) ps.recentIncluded = (1-emaAlpha)*ps.recentIncluded + emaAlpha*float64(blockIncl[peer])
@ -231,8 +232,7 @@ func (t *Tracker) checkFinalization() {
} }
ps := t.peers[peer] ps := t.peers[peer]
if ps == nil { if ps == nil {
ps = &peerStats{} continue // peer disconnected, skip credit
t.peers[peer] = ps
} }
ps.finalized++ ps.finalized++
credited++ credited++

View file

@ -110,10 +110,13 @@ func TestNotifyReceived(t *testing.T) {
txs := []*types.Transaction{makeTx(1), makeTx(2), makeTx(3)} txs := []*types.Transaction{makeTx(1), makeTx(2), makeTx(3)}
tr.NotifyAccepted("peerA", hashTxs(txs)) tr.NotifyAccepted("peerA", hashTxs(txs))
// No chain events yet — stats should be empty. // No chain events yet — peer entry exists but with zero stats.
stats := tr.GetAllPeerStats() stats := tr.GetAllPeerStats()
if len(stats) != 0 { if stats["peerA"].Finalized != 0 {
t.Fatalf("expected empty stats before any chain events, got %d peers", len(stats)) t.Fatalf("expected zero Finalized before chain events, got %d", stats["peerA"].Finalized)
}
if stats["peerA"].RecentIncluded != 0 {
t.Fatalf("expected zero RecentIncluded before chain events, got %f", stats["peerA"].RecentIncluded)
} }
} }