feat(daemon): deliver stop reliably and never kill the node unasked

Closing the wallet sent "stop" at most twice, gated on a getinfo probe
that needs the daemon's cs_main, then killed the node after 20 s (Windows)
or 30 s (POSIX) whether or not stop had arrived. A node busy with an
hour-long witness rebuild therefore never got stop, was killed mid-
rebuild, and replayed everything since its last flush on every start.

Delivery (src/daemon/stop_delivery.h, RPCClient::open/trySendStop):
- stop goes over a dedicated client with no probe, so it never waits on
  rpc_'s lock or a cs_main-bound getinfo
- every attempt is classified, and StopDeliveryPolicy decides the next:
  Accepted / 503 = delivered; Queued (reply timeout) is resent at most
  every 30 s; QueueFull / not listening are retried every 1.5 s; a
  refusal after we were heard means the node is shutting down; a 401 on
  our own node tries the next credential source
- POSIX: a group SIGTERM goes out as soon as an attempt fails, which
  bypasses the HTTP queue entirely

Waiting (DaemonController::executeDaemonStop, pure, virtual-clock
tested):
- never kills on its own. Quitting waits as long as the node needs; the
  shutdown screen offers "Close wallet — leave node running" after 3 s,
  and "Kill node (unsafe)" (confirmed) only once stop was delivered and
  the node showed no CPU/I-O progress for ~10 min outside a rebuild
- other callers (restart, rescan, reinstall, switch, ...) wait up to
  120 s, let go once the app starts quitting, and keep watching a node
  that is still running; wallet switch refuses to go ahead then
- EmbeddedDaemon gains stop-session primitives (beginStop,
  pollOwnedProcess, signalGracefulStop, forceKillOwnedProcess,
  ownedResourceUsage over the Linux process group / Windows handle,
  endStop) and only one stop session runs at a time
- the Force Quit button and its stall heuristic are gone; "Leave
  running" aborts a pending stop attempt and closes at once

Verified against the fake daemon with the real GUI: closing mid-rebuild
now delivers stop (or SIGTERM 2 ms after a queue-full refusal) and the
node exits cleanly after its rebuild, where it was SIGKILLed before. The
shutdown screen, its "Leave running" button and the idle Kill offer
(after ~11 min) were checked by screenshot. New strings are English-
only for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 22:50:11 -05:00
co-authored by Claude Opus 5.5
parent b1d5806f9e
commit 5993488176
11 changed files with 1497 additions and 146 deletions
+191
View File
@@ -948,6 +948,78 @@ bool EmbeddedDaemon::stop(int wait_ms)
return exitedOnItsOwn;
}
bool EmbeddedDaemon::beginStop()
{
stop_requested_ = true;
should_stop_ = true;
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
if (!isRunning()) return false; // handle-based: our own process only
setState(State::Stopping, "Stopping dragonxd...");
return true;
}
bool EmbeddedDaemon::pollOwnedProcess()
{
drainOutput();
return isRunning();
}
bool EmbeddedDaemon::signalGracefulStop()
{
return false; // no graceful channel on Windows: the GUI launches dragonxd without a console
}
void EmbeddedDaemon::forceKillOwnedProcess()
{
if (process_handle_ == nullptr || !isRunning()) return;
DEBUG_LOGF("Killing dragonxd at the user's request (TerminateProcess on our own handle)\n");
TerminateProcess(process_handle_, 1);
WaitForSingleObject(process_handle_, 5000);
drainOutput();
}
EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
{
ResourceUsage usage;
if (process_handle_ == nullptr) return usage;
FILETIME created, exited, kernel, user;
if (GetProcessTimes(process_handle_, &created, &exited, &kernel, &user)) {
auto ticks = [](const FILETIME& ft) {
return (static_cast<unsigned long long>(ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
};
usage.cpuSeconds = static_cast<double>(ticks(kernel) + ticks(user)) / 1e7; // 100ns units
}
IO_COUNTERS io;
if (GetProcessIoCounters(process_handle_, &io))
usage.ioBytes = static_cast<double>(io.ReadTransferCount + io.WriteTransferCount);
return usage;
}
void EmbeddedDaemon::endStop(bool keepWatching)
{
if (keepWatching && isRunning()) {
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
should_stop_ = false;
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
return;
}
const bool leftRunning = isRunning();
if (leftRunning)
DEBUG_LOGF("Leaving dragonxd running; it will finish shutting down on its own\n");
if (process_handle_ != nullptr) {
CloseHandle(process_handle_); // only our handle: the process carries on
process_handle_ = nullptr;
}
if (stdout_read_ != nullptr) {
CloseHandle(stdout_read_);
stdout_read_ = nullptr;
}
drainOutput();
setState(State::Stopped, leftRunning ? "dragonxd left running" : "dragonxd stopped");
}
// Translate Windows NTSTATUS / exit codes to human-readable descriptions
static std::string translateWindowsExitCode(DWORD code) {
switch (code) {
@@ -1396,6 +1468,125 @@ bool EmbeddedDaemon::stop(int wait_ms)
return exitedOnItsOwn;
}
bool EmbeddedDaemon::beginStop()
{
stop_requested_ = true;
should_stop_ = true;
// The monitor sees should_stop_ within one 100ms tick. If the child exited in that window the
// monitor reaped it (as a clean stop: stop_requested_ is set) and the pid is now 0.
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
if (process_pid_ <= 0) return false;
setState(State::Stopping, "Stopping dragonxd...");
return true;
}
bool EmbeddedDaemon::pollOwnedProcess()
{
const pid_t pid = process_pid_;
if (pid <= 0) return false;
drainOutput();
int status = 0;
const pid_t r = waitpid(pid, &status, WNOHANG);
if (r == 0 || (r < 0 && errno == EINTR)) return true;
if (r == pid) DEBUG_LOGF("dragonxd exited (%s)\n", describeExitStatus(status).c_str());
drainOutput();
process_pid_ = 0;
return false;
}
bool EmbeddedDaemon::signalGracefulStop()
{
const pid_t pid = process_pid_;
if (pid <= 0) return false;
// The whole group: with a bash wrapper (embedded_daemon.cpp startProcess) a SIGTERM to the wrapper
// alone would orphan dragonxd.
DEBUG_LOGF("Sending SIGTERM to dragonxd's process group -%d (graceful stop)\n", pid);
return kill(-pid, SIGTERM) == 0;
}
void EmbeddedDaemon::forceKillOwnedProcess()
{
const pid_t pid = process_pid_;
if (pid <= 0) return;
DEBUG_LOGF("Killing dragonxd (group -%d) at the user's request\n", pid);
kill(-pid, SIGKILL);
int status = 0;
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
drainOutput();
process_pid_ = 0;
}
EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
{
ResourceUsage usage;
#ifdef __APPLE__
return usage; // no sampler yet (would need proc_pid_rusage): never "idle", so Kill is never offered
#else
const pid_t pgid = process_pid_;
if (pgid <= 0) return usage;
// The tracked pid may be a bash wrapper that itself uses ~0 CPU: sum the whole process group.
unsigned long long ticks = 0;
double io = 0.0;
bool anyCpu = false, anyIo = false;
std::error_code ec;
fs::directory_iterator it("/proc", ec), end;
for (; !ec && it != end; it.increment(ec)) {
const std::string name = it->path().filename().string();
if (name.empty() || name[0] < '0' || name[0] > '9') continue;
std::ifstream statFile((it->path() / "stat").string());
std::string line;
long pgrp = 0;
unsigned long long t = 0;
if (!statFile || !std::getline(statFile, line) || !parseProcStatLine(line, pgrp, t) || pgrp != pgid)
continue;
ticks += t;
anyCpu = true;
std::ifstream ioFile((it->path() / "io").string());
std::string key;
unsigned long long value = 0;
while (ioFile >> key >> value) {
if (key == "read_bytes:" || key == "write_bytes:") {
io += static_cast<double>(value);
anyIo = true;
}
}
}
const long hz = sysconf(_SC_CLK_TCK);
if (anyCpu && hz > 0) usage.cpuSeconds = static_cast<double>(ticks) / static_cast<double>(hz);
if (anyIo) usage.ioBytes = io;
return usage;
#endif
}
void EmbeddedDaemon::endStop(bool keepWatching)
{
const pid_t pid = process_pid_;
if (pid > 0 && keepWatching) {
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
should_stop_ = false;
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
return;
}
if (pid > 0) {
// Leave it running. Close our end of its output pipe: its further writes fail with EPIPE, which
// dragonxd ignores (it ignores SIGPIPE), instead of blocking once a pipe nobody drains is full.
// A detached waiter reaps it when it exits, if this process is still around then.
DEBUG_LOGF("Leaving dragonxd (pid %d) running; it will finish shutting down on its own\n", pid);
std::thread([pid] {
int st = 0;
while (waitpid(pid, &st, 0) < 0 && errno == EINTR) {}
}).detach();
process_pid_ = 0;
}
if (stdout_fd_ >= 0) {
close(stdout_fd_);
stdout_fd_ = -1;
}
setState(State::Stopped, pid > 0 ? "dragonxd left running" : "dragonxd stopped");
}
void EmbeddedDaemon::monitorProcess()
{
char buffer[4096];