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

@@ -493,6 +493,12 @@ void App::onDisconnected(const std::string& reason)
last_tx_block_height_ = -1;
pending_opids_.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_submissions_in_flight_ = 0;
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
//
@@ -1034,6 +1049,25 @@ void App::refreshCoreData()
try {
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
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
if (result.balanceOk && settings_ && settings_->getAutoShield() &&
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)
{
if (!state_.connected || !rpc_) return;
rpc_->setBan(ip, "add", [this](const json&) {
refreshPeerInfo();
}, nullptr, duration_seconds);
if (!state_.connected || !rpc_ || !worker_) return;
// Run on the worker thread — these are called straight from the Peers tab's ImGui
// handlers, and rpc_->call() blocks on synchronous curl under curl_mutex_.
worker_->post([this, ip, duration_seconds]() -> rpc::RPCWorker::MainCb {
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)
{
if (!state_.connected || !rpc_) return;
rpc_->setBan(ip, "remove", [this](const json&) {
refreshPeerInfo();
if (!state_.connected || !rpc_ || !worker_) return;
worker_->post([this, ip]() -> rpc::RPCWorker::MainCb {
std::string err;
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()
{
if (!state_.connected || !rpc_) return;
rpc_->clearBanned([this](const json&) {
state_.banned_peers.clear();
if (!state_.connected || !rpc_ || !worker_) return;
worker_->post([this]() -> rpc::RPCWorker::MainCb {
std::string err;
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)
{
if (!state_.connected || !rpc_) {
if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback("");
return;
}
auto keyKind = services::WalletSecurityController::classifyAddress(address);
if (keyKind == services::WalletSecurityController::KeyKind::Shielded) {
// Z-address: use z_exportkey
rpc::RPCClient::TraceScope trace("Settings / Export private key");
rpc_->z_exportKey(address, [callback](const json& result) {
if (callback) callback(result.get<std::string>());
}, [callback](const std::string& error) {
DEBUG_LOGF("Export z-key error: %s\n", error.c_str());
ui::Notifications::instance().error("Key export failed: " + error);
if (callback) callback("");
});
} else {
// T-address: use dumpprivkey
rpc::RPCClient::TraceScope trace("Settings / Export private key");
rpc_->dumpPrivKey(address, [callback](const json& result) {
if (callback) callback(result.get<std::string>());
}, [callback](const std::string& error) {
DEBUG_LOGF("Export t-key error: %s\n", error.c_str());
ui::Notifications::instance().error("Key export failed: " + error);
if (callback) callback("");
});
}
const bool shielded = services::WalletSecurityController::classifyAddress(address)
== services::WalletSecurityController::KeyKind::Shielded;
const char* method = shielded ? "z_exportkey" : "dumpprivkey";
// Run on the worker thread — z_exportkey/dumpprivkey block on synchronous curl and
// are invoked straight from the export dialog (UI thread).
worker_->post([this, method, address, callback]() -> rpc::RPCWorker::MainCb {
std::string key;
std::string err;
try {
rpc::RPCClient::TraceScope trace("Settings / Export private key");
key = rpc_->call(method, {address}).get<std::string>();
} catch (const std::exception& e) {
err = e.what();
}
return [callback, key, err]() {
if (!err.empty()) {
DEBUG_LOGF("Export key error: %s\n", err.c_str());
ui::Notifications::instance().error("Key export failed: " + err);
if (callback) callback("");
} else if (callback) {
callback(key);
}
};
});
}
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)
{
if (!state_.connected || !rpc_) {
if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(false, "Not connected");
return;
}
auto keyKind = services::WalletSecurityController::classifyPrivateKey(key);
if (keyKind == services::WalletSecurityController::KeyKind::Shielded) {
rpc::RPCClient::TraceScope trace("Settings / Import private key");
rpc_->z_importKey(key, true, [this, callback](const json& result) {
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
// Run on the worker thread — import requests a full rescan (rescan=true), so the
// synchronous curl call can take many seconds; never block the UI thread on it.
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();
refreshAddresses();
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
services::WalletSecurityController::KeyKind::Shielded));
}, [callback](const std::string& error) {
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);
});
}
shielded ? services::WalletSecurityController::KeyKind::Shielded
: services::WalletSecurityController::KeyKind::Transparent));
};
});
}
void App::backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback)