feat(node): detect an unreadable block DB on startup and offer a one-click reindex
When a daemon update changes the block-index on-disk format (or the index is
corrupt), dragonxd aborts at startup — "non-canonical optional discriminant" →
"Error loading block database. Aborted." — and the wallet silently shows a zero
balance. Previously the connect loop just crash-restarted into the same abort up
to 3x and then reported a bare "Daemon crashed N times", with no path forward.
Now:
- daemon/daemon_startup_diagnosis.h: pure blockDbOutputLooksBroken() classifies
the crashed node's captured console output (the fatal block-DB markers).
- The connect loop detects it on the FIRST abort, STOPS crash-restarting into the
same failure (each retry reloads the whole index — wasteful), and offers a fix.
- A one-shot -reindex flag (EmbeddedDaemon::setReindexOnNextStart → DaemonController
forwarder → args) rebuilds the block index + chainstate from the intact raw
blocks; App::reindexBlockDatabase() arms it and un-gates the loop to restart.
- An auto-shown dialog (renderBlockDbReindexDialog) + a notification explain the
situation ("your coins are safe; the node just can't load the chain") and offer
a one-click "Rebuild block database". Full-node only (gated), lite-safe.
This is the exact trap behind a real "big wallet shows no funds" report: a
post-format-change daemon over pre-change chaindata. Reindex also fixes a plain
corrupt index.
Adds testBlockDbOutputDiagnosis (the abort sequence + individual markers trip it;
normal startup / wallet-corruption / asmap errors do not). Suite green (1/1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
src/app.cpp
52
src/app.cpp
@@ -2143,6 +2143,7 @@ void App::render()
|
||||
renderDecryptWalletDialog();
|
||||
renderPinDialogs();
|
||||
renderSwitchStopDaemonDialog();
|
||||
renderBlockDbReindexDialog();
|
||||
|
||||
// Render notifications (toast messages)
|
||||
ui::Notifications::instance().render();
|
||||
@@ -4280,6 +4281,37 @@ void App::renderAntivirusHelpDialog()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click
|
||||
// -reindex rebuild instead of leaving the wallet stuck on a silent zero balance.
|
||||
void App::renderBlockDbReindexDialog()
|
||||
{
|
||||
if (!show_block_db_reindex_confirm_) return;
|
||||
|
||||
ui::material::OverlayDialogSpec ov;
|
||||
ov.title = TR("block_db_reindex_title");
|
||||
ov.p_open = &show_block_db_reindex_confirm_; // X / backdrop dismisses (offer remains; loop stays held)
|
||||
ov.style = ui::material::OverlayStyle::BlurFloat;
|
||||
ov.cardWidth = 540.0f;
|
||||
ov.idSuffix = "blockdbreindex";
|
||||
if (!ui::material::BeginOverlayDialog(ov)) return;
|
||||
const float dp = ui::Layout::dpiScale();
|
||||
|
||||
ui::material::DialogWarningHeader(TR("block_db_reindex_warn"));
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||
ImGui::TextWrapped("%s", TR("block_db_reindex_body"));
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||
if (ui::material::TactileButton(TR("block_db_reindex_confirm"), ImVec2(260.0f * dp, 0))) {
|
||||
reindexBlockDatabase(); // clears show_block_db_reindex_confirm_ + block_db_reindex_available_
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
|
||||
show_block_db_reindex_confirm_ = false;
|
||||
// Leave block_db_reindex_available_ set: the connect loop keeps HOLDING (no crash-restart storm)
|
||||
// rather than looping into the same abort; the user can rebuild later from Settings.
|
||||
}
|
||||
ui::material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
void App::renderSwitchStopDaemonDialog()
|
||||
{
|
||||
const bool confirm = show_switch_stop_daemon_confirm_;
|
||||
@@ -6054,4 +6086,24 @@ void App::restartDaemon()
|
||||
});
|
||||
}
|
||||
|
||||
// One-click recovery for an unreadable block database: arm the one-shot -reindex flag, then un-gate the
|
||||
// connect loop (which detected the abort and stopped restarting). Its next attempt calls
|
||||
// startEmbeddedDaemon(), which consumes the flag → the node rebuilds its block index + chainstate from
|
||||
// the raw blocks. The daemon is not running here (it aborted), so no explicit stop/restart is needed.
|
||||
void App::reindexBlockDatabase()
|
||||
{
|
||||
if (!supportsFullNodeLifecycleActions()) {
|
||||
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
||||
return;
|
||||
}
|
||||
if (!daemon_controller_) return;
|
||||
daemon_controller_->setReindexOnNextStart(true);
|
||||
daemon_controller_->resetCrashCount(); // the abort no longer counts against the restart budget
|
||||
show_block_db_reindex_confirm_ = false;
|
||||
block_db_reindex_available_ = false; // un-gate → the connect loop restarts the node with -reindex
|
||||
connection_status_ = TR("sb_starting_daemon");
|
||||
ui::Notifications::instance().info(TR("block_db_reindex_started"), 12.0f);
|
||||
DEBUG_LOGF("[App] Block-database reindex requested — restarting node with -reindex\n");
|
||||
}
|
||||
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -895,6 +895,11 @@ private:
|
||||
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
|
||||
bool show_switch_stop_daemon_confirm_ = false;
|
||||
std::string pending_switch_wallet_file_;
|
||||
// Block-database recovery: set when the embedded node aborts because its block DB is unreadable
|
||||
// (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
|
||||
// connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
|
||||
bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
|
||||
bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
|
||||
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
|
||||
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
|
||||
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
|
||||
@@ -1324,6 +1329,8 @@ private:
|
||||
void renderPinDialogs();
|
||||
void renderAntivirusHelpDialog();
|
||||
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
|
||||
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
|
||||
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
|
||||
void processDeferredEncryption();
|
||||
|
||||
// Private methods - connection
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
#include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics
|
||||
#include "config/version.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "daemon/daemon_startup_diagnosis.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "daemon/seed_wallet_creator.h"
|
||||
#include "daemon/xmrig_manager.h"
|
||||
@@ -501,8 +502,21 @@ void App::tryConnect()
|
||||
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
|
||||
|
||||
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
||||
// If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata
|
||||
// format mismatch after an update, or a corrupt index), crash-restarting just repeats
|
||||
// the same abort — and each attempt reloads the whole index (wasteful). Detect it once
|
||||
// and offer a one-click reindex instead of silently looping into a zero-balance node.
|
||||
if (!block_db_reindex_available_ && daemon_controller_ && daemon_controller_->daemon() &&
|
||||
daemon::blockDbOutputLooksBroken(daemon_controller_->daemon()->getOutput())) {
|
||||
block_db_reindex_available_ = true;
|
||||
show_block_db_reindex_confirm_ = true;
|
||||
ui::Notifications::instance().error(TR("block_db_reindex_notify"), 20.0f);
|
||||
VERBOSE_LOGF("[connect #%d] Block database unreadable — offering a one-click reindex\n", attempt);
|
||||
}
|
||||
// Prevent infinite crash-restart loop
|
||||
if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
|
||||
if (block_db_reindex_available_) {
|
||||
connection_status_ = TR("sb_block_db_unreadable"); // hold; awaiting the rebuild choice
|
||||
} else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
|
||||
if (wallet_switch_pending_confirm_.load()) {
|
||||
// The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
|
||||
// fails LATE in init, past the fast start grace) — revert to the previous
|
||||
|
||||
@@ -126,6 +126,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
|
||||
daemon_->setSalvageOnNextStart(enabled);
|
||||
}
|
||||
|
||||
void DaemonController::setReindexOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setReindexOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::zapOnNextStart() const
|
||||
{
|
||||
return daemon_->zapOnNextStart();
|
||||
|
||||
@@ -108,6 +108,7 @@ public:
|
||||
void setZapOnNextStart(bool enabled);
|
||||
bool zapOnNextStart() const;
|
||||
void setSalvageOnNextStart(bool enabled);
|
||||
void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
|
||||
|
||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||
bool externalDaemonDetected,
|
||||
|
||||
30
src/daemon/daemon_startup_diagnosis.h
Normal file
30
src/daemon/daemon_startup_diagnosis.h
Normal file
@@ -0,0 +1,30 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// daemon_startup_diagnosis.h — pure classifiers over a crashed daemon's captured console output,
|
||||
// so the app can offer a targeted one-click fix instead of a bare "daemon crashed" / silent no-funds.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
// True when dragonxd aborted because its BLOCK DATABASE could not be loaded — either a
|
||||
// daemon-vs-chaindata serialization-format mismatch after a daemon update (the deterministic
|
||||
// "non-canonical optional discriminant" → "Error loading block database" → "Aborted block database
|
||||
// rebuild. Exiting." sequence) or a genuinely corrupt/incomplete block index. In BOTH cases the fix
|
||||
// is the same: `-reindex` rebuilds the index + chainstate from the intact raw blocks (blk*.dat).
|
||||
// This is what otherwise silently presents as a wallet with zero balance — the node never starts.
|
||||
inline bool blockDbOutputLooksBroken(const std::string& out)
|
||||
{
|
||||
return out.find("Error loading block database") != std::string::npos
|
||||
|| out.find("non-canonical optional discriminant") != std::string::npos
|
||||
|| out.find("Aborted block database rebuild") != std::string::npos
|
||||
|| out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value"
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -571,6 +571,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-rescan");
|
||||
}
|
||||
|
||||
// -reindex rebuilds the block index + chainstate from the raw blocks (fixes an unreadable/format-
|
||||
// mismatched block DB). It's about the CHAIN, not the wallet, so it's independent of the wallet-repair
|
||||
// chain above (and implies its own wallet rescan). One-shot, consumed here.
|
||||
if (reindex_on_next_start_.exchange(false)) {
|
||||
DEBUG_LOGF("[INFO] Adding -reindex flag to rebuild the block database from raw blocks\n");
|
||||
args.push_back("-reindex");
|
||||
}
|
||||
|
||||
// One-shot isolated-datadir override (migrate-to-seed flow): run this start against a
|
||||
// throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts
|
||||
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
|
||||
|
||||
@@ -206,6 +206,13 @@ public:
|
||||
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
||||
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
|
||||
|
||||
// -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot,
|
||||
// consumed on the next start. Offered when the node aborts on an unreadable block database (a
|
||||
// daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet
|
||||
// rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags.
|
||||
void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; }
|
||||
bool reindexOnNextStart() const { return reindex_on_next_start_.load(); }
|
||||
|
||||
/**
|
||||
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
|
||||
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
|
||||
@@ -306,6 +313,7 @@ private:
|
||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
||||
std::atomic<bool> reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB)
|
||||
std::string override_datadir_; // one-shot: -datadir for the next start
|
||||
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
||||
bool skip_port_check_ = false; // isolated instance on a non-default port
|
||||
|
||||
@@ -1185,6 +1185,14 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it.";
|
||||
strings_["switch_corrupt_repair"] = "Try to repair (salvage)";
|
||||
|
||||
// Block-database recovery (offered when the node aborts on an unreadable/format-mismatched block DB).
|
||||
strings_["block_db_reindex_title"] = "Rebuild block database?";
|
||||
strings_["block_db_reindex_warn"] = "The node can't read its block database.";
|
||||
strings_["block_db_reindex_body"] = "This usually happens after a daemon update changes the on-disk format, or if the block index is damaged. Your wallet and coins are safe — the node just can't load the chain, so balances show as zero.\n\nRebuilding re-reads your existing block files and can take a while (it also rescans your wallet). Nothing is downloaded.";
|
||||
strings_["block_db_reindex_confirm"] = "Rebuild block database";
|
||||
strings_["block_db_reindex_notify"] = "The node can't read its block database (often after a daemon update). Rebuild it to restore your balance — see the prompt, or Settings › Node.";
|
||||
strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while.";
|
||||
|
||||
// Receive Tab
|
||||
strings_["receiving_addresses"] = "Your Receiving Addresses";
|
||||
strings_["new_z_shielded"] = "New z-Address (Shielded)";
|
||||
@@ -1309,6 +1317,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["sb_connecting_err"] = "Connecting to daemon — %s";
|
||||
strings_["sb_daemon_crashed"] = "Daemon crashed %d times";
|
||||
strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd";
|
||||
strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required";
|
||||
// Persistent node-status banner (App::renderNodeStatusBanner).
|
||||
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
|
||||
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
#include "data/seed_migration_resume.h"
|
||||
#include "util/address_validation.h"
|
||||
#include "util/seed_phrase.h"
|
||||
#include "daemon/daemon_startup_diagnosis.h"
|
||||
#include "util/amount_format.h"
|
||||
#include "util/payment_uri.h"
|
||||
#include "util/platform.h"
|
||||
@@ -6114,6 +6115,32 @@ void testSeedPhraseHelpers()
|
||||
EXPECT_EQ(normalizeSeedPhrase(words24).find("\xC2\xA0"), std::string::npos);
|
||||
}
|
||||
|
||||
// Block-DB abort detection: classify a crashed daemon's console output so the app can offer a
|
||||
// one-click reindex instead of silently showing a zero balance (a daemon-vs-chaindata format break).
|
||||
void testBlockDbOutputDiagnosis()
|
||||
{
|
||||
using dragonx::daemon::blockDbOutputLooksBroken;
|
||||
|
||||
// The exact abort sequence we observed on a format-mismatched datadir.
|
||||
EXPECT_TRUE(blockDbOutputLooksBroken(
|
||||
"Opened LevelDB successfully\n"
|
||||
"GetValue: CDataStream error - non-canonical optional discriminant: iostream error\n"
|
||||
"ERROR: LoadBlockIndex() : failed to read value\n"
|
||||
": Error loading block database.\n"
|
||||
"Aborted block database rebuild. Exiting.\n"));
|
||||
// Each individual fatal marker also trips it (partial capture / different phrasing).
|
||||
EXPECT_TRUE(blockDbOutputLooksBroken("... : Error loading block database."));
|
||||
EXPECT_TRUE(blockDbOutputLooksBroken("Aborted block database rebuild. Exiting."));
|
||||
EXPECT_TRUE(blockDbOutputLooksBroken("ERROR: LoadBlockIndex() : failed to read value"));
|
||||
|
||||
// Normal startup / other failures must NOT be misread as a block-DB problem (no false reindex offer).
|
||||
EXPECT_FALSE(blockDbOutputLooksBroken(
|
||||
"Loading block index...\nVerifying blocks...\nLoading wallet...\nRescanning...\nDone loading\n"));
|
||||
EXPECT_FALSE(blockDbOutputLooksBroken("Error loading wallet")); // wallet corruption → salvage, not reindex
|
||||
EXPECT_FALSE(blockDbOutputLooksBroken("Error: Could not find any asmap file!"));
|
||||
EXPECT_FALSE(blockDbOutputLooksBroken(""));
|
||||
}
|
||||
|
||||
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
|
||||
void testLiteServerProbeLive()
|
||||
{
|
||||
@@ -7363,6 +7390,7 @@ int main()
|
||||
testAddressChecksumValidation();
|
||||
testPrivateKeyImportRecognition();
|
||||
testSeedPhraseHelpers();
|
||||
testBlockDbOutputDiagnosis();
|
||||
testLiteServerProbeLive();
|
||||
testXmrigLiveInstall();
|
||||
testGeneratedResourceBehavior();
|
||||
|
||||
Reference in New Issue
Block a user