fix(daemon): never kill a dragonxd the wallet didn't launch

On Windows, stop() with no live process of its own looked up the first
"dragonxd.exe" by name and TerminateProcess'd it after the grace period:
an old node still shutting down (how an in-progress shutdown was killed),
a node on another datadir, or the user's main node. It was reached on a
lock-gated close, for an external node, after the tracked process had
exited, and from ~EmbeddedDaemon's stop(3000).

- Windows stop(): drop the by-name branch; with no tracked process it
  logs and returns. findProcessByName is detection only.
- ~EmbeddedDaemon no longer calls stop(); it stops watching and releases
  its handles. stop() returns whether the process exited on its own.
- SeedWalletCreator never sent its isolated node an RPC stop, so on
  Windows that node was always force-killed holding the wallet.dat it
  had just created, and the stack object's destructor could then kill
  the main node by name. It now stops the node over the node's own RPC
  on every path. A node that still had to be force-killed fails the
  creation (seed wiped; nothing has been swept into it yet), so an
  uncleanly closed wallet is never adopted.
- Reinstall waited only for the RPC port, which closes long before the
  process exits (locked exe on Windows, held datadir lock everywhere).
  It now waits for the process to exit and otherwise aborts without
  touching the binaries or starting a second node.
- Linux isDaemonProcessRunning also matches /proc/<pid>/exe, so a node
  whose main thread is already renamed "hush-shutoff" (still holding the
  datadir lock) counts as running.

Tests: a Windows real-process test (a fake dragonxd.exe on an isolated
datadir survives stop() and the destructor), a POSIX test that the
destructor leaves the process running, and the pure identity rule.
scripts/test-windows.sh passes the stub path to the Windows suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 19:44:35 -05:00
co-authored by Claude Opus 5.5
parent d8175b7dc1
commit a091bbc52f
6 changed files with 232 additions and 51 deletions
+44 -31
View File
@@ -51,7 +51,30 @@ EmbeddedDaemon::EmbeddedDaemon() = default;
EmbeddedDaemon::~EmbeddedDaemon()
{
stop(3000); // Wait up to 3 seconds for clean shutdown
// Never stop() from here: a destructor must not kill a node. On Windows stop() also used to fall
// back to killing ANY dragonxd.exe by name, so destroying an object that had already stopped its own
// process (seed migration's isolated node) could take down the user's main node. Owners call stop()
// explicitly (the app's own daemon is never destroyed: main() ends in _Exit). Just stop watching and
// release what we hold; a process still running is left to finish on its own.
should_stop_ = true;
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
#ifdef _WIN32
if (process_handle_ != nullptr) {
CloseHandle(process_handle_);
process_handle_ = nullptr;
}
if (stdout_read_ != nullptr) {
CloseHandle(stdout_read_);
stdout_read_ = nullptr;
}
#else
if (stdout_fd_ >= 0) {
close(stdout_fd_);
stdout_fd_ = -1;
}
#endif
}
std::string EmbeddedDaemon::findDaemonBinary()
@@ -856,8 +879,9 @@ void EmbeddedDaemon::drainOutput()
CloseHandle(hFile);
}
void EmbeddedDaemon::stop(int wait_ms)
bool EmbeddedDaemon::stop(int wait_ms)
{
stop_requested_ = true;
should_stop_ = true;
setState(State::Stopping, "Stopping dragonxd...");
@@ -883,6 +907,7 @@ void EmbeddedDaemon::stop(int wait_ms)
}
};
bool exitedOnItsOwn = true;
if (tracked_alive) {
// Our tracked process (dragonxd.exe launched directly) is still alive.
// The RPC "stop" was already sent by the caller — wait for it to exit.
@@ -891,40 +916,21 @@ void EmbeddedDaemon::stop(int wait_ms)
DEBUG_LOGF("Timeout — forcing daemon termination...\n");
TerminateProcess(process_handle_, 1);
WaitForSingleObject(process_handle_, 2000);
exitedOnItsOwn = false;
}
drainOutput();
CloseHandle(process_handle_);
process_handle_ = nullptr;
} else {
// Tracked handle is dead (batch file case: cmd.exe already exited).
// The real dragonxd.exe may still be running as an orphan.
// No live process of OUR OWN to stop. Never go looking for one by name: findProcessByName returns
// the first dragonxd.exe of any kind (an old node still shutting down, a node on another datadir,
// an isolated seed-migration node), and killing it is how an in-progress shutdown got
// TerminateProcess'd. Nodes we didn't launch only ever get an RPC stop from the caller.
if (process_handle_ != nullptr) {
CloseHandle(process_handle_);
process_handle_ = nullptr;
}
// Find the real dragonxd.exe process by name
DWORD dragonxd_pid = findProcessByName("dragonxd.exe");
if (dragonxd_pid != 0) {
DEBUG_LOGF("Found orphaned dragonxd.exe (PID %lu) — waiting for RPC stop to take effect...\n", dragonxd_pid);
HANDLE hProc = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, dragonxd_pid);
if (hProc) {
// RPC stop was already sent — wait for graceful exit
if (!pollWait(hProc, wait_ms)) {
DEBUG_LOGF("Timeout — forcing dragonxd.exe (PID %lu) termination...\n", dragonxd_pid);
TerminateProcess(hProc, 1);
WaitForSingleObject(hProc, 2000);
}
drainOutput();
CloseHandle(hProc);
DEBUG_LOGF("dragonxd.exe stopped\n");
} else {
DEBUG_LOGF("Could not open dragonxd.exe process (PID %lu), error %lu\n",
dragonxd_pid, GetLastError());
}
} else {
DEBUG_LOGF("No dragonxd.exe process found — daemon may have already exited\n");
}
DEBUG_LOGF("No tracked daemon process to stop (any other dragonxd.exe is not ours and is left alone)\n");
}
if (stdout_read_ != nullptr) {
@@ -939,6 +945,7 @@ void EmbeddedDaemon::stop(int wait_ms)
}
setState(State::Stopped, "dragonxd stopped");
return exitedOnItsOwn;
}
// Translate Windows NTSTATUS / exit codes to human-readable descriptions
@@ -1336,10 +1343,11 @@ bool EmbeddedDaemon::reapWithin(pid_t pid, int timeout_ms, int& status)
}
}
void EmbeddedDaemon::stop(int wait_ms)
bool EmbeddedDaemon::stop(int wait_ms)
{
stop_requested_ = true;
should_stop_ = true;
bool exitedOnItsOwn = true;
// Join the monitor FIRST (it sees should_stop_ within one 100ms tick) so that from here on this is
// the only reaper. isRunning() is state-based and stays true until we set Stopped below, so it can't
@@ -1371,6 +1379,7 @@ void EmbeddedDaemon::stop(int wait_ms)
DEBUG_LOGF("Forcing dragonxd termination with SIGKILL (group -%d)...\n", pid);
kill(-pid, SIGKILL);
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
exitedOnItsOwn = false;
}
DEBUG_LOGF("dragonxd exited (%s)\n", describeExitStatus(status).c_str());
}
@@ -1384,6 +1393,7 @@ void EmbeddedDaemon::stop(int wait_ms)
}
setState(State::Stopped, "dragonxd stopped");
return exitedOnItsOwn;
}
void EmbeddedDaemon::monitorProcess()
@@ -1447,8 +1457,8 @@ bool EmbeddedDaemon::isDaemonProcessRunning()
#ifdef _WIN32
return findProcessByName("dragonxd.exe") != 0;
#elif defined(__linux__)
// Scan /proc for a process whose comm is exactly "dragonxd". Iterate with an error_code so a proc
// entry vanishing mid-scan (a process exiting) can't throw.
// Scan /proc for a dragonxd process (by comm or by executable: see isDaemonProcessIdentity). Iterate
// with an error_code so a proc entry vanishing mid-scan (a process exiting) can't throw.
std::error_code ec;
fs::directory_iterator it("/proc", ec), end;
for (; !ec && it != end; it.increment(ec)) {
@@ -1456,7 +1466,10 @@ bool EmbeddedDaemon::isDaemonProcessRunning()
if (pid.empty() || pid[0] < '0' || pid[0] > '9') continue; // numeric pid dirs only
std::ifstream f((it->path() / "comm").string());
std::string comm;
if (f && std::getline(f, comm) && comm == "dragonxd") return true;
if (f) std::getline(f, comm);
std::error_code linkEc; // another user's process: EACCES, leaving the name empty
const std::string exe = fs::read_symlink(it->path() / "exe", linkEc).filename().string();
if (isDaemonProcessIdentity(comm, exe)) return true;
}
return false;
#else