diff --git a/libraries/chain/include/eosio/chain/block_handle.hpp b/libraries/chain/include/eosio/chain/block_handle.hpp index ca08262d83..302d9a1fe4 100644 --- a/libraries/chain/include/eosio/chain/block_handle.hpp +++ b/libraries/chain/include/eosio/chain/block_handle.hpp @@ -38,6 +38,59 @@ struct block_handle { void write(const std::filesystem::path& state_file); bool read(const std::filesystem::path& state_file); + + // Returns true if this block carries a strong QC for a block that is not + // in `head_handle`'s ancestry. + // + // Under Savanna a strong QC for some block B implies that at least 2/3 of + // finalizer weight voted strong on B. The safety rule for strong votes + // locks those finalizers on B, so they cannot subsequently vote in a way + // that would let any branch not extending B form its own QC. Therefore if + // `head_handle` is on a branch that does not include the QC target, no + // block built on `head_handle` can ever be covered by a future QC, and + // `head_handle`'s branch cannot win fork-choice -- it is permanently + // locked out. + // + // Returns false in legacy (non-Savanna) mode and when no strong QC is + // present. + // + // Thread-safety: safe to call concurrently with block production / apply. + // `block_state_ptr` is a shared_ptr (its copy is atomic) and the + // `finality_core` it references is immutable after construction. The + // accessors used (`latest_qc_claim`, `get_block_reference`, `extends`) + // are const reads against that immutable state. + bool locks_out_branch_of(const block_handle& head_handle) const { + if (!std::holds_alternative(_bsp) || + !std::holds_alternative(head_handle._bsp)) + return false; + + const auto& bsp = std::get(_bsp); + const auto& head_bsp = std::get(head_handle._bsp); + + const auto qc = bsp->core.latest_qc_claim(); + if (!qc.is_strong_qc) + return false; + + const auto& this_id = bsp->id(); + const auto& head_id = head_bsp->id(); + + // If head is on this block's branch (head is this block, or this block extends head), + // they share the QC's chain -- head's branch can produce blocks that include the QC + // target as an ancestor. Not locked out. + if (head_id == this_id || bsp->core.extends(head_id)) + return false; + + // If the QC target is in head's ancestry (or is head itself), head's branch already + // includes the block the QC was formed for. Not locked out. + const auto& qc_target_id = bsp->core.get_block_reference(qc.block_num).block_id; + if (head_id == qc_target_id || head_bsp->core.extends(qc_target_id)) + return false; + + // Otherwise head's branch and the QC target are on incompatible branches: any block + // built on head conflicts with the QC target, and no QC can ever be formed on head's + // branch. Locked out. + return true; + } }; } // namespace eosio::chain diff --git a/plugins/producer_plugin/producer_plugin.cpp b/plugins/producer_plugin/producer_plugin.cpp index 6cc26d2375..7bd4eae9ff 100644 --- a/plugins/producer_plugin/producer_plugin.cpp +++ b/plugins/producer_plugin/producer_plugin.cpp @@ -938,16 +938,23 @@ class producer_plugin_impl : public std::enable_shared_from_thischain(); + // While producing our own block, normally defer applying incoming blocks to avoid disrupting + // production mid-block. Exception: if the fork-database best head carries a strong QC for a + // block not in our applied head's ancestry, our head's branch can no longer form a QC that + // wins fork-choice -- continuing to produce on it is pointless and the resulting blocks would + // be orphaned at the next fork switch. In that case fall through and apply blocks now. if (in_producing_mode()) { - if (_log.is_enabled(fc::log_level::info)) { - auto fhead = chain.fork_db_head(); + const block_handle fhead = chain.fork_db_head(); + if (!fhead.locks_out_branch_of(chain.head())) { fc_ilog(_log, "producing, fork database head at: #${num} id: ${id}", ("num", fhead.block_num())("id", fhead.id())); + _time_tracker.add_other_time(); + // return complete as we are producing and don't want to be interrupted right now. Next start_block will + // give an opportunity for this incoming block to be processed. + return {}; } - _time_tracker.add_other_time(); - // return complete as we are producing and don't want to be interrupted right now. Next start_block will - // give an opportunity for this incoming block to be processed. - return {}; + fc_ilog(_log, "applying blocks while producing: head's branch is locked out of fork-choice by a strong QC at fork-db head #${num} ${id}", + ("num", fhead.block_num())("id", fhead.id())); } // no reason to abort_block if we have nothing ready to process diff --git a/tests/nodeos_late_block_test.py b/tests/nodeos_late_block_test.py index 0f334a6061..7e412b2502 100755 --- a/tests/nodeos_late_block_test.py +++ b/tests/nodeos_late_block_test.py @@ -77,6 +77,18 @@ Print("Verify fork switch") assert node3.findInLog("switching forks .* defproducerk"), "Expected to find 'switching forks' in node_03 log" + # Verify the lockout-detection optimization fired: when the bridge reconnects, node_03 + # is still inside its producing slot, and the rest of the network's blocks (carrying a + # strong QC for a block on the canonical branch) reach node_03's fork database. The + # producer plugin's in_producing_mode early-return should fall through and apply blocks + # immediately rather than waiting for the slot to end. Without this optimization, + # node_03 would orphan its entire round. + def findApplyDuringProducing(): + return node3.findInLog("applying blocks while producing: head's branch is locked out") + applyDuringProducingLine = Utils.waitForBool(findApplyDuringProducing, timeout=30) + assert applyDuringProducingLine, \ + "Expected node_03 to apply blocks mid-slot upon detecting strong-QC lockout of its isolated fork" + Print("Wait until Node_00 to produce") node3.waitForProducer("defproducera") diff --git a/unittests/fork_db_tests.cpp b/unittests/fork_db_tests.cpp index 57ec6d9b0d..d6128d9519 100644 --- a/unittests/fork_db_tests.cpp +++ b/unittests/fork_db_tests.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -249,4 +250,79 @@ BOOST_FIXTURE_TEST_CASE(is_child_of, generate_fork_db_state) try { } FC_LOG_AND_RETHROW(); +// Tests for block_handle::locks_out_branch_of() +// ---------------------------------------------- +// Build two branches of equal length sharing a common root, attach blocks with +// specific QC claims (strong on a non-shared block, strong on a shared block, +// weak), and verify lockout is reported only when the strong-QC's target is +// not in the candidate head's ancestry and the head is not on the QC carrier's +// branch. +BOOST_AUTO_TEST_CASE(locks_out_branch_of_test) try { + nonce = 0; + + // Helper that overlays a custom qc_claim on a freshly-created block_state. + auto make_block_with_qc = [](block_num_type block_num, const block_state_ptr& prev, + const qc_claim_t& qc) { + auto bsp = test_block_state_accessor::make_unique_block_state(block_num, prev); + bsp->core = prev->core.next(prev->make_block_ref(), qc); + return bsp; + }; + + // root (block 10) + // / \ + // bsp11a bsp11b + // | | + // bsp12a bsp12b + // | + // { bsp13a_strong, bsp13a_weak, bsp13a_shared } (three variants of block 13 on branch A, + // differing only in their carried qc_claim) + // and + // bsp13b_strong (on branch B, strong QC for bsp12b) + + block_state_ptr root = test_block_state_accessor::make_genesis_block_state(); + block_state_ptr bsp11a = test_block_state_accessor::make_unique_block_state(11, root); + block_state_ptr bsp12a = test_block_state_accessor::make_unique_block_state(12, bsp11a); + block_state_ptr bsp11b = test_block_state_accessor::make_unique_block_state(11, root); + block_state_ptr bsp12b = test_block_state_accessor::make_unique_block_state(12, bsp11b); + + block_state_ptr bsp13a_strong = make_block_with_qc(13, bsp12a, {.block_num = 12, .is_strong_qc = true}); + block_state_ptr bsp13a_weak = make_block_with_qc(13, bsp12a, {.block_num = 12, .is_strong_qc = false}); + block_state_ptr bsp13a_shared = make_block_with_qc(13, bsp12a, {.block_num = 10, .is_strong_qc = true}); + block_state_ptr bsp13b_strong = make_block_with_qc(13, bsp12b, {.block_num = 12, .is_strong_qc = true}); + + block_handle h13a_strong{bsp13a_strong}; + block_handle h13a_weak{bsp13a_weak}; + block_handle h13a_shared{bsp13a_shared}; + block_handle h13b_strong{bsp13b_strong}; + block_handle h12a{bsp12a}; + block_handle h12b{bsp12b}; + block_handle h11a{bsp11a}; + block_handle h11b{bsp11b}; + block_handle h_root{root}; + + // Strong QC for a block on a different branch from `head` -> head locked out. + BOOST_TEST(h13a_strong.locks_out_branch_of(h12b)); + BOOST_TEST(h13a_strong.locks_out_branch_of(h11b)); + BOOST_TEST(h13b_strong.locks_out_branch_of(h12a)); + BOOST_TEST(h13b_strong.locks_out_branch_of(h11a)); + + // Strong QC for a block on the same branch as head -> not locked out. + // h12a is the QC target itself; h11a is an ancestor of the QC target. + BOOST_TEST(!h13a_strong.locks_out_branch_of(h12a)); + BOOST_TEST(!h13a_strong.locks_out_branch_of(h11a)); + + // Strong QC for a shared ancestor (the genesis root) -> not locked out for either branch. + BOOST_TEST(!h13a_shared.locks_out_branch_of(h12b)); + BOOST_TEST(!h13a_shared.locks_out_branch_of(h11b)); + BOOST_TEST(!h13a_shared.locks_out_branch_of(h_root)); + + // Head identical to the new block -> not locked out. + BOOST_TEST(!h13a_strong.locks_out_branch_of(h13a_strong)); + + // Weak QC, even on a non-shared block -> not locked out (weak votes don't lock finalizers). + BOOST_TEST(!h13a_weak.locks_out_branch_of(h12b)); + BOOST_TEST(!h13a_weak.locks_out_branch_of(h11b)); + +} FC_LOG_AND_RETHROW(); + BOOST_AUTO_TEST_SUITE_END()