Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ci/dash/matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
46 changes: 45 additions & 1 deletion test/functional/test_framework/test_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import subprocess
import tempfile
import time
import unittest
import urllib.parse
import shlex
import collections
Expand All @@ -37,6 +38,7 @@
get_auth_cookie,
get_rpc_proxy,
rpc_url,
split_lsan_suppression_summary,
wait_until_helper,
p2p_port,
get_chain_folder,
Expand All @@ -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.

Expand Down Expand Up @@ -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."""
Expand All @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions test/functional/test_framework/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +29 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Accept LSan's native summary boundaries

LLVM's PrintMatchedSuppressions() emits the opening separator directly and terminates the final separator with exactly two newlines. This parser instead requires a newline before the opening separator and requires the final separator to be the absolute end of the string. Consequently, raw dash-wallet stderr containing only a native suppression summary is not recognized, and assert_tool_output() subsequently fails its empty-stderr assertion. Parse both summary-only and application-stderr-prefixed forms, accept either the native two-newline trailer or the intentionally stripped representation, and add a regression fixture using LLVM's exact native output.

Suggested change
def split_lsan_suppression_summary(stderr):
separator = '-' * 53
marker = f'\n{separator}\nSuppressions used:\n count bytes template\n'
if marker not in stderr:
return stderr, None
application_stderr, summary = stderr.rsplit(marker, 1)
footer = f'\n{separator}'
if not summary.endswith(footer):
return stderr, None
suppressions = []
for row in summary[:-len(footer)].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
def split_lsan_suppression_summary(stderr):
separator = '-' * 53
marker = f'{separator}\nSuppressions used:\n count bytes template\n'
marker_index = stderr.rfind(marker)
if marker_index == -1 or (marker_index > 0 and stderr[marker_index - 1] != '\n'):
return stderr, None
application_stderr = stderr[:marker_index]
if application_stderr.endswith('\n'):
application_stderr = application_stderr[:-1]
summary = stderr[marker_index + len(marker):]
footer = f'\n{separator}'
if summary.endswith(footer + '\n\n'):
summary = summary[:-2]
elif not summary.endswith(footer):
return stderr, None
suppressions = []
for row in summary[:-len(footer)].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

source: ['codex', 'coderabbit']

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Accept LSan's native summary boundaries no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.



# Assert functions
##################

Expand Down
1 change: 1 addition & 0 deletions test/functional/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"crypto.ripemd160",
"script",
"segwit_addr",
"test_node",
]

EXTENDED_SCRIPTS = [
Expand Down
11 changes: 9 additions & 2 deletions test/functional/tool_wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from test_framework.util import (
assert_equal,
sha256sum_file,
split_lsan_suppression_summary,
)

class ToolWalletTest(BitcoinTestFramework):
Expand All @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions test/functional/wallet_dust_protection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion test/functional/wallet_hd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions test/sanitizer_suppressions/lsan
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading