fix(rescan): probe the node's block range off the UI thread

Finding the lowest block a node still has (Settings > Rescan, and the
automatic reconcile after a bootstrap) ran a binary search of about 42
synchronous getblockhash/getblock calls on the UI thread, each waiting
for the RPC connection behind any wallet scan (10-20 s on a big wallet)
and each with a 30 s timeout. The window froze for that long.

It now runs as one task on the wallet RPC worker, bounded to 90 s, and
the answer comes back on the UI thread; meanwhile the dialog shows
"Checking which blocks..." with Rescan disabled. An answer from an
earlier connection counts as "could not tell", and one for an earlier
opening of the dialog is dropped. The search probes block 1 first, so a
full-history node answers in one probe, and it answers "could not tell"
at once while a runtime rescan holds the lane.

Fixed along the way: any probe error (timeout, busy node) read as "block
missing", so a busy node could get a bogus "bootstrapped, rescan from
the tip"; and a non-string getblockhash reply never called back, leaving
the dialog on "Checking..." for good. Only the daemon's "can't read
block" error now counts as missing; anything else means "could not
tell".

User-visible:
- after a bootstrap, a probe that cannot tell is tried up to 6 times,
  30 s apart and only while the node is not busy, then a toast points
  to Settings > Rescan, instead of falling back to the -rescan
  restart, which fails on a bootstrapped node;
- the Settings Rescan dialog warns when the probe could not tell.

Two new strings, translated into all eight languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent ad0f60a747
commit 2618744e59
16 changed files with 275 additions and 32 deletions
+37 -21
View File
@@ -33,6 +33,7 @@
#include "rpc/rpc_client.h"
#include "rpc/rpc_worker.h"
#include "rpc/connection.h"
#include "services/lowest_block_search.h" // detectLowestAvailableBlockHeight: pure search + probe classification
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
#include "ui/windows/shield_dialog.h" // ui::ShieldDialog — Merge to Address shortcut from the bloat nudge
@@ -7009,7 +7010,7 @@ void App::resendWithFeeGapWorkaround(const std::string& from, const std::string&
// --------------------------------------------------------------------------------------------
void App::detectLowestAvailableBlockHeight(std::function<void(bool, int, bool)> cb)
{
if (!rpc_ || !rpc_->isConnected()) {
if (!rpc_ || !rpc_->isConnected() || !worker_) {
if (cb) cb(false, 0, false);
return;
}
@@ -7019,28 +7020,43 @@ void App::detectLowestAvailableBlockHeight(std::function<void(bool, int, bool)>
if (cb) cb(false, 0, true);
return;
}
// A runtime rescan holds worker_ (and rpc_'s curl lock) inside one `rescan` call for minutes: a probe queued
// behind it would sit on "detecting" that long. Answer "could not tell" now.
if (runtime_rescan_active_) {
if (cb) cb(false, 0, false);
return;
}
// Shared search window; each probe halves it. Invariant maintained: block at `hi` is readable.
struct SearchState { int lo; int hi; std::function<void(bool, int, bool)> cb; };
auto st = std::make_shared<SearchState>(SearchState{0, tip, std::move(cb)});
auto step = std::make_shared<std::function<void()>>();
*step = [this, st, step]() {
if (st->lo >= st->hi) {
const bool fullHistory = (st->lo <= 1); // genesis/early block present → not bootstrapped
if (st->cb) st->cb(true, st->lo, fullHistory);
return;
// Off the UI thread: this used to run ~42 synchronous getblockhash/getblock calls inside the caller's frame
// (behind rpc_'s curl lock, i.e. behind any 10-20 s wallet scan on worker_). tip and the session are read
// here; the worker touches only rpc_ (it serializes its own calls) and locals; cb runs on the UI thread from
// worker_->drainResults(), whose pending result also wakes the idle loop.
const std::uint64_t session = rpc_session_;
worker_->post([this, rpc = rpc_.get(), tip, session, cb = std::move(cb)]() mutable -> rpc::RPCWorker::MainCb {
services::LowestBlockResult res;
try {
rpc::RPCClient::TraceScope trace("Rescan / Block range probe");
// Bound how long this holds the wallet lane: each call has the 30 s RPC timeout, and a node that
// answers every probe slowly must not keep worker_ for ~44 x 30 s.
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(90);
res = services::findLowestAvailableBlock(tip, [rpc, deadline](int height) {
// No isConnected() here (an unlocked read off the UI thread): a disconnected client's call()
// throws "Not connected" under its own lock, which probeBlockPresence classifies as Unknown.
if (std::chrono::steady_clock::now() > deadline)
return services::BlockProbe::Unknown;
return services::probeBlockPresence(
[rpc](const std::string& method, const nlohmann::json& params) { return rpc->call(method, params); },
height);
});
} catch (...) {
res = services::LowestBlockResult{}; // never leave the caller on "detecting": "could not tell"
}
const int mid = st->lo + (st->hi - st->lo) / 2;
rpc_->getBlock(mid, [st, step, mid](const nlohmann::json& result, const std::string& error) {
if (error.empty() && !result.is_null()) {
st->hi = mid; // block data present → lowest available is <= mid
} else {
st->lo = mid + 1; // block data missing → lowest available is > mid
}
(*step)();
});
};
(*step)();
return [this, session, res, cb = std::move(cb)]() {
// Disconnected / reconnected since the post (another wallet or daemon): not this session's answer.
const services::LowestBlockResult r = (session == rpc_session_) ? res : services::LowestBlockResult{};
if (cb) cb(r.ok, r.lowest, r.fullHistory);
};
});
}
void App::runtimeRescan(int startHeight)