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
+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