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:
2026-10-02 11:22:39 -05:00
co-authored by Claude Opus 5.5
parent 1215ded1a0
commit e20b70d73f
22 changed files with 320 additions and 122 deletions
+23 -9
View File
@@ -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:
+14 -1
View File
@@ -38,6 +38,7 @@
#include <netinet/in.h>
#include <arpa/inet.h>
#ifdef __APPLE__
#include <libproc.h>
#include <sys/sysctl.h>
#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<pid_t> pids(static_cast<size_t>(count) + 64); // room for processes started meanwhile
const int n = proc_listallpids(pids.data(), static_cast<int>(pids.size() * sizeof(pid_t)));
char path[PROC_PIDPATHINFO_MAXSIZE];
for (int i = 0; i < n && i < static_cast<int>(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
}