Skip to content

Commit a8d9a0e

Browse files
committed
test: adds outbound eviction functional tests, updates comment in ConsiderEviction
1 parent 3c88eac commit a8d9a0e

File tree

3 files changed

+232
-4
lines changed

3 files changed

+232
-4
lines changed

src/net_processing.cpp

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5165,16 +5165,19 @@ void PeerManagerImpl::ConsiderEviction(CNode& pto, Peer& peer, std::chrono::seco
51655165
// unless it's invalid, in which case we should find that out and
51665166
// disconnect from them elsewhere).
51675167
if (state.pindexBestKnownBlock != nullptr && state.pindexBestKnownBlock->nChainWork >= m_chainman.ActiveChain().Tip()->nChainWork) {
5168+
// The outbound peer has sent us a block with at least as much work as our current tip, so reset the timeout if it was set
51685169
if (state.m_chain_sync.m_timeout != 0s) {
51695170
state.m_chain_sync.m_timeout = 0s;
51705171
state.m_chain_sync.m_work_header = nullptr;
51715172
state.m_chain_sync.m_sent_getheaders = false;
51725173
}
51735174
} else if (state.m_chain_sync.m_timeout == 0s || (state.m_chain_sync.m_work_header != nullptr && state.pindexBestKnownBlock != nullptr && state.pindexBestKnownBlock->nChainWork >= state.m_chain_sync.m_work_header->nChainWork)) {
5174-
// Our best block known by this peer is behind our tip, and we're either noticing
5175-
// that for the first time, OR this peer was able to catch up to some earlier point
5176-
// where we checked against our tip.
5177-
// Either way, set a new timeout based on current tip.
5175+
// At this point we know that the outbound peer has either never sent us a block/header or they have, but its tip is behind ours
5176+
// AND
5177+
// we are noticing this for the first time (m_timeout is 0)
5178+
// OR we noticed this at some point within the last CHAIN_SYNC_TIMEOUT + HEADERS_RESPONSE_TIME seconds and set a timeout
5179+
// for them, they caught up to our tip at the time of setting the timer but not to our current one (we've also advanced).
5180+
// Either way, set a new timeout based on our current tip.
51785181
state.m_chain_sync.m_timeout = time_in_seconds + CHAIN_SYNC_TIMEOUT;
51795182
state.m_chain_sync.m_work_header = m_chainman.ActiveChain().Tip();
51805183
state.m_chain_sync.m_sent_getheaders = false;
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2019-2021 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
6+
""" Test node outbound peer eviction logic
7+
8+
A subset of our outbound peers are subject to eviction logic if they cannot keep up
9+
with our vision of the best chain. This criteria applies only to non-protected peers,
10+
and can be triggered by either not learning about any blocks from an outbound peer after
11+
a certain deadline, or by them not being able to catch up fast enough (under the same deadline).
12+
13+
This tests the different eviction paths based on the peer's behavior and on whether they are protected
14+
or not.
15+
"""
16+
import time
17+
18+
from test_framework.messages import (
19+
from_hex,
20+
msg_headers,
21+
CBlockHeader,
22+
)
23+
from test_framework.p2p import P2PInterface
24+
from test_framework.test_framework import BitcoinTestFramework
25+
26+
# Timeouts (in seconds)
27+
CHAIN_SYNC_TIMEOUT = 20 * 60
28+
HEADERS_RESPONSE_TIME = 2 * 60
29+
30+
31+
class P2POutEvict(BitcoinTestFramework):
32+
def set_test_params(self):
33+
self.num_nodes = 1
34+
35+
def test_outbound_eviction_unprotected(self):
36+
# This tests the eviction logic for **unprotected** outbound peers (that is, PeerManagerImpl::ConsiderEviction)
37+
node = self.nodes[0]
38+
cur_mock_time = node.mocktime
39+
40+
# Get our tip header and its parent
41+
tip_header = from_hex(CBlockHeader(), node.getblockheader(node.getbestblockhash(), False))
42+
prev_header = from_hex(CBlockHeader(), node.getblockheader(f"{tip_header.hashPrevBlock:064x}", False))
43+
44+
self.log.info("Create an outbound connection and don't send any headers")
45+
# Test disconnect due to no block being announced in 22+ minutes (headers are not even exchanged)
46+
peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay")
47+
# Wait for over 20 min to trigger the first eviction timeout. This sets the last call past 2 min in the future.
48+
cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1)
49+
node.setmocktime(cur_mock_time)
50+
peer.sync_with_ping()
51+
# Wait for over 2 more min to trigger the disconnection
52+
peer.wait_for_getheaders(block_hash=tip_header.hashPrevBlock)
53+
cur_mock_time += (HEADERS_RESPONSE_TIME + 1)
54+
node.setmocktime(cur_mock_time)
55+
self.log.info("Test that the peer gets evicted")
56+
peer.wait_for_disconnect()
57+
58+
self.log.info("Create an outbound connection and send header but never catch up")
59+
# Mimic a node that just falls behind for long enough
60+
# This should also apply for a node doing IBD that does not catch up in time
61+
# Connect a peer and make it send us headers ending in our tip's parent
62+
peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay")
63+
peer.send_and_ping(msg_headers([prev_header]))
64+
65+
# Trigger the timeouts
66+
cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1)
67+
node.setmocktime(cur_mock_time)
68+
peer.sync_with_ping()
69+
peer.wait_for_getheaders(block_hash=tip_header.hashPrevBlock)
70+
cur_mock_time += (HEADERS_RESPONSE_TIME + 1)
71+
node.setmocktime(cur_mock_time)
72+
self.log.info("Test that the peer gets evicted")
73+
peer.wait_for_disconnect()
74+
75+
self.log.info("Create an outbound connection and keep lagging behind, but not too much")
76+
# Test that if the peer never catches up with our current tip, but it does with the
77+
# expected work that we set when setting the timer (that is, our tip at the time)
78+
# we do not disconnect the peer
79+
peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay")
80+
81+
self.log.info("Mine a block so our peer starts lagging")
82+
prev_prev_hash = tip_header.hashPrevBlock
83+
best_block_hash = self.generateblock(node, output="raw(42)", transactions=[])["hash"]
84+
peer.sync_with_ping()
85+
86+
self.log.info("Keep catching up with the old tip and check that we are not evicted")
87+
for i in range(10):
88+
# Generate an additional block so the peers is 2 blocks behind
89+
prev_header = from_hex(CBlockHeader(), node.getblockheader(best_block_hash, False))
90+
best_block_hash = self.generateblock(node, output="raw(42)", transactions=[])["hash"]
91+
peer.sync_with_ping()
92+
93+
# Advance time but not enough to evict the peer
94+
cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1)
95+
node.setmocktime(cur_mock_time)
96+
peer.sync_with_ping()
97+
98+
# Wait until we get out last call (by receiving a getheaders)
99+
peer.wait_for_getheaders(block_hash=prev_prev_hash)
100+
101+
# Send a header with the previous tip (so we go back to 1 block behind)
102+
peer.send_and_ping(msg_headers([prev_header]))
103+
prev_prev_hash = tip_header.hash
104+
105+
self.log.info("Create an outbound connection and take some time to catch up, but do it in time")
106+
# Check that if the peer manages to catch up within time, the timeouts are removed (and the peer is not disconnected)
107+
# We are reusing the peer from the previous case which already sent us a valid (but old) block and whose timer is ticking
108+
109+
# Send an updated headers message matching our tip
110+
peer.send_and_ping(msg_headers([from_hex(CBlockHeader(), node.getblockheader(best_block_hash, False))]))
111+
112+
# Wait for long enough for the timeouts to have triggered and check that we are still connected
113+
cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1)
114+
node.setmocktime(cur_mock_time)
115+
peer.sync_with_ping()
116+
cur_mock_time += (HEADERS_RESPONSE_TIME + 1)
117+
node.setmocktime(cur_mock_time)
118+
self.log.info("Test that the peer does not get evicted")
119+
peer.sync_with_ping()
120+
121+
node.disconnect_p2ps()
122+
123+
def test_outbound_eviction_protected(self):
124+
# This tests the eviction logic for **protected** outbound peers (that is, PeerManagerImpl::ConsiderEviction)
125+
# Outbound connections are flagged as protected as long as they have sent us a connecting block with at least as
126+
# much work as our current tip and we have enough empty protected_peers slots.
127+
node = self.nodes[0]
128+
cur_mock_time = node.mocktime
129+
tip_header = from_hex(CBlockHeader(), node.getblockheader(node.getbestblockhash(), False))
130+
131+
self.log.info("Create an outbound connection to a peer that shares our tip so it gets granted protection")
132+
peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="outbound-full-relay")
133+
peer.send_and_ping(msg_headers([tip_header]))
134+
135+
self.log.info("Mine a new block and sync with our peer")
136+
self.generateblock(node, output="raw(42)", transactions=[])
137+
peer.sync_with_ping()
138+
139+
self.log.info("Let enough time pass for the timeouts to go off")
140+
# Trigger the timeouts and check how we are still connected
141+
cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1)
142+
node.setmocktime(cur_mock_time)
143+
peer.sync_with_ping()
144+
peer.wait_for_getheaders(block_hash=tip_header.hashPrevBlock)
145+
cur_mock_time += (HEADERS_RESPONSE_TIME + 1)
146+
node.setmocktime(cur_mock_time)
147+
self.log.info("Test that the node does not get evicted")
148+
peer.sync_with_ping()
149+
150+
node.disconnect_p2ps()
151+
152+
def test_outbound_eviction_mixed(self):
153+
# This tests the outbound eviction logic for a mix of protected and unprotected peers.
154+
node = self.nodes[0]
155+
cur_mock_time = node.mocktime
156+
157+
self.log.info("Create a mix of protected and unprotected outbound connections to check against eviction")
158+
159+
# Let's try this logic having multiple peers, some protected and some unprotected
160+
# We protect up to 4 peers as long as they have provided a block with the same amount of work as our tip
161+
self.log.info("The first 4 peers are protected by sending us a valid block with enough work")
162+
tip_header = from_hex(CBlockHeader(), node.getblockheader(node.getbestblockhash(), False))
163+
headers_message = msg_headers([tip_header])
164+
protected_peers = []
165+
for i in range(4):
166+
peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=i, connection_type="outbound-full-relay")
167+
peer.send_and_ping(headers_message)
168+
protected_peers.append(peer)
169+
170+
# We can create 4 additional outbound connections to peers that are unprotected. 2 of them will be well behaved,
171+
# whereas the other 2 will misbehave (1 sending no headers, 1 sending old ones)
172+
self.log.info("The remaining 4 peers will be mixed between honest (2) and misbehaving peers (2)")
173+
prev_header = from_hex(CBlockHeader(), node.getblockheader(f"{tip_header.hashPrevBlock:064x}", False))
174+
headers_message = msg_headers([prev_header])
175+
honest_unprotected_peers = []
176+
for i in range(2):
177+
peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=4+i, connection_type="outbound-full-relay")
178+
peer.send_and_ping(headers_message)
179+
honest_unprotected_peers.append(peer)
180+
181+
misbehaving_unprotected_peers = []
182+
for i in range(2):
183+
peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=6+i, connection_type="outbound-full-relay")
184+
if i%2==0:
185+
peer.send_and_ping(headers_message)
186+
misbehaving_unprotected_peers.append(peer)
187+
188+
self.log.info("Mine a new block and keep the unprotected honest peer on sync, all the rest off-sync")
189+
# Mine a block so all peers become outdated
190+
target_hash = prev_header.rehash()
191+
tip_hash = self.generateblock(node, output="raw(42)", transactions=[])["hash"]
192+
tip_header = from_hex(CBlockHeader(), node.getblockheader(tip_hash, False))
193+
tip_headers_message = msg_headers([tip_header])
194+
195+
# Let the timeouts hit and check back
196+
cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1)
197+
node.setmocktime(cur_mock_time)
198+
for peer in protected_peers + misbehaving_unprotected_peers:
199+
peer.sync_with_ping()
200+
peer.wait_for_getheaders(block_hash=target_hash)
201+
for peer in honest_unprotected_peers:
202+
peer.send_and_ping(tip_headers_message)
203+
peer.wait_for_getheaders(block_hash=target_hash)
204+
205+
cur_mock_time += (HEADERS_RESPONSE_TIME + 1)
206+
node.setmocktime(cur_mock_time)
207+
self.log.info("Check how none of the honest nor protected peers was evicted but all the misbehaving unprotected were")
208+
for peer in protected_peers + honest_unprotected_peers:
209+
peer.sync_with_ping()
210+
for peer in misbehaving_unprotected_peers:
211+
peer.wait_for_disconnect()
212+
213+
node.disconnect_p2ps()
214+
215+
216+
def run_test(self):
217+
self.nodes[0].setmocktime(int(time.time()))
218+
self.test_outbound_eviction_unprotected()
219+
self.test_outbound_eviction_protected()
220+
self.test_outbound_eviction_mixed()
221+
222+
223+
if __name__ == '__main__':
224+
P2POutEvict().main()

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@
292292
'p2p_leak_tx.py --v1transport',
293293
'p2p_leak_tx.py --v2transport',
294294
'p2p_eviction.py',
295+
'p2p_outbound_eviction.py',
295296
'p2p_ibd_stalling.py --v1transport',
296297
'p2p_ibd_stalling.py --v2transport',
297298
'p2p_net_deadlock.py --v1transport',

0 commit comments

Comments
 (0)