diff --git a/src/app.cpp b/src/app.cpp index 8a09e3b..7662085 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 224d5a7..fea33e3 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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%%"; diff --git a/src/wallet/lite_result_parsers.cpp b/src/wallet/lite_result_parsers.cpp index f934b39..0918ad6 100644 --- a/src/wallet/lite_result_parsers.cpp +++ b/src/wallet/lite_result_parsers.cpp @@ -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 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 diff --git a/src/wallet/lite_result_parsers.h b/src/wallet/lite_result_parsers.h index 3bc981d..c7210d1 100644 --- a/src/wallet/lite_result_parsers.h +++ b/src/wallet/lite_result_parsers.h @@ -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; }; diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index a288960..50e3d8a 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -1070,7 +1070,12 @@ std::optional 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 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 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 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 lock(modelMutex_); pendingModel_ = std::move(model); } else { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 2a7a522..3bfba3c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -12006,6 +12006,24 @@ void testLiteSyncStatusParserRealShapes() const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}")); EXPECT_FALSE(p.ok); } + // Missing or unrecognised "syncing" must NOT default to fully synced. + { + const auto p = parseLiteSyncStatusResponse(std::string("{}")); + EXPECT_TRUE(p.ok); + EXPECT_FALSE(p.syncStatus.complete); + EXPECT_TRUE(p.syncStatus.indeterminate); + EXPECT_NEAR(p.syncStatus.progress, 0.0, 1e-9); + const auto q = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"maybe\"}")); + EXPECT_TRUE(q.ok); + EXPECT_FALSE(q.syncStatus.complete); + EXPECT_TRUE(q.syncStatus.indeterminate); + } + // Explicit idle is still caught-up and determinate. + { + const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}")); + EXPECT_TRUE(p.syncStatus.complete); + EXPECT_FALSE(p.syncStatus.indeterminate); + } } // Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)