33#include " share.hpp"
44#include " mint_runloop.hpp" // dash::mint::elect_best_share (election policy SSOT)
55#include " known_txs_retention.hpp" // dash::retain_template_txs / all_txs_backable (F1/F3)
6+ #include " think_gate.hpp" // dash::think:: think-slot protocol + clean-cycle tip decision (#854)
67
78#include < cassert>
89
@@ -488,16 +489,37 @@ void NodeImpl::apply_min_protocol_ratchet()
488489
489490void NodeImpl::run_think ()
490491{
492+ // THREAD-CONFINEMENT (enforced, not assumed — register_template_txs
493+ // precedent): the think-slot flags (m_think_running / m_rethink_pending)
494+ // are IO-thread-only. Every caller is on the single ioc.run() thread: the
495+ // mint hook (add_local_share), the reception path (add_verified_shares,
496+ // which posts itself back to m_context after the verify pool), the 2 s
497+ // advert drain, and both cycles' IO-phase epilogues. The compute thread
498+ // NEVER touches them — it only runs the tracker under the exclusive lock.
499+ // A compute-thread caller would break the "release then re-post" handshake
500+ // below (it would re-enter the slot it still owns), so trip loudly in
501+ // debug builds rather than let it be discovered in production.
502+ assert (!is_compute_thread () &&
503+ " run_think must not be called from the compute thread — the think "
504+ " slot (m_think_running/m_rethink_pending) is IO-thread-confined" );
505+
491506 // Serialize: only one think() in flight (compute pool has 1 thread).
492- if (m_think_running.exchange (true )) {
507+ // #854: a skipped request is no longer DROPPED — acquire_think_slot()
508+ // records it in m_rethink_pending and the cycle that owns the slot
509+ // re-posts run_think() when it releases. Without that, a local mint or a
510+ // peer best-advert landing during a think/clean IO-phase (tracker lock
511+ // already released, flag still true) silently loses its re-election, so
512+ // the tip change never reaches the rigs until the 25 s keepalive.
513+ if (!dash::think::acquire_think_slot (m_think_running, m_rethink_pending)) {
493514 static int skip_log = 0 ;
494515 if (skip_log++ % 20 == 0 )
495- LOG_INFO << " [ASYNC-THINK] skipped — compute thread busy" ;
516+ LOG_INFO << " [ASYNC-THINK] skipped — compute thread busy (re-think queued) " ;
496517 return ;
497518 }
498519 if (!m_context) {
499- // Rig-free (KAT/standalone) node: no IO context to hop back to.
500- m_think_running.store (false );
520+ // Rig-free (KAT/standalone) node: no IO context to hop back to, so
521+ // there is nowhere to post a queued re-think either — drop it.
522+ (void )dash::think::release_think_slot (m_think_running, m_rethink_pending);
501523 return ;
502524 }
503525
@@ -611,16 +633,24 @@ void NodeImpl::run_think()
611633
612634 drain_pending_adds ();
613635
614- m_think_running.store (false );
636+ // #854: release_think_slot() reports whether a run_think() request
637+ // was dropped while this cycle held the slot — e.g. the mint that
638+ // drain_pending_adds()/add_local_share() just landed. Serve it, or
639+ // that share's election (and the miner notify it drives) is lost
640+ // until the next timer tick.
641+ const bool rethink_owed =
642+ dash::think::release_think_slot (m_think_running, m_rethink_pending);
615643
616- if (needs_continue)
644+ if (needs_continue || rethink_owed )
617645 boost::asio::post (*m_context, [this ]() { run_think (); });
618646 } catch (const std::exception& e) {
619647 LOG_ERROR << " run_think() IO phase failed: " << e.what ();
620- m_think_running.store (false );
648+ if (dash::think::release_think_slot (m_think_running, m_rethink_pending))
649+ boost::asio::post (*m_context, [this ]() { run_think (); });
621650 } catch (...) {
622651 LOG_ERROR << " run_think() IO phase failed: unknown error" ;
623- m_think_running.store (false );
652+ if (dash::think::release_think_slot (m_think_running, m_rethink_pending))
653+ boost::asio::post (*m_context, [this ]() { run_think (); });
624654 }
625655 });
626656 });
@@ -812,11 +842,12 @@ void NodeImpl::drain_peer_best_adverts()
812842 to_download.emplace_back (weak_peer, best);
813843 }
814844 }
815- // TODO(dash-async, follow-up): when drain_peer_best_adverts runs FROM the
816- // think() IO-phase, m_think_running is still true, so this run_think() is a
817- // no-op (skipped). A m_rethink_pending flag checked at the end of the think
818- // cycle would guarantee the re-election happens. Benign today: the 2s
819- // advert timer calls drain again shortly, and by then think() has finished.
845+ // #854 (was a TODO here): when drain_peer_best_adverts runs FROM the
846+ // think() IO-phase, m_think_running is still true, so this run_think()
847+ // cannot start a cycle. It is no longer a no-op — acquire_think_slot()
848+ // records the request in m_rethink_pending and the owning cycle re-posts
849+ // run_think() when it releases, so the re-election is guaranteed rather
850+ // than left to the next 2 s advert-timer tick.
820851 if (rethink)
821852 run_think ();
822853 for (auto & [weak_peer, best] : to_download)
@@ -913,26 +944,25 @@ void NodeImpl::clean_tracker()
913944 if (m_clean_running.exchange (true ))
914945 return ;
915946
916- // Skip if think() is already in flight — the periodic timer will retry.
917- // TODO(dash-async, follow-up): this load()-then-store() is check-then-act;
918- // a run_think() could win the m_think_running flag between the load and the
919- // store below. Benign today because both only ever run on the IO thread
920- // (the timer callbacks are serialized on the single io_context), so there
921- // is no true concurrency — but a compare_exchange on m_think_running would
922- // make the guard symmetric with the exchange() above and future-proof.
923- if (m_think_running.load ()) {
924- m_clean_running.store (false );
925- return ;
926- }
927947 if (!m_context) {
928948 m_clean_running.store (false );
929949 return ; // rig-free (KAT/standalone) node
930950 }
931951
952+ // Take the think slot, which also blocks run_think() re-entry for the
953+ // duration of the clean. #854: acquire_clean_slot() is a compare_exchange,
954+ // closing the load()-then-store() check-then-act window this prologue used
955+ // to carry as a TODO — the guard is now symmetric with the m_clean_running
956+ // exchange() above. A failure records NO pending re-think: the think cycle
957+ // that won the slot performs the same election clean's Step 1 would, and
958+ // the periodic timer retries the prune shortly.
959+ if (!dash::think::acquire_clean_slot (m_think_running)) {
960+ m_clean_running.store (false );
961+ return ;
962+ }
963+
932964 // Post the entire body to the compute thread: chain modifications happen
933965 // under the exclusive lock, never concurrent with think() or IO reads.
934- m_think_running.store (true ); // block think() re-entry during clean
935-
936966 boost::asio::post (m_think_pool, [this ]() {
937967 m_compute_thread_id.store (std::this_thread::get_id (), std::memory_order_relaxed);
938968
@@ -941,6 +971,14 @@ void NodeImpl::clean_tracker()
941971 try {
942972 std::unique_lock lock (m_tracker_mutex); // exclusive
943973
974+ // #854: the tip AS THE CLEAN CYCLE FOUND IT. clean_best_changed is
975+ // decided against this, not against the value Step 1 has already
976+ // written — otherwise an election absorbed by Step 1 (the case a mint
977+ // landing during a think IO-phase produces) makes Step 4's comparison
978+ // trivially false and no work is pushed to the rigs. Read under the
979+ // exclusive lock, on the compute thread, before either think() runs.
980+ const uint256 best_at_entry = m_best_share_hash;
981+
944982 auto block_rel_height = m_block_rel_height_fn
945983 ? m_block_rel_height_fn
946984 : std::function<int32_t (uint256)>([](uint256) -> int32_t { return 0 ; });
@@ -1072,10 +1110,16 @@ void NodeImpl::clean_tracker()
10721110 /* bits=*/ 0 , bootstrap);
10731111 m_last_top5_heads = std::move (result.top5_heads );
10741112 if (!result.best .IsNull ()) {
1075- clean_best_changed = (m_best_share_hash != result.best );
10761113 m_best_share_hash = result.best ;
10771114 apply_min_protocol_ratchet (); // v36 accept-floor ratchet (dgb node.cpp:2077 parallel)
10781115 }
1116+ // #854: decide across BOTH thinks — entry tip vs exit tip. Placed
1117+ // outside the IsNull() guard on purpose: if Step 1 elected a new
1118+ // tip and Step 4's think returns nothing (e.g. everything it would
1119+ // have scored was just pruned), m_best_share_hash still carries
1120+ // Step 1's election and that IS a tip change the rigs must see.
1121+ clean_best_changed =
1122+ dash::think::clean_cycle_best_changed (best_at_entry, m_best_share_hash);
10791123 publish_snapshot ();
10801124 flush_verified_to_leveldb ();
10811125 }
@@ -1100,6 +1144,10 @@ void NodeImpl::clean_tracker()
11001144 try {
11011145 if (clean_best_changed) {
11021146 LOG_INFO << " [CLEAN] IO-phase: work refresh (best changed)" ;
1147+ // Same single fan-out every other tip-change leg uses: the
1148+ // binding in main_dash.cpp calls dash::stratum::
1149+ // fire_share_tip_refresh (bump -> notify_all -> dashboard).
1150+ // No second notify call-site with its own logic.
11031151 if (m_on_best_share_changed)
11041152 m_on_best_share_changed ();
11051153 broadcast_share (m_best_share_hash); // re-announce new head
@@ -1110,8 +1158,14 @@ void NodeImpl::clean_tracker()
11101158 } catch (...) {
11111159 LOG_ERROR << " [CLEAN] IO phase failed: unknown error" ;
11121160 }
1113- m_think_running.store (false );
1161+ // #854: the clean cycle holds the think slot too, so run_think()
1162+ // requests that arrived during it (a mint, a peer best-advert) were
1163+ // queued rather than dropped — serve them now.
1164+ const bool rethink_owed =
1165+ dash::think::release_think_slot (m_think_running, m_rethink_pending);
11141166 m_clean_running.store (false );
1167+ if (rethink_owed)
1168+ boost::asio::post (*m_context, [this ]() { run_think (); });
11151169 });
11161170 });
11171171}
@@ -1379,6 +1433,11 @@ uint256 NodeImpl::add_local_share(ShareType share)
13791433 }
13801434
13811435 // Lock released — relay + rescore (both take their own locks / post).
1436+ // #854: this run_think() is the ONLY thing that promotes the share we just
1437+ // minted to the tip and thereby pushes fresh work to the rigs. It used to
1438+ // be dropped outright whenever it landed during a think/clean IO-phase
1439+ // (IO thread, tracker lock already released, m_think_running still true) —
1440+ // the residual missed-notify path. acquire_think_slot() now queues it.
13821441 broadcast_share (hash);
13831442 run_think ();
13841443 return hash;
0 commit comments