From dbeae3ac98b3c08aab1f9a1836a3175482a75f3f Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 7 Jun 2026 16:53:24 -0500 Subject: [PATCH] feat(lite): async wallet open with server failover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening an existing lite wallet ran synchronously on the UI thread and used a single server, so a dead/unreachable lightwalletd server froze startup for the connect timeout and then stranded the wallet ("disconnected" spinner) — and the DragonX lite servers are flaky (often several down at once). Add LiteWalletController::beginOpenExisting() / pumpAsyncOpen(): the open runs on a background thread (mirroring the sync/broadcast shared-lifetime pattern — it captures only shared_ptrs + value copies, never `this`), trying the preferred server first and then every other usable default until one succeeds. The main thread finalizes the result (flips walletOpen, starts sync) or records the reason. The rollout gate is still checked up-front on the main thread. App: auto-open now calls beginOpenExisting() and pumps it each tick, retrying on a 20s interval so a transient outage self-heals once a server returns; a failed open surfaces its reason (notification + Network tab) instead of a silent spinner. Tested: a fake bridge that fails specific servers exercises both preferred-dead -> fallback-opens and all-dead -> fails-with-reason. Built clean for full-node, lite, and Windows cross-compile. Co-Authored-By: Claude Opus 4.8 --- src/app.cpp | 44 ++++++----- src/app.h | 1 + src/wallet/lite_wallet_controller.cpp | 101 ++++++++++++++++++++++++++ src/wallet/lite_wallet_controller.h | 26 +++++++ tests/fake_lite_backend.h | 14 +++- tests/test_phase4.cpp | 53 ++++++++++++++ 6 files changed, 219 insertions(+), 20 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 61ddf34..e65ccb4 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -480,25 +480,21 @@ void App::update() // Apply any lite-wallet refresh the controller's background worker produced (main thread). if (lite_wallet_) { - // Auto-open an existing wallet once, after the window is up. initialize_existing needs no - // passphrase (it just loads the file); a previously-synced wallet then resumes from its - // saved height (fast) rather than re-scanning from the checkpoint. - if (!lite_autoopen_done_) { + // Finalize any completed async open on the main thread (flips walletOpen / surfaces failure). + lite_wallet_->pumpAsyncOpen(); + + // Auto-open an existing wallet asynchronously with server failover (initialize_existing + // needs no passphrase — it just loads the file and contacts the server). Running it off + // the UI thread means an unreachable server never freezes startup, and trying the other + // default servers means one dead server no longer strands the wallet. Retried on an + // interval so a transient outage self-heals once a server comes back. + const double nowSecs = ImGui::GetTime(); + if (!lite_wallet_->walletOpen() && !lite_wallet_->openInProgress() && + lite_wallet_->walletExists() && + (!lite_autoopen_done_ || nowSecs - lite_open_last_attempt_ > 20.0)) { lite_autoopen_done_ = true; - if (!lite_wallet_->walletOpen() && lite_wallet_->walletExists()) { - const auto openResult = lite_wallet_->openWallet(wallet::LiteWalletOpenRequest{}); - // Surface why an existing wallet failed to open (e.g. the lightwalletd server - // is unreachable) — otherwise the UI just shows a silent "disconnected" spinner. - if (!openResult.ok && !openResult.walletReady) { - lite_open_error_ = !openResult.error.empty() ? openResult.error - : (!openResult.status.message.empty() - ? openResult.status.message - : "Could not open the wallet"); - DEBUG_LOGF("[Lite] auto-open failed: %s\n", lite_open_error_.c_str()); - ui::Notifications::instance().error( - std::string("Wallet open failed: ") + lite_open_error_, 8.0f); - } - } + lite_open_last_attempt_ = nowSecs; + lite_wallet_->beginOpenExisting(); } // Lite has no RPC daemon (tryConnect() is a no-op in lite builds), so derive the app's @@ -506,7 +502,17 @@ void App::update() // A wallet is open only after a successful backend init against the lite server, so this // is a non-blocking proxy for "lite backend operational". state_.connected = lite_wallet_->walletOpen(); - if (state_.connected) lite_open_error_.clear(); // opened successfully — clear any prior error + if (state_.connected) { + lite_open_error_.clear(); // opened successfully — clear any prior error + } else { + // Surface a failed open (e.g. server unreachable) once per distinct reason, so the + // disconnected state isn't silent. + const std::string& err = lite_wallet_->lastOpenError(); + if (!err.empty() && err != lite_open_error_) { + lite_open_error_ = err; + ui::Notifications::instance().error(std::string("Wallet open failed: ") + err, 8.0f); + } + } // Suppress the status bar's full-node connection-detail line in lite ("" and "Connected" // are both hidden); the connected/no-wallet indicator + sync status convey lite state. connection_status_ = state_.connected ? "Connected" : ""; diff --git a/src/app.h b/src/app.h index 1a7ef1f..0e27f28 100644 --- a/src/app.h +++ b/src/app.h @@ -444,6 +444,7 @@ private: // One-shot guard: auto-open an existing lite wallet on the first update() tick (kept off // init() so a slow initialize_existing network call doesn't freeze startup before the window). bool lite_autoopen_done_ = false; + double lite_open_last_attempt_ = 0.0; // ImGui time of the last async open attempt (retry timer) // Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in // the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens. std::string lite_open_error_; diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index f45dd6e..509b594 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -6,6 +6,7 @@ #include "../data/wallet_state.h" +#include #include #include #include @@ -229,6 +230,7 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities, LiteWalletControllerOptions options) : bridge_(std::make_shared(std::move(bridge))), chainName_(connectionSettings.chainName), + connectionSettings_(connectionSettings), lifecycle_(capabilities, connectionSettings, bridge_.get(), LiteWalletLifecycleOptions{options.allowBridgeCalls, options.rolloutBlocked, options.rolloutMessage}), @@ -257,6 +259,9 @@ LiteWalletController::~LiteWalletController() // Likewise the broadcast thread (send/shield proving): it captures shared refs (bridge + // running flag + result slot), never `this`, so detaching is safe. if (broadcastThread_.joinable()) broadcastThread_.detach(); + // The async-open failover thread captures only shared refs (bridge + running flag + result + // slot), never `this`, so detaching is safe if it's still trying servers at shutdown. + if (openThread_.joinable()) openThread_.detach(); } std::unique_ptr LiteWalletController::createLinked( @@ -282,6 +287,102 @@ void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& re } } +std::vector LiteWalletController::failoverServerUrls() const +{ + std::vector urls; + const auto add = [&urls](const std::string& url) { + if (!isLiteServerUrlUsable(url)) return; + if (std::find(urls.begin(), urls.end(), url) == urls.end()) urls.push_back(url); + }; + // Preferred server first (honours the user's sticky/random selection), then every other + // usable configured server as a fallback so one dead server can't strand the wallet. + const auto selected = selectLiteServer(connectionSettings_); + if (selected.ok) add(selected.server.url); + for (const auto& endpoint : connectionSettings_.servers) { + if (endpoint.enabled) add(endpoint.url); + } + return urls; +} + +bool LiteWalletController::beginOpenExisting() +{ + if (walletOpen_.load() || openRunning_->load()) return false; + if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) { + const std::string& reason = lifecycle_.status().message; + status_ = WalletBackendStatus{WalletBackendState::Error, + reason.empty() ? "lite wallet is not available" : reason, {}, {}, 0.0}; + lastOpenError_ = status_.message; + return false; + } + auto servers = failoverServerUrls(); + if (servers.empty()) { + status_ = WalletBackendStatus{WalletBackendState::Error, + "no usable lite servers are configured", {}, {}, 0.0}; + lastOpenError_ = status_.message; + return false; + } + + if (openThread_.joinable()) openThread_.join(); // a prior attempt has fully finished + { + std::lock_guard lk(*openResultMutex_); + openResult_->reset(); + } + openRunning_->store(true); + status_ = WalletBackendStatus{WalletBackendState::Connecting, "opening wallet", {}, {}, 0.0}; + + // Capture only shared refs + value copies (never `this`) so the thread can safely outlive us. + auto bridge = bridge_; + auto running = openRunning_; + auto resultMutex = openResultMutex_; + auto resultSlot = openResult_; + openThread_ = std::thread([bridge, servers, running, resultMutex, resultSlot]() { + OpenOutcome outcome; + outcome.error = "could not reach any lite server"; + for (const auto& url : servers) { + if (!bridge) break; + // initialize_existing loads the wallet file but contacts the server to start the + // light client; ok && non-empty value == ready (mirrors the lifecycle's success test). + const auto call = bridge->initializeExisting(/*dangerous=*/false, url); + if (call.ok && !call.value.empty()) { + outcome.ok = true; + outcome.serverUrl = url; + break; + } + if (!call.error.empty()) outcome.error = call.error; + } + { + std::lock_guard lk(*resultMutex); + *resultSlot = outcome; + } + running->store(false); + }); + return true; +} + +void LiteWalletController::pumpAsyncOpen() +{ + OpenOutcome outcome; + { + std::lock_guard lk(*openResultMutex_); + if (!openResult_->has_value()) return; // nothing finished since last pump + outcome = std::move(openResult_->value()); + openResult_->reset(); + } + if (openThread_.joinable()) openThread_.join(); // producer set its result, then exits + + if (outcome.ok) { + walletOpen_ = true; + lastOpenError_.clear(); + status_ = WalletBackendStatus{WalletBackendState::Ready, "wallet open", {}, {}, 0.0}; + if (persist_) persist_(); + startSync(); // begin background sync on the backend + startWorker(); // begin periodic refresh -> WalletState + } else { + lastOpenError_ = outcome.error; + status_ = WalletBackendStatus{WalletBackendState::Error, outcome.error, {}, {}, 0.0}; + } +} + void LiteWalletController::startSync() { if (syncLaunched_.exchange(true)) return; diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index 1a813f1..2883239 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -161,6 +161,19 @@ public: LiteWalletLifecycleResult openWallet(LiteWalletOpenRequest request); LiteWalletLifecycleResult restoreWallet(LiteWalletRestoreRequest request); + // Asynchronous open of an EXISTING wallet WITH server failover: tries the selected lite + // server first, then the other usable defaults, on a background thread — so an unreachable + // server never freezes the UI and a single dead server no longer strands the wallet. + // Non-blocking; call pumpAsyncOpen() each tick to finalize. Returns false if a wallet is + // already open, an open is already in progress, no wallet exists, or the rollout gate blocks + // lite. On all-servers-fail, status()/lastOpenError() report the reason. + bool beginOpenExisting(); + // Finalize a completed async open on the calling (main) thread: flips walletOpen()/status and + // starts sync, or records the failure. Cheap no-op when nothing is pending. Call each tick. + void pumpAsyncOpen(); + bool openInProgress() const { return openRunning_ && openRunning_->load(); } + const std::string& lastOpenError() const { return lastOpenError_; } + bool syncStarted() const { return syncStarted_; } bool syncComplete() const { return syncDone_ && syncDone_->load(); } @@ -249,6 +262,7 @@ private: // mutex would instead serialize sync against syncstatus polling and defeat the design. std::shared_ptr bridge_; std::string chainName_; // backend chain id (for walletExists); from connection settings + LiteConnectionSettings connectionSettings_; // kept for the failover candidate-server list LiteWalletLifecycleService lifecycle_; LiteWalletGateway gateway_; LiteSyncService sync_; @@ -272,6 +286,18 @@ private: std::shared_ptr> broadcastResult_ = std::make_shared>(); + // Asynchronous open with server failover (mirrors the sync/broadcast shared-lifetime pattern: + // the detached thread captures only shared_ptrs + value copies, never `this`, so it can + // safely outlive the controller). pumpAsyncOpen() finalizes the result on the main thread. + std::vector failoverServerUrls() const; + struct OpenOutcome { bool ok = false; std::string serverUrl; std::string error; }; + std::thread openThread_; + std::shared_ptr> openRunning_ = std::make_shared>(false); + std::shared_ptr openResultMutex_ = std::make_shared(); + std::shared_ptr> openResult_ = + std::make_shared>(); + std::string lastOpenError_; // main-thread only + // Joinable background refresh worker (fast iterations: syncstatus, plus data once synced). std::thread worker_; std::atomic running_{false}; diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index 3c492e7..4b1e497 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -38,12 +38,15 @@ inline std::atomic g_liteFakeSendFails{false}; // when true, "send"/"shie inline std::atomic g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks) inline std::atomic g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...) inline std::atomic g_liteFakeLocked{false}; // spending-keys-locked state +// initialize_existing fails for any server URL containing this substring (open-failover tests). +inline std::string g_liteFakeDeadServerSubstr; inline void resetLiteFakeCounters() { g_liteFakeAlloc = 0; g_liteFakeFreed = 0; g_liteFakeShutdownCalled = false; + g_liteFakeDeadServerSubstr.clear(); } inline char* liteFakeDup(const char* s) @@ -67,7 +70,16 @@ inline char* liteFakeInitFromPhrase(bool, const char*, const char*, { return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}"); } -inline char* liteFakeInitExisting(bool, const char*) { return liteFakeDup("OK"); } +// initialize_existing fails (server unreachable) for any server URL containing +// g_liteFakeDeadServerSubstr, so tests can exercise the controller's open-with-failover. +inline char* liteFakeInitExisting(bool, const char* server) +{ + if (!g_liteFakeDeadServerSubstr.empty() && server && + std::string(server).find(g_liteFakeDeadServerSubstr) != std::string::npos) { + return liteFakeDup("Error: could not connect to server"); // bridge maps to ok=false + } + return liteFakeDup("OK"); +} inline char* liteFakeExecute(const char* command, const char* args) { // new-address generation returns a JSON array with the new address (type from args: zs/R). diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 3c48c57..7fc4569 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3538,6 +3538,58 @@ void testLiteChainNameMigration() fs::remove_all(dir, ec); } +// Async open with server failover: when the preferred lite server is unreachable, the controller +// transparently opens against the next usable one; only an all-servers-down case fails (and then +// surfaces a reason). Open runs off the UI thread, finalized by pumpAsyncOpen() on the main thread. +void testLiteWalletControllerOpenFailover() +{ + using namespace dragonx::wallet; + const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + + LiteConnectionSettings conn; + conn.chainName = "main"; + conn.servers = { + LiteServerEndpoint{"https://dead.example", "Dead", true}, + LiteServerEndpoint{"https://good.example", "Good", true}, + }; + conn.selectionMode = LiteServerSelectionMode::Sticky; + conn.stickyServerUrl = "https://dead.example"; // the PREFERRED server is the dead one + + // Preferred server dead, fallback good -> the wallet opens via the fallback. + { + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeWalletExists = true; + dragonx::test::g_liteFakeDeadServerSubstr = "dead.example"; + LiteWalletController controller(liteCaps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + EXPECT_TRUE(controller.beginOpenExisting()); + for (int i = 0; i < 400 && controller.openInProgress(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + EXPECT_FALSE(controller.openInProgress()); + controller.pumpAsyncOpen(); + EXPECT_TRUE(controller.walletOpen()); + EXPECT_TRUE(controller.lastOpenError().empty()); + } + + // All servers dead -> open fails, wallet stays closed, reason surfaced. + { + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeWalletExists = true; + dragonx::test::g_liteFakeDeadServerSubstr = "example"; // both servers fail + LiteWalletController controller(liteCaps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + EXPECT_TRUE(controller.beginOpenExisting()); + for (int i = 0; i < 400 && controller.openInProgress(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + controller.pumpAsyncOpen(); + EXPECT_FALSE(controller.walletOpen()); + EXPECT_TRUE(!controller.lastOpenError().empty()); + } + + dragonx::test::g_liteFakeWalletExists = false; + dragonx::test::g_liteFakeDeadServerSubstr.clear(); +} + // M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop // the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address // split, transaction typing, confirmations, and sync progress. @@ -4419,6 +4471,7 @@ int main() testLiteClientBridgeUsesRuntimeOwnedStringCleanup(); testLiteBackendInjectableFakeBridge(); testLiteWalletControllerLifecycle(); + testLiteWalletControllerOpenFailover(); testLiteWalletControllerM4(); testLiteWalletControllerM5Persistence(); testLiteWalletControllerEncryption();