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:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user