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:
+25
-6
@@ -1456,17 +1456,36 @@ void App::update()
|
||||
// is rejected. The -rescan restart can't do it (the snapshot lacks pre-snapshot blocks), so we
|
||||
// detect the lowest available block height and run a runtime rescanblockchain from there. Wait
|
||||
// until the daemon is connected and out of warmup so the probe and rescan can actually run.
|
||||
if (post_bootstrap_rescan_pending_ && rpcConnected && state_.connected &&
|
||||
if (post_bootstrap_rescan_pending_ && !post_bootstrap_probe_in_flight_ &&
|
||||
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_ &&
|
||||
state_.sync.blocks > 1) { // wait until the tip is known so the probe has a real range
|
||||
post_bootstrap_rescan_pending_ = false;
|
||||
ui::Notifications::instance().info(TR("appx_bootstrap_complete_reconciling"));
|
||||
if (post_bootstrap_probe_attempts_ == 0)
|
||||
ui::Notifications::instance().info(TR("appx_bootstrap_complete_reconciling"));
|
||||
++post_bootstrap_probe_attempts_;
|
||||
post_bootstrap_probe_in_flight_ = true;
|
||||
// Asynchronous (worker_): the callback runs on the UI thread a few frames later.
|
||||
detectLowestAvailableBlockHeight([this](bool ok, int lowest, bool fullHistory) {
|
||||
if (ok && !fullHistory) {
|
||||
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
|
||||
post_bootstrap_probe_in_flight_ = false;
|
||||
if (shutting_down_ || !post_bootstrap_rescan_pending_) return;
|
||||
if (ok) {
|
||||
post_bootstrap_rescan_pending_ = false;
|
||||
post_bootstrap_probe_attempts_ = 0;
|
||||
if (!fullHistory) {
|
||||
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
|
||||
} else {
|
||||
rescanBlockchain(); // full-history node: the -rescan restart works
|
||||
}
|
||||
} else if (post_bootstrap_probe_attempts_ < 6) {
|
||||
// Could not tell (busy node, a timeout, a reconnect). NOT the -rescan restart: on a bootstrapped
|
||||
// node it fails. Keep the reconcile armed and probe again in 30 s.
|
||||
post_bootstrap_probe_retry_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
} else {
|
||||
rescanBlockchain(); // full-history node (or probe failed): -rescan restart works
|
||||
post_bootstrap_rescan_pending_ = false;
|
||||
post_bootstrap_probe_attempts_ = 0;
|
||||
ui::Notifications::instance().warning(TR("appx_bootstrap_reconcile_probe_failed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -645,13 +645,18 @@ public:
|
||||
// -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);
|
||||
// Async binary-search probe for the lowest block height the node still has on disk.
|
||||
// cb(ok, lowestHeight, fullHistory): fullHistory==true when genesis is present (a normal,
|
||||
// non-bootstrapped node). Runs on the UI thread via the RPC worker callbacks.
|
||||
// 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
|
||||
// node). ok==false means "could not tell" (busy node, timeout, reconnect): never "blocks are missing".
|
||||
void detectLowestAvailableBlockHeight(std::function<void(bool ok, int lowestHeight, bool fullHistory)> cb);
|
||||
// Flag that a bootstrap just finished so the wallet auto-reconciles spent-state once the
|
||||
// daemon is back up (consumed in update()).
|
||||
void markPostBootstrapRescanPending() { post_bootstrap_rescan_pending_ = true; }
|
||||
void markPostBootstrapRescanPending() {
|
||||
post_bootstrap_rescan_pending_ = true;
|
||||
post_bootstrap_probe_attempts_ = 0;
|
||||
post_bootstrap_probe_retry_at_ = {};
|
||||
}
|
||||
bool runtimeRescanActive() const { return runtime_rescan_active_; }
|
||||
void repairWallet(); // restart daemon with -zapwallettxes=2 (wipe & rebuild wallet tx records)
|
||||
void reinstallBundledDaemon(); // stop daemon, overwrite installed binary with the bundled one, restart
|
||||
@@ -1720,6 +1725,9 @@ private:
|
||||
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
|
||||
// that reconciles the preserved wallet.dat against the freshly-imported chain.
|
||||
bool post_bootstrap_rescan_pending_ = false;
|
||||
bool post_bootstrap_probe_in_flight_ = false; // its detectLowestAvailableBlockHeight() is on worker_
|
||||
int post_bootstrap_probe_attempts_ = 0; // probes that could not tell; gives up after 6
|
||||
std::chrono::steady_clock::time_point post_bootstrap_probe_retry_at_{};
|
||||
// Largest "blocks remaining" seen during the current witness-rebuild phase. The daemon's
|
||||
// "Building Witnesses for block" fraction resets every call (it's re-invoked per connected
|
||||
// block, each walking from its own start height to the tip), so we derive a stable, monotonic
|
||||
|
||||
+37
-21
@@ -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)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure search for the lowest block height whose data the node can still read (a node restored from a
|
||||
// bootstrap snapshot lacks everything below the snapshot base). No I/O: the caller supplies the probe.
|
||||
// Header-only so the test target needs no new source.
|
||||
#pragma once
|
||||
|
||||
#include "../rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
enum class BlockProbe { Present, Missing, Unknown };
|
||||
|
||||
struct LowestBlockResult {
|
||||
bool ok = false; // false: could not tell (never read as "missing")
|
||||
int lowest = 0; // lowest readable height (valid when ok)
|
||||
bool fullHistory = false; // block 1 readable: not a bootstrapped / pruned node
|
||||
};
|
||||
|
||||
// What a failed getblockhash / getblock means. Only "the daemon says it cannot read the block data"
|
||||
// (RPC_INTERNAL_ERROR -32603: "Can't read block from disk" / "Block not available (pruned data)",
|
||||
// external/dragonx/src/rpc/blockchain.cpp) is Missing. Every transport problem (timeout, busy gate
|
||||
// "RPC deferred", queue full, abort, not listening), warmup (-28), out of range (-8), not found (-5) and
|
||||
// anything unrecognised is Unknown, which stops the search.
|
||||
inline BlockProbe classifyBlockProbeError(const std::exception& e)
|
||||
{
|
||||
if (const auto* r = dynamic_cast<const rpc::RpcError*>(&e)) {
|
||||
if (r->code == -32603) return BlockProbe::Missing;
|
||||
const std::string m = r->what();
|
||||
if (m.find("from disk") != std::string::npos || m.find("pruned") != std::string::npos)
|
||||
return BlockProbe::Missing;
|
||||
return BlockProbe::Unknown;
|
||||
}
|
||||
return BlockProbe::Unknown; // RpcTransportError, runtime_error("Not connected"), json errors
|
||||
}
|
||||
|
||||
// call(method, params) -> json, throwing like RPCClient::call. Present = both calls answered.
|
||||
template <typename Call>
|
||||
BlockProbe probeBlockPresence(Call&& call, int height)
|
||||
{
|
||||
try {
|
||||
nlohmann::json h = call("getblockhash", nlohmann::json::array({height}));
|
||||
if (!h.is_string()) return BlockProbe::Unknown;
|
||||
nlohmann::json b = call("getblock", nlohmann::json::array({h.get<std::string>()}));
|
||||
return b.is_null() ? BlockProbe::Unknown : BlockProbe::Present;
|
||||
} catch (const std::exception& e) {
|
||||
return classifyBlockProbeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Assumes block data is present from some base height upward (as the old search did). tip = a height known
|
||||
// to exist (the caller's synced tip). Probe order: 1 (a normal full-history node answers in ONE probe), then
|
||||
// the tip (the search's premise), then a binary search over (1, tip]. Any Unknown ends it with ok=false:
|
||||
// nothing is guessed.
|
||||
inline LowestBlockResult findLowestAvailableBlock(int tip, const std::function<BlockProbe(int)>& probe)
|
||||
{
|
||||
LowestBlockResult out;
|
||||
if (tip <= 1) return out;
|
||||
switch (probe(1)) {
|
||||
case BlockProbe::Present: out.ok = true; out.lowest = 1; out.fullHistory = true; return out;
|
||||
case BlockProbe::Unknown: return out;
|
||||
case BlockProbe::Missing: break;
|
||||
}
|
||||
if (probe(tip) != BlockProbe::Present) return out; // an unreadable tip is as unusable as Unknown
|
||||
int lo = 1, hi = tip; // invariant: lo Missing, hi Present
|
||||
while (hi - lo > 1) {
|
||||
const int mid = lo + (hi - lo) / 2;
|
||||
switch (probe(mid)) {
|
||||
case BlockProbe::Present: hi = mid; break;
|
||||
case BlockProbe::Missing: lo = mid; break;
|
||||
case BlockProbe::Unknown: return out;
|
||||
}
|
||||
}
|
||||
out.ok = true;
|
||||
out.lowest = hi;
|
||||
out.fullHistory = false;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -174,6 +174,8 @@ struct SettingsPageState {
|
||||
bool rescan_height_detected = false;
|
||||
bool rescan_full_history = true; // genesis present → traditional -rescan restart
|
||||
int rescan_start_height = 0; // editable pre-fill for the runtime rescan
|
||||
bool rescan_probe_failed = false; // the probe could not tell (node busy): warn before the -rescan restart
|
||||
int rescan_probe_seq = 0; // the probe is async: a late result for an earlier opening is dropped
|
||||
bool confirm_repair_wallet = false;
|
||||
bool confirm_reinstall_daemon = false;
|
||||
// Cached daemon-binary status for the "daemon binary" panel (loaded once / on Refresh,
|
||||
@@ -2404,6 +2406,7 @@ void RenderSettingsPage(App* app) {
|
||||
s_settingsState.confirm_rescan = true;
|
||||
s_settingsState.rescan_height_detecting = false;
|
||||
s_settingsState.rescan_height_detected = false;
|
||||
s_settingsState.rescan_probe_failed = false;
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_rescan"));
|
||||
ImGui::EndDisabled();
|
||||
@@ -3062,9 +3065,14 @@ void RenderSettingsPage(App* app) {
|
||||
// Kick off the one-shot block-range probe the first frame the dialog is open.
|
||||
if (!s_settingsState.rescan_height_detecting && !s_settingsState.rescan_height_detected) {
|
||||
s_settingsState.rescan_height_detecting = true;
|
||||
app->detectLowestAvailableBlockHeight([](bool ok, int lowest, bool fullHistory) {
|
||||
// Asynchronous now (worker_): the callback runs on the UI thread some frames later, or at once when
|
||||
// there is nothing to probe. A result for an earlier opening of the dialog is dropped.
|
||||
const int probeSeq = ++s_settingsState.rescan_probe_seq;
|
||||
app->detectLowestAvailableBlockHeight([probeSeq](bool ok, int lowest, bool fullHistory) {
|
||||
if (probeSeq != s_settingsState.rescan_probe_seq) return;
|
||||
s_settingsState.rescan_height_detecting = false;
|
||||
s_settingsState.rescan_height_detected = true;
|
||||
s_settingsState.rescan_probe_failed = !ok && !fullHistory; // (false, 0, true): nothing to probe
|
||||
s_settingsState.rescan_full_history = (!ok) || fullHistory;
|
||||
s_settingsState.rescan_start_height = (ok && !fullHistory) ? lowest : 0;
|
||||
});
|
||||
@@ -3088,6 +3096,13 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::InputInt("##rescanHeight", &s_settingsState.rescan_start_height);
|
||||
if (s_settingsState.rescan_start_height < 0) s_settingsState.rescan_start_height = 0;
|
||||
} else {
|
||||
if (s_settingsState.rescan_probe_failed) {
|
||||
// A long line: TextColored does not wrap, so colour a wrapped one.
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
||||
ImGui::TextWrapped("%s", TR("rescan_probe_failed"));
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
|
||||
ImGui::Spacing();
|
||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
|
||||
|
||||
@@ -873,6 +873,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["confirm_rescan_msg"] = "This restarts the daemon and re-scans the entire blockchain for your wallet's transactions. It can take a long time and the wallet stays offline until it finishes.";
|
||||
strings_["confirm_rescan_safe"] = "Your wallet.dat and blockchain data are not deleted — only re-scanned.";
|
||||
strings_["rescan_detecting"] = "Checking which blocks your node has on disk…";
|
||||
strings_["rescan_probe_failed"] = "Couldn't check which blocks your node has on disk (it is busy or a request timed out). If this node was restored from a bootstrap, cancel and try again in a minute: the full -rescan restart below fails on a bootstrapped node.";
|
||||
strings_["rescan_bootstrapped_msg"] = "Your node was bootstrapped, so blocks below the snapshot aren't on disk and a rescan from genesis would fail. Rescan from a height your snapshot includes to reconcile your wallet's spent balance. Your wallet.dat and chain data are not deleted.";
|
||||
strings_["rescan_from_height"] = "Rescan from block height:";
|
||||
strings_["repair_wallet"] = "Repair";
|
||||
@@ -2716,6 +2717,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["appx_miner_stopped_prefix"] = "Miner stopped: ";
|
||||
strings_["appx_pool_miner_connected_and_hashing"] = "Pool miner connected and hashing.";
|
||||
strings_["appx_bootstrap_complete_reconciling"] = "Bootstrap complete — reconciling your wallet with the new chain data.";
|
||||
strings_["appx_bootstrap_reconcile_probe_failed"] = "Couldn't reconcile your wallet with the bootstrapped chain data: the node stayed too busy to check which blocks it has. Run Rescan from Settings once it has caught up.";
|
||||
strings_["appx_blockchain_data_deleted"] = "Blockchain data deleted (%d items). The daemon is restarting to re-sync from the network.";
|
||||
strings_["appx_invalid_payment_uri_prefix"] = "Invalid payment URI: ";
|
||||
strings_["appx_payment_request_loaded"] = "Payment request loaded";
|
||||
|
||||
Reference in New Issue
Block a user