Not too important but there’s an effort to replace recursive mutexes with nonrecursive ones in #19303. Suggestion is just to replace the lock with a lock annotation.
  0--- a/src/node/interfaces.cpp
  1+++ b/src/node/interfaces.cpp
  2@@ -1112,7 +1112,11 @@ public:
  3 
  4     bool checkBlock(const CBlock& block, const node::BlockCheckOptions& options, std::string& reason, std::string& debug) override
  5     {
  6-        return TestBlockValidity(chainman().ActiveChainstate(), block, /*check_pow=*/options.check_pow, /*=check_merkle_root=*/options.check_merkle_root, reason, debug);
  7+        LOCK(chainman().GetMutex());
  8+        BlockValidationState state{TestBlockValidity(chainman().ActiveChainstate(), block, /*check_pow=*/options.check_pow, /*=check_merkle_root=*/options.check_merkle_root)};
  9+        reason = state.GetRejectReason();
 10+        debug = state.GetDebugMessage();
 11+        return state.IsValid();
 12     }
 13 
 14     NodeContext* context() override { return &m_node; }
 15--- a/src/node/miner.cpp
 16+++ b/src/node/miner.cpp
 17@@ -173,10 +173,10 @@ std::unique_ptr<CBlockTemplate> BlockAssembler::CreateNewBlock()
 18     pblock->nBits          = GetNextWorkRequired(pindexPrev, pblock, chainparams.GetConsensus());
 19     pblock->nNonce         = 0;
 20 
 21-    std::string reason;
 22-    std::string debug;
 23-    if (m_options.test_block_validity && !TestBlockValidity(m_chainstate, *pblock, /*check_pow=*/false, /*check_merkle_root=*/false, reason, debug)) {
 24-        throw std::runtime_error(strprintf("TestBlockValidity failed: %s - %s", reason, debug));
 25+    if (m_options.test_block_validity) {
 26+        if (BlockValidationState state{TestBlockValidity(m_chainstate, *pblock, /*check_pow=*/false, /*check_merkle_root=*/false)}; !state.IsValid()) {
 27+            throw std::runtime_error(strprintf("TestBlockValidity failed: %s", state.ToString()));
 28+        }
 29     }
 30     const auto time_2{SteadyClock::now()};
 31 
 32--- a/src/rpc/mining.cpp
 33+++ b/src/rpc/mining.cpp
 34@@ -387,10 +387,8 @@ static RPCHelpMan generateblock()
 35         block.vtx.insert(block.vtx.end(), txs.begin(), txs.end());
 36         RegenerateCommitments(block, chainman);
 37 
 38-        std::string reason;
 39-        std::string debug;
 40-        if (!miner.checkBlock(block, {.check_merkle_root = false, .check_pow = false}, reason, debug)) {
 41-            throw JSONRPCError(RPC_VERIFY_ERROR, strprintf("TestBlockValidity failed: %s - %s", reason, debug));
 42+        if (BlockValidationState state{TestBlockValidity(chainman.ActiveChainstate(), block, /*check_pow=*/false, /*check_merkle_root=*/false)}; !state.IsValid()) {
 43+            throw JSONRPCError(RPC_VERIFY_ERROR, strprintf("TestBlockValidity failed: %s", state.ToString()));
 44         }
 45     }
 46 
 47@@ -742,12 +740,7 @@ static RPCHelpMan getblocktemplate()
 48                 return "duplicate-inconclusive";
 49             }
 50 
 51-            std::string reason;
 52-            std::string debug;
 53-            bool res{miner.checkBlock(block, {.check_pow = false}, reason, debug)};
 54-            if (res) return UniValue::VNULL;
 55-            LogDebug(BCLog::RPC, "Invalid block: %s", debug);
 56-            return UniValue{reason};
 57+            return BIP22ValidationResult(TestBlockValidity(chainman.ActiveChainstate(), block, /*check_pow=*/false, /*check_merkle_root=*/true));
 58         }
 59 
 60         const UniValue& aClientRules = oparam.find_value("rules");
 61--- a/src/validation.cpp
 62+++ b/src/validation.cpp
 63@@ -4648,31 +4648,30 @@ MempoolAcceptResult ChainstateManager::ProcessTransaction(const CTransactionRef&
 64     return result;
 65 }
 66 
 67-bool TestBlockValidity(Chainstate& chainstate,
 68-                       const CBlock& block,
 69-                       const bool check_pow,
 70-                       const bool check_merkle_root,
 71-                       std::string& reason,
 72-                       std::string& debug)
 73+
 74+BlockValidationState TestBlockValidity(
 75+    Chainstate& chainstate,
 76+    const CBlock& block,
 77+    const bool check_pow,
 78+    const bool check_merkle_root)
 79 {
 80     // Lock must be held throughout this function for two reasons:
 81     // 1. We don't want the tip to change during several of the validation steps
 82     // 2. To prevent a CheckBlock() race condition for fChecked, see ProcessNewBlock()
 83-    LOCK(chainstate.m_chainman.GetMutex());
 84+    AssertLockHeld(chainstate.m_chainman.GetMutex());
 85 
 86     BlockValidationState state;
 87     CBlockIndex* tip{Assert(chainstate.m_chain.Tip())};
 88 
 89     if (block.hashPrevBlock != *Assert(tip->phashBlock)) {
 90-        reason = "inconclusive-not-best-prevblk";
 91-        return false;
 92+        state.Invalid({}, "inconclusive-not-best-prevblk");
 93+        return state;
 94     }
 95 
 96     // For signets CheckBlock() verifies the challenge iff fCheckPow is set.
 97     if (!CheckBlock(block, state, chainstate.m_chainman.GetConsensus(), /*fCheckPow=*/check_pow, /*fCheckMerkleRoot=*/check_merkle_root)) {
 98-        reason = state.GetRejectReason();
 99-        debug = state.GetDebugMessage();
100-        return false;
101+        if (state.IsValid()) state.Error("CheckBlock failed");
102+        return state;
103     }
104 
105     /**
106@@ -4691,15 +4690,13 @@ bool TestBlockValidity(Chainstate& chainstate,
107      */
108 
109     if (!ContextualCheckBlockHeader(block, state, chainstate.m_blockman, chainstate.m_chainman, tip)) {
110-        reason = state.GetRejectReason();
111-        debug = state.GetDebugMessage();
112-        return false;
113+        if (state.IsValid()) state.Error("ContextualCheckBlockHeader failed");
114+        return state;
115     }
116 
117     if (!ContextualCheckBlock(block, state, chainstate.m_chainman, tip)) {
118-        reason = state.GetRejectReason();
119-        debug = state.GetDebugMessage();
120-        return false;
121+        if (state.IsValid()) state.Error("ContextualCheckBlock failed");
122+        return state;
123     }
124 
125     // We don't want ConnectBlock to update the actual chainstate, so create
126@@ -4716,16 +4713,17 @@ bool TestBlockValidity(Chainstate& chainstate,
127 
128     // Set fJustCheck to true in order to update, and not clear, validation caches.
129     if(!chainstate.ConnectBlock(block, state, &index_dummy, view, /*fJustCheck=*/true)) {
130-        reason = state.GetRejectReason();
131-        debug = state.GetDebugMessage();
132-        return false;
133-    }
134-    if (!Assume(state.IsValid())) {
135-        LogError("Unexpected invalid validation state");
136-        return false;
137+        if (state.IsValid()) state.Error("ConnectBlock failed");
138+        return state;
139     }
140 
141-    return true;
142+    // Ensure no check returned successfully while also setting an invalid state.
143+    if (!Assume(state.IsValid())) {
144+        LogError("Unexpected invalid state in TestBlockValidity: %s", state.ToString());
145+        state.Error("TestBlockValidity failed");
146+    }
147+
148+    return state;
149 }
150 
151 /* This function is called from the RPC code for pruneblockchain */
152--- a/src/validation.h
153+++ b/src/validation.h
154@@ -388,21 +388,23 @@ bool CheckBlock(const CBlock& block, BlockValidationState& state, const Consensu
155  *
156  * [@param](/bitcoin-bitcoin/contributor/param/)[in]   block       The block we want to process. Must connect to the
157  *                          current tip.
158- * [@param](/bitcoin-bitcoin/contributor/param/)[in]   chainstate    The chainstate to connect to.
159- * [@param](/bitcoin-bitcoin/contributor/param/)[out]  reason      rejection reason (BIP22)
160- * [@param](/bitcoin-bitcoin/contributor/param/)[out]  debug       more detailed rejection reason
161+ * [@param](/bitcoin-bitcoin/contributor/param/)[in]   chainstate  The chainstate to connect to.
162  * [@param](/bitcoin-bitcoin/contributor/param/)[in]   check_pow   perform proof-of-work check, nBits in the header
163  *                          is always checked
164  * [@param](/bitcoin-bitcoin/contributor/param/)[in]   check_merkle_root check the merkle root
165  *
166+ * [@return](/bitcoin-bitcoin/contributor/return/) Valid or Invalid state. This doesn't currently return an Error state,
167+ *         and shouldn't unless there is something wrong with the existing
168+ *         chainstate. (This is different from functions like AcceptBlock which
169+ *         can fail trying to save new data.)
170+ *
171  * For signets the challenge verification is skipped when check_pow is false.
172  */
173-bool TestBlockValidity(Chainstate& chainstate,
174-                       const CBlock& block,
175-                       const bool check_pow,
176-                       const bool check_merkle_root,
177-                       std::string& reason,
178-                       std::string& debug);
179+BlockValidationState TestBlockValidity(
180+    Chainstate& chainstate,
181+    const CBlock& block,
182+    bool check_pow,
183+    bool check_merkle_root) EXCLUSIVE_LOCKS_REQUIRED(cs_main);
184 
185 /** Check with the proof of work on each blockheader matches the value in nBits */
186 bool HasValidProofOfWork(const std::vector<CBlockHeader>& headers, const Consensus::Params& consensusParams);