fix(wallet): wait for the old node to fully exit before restarting on switch

After the stop, the switch started the replacement dragonxd too early — while the
old (direct-connected, unowned) node was still doing a slow graceful shutdown
(~70s; its network threads block on peer TLS timeouts) and holding the DATADIR
LOCK. The replacement couldn't acquire the lock, failed repeatedly, and the
crash-wedge left it stuck "starting". Root cause: the readiness poll used
isRpcPortInUse(), which on Windows is a connect() probe that reads "free" the
moment the daemon stops ACCEPTING RPC — early in shutdown, long before the
process exits and releases the datadir.

- EmbeddedDaemon::isDaemonProcessRunning(): true while any dragonxd process is
  alive (Windows findProcessByName; Linux /proc/<pid>/comm scan; macOS port
  fallback) — reflects the PROCESS, not just RPC acceptance.
- stopDaemonForWalletSwitch: for an UNOWNED node (no handle), after the RPC stop
  wait until BOTH the port is free AND isDaemonProcessRunning() is false, bounded
  ~120s (or ~5s if the stop couldn't be sent). Owned nodes are unchanged
  (stopEmbeddedDaemon() blocks for exit via the handle).
- Switch notification reworded to set the up-to-a-minute expectation (60s toast).

daemon_restarting_ stays set across the wait so the connect loop can't spawn a
competing daemon. Adversarially verified 6/6; fixes the seed-adopt path too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:27:46 -05:00
parent 17b70a1388
commit 02839dc415
4 changed files with 64 additions and 17 deletions

View File

@@ -4178,32 +4178,45 @@ bool App::stopDaemonForWalletSwitch()
// not start the node. Gate on the handle instead.
const bool owned = daemon_controller_ && isEmbeddedDaemonRunning();
if (owned) {
// We spawned it — the normal policy stop (RPC stop + bounded SIGTERM/SIGKILL escalation).
// We spawned it — stopEmbeddedDaemon() BLOCKS for full process exit (handle wait + SIGTERM/SIGKILL
// escalation), so the datadir lock + RPC port are released by the time it returns; a short port
// check just confirms.
stopEmbeddedDaemon();
} else {
// Adopted OR direct-connected: we have 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: its port never frees and the caller aborts without starting. No PID/name kill is issued.
// switchToWallet disconnects rpc_ before this worker runs, so build a fresh temporary connection.
bool sent = false;
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");
}
DEBUG_LOGF("[App] wallet-switch stop of unowned node (saved_config_): %s\n", sent ? "sent" : "FAILED");
// 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)
// 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 = []() {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
};
for (int i = 0; i < maxTicks && stillUp() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return shutting_down_ || !daemon::EmbeddedDaemon::isRpcPortInUse();
return shutting_down_ || !stillUp();
}
void App::rescanBlockchain()