From 6f9123f651664ad12011eabfba4b97828f6e2d34 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 8 Jun 2026 11:42:47 -0500 Subject: [PATCH] feat(lite): async + failover for Settings-page create/open/restore The Settings page drove the controller's synchronous createWallet/openWallet/ restoreWallet, which blocks the UI thread on the (often flaky) lightwalletd and gives up after the first server. Add a generic async lifecycle path that mirrors the async-open failover but carries the full request (passphrase, restore seed/ birthday/account/overwrite): - beginCreateWalletAsync / beginOpenWalletAsync / beginRestoreWalletAsync run on a detached thread that builds its OWN local LiteWalletLifecycleService from captured value copies + the shared bridge (never `this`, so it can safely outlive the controller). Each request type's serverUrl override field feeds the failover: try the preferred server, then every other usable default; stop on the first ready wallet or a structural block; keep the preferred server's error on total failure. The request's secrets are wiped once the attempt finishes. - pumpLifecycleResult() finalizes on the main thread (flip walletOpen, persist, start sync) and caches the result for the UI; wired into App::update next to pumpAsyncOpen(). beginAsyncLifecycle() now also yields to an in-flight lifecycle request so the auto-open loop can't race it on the same bridge. - settings_page kicks off the async op, disables the button while in flight, and polls the cached result each frame for the status/summary. Tests: testLiteWalletControllerAsyncLifecycleFailover covers async create (with passphrase) and restore failing over preferred->fallback, plus all-servers-down. Co-Authored-By: Claude Opus 4.8 --- src/app.cpp | 2 + src/ui/pages/settings_page.cpp | 61 +++++++--- src/wallet/lite_wallet_controller.cpp | 155 +++++++++++++++++++++++++- src/wallet/lite_wallet_controller.h | 36 ++++++ tests/test_phase4.cpp | 76 +++++++++++++ 5 files changed, 315 insertions(+), 15 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 0c2e32f..8ab89d1 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -486,6 +486,8 @@ void App::update() if (lite_wallet_) { // Finalize any completed async open on the main thread (flips walletOpen / surfaces failure). lite_wallet_->pumpAsyncOpen(); + // Likewise finalize any completed async lifecycle request (Settings-page create/open/restore). + lite_wallet_->pumpLifecycleResult(); // 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 diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 0e9aaa8..e0ac9cd 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -115,6 +115,9 @@ struct SettingsPageState { bool lite_restore_overwrite = false; std::string lite_lifecycle_status; std::string lite_lifecycle_summary; + // True while an async create/open/restore is in flight (Settings drives the controller's + // async lifecycle path so a flaky server never freezes the UI; the result is polled each frame). + bool lite_lifecycle_pending = false; // Backup & keys (only populated for an open lite wallet). lite_export_secret holds the // revealed seed/private-keys backup and is SECRET: securely wiped on hide / new export / // import. lite_import_key is the import input buffer (wiped right after submission). @@ -231,30 +234,34 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) { } } - // When a linked lite backend is present, execute the operation for real through the - // App-owned controller. Otherwise fall back to the validation-only adapter. + // When a linked lite backend is present, execute the operation for real through the App-owned + // controller — ASYNCHRONOUSLY, with server failover, so an unreachable/flaky server never + // freezes the UI. The request (with its secrets) is moved into the controller; the page polls + // the outcome each frame in renderLiteLifecyclePending() below. (Secret wiping of the page char + // buffers + the moved-from request copies is handled by liteSecretScrubber at function exit.) if (auto* lite = app->liteWallet()) { - wallet::LiteWalletLifecycleResult result; + bool started = false; switch (input.request.operation) { case wallet::LiteWalletLifecycleOperation::CreateNew: - result = lite->createWallet(input.request.createRequest); + started = lite->beginCreateWalletAsync(std::move(input.request.createRequest)); break; case wallet::LiteWalletLifecycleOperation::OpenExisting: - result = lite->openWallet(input.request.openRequest); + started = lite->beginOpenWalletAsync(std::move(input.request.openRequest)); break; case wallet::LiteWalletLifecycleOperation::RestoreFromSeed: - result = lite->restoreWallet(input.request.restoreRequest); + started = lite->beginRestoreWalletAsync(std::move(input.request.restoreRequest)); break; } - - // (Secret wiping is handled unconditionally by liteSecretScrubber at function exit.) - s_settingsState.lite_lifecycle_summary = result.bridgeResponseRedacted; - if (result.walletReady) { - s_settingsState.lite_lifecycle_status = "Wallet ready"; + if (started) { + s_settingsState.lite_lifecycle_pending = true; + s_settingsState.lite_lifecycle_status = "Working…"; + s_settingsState.lite_lifecycle_summary.clear(); } else { - s_settingsState.lite_lifecycle_status = result.error.empty() - ? result.status.message - : result.error; + // Rejected before any thread launched (wallet already open, an attempt in flight, or no + // usable server). The controller's status carries the reason. + s_settingsState.lite_lifecycle_status = lite->status().message.empty() + ? "Could not start the operation" + : lite->status().message; Notifications::instance().warning(s_settingsState.lite_lifecycle_status); } return; @@ -1600,10 +1607,36 @@ void RenderSettingsPage(App* app) { sizeof(s_settingsState.lite_lifecycle_passphrase), ImGuiInputTextFlags_Password); + // Poll a completed async create/open/restore (driven by the App-owned + // controller; finalized on the main thread by App::update's + // pumpLifecycleResult()). While in flight the button is disabled. + if (s_settingsState.lite_lifecycle_pending) { + if (auto* lite = app->liteWallet()) { + if (!lite->lifecycleRequestInProgress()) { + s_settingsState.lite_lifecycle_pending = false; + const auto& result = lite->lastLifecycleResult(); + s_settingsState.lite_lifecycle_summary = result.bridgeResponseRedacted; + if (result.walletReady) { + s_settingsState.lite_lifecycle_status = "Wallet ready"; + } else { + s_settingsState.lite_lifecycle_status = result.error.empty() + ? result.status.message + : result.error; + Notifications::instance().warning(s_settingsState.lite_lifecycle_status); + } + } + } else { + s_settingsState.lite_lifecycle_pending = false; // backend went away + } + } + ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); + const bool liteLifecycleBusy = s_settingsState.lite_lifecycle_pending; + if (liteLifecycleBusy) ImGui::BeginDisabled(); if (TactileButton("Validate##LiteLifecycleValidate", ImVec2(0, 0), S.resolveFont("button"))) { evaluateLiteLifecycleRequestFromPageState(app); } + if (liteLifecycleBusy) ImGui::EndDisabled(); if (!s_settingsState.lite_lifecycle_status.empty()) { ImGui::SameLine(0, Layout::spacingSm()); diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index bde44af..03d2b86 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -242,6 +242,8 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities, : bridge_(std::make_shared(std::move(bridge))), chainName_(connectionSettings.chainName), connectionSettings_(connectionSettings), + capabilities_(capabilities), + lifecycleOptions_{options.allowBridgeCalls, options.rolloutBlocked, options.rolloutMessage}, lifecycle_(capabilities, connectionSettings, bridge_.get(), LiteWalletLifecycleOptions{options.allowBridgeCalls, options.rolloutBlocked, options.rolloutMessage}), @@ -273,6 +275,9 @@ LiteWalletController::~LiteWalletController() // 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(); + // The async full-lifecycle thread builds its own local lifecycle service from captured value + // copies + the shared bridge (never `this`), so detaching is likewise safe. + if (lifecycleThread_.joinable()) lifecycleThread_.detach(); } std::unique_ptr LiteWalletController::createLinked( @@ -325,7 +330,8 @@ bool LiteWalletController::beginCreateWallet() { return beginAsyncLifecycle(/*cr bool LiteWalletController::beginAsyncLifecycle(bool create) { - if (walletOpen_.load() || openRunning_->load()) return false; + // Don't race a Settings-page async lifecycle request (create/open/restore) on the same bridge. + if (walletOpen_.load() || openRunning_->load() || lifecycleRunning_->load()) return false; const char* verb = create ? "Create" : "Open"; if (lifecycle_.availability() != LiteWalletLifecycleAvailability::Ready) { const std::string& reason = lifecycle_.status().message; @@ -429,6 +435,153 @@ void LiteWalletController::pumpAsyncOpen() } } +bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request) +{ + auto req = std::make_shared(std::move(request)); + return beginLifecycleRequestAsync( + "Create", + [req](LiteWalletLifecycleService& svc, const std::string& url) { + req->serverUrl = url; + return svc.createWallet(*req); + }, + [req]() { secureWipeLiteSecret(req->passphrase); }); +} + +bool LiteWalletController::beginOpenWalletAsync(LiteWalletOpenRequest request) +{ + auto req = std::make_shared(std::move(request)); + return beginLifecycleRequestAsync( + "Open", + [req](LiteWalletLifecycleService& svc, const std::string& url) { + req->serverUrl = url; + return svc.openWallet(*req); + }, + [req]() { secureWipeLiteSecret(req->passphrase); }); +} + +bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest request) +{ + auto req = std::make_shared(std::move(request)); + return beginLifecycleRequestAsync( + "Restore", + [req](LiteWalletLifecycleService& svc, const std::string& url) { + req->serverUrl = url; + return svc.restoreWallet(*req); + }, + [req]() { + secureWipeLiteSecret(req->seedPhrase); + secureWipeLiteSecret(req->passphrase); + }); +} + +bool LiteWalletController::beginLifecycleRequestAsync( + const char* verb, + std::function exec, + std::function wipeSecrets) +{ + // Reject (and still wipe the caller's secrets) if a wallet is already open or any async + // open/lifecycle attempt is in flight. + if (walletOpen_.load() || lifecycleRunning_->load() || openRunning_->load()) { + if (wipeSecrets) wipeSecrets(); + return false; + } + auto servers = failoverServerUrls(); + if (servers.empty()) { + if (wipeSecrets) wipeSecrets(); + status_ = WalletBackendStatus{WalletBackendState::Error, + "no usable lite servers are configured", {}, {}, 0.0}; + lastOpenError_ = status_.message; + liteLog(std::string(verb) + " blocked: " + lastOpenError_); + return false; + } + + if (lifecycleThread_.joinable()) lifecycleThread_.join(); // a prior attempt has fully finished + { + std::lock_guard lk(*lifecycleResultMutex_); + lifecycleResult_->reset(); + } + lifecycleRunning_->store(true); + status_ = WalletBackendStatus{WalletBackendState::Connecting, + std::string(verb) + " wallet…", {}, {}, 0.0}; + liteLog(std::string(verb) + " wallet — trying " + std::to_string(servers.size()) + " server(s)"); + + // Capture only value copies + the shared bridge (never `this`): the thread builds its own + // local lifecycle service, so it can safely outlive the controller (mirrors the open thread). + auto bridge = bridge_; + auto caps = capabilities_; + auto conn = connectionSettings_; + auto opts = lifecycleOptions_; + auto running = lifecycleRunning_; + auto resultMutex = lifecycleResultMutex_; + auto resultSlot = lifecycleResult_; + lifecycleThread_ = std::thread( + [bridge, caps, conn, opts, servers, exec, wipeSecrets, running, resultMutex, resultSlot]() { + LiteWalletLifecycleService localLifecycle(caps, conn, bridge.get(), opts); + LiteWalletLifecycleResult chosen; + bool have = false; + for (const auto& url : servers) { + if (!bridge) break; + liteLog(" connecting to " + url + " ..."); + LiteWalletLifecycleResult r = exec(localLifecycle, url); + if (r.walletReady) { + liteLog(" " + url + ": ready"); + chosen = std::move(r); + have = true; + break; + } + // A non-attempted result is a structural block (availability / validation): the same + // for every server, so stop and surface it rather than retrying pointlessly. + if (!r.attempted) { + chosen = std::move(r); + have = true; + break; + } + liteLog(" " + url + ": " + (r.error.empty() ? r.status.message : r.error)); + // Keep the PREFERRED (first) server's failure — the actionable one for the user — + // rather than whichever fallback happened to be tried last. + if (!have) { + chosen = std::move(r); + have = true; + } + } + if (!have) { + chosen.error = "could not reach any lite server"; + chosen.status = WalletBackendStatus{WalletBackendState::Error, chosen.error, {}, {}, 0.0}; + } + if (wipeSecrets) wipeSecrets(); // wipe the request's secrets once the attempt is done + { + std::lock_guard lk(*resultMutex); + *resultSlot = std::move(chosen); + } + running->store(false); + }); + return true; +} + +void LiteWalletController::pumpLifecycleResult() +{ + LiteWalletLifecycleResult out; + { + std::lock_guard lk(*lifecycleResultMutex_); + if (!lifecycleResult_->has_value()) return; // nothing finished since last pump + out = std::move(lifecycleResult_->value()); + lifecycleResult_->reset(); + } + if (lifecycleThread_.joinable()) lifecycleThread_.join(); // producer set its result, then exits + + // Finalize on the main thread: flip walletOpen()/status, persist, start sync/worker on success; + // log the failure otherwise (shared with the synchronous lifecycle path). + onLifecycleResult(out); + if (out.walletReady) { + lastOpenError_.clear(); + lastOpenWarming_ = false; + } else { + lastOpenError_ = out.error.empty() ? out.status.message : out.error; + lastOpenWarming_ = liteOpenErrorIsWarmup(lastOpenError_); + } + lastLifecycleResult_ = std::move(out); +} + 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 21dff30..fb3b92c 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -178,6 +178,25 @@ public: // 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(); } + + // Asynchronous FULL lifecycle (create / open / restore WITH passphrase + restore params) with + // the same server failover as beginOpenExisting(). Unlike beginCreateWallet() (plain new seed, + // no passphrase), these carry the complete request so the Settings page can create with + // encryption, open an encrypted wallet, or restore from a seed without freezing the UI on a + // flaky server. Non-blocking; finalized by pumpLifecycleResult() on the main thread. The + // request is taken by value and its secret fields are securely wiped once the attempt finishes. + // Returns false if a wallet is open, a lifecycle/open is already in progress, or no usable + // server exists (the request's secrets are still wiped on that path). + bool beginCreateWalletAsync(LiteWalletCreateRequest request); + bool beginOpenWalletAsync(LiteWalletOpenRequest request); + bool beginRestoreWalletAsync(LiteWalletRestoreRequest request); + bool lifecycleRequestInProgress() const { return lifecycleRunning_ && lifecycleRunning_->load(); } + // Finalize a completed async lifecycle request on the main thread (flip walletOpen()/status, + // persist, start sync) and cache the result for the UI. Cheap no-op when nothing is pending. + // Call each tick alongside pumpAsyncOpen(). + void pumpLifecycleResult(); + // The most recent finalized async-lifecycle result (create/open/restore), for UI display. + const LiteWalletLifecycleResult& lastLifecycleResult() const { return lastLifecycleResult_; } const std::string& lastOpenError() const { return lastOpenError_; } // True if the last failed open hit a server that was merely warming up (JSON-RPC -28 / // "Activating best chain"): the server is healthy and will be ready shortly, so the caller @@ -273,6 +292,8 @@ private: std::shared_ptr bridge_; std::string chainName_; // backend chain id (for walletExists); from connection settings LiteConnectionSettings connectionSettings_; // kept for the failover candidate-server list + WalletCapabilities capabilities_; // for thread-local lifecycle services (async path) + LiteWalletLifecycleOptions lifecycleOptions_; // ditto (allowBridgeCalls / rollout gate) LiteWalletLifecycleService lifecycle_; LiteWalletGateway gateway_; LiteSyncService sync_; @@ -311,6 +332,21 @@ private: std::string lastOpenError_; // main-thread only bool lastOpenWarming_ = false; // last failed open hit a warming-up (-28) server + // Asynchronous FULL lifecycle (create/open/restore with passphrase + restore params). Same + // shared-lifetime discipline as the open thread: the detached thread builds its OWN local + // LiteWalletLifecycleService from captured value copies + the shared bridge (never `this`), so + // it can safely outlive the controller. pumpLifecycleResult() finalizes on the main thread. + bool beginLifecycleRequestAsync( + const char* verb, + std::function exec, + std::function wipeSecrets); + std::thread lifecycleThread_; + std::shared_ptr> lifecycleRunning_ = std::make_shared>(false); + std::shared_ptr lifecycleResultMutex_ = std::make_shared(); + std::shared_ptr> lifecycleResult_ = + std::make_shared>(); + LiteWalletLifecycleResult lastLifecycleResult_; // main-thread only; last finalized request + // Joinable background refresh worker (fast iterations: syncstatus, plus data once synced). std::thread worker_; std::atomic running_{false}; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 223b8b8..0baafbc 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3638,6 +3638,81 @@ void testLiteWalletControllerOpenFailover() dragonx::test::g_liteFakeWarmupServerSubstr.clear(); } +// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also +// fails over: the request runs off the UI thread against the preferred server, then the other +// usable defaults, finalized by pumpLifecycleResult() on the main thread. +void testLiteWalletControllerAsyncLifecycleFailover() +{ + 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"; // preferred server is the dead one + + const auto drain = [](LiteWalletController& c) { + for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + c.pumpLifecycleResult(); + }; + + // Async create with a passphrase: preferred dead, fallback good -> wallet created via fallback. + { + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeWalletExists = false; + dragonx::test::g_liteFakeDeadServerSubstr = "dead.example"; + LiteWalletController controller(liteCaps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + LiteWalletCreateRequest req; + req.passphrase = "hunter2"; + EXPECT_TRUE(controller.beginCreateWalletAsync(req)); + drain(controller); + EXPECT_TRUE(controller.walletOpen()); + EXPECT_TRUE(controller.lastLifecycleResult().walletReady); + } + + // Async restore from seed: preferred dead, fallback good -> wallet restored via fallback. + { + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeWalletExists = false; + dragonx::test::g_liteFakeDeadServerSubstr = "dead.example"; + LiteWalletController controller(liteCaps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + LiteWalletRestoreRequest req; + req.seedPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon abandon abandon art"; + req.birthday = 0; + EXPECT_TRUE(controller.beginRestoreWalletAsync(req)); + drain(controller); + EXPECT_TRUE(controller.walletOpen()); + EXPECT_TRUE(controller.lastLifecycleResult().walletReady); + } + + // All servers dead -> async lifecycle fails, wallet stays closed, reason surfaced. + { + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeWalletExists = false; + dragonx::test::g_liteFakeDeadServerSubstr = "example"; // both servers fail + LiteWalletController controller(liteCaps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + EXPECT_TRUE(controller.beginCreateWalletAsync(LiteWalletCreateRequest{})); + drain(controller); + EXPECT_FALSE(controller.walletOpen()); + EXPECT_FALSE(controller.lastLifecycleResult().walletReady); + EXPECT_TRUE(!controller.lastOpenError().empty()); + } + + dragonx::test::g_liteFakeWalletExists = false; + dragonx::test::g_liteFakeDeadServerSubstr.clear(); + dragonx::test::g_liteFakeWarmupServerSubstr.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. @@ -4520,6 +4595,7 @@ int main() testLiteBackendInjectableFakeBridge(); testLiteWalletControllerLifecycle(); testLiteWalletControllerOpenFailover(); + testLiteWalletControllerAsyncLifecycleFailover(); testLiteWalletControllerM4(); testLiteWalletControllerM5Persistence(); testLiteWalletControllerEncryption();