diff --git a/ci/dash/matrix.sh b/ci/dash/matrix.sh index 49117d71c82f..c25ad3315232 100755 --- a/ci/dash/matrix.sh +++ b/ci/dash/matrix.sh @@ -12,7 +12,9 @@ source ./ci/test/00_setup_env.sh # Configure sanitizers options export ASAN_OPTIONS="detect_leaks=1:detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1" -export LSAN_OPTIONS="suppressions=${BASE_ROOT_DIR}/test/sanitizer_suppressions/lsan" +# depends libraries omit frame pointers, so use DWARF unwinding to reach +# dependency-specific suppression frames instead of matching allocators. +export LSAN_OPTIONS="suppressions=${BASE_ROOT_DIR}/test/sanitizer_suppressions/lsan:fast_unwind_on_malloc=0" export TSAN_OPTIONS="suppressions=${BASE_ROOT_DIR}/test/sanitizer_suppressions/tsan:halt_on_error=1:second_deadlock_stack=1" export UBSAN_OPTIONS="suppressions=${BASE_ROOT_DIR}/test/sanitizer_suppressions/ubsan:print_stacktrace=1:halt_on_error=1:report_error_type=1" diff --git a/test/functional/test_framework/test_node.py b/test/functional/test_framework/test_node.py index c43bd0b3f815..3604cef36ca0 100755 --- a/test/functional/test_framework/test_node.py +++ b/test/functional/test_framework/test_node.py @@ -17,6 +17,7 @@ import subprocess import tempfile import time +import unittest import urllib.parse import shlex import collections @@ -37,6 +38,7 @@ get_auth_cookie, get_rpc_proxy, rpc_url, + split_lsan_suppression_summary, wait_until_helper, p2p_port, get_chain_folder, @@ -55,6 +57,38 @@ class ErrorMatch(Enum): PARTIAL_REGEX = 3 +class TestFrameworkTestNode(unittest.TestCase): + def test_split_lsan_suppression_summary(self): + separator = '-' * 53 + summary = ( + f'\n{separator}\nSuppressions used:\n count bytes template\n' + ' 1 160 __lock_open\n' + ' 28 1939 __memp_fopen\n' + f'{separator}\n\n' + ) + application_stderr = 'Error: expected failure\nunexpected application output' + + self.assertEqual( + split_lsan_suppression_summary(application_stderr + summary), + (application_stderr, [('__lock_open', 1, 160), ('__memp_fopen', 28, 1939)]), + ) + self.assertEqual( + split_lsan_suppression_summary(summary[1:]), + ('', [('__lock_open', 1, 160), ('__memp_fopen', 28, 1939)]), + ) + self.assertEqual( + split_lsan_suppression_summary(summary[1:].strip()), + ('', [('__lock_open', 1, 160), ('__memp_fopen', 28, 1939)]), + ) + self.assertEqual( + split_lsan_suppression_summary(application_stderr), + (application_stderr, None), + ) + + malformed = (application_stderr + summary).replace(' 1 160', 'invalid') + self.assertEqual(split_lsan_suppression_summary(malformed), (malformed, None)) + + class TestNode(): """A class for representing a dashd node under test. @@ -627,11 +661,15 @@ def _stop_perf(self, profile_name): report_cmd = "perf report -i {}".format(output_path) self.log.info("See perf output by running '{}'".format(report_cmd)) - def assert_start_raises_init_error(self, extra_args=None, expected_msg=None, match=ErrorMatch.FULL_TEXT, *args, **kwargs): + def assert_start_raises_init_error( + self, extra_args=None, expected_msg=None, match=ErrorMatch.FULL_TEXT, *args, + expected_lsan_suppressions=None, **kwargs): """Attempt to start the node and expect it to raise an error. extra_args: extra arguments to pass through to dashd expected_msg: regex that stderr should match when dashd fails + expected_lsan_suppressions: optional list of (template, count, bytes) + entries permitted in a trailing LeakSanitizer suppression summary Will throw if dashd starts without an error. Will throw if an expected_msg is provided and it does not match dashd's stdout.""" @@ -649,6 +687,12 @@ def assert_start_raises_init_error(self, extra_args=None, expected_msg=None, mat if expected_msg is not None: log_stderr.seek(0) stderr = log_stderr.read().decode('utf-8').strip() + if expected_lsan_suppressions is not None: + stderr, lsan_suppressions = split_lsan_suppression_summary(stderr) + if lsan_suppressions is not None and lsan_suppressions != expected_lsan_suppressions: + self._raise_assertion_error( + 'Expected LSan suppressions {} do not match stderr suppressions {}'.format( + expected_lsan_suppressions, lsan_suppressions)) if match == ErrorMatch.PARTIAL_REGEX: if re.search(expected_msg, stderr, flags=re.MULTILINE) is None: self._raise_assertion_error( diff --git a/test/functional/test_framework/util.py b/test/functional/test_framework/util.py index ff5ef5969d42..79aeca66ff06 100644 --- a/test/functional/test_framework/util.py +++ b/test/functional/test_framework/util.py @@ -25,6 +25,31 @@ logger = logging.getLogger("TestFramework.utils") + +def split_lsan_suppression_summary(stderr): + separator = '-' * 53 + header = f'{separator}\nSuppressions used:\n count bytes template\n' + if stderr.startswith(header): + application_stderr, summary = '', stderr[len(header):] + else: + marker = f'\n{header}' + if marker not in stderr: + return stderr, None + application_stderr, summary = stderr.rsplit(marker, 1) + footer = f'\n{separator}' + footer_index = summary.rfind(footer) + if footer_index == -1 or summary[footer_index + len(footer):] not in ('', '\n', '\n\n'): + return stderr, None + + suppressions = [] + for row in summary[:footer_index].splitlines(): + match = re.fullmatch(r'\s*(\d+)\s+(\d+)\s+(\S+)', row) + if match is None: + return stderr, None + suppressions.append((match[3], int(match[1]), int(match[2]))) + return application_stderr, suppressions + + # Assert functions ################## diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 56acde042f86..33236685edb9 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -85,6 +85,7 @@ "crypto.ripemd160", "script", "segwit_addr", + "test_node", ] EXTENDED_SCRIPTS = [ diff --git a/test/functional/tool_wallet.py b/test/functional/tool_wallet.py index 463ce8273b1d..6fe40395c262 100755 --- a/test/functional/tool_wallet.py +++ b/test/functional/tool_wallet.py @@ -15,6 +15,7 @@ from test_framework.util import ( assert_equal, sha256sum_file, + split_lsan_suppression_summary, ) class ToolWalletTest(BitcoinTestFramework): @@ -37,17 +38,23 @@ def dash_wallet_process(self, *args): return subprocess.Popen([self.options.bitcoinwallet] + default_args + list(args), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + def strip_expected_lsan_summary(self, stderr): + stderr, lsan_suppressions = split_lsan_suppression_summary(stderr) + if lsan_suppressions not in (None, [('__lock_open', 1, 160)]): + raise AssertionError('Unexpected LSan suppressions {}'.format(lsan_suppressions)) + return stderr + def assert_raises_tool_error(self, error, *args): p = self.dash_wallet_process(*args) stdout, stderr = p.communicate() assert_equal(p.poll(), 1) assert_equal(stdout, '') - assert_equal(stderr.strip(), error) + assert_equal(self.strip_expected_lsan_summary(stderr).strip(), error) def assert_tool_output(self, output, *args): p = self.dash_wallet_process(*args) stdout, stderr = p.communicate() - assert_equal(stderr, '') + assert_equal(self.strip_expected_lsan_summary(stderr), '') assert_equal(stdout, output) assert_equal(p.poll(), 0) diff --git a/test/functional/wallet_dust_protection.py b/test/functional/wallet_dust_protection.py index b4a8fadfa497..1a95eba3a627 100755 --- a/test/functional/wallet_dust_protection.py +++ b/test/functional/wallet_dust_protection.py @@ -231,12 +231,14 @@ def test_invalid_args(self): self.nodes[3].assert_start_raises_init_error( ["-dustprotectionthreshold=-1"], "Error: Invalid value for -dustprotectionthreshold: must be >= 0", + expected_lsan_suppressions=[('__lock_open', 1, 160)], ) # Above maximum (1000000) self.nodes[3].assert_start_raises_init_error( ["-dustprotectionthreshold=1000001"], "Error: Invalid value for -dustprotectionthreshold: exceeds maximum (1000000)", + expected_lsan_suppressions=[('__lock_open', 1, 160)], ) # Restart node3 normally for clean state diff --git a/test/functional/wallet_hd.py b/test/functional/wallet_hd.py index aa830e4d009a..c7840cf4aed7 100755 --- a/test/functional/wallet_hd.py +++ b/test/functional/wallet_hd.py @@ -38,7 +38,11 @@ def run_test(self): hardened = "h" if self.options.descriptors else "'" # Make sure can't switch off usehd after wallet creation self.stop_node(1) - self.nodes[1].assert_start_raises_init_error(['-usehd=0'], "Error: Error loading %s: You can't disable HD on an already existing HD wallet" % self.default_wallet_name) + self.nodes[1].assert_start_raises_init_error( + ['-usehd=0'], + "Error: Error loading %s: You can't disable HD on an already existing HD wallet" % self.default_wallet_name, + expected_lsan_suppressions=[('__lock_open', 1, 160)], + ) self.start_node(1) self.connect_nodes(0, 1) diff --git a/test/sanitizer_suppressions/lsan b/test/sanitizer_suppressions/lsan index fc1d82f45976..24a0a188ed63 100644 --- a/test/sanitizer_suppressions/lsan +++ b/test/sanitizer_suppressions/lsan @@ -2,3 +2,9 @@ leak:libQt5Widgets leak:QDBusConnectionPrivate leak:QLayoutPrivate +# Qt's process-global DBus manager retains these allocations at shutdown. +leak:QDBusConnectionManager +# Berkeley DB 4.8 retains DB_PRIVATE lock objects after reopened environments +# are torn down, and memory-pool file metadata after mock databases are closed. +leak:__lock_open +leak:__memp_fopen