feat(rpc): stop flooding a busy node's RPC queue; fix the reconnect storm
dragonxd serves RPC with 8 workers and a 16-deep queue. While a witness rebuild holds cs_main, every request needing it waits, and a request the client gave up on keeps its slot. The GUI kept polling and re-probing until all 24 slots were its own abandoned requests (even stop was then refused), treated the busy node as lost (tearing down the wallet state), and retried connect at frame rate on some pages. Busy gate (src/rpc/rpc_busy_gate.h), enforced in RPCClient::performCall for the shared rpc_ / fast_rpc_: - the first reply timeout or queue-full refusal marks the node busy; from then on nothing needing cs_main is sent (calls fail at once with the new RpcFailureKind::Deferred). stop, help and getrescaninfo are lock-free in dragonxd and always go through - one canary getinfo with no client timeout waits on its own client and thread; its answer clears the gate and triggers an immediate refresh (queue-full: retried after 2..30 s; no answer: back to normal handling) - the timer dispatch skips Core/Tx/Addresses/Peers/chat/mining/opid and connect attempts while busy - the 3-strike teardown counts only failures that put the connection in doubt, never busy ones; no balance scans follow a busy chain-info call Reconnect storm: retries were armed with setTimer(Core, kCoreDefault - 1), i.e. "4 s elapsed", which against Overview's 2 s interval meant every frame. RefreshScheduler::deferFor() is an absolute hold, and ConnectRetryBackoff picks the delay by failure kind (not listening 1/2/5 s, queue full 2..30 s, busy 30 s, else 4 s). Measured against the fake daemon: abandoned requests during a rebuild went from ~24 (every slot) to at most 2, with no teardown and recovery when the rebuild ends; a saturated queue sees about one canary per back-off step instead of ~190 requests/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+91
-15
@@ -264,6 +264,79 @@ void App::detectWalletDegraded()
|
||||
VERBOSE_LOGF("[recovery] Daemon opened wallet in DEGRADED mode — new-key derivation disabled\n");
|
||||
}
|
||||
|
||||
// One canary per busy period: a getinfo on its own client with NO client timeout, so it is the one request
|
||||
// we let wait in the node's queue instead of re-sending (each re-send would take another slot). Its answer
|
||||
// means the node serves cs_main requests again.
|
||||
enum CanaryState { kCanaryIdle = 0, kCanaryRunning, kCanaryAnswered, kCanaryRefused, kCanaryGone };
|
||||
|
||||
void App::abortRpcBusyCanary()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(canary_mutex_);
|
||||
if (canary_client_) canary_client_->requestAbort();
|
||||
}
|
||||
|
||||
void App::pumpRpcBusyCanary()
|
||||
{
|
||||
const int st = canary_state_.load();
|
||||
if (st >= kCanaryAnswered) {
|
||||
if (canary_thread_.joinable()) canary_thread_.join();
|
||||
canary_state_ = kCanaryIdle;
|
||||
if (st == kCanaryAnswered) {
|
||||
DEBUG_LOGF("[busy] the node answered again after %.0fs; resuming normal polling\n",
|
||||
rpc_busy_gate_->busySeconds());
|
||||
rpc_busy_gate_->clear();
|
||||
canary_backoff_.reset();
|
||||
connect_backoff_.reset();
|
||||
network_refresh_.markImmediateRefresh(); // also makes a pending connect attempt due at once
|
||||
} else if (st == kCanaryRefused) {
|
||||
const float wait = canary_backoff_.next(rpc::RpcFailureKind::QueueFull);
|
||||
canary_retry_at_ = std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(static_cast<int>(wait * 1000.0f));
|
||||
DEBUG_LOGF("[busy] canary refused (queue full); next one in %.0fs\n", wait);
|
||||
} else {
|
||||
// Nothing listening any more (or our own abort): there's no busy node to wait for. Normal
|
||||
// connect / lost-connection handling takes over.
|
||||
DEBUG_LOGF("[busy] canary got no answer; leaving busy mode\n");
|
||||
rpc_busy_gate_->clear();
|
||||
canary_backoff_.reset();
|
||||
}
|
||||
}
|
||||
if (!rpc_busy_gate_->busy() || canary_state_.load() != kCanaryIdle || shutting_down_) return;
|
||||
if (std::chrono::steady_clock::now() < canary_retry_at_) return;
|
||||
|
||||
const rpc::ConnectionConfig cfg = (!saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty())
|
||||
? saved_config_ : rpc::Connection::autoDetectConfig();
|
||||
DEBUG_LOGF("[busy] the node is too busy to answer (%d request(s) given up on): holding back polls; "
|
||||
"one canary getinfo waits for it\n", rpc_busy_gate_->abandoned());
|
||||
canary_state_ = kCanaryRunning;
|
||||
canary_thread_ = std::thread([this, cfg]() {
|
||||
rpc::RPCClient client; // deliberately ungated: this is the one request we allow
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(canary_mutex_);
|
||||
canary_client_ = &client;
|
||||
}
|
||||
int result = kCanaryGone;
|
||||
if (!shutting_down_ && client.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls)) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Busy node / Canary");
|
||||
client.call("getinfo", json::array(), 0L); // 0 = no client timeout
|
||||
result = kCanaryAnswered;
|
||||
} catch (const std::exception& e) {
|
||||
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
|
||||
if (kind == rpc::RpcFailureKind::AppError || kind == rpc::RpcFailureKind::Warmup)
|
||||
result = kCanaryAnswered; // a worker ran it: cs_main is free again
|
||||
else if (kind == rpc::RpcFailureKind::QueueFull)
|
||||
result = kCanaryRefused;
|
||||
}
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(canary_mutex_);
|
||||
canary_client_ = nullptr;
|
||||
}
|
||||
canary_state_ = result;
|
||||
});
|
||||
}
|
||||
|
||||
void App::tryConnect()
|
||||
{
|
||||
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
|
||||
@@ -324,8 +397,7 @@ void App::tryConnect()
|
||||
if (++daemon_wait_attempts_ == kDaemonWaitWarnAttempts) {
|
||||
ui::Notifications::instance().warning(TR("daemon_port_busy_warn"), 20.0f);
|
||||
}
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 4.0f);
|
||||
return;
|
||||
}
|
||||
daemon_wait_attempts_ = 0; // port is free — clear the bounded-wait counter
|
||||
@@ -338,8 +410,7 @@ void App::tryConnect()
|
||||
if (startEmbeddedDaemon()) {
|
||||
// Will retry connection after daemon starts
|
||||
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", connect_attempt);
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 1.0f);
|
||||
} else {
|
||||
// The daemon couldn't be started (binary not found, Sapling params missing, spawn
|
||||
// failure, …). Surface the actual reason instead of leaving the status stuck on
|
||||
@@ -356,8 +427,7 @@ void App::tryConnect()
|
||||
detail.empty() ? std::string(TR("sb_daemon_start_failed")) : detail, 30.0f);
|
||||
}
|
||||
// Keep retrying: a missing binary/params can be fixed without a restart.
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 5.0f);
|
||||
}
|
||||
} else if (!isUsingEmbeddedDaemon()) {
|
||||
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled (using external). No config found at %s\n",
|
||||
@@ -451,6 +521,7 @@ void App::tryConnect()
|
||||
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
||||
connectKind]() {
|
||||
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
||||
if (connected) connect_backoff_.reset();
|
||||
if (connected) {
|
||||
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
|
||||
warmingUp ? " (daemon warming up)" : "");
|
||||
@@ -525,10 +596,10 @@ void App::tryConnect()
|
||||
// daemon's own console output) instead of a bare technical error.
|
||||
const bool reachableButBusy = rpc::isRpcTimeout(connectKind);
|
||||
applyDaemonInitStatus(reachableButBusy);
|
||||
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n",
|
||||
attempt, connectErr.c_str());
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
const float retryIn = connect_backoff_.next(connectKind);
|
||||
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, retry in %.0fs\n",
|
||||
attempt, connectErr.c_str(), retryIn);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, retryIn);
|
||||
} else if (externalDetected) {
|
||||
state_.connected = false;
|
||||
// An external daemon is on the RPC port but not answering. A timeout means it's
|
||||
@@ -542,10 +613,10 @@ void App::tryConnect()
|
||||
} else {
|
||||
connection_status_ = TR("sb_connecting_external");
|
||||
}
|
||||
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), will retry...\n",
|
||||
attempt, connectErr.c_str());
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
const float retryIn = connect_backoff_.next(connectKind);
|
||||
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), retry in %.0fs\n",
|
||||
attempt, connectErr.c_str(), retryIn);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, retryIn);
|
||||
} else {
|
||||
onDisconnected("Connection failed");
|
||||
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
|
||||
@@ -692,6 +763,7 @@ void App::onConnected()
|
||||
// behind the main refresh batch.
|
||||
if (!fast_rpc_) {
|
||||
fast_rpc_ = std::make_unique<rpc::RPCClient>();
|
||||
fast_rpc_->setBusyGate(rpc_busy_gate_);
|
||||
}
|
||||
if (!fast_worker_) {
|
||||
fast_worker_ = std::make_unique<rpc::RPCWorker>();
|
||||
@@ -1800,7 +1872,11 @@ void App::refreshCoreData()
|
||||
constexpr int kCoreFailuresBeforeDisconnect = 3;
|
||||
if (!state_.warming_up) {
|
||||
if (!result.balanceOk && !result.blockchainOk) {
|
||||
if (++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
|
||||
// Only failures that put the connection itself in doubt count. A node that is merely
|
||||
// busy (reply timeouts, a full queue, calls held back) is still there: tearing down
|
||||
// then wiped the wallet state and restarted the connect probes that fill its queue.
|
||||
if (rpc::rpcKindMeansLost(result.blockchainErrKind) &&
|
||||
++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
|
||||
state_.connected) {
|
||||
consecutive_core_failures_ = 0;
|
||||
handleLostConnection("Lost connection to daemon");
|
||||
|
||||
Reference in New Issue
Block a user