perf(daemon): back off the monitor poll when idle

The daemon monitor thread drained output and then slept a fixed short
interval on every tick, waking several times a second for the life of
the node even long after startup, when the node emits almost nothing.
Stopping it also meant waiting out the current sleep before it saw the
stop flag.

- The monitor now waits on a condition variable instead of a plain
  sleep, so a stop request wakes it immediately rather than after the
  interval. The stop flag is set under the monitor's mutex so the wake
  cannot be lost.
- The poll interval stays fast for the first 30 s after the thread
  starts and for 3 s after any output, and otherwise doubles per idle
  tick up to a 500 ms cap. Any new output drops it straight back to
  fast, so responsiveness during startup, rescan and shutdown is
  unchanged while a quiet node is polled far less often. The interval
  choice is a pure function.

Tests cover the interval function across the warmup window, the
recent-data window, the reset on new output, the doubling, and the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 06:26:13 -05:00
co-authored by Claude Opus 4.8
parent b407b8927a
commit 30f5243dd1
3 changed files with 95 additions and 13 deletions
+58 -13
View File
@@ -64,7 +64,7 @@ EmbeddedDaemon::~EmbeddedDaemon()
// process (seed migration's isolated node) could take down the user's main node. Owners call stop()
// explicitly (the app's own daemon is never destroyed: main() ends in _Exit). Just stop watching and
// release what we hold; a process still running is left to finish on its own.
should_stop_ = true;
requestMonitorStop();
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
@@ -254,6 +254,24 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
};
}
void EmbeddedDaemon::requestMonitorStop()
{
// Take monitor_mutex_ so the flag cannot flip between the monitor's predicate check and its wait
// (lost wakeup). Lock order lifecycle_mutex_ -> monitor_mutex_ is deadlock-free: the monitor thread
// never takes lifecycle_mutex_.
{
std::lock_guard<std::mutex> lk(monitor_mutex_);
should_stop_ = true;
}
monitor_cv_.notify_all();
}
size_t EmbeddedDaemon::outputTotalSnapshot() const
{
std::lock_guard<std::mutex> lk(output_mutex_);
return output_total_;
}
void EmbeddedDaemon::setState(State s, const std::string& message)
{
// Message first, state second: a reader that sees Error must also see that error's message, not the
@@ -512,7 +530,7 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
// 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
requestMonitorStop(); // a monitor somehow still looping wakes immediately via monitor_cv_
monitor_thread_.join(); // normally immediate (it already returned)
#ifdef _WIN32
if (process_handle_ != nullptr) { CloseHandle(process_handle_); process_handle_ = nullptr; }
@@ -713,7 +731,7 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
// Supervise the process just launched. A monitor left over from an earlier run was joined above.
should_stop_ = false;
if (monitor_thread_.joinable()) { // unreachable under lifecycle_mutex_; never join a live monitor unsignalled
should_stop_ = true; monitor_thread_.join(); should_stop_ = false;
requestMonitorStop(); monitor_thread_.join(); should_stop_ = false;
}
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
@@ -947,7 +965,7 @@ bool EmbeddedDaemon::stop(int wait_ms)
{
std::lock_guard<std::mutex> life(lifecycle_mutex_);
stop_requested_ = true;
should_stop_ = true;
requestMonitorStop();
}
setState(State::Stopping, "Stopping dragonxd...");
@@ -1021,7 +1039,7 @@ 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;
requestMonitorStop();
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
@@ -1073,7 +1091,7 @@ void EmbeddedDaemon::endStop(bool keepWatching)
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();
requestMonitorStop(); monitor_thread_.join();
}
should_stop_ = false;
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
@@ -1116,10 +1134,23 @@ static std::string translateWindowsExitCode(DWORD code) {
void EmbeddedDaemon::monitorProcess()
{
const auto fast = std::chrono::milliseconds(250);
const auto cap = std::chrono::milliseconds(500);
const auto startTime = std::chrono::steady_clock::now();
auto lastData = startTime;
auto interval = fast;
while (!should_stop_ && isRunning()) {
// Tail debug.log for new output
const size_t before = outputTotalSnapshot();
drainOutput();
std::this_thread::sleep_for(std::chrono::milliseconds(250));
const auto now = std::chrono::steady_clock::now();
const bool got = outputTotalSnapshot() != before;
if (got) lastData = now;
interval = nextMonitorInterval(interval, got,
std::chrono::duration_cast<std::chrono::milliseconds>(now - startTime),
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastData), fast, cap);
std::unique_lock<std::mutex> lk(monitor_mutex_);
monitor_cv_.wait_for(lk, interval, [this] { return should_stop_.load(); });
}
// Read any remaining output after process exits
@@ -1537,7 +1568,7 @@ bool EmbeddedDaemon::stop(int wait_ms)
{
bool exitedOnItsOwn = true;
// Join the monitor FIRST (it sees should_stop_ within one 100ms tick) so that from here on this is
// Join the monitor FIRST (it wakes immediately via monitor_cv_) 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).
@@ -1545,7 +1576,7 @@ bool EmbeddedDaemon::stop(int wait_ms)
{
std::lock_guard<std::mutex> life(lifecycle_mutex_);
stop_requested_ = true;
should_stop_ = true;
requestMonitorStop();
if (monitor_thread_.joinable()) {
monitor_thread_.join();
}
@@ -1594,8 +1625,8 @@ 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
requestMonitorStop();
// The monitor wakes immediately via monitor_cv_. If the child exited in that window the
// monitor reaped it (as a clean stop: stop_requested_ is set) and the pid is now 0.
if (monitor_thread_.joinable()) {
monitor_thread_.join();
@@ -1690,7 +1721,7 @@ void EmbeddedDaemon::endStop(bool keepWatching)
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();
requestMonitorStop(); monitor_thread_.join();
}
should_stop_ = false;
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
@@ -1716,6 +1747,11 @@ void EmbeddedDaemon::endStop(bool keepWatching)
void EmbeddedDaemon::monitorProcess()
{
const auto fast = std::chrono::milliseconds(100);
const auto cap = std::chrono::milliseconds(500);
const auto startTime = std::chrono::steady_clock::now();
auto lastData = startTime;
auto interval = fast;
while (!should_stop_) {
// Check if process exited (non-blocking waitpid captures exit status)
if (process_pid_ > 0) {
@@ -1742,9 +1778,18 @@ void EmbeddedDaemon::monitorProcess()
}
// Read process output (non-blocking): the stdout/stderr pipe and the node's debug.log
const size_t before = outputTotalSnapshot();
drainOutput();
const auto now = std::chrono::steady_clock::now();
const bool got = outputTotalSnapshot() != before;
if (got) lastData = now;
interval = nextMonitorInterval(interval, got,
std::chrono::duration_cast<std::chrono::milliseconds>(now - startTime),
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastData), fast, cap);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Wakes immediately on requestMonitorStop(); idle polling backs off to `cap`.
std::unique_lock<std::mutex> lk(monitor_mutex_);
monitor_cv_.wait_for(lk, interval, [this] { return should_stop_.load(); });
}
}
+18
View File
@@ -14,6 +14,8 @@
#include <atomic>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>
#include <set>
#include <vector>
@@ -474,9 +476,23 @@ public:
* reader can tell a new crash from an error it has already reported. */
std::uint64_t getUnexpectedExitCount() const { return unexpected_exits_.load(); }
/** Monitor poll interval: stay `fast` for the first 30 s after the thread starts and for 3 s after any
* output; otherwise double per idle tick up to `cap`. Any output resets to `fast`. Pure. */
static std::chrono::milliseconds nextMonitorInterval(std::chrono::milliseconds cur, bool bytesRead,
std::chrono::milliseconds sinceStart, std::chrono::milliseconds sinceLastData,
std::chrono::milliseconds fast, std::chrono::milliseconds cap)
{
using ms = std::chrono::milliseconds;
if (bytesRead || sinceStart < ms(30000) || sinceLastData < ms(3000)) return fast;
const ms next = cur * 2;
return next > cap ? cap : (next < fast ? fast : next);
}
private:
void setState(State s, const std::string& message = "");
void monitorProcess();
void requestMonitorStop(); // set should_stop_ under monitor_mutex_ and wake the monitor
size_t outputTotalSnapshot() const; // output_total_ under output_mutex_ (monitor idle detection)
void drainOutput(); // read any pending stdout into process_output_
void appendOutput(const char* data, size_t len); // append + trim (caller holds output_mutex_)
bool startProcess(const std::string& binary_path, const std::vector<std::string>& args);
@@ -514,6 +530,8 @@ private:
std::thread monitor_thread_;
std::atomic<bool> should_stop_{false};
std::mutex monitor_mutex_; // pairs with monitor_cv_ so stop wakes the monitor at once
std::condition_variable monitor_cv_;
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.
+19
View File
@@ -6071,6 +6071,24 @@ void testOutputSinceAcrossTrim()
EXPECT_TRUE(got.empty());
}
void testNextMonitorInterval()
{
using dragonx::daemon::EmbeddedDaemon;
using ms = std::chrono::milliseconds;
const ms fast(100), cap(500);
auto next = [&](ms cur, bool got, ms sinceStart, ms sinceData) {
return EmbeddedDaemon::nextMonitorInterval(cur, got, sinceStart, sinceData, fast, cap);
};
EXPECT_TRUE(next(fast, false, ms(1000), ms(1000)) == fast); // first 30 s stay fast
EXPECT_TRUE(next(cap, false, ms(29999), ms(60000)) == fast);
EXPECT_TRUE(next(cap, false, ms(60000), ms(2999)) == fast); // recent data stays fast
EXPECT_TRUE(next(cap, true, ms(60000), ms(60000)) == fast); // data resets
EXPECT_TRUE(next(fast, false, ms(60000), ms(10000)) == ms(200)); // doubling
EXPECT_TRUE(next(ms(200), false, ms(60000), ms(10000)) == ms(400));
EXPECT_TRUE(next(ms(400), false, ms(60000), ms(10000)) == cap); // capped at 500
EXPECT_TRUE(next(cap, false, ms(60000), ms(10000)) == cap);
}
void testOutputWindowSaturation()
{
using dragonx::daemon::appendBounded;
@@ -18694,6 +18712,7 @@ int main()
testShouldConfirmStop();
testOutputSinceAcrossTrim();
testOutputWindowSaturation();
testNextMonitorInterval();
testOutputEndWatermarkReplay();
testChooseDaemonStatus();
testDaemonStopStringsTranslated();