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:
@@ -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<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
|
||||
// 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<LiteWalletAppRefreshModel> 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<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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user