Skip to content

Commit 012e540

Browse files
committed
Merge bitcoin/bitcoin#29122: test: adds outbound eviction functional tests, updates comment in ConsiderEviction
d53d848 test: adds outbound eviction tests for non outbound-full-relay peers (Sergi Delgado Segura) a8d9a0e test: adds outbound eviction functional tests, updates comment in ConsiderEviction (Sergi Delgado Segura) Pull request description: ## Motivation While checking the outbound eviction code I realized a case was not considered within the comments, which in turn made me realize we had no functional tests for the outbound eviction case (when I went to check/add the test case). This PR updates the aforementioned comment and adds functional tests to cover the outbound eviction logic, in addition to the existing unit tests found at `src/test/denialofservice_tests.cpp`. ACKs for top commit: davidgumberg: reACK bitcoin/bitcoin@d53d848 tdb3: Re ACK for d53d848 achow101: ACK d53d848 cbergqvist: ACK d53d848 Tree-SHA512: 633b84bb1229fe21e2f650c1beada33ca7f190b64eafd64df2266516d21175e5d652e019ff7114f00cb8bd19f5817dc19e65adf75767a88e24dc0842ce40c63e
2 parents ceb1e07 + d53d848 commit 012e540

File tree

3 files changed

+261
-4
lines changed

3 files changed

+261
-4
lines changed

src/net_processing.cpp

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5441,16 +5441,19 @@ void PeerManagerImpl::ConsiderEviction(CNode& pto, Peer& peer, std::chrono::seco
54415441
// unless it's invalid, in which case we should find that out and
54425442
// disconnect from them elsewhere).
54435443
if (state.pindexBestKnownBlock != nullptr && state.pindexBestKnownBlock->nChainWork >= m_chainman.ActiveChain().Tip()->nChainWork) {
5444+
// 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
54445445
if (state.m_chain_sync.m_timeout != 0s) {
54455446
state.m_chain_sync.m_timeout = 0s;
54465447
state.m_chain_sync.m_work_header = nullptr;
54475448
state.m_chain_sync.m_sent_getheaders = false;
54485449
}
54495450
} 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)) {
5450-
// Our best block known by this peer is behind our tip, and we're either noticing
5451-
// that for the first time, OR this peer was able to catch up to some earlier point
5452-
// where we checked against our tip.
5453-
// Either way, set a new timeout based on current tip.
5451+
// 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
5452+
// AND
5453+
// we are noticing this for the first time (m_timeout is 0)
5454+
// OR we noticed this at some point within the last CHAIN_SYNC_TIMEOUT + HEADERS_RESPONSE_TIME seconds and set a timeout
5455+
// 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).
5456+
// Either way, set a new timeout based on our current tip.
54545457
state.m_chain_sync.m_timeout = time_in_seconds + CHAIN_SYNC_TIMEOUT;
54555458
state.m_chain_sync.m_work_header = m_chainman.ActiveChain().Tip();
54565459
state.m_chain_sync.m_sent_getheaders = false;
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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+
def test_outbound_eviction_blocks_relay_only(self):
216+
# The logic for outbound eviction protection only applies to outbound-full-relay peers
217+
# This tests that other types of peers (blocks-relay-only for instance) are not granted protection
218+
node = self.nodes[0]
219+
cur_mock_time = node.mocktime
220+
tip_header = from_hex(CBlockHeader(), node.getblockheader(node.getbestblockhash(), False))
221+
222+
self.log.info("Create an blocks-only outbound connection to a peer that shares our tip. This would usually grant protection")
223+
peer = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=0, connection_type="block-relay-only")
224+
peer.send_and_ping(msg_headers([tip_header]))
225+
226+
self.log.info("Mine a new block and sync with our peer")
227+
self.generateblock(node, output="raw(42)", transactions=[])
228+
peer.sync_with_ping()
229+
230+
self.log.info("Let enough time pass for the timeouts to go off")
231+
# Trigger the timeouts and check how the peer gets evicted, since protection is only given to outbound-full-relay peers
232+
cur_mock_time += (CHAIN_SYNC_TIMEOUT + 1)
233+
node.setmocktime(cur_mock_time)
234+
peer.sync_with_ping()
235+
peer.wait_for_getheaders(block_hash=tip_header.hash)
236+
cur_mock_time += (HEADERS_RESPONSE_TIME + 1)
237+
node.setmocktime(cur_mock_time)
238+
self.log.info("Test that the peer gets evicted")
239+
peer.wait_for_disconnect()
240+
241+
node.disconnect_p2ps()
242+
243+
244+
def run_test(self):
245+
self.nodes[0].setmocktime(int(time.time()))
246+
self.test_outbound_eviction_unprotected()
247+
self.test_outbound_eviction_protected()
248+
self.test_outbound_eviction_mixed()
249+
self.test_outbound_eviction_blocks_relay_only()
250+
251+
252+
if __name__ == '__main__':
253+
P2POutEvict().main()

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@
285285
'p2p_leak_tx.py --v1transport',
286286
'p2p_leak_tx.py --v2transport',
287287
'p2p_eviction.py',
288+
'p2p_outbound_eviction.py',
288289
'p2p_ibd_stalling.py --v1transport',
289290
'p2p_ibd_stalling.py --v2transport',
290291
'p2p_net_deadlock.py --v1transport',

0 commit comments

Comments
 (0)