From 1ea584311efbf4db31e1f549871706d97549f08f Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 12 Oct 2026 21:42:23 -0500 Subject: [PATCH] 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 --- src/data/wallet_state.h | 1 + src/wallet/lite_result_parsers.cpp | 29 ++++ src/wallet/lite_result_parsers.h | 12 ++ src/wallet/lite_wallet_controller.cpp | 90 ++++++++--- src/wallet/lite_wallet_controller.h | 21 +++ src/wallet/lite_wallet_state_mapper.h | 1 + tests/fake_lite_backend.h | 15 ++ tests/test_phase4.cpp | 207 ++++++++++++++++++++++++++ 8 files changed, 355 insertions(+), 21 deletions(-) diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index fc69577..6e807f2 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -147,6 +147,7 @@ struct SyncInfo { double verification_progress = 0.0; bool syncing = false; std::string best_blockhash; + std::string lite_error; // lite: last sync failure text, "" = none // Rescan state (detected from daemon output) bool rescanning = false; diff --git a/src/wallet/lite_result_parsers.cpp b/src/wallet/lite_result_parsers.cpp index 1ff3556..f6af8b0 100644 --- a/src/wallet/lite_result_parsers.cpp +++ b/src/wallet/lite_result_parsers.cpp @@ -1,6 +1,7 @@ #include "wallet/lite_result_parsers.h" #include +#include #include #include @@ -583,4 +584,32 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value) return result; } +LiteSyncOpResult parseLiteSyncOpResponse(const std::string& text) +{ + LiteSyncOpResult out; + const json parsed = json::parse(text, nullptr, /*allow_exceptions=*/false); + if (parsed.is_object()) { + const auto it = parsed.find("result"); + if (it != parsed.end() && it->is_string() && it->get() == "success") { + out.ok = true; + return out; + } + } + std::size_t b = 0, e = text.size(); + while (b < e && std::isspace(static_cast(text[b]))) ++b; + while (e > b && std::isspace(static_cast(text[e - 1]))) --e; + out.error = text.substr(b, std::min(e - b, 300)); + if (out.error.empty()) out.error = "empty sync response"; + out.terminal = text.find("Reorg has exceeded") != std::string::npos; + return out; +} + +int liteSyncRetryDelayMs(int attempt, int baseMs) +{ + constexpr long long kMaxMs = 60000; + long long ms = baseMs > 0 ? baseMs : 5000; + for (int i = 1; i < attempt && ms < kMaxMs; ++i) ms *= 2; + return static_cast(ms < kMaxMs ? ms : kMaxMs); +} + } // namespace dragonx::wallet \ No newline at end of file diff --git a/src/wallet/lite_result_parsers.h b/src/wallet/lite_result_parsers.h index 77f02ed..3bc981d 100644 --- a/src/wallet/lite_result_parsers.h +++ b/src/wallet/lite_result_parsers.h @@ -166,4 +166,16 @@ LiteTransactionsParseResult parseLiteTransactionsResponse(const nlohmann::json& LiteSyncStatusParseResult parseLiteSyncStatusResponse(const std::string& jsonText); LiteSyncStatusParseResult parseLiteSyncStatusResponse(const nlohmann::json& value); +// A backend `sync` / `rescan` reply. Success is exactly a JSON object with result == "success" (both do_sync Ok paths +// and do_rescan emit it). Anything else is the failure text of do_sync/do_rescan (plain text, NO "Error:" prefix), e.g. +// "Error getting latest block ...", "Server's latest block(..) is behind ours(..)", "Reorg has exceeded 100 blocks. Aborting." +struct LiteSyncOpResult { + bool ok = false; + bool terminal = false; // retrying cannot help (reorg deeper than the wallet can unwind) + std::string error; // trimmed, <= 300 chars; empty when ok +}; +LiteSyncOpResult parseLiteSyncOpResponse(const std::string& text); +// Wait before sync-retry number `attempt` (1-based): baseMs, x2 per attempt, capped at 60000 ms. +int liteSyncRetryDelayMs(int attempt, int baseMs); + } // namespace dragonx::wallet \ No newline at end of file diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index f006a17..d1441e3 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -106,6 +106,51 @@ bool persistAfterBroadcast(LiteClientBridge& bridge) return false; } +// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has +// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after +// every attempt. Captures shared state only. +void runLiteScanThread(const std::shared_ptr& bridge, + const std::shared_ptr>& done, + const std::shared_ptr& scan, + const char* firstCommand) +{ + if (bridge) { + const char* command = firstCommand; + for (int attempt = 1;; ++attempt) { + const auto call = bridge->execute(command, ""); // blocks until synced (or errors out) + LiteSyncOpResult op; + if (call.ok) { + op = parseLiteSyncOpResponse(call.value); + } else { + op.error = call.error.empty() ? "sync failed" : call.error; + } + // The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed. + if (!bridge->execute("save", "").ok) // W5-2 + liteLog("save failed after sync — the next launch will re-scan from the checkpoint"); + if (op.ok) { + scan->setError(std::string()); + scan->failed.store(false); + break; + } + liteLog(std::string(command) + " failed (attempt " + std::to_string(attempt) + "): " + op.error); + scan->setError(op.error); + command = "sync"; + if (scan->cancel.load()) break; + if (op.terminal || attempt >= kLiteScanMaxAttempts) { + scan->failed.store(true); // explicit failed state; done is still published below + liteLog("sync gave up: " + op.error); + break; + } + const int delayMs = liteSyncRetryDelayMs(attempt, scan->retryBaseMs.load()); + for (int waited = 0; waited < delayMs && !scan->cancel.load(); waited += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (scan->cancel.load()) break; + } + } + // Only after the save, so a syncComplete() observer sees a fully-persisted wallet. + done->store(true); +} + // Build the JSON-array send payload and broadcast it. litelib_execute passes the whole args // string as ONE argument (no whitespace splitting), so send MUST use the JSON-array form // ([{address,amount,memo},..]); the space-separated CLI form would never parse. @@ -422,6 +467,7 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, state.sync.verification_progress = model.sync.progress; state.sync.syncing = !model.sync.complete; } + state.sync.lite_error = model.syncError; if (model.hasEncryptionStatus) { // Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly, and mark @@ -455,6 +501,7 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities, LiteWalletController::~LiteWalletController() { + scan_->cancel.store(true); // the detached sync thread stops retrying (it may be in a back-off sleep) stopWorker(); // joins the fast poll worker (short iterations) // Best-effort flush on shutdown: the mempool monitor's unconfirmed updates aren't persisted // by the backend (sync/send/shield already save inline). Guarded by syncDone_ and no in-flight @@ -862,17 +909,8 @@ void LiteWalletController::startSync() // a detached thread. Capture shared refs (not the controller) so it is safe to outlive us. auto bridge = bridge_; auto done = syncDone_; - syncThread_ = std::thread([bridge, done] { - if (bridge) { - bridge->execute("sync", ""); // blocks until synced (or errors out) - // The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet; - // otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only - // after the save so a syncComplete() observer sees a fully-persisted wallet. - if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-sync save silent - liteLog("save failed after sync — the next launch will re-scan from the checkpoint"); - } - done->store(true); - }); + auto scan = scan_; + syncThread_ = std::thread([bridge, done, scan] { runLiteScanThread(bridge, done, scan, "sync"); }); } bool LiteWalletController::startRescan() @@ -897,16 +935,12 @@ bool LiteWalletController::startRescan() auto bridge = bridge_; auto done = syncDone_; - syncThread_ = std::thread([bridge, done] { - if (bridge) { - // `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the - // birthday height — a blocking, uninterruptible full scan, same as `sync`. - bridge->execute("rescan", ""); - if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-rescan save silent - liteLog("save failed after rescan — the next launch will re-scan from the checkpoint"); - } - done->store(true); - }); + auto scan = scan_; + scan->failed.store(false); // a fresh attempt: the previous failure no longer applies + scan->setError(std::string()); + // `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the birthday height — a + // blocking, uninterruptible full scan, same as `sync`. Retries (if it fails) are `sync`. + syncThread_ = std::thread([bridge, done, scan] { runLiteScanThread(bridge, done, scan, "rescan"); }); return true; } @@ -939,6 +973,7 @@ std::optional LiteWalletController::refreshModelImpl( if (!syncResult.ok) return std::nullopt; LiteWalletAppRefreshModel model; applyEncryption(model); + model.syncError = scan_->errorCopy(); model.hasSyncStatus = true; model.sync.walletHeight = syncResult.syncStatus.syncedBlocks; model.sync.chainHeight = syncResult.syncStatus.totalBlocks; @@ -994,6 +1029,18 @@ std::optional LiteWalletController::refreshModelImpl( } } } + // A scan that gave up must not read as "synced". Recovery is observed, not guessed: the data commands each run a + // backend sync, so a refresh whose server-bound reads all succeeded means the wallet really caught up. + if (scan_->failed.load()) { + if (!refreshResult.syncReadFailed && refreshResult.bundle.successfulCommandCount > 0) { + scan_->failed.store(false); + scan_->setError(std::string()); + } else if (model.hasSyncStatus) { + model.sync.complete = false; + model.sync.progress = std::min(model.sync.progress, 0.999); + } + } + model.syncError = scan_->errorCopy(); return model; } @@ -1344,6 +1391,7 @@ void LiteWalletController::retire(const std::string& reason) opLane_.pump(); // ops that already finished are delivered with their real result, not "reconnecting" opLane_.abandon(); // queued ops never run (they would hit the NEXT global client); secrets wiped failPendingWalletOps(reason); // every UI callback fires exactly once (normally none: App defers the switch) + scan_->cancel.store(true); // the detached sync thread stops retrying (it may be in a back-off sleep) requestStop(); // non-blocking: the worker exits after its in-flight backend command if (bridge_) bridge_->shutdown(); // stop THIS client's mempool monitor while it is still the global one } diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index 7196bbb..508329c 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -153,6 +153,19 @@ inline bool liteServerSwitchMayProceed(bool walletOpInFlight, double deferredSec return !walletOpInFlight || deferredSeconds >= kLiteServerSwitchMaxDeferSeconds; } +// Outcome of the background sync/rescan thread, shared with it by shared_ptr (the thread never touches the controller). +struct LiteScanState { + std::atomic cancel{false}; // controller destroyed / retired: stop retrying + std::atomic failed{false}; // gave up (terminal error or too many attempts); cleared by recovery/rescan + std::atomic retryBaseMs{5000}; // first retry delay (tests shrink it) + mutable std::mutex mutex; + std::string error; // last failure text (guarded by mutex); empty = none + + void setError(const std::string& e) { std::lock_guard l(mutex); error = e; } + std::string errorCopy() const { std::lock_guard l(mutex); return error; } +}; +constexpr int kLiteScanMaxAttempts = 6; // first try + 5 retries (the backend already retries 6x62 s inside each) + class LiteWalletController { public: LiteWalletController(WalletCapabilities capabilities, @@ -235,6 +248,13 @@ public: bool syncStarted() const { return syncStarted_; } bool syncComplete() const { return syncDone_ && syncDone_->load(); } + // True after the background sync/rescan gave up (see LiteScanState); scanError() is the backend's text (also set + // while it is still retrying). Neither is persisted. Safe from any thread. + bool scanFailed() const { return scan_->failed.load(); } + std::string scanError() const { return scan_->errorCopy(); } + // Tests only: first sync-retry delay in ms (default 5000); call before createWallet()/startSync(). + void setSyncRetryBaseMsForTest(int ms) { scan_->retryBaseMs.store(ms); } + // Launch the backend sync on a detached background thread (NON-blocking; the backend's // `sync` command runs a full, uninterruptible chain scan). Auto-invoked when a lifecycle // op produces a ready wallet; safe to call once. @@ -394,6 +414,7 @@ private: std::thread syncThread_; std::atomic syncLaunched_{false}; // startSync() guard (set on the main thread) std::shared_ptr> syncDone_ = std::make_shared>(false); + std::shared_ptr scan_ = std::make_shared(); // Detached background broadcast (send/shield): proving can take seconds. Mirrors the sync // thread's shared-lifetime pattern (shared running flag + result slot captured by the diff --git a/src/wallet/lite_wallet_state_mapper.h b/src/wallet/lite_wallet_state_mapper.h index be04cfd..0f4a104 100644 --- a/src/wallet/lite_wallet_state_mapper.h +++ b/src/wallet/lite_wallet_state_mapper.h @@ -107,6 +107,7 @@ struct LiteWalletAppRefreshModel { bool hasEncryptionStatus = false; bool encrypted = false; bool locked = false; + std::string syncError; // lite only: last sync/rescan failure text ("" = none); filled by the controller std::size_t successfulCommandCount = 0; LiteWalletAppChainModel chain; diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index 9612985..8e89980 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -36,6 +36,10 @@ inline bool g_liteFakeWalletExists = true; inline bool g_liteFakeServerOnline = true; inline bool g_liteFakeShutdownCalled = false; inline std::atomic g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks +inline std::atomic g_liteFakeSyncFailures{0}; // next N "sync" commands fail with the real plain-text shape +inline std::atomic g_liteFakeSyncTerminal{false}; // "sync" returns the terminal reorg error +inline std::atomic g_liteFakeRescanFailures{0}; // next N "rescan" commands fail +inline std::atomic g_liteFakeSyncCalls{0}; inline std::atomic g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON inline std::atomic g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..} inline std::atomic 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 6a7211f..3c78242 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(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(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::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();