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:
@@ -625,6 +625,7 @@ set(APP_HEADERS
|
||||
src/services/scan_policy.h
|
||||
src/services/chat_scan_policy.h
|
||||
src/services/mining_thread_commit.h
|
||||
src/services/lowest_block_search.h
|
||||
src/services/wallet_security_controller.h
|
||||
src/services/wallet_security_workflow.h
|
||||
src/services/wallet_security_workflow_executor.h
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Eine Blockchain-Wartung läuft bereits.",
|
||||
"appx_blockchain_rescan_complete": "Blockchain-Neuscan abgeschlossen",
|
||||
"appx_bootstrap_complete_reconciling": "Bootstrap abgeschlossen — Ihr Wallet wird mit den neuen Chain-Daten abgeglichen.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Ihr Wallet konnte nicht mit den Bootstrap-Chain-Daten abgeglichen werden: Der Node war zu ausgelastet, um seine Blöcke zu prüfen. Starten Sie Neu scannen in den Einstellungen, sobald er aufgeholt hat.",
|
||||
"appx_cancel": "Abbrechen",
|
||||
"appx_cleaning_up": "Aufräumen...",
|
||||
"appx_confirm_your_backup": "Backup bestätigen",
|
||||
@@ -1597,6 +1598,7 @@
|
||||
"rescan_bootstrapped_msg": "Ihr Node wurde per Bootstrap eingerichtet, daher liegen Blöcke unterhalb des Snapshots nicht auf der Festplatte und ein Neuscan ab dem Genesis-Block würde fehlschlagen. Führen Sie den Neuscan ab einer Höhe durch, die Ihr Snapshot umfasst, um das ausgegebene Guthaben Ihrer Wallet abzugleichen. Ihre wallet.dat und Ihre Blockchain-Daten werden nicht gelöscht.",
|
||||
"rescan_detecting": "Prüfe, welche Blöcke Ihr Node auf der Festplatte hat…",
|
||||
"rescan_from_height": "Neu scannen ab Blockhöhe:",
|
||||
"rescan_probe_failed": "Es konnte nicht geprüft werden, welche Blöcke Ihr Node auf der Festplatte hat (er ist ausgelastet oder eine Anfrage ist abgelaufen). Wenn dieser Node per Bootstrap eingerichtet wurde, brechen Sie ab und versuchen Sie es in einer Minute erneut: Der vollständige -rescan-Neustart unten schlägt auf einem per Bootstrap eingerichteten Node fehl.",
|
||||
"reset_to_defaults": "Standardwerte zurücksetzen",
|
||||
"restarting_after_encryption": "Daemon wird nach Verschlüsselung neu gestartet...",
|
||||
"restore_address": "Adresse wiederherstellen",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Ya hay una operación de mantenimiento de la cadena de bloques en curso.",
|
||||
"appx_blockchain_rescan_complete": "Reescaneo de la cadena de bloques completado",
|
||||
"appx_bootstrap_complete_reconciling": "Arranque inicial completado; reconciliando tu cartera con los nuevos datos de la cadena.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "No se pudo reconciliar tu cartera con los datos de la cadena del bootstrap: el nodo estuvo demasiado ocupado para comprobar qué bloques tiene. Ejecuta Re-escanear desde Ajustes cuando se haya puesto al día.",
|
||||
"appx_cancel": "Cancelar",
|
||||
"appx_cleaning_up": "Limpiando...",
|
||||
"appx_confirm_your_backup": "Confirma tu copia de seguridad",
|
||||
@@ -1597,6 +1598,7 @@
|
||||
"rescan_bootstrapped_msg": "Tu nodo se inicializó con un bootstrap, por lo que los bloques anteriores a la instantánea no están en disco y un reescaneo desde el génesis fallaría. Reescanea desde una altura que incluya tu instantánea para reconciliar el saldo gastado de tu cartera. Tu wallet.dat y los datos de la cadena no se eliminan.",
|
||||
"rescan_detecting": "Comprobando qué bloques tiene tu nodo en disco…",
|
||||
"rescan_from_height": "Reescanear desde la altura de bloque:",
|
||||
"rescan_probe_failed": "No se pudo comprobar qué bloques tiene tu nodo en disco (está ocupado o una solicitud agotó el tiempo de espera). Si este nodo se inicializó con un bootstrap, cancela y vuelve a intentarlo en un minuto: el reinicio completo con -rescan de abajo falla en un nodo inicializado con bootstrap.",
|
||||
"reset_to_defaults": "Restablecer Valores",
|
||||
"restarting_after_encryption": "Reiniciando daemon después del cifrado...",
|
||||
"restore_address": "Restaurar dirección",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Une opération de maintenance de la blockchain est déjà en cours.",
|
||||
"appx_blockchain_rescan_complete": "Nouvelle analyse de la blockchain terminée",
|
||||
"appx_bootstrap_complete_reconciling": "Amorçage terminé — réconciliation de votre portefeuille avec les nouvelles données de la chaîne.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Impossible de réconcilier votre portefeuille avec les données de chaîne de l'amorçage : le nœud est resté trop occupé pour vérifier ses blocs. Lancez Re-scanner depuis les Paramètres une fois qu'il aura rattrapé son retard.",
|
||||
"appx_cancel": "Annuler",
|
||||
"appx_cleaning_up": "Nettoyage...",
|
||||
"appx_confirm_your_backup": "Confirmez votre sauvegarde",
|
||||
@@ -1597,6 +1598,7 @@
|
||||
"rescan_bootstrapped_msg": "Votre nœud a été initialisé par bootstrap, les blocs situés en dessous de l'instantané ne sont donc pas sur le disque et un rescan depuis la genèse échouerait. Effectuez un rescan à partir d'une hauteur incluse dans votre instantané pour réconcilier le solde dépensé de votre portefeuille. Votre fichier wallet.dat et vos données de chaîne ne sont pas supprimés.",
|
||||
"rescan_detecting": "Vérification des blocs présents sur le disque de votre nœud…",
|
||||
"rescan_from_height": "Rescanner à partir de la hauteur de bloc :",
|
||||
"rescan_probe_failed": "Impossible de vérifier quels blocs votre nœud a sur le disque (il est occupé ou une requête a expiré). Si ce nœud a été initialisé par bootstrap, annulez et réessayez dans une minute : le redémarrage complet avec -rescan ci-dessous échoue sur un nœud initialisé par bootstrap.",
|
||||
"reset_to_defaults": "Réinitialiser les paramètres",
|
||||
"restarting_after_encryption": "Redémarrage du daemon après chiffrement...",
|
||||
"restore_address": "Restaurer l'adresse",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "ブロックチェーンのメンテナンス操作がすでに進行中です。",
|
||||
"appx_blockchain_rescan_complete": "ブロックチェーンの再スキャンが完了しました",
|
||||
"appx_bootstrap_complete_reconciling": "ブートストラップが完了しました。新しいチェーンデータとウォレットを照合しています。",
|
||||
"appx_bootstrap_reconcile_probe_failed": "ブートストラップのチェーンデータとウォレットを照合できませんでした。ノードが処理中のため、保有しているブロックを確認できませんでした。同期が追いついたら、設定から再スキャンを実行してください。",
|
||||
"appx_cancel": "キャンセル",
|
||||
"appx_cleaning_up": "クリーンアップしています...",
|
||||
"appx_confirm_your_backup": "バックアップを確認",
|
||||
@@ -1597,6 +1598,7 @@
|
||||
"rescan_bootstrapped_msg": "ノードはブートストラップされているため、スナップショットより下のブロックはディスク上になく、ジェネシスからの再スキャンは失敗します。ウォレットの使用済み残高を照合するには、スナップショットに含まれる高さから再スキャンしてください。wallet.dat とチェーンデータは削除されません。",
|
||||
"rescan_detecting": "ノードがディスク上に保有しているブロックを確認中…",
|
||||
"rescan_from_height": "ブロック高から再スキャン:",
|
||||
"rescan_probe_failed": "ノードがディスク上に保有しているブロックを確認できませんでした(ノードが処理中か、リクエストがタイムアウトしました)。このノードがブートストラップされている場合は、キャンセルして1分後に再試行してください。ブートストラップされたノードでは、下の -rescan による再起動は失敗します。",
|
||||
"reset_to_defaults": "デフォルトにリセット",
|
||||
"restarting_after_encryption": "暗号化後にデーモンを再起動中...",
|
||||
"restore_address": "アドレスを復元",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "블록체인 유지 관리 작업이 이미 진행 중입니다.",
|
||||
"appx_blockchain_rescan_complete": "블록체인 다시 스캔 완료",
|
||||
"appx_bootstrap_complete_reconciling": "부트스트랩 완료 — 새 체인 데이터와 지갑을 대조하는 중입니다.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "부트스트랩 체인 데이터와 지갑을 대조하지 못했습니다. 노드가 사용 중이어서 보유한 블록을 확인할 수 없었습니다. 동기화가 따라잡으면 설정에서 재스캔을 실행하세요.",
|
||||
"appx_cancel": "취소",
|
||||
"appx_cleaning_up": "정리하는 중...",
|
||||
"appx_confirm_your_backup": "백업 확인",
|
||||
@@ -1597,6 +1598,7 @@
|
||||
"rescan_bootstrapped_msg": "노드가 부트스트랩되어 스냅샷 이전 블록은 디스크에 없으므로 제네시스부터의 재스캔은 실패합니다. 지갑의 사용된 잔액을 조정하려면 스냅샷에 포함된 높이부터 재스캔하세요. wallet.dat과 체인 데이터는 삭제되지 않습니다.",
|
||||
"rescan_detecting": "노드가 디스크에 어떤 블록을 가지고 있는지 확인하는 중…",
|
||||
"rescan_from_height": "재스캔 시작 블록 높이:",
|
||||
"rescan_probe_failed": "노드가 디스크에 어떤 블록을 가지고 있는지 확인하지 못했습니다(노드가 사용 중이거나 요청 시간이 초과되었습니다). 이 노드가 부트스트랩된 경우 취소하고 1분 후에 다시 시도하세요. 부트스트랩된 노드에서는 아래의 -rescan 재시작이 실패합니다.",
|
||||
"reset_to_defaults": "기본값으로 재설정",
|
||||
"restarting_after_encryption": "암호화 후 데몬 재시작 중...",
|
||||
"restore_address": "주소 복원",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Uma operação de manutenção da blockchain já está em andamento.",
|
||||
"appx_blockchain_rescan_complete": "Reescaneamento da blockchain concluído",
|
||||
"appx_bootstrap_complete_reconciling": "Bootstrap concluído — reconciliando sua carteira com os novos dados da chain.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Não foi possível reconciliar sua carteira com os dados da chain do bootstrap: o nó ficou ocupado demais para verificar quais blocos tem. Execute Reescanear nos Ajustes quando ele estiver em dia.",
|
||||
"appx_cancel": "Cancelar",
|
||||
"appx_cleaning_up": "Limpando...",
|
||||
"appx_confirm_your_backup": "Confirme seu backup",
|
||||
@@ -1597,6 +1598,7 @@
|
||||
"rescan_bootstrapped_msg": "Seu nó foi inicializado por bootstrap, então os blocos abaixo do snapshot não estão em disco e um reescaneamento a partir do gênesis falharia. Reescaneie a partir de uma altura que seu snapshot inclua para reconciliar o saldo gasto da sua carteira. Seu wallet.dat e os dados da cadeia não são excluídos.",
|
||||
"rescan_detecting": "Verificando quais blocos seu nó tem em disco…",
|
||||
"rescan_from_height": "Reescanear a partir da altura do bloco:",
|
||||
"rescan_probe_failed": "Não foi possível verificar quais blocos seu nó tem em disco (ele está ocupado ou uma solicitação expirou). Se este nó foi inicializado por bootstrap, cancele e tente novamente em um minuto: a reinicialização completa com -rescan abaixo falha em um nó inicializado por bootstrap.",
|
||||
"reset_to_defaults": "Redefinir Padrões",
|
||||
"restarting_after_encryption": "Reiniciando daemon após criptografia...",
|
||||
"restore_address": "Restaurar endereço",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Операция обслуживания блокчейна уже выполняется.",
|
||||
"appx_blockchain_rescan_complete": "Повторное сканирование блокчейна завершено",
|
||||
"appx_bootstrap_complete_reconciling": "Начальная загрузка завершена — сверка кошелька с новыми данными цепи.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Не удалось сверить кошелёк с данными цепи из бутстрапа: узел был слишком занят, чтобы проверить свои блоки. Запустите Пересканировать в Настройках, когда узел догонит сеть.",
|
||||
"appx_cancel": "Отмена",
|
||||
"appx_cleaning_up": "Очистка...",
|
||||
"appx_confirm_your_backup": "Подтвердите резервную копию",
|
||||
@@ -1597,6 +1598,7 @@
|
||||
"rescan_bootstrapped_msg": "Ваш узел был инициализирован из бутстрапа, поэтому блоки ниже снимка отсутствуют на диске, и пересканирование с генезиса не удастся. Пересканируйте с высоты, входящей в ваш снимок, чтобы согласовать потраченный баланс кошелька. Ваш wallet.dat и данные блокчейна не удаляются.",
|
||||
"rescan_detecting": "Проверка, какие блоки уже есть на диске вашего узла…",
|
||||
"rescan_from_height": "Пересканировать с высоты блока:",
|
||||
"rescan_probe_failed": "Не удалось проверить, какие блоки есть на диске вашего узла (он занят или истекло время ожидания запроса). Если узел был инициализирован из бутстрапа, отмените и повторите попытку через минуту: полный перезапуск с -rescan ниже на таком узле не удастся.",
|
||||
"reset_to_defaults": "Сбросить настройки",
|
||||
"restarting_after_encryption": "Перезапуск демона после шифрования...",
|
||||
"restore_address": "Восстановить адрес",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "已有区块链维护操作正在进行中。",
|
||||
"appx_blockchain_rescan_complete": "区块链重新扫描完成",
|
||||
"appx_bootstrap_complete_reconciling": "引导数据完成——正在将你的钱包与新的链数据进行核对。",
|
||||
"appx_bootstrap_reconcile_probe_failed": "无法将你的钱包与引导数据的链数据进行核对:节点一直太忙,无法检查其已有的区块。待节点同步完成后,请在设置中运行重新扫描。",
|
||||
"appx_cancel": "取消",
|
||||
"appx_cleaning_up": "正在清理…",
|
||||
"appx_confirm_your_backup": "确认你的备份",
|
||||
@@ -1597,6 +1598,7 @@
|
||||
"rescan_bootstrapped_msg": "您的节点使用了引导程序,因此快照以下的区块不在磁盘上,从创世区块重新扫描将失败。请从快照所包含的高度开始重新扫描,以核对钱包的已花费余额。您的 wallet.dat 和区块链数据不会被删除。",
|
||||
"rescan_detecting": "正在检查您的节点在磁盘上已有哪些区块…",
|
||||
"rescan_from_height": "从区块高度重新扫描:",
|
||||
"rescan_probe_failed": "无法检查您的节点在磁盘上已有哪些区块(节点正忙或请求超时)。如果此节点使用了引导程序,请取消并在一分钟后重试:在使用引导程序的节点上,下面的 -rescan 重启将失败。",
|
||||
"reset_to_defaults": "重置为默认值",
|
||||
"restarting_after_encryption": "加密后重启守护进程...",
|
||||
"restore_address": "恢复地址",
|
||||
|
||||
+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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user