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:
2026-10-02 09:49:55 -05:00
co-authored by Claude Opus 5.5
parent 0c821333c8
commit 0f25c67600
12 changed files with 501 additions and 24 deletions
+4 -1
View File
@@ -1,4 +1,5 @@
#include "network_refresh_service.h"
#include "rpc/rpc_busy_gate.h"
#include "../util/logger.h"
#include <algorithm>
@@ -324,7 +325,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
if (lc > 0 && b < lc - 2) behind = true;
}
if (includeBalance && !behind) {
// Don't follow a chain-info call that found the node busy (a reply timeout or a full queue) with two
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
if (includeBalance && !behind && !rpc::rpcKindMeansBusy(blockErrKind)) {
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
// (balanceRefreshDue()), keeping balance scans from starving block connection.
+1
View File
@@ -386,6 +386,7 @@ public:
void reset(Timer timer) { scheduler_.reset(timer); }
void markDue(Timer timer) { scheduler_.markDue(timer); }
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
void deferFor(Timer timer, float seconds) { scheduler_.deferFor(timer, seconds); }
float timer(Timer timer) const { return scheduler_.timer(timer); }
float interval(Timer timer) const { return scheduler_.interval(timer); }
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
+14
View File
@@ -47,10 +47,16 @@ void RefreshScheduler::tick(float deltaSeconds)
timers_.fast += delta;
timers_.txAge += delta;
timers_.opid += delta;
for (float& h : holds_) {
if (h > 0.0f) h = std::max(0.0f, h - delta);
}
}
bool RefreshScheduler::isDue(Timer timer) const
{
const float h = hold(timer);
if (h > 0.0f) return false; // held by deferFor()
if (h == 0.0f) return true; // the hold ran out: due now, whatever the interval
float timerInterval = interval(timer);
return timerInterval > 0.0f && timerRef(timer) >= timerInterval;
}
@@ -65,19 +71,27 @@ bool RefreshScheduler::consumeDue(Timer timer)
void RefreshScheduler::reset(Timer timer)
{
timerRef(timer) = 0.0f;
holdRef(timer) = -1.0f;
}
void RefreshScheduler::markDue(Timer timer)
{
holdRef(timer) = -1.0f; // an explicit "now" (user action) overrides a backoff
float timerInterval = interval(timer);
timerRef(timer) = timerInterval > 0.0f ? timerInterval : 0.0f;
}
void RefreshScheduler::setTimer(Timer timer, float seconds)
{
holdRef(timer) = -1.0f;
timerRef(timer) = std::max(0.0f, seconds);
}
void RefreshScheduler::deferFor(Timer timer, float seconds)
{
holdRef(timer) = std::max(0.0f, seconds);
}
float RefreshScheduler::timer(Timer timer) const
{
return timerRef(timer);
+12
View File
@@ -2,6 +2,8 @@
#include "ui/sidebar.h"
#include <array>
namespace dragonx {
namespace services {
@@ -55,6 +57,11 @@ public:
void reset(Timer timer);
void markDue(Timer timer);
void setTimer(Timer timer, float seconds);
// Hold a timer for exactly `seconds`, whatever the page's interval (which only measures elapsed
// time): not due until then, due as soon as it runs out. consumeDue/reset/markDue/setTimer end it.
// For retry backoff: the old "setTimer(interval - delay)" collapsed to an immediate retry wherever
// the page interval was shorter than the delay (every frame on Overview/Send).
void deferFor(Timer timer, float seconds);
float timer(Timer timer) const;
float interval(Timer timer) const;
@@ -79,9 +86,14 @@ private:
float& timerRef(Timer timer);
const float& timerRef(Timer timer) const;
static constexpr int kTimerCount = 8;
float& holdRef(Timer timer) { return holds_[static_cast<int>(timer)]; }
float hold(Timer timer) const { return holds_[static_cast<int>(timer)]; }
Intervals intervals_{kCoreDefault, kTransactionDefault, kAddressDefault, kPeerDefault};
Timers timers_;
// deferFor() holds: > 0 counting down (not due), 0 ran out (due), < 0 none.
std::array<float, kTimerCount> holds_{{-1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f}};
};
} // namespace services