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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user