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
+76
View File
@@ -37,6 +37,7 @@
#include "services/mining_thread_commit.h"
#include "services/network_refresh_service.h"
#include "services/refresh_scheduler.h"
#include "services/lowest_block_search.h"
#include "services/note_consolidation.h"
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
@@ -4447,6 +4448,80 @@ void testHttpGetCancel()
EXPECT_EQ(dragonx::util::httpGetString("https://127.0.0.1:9/never", "[test]", o), std::string());
}
// C67: the pure search and the error classification.
void testLowestBlockSearch()
{
using namespace dragonx::services;
using dragonx::rpc::RpcError;
using dragonx::rpc::RpcTransportError;
using dragonx::rpc::RpcFailureKind;
using json = nlohmann::json;
int calls = 0;
auto run = [&calls](int tip, int base) { // block data present from `base` upward
calls = 0;
return findLowestAvailableBlock(tip, [&calls, base](int h) {
++calls;
return h >= base ? BlockProbe::Present : BlockProbe::Missing;
});
};
const int tip = 1000000;
LowestBlockResult r = run(tip, 1); // full history: ONE probe (was ~42)
EXPECT_TRUE(r.ok); EXPECT_TRUE(r.fullHistory); EXPECT_EQ(r.lowest, 1); EXPECT_EQ(calls, 1);
for (int base : {2, 7, 524288, 999999, 1000000}) {
r = run(tip, base);
EXPECT_TRUE(r.ok);
EXPECT_FALSE(r.fullHistory);
EXPECT_EQ(r.lowest, base);
EXPECT_TRUE(calls <= 2 + 20); // 1, the tip, then ceil(log2(tip)) halvings
}
r = run(2, 2);
EXPECT_TRUE(r.ok); EXPECT_EQ(r.lowest, 2); EXPECT_EQ(calls, 2);
r = run(1, 1); // no usable tip: nothing probed
EXPECT_FALSE(r.ok); EXPECT_EQ(calls, 0);
r = run(tip, tip + 1); // unreadable tip: no answer
EXPECT_FALSE(r.ok);
// THE regression: every probe failing used to read as "missing" and answer ok, lowest = tip.
calls = 0;
r = findLowestAvailableBlock(tip, [&calls](int) { ++calls; return BlockProbe::Unknown; });
EXPECT_FALSE(r.ok); EXPECT_EQ(calls, 1);
// Unknown in the middle of the search stops it at once.
calls = 0;
r = findLowestAvailableBlock(tip, [&calls](int h) {
++calls;
if (calls == 4) return BlockProbe::Unknown;
return h >= 500000 ? BlockProbe::Present : BlockProbe::Missing;
});
EXPECT_FALSE(r.ok); EXPECT_EQ(calls, 4);
EXPECT_TRUE(classifyBlockProbeError(RpcError(-32603, "Can't read block from disk")) == BlockProbe::Missing);
EXPECT_TRUE(classifyBlockProbeError(RpcError(-32603, "RPC error: Block not available (pruned data)")) == BlockProbe::Missing);
EXPECT_TRUE(classifyBlockProbeError(RpcError(-1, "pruned data")) == BlockProbe::Missing);
EXPECT_TRUE(classifyBlockProbeError(RpcError(-28, "Loading block index...")) == BlockProbe::Unknown);
EXPECT_TRUE(classifyBlockProbeError(RpcError(-8, "Block height out of range")) == BlockProbe::Unknown);
EXPECT_TRUE(classifyBlockProbeError(RpcError(-5, "Block not found")) == BlockProbe::Unknown);
EXPECT_TRUE(classifyBlockProbeError(RpcTransportError(RpcFailureKind::Deferred, "RPC deferred: the node is busy")) == BlockProbe::Unknown);
EXPECT_TRUE(classifyBlockProbeError(RpcTransportError(RpcFailureKind::ReplyTimeout, "RPC request failed: Timeout was reached")) == BlockProbe::Unknown);
EXPECT_TRUE(classifyBlockProbeError(std::runtime_error("Not connected")) == BlockProbe::Unknown);
BlockProbe p = probeBlockPresence([](const std::string& m, const json&) -> json {
return m == "getblockhash" ? json("00ab") : json::object({{"height", 5}});
}, 5);
EXPECT_TRUE(p == BlockProbe::Present);
p = probeBlockPresence([](const std::string&, const json&) -> json { return json(5); }, 5); // hash not a string
EXPECT_TRUE(p == BlockProbe::Unknown);
p = probeBlockPresence([](const std::string& m, const json&) -> json {
if (m == "getblock") throw RpcError(-32603, "Can't read block from disk");
return json("00ab");
}, 5);
EXPECT_TRUE(p == BlockProbe::Missing);
p = probeBlockPresence([](const std::string&, const json&) -> json {
throw RpcTransportError(RpcFailureKind::Deferred, "RPC deferred: the node is busy");
}, 5);
EXPECT_TRUE(p == BlockProbe::Unknown);
}
void testConnectRetryBackoff()
{
using dragonx::rpc::ConnectRetryBackoff;
@@ -12809,6 +12884,7 @@ int main()
testConnectRetryBackoff();
testFetchBackoff();
testHttpGetCancel();
testLowestBlockSearch();
testNetworkRefreshService();
testNetworkRefreshSnapshotHelpers();
testParseDaemonRescanOutput();