feat(lite): async wallet open with server failover

Opening an existing lite wallet ran synchronously on the UI thread and used a
single server, so a dead/unreachable lightwalletd server froze startup for the
connect timeout and then stranded the wallet ("disconnected" spinner) — and the
DragonX lite servers are flaky (often several down at once).

Add LiteWalletController::beginOpenExisting() / pumpAsyncOpen(): the open runs on
a background thread (mirroring the sync/broadcast shared-lifetime pattern — it
captures only shared_ptrs + value copies, never `this`), trying the preferred
server first and then every other usable default until one succeeds. The main
thread finalizes the result (flips walletOpen, starts sync) or records the reason.
The rollout gate is still checked up-front on the main thread.

App: auto-open now calls beginOpenExisting() and pumps it each tick, retrying on
a 20s interval so a transient outage self-heals once a server returns; a failed
open surfaces its reason (notification + Network tab) instead of a silent spinner.

Tested: a fake bridge that fails specific servers exercises both
preferred-dead -> fallback-opens and all-dead -> fails-with-reason. Built clean
for full-node, lite, and Windows cross-compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:53:24 -05:00
parent 9ff5508989
commit dbeae3ac98
6 changed files with 219 additions and 20 deletions

View File

@@ -38,12 +38,15 @@ inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shie
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
inline std::atomic<bool> g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...)
inline std::atomic<bool> 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).

View File

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