fix(lite): surface sync failures instead of a frozen "Syncing 99.9%"; don't report a lost server as "Synced"

Lite-parity audit, sync-progress semantics (state/presentation only — no send/sync backend call changed):
- A scan failure set scan->failed + lite_error, but no UI read lite_error, so the user saw "Syncing 99.9%"
  forever. The status bar now shows the error text on a lite scan/server failure.
- After the initial catch-up, a server loss on the cheap probe path (ProbeFailed) left the last "Synced"
  model on screen. The worker now re-publishes that model demoted (not complete, reason "server unreachable").
- refreshModelImpl's height-read "synced" override no longer fires while syncstatus says the backend is
  still scanning or is indeterminate, and a server-bound read failure in a full cycle demotes to not-synced.
- The syncstatus parser no longer treats a missing/unrecognised "syncing" field as fully synced: it reports
  an explicit "indeterminate" (not complete). Explicit "false" stays caught-up; the fake backend's
  syncing:true 1000/1000 behaviour is unchanged.

Tests: parser cases for missing/odd/idle "syncing" (testLiteSyncStatus*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:16:36 -05:00
co-authored by Claude Opus 4.8
parent 02fa1a228a
commit 6f369376ec
6 changed files with 64 additions and 4 deletions
+28 -1
View File
@@ -1070,7 +1070,12 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
// surface it as the synced height/tip so the block count is correct at rest.
if (bridge_ && (!honorStop || running_.load())) {
// Do not force "synced" from the height read while syncstatus says the backend is still scanning (work remaining)
// or could not be interpreted: the height only proves the last scanned block, not that we reached the tip.
const bool backendBusy = syncResult.ok && !syncResult.syncStatus.complete &&
(syncResult.syncStatus.indeterminate ||
syncResult.syncStatus.syncedBlocks < syncResult.syncStatus.totalBlocks);
if (bridge_ && !backendBusy && (!honorStop || running_.load())) {
const auto h = bridge_->execute("height", "");
if (h.ok) {
const auto parsed = parseLiteHeightResponse(h.value);
@@ -1094,7 +1099,14 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
model.sync.progress = std::min(model.sync.progress, 0.999);
}
}
// A server-bound read failed this cycle (server lost after catch-up): not synced, and say why. Without this the
// height override above kept reporting Synced because only a failed *scan* (scan_->failed) demoted it.
if (refreshResult.syncReadFailed && model.hasSyncStatus) {
model.sync.complete = false;
model.sync.progress = std::min(model.sync.progress, 0.999);
}
model.syncError = scan_->errorCopy();
if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable";
return model;
}
@@ -1633,6 +1645,7 @@ void LiteWalletController::workerLoop()
{
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
std::optional<LiteWalletAppRefreshModel> lastModel; // last published full model (to demote on a failed probe)
while (running_.load()) {
if (walletOpen_.load()) {
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
@@ -1644,6 +1657,19 @@ void LiteWalletController::workerLoop()
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
// applies and we don't double the dead-server stall. Next cycle takes the full path.
failStreak += 1;
// The probe path never re-derived sync state, so the last 'Synced' model stayed on screen while the
// server was gone. Re-publish it demoted, with the reason.
if (lastModel && running_.load()) {
auto demoted = *lastModel;
if (demoted.hasSyncStatus) {
demoted.sync.complete = false;
demoted.sync.progress = std::min(demoted.sync.progress, 0.999);
}
if (demoted.syncError.empty()) demoted.syncError = "server unreachable";
lastModel = demoted;
std::lock_guard<std::mutex> lock(modelMutex_);
pendingModel_ = std::move(demoted);
}
} else { // DoFull
// Sample the change baselines BEFORE the data commands (race rule).
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
@@ -1661,6 +1687,7 @@ void LiteWalletController::workerLoop()
gate.encrypted = m.encrypted;
gate.locked = m.locked;
gate.fullAt = std::chrono::steady_clock::now();
lastModel = m;
std::lock_guard<std::mutex> lock(modelMutex_);
pendingModel_ = std::move(model);
} else {