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:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent b3a57b07ad
commit 81733f5603
20 changed files with 776 additions and 25 deletions
+28 -7
View File
@@ -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_) {
+16 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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;
}
+113
View File
@@ -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
+22
View File
@@ -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 {
+6
View File
@@ -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;
+9
View File
@@ -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";