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 <noreply@anthropic.com>
This commit is contained in:
+93
-75
@@ -315,7 +315,7 @@ class AppDaemonLifecycleRuntime final : public daemon::DaemonController::Lifecyc
|
||||
public:
|
||||
explicit AppDaemonLifecycleRuntime(App& app) : app_(app) {}
|
||||
|
||||
void stopDaemonWithPolicy() override { app_.stopEmbeddedDaemon(); }
|
||||
bool stopDaemonWithPolicy() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; }
|
||||
bool startDaemon() override { return app_.startEmbeddedDaemon(); }
|
||||
void resetOutputOffset() override
|
||||
{
|
||||
@@ -323,13 +323,13 @@ public:
|
||||
app_.resetDaemonBusy();
|
||||
}
|
||||
|
||||
void requestRpcStopAndDisconnect(const char* context, const char* reason) override
|
||||
void beginBackgroundStop(const char* reason) override
|
||||
{
|
||||
if (app_.rpc_ && app_.rpc_->isConnected()) {
|
||||
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<std::mutex> 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<rpc::RPCClient>();
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user