perf(lite): move wallet ops and server switches off the UI thread

New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).

- The four ops run one at a time on a background lane and their results
  are applied on the main thread; Settings shows "Working…" meanwhile.
  Key and passphrase copies are wiped after the call, or when a queued
  op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
  first scan starts, so the save takes milliseconds and the scan runs on
  the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
  disabled while a scan runs ("Wait for blockchain to sync"); Redownload
  blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
  dropped, pending ones reported failed, its client shut down) and
  destroys it on a background thread. It no longer saves on the way out:
  the new client re-scans the blocks since the last save, as after a
  restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
  finish and save on the old client while the new one owns the wallet
  file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
  server reads once one fails, and then retries after 4, 8, then 15 s
  instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.

Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent b2a57a61a0
commit 55975901a2
11 changed files with 1023 additions and 147 deletions
+416
View File
@@ -8054,6 +8054,414 @@ void testSerialJobLane()
}
}
void testLiteGatewayRefreshPolicyHelpers()
{
using namespace dragonx::wallet;
EXPECT_TRUE(liteResponseIsBackendError(""));
EXPECT_FALSE(liteResponseIsBackendError(" {\"a\":1}"));
EXPECT_FALSE(liteResponseIsBackendError("\n[]"));
EXPECT_TRUE(liteResponseIsBackendError("Error getting latest block transport error"));
EXPECT_TRUE(liteResponseIsBackendError("Server's latest block(5) is behind ours(9)"));
EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Info));
EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Balance));
EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Notes));
EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::List));
EXPECT_FALSE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Height));
EXPECT_FALSE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Addresses));
const int expected[] = {2000, 4000, 8000, 15000, 15000};
for (int i = 0; i < 5; ++i) EXPECT_EQ(liteRefreshDelayMs(i, 2000), expected[i]);
EXPECT_EQ(liteRefreshDelayMs(1, 0), 4000); // base defaults to 2000
EXPECT_EQ(liteRefreshDelayMs(100000, 2000), 15000);
// Server-switch deferral
EXPECT_TRUE(liteServerSwitchMayProceed(false, 0.0));
EXPECT_FALSE(liteServerSwitchMayProceed(true, 0.0));
EXPECT_FALSE(liteServerSwitchMayProceed(true, kLiteServerSwitchMaxDeferSeconds - 0.5));
EXPECT_TRUE(liteServerSwitchMayProceed(true, kLiteServerSwitchMaxDeferSeconds));
}
void testLiteGatewayRefreshCancelAndSkip()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{true});
{ // keepGoing stops between commands
LiteWalletRefreshRequest req;
int calls = 0;
req.keepGoing = [&calls]() { return ++calls <= 2; };
const auto r = gateway.refresh(req);
EXPECT_TRUE(r.cancelled);
EXPECT_FALSE(r.ok);
EXPECT_EQ(r.commandResults.size(), static_cast<std::size_t>(2));
EXPECT_TRUE(r.commandResults.size() < r.plan.commands.size());
}
{ // after a backend error, the remaining syncing reads are skipped; local `addresses` still runs
dragonx::test::g_liteFakeBalanceError = true;
dragonx::test::g_liteFakeLogEnabled = true;
(void)dragonx::test::liteFakeTakeLog();
LiteWalletRefreshRequest req;
req.includeInfo = false;
req.includeHeight = false;
req.stopSyncReadsOnFailure = true;
const auto r = gateway.refresh(req);
const auto log = dragonx::test::liteFakeTakeLog();
EXPECT_TRUE(r.syncReadFailed);
EXPECT_TRUE(r.ok);
EXPECT_TRUE(r.bundle.hasAddresses);
EXPECT_FALSE(r.bundle.hasBalance);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "balance"), 1L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "addresses"), 1L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "list"), 0L);
// Without the flag (existing behaviour) every command still runs.
req.stopSyncReadsOnFailure = false;
(void)gateway.refresh(req);
const auto log2 = dragonx::test::liteFakeTakeLog();
EXPECT_EQ(dragonx::test::liteFakeCount(log2, "notes"), 1L);
EXPECT_EQ(dragonx::test::liteFakeCount(log2, "list"), 1L);
dragonx::test::g_liteFakeBalanceError = false;
dragonx::test::g_liteFakeLogEnabled = false;
}
}
void testLiteWalletControllerWalletOpsAsync()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
{ // closed controller: rejected inline, exactly once
int n = 0; bool ok = true;
EXPECT_FALSE(c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; ok = r.ok; }));
EXPECT_EQ(n, 1);
EXPECT_FALSE(ok);
EXPECT_FALSE(c.walletOpInProgress());
}
EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
const auto waitCompletion = [&c]() {
for (int i = 0; i < 600 && !c.hasWalletOpCompletions(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
};
{ // a blocked op doesn't block the caller; callback once, from pumpWalletOps()
dragonx::test::g_liteFakeBlockNew = true;
int n = 0; LiteNewAddressResult got;
const auto t0 = std::chrono::steady_clock::now();
EXPECT_TRUE(c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; got = r; }));
EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500));
EXPECT_TRUE(c.walletOpInProgress());
EXPECT_EQ(c.pumpWalletOps(), 0);
EXPECT_EQ(n, 0);
dragonx::test::g_liteFakeBlockNew = false;
waitCompletion();
EXPECT_EQ(c.pumpWalletOps(), 1);
EXPECT_EQ(n, 1);
EXPECT_TRUE(got.ok);
EXPECT_EQ(got.address, std::string("zs1fakenew"));
EXPECT_FALSE(c.walletOpInProgress());
}
{ // encrypt + save is ONE op; decrypt likewise; empty passphrase rejected inline
dragonx::test::g_liteFakeBlockSave = true;
int n = 0; LiteEncryptionResult got;
EXPECT_TRUE(c.encryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; }));
for (int i = 0; i < 400 && !dragonx::test::g_liteFakeEncrypted.load(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(dragonx::test::g_liteFakeEncrypted.load()); // encrypted, save still pending
EXPECT_EQ(c.pumpWalletOps(), 0);
EXPECT_EQ(n, 0);
dragonx::test::g_liteFakeBlockSave = false;
waitCompletion();
c.pumpWalletOps();
EXPECT_EQ(n, 1);
EXPECT_TRUE(got.ok);
EXPECT_TRUE(c.encryptionStatus().encrypted);
n = 0;
EXPECT_TRUE(c.decryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; }));
waitCompletion();
c.pumpWalletOps();
EXPECT_EQ(n, 1);
EXPECT_TRUE(got.ok);
EXPECT_FALSE(c.encryptionStatus().encrypted);
n = 0;
EXPECT_FALSE(c.encryptWalletAsync("", [&](const LiteEncryptionResult& r) { ++n; got = r; }));
EXPECT_EQ(n, 1);
EXPECT_FALSE(got.ok);
}
{ // import
int n = 0; LiteImportResult got;
EXPECT_TRUE(c.importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n; got = r; }));
waitCompletion();
c.pumpWalletOps();
EXPECT_EQ(n, 1);
EXPECT_TRUE(got.ok);
}
{ // failPendingWalletOps: each callback once with the reason; late results dropped
dragonx::test::g_liteFakeBlockNew = true;
int n1 = 0, n2 = 0; std::string e1, e2;
c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n1; e1 = r.error; });
c.newAddressAsync(false, [&](const LiteNewAddressResult& r) { ++n2; e2 = r.error; });
c.failPendingWalletOps("x");
EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1);
EXPECT_EQ(e1, std::string("x")); EXPECT_EQ(e2, std::string("x"));
EXPECT_FALSE(c.walletOpInProgress());
dragonx::test::g_liteFakeBlockNew = false;
EXPECT_TRUE(c.waitWalletOpsIdle(std::chrono::milliseconds(3000)));
c.pumpWalletOps();
EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1);
}
}
void testLiteWalletControllerRetire()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
auto c = std::make_unique<LiteWalletController>(caps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
// One op in flight (blocked "new") + one queued (import, stuck behind it on the single lane); the worker parked
// inside "balance".
dragonx::test::g_liteFakeLogEnabled = true;
dragonx::test::g_liteFakeBlockNew = true;
int n1 = 0, n2 = 0; bool ok1 = true, ok2 = true;
EXPECT_TRUE(c->newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n1; ok1 = r.ok; }));
EXPECT_TRUE(c->importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n2; ok2 = r.ok; }));
const long entered = dragonx::test::g_liteFakeBalanceEntered.load();
dragonx::test::g_liteFakeBlockBalance = true;
for (int i = 0; i < 1000 && dragonx::test::g_liteFakeBalanceEntered.load() == entered; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(dragonx::test::g_liteFakeBalanceEntered.load() > entered);
(void)dragonx::test::liteFakeTakeLog(); // only what happens from here on is checked
dragonx::test::g_liteFakeShutdownCalled = false; // a late bridge release from an earlier test could have set it
const auto t0 = std::chrono::steady_clock::now();
c->retire("switch");
EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500)); // never joins
EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1);
EXPECT_FALSE(ok1); EXPECT_FALSE(ok2);
EXPECT_TRUE(dragonx::test::g_liteFakeShutdownCalled);
int n3 = 0;
EXPECT_FALSE(c->newAddressAsync(true, [&](const LiteNewAddressResult&) { ++n3; })); // rejected inline
EXPECT_EQ(n3, 1);
const long saves = dragonx::test::g_liteFakeSaveCount.load();
auto fut = std::async(std::launch::async, [&c]() { c.reset(); });
EXPECT_TRUE(fut.wait_for(std::chrono::milliseconds(150)) == std::future_status::timeout); // joins the parked worker
dragonx::test::g_liteFakeBlockBalance = false;
EXPECT_TRUE(fut.wait_for(std::chrono::milliseconds(3000)) == std::future_status::ready);
dragonx::test::g_liteFakeBlockNew = false; // let the in-flight "new" finish; its completion is dropped
std::this_thread::sleep_for(std::chrono::milliseconds(100));
const auto log = dragonx::test::liteFakeTakeLog();
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), saves); // retired: no final save
EXPECT_EQ(dragonx::test::liteFakeCount(log, "import") + dragonx::test::liteFakeCount(log, "timport"), 0L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L); // the cancelled cycle stopped after "balance"
dragonx::test::g_liteFakeLogEnabled = false;
}
// retire() mid-op: a running op issues no follow-up backend command (it would land on the NEXT server's client), and an
// op that had already finished is delivered with its real result instead of being failed as "reconnecting".
void testLiteWalletControllerRetireStopsOpFollowUps()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
const auto openController = [&caps, &conn]() {
auto c = std::make_unique<LiteWalletController>(caps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
(void)dragonx::test::liteFakeTakeLog();
dragonx::test::g_liteFakeLogEnabled = true;
return c;
};
const auto drainLog = [](std::vector<std::string>& log) {
auto more = dragonx::test::liteFakeTakeLog();
log.insert(log.end(), more.begin(), more.end());
};
// The fake logs a command before blocking in it, so this returns once `cmd` is in flight.
const auto waitForCommand = [&drainLog](std::vector<std::string>& log, const char* cmd) {
for (int i = 0; i < 600 && dragonx::test::liteFakeCount(log, cmd) == 0; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
drainLog(log);
}
return dragonx::test::liteFakeCount(log, cmd) > 0;
};
{ // "encrypt" in flight -> retire -> its "save" never runs (it would save on the next client)
auto c = openController();
dragonx::test::g_liteFakeBlockEncrypt = true;
int n = 0; LiteEncryptionResult got; got.ok = true;
EXPECT_TRUE(c->encryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; }));
std::vector<std::string> log;
EXPECT_TRUE(waitForCommand(log, "encrypt"));
c->retire("switch");
EXPECT_EQ(n, 1);
EXPECT_FALSE(got.ok);
dragonx::test::g_liteFakeBlockEncrypt = false;
EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000)));
c.reset();
drainLog(log);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "save"), 0L);
EXPECT_EQ(n, 1);
dragonx::test::g_liteFakeLogEnabled = false;
}
{ // control: an unrecognized key is retried with the other import command
auto c = openController();
dragonx::test::g_liteFakeImportFails = true;
int n = 0; bool ok = true;
EXPECT_TRUE(c->importKeyAsync("secret-extended-key-typo", [&](const LiteImportResult& r) { ++n; ok = r.ok; }));
EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000)));
c->pumpWalletOps();
EXPECT_EQ(n, 1);
EXPECT_FALSE(ok);
std::vector<std::string> log;
drainLog(log);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 1L);
c.reset();
dragonx::test::g_liteFakeImportFails = false;
dragonx::test::g_liteFakeLogEnabled = false;
}
{ // the first import attempt in flight (and failing) -> retire -> the fallback never runs
auto c = openController();
dragonx::test::g_liteFakeImportFails = true;
dragonx::test::g_liteFakeBlockImport = true;
int n = 0; bool ok = true;
EXPECT_TRUE(c->importKeyAsync("secret-extended-key-typo", [&](const LiteImportResult& r) { ++n; ok = r.ok; }));
std::vector<std::string> log;
EXPECT_TRUE(waitForCommand(log, "import"));
c->retire("switch");
EXPECT_EQ(n, 1);
EXPECT_FALSE(ok);
dragonx::test::g_liteFakeBlockImport = false;
EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000)));
c.reset();
drainLog(log);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 0L);
EXPECT_EQ(n, 1);
dragonx::test::g_liteFakeImportFails = false;
dragonx::test::g_liteFakeLogEnabled = false;
}
{ // an op that finished before retire() (completion not yet pumped) is delivered with its real result
auto c = openController();
int n = 0; LiteNewAddressResult got;
EXPECT_TRUE(c->newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; got = r; }));
for (int i = 0; i < 600 && !c->hasWalletOpCompletions(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c->hasWalletOpCompletions());
EXPECT_EQ(n, 0);
c->retire("switch");
EXPECT_EQ(n, 1);
EXPECT_TRUE(got.ok);
EXPECT_EQ(got.address, std::string("zs1fakenew"));
c.reset();
dragonx::test::g_liteFakeLogEnabled = false;
}
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
}
void testLiteWalletControllerEncryptsBeforeSyncStarts()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
LiteConnectionSettings conn;
conn.chainName = "main";
conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} };
conn.selectionMode = LiteServerSelectionMode::Sticky;
conn.stickyServerUrl = "https://good.example";
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
dragonx::test::g_liteFakeLogEnabled = true;
{
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
EXPECT_TRUE(c.beginCreateWalletAsync(req));
for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
c.pumpLifecycleResult();
for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
const auto log = dragonx::test::liteFakeTakeLog();
const auto idx = [&log](const char* cmd) {
for (std::size_t i = 0; i < log.size(); ++i) if (log[i] == cmd) return static_cast<long>(i);
return -1L;
};
EXPECT_TRUE(idx("encrypt") >= 0);
EXPECT_TRUE(idx("save") > idx("encrypt"));
EXPECT_TRUE(idx("sync") > idx("save")); // the scan starts only after the encrypted wallet is saved
}
dragonx::test::g_liteFakeLogEnabled = false;
dragonx::test::g_liteFakeWalletExists = true;
}
void testLiteWalletControllerWorkerBacksOffOnSyncReadFailure()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeBalanceError = true;
dragonx::test::g_liteFakeLogEnabled = true;
{
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady);
std::vector<std::string> log;
for (int i = 0; i < 1000 && dragonx::test::liteFakeCount(log, "balance") == 0; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
auto more = dragonx::test::liteFakeTakeLog();
log.insert(log.end(), more.begin(), more.end());
}
std::this_thread::sleep_for(std::chrono::milliseconds(300));
auto more = dragonx::test::liteFakeTakeLog();
log.insert(log.end(), more.begin(), more.end());
EXPECT_EQ(dragonx::test::liteFakeCount(log, "balance"), 1L); // next cycle only after the 4 s back-off
EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L); // skipped after the failed balance
EXPECT_EQ(dragonx::test::liteFakeCount(log, "list"), 0L);
EXPECT_TRUE(dragonx::test::liteFakeCount(log, "addresses") >= 1L);
} // destructor wakes the back-off wait at once
dragonx::test::g_liteFakeBalanceError = false;
dragonx::test::g_liteFakeLogEnabled = false;
}
void testLiteWalletControllerDestroyWithBlockedOp()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
int n = 0;
const auto t0 = std::chrono::steady_clock::now();
{
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
dragonx::test::g_liteFakeBlockNew = true;
EXPECT_TRUE(c.newAddressAsync(true, [&](const LiteNewAddressResult&) { ++n; }));
} // lane abandoned, not joined
EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(3000));
dragonx::test::g_liteFakeBlockNew = false;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // let the detached job unwind
EXPECT_EQ(n, 0); // a destroyed controller never delivers
}
// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
void testLiteRolloutVersionCompare()
{
@@ -13184,6 +13592,14 @@ int main()
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testSerialJobLane();
testLiteGatewayRefreshPolicyHelpers();
testLiteGatewayRefreshCancelAndSkip();
testLiteWalletControllerWalletOpsAsync();
testLiteWalletControllerRetire();
testLiteWalletControllerRetireStopsOpFollowUps();
testLiteWalletControllerEncryptsBeforeSyncStarts();
testLiteWalletControllerWorkerBacksOffOnSyncReadFailure();
testLiteWalletControllerDestroyWithBlockedOp();
testLiteRolloutVersionCompare();
testLiteRolloutBucketAndOverrideHelpers();
testLiteRolloutPolicyOverridesAndKillSwitch();