fix(wallet): route switch stop by process-handle ownership, not the external latch

Live logs showed the app usually just DIRECT-CONNECTS to an already-running
dragonxd (config found → connect; EmbeddedDaemon::start() never called). Two
consequences broke the switch: no process handle, and externalDaemonDetected()
stays false (it's only latched inside start()). So a direct-connected node was
treated as "owned" → stopEmbeddedDaemon() → an autoDetectConfig() temp RPC stop
that never reached the daemon → the node never stopped → the ~40s port poll timed
out → "the running node didn't release its connection in time" revert.

Gate on the real ownership signal — whether we hold a live process handle
(isEmbeddedDaemonRunning()) — instead of the unreliable externalDaemonDetected():
- stopDaemonForWalletSwitch: owned (we spawned it) → stopEmbeddedDaemon() with
  SIGTERM/SIGKILL; NOT owned (adopted or direct-connect, no handle) → RPC "stop"
  over the exact creds we're connected with (saved_config_), which is guaranteed
  to reach our node. Then the unchanged port-free poll.
- switchToWallet confirm gate: show "Stop the running node?" when connected to a
  node this session didn't spawn (state_.connected && !isEmbeddedDaemonRunning()).
- Fixes beginAdoptSeedWallet's direct-connect case identically (shared helper).

Adversarially verified (6/6): ownership signal, saved_config_ delivery incl.
cookie auth, foreign-daemon safety, confirm gate, seed-adopt, re-ownership/revert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 19:46:11 -05:00
parent 42d59709f9
commit 17b70a1388
2 changed files with 22 additions and 17 deletions

View File

@@ -4171,14 +4171,22 @@ bool App::isEmbeddedDaemonRunning() const
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.
// "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 — the normal policy stop (RPC stop + bounded SIGTERM/SIGKILL escalation).
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;
auto tmp = std::make_unique<rpc::RPCClient>();
if (tmp->connect(saved_config_.host, saved_config_.port, saved_config_.rpcuser,
@@ -4186,10 +4194,7 @@ bool App::stopDaemonForWalletSwitch()
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();
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()

View File

@@ -1056,11 +1056,11 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
return;
}
// The node we'd restart may be one this session ADOPTED — a dragonxd already running when the wallet
// launched (left running by "keep node running", or started by the user). Switching must stop+restart
// it on the new wallet, so confirm first rather than killing a node the user may have kept up on
// purpose. (An owned node we started this session restarts silently, as before.)
if (!stopDaemonConfirmed && daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
// If we're connected to a node this session did NOT spawn (no live process handle — it was left
// running by "keep node running", started by the user, or we just direct-connected to a config-provided
// one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
// have kept it running on purpose. A node we started ourselves (handle held) restarts silently.
if (!stopDaemonConfirmed && state_.connected && !isEmbeddedDaemonRunning()) {
pending_switch_wallet_file_ = walletFile;
show_switch_stop_daemon_confirm_ = true;
return;