From c3ffc97b314ad0ec019419bf09b637a04e456231 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 12 Oct 2026 21:42:23 -0500 Subject: [PATCH] fix(ui): finish a stranded bootstrap even when its dialog is closed A Settings bootstrap was only wrapped up inside the dialog's own draw path. If the dialog finished downloading, verifying or extracting while the user had clicked away or minimized the window, completion was never processed: the connect and reconcile gate stayed held and the post-bootstrap rescan was never armed. - Completion is now a pure decision driven from a pump() that both the per-frame update and the dialog's render call, so a finished bootstrap is handled every tick whether or not the dialog is drawn. - With the dialog open the behaviour is unchanged: it advances to the Done or Failed screen and those screens still own the gate. With the dialog closed, pump() releases the gate itself and shows a success or failure toast; a user-cancelled run is released silently. User-visible: a bootstrap started from Settings now completes, arms its rescan and reports its result even if the dialog is closed or the window is minimized when it finishes. Tests: the completion decision for an unfinished worker, a completed run with the dialog open and closed, a failed run, and a user-cancelled run. Co-Authored-By: Claude Opus 4.8 --- src/app.cpp | 4 ++ src/ui/windows/bootstrap_completion.h | 36 ++++++++++++++++ src/ui/windows/bootstrap_download_dialog.h | 48 +++++++++++++++------- tests/test_phase4.cpp | 42 +++++++++++++++++++ 4 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 src/ui/windows/bootstrap_completion.h diff --git a/src/app.cpp b/src/app.cpp index 43ee453..7ab136d 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1518,6 +1518,10 @@ void App::update() // C210: the "Keep in each address" consolidation run, if one is going (paced; one RPC at a time). pumpConsolidation(); + // A Settings bootstrap that finished while its dialog wasn't drawn (clicked away, minimized): process it + // here so the connect and reconcile gates release and the post-bootstrap rescan is armed. + ui::BootstrapDownloadDialog::pump(); + // After a bootstrap the preserved wallet.dat has stale spent-state and must be reconciled // against the freshly-imported chain, or the first send tries to spend already-spent notes and // is rejected. The -rescan restart can't do it (the snapshot lacks pre-snapshot blocks), so we diff --git a/src/ui/windows/bootstrap_completion.h b/src/ui/windows/bootstrap_completion.h new file mode 100644 index 0000000..0fad045 --- /dev/null +++ b/src/ui/windows/bootstrap_completion.h @@ -0,0 +1,36 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Pure decision for what to do when the Settings bootstrap worker finishes (C221). + +#pragma once + +#include "../../util/bootstrap.h" + +namespace dragonx { +namespace ui { + +struct BootstrapCompletion { + enum class Next { Pending, Done, Failed }; + Next next = Next::Pending; + bool markRescan = false; // completed: reconcile the preserved wallet.dat against the new chain + bool releaseGate = false; // dialog not open: clear App::bootstrap_downloading_ ourselves + bool toast = false; // dialog not open (and not a user cancel): tell the user the outcome +}; + +inline BootstrapCompletion resolveBootstrapCompletion(bool workerDone, util::Bootstrap::State state, + bool dialogOpen, bool userCancelled) +{ + BootstrapCompletion c; + if (!workerDone) return c; + const bool ok = (state == util::Bootstrap::State::Completed); + c.next = ok ? BootstrapCompletion::Next::Done : BootstrapCompletion::Next::Failed; + c.markRescan = ok; + c.releaseGate = !dialogOpen; + c.toast = !dialogOpen && !userCancelled; + return c; +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/bootstrap_download_dialog.h b/src/ui/windows/bootstrap_download_dialog.h index 57f5a77..d31f4aa 100644 --- a/src/ui/windows/bootstrap_download_dialog.h +++ b/src/ui/windows/bootstrap_download_dialog.h @@ -15,6 +15,8 @@ #include "../material/type.h" #include "../material/colors.h" #include "../theme.h" +#include "../notifications.h" +#include "bootstrap_completion.h" #include "../../embedded/IconsMaterialDesign.h" #include "imgui.h" @@ -69,6 +71,35 @@ public: else (void)s_bootstrap.release(); } + // Process a finished worker. Idempotent and safe every tick: App::update() calls it so completion never + // depends on the dialog being drawn (clicked away, window minimized); renderProgress() calls it too (the + // UI sweep returns from update() early). With the dialog open it only advances the screen (Done/Failed own + // the gate: Close / Restart daemon clear it, so the Done screen behaves as before). With the dialog + // closed nobody can press those, so release the connect/reconcile gate here and toast the outcome. + static void pump() { + if (!s_app || !s_bootstrap || s_app->isShuttingDown()) return; // quit: abortForShutdown() owns it + if (!s_bootstrap->isDone()) return; + const auto finalProg = s_bootstrap->getProgress(); + const auto c = resolveBootstrapCompletion(true, finalProg.state, s_open, s_cancelling); + if (c.markRescan) s_app->markPostBootstrapRescanPending(); + if (c.next == BootstrapCompletion::Next::Done) { + s_state = State::Done; + } else { + s_errorMsg = finalProg.error; + if (s_errorMsg.empty()) s_errorMsg = TR("grpc_bootstrap_failed"); + s_state = State::Failed; + } + s_bootstrap.reset(); + s_cancelling = false; + if (c.releaseGate) s_app->setBootstrapDownloading(false); + if (c.toast) { + if (c.next == BootstrapCompletion::Next::Done) + Notifications::instance().success(TR("bootstrap_success")); + else + Notifications::instance().error(std::string(TR("bootstrap_failed")) + ": " + s_errorMsg); + } + } + static void render() { if (!s_app) return; if (!s_open) { @@ -240,21 +271,8 @@ private: } ImGui::EndDisabled(); - // Check completion - if (s_bootstrap->isDone()) { - auto finalProg = s_bootstrap->getProgress(); - if (finalProg.state == util::Bootstrap::State::Completed) { - // Reconcile the preserved wallet.dat against the new chain once the daemon is back up. - s_app->markPostBootstrapRescanPending(); - s_state = State::Done; - } else { - s_errorMsg = finalProg.error; - if (s_errorMsg.empty()) s_errorMsg = TR("grpc_bootstrap_failed"); - s_state = State::Failed; - } - s_bootstrap.reset(); - s_cancelling = false; - } + // Check completion (shared with App::update() so a closed dialog can't strand a finished bootstrap) + pump(); } // ---- Done screen ---- diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 1060cdf..e1819e3 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -89,6 +89,7 @@ #include "util/daemon_updater.h" #include "util/http_download.h" #include "util/bootstrap.h" +#include "ui/windows/bootstrap_completion.h" #include "util/background_sampler.h" #include "util/lite_server_probe.h" #include "util/wallet_file_probe.h" @@ -17667,6 +17668,46 @@ void testWriteFileIfDifferent() removeTree(dir); } +void testBootstrapCompletion() +{ + using dragonx::ui::resolveBootstrapCompletion; + using B = dragonx::util::Bootstrap; + using N = dragonx::ui::BootstrapCompletion::Next; + + // Worker not finished: nothing happens regardless of state/dialog. + { + auto c = resolveBootstrapCompletion(false, B::State::Extracting, false, false); + EXPECT_TRUE(c.next == N::Pending); + EXPECT_TRUE(!c.markRescan && !c.releaseGate && !c.toast); + } + // Completed, dialog open: rescan, but the dialog owns the gate/toast. + { + auto c = resolveBootstrapCompletion(true, B::State::Completed, true, false); + EXPECT_TRUE(c.next == N::Done); + EXPECT_TRUE(c.markRescan); + EXPECT_TRUE(!c.releaseGate && !c.toast); + } + // Completed, dialog closed: we release the gate and toast the success. + { + auto c = resolveBootstrapCompletion(true, B::State::Completed, false, false); + EXPECT_TRUE(c.next == N::Done); + EXPECT_TRUE(c.markRescan && c.releaseGate && c.toast); + } + // Failed, dialog closed: release the gate and toast, but no rescan. + { + auto c = resolveBootstrapCompletion(true, B::State::Failed, false, false); + EXPECT_TRUE(c.next == N::Failed); + EXPECT_TRUE(!c.markRescan); + EXPECT_TRUE(c.releaseGate && c.toast); + } + // User-cancelled, dialog closed: release the gate silently (no toast, no rescan). + { + auto c = resolveBootstrapCompletion(true, B::State::Failed, false, true); + EXPECT_TRUE(c.next == N::Failed); + EXPECT_TRUE(!c.markRescan && c.releaseGate && !c.toast); + } +} + } // namespace int main() @@ -17800,6 +17841,7 @@ int main() testBootstrapPreflightKeepsOldChain(); testBootstrapUserCancelWindow(); testBootstrapQuitWait(); + testBootstrapCompletion(); testWriteFileAtomic(); testCopyFileAtomic(); testBundleCopy();