From 7e8b99a82bbe8428079d037f7c50a7e8c30e77a9 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 30 Aug 2026 23:32:03 -0500 Subject: [PATCH] feat(shutdown): confirm before stopping the daemon mid witness-cache rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopping dragonxd while it's rebuilding the Sapling witness cache discards the in-progress work — BuildWitnessCache aborts on shutdown without persisting — so the next launch redoes a multi-minute rebuild (the "Activating best chain…" hang). This bites especially with stop_external_daemon enabled, where wallet exit sends the node a stop. beginShutdown() now defers when it would StopDaemon while a rebuild is active and shows a confirm modal: "Keep node running & quit" (DisconnectOnly — leaves it up to finish), "Stop anyway & quit", or "Cancel". Rebuild detection reads the debug.log tail markers (Cleared witness data / Setting Initial Sapling Witness / Reading blocks for witness rebuild, vs. the "rebuilt … in …ms" / abort lines). The gate lives entirely in beginShutdown()/render() — no SDL event-loop changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/app.h | 13 +++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/app.cpp b/src/app.cpp index 304d60b..4d25ca5 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -2224,6 +2224,7 @@ void App::render() renderDecryptWalletDialog(); renderPinDialogs(); renderSwitchStopDaemonDialog(); + renderDaemonStopConfirm(); renderBlockDbReindexDialog(); renderWalletRecoveredDialog(); renderEmptyWalletWarningDialog(); @@ -5652,6 +5653,16 @@ void App::beginShutdown() { // Only start shutdown once if (shutting_down_) return; + + // Guard: don't silently discard an in-progress witness-cache rebuild. If we're about to stop the + // daemon while it's rebuilding (stopping now forces a multi-minute rebuild on the next launch), + // defer shutdown and let render() show the confirm modal. The user's choice re-enters beginShutdown() + // with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_). + if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) { + pending_shutdown_confirm_ = true; + return; // NOT shutting down yet — the normal UI + modal keep rendering + } + shutting_down_ = true; quit_requested_ = true; shutdown_timer_ = 0.0f; @@ -5712,7 +5723,7 @@ void App::beginShutdown() } auto shutdownDecision = daemon_controller_->shutdownDecision( - settings_ && settings_->getKeepDaemonRunning(), + (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_, settings_ && settings_->getStopExternalDaemon()); if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason); @@ -5775,6 +5786,100 @@ std::vector App::tailDaemonDebugLog(int maxLines) const return lines; } +bool App::daemonWitnessRebuildActive() const +{ + // Scan the debug.log tail for the daemon's witness-rebuild markers (wallet.cpp): "Cleared witness + // data from" (start), "Setting Initial Sapling Witness" / "Reading blocks for witness rebuild" + // (progress), vs. "rebuilt N note witness cache(s)" / "aborting…" (finished). Active iff the most + // recent relevant line is a start/progress line, not a completion. + const auto lines = tailDaemonDebugLog(80); + int state = 0; // 0 none, 1 active, 2 finished/aborted + for (const auto& l : lines) { + if (l.find("note witness cache(s) to height") != std::string::npos || + l.find("aborting witness rebuild") != std::string::npos || + l.find("aborted during witness rebuild") != std::string::npos) { + state = 2; + } else if (l.find("Reading blocks for witness rebuild") != std::string::npos || + l.find("Setting Initial Sapling Witness") != std::string::npos || + l.find("Cleared witness data from") != std::string::npos) { + state = 1; + } + } + return state == 1; +} + +bool App::shouldConfirmDaemonStop() const +{ + if (!daemon_controller_) return false; + // Only relevant when this shutdown would actually STOP the daemon (embedded, or external with + // stop-on-exit) — a DisconnectOnly shutdown leaves it running and loses nothing. + const auto decision = daemon_controller_->shutdownDecision( + settings_ && settings_->getKeepDaemonRunning(), + settings_ && settings_->getStopExternalDaemon()); + if (decision.action != daemon::DaemonController::ShutdownAction::StopDaemon) return false; + return daemonWitnessRebuildActive(); +} + +void App::renderDaemonStopConfirm() +{ + using namespace ui::material; + if (pending_shutdown_confirm_) { + ImGui::OpenPopup("##DaemonStopConfirm"); + pending_shutdown_confirm_ = false; + daemon_stop_confirm_open_ = true; + } + if (!daemon_stop_confirm_open_) return; + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + bool proceed = false; + if (ImGui::BeginPopupModal("##DaemonStopConfirm", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1()); + ImGui::TextUnformatted("Node is rebuilding its witness cache"); + if (Type().subtitle1()) ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f); + ImGui::TextUnformatted( + "Stopping the node now discards the in-progress rebuild and restarts it (several minutes) " + "the next time you open the wallet. You can keep the node running instead."); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + ImGui::Spacing(); + + if (TactileButton("Keep node running & quit", ImVec2(0, 0))) { + shutdown_keep_daemon_override_ = true; + shutdown_confirmed_ = true; + daemon_stop_confirm_open_ = false; + proceed = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210))); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error())); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160))); + const bool stopAnyway = TactileButton("Stop anyway & quit", ImVec2(0, 0)); + ImGui::PopStyleColor(3); + if (stopAnyway) { + shutdown_confirmed_ = true; + daemon_stop_confirm_open_ = false; + proceed = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (TactileButton("Cancel", ImVec2(0, 0))) { + daemon_stop_confirm_open_ = false; // abort the quit; stay open + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } else { + daemon_stop_confirm_open_ = false; // dismissed via Esc / click-away = Cancel + } + + // Re-enter shutdown outside the popup scope now that the user has chosen (shutdown_confirmed_ set). + if (proceed) beginShutdown(); +} + void App::renderShutdownScreen() { using namespace ui::material; diff --git a/src/app.h b/src/app.h index ba1f41c..0bcd80d 100644 --- a/src/app.h +++ b/src/app.h @@ -182,6 +182,14 @@ public: */ std::vector tailDaemonDebugLog(int maxLines) const; + // True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort + // heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch. + bool daemonWitnessRebuildActive() const; + // Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress). + bool shouldConfirmDaemonStop() const; + // The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render(). + void renderDaemonStopConfirm(); + /** * @brief Render loading overlay in content area while daemon is starting/syncing * @param contentH Height of the content area child window @@ -911,6 +919,11 @@ private: bool address_list_dirty_ = false; // P8: dedup rebuildAddressList GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05) std::thread shutdown_thread_; + // Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm) + bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal + bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing + bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry + bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only float shutdown_timer_ = 0.0f; bool force_quit_confirm_ = false; std::chrono::steady_clock::time_point shutdown_start_time_;