Skip to content

Commit ee906e8

Browse files
authored
Merge pull request #97 from frstrtr/btc/v36-livelock-lockyield-watchdog
btc(v36): livelock mirror — lock-yield + think() watchdog (scaffold)
2 parents 96b22ad + a850eba commit ee906e8

4 files changed

Lines changed: 192 additions & 5 deletions

File tree

src/impl/btc/node.cpp

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
#include <iomanip>
1313
#include <random>
1414

15+
#include <execinfo.h> // backtrace() for think() watchdog stack dump
16+
1517
// Static members for DensePPLNSWindow precomputed decay table
1618
std::vector<uint64_t> btc::DensePPLNSWindow::s_decay_table;
1719
uint64_t btc::DensePPLNSWindow::s_decay_per = 0;
@@ -394,6 +396,19 @@ void NodeImpl::processing_shares_phase2(HandleSharesData& data, NetService addr)
394396
{
395397
std::unique_lock lock(m_tracker_mutex, std::try_to_lock);
396398
if (!lock.owns_lock()) {
399+
// ── Backpressure (V36 livelock defense-in-depth) ──────────────
400+
// If think() is wedged/slow the deferred queue could grow without
401+
// bound and blow memory. Cap it: over MAX_PENDING_ADDS we DROP the
402+
// new batch (peers re-advertise their best share, so dropped shares
403+
// are re-requested later) and warn instead of growing unbounded.
404+
if (m_pending_adds.size() >= MAX_PENDING_ADDS) {
405+
LOG_WARNING << "[ASYNC-DEFER] BACKPRESSURE: pending_adds at cap ("
406+
<< m_pending_adds.size() << "/" << MAX_PENDING_ADDS
407+
<< "), dropping batch of " << data.m_items.size()
408+
<< " shares from " << addr.to_string()
409+
<< " — think() may be wedged";
410+
return;
411+
}
397412
LOG_INFO << "[ASYNC-DEFER] processing_shares_phase2: mutex busy, queuing "
398413
<< data.m_items.size() << " shares from " << addr.to_string()
399414
<< " (pending=" << m_pending_adds.size() + 1 << ")";
@@ -1313,6 +1328,82 @@ void NodeImpl::prune_shares(const uint256& /*best_share*/)
13131328

13141329
// (old phases 5-7 removed — replaced by p2pool-style pruning above)
13151330

1331+
// ── think() watchdog (V36 livelock defense-in-depth) ────────────────────
1332+
// Best-effort recovery if a think() cycle wedges. Runs ENTIRELY on the IO
1333+
// thread and NEVER acquires m_tracker_mutex — so it stays responsive even
1334+
// while the compute thread holds the exclusive lock. Uses an atomic deadline
1335+
// + generation counter rather than a per-dispatch timer object lifetime so a
1336+
// late fire cannot act on a newer cycle.
1337+
void NodeImpl::arm_think_watchdog()
1338+
{
1339+
if (!m_context)
1340+
return;
1341+
auto deadline = std::chrono::steady_clock::now()
1342+
+ std::chrono::seconds(THINK_WATCHDOG_SECONDS);
1343+
m_think_deadline_ns.store(
1344+
std::chrono::duration_cast<std::chrono::nanoseconds>(
1345+
deadline.time_since_epoch()).count(),
1346+
std::memory_order_relaxed);
1347+
uint64_t gen = m_think_generation.fetch_add(1, std::memory_order_relaxed) + 1;
1348+
1349+
if (!m_watchdog_timer)
1350+
m_watchdog_timer = std::make_unique<boost::asio::steady_timer>(*m_context);
1351+
m_watchdog_timer->expires_after(std::chrono::seconds(THINK_WATCHDOG_SECONDS));
1352+
m_watchdog_timer->async_wait([this, gen](const boost::system::error_code& ec) {
1353+
if (ec) return; // cancelled by disarm (normal completion)
1354+
// Only act if this is still the cycle we armed for and it has not
1355+
// already completed (deadline cleared to 0 by disarm).
1356+
if (m_think_generation.load(std::memory_order_relaxed) != gen)
1357+
return;
1358+
int64_t dl = m_think_deadline_ns.load(std::memory_order_relaxed);
1359+
if (dl == 0)
1360+
return; // cycle completed in the gap before this fire
1361+
auto now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
1362+
std::chrono::steady_clock::now().time_since_epoch()).count();
1363+
if (now_ns < dl)
1364+
return; // not actually overdue (spurious) — let next arm handle it
1365+
1366+
// (1) Log + best-effort backtrace dump of the current (IO) thread.
1367+
// We CANNOT safely unwind the compute thread's stack from here, but
1368+
// a backtrace of the watchdog firing plus the timing is enough to
1369+
// confirm the wedge and correlate with the last [ASYNC-THINK] logs.
1370+
LOG_ERROR << "[THINK-WATCHDOG] think() cycle exceeded "
1371+
<< THINK_WATCHDOG_SECONDS << "s (gen=" << gen
1372+
<< ", pending_adds=" << m_pending_adds.size()
1373+
<< ") — compute thread appears wedged; recovering pipeline";
1374+
{
1375+
void* frames[64];
1376+
int n = ::backtrace(frames, 64);
1377+
char** syms = ::backtrace_symbols(frames, n);
1378+
if (syms) {
1379+
for (int i = 0; i < n; ++i)
1380+
LOG_ERROR << "[THINK-WATCHDOG] bt[" << i << "] " << syms[i];
1381+
::free(syms);
1382+
}
1383+
}
1384+
1385+
// (2)+(3) Flag the cycle aborted and reset the running flag so the
1386+
// pipeline recovers. NOTE: this does NOT forcibly unwind the compute
1387+
// thread (unsafe); it lets a fresh think() be scheduled. The stuck
1388+
// think(), if it ever returns, will find m_think_running already false
1389+
// and its IO-phase still runs harmlessly (idempotent drain + reset).
1390+
m_think_deadline_ns.store(0, std::memory_order_relaxed);
1391+
m_think_running.store(false, std::memory_order_relaxed);
1392+
LOG_WARNING << "[THINK-WATCHDOG] m_think_running reset to false; "
1393+
"a new think() cycle may now be scheduled";
1394+
});
1395+
}
1396+
1397+
void NodeImpl::disarm_think_watchdog()
1398+
{
1399+
// Mark cycle complete: clears the deadline so a racing watchdog fire is a
1400+
// no-op, and cancels the pending timer.
1401+
m_think_deadline_ns.store(0, std::memory_order_relaxed);
1402+
if (m_watchdog_timer) {
1403+
m_watchdog_timer->cancel();
1404+
}
1405+
}
1406+
13161407
void NodeImpl::run_think()
13171408
{
13181409
// Skip if a think() is already running on the compute thread.
@@ -1327,6 +1418,10 @@ void NodeImpl::run_think()
13271418
<< " pending_adds=" << m_pending_adds.size()
13281419
<< " peers=" << m_peers.size();
13291420

1421+
// Arm the think() watchdog (IO-thread timer; never touches the tracker
1422+
// mutex). Disarmed in the IO-phase below on normal completion.
1423+
arm_think_watchdog();
1424+
13301425
// Capture block_rel_height fn by value for thread safety
13311426
auto block_rel_height = m_block_rel_height_fn
13321427
? m_block_rel_height_fn
@@ -1394,7 +1489,12 @@ void NodeImpl::run_think()
13941489
// Publish lock-free snapshot for all IO-thread consumers
13951490
publish_snapshot();
13961491

1397-
needs_continue = m_tracker.m_think_needs_continue;
1492+
// Continue if EITHER the Phase 2 verification budget OR the V36
1493+
// scoring-walk lock-yield budget was exhausted. Both reuse the same
1494+
// run_think() re-post: lock released here, drain_pending_adds() runs,
1495+
// continuation scheduled below.
1496+
needs_continue = m_tracker.m_think_needs_continue
1497+
|| m_tracker.m_think_walk_needs_continue;
13981498

13991499
// Flush verified hashes to LevelDB while lock is held
14001500
flush_verified_to_leveldb();
@@ -1482,6 +1582,10 @@ void NodeImpl::run_think()
14821582
<< " pending batches, peers=" << m_peers.size();
14831583
drain_pending_adds();
14841584

1585+
// Cycle completed normally — disarm the watchdog first so it
1586+
// cannot fire on this (now-finished) generation.
1587+
disarm_think_watchdog();
1588+
14851589
// Clear flag AFTER drain — prevents new think() from starting
14861590
// while deferred shares are still being added to the tracker.
14871591
m_think_running.store(false);
@@ -1494,9 +1598,11 @@ void NodeImpl::run_think()
14941598
}
14951599
} catch (const std::exception& e) {
14961600
LOG_ERROR << "run_think() IO phase failed: " << e.what();
1601+
disarm_think_watchdog();
14971602
m_think_running.store(false);
14981603
} catch (...) {
14991604
LOG_ERROR << "run_think() IO phase failed: unknown error";
1605+
disarm_think_watchdog();
15001606
m_think_running.store(false);
15011607
}
15021608
});

src/impl/btc/node.hpp

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
#include <sharechain/prepared_list.hpp>
1414
#include <c2pool/storage/sharechain_storage.hpp>
1515

16+
#include <boost/asio/steady_timer.hpp>
17+
1618
#include <atomic>
1719
#include <chrono>
1820
#include <mutex>
@@ -113,6 +115,30 @@ class NodeImpl : public pool::BaseNode<btc::Config, btc::ShareChain, btc::Peer>
113115
};
114116
std::vector<PendingShareBatch> m_pending_adds;
115117

118+
// ── think() watchdog + backpressure (V36 livelock defense-in-depth) ──
119+
// If a think() cycle exceeds THINK_WATCHDOG_SECONDS the watchdog logs the
120+
// compute-thread state (timing + backtrace dump), flags the cycle, and
121+
// resets m_think_running so the pipeline recovers instead of wedging.
122+
// Implemented as an atomic deadline checked by an IO-thread steady_timer:
123+
// the watchdog NEVER touches m_tracker_mutex (would itself block on the
124+
// stuck compute thread). MAX_PENDING_ADDS caps the deferred queue so a
125+
// stuck/slow think() cannot grow memory without bound — over the cap new
126+
// batches are dropped with a LOG_WARNING backpressure message.
127+
static constexpr int THINK_WATCHDOG_SECONDS = 30;
128+
static constexpr size_t MAX_PENDING_ADDS = 256;
129+
// Steady-clock time-point (ns since epoch) by which the in-flight think()
130+
// cycle must complete. 0 == no cycle armed. Set on dispatch, cleared on
131+
// completion; read by the watchdog timer on the IO thread.
132+
std::atomic<int64_t> m_think_deadline_ns{0};
133+
// Generation counter: bumped each dispatch so a fired watchdog only acts
134+
// on the cycle it was armed for (guards against late/duplicate fires).
135+
std::atomic<uint64_t> m_think_generation{0};
136+
// IO-thread timer that polls the deadline. Armed on run_think() dispatch.
137+
std::unique_ptr<boost::asio::steady_timer> m_watchdog_timer;
138+
// Arm/disarm helpers (defined in node.cpp).
139+
void arm_think_watchdog();
140+
void disarm_think_watchdog();
141+
116142
// Top-5 scored heads from last think() — used by clean_tracker()
117143
// to protect the best chains from head pruning (p2pool node.py:363).
118144
std::vector<uint256> m_last_top5_heads;
@@ -205,8 +231,7 @@ class NodeImpl : public pool::BaseNode<btc::Config, btc::ShareChain, btc::Peer>
205231
if (on_compute) {
206232
ok_ = true; // exclusive lock already held by caller
207233
} else {
208-
lock_.try_lock();
209-
ok_ = lock_.owns_lock();
234+
ok_ = lock_.try_lock();
210235
}
211236
}
212237
TrackerReadGuard(TrackerReadGuard&&) = default;

src/impl/btc/share_tracker.hpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,16 @@ class ShareTracker
322322
// Checked by run_think() to schedule a deferred continuation.
323323
bool m_think_needs_continue{false};
324324

325+
// V36 livelock mirror (lock-yield): set by think() when the long
326+
// decayed-cumulative-weight + PPLNS scoring walk exhausts its
327+
// cooperative-yield budget (THINK_WALK_YIELD_BUDGET). It reuses the
328+
// SAME run_think() continuation path as m_think_needs_continue so the
329+
// compute thread RELEASES m_tracker_mutex, drain_pending_adds() runs,
330+
// and a continuation is re-posted — breaking IO-thread starvation when
331+
// the scoring window is pathologically large. On healthy windows the
332+
// budget never trips (semantics unchanged).
333+
bool m_think_walk_needs_continue{false};
334+
325335
private:
326336
// Retry counter for log throttling only — p2pool retries every think()
327337
// with no limit. Counter is cleared on successful verification or
@@ -854,6 +864,23 @@ class ShareTracker
854864
constexpr int THINK_VERIFY_BUDGET = 100;
855865
int budget_remaining = bootstrap_mode ? INT_MAX : THINK_VERIFY_BUDGET;
856866
m_think_needs_continue = false;
867+
868+
// ── V36 livelock mirror: cooperative-yield budget for the scoring walk ──
869+
// The Phase 3/4 scoring step walks the decayed-cumulative-weight + PPLNS
870+
// window per scored head while holding m_tracker_mutex. On a healthy
871+
// chain this is cheap, but a pathologically large/forked window can pin
872+
// the lock long enough to starve the IO thread (the livelock). We reuse
873+
// the existing needs_continue continuation mechanism: when this budget is
874+
// exhausted we stop scoring, release the lock (run_think() does this),
875+
// let drain_pending_adds() run, and re-post a continuation.
876+
//
877+
// The budget is sized FAR above a normal full-window scoring pass so it
878+
// NEVER trips on healthy load — behaviour and scoring results are
879+
// unchanged for normal-size windows. It is purely a starvation circuit
880+
// breaker for the degenerate case.
881+
constexpr int THINK_WALK_YIELD_BUDGET = 1000000;
882+
int walk_budget_remaining = bootstrap_mode ? INT_MAX : THINK_WALK_YIELD_BUDGET;
883+
m_think_walk_needs_continue = false;
857884
{
858885
static int p2_skip_log = 0;
859886
if (p2_skip_log++ % 20 == 0)
@@ -1063,6 +1090,20 @@ class ShareTracker
10631090
std::vector<DecoratedData<TailScore>> decorated_tails;
10641091
for (auto& [tail_hash, head_hashes] : verified.get_tails())
10651092
{
1093+
// ── V36 livelock mirror: COARSE walk-yield boundary ──────────────
1094+
// Conservative boundary: checked once per scored head, NOT inside the
1095+
// innermost decay iteration (that exact intra-walk yield point is the
1096+
// marked TODO below, pending ltc-doge PIE symbolization). When the
1097+
// scoring walk has consumed its (generous) budget, defer the remaining
1098+
// tails to a re-posted continuation so the lock is released and
1099+
// drain_pending_adds() can run. Trips only on a degenerate window.
1100+
if (walk_budget_remaining <= 0) {
1101+
m_think_walk_needs_continue = true;
1102+
LOG_WARNING << "[think-WALK-YIELD] scoring-walk budget exhausted; "
1103+
"deferring remaining tails to continuation";
1104+
break;
1105+
}
1106+
10661107
// p2pool: max(verified.tails[tail_hash], key=verified.get_work)
10671108
uint256 best_head;
10681109
uint288 best_work;
@@ -1085,6 +1126,12 @@ class ShareTracker
10851126
auto s = score(best_head, block_rel_height_func);
10861127
s.best_head_work = best_work; // tiebreak by total work
10871128
decorated_tails.push_back({s, tail_hash});
1129+
// Charge the (coarse) cost of one scored head against the
1130+
// walk-yield budget. score() walks up to CHAIN_LENGTH/16
1131+
// window entries; charging the chain_len keeps the accounting
1132+
// proportional to real lock-hold time without descending into
1133+
// the inner decay loop (see TODO marker there).
1134+
walk_budget_remaining -= std::max(s.chain_len, 1);
10881135
} catch (const std::exception&) {
10891136
// Chain was concurrently modified (trim removed an
10901137
// ancestor). Skip this tail — will be scored next cycle.
@@ -1713,6 +1760,16 @@ class ShareTracker
17131760

17141761
// Single-pass walk matching p2pool's while loop in
17151762
// get_decayed_cumulative_weights. No pre-collection needed.
1763+
//
1764+
// TODO(ltc-doge): pin exact intra-walk yield boundary + optional
1765+
// zero-divisor guard (degenerate target_ratio==0). This inner decay
1766+
// iteration is the candidate finest-grained yield point for the V36
1767+
// livelock lock-yield mechanism; the cooperative budget is currently
1768+
// enforced at the COARSE per-scored-head boundary in think() Phase 3
1769+
// (THINK_WALK_YIELD_BUDGET). Once PIE core symbolization pins the true
1770+
// hot site, move/refine the budget check here. The zero-divisor guard
1771+
// referenced is the `!this_total.IsNull()` proration guard just below
1772+
// (remaining / this_total) — confirm it covers the degenerate case.
17161773
auto cur = start;
17171774
while (!cur.IsNull() && chain.contains(cur) && share_count < max_shares)
17181775
{

src/impl/ltc/node.hpp

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,7 @@ class NodeImpl : public pool::BaseNode<ltc::Config, ltc::ShareChain, ltc::Peer>
216216
if (on_compute) {
217217
ok_ = true; // exclusive lock already held by caller
218218
} else {
219-
lock_.try_lock();
220-
ok_ = lock_.owns_lock();
219+
ok_ = lock_.try_lock();
221220
}
222221
}
223222
TrackerReadGuard(TrackerReadGuard&&) = default;

0 commit comments

Comments
 (0)