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:
@@ -147,6 +147,7 @@ struct SyncInfo {
|
|||||||
double verification_progress = 0.0;
|
double verification_progress = 0.0;
|
||||||
bool syncing = false;
|
bool syncing = false;
|
||||||
std::string best_blockhash;
|
std::string best_blockhash;
|
||||||
|
std::string lite_error; // lite: last sync failure text, "" = none
|
||||||
|
|
||||||
// Rescan state (detected from daemon output)
|
// Rescan state (detected from daemon output)
|
||||||
bool rescanning = false;
|
bool rescanning = false;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "wallet/lite_result_parsers.h"
|
#include "wallet/lite_result_parsers.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
@@ -583,4 +584,32 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
|
|||||||
return result;
|
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<std::string>() == "success") {
|
||||||
|
out.ok = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::size_t b = 0, e = text.size();
|
||||||
|
while (b < e && std::isspace(static_cast<unsigned char>(text[b]))) ++b;
|
||||||
|
while (e > b && std::isspace(static_cast<unsigned char>(text[e - 1]))) --e;
|
||||||
|
out.error = text.substr(b, std::min<std::size_t>(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<int>(ms < kMaxMs ? ms : kMaxMs);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace dragonx::wallet
|
} // namespace dragonx::wallet
|
||||||
@@ -166,4 +166,16 @@ LiteTransactionsParseResult parseLiteTransactionsResponse(const nlohmann::json&
|
|||||||
LiteSyncStatusParseResult parseLiteSyncStatusResponse(const std::string& jsonText);
|
LiteSyncStatusParseResult parseLiteSyncStatusResponse(const std::string& jsonText);
|
||||||
LiteSyncStatusParseResult parseLiteSyncStatusResponse(const nlohmann::json& value);
|
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
|
} // namespace dragonx::wallet
|
||||||
@@ -106,6 +106,51 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
|
|||||||
return false;
|
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<LiteClientBridge>& bridge,
|
||||||
|
const std::shared_ptr<std::atomic<bool>>& done,
|
||||||
|
const std::shared_ptr<LiteScanState>& 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
|
// 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
|
// 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.
|
// ([{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.verification_progress = model.sync.progress;
|
||||||
state.sync.syncing = !model.sync.complete;
|
state.sync.syncing = !model.sync.complete;
|
||||||
}
|
}
|
||||||
|
state.sync.lite_error = model.syncError;
|
||||||
|
|
||||||
if (model.hasEncryptionStatus) {
|
if (model.hasEncryptionStatus) {
|
||||||
// Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly, and mark
|
// Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly, and mark
|
||||||
@@ -455,6 +501,7 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
|
|||||||
|
|
||||||
LiteWalletController::~LiteWalletController()
|
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)
|
stopWorker(); // joins the fast poll worker (short iterations)
|
||||||
// Best-effort flush on shutdown: the mempool monitor's unconfirmed updates aren't persisted
|
// 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
|
// 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.
|
// a detached thread. Capture shared refs (not the controller) so it is safe to outlive us.
|
||||||
auto bridge = bridge_;
|
auto bridge = bridge_;
|
||||||
auto done = syncDone_;
|
auto done = syncDone_;
|
||||||
syncThread_ = std::thread([bridge, done] {
|
auto scan = scan_;
|
||||||
if (bridge) {
|
syncThread_ = std::thread([bridge, done, scan] { runLiteScanThread(bridge, done, scan, "sync"); });
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LiteWalletController::startRescan()
|
bool LiteWalletController::startRescan()
|
||||||
@@ -897,16 +935,12 @@ bool LiteWalletController::startRescan()
|
|||||||
|
|
||||||
auto bridge = bridge_;
|
auto bridge = bridge_;
|
||||||
auto done = syncDone_;
|
auto done = syncDone_;
|
||||||
syncThread_ = std::thread([bridge, done] {
|
auto scan = scan_;
|
||||||
if (bridge) {
|
scan->failed.store(false); // a fresh attempt: the previous failure no longer applies
|
||||||
// `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the
|
scan->setError(std::string());
|
||||||
// birthday height — a blocking, uninterruptible full scan, same as `sync`.
|
// `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the birthday height — a
|
||||||
bridge->execute("rescan", "");
|
// blocking, uninterruptible full scan, same as `sync`. Retries (if it fails) are `sync`.
|
||||||
if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-rescan save silent
|
syncThread_ = std::thread([bridge, done, scan] { runLiteScanThread(bridge, done, scan, "rescan"); });
|
||||||
liteLog("save failed after rescan — the next launch will re-scan from the checkpoint");
|
|
||||||
}
|
|
||||||
done->store(true);
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,6 +973,7 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
if (!syncResult.ok) return std::nullopt;
|
if (!syncResult.ok) return std::nullopt;
|
||||||
LiteWalletAppRefreshModel model;
|
LiteWalletAppRefreshModel model;
|
||||||
applyEncryption(model);
|
applyEncryption(model);
|
||||||
|
model.syncError = scan_->errorCopy();
|
||||||
model.hasSyncStatus = true;
|
model.hasSyncStatus = true;
|
||||||
model.sync.walletHeight = syncResult.syncStatus.syncedBlocks;
|
model.sync.walletHeight = syncResult.syncStatus.syncedBlocks;
|
||||||
model.sync.chainHeight = syncResult.syncStatus.totalBlocks;
|
model.sync.chainHeight = syncResult.syncStatus.totalBlocks;
|
||||||
@@ -994,6 +1029,18 @@ std::optional<LiteWalletAppRefreshModel> 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;
|
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_.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
|
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)
|
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
|
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
|
if (bridge_) bridge_->shutdown(); // stop THIS client's mempool monitor while it is still the global one
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,19 @@ inline bool liteServerSwitchMayProceed(bool walletOpInFlight, double deferredSec
|
|||||||
return !walletOpInFlight || deferredSeconds >= kLiteServerSwitchMaxDeferSeconds;
|
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<bool> cancel{false}; // controller destroyed / retired: stop retrying
|
||||||
|
std::atomic<bool> failed{false}; // gave up (terminal error or too many attempts); cleared by recovery/rescan
|
||||||
|
std::atomic<int> 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<std::mutex> l(mutex); error = e; }
|
||||||
|
std::string errorCopy() const { std::lock_guard<std::mutex> l(mutex); return error; }
|
||||||
|
};
|
||||||
|
constexpr int kLiteScanMaxAttempts = 6; // first try + 5 retries (the backend already retries 6x62 s inside each)
|
||||||
|
|
||||||
class LiteWalletController {
|
class LiteWalletController {
|
||||||
public:
|
public:
|
||||||
LiteWalletController(WalletCapabilities capabilities,
|
LiteWalletController(WalletCapabilities capabilities,
|
||||||
@@ -235,6 +248,13 @@ public:
|
|||||||
bool syncStarted() const { return syncStarted_; }
|
bool syncStarted() const { return syncStarted_; }
|
||||||
bool syncComplete() const { return syncDone_ && syncDone_->load(); }
|
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
|
// 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
|
// `sync` command runs a full, uninterruptible chain scan). Auto-invoked when a lifecycle
|
||||||
// op produces a ready wallet; safe to call once.
|
// op produces a ready wallet; safe to call once.
|
||||||
@@ -394,6 +414,7 @@ private:
|
|||||||
std::thread syncThread_;
|
std::thread syncThread_;
|
||||||
std::atomic<bool> syncLaunched_{false}; // startSync() guard (set on the main thread)
|
std::atomic<bool> syncLaunched_{false}; // startSync() guard (set on the main thread)
|
||||||
std::shared_ptr<std::atomic<bool>> syncDone_ = std::make_shared<std::atomic<bool>>(false);
|
std::shared_ptr<std::atomic<bool>> syncDone_ = std::make_shared<std::atomic<bool>>(false);
|
||||||
|
std::shared_ptr<LiteScanState> scan_ = std::make_shared<LiteScanState>();
|
||||||
|
|
||||||
// Detached background broadcast (send/shield): proving can take seconds. Mirrors the sync
|
// 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
|
// thread's shared-lifetime pattern (shared running flag + result slot captured by the
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ struct LiteWalletAppRefreshModel {
|
|||||||
bool hasEncryptionStatus = false;
|
bool hasEncryptionStatus = false;
|
||||||
bool encrypted = false;
|
bool encrypted = false;
|
||||||
bool locked = false;
|
bool locked = false;
|
||||||
|
std::string syncError; // lite only: last sync/rescan failure text ("" = none); filled by the controller
|
||||||
std::size_t successfulCommandCount = 0;
|
std::size_t successfulCommandCount = 0;
|
||||||
|
|
||||||
LiteWalletAppChainModel chain;
|
LiteWalletAppChainModel chain;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ inline bool g_liteFakeWalletExists = true;
|
|||||||
inline bool g_liteFakeServerOnline = true;
|
inline bool g_liteFakeServerOnline = true;
|
||||||
inline bool g_liteFakeShutdownCalled = false;
|
inline bool g_liteFakeShutdownCalled = false;
|
||||||
inline std::atomic<bool> g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks
|
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_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
|
||||||
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
|
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)
|
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
|
||||||
@@ -96,6 +100,10 @@ inline void resetLiteFakeCounters()
|
|||||||
g_liteFakeBlockImport = false;
|
g_liteFakeBlockImport = false;
|
||||||
g_liteFakeImportFails = false;
|
g_liteFakeImportFails = false;
|
||||||
g_liteFakeImportEncrypted = false;
|
g_liteFakeImportEncrypted = false;
|
||||||
|
g_liteFakeSyncFailures = 0;
|
||||||
|
g_liteFakeSyncTerminal = false;
|
||||||
|
g_liteFakeRescanFailures = 0;
|
||||||
|
g_liteFakeSyncCalls = 0;
|
||||||
g_liteFakeLogEnabled = false;
|
g_liteFakeLogEnabled = false;
|
||||||
(void)liteFakeTakeLog();
|
(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
|
// Simulate the real backend's blocking full sync when requested, so tests can
|
||||||
// verify shutdown doesn't hang on an in-flight sync.
|
// verify shutdown doesn't hang on an in-flight sync.
|
||||||
while (g_liteFakeSyncBlock.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
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\"}");
|
return liteFakeDup("{\"result\":\"success\"}");
|
||||||
}
|
}
|
||||||
if (std::strcmp(c, "syncstatus") == 0) // real backend shape: "syncing" is a string
|
if (std::strcmp(c, "syncstatus") == 0) // real backend shape: "syncing" is a string
|
||||||
|
|||||||
@@ -11503,6 +11503,207 @@ void testLiteWalletControllerWorkerBacksOffOnSyncReadFailure()
|
|||||||
dragonx::test::g_liteFakeLogEnabled = false;
|
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()
|
void testLiteWalletControllerDestroyWithBlockedOp()
|
||||||
{
|
{
|
||||||
using namespace dragonx::wallet;
|
using namespace dragonx::wallet;
|
||||||
@@ -17483,6 +17684,12 @@ int main()
|
|||||||
testLiteWalletControllerImportClassifier();
|
testLiteWalletControllerImportClassifier();
|
||||||
testLiteWalletControllerEncryptsBeforeSyncStarts();
|
testLiteWalletControllerEncryptsBeforeSyncStarts();
|
||||||
testLiteWalletControllerWorkerBacksOffOnSyncReadFailure();
|
testLiteWalletControllerWorkerBacksOffOnSyncReadFailure();
|
||||||
|
testLiteSyncOpResponseParser();
|
||||||
|
testLiteWalletControllerSyncRetriesAfterFailure();
|
||||||
|
testLiteWalletControllerSyncGivesUpAndReportsFailure();
|
||||||
|
testLiteWalletControllerTerminalReorgStopsRetrying();
|
||||||
|
testLiteWalletControllerRescanFailureRetriesWithSyncNotRescan();
|
||||||
|
testLiteWalletControllerCancelStopsSyncRetry();
|
||||||
testLiteWalletControllerDestroyWithBlockedOp();
|
testLiteWalletControllerDestroyWithBlockedOp();
|
||||||
testLiteRolloutVersionCompare();
|
testLiteRolloutVersionCompare();
|
||||||
testLiteRolloutBucketAndOverrideHelpers();
|
testLiteRolloutBucketAndOverrideHelpers();
|
||||||
|
|||||||
Reference in New Issue
Block a user