This commit is contained in:
hodlinator 2025-03-13 02:02:45 +01:00 committed by GitHub
commit a7c050f2e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 155 additions and 26 deletions

View file

@ -0,0 +1,117 @@
#!/usr/bin/env python3
# Copyright (c) 2025-present The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""
Verify various startup failures only raise one exception since multiple
exceptions being raised muddies the waters of what actually went wrong.
We should maintain this bar of only raising one exception as long as
additional maintenance and complexity is low.
Test relaunches itself into a child processes in order to trigger failure
without the parent process' BitcoinTestFramework also failing.
"""
from test_framework.util import rpc_port
from test_framework.test_framework import BitcoinTestFramework
import re
import subprocess
import sys
class FeatureFrameworkRPCFailure(BitcoinTestFramework):
def set_test_params(self):
# Only run a node for child processes
self.num_nodes = 1 if any(o is not None for o in [self.options.internal_rpc_timeout,
self.options.internal_extra_args,
self.options.internal_start_stop]) else 0
if self.options.internal_rpc_timeout is not None:
self.rpc_timeout = self.options.internal_rpc_timeout
if self.options.internal_extra_args:
self.extra_args = [[self.options.internal_extra_args]]
def add_options(self, parser):
parser.add_argument("--internal-rpc_timeout", dest="internal_rpc_timeout", help="ONLY TO BE USED WHEN TEST RELAUNCHES ITSELF")
parser.add_argument("--internal-extra_args", dest="internal_extra_args", help="ONLY TO BE USED WHEN TEST RELAUNCHES ITSELF")
parser.add_argument("--internal-start_stop", dest="internal_start_stop", help="ONLY TO BE USED WHEN TEST RELAUNCHES ITSELF")
def setup_network(self):
# Avoid doing anything if num_nodes == 0, otherwise we fail.
if self.num_nodes > 0:
if self.options.internal_start_stop:
self.add_nodes(self.num_nodes, self.extra_args)
self.nodes[0].start()
self.nodes[0].stop_node()
else:
BitcoinTestFramework.setup_network(self)
def _run_test_internal(self, args, expected_exception):
try:
result = subprocess.run([sys.executable, __file__] + args, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=10 * self.options.timeout_factor)
except subprocess.TimeoutExpired as e:
print(f"Unexpected timeout, subprocess output:\n{e.output}\nSubprocess output end", file=sys.stderr)
raise
success = True
traceback_count = len(re.findall("Traceback", result.stdout))
if traceback_count != 1:
self.log.error(f"Found {traceback_count}/1 tracebacks - expecting exactly one with no knock-on exceptions.")
success = False
matching_exception_count = len(re.findall(expected_exception, result.stdout))
if matching_exception_count != 1:
self.log.error(f"Found {matching_exception_count}/1 occurrences of the specific exception: {expected_exception}")
success = False
test_failure_msg_count = len(re.findall("Test failed. Test logging available at", result.stdout))
if test_failure_msg_count != 1:
self.log.error(f"Found {test_failure_msg_count}/1 test failure output messages.")
success = False
if not success:
raise AssertionError(f"Child test didn't contain expected errors.\n<CHILD OUTPUT BEGIN>:\n{result.stdout}\n<CHILD OUTPUT END>\n")
def test_instant_rpc_timeout(self):
self.log.info("Verifying timeout in connecting to bitcoind's RPC interface results in only one exception.")
self._run_test_internal(
["--internal-rpc_timeout=0"],
"AssertionError: \\[node 0\\] Unable to connect to bitcoind after 0s"
)
def test_wrong_rpc_port(self):
self.log.info("Verifying inability to connect to bitcoind's RPC interface due to wrong port results in one exception containing at least one OSError.")
self._run_test_internal(
# Lower the timeout so we don't wait that long.
[f"--internal-rpc_timeout={int(max(3, self.options.timeout_factor))}",
# Override RPC port to something TestNode isn't expecting so that we
# are unable to establish an RPC connection.
f"--internal-extra_args=-rpcport={rpc_port(2)}"],
r"AssertionError: \[node 0\] Unable to connect to bitcoind after \d+s \(ignored errors: {[^}]*'OSError \w+'?: \d+[^}]*}, latest error: \w+\([^)]+\)\)"
)
def test_init_error(self):
self.log.info("Verify startup failure due to invalid arg results in only one exception.")
self._run_test_internal(
["--internal-extra_args=-nonexistentarg"],
"FailedToStartError: \\[node 0\\] bitcoind exited with status 1 during initialization. Error: Error parsing command line arguments: Invalid parameter -nonexistentarg"
)
def test_start_stop(self):
self.log.info("Verify start() then stop_node() on a node without wait_for_rpc_connection() in between raises a clear exception.")
self._run_test_internal(
["--internal-start_stop=1"],
r"RuntimeError: \[node 0\] Cannot call stop-RPC as we don't have an RPC connection to process \d+, wait_for_rpc_connection\(\) failed or was never called."
)
def run_test(self):
if self.options.internal_rpc_timeout is None and self.options.internal_extra_args is None:
self.test_instant_rpc_timeout()
self.test_wrong_rpc_port()
self.test_init_error()
self.test_start_stop()
if __name__ == '__main__':
FeatureFrameworkRPCFailure(__file__).main()

View file

@ -323,11 +323,11 @@ class BitcoinTestFramework(metaclass=BitcoinTestMetaClass):
self.log.debug('Closing down network thread')
self.network_thread.close()
if self.success == TestStatus.FAILED:
self.log.info("Not stopping nodes as test failed. The dangling processes will be cleaned up later.")
else:
self.log.info("Stopping nodes")
if self.nodes:
if self.nodes:
if self.success == TestStatus.FAILED:
self.log.info("Not stopping nodes as test failed. The dangling processes will be cleaned up later.")
else:
self.log.info("Stopping nodes")
self.stop_nodes()
should_clean_up = (

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
# Copyright (c) 2017-2022 The Bitcoin Core developers
# Copyright (c) 2017-present The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Class for bitcoind node under test"""
@ -8,7 +8,6 @@ import contextlib
import decimal
import errno
from enum import Enum
import http.client
import json
import logging
import os
@ -265,6 +264,8 @@ class TestNode():
"""Sets up an RPC connection to the bitcoind process. Returns False if unable to connect."""
# Poll at a rate of four times per second
poll_per_s = 4
suppressed_errors = collections.defaultdict(int)
latest_error = ""
for _ in range(poll_per_s * self.rpc_timeout):
if self.process.poll() is not None:
# Attach abrupt shutdown error/s to the exception message
@ -305,33 +306,43 @@ class TestNode():
# overhead is trivial, and the added guarantees are worth
# the minimal performance cost.
self.log.debug("RPC successfully started")
# Set rpc_connected even if we are in use_cli mode so that we know we can call self.stop() if needed.
self.rpc_connected = True
if self.use_cli:
return
self.rpc = rpc
self.rpc_connected = True
self.url = self.rpc.rpc_url
return
except JSONRPCException as e: # Initialization phase
except JSONRPCException as e:
# Suppress these in favor of a later outcome (success, FailedToStartError, or timeout).
# -28 RPC in warmup
# -342 Service unavailable, RPC server started but is shutting down due to error
if e.error['code'] != -28 and e.error['code'] != -342:
# -342 Service unavailable, could be starting up or shutting down
if e.error['code'] not in [-28, -342]:
raise # unknown JSON RPC exception
except ConnectionResetError:
# This might happen when the RPC server is in warmup, but shut down before the call to getblockcount
# succeeds. Try again to properly raise the FailedToStartError
pass
suppressed_errors[f"JSONRPCException {e.error['code']}"] += 1
latest_error = repr(e)
except OSError as e:
if e.errno == errno.ETIMEDOUT:
pass # Treat identical to ConnectionResetError
elif e.errno == errno.ECONNREFUSED:
pass # Port not yet open?
else:
error_num = e.errno
# Workaround issue observed on Windows, Python v3.13.1 where socket timeouts don't have errno set.
if error_num is None:
assert isinstance(e, TimeoutError)
error_num = errno.ETIMEDOUT
# Suppress similarly to the above JSONRPCException errors.
if error_num not in [ errno.ECONNRESET, # This might happen when the RPC server is in warmup,
# but shut down before the call to getblockcount succeeds.
errno.ETIMEDOUT, # Treat identical to ECONNRESET
errno.ECONNREFUSED ]: # Port not yet open?
raise # unknown OS error
except ValueError as e: # cookie file not found and no rpcuser or rpcpassword; bitcoind is still starting
suppressed_errors[f"OSError {errno.errorcode[error_num]}"] += 1
latest_error = repr(e)
except ValueError as e:
# Suppress if cookie file isn't generated yet and no rpcuser or rpcpassword; bitcoind may be starting.
if "No RPC credentials" not in str(e):
raise
suppressed_errors["missing_credentials"] += 1
latest_error = repr(e)
time.sleep(1.0 / poll_per_s)
self._raise_assertion_error("Unable to connect to bitcoind after {}s".format(self.rpc_timeout))
self._raise_assertion_error(f"Unable to connect to bitcoind after {self.rpc_timeout}s (ignored errors: {str(dict(suppressed_errors))}, latest error: {latest_error})")
def wait_for_cookie_credentials(self):
"""Ensures auth cookie credentials can be read, e.g. for testing CLI with -rpcwait before RPC connection is up."""
@ -389,14 +400,14 @@ class TestNode():
if not self.running:
return
self.log.debug("Stopping node")
try:
if self.rpc_connected:
# Do not use wait argument when testing older nodes, e.g. in wallet_backwards_compatibility.py
if self.version_is_at_least(180000):
self.stop(wait=wait)
else:
self.stop()
except http.client.CannotSendRequest:
self.log.exception("Unable to stop node.")
else:
raise RuntimeError(self._node_msg(f"Cannot call stop-RPC as we don't have an RPC connection to process {self.process.pid}, wait_for_rpc_connection() failed or was never called."))
# If there are any running perf processes, stop them.
for profile_name in tuple(self.perf_subprocesses.keys()):

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
# Copyright (c) 2014-2022 The Bitcoin Core developers
# Copyright (c) 2014-present The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Run regression test suite.
@ -410,6 +410,7 @@ BASE_SCRIPTS = [
'p2p_handshake.py --v2transport',
'feature_dirsymlinks.py',
'feature_help.py',
'feature_framework_startup_failures.py',
'feature_shutdown.py',
'wallet_migration.py',
'p2p_ibd_txrelay.py',