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();