fix(rpc): detect mid-session disconnects and stop blocking the UI thread

The connection state machine never tore down on a lost connection: refresh-loop
RPC errors were swallowed, rpc_->isConnected() stayed true after a daemon
crash/restart/socket drop, and the UI showed stale balances with no reconnect.
Several operations also ran synchronous curl straight from ImGui handlers.

- Add handleLostConnection(): after N consecutive cycles where BOTH core RPCs
  fail (warmup excluded, so no reconnect loop), disconnect so update()'s
  reconnect branch re-enters tryConnect().
- Move banPeer/unbanPeer/clearBans and key export/import onto the worker thread
  (import requests a rescan that could freeze the UI for the curl timeout).
- Run the block-info dialog's two chained RPCs on the worker thread (+ guard the
  getblockhash result type).
- Detect daemon warmup via the JSON-RPC -28 code (new RpcError carrying the code;
  message text preserved so 401/warmup string-matching is unaffected), and widen
  CONNECTTIMEOUT to 10s for remote/TLS hosts (2s localhost).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:17:17 -05:00
parent 1bc7f5c8cd
commit 53a10e149d
5 changed files with 221 additions and 78 deletions

View File

@@ -574,6 +574,9 @@ private:
bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect
bool rescan_status_poll_in_progress_ = false; bool rescan_status_poll_in_progress_ = false;
bool opid_poll_in_progress_ = false; bool opid_poll_in_progress_ = false;
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
int consecutive_core_failures_ = 0;
// Pending z_sendmany operation tracking // Pending z_sendmany operation tracking
bool send_progress_active_ = false; bool send_progress_active_ = false;
@@ -692,6 +695,10 @@ private:
void tryConnect(); void tryConnect();
void onConnected(); void onConnected();
void onDisconnected(const std::string& reason); void onDisconnected(const std::string& reason);
// Tear down a connection that died mid-session (daemon crash / restart / dropped
// socket) so update()'s reconnect branch re-enters tryConnect(). Unlike onDisconnected
// alone, this also rpc_->disconnect()s so rpc_->isConnected() actually flips to false.
void handleLostConnection(const std::string& reason);
void applyDefaultBanlist(); void applyDefaultBanlist();
// Private methods - data refresh // Private methods - data refresh

View File

@@ -493,6 +493,12 @@ void App::onDisconnected(const std::string& reason)
last_tx_block_height_ = -1; last_tx_block_height_ = -1;
pending_opids_.clear(); pending_opids_.clear();
pending_send_info_.clear(); pending_send_info_.clear();
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
for (auto& entry : pending_send_callbacks_) {
if (entry.second) entry.second(false, reason);
}
pending_send_callbacks_.clear();
consecutive_core_failures_ = 0;
send_progress_active_ = false; send_progress_active_ = false;
send_submissions_in_flight_ = 0; send_submissions_in_flight_ = 0;
network_refresh_.resetJobs(); network_refresh_.resetJobs();
@@ -510,6 +516,15 @@ void App::onDisconnected(const std::string& reason)
} }
} }
void App::handleLostConnection(const std::string& reason)
{
DEBUG_LOGF("[Connection] %s — tearing down for reconnect\n", reason.c_str());
// Flip the main client's connected_ flag so update()'s else-branch re-enters
// tryConnect(). onDisconnected() alone only tears down the fast lane.
if (rpc_) rpc_->disconnect();
onDisconnected(reason);
}
// ============================================================================ // ============================================================================
// Data Refresh — Tab-Aware Prioritized System // Data Refresh — Tab-Aware Prioritized System
// //
@@ -1034,6 +1049,25 @@ void App::refreshCoreData()
try { try {
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr)); NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
applyPendingSendBalanceDeltas(true); applyPendingSendBalanceDeltas(true);
// Mid-session connection-loss detection. During normal operation, both core
// RPCs failing together means the daemon connection is dead (a busy daemon
// fails them individually, not both at once). Warmup is excluded — both fail
// with -28 there legitimately, and counting it would cause a reconnect loop.
constexpr int kCoreFailuresBeforeDisconnect = 3;
if (!state_.warming_up) {
if (!result.balanceOk && !result.blockchainOk) {
if (++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
state_.connected) {
consecutive_core_failures_ = 0;
handleLostConnection("Lost connection to daemon");
return; // state torn down — skip the rest of this callback
}
} else {
consecutive_core_failures_ = 0;
}
}
// Auto-shield transparent funds if enabled // Auto-shield transparent funds if enabled
if (result.balanceOk && settings_ && settings_->getAutoShield() && if (result.balanceOk && settings_ && settings_->getAutoShield() &&
state_.transparent_balance > 0.0001 && !state_.sync.syncing && state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
@@ -1573,28 +1607,58 @@ void App::stopPoolMining()
void App::banPeer(const std::string& ip, int duration_seconds) void App::banPeer(const std::string& ip, int duration_seconds)
{ {
if (!state_.connected || !rpc_) return; if (!state_.connected || !rpc_ || !worker_) return;
// Run on the worker thread — these are called straight from the Peers tab's ImGui
rpc_->setBan(ip, "add", [this](const json&) { // handlers, and rpc_->call() blocks on synchronous curl under curl_mutex_.
refreshPeerInfo(); worker_->post([this, ip, duration_seconds]() -> rpc::RPCWorker::MainCb {
}, nullptr, duration_seconds); std::string err;
try {
rpc::RPCClient::TraceScope trace("Peers / Ban");
rpc_->call("setban", {ip, "add", duration_seconds});
} catch (const std::exception& e) {
err = e.what();
}
return [this, err]() {
if (!err.empty()) ui::Notifications::instance().error("Ban failed: " + err);
else refreshPeerInfo();
};
});
} }
void App::unbanPeer(const std::string& ip) void App::unbanPeer(const std::string& ip)
{ {
if (!state_.connected || !rpc_) return; if (!state_.connected || !rpc_ || !worker_) return;
worker_->post([this, ip]() -> rpc::RPCWorker::MainCb {
rpc_->setBan(ip, "remove", [this](const json&) { std::string err;
refreshPeerInfo(); try {
rpc::RPCClient::TraceScope trace("Peers / Unban");
rpc_->call("setban", {ip, "remove"});
} catch (const std::exception& e) {
err = e.what();
}
return [this, err]() {
if (!err.empty()) ui::Notifications::instance().error("Unban failed: " + err);
else refreshPeerInfo();
};
}); });
} }
void App::clearBans() void App::clearBans()
{ {
if (!state_.connected || !rpc_) return; if (!state_.connected || !rpc_ || !worker_) return;
worker_->post([this]() -> rpc::RPCWorker::MainCb {
rpc_->clearBanned([this](const json&) { std::string err;
state_.banned_peers.clear(); try {
rpc::RPCClient::TraceScope trace("Peers / Clear bans");
rpc_->call("clearbanned", nlohmann::json::array());
} catch (const std::exception& e) {
err = e.what();
}
return [this, err]() {
if (!err.empty()) { ui::Notifications::instance().error("Clear bans failed: " + err); return; }
state_.banned_peers.clear();
refreshPeerInfo();
};
}); });
} }
@@ -1881,33 +1945,35 @@ void App::invalidateAddressValidationCache()
void App::exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback) void App::exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback)
{ {
if (!state_.connected || !rpc_) { if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(""); if (callback) callback("");
return; return;
} }
auto keyKind = services::WalletSecurityController::classifyAddress(address); const bool shielded = services::WalletSecurityController::classifyAddress(address)
if (keyKind == services::WalletSecurityController::KeyKind::Shielded) { == services::WalletSecurityController::KeyKind::Shielded;
// Z-address: use z_exportkey const char* method = shielded ? "z_exportkey" : "dumpprivkey";
rpc::RPCClient::TraceScope trace("Settings / Export private key"); // Run on the worker thread — z_exportkey/dumpprivkey block on synchronous curl and
rpc_->z_exportKey(address, [callback](const json& result) { // are invoked straight from the export dialog (UI thread).
if (callback) callback(result.get<std::string>()); worker_->post([this, method, address, callback]() -> rpc::RPCWorker::MainCb {
}, [callback](const std::string& error) { std::string key;
DEBUG_LOGF("Export z-key error: %s\n", error.c_str()); std::string err;
ui::Notifications::instance().error("Key export failed: " + error); try {
if (callback) callback(""); rpc::RPCClient::TraceScope trace("Settings / Export private key");
}); key = rpc_->call(method, {address}).get<std::string>();
} else { } catch (const std::exception& e) {
// T-address: use dumpprivkey err = e.what();
rpc::RPCClient::TraceScope trace("Settings / Export private key"); }
rpc_->dumpPrivKey(address, [callback](const json& result) { return [callback, key, err]() {
if (callback) callback(result.get<std::string>()); if (!err.empty()) {
}, [callback](const std::string& error) { DEBUG_LOGF("Export key error: %s\n", err.c_str());
DEBUG_LOGF("Export t-key error: %s\n", error.c_str()); ui::Notifications::instance().error("Key export failed: " + err);
ui::Notifications::instance().error("Key export failed: " + error); if (callback) callback("");
if (callback) callback(""); } else if (callback) {
}); callback(key);
} }
};
});
} }
void App::exportAllKeys(std::function<void(const std::string&)> callback) void App::exportAllKeys(std::function<void(const std::string&)> callback)
@@ -1961,34 +2027,36 @@ void App::exportAllKeys(std::function<void(const std::string&)> callback)
void App::importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback) void App::importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback)
{ {
if (!state_.connected || !rpc_) { if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(false, "Not connected"); if (callback) callback(false, "Not connected");
return; return;
} }
auto keyKind = services::WalletSecurityController::classifyPrivateKey(key); const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
if (keyKind == services::WalletSecurityController::KeyKind::Shielded) { // Run on the worker thread — import requests a full rescan (rescan=true), so the
rpc::RPCClient::TraceScope trace("Settings / Import private key"); // synchronous curl call can take many seconds; never block the UI thread on it.
rpc_->z_importKey(key, true, [this, callback](const json& result) { worker_->post([this, key, shielded, callback]() -> rpc::RPCWorker::MainCb {
std::string err;
try {
rpc::RPCClient::TraceScope trace("Settings / Import private key");
if (shielded) rpc_->call("z_importkey", {key, "yes"}); // rescan
else rpc_->call("importprivkey", {key, "", true}); // label "", rescan
} catch (const std::exception& e) {
err = e.what();
}
return [this, shielded, err, callback]() {
if (!err.empty()) {
if (callback) callback(false, err);
return;
}
invalidateAddressValidationCache(); invalidateAddressValidationCache();
refreshAddresses(); refreshAddresses();
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage( if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
services::WalletSecurityController::KeyKind::Shielded)); shielded ? services::WalletSecurityController::KeyKind::Shielded
}, [callback](const std::string& error) { : services::WalletSecurityController::KeyKind::Transparent));
if (callback) callback(false, error); };
}); });
} else {
rpc::RPCClient::TraceScope trace("Settings / Import private key");
rpc_->importPrivKey(key, true, [this, callback](const json& result) {
invalidateAddressValidationCache();
refreshAddresses();
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
services::WalletSecurityController::KeyKind::Transparent));
}, [callback](const std::string& error) {
if (callback) callback(false, error);
});
}
} }
void App::backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback) void App::backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback)

View File

@@ -6,6 +6,7 @@
// All calls are blocking; run on RPCWorker threads, never on main thread. // All calls are blocking; run on RPCWorker threads, never on main thread.
#include "rpc_client.h" #include "rpc_client.h"
#include "connection.h"
#include "../config/version.h" #include "../config/version.h"
#include "../util/base64.h" #include "../util/base64.h"
@@ -170,7 +171,10 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers); curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback); curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, 1L); // localhost fails fast if not listening // Localhost fails fast if nothing is listening; a remote/TLS daemon needs a larger
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
// Test connection with getinfo // Test connection with getinfo
try { try {
@@ -191,7 +195,12 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// it just hasn't finished initializing yet. Mark as connected+warmup // it just hasn't finished initializing yet. Mark as connected+warmup
// so the wallet can show the UI instead of a blocking overlay. // so the wallet can show the UI instead of a blocking overlay.
std::string msg = e.what(); std::string msg = e.what();
bool isWarmup = (msg.find("Loading") != std::string::npos || // Warmup is JSON-RPC error code -28 (RPC_IN_WARMUP) — the robust signal. Fall back
// to message substrings for any path that didn't carry the numeric code.
int code = 0;
if (const auto* re = dynamic_cast<const RpcError*>(&e)) code = re->code;
bool isWarmup = (code == -28) ||
(msg.find("Loading") != std::string::npos ||
msg.find("Verifying") != std::string::npos || msg.find("Verifying") != std::string::npos ||
msg.find("Activating") != std::string::npos || msg.find("Activating") != std::string::npos ||
msg.find("Rewinding") != std::string::npos || msg.find("Rewinding") != std::string::npos ||
@@ -281,23 +290,36 @@ json RPCClient::call(const std::string& method, const json& params)
// (insufficient funds, bad params, etc.) with a valid JSON body. // (insufficient funds, bad params, etc.) with a valid JSON body.
// Parse the body first to extract the real error message. // Parse the body first to extract the real error message.
if (http_code != 200) { if (http_code != 200) {
int errCode = 0;
try { try {
json response = json::parse(response_data); json response = json::parse(response_data);
if (response.contains("error") && !response["error"].is_null()) { if (response.contains("error") && response["error"].is_object()) {
std::string err_msg = response["error"]["message"].get<std::string>(); if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
throw std::runtime_error(err_msg); errCode = response["error"]["code"].get<int>();
if (response["error"].contains("message") && response["error"]["message"].is_string())
throw RpcError(errCode, response["error"]["message"].get<std::string>());
// message missing/non-string — keep the detail instead of a bare HTTP code
throw RpcError(errCode, "RPC error: " + response["error"].dump());
} }
} catch (const json::exception&) { } catch (const json::exception&) {
// Body wasn't valid JSON — fall through to generic HTTP error // Body wasn't valid JSON — fall through to generic HTTP error
} }
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code)); throw RpcError(errCode, "RPC error: HTTP " + std::to_string(http_code));
} }
json response = json::parse(response_data); json response = json::parse(response_data);
if (response.contains("error") && !response["error"].is_null()) { if (response.contains("error") && !response["error"].is_null()) {
std::string err_msg = response["error"]["message"].get<std::string>(); int errCode = 0;
throw std::runtime_error("RPC error: " + err_msg); std::string err_msg;
if (response["error"].is_object()) {
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
errCode = response["error"]["code"].get<int>();
if (response["error"].contains("message") && response["error"]["message"].is_string())
err_msg = response["error"]["message"].get<std::string>();
}
if (err_msg.empty()) err_msg = response["error"].dump();
throw RpcError(errCode, "RPC error: " + err_msg);
} }
return response["result"]; return response["result"];
@@ -340,20 +362,32 @@ json RPCClient::call(const std::string& method, const json& params, long timeout
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code); curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code);
if (http_code != 200) { if (http_code != 200) {
int errCode = 0;
try { try {
json response = json::parse(response_data); json response = json::parse(response_data);
if (response.contains("error") && !response["error"].is_null()) { if (response.contains("error") && response["error"].is_object()) {
std::string err_msg = response["error"]["message"].get<std::string>(); if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
throw std::runtime_error(err_msg); errCode = response["error"]["code"].get<int>();
if (response["error"].contains("message") && response["error"]["message"].is_string())
throw RpcError(errCode, response["error"]["message"].get<std::string>());
throw RpcError(errCode, "RPC error: " + response["error"].dump());
} }
} catch (const json::exception&) {} } catch (const json::exception&) {}
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code)); throw RpcError(errCode, "RPC error: HTTP " + std::to_string(http_code));
} }
json response = json::parse(response_data); json response = json::parse(response_data);
if (response.contains("error") && !response["error"].is_null()) { if (response.contains("error") && !response["error"].is_null()) {
std::string err_msg = response["error"]["message"].get<std::string>(); int errCode = 0;
throw std::runtime_error("RPC error: " + err_msg); std::string err_msg;
if (response["error"].is_object()) {
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
errCode = response["error"]["code"].get<int>();
if (response["error"].contains("message") && response["error"]["message"].is_string())
err_msg = response["error"]["message"].get<std::string>();
}
if (err_msg.empty()) err_msg = response["error"].dump();
throw RpcError(errCode, "RPC error: " + err_msg);
} }
return response["result"]; return response["result"];

View File

@@ -9,6 +9,7 @@
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <stdexcept>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
namespace dragonx { namespace dragonx {
@@ -18,6 +19,21 @@ using json = nlohmann::json;
using Callback = std::function<void(const json&)>; using Callback = std::function<void(const json&)>;
using ErrorCallback = std::function<void(const std::string&)>; using ErrorCallback = std::function<void(const std::string&)>;
/**
* @brief A JSON-RPC error carrying the daemon's numeric error code.
*
* what() preserves the exact human-readable message (so existing string matching
* still works); `code` exposes the JSON-RPC error code — notably -28 (RPC_IN_WARMUP)
* for a daemon still starting up. Derives from std::runtime_error, so every existing
* `catch (const std::exception&)` continues to handle it unchanged.
*/
class RpcError : public std::runtime_error {
public:
RpcError(int errorCode, const std::string& message)
: std::runtime_error(message), code(errorCode) {}
int code = 0;
};
/** /**
* @brief JSON-RPC client for dragonxd * @brief JSON-RPC client for dragonxd
* *

View File

@@ -5,6 +5,7 @@
#include "block_info_dialog.h" #include "block_info_dialog.h"
#include "../../app.h" #include "../../app.h"
#include "../../rpc/rpc_client.h" #include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../notifications.h" #include "../notifications.h"
#include "../schema/ui_schema.h" #include "../schema/ui_schema.h"
@@ -124,14 +125,31 @@ void BlockInfoDialog::render(App* app)
} }
if (material::StyledButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) { if (material::StyledButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
if (rpc && rpc->isConnected()) { if (rpc && rpc->isConnected() && app->worker()) {
s_loading = true; s_loading = true;
s_error.clear(); s_error.clear();
s_has_data = false; s_has_data = false;
s_pending_app = app; s_pending_app = app;
// Use getBlock(height) which uses UnifiedCallback // Run the two chained RPCs (getblockhash → getblock) on the worker thread;
rpc->getBlock(s_height, handleBlockResponseUnified); // doing them inline froze the UI for two round-trips. Guard the hash type.
int height = s_height;
app->worker()->post([rpc, height]() -> rpc::RPCWorker::MainCb {
json block;
std::string error;
try {
rpc::RPCClient::TraceScope trace("Explorer / Block info");
auto hashResult = rpc->call("getblockhash", {height});
if (!hashResult.is_string()) {
error = "unexpected getblockhash result";
} else {
block = rpc->call("getblock", {hashResult.get<std::string>()});
}
} catch (const std::exception& e) {
error = e.what();
}
return [block, error]() { handleBlockResponseUnified(block, error); };
});
} }
} }