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:
@@ -36,6 +36,10 @@ inline bool g_liteFakeWalletExists = true;
|
||||
inline bool g_liteFakeServerOnline = true;
|
||||
inline bool g_liteFakeShutdownCalled = false;
|
||||
inline std::atomic<bool> g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks
|
||||
inline std::atomic<int> g_liteFakeSyncFailures{0}; // next N "sync" commands fail with the real plain-text shape
|
||||
inline std::atomic<bool> g_liteFakeSyncTerminal{false}; // "sync" returns the terminal reorg error
|
||||
inline std::atomic<int> g_liteFakeRescanFailures{0}; // next N "rescan" commands fail
|
||||
inline std::atomic<long> g_liteFakeSyncCalls{0};
|
||||
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
|
||||
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
|
||||
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
|
||||
@@ -96,6 +100,10 @@ inline void resetLiteFakeCounters()
|
||||
g_liteFakeBlockImport = false;
|
||||
g_liteFakeImportFails = false;
|
||||
g_liteFakeImportEncrypted = false;
|
||||
g_liteFakeSyncFailures = 0;
|
||||
g_liteFakeSyncTerminal = false;
|
||||
g_liteFakeRescanFailures = 0;
|
||||
g_liteFakeSyncCalls = 0;
|
||||
g_liteFakeLogEnabled = false;
|
||||
(void)liteFakeTakeLog();
|
||||
}
|
||||
@@ -184,6 +192,13 @@ inline char* liteFakeExecute(const char* command, const char* args)
|
||||
// Simulate the real backend's blocking full sync when requested, so tests can
|
||||
// verify shutdown doesn't hang on an in-flight sync.
|
||||
while (g_liteFakeSyncBlock.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
++g_liteFakeSyncCalls;
|
||||
if (g_liteFakeSyncTerminal.load()) return liteFakeDup("Reorg has exceeded 100 blocks. Aborting.");
|
||||
if (g_liteFakeSyncFailures.load() > 0) { --g_liteFakeSyncFailures; return liteFakeDup("Error getting latest block: simulated connect failure"); }
|
||||
return liteFakeDup("{\"result\":\"success\"}");
|
||||
}
|
||||
if (std::strcmp(c, "rescan") == 0) {
|
||||
if (g_liteFakeRescanFailures.load() > 0) { --g_liteFakeRescanFailures; return liteFakeDup("Error fetching blocks: simulated"); }
|
||||
return liteFakeDup("{\"result\":\"success\"}");
|
||||
}
|
||||
if (std::strcmp(c, "syncstatus") == 0) // real backend shape: "syncing" is a string
|
||||
|
||||
@@ -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