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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -242,6 +242,8 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
|
||||
: bridge_(std::make_shared<LiteClientBridge>(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> 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<LiteWalletCreateRequest>(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<LiteWalletOpenRequest>(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<LiteWalletRestoreRequest>(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<LiteWalletLifecycleResult(LiteWalletLifecycleService&, const std::string&)> exec,
|
||||
std::function<void()> 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<std::mutex> 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<std::mutex> lk(*resultMutex);
|
||||
*resultSlot = std::move(chosen);
|
||||
}
|
||||
running->store(false);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void LiteWalletController::pumpLifecycleResult()
|
||||
{
|
||||
LiteWalletLifecycleResult out;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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;
|
||||
|
||||
@@ -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<LiteClientBridge> 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<LiteWalletLifecycleResult(LiteWalletLifecycleService&, const std::string&)> exec,
|
||||
std::function<void()> wipeSecrets);
|
||||
std::thread lifecycleThread_;
|
||||
std::shared_ptr<std::atomic<bool>> lifecycleRunning_ = std::make_shared<std::atomic<bool>>(false);
|
||||
std::shared_ptr<std::mutex> lifecycleResultMutex_ = std::make_shared<std::mutex>();
|
||||
std::shared_ptr<std::optional<LiteWalletLifecycleResult>> lifecycleResult_ =
|
||||
std::make_shared<std::optional<LiteWalletLifecycleResult>>();
|
||||
LiteWalletLifecycleResult lastLifecycleResult_; // main-thread only; last finalized request
|
||||
|
||||
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
|
||||
std::thread worker_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
Reference in New Issue
Block a user