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:
@@ -3110,6 +3110,10 @@ void App::renderStatusBar()
|
||||
} else if (rpc_busy_gate_->busy()) {
|
||||
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
||||
} else if (isLiteBuild() && !state_.sync.lite_error.empty()) {
|
||||
// Lite scan/server failure: show the error instead of a frozen 'Syncing 99.9%'.
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Error()), TR("sb_lite_sync_error"),
|
||||
state_.sync.lite_error.c_str());
|
||||
} else if (state_.sync.syncing) {
|
||||
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
||||
int blocksLeft = chainTip - state_.sync.blocks;
|
||||
|
||||
@@ -1595,6 +1595,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["sb_mining_hs"] = "%.1f H/s";
|
||||
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
||||
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
||||
strings_["sb_lite_sync_error"] = "Sync error: %.80s";
|
||||
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
||||
strings_["sb_rescanning"] = "Rescanning";
|
||||
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
||||
|
||||
@@ -587,11 +587,20 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
|
||||
|
||||
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
||||
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
||||
std::string syncing = "false";
|
||||
if (!readOptionalStringField(value, "syncing", syncing, result)) return result;
|
||||
std::optional<std::string> syncingField;
|
||||
if (!readOptionalStringField(value, "syncing", syncingField, result)) return result;
|
||||
const std::string syncing = syncingField.value_or(std::string());
|
||||
const bool isSyncing = (syncing == "true" || syncing == "1");
|
||||
const bool isIdle = (syncing == "false" || syncing == "0");
|
||||
|
||||
if (isSyncing) {
|
||||
if (!isSyncing && !isIdle) {
|
||||
// Missing/odd "syncing": we do not KNOW the wallet is caught up, so never default to synced.
|
||||
result.syncStatus.syncedBlocks = 0;
|
||||
result.syncStatus.totalBlocks = 0;
|
||||
result.syncStatus.complete = false;
|
||||
result.syncStatus.indeterminate = true;
|
||||
result.syncStatus.progress = 0.0;
|
||||
} else if (isSyncing) {
|
||||
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
||||
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
||||
result.syncStatus.complete = false; // actively syncing
|
||||
|
||||
@@ -130,6 +130,7 @@ struct LiteSyncStatusResponse {
|
||||
std::uint64_t totalBlocks = 0;
|
||||
double progress = 0.0;
|
||||
bool complete = false;
|
||||
bool indeterminate = false; // "syncing" missing/unrecognised: state unknown, NOT assumed synced
|
||||
};
|
||||
|
||||
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user