Skip to content

Commit 10845cd

Browse files
committed
qa: Add feature_framework_startup_failures.py
1 parent 28e282e commit 10845cd

File tree

2 files changed

+174
-1
lines changed

2 files changed

+174
-1
lines changed
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2025-present The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""
6+
Verify framework startup failures only raise one exception since
7+
multiple exceptions being raised muddies the waters of what actually
8+
went wrong. We should maintain this bar of only raising one exception as
9+
long as additional maintenance and complexity is low.
10+
11+
Test relaunches itself into child processes in order to trigger failures
12+
without the parent process' BitcoinTestFramework also failing.
13+
"""
14+
15+
from test_framework.util import (
16+
assert_raises_message,
17+
rpc_port,
18+
)
19+
from test_framework.test_framework import BitcoinTestFramework
20+
21+
from hashlib import md5
22+
from os import linesep
23+
import re
24+
import subprocess
25+
import sys
26+
import time
27+
28+
class FeatureFrameworkStartupFailures(BitcoinTestFramework):
29+
def set_test_params(self):
30+
self.num_nodes = 1
31+
32+
def setup_network(self):
33+
# Don't start the node yet, as we want to measure during run_test.
34+
self.add_nodes(self.num_nodes, self.extra_args)
35+
36+
# Launches a child test process that runs this same file, but instantiates
37+
# a child test. Verifies that it raises only the expected exception, once.
38+
def _verify_startup_failure(self, test, internal_args, expected_exception):
39+
# Inherit args from parent, only modifying tmpdir so children don't fail
40+
# as a cause of colliding with the parent dir.
41+
parent_args = sys.argv.copy()
42+
assert self.options.tmpdir, "Framework should always set tmpdir."
43+
i, path = next(((i, m[1]) for i, arg in enumerate(parent_args) if (m := re.match(r'--tm(?:p(?:d(?:ir?)?)?)?=(.+)', arg))),
44+
(len(parent_args), self.options.tmpdir))
45+
subdir = md5(expected_exception.encode('utf-8')).hexdigest()[:8]
46+
parent_args[i:i + 1] = [f"--tmpdir={path}/{subdir}"]
47+
args = [sys.executable] + parent_args + [f"--internal_test={test.__name__}"] + internal_args
48+
49+
try:
50+
# The subprocess encoding argument gives different results for e.output
51+
# on Linux/Windows, so handle decoding by ourselves for consistency.
52+
output = subprocess.run(args, timeout=60 * self.options.timeout_factor,
53+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT).stdout.decode("utf-8")
54+
except subprocess.TimeoutExpired as e:
55+
print("Unexpected child process timeout!\n"
56+
"WARNING: Timeouts like this halt execution of TestNode logic, "
57+
"meaning dangling bitcoind processes are to be expected.\n"
58+
f"<CHILD OUTPUT BEGIN>\n{e.output.decode('utf-8')}\n<CHILD OUTPUT END>",
59+
file=sys.stderr)
60+
raise
61+
62+
errors = []
63+
if (n := output.count("Traceback")) != 1:
64+
errors.append(f"Found {n}/1 tracebacks - expecting exactly one with no knock-on exceptions.")
65+
if (n := len(re.findall(expected_exception, output))) != 1:
66+
errors.append(f"Found {n}/1 occurrences of the specific exception: {expected_exception}")
67+
if (n := output.count("Test failed. Test logging available at")) != 1:
68+
errors.append(f"Found {n}/1 test failure output messages.")
69+
70+
assert not errors, f"Child test didn't contain (only) expected errors:\n{linesep.join(errors)}\n<CHILD OUTPUT BEGIN>\n{output}\n<CHILD OUTPUT END>\n"
71+
72+
def run_test(self):
73+
self.log.info("Verifying _verify_startup_failure() functionality (self-check).")
74+
assert_raises_message(
75+
AssertionError,
76+
("Child test didn't contain (only) expected errors:\n" +
77+
linesep.join(["Found 0/1 tracebacks - expecting exactly one with no knock-on exceptions.",
78+
"Found 0/1 occurrences of the specific exception: NonExistentError",
79+
"Found 0/1 test failure output messages."])).encode("unicode_escape").decode("utf-8"),
80+
self._verify_startup_failure,
81+
TestSuccess, [],
82+
"NonExistentError",
83+
)
84+
85+
self.log.info("Parent process is measuring node startup duration in order to obtain a reasonable timeout value for later test...")
86+
node_start_time = time.time()
87+
self.nodes[0].start()
88+
self.nodes[0].wait_for_rpc_connection()
89+
node_start_duration = time.time() - node_start_time
90+
self.nodes[0].stop_node()
91+
self.log.info(f"...measured {node_start_duration:.1f}s.")
92+
93+
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.")
94+
self._verify_startup_failure(
95+
TestWrongRpcPortStartupFailure, [f"--internal_node_start_duration={node_start_duration}"],
96+
r"AssertionError: \[node 0\] Unable to connect to bitcoind after \d+s \(ignored errors: {[^}]*'OSError \w+'?: \d+[^}]*}, latest error: \w+\([^)]+\)\)"
97+
)
98+
99+
self.log.info("Verifying startup failure due to invalid arg results in only one exception.")
100+
self._verify_startup_failure(
101+
TestInitErrorStartupFailure, [],
102+
r"FailedToStartError: \[node 0\] bitcoind exited with status 1 during initialization\. Error: Error parsing command line arguments: Invalid parameter -nonexistentarg"
103+
)
104+
105+
self.log.info("Verifying start() then stop_node() on a node without wait_for_rpc_connection() in between raises an assert.")
106+
self._verify_startup_failure(
107+
TestStartStopStartupFailure, [],
108+
r"AssertionError: \[node 0\] Should only call stop_node\(\) on a running node after wait_for_rpc_connection\(\) succeeded\. Did you forget to call the latter after start\(\)\? Not connected to process: \d+"
109+
)
110+
111+
class InternalTestMixin:
112+
def add_options(self, parser):
113+
# Just here to silence unrecognized argument error, we actually read the value in the if-main at the bottom.
114+
parser.add_argument("--internal_test", dest="internal_never_read", help="ONLY TO BE USED WHEN TEST RELAUNCHES ITSELF")
115+
116+
class TestWrongRpcPortStartupFailure(InternalTestMixin, BitcoinTestFramework):
117+
def add_options(self, parser):
118+
parser.add_argument("--internal_node_start_duration", dest="node_start_duration", help="ONLY TO BE USED WHEN TEST RELAUNCHES ITSELF", type=float)
119+
InternalTestMixin.add_options(self, parser)
120+
121+
def set_test_params(self):
122+
self.num_nodes = 1
123+
# Override RPC listen port to something TestNode isn't expecting so that
124+
# we are unable to establish an RPC connection.
125+
self.extra_args = [[f"-rpcport={rpc_port(2)}"]]
126+
# Override the timeout to avoid waiting unnecessarily long to realize
127+
# nothing is on that port. Divide by timeout_factor to counter
128+
# multiplication in base, 2 * node_start_duration should be enough.
129+
self.rpc_timeout = max(3, 2 * self.options.node_start_duration) / self.options.timeout_factor
130+
131+
def run_test(self):
132+
assert False, "Should have failed earlier during startup."
133+
134+
class TestInitErrorStartupFailure(InternalTestMixin, BitcoinTestFramework):
135+
def set_test_params(self):
136+
self.num_nodes = 1
137+
self.extra_args = [["-nonexistentarg"]]
138+
139+
def run_test(self):
140+
assert False, "Should have failed earlier during startup."
141+
142+
class TestStartStopStartupFailure(InternalTestMixin, BitcoinTestFramework):
143+
def set_test_params(self):
144+
self.num_nodes = 1
145+
146+
def setup_network(self):
147+
self.add_nodes(self.num_nodes, self.extra_args)
148+
self.nodes[0].start()
149+
self.nodes[0].stop_node()
150+
assert False, "stop_node() should raise an exception when we haven't called wait_for_rpc_connection()"
151+
152+
def run_test(self):
153+
assert False, "Should have failed earlier during startup."
154+
155+
class TestSuccess(InternalTestMixin, BitcoinTestFramework):
156+
def set_test_params(self):
157+
self.num_nodes = 1
158+
159+
def setup_network(self):
160+
pass # Don't need to start our node.
161+
162+
def run_test(self):
163+
pass # Just succeed.
164+
165+
166+
if __name__ == '__main__':
167+
if class_name := next((m[1] for arg in sys.argv[1:] if (m := re.match(r'--internal_test=(.+)', arg))), None):
168+
internal_test = globals().get(class_name)
169+
assert internal_test, f"Unrecognized test: {class_name}"
170+
internal_test(__file__).main()
171+
else:
172+
FeatureFrameworkStartupFailures(__file__).main()

test/functional/test_runner.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
# Copyright (c) 2014-2022 The Bitcoin Core developers
2+
# Copyright (c) 2014-present The Bitcoin Core developers
33
# Distributed under the MIT software license, see the accompanying
44
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
55
"""Run regression test suite.
@@ -410,6 +410,7 @@
410410
'p2p_handshake.py --v2transport',
411411
'feature_dirsymlinks.py',
412412
'feature_help.py',
413+
'feature_framework_startup_failures.py',
413414
'feature_shutdown.py',
414415
'wallet_migration.py',
415416
'p2p_ibd_txrelay.py',

0 commit comments

Comments
 (0)