@@ -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
387442StratumSession::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()
445504void 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
12891353void 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