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_) {