fix(lite): honor the backend sync result with retry and a failed state

A failed backend sync was silently swallowed: the reply was never
inspected, so a sync that errored out left the wallet showing a
"syncing" state that never completed and never reported a problem.

- The sync and rescan replies are now parsed. On failure the partial
  progress is saved and the sync is retried with exponential back-off
  (5s doubling up to 60s, six attempts in all), then an explicit failed
  state is published: done, sync not complete, and the backend's error
  text carried through.
- A failed rescan is retried as a plain sync, because a rescan has
  already cleared the wallet and re-running it would throw away the
  partial progress just saved.
- A terminal error, such as a reorg deeper than the wallet can unwind,
  stops the retries immediately instead of waiting out the attempts.
- Recovery is observed, not guessed: once a later refresh's server-bound
  reads all succeed, the failed state clears itself.

Behaviour change: a sync failure now surfaces to the user as a failed
state with a message, instead of a frozen "syncing", and the client
retries on its own with back-off.

Tests: a parser case for the success, failure, and terminal replies, and
fake-backend controller cases for retrying after a failure, giving up
after the attempt cap, a terminal reorg stopping at once, a failed
rescan retrying as sync, cancel stopping the back-off, and the failed
state self-healing on a good refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 21:42:23 -05:00
co-authored by Claude Opus 4.8
parent 3f7af2d028
commit 1ea584311e
8 changed files with 355 additions and 21 deletions
+207
View File
@@ -11503,6 +11503,207 @@ void testLiteWalletControllerWorkerBacksOffOnSyncReadFailure()
dragonx::test::g_liteFakeLogEnabled = false;
}
// C26: classify the backend sync/rescan reply; honor it with bounded retry + a failed state.
void testLiteSyncOpResponseParser()
{
using namespace dragonx::wallet;
{
const auto r = parseLiteSyncOpResponse("{\n \"result\": \"success\"\n}");
EXPECT_TRUE(r.ok);
EXPECT_FALSE(r.terminal);
EXPECT_TRUE(r.error.empty());
}
{
const auto r = parseLiteSyncOpResponse("Error getting latest block: x");
EXPECT_FALSE(r.ok);
EXPECT_FALSE(r.terminal);
EXPECT_EQ(r.error, std::string("Error getting latest block: x"));
}
{
const auto r = parseLiteSyncOpResponse("Server's latest block(5) is behind ours(9)");
EXPECT_FALSE(r.ok);
EXPECT_FALSE(r.terminal);
}
{
const auto r = parseLiteSyncOpResponse("Reorg has exceeded 100 blocks. Aborting.");
EXPECT_FALSE(r.ok);
EXPECT_TRUE(r.terminal);
}
{
const auto r = parseLiteSyncOpResponse("");
EXPECT_FALSE(r.ok);
EXPECT_EQ(r.error, std::string("empty sync response"));
}
{
EXPECT_FALSE(parseLiteSyncOpResponse("{\"result\":\"error\"}").ok);
EXPECT_FALSE(parseLiteSyncOpResponse("{\"error\":\"x\"}").ok);
EXPECT_FALSE(parseLiteSyncOpResponse("{\"version\":\"x\"}").ok);
}
{
const auto r = parseLiteSyncOpResponse(std::string(400, 'a'));
EXPECT_FALSE(r.ok);
EXPECT_EQ(static_cast<int>(r.error.size()), 300);
}
EXPECT_EQ(liteSyncRetryDelayMs(1, 5000), 5000);
EXPECT_EQ(liteSyncRetryDelayMs(2, 5000), 10000);
EXPECT_EQ(liteSyncRetryDelayMs(6, 5000), 60000);
EXPECT_EQ(liteSyncRetryDelayMs(30, 5000), 60000);
}
void testLiteWalletControllerSyncRetriesAfterFailure()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeSyncFailures = 2;
dragonx::test::g_liteFakeLogEnabled = true;
{
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
c.setSyncRetryBaseMsForTest(5);
EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 600 && !c.syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c.syncComplete());
EXPECT_EQ(dragonx::test::g_liteFakeSyncCalls.load(), 3L); // 1 fail + 1 fail + 1 success
EXPECT_FALSE(c.scanFailed());
EXPECT_TRUE(c.scanError().empty());
EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() >= 3L); // saved after every attempt
auto log = dragonx::test::liteFakeTakeLog();
EXPECT_EQ(dragonx::test::liteFakeCount(log, "rescan"), 0L); // retry is "sync", never "rescan"
}
dragonx::test::g_liteFakeSyncFailures = 0;
dragonx::test::g_liteFakeLogEnabled = false;
}
void testLiteWalletControllerSyncGivesUpAndReportsFailure()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeSyncFailures = 1000;
dragonx::test::g_liteFakeBalanceError = true; // keep data reads failing so the worker can't self-heal yet
{
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
c.setSyncRetryBaseMsForTest(1);
EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 600 && !c.syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c.syncComplete());
EXPECT_EQ(dragonx::test::g_liteFakeSyncCalls.load(), static_cast<long>(kLiteScanMaxAttempts));
EXPECT_TRUE(c.scanFailed());
EXPECT_TRUE(c.scanError().find("Error getting latest block") != std::string::npos);
const auto model = c.refreshModel();
EXPECT_TRUE(model.has_value());
EXPECT_FALSE(model->syncError.empty());
EXPECT_FALSE(model->sync.complete);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(*model, state);
EXPECT_TRUE(state.sync.syncing);
EXPECT_FALSE(state.sync.lite_error.empty());
// Server back: a refresh whose server-bound reads all succeed clears the failed state.
dragonx::test::g_liteFakeSyncFailures = 0;
dragonx::test::g_liteFakeBalanceError = false;
const auto healed = c.refreshModel();
EXPECT_TRUE(healed.has_value());
EXPECT_FALSE(c.scanFailed());
EXPECT_TRUE(healed->sync.complete);
}
dragonx::test::g_liteFakeSyncFailures = 0;
dragonx::test::g_liteFakeBalanceError = false;
}
void testLiteWalletControllerTerminalReorgStopsRetrying()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeSyncTerminal = true;
dragonx::test::g_liteFakeBalanceError = true; // keep data reads failing so the worker can't self-heal yet
{
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
c.setSyncRetryBaseMsForTest(1);
EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 600 && !c.syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c.syncComplete());
EXPECT_EQ(dragonx::test::g_liteFakeSyncCalls.load(), 1L); // terminal: no retry
EXPECT_TRUE(c.scanFailed());
// The user is not trapped: a rescan is allowed and (succeeding) clears the failure.
dragonx::test::g_liteFakeSyncTerminal = false;
dragonx::test::g_liteFakeBalanceError = false;
EXPECT_TRUE(c.startRescan());
for (int i = 0; i < 600 && !c.syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c.syncComplete());
EXPECT_FALSE(c.scanFailed());
}
dragonx::test::g_liteFakeSyncTerminal = false;
dragonx::test::g_liteFakeBalanceError = false;
}
void testLiteWalletControllerRescanFailureRetriesWithSyncNotRescan()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
{
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
c.setSyncRetryBaseMsForTest(1);
EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 600 && !c.syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c.syncComplete());
dragonx::test::g_liteFakeRescanFailures = 1;
dragonx::test::g_liteFakeLogEnabled = true;
(void)dragonx::test::liteFakeTakeLog(); // drop everything up to the rescan
EXPECT_TRUE(c.startRescan());
for (int i = 0; i < 600 && !c.syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(c.syncComplete());
EXPECT_FALSE(c.scanFailed()); // the retry (sync) succeeded
auto log = dragonx::test::liteFakeTakeLog();
EXPECT_EQ(dragonx::test::liteFakeCount(log, "rescan"), 1L); // exactly one rescan
EXPECT_TRUE(dragonx::test::liteFakeCount(log, "sync") >= 1L); // then retried as sync
}
dragonx::test::g_liteFakeRescanFailures = 0;
dragonx::test::g_liteFakeLogEnabled = false;
}
void testLiteWalletControllerCancelStopsSyncRetry()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeSyncFailures = 1000;
long callsAtDestroy = 0;
const auto start = std::chrono::steady_clock::now();
{
LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
c.setSyncRetryBaseMsForTest(60000); // a long back-off the cancel must cut short
EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 600 && dragonx::test::g_liteFakeSyncCalls.load() < 1; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
callsAtDestroy = dragonx::test::g_liteFakeSyncCalls.load();
EXPECT_TRUE(callsAtDestroy >= 1L);
// controller destructs here while the sync thread is in its 60 s back-off sleep
}
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
EXPECT_TRUE(elapsedMs < 1500); // destructor cancelled the back-off and returned promptly
std::this_thread::sleep_for(std::chrono::milliseconds(300));
EXPECT_EQ(dragonx::test::g_liteFakeSyncCalls.load(), callsAtDestroy); // no further sync after cancel
dragonx::test::g_liteFakeSyncFailures = 0;
}
void testLiteWalletControllerDestroyWithBlockedOp()
{
using namespace dragonx::wallet;
@@ -17483,6 +17684,12 @@ int main()
testLiteWalletControllerImportClassifier();
testLiteWalletControllerEncryptsBeforeSyncStarts();
testLiteWalletControllerWorkerBacksOffOnSyncReadFailure();
testLiteSyncOpResponseParser();
testLiteWalletControllerSyncRetriesAfterFailure();
testLiteWalletControllerSyncGivesUpAndReportsFailure();
testLiteWalletControllerTerminalReorgStopsRetrying();
testLiteWalletControllerRescanFailureRetriesWithSyncNotRescan();
testLiteWalletControllerCancelStopsSyncRetry();
testLiteWalletControllerDestroyWithBlockedOp();
testLiteRolloutVersionCompare();
testLiteRolloutBucketAndOverrideHelpers();