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:
2026-06-08 11:42:47 -05:00
parent 320c659689
commit 6f9123f651
5 changed files with 315 additions and 15 deletions

View File

@@ -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;