Skip to content

Commit 656e723

Browse files
committed
dash: a busy tracker must not forfeit the block-winning share (#889)
add_local_share acquires the exclusive tracker with std::try_to_lock and DECLINES when the compute thread is mid-think(). For an ordinary share that is a sound trade -- the decline costs one share and the NEXT solve mints instead. A block-winning share has no next solve: the block is found once, so the same decline permanently forfeits the highest-work share the node will ever produce. And it is not only share weight. p2pool nodes do not learn about a pool block from a block announcement; they detect it THROUGH THE SHARECHAIN, by watching for a share with pow_hash <= header['bits'].target (p2pool/node.py:145-147). A block-winning share that never enters the sharechain is a won block that NO peer -- including our own oracle -- can ever record. A try_to_lock decline therefore reproduces the FULL #887 symptom (invisible block AND lost weight), just intermittently and far harder to notice. This is the residual hole PR #888 left open and it must ship with it. THE FIX. The mint path carries a won_block flag from the stratum classify arm down to the two tracker acquisitions on it, and takes a BOUNDED WAIT on that path ONLY. The ordinary share path is byte-for-byte unchanged: Urgency:: Opportunistic is literally the same single try_to_lock, and it is the default argument everywhere, so nothing can inherit the wait by omission. BOUND: 2000 ms, chosen from measurement, not taste. Over a 42 h DASH sharechain soak ([ASYNC-THINK] compute done in Nms, n=6721 post-join think cycles): p50 125 ms, p90 193 ms, p99 300 ms, p99.9 512 ms, max 1074 ms; >500 ms in 8 cycles (0.119%), >1000 ms in exactly 1 (0.015%). 2000 ms covers 100% of the observed steady-state distribution with ~1.9x headroom over the single worst cycle, while capping well below the 6-15 s bootstrap think cycles the same log shows in the first ~5 min after a cold join (a node in bootstrap has no sharechain to mint onto and is not winning blocks). An expired budget is a LOG_ERROR plus a forfeit counter, never a silent drop. Polled try_lock rather than a blocking lock(): it must be bounded, and std::shared_mutex has no timed acquire -- switching to shared_timed_mutex would rewrite ~30 reader-discipline call sites across node.hpp/node.cpp for no gain. The poll keeps the mutex type and every existing call site identical. BLOCK SUBMIT ORDERING IS UNCHANGED AND UNCONDITIONAL. The won-block arm dispatches the block via submit_block_fn_ and only THEN invokes the mint seam (#887/#888). Whatever the wait costs, the block is already out; an ordering witness in the new end-to-end KAT asserts submit_called was already true when the mint ran, so a future reorder goes red. CALLER-SIDE LOCK TRACE (#878/#881), re-verified against this tree, not taken on trust from #888: asio handler -> StratumSession::process_message -> handle_submit -> mining_submit carries no mutex at all (grep for mutex/lock over stratum_server.cpp:1030-1360 is empty); mining_submit copies mint_share_fn_ under a scoped mint_share_mutex_ and releases before calling it, and found_block_mutex_ on the block arm is likewise scoped and released first; the mint lambda's read guard is scoped and RELEASED before add_local_share takes the exclusive lock -- never nested. Zero locks held on entry, which is what makes a bounded wait viable rather than a deadlock. is_compute_thread() is the belt-and-braces guard: the compute thread already owns the lock and never waits. NO CONSENSUS CHANGE. Share construction, target derivation and serialisation are untouched; this changes only how hard we try to acquire a lock. Tests folded into EXISTING allowlisted targets (no new add_executable): test_dash_node 13/13 PASSED (+5, +dash/sharechain link so the KATs drive the REAL add_local_share against the REAL m_tracker_mutex held by another thread) test_dash_stratum_work_source 50/50 PASSED (+4, incl. an end-to-end block-target submit through an exclusively held tracker) All 9 verified as REGISTERED AND EXECUTED under ctest (#895), with non-zero durations. Negative control (block path forced back to Opportunistic + won_block forced false) reddens 4 of them and leaves the ordinary-path controls green, as designed. SCOPE: dash only. btc/bch reach the same seam through CreateShareFn, whose signature carries no solve class; widening it touches four more files plus three test binders and is not the hotel-deploy path. dgb cannot mint any local share until #884. Tracked as follow-ups, not silently assumed fixed.
1 parent 3866a46 commit 656e723

9 files changed

Lines changed: 841 additions & 22 deletions

File tree

src/c2pool/main_dash.cpp

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,12 +1868,44 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
18681868
return uint256();
18691869
}
18701870

1871+
// #889: a WON BLOCK is unrepeatable — there is no next solve to
1872+
// mint instead, and an unminted block-winning share is a block
1873+
// no p2pool peer can ever see (they detect pool blocks by
1874+
// watching the sharechain for a share meeting the block target,
1875+
// p2pool/node.py:145-147). So the block arm takes a BOUNDED WAIT
1876+
// for the tracker; the ordinary share arm keeps today's single
1877+
// try-and-decline, which is the right trade when the next solve
1878+
// mints. THE BLOCK IS ALREADY SUBMITTED at this point — the
1879+
// won-block arm in work_source.cpp dispatches it before invoking
1880+
// this seam — so no wait here can delay or endanger it.
1881+
//
1882+
// #878/#881: this lambda holds ZERO locks on entry (stratum
1883+
// asio handler -> process_message -> handle_submit ->
1884+
// mining_submit -> mint seam; verified lock-free end to end),
1885+
// which is what makes a bounded wait viable instead of a
1886+
// deadlock. The guard below is scoped and RELEASED before
1887+
// add_local_share takes the exclusive lock — never nested.
1888+
const auto urgency = in.won_block
1889+
? dash::tracker_acquire::Urgency::BlockWinning
1890+
: dash::tracker_acquire::Urgency::Opportunistic;
1891+
18711892
std::optional<dash::producer::BuiltShare> built;
18721893
{
1873-
auto guard = node_ptr->read_tracker();
1894+
auto guard = node_ptr->read_tracker(urgency);
18741895
if (!guard) {
1875-
LOG_WARNING << "[MINT] tracker busy — solve declined "
1876-
"(retry on next share)";
1896+
if (in.won_block) {
1897+
LOG_ERROR << "[MINT-BLOCK] FORFEIT — tracker still "
1898+
"busy after "
1899+
<< dash::tracker_acquire::
1900+
BLOCK_SHARE_LOCK_BUDGET.count()
1901+
<< "ms; cannot even REBUILD the "
1902+
"block-winning share. The block was "
1903+
"already submitted, but no p2pool peer "
1904+
"will see it.";
1905+
} else {
1906+
LOG_WARNING << "[MINT] tracker busy — solve declined "
1907+
"(retry on next share)";
1908+
}
18771909
return uint256();
18781910
}
18791911
built = dash::mint::mint_from_inputs(
@@ -1887,7 +1919,13 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
18871919

18881920
dash::ShareType share;
18891921
share = new dash::DashShare(std::move(built->share));
1890-
const uint256 minted = node_ptr->add_local_share(share);
1922+
// #889: same urgency for the tracker WRITE. add_local_share
1923+
// still returns ZERO if the bounded wait expires — the decline
1924+
// is preserved, it is just no longer silent (LOG_ERROR + a
1925+
// forfeit counter) and no longer triggered by a merely
1926+
// momentary think().
1927+
const uint256 minted =
1928+
node_ptr->add_local_share(share, in.won_block);
18911929
if (minted.IsNull()) {
18921930
// Not inserted (busy/duplicate) — reclaim the allocation.
18931931
share.invoke([](auto* obj) { delete obj; });

src/impl/dash/node.cpp

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,20 +1374,65 @@ std::vector<uint256> NodeImpl::send_shares(peer_ptr peer,
13741374
return sent;
13751375
}
13761376

1377-
uint256 NodeImpl::add_local_share(ShareType share)
1377+
uint256 NodeImpl::add_local_share(ShareType share, bool block_winning)
13781378
{
13791379
const uint256 hash = share.hash();
13801380
if (hash.IsNull())
13811381
return uint256::ZERO;
13821382

13831383
{
1384-
// Exclusive tracker lock, non-blocking (architectural rule): if the
1385-
// compute thread is mid-think, DECLINE the mint (fail-closed) — the
1386-
// miner keeps the pseudoshare credit, the next solve mints.
1387-
std::unique_lock lock(m_tracker_mutex, std::try_to_lock);
1384+
// Exclusive tracker lock.
1385+
//
1386+
// ORDINARY SHARE (block_winning == false) — UNCHANGED. Non-blocking
1387+
// (architectural rule, node.hpp): if the compute thread is mid-think,
1388+
// DECLINE the mint (fail-closed) — the miner keeps the pseudoshare
1389+
// credit, the next solve mints. That trade is correct and stays exactly
1390+
// as it was; tracker_acquire::exclusive() with Urgency::Opportunistic is
1391+
// literally the same single `try_to_lock`.
1392+
//
1393+
// BLOCK-WINNING SHARE (#889) — BOUNDED WAIT. There is no next solve: the
1394+
// block is found once, so a decline here permanently forfeits the
1395+
// highest-work share this node will ever produce. Worse, it is not only
1396+
// OUR share weight. p2pool nodes detect a pool block by watching for a
1397+
// SHARE with pow_hash <= header bits target (p2pool/node.py:145-147), so
1398+
// a block-winning share that never enters the sharechain is a block NO
1399+
// peer — not even our own oracle — can ever record. A momentarily busy
1400+
// tracker must not be allowed to do that.
1401+
//
1402+
// ⚠️ THE BLOCK IS ALREADY SUBMITTED BEFORE WE GET HERE. The won-block
1403+
// arm in stratum/work_source.cpp dispatches the block via
1404+
// submit_block_fn_ and only THEN calls the mint seam (#887/#888
1405+
// ordering, with an ordering-witness KAT pinning it). So whatever this
1406+
// wait costs, it can never delay, gate or endanger the block submit.
1407+
//
1408+
// #878/#881 GUARD: the compute thread already owns this mutex
1409+
// exclusively, so it must never wait on it. is_compute_thread() degrades
1410+
// the bounded path to a single try there. The live block-winning caller
1411+
// (main_dash.cpp's mint lambda, reached from the stratum handler via
1412+
// process_message -> handle_submit -> mining_submit) holds zero locks.
1413+
const auto urgency = block_winning
1414+
? tracker_acquire::Urgency::BlockWinning
1415+
: tracker_acquire::Urgency::Opportunistic;
1416+
auto lock = tracker_acquire::exclusive(m_tracker_mutex, urgency,
1417+
is_compute_thread());
13881418
if (!lock.owns_lock()) {
1389-
LOG_WARNING << "[MINT] tracker busy — local share "
1390-
<< hash.GetHex().substr(0, 16) << " declined (retry on next solve)";
1419+
if (block_winning) {
1420+
// LOUD + COUNTED. A silent drop is precisely the defect; this
1421+
// is the only remaining way a won block can go unseen, and it
1422+
// must be impossible to miss in a log or a dashboard.
1423+
m_block_share_lock_forfeits.fetch_add(1, std::memory_order_relaxed);
1424+
LOG_ERROR << "[MINT-BLOCK] FORFEIT — tracker still busy after "
1425+
<< tracker_acquire::BLOCK_SHARE_LOCK_BUDGET.count()
1426+
<< "ms; BLOCK-WINNING share "
1427+
<< hash.GetHex().substr(0, 16)
1428+
<< " NOT minted. The block itself was already "
1429+
"submitted, but no p2pool peer will see it and its "
1430+
"PPLNS weight is lost. forfeits="
1431+
<< m_block_share_lock_forfeits.load(std::memory_order_relaxed);
1432+
} else {
1433+
LOG_WARNING << "[MINT] tracker busy — local share "
1434+
<< hash.GetHex().substr(0, 16) << " declined (retry on next solve)";
1435+
}
13911436
return uint256::ZERO;
13921437
}
13931438
if (m_chain->contains(hash)) {
@@ -1412,10 +1457,26 @@ uint256 NodeImpl::add_local_share(ShareType share)
14121457
m_known_txs);
14131458
});
14141459
if (!backable) {
1415-
LOG_WARNING << "[MINT] local share " << hash.GetHex().substr(0, 16)
1416-
<< " references template tx(s) no longer retained — "
1417-
"declined (stale job past retention window; retry "
1418-
"on next solve)";
1460+
// #889 scope note: this decline is NOT the lock hazard and is
1461+
// deliberately NOT bypassed for a block-winning share — minting
1462+
// a share whose txs we cannot serve would produce a share
1463+
// send_shares must withhold, i.e. the same invisibility by a
1464+
// different route. It is escalated to LOG_ERROR on the block
1465+
// path only so the loss is never quiet.
1466+
if (block_winning) {
1467+
LOG_ERROR << "[MINT-BLOCK] BLOCK-WINNING share "
1468+
<< hash.GetHex().substr(0, 16)
1469+
<< " references template tx(s) no longer retained "
1470+
"— NOT minted (stale job past the retention "
1471+
"window). The block itself was already "
1472+
"submitted; this is a separate, non-lock "
1473+
"forfeit — see register_template_txs retention.";
1474+
} else {
1475+
LOG_WARNING << "[MINT] local share " << hash.GetHex().substr(0, 16)
1476+
<< " references template tx(s) no longer retained — "
1477+
"declined (stale job past retention window; retry "
1478+
"on next solve)";
1479+
}
14191480
return uint256::ZERO;
14201481
}
14211482
}

src/impl/dash/node.hpp

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "share_tracker.hpp"
2323
#include "peer.hpp"
2424
#include "min_protocol_gate.hpp"
25+
#include "tracker_acquire.hpp" // #889: bounded acquisition — BLOCK-WINNING mint path only
2526
#include "auto_ratchet.hpp" // dash::apply_min_protocol_ratchet_decision (v36 accept-floor ratchet)
2627
#include "version_negotiation.hpp" // dash::version_negotiation::get_desired_version_weights (ratchet window)
2728
#include "messages.hpp"
@@ -244,6 +245,12 @@ class NodeImpl : public pool::BaseNode<dash::Config, dash::ShareChain, dash::Pee
244245
// thread under m_tracker_mutex (mirrors btc::NodeImpl::m_best_share_hash).
245246
uint256 m_best_share_hash;
246247

248+
// #889: block-winning mints forfeited because the BOUNDED wait on the
249+
// exclusive tracker lock expired. Written from the stratum IO thread only,
250+
// read from anywhere (dashboard/KAT) — atomic, relaxed; it is a diagnostic
251+
// gauge, not a synchronisation point.
252+
std::atomic<uint64_t> m_block_share_lock_forfeits{0};
253+
247254
// ── v36 min-protocol accept-floor ratchet (#643/#646, mirrors dgb) ──────────
248255
// Runtime P2P accept-floor, seeded from the COLD config floor (1700, accept-all)
249256
// and lifted to NEW_MINIMUM_PROTOCOL_VERSION (3600) by apply_min_protocol_ratchet()
@@ -829,7 +836,29 @@ class NodeImpl : public pool::BaseNode<dash::Config, dash::ShareChain, dash::Pee
829836
/// (+ LevelDB persist + attempt_verify), then broadcast + run_think.
830837
/// Returns the share hash, or ZERO when the tracker lock is busy or the
831838
/// share is a duplicate (fail-closed: caller declines the mint).
832-
uint256 add_local_share(ShareType share);
839+
///
840+
/// #889 `block_winning`: true ONLY when this solve already cleared the COIN
841+
/// BLOCK target. That share is unrepeatable — there is no next solve — and a
842+
/// share that is never minted is also a block no p2pool peer can ever see
843+
/// (they detect pool blocks through the sharechain, not through a block
844+
/// announcement). It therefore takes a BOUNDED WAIT on the exclusive lock
845+
/// instead of the single try. false (the default) is the ordinary share
846+
/// path: byte-for-byte today's `try_to_lock` decline, deliberately unchanged.
847+
///
848+
/// CALLER CONTRACT (#878/#881): a `block_winning` caller MUST hold no
849+
/// tracker lock. The block-arm caller (main_dash.cpp's mint lambda, reached
850+
/// from the stratum handler) holds zero locks; the compute thread is guarded
851+
/// against explicitly inside.
852+
uint256 add_local_share(ShareType share, bool block_winning = false);
853+
854+
/// #889 observability: block-winning mints lost because the bounded wait on
855+
/// the tracker expired. MUST stay 0 in production — every increment is one
856+
/// won block that is invisible to the whole p2pool network. Each one also
857+
/// emits a LOG_ERROR; this counter is what makes it aggregatable instead of
858+
/// a line someone has to notice.
859+
uint64_t block_share_lock_forfeits() const {
860+
return m_block_share_lock_forfeits.load(std::memory_order_relaxed);
861+
}
833862

834863
/// Register the current template's txs in m_known_txs so send_shares can
835864
/// serve remember_tx for a minted share's new_transaction_hashes. The
@@ -1017,16 +1046,29 @@ class NodeImpl : public pool::BaseNode<dash::Config, dash::ShareChain, dash::Pee
10171046
/// RAII guard for IO-thread tracker reads.
10181047
/// - IO thread: acquires shared_lock(try_to_lock); falsy if busy.
10191048
/// - Compute thread: skips locking (exclusive already held); always truthy.
1049+
///
1050+
/// #889: an optional `urgency` selects a BOUNDED WAIT instead of the single
1051+
/// try. It defaults to Opportunistic, so every existing call site keeps
1052+
/// today's exact non-blocking behaviour; only the block-winning mint (which
1053+
/// has no "next solve" to fall back on) passes BlockWinning.
10201054
class TrackerReadGuard {
10211055
std::shared_lock<std::shared_mutex> lock_;
10221056
ShareTracker& tracker_;
10231057
bool ok_;
10241058
public:
1025-
TrackerReadGuard(std::shared_mutex& mtx, ShareTracker& t, bool on_compute)
1059+
TrackerReadGuard(std::shared_mutex& mtx, ShareTracker& t, bool on_compute,
1060+
tracker_acquire::Urgency urgency =
1061+
tracker_acquire::Urgency::Opportunistic)
10261062
: lock_(mtx, std::defer_lock), tracker_(t)
10271063
{
1028-
if (on_compute) ok_ = true; // exclusive lock already held
1029-
else ok_ = lock_.try_lock();
1064+
if (on_compute) {
1065+
ok_ = true; // exclusive lock already held
1066+
} else if (urgency == tracker_acquire::Urgency::Opportunistic) {
1067+
ok_ = lock_.try_lock(); // unchanged: single try, skip if busy
1068+
} else {
1069+
lock_ = tracker_acquire::shared(mtx, urgency, /*on_compute=*/false);
1070+
ok_ = lock_.owns_lock();
1071+
}
10301072
}
10311073
TrackerReadGuard(TrackerReadGuard&&) = default;
10321074
TrackerReadGuard(const TrackerReadGuard&) = delete;
@@ -1046,8 +1088,14 @@ class NodeImpl : public pool::BaseNode<dash::Config, dash::ShareChain, dash::Pee
10461088
/// Preferred tracker accessor for IO-thread callbacks. On the IO thread it
10471089
/// acquires shared_lock(try_to_lock) (check `if (!guard)`); on the compute
10481090
/// thread it skips locking (exclusive already held).
1049-
TrackerReadGuard read_tracker() {
1050-
return TrackerReadGuard(m_tracker_mutex, m_tracker, is_compute_thread());
1091+
///
1092+
/// #889: pass tracker_acquire::Urgency::BlockWinning ONLY from a solve that
1093+
/// already cleared the BLOCK target, and ONLY from a caller holding zero
1094+
/// locks (#878/#881). Everything else keeps the default, i.e. no change.
1095+
TrackerReadGuard read_tracker(tracker_acquire::Urgency urgency =
1096+
tracker_acquire::Urgency::Opportunistic) {
1097+
return TrackerReadGuard(m_tracker_mutex, m_tracker, is_compute_thread(),
1098+
urgency);
10511099
}
10521100

10531101
/// Acquire shared (reader) lock on the tracker mutex — BLOCKING. Only for

src/impl/dash/stratum/work_source.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,12 @@ nlohmann::json DASHWorkSource::mining_submit(
857857
in.merkle_branches = std::move(branch_hashes);
858858
in.payout_script = core::address_to_script(username);
859859
in.pow_hash = pow_hash;
860+
// #889: tell the mint binding this solve is a WON BLOCK, so it
861+
// takes a bounded wait on the tracker rather than the ordinary
862+
// try-and-decline. The block has already been dispatched above —
863+
// this flag can only affect how hard we try to MINT, never the
864+
// submit. On the share arm it stays false: unchanged behaviour.
865+
in.won_block = won_block;
860866
// ref_hash: the coinb1/coinb2 split sits immediately after the
861867
// 32-byte OP_RETURN ref_hash (before the 8B nonce64 slot), so the
862868
// commitment is the coinb1 tail. Raw LE-internal bytes — embedded

src/impl/dash/stratum/work_source.hpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ class DASHWorkSource : public core::stratum::IWorkSource
124124
/// Template tx hex frozen with the job (shared, may be null) — the
125125
/// run-loop registers these for share relay (remember_tx serving).
126126
std::shared_ptr<const std::vector<std::string>> tx_data;
127+
/// #889: this solve already cleared the COIN BLOCK target — the block
128+
/// has ALREADY been dispatched by the time the mint seam is invoked
129+
/// (#887/#888 ordering). It matters because the share is unrepeatable:
130+
/// there is no next solve to mint instead, and p2pool peers detect a
131+
/// pool block ONLY by seeing a share that meets the block target
132+
/// (p2pool/node.py:145-147), so failing to mint it also makes the won
133+
/// block invisible to the entire network. The mint binding uses this to
134+
/// take a BOUNDED WAIT on the tracker instead of the ordinary
135+
/// try-and-decline. false on the ordinary share arm — unchanged path.
136+
bool won_block{false};
127137
};
128138
/// Returns the minted share hash, or null-uint256 when the mint was
129139
/// declined/deferred (reason logged by the callee).

0 commit comments

Comments
 (0)