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

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