Skip to content

Commit d870d53

Browse files
authored
dash(node): notify on tip changes absorbed by the CLEAN cycle (closes #854)
Closes issue #854 — the residual missed-notify path left after #853. CLEAN-cycle Step 1 assigned m_best_share_hash without setting clean_best_changed, so Step 4 compared against the already-updated value and skipped the notify. A second latent case was found during the fix: the decision also sat inside Step 4's IsNull() guard, so a Step-1 election died on an empty Step 4. - New think_gate.hpp holds the slot protocol so node.cpp and the KATs run the same code. - CLEAN captures best-at-entry under the exclusive lock; the decision is computed after Step 4 and outside the IsNull() guard. - m_rethink_pending records a think request dropped by the m_think_running skip race and services it on release, closing the mint-during-IO-phase window at all three run_think producer call sites. - clean_tracker()'s prologue load-then-store becomes compare_exchange_strong. - No new notify call-site: both IO phases still fire m_on_best_share_changed into the fire_share_tip_refresh helper added by #853. Reward/consensus inert: only flag protocol and decision placement change. think() is invoked with identical arguments in identical positions; both apply_min_protocol_ratchet() calls stay byte-identical inside the same guards. No share construction, timestamp, gentx/ref_hash, coinbase, payee, PPLNS, validation, wire-message or won-block-submit code is touched. DASH-only. Reviewed: no stall, spin, double-entry or lost wakeup under the actual thread topology (all flag touches verified IO-thread-confined); the regression pin was checked against master's real pre-image and genuinely diverges rather than passing either way. ctest -R Dash 712/712 reproduced independently; roundtrip 23/23; selftest OK. Follow-up nits recorded separately: an RAII slot guard would eliminate the pre-existing "post() throws while slot held" stall class (master has the identical shape).
1 parent ff21b56 commit d870d53

5 files changed

Lines changed: 500 additions & 28 deletions

File tree

src/impl/dash/local_mint_ledger.hpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@ enum class MintVerdict {
5050
/// height and get_nth_parent_via_skip() is the O(log n) Bitcoin-Core skip-list
5151
/// walk — the same two primitives the producer walks already use. The caller
5252
/// holds the tracker read guard.
53+
///
54+
/// ── LOCK-SAFETY NOTE (do not delete — #828 GP-fault crash class) ────────────
55+
/// Both primitives are NON-CONST and mutate lazily-built caches, yet the only
56+
/// caller (main_dash.cpp's best-share-changed binding) holds a SHARED tracker
57+
/// lock, so two of them can run concurrently. That is safe TODAY, and ONLY
58+
/// because each cache carries its own internal LEAF mutex:
59+
///
60+
/// * get_acc_height() -> TrackerView::get_delta() populates m_deltas /
61+
/// m_reverse_deltas / m_delta_refs / m_reverse_delta_refs, all serialized
62+
/// by TrackerView::m_cache_mutex (src/sharechain/tracker_view.hpp:118-124);
63+
/// * get_nth_parent_via_skip() -> DistanceSkipList builds m_skips lazily,
64+
/// serialized by DistanceSkipList::m_skips_mutex (src/sharechain/
65+
/// skip_list.hpp:76-80), documented there as a leaf — taken around each
66+
/// individual map operation, with m_previous_fn invoked outside it, so it
67+
/// never nests with the tracker lock.
68+
///
69+
/// Strip either leaf mutex — e.g. a "faster" unsynchronized height/skip cache
70+
/// refactor — and this call becomes one thread rehashing an unordered_map
71+
/// while another iterates it: the exact freed-memory dereference of the #828
72+
/// GP-fault class. If you touch those caches, either keep the leaf mutexes or
73+
/// promote this call site to the EXCLUSIVE tracker lock. Do NOT reason "the
74+
/// tracker lock protects it" — the tracker lock is SHARED here, by design.
5375
template <typename ChainT>
5476
inline MintVerdict classify_local_mint(ChainT& chain,
5577
const uint256& best_share,

src/impl/dash/node.cpp

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
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

489490
void 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;

src/impl/dash/node.hpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,18 @@ class NodeImpl : public pool::BaseNode<dash::Config, dash::ShareChain, dash::Pee
146146
boost::asio::thread_pool m_think_pool{1};
147147
std::atomic<bool> m_think_running{false};
148148
std::atomic<bool> m_clean_running{false};
149+
// #854: a run_think() request that arrives while a cycle is already in
150+
// flight used to be DROPPED. It is reachable on the hot path — a local
151+
// mint (add_local_share) or a peer best-advert drain landing during a
152+
// think/clean IO-phase, when the tracker lock is already released but
153+
// m_think_running is still true — and the dropped election is the tip
154+
// change that never reaches the rigs. Set by dash::think::
155+
// acquire_think_slot() when the slot is busy, consumed by
156+
// dash::think::release_think_slot() at the end of the running cycle,
157+
// which then re-posts run_think(). Both flags are IO-thread-only (single
158+
// ioc.run() thread in main_dash); the compute thread never touches them.
159+
// Protocol + rationale: src/impl/dash/think_gate.hpp.
160+
std::atomic<bool> m_rethink_pending{false};
149161
mutable std::shared_mutex m_tracker_mutex;
150162

151163
// ── Lock-free stats snapshot ─────────────────────────────────────────

0 commit comments

Comments
 (0)