fix(wallet): let a switch restart an adopted (external) daemon

Switching to a named wallet failed every time when the app had connected to a
pre-existing dragonxd at startup: the daemon is flagged externalDaemonDetected,
so stopEmbeddedDaemon()'s policy is DisconnectOnly ("not ours to stop") and the
switch skips the stop entirely. The old daemon keeps the RPC port, and the
relaunch fast-fails on EmbeddedDaemon::start()'s isPortInUse check — misread as a
bad wallet, reverting after a ~40s hang on the process-handle-only run-wait
(which reads false immediately for an adopted daemon).

A switch legitimately needs to restart the node, so:
- App::stopDaemonForWalletSwitch(): for an adopted daemon, send a graceful RPC
  "stop" using the creds we actually connected with (saved_config_) — only our
  own daemon obeys it, so a foreign dragonxd is a safe no-op — then wait (bounded
  ~40s) for the RPC port to actually free (isRpcPortInUse, the same gate start()
  uses). Owned daemons take the normal stopEmbeddedDaemon() path. No PID/name kill
  is ever issued at an adopted daemon.
- switchToWallet: gate start() on the port actually freeing; if it doesn't,
  abort with a distinct switch_stop_failed_ reason instead of starting into a busy
  port. Clear the external latch before relaunch so the fresh process is owned.
- EmbeddedDaemon::clearExternalDaemonDetected() (+ controller forwarder).
- Accurate revert message: "the running node didn't release its connection in
  time" vs. the bad-wallet message.

Root-caused from live Windows logs; design + implementation adversarially
verified. Note: beginAdoptSeedWallet has the identical pattern and is left for a
separate fund-critical review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:20:50 -05:00
parent bb5124c1ae
commit 3808b3ee82
6 changed files with 85 additions and 14 deletions

View File

@@ -4132,6 +4132,38 @@ bool App::isEmbeddedDaemonRunning() const
return daemon_controller_ && daemon_controller_->isRunning(); return daemon_controller_ && daemon_controller_->isRunning();
} }
bool App::stopDaemonForWalletSwitch()
{
const bool external = daemon_controller_ && daemon_controller_->externalDaemonDetected();
if (external) {
// Adopted daemon: stopEmbeddedDaemon()'s policy would DisconnectOnly and leave it running, but a
// switch MUST free the RPC port to relaunch on -wallet=<name>. Send a graceful RPC "stop" using the
// creds we actually connected with (saved_config_) — only OUR daemon accepts them, so a foreign
// dragonxd (which would have a different rpcpassword) is a safe no-op: its port never frees and the
// caller aborts without starting. No PID/name kill is ever issued at an adopted daemon. switchToWallet
// already disconnected rpc_, so build a fresh temporary connection from the known-good creds.
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 adopted daemon: %s\n", sent ? "sent" : "FAILED");
} else {
// We own the process — the normal policy stop (RPC stop + bounded SIGTERM/SIGKILL escalation).
stopEmbeddedDaemon();
}
// Gate on the RPC port ACTUALLY releasing (mirrors reinstallBundledDaemon). isEmbeddedDaemonRunning()
// is process-handle-only, so it reports "not running" immediately for an adopted daemon — the port is
// the correct readiness signal, and it's the same check start() gates on (isRpcPortInUse). Bounded
// ~40s: an adopted daemon has no SIGKILL fallback and a large-chain LevelDB flush can take 15-20s.
for (int i = 0; i < 400 && daemon::EmbeddedDaemon::isRpcPortInUse() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return shutting_down_ || !daemon::EmbeddedDaemon::isRpcPortInUse();
}
void App::rescanBlockchain() void App::rescanBlockchain()
{ {
if (!supportsFullNodeLifecycleActions()) { if (!supportsFullNodeLifecycleActions()) {

View File

@@ -415,6 +415,12 @@ public:
// Embedded daemon control // Embedded daemon control
bool startEmbeddedDaemon(); bool startEmbeddedDaemon();
void stopEmbeddedDaemon(); void stopEmbeddedDaemon();
// Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on
// -wallet=<name>. 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).
bool stopDaemonForWalletSwitch();
bool isEmbeddedDaemonRunning() const; bool isEmbeddedDaemonRunning() const;
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; } bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); } void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
@@ -748,6 +754,9 @@ private:
// wallet_switch_prev_file_ (settings writes stay on the main thread) so a broken wallet isn't // wallet_switch_prev_file_ (settings writes stay on the main thread) so a broken wallet isn't
// persisted across restarts. daemon_restarting_ stays set until the revert re-arms the reconnect. // persisted across restarts. daemon_restarting_ stays set until the revert re-arms the reconnect.
std::atomic<bool> wallet_switch_failed_{false}; std::atomic<bool> wallet_switch_failed_{false};
// Distinguishes the failure reason for the revert message: true when the switch failed because the
// running node wouldn't release the RPC port in time (not because the target wallet is bad).
std::atomic<bool> switch_stop_failed_{false};
// True from a switch until the new wallet's daemon actually connects (onConnected). If the daemon // True from a switch until the new wallet's daemon actually connects (onConnected). If the daemon
// instead crash-wedges (a wallet that fails LATE in init, past the fast start grace), the connect // instead crash-wedges (a wallet that fails LATE in init, past the fast start grace), the connect
// loop flags a revert so a broken wallet still can't stick. // loop flags a revert so a broken wallet still can't stick.

View File

@@ -1065,6 +1065,7 @@ void App::switchToWallet(const std::string& walletFile)
const std::string prevWallet = settings_->getActiveWalletFile(); // for revert if the new one fails const std::string prevWallet = settings_->getActiveWalletFile(); // for revert if the new one fails
wallet_switch_prev_file_ = prevWallet; wallet_switch_prev_file_ = prevWallet;
wallet_switch_failed_.store(false); wallet_switch_failed_.store(false);
switch_stop_failed_.store(false); // reason latch for the revert message; a stale one must not leak
wallet_switch_pending_confirm_.store(true); // cleared on a successful connect (onConnected) wallet_switch_pending_confirm_.store(true); // cleared on a successful connect (onConnected)
settings_->setActiveWalletFile(walletFile); settings_->setActiveWalletFile(walletFile);
settings_->save(); settings_->save();
@@ -1099,19 +1100,30 @@ void App::switchToWallet(const std::string& walletFile)
async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) { async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) {
bool ok = false; bool ok = false;
try { try {
// Stop the node fully so it releases the datadir .lock + RPC port before we relaunch. // Stop the node so it releases the datadir .lock + RPC port before we relaunch. For an adopted
stopEmbeddedDaemon(); // (external) daemon this sends a graceful RPC "stop" — stopEmbeddedDaemon()'s policy would leave
// Break the wait promptly if shutdown starts, so beginShutdown's join() doesn't freeze the // it running — and then waits for the RPC PORT to actually free, the correct readiness signal
// UI for the full 30s (shutdown then stops the daemon itself; we won't start a new one). // (isEmbeddedDaemonRunning() is process-handle-only and reads false immediately for an adopted
for (int i = 0; i < 60 && isEmbeddedDaemonRunning() && !shutting_down_; ++i) // daemon). The wait breaks promptly on shutdown so beginShutdown's join() doesn't freeze the UI.
std::this_thread::sleep_for(std::chrono::milliseconds(500)); const bool port_free = stopDaemonForWalletSwitch();
if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); if (!port_free) {
ok = shutting_down_ ? true : startEmbeddedDaemon(); // The old node wouldn't release the port in time — don't start() into a busy port (that
// A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a // fast-fails on the isPortInUse check and gets misread as a bad wallet). Revert with an
// moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.) // accurate, distinct reason instead.
if (ok && !shutting_down_) { switch_stop_failed_.store(true);
std::this_thread::sleep_for(std::chrono::milliseconds(1500)); ok = false;
if (!isEmbeddedDaemonRunning()) ok = false; } else {
// The relaunch is ours — clear the adopted-external latch so the fresh process is treated
// as owned (stop/isRunning/exit behave normally for the next switch and app exit).
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
ok = shutting_down_ ? true : startEmbeddedDaemon();
// A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a
// moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.)
if (ok && !shutting_down_) {
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
if (!isEmbeddedDaemonRunning()) ok = false;
}
} }
} catch (...) { } catch (...) {
ok = false; // a throw during stop/start is a failed switch — revert rather than wedge ok = false; // a throw during stop/start is a failed switch — revert rather than wedge
@@ -1142,7 +1154,15 @@ void App::processWalletSwitchRevert()
} }
// Clear the crash-wedge so the (good) previous wallet's daemon is allowed to start again. // Clear the crash-wedge so the (good) previous wallet's daemon is allowed to start again.
if (daemon_controller_) daemon_controller_->resetCrashCount(); if (daemon_controller_) daemon_controller_->resetCrashCount();
ui::Notifications::instance().error("Couldn't open that wallet — reverted to the previous one."); // Accurate reason: "port wouldn't free" (the running node kept the connection) vs. a genuinely bad
// target wallet. The former is the adopted-external-daemon case; the latter a daemon that exited in init.
if (switch_stop_failed_.exchange(false)) {
ui::Notifications::instance().error(
"Couldn't switch wallets — the running node didn't release its connection in time. "
"It's still on the previous wallet.");
} else {
ui::Notifications::instance().error("Couldn't open that wallet — reverted to the previous one.");
}
daemon_restarting_ = false; // the connect loop now restarts the previous wallet's daemon daemon_restarting_ = false; // the connect loop now restarts the previous wallet's daemon
} }

View File

@@ -61,6 +61,11 @@ bool DaemonController::externalDaemonDetected() const
return daemon_->externalDaemonDetected(); return daemon_->externalDaemonDetected();
} }
void DaemonController::clearExternalDaemonDetected()
{
daemon_->clearExternalDaemonDetected();
}
DaemonController::State DaemonController::state() const DaemonController::State DaemonController::state() const
{ {
return daemon_->getState(); return daemon_->getState();

View File

@@ -93,6 +93,7 @@ public:
bool isRunning() const; bool isRunning() const;
bool externalDaemonDetected() const; bool externalDaemonDetected() const;
void clearExternalDaemonDetected();
State state() const; State state() const;
const std::string& lastError() const; const std::string& lastError() const;
int crashCount() const; int crashCount() const;

View File

@@ -142,6 +142,10 @@ public:
* When true the wallet should connect to it instead of showing an error. * When true the wallet should connect to it instead of showing an error.
*/ */
bool externalDaemonDetected() const { return external_daemon_detected_; } bool externalDaemonDetected() const { return external_daemon_detected_; }
// Clear the adopted-external latch before relaunching our OWN process (e.g. a wallet switch that
// stopped the adopted daemon), so the freshly spawned daemon is treated as owned. start() also
// clears it on the port-free fall-through, but only if not short-circuited by an early guard.
void clearExternalDaemonDetected() { external_daemon_detected_ = false; }
/** /**
* @brief Set callback for state changes * @brief Set callback for state changes