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