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