test: Add coverage for m_blocks_unlinked invariant in LoadBlockIndex
What changed, and why it matters
This commit only adds a new automated test to Bitcoin Core. It does not change any production code. The test checks that when a node restarts after pruning a stale side-chain block, the internal block-tracking data structure does not incorrectly mark the block as 'unlinked.' There is no fix or vulnerability patch here—just extra test coverage for an existing invariant.
No security action required. Treat as routine test-coverage improvement. If reviewing a related change, ensure the underlying LoadBlockIndex/m_blocks_unlinked behavior is already correct in the codebase this test targets.
Security signals we found
No production code changes
Pure test/functional addition
Commit message references an internal data-structure invariant, not a security bug
Evidence from the diff
The diff creates test/functional/feature_prune_stale_fork.py and registers it in test_runner.py. The test builds a two-block stale fork where the parent has zero transactions, submits the headers/blocks to a pruned node, advances and prunes the chain, restarts the node, and verifies it reloads cleanly and can mine another block. The commit message frames this as coverage for an ‘m_blocks_unlinked invariant in LoadBlockIndex.’ No C++ consensus, networking, or validation code is modified.
Changed components
test/functional/feature_prune_stale_fork.pytest/functional/test_runner.pyInspect captured patch +40 / −0
diff --git a/test/functional/feature_prune_stale_fork.py b/test/functional/feature_prune_stale_fork.py
new file mode 100755
index 00000000..5badca0a
--- /dev/null
+++ b/test/functional/feature_prune_stale_fork.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026-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.
+"""Test node restart with a pruned stale-fork block whose parent has no transactions."""
+
+from test_framework.blocktools import create_empty_fork
+from test_framework.test_framework import BitcoinTestFramework
+from test_framework.util import assert_equal, assert_raises_rpc_error
+
+
+class FeaturePruneStaleForkTest(BitcoinTestFramework):
+ def set_test_params(self):
+ self.num_nodes = 1
+ self.extra_args = [["-prune=1", "-fastprune"]]
+
+ def run_test(self):
+ node = self.nodes[0]
+
+ self.log.info("Create a 2-block stale fork: parent has no transactions, child has transactions")
+ [side_parent, side_child] = create_empty_fork(node, 2)
+
+ node.submitheader(side_parent.serialize().hex())
+ node.submitblock(side_child.serialize().hex())
+ assert_equal(node.getblockheader(side_parent.hash_hex)["nTx"], 0)
+ assert_equal(node.getblockheader(side_child.hash_hex)["nTx"], 1)
+
+ self.log.info("Advance and prune so the stale-fork child's block data is removed from disk")
+ self.generate(node, 500)
+ node.pruneblockchain(node.getblockcount() - 100)
+ assert_raises_rpc_error(-1, "Block not available (pruned data)", node.getblock, side_child.hash_hex)
+
+ self.log.info("Restart and mine; node must reload cleanly after the stale-fork child was pruned")
+ self.restart_node(0)
+ self.generate(node, 1)
+
+
+if __name__ == '__main__':
+ FeaturePruneStaleForkTest(__file__).main()
diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py
index 698742bc..9236f3a9 100755
--- a/test/functional/test_runner.py
+++ b/test/functional/test_runner.py
@@ -369,6 +369,7 @@ BASE_SCRIPTS = [
'feature_blocksdir.py',
'wallet_startup.py',
'feature_remove_pruned_files_on_startup.py',
+ 'feature_prune_stale_fork.py',
'p2p_i2p_ports.py',
'p2p_i2p_sessions.py',
'feature_presegwit_node_upgrade.py',
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.