dash: a busy tracker must not forfeit the block-winning share (#889) - #903
Conversation
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.
656e723 to
5923e13
Compare
|
Integrator review — reward-path PR, so this got a caller-side lock trace rather than a CI glance. Verified independently (not taken from the PR text):
Two design choices I want to endorse explicitly, because both are the harder-but-correct call:
Scope is correct: dash only, no closing keyword, #889 stays open for btc/bch ( Holding merge on the CI rollup — 3 pass / 25 pending at review time. |
Addresses the dash lane of #889.
Rebased onto master (
22e8c692) now that #888 has squash-merged as3e8a88c9. This branch previously carried #888's original commits; those were dropped withgit rebase --onto origin/master 3866a460, which replayed cleanly with no conflicts — the two files the merge route conflicted in (src/impl/dash/stratum/work_source.cpp,test/test_dash_stratum_work_source.cpp) were the same content arriving twice, which a rebase simply does not have to reconcile. The diff is now only the #889 work: theUrgencyenum,tracker_acquire.hpp, the 2000 ms bound, theon_compute_threadguard, the forfeit counter, and the tests.Post-rebase verification (all five coordinator checks, re-run on the rebased tree)
1. Block-arm ordering still block-dispatch-FIRST. Unchanged and confirmed by line order in the rebased
work_source.cpp:submit_block_fn_(block_bytes, ...)at :1013 → dashboardfb_fn(height, ...)at :1037 →mint_solved_share(/*won_block=*/true)at :1053 →bump(true)/returnat :1055-1056. My entire change to that file is a 6-line addition inside the already-downstream helper —git diff origin/master...HEAD -- src/impl/dash/stratum/work_source.cppis one hunk,in.won_block = won_block;plus its comment. Nothing was moved or reordered.2. The
submit_called-before-mint ordering KATs pass.The third is mine and carries its own ordering witness: it reads
rig.fx.submit_calledat the instant the mint runs, before it waits, and asserts it was alreadytrue.3.
Urgency::Opportunisticis still what the ordinary share path gets. Audited by grep rather than by eye, because the coordinator is right that this would not necessarily fail a test:add_local_share(ShareType share, bool block_winning = false)— defaultfalse.read_tracker(Urgency = Urgency::Opportunistic)— default Opportunistic.read_tracker(call site insrc/is bareread_tracker()except exactly one:main_dash.cpp:1933, the mint lambda, which passes the urgency derived fromin.won_block.add_local_sharehas exactly one production caller —main_dash.cpp:1967— which passesin.won_block.Urgency::Opportunisticshort-circuits before the wait loop in bothtracker_acquire::exclusiveand::shared, so it is literally onetry_to_lock.Behaviourally pinned too:
OrdinaryShareStillDeclinesWhileTrackerHeldandDefaultArgumentIsTheOpportunisticPathboth drive a held tracker and assert the decline still happens.4.
mint_solved_share's single-call contract survives. The twostd::moves (branch_hashesat :857,coinbaseat :883) are inside the helper, and the helper has exactly two call sites —:1053(block arm) and:1064(share arm) — in mutually exclusive classify arms, the block arm returning at:1056before the share arm is reachable. At most one invocation permining_submit, unchanged from #888.5. Negative control still shows the same pairing — see Negative control below, re-run on the rebased tree with identical results: 4 block-path tests red, all 4 ordinary-path controls green.
Full suites re-run post-rebase:
test_dash_node13/13,test_dash_stratum_work_source50/50,c2pool-dashlinks clean. CI drift-guard now reports220 per-coin test target(s) across 6 coin lane(s)(widened by #893 on master) — still OK.The residual hole
add_local_sharetakes the exclusive tracker withstd::try_to_lockand declines if 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 a momentarily busy tracker permanently forfeits the highest-work share the node will ever produce.
Why this is more than share-weight loss
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). So a block-winning share that never enters the sharechain is a won block no peer — including our own oracle — can ever record. Established live today:2511241(64 conf) and2511303, both dashd-accepted and correctly paid, and no legacy node registered either.A
try_to_lockdecline therefore reproduces the full #887 symptom — invisible block and lost weight — just intermittently and far harder to notice.Measured cost of the share-weight half alone: ~135 shares/hour minted and ~10.9 blocks/day won ⇒ each lost block-share is ~0.2% of daily PPLNS weight. The visibility half is total for that block.
Approach chosen: bounded blocking, not deferred retry
The issue offered both. I took the bounded blocking acquire (its primary suggestion), for three reasons:
mint_from_inputswalks the chain). If it declines there is no share object to queue at all — a deferral would have to queue the rawMintShareInputsplus its frozen-job handle and re-run the whole mint from a think-completion hook: a new queue, new lifetime rules, and a new re-entrancy surface on a live reward path.The ordinary share path is byte-for-byte unchanged:
Urgency::Opportunisticis today's singletry_to_lock, and it is the default argument at every call site, so nothing can inherit the wait by omission.Why polled
try_lockand not a blockinglock()It must be bounded — an unbounded acquire on the stratum IO thread would stall other miners' submissions with no ceiling and no diagnostic.
std::shared_mutexhas no timed acquire (that isstd::shared_timed_mutex); switching the type would rewrite ~30 explicitstd::shared_lock<std::shared_mutex>reader-discipline call sites acrossnode.hpp/node.cpp— a far larger and riskier diff than the defect warrants. The poll keeps the mutex type and every existing call site identical, and gives an exact, testable deadline.Fairness is stated honestly in the header: polled
try_lockis not fair, and could in principle be starved by an unbroken stream of shared readers. Every shared reader on this mutex is itself an IO-threadtry_to_lockholding for microseconds, so unbroken 2 s coverage is not a realistic state — and if it happened the bound turns it into a logged, counted forfeit instead of a wedged IO thread. Strictly better than today, where the same situation is a silent drop.★ The bound: 2000 ms, and where the number comes from
Measured, not guessed. From a 42 h DASH sharechain soak —
[ASYNC-THINK] compute done in Nms, n = 6721 post-join think cycles:>250 ms: 242 (3.60%) ·>500 ms: 8 (0.119%) ·>750 ms: 3 (0.045%) ·>1000 ms: 1 (0.015%)2000 ms covers 100% of the observed steady-state distribution, with ~1.9x headroom over the single worst cycle.
publish_snapshot()+flush_verified_to_leveldb()(slightly beyond the loggedthink_ms), andclean_tracker()takes the same exclusive lock for a think + stale-head eat + tail drop + LevelDB prune which this build does not time at all. Against an unmeasured second holder the safe direction is more headroom — the failure mode of a too-tight budget is exactly the loss being fixed.Expected cost in production. Total exclusive-lock duty cycle over the soak is ~0.7%, so of ~10.9 block solves/day roughly one every two weeks enters the wait at all, and its expected wait is one think tail (~125 ms). Worst case is 2 s per acquisition. Both acquisitions on the path use the same budget, so the theoretical ceiling is 4 s — but the second is near-free whenever the first succeeded, because succeeding means the holder had already released. Nothing is lost during a stall: miner submissions sit in the socket buffer, jobs are not invalidated, and vardiff credit is not affected.
On expiry:
LOG_ERROR+m_block_share_lock_forfeits, a counter exposed viablock_share_lock_forfeits(). It must stay 0 in production; every increment is one won block invisible to the whole network. A silent drop is the defect — an accounted one is the fix's failure mode.★ Block submit stays FIRST and UNCONDITIONAL
Not regressed, and verified two ways.
Statically. The won-block arm in
src/impl/dash/stratum/work_source.cppruns the payee guard, dispatches throughsubmit_block_fn_, records the dashboard entry, and only then callsmint_solved_share(true). My change adds one field assignment (in.won_block = won_block) inside that already-downstream helper. Nothing was moved. If the bounded wait blocks, the block has already been submitted.Dynamically. The new end-to-end KAT carries an ordering witness: the bound mint records
rig.fx.submit_calledat the instant it runs, before it waits, and the test asserts it was alreadytrue. Any future reorder of the mint ahead of the submit turns that assertion red.★ Caller-side lock trace — re-verified against this tree
The #878/#881 hazard is that a caller holding the exclusive tracker lock across a call into a self-locking callee makes the callee dead code — and making it blocking in that situation is a deadlock, not a no-op. #888's trace was not taken on trust; I re-ran it on the tree this builds on.
Caller side — zero locks held.
asio async_read_untilhandler →StratumSession::process_message(core/stratum_server.cpp:505) →handle_submit(:1036) →mining_interface_->mining_submit(...)(:1332pool-share arm,:1347pseudoshare-is-a-block arm). A scan ofstratum_server.cpp:1030-1360formutex|lock_guard|unique_lock|shared_lock|lock(returns zero hits —handle_submitholds nothing, and it deliberately copies the job snapshot rather than holding a reference.Inside
mining_submit— zero locks across the seam, on both arms.mint_share_mutex_is taken in a scoped block that copiesmint_share_fn_and releases before the call;found_block_mutex_on the block arm is likewise scoped and released before the mint;workers_mutex_is only touched afterwards in thebumplambda. Both classify arms sit in the same function at the same lock depth (zero) under the same caller.Callee side.
main_dash.cpp's mint lambda takes its read guard inside a scoped block, releases it, and only then callsadd_local_share, which takes the exclusive lock. Sequential, never nested — so making both bounded cannot self-deadlock.Belt and braces.
tracker_acquiretakes anon_compute_threadflag and never waits when set;add_local_sharepassesis_compute_thread(). The compute thread already owns the exclusive lock, so the one path that would be a self-deadlock degrades to a single try. Pinned byAcquirePrimitiveContract.Conclusion: the caller holds zero locks, so the bounded blocking acquire is viable — the deferred-retry alternative was not required.
Test evidence
The regression is "hold the tracker exclusively, drive a block-target submit, assert the share still lands" — and nothing existing covered it: every prior mint KAT runs against a free tracker, where
try_to_lockand a bounded wait are indistinguishable.Folded into existing allowlisted targets; no new
add_executable.tools/ci/check_test_target_allowlist.py→CI drift-guard OK: 120 per-coin test target(s) across 5 coin lane(s) all present..github/workflows/untouched.test_dash_nodegainsdash+sharechainon its link line so the KATs drive the realNodeImpl::add_local_shareagainst the realm_tracker_mutex, held exclusively by another thread. That closes #888's own stated honest limit ("the KATs bind their own callback") for the part #889 is about.dash's deps were already on that line or come in transitively; nothing outsidesrc/impl/dashis added.test_dash_node(+5)test_dash_stratum_work_source(+4)Build: Release, gcc 13.3.
c2pool-dashlinks clean.LandsWhileTrackerHeldExclusivelyalso asserts the call waited ≥ 350 ms — i.e. it genuinely rode out the holder rather than finding a free mutex, which is what makes it a real exercise of the bounded acquire and not an accidental pass on an idle lock.#895 / #898 checks
Every new case is a plain
TEST, not#ifdef-guarded, sogtest_add_tests(AUTO)cannot register something unrunnable. Verified as actually EXECUTED under ctest, with real durations — not "Not Run", not a phantom pass:test_dash_nodeandtest_dash_stratum_work_sourceusegtest_add_tests(... AUTO)(source parsing), notgtest_discover_tests(... PRE_TEST), so the #898 5 s discovery timeout does not apply to them.Negative control
Fix removed in two places —
add_local_shareforced back toUrgency::Opportunistic, andin.won_blockforcedfalse— then rebuilt and re-run:and the ordinary-path controls stayed green by design —
OrdinaryShareStillDeclinesWhileTrackerHeld,DefaultArgumentIsTheOpportunisticPath,MiningSubmitOrdinaryShareIsNotFlaggedBlockWinning,OrdinaryShareStillDeclinesThroughAHeldTracker. That pairing is deliberate: if the block-winning tests ever passed merely because the mutex happened to be free, these would fail alongside them. Restored, rebuilt, re-ran → 13/13 and 50/50 green.No consensus change
Share construction, target derivation and serialisation are entirely inside the untouched mint /
create_local_shareseams. This changes only how hard we try to acquire a lock.git difftouches no share struct, no target math, no serializer.Scope
dash only, deliberately — so #889 stays OPEN after this merges.
CreateShareFn, whose signature carries no solve class. Widening it toucheswork_source.hpp/.cppfor both coins plusmain_btc.cppandpool_entrypoint.hpp, and breaks three existing test binders. Neither is the hotel-deploy path this must ship with, and both would raise conflict probability against sibling PRs. Not silently assumed fixed — the same defect is live there and should be a follow-up.set_mint_share_fn, so there is nothing to make blocking.Untouched by design:
src/core/socket.cpp,src/core/web_server.cpp,src/impl/*/protocol_legacy.cpp,src/impl/dash/coin/checkpoints/**,mn_checkpoint*(#899), andmain_dash.cpp's arm-resolution block (#891 — itsmain_dash.cpphunks are at 1148-1349 and 2403+; mine are at 1871-1928, no overlap).Not verified / follow-ups
clean_tracker()'s exclusive hold is not instrumented in this build, so the 2000 ms budget is sized againstthink()alone plus judgement. If clean cycles routinely exceed 2 s the counter will say so — which is precisely why the counter exists. Adding a[CLEAN] done in Nmsline is a cheap follow-up.test_dash_nodedrives the realadd_local_sharethrough the real mutex, butmain_dash.cpp's lambda body is in the executable TU. Its read guard now uses the same helper under the same urgency, and the work-source KAT exercises that discipline end to end.