1 #!/usr/bin/env python3
2 # Copyright (c) 2021-present The Limenka developers
3 # Distributed under the MIT software license, see the accompanying
4 # file COPYING or http://www.opensource.org/licenses/mit-license.php.
5 """Tests related to node initialization."""
6 from concurrent.futures import ThreadPoolExecutor
7 from pathlib import Path
8 import os
9 import platform
10 import shutil
11 import signal
12 import subprocess
13 import time
14 15 from test_framework.test_framework import LimenkaTestFramework
16 from test_framework.test_node import (
17 LIMENKA_PID_FILENAME_DEFAULT,
18 ErrorMatch,
19 )
20 from test_framework.util import assert_equal
21 22 23 class InitTest(LimenkaTestFramework):
24 """
25 Ensure that initialization can be interrupted at a number of points and not impair
26 subsequent starts.
27 """
28 29 def add_options(self, parser):
30 self.add_wallet_options(parser)
31 32 def set_test_params(self):
33 self.setup_clean_chain = False
34 self.num_nodes = 2
35 36 def init_stress_test(self):
37 """
38 - test terminating initialization after seeing a certain log line.
39 - test removing certain essential files to test startup error paths.
40 """
41 self.stop_node(0)
42 node = self.nodes[0]
43 44 def sigterm_node():
45 if platform.system() == 'Windows':
46 # Don't call Python's terminate() since it calls
47 # TerminateProcess(), which unlike SIGTERM doesn't allow
48 # limenkad to perform any shutdown logic.
49 os.kill(node.process.pid, signal.CTRL_BREAK_EVENT)
50 else:
51 node.process.terminate()
52 node.process.wait()
53 54 def start_expecting_error(err_fragment):
55 node.assert_start_raises_init_error(
56 extra_args=['-txindex=1', '-blockfilterindex=1', '-coinstatsindex=1', '-checkblocks=200', '-checklevel=4'],
57 expected_msg=err_fragment,
58 match=ErrorMatch.PARTIAL_REGEX,
59 )
60 61 def check_clean_start():
62 """Ensure that node restarts successfully after various interrupts."""
63 node.start()
64 node.wait_for_rpc_connection()
65 assert_equal(200, node.getblockcount())
66 67 lines_to_terminate_after = [
68 b'Validating signatures for all blocks',
69 b'scheduler thread start',
70 b'Starting HTTP server',
71 b'Loading P2P addresses',
72 b'Loading banlist',
73 b'Loading block index',
74 b'Checking all blk files are present',
75 b'Loaded best chain:',
76 b'init message: Verifying blocks',
77 b'init message: Starting network threads',
78 b'net thread start',
79 b'addcon thread start',
80 b'initload thread start',
81 b'txindex thread start',
82 b'block filter index thread start',
83 b'coinstatsindex thread start',
84 b'msghand thread start',
85 b'net thread start',
86 b'addcon thread start',
87 ]
88 if self.is_wallet_compiled():
89 lines_to_terminate_after.append(b'Verifying wallet')
90 91 args = ['-txindex=1', '-blockfilterindex=1', '-coinstatsindex=1']
92 for terminate_line in lines_to_terminate_after:
93 self.log.info(f"Starting node and will exit after line {terminate_line}")
94 with node.busy_wait_for_debug_log([terminate_line]):
95 if platform.system() == 'Windows':
96 # CREATE_NEW_PROCESS_GROUP is required in order to be able
97 # to terminate the child without terminating the test.
98 node.start(extra_args=args, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
99 else:
100 node.start(extra_args=args)
101 self.log.debug("Terminating node after terminate line was found")
102 sigterm_node()
103 104 check_clean_start()
105 self.stop_node(0)
106 107 self.log.info("Test startup errors after removing certain essential files")
108 109 files_to_delete = {
110 'blocks/index/*.ldb': 'Error opening block database.',
111 'chainstate/*.ldb': 'Error opening coins database.',
112 'blocks/blk*.dat': 'Error loading block database.',
113 }
114 115 files_to_perturb = {
116 'blocks/index/*.ldb': 'Error loading block database.',
117 'chainstate/*.ldb': 'Error opening coins database.',
118 'blocks/blk*.dat': 'Corrupted block database detected.',
119 }
120 121 for file_patt, err_fragment in files_to_delete.items():
122 target_files = list(node.chain_path.glob(file_patt))
123 124 for target_file in target_files:
125 self.log.info(f"Deleting file to ensure failure {target_file}")
126 bak_path = str(target_file) + ".bak"
127 target_file.rename(bak_path)
128 129 start_expecting_error(err_fragment)
130 131 for target_file in target_files:
132 bak_path = str(target_file) + ".bak"
133 self.log.debug(f"Restoring file from {bak_path} and restarting")
134 Path(bak_path).rename(target_file)
135 136 check_clean_start()
137 self.stop_node(0)
138 139 self.log.info("Test startup errors after perturbing certain essential files")
140 for file_patt, err_fragment in files_to_perturb.items():
141 shutil.copytree(node.chain_path / "blocks", node.chain_path / "blocks_bak")
142 shutil.copytree(node.chain_path / "chainstate", node.chain_path / "chainstate_bak")
143 target_files = list(node.chain_path.glob(file_patt))
144 145 for target_file in target_files:
146 self.log.info(f"Perturbing file to ensure failure {target_file}")
147 with open(target_file, "r+b") as tf:
148 # Since the genesis block is not checked by -checkblocks, the
149 # perturbation window must be chosen such that a higher block
150 # in blk*.dat is affected.
151 tf.seek(150)
152 tf.write(b"1" * 200)
153 154 start_expecting_error(err_fragment)
155 156 shutil.rmtree(node.chain_path / "blocks")
157 shutil.rmtree(node.chain_path / "chainstate")
158 shutil.move(node.chain_path / "blocks_bak", node.chain_path / "blocks")
159 shutil.move(node.chain_path / "chainstate_bak", node.chain_path / "chainstate")
160 161 def init_pid_test(self):
162 LIMENKA_PID_FILENAME_CUSTOM = "my_fancy_limenka_pid_file.foobar"
163 164 self.log.info("Test specifying custom pid file via -pid command line option")
165 custom_pidfile_relative = LIMENKA_PID_FILENAME_CUSTOM
166 self.log.info(f"-> path relative to datadir ({custom_pidfile_relative})")
167 self.restart_node(0, [f"-pid={custom_pidfile_relative}"])
168 datadir = self.nodes[0].chain_path
169 assert not (datadir / LIMENKA_PID_FILENAME_DEFAULT).exists()
170 assert (datadir / custom_pidfile_relative).exists()
171 self.stop_node(0)
172 assert not (datadir / custom_pidfile_relative).exists()
173 174 custom_pidfile_absolute = Path(self.options.tmpdir) / LIMENKA_PID_FILENAME_CUSTOM
175 self.log.info(f"-> absolute path ({custom_pidfile_absolute})")
176 self.restart_node(0, [f"-pid={custom_pidfile_absolute}"])
177 assert not (datadir / LIMENKA_PID_FILENAME_DEFAULT).exists()
178 assert custom_pidfile_absolute.exists()
179 self.stop_node(0)
180 assert not custom_pidfile_absolute.exists()
181 182 def break_wait_test(self):
183 """Test what happens when a break signal is sent during a
184 waitforblockheight RPC call with a long timeout. Ctrl-Break is sent on
185 Windows and SIGTERM is sent on other platforms, to trigger the same node
186 shutdown sequence that would happen if Ctrl-C were pressed in a
187 terminal. (This can be different than the node shutdown sequence that
188 happens when the stop RPC is sent.)
189 190 The waitforblockheight call should be interrupted and return right away,
191 and not time out."""
192 193 self.log.info("Testing waitforblockheight RPC call followed by break signal")
194 node = self.nodes[0]
195 196 if platform.system() == 'Windows':
197 # CREATE_NEW_PROCESS_GROUP prevents python test from exiting
198 # with STATUS_CONTROL_C_EXIT (-1073741510) when break is sent.
199 self.start_node(node.index, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
200 else:
201 self.start_node(node.index)
202 203 current_height = node.getblock(node.getbestblockhash())['height']
204 205 with ThreadPoolExecutor(max_workers=1) as ex:
206 # Call waitforblockheight with wait timeout longer than RPC timeout,
207 # so it is possible to distinguish whether it times out or returns
208 # early. If it times out it will throw an exception, and if it
209 # returns early it will return the current block height.
210 self.log.debug(f"Calling waitforblockheight with {self.rpc_timeout} sec RPC timeout")
211 fut = ex.submit(node.waitforblockheight, height=current_height+1, timeout=self.rpc_timeout*1000*2)
212 time.sleep(1)
213 214 self.log.debug(f"Sending break signal to pid {node.process.pid}")
215 if platform.system() == 'Windows':
216 # Note: CTRL_C_EVENT should not be sent here because unlike
217 # CTRL_BREAK_EVENT it can not be targeted at a specific process
218 # group and may behave unpredictably.
219 node.process.send_signal(signal.CTRL_BREAK_EVENT)
220 else:
221 # Note: signal.SIGINT would work here as well
222 node.process.send_signal(signal.SIGTERM)
223 node.process.wait()
224 225 result = fut.result()
226 self.log.debug(f"waitforblockheight returned {result!r}")
227 assert_equal(result["height"], current_height)
228 node.wait_until_stopped()
229 230 def restart_node_with_fd_limit(self, limit):
231 """Restart node 1 with a given soft RLIMIT_NOFILE. Skips if the limit cannot be set."""
232 import resource
233 soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
234 try:
235 resource.setrlimit(resource.RLIMIT_NOFILE, (limit, hard))
236 except (ValueError, OSError):
237 self.log.info(f"Skipping rlimit test: cannot set soft limit (hard={hard})")
238 return
239 try:
240 self.restart_node(1)
241 self.log.debug(f"Node started successfully with RLIM_INFINITY limit (soft={limit})")
242 finally:
243 resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
244 self.log.debug(f"Restored previous RLIMIT_NOFILE limits (soft={soft}, hard={hard})")
245 246 def init_rlimit_test(self):
247 """Test that limenkad starts correctly when the soft RLIMIT_NOFILE limit is RLIM_INFINITY."""
248 if self.RLIM_INFINITY is None:
249 self.log.info("Skipping: resource module not available")
250 return
251 252 self.log.info("Testing node startup with RLIM_INFINITY fd limit")
253 self.restart_node_with_fd_limit(self.RLIM_INFINITY)
254 255 def init_rlimit_large_test(self):
256 """Test that limenkad starts correctly when the soft RLIMIT_NOFILE limit is above INT_MAX."""
257 if self.RLIM_INFINITY is None:
258 self.log.info("Skipping: resource module not available")
259 return
260 261 self.log.info("Testing node startup with fd limit above INT_MAX")
262 self.restart_node_with_fd_limit(1 << 31)
263 264 def run_test(self):
265 self.init_pid_test()
266 self.init_stress_test()
267 self.break_wait_test()
268 self.init_rlimit_test()
269 self.init_rlimit_large_test()
270 271 272 if __name__ == '__main__':
273 InitTest(__file__).main()
274