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();