feature_init.py raw

   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