fix(daemon): supervise a daemon restarted after a crash

After an unexpected exit the monitor thread returned on its own but
stayed joinable, and start() only spawned a monitor when none was
joinable. A node restarted after a crash (no stop() in between) then ran
unwatched: a second crash was never detected, its output was never
drained, and the dead run's stdout pipe leaked into the next fork.

- start() joins a monitor that returned on its own (only when joinable,
  at the top, after the "already running" early returns), closes the
  dead run's stdout pipe (Windows: process and pipe handles), and always
  starts a new monitor for the process it launched. crash_count_ is kept.
- New lifecycle_mutex_ serializes start(), beginStop(), stop()'s flag
  writes and joins, and endStop(), so two threads can never join the same
  monitor. The monitor thread and the state callback never take it; a
  beginStop() racing a start() waits for it to return (its longest wait
  is the ~300 ms datadir-lock poll).
- start() is refused (returns false, state unchanged) while a stop is in
  progress, i.e. between beginStop() and endStop().
- The daemon tests now start the stand-in only in isolation (temp
  -datadir override, port check skipped): they no longer write the real
  ~/.hush/DRAGONX/obsidiandragon-dragonxd.pid or tail the real debug.log.
  New tests: restart after a crash is supervised, a restarted run stops
  cleanly, no fd leak over crash+restart cycles (Linux), and start() is
  refused while stopping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent f6e5038043
commit 70d8b0f687
3 changed files with 269 additions and 22 deletions
+55 -11
View File
@@ -500,6 +500,8 @@ static bool isPortInUse(int port)
bool EmbeddedDaemon::start(const std::string& binary_path)
{
std::lock_guard<std::mutex> life(lifecycle_mutex_);
// Don't start if already running or starting
if (state_ == State::Starting || state_ == State::Running) {
DEBUG_LOGF("[INFO] Daemon already %s\n", state_ == State::Starting ? "starting" : "running");
@@ -511,6 +513,27 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
return true;
}
// A stop session owns this object until endStop() sets Stopped; never spawn under it (its endStop would
// adopt and abandon our new pid, and it still uses stdout_fd_/process_handle_).
if (state_ == State::Stopping) {
DEBUG_LOGF("[INFO] start(): a stop is still in progress, not starting\n");
return false;
}
// A monitor that returned on its own (unexpected exit, or a requested stop that landed before stop()) is
// still joinable. Join it, then release what its run left behind. Only in this case: if a stop session
// already joined it, the fd/handle belong to that session and endStop() closes them.
if (monitor_thread_.joinable()) {
should_stop_ = true; // a monitor somehow still looping ends within one tick
monitor_thread_.join(); // normally immediate (it already returned)
#ifdef _WIN32
if (process_handle_ != nullptr) { CloseHandle(process_handle_); process_handle_ = nullptr; }
if (stdout_read_ != nullptr) { CloseHandle(stdout_read_); stdout_read_ = nullptr; }
#else
if (stdout_fd_ >= 0) { close(stdout_fd_); stdout_fd_ = -1; } // also stops it leaking into the next fork
#endif
}
should_stop_ = false;
// Check if something is already listening on the RPC port. An isolated instance (migrate-to-
// seed) runs on its own non-default port alongside the main daemon, so it skips this bail.
int rpc_port = std::atoi(DRAGONX_DEFAULT_RPC_PORT);
@@ -691,11 +714,12 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
if (pid > 0 && !rec.startToken.empty()) writeOwnershipRecord(ownershipRecordPath(), rec);
}
// Start monitor thread (if not already running)
// Supervise the process just launched. A monitor left over from an earlier run was joined above.
should_stop_ = false;
if (!monitor_thread_.joinable()) {
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
if (monitor_thread_.joinable()) { // unreachable under lifecycle_mutex_; never join a live monitor unsignalled
should_stop_ = true; monitor_thread_.join(); should_stop_ = false;
}
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
return true;
}
@@ -924,8 +948,11 @@ void EmbeddedDaemon::drainOutput()
bool EmbeddedDaemon::stop(int wait_ms)
{
stop_requested_ = true;
should_stop_ = true;
{
std::lock_guard<std::mutex> life(lifecycle_mutex_);
stop_requested_ = true;
should_stop_ = true;
}
setState(State::Stopping, "Stopping dragonxd...");
// Check if our tracked process handle is still alive
@@ -983,8 +1010,11 @@ bool EmbeddedDaemon::stop(int wait_ms)
// Final drain of debug.log
drainOutput();
if (monitor_thread_.joinable()) {
monitor_thread_.join();
{
std::lock_guard<std::mutex> life(lifecycle_mutex_);
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
}
setState(State::Stopped, "dragonxd stopped");
@@ -993,6 +1023,7 @@ bool EmbeddedDaemon::stop(int wait_ms)
bool EmbeddedDaemon::beginStop()
{
std::lock_guard<std::mutex> life(lifecycle_mutex_); // before the flags: a racing start() would reset should_stop_
stop_requested_ = true;
should_stop_ = true;
if (monitor_thread_.joinable()) {
@@ -1042,8 +1073,12 @@ EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
void EmbeddedDaemon::endStop(bool keepWatching)
{
std::lock_guard<std::mutex> life(lifecycle_mutex_);
if (keepWatching && isRunning()) {
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
if (monitor_thread_.joinable()) { // unreachable after beginStop()'s join; never join a live monitor unsignalled
should_stop_ = true; monitor_thread_.join();
}
should_stop_ = false;
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
return;
@@ -1504,16 +1539,20 @@ bool EmbeddedDaemon::reapWithin(pid_t pid, int timeout_ms, int& status)
bool EmbeddedDaemon::stop(int wait_ms)
{
stop_requested_ = true;
should_stop_ = true;
bool exitedOnItsOwn = true;
// Join the monitor FIRST (it sees should_stop_ within one 100ms tick) so that from here on this is
// the only reaper. isRunning() is state-based and stays true until we set Stopped below, so it can't
// tell us the child exited: poll waitpid() on a cached pid instead. If the child exited inside that
// tick, the monitor already reaped it and, seeing stop_requested_, recorded a clean stop (pid now 0).
if (monitor_thread_.joinable()) {
monitor_thread_.join();
// lifecycle_mutex_ covers the flags and the join only, never the reap waits below.
{
std::lock_guard<std::mutex> life(lifecycle_mutex_);
stop_requested_ = true;
should_stop_ = true;
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
}
const pid_t pid = process_pid_;
@@ -1557,6 +1596,7 @@ bool EmbeddedDaemon::stop(int wait_ms)
bool EmbeddedDaemon::beginStop()
{
std::lock_guard<std::mutex> life(lifecycle_mutex_); // before the flags: a racing start() would reset should_stop_
stop_requested_ = true;
should_stop_ = true;
// The monitor sees should_stop_ within one 100ms tick. If the child exited in that window the
@@ -1649,9 +1689,13 @@ EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
void EmbeddedDaemon::endStop(bool keepWatching)
{
std::lock_guard<std::mutex> life(lifecycle_mutex_);
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.
if (monitor_thread_.joinable()) { // unreachable after beginStop()'s join; never join a live monitor unsignalled
should_stop_ = true; monitor_thread_.join();
}
should_stop_ = false;
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
return;
+5 -1
View File
@@ -53,7 +53,8 @@ public:
/**
* @brief Start the embedded dragonxd daemon
* @param binary_path Path to dragonxd binary (auto-detect if empty)
* @return true if process started successfully
* @return true if process started successfully (or is already running); false while a stop
* session (beginStop() .. endStop()) is still in progress
*/
bool start(const std::string& binary_path = "");
@@ -501,6 +502,9 @@ private:
std::thread monitor_thread_;
std::atomic<bool> should_stop_{false};
std::atomic<bool> stop_requested_{false}; // a stop was asked for: the next exit is not a crash
// Serializes start/beginStop/stop/endStop over monitor_thread_, should_stop_ and stop_requested_ (two
// threads joining one std::thread is undefined). Never taken by the monitor thread or the state callback.
std::mutex lifecycle_mutex_;
std::set<std::string> debug_categories_;
int max_connections_ = 0; // 0 = daemon default
bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it)
+209 -10
View File
@@ -3896,6 +3896,27 @@ void testPlatformEnsureDirectory()
#ifndef _WIN32
// Integration tests that drive the REAL EmbeddedDaemon fork/exec/waitpid paths (POSIX only).
// Every start() in these tests is isolated (X13). The skipped port check also skips the datadir-lock gate;
// the -datadir override (a temp dir) means no ownership record (the user's real
// ~/.hush/DRAGONX/obsidiandragon-dragonxd.pid) is written and <dir>/debug.log is tailed instead of the real
// one. The override is one-shot: set it again before every start().
void isolateNextDaemonStart(dragonx::daemon::EmbeddedDaemon& d, const fs::path& dir)
{
d.setSkipPortCheck(true);
d.setNextStartOverride(dir.string(), {});
}
bool waitUntilTrue(const std::function<bool()>& cond, double seconds)
{
const auto t0 = std::chrono::steady_clock::now();
while (!cond()) {
if (secondsSince(t0) >= seconds) return false;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return true;
}
void testExecFailureReported()
{
using dragonx::daemon::EmbeddedDaemon;
@@ -3907,13 +3928,15 @@ void testExecFailureReported()
{ std::ofstream(bin.string(), std::ios::binary) << "this is not an executable"; }
fsn::permissions(bin, fsn::perms::owner_read, fsn::perm_options::replace); // 0400, no +x
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true); // bypass the port + datadir-lock gates so we reach startProcess()
isolateNextDaemonStart(d, tmp); // bypass the port + datadir-lock gates so we reach startProcess()
EXPECT_TRUE(!d.start(bin.string()));
EXPECT_TRUE(d.getLastError().find("not executable or wrong architecture") != std::string::npos);
EXPECT_TRUE(!d.isRunning());
std::error_code ec; fsn::remove(bin, ec);
removeTree(tmp);
}
void testDaemonCrashDetected()
@@ -3927,8 +3950,9 @@ void testDaemonCrashDetected()
{ std::ofstream(script.string()) << "#!/bin/bash\nsleep 0.2\nexit 7\n"; }
fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true);
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(d.isRunning()); // reads the atomic state_, not a racy waitpid()
@@ -3946,6 +3970,7 @@ void testDaemonCrashDetected()
d.stop(); // join the monitor thread cleanly
EXPECT_EQ(d.getCrashCount(), crashes); // a later stop never erases a real crash
std::error_code ec; fsn::remove(script, ec);
removeTree(tmp);
}
// Writes an executable bash stand-in for dragonxd (EmbeddedDaemon runs it via /bin/bash); returns its path.
@@ -3958,6 +3983,158 @@ std::filesystem::path writeFakeDaemonScript(const std::string& name, const std::
return script;
}
// The same, in `dir` (a per-test temp dir).
std::filesystem::path writeFakeDaemonScript(const fs::path& dir, const std::string& name, const std::string& body)
{
const fs::path script = dir / name;
{ std::ofstream(script.string()) << "#!/bin/bash\n" << body; }
fs::permissions(script, fs::perms::owner_all, fs::perm_options::replace); // +x
return script;
}
size_t countSubstring(const std::string& hay, const std::string& needle)
{
size_t n = 0;
for (size_t pos = hay.find(needle); pos != std::string::npos; pos = hay.find(needle, pos + needle.size())) ++n;
return n;
}
// C101: start() after an unexpected exit, with no stop() in between, must supervise the new run. The
// crashed run's monitor returned on its own but stayed joinable, and start() only spawned a monitor when
// none was joinable: the restarted node ran unwatched (its crash never seen, its output never drained).
void testDaemonRestartAfterCrashIsSupervised()
{
using dragonx::daemon::EmbeddedDaemon;
const fs::path tmp = makeTempDir();
const auto script = writeFakeDaemonScript(tmp, "od_fake_daemon_restart.sh", "echo RUN\nsleep 0.2\nexit 7\n");
EmbeddedDaemon d;
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(waitUntilTrue([&d] { return d.getCrashCount() == 1; }, 3.0));
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string())); // no stop() in between
EXPECT_TRUE(waitUntilTrue([&d] {
return d.getCrashCount() == 2 && d.getState() == EmbeddedDaemon::State::Error;
}, 3.0));
EXPECT_EQ(countSubstring(d.getOutput(), "RUN"), static_cast<size_t>(2)); // both runs' output drained
const auto t0 = std::chrono::steady_clock::now();
d.stop(500);
EXPECT_TRUE(secondsSince(t0) < 2.0);
EXPECT_EQ(d.getCrashCount(), 2);
removeTree(tmp);
}
// C101: a run restarted after a crash is watched by a new monitor, and a later stop joins it: the stop is
// prompt, recorded as Stopped and not as a crash, and nothing is left running.
void testRestartAfterCrashThenStop()
{
using dragonx::daemon::EmbeddedDaemon;
const fs::path tmp = makeTempDir();
const std::string runs = (tmp / "runs").string();
// Run 1 exits 3 at once; every later run prints RUN<n> and sleeps 30 s.
const auto script = writeFakeDaemonScript(tmp, "od_fake_daemon_restart_stop.sh",
"n=$(cat '" + runs + "' 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > '" + runs + "'\n"
"if [ \"$n\" -eq 1 ]; then exit 3; fi\necho RUN$n\nsleep 30\n");
EmbeddedDaemon d;
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(waitUntilTrue([&d] { return d.getCrashCount() == 1; }, 3.0));
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string())); // run 2 (sleeps 30 s)
EXPECT_TRUE(waitUntilTrue([&d] { return d.getOutput().find("RUN2") != std::string::npos; }, 3.0)); // drained
EXPECT_TRUE(d.isRunning());
d.markStopRequested();
const auto t0 = std::chrono::steady_clock::now();
d.stop(500); // 0.5 s grace, then the group SIGTERM ends bash and its sleep
EXPECT_TRUE(secondsSince(t0) < 2.0);
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
EXPECT_FALSE(d.isRunning());
EXPECT_EQ(d.getCrashCount(), 1);
removeTree(tmp);
}
#ifdef __linux__
size_t countOpenFds()
{
size_t n = 0;
std::error_code ec;
for (fs::directory_iterator it("/proc/self/fd", ec), end; !ec && it != end; it.increment(ec)) ++n;
return n;
}
// C101 (Linux): crash + restart cycles leak no descriptor. Each run's stdout pipe used to be overwritten by
// the next start() without being closed (and inherited by the next child).
void testDaemonRestartCyclesLeakNoFds()
{
using dragonx::daemon::EmbeddedDaemon;
const fs::path tmp = makeTempDir();
const auto script = writeFakeDaemonScript(tmp, "od_fake_daemon_fdleak.sh", "sleep 0.1\nexit 9\n");
const size_t baseline = countOpenFds();
{
EmbeddedDaemon d;
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(waitUntilTrue([&d] { return d.getCrashCount() == 1; }, 3.0));
for (int cycle = 2; cycle <= 4; ++cycle) { // 3 crash + restart cycles
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(waitUntilTrue([&d, cycle] { return d.getCrashCount() == cycle; }, 3.0));
}
d.stop(500);
EXPECT_EQ(countOpenFds(), baseline);
}
removeTree(tmp);
}
#endif
// C101 D4: start() is refused while a stop session is in progress. After beginStop() and a reap (here
// forceKillOwnedProcess(): pid 0, state still Stopping) start() used to spawn a process that the session's
// endStop() then abandoned.
void testStartRefusedWhileStopping()
{
using dragonx::daemon::EmbeddedDaemon;
const fs::path tmp = makeTempDir();
const fs::path launches = tmp / "launches";
const auto script = writeFakeDaemonScript(tmp, "od_fake_daemon_stopping.sh",
"echo $$ >> '" + launches.string() + "'\nsleep 30\n");
auto launchPids = [&launches] {
std::vector<long> v;
std::ifstream in(launches);
long p = 0;
while (in >> p) v.push_back(p);
return v;
};
EmbeddedDaemon d;
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(waitUntilTrue([&launchPids] { return launchPids().size() == 1; }, 3.0));
EXPECT_TRUE(d.beginStop());
d.forceKillOwnedProcess();
EXPECT_FALSE(d.isRunning());
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopping);
isolateNextDaemonStart(d, tmp);
EXPECT_FALSE(d.start(script.string()));
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopping); // unchanged
std::this_thread::sleep_for(std::chrono::milliseconds(300));
const auto pids = launchPids();
EXPECT_EQ(pids.size(), static_cast<size_t>(1)); // nothing spawned
d.clearNextStartOverride(); // the refused start didn't consume it
d.endStop(/*keepWatching=*/false);
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
EXPECT_EQ(d.getCrashCount(), 0);
// Before the fix a second run was spawned and endStop() left it running: end it (its own process group).
for (size_t i = 1; i < pids.size(); ++i)
if (pids[i] > 0) kill(-static_cast<pid_t>(pids[i]), SIGKILL);
removeTree(tmp);
}
// S4: stop() must see a clean exit and return at once. Before the fix the monitor thread exited
// without reaping and the state-based isRunning() stayed true, so every POSIX stop waited out the
// whole grace period, then sent SIGTERM, then SIGKILL (here ~5 + 10 = 15s).
@@ -3966,8 +4143,9 @@ void testPosixStopReturnsAfterCleanExit()
using dragonx::daemon::EmbeddedDaemon;
const auto script = writeFakeDaemonScript("od_fake_daemon_clean.sh", "sleep 1\nexit 0\n");
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true);
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
const auto t0 = std::chrono::steady_clock::now();
d.stop(5000);
@@ -3976,6 +4154,7 @@ void testPosixStopReturnsAfterCleanExit()
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
EXPECT_TRUE(!d.isRunning());
std::error_code ec; std::filesystem::remove(script, ec);
removeTree(tmp);
}
// S4: an exit after markStopRequested() is the stop we asked for, even when the monitor reaps it
@@ -3985,8 +4164,9 @@ void testPosixRequestedStopIsNotACrash()
using dragonx::daemon::EmbeddedDaemon;
const auto script = writeFakeDaemonScript("od_fake_daemon_requested.sh", "sleep 0.3\nexit 0\n");
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true);
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
d.markStopRequested();
for (int i = 0; i < 300 && d.getState() == EmbeddedDaemon::State::Running; ++i)
@@ -3999,6 +4179,7 @@ void testPosixRequestedStopIsNotACrash()
EXPECT_TRUE(secondsSince(t0) < 1.0);
EXPECT_EQ(d.getCrashCount(), 0);
std::error_code ec; std::filesystem::remove(script, ec);
removeTree(tmp);
}
// C103: EmbeddedDaemon::memoryProbe (POSIX: place after testPosixRequestedStopIsNotACrash, it uses
@@ -4012,8 +4193,9 @@ void testEmbeddedDaemonMemoryProbe()
EXPECT_NEAR(idle.getMemoryUsageMB(), 0.0, 1e-9);
}
const auto script = writeFakeDaemonScript("od_fake_daemon_mem.sh", "sleep 2\nexit 0\n");
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true);
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
auto probe = d.memoryProbe();
EXPECT_TRUE(static_cast<bool>(probe));
@@ -4023,6 +4205,7 @@ void testEmbeddedDaemonMemoryProbe()
EXPECT_TRUE(mb > 0.0); // the bash stand-in's process group
d.stop(5000);
std::error_code ec; std::filesystem::remove(script, ec);
removeTree(tmp);
}
// S4: SIGTERM is graceful on POSIX (dragonxd treats it like RPC stop), and stop() reaps the clean
@@ -4040,8 +4223,9 @@ void testPosixStopSigtermIsGraceful()
"while :; do sleep 0.1; done\n");
setenv("OD_TEST_EVENTS", events.string().c_str(), 1);
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true);
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
const auto t0 = std::chrono::steady_clock::now();
d.stop(1000); // nothing asked it to stop, so SIGTERM goes out at +1s
@@ -4055,6 +4239,7 @@ void testPosixStopSigtermIsGraceful()
unsetenv("OD_TEST_EVENTS");
fsn::remove(script, ec);
fsn::remove(events, ec);
removeTree(tmp);
}
// S3: destroying an EmbeddedDaemon must not stop (let alone kill) the process. The destructor used to
@@ -4072,8 +4257,9 @@ void testPosixDestructorLeavesProcessRunning()
setenv("OD_TEST_PIDFILE", pidFile.string().c_str(), 1);
pid_t pid = 0;
const fs::path tmp = makeTempDir();
auto d = std::make_unique<EmbeddedDaemon>();
d->setSkipPortCheck(true);
isolateNextDaemonStart(*d, tmp);
EXPECT_TRUE(d->start(script.string()));
for (int i = 0; i < 300 && pid <= 0; ++i) {
std::ifstream in(pidFile);
@@ -4093,6 +4279,7 @@ void testPosixDestructorLeavesProcessRunning()
unsetenv("OD_TEST_PIDFILE");
fsn::remove(script, ec);
fsn::remove(pidFile, ec);
removeTree(tmp);
}
// executeDaemonStop over a REAL EmbeddedDaemon child: steady clock, real waitpid/signals/CPU sampling.
@@ -4156,8 +4343,9 @@ void testPosixStopSessionDetach()
"trap '' TERM\necho $$ > \"$OD_TEST_PIDFILE\"\nwhile :; do :; done\n"); // pid file = trap is set
setenv("OD_TEST_PIDFILE", pidFile.string().c_str(), 1);
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true);
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
const pid_t pid = readPidFile(pidFile);
EXPECT_TRUE(pid > 0);
@@ -4189,6 +4377,7 @@ void testPosixStopSessionDetach()
unsetenv("OD_TEST_PIDFILE");
fsn::remove(script, ec);
fsn::remove(pidFile, ec);
removeTree(tmp);
}
// S1/S2 on POSIX: with no RPC answer, the group SIGTERM stops a node that handles it, promptly and
@@ -4199,8 +4388,9 @@ void testPosixStopSessionSigterm()
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");
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true);
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(d.beginStop());
RealStopRuntime rt(d);
@@ -4214,6 +4404,7 @@ void testPosixStopSessionSigterm()
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
EXPECT_EQ(d.getCrashCount(), 0);
std::error_code ec; std::filesystem::remove(script, ec);
removeTree(tmp);
}
// S1: a bounded wait that gives up keeps watching the node: its later exit is still seen, and booked
@@ -4230,8 +4421,9 @@ void testPosixStopSessionGiveUpKeepsWatching()
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);
const fs::path tmp = makeTempDir();
EmbeddedDaemon d;
d.setSkipPortCheck(true);
isolateNextDaemonStart(d, tmp);
EXPECT_TRUE(d.start(script.string()));
EXPECT_TRUE(readPidFile(readyFile) > 0);
EXPECT_TRUE(d.beginStop());
@@ -4252,6 +4444,7 @@ void testPosixStopSessionGiveUpKeepsWatching()
std::error_code ec;
fsn::remove(script, ec);
fsn::remove(readyFile, ec);
removeTree(tmp);
}
#endif // !_WIN32
@@ -16103,6 +16296,12 @@ int main()
#ifndef _WIN32
testExecFailureReported();
testDaemonCrashDetected();
testDaemonRestartAfterCrashIsSupervised();
testRestartAfterCrashThenStop();
#ifdef __linux__
testDaemonRestartCyclesLeakNoFds();
#endif
testStartRefusedWhileStopping();
testPosixStopReturnsAfterCleanExit();
testPosixRequestedStopIsNotACrash();
testEmbeddedDaemonMemoryProbe();