fix(rpc): run rescans and key imports without the 30 s timeout
A rescan or key import holds the node's cs_main for the whole wallet scan. On the shared clients it hit the 30 s curl timeout, reported a failure that wasn't one, abandoned a daemon RPC slot and flipped the app into "node busy". - One long wallet RPC at a time (rescan, importprivkey, importaddress, importwallet, z_importkey, z_importviewingkey, z_importwallet) runs on its own ungated client with no client timeout (rpc/long_call_lane.h). It can be aborted from any thread, and an abort is never lost, even one that lands before the client opens. - The GUI rescan, key import and sweep use it. The sweep's fund-moving body is unchanged; only the client it calls differs. runtimeRescan() returns bool, and a refused post-bootstrap reconcile stays armed. - The console routes rescan and the import commands through the lane; its input stays usable meanwhile, and a refusal is printed like any other result. RPCClient gains callRaw(.., timeoutSec). - Restart, wallet switch, delete-chain, bootstrap (now also from Settings) and the other node-stopping flows are refused while it runs, with a message saying a key import or rescan is still running; quit asks for confirmation. The lane is aborted at quit. - A second import or rescan is refused before the key is copied anywhere. An import cut off after the request reached the node (a lost reply, or a proxy's 502/504 while the node still imports) says the key may already be in the wallet and to run Settings -> Rescan. Only the node's own HTTP-layer refusals (401, 503, work queue full, unhandled request) say that the key was not imported. A pure classifyLongCallCutOff() makes that choice. - New strings in all 8 languages; console results stay English. Tests: the long-method list and the console's routing; one lane at a time; an abort lands whether it comes before the client opens or during the call; a call with no client timeout outlasts a 3 s scan, where a 1 s gated client times out and marks the node busy; classifyLongCallCutOff() for every failure kind with and without a sent request, and from raw HTTP replies (502, 504, a plain 500 -> cut off; 401, 503, queue full, unhandled -> refused). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+28
-7
@@ -1513,7 +1513,7 @@ void App::update()
|
||||
std::chrono::steady_clock::now() >= post_bootstrap_probe_retry_at_ &&
|
||||
rpcConnected && state_.connected && !rpcBusy &&
|
||||
!state_.warming_up && !state_.isLocked() && !state_.sync.rescanning &&
|
||||
!runtime_rescan_active_ && !bootstrap_downloading_ &&
|
||||
!runtime_rescan_active_ && !long_lane_.active() && !bootstrap_downloading_ &&
|
||||
state_.sync.blocks > 1) { // wait until the tip is known so the probe has a real range
|
||||
if (post_bootstrap_probe_attempts_ == 0)
|
||||
ui::Notifications::instance().info(TR("appx_bootstrap_complete_reconciling"));
|
||||
@@ -1527,7 +1527,12 @@ void App::update()
|
||||
post_bootstrap_rescan_pending_ = false;
|
||||
post_bootstrap_probe_attempts_ = 0;
|
||||
if (!fullHistory) {
|
||||
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
|
||||
// bootstrapped/pruned: rescan from the snapshot base. Refused (a key import or rescan is still
|
||||
// running, the node is busy): keep the reconcile armed and try again in 30 s.
|
||||
if (!runtimeRescan(lowest)) {
|
||||
post_bootstrap_rescan_pending_ = true;
|
||||
post_bootstrap_probe_retry_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
}
|
||||
} else {
|
||||
rescanBlockchain(); // full-history node: the -rescan restart works
|
||||
}
|
||||
@@ -6456,7 +6461,8 @@ void App::beginShutdown()
|
||||
// with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_).
|
||||
if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) {
|
||||
DEBUG_LOGF("beginShutdown: the node is %s — asking before stopping it\n",
|
||||
node_rebuilding_ ? "rebuilding witnesses" : "too busy to answer RPC");
|
||||
node_rebuilding_ ? "rebuilding witnesses"
|
||||
: long_lane_.active() ? "running a key import or rescan" : "too busy to answer RPC");
|
||||
pending_shutdown_confirm_ = true;
|
||||
// The modal only renders in a visible frame: a quit from the taskbar while minimized (or behind
|
||||
// other windows) would otherwise wait on a prompt nobody can see.
|
||||
@@ -6490,6 +6496,7 @@ void App::beginShutdown()
|
||||
if (rpc_) rpc_->requestAbort();
|
||||
if (fast_rpc_) fast_rpc_->requestAbort();
|
||||
abortRpcBusyCanary();
|
||||
long_lane_.abort(); // a key import / rescan waiting with no client timeout; the node's own stop ends its scan
|
||||
if (worker_) {
|
||||
worker_->requestStop();
|
||||
}
|
||||
@@ -6811,12 +6818,19 @@ bool App::nodeBusyForStop() const
|
||||
// external node's busyness shows only as RPC that has stopped answering.
|
||||
daemon::DaemonBusyTracker ours;
|
||||
if (busyTrackerIsLive()) ours = daemon_busy_;
|
||||
return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive());
|
||||
// A key import or rescan on the long-call lane (C61) counts too: stopping the node would cut it off, and
|
||||
// aborting the wallet's wait would not stop the node's scan, so the stop would wait for it anyway.
|
||||
return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive() || long_lane_.active());
|
||||
}
|
||||
|
||||
bool App::refuseIfNodeBusy()
|
||||
{
|
||||
if (!nodeBusyForStop()) return false;
|
||||
if (long_lane_.active()) {
|
||||
DEBUG_LOGF("[App] refused: a key import or rescan is still running — stopping the node now would cut it off\n");
|
||||
ui::Notifications::instance().warning(TR("appx_wallet_op_running"), 10.0f);
|
||||
return true;
|
||||
}
|
||||
DEBUG_LOGF("[App] refused: the node is busy (%s) — stopping it now would make it start over\n",
|
||||
daemon_busy_.rebuilding() ? "rebuilding witnesses" : "not answering RPC");
|
||||
ui::Notifications::instance().warning(TR("appx_node_busy_try_later"), 10.0f);
|
||||
@@ -6847,13 +6861,18 @@ void App::renderDaemonStopConfirm()
|
||||
elapsed = formatBusyDuration(std::chrono::duration<double>(
|
||||
std::chrono::steady_clock::now() - rpc_busy_since_).count());
|
||||
const bool rebuilding = busy.rebuilding;
|
||||
const bool walletOp = !rebuilding && long_lane_.active(); // a key import or rescan (C61)
|
||||
if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1());
|
||||
ImGui::TextUnformatted(TR(rebuilding ? "stopconfirm_rebuild_title" : "stopconfirm_busy_title"));
|
||||
ImGui::TextUnformatted(TR(rebuilding ? "stopconfirm_rebuild_title"
|
||||
: walletOp ? "stopconfirm_walletop_title" : "stopconfirm_busy_title"));
|
||||
if (Type().subtitle1()) ImGui::PopFont();
|
||||
ImGui::Spacing();
|
||||
char body[1024];
|
||||
snprintf(body, sizeof(body), TR(rebuilding ? "stopconfirm_rebuild_body" : "stopconfirm_busy_body"),
|
||||
elapsed.empty() ? "0:00" : elapsed.c_str());
|
||||
if (walletOp)
|
||||
snprintf(body, sizeof(body), "%s", TR("stopconfirm_walletop_body"));
|
||||
else
|
||||
snprintf(body, sizeof(body), TR(rebuilding ? "stopconfirm_rebuild_body" : "stopconfirm_busy_body"),
|
||||
elapsed.empty() ? "0:00" : elapsed.c_str());
|
||||
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f);
|
||||
ImGui::TextUnformatted(body);
|
||||
ImGui::PopTextWrapPos();
|
||||
@@ -7627,6 +7646,7 @@ void App::shutdown()
|
||||
DEBUG_LOGF("Synchronous shutdown fallback...\n");
|
||||
http_cancel_.store(true, std::memory_order_relaxed); // queued / in-flight market GETs end at once
|
||||
async_tasks_.cancelAll();
|
||||
long_lane_.abort(); // else joinAll() waits out a key import / rescan (no client timeout)
|
||||
async_tasks_.joinAll();
|
||||
if (rpc_) rpc_->requestAbort(); // unblock any in-flight curl before joining
|
||||
if (fast_rpc_) fast_rpc_->requestAbort();
|
||||
@@ -7659,6 +7679,7 @@ void App::shutdown()
|
||||
if (shutdown_thread_.joinable()) {
|
||||
shutdown_thread_.join();
|
||||
}
|
||||
long_lane_.abort(); // beginShutdown() already did; harmless again, and joinAll() must never wait out a scan
|
||||
async_tasks_.joinAll();
|
||||
// Join the RPC worker thread (was signaled in beginShutdown via requestStop)
|
||||
if (worker_) {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include "daemon/stop_delivery.h"
|
||||
#include "daemon/daemon_busy_detect.h"
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "rpc/long_call_lane.h"
|
||||
#include "services/history_scan_policy.h"
|
||||
#include "services/connect_scan_gate.h"
|
||||
#include "services/scan_policy.h"
|
||||
@@ -249,6 +250,15 @@ public:
|
||||
// For an operation that would stop the node (restart, rescan, switch, reinstall, bootstrap, ...): when
|
||||
// nodeBusyForStop(), tell the user to try again later and return true; the caller then does nothing.
|
||||
bool refuseIfNodeBusy();
|
||||
// Long wallet RPCs (rescan, key imports; C61) run one at a time on long_lane_. Main thread. Decide a refusal
|
||||
// BEFORE copying a key into a closure: true when one can't start now, with the reason in English in *why
|
||||
// (the console's text) and as an i18n key in *trKey (the GUI's toast / status line).
|
||||
bool longWalletOpRefused(std::string* why, const char** trKey = nullptr) const;
|
||||
// Main thread. Runs body on the lane's own ungated client (no client timeout, aborted at quit) on an async
|
||||
// task; body must catch everything and returns the continuation, which runs on the main thread once the lane
|
||||
// is free again. false (body never runs) when the lane is already taken.
|
||||
using LongWalletBody = std::function<std::function<void()>(rpc::RPCClient&)>;
|
||||
bool runLongWalletTask(LongWalletBody body);
|
||||
// Record how an RPC to the node just went, for rpcBusyWhileAlive(): busy kinds (ReplyTimeout /
|
||||
// QueueFull) start the clock, an answer stops it, anything else leaves it alone.
|
||||
void noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind);
|
||||
@@ -651,7 +661,8 @@ public:
|
||||
// Runtime rescanblockchain RPC starting at a snapshot-available height. Unlike the
|
||||
// -rescan restart, this works on bootstrapped/pruned nodes (which lack pre-snapshot
|
||||
// block data), reconciling the wallet's stale spent-state without a daemon restart.
|
||||
void runtimeRescan(int startHeight);
|
||||
// Runs on the long-call lane; false when it did not start (refused, not connected, one already running).
|
||||
bool runtimeRescan(int startHeight);
|
||||
// Probe for the lowest block height the node still has on disk, on worker_ (never on the UI thread).
|
||||
// cb(ok, lowestHeight, fullHistory) runs on the UI thread: from worker_->drainResults(), or synchronously
|
||||
// when there is nothing to probe. fullHistory==true when block 1 is readable (a normal, non-bootstrapped
|
||||
@@ -1212,6 +1223,10 @@ private:
|
||||
void startStartupFilesProvision();
|
||||
void pollStartupFiles(); // UI thread, every update()
|
||||
void renderStartupFilesChip(); // UI thread, top of render()
|
||||
// The long wallet RPC lane (C61). Its task runs on async_tasks_, so it is declared BEFORE async_tasks_ and
|
||||
// outlives the task threads. Aborted at quit (beginShutdown / shutdown); never to make way for another flow:
|
||||
// those are refused while it is active (nodeBusyForStop), because an aborted client does not stop the scan.
|
||||
rpc::LongCallLane long_lane_;
|
||||
util::AsyncTaskManager async_tasks_;
|
||||
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
|
||||
|
||||
|
||||
+139
-17
@@ -337,6 +337,57 @@ void App::pumpRpcBusyCanary()
|
||||
});
|
||||
}
|
||||
|
||||
bool App::longWalletOpRefused(std::string* why, const char** trKey) const
|
||||
{
|
||||
// The English texts equal the i18n source strings of the keys; the console shows them untranslated.
|
||||
const char* key = nullptr;
|
||||
const char* english = nullptr;
|
||||
if (shutting_down_ || !worker_) {
|
||||
key = "appx_wallet_op_shutting_down";
|
||||
english = "The wallet is shutting down.";
|
||||
} else if (long_lane_.active()) {
|
||||
key = "appx_wallet_op_running";
|
||||
english = "A key import or rescan is still running. Try again when it finishes.";
|
||||
} else if (rpc_busy_gate_ && rpc_busy_gate_->busy()) {
|
||||
key = "appx_wallet_op_node_busy";
|
||||
english = "The node is busy and not answering. Try the import or rescan again when it finishes.";
|
||||
}
|
||||
if (!key) return false;
|
||||
if (why) *why = english;
|
||||
if (trKey) *trKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool App::runLongWalletTask(LongWalletBody body)
|
||||
{
|
||||
if (!body || !long_lane_.tryBegin()) return false;
|
||||
const rpc::ConnectionConfig cfg = (!saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty())
|
||||
? saved_config_ : rpc::Connection::autoDetectConfig(); // as the canary
|
||||
if (shutting_down_) long_lane_.abort();
|
||||
// One task name plus tryBegin()'s single flight: submit()'s join(name) only ever waits on the previous task's
|
||||
// last few instructions (its continuation, which frees the lane, is posted at its very end).
|
||||
async_tasks_.submit("Long wallet RPC", [this, cfg, body = std::move(body)](const util::AsyncTaskManager::Token& tok) mutable {
|
||||
rpc::RPCClient client; // ungated on purpose: it never trips or waits on the busy gate
|
||||
std::function<void()> cont;
|
||||
if (tok.cancelled()) long_lane_.abort();
|
||||
try {
|
||||
long_lane_.run(client, cfg, [&](rpc::RPCClient& c) { cont = body(c); });
|
||||
} catch (...) {
|
||||
// The bodies catch everything themselves; this only keeps the lane from staying taken.
|
||||
DEBUG_LOGF("[LongRPC] the call body threw\n");
|
||||
}
|
||||
body = nullptr; // destroy the closure (and its scrubbed key copy) on this thread
|
||||
if (tok.cancelled() || !worker_) { long_lane_.end(); return; } // quitting: nobody to tell
|
||||
worker_->post([this, cont = std::move(cont)]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, cont]() {
|
||||
long_lane_.end();
|
||||
if (cont) cont();
|
||||
};
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void App::tryConnect()
|
||||
{
|
||||
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
|
||||
@@ -5329,14 +5380,26 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
||||
"mistyped characters, and that this is a DragonX key (not another coin).", "");
|
||||
return;
|
||||
}
|
||||
// One long wallet RPC at a time (C61): refuse before the key is copied into a closure.
|
||||
{
|
||||
std::string why;
|
||||
const char* trKey = nullptr;
|
||||
if (longWalletOpRefused(&why, &trKey)) {
|
||||
DEBUG_LOGF("[App] key import refused: %s\n", why.c_str());
|
||||
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
||||
if (callback) callback(false, TR(trKey), "");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bool viewing = services::WalletSecurityController::isViewingKey(key);
|
||||
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, viewing, shielded, startHeight, callback]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// Run on the long-call lane — import requests a full rescan (rescan=true), so the call can take
|
||||
// minutes to hours: no client timeout, and never on the UI thread or a shared client.
|
||||
const bool started = runLongWalletTask([this, key, viewing, shielded, startHeight, callback](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string err, addr;
|
||||
rpc::LongCallCutOff cutOff = rpc::LongCallCutOff::None; // C61: how a transport failure ended the call
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Import key");
|
||||
std::string method;
|
||||
@@ -5346,7 +5409,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
||||
else { method = "importprivkey"; params = {key, "", true}; }
|
||||
// A start height (shielded RPCs only) rescans from that block instead of genesis.
|
||||
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
|
||||
nlohmann::json r = rpc_->call(method, params);
|
||||
nlohmann::json r = c.call(method, params, 0L); // 0 = no client timeout (the scan can take hours)
|
||||
// Scrub the key out of the request params (the json holds its own copy of it).
|
||||
if (params.is_array() && !params.empty() && params[0].is_string()) {
|
||||
std::string& pk = params[0].get_ref<std::string&>();
|
||||
@@ -5359,6 +5422,10 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
||||
addr = r.get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
err = e.what();
|
||||
// Cut off after the key may have reached the node: that is not a rejected key (D6 wording below).
|
||||
// Only the node's own HTTP-layer refusals say the import never ran.
|
||||
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e))
|
||||
cutOff = rpc::classifyLongCallCutOff(t->kind, t->requestSent);
|
||||
} catch (...) {
|
||||
// Non-std throw must not escape into the worker — the main-thread callback
|
||||
// below would never run, leaving a stuck "Importing…" spinner.
|
||||
@@ -5374,9 +5441,18 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
||||
err += " — check the key is for DragonX (not another coin or network) and has no missing "
|
||||
"or altered characters.";
|
||||
}
|
||||
return [this, err, addr, callback]() {
|
||||
return [this, err, addr, callback, cutOff]() {
|
||||
if (!err.empty()) {
|
||||
if (callback) callback(false, err, "");
|
||||
// The node may still be scanning, and its z_importkey default ("whenkeyisnew") won't rescan a key
|
||||
// it already has: say how to make the funds appear.
|
||||
std::string msg = err;
|
||||
switch (cutOff) {
|
||||
case rpc::LongCallCutOff::Cancelled: msg = TR("appx_import_cancelled"); break;
|
||||
case rpc::LongCallCutOff::Interrupted: msg = err + " — " + TR("appx_import_interrupted"); break;
|
||||
case rpc::LongCallCutOff::Refused: msg = err + " — " + TR("appx_import_refused"); break;
|
||||
case rpc::LongCallCutOff::None: break;
|
||||
}
|
||||
if (callback) callback(false, msg, "");
|
||||
return;
|
||||
}
|
||||
invalidateAddressValidationCache();
|
||||
@@ -5384,6 +5460,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
||||
if (callback) callback(true, "", addr);
|
||||
};
|
||||
});
|
||||
if (!started && callback) callback(false, TR("appx_wallet_op_running"), "");
|
||||
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
||||
}
|
||||
|
||||
@@ -5413,6 +5490,18 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
sweep_step_ = SweepStep::Error;
|
||||
return;
|
||||
}
|
||||
// One long wallet RPC at a time (C61): refuse before Running and before the key is copied into a closure.
|
||||
{
|
||||
std::string why;
|
||||
const char* trKey = nullptr;
|
||||
if (longWalletOpRefused(&why, &trKey)) {
|
||||
DEBUG_LOGF("[App] sweep refused: %s\n", why.c_str());
|
||||
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
||||
sweep_step_ = SweepStep::Error;
|
||||
sweep_status_ = TR(trKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sweep_step_ = SweepStep::Running;
|
||||
sweep_status_ = "Importing key & rescanning…";
|
||||
@@ -5422,7 +5511,9 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
|
||||
== services::WalletSecurityController::KeyKind::Shielded;
|
||||
const double fee = DRAGONX_DEFAULT_FEE;
|
||||
worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// The body below is today's worker body, unchanged except that it calls the lane's client c (the import
|
||||
// with no client timeout) instead of rpc_: a fund-moving sequence, not split or reordered (C61 D3).
|
||||
const bool started = runLongWalletTask([this, key, startHeight, destMode, destExisting, shielded, fee](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string err, dest, sourceAddr, amountStr;
|
||||
double amount = 0.0;
|
||||
try {
|
||||
@@ -5434,7 +5525,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
bool preZok = false;
|
||||
auto listZ = [&](std::vector<std::string>& out) {
|
||||
out.clear();
|
||||
auto la = rpc_->call("z_listaddresses");
|
||||
auto la = c.call("z_listaddresses");
|
||||
if (la.is_array()) for (auto& a : la) if (a.is_string()) out.push_back(a.get<std::string>());
|
||||
};
|
||||
if (shielded) { try { listZ(preZ); preZok = true; } catch (...) { preZok = false; } }
|
||||
@@ -5445,7 +5536,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
if (shielded) { method = "z_importkey"; params = {key, "yes"}; }
|
||||
else { method = "importprivkey"; params = {key, "", true}; }
|
||||
if (startHeight > 0 && shielded) params.push_back(startHeight);
|
||||
nlohmann::json r = rpc_->call(method, params);
|
||||
nlohmann::json r = c.call(method, params, 0L);
|
||||
// Scrub the key out of the request params (the json holds its own copy of it).
|
||||
if (params.is_array() && !params.empty() && params[0].is_string()) {
|
||||
std::string& pk = params[0].get_ref<std::string&>();
|
||||
@@ -5476,7 +5567,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
||||
auto balAt = [&](int minconf) -> double {
|
||||
try {
|
||||
nlohmann::json b = rpc_->call("z_getbalance", {sourceAddr, minconf});
|
||||
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
||||
return b.is_number() ? b.get<double>()
|
||||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||||
} catch (...) { return 0.0; }
|
||||
@@ -5488,7 +5579,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
if (bal <= fee) throw std::runtime_error("__DUST__");
|
||||
|
||||
// 4. Resolve the destination — a fresh shielded address by default, else the picked one.
|
||||
if (destMode == 0) dest = rpc_->call("z_getnewaddress").get<std::string>();
|
||||
if (destMode == 0) dest = c.call("z_getnewaddress").get<std::string>();
|
||||
else dest = destExisting;
|
||||
if (dest.empty()) throw std::runtime_error("No destination address for the sweep.");
|
||||
|
||||
@@ -5547,6 +5638,10 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
});
|
||||
};
|
||||
});
|
||||
if (!started) {
|
||||
sweep_step_ = SweepStep::Error;
|
||||
sweep_status_ = TR("appx_wallet_op_running");
|
||||
}
|
||||
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
||||
}
|
||||
|
||||
@@ -7091,18 +7186,28 @@ void App::detectLowestAvailableBlockHeight(std::function<void(bool, int, bool)>
|
||||
});
|
||||
}
|
||||
|
||||
void App::runtimeRescan(int startHeight)
|
||||
bool App::runtimeRescan(int startHeight)
|
||||
{
|
||||
if (!supportsFullNodeLifecycleActions()) {
|
||||
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!state_.connected || !rpc_ || !rpc_->isConnected() || !worker_) {
|
||||
ui::Notifications::instance().warning("Not connected to daemon");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (runtime_rescan_active_) return;
|
||||
if (runtime_rescan_active_) return false;
|
||||
if (startHeight < 0) startHeight = 0;
|
||||
// One long wallet RPC at a time (C61).
|
||||
{
|
||||
std::string why;
|
||||
const char* trKey = nullptr;
|
||||
if (longWalletOpRefused(&why, &trKey)) {
|
||||
DEBUG_LOGF("[App] runtime rescan refused: %s\n", why.c_str());
|
||||
ui::Notifications::instance().warning(TR(trKey));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[App] Starting runtime rescanblockchain from height %d\n", startHeight);
|
||||
|
||||
@@ -7110,6 +7215,11 @@ void App::runtimeRescan(int startHeight)
|
||||
// completion via this RPC's callback; rescan_confirmed_active_ keeps the -rescan-style pollers
|
||||
// from misreading state, and runtime_rescan_active_ suppresses the per-second pollers that
|
||||
// would otherwise pile up behind the held lock.
|
||||
const bool prevRescanning = state_.sync.rescanning;
|
||||
const bool prevConfirmed = rescan_confirmed_active_;
|
||||
const bool prevUserInitiated = user_initiated_rescan_.load();
|
||||
const float prevProgress = state_.sync.rescan_progress;
|
||||
const std::string prevStatus = state_.sync.rescan_status;
|
||||
runtime_rescan_active_ = true;
|
||||
state_.sync.rescanning = true;
|
||||
rescan_confirmed_active_ = true;
|
||||
@@ -7123,12 +7233,12 @@ void App::runtimeRescan(int startHeight)
|
||||
ui::Notifications::instance().info("Rescanning blockchain from block " + std::to_string(startHeight) +
|
||||
" — this can take a while.");
|
||||
|
||||
worker_->post([this, startHeight]() -> rpc::RPCWorker::MainCb {
|
||||
const bool started = runLongWalletTask([this, startHeight](rpc::RPCClient& c) -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string err;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Runtime rescan");
|
||||
rpc_->call("rescan", {startHeight}); // hush "rescan <height>"; blocks until the scan finishes
|
||||
c.call("rescan", {startHeight}, 0L); // hush "rescan <height>"; blocks until the scan finishes (no client timeout)
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
err = e.what();
|
||||
@@ -7150,6 +7260,18 @@ void App::runtimeRescan(int startHeight)
|
||||
}
|
||||
};
|
||||
});
|
||||
if (!started) {
|
||||
// The lane was taken after all: undo the state set up above.
|
||||
runtime_rescan_active_ = false;
|
||||
state_.sync.rescanning = prevRescanning;
|
||||
rescan_confirmed_active_ = prevConfirmed;
|
||||
user_initiated_rescan_ = prevUserInitiated;
|
||||
state_.sync.rescan_progress = prevProgress;
|
||||
state_.sync.rescan_status = prevStatus;
|
||||
ui::Notifications::instance().warning(TR("appx_wallet_op_running"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -281,6 +281,8 @@ const char* App::fullSweepBlockedReason() const
|
||||
return "the seed-phrase backup dialog is open";
|
||||
if (bootstrap_ || bootstrap_downloading_)
|
||||
return "a bootstrap is in progress";
|
||||
if (long_lane_.active() || import_in_progress_ || runtime_rescan_active_)
|
||||
return "a key import or rescan is in progress";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Long wallet RPCs (C61). A rescan or a key import holds the daemon's cs_main for as long as the wallet scan
|
||||
// takes: minutes to hours on a big wallet. On the shared clients they used to hit the 30 s curl timeout, report
|
||||
// a failure that wasn't one and flip the app into "node busy" (a reply timeout is recorded by the busy gate).
|
||||
// They now run one at a time on a dedicated, ungated client with no client timeout, abortable from any thread.
|
||||
|
||||
#include "connection.h" // ConnectionConfig
|
||||
#include "rpc_client.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
// The wallet RPCs that scan the chain inside the call: they run on the long-call lane, never on the shared
|
||||
// clients (rpc_ / the console's fast lane), whose 30 s timeout they outlast.
|
||||
inline bool isLongWalletMethod(const std::string& method)
|
||||
{
|
||||
return method == "rescan" || method == "importprivkey" || method == "importaddress" ||
|
||||
method == "importwallet" || method == "z_importkey" || method == "z_importviewingkey" ||
|
||||
method == "z_importwallet";
|
||||
}
|
||||
|
||||
// How a long wallet call that threw an RpcTransportError ended, which picks the wording shown to the user.
|
||||
enum class LongCallCutOff {
|
||||
None, // the request never reached the node: its error is the whole story
|
||||
Cancelled, // our own abort cut the transfer; the node may still be scanning
|
||||
Interrupted, // the request was sent but no answer came back (a dropped connection, or a proxy's 502/504
|
||||
// while the node still runs it): the key may already be in the wallet
|
||||
Refused, // the node's HTTP layer turned it away before the method ran (401, 503, queue full,
|
||||
// unhandled), so nothing was imported
|
||||
};
|
||||
|
||||
inline LongCallCutOff classifyLongCallCutOff(RpcFailureKind kind, bool requestSent)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::Aborted:
|
||||
return LongCallCutOff::Cancelled;
|
||||
case RpcFailureKind::Auth:
|
||||
case RpcFailureKind::ShuttingDown:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Unhandled:
|
||||
return LongCallCutOff::Refused;
|
||||
default:
|
||||
// Any other HTTP status (a proxy's 502/504) or a lost reply proves nothing about the node's side.
|
||||
return requestSent ? LongCallCutOff::Interrupted : LongCallCutOff::None;
|
||||
}
|
||||
}
|
||||
|
||||
// One long wallet RPC at a time on its own ungated client, with no client timeout, abortable from any thread.
|
||||
class LongCallLane {
|
||||
public:
|
||||
// Main thread: claim the lane. false when a long call is already running. A new call starts un-aborted.
|
||||
bool tryBegin()
|
||||
{
|
||||
bool idle = false;
|
||||
if (!active_.compare_exchange_strong(idle, true)) return false;
|
||||
abort_ = false;
|
||||
return true;
|
||||
}
|
||||
// The call and its continuation are done: the lane is free again.
|
||||
void end() { active_ = false; }
|
||||
bool active() const { return active_.load(); }
|
||||
bool aborted() const { return abort_.load(); }
|
||||
|
||||
// Any thread. Sticky until the next tryBegin(): a call that has not opened its client yet is aborted as soon
|
||||
// as it does, and an in-flight transfer is cut through the client's progress callback (about a second).
|
||||
void abort()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
abort_ = true;
|
||||
if (client_) client_->requestAbort();
|
||||
}
|
||||
|
||||
// Lane thread. open() FIRST (it clears the client's abort flag), THEN publish the client, THEN re-check the
|
||||
// sticky flag under the same mutex, so an abort that landed before or during open() is never erased.
|
||||
// An open() failure is not handled here: the body's first call then throws "Not connected".
|
||||
template <class Body>
|
||||
void run(RPCClient& c, const ConnectionConfig& cfg, Body&& body)
|
||||
{
|
||||
c.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
client_ = &c;
|
||||
if (abort_) c.requestAbort();
|
||||
}
|
||||
struct Unpublish {
|
||||
LongCallLane& lane;
|
||||
~Unpublish()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(lane.mx_);
|
||||
lane.client_ = nullptr;
|
||||
}
|
||||
} unpublish{*this};
|
||||
body(c);
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex mx_;
|
||||
RPCClient* client_ = nullptr; // guarded by mx_; set only while run() executes the body
|
||||
std::atomic<bool> abort_{false};
|
||||
std::atomic<bool> active_{false};
|
||||
};
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
@@ -585,6 +585,28 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string RPCClient::callRaw(const std::string& method, const json& params, long timeoutSec)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
if (!impl_->curl) {
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
// Temporarily override the timeout, as call(method, params, timeoutSec) does
|
||||
long prevTimeout = 30L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
|
||||
|
||||
try {
|
||||
std::string out = callRaw(method, params);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
|
||||
return out;
|
||||
} catch (...) {
|
||||
// Ensure timeout is always restored
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -184,6 +184,12 @@ public:
|
||||
*/
|
||||
std::string callRaw(const std::string& method, const json& params = json::array());
|
||||
|
||||
/**
|
||||
* @brief callRaw() with a custom timeout, restored to the default afterwards (like the 3-arg call())
|
||||
* @param timeoutSec Timeout in seconds (0 = no timeout)
|
||||
*/
|
||||
std::string callRaw(const std::string& method, const json& params, long timeoutSec);
|
||||
|
||||
// High-level API methods - mirror the Qt version
|
||||
|
||||
// Info methods
|
||||
|
||||
@@ -299,6 +299,8 @@ private:
|
||||
// ---- Shared: kick off download ----
|
||||
static void startDownload(const std::string& url) {
|
||||
if (!s_app || !s_app->supportsFullNodeLifecycleActions()) return;
|
||||
// It stops the node first: not while it is busy or running a key import / rescan (as the wizard's bootstrap).
|
||||
if (s_app->refuseIfNodeBusy()) return;
|
||||
s_wasDaemonRunning = s_app->stopDaemonForBootstrap();
|
||||
s_app->setBootstrapDownloading(true);
|
||||
s_bootstrap = std::make_unique<util::Bootstrap>();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "../../data/wallet_state.h"
|
||||
#include "../../rpc/rpc_client.h"
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
#include "../../rpc/long_call_lane.h" // isLongWalletMethod
|
||||
#include "../../daemon/embedded_daemon.h"
|
||||
#include "../../daemon/xmrig_manager.h"
|
||||
#include "../../wallet/lite_wallet_controller.h"
|
||||
@@ -20,6 +21,7 @@
|
||||
#include "../material/colors.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sodium.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
@@ -34,6 +36,19 @@ using namespace material;
|
||||
// executors drain the same LiteDiagnostics ring, so declare it up front for the full-node drain.
|
||||
namespace { ConsoleChannel liteLogChannel(const std::string& line); }
|
||||
|
||||
namespace {
|
||||
// Zero every string in a console call's params: a key import's carry the key.
|
||||
void scrubParamStrings(nlohmann::json& j)
|
||||
{
|
||||
if (j.is_string()) {
|
||||
std::string& s = j.get_ref<std::string&>();
|
||||
if (!s.empty()) sodium_memzero(&s[0], s.size());
|
||||
} else if (j.is_structured()) {
|
||||
for (auto& el : j) scrubParamStrings(el);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ============================================================================
|
||||
// FullNodeConsoleExecutor
|
||||
// ============================================================================
|
||||
@@ -61,6 +76,44 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Rescans and key imports scan the chain inside the call (C61): run them on the app's long-call lane (no
|
||||
// client timeout, one at a time) instead of this lane's 30 s client. in_flight_ is left alone, so the input
|
||||
// stays usable while one runs; the result (or the refusal, in English like every console result) is
|
||||
// pushed as usual.
|
||||
if (rpc::isLongWalletMethod(call.method)) {
|
||||
std::string why;
|
||||
bool refused = app_->longWalletOpRefused(&why);
|
||||
if (!refused) {
|
||||
refused = !app_->runLongWalletTask(
|
||||
[this, method = call.method, params = call.params](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string result_str;
|
||||
bool is_error = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Console tab / Long wallet command");
|
||||
result_str = c.callRaw(method, params, 0L); // 0 = no client timeout
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
is_error = true;
|
||||
} catch (...) {
|
||||
result_str = "RPC failed (unknown error)";
|
||||
is_error = true;
|
||||
}
|
||||
scrubParamStrings(params);
|
||||
return [this, result_str, is_error]() {
|
||||
if (app_) app_->noteWalletMutation();
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
results_.push_back({result_str, is_error});
|
||||
};
|
||||
});
|
||||
if (refused) why = "A key import or rescan is still running. Try again when it finishes.";
|
||||
}
|
||||
scrubParamStrings(call.params);
|
||||
if (refused) {
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
results_.push_back({why, true});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const std::string method = call.method;
|
||||
const nlohmann::json params = call.params;
|
||||
|
||||
|
||||
@@ -635,6 +635,15 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["appx_daemon_reinstall_node_busy"] = "The node is still running, so the daemon was not reinstalled. Try again once it has stopped.";
|
||||
strings_["appx_node_busy_try_later"] = "The node is busy finishing a long task (such as rebuilding note witnesses). Try again when it is done; stopping it now could make it start that work over.";
|
||||
strings_["appx_node_not_stopped"] = "The node didn't stop in time, so nothing was changed. Try again in a few minutes.";
|
||||
// Long wallet RPCs: one key import or rescan at a time, refusals and the quit prompt (C61)
|
||||
strings_["appx_wallet_op_running"] = "A key import or rescan is still running. Try again when it finishes.";
|
||||
strings_["appx_wallet_op_shutting_down"] = "The wallet is shutting down.";
|
||||
strings_["appx_wallet_op_node_busy"] = "The node is busy and not answering. Try the import or rescan again when it finishes.";
|
||||
strings_["appx_import_cancelled"] = "Cancelled: the node may still be scanning. If the key's funds don't appear, run Settings → Rescan.";
|
||||
strings_["appx_import_interrupted"] = "The import was cut off, so the key may already be in the wallet. If its funds don't appear, run Settings → Rescan.";
|
||||
strings_["appx_import_refused"] = "The node refused the request, so the key was not imported.";
|
||||
strings_["stopconfirm_walletop_title"] = "A key import or rescan is running";
|
||||
strings_["stopconfirm_walletop_body"] = "The node is scanning the chain for a key import or rescan. Stopping it now ends that scan early, and funds it has not reached yet appear only after Settings → Rescan.\n\nRecommended: close the wallet and leave the node running. It finishes the scan on its own.";
|
||||
strings_["reduce_motion"] = "Reduce Motion";
|
||||
strings_["tt_reduce_motion"] = "Disable animated transitions and balance lerp for accessibility";
|
||||
strings_["ago"] = "ago";
|
||||
|
||||
Reference in New Issue
Block a user