Skip to content

Commit 9ac8b3d

Browse files
authored
Merge pull request #781 from frstrtr/dash/io-thread-decouple
dash: decouple blocking coin-RPC + heavy work off the stratum io_context (io-thread-decouple)
2 parents 14718d5 + 7fdeed1 commit 9ac8b3d

9 files changed

Lines changed: 593 additions & 51 deletions

File tree

src/c2pool/main_dash.cpp

Lines changed: 89 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@
9292
#include <random>
9393

9494
#include <boost/asio.hpp>
95+
#include <boost/asio/thread_pool.hpp> // io-thread-decouple: background RPC pool
96+
#include <boost/asio/post.hpp>
9597

9698
#include <cstdint>
9799
#include <cstdlib> // std::getenv
@@ -1495,49 +1497,94 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
14951497
// trivial RPC; failures are swallowed so a daemon hiccup never crashes the
14961498
// run-loop (retry next tick). Reuses the existing NodeRPC client — no new
14971499
// dependency, no dashd config change, no new notify mechanism.
1500+
// io-thread-decouple: dedicated single-thread pool for the fallback arm's
1501+
// BLOCKING dashd RPC (getbestblockhash tip probe + the background template
1502+
// re-source). Mirrors main_ltc.cpp hdr_pool ("keeps scrypt off io_context"):
1503+
// synchronous beast I/O runs HERE, never on the stratum io_context, so 60+
1504+
// sessions never starve while dashd is queried (or wedged -- the NodeRPC
1505+
// socket timeout + m_rpc_mutex bound this thread). Declared here (after rpc /
1506+
// work_source / stratum_server) so its explicit stop()+join() after the run
1507+
// loop -- and its destructor -- happen BEFORE those objects unwind: no
1508+
// background probe is ever mid-flight against freed state. Only created on
1509+
// the fallback arm; null on the embedded arm (legacy inline path unchanged).
1510+
std::shared_ptr<boost::asio::thread_pool> rpc_pool;
14981511
if (!coin_p2p && rpc && stratum_server) {
1512+
rpc_pool = std::make_shared<boost::asio::thread_pool>(1);
1513+
1514+
// Non-blocking template re-source: cached_work() hands the blocking
1515+
// select_work()/GBT to rpc_pool as a single-flight background job
1516+
// instead of blocking the io thread on every stale/generation miss (the
1517+
// per-share ~15-30 s GBT block). The io thread serves the cached template
1518+
// immediately; the pool updates it and the next notify picks it up.
1519+
work_source->set_refresh_executor(
1520+
[rpc_pool](std::function<void()> job) {
1521+
boost::asio::post(*rpc_pool, std::move(job));
1522+
});
1523+
14991524
auto tip_timer = std::make_shared<io::steady_timer>(ioc);
15001525
auto last_tip = std::make_shared<std::string>();
15011526
auto tip_tick = std::make_shared<
15021527
std::function<void(const boost::system::error_code&)>>();
1528+
// tip_tick runs ON ioc when the 3 s timer fires. It does NOT call the
1529+
// blocking RPC itself: it hands getbestblockhash to rpc_pool and posts
1530+
// the tip-change follow-up (invalidate + bump + notify) + the timer
1531+
// re-arm BACK onto ioc (ws/ss/tip_timer are io-thread-confined), exactly
1532+
// like main_ltc.cpp's post-to-pool -> post-back-to-ioc pattern. The
1533+
// timer is re-armed only AFTER the RPC completes, so a slow dashd cannot
1534+
// pile up overlapping polls. Lost-block-prevention is preserved: a real
1535+
// tip change still fires invalidate + notify -- only WHERE the probe runs
1536+
// has moved off the stratum io thread.
15031537
*tip_tick = [rpc = rpc.get(), ws = work_source.get(),
1504-
ss = stratum_server.get(), tip_timer, last_tip, tip_tick](
1505-
const boost::system::error_code& ec) {
1538+
ss = stratum_server.get(), tip_timer, last_tip, tip_tick,
1539+
rpc_pool, &ioc](const boost::system::error_code& ec) {
15061540
if (ec) return; // cancelled at shutdown
1507-
try {
1508-
const std::string tip = rpc->getbestblockhash();
1509-
if (!tip.empty() && *last_tip != tip) {
1510-
const bool first_seen = last_tip->empty();
1511-
*last_tip = tip;
1512-
// Skip the notify on the very first observation (baseline);
1513-
// only a genuine tip CHANGE forces a refresh.
1514-
if (!first_seen) {
1515-
ws->invalidate_template_cache(
1516-
"tip-poll: dashd best-block changed");
1517-
ws->bump_work_generation();
1518-
ss->notify_all();
1519-
LOG_INFO << "[Stratum] tip-poll: new tip "
1520-
<< tip.substr(0, 16)
1521-
<< ", forcing template refresh + notify";
1522-
std::cout << "[Stratum] tip-poll: new tip "
1523-
<< tip.substr(0, 16)
1524-
<< ", forcing template refresh + notify\n";
1541+
boost::asio::post(*rpc_pool,
1542+
[rpc, ws, ss, tip_timer, last_tip, tip_tick, &ioc]() {
1543+
std::string tip;
1544+
bool ok = false;
1545+
try {
1546+
tip = rpc->getbestblockhash(); // BLOCKING -- BACKGROUND THREAD
1547+
ok = true;
1548+
} catch (const std::exception& e) {
1549+
LOG_WARNING << "[Stratum] tip-poll getbestblockhash failed "
1550+
"(non-fatal, retry next tick): " << e.what();
1551+
} catch (...) {
1552+
// swallow — never crash on a tip probe
15251553
}
1526-
}
1527-
} catch (const std::exception& e) {
1528-
LOG_WARNING << "[Stratum] tip-poll getbestblockhash failed "
1529-
"(non-fatal, retry next tick): " << e.what();
1530-
} catch (...) {
1531-
// swallow — never crash the run-loop on a tip probe
1532-
}
1533-
tip_timer->expires_after(std::chrono::seconds(3));
1534-
tip_timer->async_wait(*tip_tick);
1554+
// Follow-up + timer re-arm run BACK ON ioc (io-thread-confined
1555+
// state). If ioc is already stopped (shutdown) this handler
1556+
// simply never runs -> the poll stops cleanly.
1557+
boost::asio::post(ioc,
1558+
[ok, tip = std::move(tip), ws, ss, tip_timer, last_tip, tip_tick]() {
1559+
if (ok && !tip.empty() && *last_tip != tip) {
1560+
const bool first_seen = last_tip->empty();
1561+
*last_tip = tip;
1562+
// Skip the notify on the very first observation
1563+
// (baseline); only a genuine tip CHANGE refreshes.
1564+
if (!first_seen) {
1565+
ws->invalidate_template_cache(
1566+
"tip-poll: dashd best-block changed");
1567+
ws->bump_work_generation();
1568+
ss->notify_all();
1569+
LOG_INFO << "[Stratum] tip-poll: new tip "
1570+
<< tip.substr(0, 16)
1571+
<< ", forcing template refresh + notify";
1572+
std::cout << "[Stratum] tip-poll: new tip "
1573+
<< tip.substr(0, 16)
1574+
<< ", forcing template refresh + notify\n";
1575+
}
1576+
}
1577+
tip_timer->expires_after(std::chrono::seconds(3));
1578+
tip_timer->async_wait(*tip_tick);
1579+
});
1580+
});
15351581
};
15361582
tip_timer->expires_after(std::chrono::seconds(3));
15371583
tip_timer->async_wait(*tip_tick);
15381584
std::cout << "[run] fallback-arm tip-poll ARMED (dashd getbestblockhash "
1539-
"every 3 s -> event-driven template refresh + clean_jobs "
1540-
"notify on tip change)\n";
1585+
"every 3 s on a dedicated RPC thread -> event-driven template "
1586+
"refresh + clean_jobs notify on tip change; io thread never "
1587+
"blocks on dashd)\n";
15411588
}
15421589

15431590
std::cout << "[run] run-loop up (Ctrl-C to stop); won blocks relay DUAL-PATH:\n"
@@ -1567,6 +1614,17 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
15671614
}
15681615
}
15691616

1617+
// io-thread-decouple: join the background RPC pool FIRST, before any of the
1618+
// objects it dereferences (rpc / work_source / stratum_server) unwind. run()
1619+
// has returned (ioc stopped), so no NEW work is posted; stop()+join() waits
1620+
// out any in-flight getbestblockhash/GBT re-source (bounded by the NodeRPC
1621+
// socket timeout) so no pool thread ever touches freed state. Any post-back
1622+
// to the stopped ioc simply never executes.
1623+
if (rpc_pool) {
1624+
rpc_pool->stop();
1625+
rpc_pool->join();
1626+
}
1627+
15701628
// Tear the acceptor + sessions down while the work source and node_coin_state
15711629
// it references are still alive -- explicit reset keeps destruction order safe
15721630
// (stratum_server was declared before them, so it would otherwise outlive them).

src/core/stratum_server.cpp

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ StratumServer::StratumServer(net::io_context& ioc, const std::string& address, u
9292
, bind_address_(address)
9393
, port_(port)
9494
, running_(false)
95+
, idle_reap_timer_(ioc)
9596
{
9697
}
9798

@@ -113,7 +114,8 @@ bool StratumServer::start()
113114

114115
running_ = true;
115116
accept_connections();
116-
117+
start_idle_reaper(); // no-op unless session_idle_timeout_sec > 0
118+
117119
LOG_INFO << "StratumServer started on " << bind_address_ << ":" << port_;
118120
return true;
119121

@@ -133,6 +135,7 @@ void StratumServer::stop()
133135
boost::system::error_code ec;
134136
acceptor_.cancel(ec);
135137
acceptor_.close(ec);
138+
idle_reap_timer_.cancel(); // stop the zombie-session reaper
136139
running_ = false;
137140

138141
// Snapshot + clear the live-sessions set under the mutex, then close
@@ -383,14 +386,68 @@ void StratumServer::notify_all()
383386
}
384387
}
385388

389+
void StratumServer::start_idle_reaper()
390+
{
391+
// Zombie-session reaper (belt-and-suspenders alongside OS TCP keepalive):
392+
// periodically close sessions that have sent no inbound line past the idle
393+
// timeout. A live miner submits shares far inside the window; a half-open
394+
// NAT-dead peer sends nothing. No-op (never re-armed) unless the DASH-set
395+
// StratumConfig::session_idle_timeout_sec > 0 -> other coins unchanged.
396+
const auto& cfg0 = mining_interface_ ? mining_interface_->get_stratum_config()
397+
: core::stratum::StratumConfig{};
398+
const uint32_t timeout = cfg0.session_idle_timeout_sec;
399+
const bool keepalive_on = cfg0.tcp_keepalive_enabled;
400+
if (timeout == 0 || !running_) return;
401+
402+
// Sweep at half the timeout, bounded to [15 s, 60 s], so a dead session is
403+
// reclaimed within ~1.5x the timeout.
404+
const uint32_t period = std::max<uint32_t>(15, std::min<uint32_t>(60, timeout / 2));
405+
idle_reap_timer_.expires_after(std::chrono::seconds(period));
406+
idle_reap_timer_.async_wait([this, timeout, keepalive_on](const boost::system::error_code& ec) {
407+
if (ec) return; // cancelled at shutdown
408+
if (!running_) return;
409+
std::vector<std::shared_ptr<StratumSession>> snapshot;
410+
{
411+
std::lock_guard<std::mutex> lk(sessions_mutex_);
412+
snapshot.assign(sessions_.begin(), sessions_.end());
413+
}
414+
size_t reaped = 0;
415+
for (auto& s : snapshot) {
416+
if (!s->is_connected())
417+
continue; // socket already dead -> notify_all prunes it
418+
if (s->seconds_since_activity() <= timeout)
419+
continue; // recently active
420+
// KEEPALIVE-AWARE: when OS TCP keepalive is enabled it is the
421+
// liveness authority -- a still-open socket means keepalive has NOT
422+
// declared the peer dead (it force-closes dead NAT paths, which the
423+
// is_connected() guard above then catches). Do NOT reap an idle-but-
424+
// keepalive-validated AUTHORIZED rig: a high fixed-diff suffix
425+
// (ADDR+N) can legitimately exceed the idle window between submits,
426+
// and reaping it would drop a LIVE, paying miner. Idle-time reaping
427+
// is the FALLBACK only where keepalive is unavailable; unauthorized
428+
// sessions (never completed handshake) are always fair game.
429+
if (keepalive_on && s->is_authorized())
430+
continue;
431+
s->drop_stale("idle timeout (no inbound share/line)");
432+
++reaped;
433+
}
434+
if (reaped)
435+
LOG_INFO << "[Stratum] idle-reaper closed " << reaped
436+
<< " stale session(s) (> " << timeout << "s idle)";
437+
start_idle_reaper(); // re-arm
438+
});
439+
}
440+
386441
/// StratumSession Implementation
387442
StratumSession::StratumSession(tcp::socket socket, std::shared_ptr<IWorkSource> mining_interface,
388443
StratumServer* server)
389444
: socket_(std::move(socket))
390445
, mining_interface_(mining_interface)
391446
, server_(server)
392447
, connected_at_(std::chrono::steady_clock::now())
448+
, last_activity_(connected_at_)
393449
, work_push_timer_(socket_.get_executor())
450+
, handshake_timer_(socket_.get_executor())
394451
{
395452
subscription_id_ = generate_subscription_id();
396453
extranonce1_ = generate_extranonce1();
@@ -411,6 +468,8 @@ void StratumSession::start()
411468
auto ep = socket_.remote_endpoint(ec);
412469
if (ec) return; // socket closed before start() was dispatched
413470
LOG_INFO << "StratumSession started for client: " << ep;
471+
apply_socket_keepalive(); // OS keepalive (no-op unless enabled) -- reaps NAT-dead peers
472+
arm_handshake_timer(); // authorize deadline (no-op unless configured)
414473
read_message();
415474
}
416475

@@ -445,10 +504,13 @@ void StratumSession::read_message()
445504
void StratumSession::process_message(std::size_t bytes_read)
446505
{
447506
try {
507+
// Zombie-session reaper: a received line is proof of a live peer.
508+
last_activity_ = std::chrono::steady_clock::now();
509+
448510
std::istream is(&buffer_);
449511
std::string line;
450512
std::getline(is, line);
451-
513+
452514
if (!line.empty() && line.back() == '\r') {
453515
line.pop_back(); // Remove \r if present
454516
}
@@ -531,6 +593,8 @@ nlohmann::json StratumSession::handle_authorize(const nlohmann::json& params, co
531593
if (params.size() >= 1 && params[0].is_string()) {
532594
username_ = params[0];
533595
authorized_ = true;
596+
// Handshake completed -> disarm the authorize deadline (zombie-session fix).
597+
handshake_timer_.cancel();
534598

535599
// ─── Step 1: Strip fixed difficulty suffix (+N) before any parsing ───
536600
// Format: "ADDRESS+1024" or "ADDR,ADDR+512" → suggested_difficulty_=N
@@ -1289,6 +1353,20 @@ nlohmann::json StratumSession::handle_submit(const nlohmann::json& params, const
12891353
void StratumSession::send_response(const nlohmann::json& response)
12901354
{
12911355
try {
1356+
// Write-queue backlog cap (zombie-session fix): a dead peer whose kernel
1357+
// send buffer has filled never drains the async_write, so the queue
1358+
// grows unbounded. Past the configured depth, drop the session rather
1359+
// than accumulate. 0 = unlimited (legacy; other coins unchanged).
1360+
const size_t cap = mining_interface_
1361+
? mining_interface_->get_stratum_config().max_write_queue_depth
1362+
: 0;
1363+
if (cap > 0 && write_queue_.size() >= cap) {
1364+
LOG_WARNING << "[Stratum] session " << session_id_
1365+
<< " write backlog " << write_queue_.size()
1366+
<< " >= cap " << cap << " -- dropping stuck session";
1367+
drop_stale("write-queue backlog exceeded");
1368+
return;
1369+
}
12921370
std::string message = response.dump() + "\n";
12931371
write_queue_.push_back(std::move(message));
12941372
if (!writing_)
@@ -1790,12 +1868,82 @@ void StratumSession::cancel_timers()
17901868
// Cancel pending timer — callback fires with ec=operation_aborted → returns.
17911869
// Timer is a member, so it outlives all its callbacks (no use-after-free).
17921870
work_push_timer_.cancel();
1871+
handshake_timer_.cancel();
17931872
// Close socket so is_connected() returns false for any already-dequeued
17941873
// callbacks that fire with ec=success after cancel().
17951874
boost::system::error_code ec;
17961875
socket_.close(ec);
17971876
}
17981877

1878+
// ── Zombie-session fixes (transport/liveness only; consensus-neutral) ────────
1879+
1880+
void StratumSession::apply_socket_keepalive()
1881+
{
1882+
// Enable OS TCP keepalive so the kernel actively probes an idle peer and
1883+
// errors the pending async_read when a NAT path has gone dead (the peer
1884+
// never sent FIN/RST). This is THE root fix for immortal subscribed
1885+
// sessions -- socket_.is_open() alone never falsifies for a half-open NAT
1886+
// drop. No-op unless the (DASH-set) StratumConfig knob is enabled, so
1887+
// LTC/BTC/DGB behaviour is unchanged.
1888+
const auto& cfg = mining_interface_->get_stratum_config();
1889+
if (!cfg.tcp_keepalive_enabled) return;
1890+
1891+
boost::system::error_code ec;
1892+
socket_.set_option(boost::asio::socket_base::keep_alive(true), ec); // portable (asio)
1893+
if (ec) {
1894+
LOG_WARNING << "[Stratum] SO_KEEPALIVE set failed: " << ec.message();
1895+
return;
1896+
}
1897+
// Per-probe tuning (idle/interval/count) is Linux-specific -- Windows tunes
1898+
// keepalive via WSAIoctl(SIO_KEEPALIVE_VALS) and macOS via TCP_KEEPALIVE, so
1899+
// guard exactly like sample_tcp_rtt_ms() above (#ifdef __linux__). Off-Linux
1900+
// the portable keep_alive(true) above still arms keepalive with OS defaults;
1901+
// the DASH deploy target is Linux, where the 60/10/3 tuning (~90 s detect)
1902+
// applies. This keeps core building on the Windows/macOS CI lanes.
1903+
#ifdef __linux__
1904+
const int fd = socket_.native_handle();
1905+
int idle = static_cast<int>(cfg.tcp_keepalive_idle_sec);
1906+
int intvl = static_cast<int>(cfg.tcp_keepalive_interval_sec);
1907+
int cnt = static_cast<int>(cfg.tcp_keepalive_count);
1908+
::setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
1909+
::setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
1910+
::setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
1911+
LOG_TRACE << "[Stratum] TCP keepalive armed (idle=" << idle
1912+
<< "s interval=" << intvl << "s count=" << cnt << ")";
1913+
#else
1914+
LOG_TRACE << "[Stratum] TCP keepalive armed (OS-default probe timing; "
1915+
"per-probe tuning is Linux-only)";
1916+
#endif
1917+
}
1918+
1919+
void StratumSession::arm_handshake_timer()
1920+
{
1921+
// Drop a session that never completes mining.authorize within the deadline
1922+
// (a live rig authorizes in << 1 s; a half-open probe never does). Disarmed
1923+
// in handle_authorize(). No-op unless configured. Member timer + weak self
1924+
// keepalive so a fired-after-close callback is a safe no-op.
1925+
const auto& cfg = mining_interface_->get_stratum_config();
1926+
if (cfg.handshake_timeout_sec == 0) return;
1927+
handshake_timer_.expires_after(std::chrono::seconds(cfg.handshake_timeout_sec));
1928+
std::weak_ptr<StratumSession> weak = shared_from_this();
1929+
handshake_timer_.async_wait([weak](const boost::system::error_code& ec) {
1930+
if (ec) return; // cancelled (authorized, or shutdown)
1931+
auto self = weak.lock();
1932+
if (!self) return;
1933+
if (!self->authorized_ && self->is_connected())
1934+
self->drop_stale("handshake (authorize) deadline");
1935+
});
1936+
}
1937+
1938+
void StratumSession::drop_stale(const char* reason)
1939+
{
1940+
LOG_INFO << "[Stratum] dropping stale session " << session_id_
1941+
<< " (" << reason << ")";
1942+
// cancel_timers() closes the socket; the pending async_read then fails and
1943+
// runs the standard disconnect path (unregister worker, log). Idempotent.
1944+
cancel_timers();
1945+
}
1946+
17991947
// Parse multi-chain addresses from username string.
18001948
// Tries multiple separator formats for maximum miner compatibility:
18011949
// 1. Slash+colon: "LTC_ADDR/98:DOGE_ADDR" (explicit chain ID)

0 commit comments

Comments
 (0)