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