From e20b70d73ffbdc0cfc4b91343a665b07cc71ca5e Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 2 Oct 2026 11:22:39 -0500 Subject: [PATCH] fix(daemon): never restart, delete or swap under a node that hasn't exited Every operation that stops the node (restart, rescan, repair, delete chain data, reinstall, bootstrap, wallet switch/adopt/restore/rebuild, wizard restart, encrypt, decrypt) now: - refuses up front while the node is rebuilding witnesses or has not answered for 30s, with a "try again when it's done" warning, and sends no stop; - goes ahead only once the node's PROCESS has exited, not its RPC port. A stop that doesn't finish within 120s cancels the operation (warning; a cancelled rescan/repair disarms its flags); nothing is started over the old node, chain data isn't deleted, wallet.dat isn't swapped. Specifics: - stopDaemonForWalletSwitch() no longer reports "stopped" when the app is quitting (adopt could swap wallet.dat under a live node). - Decrypt waited a fixed 3s after "stop" before renaming wallet.dat; it now waits for the process to exit, and scrubs the key export if not. - Bootstrap waits for the process before cleanChainData and stops the node through the stop routine instead of the shared RPC client. - StopScope::Connected lets switch/reinstall/decrypt stop an adopted or external node through the same never-kill routine. - A bounded stop that can't reach the node (a foreign node refusing our credentials) gives up after 5s instead of 120s. - macOS: isDaemonProcessRunning() checks processes (libproc), not the RPC port. - rpcBusyWhileAlive() also reads the busy gate: since the gate, the first timeout usually lands on another lane and the 30s clock never started (also weakened the quit prompt for a silent rebuild). - Notifications render during the first-run wizard. Co-Authored-By: Claude Opus 5.5 --- res/fonts/NotoSansCJK-Subset.ttf | Bin 763608 -> 763744 bytes res/lang/de.json | 2 + res/lang/es.json | 2 + res/lang/fr.json | 2 + res/lang/ja.json | 2 + res/lang/ko.json | 2 + res/lang/pt.json | 2 + res/lang/ru.json | 2 + res/lang/zh.json | 2 + scripts/add_missing_translations.py | 20 +++ src/app.cpp | 168 ++++++++++-------- src/app.h | 26 ++- src/app_network.cpp | 4 + src/app_security.cpp | 37 ++-- src/app_wizard.cpp | 14 +- src/daemon/daemon_controller.h | 32 +++- src/daemon/embedded_daemon.cpp | 15 +- .../wallet_security_workflow_executor.cpp | 4 +- .../wallet_security_workflow_executor.h | 2 +- src/util/bootstrap.cpp | 12 +- src/util/i18n.cpp | 2 + tests/test_phase4.cpp | 90 +++++++++- 22 files changed, 320 insertions(+), 122 deletions(-) diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 3a5d5fb9d25908455a4b1e6902e8b0c575922400..309a1eff1fa3cc3a83b3743568aa3f70a1d0c2a0 100644 GIT binary patch delta 11457 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 D0H7#V delta 11294 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

2Dv#2Jji#ozz1c>*a6Z38bCJ>y77L(ccfc4 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 { - app_.sendStopCommandSafely(*app_.rpc_, context); - app_.rpc_->disconnect(); - } + if (app_.rpc_ && app_.rpc_->isConnected()) app_.rpc_->disconnect(); app_.onDisconnected(reason ? reason : "Daemon lifecycle"); + App& app = app_; + app.async_tasks_.submit("background-stop-daemon", + [&app](const util::AsyncTaskManager::Token&) { app.stopEmbeddedDaemon(); }); } int deleteBlockchainData() override @@ -1614,6 +1614,7 @@ void App::render() if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) { renderFirstRunWizard(); renderDaemonStopConfirm(); // a quit during the wizard still gets its "keep the node running?" prompt + ui::Notifications::instance().render(); return; } @@ -1643,6 +1644,19 @@ void App::render() } if (pending_reinstall_node_busy_.exchange(false, std::memory_order_relaxed)) ui::Notifications::instance().warning(TR("appx_daemon_reinstall_node_busy"), 12.0f); + if (const int failed = pending_stop_failed_.exchange(0, std::memory_order_relaxed)) { + if ((failed & kStopFailedUndoRescan) && daemon_controller_) { + // The rescan/repair never started: disarm its flags so a later start doesn't run it unasked. + daemon_controller_->setRescanOnNextStart(false); + daemon_controller_->setZapOnNextStart(false); + state_.sync.rescanning = false; + state_.sync.rescan_status.clear(); + rescan_confirmed_active_ = false; + user_initiated_rescan_ = false; + } + if ((failed & kStopFailedNotify) && !shutting_down_) + ui::Notifications::instance().warning(TR("appx_node_not_stopped"), 12.0f); + } } @@ -5554,22 +5568,23 @@ void App::chooseStopAction(daemon::StopUserChoice choice) } } -daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode) +daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode, StopScope scope) { - if (!daemon_controller_) return daemon::StopOutcome::Exited; - - auto shutdownDecision = daemon_controller_->shutdownDecision( - false, settings_ && settings_->getStopExternalDaemon()); - if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { - DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason); - return daemon::StopOutcome::StillRunning; + if (scope == StopScope::Policy) { + if (!daemon_controller_) return daemon::StopOutcome::Exited; + auto shutdownDecision = daemon_controller_->shutdownDecision( + false, settings_ && settings_->getStopExternalDaemon()); + if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { + DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason); + return daemon::StopOutcome::StillRunning; + } } // One stop at a time: a second caller (say the shutdown thread while a restart task is stopping the // node) waits for the first. Background stops let go as soon as the app starts quitting, so the // shutdown never waits long behind one. std::lock_guard single(stop_mutex_); - daemon::EmbeddedDaemon* d = daemon_controller_->daemon(); + daemon::EmbeddedDaemon* d = daemon_controller_ ? daemon_controller_->daemon() : nullptr; // beginStop() also marks the stop as requested (an early exit is not a crash) and makes this session // the only reaper. Only a process we launched and still hold is "owned": a lock-gated start, an // external node and a node we already let go of are not, and are never waited on, signalled or killed. @@ -5654,52 +5669,23 @@ bool App::isEmbeddedDaemonRunning() const bool App::stopDaemonForWalletSwitch() { - // "Owned" means we spawned the node this session and hold a live process handle. That is the ONLY - // reliable ownership signal here: externalDaemonDetected() is latched only inside EmbeddedDaemon::start(), - // so when the app just direct-connects to an already-running, config-provided node (the common - // keep-node-running case) start() is never called and that flag stays false even though we clearly did - // not start the node. Gate on the handle instead. - const bool owned = daemon_controller_ && isEmbeddedDaemonRunning(); - if (owned) { - // We spawned it — stopEmbeddedDaemon() waits for the process to exit (up to 120s, never killing - // it), so on Exited the datadir lock + RPC port are released; a short port check just confirms. - // Still running means the switch must not go ahead: the old node holds the wallet and the lock. - if (stopEmbeddedDaemon() != daemon::StopOutcome::Exited) return shutting_down_; - for (int i = 0; i < 200 && daemon::EmbeddedDaemon::isRpcPortInUse() && !shutting_down_; ++i) - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - return shutting_down_ || !daemon::EmbeddedDaemon::isRpcPortInUse(); - } - - // Adopted OR direct-connected: no process handle, so RPC "stop" is the only lever, and - // stopEmbeddedDaemon()'s temp connection (autoDetectConfig) can't be trusted here. Send a graceful RPC - // "stop" over the exact creds we're connected with (saved_config_) — guaranteed to reach our node, and - // only OUR node accepts them, so a foreign dragonxd (different rpcpassword) is a safe no-op. switchToWallet - // disconnects rpc_ before this worker runs, so build a fresh temporary connection. - bool sent = false; - { - auto tmp = std::make_unique(); - if (tmp->connect(saved_config_.host, saved_config_.port, saved_config_.rpcuser, - saved_config_.rpcpassword, saved_config_.use_tls)) { - sent = sendStopCommandSafely(*tmp, "wallet-switch stop"); - tmp->disconnect(); - } - } - DEBUG_LOGF("[App] wallet-switch stop of unowned node (saved_config_): %s\n", sent ? "sent" : "FAILED"); - - // CRITICAL: wait for the node to FULLY EXIT before the caller starts the replacement — NOT just for the - // RPC port. On Windows isRpcPortInUse() is a connect() probe that reads "free" the moment the daemon - // stops accepting RPC (early in shutdown), but the process keeps the DATADIR LOCK until it exits, and a - // graceful shutdown can take 60-90s when the node's network threads block on peer timeouts. Starting a - // replacement into a still-locked datadir fails ("Cannot obtain a lock…") and wedges the switch. So gate - // on the PROCESS being gone (isDaemonProcessRunning) AND the port free. Bounded ~120s while stopping; if - // we couldn't even send the stop (foreign node / bad creds → its port never frees), only a brief grace. - const int maxTicks = sent ? 1200 : 50; - auto stillUp = []() { + // Stop whatever node we're on: ours, or the adopted/external one we're connected to. "Owned" can't be + // read from externalDaemonDetected() (it's latched only inside EmbeddedDaemon::start(), so a node we + // direct-connected to reads as ours); StopScope::Connected covers both — our process handle when we have + // one, else a graceful "stop" with the exact credentials we connected with (only our node accepts them, + // so a foreign dragonxd is a safe no-op). Bounded, never killed, abandoned when the app starts quitting. + const daemon::StopOutcome outcome = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected); + // CRITICAL: the caller swaps wallet.dat or starts a replacement next, so it may go ahead only once the node + // has fully EXITED. dragonxd unbinds RPC early in its shutdown but keeps wallet.dat and the datadir lock + // until the process exits, which can take minutes. A wait abandoned because the app is quitting proves + // nothing: report "not stopped" then too. + if (outcome != daemon::StopOutcome::Exited || shutting_down_) return false; + auto stillUp = [] { return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); }; - for (int i = 0; i < maxTicks && stillUp() && !shutting_down_; ++i) + for (int i = 0; i < 200 && stillUp() && !shutting_down_; ++i) // up to 20s for the port to free std::this_thread::sleep_for(std::chrono::milliseconds(100)); - return shutting_down_ || !stillUp(); + return !shutting_down_ && !stillUp(); } void App::rescanBlockchain() @@ -5728,6 +5714,7 @@ void App::rescanBlockchain() ui::Notifications::instance().warning(TR("appx_node_busy_restarting")); return; } + if (refuseIfNodeBusy()) return; DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_restarting_daemon_rescan_flag")); @@ -5753,7 +5740,8 @@ void App::rescanBlockchain() DEBUG_LOGF("[App] Stopping daemon for rescan...\n"); AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext context(token, shutting_down_); - daemon_controller_->executeLifecycleOperation(decision, runtime, context); + if (daemon_controller_->executeLifecycleOperation(decision, runtime, context).stopFailed) + pending_stop_failed_.fetch_or(kStopFailedNotify | kStopFailedUndoRescan); }); } @@ -5780,6 +5768,7 @@ void App::repairWallet() ui::Notifications::instance().warning(TR("appx_node_busy_restarting")); return; } + if (refuseIfNodeBusy()) return; DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_restarting_daemon_zapwallettxes")); @@ -5804,7 +5793,8 @@ void App::repairWallet() DEBUG_LOGF("[App] Stopping daemon for wallet repair...\n"); AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext context(token, shutting_down_); - daemon_controller_->executeLifecycleOperation(decision, runtime, context); + if (daemon_controller_->executeLifecycleOperation(decision, runtime, context).stopFailed) + pending_stop_failed_.fetch_or(kStopFailedNotify | kStopFailedUndoRescan); }); } @@ -5831,6 +5821,7 @@ void App::reinstallBundledDaemon() ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress")); return; } + if (refuseIfNodeBusy()) return; DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_installing_bundled_daemon")); @@ -5838,14 +5829,9 @@ void App::reinstallBundledDaemon() async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) { AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_); - // Stop the running node so its binary is free. stopDaemonWithPolicy() covers a wallet-managed - // daemon; if we're instead attached to an external/leftover dragonxd (daemon_controller_ is - // null, so stopEmbeddedDaemon() returns immediately), send an explicit RPC stop so that node - // exits too — otherwise it keeps the RPC port and the fresh daemon can't start. - if (!daemon_controller_ && rpc_ && rpc_->isConnected()) { - sendStopCommandSafely(*rpc_, "reinstall-daemon"); - } - runtime.stopDaemonWithPolicy(); + // Stop the running node so its binary is free: ours, or the external/leftover dragonxd we're attached + // to (it would keep the RPC port and the fresh daemon couldn't start). Bounded, never killed. + stopEmbeddedDaemon(StopMode::Background, StopScope::Connected); // Wait (bounded, ~120s) for the old node's PROCESS to exit, not just its RPC port: dragonxd unbinds // RPC first and can take a minute or more to flush and close its wallet. Until it exits its exe is // locked on Windows (the overwrite would fail) and it holds the datadir lock (a restart would fail). @@ -5892,6 +5878,7 @@ void App::deleteBlockchainData() ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress")); return; } + if (refuseIfNodeBusy()) return; DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_stopping_daemon_deleting_blockchain")); @@ -5902,6 +5889,11 @@ void App::deleteBlockchainData() AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext context(token, shutting_down_); auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context); + if (result.stopFailed) { + DEBUG_LOGF("[App] Blockchain data NOT deleted: the node did not exit\n"); + pending_stop_failed_.fetch_or(kStopFailedNotify); + return; + } DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", result.deletedItems); // Hand the count to the main thread for a completion toast (Notifications isn't thread-safe). pending_delete_result_.store(result.deletedItems, std::memory_order_relaxed); @@ -6196,8 +6188,12 @@ void App::noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind) bool App::rpcBusyWhileAlive() const { - if (rpc_busy_since_ == std::chrono::steady_clock::time_point{}) return false; - if (std::chrono::steady_clock::now() - rpc_busy_since_ < std::chrono::seconds(30)) return false; + // Two clocks: the connect/core-refresh results, and the busy gate. The gate hears every lane (since S6 the + // first timeout often lands on another lane, after which the core refresh is only ever Deferred). + const bool resultsBusy = rpc_busy_since_ != std::chrono::steady_clock::time_point{} && + std::chrono::steady_clock::now() - rpc_busy_since_ >= std::chrono::seconds(30); + const bool gateBusy = rpc_busy_gate_ && rpc_busy_gate_->busySeconds() >= 30.0; + if (!resultsBusy && !gateBusy) return false; return (daemon_controller_ && daemon_controller_->isRunning()) || daemon::EmbeddedDaemon::isRpcPortInUse(); } @@ -6210,12 +6206,27 @@ bool App::shouldConfirmDaemonStop() const settings_ && settings_->getKeepDaemonRunning(), settings_ && settings_->getStopExternalDaemon()); const bool stops = decision.action == daemon::DaemonController::ShutdownAction::StopDaemon; - // The tracker reads OUR node's output; once that process is gone its last state means nothing. No - // daemon version suppresses this: v1.3.0 does not checkpoint a witness rebuild, and nothing survives + // No daemon version suppresses this: v1.3.0 does not checkpoint a witness rebuild, and nothing survives // a kill. + return stops && nodeBusyForStop(); +} + +bool App::nodeBusyForStop() const +{ + // The tracker reads OUR node's output; once that process is gone its last state means nothing. An + // external node's busyness shows only as RPC that has stopped answering. daemon::DaemonBusyTracker ours; - if (daemon_controller_->isRunning()) ours = daemon_busy_; - return daemon::shouldConfirmStop(stops, ours, rpcBusyWhileAlive()); + if (daemon_controller_ && daemon_controller_->isRunning()) ours = daemon_busy_; + return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive()); +} + +bool App::refuseIfNodeBusy() +{ + if (!nodeBusyForStop()) return false; + DEBUG_LOGF("[App] refused: the node is busy (%s) — stopping it now would make it start over\n", + daemon_busy_.rebuilding() ? "rebuilding witnesses" : "not answering RPC"); + ui::Notifications::instance().warning(TR("appx_node_busy_try_later"), 10.0f); + return true; } void App::renderDaemonStopConfirm() @@ -7295,6 +7306,7 @@ void App::restartDaemon() daemon::DaemonController::LifecycleOperation::ManualRestart, isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load()); if (!decision.allowed) return; + if (refuseIfNodeBusy()) return; daemon_restarting_ = true; daemon_controller_->prepareLifecycleOperation(decision, settings_.get()); @@ -7314,8 +7326,14 @@ void App::restartDaemon() async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) { AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext context(token, shutting_down_); - daemon_controller_->executeLifecycleOperation(decision, runtime, context); + const auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context); + // Re-arm the connect loop either way: it reconnects to the new node, or to the old one still running. daemon_restarting_ = false; + if (result.stopFailed) { + DEBUG_LOGF("[App] Daemon restart cancelled: the node did not exit; not starting a second one\n"); + pending_stop_failed_.fetch_or(kStopFailedNotify); + return; + } DEBUG_LOGF("[App] Daemon restart complete — waiting for RPC...\n"); }); } diff --git a/src/app.h b/src/app.h index 1fc2327..dd491e6 100644 --- a/src/app.h +++ b/src/app.h @@ -200,6 +200,12 @@ public: // The node is alive and its RPC has only timed out / been refused as busy for >= 30s (a rebuild that // logged no marker, e.g. an external node whose output we can't read). bool rpcBusyWhileAlive() const; + // The node is rebuilding witnesses (our own node's log says so) or has been too busy to answer for a + // while: stopping it now either waits up to an hour or makes it start over. UI thread only. + bool nodeBusyForStop() const; + // For an operation that would stop the node (restart, rescan, switch, reinstall, bootstrap, ...): when + // nodeBusyForStop(), tell the user to try again later and return true; the caller then does nothing. + bool refuseIfNodeBusy(); // Record how an RPC to the node just went, for rpcBusyWhileAlive(): busy kinds (ReplyTimeout / // QueueFull) start the clock, an answer stops it, anything else leaves it alone. void noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind); @@ -497,12 +503,16 @@ public: Background, // restart/rescan/switch/...: up to 120s, and let go as soon as the app starts quitting Fallback, // the synchronous shutdown fallback on the UI thread: up to 20s }; - daemon::StopOutcome stopEmbeddedDaemon(StopMode mode = StopMode::Background); - // Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on - // -wallet=. Unlike stopEmbeddedDaemon() (whose DisconnectOnly policy leaves an adopted/external - // daemon running), this sends a graceful RPC "stop" — using our own connection creds, so only our - // daemon obeys it — even to an adopted daemon, then waits (bounded) for the port to actually release. - // Returns true once the RPC port is free (or we're shutting down). + // Which node it may stop. Policy: our own, or an external one only when the "stop external daemon" + // setting says so. Connected: also the node we're connected to (adopted or external), with the + // credentials we connected with — a wallet switch, a reinstall and decrypt must free the port, the binary + // and wallet.dat. Only a node that accepts those credentials obeys. + enum class StopScope { Policy, Connected }; + daemon::StopOutcome stopEmbeddedDaemon(StopMode mode = StopMode::Background, StopScope scope = StopScope::Policy); + // Stop the node for a wallet switch / adopt / restore / rebuild, which swap wallet.dat or relaunch on + // -wallet=. Unlike stopEmbeddedDaemon()'s default policy (which leaves an adopted/external node + // running) it stops the node we're connected to too (StopScope::Connected). True only once the process + // has exited and the RPC port is free; false if it is still running, or the app started quitting. bool stopDaemonForWalletSwitch(); bool isEmbeddedDaemonRunning() const; bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; } @@ -1078,6 +1088,10 @@ private: // and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it. std::atomic pending_delete_result_{-1}; std::atomic pending_reinstall_node_busy_{false}; // reinstall gave up: the old node never exited + // A worker stopped the node for an operation and it didn't exit: the operation was cancelled. Told (and, + // for a rescan/repair, its armed state undone) on the UI thread. Bits: kStopFailed*. + static constexpr int kStopFailedNotify = 1, kStopFailedUndoRescan = 2; + std::atomic pending_stop_failed_{0}; // Encryption state check timeout float encryption_check_timer_ = 0.0f; diff --git a/src/app_network.cpp b/src/app_network.cpp index 4d3446e..88070a6 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1391,6 +1391,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets."); return; } + if (refuseIfNodeBusy()) return; // W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a // fresh empty wallet for a missing -wallet=, so without this a moved/deleted wallet file would // silently "open" as a brand-new empty wallet with a zero balance — looking exactly like fund loss. @@ -5071,6 +5072,7 @@ void App::locateSweepTxid() // restart with -rescan so the new keys pick up the swept funds. Runs on a background thread. void App::beginAdoptSeedWallet() { + if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free seed_migration_step_ = SeedMigrationStep::Adopting; seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…"; { std::lock_guard lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; } @@ -5186,6 +5188,7 @@ void App::restoreOriginalWallet() ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; } + if (refuseIfNodeBusy()) return; // Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the // honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every // file-safety step below is unchanged. @@ -5353,6 +5356,7 @@ void App::rebuildWalletDatabase() return; } if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; } + if (refuseIfNodeBusy()) return; const std::string helper = findWalletRebuildHelper(); if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; } diff --git a/src/app_security.cpp b/src/app_security.cpp index 8ba1ba2..819e231 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -214,7 +214,7 @@ public: : app_(app), token_(token) {} bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); } - void stopEmbeddedDaemon() override { app_.stopEmbeddedDaemon(); } + bool stopEmbeddedDaemon() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; } bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); } bool cancelled() const override { return token_.cancelled(); } bool shuttingDown() const override { return app_.isShuttingDown(); } @@ -292,8 +292,13 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100)); if (!token.cancelled() && !shutting_down_) { - stopEmbeddedDaemon(); - if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon(); + if (stopEmbeddedDaemon() == daemon::StopOutcome::Exited) { + if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon(); + } else if (!shutting_down_) { + // Still running: never start a second node over it. The connect loop goes back to it. + DEBUG_LOGF("[App] encryption restart: the node did not exit; not restarting it\n"); + pending_stop_failed_.fetch_or(kStopFailedNotify); + } } } catch (...) {} daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop) @@ -306,6 +311,7 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar void App::encryptWalletWithPassphrase(const std::string& passphrase) { if (!rpc_ || !rpc_->isConnected()) return; + if (refuseIfNodeBusy()) return; // encrypting ends in a node restart encrypt_in_progress_ = true; encrypt_status_ = TR("sec_encrypting_wallet"); @@ -1561,7 +1567,9 @@ void App::renderDecryptWalletDialog() { float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; ImGui::BeginDisabled(!valid || decryptState.inProgress); - if (ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) { + // Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click). + if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) && + !refuseIfNodeBusy()) { std::string passphrase(decrypt_pass_buf_); memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_)); wallet_security_workflow_.start(std::chrono::steady_clock::now()); @@ -1613,15 +1621,18 @@ void App::renderDecryptWalletDialog() { // Continue with step 3 worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb { - WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(), - [this](rpc::RPCClient& client, const char* context) { - return sendStopCommandSafely(client, context); - }); - services::WalletSecurityWorkflowExecutor::stopDaemon(decryptRpc); - - // Wait for daemon to fully stop - for (int i = 0; i < 30 && !shutting_down_; ++i) - std::this_thread::sleep_for(std::chrono::milliseconds(100)); + // Stop the node and wait for its PROCESS to exit before wallet.dat is + // renamed: dragonxd keeps the file open until it exits, which can take + // minutes on a large wallet (a fixed 3s wait let the rename run under a + // live node). Ours or the one we're connected to; bounded, never killed. + const bool stopped = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected) == + daemon::StopOutcome::Exited; + if (!stopped) { + App::scrubAndRemoveExport(filePlan.exportPath); // nothing will import it + return [this]() { + wallet_security_workflow_.fail(TR("appx_node_not_stopped")); + }; + } return [this, filePlan]() { wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet, diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp index 2b30807..e10f04c 100644 --- a/src/app_wizard.cpp +++ b/src/app_wizard.cpp @@ -77,6 +77,7 @@ void App::restartWizard() return; } + if (refuseIfNodeBusy()) return; // the wizard stops the node DEBUG_LOGF("[App] Restarting setup wizard — stopping daemon...\n"); // Reset crash counter for fresh wizard attempt @@ -874,7 +875,7 @@ void App::renderFirstRunWizard() { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary())); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::BeginDisabled(!supportsFullNodeLifecycleActions()); - if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2))) { + if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2)) && !refuseIfNodeBusy()) { // Stop embedded daemon before bootstrap to avoid chain data corruption stopDaemonForBootstrap(); bootstrap_ = std::make_unique(); @@ -974,9 +975,12 @@ void App::renderFirstRunWizard() { } } wizard_stop_status_ = TR("wiz_daemon_waiting_stop"); - for (int i = 0; i < 60 && !token.cancelled(); i++) { + // Stopped means the PROCESS is gone, not just its RPC port: dragonxd unbinds RPC + // early in its shutdown and holds the datadir lock until it exits. + for (int i = 0; i < 120 && !token.cancelled(); i++) { std::this_thread::sleep_for(std::chrono::seconds(1)); - if (!daemon::EmbeddedDaemon::isRpcPortInUse()) { + if (!daemon::EmbeddedDaemon::isRpcPortInUse() && + !daemon::EmbeddedDaemon::isDaemonProcessRunning()) { wizard_stop_status_ = TR("wiz_daemon_stopped_ok"); wizard_stopping_external_ = false; return; @@ -1058,7 +1062,7 @@ void App::renderFirstRunWizard() { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary())); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::BeginDisabled(!supportsFullNodeLifecycleActions()); - if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2))) { + if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2)) && !refuseIfNodeBusy()) { // Stop embedded daemon before bootstrap to avoid chain data corruption stopDaemonForBootstrap(); bootstrap_ = std::make_unique(); @@ -1077,7 +1081,7 @@ void App::renderFirstRunWizard() { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface())); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::BeginDisabled(!supportsFullNodeLifecycleActions()); - if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2))) { + if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) { stopDaemonForBootstrap(); bootstrap_ = std::make_unique(); std::string dataDir = util::Platform::getDragonXDataDir(); diff --git a/src/daemon/daemon_controller.h b/src/daemon/daemon_controller.h index ea03c90..245cac1 100644 --- a/src/daemon/daemon_controller.h +++ b/src/daemon/daemon_controller.h @@ -63,17 +63,21 @@ public: class LifecycleRuntime { public: virtual ~LifecycleRuntime() = default; - virtual void stopDaemonWithPolicy() = 0; + // Stop the node and wait (bounded, never killing it) for it to exit. False: it is still running + // (or we stopped waiting), and nothing that needs it gone may run. + virtual bool stopDaemonWithPolicy() = 0; virtual bool startDaemon() = 0; virtual int deleteBlockchainData() = 0; virtual void resetOutputOffset() = 0; - virtual void requestRpcStopAndDisconnect(const char* context, const char* reason) = 0; + // Disconnect and stop the node off this thread; whoever needs it gone waits for the process to exit. + virtual void beginBackgroundStop(const char* reason) = 0; }; struct LifecycleExecutionResult { bool completed = false; bool cancelled = false; bool stopped = false; + bool stopFailed = false; // the node didn't exit: the operation was cancelled before changing anything bool started = false; int deletedItems = 0; }; @@ -194,22 +198,24 @@ public: switch (decision.operation) { case LifecycleOperation::BootstrapStop: + // The download takes minutes; the bootstrap worker waits for the process to exit before it + // touches chain data. if (decision.wasRunning) { - runtime.requestRpcStopAndDisconnect("Bootstrap daemon stop", "Bootstrap"); + runtime.beginBackgroundStop("Bootstrap"); result.stopped = true; } result.completed = true; return result; case LifecycleOperation::ManualRestart: - if (decision.wasRunning) { - runtime.stopDaemonWithPolicy(); - result.stopped = true; - } - break; case LifecycleOperation::Rescan: case LifecycleOperation::RepairWallet: case LifecycleOperation::DeleteBlockchainData: - runtime.stopDaemonWithPolicy(); + if (decision.operation == LifecycleOperation::ManualRestart && !decision.wasRunning) break; + // Never start a second node, or delete chain data, under one that hasn't exited. + if (!runtime.stopDaemonWithPolicy()) { + result.stopFailed = true; + return result; + } result.stopped = true; break; } @@ -255,6 +261,9 @@ public: bool abandonOnShutdown = false; // stop waiting once the app starts shutting down (background callers) double idleOfferSec = 600.0; // delivered + no CPU/I-O progress for this long: offer Kill }; + // A bounded stop that has no way to reach the node (a node we don't own refused our credentials, or our + // own did with every source and there is no signal) gives up after this long rather than its full budget. + static constexpr double kUndeliverableGraceSec = 5.0; // What the stop loop reports for display. struct StopStatus { @@ -342,6 +351,7 @@ public: StopUserChoice choice = StopUserChoice::None; bool killOffered = false; // the Kill offer is on screen right now bool shuttingDown = false; // the app began shutting down / the task was cancelled + bool undeliverable = false; // no stop delivered, none will be sent, and no signal to send }; static inline StopWaitDecision evaluateStopWait(const StopPolicy& p, const StopWaitSample& s) @@ -352,6 +362,8 @@ public: return StopWaitDecision::ForceKill; if (p.abandonOnShutdown && s.shuttingDown) return StopWaitDecision::GiveUp; if (p.maxWaitSec > 0 && s.elapsedSec >= p.maxWaitSec) return StopWaitDecision::GiveUp; + if (p.maxWaitSec > 0 && s.undeliverable && s.elapsedSec >= kUndeliverableGraceSec) + return StopWaitDecision::GiveUp; if (p.userChoices && p.owned && s.delivered && !s.rebuildActive && s.idleSec >= p.idleOfferSec) return StopWaitDecision::OfferForceKill; return StopWaitDecision::Wait; @@ -406,6 +418,8 @@ public: s.choice = rt.userChoice(); s.killOffered = offered; s.shuttingDown = task.cancelled() || task.shuttingDown(); + s.undeliverable = delivery.finished() && !delivery.delivered() && !signalled && + !(policy.gracefulSignal && policy.owned); switch (evaluateStopWait(policy, s)) { case StopWaitDecision::Done: diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index 47122e2..7639e7c 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -38,6 +38,7 @@ #include #include #ifdef __APPLE__ +#include #include #endif #endif @@ -1664,8 +1665,20 @@ bool EmbeddedDaemon::isDaemonProcessRunning() if (isDaemonProcessIdentity(comm, exe)) return true; } return false; +#elif defined(__APPLE__) + // Match executables named dragonxd. Not the RPC port: dragonxd frees it early in its shutdown, long + // before the process exits and lets go of wallet.dat and the datadir lock. + const int count = proc_listallpids(nullptr, 0); + if (count <= 0) return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT)); // can't list: best effort + std::vector pids(static_cast(count) + 64); // room for processes started meanwhile + const int n = proc_listallpids(pids.data(), static_cast(pids.size() * sizeof(pid_t))); + char path[PROC_PIDPATHINFO_MAXSIZE]; + for (int i = 0; i < n && i < static_cast(pids.size()); ++i) { + if (pids[i] <= 0 || proc_pidpath(pids[i], path, sizeof(path)) <= 0) continue; + if (isDaemonProcessIdentity("", fs::path(path).filename().string())) return true; + } + return false; #else - // macOS has no /proc; fall back to the RPC-port probe (best-effort). return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT)); #endif } diff --git a/src/services/wallet_security_workflow_executor.cpp b/src/services/wallet_security_workflow_executor.cpp index 80ce722..60e4fd5 100644 --- a/src/services/wallet_security_workflow_executor.cpp +++ b/src/services/wallet_security_workflow_executor.cpp @@ -64,8 +64,10 @@ WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::restartD if (!waitForMs(preRestartDelayMs)) return {false, "", false}; if (daemon.isUsingEmbeddedDaemon()) { - daemon.stopEmbeddedDaemon(); + const bool stopped = daemon.stopEmbeddedDaemon(); if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false}; + // Never start a second node over one that hasn't exited (it holds wallet.dat and the datadir lock). + if (!stopped) return {false, "The node did not stop in time, so it was not restarted.", false}; if (!waitForMs(embeddedRestartSettleMs)) return {false, "", false}; daemon.startEmbeddedDaemon(); } diff --git a/src/services/wallet_security_workflow_executor.h b/src/services/wallet_security_workflow_executor.h index 559d61c..b767a85 100644 --- a/src/services/wallet_security_workflow_executor.h +++ b/src/services/wallet_security_workflow_executor.h @@ -49,7 +49,7 @@ public: public: virtual ~DaemonGateway() = default; virtual bool isUsingEmbeddedDaemon() const = 0; - virtual void stopEmbeddedDaemon() = 0; + virtual bool stopEmbeddedDaemon() = 0; // true: the node exited (false: still running; never killed) virtual bool startEmbeddedDaemon() = 0; virtual bool cancelled() const = 0; virtual bool shuttingDown() const = 0; diff --git a/src/util/bootstrap.cpp b/src/util/bootstrap.cpp index a8491c6..35ea463 100644 --- a/src/util/bootstrap.cpp +++ b/src/util/bootstrap.cpp @@ -162,11 +162,17 @@ void Bootstrap::start(const std::string& dataDir, const std::string& url) { worker_running_ = false; return; } - // Step 3: Ensure daemon is fully stopped before touching chain data + // Step 3: Ensure daemon is fully stopped before touching chain data. Gone means the PROCESS has + // exited, not just its RPC port: dragonxd unbinds RPC early in its shutdown and keeps its files + // (and the datadir lock) until it exits — deleting chain data under it corrupts it, or fails on + // Windows' locked files. { setProgress(State::Extracting, "Waiting for daemon to stop..."); + auto daemonUp = [] { + return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); + }; int waited = 0; - while (daemon::EmbeddedDaemon::isRpcPortInUse() && waited < 60 && !cancel_requested_) { + while (daemonUp() && waited < 120 && !cancel_requested_) { std::this_thread::sleep_for(std::chrono::seconds(1)); waited++; if (waited % 5 == 0) @@ -177,7 +183,7 @@ void Bootstrap::start(const std::string& dataDir, const std::string& url) { worker_running_ = false; return; } - if (daemon::EmbeddedDaemon::isRpcPortInUse()) { + if (daemonUp()) { setProgress(State::Failed, "Daemon is still running — stop it before using bootstrap"); worker_running_ = false; return; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 3dbeea2..c6b0d58 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -616,6 +616,8 @@ void I18n::loadBuiltinEnglish() strings_["stopconfirm_busy_body"] = "It has been too busy to answer the wallet for %s, probably because of a long operation such as a witness rebuild. Stopping it now can make its next start redo that work.\n\nRecommended: close the wallet and leave the node running. It finishes on its own."; strings_["stopconfirm_stop_wait"] = "Stop node & wait"; strings_["appx_daemon_reinstall_node_busy"] = "The node is still running, so the daemon was not reinstalled. Try again once it has stopped."; + strings_["appx_node_busy_try_later"] = "The node is busy finishing a long task (such as rebuilding note witnesses). Try again when it is done; stopping it now could make it start that work over."; + strings_["appx_node_not_stopped"] = "The node didn't stop in time, so nothing was changed. Try again in a few minutes."; strings_["reduce_motion"] = "Reduce Motion"; strings_["tt_reduce_motion"] = "Disable animated transitions and balance lerp for accessibility"; strings_["ago"] = "ago"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 9eb783c..4ac7988 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -465,10 +465,14 @@ public: std::vector calls; int deletedItems = 7; bool startResult = true; - std::string rpcStopContext; + bool stopResult = true; // false: the node didn't exit std::string disconnectReason; - void stopDaemonWithPolicy() override { calls.push_back("stop"); } + bool stopDaemonWithPolicy() override + { + calls.push_back("stop"); + return stopResult; + } bool startDaemon() override { calls.push_back("start"); @@ -480,10 +484,9 @@ public: return deletedItems; } void resetOutputOffset() override { calls.push_back("reset-output"); } - void requestRpcStopAndDisconnect(const char* context, const char* reason) override + void beginBackgroundStop(const char* reason) override { - calls.push_back("rpc-stop-disconnect"); - rpcStopContext = context ? context : ""; + calls.push_back("background-stop"); disconnectReason = reason ? reason : ""; } }; @@ -599,9 +602,10 @@ public: int sleptMs = 0; int stopCalls = 0; int startCalls = 0; + bool stopResult = true; bool isUsingEmbeddedDaemon() const override { return usingEmbedded; } - void stopEmbeddedDaemon() override { ++stopCalls; } + bool stopEmbeddedDaemon() override { ++stopCalls; return stopResult; } bool startEmbeddedDaemon() override { ++startCalls; return true; } bool cancelled() const override { return isCancelled; } bool shuttingDown() const override { return isShuttingDown; } @@ -2841,6 +2845,15 @@ void testWalletSecurityWorkflowExecutor() EXPECT_FALSE(cancelled.ok); EXPECT_TRUE(cancelled.error.empty()); + MockWorkflowDaemon stuckDaemon; // S11: never start a second node over one that hasn't exited + stuckDaemon.stopResult = false; + rpc.probeCalls = 0; + auto stuckRestart = Executor::restartDaemonAndWait(stuckDaemon, rpc, 0, 0, 5); + EXPECT_FALSE(stuckRestart.ok); + EXPECT_FALSE(stuckRestart.error.empty()); + EXPECT_EQ(stuckDaemon.startCalls, 0); + EXPECT_EQ(rpc.probeCalls, 0); + MockWorkflowDaemon failedDaemon; rpc.probeCalls = 0; rpc.probeSuccessOnCall = 99; @@ -3868,6 +3881,13 @@ void testEvaluateStopWait() unowned.owned = false; s = sample(); s.delivered = true; s.idleSec = 6000; s.killOffered = true; s.choice = DC::StopUserChoice::Kill; EXPECT_TRUE(DC::evaluateStopWait(unowned, s) == D::Wait); // never kill what we didn't launch + // S11: no way to reach the node (refused our credentials, no signal): a bounded stop gives up after + // a short grace instead of its whole budget; the shutdown screen keeps waiting (the user decides). + s = sample(); s.undeliverable = true; s.elapsedSec = DC::kUndeliverableGraceSec - 0.1; + EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::Wait); + s.elapsedSec = DC::kUndeliverableGraceSec; + EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::GiveUp); + EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait); } // A scripted node for executeDaemonStop, on a virtual clock (seconds) that sleeps and sends advance. @@ -3977,6 +3997,30 @@ void testDaemonStopExecution() EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited); EXPECT_TRUE(rt.calls.empty()); } + { // S11: a wallet switch stopping a node we don't own that refuses our credentials (a foreign dragonxd): + // one attempt, then give up after the short grace, not the 120s budget. Never kill it. + MockStopRuntime rt; MockStopTask task(rt); + rt.sends = {R::Auth}; + DC::StopPolicy p; + p.owned = false; + p.maxWaitSec = 120; + p.abandonOnShutdown = true; + EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == O::StillRunning); + EXPECT_EQ(rt.count("stop"), 1); + EXPECT_EQ(rt.count("kill"), 0); + EXPECT_TRUE(rt.clock >= DC::kUndeliverableGraceSec && rt.clock < DC::kUndeliverableGraceSec + 1.0); + } + { // ...whereas our own node stays waited on (out of credentials, but SIGTERM still reaches it). + MockStopRuntime rt; MockStopTask task(rt); + rt.sends = {R::Auth}; + rt.hasSignal = true; + rt.exitAfterDelivery = 20.0; + DC::StopPolicy p; + p.gracefulSignal = true; + p.maxWaitSec = 120; + EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == O::Exited); + EXPECT_EQ(rt.count("sigterm"), 1); + } { // The 2026-09-30 12:21:44 -> 12:22:04 regression: the queue is full for a while. Retry until it // lands (Windows-like, no signal channel), then wait for the exit. Never kill. MockStopRuntime rt; MockStopTask task(rt); @@ -4379,6 +4423,7 @@ void testDaemonStopStringsTranslated() "node_busy_rpc_desc", "node_starting_title", "node_starting_busy_desc", "node_starting_launch_desc", "sb_node_busy", "stopconfirm_rebuild_title", "stopconfirm_rebuild_body", "stopconfirm_busy_title", "stopconfirm_busy_body", "stopconfirm_stop_wait", "appx_daemon_reinstall_node_busy", "node_busy_running", + "appx_node_busy_try_later", "appx_node_not_stopped", }; // printf conversions in order, "%%" excluded: what must survive translation. auto conversions = [](const std::string& s) { @@ -4483,10 +4528,39 @@ void testDaemonLifecycleExecution() auto bootstrapResult = DaemonController::executeLifecycleOperation(bootstrap, bootstrapRuntime, bootstrapTask); EXPECT_TRUE(bootstrapResult.completed); EXPECT_TRUE(bootstrapResult.stopped); - EXPECT_TRUE(bootstrapRuntime.calls == std::vector({"rpc-stop-disconnect"})); - EXPECT_EQ(bootstrapRuntime.rpcStopContext, std::string("Bootstrap daemon stop")); + EXPECT_TRUE(bootstrapRuntime.calls == std::vector({"background-stop"})); EXPECT_EQ(bootstrapRuntime.disconnectReason, std::string("Bootstrap")); + // S11: a node that doesn't exit cancels the operation before it changes anything: no second node is + // started over it, and chain data is not deleted under it. + for (auto op : {DaemonController::LifecycleOperation::ManualRestart, DaemonController::LifecycleOperation::Rescan, + DaemonController::LifecycleOperation::RepairWallet, + DaemonController::LifecycleOperation::DeleteBlockchainData}) { + auto decision = DaemonController::evaluateLifecycleOperation(op, true, true, true, false); + MockDaemonLifecycleRuntime stuckRuntime; + stuckRuntime.stopResult = false; + MockDaemonLifecycleTask stuckTask; + auto stuck = DaemonController::executeLifecycleOperation(decision, stuckRuntime, stuckTask); + EXPECT_TRUE(stuck.stopFailed); + EXPECT_FALSE(stuck.stopped); + EXPECT_FALSE(stuck.started); + EXPECT_FALSE(stuck.completed); + EXPECT_EQ(stuck.deletedItems, 0); + EXPECT_TRUE(stuckRuntime.calls == std::vector({"stop"})); + EXPECT_EQ(stuckTask.sleeps, 0); + } + { // A restart with no node of ours running just starts one (nothing to stop). + auto idle = DaemonController::evaluateLifecycleOperation( + DaemonController::LifecycleOperation::ManualRestart, true, true, false, false); + MockDaemonLifecycleRuntime idleRuntime; + idleRuntime.stopResult = false; + MockDaemonLifecycleTask idleTask; + auto idleResult = DaemonController::executeLifecycleOperation(idle, idleRuntime, idleTask); + EXPECT_FALSE(idleResult.stopFailed); + EXPECT_TRUE(idleResult.started); + EXPECT_TRUE(idleRuntime.calls == std::vector({"start"})); + } + MockDaemonLifecycleRuntime cancelRuntime; MockDaemonLifecycleTask cancelTask; cancelTask.cancelAfterSleeps = 1;