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:
+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
|
||||
|
||||
Reference in New Issue
Block a user