fix(daemon): let POSIX stop() see the daemon exit instead of always SIGKILLing

POSIX stop() set should_stop_, which made the monitor thread return
without reaping the child, and then polled the state-based isRunning(),
which stays true while the state is Stopping. So every Linux/macOS stop
waited out its whole grace period, sent SIGTERM, waited 10 s more and
SIGKILLed, even when the node had exited seconds earlier. Closing the GUI
with an idle node took ~30 s; a node still flushing at 30 s was killed.

- stop() joins the monitor first, then polls waitpid(pid, WNOHANG) on
  a cached pid, so it is the only reaper and sees the exit at once.
- markStopRequested() (set by App::stopEmbeddedDaemon before the RPC
  stop, and by stop() itself; cleared by start()) makes an exit that
  lands before stop() runs a clean stop instead of a crash, on POSIX
  and in the Windows monitor. crash_count_ drives auto-restart, and a
  crash recorded before the request keeps its count.
- The SIGTERM -> SIGKILL escalation stays for now, but only fires for
  a daemon that has really not exited. Replacing it with a wait policy
  and an explicit user choice is follow-up work.

Verified against the fake daemon: an idle node that exits 3.4 s after
stop used to be SIGTERMed at +20 s and SIGKILLed at +30 s (GUI closed
in 30.6 s); it is now reaped as "exited cleanly" ~50 ms after it exits
(GUI closes in 4.0 s). New POSIX tests cover the clean exit, the early
requested exit and a graceful SIGTERM, each time-bounded so the old
code fails fast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:02:45 -05:00
co-authored by Claude Opus 5.5
parent bade958849
commit 2c04b88e19
6 changed files with 187 additions and 59 deletions
+95
View File
@@ -3181,9 +3181,101 @@ void testDaemonCrashDetected()
EXPECT_TRUE(d.getCrashCount() >= 1); // the unexpected exit was detected and counted
EXPECT_TRUE(!d.isRunning()); // state_ flipped to Error
const int crashes = d.getCrashCount();
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);
}
// Writes an executable bash stand-in for dragonxd (EmbeddedDaemon runs it via /bin/bash); returns its path.
std::filesystem::path writeFakeDaemonScript(const std::string& name, const std::string& body)
{
namespace fsn = std::filesystem;
fsn::path script = fsn::temp_directory_path() / name;
{ std::ofstream(script.string()) << "#!/bin/bash\n" << body; }
fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x
return script;
}
double secondsSince(std::chrono::steady_clock::time_point t0)
{
return std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
}
// 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).
void testPosixStopReturnsAfterCleanExit()
{
using dragonx::daemon::EmbeddedDaemon;
const auto script = writeFakeDaemonScript("od_fake_daemon_clean.sh", "sleep 1\nexit 0\n");
EmbeddedDaemon d;
d.setSkipPortCheck(true);
EXPECT_TRUE(d.start(script.string()));
const auto t0 = std::chrono::steady_clock::now();
d.stop(5000);
EXPECT_TRUE(secondsSince(t0) < 2.5);
EXPECT_EQ(d.getCrashCount(), 0);
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
EXPECT_TRUE(!d.isRunning());
std::error_code ec; std::filesystem::remove(script, ec);
}
// S4: an exit after markStopRequested() is the stop we asked for, even when the monitor reaps it
// before stop() runs (the RPC stop landed first). It must not count as a crash or flip to Error.
void testPosixRequestedStopIsNotACrash()
{
using dragonx::daemon::EmbeddedDaemon;
const auto script = writeFakeDaemonScript("od_fake_daemon_requested.sh", "sleep 0.3\nexit 0\n");
EmbeddedDaemon d;
d.setSkipPortCheck(true);
EXPECT_TRUE(d.start(script.string()));
d.markStopRequested();
for (int i = 0; i < 300 && d.getState() == EmbeddedDaemon::State::Running; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // the monitor reaps it
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
EXPECT_EQ(d.getCrashCount(), 0);
const auto t0 = std::chrono::steady_clock::now();
d.stop(5000);
EXPECT_TRUE(secondsSince(t0) < 1.0);
EXPECT_EQ(d.getCrashCount(), 0);
std::error_code ec; std::filesystem::remove(script, ec);
}
// S4: SIGTERM is graceful on POSIX (dragonxd treats it like RPC stop), and stop() reaps the clean
// exit it leads to at once instead of waiting out the 10s SIGKILL timer (~11s before the fix).
void testPosixStopSigtermIsGraceful()
{
using dragonx::daemon::EmbeddedDaemon;
namespace fsn = std::filesystem;
const fsn::path events = fsn::temp_directory_path() / "od_fake_daemon_term.events";
std::error_code ec;
fsn::remove(events, ec);
// Flushes for 1s on SIGTERM, then exits 0; otherwise never exits on its own.
const auto script = writeFakeDaemonScript("od_fake_daemon_term.sh",
"trap 'echo term >> \"$OD_TEST_EVENTS\"; sleep 1; echo clean >> \"$OD_TEST_EVENTS\"; exit 0' TERM\n"
"while :; do sleep 0.1; done\n");
setenv("OD_TEST_EVENTS", events.string().c_str(), 1);
EmbeddedDaemon d;
d.setSkipPortCheck(true);
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
EXPECT_TRUE(secondsSince(t0) < 4.0);
std::ifstream in(events);
const std::string seen((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
EXPECT_EQ(seen, std::string("term\nclean\n")); // the trap ran to completion: no SIGKILL
EXPECT_EQ(d.getCrashCount(), 0);
EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped);
unsetenv("OD_TEST_EVENTS");
fsn::remove(script, ec);
fsn::remove(events, ec);
}
#endif // !_WIN32
void testDatadirLockGate()
@@ -7753,6 +7845,9 @@ int main()
#ifndef _WIN32
testExecFailureReported();
testDaemonCrashDetected();
testPosixStopReturnsAfterCleanExit();
testPosixRequestedStopIsNotACrash();
testPosixStopSigtermIsGraceful();
#endif
testPlatformEnsureDirectory();
testVerifySaplingParams();