|
| 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() |
0 commit comments