Also, could we add a targeted test that stresses
The simplest test I found adds two equal-work siblings, resets block-sequence state, seeds CoinsTip with the worst-sorting candidate, calls LoadBlockIndex && LoadChainTip, then verifies every setBlockIndexCandidates entry is not worse than the tip and that set iteration order is comparator-consistent:
diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp
--- a/src/test/validation_chainstatemanager_tests.cpp (revision b2805eec35ce9fdb69892e864556be4ae057ce6a)
+++ b/src/test/validation_chainstatemanager_tests.cpp (date 1770547026792)
@@ -24,6 +24,9 @@
#include <tinyformat.h>
+#include <algorithm>
+#include <array>
+#include <ranges>
#include <vector>
#include <boost/test/unit_test.hpp>
@@ -591,6 +594,47 @@
BOOST_CHECK_EQUAL(cs2.setBlockIndexCandidates.size(), num_indexes - last_assumed_valid_idx + 1);
}
+//! Regression test for candidate pruning when LoadChainTip rewrites nSequenceId.
+BOOST_FIXTURE_TEST_CASE(chainstatemanager_loadchaintip_prunes_worse_candidates, TestChain100Setup)
+{
+ constexpr auto comparator{node::CBlockIndexWorkComparator{}};
+ auto& chainman{*Assert(m_node.chainman)};
+ auto& chainstate{chainman.ActiveChainstate()};
+ auto* active_tip{WITH_LOCK(chainman.GetMutex(), return chainman.ActiveTip())};
+ BOOST_REQUIRE(active_tip && active_tip->pprev);
+
+ // Add two equal-work siblings and use the worse one as CoinsTip best block.
+ LOCK(cs_main);
+ auto header{active_tip->GetBlockHeader()};
+ auto make_sibling{[&]() EXCLUSIVE_LOCKS_REQUIRED(cs_main) {
+ ++header.nNonce;
+ auto* sibling{chainman.m_blockman.AddToBlockIndex(header, chainman.m_best_header)};
+ sibling->RaiseValidity(BLOCK_VALID_TRANSACTIONS);
+ sibling->m_chain_tx_count = active_tip->m_chain_tx_count;
+ return sibling;
+ }};
+ std::array candidates{active_tip, make_sibling(), make_sibling()};
+ chainman.ResetBlockSequenceCounters();
+ for (auto& index : chainman.m_blockman.m_block_index | std::views::values) {
+ index.nSequenceId = SEQ_ID_INIT_FROM_DISK;
+ }
+ std::ranges::sort(candidates, comparator);
+
+ chainstate.m_chain.SetTip(*active_tip);
+ chainstate.CoinsTip().SetBestBlock(candidates.front()->GetBlockHash());
+ chainstate.ClearBlockIndexCandidates();
+
+ BOOST_REQUIRE(chainman.LoadBlockIndex() && chainstate.LoadChainTip());
+ const CBlockIndex* prev{nullptr};
+ for (auto* pindex : chainstate.setBlockIndexCandidates) {
+ BOOST_CHECK_MESSAGE(!comparator(pindex, chainstate.m_chain.Tip()), "Candidate sorts worse than the tip");
+ if (prev) {
+ BOOST_CHECK_MESSAGE(!comparator(pindex, prev), "Wrong candidate set iteration order");
+ }
+ prev = pindex;
+ }
+}
+
//! Ensure that snapshot chainstate can be loaded when found on disk after a
//! restart, and that new blocks can be connected to both chainstates.
BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_init, SnapshotTestSetup)
Before the fix this fails with:
test/validation_chainstatemanager_tests.cpp:630: error: in "validation_chainstatemanager_tests/chainstatemanager_loadchaintip_prunes_worse_candidates": Candidate sorts worse than the tip
test/validation_chainstatemanager_tests.cpp:632: error: in "validation_chainstatemanager_tests/chainstatemanager_loadchaintip_prunes_worse_candidates": Wrong candidate set iteration order
And passes after with the erase in the loop:
diff --git a/src/validation.cpp b/src/validation.cpp
--- a/src/validation.cpp (revision b2805eec35ce9fdb69892e864556be4ae057ce6a)
+++ b/src/validation.cpp (date 1770547212278)
@@ -4614,15 +4614,15 @@
// Make sure our chain tip before shutting down scores better than any other candidate
// to maintain a consistent best tip over reboots in case of a tie.
+ // Erase each block from candidates before changing its nSequenceId. The set
+ // uses nSequenceId for ordering, so modifying it in-place is undefined behavior.
auto target = tip;
while (target) {
+ setBlockIndexCandidates.erase(target);
target->nSequenceId = SEQ_ID_BEST_CHAIN_FROM_DISK;
target = target->pprev;
}
- // Block index candidates are loaded before the chain tip, so we need to replace this entry
- // Otherwise the scoring will be based on the memory address location instead of the nSequenceId
- setBlockIndexCandidates.erase(tip);
TryAddBlockIndexCandidate(tip);
PruneBlockIndexCandidates();
(though I couldn't write a test that fails for your solution only (but passes for the looped erase), so the two fixes are likely equivalent - though conceptually mutating any key still in a std::set is UB so we should likely delete (or not insert) all of them).