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
+274 -140
View File
@@ -5378,105 +5378,231 @@ bool App::startEmbeddedDaemon()
return daemon_controller_->start(settings_.get());
}
void App::stopEmbeddedDaemon()
// Drives one stop session for DaemonController::executeDaemonStop: a dedicated RPC client for "stop" (not
// rpc_, which another thread may hold, and whose getinfo probe needs the daemon's cs_main), the owned
// process's exit / signal / CPU, and the shutdown screen's choices.
class AppStopRuntime final : public daemon::DaemonController::StopRuntime {
public:
using DC = daemon::DaemonController;
AppStopRuntime(App& app, daemon::EmbeddedDaemon* owned, std::vector<rpc::ConnectionConfig> credentials)
: app_(app), owned_(owned), creds_(std::move(credentials)), t0_(std::chrono::steady_clock::now())
{
std::lock_guard<std::mutex> lk(app_.stop_client_mutex_);
app_.active_stop_client_ = &client_;
}
~AppStopRuntime() override
{
std::lock_guard<std::mutex> lk(app_.stop_client_mutex_);
app_.active_stop_client_ = nullptr;
}
double nowSec() override
{
return std::chrono::duration<double>(std::chrono::steady_clock::now() - t0_).count();
}
bool processAlive() override
{
if (owned_) return owned_->pollOwnedProcess();
// Not ours: we can only watch for it to go (port and process), never wait on or signal it.
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
}
daemon::StopSendResult trySendStop() override
{
if (creds_.empty()) return daemon::StopSendResult::Other;
if (!clientOpen_) {
const auto& c = creds_[credIndex_];
clientOpen_ = client_.open(c.host, c.port, c.rpcuser, c.rpcpassword, c.use_tls);
if (!clientOpen_) return daemon::StopSendResult::Other;
}
const rpc::RpcFailureKind kind = client_.trySendStop(2);
const daemon::StopSendResult r = daemon::stopSendResultFromKind(kind);
++attempts_;
if (attempts_ == 1 || r != lastLogged_) { // log changes, not every retry
DEBUG_LOGF("[stop] attempt %d at %.1fs: %s (%s)\n", attempts_, nowSec(),
daemon::stopSendResultName(r), rpc::rpcFailureKindName(kind));
lastLogged_ = r;
}
return r;
}
bool nextCredentials() override
{
if (credIndex_ + 1 >= creds_.size()) return false;
++credIndex_;
clientOpen_ = false;
DEBUG_LOGF("[stop] HTTP 401 — retrying with the next credential source (%zu of %zu)\n",
credIndex_ + 1, creds_.size());
return true;
}
bool sendGracefulSignal() override { return owned_ && owned_->signalGracefulStop(); }
double cpuSeconds() override
{
usage_ = owned_ ? owned_->ownedResourceUsage() : daemon::EmbeddedDaemon::ResourceUsage{};
return usage_.cpuSeconds;
}
double ioBytes() override { return usage_.ioBytes; } // sampled with cpuSeconds()
bool rebuildActive() override { return false; } // the witness-rebuild tracker is not wired yet
daemon::StopUserChoice userChoice() override
{
return static_cast<daemon::StopUserChoice>(app_.stop_user_choice_.load());
}
void offerForceKill(bool offered) override
{
DEBUG_LOGF("[stop] %s\n", offered ? "no CPU/I-O progress for a long time: offering Kill"
: "progress resumed: Kill offer withdrawn");
app_.stop_kill_offered_ = offered;
}
void forceKill() override
{
DEBUG_LOGF("[stop] user chose Kill after %.0fs\n", nowSec());
if (owned_) owned_->forceKillOwnedProcess();
}
void setStatus(const DC::StopStatus& s) override
{
if (s.delivered && !loggedDelivered_) {
DEBUG_LOGF("[stop] stop delivered at %.1fs (%s)\n", s.elapsedSec,
s.signalled ? "SIGTERM to the process group" : daemon::stopSendResultName(s.lastSend));
loggedDelivered_ = true;
}
std::string text = TR(s.delivered ? "shutdown_stop_delivered"
: s.heard ? "shutdown_stop_queued"
: "shutdown_stop_asking");
if (s.cpuPercent >= 0) {
char buf[64];
snprintf(buf, sizeof(buf), TR("shutdown_stop_cpu_suffix"), static_cast<int>(s.cpuPercent + 0.5));
text += buf;
}
app_.shutdown_status_ = text;
app_.stop_idle_minutes_ = static_cast<int>(s.idleSec / 60.0);
}
private:
App& app_;
daemon::EmbeddedDaemon* owned_ = nullptr;
std::vector<rpc::ConnectionConfig> creds_;
size_t credIndex_ = 0;
rpc::RPCClient client_;
bool clientOpen_ = false;
int attempts_ = 0;
daemon::StopSendResult lastLogged_ = daemon::StopSendResult::Other;
bool loggedDelivered_ = false;
daemon::EmbeddedDaemon::ResourceUsage usage_;
std::chrono::steady_clock::time_point t0_;
};
class AppStopTaskContext final : public daemon::DaemonController::LifecycleTaskContext {
public:
AppStopTaskContext(App& app, bool watchShutdown) : app_(app), watchShutdown_(watchShutdown) {}
bool cancelled() const override { return false; }
bool shuttingDown() const override { return watchShutdown_ && app_.shutting_down_.load(); }
void sleepForMs(int ms) override { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
private:
App& app_;
bool watchShutdown_ = false;
};
void App::chooseStopAction(daemon::StopUserChoice choice)
{
if (!daemon_controller_) return;
stop_user_choice_ = static_cast<int>(choice);
if (choice == daemon::StopUserChoice::Detach) {
// End a pending stop attempt now rather than after its timeout.
std::lock_guard<std::mutex> lk(stop_client_mutex_);
if (active_stop_client_) active_stop_client_->requestAbort();
}
}
daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode)
{
if (!daemon_controller_) return daemon::StopOutcome::Exited;
auto shutdownDecision = daemon_controller_->shutdownDecision(
false, settings_ && settings_->getStopExternalDaemon());
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason);
return;
return daemon::StopOutcome::StillRunning;
}
// Do we hold a live process handle for this node? Capture it BEFORE stop() reaps the pid. If owned,
// daemon_controller_->stop() below BLOCKS until the process actually exits. If NOT owned (external /
// adopted / direct-connected / a lock-gated start that never spawned), stop() returns at once on every
// platform — it only ever waits on (or kills) a process this app launched, never one found by name —
// so we can only ask it over RPC and then watch for it to disappear (handled after the stop() call).
const bool owned = daemon_controller_->isRunning();
// One stop at a time: a second caller (say the shutdown thread while a restart task is stopping the
// node) waits for the first. Background stops let go as soon as the app starts quitting, so the
// shutdown never waits long behind one.
std::lock_guard<std::mutex> single(stop_mutex_);
daemon::EmbeddedDaemon* d = daemon_controller_->daemon();
// beginStop() also marks the stop as requested (an early exit is not a crash) and makes this session
// the only reaper. Only a process we launched and still hold is "owned": a lock-gated start, an
// external node and a node we already let go of are not, and are never waited on, signalled or killed.
const bool owned = d && d->beginStop();
if (d && !owned) d->endStop(/*keepWatching=*/false);
// Any exit from here on is the stop we are asking for, not a crash — even one that lands before
// daemon_controller_->stop() below gets to reap it (crash_count_ drives auto-restart / give-up).
daemon_controller_->markStopRequested();
if (!owned && !daemon::EmbeddedDaemon::isRpcPortInUse()) {
// Nothing listening for a stop: either it is gone, or it is another dragonxd we can't talk to (one
// still finishing its own shutdown trips the start() lock gate this way). Leave it be.
const bool running = daemon::EmbeddedDaemon::isDaemonProcessRunning();
DEBUG_LOGF("stopEmbeddedDaemon: no node of ours and no RPC listener%s\n",
running ? " — another dragonxd is running; leaving it alone" : "");
return running ? daemon::StopOutcome::StillRunning : daemon::StopOutcome::Exited;
}
// Send RPC "stop" command — this is the graceful path that lets the
// daemon flush state, save block indexes, close sockets, etc.
bool stop_sent = false;
// Try the existing RPC connection first. beginShutdown() called rpc_->requestAbort() to unblock any
// in-flight request — but that sticky abort (cleared only by connect()) would make THIS "stop" self-
// abort mid-flight (CURLE_ABORTED_BY_CALLBACK). The old async rpc_->stop() swallowed that error yet
// set stop_sent=true, so the daemon never heard "stop" and the working temp-connection fallback was
// skipped — which is why "Stop external daemon" left an external dragonxd running (it only died via
// the 20s force-kill). Clear the abort and send SYNCHRONOUSLY so real success is surfaced.
if (rpc_ && rpc_->isConnected()) {
DEBUG_LOGF("Sending stop command via existing RPC connection...\n");
rpc_->clearAbort();
if (sendStopCommandSafely(*rpc_, "existing connection stop"))
stop_sent = true;
// Credentials for the stop: our own node's conf (and, if those are stale, our last working
// connection, then the .cookie); a node we don't own, the exact credentials we connected with.
std::vector<rpc::ConnectionConfig> creds;
const rpc::ConnectionConfig detected = rpc::Connection::autoDetectConfig();
const bool haveSaved = !saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty();
if (owned) {
creds.push_back(detected);
if (haveSaved && (saved_config_.rpcuser != detected.rpcuser ||
saved_config_.rpcpassword != detected.rpcpassword || saved_config_.port != detected.port))
creds.push_back(saved_config_);
rpc::ConnectionConfig cookie;
if (rpc::Connection::buildCookieAuthConfig(detected, cookie)) creds.push_back(cookie);
} else {
creds.push_back(haveSaved ? saved_config_ : detected);
}
// If the main connection wasn't established (e.g. daemon was still
// starting up when user closed the window), create a temporary
// RPC connection just to send the stop command.
if (!stop_sent) {
DEBUG_LOGF("Main RPC not connected — creating temporary connection for stop...\n");
auto config = rpc::Connection::autoDetectConfig();
if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
auto tmp_rpc = std::make_unique<rpc::RPCClient>();
// Use a short timeout — if daemon isn't listening yet, don't block
if (tmp_rpc->connect(config.host, config.port,
config.rpcuser, config.rpcpassword,
config.use_tls)) {
DEBUG_LOGF("Temporary RPC connected, sending stop...\n");
if (sendStopCommandSafely(*tmp_rpc, "Temporary daemon stop")) {
stop_sent = true;
}
tmp_rpc->disconnect();
} else {
DEBUG_LOGF("Could not establish temporary RPC connection\n");
}
} else {
DEBUG_LOGF("No RPC credentials available (DRAGONX.conf missing?)\n");
}
}
if (stop_sent) {
DEBUG_LOGF("Waiting for daemon to flush block index and shut down...\n");
shutdown_status_ = "Waiting for daemon to flush block index...";
// Give the daemon time to flush LevelDB to disk before we
// escalate to SIGTERM. On macOS/APFS, LevelDB compaction +
// fsync can take 15-20s on a large chain. The stop() method
// will wait this long for a *natural* exit (via the RPC stop
// we already sent) before falling back to SIGTERM.
}
shutdown_status_ = "Waiting for dragonxd process to exit...";
// 20s grace period for the RPC "stop" to complete (LevelDB flush).
// Only after that does stop() escalate to SIGTERM, then SIGKILL.
daemon_controller_->stop(20000);
// EXTERNAL / adopted node during app shutdown: we hold no process handle, so the stop() above returned
// immediately (it can only wait on a node WE spawned). But the user turned on "Stop external daemon",
// so keep the window on the shutdown screen and poll until the node is actually gone — surfacing a live
// status so they can SEE it stop — rather than closing while it's still flushing. Bounded ~120s (a
// graceful full-node shutdown can flush LevelDB for 60-90s). Scoped to real shutdown; the shutdown
// screen's Force Quit stays available and flips shutdown_complete_, which breaks us out at once.
if (stop_sent && !owned && shutting_down_) {
auto stillUp = []() {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
};
// Set the phase text ONCE, then just poll — the shutdown screen already renders a live "N seconds"
// elapsed counter on the UI thread, so the user still sees time passing. (shutdown_status_ is now a
// GuardedStatus, so per-iteration writes would be race-safe; the single write is just a UX choice.)
shutdown_status_ = "Waiting for the external node to stop...";
for (int i = 0; i < 1200 && stillUp() && !shutdown_complete_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
shutdown_status_ = stillUp() ? "External node still stopping — closing anyway..."
: "External node stopped";
DEBUG_LOGF("stopEmbeddedDaemon: external node %s\n", stillUp() ? "still up (timed out)" : "confirmed stopped");
daemon::DaemonController::StopPolicy policy;
policy.owned = owned;
#ifndef _WIN32
policy.gracefulSignal = owned; // dragonxd treats SIGTERM like stop; Windows has no such channel
#endif
switch (mode) {
case StopMode::AppShutdown:
policy.userChoices = true;
policy.maxWaitSec = owned ? 0.0 : 120.0; // a node we don't own: watch it go for up to 2 min
break;
case StopMode::Background:
policy.maxWaitSec = 120.0;
policy.abandonOnShutdown = true;
stop_user_choice_ = 0;
break;
case StopMode::Fallback:
policy.maxWaitSec = 20.0;
stop_user_choice_ = 0;
break;
}
DEBUG_LOGF("stopEmbeddedDaemon: %s node, %s\n", owned ? "our own" : "an external",
mode == StopMode::AppShutdown ? "quitting (no time limit; the user may leave it running)"
: mode == StopMode::Background ? "background (up to 120s, never killed)"
: "fallback (up to 20s, never killed)");
stop_kill_offered_ = false;
stop_session_active_ = true;
daemon::StopOutcome outcome;
{
AppStopRuntime runtime(*this, owned ? d : nullptr, std::move(creds));
AppStopTaskContext task(*this, policy.abandonOnShutdown);
outcome = daemon::DaemonController::executeDaemonStop(policy, runtime, task);
}
stop_session_active_ = false;
stop_kill_offered_ = false;
// Still running after a background wait: keep watching it (its exit is booked as a clean stop). Left
// running on purpose ("Leave running"), exited or killed: let go of it.
if (owned) d->endStop(/*keepWatching=*/outcome == daemon::StopOutcome::StillRunning);
DEBUG_LOGF("stopEmbeddedDaemon: %s\n",
outcome == daemon::StopOutcome::Exited ? "the node exited"
: outcome == daemon::StopOutcome::Detached ? "left running at the user's request"
: outcome == daemon::StopOutcome::Killed ? "killed at the user's request"
: "still running (stopped waiting; never killed)");
return outcome;
}
bool App::isEmbeddedDaemonRunning() const
@@ -5493,10 +5619,10 @@ bool App::stopDaemonForWalletSwitch()
// not start the node. Gate on the handle instead.
const bool owned = daemon_controller_ && isEmbeddedDaemonRunning();
if (owned) {
// 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();
// We spawned it — stopEmbeddedDaemon() waits for the process to exit (up to 120s, never killing
// it), so on Exited the datadir lock + RPC port are released; a short port check just confirms.
// Still running means the switch must not go ahead: the old node holds the wallet and the lock.
if (stopEmbeddedDaemon() != daemon::StopOutcome::Exited) return shutting_down_;
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();
@@ -5808,6 +5934,8 @@ void App::beginShutdown()
shutting_down_ = true;
quit_requested_ = true;
shutdown_timer_ = 0.0f;
stop_user_choice_ = 0;
stop_kill_offered_ = false;
shutdown_start_time_ = std::chrono::steady_clock::now();
async_tasks_.cancelAll();
// The seed-migration adopt task drives daemon stop/start and swaps wallet.dat; let it finish
@@ -5883,8 +6011,9 @@ void App::beginShutdown()
shutdown_thread_ = std::thread([this]() {
DEBUG_LOGF("shutdown thread: calling stopEmbeddedDaemon()\n");
shutdown_status_ = TR("appx_sending_stop_command_to_daemon");
// Send RPC stop command
stopEmbeddedDaemon();
// Deliver stop and wait for the node to exit: no time limit, never killed unasked. The shutdown
// screen offers "Leave running" (and "Kill" if the node goes idle), which end the wait at once.
stopEmbeddedDaemon(StopMode::AppShutdown);
DEBUG_LOGF("shutdown thread: daemon stopped, disconnecting RPC\n");
shutdown_status_ = TR("appx_cleaning_up");
@@ -6055,17 +6184,10 @@ void App::renderShutdownScreen()
};
shutdown_timer_ += ImGui::GetIO().DeltaTime;
// Track how long the status text has been unchanged. A normal shutdown keeps updating the status
// ("Stopping pool miner…" -> "Flushing…" -> "Cleaning up…"); a genuine hang stalls it. Offer Force
// Quit only once the status has STALLED (likely hung), not on a bare timer, with a hard ceiling so
// it's never impossible to escape.
static std::string s_lastShutStatus;
static float s_shutStallTimer = 0.0f;
const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05)
if (curShut != s_lastShutStatus) { s_lastShutStatus = curShut; s_shutStallTimer = 0.0f; }
else s_shutStallTimer += ImGui::GetIO().DeltaTime;
const bool shutdownStalled = s_shutStallTimer >= 8.0f;
const bool allowForceQuit = shutdownStalled || shutdown_timer_ >= 20.0f;
// After a few seconds, offer to close the wallet and leave the node running: stopping can take as long
// as the node needs (a witness rebuild can hold it for an hour), and it is never killed unasked.
const bool allowChoices = shutdown_timer_ >= 3.0f;
// Use the main viewport so the overlay covers the primary window
ImGuiViewport* vp = ImGui::GetMainViewport();
@@ -6086,9 +6208,8 @@ void App::renderShutdownScreen()
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoSavedSettings;
// Allow input only once Force Quit is offered (status stalled / hard ceiling), so the screen stays
// click-through while a normal shutdown is progressing.
if (!allowForceQuit)
// Allow input only once the choices are offered, so the screen stays click-through for a quick exit.
if (!allowChoices)
shutdownFlags |= ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav;
ImGui::Begin("##ShutdownOverlay", nullptr, shutdownFlags);
@@ -6189,51 +6310,64 @@ void App::renderShutdownScreen()
}
// -------------------------------------------------------------------
// 4b. Force Quit button — appears once the shutdown appears stalled (or a hard-ceiling backstop)
// 4b. "Leave running" (always, after a few seconds) and "Kill" (only once the node looks stuck)
// -------------------------------------------------------------------
if (allowForceQuit) {
if (allowChoices) {
ImGui::Spacing();
ImGui::Spacing();
// State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the
// chainstate; say so instead of a bare button.
if (shutdownStalled && !curShut.empty()) {
std::string stalledMsg = std::string(TR("appx_still_status_prefix")) + curShut +
TR("appx_still_status_suffix");
ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str());
ImGui::SetCursorPosX(cx - ms.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
ImGui::TextUnformatted(stalledMsg.c_str());
ImGui::PopStyleColor();
ImGui::Spacing();
}
const char* forceLabel = TR("force_quit");
ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f * dp, 0);
ImGui::SetCursorPosX(cx - btnSize.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.15f, 0.15f, 0.9f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.75f, 0.2f, 0.2f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.5f, 0.1f, 0.1f, 1.0f));
if (ui::material::TactileButton(forceLabel, btnSize)) {
force_quit_confirm_ = true;
const char* leaveLabel = TR("shutdown_leave_running");
ImVec2 leaveSize(ImGui::CalcTextSize(leaveLabel).x + 32.0f * dp, 0);
ImGui::SetCursorPosX(cx - leaveSize.x * 0.5f);
if (ui::material::TactileButton(leaveLabel, leaveSize)) {
DEBUG_LOGF("Shutdown: user chose to leave the node running after %.0fs\n", shutdown_timer_);
chooseStopAction(daemon::StopUserChoice::Detach);
// No stop session (yet): nothing to wait for, so close now. A session that starts later sees
// the choice and returns at once.
if (!stop_session_active_) shutdown_complete_ = true;
}
if (ImGui::IsItemHovered()) {
ui::material::Tooltip("%s", TR("force_quit_warning"));
ui::material::Tooltip("%s", TR("shutdown_leave_running_tip"));
}
if (stop_kill_offered_) {
ImGui::Spacing();
char idleMsg[160];
snprintf(idleMsg, sizeof(idleMsg), TR("shutdown_stop_idle"), stop_idle_minutes_.load());
ImVec2 ms = ImGui::CalcTextSize(idleMsg);
ImGui::SetCursorPosX(cx - ms.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
ImGui::TextUnformatted(idleMsg);
ImGui::PopStyleColor();
ImGui::Spacing();
const char* killLabel = TR("shutdown_kill_node");
ImVec2 killSize(ImGui::CalcTextSize(killLabel).x + 32.0f * dp, 0);
ImGui::SetCursorPosX(cx - killSize.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160)));
if (ui::material::TactileButton(killLabel, killSize)) {
stop_kill_confirm_ = true;
}
ImGui::PopStyleColor(3);
if (ImGui::IsItemHovered()) {
ui::material::Tooltip("%s", TR("shutdown_kill_node_tip"));
}
}
ImGui::PopStyleColor(3);
}
// Force Quit confirmation popup
if (force_quit_confirm_) {
ImGui::OpenPopup("##ForceQuitConfirm");
force_quit_confirm_ = false;
// Kill confirmation
if (stop_kill_confirm_) {
ImGui::OpenPopup("##KillNodeConfirm");
stop_kill_confirm_ = false;
}
ImVec2 popupCenter(wp.x + vp_size.x * 0.5f, wp.y + vp_size.y * 0.5f);
ImGui::SetNextWindowPos(popupCenter, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (ImGui::BeginPopupModal("##ForceQuitConfirm", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
if (ImGui::BeginPopupModal("##KillNodeConfirm", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
ImGui::PushFont(Type().subtitle1());
ImGui::TextUnformatted(TR("force_quit_confirm_title"));
ImGui::TextUnformatted(TR("shutdown_kill_confirm_title"));
ImGui::PopFont();
ImGui::Spacing();
ImGui::TextUnformatted(TR("force_quit_confirm_msg"));
ImGui::TextUnformatted(TR("shutdown_kill_confirm_msg"));
ImGui::Spacing();
ImGui::Spacing();
@@ -6249,9 +6383,9 @@ void App::renderShutdownScreen()
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160)));
if (ui::material::TactileButton(TR("force_quit_yes"), ImVec2(btnW, 0))) {
DEBUG_LOGF("Force quit confirmed by user after %.0fs\n", shutdown_timer_);
shutdown_complete_ = true;
if (ui::material::TactileButton(TR("shutdown_kill_confirm_yes"), ImVec2(btnW, 0))) {
DEBUG_LOGF("Shutdown: user confirmed Kill after %.0fs\n", shutdown_timer_);
chooseStopAction(daemon::StopUserChoice::Kill);
ImGui::CloseCurrentPopup();
}
ImGui::PopStyleColor(3);
@@ -6767,7 +6901,7 @@ void App::shutdown()
settings_->save();
}
if (daemon_controller_) {
stopEmbeddedDaemon();
stopEmbeddedDaemon(StopMode::Fallback); // on the UI thread: bounded, never killed
}
if (rpc_) {
rpc_->disconnect();
+21 -2
View File
@@ -21,6 +21,7 @@
#include "data/wallet_index.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
#include "daemon/stop_delivery.h"
#include "services/network_refresh_service.h"
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
@@ -480,7 +481,13 @@ public:
// Embedded daemon control
bool startEmbeddedDaemon();
void stopEmbeddedDaemon();
// How long stopEmbeddedDaemon() may wait, and who decides. It never kills the node on its own.
enum class StopMode {
AppShutdown, // quitting: no time limit; the shutdown screen offers "Leave running" (and "Kill" when stuck)
Background, // restart/rescan/switch/...: up to 120s, and let go as soon as the app starts quitting
Fallback, // the synchronous shutdown fallback on the UI thread: up to 20s
};
daemon::StopOutcome stopEmbeddedDaemon(StopMode mode = StopMode::Background);
// Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on
// -wallet=<name>. Unlike stopEmbeddedDaemon() (whose DisconnectOnly policy leaves an adopted/external
// daemon running), this sends a graceful RPC "stop" — using our own connection creds, so only our
@@ -632,6 +639,8 @@ public:
private:
friend class AppDaemonLifecycleRuntime;
friend class AppStopRuntime;
friend class AppStopTaskContext;
friend class AppDaemonLifecycleTaskContext;
// Global keyboard-shortcut handling, dispatched once per frame from update().
@@ -964,9 +973,19 @@ private:
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
float shutdown_timer_ = 0.0f;
bool force_quit_confirm_ = false;
std::chrono::steady_clock::time_point shutdown_start_time_;
// One stop session at a time (stopEmbeddedDaemon), and the shutdown screen's view of it.
std::mutex stop_mutex_;
std::atomic<bool> stop_session_active_{false};
std::atomic<int> stop_user_choice_{0}; // daemon::StopUserChoice, set by the shutdown screen
std::atomic<bool> stop_kill_offered_{false}; // the node looks stuck: the screen may offer "Kill"
std::atomic<int> stop_idle_minutes_{0};
std::mutex stop_client_mutex_;
rpc::RPCClient* active_stop_client_ = nullptr; // aborted on "Leave running" so a pending attempt ends at once
bool stop_kill_confirm_ = false;
void chooseStopAction(daemon::StopUserChoice choice);
// Daemon restart (e.g. after changing debug log categories)
std::atomic<bool> daemon_restarting_{false};
+211
View File
@@ -1,9 +1,11 @@
#pragma once
#include "embedded_daemon.h"
#include "stop_delivery.h"
#include <algorithm>
#include <cstddef>
#include <deque>
#include <memory>
#include <string>
#include <vector>
@@ -234,6 +236,215 @@ public:
ShutdownDecision shutdownDecision(bool keepDaemonRunning,
bool stopExternalDaemon) const;
// ------------------------------------------------------------------------------------------------
// Stopping the node: deliver "stop" until it lands, then wait for the process to exit. Never kill it
// on our own: a node that is busy (a witness rebuild can hold it for an hour) or flushing would lose
// everything since its last save and replay it on the next start. Only an explicit user "Kill",
// offered once the node has gone idle, ends it by force; "Leave running" lets the wallet close while
// the node finishes on its own.
// ------------------------------------------------------------------------------------------------
using StopUserChoice = daemon::StopUserChoice;
using StopOutcome = daemon::StopOutcome;
struct StopPolicy {
bool owned = true; // we launched it: watch the process, signal it, offer Kill
bool gracefulSignal = false; // POSIX: a group SIGTERM is a stop channel (dragonxd treats it as stop)
double maxWaitSec = 0.0; // stop waiting (never kill) after this long; 0 = no limit
bool userChoices = false; // the shutdown screen offers "Leave running" / "Kill"
bool abandonOnShutdown = false; // stop waiting once the app starts shutting down (background callers)
double idleOfferSec = 600.0; // delivered + no CPU/I-O progress for this long: offer Kill
};
// What the stop loop reports for display.
struct StopStatus {
bool delivered = false; // stop accepted (or the node is shutting down, or was signalled)
bool heard = false; // the node has the request (possibly still queued)
bool signalled = false; // a graceful signal (POSIX SIGTERM) went out
double elapsedSec = 0.0;
double cpuPercent = -1.0; // < 0: unknown
double idleSec = 0.0;
bool killOffered = false;
bool rebuildActive = false;
StopSendResult lastSend = StopSendResult::Other;
int attempts = 0;
};
class StopRuntime {
public:
virtual ~StopRuntime() = default;
virtual double nowSec() = 0;
virtual bool processAlive() = 0;
virtual StopSendResult trySendStop() = 0;
virtual bool nextCredentials() = 0; // switch to the next stop credentials; false when none left
virtual bool sendGracefulSignal() = 0; // POSIX group SIGTERM; false where there is none (Windows)
virtual double cpuSeconds() = 0; // cumulative CPU of the node; < 0 if unknown
virtual double ioBytes() = 0; // cumulative I/O bytes of the node; < 0 if unknown
virtual bool rebuildActive() = 0; // a witness rebuild is running: never offer Kill
virtual StopUserChoice userChoice() = 0;
virtual void offerForceKill(bool offered) = 0;
virtual void forceKill() = 0;
virtual void setStatus(const StopStatus& status) = 0;
};
/**
* "Making progress" = >= 0.05 cores of CPU or >= 1 MB of I/O over the last >= 60s. A flush that
* fsyncs a big wallet is mostly I/O at low CPU; a rebuild is mostly CPU. Idle time counts from the
* first sample. With no readings at all (unknown), it never reports idle, so Kill is never offered.
*/
class StopProgressTracker {
public:
static constexpr double kWindowSec = 60.0;
static constexpr double kMinCores = 0.05;
static constexpr double kMinIoBytes = 1024.0 * 1024.0;
void add(double t, double cpuSec, double ioBytes)
{
if (cpuSec >= 0 || ioBytes >= 0) measurable_ = true;
if (samples_.empty()) lastProgressAt_ = t;
samples_.push_back({t, cpuSec, ioBytes});
// Keep the newest sample that is at least a full window old as the comparison base.
while (samples_.size() > 2 && samples_[1].t <= t - kWindowSec) samples_.pop_front();
const Sample& base = samples_.front();
const double span = t - base.t;
if (span >= kWindowSec) {
const bool cpuMoved = cpuSec >= 0 && base.cpu >= 0 && (cpuSec - base.cpu) / span >= kMinCores;
const bool ioMoved = ioBytes >= 0 && base.io >= 0 && (ioBytes - base.io) >= kMinIoBytes;
if (cpuMoved || ioMoved) lastProgressAt_ = t;
}
}
double idleSec(double t) const { return measurable_ ? std::max(0.0, t - lastProgressAt_) : 0.0; }
// CPU over the most recent sample interval (100 = one core), for display; < 0 unknown.
double cpuPercent() const
{
if (samples_.size() < 2) return -1.0;
const Sample& a = samples_[samples_.size() - 2];
const Sample& b = samples_.back();
if (a.cpu < 0 || b.cpu < 0 || b.t <= a.t) return -1.0;
return std::max(0.0, (b.cpu - a.cpu) / (b.t - a.t) * 100.0);
}
private:
struct Sample { double t, cpu, io; };
std::deque<Sample> samples_;
double lastProgressAt_ = 0.0;
bool measurable_ = false;
};
enum class StopWaitDecision { Done, Wait, GiveUp, Detach, OfferForceKill, ForceKill };
struct StopWaitSample {
bool processAlive = true;
bool delivered = false;
double elapsedSec = 0.0;
double idleSec = 0.0;
bool rebuildActive = false;
StopUserChoice choice = StopUserChoice::None;
bool killOffered = false; // the Kill offer is on screen right now
bool shuttingDown = false; // the app began shutting down / the task was cancelled
};
static inline StopWaitDecision evaluateStopWait(const StopPolicy& p, const StopWaitSample& s)
{
if (!s.processAlive) return StopWaitDecision::Done;
if (p.userChoices && s.choice == StopUserChoice::Detach) return StopWaitDecision::Detach;
if (p.userChoices && p.owned && s.choice == StopUserChoice::Kill && s.killOffered)
return StopWaitDecision::ForceKill;
if (p.abandonOnShutdown && s.shuttingDown) return StopWaitDecision::GiveUp;
if (p.maxWaitSec > 0 && s.elapsedSec >= p.maxWaitSec) return StopWaitDecision::GiveUp;
if (p.userChoices && p.owned && s.delivered && !s.rebuildActive && s.idleSec >= p.idleOfferSec)
return StopWaitDecision::OfferForceKill;
return StopWaitDecision::Wait;
}
static constexpr int kStopTickMs = 250;
static inline StopOutcome executeDaemonStop(const StopPolicy& policy, StopRuntime& rt,
LifecycleTaskContext& task)
{
if (!rt.processAlive()) return StopOutcome::Exited; // already gone: not a single RPC call
const double t0 = rt.nowSec();
StopDeliveryPolicy delivery(policy.owned);
StopProgressTracker progress;
bool signalled = false;
bool offered = false;
auto withdrawOffer = [&] {
if (offered) { offered = false; rt.offerForceKill(false); }
};
for (;;) {
double now = rt.nowSec() - t0;
if (delivery.shouldSend(now)) {
const StopSendResult r = delivery.record(rt.trySendStop(), now);
if (r == StopSendResult::Auth && policy.owned) {
// Our own node refusing our credentials means they're stale (e.g. a regenerated
// .cookie): try the next source; out of sources, the graceful signal below remains.
if (rt.nextCredentials()) delivery.retryNow(rt.nowSec() - t0);
else delivery.giveUp();
}
// POSIX: a group SIGTERM bypasses the RPC queue altogether (dragonxd's handler sets the same
// flag as stop). Send it as soon as an attempt shows stop didn't get through.
if (policy.gracefulSignal && !signalled && !delivery.delivered() &&
r != StopSendResult::Queued && rt.sendGracefulSignal()) {
signalled = true;
delivery.markDelivered();
}
}
if (!rt.processAlive()) {
withdrawOffer();
return StopOutcome::Exited;
}
now = rt.nowSec() - t0;
progress.add(now, rt.cpuSeconds(), rt.ioBytes());
StopWaitSample s;
s.delivered = delivery.delivered();
s.elapsedSec = now;
s.idleSec = progress.idleSec(now);
s.rebuildActive = rt.rebuildActive();
s.choice = rt.userChoice();
s.killOffered = offered;
s.shuttingDown = task.cancelled() || task.shuttingDown();
switch (evaluateStopWait(policy, s)) {
case StopWaitDecision::Done:
withdrawOffer();
return StopOutcome::Exited;
case StopWaitDecision::Detach:
withdrawOffer();
return StopOutcome::Detached;
case StopWaitDecision::ForceKill:
withdrawOffer();
rt.forceKill();
return StopOutcome::Killed;
case StopWaitDecision::GiveUp:
withdrawOffer();
return StopOutcome::StillRunning;
case StopWaitDecision::OfferForceKill:
if (!offered) { offered = true; rt.offerForceKill(true); }
break;
case StopWaitDecision::Wait:
withdrawOffer(); // progress resumed (or a rebuild started): the node isn't stuck after all
break;
}
StopStatus st;
st.delivered = delivery.delivered();
st.heard = delivery.heard();
st.signalled = signalled;
st.elapsedSec = now;
st.cpuPercent = progress.cpuPercent();
st.idleSec = s.idleSec;
st.killOffered = offered;
st.rebuildActive = s.rebuildActive;
st.lastSend = delivery.last();
st.attempts = delivery.attempts();
rt.setStatus(st);
task.sleepForMs(kStopTickMs);
}
}
private:
std::unique_ptr<EmbeddedDaemon> daemon_;
};
+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];
+42
View File
@@ -4,6 +4,7 @@
#pragma once
#include <cstdio>
#include <string>
#include <functional>
#include <memory>
@@ -71,6 +72,47 @@ public:
*/
void markStopRequested() { stop_requested_ = true; }
// --- A stop session, driven by DaemonController::executeDaemonStop through the app's StopRuntime ---
/** Marks the stop as requested and stops the monitor, so the session is the only reaper.
* @return true if a process this object launched is still alive; then finish with endStop(). */
bool beginStop();
/** Polls our own process (reaping it on POSIX) and drains its output; false once it has exited. */
bool pollOwnedProcess();
/** POSIX: SIGTERM our process group (dragonxd treats it like RPC stop). Windows has no such channel. */
bool signalGracefulStop();
/** Ends our own process by force (SIGKILL / TerminateProcess) and reaps it. Explicit user choice only. */
void forceKillOwnedProcess();
struct ResourceUsage {
double cpuSeconds = -1.0; // cumulative; < 0 if unknown
double ioBytes = -1.0; // cumulative storage I/O; < 0 if unknown
};
/** CPU and I/O used so far by our process (its whole process group on Linux). */
ResourceUsage ownedResourceUsage() const;
/** Finishes a stop session. The process is gone: release everything. Still running: keepWatching
* resumes the monitor (its exit is still seen, and booked as a clean stop); otherwise let it go —
* the user chose to leave it running. */
void endStop(bool keepWatching);
/**
* @brief Parse a /proc/<pid>/stat line: process group (field 5) and utime+stime ticks (fields 14+15)
*
* comm (field 2) is parenthesised and may itself contain spaces and ')', so fields are read from
* after the LAST ')'.
*/
static bool parseProcStatLine(const std::string& line, long& pgrp, unsigned long long& cpuTicks)
{
const size_t close = line.rfind(')');
if (close == std::string::npos) return false;
unsigned long long utime = 0, stime = 0;
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime
if (std::sscanf(line.c_str() + close + 1, " %*c %*d %ld %*d %*d %*d %*u %*u %*u %*u %*u %llu %llu",
&pgrp, &utime, &stime) != 3)
return false;
cpuTicks = utime + stime;
return true;
}
/**
* @brief Check if daemon process is running
*/
+155
View File
@@ -0,0 +1,155 @@
#pragma once
// Delivering "stop" to dragonxd: what one attempt achieved, and when to send another. Pure and
// header-only so the test target can drive it without the RPC client.
//
// Why retrying matters: dragonxd's HTTP server has 8 workers and a 16-deep queue. While a long
// operation holds cs_main, the GUI's own abandoned polls can fill every slot, and each new request is
// refused at once with "Work queue depth exceeded" until a slot frees. The old code made one or two
// attempts, gated on a getinfo probe that needs cs_main, so stop was never delivered and the node was
// then killed.
#include "rpc/rpc_failure.h"
namespace dragonx {
namespace daemon {
// What the user picked on the shutdown screen while the node was stopping.
enum class StopUserChoice { None, Detach, Kill };
// How a stop session ended. The wallet never kills the node unless the user chose Kill.
enum class StopOutcome {
Exited, // the process is gone
StillRunning, // we stopped waiting (time budget, app shutdown, cancellation); never killed
Detached, // the user chose to leave it running
Killed, // the user chose Kill
};
inline const char* stopOutcomeName(StopOutcome o)
{
switch (o) {
case StopOutcome::Exited: return "Exited";
case StopOutcome::StillRunning: return "StillRunning";
case StopOutcome::Detached: return "Detached";
case StopOutcome::Killed: return "Killed";
}
return "Exited";
}
enum class StopSendResult {
Accepted, // HTTP 200: the daemon ran stop
Queued, // reply timeout: it reached the daemon and waits for a worker; it will run
QueueFull, // "Work queue depth exceeded": refused before queueing (holds no slot)
NotListening, // refused / connect timeout: RPC isn't bound (not up yet, or already unbound)
ShuttingDown, // 503 / "Unhandled request": the daemon is already shutting down
Auth, // 401: our credentials are wrong or stale
Other,
};
inline const char* stopSendResultName(StopSendResult r)
{
switch (r) {
case StopSendResult::Accepted: return "Accepted";
case StopSendResult::Queued: return "Queued";
case StopSendResult::QueueFull: return "QueueFull";
case StopSendResult::NotListening: return "NotListening";
case StopSendResult::ShuttingDown: return "ShuttingDown";
case StopSendResult::Auth: return "Auth";
case StopSendResult::Other: return "Other";
}
return "Other";
}
// What a stop call's failure kind (RpcFailureKind::None = HTTP 200) means for delivery.
inline StopSendResult stopSendResultFromKind(rpc::RpcFailureKind kind)
{
using K = rpc::RpcFailureKind;
switch (kind) {
case K::None: return StopSendResult::Accepted;
case K::ReplyTimeout: return StopSendResult::Queued;
case K::QueueFull: return StopSendResult::QueueFull;
case K::NotListening:
case K::ConnectTimeout: return StopSendResult::NotListening;
case K::ShuttingDown:
case K::Unhandled: return StopSendResult::ShuttingDown;
case K::Auth: return StopSendResult::Auth;
default: return StopSendResult::Other;
}
}
/**
* When to (re)send stop. Times are seconds on the caller's clock.
*
* - Accepted / ShuttingDown: delivered, never resend.
* - Queued: the daemon has it. Resend at most every 30s: each resend takes another queue slot, and
* concurrent stops race on the miner threads (stop's GenerateBitcoins(false)).
* - QueueFull / NotListening / Other: not delivered, cheap to retry: again after 1.5s.
* - NotListening AFTER the daemon had heard us means it unbound its listener to shut down: delivered.
* - Auth: give up for a node we don't own. For our own node the caller may switch credentials and
* retry at once (retryNow), or give up (giveUp).
*/
class StopDeliveryPolicy {
public:
static constexpr double kRetrySec = 1.5;
static constexpr double kQueuedResendSec = 30.0;
explicit StopDeliveryPolicy(bool owned) : owned_(owned) {}
bool shouldSend(double now) const { return !finished_ && now >= nextSendAt_; }
// Records one attempt; returns it as interpreted (a refusal after we were heard reads as ShuttingDown).
StopSendResult record(StopSendResult r, double now)
{
++attempts_;
if (r == StopSendResult::NotListening && heard_) r = StopSendResult::ShuttingDown;
switch (r) {
case StopSendResult::Accepted:
heard_ = true;
markDelivered();
break;
case StopSendResult::ShuttingDown:
markDelivered();
break;
case StopSendResult::Queued:
heard_ = true;
nextSendAt_ = now + kQueuedResendSec;
break;
case StopSendResult::Auth:
authRejected_ = true;
if (!owned_) finished_ = true;
nextSendAt_ = now + kRetrySec;
break;
case StopSendResult::QueueFull:
case StopSendResult::NotListening:
case StopSendResult::Other:
nextSendAt_ = now + kRetrySec;
break;
}
last_ = r;
return r;
}
void markDelivered() { delivered_ = finished_ = true; } // also for a graceful signal (POSIX SIGTERM)
void retryNow(double now) { nextSendAt_ = now; }
void giveUp() { finished_ = true; }
bool delivered() const { return delivered_; } // the daemon is stopping (or was signalled)
bool heard() const { return heard_; } // it has (or ran) our request
bool finished() const { return finished_; } // no more attempts
bool authRejected() const { return authRejected_; }
int attempts() const { return attempts_; }
StopSendResult last() const { return last_; }
private:
bool owned_ = true;
bool delivered_ = false;
bool heard_ = false;
bool finished_ = false;
bool authRejected_ = false;
int attempts_ = 0;
double nextSendAt_ = 0.0;
StopSendResult last_ = StopSendResult::Other;
};
} // namespace daemon
} // namespace dragonx
+3 -4
View File
@@ -2147,10 +2147,9 @@ int main(int argc, char* argv[])
// Watchdog: if cleanup takes too long the process lingers without a
// window, showing up as a "Background Service" in Task Manager.
// Force-exit after timeout — all critical state (settings, daemon
// stop) was handled in beginShutdown().
// Allow enough time for the daemon to shut down gracefully (up to
// 10s in stop()) plus RPC disconnect overhead.
// Force-exit after timeout. The daemon stop is already over by the time the render loop exits: the
// node has exited, or the user chose to leave it running (it carries on as its own process), so this
// only bounds the remaining thread joins and RPC disconnects — it never cuts a node's shutdown short.
std::thread([]() {
std::this_thread::sleep_for(std::chrono::seconds(15));
fflush(stdout);
+26
View File
@@ -163,6 +163,17 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
bool useTls)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
if (!open(host, port, user, password, useTls)) return false;
return probeConnection(host);
}
bool RPCClient::open(const std::string& host, const std::string& port,
const std::string& user, const std::string& password,
bool useTls)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
connected_ = false; // only a successful probe (connect()) sets these
warming_up_ = false;
host_ = host;
port_ = port;
last_connect_info_ = json();
@@ -218,7 +229,11 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
return true;
}
bool RPCClient::probeConnection(const std::string& host)
{
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
// hang means a wedged/loading occupant — no point blocking the full 30s before we retry and
@@ -286,6 +301,17 @@ void RPCClient::logConnectFailure(const std::string& msg)
DEBUG_LOGF("Connection failed %s (repeated %u times)\n", line.c_str(), connect_error_repeats_);
}
RpcFailureKind RPCClient::trySendStop(long timeoutSec)
{
try {
TraceScope trace("Daemon lifecycle / Stop command");
call("stop", json::array(), timeoutSec);
return RpcFailureKind::None;
} catch (const std::exception& e) {
return rpcFailureKindOf(e);
}
}
json RPCClient::getLastConnectInfo() const
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
+18
View File
@@ -64,6 +64,23 @@ public:
const std::string& user, const std::string& password,
bool useTls);
/**
* @brief Set up the connection without probing it (connect() = open() + a getinfo probe)
*
* For sending "stop": the getinfo probe needs the daemon's cs_main, so against a busy node it
* times out (holding one of the daemon's few RPC slots) or is refused, and stop never went out.
* isConnected() stays false; call() works once this returns true.
*/
bool open(const std::string& host, const std::string& port,
const std::string& user, const std::string& password, bool useTls);
/**
* @brief Send "stop" once, with its own timeout; returns how it went (None = the daemon ran it)
*
* A ReplyTimeout here is NOT a failure: the request reached the daemon and waits for a worker.
*/
RpcFailureKind trySendStop(long timeoutSec = 2);
/**
* @brief Disconnect from dragonxd
*/
@@ -265,6 +282,7 @@ private:
// A non-200 reply: RpcError when the body carries a JSON-RPC error, else RpcTransportError classified
// from the status and plain-text body. hideBody keeps the body out of the message (secret calls).
[[noreturn]] static void throwHttpFailure(long httpCode, const std::string& body, bool hideBody);
bool probeConnection(const std::string& host); // connect()'s getinfo probe, after open()
void logConnectFailure(const std::string& msg);
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
+13
View File
@@ -587,6 +587,19 @@ void I18n::loadBuiltinEnglish()
strings_["force_quit_confirm_title"] = "Force Quit?";
strings_["force_quit_confirm_msg"] = "This will immediately kill the daemon without a clean shutdown.\nThis may corrupt the blockchain index and require a resync.";
strings_["force_quit_yes"] = "Force Quit";
// Stopping the node on quit: deliver stop, wait, never kill unasked (translations: S10 batch)
strings_["shutdown_leave_running"] = "Close wallet — leave node running";
strings_["shutdown_leave_running_tip"] = "Closes the wallet now. The node keeps running and finishes shutting down on its own; nothing is lost.";
strings_["shutdown_kill_node"] = "Kill node (unsafe)";
strings_["shutdown_kill_node_tip"] = "Ends the node at once. Anything it has not saved is lost, and its next start replays the blocks since its last save.";
strings_["shutdown_kill_confirm_title"] = "Kill the node?";
strings_["shutdown_kill_confirm_msg"] = "The node has shown no CPU or disk activity for a long time and may be stuck.\nKilling it loses everything it has not saved; its next start can take much longer.";
strings_["shutdown_kill_confirm_yes"] = "Kill node";
strings_["shutdown_stop_asking"] = "Asking the node to stop…";
strings_["shutdown_stop_queued"] = "The node has the stop request but is busy — it will stop when it can";
strings_["shutdown_stop_delivered"] = "The node is shutting down and saving its state";
strings_["shutdown_stop_cpu_suffix"] = " · CPU %d%%";
strings_["shutdown_stop_idle"] = "No CPU or disk activity for %d min — the node may be stuck";
strings_["reduce_motion"] = "Reduce Motion";
strings_["tt_reduce_motion"] = "Disable animated transitions and balance lerp for accessibility";
strings_["ago"] = "ago";
+543
View File
@@ -80,6 +80,8 @@
#include <filesystem>
#include <fstream>
#include <iostream>
#include <algorithm>
#include <functional>
#include <memory>
#include <new>
#include <set>
@@ -3431,6 +3433,165 @@ void testPosixDestructorLeavesProcessRunning()
fsn::remove(script, ec);
fsn::remove(pidFile, ec);
}
// executeDaemonStop over a REAL EmbeddedDaemon child: steady clock, real waitpid/signals/CPU sampling.
// No RPC here (stop attempts report NotListening), so on POSIX the group SIGTERM is the stop channel.
class RealStopRuntime final : public dragonx::daemon::DaemonController::StopRuntime {
public:
using DC = dragonx::daemon::DaemonController;
explicit RealStopRuntime(dragonx::daemon::EmbeddedDaemon& d) : d_(d), t0_(std::chrono::steady_clock::now()) {}
double detachAt = -1.0; // choose "Leave running" at this time
double maxCpuPercent = -1.0;
double nowSec() override { return secondsSince(t0_); }
bool processAlive() override { return d_.pollOwnedProcess(); }
dragonx::daemon::StopSendResult trySendStop() override { return dragonx::daemon::StopSendResult::NotListening; }
bool nextCredentials() override { return false; }
bool sendGracefulSignal() override { return d_.signalGracefulStop(); }
double cpuSeconds() override { return d_.ownedResourceUsage().cpuSeconds; }
double ioBytes() override { return -1.0; }
bool rebuildActive() override { return false; }
DC::StopUserChoice userChoice() override
{
return (detachAt >= 0 && nowSec() >= detachAt) ? DC::StopUserChoice::Detach : DC::StopUserChoice::None;
}
void offerForceKill(bool) override {}
void forceKill() override { d_.forceKillOwnedProcess(); }
void setStatus(const DC::StopStatus& s) override { maxCpuPercent = std::max(maxCpuPercent, s.cpuPercent); }
private:
dragonx::daemon::EmbeddedDaemon& d_;
std::chrono::steady_clock::time_point t0_;
};
class RealSleepTask final : public dragonx::daemon::DaemonController::LifecycleTaskContext {
public:
bool cancelled() const override { return false; }
bool shuttingDown() const override { return false; }
void sleepForMs(int ms) override { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
};
pid_t readPidFile(const std::filesystem::path& pidFile)
{
for (int i = 0; i < 300; ++i) {
std::ifstream in(pidFile);
long v = 0;
if (in >> v && v > 0) return static_cast<pid_t>(v);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return 0;
}
// S1: "Leave running" on a node that ignores SIGTERM and keeps a core busy: the stop returns at the next
// tick, nothing kills it, it is still alive afterwards, and its CPU was being measured.
void testPosixStopSessionDetach()
{
using dragonx::daemon::EmbeddedDaemon;
using DC = dragonx::daemon::DaemonController;
namespace fsn = std::filesystem;
const fsn::path pidFile = fsn::temp_directory_path() / "od_fake_daemon_detach.pid";
std::error_code ec;
fsn::remove(pidFile, ec);
const auto script = writeFakeDaemonScript("od_fake_daemon_detach.sh",
"trap '' TERM\necho $$ > \"$OD_TEST_PIDFILE\"\nwhile :; do :; done\n"); // pid file = trap is set
setenv("OD_TEST_PIDFILE", pidFile.string().c_str(), 1);
EmbeddedDaemon d;
d.setSkipPortCheck(true);
EXPECT_TRUE(d.start(script.string()));
const pid_t pid = readPidFile(pidFile);
EXPECT_TRUE(pid > 0);
EXPECT_TRUE(d.beginStop());
RealStopRuntime rt(d);
rt.detachAt = 2.0;
RealSleepTask task;
DC::StopPolicy p;
p.gracefulSignal = true;
p.userChoices = true;
const auto outcome = DC::executeDaemonStop(p, rt, task);
EXPECT_TRUE(outcome == DC::StopOutcome::Detached);
EXPECT_TRUE(rt.nowSec() < 2.6);
EXPECT_TRUE(rt.maxCpuPercent > 20.0); // the busy loop was measured
d.endStop(/*keepWatching=*/false);
EXPECT_TRUE(pid > 0 && kill(pid, 0) == 0); // left running
EXPECT_FALSE(d.isRunning());
if (pid > 0) {
kill(-pid, SIGKILL);
// endStop() left a detached reaper waiting on it: the zombie disappears once it is reaped.
bool reaped = false;
for (int i = 0; i < 200 && !reaped; ++i) {
reaped = (kill(pid, 0) != 0);
if (!reaped) std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
EXPECT_TRUE(reaped);
}
unsetenv("OD_TEST_PIDFILE");
fsn::remove(script, ec);
fsn::remove(pidFile, ec);
}
// S1/S2 on POSIX: with no RPC answer, the group SIGTERM stops a node that handles it, promptly and
// without counting a crash.
void testPosixStopSessionSigterm()
{
using dragonx::daemon::EmbeddedDaemon;
using DC = dragonx::daemon::DaemonController;
const auto script = writeFakeDaemonScript("od_fake_daemon_session_term.sh",
"trap 'exit 0' TERM\nwhile :; do sleep 0.1; done\n");
EmbeddedDaemon d;
d.setSkipPortCheck(true);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(d.beginStop());
RealStopRuntime rt(d);
RealSleepTask task;
DC::StopPolicy p;
p.gracefulSignal = true;
p.userChoices = true;
EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == DC::StopOutcome::Exited);
EXPECT_TRUE(rt.nowSec() < 2.0);
d.endStop(/*keepWatching=*/false);
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
EXPECT_EQ(d.getCrashCount(), 0);
std::error_code ec; std::filesystem::remove(script, ec);
}
// S1: a bounded wait that gives up keeps watching the node: its later exit is still seen, and booked
// as the clean stop we asked for (not a crash).
void testPosixStopSessionGiveUpKeepsWatching()
{
using dragonx::daemon::EmbeddedDaemon;
using DC = dragonx::daemon::DaemonController;
namespace fsn = std::filesystem;
const fsn::path readyFile = fsn::temp_directory_path() / "od_fake_daemon_session_slow.ready";
std::error_code rmEc;
fsn::remove(readyFile, rmEc);
// Signal readiness only once the trap is installed: a SIGTERM before that line would kill it.
const auto script = writeFakeDaemonScript("od_fake_daemon_session_slow.sh",
"trap '' TERM\necho $$ > \"$OD_TEST_PIDFILE\"\nsleep 1.5\nexit 0\n");
setenv("OD_TEST_PIDFILE", readyFile.string().c_str(), 1);
EmbeddedDaemon d;
d.setSkipPortCheck(true);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(readPidFile(readyFile) > 0);
EXPECT_TRUE(d.beginStop());
RealStopRuntime rt(d);
RealSleepTask task;
DC::StopPolicy p;
p.gracefulSignal = true;
p.maxWaitSec = 0.5;
EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == DC::StopOutcome::StillRunning);
d.endStop(/*keepWatching=*/true);
EXPECT_TRUE(d.isRunning());
for (int i = 0; i < 400 && d.getState() != EmbeddedDaemon::State::Stopped; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
EXPECT_EQ(d.getCrashCount(), 0);
d.stop(1000); // join the resumed monitor
unsetenv("OD_TEST_PIDFILE");
std::error_code ec;
fsn::remove(script, ec);
fsn::remove(readyFile, ec);
}
#endif // !_WIN32
#ifdef _WIN32
@@ -3481,6 +3642,380 @@ void testWindowsStopNeverKillsForeignDaemon()
}
#endif // _WIN32
// ---------------------------------------------------------------------------------------------------
// S2 + S1: delivering "stop" and the never-kill wait (DaemonController::executeDaemonStop), driven by a
// scripted runtime on a virtual clock.
// ---------------------------------------------------------------------------------------------------
void testStopDeliveryPolicy()
{
using dragonx::daemon::StopDeliveryPolicy;
using dragonx::daemon::StopSendResult;
using R = StopSendResult;
{ // Refused before queueing: not delivered, cheap to retry after 1.5s.
StopDeliveryPolicy p(true);
EXPECT_TRUE(p.shouldSend(0.0));
EXPECT_TRUE(p.record(R::QueueFull, 0.0) == R::QueueFull);
EXPECT_FALSE(p.shouldSend(1.0));
EXPECT_TRUE(p.shouldSend(1.5));
EXPECT_FALSE(p.delivered());
}
{ // Queued: the daemon has it; no resend for 30s (each resend takes a queue slot).
StopDeliveryPolicy p(true);
p.record(R::Queued, 0.0);
EXPECT_TRUE(p.heard());
EXPECT_FALSE(p.delivered());
EXPECT_FALSE(p.shouldSend(29.9));
EXPECT_TRUE(p.shouldSend(30.0));
// ...and a refusal after that means it unbound its listener to shut down.
EXPECT_TRUE(p.record(R::NotListening, 30.0) == R::ShuttingDown);
EXPECT_TRUE(p.delivered());
}
{ // NotListening before we were ever heard is just "not up / not bound": retry.
StopDeliveryPolicy p(true);
EXPECT_TRUE(p.record(R::NotListening, 0.0) == R::NotListening);
EXPECT_FALSE(p.delivered());
EXPECT_TRUE(p.shouldSend(1.5));
}
{ // Accepted / ShuttingDown: delivered, never resend.
StopDeliveryPolicy a(true), s(true);
a.record(R::Accepted, 0.0);
s.record(R::ShuttingDown, 0.0);
EXPECT_TRUE(a.delivered() && a.finished() && !a.shouldSend(1000.0));
EXPECT_TRUE(s.delivered() && s.finished() && !s.shouldSend(1000.0));
}
{ // Auth: terminal for a node we don't own; our own may retry with other credentials.
StopDeliveryPolicy unowned(false), owned(true);
unowned.record(R::Auth, 0.0);
EXPECT_TRUE(unowned.finished() && !unowned.delivered());
owned.record(R::Auth, 0.0);
EXPECT_FALSE(owned.finished());
owned.retryNow(0.2);
EXPECT_TRUE(owned.shouldSend(0.2));
}
using dragonx::daemon::stopSendResultFromKind;
using K = dragonx::rpc::RpcFailureKind;
EXPECT_TRUE(stopSendResultFromKind(K::None) == R::Accepted);
EXPECT_TRUE(stopSendResultFromKind(K::ReplyTimeout) == R::Queued);
EXPECT_TRUE(stopSendResultFromKind(K::QueueFull) == R::QueueFull);
EXPECT_TRUE(stopSendResultFromKind(K::NotListening) == R::NotListening);
EXPECT_TRUE(stopSendResultFromKind(K::ConnectTimeout) == R::NotListening);
EXPECT_TRUE(stopSendResultFromKind(K::ShuttingDown) == R::ShuttingDown);
EXPECT_TRUE(stopSendResultFromKind(K::Unhandled) == R::ShuttingDown);
EXPECT_TRUE(stopSendResultFromKind(K::Auth) == R::Auth);
EXPECT_TRUE(stopSendResultFromKind(K::Aborted) == R::Other);
}
void testProcStatParse()
{
using dragonx::daemon::EmbeddedDaemon;
long pgrp = 0;
unsigned long long ticks = 0;
// comm with spaces AND a ')': fields must be read after the LAST ')'. tpgid -1 must parse.
EXPECT_TRUE(EmbeddedDaemon::parseProcStatLine(
"4242 (weird) name) S 1 4240 4240 0 -1 4194560 100 0 0 0 250 50 0 0 20 0 1 0 12345 1000 10",
pgrp, ticks));
EXPECT_EQ(pgrp, 4240L);
EXPECT_EQ(ticks, 300ULL);
EXPECT_FALSE(EmbeddedDaemon::parseProcStatLine("garbage without parens", pgrp, ticks));
EXPECT_FALSE(EmbeddedDaemon::parseProcStatLine("1 (x) S 1", pgrp, ticks));
}
void testStopProgressTracker()
{
using Tracker = dragonx::daemon::DaemonController::StopProgressTracker;
{ // Flat CPU and I/O: idle time grows from the first sample.
Tracker tr;
for (int s = 0; s <= 300; ++s) tr.add(s, 10.0, 5e6);
EXPECT_NEAR(tr.idleSec(300.0), 300.0, 1e-9);
}
{ // 0.1 cores is progress (threshold 0.05).
Tracker tr;
for (int s = 0; s <= 300; ++s) tr.add(s, 0.1 * s, -1.0);
EXPECT_TRUE(tr.idleSec(300.0) < 1.0);
EXPECT_NEAR(tr.cpuPercent(), 10.0, 1e-6);
}
{ // A low-CPU flush still writing ~2 MB/min is progress too.
Tracker tr;
for (int s = 0; s <= 300; ++s) tr.add(s, 1.0, 2e6 / 60.0 * s);
EXPECT_TRUE(tr.idleSec(300.0) < 1.0);
}
{ // No readings at all: never "idle", so Kill is never offered.
Tracker tr;
for (int s = 0; s <= 3600; s += 10) tr.add(s, -1.0, -1.0);
EXPECT_NEAR(tr.idleSec(3600.0), 0.0, 1e-9);
EXPECT_TRUE(tr.cpuPercent() < 0);
}
}
void testEvaluateStopWait()
{
using DC = dragonx::daemon::DaemonController;
using D = DC::StopWaitDecision;
DC::StopPolicy shutdown; // the shutdown screen: owned, unbounded, choices
shutdown.userChoices = true;
DC::StopPolicy background; // a restart/rescan/switch: bounded, no choices
background.maxWaitSec = 120;
background.abandonOnShutdown = true;
auto sample = [] { DC::StopWaitSample s; return s; };
auto s = sample(); s.processAlive = false;
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Done);
s = sample(); s.choice = DC::StopUserChoice::Detach;
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Detach);
EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::Wait); // no choices there
s = sample(); s.choice = DC::StopUserChoice::Kill; // Kill without an offer: ignored
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait);
s.killOffered = true;
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::ForceKill);
s = sample(); s.delivered = true; s.idleSec = 600; // delivered + idle 10 min: offer
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::OfferForceKill);
s.rebuildActive = true; // ...never during a rebuild
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait);
s = sample(); s.delivered = false; s.idleSec = 6000; // never delivered: no offer
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait);
s = sample(); s.elapsedSec = 120;
EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::GiveUp);
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait); // the shutdown screen has no budget
s = sample(); s.shuttingDown = true;
EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::GiveUp);
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait);
DC::StopPolicy unowned = shutdown;
unowned.owned = false;
s = sample(); s.delivered = true; s.idleSec = 6000; s.killOffered = true; s.choice = DC::StopUserChoice::Kill;
EXPECT_TRUE(DC::evaluateStopWait(unowned, s) == D::Wait); // never kill what we didn't launch
}
// A scripted node for executeDaemonStop, on a virtual clock (seconds) that sleeps and sends advance.
class MockStopRuntime final : public dragonx::daemon::DaemonController::StopRuntime {
public:
using DC = dragonx::daemon::DaemonController;
using R = dragonx::daemon::StopSendResult;
double clock = 0.0;
std::vector<R> sends; // results in order; the last one repeats
double exitAt = -1.0; // the process exits at this time (< 0: not on its own)
double exitAfterDelivery = -1.0; // ...or this long after stop/SIGTERM reached it
bool hasSignal = false; // POSIX-like graceful channel
int credentialsLeft = 0;
std::function<double(double)> cpuAt; // cumulative CPU seconds at t; empty = unknown
std::function<DC::StopUserChoice(double)> choiceAt;
bool rebuild = false;
std::vector<std::string> calls; // "stop", "sigterm", "creds", "offer", "withdraw", "kill"
double deliveredAt = -1.0, firstSigtermAt = -1.0, firstOfferAt = -1.0;
double lastStopAt = -1.0;
bool killed = false;
bool hitSafetyLimit = false;
DC::StopStatus lastStatus;
int count(const std::string& c) const { return (int)std::count(calls.begin(), calls.end(), c); }
double nowSec() override { return clock; }
bool processAlive() override
{
if (clock > 36000.0) { hitSafetyLimit = true; return false; } // a test bug, not an outcome
if (killed) return false;
if (exitAt >= 0 && clock >= exitAt) return false;
if (exitAfterDelivery >= 0 && deliveredAt >= 0 && clock >= deliveredAt + exitAfterDelivery) return false;
return true;
}
R trySendStop() override
{
calls.push_back("stop");
lastStopAt = clock;
const size_t n = calls.empty() ? 0 : (size_t)count("stop") - 1;
const R r = sends.empty() ? R::Accepted : sends[std::min(n, sends.size() - 1)];
clock += (r == R::Queued) ? 2.0 : 0.01; // a reply timeout costs the client timeout
if ((r == R::Accepted || r == R::ShuttingDown) && deliveredAt < 0) deliveredAt = clock;
return r;
}
bool nextCredentials() override
{
calls.push_back("creds");
return credentialsLeft-- > 0;
}
bool sendGracefulSignal() override
{
if (!hasSignal) return false;
calls.push_back("sigterm");
if (firstSigtermAt < 0) firstSigtermAt = clock;
if (deliveredAt < 0) deliveredAt = clock;
return true;
}
double cpuSeconds() override { return cpuAt ? cpuAt(clock) : -1.0; }
double ioBytes() override { return -1.0; }
bool rebuildActive() override { return rebuild; }
DC::StopUserChoice userChoice() override { return choiceAt ? choiceAt(clock) : DC::StopUserChoice::None; }
void offerForceKill(bool offered) override
{
calls.push_back(offered ? "offer" : "withdraw");
if (offered && firstOfferAt < 0) firstOfferAt = clock;
}
void forceKill() override
{
calls.push_back("kill");
killed = true;
}
void setStatus(const DC::StopStatus& s) override { lastStatus = s; }
};
class MockStopTask final : public dragonx::daemon::DaemonController::LifecycleTaskContext {
public:
explicit MockStopTask(MockStopRuntime& rt) : rt_(rt) {}
double shutdownAt = -1.0;
bool cancelled() const override { return false; }
bool shuttingDown() const override { return shutdownAt >= 0 && rt_.clock >= shutdownAt; }
void sleepForMs(int ms) override { rt_.clock += ms / 1000.0; }
private:
MockStopRuntime& rt_;
};
dragonx::daemon::DaemonController::StopPolicy shutdownStopPolicy(bool posix)
{
dragonx::daemon::DaemonController::StopPolicy p;
p.owned = true;
p.gracefulSignal = posix;
p.userChoices = true;
return p;
}
void testDaemonStopExecution()
{
using DC = dragonx::daemon::DaemonController;
using R = dragonx::daemon::StopSendResult;
using O = DC::StopOutcome;
{ // Already exited: not a single RPC call.
MockStopRuntime rt; MockStopTask task(rt);
rt.exitAt = 0.0;
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited);
EXPECT_TRUE(rt.calls.empty());
}
{ // The 2026-09-30 12:21:44 -> 12:22:04 regression: the queue is full for a while. Retry until it
// lands (Windows-like, no signal channel), then wait for the exit. Never kill.
MockStopRuntime rt; MockStopTask task(rt);
rt.sends = {R::QueueFull, R::QueueFull, R::QueueFull, R::QueueFull, R::QueueFull, R::Accepted};
rt.exitAfterDelivery = 3.0;
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited);
EXPECT_EQ(rt.count("stop"), 6);
EXPECT_EQ(rt.count("kill"), 0);
EXPECT_TRUE(rt.deliveredAt >= 5 * 1.5); // retries were spaced, not a tight loop
EXPECT_FALSE(rt.hitSafetyLimit);
}
{ // Never automatic: queue full for an hour, CPU busy (a rebuild with no log marker). No kill, no
// offer; the node exits on its own at 60 min.
MockStopRuntime rt; MockStopTask task(rt);
rt.sends = {R::QueueFull};
rt.exitAt = 3600.0;
rt.cpuAt = [](double t) { return 6.0 * t; };
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited);
EXPECT_EQ(rt.count("kill"), 0);
EXPECT_EQ(rt.count("offer"), 0);
EXPECT_TRUE(rt.count("stop") > 1000); // still trying every 1.5s
EXPECT_FALSE(rt.hitSafetyLimit);
}
{ // POSIX: the first attempt is refused, so the group SIGTERM goes out at once (<= 2s), and no
// more RPC attempts are needed.
MockStopRuntime rt; MockStopTask task(rt);
rt.sends = {R::QueueFull};
rt.hasSignal = true;
rt.exitAfterDelivery = 2.0;
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(true), rt, task) == O::Exited);
EXPECT_TRUE(rt.firstSigtermAt >= 0 && rt.firstSigtermAt <= 2.0);
EXPECT_EQ(rt.count("stop"), 1);
EXPECT_EQ(rt.count("kill"), 0);
}
{ // Queued (reply timeout) is not a failure: no SIGTERM, no resend before 30s.
MockStopRuntime rt; MockStopTask task(rt);
rt.sends = {R::Queued};
rt.hasSignal = true;
rt.exitAt = 25.0;
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(true), rt, task) == O::Exited);
EXPECT_EQ(rt.count("stop"), 1);
EXPECT_EQ(rt.count("sigterm"), 0);
}
{ // Delivered, then flat CPU: Kill is OFFERED after ~10 min, and only an explicit Kill kills (once).
MockStopRuntime rt; MockStopTask task(rt);
rt.cpuAt = [](double) { return 42.0; };
rt.choiceAt = [](double t) { return t >= 900.0 ? DC::StopUserChoice::Kill : DC::StopUserChoice::None; };
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Killed);
EXPECT_TRUE(rt.firstOfferAt >= 600.0 && rt.firstOfferAt < 602.0);
EXPECT_EQ(rt.count("kill"), 1);
EXPECT_TRUE(rt.clock >= 900.0); // waited for the user, didn't act on the offer itself
}
{ // ...with no answer to the offer, it keeps waiting (and never kills).
MockStopRuntime rt; MockStopTask task(rt);
rt.cpuAt = [](double) { return 42.0; };
rt.exitAt = 3600.0;
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited);
EXPECT_EQ(rt.count("offer"), 1);
EXPECT_EQ(rt.count("kill"), 0);
}
{ // A rebuild in progress suppresses the offer even with flat readings.
MockStopRuntime rt; MockStopTask task(rt);
rt.cpuAt = [](double) { return 42.0; };
rt.rebuild = true;
rt.exitAt = 1800.0;
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited);
EXPECT_EQ(rt.count("offer"), 0);
}
{ // "Leave running": returns at once, no kill, no further stop attempts.
MockStopRuntime rt; MockStopTask task(rt);
rt.sends = {R::QueueFull};
rt.choiceAt = [](double t) { return t >= 5.0 ? DC::StopUserChoice::Detach : DC::StopUserChoice::None; };
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Detached);
EXPECT_EQ(rt.count("kill"), 0);
EXPECT_TRUE(rt.lastStopAt < 5.0);
EXPECT_TRUE(rt.clock < 5.5);
}
{ // A node we didn't launch is never killed, even with Kill chosen; a bounded wait then gives up.
MockStopRuntime rt; MockStopTask task(rt);
DC::StopPolicy p = shutdownStopPolicy(false);
p.owned = false;
p.maxWaitSec = 120.0;
rt.choiceAt = [](double) { return DC::StopUserChoice::Kill; };
EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == O::StillRunning);
EXPECT_EQ(rt.count("kill"), 0);
EXPECT_EQ(rt.count("offer"), 0);
EXPECT_TRUE(rt.clock >= 120.0 && rt.clock < 121.0);
}
{ // Background callers (restart, rescan, switch): bounded and never kill; they also let go as soon
// as the app starts shutting down, so the shutdown can take over.
DC::StopPolicy bg;
bg.maxWaitSec = 120.0;
bg.abandonOnShutdown = true;
MockStopRuntime a; MockStopTask ta(a);
a.cpuAt = [](double) { return 1.0; };
EXPECT_TRUE(DC::executeDaemonStop(bg, a, ta) == O::StillRunning);
EXPECT_EQ(a.count("kill"), 0);
EXPECT_TRUE(a.clock >= 120.0 && a.clock < 121.0);
MockStopRuntime b; MockStopTask tb(b);
tb.shutdownAt = 10.0;
EXPECT_TRUE(DC::executeDaemonStop(bg, b, tb) == O::StillRunning);
EXPECT_TRUE(b.clock >= 10.0 && b.clock < 11.0);
}
{ // Our own node refusing our credentials: try the next source, then deliver.
MockStopRuntime rt; MockStopTask task(rt);
rt.sends = {R::Auth, R::Auth, R::Accepted};
rt.credentialsLeft = 2;
rt.exitAfterDelivery = 1.0;
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited);
EXPECT_EQ(rt.count("creds"), 2);
EXPECT_EQ(rt.count("stop"), 3);
}
{ // ...out of credential sources on POSIX: the graceful signal still stops it.
MockStopRuntime rt; MockStopTask task(rt);
rt.sends = {R::Auth};
rt.hasSignal = true;
rt.exitAfterDelivery = 1.0;
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(true), rt, task) == O::Exited);
EXPECT_EQ(rt.count("sigterm"), 1);
EXPECT_EQ(rt.count("kill"), 0);
}
}
// S3: on Linux a node in its final shutdown phase reads comm "hush-shutoff"; its executable still says
// dragonxd, so it must still count as running (it holds the datadir lock until it exits).
void testDaemonProcessIdentity()
@@ -8064,6 +8599,11 @@ int main()
testDaemonShutdownPolicy();
testDatadirLockGate();
testDaemonProcessIdentity();
testStopDeliveryPolicy();
testProcStatParse();
testStopProgressTracker();
testEvaluateStopWait();
testDaemonStopExecution();
#ifdef _WIN32
testWindowsStopNeverKillsForeignDaemon();
#endif
@@ -8074,6 +8614,9 @@ int main()
testPosixRequestedStopIsNotACrash();
testPosixStopSigtermIsGraceful();
testPosixDestructorLeavesProcessRunning();
testPosixStopSessionDetach();
testPosixStopSessionSigterm();
testPosixStopSessionGiveUpKeepsWatching();
#endif
testPlatformEnsureDirectory();
testVerifySaplingParams();